Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .docs/kleer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions app/src/lib/period-lock/lock-periods.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
7 changes: 3 additions & 4 deletions app/src/lib/period-lock/lock-periods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand Down Expand Up @@ -82,7 +82,8 @@ export function splitAtMonthEnd(range: LockRange): Array<LockRange> | 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<LockDay>, range: LockRange) {
return days.filter((day) => day.date >= range.from && day.date <= range.to);
Expand Down Expand Up @@ -120,13 +121,11 @@ export function scopeAction(days: Array<LockDay>): ScopeAction {
}

export function summarize(days: Array<LockDay>) {
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,
Expand Down
3 changes: 1 addition & 2 deletions app/src/lib/period-lock/use-lock-days.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
lockedThrough,
lockLevel,
monthRange,
needsLock,
rangesWithStatus,
scopeAction,
splitAtMonthEnd,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
<div className={cn(!review.isReady && "opacity-40")}>
Expand Down Expand Up @@ -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 (
<button
Expand Down
Loading