diff --git a/app/src/lib/period-lock/lock-periods.test.js b/app/src/lib/period-lock/lock-periods.test.js index 39ebddd3..89d32ac8 100644 --- a/app/src/lib/period-lock/lock-periods.test.js +++ b/app/src/lib/period-lock/lock-periods.test.js @@ -5,6 +5,8 @@ import { listCheckpoints, lockLevel, rangesWithStatus, + weekParts, + workweekRange, } from "./lock-periods.ts"; function workdays(from, to, today, lockedThrough) { @@ -99,3 +101,26 @@ test("unlocking skips certified days instead of spanning them", () => { { from: "2026-10-22", to: "2026-10-23" }, ]); }); + +describe("locking Monday to Friday", () => { + test("a Sunday belongs to the week that started six days before", () => { + expect(workweekRange("2026-11-08")).toEqual({ + from: "2026-11-02", + to: "2026-11-06", + }); + }); + + test("a week crossing a month end keeps its Friday", () => { + expect(workweekRange("2026-07-01")).toEqual({ + from: "2026-06-29", + to: "2026-07-03", + }); + }); + + test("a month ending on a Friday offers Mon–Fri once", () => { + expect(weekParts("2026-07-29")).toEqual([ + { from: "2026-07-27", to: "2026-07-31" }, + { from: "2026-08-01", to: "2026-08-02" }, + ]); + }); +}); diff --git a/app/src/lib/period-lock/lock-periods.ts b/app/src/lib/period-lock/lock-periods.ts index 41f61f66..acd29254 100644 --- a/app/src/lib/period-lock/lock-periods.ts +++ b/app/src/lib/period-lock/lock-periods.ts @@ -47,6 +47,12 @@ export function weekRange(date: string): LockRange { }; } +/** Monday through Friday, leaving the weekend open. */ +export function workweekRange(date: string): LockRange { + const { from } = weekRange(date); + return { from, to: toIsoDate(addDays(parseISO(from), 4)) }; +} + export function monthRange(date: string): LockRange { const day = parseISO(date); return { @@ -80,6 +86,19 @@ export function splitAtMonthEnd(range: LockRange): Array | null { ]; } +export const isSameRange = (a: LockRange | null, b: LockRange) => + a?.from === b.from && a?.to === b.to; + +/** What a week can be locked as: Mon–Fri, then any split at a month end. */ +export function weekParts(date: string): Array { + const workweek = workweekRange(date); + // A month ending on a Friday would offer Mon–Fri twice. + const monthParts = (splitAtMonthEnd(weekRange(date)) ?? []).filter( + (part) => !isSameRange(part, workweek), + ); + return [workweek, ...monthParts]; +} + export const isLocked = (day: LockDay) => day.status !== "open"; /** Every calendar day counts: an empty weekend left open keeps its week open in Kleer. */ diff --git a/app/src/routes/_layout/time-tracking/-components/period-lock/sign-off.tsx b/app/src/routes/_layout/time-tracking/-components/period-lock/sign-off.tsx index 2ad6e9a8..1d60c102 100644 --- a/app/src/routes/_layout/time-tracking/-components/period-lock/sign-off.tsx +++ b/app/src/routes/_layout/time-tracking/-components/period-lock/sign-off.tsx @@ -19,6 +19,7 @@ import { formatRange, hoursShort, isLocked, + isSameRange, isoWeekOf, lockedThrough, lockLevel, @@ -26,10 +27,11 @@ import { needsLock, rangesWithStatus, scopeAction, - splitAtMonthEnd, summarize, toIsoDate, + weekParts, weekRange, + workweekRange, type LockDay, type LockRange, } from "@/lib/period-lock/lock-periods"; @@ -42,6 +44,7 @@ import { cn, formatHoursMinutes } from "@/lib/utils"; import { LockGlyph } from "./lock-glyph"; const SETTLE_MS = 1700; +const WORKWEEK_LABEL = "Mon–Fri"; type Kind = "due" | "week" | "month"; type Selection = { @@ -90,6 +93,9 @@ function step({ kind, anchor }: Selection, direction: 1 | -1): string { function titleOf(selection: Selection) { if (selection.name) return selection.name; + if (isSameRange(selection.part, workweekRange(selection.anchor))) { + return `Week ${isoWeekOf(selection.anchor)}, ${WORKWEEK_LABEL}`; + } if (selection.part) return formatRange(selection.part); if (selection.kind === "month") { return format(parseISO(selection.anchor), "MMMM yyyy"); @@ -136,7 +142,12 @@ function useSignOff() { const coversOneWeek = weekRange(range.from).from === weekRange(range.to).from; const parts = - selection.kind === "week" ? splitAtMonthEnd(weekRange(selection.anchor)) : null; + selection.kind === "week" + ? weekParts(selection.anchor).map((part, index) => ({ + range: part, + label: index === 0 ? WORKWEEK_LABEL : formatRange(part), + })) + : []; return { today, @@ -176,8 +187,9 @@ function useSignOff() { try { await lock(range, rangesWithStatus(days, "open")); } catch { - // The failure has its toast, and the period stays as it was. - setPicked(null); + // The failure has its toast. Keep what was picked so a retry locks + // the same days, even after part of a range crossing months locked. + setPicked(picked); return; } setIsJustLocked(true); @@ -449,27 +461,31 @@ function PeriodControls({ review }: { review: Review }) { - {review.parts?.map((part) => { - const isActive = - review.selection.part?.from === part.from && - review.selection.part.to === part.to; - return ( - - ); - })} + {review.parts.length > 0 && ( + // The chips wrap as one group, never one stray chip on its own line. + + {review.parts.map(({ range, label }) => { + const isActive = isSameRange(review.selection.part, range); + return ( + + ); + })} + + )} ); }