mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-16 20:22:15 +01:00
add break time entries and simplified time tracker ui
This commit is contained in:
323
resources/js/utils/timesheet/useBreakPlacement.ts
Normal file
323
resources/js/utils/timesheet/useBreakPlacement.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
import { ref, type Ref } from 'vue';
|
||||
import { useQueryClient } from '@tanstack/vue-query';
|
||||
import { api, type TimeEntry } from '@/packages/api/src';
|
||||
import { getDayJsInstance } from '@/packages/ui/src/utils/time';
|
||||
import { getUserTimezone } from '@/packages/ui/src/utils/settings';
|
||||
import { getCurrentMembershipId } from '@/utils/useUser';
|
||||
import type { TimesheetRow } from '@/utils/useTimesheetGrid';
|
||||
import { useNotificationsStore } from '@/utils/notification';
|
||||
import { localDayBounds, NoFreeWindowError } from './cellMath';
|
||||
import {
|
||||
buildDayPlacementContext,
|
||||
findValidBreakGap,
|
||||
findValidBreakGapNear,
|
||||
placementMode,
|
||||
planMoveInsert,
|
||||
planSplitEntry,
|
||||
suggestMovePlan,
|
||||
type BreakPlacementRequest,
|
||||
type DayPlacementContext,
|
||||
} from './breakPlacementMath';
|
||||
|
||||
/** Signals the caller that a break create/edit is waiting on the placement modal. */
|
||||
export class BreakPlacementDeferred extends Error {
|
||||
constructor() {
|
||||
super('Break placement deferred to modal');
|
||||
this.name = 'BreakPlacementDeferred';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic entry primitives the break subsystem borrows from the cell-mutation
|
||||
* layer. `createCell` drops an entry in the first free window (used when there
|
||||
* is no work to anchor a break to); `updateEntry`/`requireOrgId` are the shared
|
||||
* API helpers. Keeping them injected avoids a circular dependency and makes the
|
||||
* break flow unit-testable in isolation.
|
||||
*/
|
||||
export interface BreakPlacementDeps {
|
||||
weekDays: Ref<string[]>;
|
||||
timeEntries: Ref<TimeEntry[]>;
|
||||
requireOrgId: () => string;
|
||||
createCell: (
|
||||
row: TimesheetRow,
|
||||
dayIndex: number,
|
||||
totalSeconds: number,
|
||||
afterCursor?: string
|
||||
) => Promise<void>;
|
||||
updateEntry: (entry: TimeEntry) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Break-placement subsystem for the timesheet. Owns the placement-modal request
|
||||
* state and everything that positions a break relative to work — auto-placing it
|
||||
* into a valid gap when one exists, or deferring to the split/move modal when the
|
||||
* day has to be rearranged.
|
||||
*/
|
||||
export function useBreakPlacement(deps: BreakPlacementDeps) {
|
||||
const { weekDays, timeEntries, requireOrgId, createCell, updateEntry } = deps;
|
||||
const dayjs = getDayJsInstance();
|
||||
const queryClient = useQueryClient();
|
||||
const notifications = useNotificationsStore();
|
||||
|
||||
// Set when a break needs manual placement; the page shows the modal for it.
|
||||
const breakPlacementRequest = ref<BreakPlacementRequest | null>(null);
|
||||
|
||||
/**
|
||||
* Movable work/breaks on the target local day plus the usable day window.
|
||||
* Entries crossing a day boundary shrink the window instead of being
|
||||
* movable (see buildDayPlacementContext) — the padded timesheet fetch
|
||||
* makes them visible even at the week edges.
|
||||
*/
|
||||
function dayPlacementContext(
|
||||
date: string,
|
||||
tz: string,
|
||||
excludeBreakId?: string
|
||||
): DayPlacementContext {
|
||||
const { dayStart, dayEnd } = localDayBounds(date, tz);
|
||||
return buildDayPlacementContext(
|
||||
timeEntries.value,
|
||||
dayStart.format(),
|
||||
dayEnd.format(),
|
||||
excludeBreakId ?? null
|
||||
);
|
||||
}
|
||||
|
||||
async function createBreakEntry(start: string, end: string, memberId?: string): Promise<void> {
|
||||
const orgId = requireOrgId();
|
||||
const member = memberId ?? getCurrentMembershipId();
|
||||
if (!member) throw new Error('No member context');
|
||||
await api.createTimeEntry(
|
||||
{
|
||||
member_id: member,
|
||||
project_id: null,
|
||||
task_id: null,
|
||||
start,
|
||||
end,
|
||||
billable: false,
|
||||
type: 'break',
|
||||
description: null,
|
||||
tags: [],
|
||||
},
|
||||
{ params: { organization: orgId } }
|
||||
);
|
||||
}
|
||||
|
||||
async function saveBreakEntry(
|
||||
start: string,
|
||||
end: string,
|
||||
replaceBreakId?: string,
|
||||
memberId?: string
|
||||
): Promise<void> {
|
||||
if (replaceBreakId) {
|
||||
const existing = timeEntries.value.find((entry) => entry.id === replaceBreakId);
|
||||
if (!existing) throw new Error('Break to update no longer exists');
|
||||
|
||||
await updateEntry({ ...existing, start, end });
|
||||
return;
|
||||
}
|
||||
|
||||
await createBreakEntry(start, end, memberId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Place a break on the day (new, or re-placing an existing one when `replaceBreakId`
|
||||
* is given). Prefers a gap that already satisfies the placement tolerance; otherwise
|
||||
* raises BreakPlacementDeferred so the page opens the modal. With no work to anchor to,
|
||||
* the break is just dropped in / resized in the first free window.
|
||||
*/
|
||||
async function placeBreak(
|
||||
row: TimesheetRow,
|
||||
dayIndex: number,
|
||||
durationSeconds: number,
|
||||
replaceBreakId?: string
|
||||
): Promise<void> {
|
||||
const date = weekDays.value[dayIndex]!;
|
||||
const tz = getUserTimezone();
|
||||
const { work, breaks, blocked, dayStart, dayEnd } = dayPlacementContext(
|
||||
date,
|
||||
tz,
|
||||
replaceBreakId
|
||||
);
|
||||
// Existing breaks block auto-placement into a gap (obstacles), but move
|
||||
// along with the surrounding work when a move plan shifts entries.
|
||||
// Running entries block everything from their start (never movable).
|
||||
const obstacles = [...breaks, ...blocked];
|
||||
|
||||
// On edit, keep the break where it is when its current gap still fits it; only
|
||||
// fall back to the first-gap-centered placement when it can't stay put.
|
||||
const anchorStart = replaceBreakId
|
||||
? (timeEntries.value.find((e) => e.id === replaceBreakId)?.start ?? null)
|
||||
: null;
|
||||
const validGap =
|
||||
(anchorStart !== null
|
||||
? findValidBreakGapNear(work, durationSeconds, anchorStart, obstacles)
|
||||
: null) ?? findValidBreakGap(work, durationSeconds, obstacles);
|
||||
if (validGap) {
|
||||
await saveBreakEntry(validGap.start, validGap.end, replaceBreakId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (work.length === 0) {
|
||||
// No work to sit between: for an edit, resize the break in place; for a new
|
||||
// break, drop it in the first free window. Nothing to align to either way.
|
||||
if (replaceBreakId) {
|
||||
const existing = timeEntries.value.find((e) => e.id === replaceBreakId);
|
||||
if (existing) {
|
||||
const newEnd = dayjs
|
||||
.utc(existing.start)
|
||||
.add(durationSeconds, 'second')
|
||||
.format();
|
||||
await updateEntry({ ...existing, end: newEnd });
|
||||
return;
|
||||
}
|
||||
}
|
||||
await createCell(row, dayIndex, durationSeconds);
|
||||
return;
|
||||
}
|
||||
|
||||
const mode: 'split' | 'move' = work.length === 1 ? 'split' : 'move';
|
||||
const defaultBreakStart =
|
||||
mode === 'split'
|
||||
? (planSplitEntry(work[0]!, durationSeconds)?.breakSlot.start ?? null)
|
||||
: (suggestMovePlan(work, dayStart, dayEnd, durationSeconds, breaks)?.breakSlot
|
||||
.start ?? null);
|
||||
|
||||
if (!defaultBreakStart) {
|
||||
// Even splitting/moving can't open a slot on this day.
|
||||
throw new NoFreeWindowError(date, durationSeconds);
|
||||
}
|
||||
|
||||
breakPlacementRequest.value = {
|
||||
date,
|
||||
durationSeconds,
|
||||
dayStart,
|
||||
dayEnd,
|
||||
workEntries: work,
|
||||
otherEntries: breaks,
|
||||
defaultBreakStart,
|
||||
replaceBreakId: replaceBreakId ?? null,
|
||||
};
|
||||
throw new BreakPlacementDeferred();
|
||||
}
|
||||
|
||||
function dismissBreakPlacement(): void {
|
||||
breakPlacementRequest.value = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit a break at `breakStart` by executing the split or move plan. Shifts
|
||||
* happen before the break is saved so its target slot is free first.
|
||||
*/
|
||||
async function applyBreakPlacement(breakStart: string, durationSeconds: number): Promise<void> {
|
||||
const req = breakPlacementRequest.value;
|
||||
if (!req) return;
|
||||
|
||||
// The timesheet is the current member's own, so all created/edited entries stay with them.
|
||||
const memberId = getCurrentMembershipId();
|
||||
if (!memberId) throw new Error('No member context');
|
||||
|
||||
let entriesAdjusted = true;
|
||||
try {
|
||||
if (placementMode(req) === 'split') {
|
||||
const original = timeEntries.value.find((e) => e.id === req.workEntries[0]!.id);
|
||||
const plan = planSplitEntry(req.workEntries[0]!, durationSeconds, breakStart);
|
||||
if (!original || !plan) throw new NoFreeWindowError(req.date, durationSeconds);
|
||||
// Shrink the original to the first half, then add the second half + break.
|
||||
await updateEntry({
|
||||
...original,
|
||||
start: plan.firstHalf.start,
|
||||
end: plan.firstHalf.end,
|
||||
});
|
||||
await api.createTimeEntry(
|
||||
{
|
||||
member_id: memberId,
|
||||
project_id: original.project_id,
|
||||
task_id: original.task_id,
|
||||
start: plan.secondHalf.start,
|
||||
end: plan.secondHalf.end,
|
||||
billable: original.billable,
|
||||
type: 'work',
|
||||
description: original.description ?? null,
|
||||
tags: original.tags ?? [],
|
||||
},
|
||||
{ params: { organization: requireOrgId() } }
|
||||
);
|
||||
await saveBreakEntry(
|
||||
plan.breakSlot.start,
|
||||
plan.breakSlot.end,
|
||||
req.replaceBreakId ?? undefined,
|
||||
memberId
|
||||
);
|
||||
} else {
|
||||
const plan = planMoveInsert(
|
||||
[...req.workEntries, ...req.otherEntries],
|
||||
req.dayStart,
|
||||
req.dayEnd,
|
||||
breakStart,
|
||||
durationSeconds
|
||||
);
|
||||
if (!plan) throw new NoFreeWindowError(req.date, durationSeconds);
|
||||
entriesAdjusted = plan.shifted.length > 0;
|
||||
// Order the shifts so no intermediate step overlaps (matters when the org
|
||||
// prevents overlapping entries): entries moving earlier are updated left-to-right,
|
||||
// entries moving later right-to-left, so each one vacates before its neighbour moves.
|
||||
const shifts = plan.shifted
|
||||
.map((shift) => ({
|
||||
shift,
|
||||
original: timeEntries.value.find((e) => e.id === shift.id),
|
||||
}))
|
||||
.filter(
|
||||
(x): x is { shift: (typeof plan.shifted)[number]; original: TimeEntry } =>
|
||||
!!x.original
|
||||
);
|
||||
const movingEarlier = shifts
|
||||
.filter((x) => x.shift.start < x.original.start)
|
||||
.sort((a, b) => a.original.start.localeCompare(b.original.start));
|
||||
const movingLater = shifts
|
||||
.filter((x) => x.shift.start >= x.original.start)
|
||||
.sort((a, b) => b.original.start.localeCompare(a.original.start));
|
||||
for (const { shift, original } of [...movingEarlier, ...movingLater]) {
|
||||
await updateEntry({ ...original, start: shift.start, end: shift.end });
|
||||
}
|
||||
await saveBreakEntry(
|
||||
plan.breakSlot.start,
|
||||
plan.breakSlot.end,
|
||||
req.replaceBreakId ?? undefined,
|
||||
memberId
|
||||
);
|
||||
}
|
||||
notifications.addNotification(
|
||||
'success',
|
||||
req.replaceBreakId ? 'Break updated' : 'Break added',
|
||||
entriesAdjusted
|
||||
? 'Your entries were adjusted to make room for the break.'
|
||||
: 'The break was added at the selected time.'
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof NoFreeWindowError) {
|
||||
notifications.addNotification(
|
||||
'error',
|
||||
"This day can't fit the break",
|
||||
'Try a shorter break or a different time.'
|
||||
);
|
||||
} else {
|
||||
notifications.addNotification(
|
||||
'error',
|
||||
'Failed to add break',
|
||||
'Please try again later.'
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
breakPlacementRequest.value = null;
|
||||
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
breakPlacementRequest,
|
||||
placeBreak,
|
||||
dismissBreakPlacement,
|
||||
applyBreakPlacement,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user