mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-14 11:12:16 +01:00
add break time entries and simplified time tracker ui
This commit is contained in:
@@ -91,12 +91,13 @@ function prefetchDashboard(queryClient: QueryClient) {
|
||||
prefetchTasks(queryClient);
|
||||
|
||||
// Prefetch all dashboard card data
|
||||
// Must match the query in RecentlyTrackedTasksCard exactly — same key, same params
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ['timeEntries', organizationId],
|
||||
queryFn: () =>
|
||||
api.getTimeEntries({
|
||||
params: { organization: organizationId },
|
||||
queries: { limit: 10, offset: 0, only_full_dates: 'true' },
|
||||
queries: { member_id: getCurrentMembershipId(), type: 'work' },
|
||||
}),
|
||||
staleTime: 30000,
|
||||
});
|
||||
|
||||
490
resources/js/utils/timesheet/breakPlacementMath.test.ts
Normal file
490
resources/js/utils/timesheet/breakPlacementMath.test.ts
Normal file
@@ -0,0 +1,490 @@
|
||||
import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
BREAK_GAP_TOLERANCE_SECONDS,
|
||||
buildDayPlacementContext,
|
||||
findValidBreakGap,
|
||||
findValidBreakGapNear,
|
||||
planMoveInsert,
|
||||
planSplitEntry,
|
||||
suggestMovePlan,
|
||||
type MovableInterval,
|
||||
} from './breakPlacementMath';
|
||||
|
||||
dayjs.extend(utc);
|
||||
|
||||
const HALF_HOUR = 1800;
|
||||
const HOUR = 3600;
|
||||
const DAY = '2026-07-14';
|
||||
const dayStart = `${DAY}T00:00:00Z`;
|
||||
const dayEnd = `${DAY}T24:00:00Z`;
|
||||
|
||||
function iv(startH: number, endH: number) {
|
||||
const h = (n: number) => {
|
||||
const totalMin = Math.round(n * 60);
|
||||
const hh = Math.floor(totalMin / 60);
|
||||
const mm = totalMin % 60;
|
||||
return `${DAY}T${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}:00Z`;
|
||||
};
|
||||
return { start: h(startH), end: h(endH) };
|
||||
}
|
||||
|
||||
describe('findValidBreakGap', () => {
|
||||
it('centers the break in a gap that fits within tolerance', () => {
|
||||
// 09-12 and 13-17 → 1h gap, 30m break → centered at 12:15-12:45
|
||||
const gap = findValidBreakGap([iv(9, 12), iv(13, 17)], HALF_HOUR);
|
||||
expect(gap).toEqual({ start: `${DAY}T12:15:00Z`, end: `${DAY}T12:45:00Z` });
|
||||
});
|
||||
|
||||
it('rejects a gap that is too small for the break', () => {
|
||||
// 09-12 and 12:15-17 → 15m gap, 30m break does not fit
|
||||
expect(findValidBreakGap([iv(9, 12), iv(12.25, 17)], HALF_HOUR)).toBeNull();
|
||||
});
|
||||
|
||||
it('places the break flush after work in an oversized gap instead of rejecting it', () => {
|
||||
// 09-12 and 14-17 → 2h gap. No placement keeps both sides within tolerance,
|
||||
// but the gap easily holds the break — place it flush after the first entry
|
||||
// and leave the gap otherwise untouched (distance to work is only a soft hint).
|
||||
expect(findValidBreakGap([iv(9, 12), iv(14, 17)], HALF_HOUR)).toEqual({
|
||||
start: `${DAY}T12:00:00Z`,
|
||||
end: `${DAY}T12:30:00Z`,
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers a within-tolerance gap over an earlier oversized gap', () => {
|
||||
// 09-10, 13-14, 15-16: the first gap (3h) is oversized, the second (1h) is
|
||||
// valid → center in the second instead of going flush-left in the first.
|
||||
expect(findValidBreakGap([iv(9, 10), iv(13, 14), iv(15, 16)], HALF_HOUR)).toEqual({
|
||||
start: `${DAY}T14:15:00Z`,
|
||||
end: `${DAY}T14:45:00Z`,
|
||||
});
|
||||
});
|
||||
|
||||
it('slides past an obstacle when placing into an oversized gap', () => {
|
||||
// 09-12 and 16-17 with an existing break flush at 12:00 → the new break
|
||||
// lands right after that break.
|
||||
expect(findValidBreakGap([iv(9, 12), iv(16, 17)], HALF_HOUR, [iv(12, 12.75)])).toEqual({
|
||||
start: `${DAY}T12:45:00Z`,
|
||||
end: `${DAY}T13:15:00Z`,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not fabricate a gap from an entry contained in a longer one', () => {
|
||||
// 10-11 sits inside 09-17; the only real gap is 17:00-18:00 → centered there.
|
||||
expect(findValidBreakGap([iv(9, 17), iv(10, 11), iv(18, 19)], HALF_HOUR)).toEqual({
|
||||
start: `${DAY}T17:15:00Z`,
|
||||
end: `${DAY}T17:45:00Z`,
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts a gap exactly at duration + 2*tolerance', () => {
|
||||
const gapEnd = 12 + (HALF_HOUR + 2 * BREAK_GAP_TOLERANCE_SECONDS) / HOUR;
|
||||
const gap = findValidBreakGap([iv(9, 12), iv(gapEnd, gapEnd + 1)], HALF_HOUR);
|
||||
expect(gap).not.toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when there is only one work entry', () => {
|
||||
expect(findValidBreakGap([iv(9, 17)], HALF_HOUR)).toBeNull();
|
||||
});
|
||||
|
||||
it('skips a gap already occupied by another break', () => {
|
||||
// The only valid gap (12:15–12:45) is taken by an existing break → no auto placement
|
||||
expect(
|
||||
findValidBreakGap([iv(9, 12), iv(13, 17)], HALF_HOUR, [
|
||||
{ start: `${DAY}T12:15:00Z`, end: `${DAY}T12:45:00Z` },
|
||||
])
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores obstacles that fall outside the chosen gap', () => {
|
||||
expect(findValidBreakGap([iv(9, 12), iv(13, 17)], HALF_HOUR, [iv(20, 21)])).toEqual({
|
||||
start: `${DAY}T12:15:00Z`,
|
||||
end: `${DAY}T12:45:00Z`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('planSplitEntry', () => {
|
||||
it('splits a single entry and centers the break', () => {
|
||||
const plan = planSplitEntry(iv(9, 17), HALF_HOUR);
|
||||
expect(plan).not.toBeNull();
|
||||
expect(plan!.firstHalf.start).toBe(`${DAY}T09:00:00Z`);
|
||||
expect(plan!.breakSlot.start).toBe(plan!.firstHalf.end);
|
||||
expect(plan!.secondHalf.start).toBe(plan!.breakSlot.end);
|
||||
expect(plan!.secondHalf.end).toBe(`${DAY}T17:00:00Z`);
|
||||
// break is 30m and centered → 12:45-13:15
|
||||
expect(plan!.breakSlot).toEqual({ start: `${DAY}T12:45:00Z`, end: `${DAY}T13:15:00Z` });
|
||||
});
|
||||
|
||||
it('honors an explicit break start', () => {
|
||||
const plan = planSplitEntry(iv(9, 17), HALF_HOUR, `${DAY}T10:00:00Z`);
|
||||
expect(plan!.firstHalf).toEqual({ start: `${DAY}T09:00:00Z`, end: `${DAY}T10:00:00Z` });
|
||||
expect(plan!.secondHalf.start).toBe(`${DAY}T10:30:00Z`);
|
||||
});
|
||||
|
||||
it('returns null when the entry is too short to leave work on both sides', () => {
|
||||
expect(planSplitEntry(iv(9, 9.25), HALF_HOUR)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an explicit break start before the entry instead of clamping it', () => {
|
||||
// 07:00 lies before the 09:00-17:00 entry — relocating it silently would
|
||||
// leave a hair-thin first fragment at a time the user never picked.
|
||||
expect(planSplitEntry(iv(9, 17), HALF_HOUR, `${DAY}T07:00:00Z`)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an explicit break start whose break would reach past the entry end', () => {
|
||||
expect(planSplitEntry(iv(9, 17), HALF_HOUR, `${DAY}T16:45:00Z`)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an explicit break start that leaves less than the minimum fragment', () => {
|
||||
// 09:00:30 would leave only 30s of work before the break.
|
||||
expect(planSplitEntry(iv(9, 17), HALF_HOUR, `${DAY}T09:00:30Z`)).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts an explicit break start leaving exactly the minimum fragment on each side', () => {
|
||||
// 09:01 leaves 60s before; on a 09:00-09:32 entry a 30m break also leaves 60s after.
|
||||
const plan = planSplitEntry(iv(9, 9 + 32 / 60), HALF_HOUR, `${DAY}T09:01:00Z`);
|
||||
expect(plan).not.toBeNull();
|
||||
expect(plan!.firstHalf).toEqual({ start: `${DAY}T09:00:00Z`, end: `${DAY}T09:01:00Z` });
|
||||
expect(plan!.secondHalf).toEqual({ start: `${DAY}T09:31:00Z`, end: `${DAY}T09:32:00Z` });
|
||||
});
|
||||
|
||||
it('returns null when the entry cannot hold the break plus a minimum fragment per side', () => {
|
||||
// 31 minutes of work cannot hold a 30m break with 60s of work on each side.
|
||||
expect(planSplitEntry(iv(9, 9 + 31 / 60), HALF_HOUR)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('planMoveInsert', () => {
|
||||
const movable = (id: string, startH: number, endH: number): MovableInterval => ({
|
||||
id,
|
||||
...iv(startH, endH),
|
||||
});
|
||||
|
||||
it('pushes the right block later to open a slot for the break', () => {
|
||||
// Back-to-back 09-12 and 12-17. Insert 30m break at 12:00.
|
||||
const plan = planMoveInsert(
|
||||
[movable('a', 9, 12), movable('b', 12, 17)],
|
||||
dayStart,
|
||||
dayEnd,
|
||||
`${DAY}T12:00:00Z`,
|
||||
HALF_HOUR
|
||||
);
|
||||
expect(plan).not.toBeNull();
|
||||
expect(plan!.breakSlot).toEqual({ start: `${DAY}T12:00:00Z`, end: `${DAY}T12:30:00Z` });
|
||||
// 'a' untouched (not in shifted), 'b' shifted +30m
|
||||
expect(plan!.shifted).toEqual([
|
||||
{ id: 'b', start: `${DAY}T12:30:00Z`, end: `${DAY}T17:30:00Z` },
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves an oversized gap alone instead of pulling the right block flush', () => {
|
||||
// 09-12 and 15-17 (3h gap). Break flush after first at 12:00 fits in the gap
|
||||
// → nothing moves; the user's gap is preserved.
|
||||
const plan = planMoveInsert(
|
||||
[movable('a', 9, 12), movable('b', 15, 17)],
|
||||
dayStart,
|
||||
dayEnd,
|
||||
`${DAY}T12:00:00Z`,
|
||||
HALF_HOUR
|
||||
);
|
||||
expect(plan!.breakSlot).toEqual({ start: `${DAY}T12:00:00Z`, end: `${DAY}T12:30:00Z` });
|
||||
expect(plan!.shifted).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not drag entries flush when the break sits mid-gap', () => {
|
||||
// Break at 13:00 in the middle of the 12:00-15:00 gap → neither side moves.
|
||||
const plan = planMoveInsert(
|
||||
[movable('a', 9, 12), movable('b', 15, 17)],
|
||||
dayStart,
|
||||
dayEnd,
|
||||
`${DAY}T13:00:00Z`,
|
||||
HALF_HOUR
|
||||
);
|
||||
expect(plan!.shifted).toEqual([]);
|
||||
});
|
||||
|
||||
it('shifts each side only as much as needed to clear the slot', () => {
|
||||
// Break 14:45-15:15 overlaps only the start of 'b' → 'b' pushed 15m later,
|
||||
// 'a' untouched.
|
||||
const plan = planMoveInsert(
|
||||
[movable('a', 9, 12), movable('b', 15, 17)],
|
||||
dayStart,
|
||||
dayEnd,
|
||||
`${DAY}T14:45:00Z`,
|
||||
HALF_HOUR
|
||||
);
|
||||
expect(plan!.shifted).toEqual([
|
||||
{ id: 'b', start: `${DAY}T15:15:00Z`, end: `${DAY}T17:15:00Z` },
|
||||
]);
|
||||
});
|
||||
|
||||
it('pulls the left block earlier only when it overlaps the slot', () => {
|
||||
// Break 11:45-12:15 overlaps the end of 'a' → 'a' pulled 15m earlier;
|
||||
// 'b' (15-17) already clears the slot and stays put.
|
||||
const plan = planMoveInsert(
|
||||
[movable('a', 9, 12), movable('b', 15, 17)],
|
||||
dayStart,
|
||||
dayEnd,
|
||||
`${DAY}T11:45:00Z`,
|
||||
HALF_HOUR
|
||||
);
|
||||
expect(plan!.shifted).toEqual([
|
||||
{ id: 'a', start: `${DAY}T08:45:00Z`, end: `${DAY}T11:45:00Z` },
|
||||
]);
|
||||
});
|
||||
|
||||
it('shifts the left block earlier when the right block cannot move within the day', () => {
|
||||
// Right entry ends at 23:50; pushing it later would cross midnight, so the
|
||||
// left block must move earlier instead.
|
||||
const plan = planMoveInsert(
|
||||
[movable('a', 9, 12), movable('b', 12, 23 + 50 / 60)],
|
||||
dayStart,
|
||||
dayEnd,
|
||||
`${DAY}T12:00:00Z`,
|
||||
HALF_HOUR
|
||||
);
|
||||
// Not feasible by pushing right; solver returns null (caller lets the user pick another spot)
|
||||
expect(plan).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the break itself would fall outside the day', () => {
|
||||
expect(
|
||||
planMoveInsert([movable('a', 9, 12)], dayStart, dayEnd, `${DAY}T23:50:00Z`, HALF_HOUR)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('places a break between entries without shifting when they already have exactly the gap', () => {
|
||||
const plan = planMoveInsert(
|
||||
[movable('a', 9, 12), movable('b', 12.5, 17)],
|
||||
dayStart,
|
||||
dayEnd,
|
||||
`${DAY}T12:00:00Z`,
|
||||
HALF_HOUR
|
||||
);
|
||||
// gap is exactly 30m → 'b' already starts at break end, nothing to shift
|
||||
expect(plan!.breakSlot).toEqual({ start: `${DAY}T12:00:00Z`, end: `${DAY}T12:30:00Z` });
|
||||
expect(plan!.shifted).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('suggestMovePlan', () => {
|
||||
const movable = (id: string, startH: number, endH: number): MovableInterval => ({
|
||||
id,
|
||||
...iv(startH, endH),
|
||||
});
|
||||
|
||||
it('finds a flush-after placement for back-to-back entries', () => {
|
||||
const plan = suggestMovePlan(
|
||||
[movable('a', 9, 12), movable('b', 12, 17)],
|
||||
dayStart,
|
||||
dayEnd,
|
||||
HALF_HOUR
|
||||
);
|
||||
expect(plan!.breakSlot).toEqual({ start: `${DAY}T12:00:00Z`, end: `${DAY}T12:30:00Z` });
|
||||
});
|
||||
|
||||
it('falls back to a flush-before placement when the day is nearly full at the end', () => {
|
||||
// 09-12 and 12-23:50: pushing right past midnight is impossible, so the break
|
||||
// is placed just before the second entry, pulling the first entry earlier.
|
||||
const plan = suggestMovePlan(
|
||||
[movable('a', 9, 12), movable('b', 12, 23 + 50 / 60)],
|
||||
dayStart,
|
||||
dayEnd,
|
||||
HALF_HOUR
|
||||
);
|
||||
expect(plan).not.toBeNull();
|
||||
expect(plan!.breakSlot).toEqual({ start: `${DAY}T11:30:00Z`, end: `${DAY}T12:00:00Z` });
|
||||
// first entry pulled 30m earlier, second untouched
|
||||
expect(plan!.shifted.find((s) => s.id === 'a')).toEqual({
|
||||
id: 'a',
|
||||
start: `${DAY}T08:30:00Z`,
|
||||
end: `${DAY}T11:30:00Z`,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when the day is completely full', () => {
|
||||
const plan = suggestMovePlan([movable('a', 0, 24)], dayStart, dayEnd, HALF_HOUR);
|
||||
expect(plan).toBeNull();
|
||||
});
|
||||
|
||||
it('moves existing breaks along with the surrounding work', () => {
|
||||
// Fully packed day: work 09-12, break 12-12:30, work 12:30-17. Opening a
|
||||
// slot after the morning work pushes the existing break and the afternoon
|
||||
// work later together — the plan never lands on top of the break.
|
||||
const plan = suggestMovePlan(
|
||||
[movable('a', 9, 12), movable('c', 12.5, 17)],
|
||||
dayStart,
|
||||
dayEnd,
|
||||
HALF_HOUR,
|
||||
[movable('x', 12, 12.5)]
|
||||
);
|
||||
expect(plan!.breakSlot).toEqual({ start: `${DAY}T12:00:00Z`, end: `${DAY}T12:30:00Z` });
|
||||
expect([...plan!.shifted].sort((a, b) => a.id.localeCompare(b.id))).toEqual([
|
||||
{ id: 'c', start: `${DAY}T13:00:00Z`, end: `${DAY}T17:30:00Z` },
|
||||
{ id: 'x', start: `${DAY}T12:30:00Z`, end: `${DAY}T13:00:00Z` },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDayPlacementContext', () => {
|
||||
const PREV_DAY = '2026-07-13';
|
||||
const entry = (id: string, start: string, end: string | null, type = 'work') => ({
|
||||
id,
|
||||
start,
|
||||
end,
|
||||
type,
|
||||
});
|
||||
|
||||
it('separates movable work and breaks fully inside the day', () => {
|
||||
const ctx = buildDayPlacementContext(
|
||||
[
|
||||
entry('w1', `${DAY}T09:00:00Z`, `${DAY}T12:00:00Z`),
|
||||
entry('b1', `${DAY}T12:00:00Z`, `${DAY}T12:30:00Z`, 'break'),
|
||||
entry('w2', `${DAY}T13:00:00Z`, `${DAY}T17:00:00Z`),
|
||||
],
|
||||
dayStart,
|
||||
dayEnd
|
||||
);
|
||||
expect(ctx.work.map((e) => e.id)).toEqual(['w1', 'w2']);
|
||||
expect(ctx.breaks.map((e) => e.id)).toEqual(['b1']);
|
||||
expect(ctx.dayStart).toBe(`${DAY}T00:00:00Z`);
|
||||
// `T24:00` normalizes to the next day's midnight
|
||||
expect(ctx.dayEnd).toBe(`2026-07-15T00:00:00Z`);
|
||||
});
|
||||
|
||||
it('excludes the break being re-placed', () => {
|
||||
const ctx = buildDayPlacementContext(
|
||||
[entry('b1', `${DAY}T12:00:00Z`, `${DAY}T12:30:00Z`, 'break')],
|
||||
dayStart,
|
||||
dayEnd,
|
||||
'b1'
|
||||
);
|
||||
expect(ctx.breaks).toEqual([]);
|
||||
});
|
||||
|
||||
it('turns entries crossing midnight into walls that shrink the day window', () => {
|
||||
// 22:00 (prev day) - 02:00 spills in; 23:00 - 01:00 (next day) spills out.
|
||||
const ctx = buildDayPlacementContext(
|
||||
[
|
||||
entry('overnight', `${PREV_DAY}T22:00:00Z`, `${DAY}T02:00:00Z`),
|
||||
entry('w1', `${DAY}T09:00:00Z`, `${DAY}T17:00:00Z`),
|
||||
entry('late', `${DAY}T23:00:00Z`, `2026-07-15T01:00:00Z`),
|
||||
],
|
||||
dayStart,
|
||||
dayEnd
|
||||
);
|
||||
// Boundary-crossers are not movable...
|
||||
expect(ctx.work.map((e) => e.id)).toEqual(['w1']);
|
||||
// ...but clamp the usable window so nothing can be shifted into them.
|
||||
expect(ctx.dayStart).toBe(`${DAY}T02:00:00Z`);
|
||||
expect(ctx.dayEnd).toBe(`${DAY}T23:00:00Z`);
|
||||
});
|
||||
|
||||
it('ignores entries on other days', () => {
|
||||
const ctx = buildDayPlacementContext(
|
||||
[entry('other-day', `${PREV_DAY}T09:00:00Z`, `${PREV_DAY}T10:00:00Z`)],
|
||||
dayStart,
|
||||
dayEnd
|
||||
);
|
||||
expect(ctx.work).toEqual([]);
|
||||
expect(ctx.breaks).toEqual([]);
|
||||
expect(ctx.blocked).toEqual([]);
|
||||
});
|
||||
|
||||
it('turns a running entry into a blocker that caps the day window', () => {
|
||||
const ctx = buildDayPlacementContext(
|
||||
[
|
||||
entry('w1', `${DAY}T06:00:00Z`, `${DAY}T08:00:00Z`),
|
||||
entry('running', `${DAY}T09:00:00Z`, null),
|
||||
],
|
||||
dayStart,
|
||||
dayEnd
|
||||
);
|
||||
// The running entry is not movable, blocks the day from its start on,
|
||||
// and nothing can be shifted to or past it.
|
||||
expect(ctx.work.map((e) => e.id)).toEqual(['w1']);
|
||||
expect(ctx.blocked).toEqual([{ start: `${DAY}T09:00:00Z`, end: dayEnd }]);
|
||||
expect(ctx.dayEnd).toBe(`${DAY}T09:00:00Z`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findValidBreakGapNear', () => {
|
||||
// 09-10 and 11:30-12:30 → a 90-min gap (10:00-11:30). A 1h break has a valid
|
||||
// start window of 10:00-10:30; findValidBreakGap would center it at 10:15.
|
||||
const work = [iv(9, 10), iv(11.5, 12.5)];
|
||||
|
||||
it('keeps the break at its current start instead of recentering', () => {
|
||||
const gap = findValidBreakGapNear(work, HOUR, `${DAY}T10:00:00Z`);
|
||||
expect(gap).toEqual({ start: `${DAY}T10:00:00Z`, end: `${DAY}T11:00:00Z` });
|
||||
});
|
||||
|
||||
it('clamps the anchor into the tolerance window when it sits too late', () => {
|
||||
// Anchored at 11:00 (beyond the window) → clamped back to 10:30.
|
||||
const gap = findValidBreakGapNear(work, HOUR, `${DAY}T11:00:00Z`);
|
||||
expect(gap).toEqual({ start: `${DAY}T10:30:00Z`, end: `${DAY}T11:30:00Z` });
|
||||
});
|
||||
|
||||
it('keeps the break in place inside an oversized gap', () => {
|
||||
// 09-10 and 14-15 → 4h gap. The break stays exactly where the user left it;
|
||||
// its distance from work is a soft hint, not a reason to move it.
|
||||
const wideWork = [iv(9, 10), iv(14, 15)];
|
||||
expect(findValidBreakGapNear(wideWork, HALF_HOUR, `${DAY}T11:00:00Z`)).toEqual({
|
||||
start: `${DAY}T11:00:00Z`,
|
||||
end: `${DAY}T11:30:00Z`,
|
||||
});
|
||||
});
|
||||
|
||||
it('clamps the anchor so the break stays inside the gap', () => {
|
||||
// Anchored at 13:50 in the 10:00-14:00 gap → a 30m break would spill into
|
||||
// the next work entry, so it is clamped back to 13:30-14:00.
|
||||
const wideWork = [iv(9, 10), iv(14, 15)];
|
||||
expect(findValidBreakGapNear(wideWork, HALF_HOUR, `${DAY}T13:50:00Z`)).toEqual({
|
||||
start: `${DAY}T13:30:00Z`,
|
||||
end: `${DAY}T14:00:00Z`,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when the anchor's gap can't hold the new duration", () => {
|
||||
// 09-10 and 10:30-12:30 → only a 30-min gap; a 1h break no longer fits.
|
||||
const tightWork = [iv(9, 10), iv(10.5, 12.5)];
|
||||
expect(findValidBreakGapNear(tightWork, HOUR, `${DAY}T10:00:00Z`)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the anchor sits outside every inter-work gap', () => {
|
||||
// Anchor before the first work entry — a genuinely misplaced break, which the
|
||||
// caller then re-places via findValidBreakGap instead.
|
||||
expect(findValidBreakGapNear(work, HOUR, `${DAY}T08:00:00Z`)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when no free window in the gap can hold the break', () => {
|
||||
// Another break occupies 10:00-11:00; the leftover windows (none before,
|
||||
// 30m after) can't hold a 1h break → fall back to findValidBreakGap.
|
||||
expect(findValidBreakGapNear(work, HOUR, `${DAY}T10:00:00Z`, [iv(10, 11)])).toBeNull();
|
||||
});
|
||||
|
||||
it('slides past a neighboring break inside the same gap instead of bailing', () => {
|
||||
// Gap 10:00-12:00 between work; another break sits at 10:30-11:00. Growing
|
||||
// the 10:00 break to 45m no longer fits before it, so it settles right
|
||||
// after the neighbor (11:00) — not in a different gap across the day.
|
||||
const wideWork = [iv(9, 10), iv(12, 13)];
|
||||
expect(findValidBreakGapNear(wideWork, 2700, `${DAY}T10:00:00Z`, [iv(10.5, 11)])).toEqual({
|
||||
start: `${DAY}T11:00:00Z`,
|
||||
end: `${DAY}T11:45:00Z`,
|
||||
});
|
||||
});
|
||||
|
||||
it('settles in the free window closest to the anchor', () => {
|
||||
// Gap 10:00-13:00 with obstacles 10:45-11:00 and 11:15-12:30. For a 30m
|
||||
// break anchored at 10:50 the candidates are 10:15 (35m away) and 12:30
|
||||
// (100m away); the middle window is too small.
|
||||
const wideWork = [iv(9, 10), iv(13, 14)];
|
||||
expect(
|
||||
findValidBreakGapNear(wideWork, HALF_HOUR, `${DAY}T10:50:00Z`, [
|
||||
iv(10.75, 11),
|
||||
iv(11.25, 12.5),
|
||||
])
|
||||
).toEqual({ start: `${DAY}T10:15:00Z`, end: `${DAY}T10:45:00Z` });
|
||||
});
|
||||
});
|
||||
471
resources/js/utils/timesheet/breakPlacementMath.ts
Normal file
471
resources/js/utils/timesheet/breakPlacementMath.ts
Normal file
@@ -0,0 +1,471 @@
|
||||
import { getDayJsInstance } from '@/packages/ui/src/utils/time';
|
||||
import { BREAK_GAP_TOLERANCE_MINUTES } from '@/packages/ui/src/utils/breakPlacement';
|
||||
|
||||
/**
|
||||
* Break placement solver for the timesheet.
|
||||
*
|
||||
* A break only means something sitting between work, ideally within a tolerance
|
||||
* of it on both sides (see BREAK_GAP_TOLERANCE_MINUTES). When a break is added
|
||||
* to a day we first try to drop it into an existing gap without touching any
|
||||
* other entry — preferring a gap where the tolerance holds, but accepting any
|
||||
* gap big enough to hold the break. The tolerance is a soft, read-time hint
|
||||
* (see getBreakPlacementHint), never a reason to rearrange entries the user
|
||||
* tracked deliberately. Only when no gap can physically hold the break does the
|
||||
* caller resolve it via a modal that either splits the single work entry or
|
||||
* moves the surrounding entries to open a slot — always keeping everything
|
||||
* inside the day.
|
||||
*
|
||||
* All timestamps are UTC ISO strings. Shift arithmetic is done in epoch
|
||||
* milliseconds so it is DST-safe (a wall-clock day can be 23h or 25h long).
|
||||
*/
|
||||
|
||||
export const BREAK_GAP_TOLERANCE_SECONDS = BREAK_GAP_TOLERANCE_MINUTES * 60;
|
||||
|
||||
export interface Interval {
|
||||
start: string;
|
||||
end: string;
|
||||
}
|
||||
|
||||
export interface MovableInterval extends Interval {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface MovePlan {
|
||||
breakSlot: Interval;
|
||||
// Entries whose start/end changed to make room for the break
|
||||
shifted: MovableInterval[];
|
||||
}
|
||||
|
||||
export interface SplitPlan {
|
||||
firstHalf: Interval;
|
||||
breakSlot: Interval;
|
||||
secondHalf: Interval;
|
||||
}
|
||||
|
||||
/**
|
||||
* A break that could not be auto-placed within tolerance. The timesheet raises
|
||||
* one of these so the page can open the placement modal, where the user either
|
||||
* splits the single work entry or shifts entries to open a slot.
|
||||
*/
|
||||
export interface BreakPlacementRequest {
|
||||
date: string;
|
||||
durationSeconds: number;
|
||||
dayStart: string;
|
||||
dayEnd: string;
|
||||
// Work entries on the day (finished, movable), used to split or shift
|
||||
workEntries: MovableInterval[];
|
||||
// Existing breaks on the day (minus the one being re-placed). They shift
|
||||
// along with the surrounding work in move mode so a plan can never land
|
||||
// on top of them.
|
||||
otherEntries: MovableInterval[];
|
||||
defaultBreakStart: string;
|
||||
// When re-placing an existing break (an edit), the id to update in place
|
||||
replaceBreakId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* How a request will be resolved: a single work entry is split around the
|
||||
* break; with several, the surrounding entries move to open a slot.
|
||||
*/
|
||||
export function placementMode(request: BreakPlacementRequest): 'split' | 'move' {
|
||||
return request.workEntries.length === 1 ? 'split' : 'move';
|
||||
}
|
||||
|
||||
/** The minimal shape of a time entry the day-context builder needs. */
|
||||
export interface DayEntryLike {
|
||||
id: string;
|
||||
start: string;
|
||||
end: string | null;
|
||||
type: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the placement flow needs to know about one local day:
|
||||
* finished work and break entries fully inside the day (both movable), and the
|
||||
* usable day window. Entries that reach across a day boundary belong partly to
|
||||
* another day and must not be moved — they shrink `dayStart`/`dayEnd` instead,
|
||||
* so no plan can shift anything into them.
|
||||
*/
|
||||
export interface DayPlacementContext {
|
||||
work: MovableInterval[];
|
||||
breaks: MovableInterval[];
|
||||
// Immovable blockers: a running entry keeps growing from its start, so it
|
||||
// blocks placement from there through the end of the day.
|
||||
blocked: Interval[];
|
||||
dayStart: string;
|
||||
dayEnd: string;
|
||||
}
|
||||
|
||||
export function buildDayPlacementContext(
|
||||
entries: DayEntryLike[],
|
||||
dayStart: string,
|
||||
dayEnd: string,
|
||||
excludeBreakId: string | null = null
|
||||
): DayPlacementContext {
|
||||
const dayjs = getDayJsInstance();
|
||||
const dayStartMs = dayjs.utc(dayStart).valueOf();
|
||||
const dayEndMs = dayjs.utc(dayEnd).valueOf();
|
||||
let effStartMs = dayStartMs;
|
||||
let effEndMs = dayEndMs;
|
||||
const work: MovableInterval[] = [];
|
||||
const breaks: MovableInterval[] = [];
|
||||
const blocked: Interval[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.id === excludeBreakId) continue;
|
||||
const startMs = dayjs.utc(entry.start).valueOf();
|
||||
// A running entry keeps growing from its start: nothing can be placed
|
||||
// at or after it, so it caps the usable window and blocks the rest of
|
||||
// the day instead of being movable.
|
||||
if (entry.end === null) {
|
||||
if (startMs < dayEndMs) {
|
||||
if (startMs < effEndMs) effEndMs = startMs;
|
||||
blocked.push({ start: entry.start, end: dayEnd });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const endMs = dayjs.utc(entry.end).valueOf();
|
||||
if (startMs >= dayEndMs || endMs <= dayStartMs) continue;
|
||||
|
||||
const crossesStart = startMs < dayStartMs;
|
||||
const crossesEnd = endMs > dayEndMs;
|
||||
if (crossesStart || crossesEnd) {
|
||||
if (crossesStart && endMs > effStartMs) effStartMs = endMs;
|
||||
if (crossesEnd && startMs < effEndMs) effEndMs = startMs;
|
||||
continue;
|
||||
}
|
||||
|
||||
const interval = { id: entry.id, start: entry.start, end: entry.end };
|
||||
if (entry.type === 'break') {
|
||||
breaks.push(interval);
|
||||
} else {
|
||||
work.push(interval);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
work: sortByStart(work),
|
||||
breaks: sortByStart(breaks),
|
||||
blocked: sortByStart(blocked),
|
||||
dayStart: dayjs.utc(effStartMs).format(),
|
||||
dayEnd: dayjs.utc(effEndMs).format(),
|
||||
};
|
||||
}
|
||||
|
||||
function sortByStart<T extends Interval>(intervals: T[]): T[] {
|
||||
return [...intervals].sort((a, b) => a.start.localeCompare(b.start));
|
||||
}
|
||||
|
||||
interface IntervalMs {
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
}
|
||||
|
||||
function toIntervalMs(interval: Interval): IntervalMs {
|
||||
const dayjs = getDayJsInstance();
|
||||
return {
|
||||
startMs: dayjs.utc(interval.start).valueOf(),
|
||||
endMs: dayjs.utc(interval.end).valueOf(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge overlapping/touching work intervals so the space between two
|
||||
* consecutive merged intervals is genuinely work-free. Without this, an entry
|
||||
* contained in a longer one would fabricate a "gap" that overlaps work.
|
||||
*/
|
||||
function mergedWorkMs(work: Interval[]): IntervalMs[] {
|
||||
const sorted = work.map(toIntervalMs).sort((a, b) => a.startMs - b.startMs);
|
||||
const merged: IntervalMs[] = [];
|
||||
for (const current of sorted) {
|
||||
const last = merged[merged.length - 1];
|
||||
if (last && current.startMs <= last.endMs) {
|
||||
last.endMs = Math.max(last.endMs, current.endMs);
|
||||
} else {
|
||||
merged.push({ ...current });
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** Work-free gaps between consecutive merged work intervals, in day order. */
|
||||
function workFreeGapsMs(work: Interval[]): IntervalMs[] {
|
||||
const merged = mergedWorkMs(work);
|
||||
const gaps: IntervalMs[] = [];
|
||||
for (let i = 0; i < merged.length - 1; i++) {
|
||||
gaps.push({ startMs: merged[i]!.endMs, endMs: merged[i + 1]!.startMs });
|
||||
}
|
||||
return gaps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a gap between work entries that can hold a break of `durationSeconds`,
|
||||
* without touching any other entry.
|
||||
*
|
||||
* Preference order: first a gap where the centered break stays within
|
||||
* `toleranceSeconds` of work on both sides. When no such gap exists, any gap
|
||||
* big enough to physically hold the break is accepted — the break is placed
|
||||
* flush after the preceding work (sliding past obstacles such as existing
|
||||
* breaks) and the rest of the gap is left untouched. Such a break may end up
|
||||
* further from work than the tolerance; that is surfaced as a read-time hint
|
||||
* (getBreakPlacementHint), not treated as infeasible. Returns null only when
|
||||
* no work-free gap can hold the break at all.
|
||||
*/
|
||||
export function findValidBreakGap(
|
||||
work: Interval[],
|
||||
durationSeconds: number,
|
||||
obstacles: Interval[] = [],
|
||||
toleranceSeconds: number = BREAK_GAP_TOLERANCE_SECONDS
|
||||
): Interval | null {
|
||||
if (durationSeconds <= 0) return null;
|
||||
const dayjs = getDayJsInstance();
|
||||
const durationMs = durationSeconds * 1000;
|
||||
const gaps = workFreeGapsMs(work);
|
||||
const obstaclesMs = obstacles.map(toIntervalMs);
|
||||
|
||||
const blockers = (startMs: number): IntervalMs[] =>
|
||||
obstaclesMs.filter((o) => startMs < o.endMs && o.startMs < startMs + durationMs);
|
||||
const slot = (startMs: number): Interval => ({
|
||||
start: dayjs.utc(startMs).format(),
|
||||
end: dayjs.utc(startMs + durationMs).format(),
|
||||
});
|
||||
|
||||
// Pass 1: a gap where the centered break keeps both sides within tolerance.
|
||||
for (const gap of gaps) {
|
||||
const gapMs = gap.endMs - gap.startMs;
|
||||
if (gapMs < durationMs || gapMs > durationMs + 2 * toleranceSeconds * 1000) continue;
|
||||
const startMs = gap.startMs + Math.floor((gapMs - durationMs) / 2000) * 1000;
|
||||
if (blockers(startMs).length > 0) continue;
|
||||
return slot(startMs);
|
||||
}
|
||||
|
||||
// Pass 2: any gap that can physically hold the break. Start flush after the
|
||||
// preceding work and slide right past obstacles until the slot is free.
|
||||
for (const gap of gaps) {
|
||||
let startMs = gap.startMs;
|
||||
while (startMs + durationMs <= gap.endMs) {
|
||||
const blocking = blockers(startMs);
|
||||
if (blocking.length === 0) return slot(startMs);
|
||||
startMs = Math.max(...blocking.map((o) => o.endMs));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-place an existing break as close to `anchorStart` as possible, instead of
|
||||
* jumping to the first gap (which findValidBreakGap does). Only the gap the
|
||||
* anchor currently sits in is considered — the break keeps its position when
|
||||
* that gap can still physically hold the new duration, clamped only to stay
|
||||
* inside the gap (how far it then sits from work is a soft read-time hint, not
|
||||
* a constraint). Obstacles (other breaks) don't evict the break from its gap:
|
||||
* it settles into the free window of the gap closest to the anchor, sliding
|
||||
* just past whatever is in the way. Returns null only when the anchor sits in
|
||||
* no work-free gap or that gap has no free window big enough; the caller then
|
||||
* falls back to findValidBreakGap.
|
||||
*/
|
||||
export function findValidBreakGapNear(
|
||||
work: Interval[],
|
||||
durationSeconds: number,
|
||||
anchorStart: string,
|
||||
obstacles: Interval[] = []
|
||||
): Interval | null {
|
||||
if (durationSeconds <= 0) return null;
|
||||
const dayjs = getDayJsInstance();
|
||||
const durationMs = durationSeconds * 1000;
|
||||
const anchorMs = dayjs.utc(anchorStart).valueOf();
|
||||
|
||||
for (const gap of workFreeGapsMs(work)) {
|
||||
// The anchor must fall inside this gap for it to be "where the break is".
|
||||
if (anchorMs < gap.startMs || anchorMs >= gap.endMs) continue;
|
||||
if (gap.endMs - gap.startMs < durationMs) return null;
|
||||
|
||||
// Walk the gap's free windows around obstacles and pick the start
|
||||
// closest to the anchor, so the break moves as little as possible
|
||||
// from where the user left it.
|
||||
const blockers = obstacles
|
||||
.map(toIntervalMs)
|
||||
.filter((o) => o.startMs < gap.endMs && o.endMs > gap.startMs)
|
||||
.sort((a, b) => a.startMs - b.startMs);
|
||||
let best: number | null = null;
|
||||
const consider = (winStartMs: number, winEndMs: number) => {
|
||||
if (winEndMs - winStartMs < durationMs) return;
|
||||
const candidate = Math.min(Math.max(anchorMs, winStartMs), winEndMs - durationMs);
|
||||
if (best === null || Math.abs(candidate - anchorMs) < Math.abs(best - anchorMs)) {
|
||||
best = candidate;
|
||||
}
|
||||
};
|
||||
let cursor = gap.startMs;
|
||||
for (const blocker of blockers) {
|
||||
consider(cursor, blocker.startMs);
|
||||
cursor = Math.max(cursor, blocker.endMs);
|
||||
}
|
||||
consider(cursor, gap.endMs);
|
||||
|
||||
if (best === null) return null;
|
||||
return { start: dayjs.utc(best).format(), end: dayjs.utc(best + durationMs).format() };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// A split must leave a meaningful chunk of work on each side of the break;
|
||||
// hair-thin fragments would only exist to make a bad placement "fit".
|
||||
export const MIN_SPLIT_FRAGMENT_SECONDS = 60;
|
||||
|
||||
/**
|
||||
* Split a single work entry to insert a break. `breakStart` (UTC ISO) lets the
|
||||
* caller position it; without one the break is centered. Returns null when the
|
||||
* entry is too short to leave at least MIN_SPLIT_FRAGMENT_SECONDS of work on
|
||||
* both sides of the break, or when an explicit `breakStart` would not — an
|
||||
* out-of-range request is rejected rather than clamped, because silently
|
||||
* relocating the break would contradict the time the user picked.
|
||||
*/
|
||||
export function planSplitEntry(
|
||||
entry: Interval,
|
||||
durationSeconds: number,
|
||||
breakStart?: string
|
||||
): SplitPlan | null {
|
||||
if (durationSeconds <= 0) return null;
|
||||
const dayjs = getDayJsInstance();
|
||||
const entryStart = dayjs.utc(entry.start);
|
||||
const entryEnd = dayjs.utc(entry.end);
|
||||
const total = entryEnd.diff(entryStart, 'second');
|
||||
if (total < durationSeconds + 2 * MIN_SPLIT_FRAGMENT_SECONDS) return null;
|
||||
|
||||
const earliest = entryStart.add(MIN_SPLIT_FRAGMENT_SECONDS, 'second');
|
||||
const latest = entryEnd.subtract(durationSeconds + MIN_SPLIT_FRAGMENT_SECONDS, 'second');
|
||||
|
||||
let bStart = breakStart
|
||||
? dayjs.utc(breakStart)
|
||||
: entryStart.add(Math.floor((total - durationSeconds) / 2), 'second');
|
||||
|
||||
if (breakStart) {
|
||||
if (bStart.isBefore(earliest) || bStart.isAfter(latest)) return null;
|
||||
} else {
|
||||
// Safety net for rounding of the centered position only.
|
||||
if (bStart.isBefore(earliest)) bStart = earliest;
|
||||
if (bStart.isAfter(latest)) bStart = latest;
|
||||
}
|
||||
|
||||
const bEnd = bStart.add(durationSeconds, 'second');
|
||||
if (!bStart.isAfter(entryStart) || !bEnd.isBefore(entryEnd)) return null;
|
||||
|
||||
return {
|
||||
firstHalf: { start: entryStart.format(), end: bStart.format() },
|
||||
breakSlot: { start: bStart.format(), end: bEnd.format() },
|
||||
secondHalf: { start: bEnd.format(), end: entryEnd.format() },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a break at `breakStart`, shifting the surrounding entries only as much
|
||||
* as needed to clear the slot. Entries starting before the break form the left
|
||||
* block: when it reaches into the slot it is translated earlier so its latest
|
||||
* end meets the break start. The rest form the right block: when the slot
|
||||
* reaches into it, it is translated later so its earliest start meets the break
|
||||
* end. Blocks that already clear the slot are left untouched — existing gaps
|
||||
* are preserved, never tightened. Returns null if a required shift would push
|
||||
* an entry outside `[dayStart, dayEnd]`.
|
||||
*/
|
||||
export function planMoveInsert(
|
||||
entries: MovableInterval[],
|
||||
dayStart: string,
|
||||
dayEnd: string,
|
||||
breakStart: string,
|
||||
durationSeconds: number
|
||||
): MovePlan | null {
|
||||
if (durationSeconds <= 0) return null;
|
||||
const dayjs = getDayJsInstance();
|
||||
const bStartMs = dayjs.utc(breakStart).valueOf();
|
||||
const bEndMs = bStartMs + durationSeconds * 1000;
|
||||
const dayStartMs = dayjs.utc(dayStart).valueOf();
|
||||
const dayEndMs = dayjs.utc(dayEnd).valueOf();
|
||||
if (bStartMs < dayStartMs || bEndMs > dayEndMs) return null;
|
||||
|
||||
const toMs = (iso: string) => dayjs.utc(iso).valueOf();
|
||||
const left = entries.filter((e) => toMs(e.start) < bStartMs);
|
||||
const right = entries.filter((e) => toMs(e.start) >= bStartMs);
|
||||
|
||||
const shifted: MovableInterval[] = [];
|
||||
const translate = (block: MovableInterval[], shiftMs: number) => {
|
||||
for (const e of block) {
|
||||
shifted.push({
|
||||
id: e.id,
|
||||
start: dayjs.utc(toMs(e.start) + shiftMs).format(),
|
||||
end: dayjs.utc(toMs(e.end) + shiftMs).format(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (left.length > 0) {
|
||||
const maxLeftEnd = Math.max(...left.map((e) => toMs(e.end)));
|
||||
const minLeftStart = Math.min(...left.map((e) => toMs(e.start)));
|
||||
// Only pull earlier when the block overlaps the slot, never later.
|
||||
const shift = Math.min(0, bStartMs - maxLeftEnd);
|
||||
if (shift !== 0) {
|
||||
if (minLeftStart + shift < dayStartMs) return null;
|
||||
translate(left, shift);
|
||||
}
|
||||
}
|
||||
if (right.length > 0) {
|
||||
const minRightStart = Math.min(...right.map((e) => toMs(e.start)));
|
||||
const maxRightEnd = Math.max(...right.map((e) => toMs(e.end)));
|
||||
// Only push later when the slot overlaps the block, never earlier.
|
||||
const shift = Math.max(0, bEndMs - minRightStart);
|
||||
if (shift !== 0) {
|
||||
if (maxRightEnd + shift > dayEndMs) return null;
|
||||
translate(right, shift);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
breakSlot: {
|
||||
start: dayjs.utc(bStartMs).format(),
|
||||
end: dayjs.utc(bEndMs).format(),
|
||||
},
|
||||
shifted,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a feasible default break position for the move case — only reached when
|
||||
* no work-free gap can hold the break, so opening a slot requires shifting.
|
||||
* Only boundaries *between* two consecutive work entries are considered, so the
|
||||
* break always ends up flanked by work (a break before the first entry or after
|
||||
* the last one would be misplaced). For each boundary it tries pushing the
|
||||
* right block later first, then pulling the left block earlier, and returns the
|
||||
* first placement whose shifts stay inside the day. `otherEntries` (existing
|
||||
* breaks) shift along with the work around them. Null when nothing fits.
|
||||
*/
|
||||
export function suggestMovePlan(
|
||||
work: MovableInterval[],
|
||||
dayStart: string,
|
||||
dayEnd: string,
|
||||
durationSeconds: number,
|
||||
otherEntries: MovableInterval[] = []
|
||||
): MovePlan | null {
|
||||
const dayjs = getDayJsInstance();
|
||||
const sorted = sortByStart(work);
|
||||
const movable = [...work, ...otherEntries];
|
||||
|
||||
for (let i = 0; i < sorted.length - 1; i++) {
|
||||
// Push the right block later: break starts where the earlier entry ends.
|
||||
const pushRight = planMoveInsert(
|
||||
movable,
|
||||
dayStart,
|
||||
dayEnd,
|
||||
sorted[i]!.end,
|
||||
durationSeconds
|
||||
);
|
||||
if (pushRight) return pushRight;
|
||||
|
||||
// Pull the left block earlier: break ends where the later entry starts.
|
||||
const before = dayjs
|
||||
.utc(sorted[i + 1]!.start)
|
||||
.subtract(durationSeconds, 'second')
|
||||
.format();
|
||||
const pullLeft = planMoveInsert(movable, dayStart, dayEnd, before, durationSeconds);
|
||||
if (pullLeft) return pullLeft;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -26,7 +26,7 @@ interface Interval {
|
||||
end: Dayjs;
|
||||
}
|
||||
|
||||
function localDayBounds(date: string, tz: string): { dayStart: Dayjs; dayEnd: Dayjs } {
|
||||
export function localDayBounds(date: string, tz: string): { dayStart: Dayjs; dayEnd: Dayjs } {
|
||||
const dayjs = getDayJsInstance();
|
||||
// `.add(1, 'day')` on a Dayjs instance advances by a fixed 24h, which is
|
||||
// wrong on DST-transition days (the local day is 23h or 25h long). Derive
|
||||
|
||||
191
resources/js/utils/timesheet/useBreakPlacement.test.ts
Normal file
191
resources/js/utils/timesheet/useBreakPlacement.test.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ref } from 'vue';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { useBreakPlacement, BreakPlacementDeferred } from './useBreakPlacement';
|
||||
import { api } from '@/packages/api/src';
|
||||
import type { TimeEntry } from '@/packages/api/src';
|
||||
import type { TimesheetRow } from '@/utils/useTimesheetGrid';
|
||||
|
||||
const addNotification = vi.fn();
|
||||
|
||||
vi.mock('@/utils/useUser', () => ({
|
||||
getCurrentOrganizationId: vi.fn(() => 'org-1'),
|
||||
getCurrentMembershipId: vi.fn(() => 'mem-1'),
|
||||
}));
|
||||
|
||||
vi.mock('@tanstack/vue-query', () => ({
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/notification', () => ({
|
||||
useNotificationsStore: () => ({ addNotification }),
|
||||
}));
|
||||
|
||||
vi.mock('@/packages/api/src', () => ({
|
||||
api: {
|
||||
createTimeEntry: vi.fn(async () => ({ data: { id: 'new-id' } })),
|
||||
updateTimeEntry: vi.fn(async () => undefined),
|
||||
deleteTimeEntry: vi.fn(async () => undefined),
|
||||
deleteTimeEntries: vi.fn(async () => undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
const apiMocks = vi.mocked(api);
|
||||
const DATE = '2026-04-10';
|
||||
const HOUR = 3600;
|
||||
|
||||
function entry(start: string, end: string | null, overrides: Partial<TimeEntry> = {}): TimeEntry {
|
||||
return {
|
||||
id: overrides.id ?? `e-${start}`,
|
||||
start,
|
||||
end,
|
||||
description: '',
|
||||
member_id: 'mem-1',
|
||||
project_id: 'p-1',
|
||||
task_id: null,
|
||||
billable: false,
|
||||
tags: [],
|
||||
type: 'work',
|
||||
...overrides,
|
||||
} as unknown as TimeEntry;
|
||||
}
|
||||
|
||||
const breakRow: TimesheetRow = {
|
||||
key: 'break-row',
|
||||
projectId: null,
|
||||
taskId: null,
|
||||
billable: false,
|
||||
tags: [],
|
||||
type: 'break',
|
||||
cells: new Map(),
|
||||
totalSeconds: 0,
|
||||
};
|
||||
|
||||
function setup(allEntries: TimeEntry[]) {
|
||||
const createCell = vi.fn(async () => undefined);
|
||||
const updateEntry = vi.fn(async () => undefined);
|
||||
const bp = useBreakPlacement({
|
||||
weekDays: ref([DATE, '2026-04-11', '2026-04-12']),
|
||||
timeEntries: ref(allEntries),
|
||||
requireOrgId: () => 'org-1',
|
||||
createCell,
|
||||
updateEntry,
|
||||
});
|
||||
return { bp, createCell, updateEntry };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
apiMocks.createTimeEntry.mockClear();
|
||||
apiMocks.updateTimeEntry.mockClear();
|
||||
addNotification.mockClear();
|
||||
});
|
||||
|
||||
describe('useBreakPlacement.placeBreak', () => {
|
||||
it('saves the break directly when it drops into a valid gap', async () => {
|
||||
const morning = entry('2026-04-10T09:00:00Z', '2026-04-10T12:00:00Z', {
|
||||
id: 'morning',
|
||||
});
|
||||
const afternoon = entry('2026-04-10T13:00:00Z', '2026-04-10T17:00:00Z', {
|
||||
id: 'afternoon',
|
||||
});
|
||||
const { bp } = setup([morning, afternoon]);
|
||||
|
||||
await bp.placeBreak(breakRow, 0, HOUR); // exactly fills the 12:00-13:00 gap
|
||||
|
||||
expect(apiMocks.createTimeEntry).toHaveBeenCalledTimes(1);
|
||||
expect(apiMocks.createTimeEntry.mock.calls[0]![0]).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'break',
|
||||
start: '2026-04-10T12:00:00Z',
|
||||
end: '2026-04-10T13:00:00Z',
|
||||
})
|
||||
);
|
||||
expect(bp.breakPlacementRequest.value).toBeNull();
|
||||
});
|
||||
|
||||
it('never places a break over a running entry', async () => {
|
||||
const morning = entry('2026-04-10T09:00:00Z', '2026-04-10T12:00:00Z', { id: 'morning' });
|
||||
const afternoon = entry('2026-04-10T13:00:00Z', '2026-04-10T17:00:00Z', {
|
||||
id: 'afternoon',
|
||||
});
|
||||
const running = entry('2026-04-10T12:30:00Z', null, { id: 'running' });
|
||||
const { bp } = setup([morning, afternoon, running]);
|
||||
|
||||
// Centered placement (12:15-12:45) would overlap the running entry, so
|
||||
// the break slides to the free part of the gap instead.
|
||||
await bp.placeBreak(breakRow, 0, HOUR / 2);
|
||||
|
||||
expect(apiMocks.createTimeEntry).toHaveBeenCalledTimes(1);
|
||||
expect(apiMocks.createTimeEntry.mock.calls[0]![0]).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'break',
|
||||
start: '2026-04-10T12:00:00Z',
|
||||
end: '2026-04-10T12:30:00Z',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('defers to the split modal when a single work entry blocks every gap', async () => {
|
||||
const work = entry('2026-04-10T09:00:00Z', '2026-04-10T17:00:00Z', { id: 'w1' });
|
||||
const { bp } = setup([work]);
|
||||
|
||||
await expect(bp.placeBreak(breakRow, 0, HOUR)).rejects.toBeInstanceOf(
|
||||
BreakPlacementDeferred
|
||||
);
|
||||
expect(bp.breakPlacementRequest.value).toEqual(
|
||||
expect.objectContaining({
|
||||
durationSeconds: HOUR,
|
||||
replaceBreakId: null,
|
||||
workEntries: [expect.objectContaining({ id: 'w1' })],
|
||||
})
|
||||
);
|
||||
expect(apiMocks.createTimeEntry).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useBreakPlacement.applyBreakPlacement (split)', () => {
|
||||
it('shrinks the original, creates the second half, and saves the break', async () => {
|
||||
const work = entry('2026-04-10T09:00:00Z', '2026-04-10T17:00:00Z', { id: 'w1' });
|
||||
const { bp, updateEntry } = setup([work]);
|
||||
|
||||
// Open the placement request, then commit the break at noon.
|
||||
await bp.placeBreak(breakRow, 0, HOUR).catch(() => undefined);
|
||||
await bp.applyBreakPlacement('2026-04-10T12:00:00Z', HOUR);
|
||||
|
||||
// Original work shrunk to its first half.
|
||||
expect(updateEntry).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'w1',
|
||||
start: '2026-04-10T09:00:00Z',
|
||||
end: '2026-04-10T12:00:00Z',
|
||||
})
|
||||
);
|
||||
// Second half of work + the break both created.
|
||||
const created = apiMocks.createTimeEntry.mock.calls.map((c) => c[0]);
|
||||
expect(created).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: 'work',
|
||||
start: '2026-04-10T13:00:00Z',
|
||||
end: '2026-04-10T17:00:00Z',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'break',
|
||||
start: '2026-04-10T12:00:00Z',
|
||||
end: '2026-04-10T13:00:00Z',
|
||||
}),
|
||||
])
|
||||
);
|
||||
// Request cleared and a success toast surfaced.
|
||||
expect(bp.breakPlacementRequest.value).toBeNull();
|
||||
expect(addNotification).toHaveBeenCalledWith('success', 'Break added', expect.any(String));
|
||||
});
|
||||
|
||||
it('does nothing when there is no pending placement request', async () => {
|
||||
const { bp, updateEntry } = setup([]);
|
||||
await bp.applyBreakPlacement('2026-04-10T12:00:00Z', HOUR);
|
||||
expect(updateEntry).not.toHaveBeenCalled();
|
||||
expect(apiMocks.createTimeEntry).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -41,8 +41,10 @@ export function useCopyLastWeek(
|
||||
projectId: string | null,
|
||||
taskId: string | null,
|
||||
billable: boolean,
|
||||
tags: string[]
|
||||
) => string
|
||||
tags: string[],
|
||||
type?: 'work' | 'break'
|
||||
) => string,
|
||||
breaksEnabled: Ref<boolean>
|
||||
) {
|
||||
const dayjs = getDayJsInstance();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -50,6 +52,14 @@ export function useCopyLastWeek(
|
||||
|
||||
const isCopyingLastWeek = ref(false);
|
||||
|
||||
// The server rejects creating break entries while breaks are disabled,
|
||||
// so leave last week's breaks out of the copy in that case
|
||||
function copyableEntries(response: TimeEntryResponse): TimeEntry[] {
|
||||
return breaksEnabled.value
|
||||
? response.data
|
||||
: response.data.filter((entry) => entry.type !== 'break');
|
||||
}
|
||||
|
||||
async function fetchLastWeekEntries(): Promise<TimeEntryResponse | null> {
|
||||
const prevStart = weekStart.value.subtract(7, 'day');
|
||||
const prevEnd = weekStart.value;
|
||||
@@ -73,16 +83,22 @@ export function useCopyLastWeek(
|
||||
*/
|
||||
function addMissingRowsFromPreviousWeek(prevEntries: TimeEntry[]): void {
|
||||
const existingIdentities = new Set(
|
||||
rows.value.map((r) => makeRowKey(r.projectId, r.taskId, r.billable, r.tags))
|
||||
rows.value.map((r) => makeRowKey(r.projectId, r.taskId, r.billable, r.tags, r.type))
|
||||
);
|
||||
const addedIdentities = new Set<string>();
|
||||
|
||||
for (const entry of prevEntries) {
|
||||
const tags = entry.tags ?? [];
|
||||
const identity = makeRowKey(entry.project_id, entry.task_id, entry.billable, tags);
|
||||
const identity = makeRowKey(
|
||||
entry.project_id,
|
||||
entry.task_id,
|
||||
entry.billable,
|
||||
tags,
|
||||
entry.type
|
||||
);
|
||||
if (!existingIdentities.has(identity) && !addedIdentities.has(identity)) {
|
||||
addedIdentities.add(identity);
|
||||
addSlot(entry.project_id, entry.task_id, entry.billable, tags);
|
||||
addSlot(entry.project_id, entry.task_id, entry.billable, tags, entry.type);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,7 +108,7 @@ export function useCopyLastWeek(
|
||||
try {
|
||||
const prev = await fetchLastWeekEntries();
|
||||
if (!prev) return;
|
||||
addMissingRowsFromPreviousWeek(prev.data);
|
||||
addMissingRowsFromPreviousWeek(copyableEntries(prev));
|
||||
} finally {
|
||||
isCopyingLastWeek.value = false;
|
||||
}
|
||||
@@ -110,7 +126,8 @@ export function useCopyLastWeek(
|
||||
|
||||
const tz = getUserTimezone();
|
||||
|
||||
addMissingRowsFromPreviousWeek(prev.data);
|
||||
const prevEntries = copyableEntries(prev);
|
||||
addMissingRowsFromPreviousWeek(prevEntries);
|
||||
|
||||
const prevWeekStart = weekStart.value.subtract(7, 'day');
|
||||
|
||||
@@ -125,7 +142,7 @@ export function useCopyLastWeek(
|
||||
let overlapFailures = 0;
|
||||
let otherFailures = 0;
|
||||
|
||||
for (const entry of prev.data) {
|
||||
for (const entry of prevEntries) {
|
||||
if (!entry.end || !entry.duration) continue;
|
||||
|
||||
// Map previous-week date → same day-of-week in current week.
|
||||
@@ -174,6 +191,7 @@ export function useCopyLastWeek(
|
||||
start: window.start,
|
||||
end: window.end,
|
||||
billable: entry.billable,
|
||||
type: entry.type,
|
||||
description: entry.description ?? null,
|
||||
tags: entry.tags ?? [],
|
||||
};
|
||||
|
||||
@@ -79,6 +79,7 @@ function buildRow(
|
||||
taskId: null,
|
||||
billable: false,
|
||||
tags: [],
|
||||
type: 'work',
|
||||
cells: new Map([[0, cell]]),
|
||||
totalSeconds: cell.totalSeconds,
|
||||
};
|
||||
@@ -91,6 +92,7 @@ function buildEmptyRow(projectId: string | null, key = `${projectId}:null`): Tim
|
||||
taskId: null,
|
||||
billable: false,
|
||||
tags: [],
|
||||
type: 'work',
|
||||
cells: new Map(),
|
||||
totalSeconds: 0,
|
||||
};
|
||||
@@ -308,6 +310,144 @@ describe('useTimesheetCellMutations.handleCellUpdate', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('break placement', () => {
|
||||
it('updates an existing break in place when moving it into a valid gap', async () => {
|
||||
const morning = entry('2026-04-10T09:00:00Z', '2026-04-10T12:00:00Z', {
|
||||
id: 'morning',
|
||||
type: 'work',
|
||||
});
|
||||
const afternoon = entry('2026-04-10T13:00:00Z', '2026-04-10T17:00:00Z', {
|
||||
id: 'afternoon',
|
||||
type: 'work',
|
||||
});
|
||||
const existingBreak = entry('2026-04-10T08:00:00Z', '2026-04-10T08:30:00Z', {
|
||||
id: 'break-1',
|
||||
project_id: null,
|
||||
type: 'break',
|
||||
});
|
||||
const row = buildRow(null, [existingBreak], 'break-row');
|
||||
row.type = 'break';
|
||||
const { cellMutations } = setup([morning, afternoon, existingBreak]);
|
||||
|
||||
await cellMutations.handleCellUpdate(row, 0, HOUR);
|
||||
|
||||
expect(apiMocks.updateTimeEntry).toHaveBeenCalledTimes(1);
|
||||
expect(firstArg(apiMocks.updateTimeEntry)).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'break-1',
|
||||
start: '2026-04-10T12:00:00Z',
|
||||
end: '2026-04-10T13:00:00Z',
|
||||
})
|
||||
);
|
||||
expect(apiMocks.deleteTimeEntry).not.toHaveBeenCalled();
|
||||
expect(apiMocks.createTimeEntry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps an edited break anchored to its position instead of recentering', async () => {
|
||||
// 09-10 and 11:30-12:30 leave a 90-min gap; a resized 1h break has a valid
|
||||
// window of 10:00-10:30. The break already starts at 10:00, so it must stay
|
||||
// there (10:00-11:00) rather than jump to the centered 10:15-11:15.
|
||||
const morning = entry('2026-04-10T09:00:00Z', '2026-04-10T10:00:00Z', {
|
||||
id: 'morning',
|
||||
type: 'work',
|
||||
});
|
||||
const afternoon = entry('2026-04-10T11:30:00Z', '2026-04-10T12:30:00Z', {
|
||||
id: 'afternoon',
|
||||
type: 'work',
|
||||
});
|
||||
const existingBreak = entry('2026-04-10T10:00:00Z', '2026-04-10T10:30:00Z', {
|
||||
id: 'break-1',
|
||||
project_id: null,
|
||||
type: 'break',
|
||||
});
|
||||
const row = buildRow(null, [existingBreak], 'break-row');
|
||||
row.type = 'break';
|
||||
const { cellMutations } = setup([morning, afternoon, existingBreak]);
|
||||
|
||||
await cellMutations.handleCellUpdate(row, 0, HOUR);
|
||||
|
||||
expect(apiMocks.updateTimeEntry).toHaveBeenCalledTimes(1);
|
||||
expect(firstArg(apiMocks.updateTimeEntry)).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'break-1',
|
||||
start: '2026-04-10T10:00:00Z',
|
||||
end: '2026-04-10T11:00:00Z',
|
||||
})
|
||||
);
|
||||
expect(apiMocks.createTimeEntry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('grows a multi-break cell by re-placing the latest break, not fragmenting', async () => {
|
||||
// Two breaks share the break cell. Growing the cell total must extend the
|
||||
// latest-ending break (break-b) in place — never create a third break entry.
|
||||
const w1 = entry('2026-04-10T12:00:00Z', '2026-04-10T14:00:00Z', {
|
||||
id: 'w1',
|
||||
type: 'work',
|
||||
});
|
||||
const w2 = entry('2026-04-10T15:00:00Z', '2026-04-10T17:00:00Z', {
|
||||
id: 'w2',
|
||||
type: 'work',
|
||||
});
|
||||
const breakA = entry('2026-04-10T10:00:00Z', '2026-04-10T10:30:00Z', {
|
||||
id: 'break-a',
|
||||
project_id: null,
|
||||
type: 'break',
|
||||
});
|
||||
const breakB = entry('2026-04-10T14:00:00Z', '2026-04-10T14:30:00Z', {
|
||||
id: 'break-b',
|
||||
project_id: null,
|
||||
type: 'break',
|
||||
});
|
||||
const row = buildRow(null, [breakA, breakB], 'break-row');
|
||||
row.type = 'break';
|
||||
const { cellMutations } = setup([w1, w2, breakA, breakB]);
|
||||
|
||||
// Cell total 60m → 90m (the extra 30m lands on break-b, taking it to 60m,
|
||||
// which fills the 14:00-15:00 gap).
|
||||
await cellMutations.handleCellUpdate(row, 0, 90 * 60);
|
||||
|
||||
expect(apiMocks.updateTimeEntry).toHaveBeenCalledTimes(1);
|
||||
expect(firstArg(apiMocks.updateTimeEntry)).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'break-b',
|
||||
start: '2026-04-10T14:00:00Z',
|
||||
end: '2026-04-10T15:00:00Z',
|
||||
})
|
||||
);
|
||||
expect(apiMocks.createTimeEntry).not.toHaveBeenCalled();
|
||||
expect(apiMocks.deleteTimeEntry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shrinks a multi-break cell by trimming the tail break, not fragmenting', async () => {
|
||||
const breakA = entry('2026-04-10T10:00:00Z', '2026-04-10T10:30:00Z', {
|
||||
id: 'break-a',
|
||||
project_id: null,
|
||||
type: 'break',
|
||||
});
|
||||
const breakB = entry('2026-04-10T14:00:00Z', '2026-04-10T14:30:00Z', {
|
||||
id: 'break-b',
|
||||
project_id: null,
|
||||
type: 'break',
|
||||
});
|
||||
const row = buildRow(null, [breakA, breakB], 'break-row');
|
||||
row.type = 'break';
|
||||
const { cellMutations } = setup([breakA, breakB]);
|
||||
|
||||
// Cell total 60m → 40m: trim 20m off the latest break (break-b → 14:00-14:10).
|
||||
await cellMutations.handleCellUpdate(row, 0, 40 * 60);
|
||||
|
||||
expect(apiMocks.updateTimeEntry).toHaveBeenCalledTimes(1);
|
||||
expect(firstArg(apiMocks.updateTimeEntry)).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'break-b',
|
||||
end: '2026-04-10T14:10:00Z',
|
||||
})
|
||||
);
|
||||
expect(apiMocks.createTimeEntry).not.toHaveBeenCalled();
|
||||
expect(apiMocks.deleteTimeEntry).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Extend cell (Phase 2) ──────────────────────────────────────
|
||||
|
||||
describe('extendCell', () => {
|
||||
@@ -426,6 +566,7 @@ describe('useTimesheetCellMutations.handleCellUpdate', () => {
|
||||
taskId: null,
|
||||
billable: false,
|
||||
tags: [],
|
||||
type: 'work',
|
||||
cells: new Map([[0, cell]]),
|
||||
totalSeconds: HOUR,
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
workDayStartOn,
|
||||
type FreeWindow,
|
||||
} from './cellMath';
|
||||
import { useBreakPlacement, BreakPlacementDeferred } from './useBreakPlacement';
|
||||
|
||||
export type CellSaveStatus = 'saving' | 'saved' | 'error';
|
||||
|
||||
@@ -65,6 +66,12 @@ export function useTimesheetCellMutations(
|
||||
const cellPendingSeconds = ref<Record<string, number>>({});
|
||||
const statusClearTimers: Record<string, ReturnType<typeof setTimeout>> = {};
|
||||
|
||||
// Break placement (positioning a break relative to work, plus the split/move
|
||||
// modal flow) is its own subsystem — it borrows the generic entry primitives
|
||||
// below (hoisted function declarations, so referenceable here).
|
||||
const { breakPlacementRequest, placeBreak, dismissBreakPlacement, applyBreakPlacement } =
|
||||
useBreakPlacement({ weekDays, timeEntries, requireOrgId, createCell, updateEntry });
|
||||
|
||||
function clearStatusTimer(key: string): void {
|
||||
clearTimeout(statusClearTimers[key]);
|
||||
delete statusClearTimers[key];
|
||||
@@ -130,6 +137,14 @@ export function useTimesheetCellMutations(
|
||||
}
|
||||
markSaved(statusKey);
|
||||
} catch (err) {
|
||||
if (err instanceof BreakPlacementDeferred) {
|
||||
// The break needs manual placement — revert the cell to idle (the
|
||||
// modal drives the actual save) instead of showing an error.
|
||||
clearStatusTimer(statusKey);
|
||||
delete cellStatus.value[statusKey];
|
||||
delete cellPendingSeconds.value[statusKey];
|
||||
return;
|
||||
}
|
||||
markError(statusKey);
|
||||
if (err instanceof NoFreeWindowError) {
|
||||
const friendlyDuration = formatHumanReadableDuration(
|
||||
@@ -155,11 +170,11 @@ export function useTimesheetCellMutations(
|
||||
}
|
||||
|
||||
function hasDuplicateIdentitySlot(row: TimesheetRow): boolean {
|
||||
const target = makeRowKey(row.projectId, row.taskId, row.billable, row.tags);
|
||||
const target = makeRowKey(row.projectId, row.taskId, row.billable, row.tags, row.type);
|
||||
return rows.value.some(
|
||||
(r) =>
|
||||
r.key !== row.key &&
|
||||
makeRowKey(r.projectId, r.taskId, r.billable, r.tags) === target
|
||||
makeRowKey(r.projectId, r.taskId, r.billable, r.tags, r.type) === target
|
||||
);
|
||||
}
|
||||
|
||||
@@ -178,10 +193,34 @@ export function useTimesheetCellMutations(
|
||||
}
|
||||
|
||||
if (!cell || existingSeconds === 0) {
|
||||
// Breaks are placed relative to work (within tolerance), not just in the
|
||||
// first free slot, and may need the placement modal to resolve.
|
||||
if (row.type === 'break' && newTotalSeconds > 0) {
|
||||
await placeBreak(row, dayIndex, newTotalSeconds);
|
||||
return;
|
||||
}
|
||||
await createCell(row, dayIndex, newTotalSeconds);
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-place breaks rather than extend/shrink them, which would fragment a break into
|
||||
// a second entry. A day's breaks share one cell: a single break re-places at the new
|
||||
// total; growing a multi-break cell grows the latest-ending break in place (others
|
||||
// become obstacles); shrinking just trims the tail, which can't fragment.
|
||||
if (row.type === 'break') {
|
||||
if (cell.entries.length === 1) {
|
||||
await placeBreak(row, dayIndex, newTotalSeconds, cell.entries[0]!.id);
|
||||
return;
|
||||
}
|
||||
const tail = pickLatestEndedEntry(cell);
|
||||
if (diff > 0 && tail?.end) {
|
||||
await placeBreak(row, dayIndex, (tail.duration ?? 0) + diff, tail.id);
|
||||
return;
|
||||
}
|
||||
await shrinkFromEnd(cell, -diff);
|
||||
return;
|
||||
}
|
||||
|
||||
if (diff > 0) {
|
||||
await extendCell(row, dayIndex, cell, diff);
|
||||
return;
|
||||
@@ -241,6 +280,7 @@ export function useTimesheetCellMutations(
|
||||
start: window.start,
|
||||
end: window.end,
|
||||
billable: row.billable,
|
||||
type: row.type,
|
||||
description: null,
|
||||
tags: row.tags,
|
||||
};
|
||||
@@ -371,5 +411,12 @@ export function useTimesheetCellMutations(
|
||||
return best;
|
||||
}
|
||||
|
||||
return { handleCellUpdate, cellStatus, cellPendingSeconds };
|
||||
return {
|
||||
handleCellUpdate,
|
||||
cellStatus,
|
||||
cellPendingSeconds,
|
||||
breakPlacementRequest,
|
||||
applyBreakPlacement,
|
||||
dismissBreakPlacement,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ function buildRow(key: string, projectId: string | null, entries: TimeEntry[]):
|
||||
taskId: null,
|
||||
billable: false,
|
||||
tags: [],
|
||||
type: 'work',
|
||||
cells,
|
||||
totalSeconds,
|
||||
};
|
||||
@@ -177,6 +178,7 @@ describe('useTimesheetRowMutations', () => {
|
||||
taskId: null,
|
||||
billable: false,
|
||||
tags: [],
|
||||
type: 'work',
|
||||
});
|
||||
expect(removeSlot).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -207,6 +209,7 @@ describe('useTimesheetRowMutations', () => {
|
||||
taskId: null,
|
||||
billable: false,
|
||||
tags: [],
|
||||
type: 'work',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -233,6 +236,7 @@ describe('useTimesheetRowMutations', () => {
|
||||
taskId: null,
|
||||
billable: true,
|
||||
tags: [],
|
||||
type: 'work',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -38,7 +38,8 @@ export function useTimesheetRowMutations(
|
||||
projectId: string | null,
|
||||
taskId: string | null,
|
||||
billable: boolean,
|
||||
tags: string[]
|
||||
tags: string[],
|
||||
type?: 'work' | 'break'
|
||||
) => TimesheetRowKey,
|
||||
updateSlot: (key: TimesheetRowKey, identity: TimesheetRowIdentity) => void,
|
||||
removeSlot: (key: TimesheetRowKey) => void
|
||||
@@ -61,7 +62,8 @@ export function useTimesheetRowMutations(
|
||||
identity.projectId,
|
||||
identity.taskId,
|
||||
identity.billable,
|
||||
identity.tags
|
||||
identity.tags,
|
||||
identity.type
|
||||
);
|
||||
|
||||
return rows.value.some(
|
||||
@@ -71,7 +73,8 @@ export function useTimesheetRowMutations(
|
||||
candidate.projectId,
|
||||
candidate.taskId,
|
||||
candidate.billable,
|
||||
candidate.tags
|
||||
candidate.tags,
|
||||
candidate.type
|
||||
) === target
|
||||
);
|
||||
}
|
||||
@@ -80,13 +83,24 @@ export function useTimesheetRowMutations(
|
||||
row: TimesheetRow,
|
||||
partial: Partial<TimesheetRowIdentity>
|
||||
): Promise<void> {
|
||||
// Break rows have a fixed identity (no project/task/billable)
|
||||
if (row.type === 'break' && ('projectId' in partial || 'billable' in partial)) {
|
||||
return;
|
||||
}
|
||||
const entryIds = collectEntryIds(row);
|
||||
const currentIdentity = makeRowKey(row.projectId, row.taskId, row.billable, row.tags);
|
||||
const currentIdentity = makeRowKey(
|
||||
row.projectId,
|
||||
row.taskId,
|
||||
row.billable,
|
||||
row.tags,
|
||||
row.type
|
||||
);
|
||||
let merged: TimesheetRowIdentity = {
|
||||
projectId: row.projectId,
|
||||
taskId: row.taskId,
|
||||
billable: row.billable,
|
||||
tags: row.tags,
|
||||
type: row.type,
|
||||
...partial,
|
||||
};
|
||||
|
||||
@@ -111,7 +125,8 @@ export function useTimesheetRowMutations(
|
||||
merged.projectId,
|
||||
merged.taskId,
|
||||
merged.billable,
|
||||
merged.tags
|
||||
merged.tags,
|
||||
merged.type
|
||||
);
|
||||
const shouldMergeIntoExistingRow =
|
||||
entryIds.length > 0 &&
|
||||
|
||||
39
resources/js/utils/useCurrentTimeEntry.test.ts
Normal file
39
resources/js/utils/useCurrentTimeEntry.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { TimeEntry } from '@/packages/api/src';
|
||||
import { getLastWorkTimeEntry } from './useCurrentTimeEntry';
|
||||
|
||||
dayjs.extend(utc);
|
||||
|
||||
function timeEntry(id: string, start: string, type: 'work' | 'break'): TimeEntry {
|
||||
return {
|
||||
id,
|
||||
start,
|
||||
end: null,
|
||||
duration: null,
|
||||
description: '',
|
||||
project_id: null,
|
||||
task_id: null,
|
||||
organization_id: 'organization-1',
|
||||
user_id: 'user-1',
|
||||
tags: [],
|
||||
billable: false,
|
||||
type,
|
||||
} as TimeEntry;
|
||||
}
|
||||
|
||||
describe('getLastWorkTimeEntry', () => {
|
||||
it('returns the newest work entry that is not in the future', () => {
|
||||
const entries = [
|
||||
timeEntry('future-work', '2026-07-14T14:00:00Z', 'work'),
|
||||
timeEntry('break', '2026-07-14T12:00:00Z', 'break'),
|
||||
timeEntry('last-work', '2026-07-14T11:00:00Z', 'work'),
|
||||
timeEntry('older-work', '2026-07-14T10:00:00Z', 'work'),
|
||||
];
|
||||
|
||||
expect(getLastWorkTimeEntry(entries, dayjs.utc('2026-07-14T13:00:00Z'))?.id).toBe(
|
||||
'last-work'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -26,9 +26,33 @@ const emptyTimeEntry = {
|
||||
project_id: null,
|
||||
tags: [],
|
||||
billable: false,
|
||||
type: 'work',
|
||||
organization_id: '',
|
||||
} as TimeEntry;
|
||||
|
||||
export type ResumeTimeEntryContext = {
|
||||
description: string | null;
|
||||
project_id: string | null;
|
||||
task_id: string | null;
|
||||
tags: string[];
|
||||
billable: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Time entries are loaded newest-first. Ignore scheduled entries so resuming after a break
|
||||
* always uses the latest work entry that has actually started.
|
||||
*/
|
||||
export function getLastWorkTimeEntry(
|
||||
timeEntries: TimeEntry[],
|
||||
currentTime: Dayjs = dayjs().utc()
|
||||
): TimeEntry | null {
|
||||
return (
|
||||
timeEntries.find(
|
||||
(entry) => entry.type === 'work' && !dayjs(entry.start).utc().isAfter(currentTime)
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
|
||||
const currentTimeEntry = ref<TimeEntry>({ ...emptyTimeEntry });
|
||||
const { handleApiRequestNotifications } = useNotificationsStore();
|
||||
@@ -117,6 +141,7 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
|
||||
project_id: currentTimeEntry.value?.project_id,
|
||||
task_id: currentTimeEntry.value?.task_id,
|
||||
billable: currentTimeEntry.value.billable,
|
||||
type: currentTimeEntry.value?.type ?? 'work',
|
||||
tags: currentTimeEntry.value?.tags,
|
||||
},
|
||||
{ params: { organization: organization } }
|
||||
@@ -133,11 +158,11 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function stopTimer() {
|
||||
async function stopTimer(endTime?: string) {
|
||||
const user = getCurrentUserId();
|
||||
const organization = getCurrentOrganizationId();
|
||||
if (organization) {
|
||||
const currentDateTime = dayjs().utc().format();
|
||||
const currentDateTime = endTime ?? dayjs().utc().format();
|
||||
await handleApiRequestNotifications(
|
||||
() =>
|
||||
api.updateTimeEntry(
|
||||
@@ -161,6 +186,58 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function startBreak() {
|
||||
const organization = getCurrentOrganizationId();
|
||||
const membership = getCurrentMembershipId();
|
||||
if (!organization || !membership) {
|
||||
throw new Error('Failed to start break because organization ID is missing.');
|
||||
}
|
||||
// One timestamp for both the work end and the break start, so the entries touch exactly
|
||||
const switchTime = dayjs().utc().format();
|
||||
if (isActive.value && currentTimeEntry.value.type !== 'break') {
|
||||
await stopTimer(switchTime);
|
||||
}
|
||||
startLiveTimer();
|
||||
const response = await handleApiRequestNotifications(
|
||||
() =>
|
||||
api.createTimeEntry(
|
||||
{
|
||||
member_id: membership,
|
||||
start: switchTime,
|
||||
billable: false,
|
||||
type: 'break',
|
||||
},
|
||||
{ params: { organization: organization } }
|
||||
),
|
||||
'Break started!',
|
||||
'Your timer was stopped, but the break could not be started.'
|
||||
);
|
||||
if (response?.data) {
|
||||
currentTimeEntry.value = response.data;
|
||||
} else {
|
||||
stopLiveTimer();
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
||||
}
|
||||
|
||||
async function resumeWorkAfterBreak(context: ResumeTimeEntryContext) {
|
||||
if (isActive.value && currentTimeEntry.value.type === 'break') {
|
||||
stopLiveTimer();
|
||||
await stopTimer();
|
||||
}
|
||||
currentTimeEntry.value = {
|
||||
...emptyTimeEntry,
|
||||
description: context.description ?? '',
|
||||
project_id: context.project_id,
|
||||
task_id: context.task_id,
|
||||
tags: context.tags ?? [],
|
||||
billable: context.billable,
|
||||
};
|
||||
startLiveTimer();
|
||||
await startTimer();
|
||||
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
||||
}
|
||||
|
||||
async function updateTimer() {
|
||||
const user = getCurrentUserId();
|
||||
const organization = getCurrentOrganizationId();
|
||||
@@ -213,6 +290,10 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
|
||||
return false;
|
||||
});
|
||||
|
||||
const isOnBreak = computed(() => {
|
||||
return isActive.value && currentTimeEntry.value.type === 'break';
|
||||
});
|
||||
|
||||
async function setActiveState(newState: boolean) {
|
||||
if (newState) {
|
||||
startLiveTimer();
|
||||
@@ -229,6 +310,9 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
|
||||
fetchCurrentTimeEntry,
|
||||
updateTimer,
|
||||
isActive,
|
||||
isOnBreak,
|
||||
startBreak,
|
||||
resumeWorkAfterBreak,
|
||||
startLiveTimer,
|
||||
stopLiveTimer,
|
||||
now,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useClientsQuery } from '@/utils/useClientsQuery';
|
||||
import { useTagsQuery } from '@/utils/useTagsQuery';
|
||||
import { CheckCircleIcon, UserCircleIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
|
||||
import { DocumentTextIcon, FolderIcon } from '@heroicons/vue/16/solid';
|
||||
import { Coffee } from '@lucide/vue';
|
||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||
|
||||
export type GroupingOption =
|
||||
@@ -17,7 +18,8 @@ export type GroupingOption =
|
||||
| 'billable'
|
||||
| 'client'
|
||||
| 'description'
|
||||
| 'tag';
|
||||
| 'tag'
|
||||
| 'type';
|
||||
|
||||
export const useReportingStore = defineStore('reporting', () => {
|
||||
// Cache query composables to avoid creating new subscriptions on every call
|
||||
@@ -35,6 +37,7 @@ export const useReportingStore = defineStore('reporting', () => {
|
||||
client: 'No Client',
|
||||
description: 'No Description',
|
||||
tag: 'No Tag',
|
||||
type: 'Work time',
|
||||
} as Record<string, string>;
|
||||
|
||||
function getNameForReportingRowEntry(key: string | null, type: string | null) {
|
||||
@@ -70,6 +73,9 @@ export const useReportingStore = defineStore('reporting', () => {
|
||||
return 'Billable';
|
||||
}
|
||||
}
|
||||
if (type === 'type') {
|
||||
return key === 'break' ? 'Break' : 'Work time';
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
@@ -103,6 +109,11 @@ export const useReportingStore = defineStore('reporting', () => {
|
||||
value: 'billable',
|
||||
icon: BillableIcon,
|
||||
},
|
||||
{
|
||||
label: 'Type',
|
||||
value: 'type',
|
||||
icon: Coffee,
|
||||
},
|
||||
{
|
||||
label: 'Description',
|
||||
value: 'description',
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useNotificationsStore } from '@/utils/notification';
|
||||
|
||||
export function useTimeEntriesMutations() {
|
||||
const queryClient = useQueryClient();
|
||||
const { handleApiRequestNotifications } = useNotificationsStore();
|
||||
const { handleApiRequestNotifications, addNotification } = useNotificationsStore();
|
||||
|
||||
const { mutateAsync: createTimeEntry } = useMutation({
|
||||
mutationFn: async (timeEntry: Omit<CreateTimeEntryBody, 'member_id'>) => {
|
||||
@@ -71,7 +71,7 @@ export function useTimeEntriesMutations() {
|
||||
}) => {
|
||||
const organizationId = getCurrentOrganizationId();
|
||||
if (organizationId) {
|
||||
return await handleApiRequestNotifications(
|
||||
const response = await handleApiRequestNotifications(
|
||||
() =>
|
||||
api.updateMultipleTimeEntries(
|
||||
{
|
||||
@@ -84,9 +84,23 @@ export function useTimeEntriesMutations() {
|
||||
},
|
||||
}
|
||||
),
|
||||
'Time entries updated successfully',
|
||||
undefined,
|
||||
'Failed to update time entries'
|
||||
);
|
||||
// The endpoint applies the changeset per entry and skips entries it can't
|
||||
// apply it to (e.g. breaks with a project/tags/billable change) — a 200
|
||||
// with their ids in `error`. Surface that instead of claiming success.
|
||||
const skippedCount = response?.error.length ?? 0;
|
||||
if (skippedCount > 0) {
|
||||
addNotification(
|
||||
'error',
|
||||
`${skippedCount} of ${ids.length} time entries ${skippedCount === 1 ? 'was' : 'were'} skipped`,
|
||||
'No changes were applied to the skipped entries — break entries can not have a project or tags, or be billable.'
|
||||
);
|
||||
} else {
|
||||
addNotification('success', 'Time entries updated successfully');
|
||||
}
|
||||
return response;
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
|
||||
@@ -274,4 +274,108 @@ describe('useTimesheetGrid', () => {
|
||||
expect(dayTotals.value[4]).toBe(9000);
|
||||
expect(grandTotal.value).toBe(9000);
|
||||
});
|
||||
|
||||
it('always seeds a break row pinned below all other rows when breaks are enabled', async () => {
|
||||
const timeEntries = ref([
|
||||
entry('2026-04-10T09:00:00Z', '2026-04-10T10:00:00Z', {
|
||||
id: 'work-1',
|
||||
project_id: 'p-1',
|
||||
type: 'work',
|
||||
}),
|
||||
]);
|
||||
const projects = ref([project('p-1', 'Alpha')]);
|
||||
|
||||
const { rows, addSlot } = useTimesheetGrid(
|
||||
timeEntries,
|
||||
ref(WEEK_DAYS),
|
||||
projects,
|
||||
ref<Task[]>([]),
|
||||
ref<Dayjs | null>(null),
|
||||
ref(true)
|
||||
);
|
||||
|
||||
expect(rows.value).toHaveLength(2);
|
||||
expect(rows.value[1]?.type).toBe('break');
|
||||
expect(rows.value[1]?.totalSeconds).toBe(0);
|
||||
|
||||
// User-added work rows stay above the break row
|
||||
addSlot('p-1', null, true, []);
|
||||
timeEntries.value = [...timeEntries.value];
|
||||
await nextTick();
|
||||
|
||||
expect(rows.value).toHaveLength(3);
|
||||
expect(rows.value[2]?.type).toBe('break');
|
||||
});
|
||||
|
||||
it('claims break entries for the seeded break row and does not duplicate it', () => {
|
||||
const breakEntry = entry('2026-04-10T12:00:00Z', '2026-04-10T12:30:00Z', {
|
||||
id: 'break-1',
|
||||
project_id: null,
|
||||
type: 'break',
|
||||
} as Partial<TimeEntry>);
|
||||
|
||||
const { rows } = useTimesheetGrid(
|
||||
ref([breakEntry]),
|
||||
ref(WEEK_DAYS),
|
||||
ref<Project[]>([]),
|
||||
ref<Task[]>([]),
|
||||
ref<Dayjs | null>(null),
|
||||
ref(true)
|
||||
);
|
||||
|
||||
expect(rows.value).toHaveLength(1);
|
||||
expect(rows.value[0]?.type).toBe('break');
|
||||
expect(rows.value[0]?.totalSeconds).toBe(1800);
|
||||
});
|
||||
|
||||
it('sums break time into break totals and keeps it out of the worked totals', () => {
|
||||
const work = entry('2026-04-10T09:00:00Z', '2026-04-10T10:00:00Z', {
|
||||
id: 'work-1',
|
||||
project_id: 'p-1',
|
||||
type: 'work',
|
||||
});
|
||||
const brk = entry('2026-04-10T12:00:00Z', '2026-04-10T12:30:00Z', {
|
||||
id: 'break-1',
|
||||
project_id: null,
|
||||
type: 'break',
|
||||
} as Partial<TimeEntry>);
|
||||
|
||||
const { dayTotals, grandTotal, breakDayTotals, breakGrandTotal } = useTimesheetGrid(
|
||||
ref([work, brk]),
|
||||
ref(WEEK_DAYS),
|
||||
ref([project('p-1', 'Alpha')]),
|
||||
ref<Task[]>([]),
|
||||
ref<Dayjs | null>(null),
|
||||
ref(true)
|
||||
);
|
||||
|
||||
// 2026-04-10 is dayIndex 4. Worked totals see only the 1h work entry.
|
||||
expect(dayTotals.value[4]).toBe(3600);
|
||||
expect(grandTotal.value).toBe(3600);
|
||||
// Break time is tallied separately, per day and for the week.
|
||||
expect(breakDayTotals.value[4]).toBe(1800);
|
||||
expect(breakGrandTotal.value).toBe(1800);
|
||||
// Days without a break contribute nothing.
|
||||
expect(breakDayTotals.value[0]).toBe(0);
|
||||
});
|
||||
|
||||
it('does not seed a break row when breaks are disabled', () => {
|
||||
const { rows } = useTimesheetGrid(
|
||||
ref([
|
||||
entry('2026-04-10T09:00:00Z', '2026-04-10T10:00:00Z', {
|
||||
id: 'work-1',
|
||||
project_id: 'p-1',
|
||||
type: 'work',
|
||||
}),
|
||||
]),
|
||||
ref(WEEK_DAYS),
|
||||
ref([project('p-1', 'Alpha')]),
|
||||
ref<Task[]>([]),
|
||||
ref<Dayjs | null>(null),
|
||||
ref(false)
|
||||
);
|
||||
|
||||
expect(rows.value).toHaveLength(1);
|
||||
expect(rows.value[0]?.type).toBe('work');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TimeEntry, Project, Task } from '@/packages/api/src';
|
||||
import type { TimeEntry, TimeEntryType, Project, Task } from '@/packages/api/src';
|
||||
import { getDayJsInstance, getLocalizedDateFromTimestamp } from '@/packages/ui/src/utils/time';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import { computed, ref, watch, type Ref } from 'vue';
|
||||
@@ -18,6 +18,7 @@ export interface TimesheetRow {
|
||||
taskId: string | null;
|
||||
billable: boolean;
|
||||
tags: string[];
|
||||
type: TimeEntryType;
|
||||
cells: Map<number, TimesheetCell>;
|
||||
totalSeconds: number;
|
||||
}
|
||||
@@ -27,9 +28,11 @@ export interface TimesheetRowIdentity {
|
||||
taskId: string | null;
|
||||
billable: boolean;
|
||||
tags: string[];
|
||||
type?: TimeEntryType;
|
||||
}
|
||||
|
||||
interface Slot extends TimesheetRowIdentity {
|
||||
type: TimeEntryType;
|
||||
id: string;
|
||||
// 'seeded' slots are derived from the entries query and re-sort
|
||||
// alphabetically whenever project/task lists change. 'user' slots
|
||||
@@ -46,13 +49,14 @@ export function makeRowKey(
|
||||
projectId: string | null,
|
||||
taskId: string | null,
|
||||
billable: boolean,
|
||||
tags: string[]
|
||||
tags: string[],
|
||||
type: TimeEntryType = 'work'
|
||||
): TimesheetRowKey {
|
||||
return JSON.stringify([projectId, taskId, billable, sortTags(tags)]);
|
||||
return JSON.stringify([projectId, taskId, billable, sortTags(tags), type]);
|
||||
}
|
||||
|
||||
function slotIdentityKey(slot: Slot): TimesheetRowKey {
|
||||
return makeRowKey(slot.projectId, slot.taskId, slot.billable, slot.tags);
|
||||
return makeRowKey(slot.projectId, slot.taskId, slot.billable, slot.tags, slot.type);
|
||||
}
|
||||
|
||||
let slotCounter = 0;
|
||||
@@ -79,7 +83,9 @@ function newSlotId(): string {
|
||||
* identity that doesn't already have one. Initial loads come in as a
|
||||
* batch and are sorted by project name so the first render is stable;
|
||||
* slots added later (via `addSlot` or post-mutation refetches) append
|
||||
* at the end.
|
||||
* at the end. With breaks enabled a break slot is always seeded, so
|
||||
* the grid shows a permanent break row (pinned to the bottom) even
|
||||
* when the week has no break entries.
|
||||
*
|
||||
* Mutations:
|
||||
* - `addSlot` push a blank or pre-populated slot at the end
|
||||
@@ -94,7 +100,8 @@ export function useTimesheetGrid(
|
||||
weekDays: Ref<string[]>,
|
||||
projects: Ref<Project[]>,
|
||||
tasks: Ref<Task[]>,
|
||||
currentTime: Ref<Dayjs | null>
|
||||
currentTime: Ref<Dayjs | null>,
|
||||
breaksEnabled?: Ref<boolean>
|
||||
) {
|
||||
const dayjs = getDayJsInstance();
|
||||
const slots = ref<Slot[]>([]);
|
||||
@@ -105,7 +112,12 @@ export function useTimesheetGrid(
|
||||
// deterministic. User-added slots keep their insertion order and
|
||||
// stay after the seeded block.
|
||||
watch(
|
||||
[() => timeEntries.value, () => projects.value, () => tasks.value],
|
||||
[
|
||||
() => timeEntries.value,
|
||||
() => projects.value,
|
||||
() => tasks.value,
|
||||
() => breaksEnabled?.value,
|
||||
],
|
||||
([entries, projectList, taskList]) => {
|
||||
const present = new Set(slots.value.map(slotIdentityKey));
|
||||
for (const entry of entries) {
|
||||
@@ -113,7 +125,8 @@ export function useTimesheetGrid(
|
||||
entry.project_id,
|
||||
entry.task_id,
|
||||
entry.billable,
|
||||
sortTags(entry.tags)
|
||||
sortTags(entry.tags),
|
||||
entry.type
|
||||
);
|
||||
if (present.has(key)) continue;
|
||||
present.add(key);
|
||||
@@ -124,6 +137,23 @@ export function useTimesheetGrid(
|
||||
taskId: entry.task_id,
|
||||
billable: entry.billable,
|
||||
tags: sortTags(entry.tags),
|
||||
type: entry.type,
|
||||
});
|
||||
}
|
||||
|
||||
// With breaks enabled the grid always shows a break row, even when
|
||||
// the week has no break entries yet. Break entries can only have
|
||||
// one identity (no project/task/tags, non-billable), so one break
|
||||
// slot covers every break entry of the week.
|
||||
if (breaksEnabled?.value && !present.has(makeRowKey(null, null, false, [], 'break'))) {
|
||||
slots.value.push({
|
||||
id: newSlotId(),
|
||||
origin: 'seeded',
|
||||
projectId: null,
|
||||
taskId: null,
|
||||
billable: false,
|
||||
tags: [],
|
||||
type: 'break',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -138,10 +168,12 @@ export function useTimesheetGrid(
|
||||
return `${projectName}\x00${taskName}\x00${s.billable ? '1' : '0'}\x00${s.tags.join(',')}`;
|
||||
};
|
||||
|
||||
const seeded = slots.value.filter((s) => s.origin === 'seeded');
|
||||
const userAdded = slots.value.filter((s) => s.origin === 'user');
|
||||
const seeded = slots.value.filter((s) => s.origin === 'seeded' && s.type !== 'break');
|
||||
const userAdded = slots.value.filter((s) => s.origin === 'user' && s.type !== 'break');
|
||||
// The break row is pinned below all work rows, including user-added ones
|
||||
const breakSlots = slots.value.filter((s) => s.type === 'break');
|
||||
seeded.sort((a, b) => sortKey(a).localeCompare(sortKey(b)));
|
||||
slots.value = [...seeded, ...userAdded];
|
||||
slots.value = [...seeded, ...userAdded, ...breakSlots];
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
@@ -159,7 +191,8 @@ export function useTimesheetGrid(
|
||||
entry.project_id,
|
||||
entry.task_id,
|
||||
entry.billable,
|
||||
sortTags(entry.tags)
|
||||
sortTags(entry.tags),
|
||||
entry.type
|
||||
);
|
||||
if (!entriesByIdentity.has(identityKey)) entriesByIdentity.set(identityKey, []);
|
||||
entriesByIdentity.get(identityKey)!.push(entry);
|
||||
@@ -222,25 +255,43 @@ export function useTimesheetGrid(
|
||||
taskId: slot.taskId,
|
||||
billable: slot.billable,
|
||||
tags: slot.tags,
|
||||
type: slot.type,
|
||||
cells,
|
||||
totalSeconds,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
// Breaks are not working time: the totals sum work rows only, the break row itself shows the break time
|
||||
const dayTotals = computed<number[]>(() =>
|
||||
weekDays.value.map((_, dayIndex) =>
|
||||
rows.value.reduce((sum, row) => sum + (row.cells.get(dayIndex)?.totalSeconds ?? 0), 0)
|
||||
rows.value
|
||||
.filter((row) => row.type !== 'break')
|
||||
.reduce((sum, row) => sum + (row.cells.get(dayIndex)?.totalSeconds ?? 0), 0)
|
||||
)
|
||||
);
|
||||
|
||||
const grandTotal = computed(() => dayTotals.value.reduce((a, b) => a + b, 0));
|
||||
|
||||
// Break time is surfaced separately from worked time (dayTotals excludes it). These
|
||||
// sum only the break rows, per day and for the week; consumers render them only when
|
||||
// non-zero so break-free timesheets look unchanged.
|
||||
const breakDayTotals = computed<number[]>(() =>
|
||||
weekDays.value.map((_, dayIndex) =>
|
||||
rows.value
|
||||
.filter((row) => row.type === 'break')
|
||||
.reduce((sum, row) => sum + (row.cells.get(dayIndex)?.totalSeconds ?? 0), 0)
|
||||
)
|
||||
);
|
||||
|
||||
const breakGrandTotal = computed(() => breakDayTotals.value.reduce((a, b) => a + b, 0));
|
||||
|
||||
function addSlot(
|
||||
projectId: string | null,
|
||||
taskId: string | null,
|
||||
billable: boolean,
|
||||
tags: string[]
|
||||
tags: string[],
|
||||
type: TimeEntryType = 'work'
|
||||
): TimesheetRowKey {
|
||||
const id = newSlotId();
|
||||
slots.value.push({
|
||||
@@ -250,6 +301,7 @@ export function useTimesheetGrid(
|
||||
taskId,
|
||||
billable,
|
||||
tags: sortTags(tags),
|
||||
type,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
@@ -265,6 +317,7 @@ export function useTimesheetGrid(
|
||||
slot.taskId = identity.taskId;
|
||||
slot.billable = identity.billable;
|
||||
slot.tags = sortTags(identity.tags);
|
||||
slot.type = identity.type ?? slot.type;
|
||||
}
|
||||
|
||||
function clearSlots() {
|
||||
@@ -275,6 +328,8 @@ export function useTimesheetGrid(
|
||||
rows,
|
||||
dayTotals,
|
||||
grandTotal,
|
||||
breakDayTotals,
|
||||
breakGrandTotal,
|
||||
slots,
|
||||
addSlot,
|
||||
removeSlot,
|
||||
|
||||
@@ -55,8 +55,11 @@ export function useTimesheetQuery(
|
||||
const dateRange = computed(() => {
|
||||
if (!weekStart.value || !weekEnd.value) return { start: null, end: null };
|
||||
return {
|
||||
start: localDateToUtc(weekStart.value),
|
||||
end: localDateToUtc(weekEnd.value),
|
||||
// One padding day on each side so entries crossing midnight at the
|
||||
// week edges are loaded — break placement treats them as walls that
|
||||
// shrink the usable day window. The grid filters back to the week.
|
||||
start: localDateToUtc(weekStart.value.subtract(1, 'day')),
|
||||
end: localDateToUtc(weekEnd.value.add(1, 'day')),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -83,8 +86,9 @@ export function useTimesheetQuery(
|
||||
}
|
||||
|
||||
export function prefetchTimesheetWeek(queryClient: QueryClient, weekStart: Dayjs, weekEnd: Dayjs) {
|
||||
const start = localDateToUtc(weekStart);
|
||||
const end = localDateToUtc(weekEnd);
|
||||
// Same one-day padding as useTimesheetQuery so the prefetched key matches.
|
||||
const start = localDateToUtc(weekStart.subtract(1, 'day'));
|
||||
const end = localDateToUtc(weekEnd.add(1, 'day'));
|
||||
const organizationId = getCurrentOrganizationId();
|
||||
const memberId = getCurrentMembershipId();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user