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
25 changes: 25 additions & 0 deletions app/src/lib/period-lock/lock-periods.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
listCheckpoints,
lockLevel,
rangesWithStatus,
weekParts,
workweekRange,
} from "./lock-periods.ts";

function workdays(from, to, today, lockedThrough) {
Expand Down Expand Up @@ -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" },
]);
});
});
19 changes: 19 additions & 0 deletions app/src/lib/period-lock/lock-periods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -80,6 +86,19 @@ export function splitAtMonthEnd(range: LockRange): Array<LockRange> | 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<LockRange> {
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. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,19 @@ import {
formatRange,
hoursShort,
isLocked,
isSameRange,
isoWeekOf,
lockedThrough,
lockLevel,
monthRange,
needsLock,
rangesWithStatus,
scopeAction,
splitAtMonthEnd,
summarize,
toIsoDate,
weekParts,
weekRange,
workweekRange,
type LockDay,
type LockRange,
} from "@/lib/period-lock/lock-periods";
Expand All @@ -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 = {
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -449,27 +461,31 @@ function PeriodControls({ review }: { review: Review }) {
<ChevronRight className="h-4 w-4" />
</Button>
</span>
{review.parts?.map((part) => {
const isActive =
review.selection.part?.from === part.from &&
review.selection.part.to === part.to;
return (
<button
key={part.from}
type="button"
aria-pressed={isActive}
onClick={() => 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)}
</button>
);
})}
{review.parts.length > 0 && (
// The chips wrap as one group, never one stray chip on its own line.
<span className="flex gap-1">
{review.parts.map(({ range, label }) => {
const isActive = isSameRange(review.selection.part, range);
return (
<button
key={`${range.from}-${range.to}`}
type="button"
aria-pressed={isActive}
title={formatRange(range)}
onClick={() => review.pickPart(isActive ? null : range)}
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",
)}
>
{label}
</button>
);
})}
</span>
)}
</div>
);
}
Expand Down
Loading