diff --git a/.docs/kleer.md b/.docs/kleer.md index b5a9014c..49020d8d 100644 --- a/.docs/kleer.md +++ b/.docs/kleer.md @@ -100,6 +100,7 @@ Toki's lock and unlock are Kleer's `POST /event/approve` and `POST /event/unlock - A range must stay inside one calendar month, otherwise Kleer answers `500` with `Start month and end month must be the same`. This is why Kleer's own menu splits a week crossing a month end. The adapter splits ranges and sends one request per month, so a failure can leave earlier months locked. Locking is idempotent, so a retry finishes the job. - A reversed range is accepted with `200` and acts on its mirror image. The domain service rejects it. +- Every calendar day counts. On 2026-10-30 production had a status for every day of the previous three months, empty weekends included, and Kleer's week page reported a week ending in an open empty Saturday as not locked, while its month page still said `Godkänd`. Toki treats a period as done only when all its days are locked. - Locking covers empty and future days. `GET /event/statuses` omits days that were never locked; once locked or unlocked they are listed, as `OPEN` after an unlock. - A locked day refuses new and edited events with a `500`. - Locking over a day certified by someone else leaves it certified. diff --git a/app/src/lib/period-lock/lock-periods.test.js b/app/src/lib/period-lock/lock-periods.test.js index a0af13e6..39ebddd3 100644 --- a/app/src/lib/period-lock/lock-periods.test.js +++ b/app/src/lib/period-lock/lock-periods.test.js @@ -47,6 +47,12 @@ describe("what has to be locked", () => { expect(due?.to).toBe("2026-09-30"); }); + test("an empty weekend left open keeps the week and month due", () => { + const due = dueOn("2026-10-30", "2026-10-30"); + expect(due?.title).toBe("Week 44 and October are due today"); + expect([due?.from, due?.to]).toEqual(["2026-10-31", "2026-11-01"]); + }); + test("an unlocked week is overdue once it has ended", () => { const due = dueOn("2026-10-27", "2026-10-18"); expect(due?.title).toBe("Week 43 is overdue"); diff --git a/app/src/lib/period-lock/lock-periods.ts b/app/src/lib/period-lock/lock-periods.ts index c2c854a4..41f61f66 100644 --- a/app/src/lib/period-lock/lock-periods.ts +++ b/app/src/lib/period-lock/lock-periods.ts @@ -24,7 +24,7 @@ export type LockDay = { hasOverlap: boolean; isFuture: boolean; isToday: boolean; - /** Nothing reported and nothing scheduled, so the day has nothing to lock. */ + /** Nothing reported and nothing scheduled, so the day has nothing to review. */ isIdle: boolean; }; @@ -82,7 +82,8 @@ export function splitAtMonthEnd(range: LockRange): Array | null { export const isLocked = (day: LockDay) => day.status !== "open"; -export const needsLock = (day: LockDay) => day.status === "open" && !day.isIdle; +/** Every calendar day counts: an empty weekend left open keeps its week open in Kleer. */ +export const needsLock = (day: LockDay) => day.status === "open"; export function daysIn(days: Array, range: LockRange) { return days.filter((day) => day.date >= range.from && day.date <= range.to); @@ -120,13 +121,11 @@ export function scopeAction(days: Array): ScopeAction { } export function summarize(days: Array) { - const toLock = days.filter(needsLock); const reportedHours = days.reduce((sum, d) => sum + d.reportedHours, 0); const scheduledHours = days.reduce((sum, d) => sum + d.scheduledHours, 0); const issueDays = days.filter((day) => dayIssues(day).length > 0).length; return { - toLock: toLock.length, reportedHours, scheduledHours, issueDays, diff --git a/app/src/lib/period-lock/use-lock-days.ts b/app/src/lib/period-lock/use-lock-days.ts index 4afbf46a..be9f95f5 100644 --- a/app/src/lib/period-lock/use-lock-days.ts +++ b/app/src/lib/period-lock/use-lock-days.ts @@ -99,8 +99,7 @@ function dueWindow(today: Date): LockRange { /** * What's due, for the app shell. Runs on every page, so it reads statuses and - * schedule only; weekend hours nobody scheduled go unnoticed until the time - * tracking page loads the entries. + * schedule only. */ export function useDueLockAlert() { const now = new Date(); 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 7258386f..2ad6e9a8 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 @@ -23,6 +23,7 @@ import { lockedThrough, lockLevel, monthRange, + needsLock, rangesWithStatus, scopeAction, splitAtMonthEnd, @@ -150,8 +151,7 @@ function useSignOff() { isReady: isInDueWindow || isRangeLoaded, summary: summarize(days), action: scopeAction(days), - isFullyLocked: - days.length > 0 && days.every((day) => isLocked(day) || day.isIdle), + isFullyLocked: days.length > 0 && days.every(isLocked), scopeName: titleOf(selection), coversOneWeek, parts, @@ -480,8 +480,11 @@ function ReviewRows(props: { onShowDay: ShowDay; }) { const { review } = props; - // Weekends and holidays with nothing on them have nothing to review. - const days = review.days.filter((day) => !day.isIdle); + // Empty weekends and holidays only get a row when they're all that's left to lock. + const hasWorkLeft = review.days.some((day) => needsLock(day) && !day.isIdle); + const days = review.days.filter( + (day) => !day.isIdle || (needsLock(day) && !hasWorkLeft), + ); return (
@@ -712,7 +715,7 @@ function WeekRows(props: { const inRange = daysIn(props.days, weekRange(weekStart)); const summary = summarize(inRange); const first = inRange[0]; - const isDone = inRange.every((day) => isLocked(day) || day.isIdle); + const isDone = inRange.every(isLocked); return (