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
14 changes: 14 additions & 0 deletions .docs/kleer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
Expand All @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

62 changes: 58 additions & 4 deletions app/src/components/side-nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(),
});
Expand Down Expand Up @@ -126,7 +162,7 @@ export function SideNavWrapper({ children }: { children: React.ReactNode }) {
<div className="mx-2 h-6 w-px bg-border/50 md:mx-0 md:my-3 md:h-px md:w-8" />

{/* Navigation items */}
<Nav links={menuItems} />
<Nav links={menuItems} dueLock={dueLock} />

{/* Theme switcher - inline on mobile */}
<div className="flex h-full items-center justify-center md:hidden">
Expand All @@ -153,7 +189,9 @@ export function SideNavWrapper({ children }: { children: React.ReactNode }) {

function Nav({
links,
dueLock,
}: {
dueLock: DueLock | null;
links: readonly {
title: string;
label?: string;
Expand All @@ -170,7 +208,8 @@ function Nav({
<NavLink
key={index}
title={link.title}
label={link.label}
label={link.to === "/time-tracking" ? dueLock?.title : link.label}
alert={link.to === "/time-tracking" ? dueLock?.severity : undefined}
link={{
icon: link.icon,
to: link.to,
Expand All @@ -186,10 +225,12 @@ function Nav({
function NavLink({
title,
label,
alert,
link,
}: {
title: string;
label?: string;
alert?: DueLock["severity"];
link: {
icon: LucideIcon;
to: UsedLink;
Expand Down Expand Up @@ -218,7 +259,20 @@ function NavLink({
}}
>
<link.icon className="h-5 w-5 transition-transform duration-300 group-hover:scale-110" />
<span className="sr-only">{title}</span>
<span className="sr-only">
{title}
{alert ? `, ${label}` : ""}
</span>
{alert && (
<span
className={cn(
"absolute right-1.5 top-1.5 h-2.5 w-2.5 rounded-full ring-2 ring-background",
alert === "month-overdue" ? "bg-destructive" : "bg-primary",
)}
>
<span className="absolute inset-0 rounded-full bg-inherit motion-safe:animate-[ping_1.2s_ease-out_2]" />
</span>
)}
</Link>
</TooltipTrigger>
<TooltipContent
Expand Down
49 changes: 49 additions & 0 deletions app/src/lib/api/mutations/time-tracking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ export const timeTrackingMutations = {
useCreateProjectRegistration,
useCreateAbsences,
useDeleteAbsence,
useLockTimeEntries,
useUnlockTimeEntries,
useImportKleerUsers,
useLinkKleerUsersByEmail,
useUpsertKleerUserLink,
Expand Down Expand Up @@ -588,6 +590,53 @@ function useCreateAbsences(
});
}

type TimeEntryPeriod = { from: string; to: string };
type UnlockTimeEntriesResponse = { stillLocked: Array<string> };

function refreshLockedTime(queryClient: ReturnType<typeof useQueryClient>) {
queryClient.invalidateQueries({
queryKey: timeTrackingQueries.timeEntriesBaseKey,
});
queryClient.invalidateQueries({
queryKey: timeTrackingQueries.timeEntryDayStatusesBaseKey,
});
}

function useLockTimeEntries(
options?: DefaultMutationOptions<TimeEntryPeriod, void>,
) {
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<TimeEntryPeriod, UnlockTimeEntriesResponse>,
) {
const queryClient = useQueryClient();

return useMutation({
mutationFn: async (body: TimeEntryPeriod) =>
api
.post("time-tracking/unlock", { json: body })
.json<UnlockTimeEntriesResponse>(),
...options,
onSettled: (...args) => {
refreshLockedTime(queryClient);
return options?.onSettled?.(...args);
},
});
}

function useDeleteAbsence(
options?: DefaultMutationOptions<DeleteAbsencePayload>,
) {
Expand Down
95 changes: 95 additions & 0 deletions app/src/lib/period-lock/lock-periods.test.js
Original file line number Diff line number Diff line change
@@ -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" },
]);
});
Loading
Loading