mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 00:02:15 +01:00
324 lines
13 KiB
TypeScript
324 lines
13 KiB
TypeScript
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,
|
|
};
|
|
}
|