diff --git a/.docs/kleer.md b/.docs/kleer.md
index a829e269..b5a9014c 100644
--- a/.docs/kleer.md
+++ b/.docs/kleer.md
@@ -68,6 +68,8 @@ Permanent notes for Toki's Kleer time-tracking integration. Read this before cha
- `POST /company/{companyId}/event/{eventId}`
- `DELETE /company/{companyId}/event/{eventId}`
- `GET /company/{companyId}/event/statuses`
+- `POST /company/{companyId}/event/approve`
+- `POST /company/{companyId}/event/unlock`
- `GET /company/{companyId}/payroll/user/{userId}`
- `GET /company/{companyId}/payroll/user/{userId}/schedule/{startDate}/to/{endDate}`
- `GET /company/{companyId}/payroll/monthreport/user/{userId}/year/{year}/month/{month}`
@@ -92,6 +94,18 @@ Permanent notes for Toki's Kleer time-tracking integration. Read this before cha
- `periodFlexHours`
- Toki estimates period flex as `coveredHours - scheduledHours`, where `coveredHours = workedHours + absenceHours`. `periodFlexHours` is a selected-period estimate, not Kleer's stored flex balance.
+## Locking
+
+Toki's lock and unlock are Kleer's `POST /event/approve` and `POST /event/unlock`, with `{ "user-id": { "id": 1 }, "start-date": "...", "end-date": "..." }`. `POST /event/certify` takes the same body; Toki does not call it. Validated against `test-api` on 2026-10-30; production has not been exercised:
+
+- 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.
+- 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.
+- Unlocking a day certified by someone else answers `200` with a `message` listing the refused days and leaves them certified, so read the statuses back. Two weekend days in a mixed range opened anyway; unexplained, so Toki sends only approved days. A day the integration account certified itself could be unlocked.
+- Kleer records the integration account, not the person, as `registration-user` on the events it locks.
+
## Flex Balance
- Kleer support said on 2026-04-27 that the flex balance was not available through the API. It is now: `GET /payroll/user/{userId}` returns `comp-time-balance`, the comp time (flex) balance in hours, negative when the employee owes hours. It reads the same from the production and test APIs.
diff --git a/Cargo.lock b/Cargo.lock
index 80c842de..cb33eadb 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3063,9 +3063,9 @@ dependencies = [
[[package]]
name = "libz-sys"
-version = "1.1.30"
+version = "1.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f710a23e6dbf193214fd46ca56a9d6864e550abe86202184532ae7275e46de19"
+checksum = "ff20308ecabd635a2dc1dd5834a86c462cca20efba1d5d3ddab1f8aea3455cb2"
dependencies = [
"cc",
"libc",
diff --git a/app/src/components/side-nav.tsx b/app/src/components/side-nav.tsx
index aab3cc32..2d73d5a3 100644
--- a/app/src/components/side-nav.tsx
+++ b/app/src/components/side-nav.tsx
@@ -10,10 +10,18 @@ import {
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
-import { Link, LinkProps } from "@tanstack/react-router";
+import {
+ Link,
+ LinkProps,
+ useNavigate,
+ useRouterState,
+} from "@tanstack/react-router";
import { ScrollArea } from "./ui/scroll-area";
import { router } from "@/main";
import { useQuery } from "@tanstack/react-query";
+import { toast } from "sonner";
+import { useDueLockAlert } from "@/lib/period-lock/use-lock-days";
+import type { DueLock } from "@/lib/period-lock/lock-periods";
import { differsQueries } from "@/lib/api/queries/differs";
import { userQueries } from "@/lib/api/queries/user";
import { NotificationsPopover } from "./notifications-popover/notifications-popover";
@@ -74,7 +82,35 @@ const BOARD_MENU_ORGANIZATION = "lerumsdjur";
type UsedLink = (typeof MENU_ITEMS)[number]["to"];
+const LOCK_REMINDER_KEY = "time-tracking-lock-reminded-on";
+
+/** Overdue time gets one toast a day, wherever the person is working. */
+function useOverdueLockReminder(due: DueLock | null) {
+ const navigate = useNavigate();
+ const pathname = useRouterState({ select: (s) => s.location.pathname });
+ const isOverdue = due !== null && due.severity !== "due";
+ const title = due?.title;
+
+ React.useEffect(() => {
+ if (!isOverdue || pathname.startsWith("/time-tracking")) return;
+
+ const today = new Date().toDateString();
+ if (localStorage.getItem(LOCK_REMINDER_KEY) === today) return;
+
+ localStorage.setItem(LOCK_REMINDER_KEY, today);
+ toast.warning(title, {
+ action: {
+ label: "Review",
+ onClick: () => void navigate({ to: "/time-tracking" }),
+ },
+ });
+ }, [isOverdue, pathname, title, navigate]);
+}
+
export function SideNavWrapper({ children }: { children: React.ReactNode }) {
+ const dueLock = useDueLockAlert();
+ useOverdueLockReminder(dueLock);
+
const { data: differs } = useQuery({
...differsQueries.differs(),
});
@@ -126,7 +162,7 @@ export function SideNavWrapper({ children }: { children: React.ReactNode }) {
{/* Navigation items */}
-
+
{/* Theme switcher - inline on mobile */}
@@ -153,7 +189,9 @@ export function SideNavWrapper({ children }: { children: React.ReactNode }) {
function Nav({
links,
+ dueLock,
}: {
+ dueLock: DueLock | null;
links: readonly {
title: string;
label?: string;
@@ -170,7 +208,8 @@ function Nav({
- {title}
+
+ {title}
+ {alert ? `, ${label}` : ""}
+
+ {alert && (
+
+
+
+ )}
};
+
+function refreshLockedTime(queryClient: ReturnType) {
+ queryClient.invalidateQueries({
+ queryKey: timeTrackingQueries.timeEntriesBaseKey,
+ });
+ queryClient.invalidateQueries({
+ queryKey: timeTrackingQueries.timeEntryDayStatusesBaseKey,
+ });
+}
+
+function useLockTimeEntries(
+ options?: DefaultMutationOptions,
+) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (body: TimeEntryPeriod) => {
+ await api.post("time-tracking/lock", { json: body });
+ },
+ ...options,
+ onSettled: (...args) => {
+ refreshLockedTime(queryClient);
+ return options?.onSettled?.(...args);
+ },
+ });
+}
+
+function useUnlockTimeEntries(
+ options?: DefaultMutationOptions,
+) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (body: TimeEntryPeriod) =>
+ api
+ .post("time-tracking/unlock", { json: body })
+ .json(),
+ ...options,
+ onSettled: (...args) => {
+ refreshLockedTime(queryClient);
+ return options?.onSettled?.(...args);
+ },
+ });
+}
+
function useDeleteAbsence(
options?: DefaultMutationOptions,
) {
diff --git a/app/src/lib/period-lock/lock-periods.test.js b/app/src/lib/period-lock/lock-periods.test.js
new file mode 100644
index 00000000..a0af13e6
--- /dev/null
+++ b/app/src/lib/period-lock/lock-periods.test.js
@@ -0,0 +1,95 @@
+import { describe, expect, test } from "bun:test";
+import {
+ eachDate,
+ findDueLock,
+ listCheckpoints,
+ lockLevel,
+ rangesWithStatus,
+} from "./lock-periods.ts";
+
+function workdays(from, to, today, lockedThrough) {
+ return eachDate({ from, to }).map((date) => {
+ const weekday = new Date(`${date}T12:00:00Z`).getUTCDay();
+ const scheduledHours = weekday === 0 || weekday === 6 ? 0 : 8;
+ return {
+ date,
+ status: date <= lockedThrough ? "approved" : "open",
+ reportedHours: date <= today ? scheduledHours : 0,
+ scheduledHours,
+ entries: [],
+ hasOverlap: false,
+ isFuture: date > today,
+ isToday: date === today,
+ isIdle: scheduledHours === 0,
+ };
+ });
+}
+
+function dueOn(today, lockedThrough, from = "2026-09-01", to = "2026-11-01") {
+ const days = workdays(from, to, today, lockedThrough);
+ return findDueLock(days, listCheckpoints(days, today), today);
+}
+
+describe("what has to be locked", () => {
+ test("nothing before the last scheduled day of the week", () => {
+ expect(dueOn("2026-10-27", "2026-10-25")).toBeNull();
+ });
+
+ test("a week and a month ending together are one lock", () => {
+ const due = dueOn("2026-10-30", "2026-10-25");
+ expect(due?.title).toBe("Week 44 and October are due today");
+ expect([due?.from, due?.to]).toEqual(["2026-10-26", "2026-11-01"]);
+ });
+
+ test("a month ending midweek locks only through its last day", () => {
+ const due = dueOn("2026-09-30", "2026-09-27", "2026-08-01", "2026-10-04");
+ expect(due?.title).toBe("September is due today");
+ expect(due?.to).toBe("2026-09-30");
+ });
+
+ 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");
+ expect(due?.severity).toBe("overdue");
+ });
+
+ test("an unlocked month is overdue from the 1st", () => {
+ const due = dueOn("2026-10-01", "2026-09-27", "2026-08-01", "2026-10-04");
+ expect(due?.title).toBe("September is overdue");
+ expect(due?.severity).toBe("month-overdue");
+ });
+
+ test("the last scheduled day moves the deadline, not the weekday", () => {
+ const days = workdays("2026-10-26", "2026-11-01", "2026-10-29", "2026-10-25");
+ const holiday = days.find((day) => day.date === "2026-10-30");
+ holiday.scheduledHours = 0;
+ holiday.isIdle = true;
+
+ const week = listCheckpoints(days, "2026-10-29").find(
+ (checkpoint) => checkpoint.kind === "week",
+ );
+ expect(week).toMatchObject({ dueOn: "2026-10-29", state: "due" });
+ });
+});
+
+describe("how loudly to ask", () => {
+ test("stays silent until the day before a deadline", () => {
+ expect(lockLevel(null, "2026-10-30", "2026-10-27")).toBe("clear");
+ expect(lockLevel(null, "2026-10-30", "2026-10-29")).toBe("soon");
+ });
+
+ test("today's unfinished hours are no reason to raise a flag", () => {
+ const due = dueOn("2026-10-30", "2026-10-25");
+ expect(due?.detail).toBe("26 Oct – 1 Nov.");
+ });
+});
+
+test("unlocking skips certified days instead of spanning them", () => {
+ const days = workdays("2026-10-19", "2026-10-23", "2026-10-23", "2026-10-23");
+ days[2].status = "certified";
+
+ expect(rangesWithStatus(days, "approved")).toEqual([
+ { from: "2026-10-19", to: "2026-10-20" },
+ { from: "2026-10-22", to: "2026-10-23" },
+ ]);
+});
diff --git a/app/src/lib/period-lock/lock-periods.ts b/app/src/lib/period-lock/lock-periods.ts
new file mode 100644
index 00000000..c2c854a4
--- /dev/null
+++ b/app/src/lib/period-lock/lock-periods.ts
@@ -0,0 +1,319 @@
+import {
+ addDays,
+ eachDayOfInterval,
+ endOfMonth,
+ endOfWeek,
+ format,
+ getISOWeek,
+ isSameMonth,
+ parseISO,
+ startOfMonth,
+ startOfWeek,
+} from "date-fns";
+import type { TimeEntry, TimeEntryStatus } from "@/lib/api/queries/time-tracking";
+import { formatHoursMinutes } from "@/lib/utils";
+
+export type LockRange = { from: string; to: string };
+
+export type LockDay = {
+ date: string;
+ status: TimeEntryStatus;
+ reportedHours: number;
+ scheduledHours: number;
+ entries: Array;
+ hasOverlap: boolean;
+ isFuture: boolean;
+ isToday: boolean;
+ /** Nothing reported and nothing scheduled, so the day has nothing to lock. */
+ isIdle: boolean;
+};
+
+const ISO_DATE = "yyyy-MM-dd";
+const MONDAY = { weekStartsOn: 1 } as const;
+
+export const toIsoDate = (date: Date) => format(date, ISO_DATE);
+
+export function eachDate({ from, to }: LockRange) {
+ return eachDayOfInterval({ start: parseISO(from), end: parseISO(to) }).map(
+ toIsoDate,
+ );
+}
+
+export function weekRange(date: string): LockRange {
+ const day = parseISO(date);
+ return {
+ from: toIsoDate(startOfWeek(day, MONDAY)),
+ to: toIsoDate(endOfWeek(day, MONDAY)),
+ };
+}
+
+export function monthRange(date: string): LockRange {
+ const day = parseISO(date);
+ return {
+ from: toIsoDate(startOfMonth(day)),
+ to: toIsoDate(endOfMonth(day)),
+ };
+}
+
+export const isoWeekOf = (date: string) => getISOWeek(parseISO(date));
+
+export function formatRange({ from, to }: LockRange) {
+ const start = parseISO(from);
+ const end = parseISO(to);
+ if (from === to) return format(start, "d MMM");
+ if (isSameMonth(start, end)) {
+ return `${format(start, "d")}–${format(end, "d MMM")}`;
+ }
+ return `${format(start, "d MMM")} – ${format(end, "d MMM")}`;
+}
+
+/** A week crossing a month end is also offered split at the boundary. */
+export function splitAtMonthEnd(range: LockRange): Array | null {
+ const start = parseISO(range.from);
+ const end = parseISO(range.to);
+ if (isSameMonth(start, end)) return null;
+
+ const monthEnd = endOfMonth(start);
+ return [
+ { from: range.from, to: toIsoDate(monthEnd) },
+ { from: toIsoDate(addDays(monthEnd, 1)), to: range.to },
+ ];
+}
+
+export const isLocked = (day: LockDay) => day.status !== "open";
+
+export const needsLock = (day: LockDay) => day.status === "open" && !day.isIdle;
+
+export function daysIn(days: Array, range: LockRange) {
+ return days.filter((day) => day.date >= range.from && day.date <= range.to);
+}
+
+/** Runs of days in one status, so a change never spans days it shouldn't touch. */
+export function rangesWithStatus(
+ days: Array,
+ status: TimeEntryStatus,
+): Array {
+ const ranges: Array = [];
+ let previous: LockDay | undefined;
+
+ for (const day of days) {
+ if (day.status === status) {
+ const last = ranges.at(-1);
+ if (last && previous?.status === status) {
+ last.to = day.date;
+ } else {
+ ranges.push({ from: day.date, to: day.date });
+ }
+ }
+ previous = day;
+ }
+
+ return ranges;
+}
+
+export type ScopeAction = "lock" | "unlock" | "none";
+
+export function scopeAction(days: Array): ScopeAction {
+ if (days.some(needsLock)) return "lock";
+ if (days.some((day) => day.status === "approved")) return "unlock";
+ return "none";
+}
+
+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,
+ };
+}
+
+export type DayIssue = { kind: "missing" | "short" | "overlap"; text: string };
+
+/** Hours still missing from a finished day; today isn't over, so it has none. */
+export function hoursShort(day: LockDay) {
+ if (day.isIdle || isLocked(day) || day.isFuture || day.isToday) return 0;
+ const shortBy = day.scheduledHours - day.reportedHours;
+ return shortBy >= 0.25 ? shortBy : 0;
+}
+
+export function dayIssues(day: LockDay): Array {
+ if (day.isIdle || isLocked(day) || day.isFuture) return [];
+
+ const issues: Array = [];
+ const shortBy = hoursShort(day);
+
+ if (shortBy > 0 && day.reportedHours === 0) {
+ issues.push({ kind: "missing", text: "No time reported" });
+ } else if (shortBy > 0) {
+ issues.push({
+ kind: "short",
+ text: `${formatHoursMinutes(shortBy)} short of schedule`,
+ });
+ }
+ if (day.hasOverlap) {
+ issues.push({ kind: "overlap", text: "Overlapping entries" });
+ }
+
+ return issues;
+}
+
+export function hasOverlap(entries: Array) {
+ const intervals = entries
+ .filter((entry) => entry.startTime && entry.endTime)
+ .map((entry) => ({
+ start: Math.floor(new Date(entry.startTime!).getTime() / 60_000),
+ end: Math.floor(new Date(entry.endTime!).getTime() / 60_000),
+ }))
+ .sort((a, b) => a.start - b.start);
+
+ return intervals.some(
+ (current, index) =>
+ index > 0 && current.start < intervals[index - 1].end,
+ );
+}
+
+export type Checkpoint = {
+ kind: "week" | "month";
+ label: string;
+ /** Last calendar day of the period; locking reaches this far. */
+ through: string;
+ /** Last scheduled day of the period, when the lock becomes due. */
+ dueOn: string;
+ state: "done" | "upcoming" | "due" | "overdue";
+};
+
+export function listCheckpoints(days: Array, today: string) {
+ const checkpoint = (
+ kind: Checkpoint["kind"],
+ label: string,
+ period: LockRange,
+ ): Checkpoint => {
+ const periodDays = daysIn(days, period);
+ const lastScheduled = periodDays.findLast((d) => d.scheduledHours > 0);
+ const dueOn = lastScheduled?.date ?? period.to;
+ const isDone = !periodDays.some(needsLock);
+
+ return {
+ kind,
+ label,
+ through: period.to,
+ dueOn,
+ state: isDone
+ ? "done"
+ : today > period.to
+ ? "overdue"
+ : today >= dueOn
+ ? "due"
+ : "upcoming",
+ };
+ };
+
+ return days.flatMap((day) => {
+ const week = weekRange(day.date);
+ const month = monthRange(day.date);
+ return [
+ ...(day.date === month.to
+ ? [checkpoint("month", format(parseISO(day.date), "MMMM"), month)]
+ : []),
+ ...(day.date === week.to
+ ? [checkpoint("week", `week ${isoWeekOf(day.date)}`, week)]
+ : []),
+ ];
+ });
+}
+
+export type DueLock = LockRange & {
+ name: string;
+ title: string;
+ detail: string;
+ severity: "due" | "overdue" | "month-overdue";
+};
+
+export function joinNames(required: Array) {
+ const months = required.filter((c) => c.kind === "month");
+ const weeks = required.filter((c) => c.kind === "week");
+ const weekName =
+ weeks.length > 2
+ ? `${weeks.length} weeks`
+ : weeks.map((c) => c.label).join(" and ");
+ return [weekName, ...months.map((c) => c.label)]
+ .filter(Boolean)
+ .join(" and ");
+}
+
+export const capitalize = (text: string) =>
+ text.charAt(0).toUpperCase() + text.slice(1);
+
+export type LockLevel = "clear" | "soon" | DueLock["severity"];
+
+/** How loudly to speak: nothing above "soon" until a lock is actually due. */
+export function lockLevel(
+ due: DueLock | null,
+ nextDueOn: string | undefined,
+ today: string,
+): LockLevel {
+ if (due) return due.severity;
+ const tomorrow = toIsoDate(addDays(parseISO(today), 1));
+ return nextDueOn === tomorrow ? "soon" : "clear";
+}
+
+/** The last day of the unbroken locked run, or null when the first day is open. */
+export function lockedThrough(days: Array) {
+ const firstOpen = days.findIndex(needsLock);
+ const last = firstOpen === -1 ? days.at(-1) : days[firstOpen - 1];
+ return last?.date ?? null;
+}
+
+/** Everything that has to be locked now, as one range. */
+export function findDueLock(
+ days: Array,
+ checkpoints: Array,
+ today: string,
+): DueLock | null {
+ const required = checkpoints.filter(
+ (c) => c.state === "due" || c.state === "overdue",
+ );
+ const last = required.at(-1);
+ const first = days.find(needsLock);
+ if (!last || !first) return null;
+
+ const range = { from: first.date, to: last.through };
+ const overdue = required.filter((c) => c.state === "overdue");
+ const name =
+ required.length > 2
+ ? `everything through ${formatRange({ from: last.through, to: last.through })}`
+ : joinNames(required);
+ const verb = required.length > 1 || name.endsWith("weeks") ? "are" : "is";
+ const summary = summarize(daysIn(days, range));
+ const flagged =
+ summary.issueDays > 0
+ ? `, ${summary.issueDays} ${summary.issueDays === 1 ? "day needs" : "days need"} a look`
+ : "";
+
+ if (overdue.length > 0) {
+ const overdueName = joinNames(overdue);
+ return {
+ ...range,
+ name,
+ title: `${capitalize(overdueName)} ${overdue.length > 1 ? "are" : "is"} overdue`,
+ detail: `${formatRange(range)}${flagged}.`,
+ severity: overdue.some((c) => c.kind === "month")
+ ? "month-overdue"
+ : "overdue",
+ };
+ }
+
+ return {
+ ...range,
+ name,
+ title: `${capitalize(name)} ${verb} due${last.dueOn === today ? " today" : ""}`,
+ detail: `${formatRange(range)}${flagged}.`,
+ severity: "due",
+ };
+}
diff --git a/app/src/lib/period-lock/use-lock-days.ts b/app/src/lib/period-lock/use-lock-days.ts
new file mode 100644
index 00000000..4afbf46a
--- /dev/null
+++ b/app/src/lib/period-lock/use-lock-days.ts
@@ -0,0 +1,200 @@
+import { useQuery } from "@tanstack/react-query";
+import { endOfWeek, startOfMonth, subMonths } from "date-fns";
+import { toast } from "sonner";
+import { timeTrackingMutations } from "@/lib/api/mutations/time-tracking";
+import { apiErrorToast } from "@/lib/api/errors";
+import {
+ timeTrackingQueries,
+ type AbsenceDayDefault,
+ type AbsenceEntry,
+ type TimeEntry,
+ type TimeEntryDayStatus,
+} from "@/lib/api/queries/time-tracking";
+import {
+ eachDate,
+ findDueLock,
+ formatRange,
+ hasOverlap,
+ joinNames,
+ listCheckpoints,
+ toIsoDate,
+ type LockDay,
+ type LockRange,
+} from "./lock-periods";
+
+function buildLockDays(input: {
+ range: LockRange;
+ today: string;
+ statuses: Array | undefined;
+ schedule: Array | undefined;
+ entries: Array;
+ absences: Array;
+}) {
+ const { today, statuses, schedule, entries, absences } = input;
+
+ return eachDate(input.range).map((date): LockDay => {
+ const isFuture = date > today;
+ const dayEntries = isFuture
+ ? []
+ : entries.filter((entry) => entry.date.slice(0, 10) === date);
+ const absenceHours = isFuture
+ ? 0
+ : absences
+ .filter((absence) => absence.date.slice(0, 10) === date)
+ .reduce((sum, absence) => sum + absence.hours, 0);
+ const reportedHours =
+ dayEntries.reduce((sum, entry) => sum + entry.hours, 0) + absenceHours;
+ const scheduledHours =
+ schedule?.find((day) => day.date === date)?.scheduledHours ?? 0;
+ // The provider leaves out days that were never locked.
+ const status = statuses?.find((day) => day.date === date)?.status ?? "open";
+
+ return {
+ date,
+ status,
+ reportedHours,
+ scheduledHours,
+ entries: dayEntries,
+ hasOverlap: hasOverlap(dayEntries),
+ isFuture,
+ isToday: date === today,
+ isIdle: reportedHours === 0 && scheduledHours === 0,
+ };
+ });
+}
+
+const UNDO_WINDOW_MS = 10_000;
+
+export function useLockDays(range: LockRange) {
+ const today = toIsoDate(new Date());
+ const { data: statuses } = useQuery(
+ timeTrackingQueries.timeEntryDayStatuses(range),
+ );
+ const { data: entries } = useQuery(timeTrackingQueries.timeEntries(range));
+ const { data: absences } = useQuery(timeTrackingQueries.absenceEntries(range));
+ const { data: schedule } = useQuery(
+ timeTrackingQueries.absenceDayDefaults(range),
+ );
+
+ const isLoaded = Boolean(statuses && entries && absences && schedule);
+ const days = buildLockDays({
+ range,
+ today,
+ statuses,
+ // Without it a loading day would read as having nothing scheduled or missing.
+ schedule: isLoaded ? schedule : undefined,
+ entries: entries ?? [],
+ absences: absences ?? [],
+ });
+
+ return { days, isLoaded };
+}
+
+function dueWindow(today: Date): LockRange {
+ return {
+ from: toIsoDate(startOfMonth(subMonths(today, 1))),
+ to: toIsoDate(endOfWeek(today, { weekStartsOn: 1 })),
+ };
+}
+
+/**
+ * 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.
+ */
+export function useDueLockAlert() {
+ const now = new Date();
+ const range = dueWindow(now);
+ const today = toIsoDate(now);
+ const { data: connection } = useQuery(timeTrackingQueries.connectionStatus());
+ const enabled = connection?.connected ?? false;
+ const { data: statuses } = useQuery({
+ ...timeTrackingQueries.timeEntryDayStatuses(range),
+ enabled,
+ });
+ const { data: schedule } = useQuery({
+ ...timeTrackingQueries.absenceDayDefaults(range),
+ enabled,
+ });
+
+ if (!statuses || !schedule) return null;
+
+ const days = buildLockDays({
+ range,
+ today,
+ statuses,
+ schedule,
+ entries: [],
+ absences: [],
+ });
+ return findDueLock(days, listCheckpoints(days, today), today);
+}
+
+/** From the start of last month, far enough back to find any overdue lock. */
+export function useDueLock() {
+ const today = new Date();
+ const { days, isLoaded } = useLockDays(dueWindow(today));
+ const todayKey = toIsoDate(today);
+ const checkpoints = listCheckpoints(days, todayKey);
+ const upcoming = checkpoints.filter((c) => c.state === "upcoming");
+ const nextDueOn = upcoming[0]?.dueOn;
+
+ return {
+ days,
+ isLoaded,
+ checkpoints,
+ due: isLoaded ? findDueLock(days, checkpoints, todayKey) : null,
+ next: nextDueOn
+ ? {
+ label: joinNames(upcoming.filter((c) => c.dueOn === nextDueOn)),
+ dueOn: nextDueOn,
+ }
+ : undefined,
+ };
+}
+
+export function useLockActions() {
+ const { mutateAsync: lockPeriod, isPending: isLocking } =
+ timeTrackingMutations.useLockTimeEntries({
+ onError: apiErrorToast("Failed to lock time"),
+ });
+ const { mutateAsync: unlockPeriod, isPending: isUnlocking } =
+ timeTrackingMutations.useUnlockTimeEntries({
+ onError: apiErrorToast("Failed to unlock time"),
+ });
+
+ const unlockRanges = async (ranges: Array) => {
+ const results = await Promise.all(ranges.map((r) => unlockPeriod(r)));
+ return results.flatMap((result) => result.stillLocked);
+ };
+
+ // A failed call has already shown its toast.
+ const ignoreFailure = () => undefined;
+
+ return {
+ isPending: isLocking || isUnlocking,
+ /** `openRanges` are the days this lock closes, which Undo reopens. */
+ lock: async (range: LockRange, openRanges: Array) => {
+ await lockPeriod(range);
+ toast.success(`Locked ${formatRange(range)}`, {
+ duration: UNDO_WINDOW_MS,
+ action: {
+ label: "Undo",
+ onClick: () => void unlockRanges(openRanges).catch(ignoreFailure),
+ },
+ });
+ },
+ unlock: (ranges: Array) =>
+ unlockRanges(ranges)
+ .then((stillLocked) => {
+ if (stillLocked.length > 0) {
+ toast.warning(
+ `${stillLocked.length} ${stillLocked.length === 1 ? "day stayed" : "days stayed"} locked. Kleer doesn't allow unlocking them.`,
+ );
+ } else {
+ toast.success(`Unlocked ${ranges.map(formatRange).join(", ")}`);
+ }
+ })
+ .catch(ignoreFailure),
+ };
+}
diff --git a/app/src/lib/time-tracking-provider.ts b/app/src/lib/time-tracking-provider.ts
index 7da31d70..4eceb72f 100644
--- a/app/src/lib/time-tracking-provider.ts
+++ b/app/src/lib/time-tracking-provider.ts
@@ -1,16 +1,3 @@
export const TIME_TRACKING_PROVIDER_URL =
import.meta.env.VITE_TIME_TRACKING_PROVIDER_URL?.trim() ||
"https://my.kleer.se/web/";
-
-export const KLEER_TIME_REPORTING_MONTH_URL =
- "https://my.kleer.se/web2/time-reporting/month";
-
-export function kleerTimeReportingWeekUrl({
- isoWeek,
- isoWeekYear,
-}: {
- isoWeek: number;
- isoWeekYear: number;
-}) {
- return `https://my.kleer.se/web2/time-reporting/week/${isoWeekYear}/${isoWeek}`;
-}
diff --git a/app/src/routes/_layout/time-tracking/-components/not-locked-alert.tsx b/app/src/routes/_layout/time-tracking/-components/not-locked-alert.tsx
deleted file mode 100644
index a46aa309..00000000
--- a/app/src/routes/_layout/time-tracking/-components/not-locked-alert.tsx
+++ /dev/null
@@ -1,149 +0,0 @@
-import { timeTrackingQueries, TimeEntry } from "@/lib/api/queries/time-tracking";
-import {
- KLEER_TIME_REPORTING_MONTH_URL,
- kleerTimeReportingWeekUrl,
-} from "@/lib/time-tracking-provider";
-import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
-import { Button } from "@/components/ui/button";
-import { useQuery } from "@tanstack/react-query";
-import {
- endOfMonth,
- endOfWeek,
- format,
- getISOWeek,
- getISOWeekYear,
- isAfter,
- isBefore,
- startOfMonth,
- startOfWeek,
- subDays,
- subMonths,
-} from "date-fns";
-import { AlertCircle } from "lucide-react";
-import { useMemo } from "react";
-import { atomWithStorage } from "jotai/utils";
-import { useAtom } from "jotai/react";
-
-const previousWeekAlertDisabledAtom = atomWithStorage(
- "time-tracking-previous-week-alert-disabled",
- false,
-);
-
-export const NotLockedAlert = () => {
- const [isPreviousWeekAlertDisabled, setIsPreviousWeekAlertDisabled] = useAtom(
- previousWeekAlertDisabledAtom,
- );
-
- const { data: timeEntries } = useQuery({
- ...timeTrackingQueries.timeEntries({
- from: format(startOfMonth(subMonths(new Date(), 1)), "yyyy-MM-dd"),
- to: format(new Date(), "yyyy-MM-dd"),
- }),
- staleTime: 1000 * 60 * 60 * 2, // 2 hours
- gcTime: 1000 * 60 * 60 * 24, // 24 hours
- });
-
- const { lastWeekLocked, lastMonthLocked } = useMemo(
- () => lockedStatus(timeEntries ?? []),
- [timeEntries],
- );
- const previousWeekDate = subDays(new Date(), 7);
- const previousWeekUrl = kleerTimeReportingWeekUrl({
- isoWeek: getISOWeek(previousWeekDate),
- isoWeekYear: getISOWeekYear(previousWeekDate),
- });
-
- if (lastWeekLocked && lastMonthLocked) {
- return null;
- }
-
- return (
-
- {!lastWeekLocked && !isPreviousWeekAlertDisabled && (
-
-
- Previous week unlocked
-
- Last week is still open in .
-
- setIsPreviousWeekAlertDisabled(true)}
- className="absolute right-3 top-3 h-auto p-0 !pl-0 text-xs text-muted-foreground hover:text-foreground"
- >
- Don't show again
-
-
- )}
- {!lastMonthLocked && (
-
-
- Previous month unlocked
-
- The previous month is still open in{" "}
- .
-
-
- )}
-
- );
-};
-
-function ProviderLink({ href }: { href: string }) {
- return (
-
- Kleer
-
- );
-}
-
-function lockedStatus(timeEntries: Array): {
- lastWeekLocked: boolean;
- lastMonthLocked: boolean;
-} {
- if (timeEntries.length === 0) {
- return { lastWeekLocked: true, lastMonthLocked: true };
- }
-
- const now = new Date();
- const startOfLastWeek = startOfWeek(subDays(now, 7), { weekStartsOn: 1 });
- const endOfLastWeek = endOfWeek(subDays(now, 7), { weekStartsOn: 1 });
- const startOfLastMonth = startOfMonth(subMonths(now, 1));
- const endOfLastMonth = endOfMonth(subMonths(now, 1));
-
- const entriesLastWeek: Array = [];
- const entriesLastMonth: Array = [];
- for (let i = 0; i < timeEntries.length; i++) {
- const entry = timeEntries[i];
-
- const entryDate = new Date(entry.date);
-
- if (
- isAfter(entryDate, startOfLastWeek) &&
- isBefore(entryDate, endOfLastWeek)
- ) {
- entriesLastWeek.push(entry);
- }
-
- if (
- isAfter(entryDate, startOfLastMonth) &&
- isBefore(entryDate, endOfLastMonth)
- ) {
- entriesLastMonth.push(entry);
- }
- }
-
- const lastWeekLocked = entriesLastWeek.every(
- (entry) => entry.status === "approved" || entry.status === "certified",
- );
- const lastMonthLocked = entriesLastMonth.every(
- (entry) => entry.status === "approved" || entry.status === "certified",
- );
-
- return { lastWeekLocked, lastMonthLocked };
-}
diff --git a/app/src/routes/_layout/time-tracking/-components/period-lock/lock-glyph.tsx b/app/src/routes/_layout/time-tracking/-components/period-lock/lock-glyph.tsx
new file mode 100644
index 00000000..a11a6128
--- /dev/null
+++ b/app/src/routes/_layout/time-tracking/-components/period-lock/lock-glyph.tsx
@@ -0,0 +1,42 @@
+import { motion, useReducedMotion } from "framer-motion";
+
+/** A padlock whose shackle swings open, and snaps down when the time is locked. */
+export function LockGlyph(props: { closed: boolean; className?: string }) {
+ const reduceMotion = useReducedMotion();
+
+ return (
+
+
+
+
+ );
+}
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
new file mode 100644
index 00000000..7258386f
--- /dev/null
+++ b/app/src/routes/_layout/time-tracking/-components/period-lock/sign-off.tsx
@@ -0,0 +1,746 @@
+import React from "react";
+import { addMonths, addWeeks, format, parseISO } from "date-fns";
+import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
+import {
+ AlertTriangle,
+ ChevronDown,
+ ChevronLeft,
+ ChevronRight,
+ LockIcon,
+ LockOpenIcon,
+ X,
+} from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { useTimeTrackingTimer } from "@/hooks/useTimeTrackingStore";
+import {
+ capitalize,
+ dayIssues,
+ daysIn,
+ formatRange,
+ hoursShort,
+ isLocked,
+ isoWeekOf,
+ lockedThrough,
+ lockLevel,
+ monthRange,
+ rangesWithStatus,
+ scopeAction,
+ splitAtMonthEnd,
+ summarize,
+ toIsoDate,
+ weekRange,
+ type LockDay,
+ type LockRange,
+} from "@/lib/period-lock/lock-periods";
+import {
+ useDueLock,
+ useLockActions,
+ useLockDays,
+} from "@/lib/period-lock/use-lock-days";
+import { cn, formatHoursMinutes } from "@/lib/utils";
+import { LockGlyph } from "./lock-glyph";
+
+const SETTLE_MS = 1700;
+
+type Kind = "due" | "week" | "month";
+type Selection = {
+ kind: Kind;
+ anchor: string;
+ part: LockRange | null;
+ name?: string;
+};
+type ShowDay = (date: string) => void;
+type BandTone = "neutral" | "due" | "overdue" | "month-overdue" | "done";
+
+const BAND_STYLES: Record = {
+ neutral: "bg-muted/30",
+ due: "bg-primary/10",
+ overdue: "bg-primary text-primary-foreground",
+ "month-overdue": "bg-destructive text-destructive-foreground",
+ done: "bg-[color-mix(in_srgb,var(--viz-3)_16%,transparent)]",
+};
+
+const BADGE_STYLES: Record = {
+ neutral: "bg-foreground/10",
+ due: "bg-primary/15 text-primary",
+ overdue: "bg-primary-foreground/15",
+ "month-overdue": "bg-destructive-foreground/15",
+ done: "bg-[color-mix(in_srgb,var(--viz-3)_22%,transparent)] text-[var(--viz-3)]",
+};
+
+/** The louder the state, the more times the badge calls out, then it stops. */
+const PULSES: Partial> = {
+ due: 1,
+ overdue: 2,
+ "month-overdue": 3,
+};
+
+function rangeOf({ kind, anchor, part }: Selection): LockRange {
+ if (part) return part;
+ return kind === "month" ? monthRange(anchor) : weekRange(anchor);
+}
+
+function step({ kind, anchor }: Selection, direction: 1 | -1): string {
+ const date = parseISO(anchor);
+ return toIsoDate(
+ kind === "month" ? addMonths(date, direction) : addWeeks(date, direction),
+ );
+}
+
+function titleOf(selection: Selection) {
+ if (selection.name) return selection.name;
+ if (selection.part) return formatRange(selection.part);
+ if (selection.kind === "month") {
+ return format(parseISO(selection.anchor), "MMMM yyyy");
+ }
+ return `Week ${isoWeekOf(selection.anchor)}`;
+}
+
+/** "Lock week 40", but "Lock October 2026". */
+function inSentence(scopeName: string) {
+ return scopeName.replace(/^Week/, "week");
+}
+
+const shortDate = (date: string) => format(parseISO(date), "EEE d MMM");
+
+/**
+ * Follows what's due until the person browses elsewhere. A lock pins the
+ * period so the rows can show it closing before moving on.
+ */
+function useSignOff() {
+ const today = toIsoDate(new Date());
+ const { days: dueDays, isLoaded, due, next } = useDueLock();
+ const [picked, setPicked] = React.useState(null);
+ const [isJustLocked, setIsJustLocked] = React.useState(false);
+ const { lock, unlock, isPending } = useLockActions();
+
+ const selection: Selection =
+ picked ??
+ (due
+ ? {
+ kind: "due",
+ anchor: due.to,
+ part: { from: due.from, to: due.to },
+ name: due.name,
+ }
+ : { kind: "week", anchor: today, part: null });
+ const range = rangeOf(selection);
+
+ const { days: rangeDays, isLoaded: isRangeLoaded } = useLockDays(range);
+ const isInDueWindow =
+ isLoaded &&
+ range.from >= dueDays[0].date &&
+ range.to <= dueDays[dueDays.length - 1].date;
+ const days = isInDueWindow ? daysIn(dueDays, range) : rangeDays;
+
+ const coversOneWeek = weekRange(range.from).from === weekRange(range.to).from;
+ const parts =
+ selection.kind === "week" ? splitAtMonthEnd(weekRange(selection.anchor)) : null;
+
+ return {
+ today,
+ isLoaded,
+ due,
+ next,
+ level: lockLevel(due, next?.dueOn, today),
+ lockedThrough: lockedThrough(dueDays),
+ selection,
+ range,
+ days,
+ isReady: isInDueWindow || isRangeLoaded,
+ summary: summarize(days),
+ action: scopeAction(days),
+ isFullyLocked:
+ days.length > 0 && days.every((day) => isLocked(day) || day.isIdle),
+ scopeName: titleOf(selection),
+ coversOneWeek,
+ parts,
+ isPending,
+ isJustLocked,
+ isBrowsing: picked !== null && !isJustLocked,
+ pickKind: (kind: "week" | "month") =>
+ setPicked({ kind, anchor: range.from, part: null }),
+ pickWeek: (anchor: string) =>
+ setPicked({ kind: "week", anchor, part: null }),
+ pickPart: (part: LockRange | null) =>
+ setPicked({ kind: "week", anchor: range.from, part }),
+ move: (direction: 1 | -1) =>
+ setPicked({
+ kind: selection.kind === "due" ? "week" : selection.kind,
+ anchor: step(selection, direction),
+ part: null,
+ }),
+ reset: () => setPicked(null),
+ lockIt: async (onSettled?: () => void) => {
+ setPicked(selection);
+ try {
+ await lock(range, rangesWithStatus(days, "open"));
+ } catch {
+ // The failure has its toast, and the period stays as it was.
+ setPicked(null);
+ return;
+ }
+ setIsJustLocked(true);
+ setTimeout(() => {
+ onSettled?.();
+ setIsJustLocked(false);
+ setPicked(null);
+ }, SETTLE_MS);
+ },
+ unlockIt: () => void unlock(rangesWithStatus(days, "approved")),
+ };
+}
+
+type Review = ReturnType;
+
+/**
+ * Sits beside the entries, so the review stays open while you fix what it
+ * flags. It is one line until a lock is due, then it asks louder the later it gets.
+ */
+export function SignOffDock({ onShowDay }: { onShowDay: ShowDay }) {
+ const review = useSignOff();
+ const [isOpenedEarly, setIsOpenedEarly] = React.useState(false);
+
+ if (!review.isLoaded) return
;
+
+ const showsCard =
+ review.due !== null ||
+ review.isBrowsing ||
+ review.isJustLocked ||
+ isOpenedEarly;
+ const close = () => {
+ setIsOpenedEarly(false);
+ review.reset();
+ };
+
+ return (
+
+
+ {showsCard ? (
+
+ setIsOpenedEarly(false)}
+ />
+
+ ) : (
+
+ setIsOpenedEarly(true)} />
+
+ )}
+
+
+ );
+}
+
+/** Nothing to do, so nothing to read: one line, and it never moves. */
+function QuietLine(props: { review: Review; onOpen: () => void }) {
+ const { review } = props;
+ const isSoon = review.level === "soon";
+
+ return (
+
+ {isSoon ? (
+
+
+
+ ) : (
+
+ )}
+
+ {isSoon && review.next
+ ? `${capitalize(review.next.label)} due tomorrow`
+ : review.lockedThrough
+ ? `Locked through ${shortDate(review.lockedThrough)}`
+ : "Time open"}
+
+ {!isSoon && review.next && (
+
+ Next {shortDate(review.next.dueOn)}
+
+ )}
+
+
+ );
+}
+
+function bandTone(review: Review): BandTone {
+ if (review.isJustLocked) return "done";
+ if (review.isBrowsing || !review.due) return "neutral";
+ return review.due.severity;
+}
+
+function ReviewCard(props: {
+ review: Review;
+ onClose: () => void;
+ onShowDay: ShowDay;
+ onLocked: () => void;
+}) {
+ const { review } = props;
+ const tone = bandTone(review);
+ const showsControls = tone === "neutral";
+ const isClosed = review.isJustLocked || review.isFullyLocked;
+ const title = review.isJustLocked
+ ? `${capitalize(inSentence(review.scopeName))} locked`
+ : tone === "neutral"
+ ? capitalize(review.scopeName)
+ : review.due?.title;
+ const detail = review.isJustLocked
+ ? "Nothing more to do here."
+ : tone === "neutral"
+ ? null
+ : review.due?.detail;
+
+ return (
+ <>
+
+
+
+
+ {title}
+
+ {detail &&
{detail}
}
+
+ {tone === "neutral" && (
+
+
+
+ )}
+
+
+
+ {showsControls &&
}
+
+
+
+
+
+ >
+ );
+}
+
+function LockBadge(props: { tone: BandTone; closed: boolean }) {
+ const reduceMotion = useReducedMotion();
+ const pulses = PULSES[props.tone] ?? 0;
+ const isLate = props.tone === "overdue" || props.tone === "month-overdue";
+
+ return (
+
+ {!reduceMotion && pulses > 0 && (
+
+ )}
+ {!reduceMotion && props.tone === "done" && (
+
+ )}
+
+
+
+
+ );
+}
+
+function PeriodControls({ review }: { review: Review }) {
+ return (
+
+ {(["week", "month"] as const).map((kind) => (
+ review.pickKind(kind)}
+ className={cn(
+ "capitalize underline-offset-4 transition-colors",
+ review.selection.kind === kind
+ ? "font-medium underline decoration-primary decoration-2"
+ : "text-muted-foreground hover:text-foreground",
+ )}
+ >
+ {kind}
+
+ ))}
+
+ review.move(-1)}
+ >
+
+
+ review.move(1)}
+ >
+
+
+
+ {review.parts?.map((part) => {
+ const isActive =
+ review.selection.part?.from === part.from &&
+ review.selection.part.to === part.to;
+ return (
+ review.pickPart(isActive ? null : part)}
+ className={cn(
+ "rounded-md px-2 py-0.5 text-xs transition-colors",
+ isActive
+ ? "bg-primary/15 text-foreground"
+ : "text-muted-foreground hover:bg-muted hover:text-foreground",
+ )}
+ >
+ {formatRange(part)}
+
+ );
+ })}
+
+ );
+}
+
+function ReviewRows(props: {
+ review: Review;
+ tone: BandTone;
+ onShowDay: ShowDay;
+}) {
+ const { review } = props;
+ // Weekends and holidays with nothing on them have nothing to review.
+ const days = review.days.filter((day) => !day.isIdle);
+
+ return (
+
+ {review.coversOneWeek ? (
+ days.map((day, index) => (
+ props.onShowDay(day.date)}
+ />
+ ))
+ ) : (
+
+ )}
+
+ );
+}
+
+function ReviewFooter(props: {
+ review: Review;
+ tone: BandTone;
+ onLocked: () => void;
+}) {
+ const { review, tone } = props;
+ const { state: timerState } = useTimeTrackingTimer();
+ const locksRunningTimer =
+ timerState === "running" &&
+ review.action === "lock" &&
+ review.today >= review.range.from &&
+ review.today <= review.range.to;
+ const difference =
+ review.summary.reportedHours - review.summary.scheduledHours;
+ const name = inSentence(review.scopeName);
+
+ if (review.isJustLocked) return null;
+
+ return (
+
+
+
+
+ {formatHoursMinutes(review.summary.reportedHours)}
+ {" "}
+ of{" "}
+
+ {formatHoursMinutes(review.summary.scheduledHours)}
+
+
+ 0.2
+ ? "text-[var(--viz-3)]"
+ : "text-muted-foreground",
+ )}
+ >
+ {difference > 0 ? "+" : ""}
+ {formatHoursMinutes(difference)}
+
+
+
+ {locksRunningTimer && (
+
+ Your timer is running. Save it first: a locked day can't take new
+ entries.
+
+ )}
+
+ {review.action === "lock" ? (
+
void review.lockIt(props.onLocked)}
+ >
+
+ {review.isPending ? "Locking..." : `Lock ${name}`}
+
+ ) : review.action === "unlock" ? (
+
+
+ Unlock {name}
+
+ ) : (
+
+ {review.isFullyLocked
+ ? "Locked, and it can't be unlocked here."
+ : "Nothing to lock in this period."}
+
+ )}
+
+ {tone !== "neutral" && (
+
review.pickKind("week")}
+ className="self-center text-xs text-muted-foreground underline-offset-4 transition-colors hover:text-foreground hover:underline"
+ >
+ Lock another period
+
+ )}
+
+ );
+}
+
+/** Colour says how the day went; the hatched gap is the time that's missing. */
+function DayBar(props: { day: LockDay; index: number; growsIn: boolean }) {
+ const { day } = props;
+ const reduceMotion = useReducedMotion();
+ const scale = Math.max(day.scheduledHours, 8) * 1.25;
+ const fill = Math.min(day.reportedHours / scale, 1) * 100;
+ const mark = (day.scheduledHours / scale) * 100;
+ const short = hoursShort(day);
+ const delay = props.index * 0.05;
+
+ return (
+
+ {short > 0 && (
+
+ )}
+ 0
+ ? "bg-primary"
+ : "bg-[var(--viz-3)]",
+ )}
+ initial={
+ props.growsIn && !reduceMotion
+ ? { width: 0 }
+ : { width: `${fill}%` }
+ }
+ animate={{ width: `${fill}%` }}
+ transition={{ delay, duration: 0.45, ease: "easeOut" }}
+ style={{ transitionDelay: `${props.index * 70}ms` }}
+ />
+ {day.scheduledHours > 0 && (
+
+ )}
+
+ );
+}
+
+function DayRow(props: {
+ day: LockDay;
+ index: number;
+ growsIn: boolean;
+ onShow: () => void;
+}) {
+ const { day } = props;
+ const issues = dayIssues(day);
+ const short = hoursShort(day);
+
+ return (
+ 0
+ ? issues.map((issue) => issue.text).join(", ")
+ : "Show this day"
+ }
+ onClick={props.onShow}
+ className="flex w-full items-center gap-2.5 rounded-md px-2 py-1 text-left text-sm outline-hidden transition-colors hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-ring"
+ >
+
+ {format(parseISO(day.date), "EEE d")}
+
+
+
+ {isLocked(day) ? (
+
+ ) : short > 0 ? (
+
+ -{formatHoursMinutes(short)}
+
+ ) : day.hasOverlap ? (
+
+ ) : day.isToday ? (
+ Today
+ ) : null}
+
+
+ {formatHoursMinutes(day.reportedHours)}
+
+
+ );
+}
+
+function WeekRows(props: {
+ days: Array;
+ onOpenWeek: (anchor: string) => void;
+}) {
+ const weekStarts = [
+ ...new Set(props.days.map((day) => weekRange(day.date).from)),
+ ];
+
+ return weekStarts.map((weekStart) => {
+ const inRange = daysIn(props.days, weekRange(weekStart));
+ const summary = summarize(inRange);
+ const first = inRange[0];
+ const isDone = inRange.every((day) => isLocked(day) || day.isIdle);
+
+ return (
+ props.onOpenWeek(first.date)}
+ className="flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left text-sm transition-colors hover:bg-muted/60"
+ >
+ Week {isoWeekOf(first.date)}
+
+ {formatRange({
+ from: first.date,
+ to: inRange[inRange.length - 1].date,
+ })}
+
+ {isDone ? (
+
+ ) : (
+ summary.issueDays > 0 && (
+
+ {summary.issueDays} to check
+
+ )
+ )}
+
+ {formatHoursMinutes(summary.reportedHours)}
+
+
+ );
+ });
+}
diff --git a/app/src/routes/_layout/time-tracking/-components/time-entries-list.tsx b/app/src/routes/_layout/time-tracking/-components/time-entries-list.tsx
index c640b73a..a67977a7 100644
--- a/app/src/routes/_layout/time-tracking/-components/time-entries-list.tsx
+++ b/app/src/routes/_layout/time-tracking/-components/time-entries-list.tsx
@@ -120,6 +120,8 @@ export function TimeEntriesList(props: {
return (
("week");
const [editingEntry, setEditingEntry] = useState(null);
@@ -609,13 +612,19 @@ export function TimelineView({
getInRangeDate(dateRange.from, dateRange.to),
);
- // Reset when dateRange changes
+ // Reset when dateRange changes, or show the day that was asked for
useEffect(() => {
- const target = getInRangeDate(dateRange.from, dateRange.to);
+ const isFocusInRange =
+ focusedDay &&
+ focusedDay.date >= dateRange.from &&
+ focusedDay.date <= dateRange.to;
+ const target = isFocusInRange
+ ? parseISO(focusedDay.date)
+ : getInRangeDate(dateRange.from, dateRange.to);
// eslint-disable-next-line react-hooks/set-state-in-effect
setCurrentWeekStart(startOfWeek(target, { weekStartsOn: WEEK_STARTS_ON }));
setSelectedDate(target);
- }, [dateRange.from, dateRange.to]);
+ }, [dateRange.from, dateRange.to, focusedDay]);
const colorMap = useMemo(
() => buildProjectColorMap(timeEntries),
diff --git a/app/src/routes/_layout/time-tracking/index.tsx b/app/src/routes/_layout/time-tracking/index.tsx
index c81a5d36..30ccd5f2 100644
--- a/app/src/routes/_layout/time-tracking/index.tsx
+++ b/app/src/routes/_layout/time-tracking/index.tsx
@@ -31,7 +31,6 @@ import {
TRY_CMD_K_NEXT_TIME_NOTE,
} from "@/lib/time-tracking-default-notes";
import { useTimeTrackingTimer } from "@/hooks/useTimeTrackingStore";
-import { NotLockedAlert } from "./-components/not-locked-alert";
import {
TimerIcon,
Plus,
@@ -49,6 +48,8 @@ import { TimelineView } from "./-components/timeline-view";
import { ReportAbsenceDialog } from "./-components/report-absence-dialog";
import { AbsencesPanel } from "./-components/absences-panel";
import { buildAbsenceSearchText } from "./-helpers/absence-display";
+import { SignOffDock } from "./-components/period-lock/sign-off";
+import { weekRange } from "@/lib/period-lock/lock-periods";
dayjs.extend(isoWeek);
@@ -81,6 +82,16 @@ export const Route = createFileRoute("/_layout/time-tracking/")({
component: TimeTrackingPage,
});
+/** The day appears once its week has loaded, so keep looking for a moment. */
+function scrollToDay(date: string, attempts = 15) {
+ const day = document.getElementById(`day-${date}`);
+ if (day) {
+ day.scrollIntoView({ behavior: "smooth", block: "start" });
+ } else if (attempts > 0) {
+ setTimeout(() => scrollToDay(date, attempts - 1), 200);
+ }
+}
+
function getCurrentWeekDateRange() {
const today = dayjs();
@@ -119,6 +130,14 @@ function TimeTrackingPage() {
const { data: user } = useQuery(userQueries.me());
const isAdmin = user?.roles.includes("Admin") ?? false;
+ const [focusedDay, setFocusedDay] = React.useState<{ date: string } | null>(
+ null,
+ );
+ const showDay = (date: string) => {
+ setDateRange(weekRange(date));
+ setFocusedDay({ date });
+ scrollToDay(date);
+ };
const { data: timeEntries, isLoading: isTimeEntriesLoading } = useQuery({
...timeTrackingQueries.timeEntries({
from: dateRange.from,
@@ -281,7 +300,9 @@ function TimeTrackingPage() {
{/* Main Content */}
-
+
+
+
{/* Controls Bar */}
@@ -362,6 +383,7 @@ function TimeTrackingPage() {
timeEntries={filteredTimeEntries}
absenceEntries={filteredAbsences}
dateRange={dateRange}
+ focusedDay={focusedDay}
/>
) : (
+
+
+
{!!timeEntries?.length && }
diff --git a/kleer/src/client.rs b/kleer/src/client.rs
index 0385d04d..d73a85b0 100644
--- a/kleer/src/client.rs
+++ b/kleer/src/client.rs
@@ -6,8 +6,8 @@ use time::Date;
use tracing::{field, Instrument};
use crate::types::{
- KleerActivityList, KleerClientProjectList, KleerEventList, KleerEventReadable,
- KleerEventRestrictionList, KleerEventWritable, KleerPayrollMonthReport,
+ KleerActivityList, KleerClientProjectList, KleerEventList, KleerEventPeriod,
+ KleerEventReadable, KleerEventRestrictionList, KleerEventWritable, KleerPayrollMonthReport,
KleerPayrollUserReadable, KleerSavedId, KleerScheduleMetadataList, KleerUserList, KleerUserMe,
};
@@ -153,6 +153,21 @@ impl KleerClient {
.await
}
+ pub async fn approve_events(
+ &self,
+ body: &KleerEventPeriod,
+ ) -> Result {
+ self.send_json(Method::POST, "event/approve", Some(body))
+ .await
+ }
+
+ /// Answers success even for days it refuses to unlock, so read the
+ /// statuses back to see which days opened.
+ pub async fn unlock_events(&self, body: &KleerEventPeriod) -> Result {
+ self.send_json(Method::POST, "event/unlock", Some(body))
+ .await
+ }
+
pub async fn create_event(
&self,
body: &KleerEventWritable,
diff --git a/kleer/src/types.rs b/kleer/src/types.rs
index b76cbfc5..80cb6b06 100644
--- a/kleer/src/types.rs
+++ b/kleer/src/types.rs
@@ -279,6 +279,17 @@ pub struct KleerEventWritable {
pub internal_comment: Option,
}
+/// A user's time over one calendar month, the longest range Kleer locks at once.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "kebab-case")]
+pub struct KleerEventPeriod {
+ pub user_id: KleerIdRef,
+ #[serde(with = "date_format")]
+ pub start_date: Date,
+ #[serde(with = "date_format")]
+ pub end_date: Date,
+}
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct KleerSavedId {
pub id: i64,
diff --git a/toki-api/src/adapters/outbound/kleer/mod.rs b/toki-api/src/adapters/outbound/kleer/mod.rs
index 442b0096..45cac0de 100644
--- a/toki-api/src/adapters/outbound/kleer/mod.rs
+++ b/toki-api/src/adapters/outbound/kleer/mod.rs
@@ -9,7 +9,7 @@ use std::{
use async_trait::async_trait;
use kleer::{
KleerActivityList, KleerActivityReadable, KleerClient, KleerClientProjectList,
- KleerClientProjectReadable, KleerCredentials, KleerError, KleerEventReadable,
+ KleerClientProjectReadable, KleerCredentials, KleerError, KleerEventPeriod, KleerEventReadable,
KleerEventRestrictionList, KleerEventWritable, KleerIdRef,
};
use moka::future::Cache;
@@ -538,6 +538,19 @@ impl KleerAdapter {
Ok(())
}
+ fn month_periods(&self, date_range: (Date, Date)) -> Vec {
+ month_ranges(date_range)
+ .into_iter()
+ .map(|(start_date, end_date)| KleerEventPeriod {
+ user_id: KleerIdRef {
+ id: self.target_user_id,
+ },
+ start_date,
+ end_date,
+ })
+ .collect()
+ }
+
fn to_domain_day_statuses(statuses: KleerEventRestrictionList) -> Vec {
statuses
.event_restriction_readables
@@ -828,6 +841,26 @@ impl TimeTrackingClient for KleerAdapter {
Ok(Self::to_domain_day_statuses(statuses))
}
+ async fn lock_time_entries(&self, date_range: (Date, Date)) -> Result<(), TimeTrackingError> {
+ for period in self.month_periods(date_range) {
+ self.client
+ .approve_events(&period)
+ .await
+ .map_err(map_kleer_error)?;
+ }
+ Ok(())
+ }
+
+ async fn unlock_time_entries(&self, date_range: (Date, Date)) -> Result<(), TimeTrackingError> {
+ for period in self.month_periods(date_range) {
+ self.client
+ .unlock_events(&period)
+ .await
+ .map_err(map_kleer_error)?;
+ }
+ Ok(())
+ }
+
async fn create_time_entry(
&self,
request: &CreateTimeEntryRequest,
@@ -1031,6 +1064,27 @@ impl TimeTrackingClient for KleerAdapter {
}
}
+/// Kleer refuses to lock or unlock a range that crosses a month end.
+fn month_ranges((from, to): (Date, Date)) -> Vec<(Date, Date)> {
+ let mut ranges = Vec::new();
+ let mut start = from;
+
+ while start <= to {
+ let last_of_month = start
+ .replace_day(start.month().length(start.year()))
+ .expect("a month's length is a valid day of it");
+ let end = last_of_month.min(to);
+ ranges.push((start, end));
+
+ match end.next_day() {
+ Some(next) => start = next,
+ None => break,
+ }
+ }
+
+ ranges
+}
+
fn map_kleer_error(error: KleerError) -> TimeTrackingError {
match error {
KleerError::NotFound => TimeTrackingError::TimerNotFound,
@@ -1158,6 +1212,24 @@ mod tests {
const TARGET_USER_ID: i64 = 987;
+ #[test]
+ fn month_ranges_split_at_every_month_end() {
+ let date = |month, day| Date::from_calendar_date(2026, month, day).unwrap();
+
+ assert_eq!(
+ month_ranges((date(Month::October, 26), date(Month::October, 31))),
+ [(date(Month::October, 26), date(Month::October, 31))]
+ );
+ assert_eq!(
+ month_ranges((date(Month::September, 28), date(Month::November, 1))),
+ [
+ (date(Month::September, 28), date(Month::September, 30)),
+ (date(Month::October, 1), date(Month::October, 31)),
+ (date(Month::November, 1), date(Month::November, 1)),
+ ]
+ );
+ }
+
fn activity_assignment(activity_id: i64) -> KleerProjectActivityAssignment {
KleerProjectActivityAssignment {
activity: KleerIdRef { id: activity_id },
diff --git a/toki-api/src/domain/ports/inbound/time_tracking.rs b/toki-api/src/domain/ports/inbound/time_tracking.rs
index d8e84a54..5bb23e63 100644
--- a/toki-api/src/domain/ports/inbound/time_tracking.rs
+++ b/toki-api/src/domain/ports/inbound/time_tracking.rs
@@ -106,6 +106,15 @@ pub trait TimeTrackingService: Send + Sync + 'static {
date_range: (Date, Date),
) -> Result, TimeTrackingError>;
+ /// Lock a date range against further changes.
+ async fn lock_time_entries(&self, date_range: (Date, Date)) -> Result<(), TimeTrackingError>;
+
+ /// Unlock a date range, returning the days that stayed locked.
+ async fn unlock_time_entries(
+ &self,
+ date_range: (Date, Date),
+ ) -> Result, TimeTrackingError>;
+
/// Create a new time entry.
///
/// Creates the entry in the provider and persists to local timer history.
diff --git a/toki-api/src/domain/ports/outbound/time_tracking.rs b/toki-api/src/domain/ports/outbound/time_tracking.rs
index e0fe60d1..9f804457 100644
--- a/toki-api/src/domain/ports/outbound/time_tracking.rs
+++ b/toki-api/src/domain/ports/outbound/time_tracking.rs
@@ -61,6 +61,17 @@ pub trait TimeTrackingClient: Send + Sync + 'static {
date_range: (Date, Date),
) -> Result, TimeTrackingError>;
+ /// Lock a date range against further changes.
+ ///
+ /// Days that are already locked stay as they are.
+ async fn lock_time_entries(&self, date_range: (Date, Date)) -> Result<(), TimeTrackingError>;
+
+ /// Unlock a date range.
+ ///
+ /// The provider may leave days it refuses to unlock locked without an
+ /// error; read the day statuses to see them.
+ async fn unlock_time_entries(&self, date_range: (Date, Date)) -> Result<(), TimeTrackingError>;
+
/// Create a new time entry.
///
/// Returns the registration ID from the provider.
diff --git a/toki-api/src/domain/services/time_tracking.rs b/toki-api/src/domain/services/time_tracking.rs
index 15074542..26ab4cd7 100644
--- a/toki-api/src/domain/services/time_tracking.rs
+++ b/toki-api/src/domain/services/time_tracking.rs
@@ -364,6 +364,26 @@ impl TimeTrackingService
self.client.get_time_entry_day_statuses(date_range).await
}
+ async fn lock_time_entries(&self, date_range: (Date, Date)) -> Result<(), TimeTrackingError> {
+ ensure_ordered_range(date_range)?;
+ self.client.lock_time_entries(date_range).await
+ }
+
+ async fn unlock_time_entries(
+ &self,
+ date_range: (Date, Date),
+ ) -> Result, TimeTrackingError> {
+ ensure_ordered_range(date_range)?;
+ self.client.unlock_time_entries(date_range).await?;
+
+ let statuses = self.client.get_time_entry_day_statuses(date_range).await?;
+ Ok(statuses
+ .into_iter()
+ .filter(|day| day.status != TimeEntryStatus::Open)
+ .map(|day| day.date)
+ .collect())
+ }
+
async fn create_time_entry(
&self,
user_id: &UserId,
@@ -506,6 +526,16 @@ impl TimeTrackingService
}
}
+/// A provider may read a reversed range as its mirror image, so stop it here.
+fn ensure_ordered_range((from, to): (Date, Date)) -> Result<(), TimeTrackingError> {
+ if from > to {
+ return Err(TimeTrackingError::InvalidInput(
+ "The start date must not be after the end date".to_string(),
+ ));
+ }
+ Ok(())
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -520,6 +550,8 @@ mod tests {
absence_children: Vec,
settled_flex: Option,
time_info_ranges: Mutex>,
+ day_statuses: Vec,
+ changed_ranges: Mutex>,
}
#[async_trait]
@@ -561,7 +593,23 @@ mod tests {
&self,
_date_range: (Date, Date),
) -> Result, TimeTrackingError> {
- unused_mock_method()
+ Ok(self.day_statuses.clone())
+ }
+
+ async fn lock_time_entries(
+ &self,
+ date_range: (Date, Date),
+ ) -> Result<(), TimeTrackingError> {
+ self.changed_ranges.lock().unwrap().push(date_range);
+ Ok(())
+ }
+
+ async fn unlock_time_entries(
+ &self,
+ date_range: (Date, Date),
+ ) -> Result<(), TimeTrackingError> {
+ self.changed_ranges.lock().unwrap().push(date_range);
+ Ok(())
}
async fn create_time_entry(
@@ -738,6 +786,68 @@ mod tests {
assert_eq!(saved_entry.end_time, Some(provider_request.end_time));
}
+ #[tokio::test]
+ async fn a_reversed_range_never_reaches_the_provider() {
+ let date = |day| Date::from_calendar_date(2026, time::Month::October, day).unwrap();
+ let client = Arc::new(MockTimeTrackingClient::default());
+ let repo = Arc::new(MockTimerHistoryRepository {
+ active_timer: Mutex::new(None),
+ saved_end_time: Mutex::new(None),
+ });
+ let service = TimeTrackingServiceImpl::new(client.clone(), repo);
+ let reversed = (date(30), date(26));
+
+ assert!(matches!(
+ service.lock_time_entries(reversed).await,
+ Err(TimeTrackingError::InvalidInput(_))
+ ));
+ assert!(matches!(
+ service.unlock_time_entries(reversed).await,
+ Err(TimeTrackingError::InvalidInput(_))
+ ));
+ assert!(client.changed_ranges.lock().unwrap().is_empty());
+
+ service
+ .lock_time_entries((date(26), date(26)))
+ .await
+ .unwrap();
+ assert_eq!(client.changed_ranges.lock().unwrap().len(), 1);
+ }
+
+ #[tokio::test]
+ async fn unlocking_reports_the_days_the_provider_kept_locked() {
+ let date = |day| Date::from_calendar_date(2026, time::Month::October, day).unwrap();
+ let client = Arc::new(MockTimeTrackingClient {
+ day_statuses: vec![
+ TimeEntryDayStatus {
+ date: date(22),
+ status: TimeEntryStatus::Certified,
+ },
+ TimeEntryDayStatus {
+ date: date(23),
+ status: TimeEntryStatus::Approved,
+ },
+ TimeEntryDayStatus {
+ date: date(26),
+ status: TimeEntryStatus::Open,
+ },
+ ],
+ ..Default::default()
+ });
+ let repo = Arc::new(MockTimerHistoryRepository {
+ active_timer: Mutex::new(None),
+ saved_end_time: Mutex::new(None),
+ });
+ let service = TimeTrackingServiceImpl::new(client.clone(), repo);
+
+ let still_locked = service
+ .unlock_time_entries((date(22), date(26)))
+ .await
+ .unwrap();
+
+ assert_eq!(still_locked, [date(22), date(23)]);
+ }
+
#[tokio::test]
async fn flex_balance_estimates_the_days_after_the_settled_balance() {
let date = |month, day| Date::from_calendar_date(2026, month, day).unwrap();
diff --git a/toki-api/src/routes/time_tracking/calendar.rs b/toki-api/src/routes/time_tracking/calendar.rs
index 0e8e51c1..de91fb89 100644
--- a/toki-api/src/routes/time_tracking/calendar.rs
+++ b/toki-api/src/routes/time_tracking/calendar.rs
@@ -3,7 +3,7 @@ use axum::{
http::StatusCode,
Json,
};
-use serde::Deserialize;
+use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use crate::{
@@ -204,6 +204,56 @@ pub async fn get_time_entry_day_statuses(
Ok(Json(statuses.into_iter().map(Into::into).collect()))
}
+#[derive(Debug, Deserialize)]
+pub struct TimeEntryPeriodPayload {
+ from: String,
+ to: String,
+}
+
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct UnlockTimeEntriesResponse {
+ still_locked: Vec,
+}
+
+pub async fn lock_time_entries(
+ user: AuthUser,
+ State(app_state): State,
+ Json(payload): Json,
+) -> Result {
+ record_user_id(user.id);
+ let service = app_state
+ .time_tracking_factory
+ .create_service(user.id)
+ .await?;
+
+ let from = parse_date(&payload.from)?;
+ let to = parse_date(&payload.to)?;
+ service.lock_time_entries((from, to)).await?;
+
+ Ok(StatusCode::NO_CONTENT)
+}
+
+pub async fn unlock_time_entries(
+ user: AuthUser,
+ State(app_state): State,
+ Json(payload): Json,
+) -> Result, ApiError> {
+ record_user_id(user.id);
+ let service = app_state
+ .time_tracking_factory
+ .create_service(user.id)
+ .await?;
+
+ let from = parse_date(&payload.from)?;
+ let to = parse_date(&payload.to)?;
+ let still_locked = service.unlock_time_entries((from, to)).await?;
+
+ Ok(Json(UnlockTimeEntriesResponse {
+ still_locked: still_locked.iter().map(ToString::to_string).collect(),
+ }))
+}
+
// ============================================================================
// Time Entry Mutations (Create, Edit, Delete)
// ============================================================================
diff --git a/toki-api/src/routes/time_tracking/mod.rs b/toki-api/src/routes/time_tracking/mod.rs
index ec58e1a5..44879d56 100644
--- a/toki-api/src/routes/time_tracking/mod.rs
+++ b/toki-api/src/routes/time_tracking/mod.rs
@@ -6,7 +6,7 @@ pub(crate) mod projects;
pub(crate) mod timer;
use axum::{
- routing::{get, put},
+ routing::{get, post, put},
Router,
};
@@ -43,6 +43,8 @@ pub fn router() -> Router {
"/time-entry-day-statuses",
get(calendar::get_time_entry_day_statuses),
)
+ .route("/lock", post(calendar::lock_time_entries))
+ .route("/unlock", post(calendar::unlock_time_entries))
.route(
"/time-entries",
get(calendar::get_time_entries)