add break time entries and simplified time tracker ui

This commit is contained in:
Gregor Vostrak
2026-07-21 16:48:37 +02:00
parent c07c62bfab
commit 64ca1e9115
128 changed files with 6252 additions and 437 deletions

View File

@@ -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,
});

View 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:1512: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` });
});
});

View 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;
}

View File

@@ -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

View 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();
});
});

View 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,
};
}

View File

@@ -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 ?? [],
};

View File

@@ -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,
};

View File

@@ -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,
};
}

View File

@@ -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',
});
});

View File

@@ -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 &&

View 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'
);
});
});

View File

@@ -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,

View File

@@ -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',

View File

@@ -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: () => {

View File

@@ -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');
});
});

View File

@@ -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,

View File

@@ -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();