Skip to content

Commit 22dde54

Browse files
returnsvoidjanetJanetclaude
authored
feat(collegemap): add the shared break calendar (#387)
* feat(collegemap): add the shared break calendar Everyone in the group enters their college break dates and the calendar shows the stretches where the same people are all off at once, so a trip can be planned against one view instead of five group chats. The date engine keeps every date as a `YYYY-MM-DD` calendar day and does its arithmetic in integer day numbers, so no timezone can move a break by a day. The overlap engine sweeps range boundaries rather than counting, so it can name who is free in each window and not just how many. The code was written in a standalone checkout that was never pushed anywhere; this is its first appearance in version control. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CeFLppwuNj9iQhr6HsWsxu * refactor(collegemap): keep the break engine's internals internal weekdayOf, formatShort and computeWindows were exported for their specs and for nothing else, which `knip --strict` was right to call unused. They are unexported now, and their specs reach the same behaviour through the module's real surface: weekdayOf -> the column buildMonthView files a date under, checked against an independent UTC oracle rather than against the module's own arithmetic formatShort -> formatRange over a one-day range inside its own year computeWindows -> the windows buildReport surfaces, rejoined into date order Two floors said the same thing in two places, and only the outer one was observable: computeWindows filtered to `minFree` while buildReport re-filtered every window to at least two people free. Deciding which windows are worth showing is the report's job, so the inner copy and its parameter are gone and the rule is asserted where it lives. Same output; the brute-force cross-check and the seeded end-to-end render are unchanged. 68 tests / 151 assertions, up from 66 / 147. Every behaviour a mutation could reach before still turns the suite red, except the segment-gluing branch in computeWindows, which no mutation has ever reached in either version — 40,000 randomised groups never enter it. Left in place and flagged rather than deleted on the strength of a fuzz. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CeFLppwuNj9iQhr6HsWsxu --------- Co-authored-by: Janet <janet@petalcat.dev> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6d7a1f9 commit 22dde54

14 files changed

Lines changed: 3118 additions & 2 deletions

File tree

apps/collegemap/package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@
1313
"db:studio": "drizzle-kit studio",
1414
"dev": "vite dev",
1515
"prepare": "svelte-kit sync || echo ''",
16-
"preview": "vite preview"
16+
"preview": "vite preview",
17+
"test": "vitest run"
1718
},
1819
"dependencies": {
1920
"@libsql/client": "catalog:prod",
21+
"@lucide/svelte": "catalog:prod",
2022
"@sveltejs/kit": "catalog:prod",
2123
"@types/leaflet": "catalog:types",
2224
"drizzle-orm": "catalog:prod",
@@ -37,6 +39,7 @@
3739
"drizzle-kit": "catalog:dev",
3840
"svelte-check": "catalog:dev",
3941
"typescript": "catalog:dev",
40-
"vite": "catalog:dev"
42+
"vite": "catalog:dev",
43+
"vitest": "catalog:dev"
4144
}
4245
}
Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import {
4+
buildMonthView,
5+
pickInitialMonth,
6+
shiftMonth,
7+
toParticipants,
8+
WEEKDAY_LABELS,
9+
} from "./calendar";
10+
import { toDay } from "./dates";
11+
import type { Participant } from "./overlap";
12+
13+
const r = (start: string, end: string) => ({ start: toDay(start), end: toDay(end) });
14+
const cells = (view: ReturnType<typeof buildMonthView>) => view.weeks.flat();
15+
const cell = (view: ReturnType<typeof buildMonthView>, iso: string) => {
16+
const found = cells(view).find((c) => c.iso === iso);
17+
if (!found) throw new Error(`no cell for ${iso}`);
18+
return found;
19+
};
20+
21+
/**
22+
* The weekday of a calendar date, worked out without the module under test.
23+
*
24+
* `Date` is safe here only because the string is pinned to UTC midnight and read back through
25+
* `getUTCDay`. This is the independent oracle the grid gets checked against, so it deliberately
26+
* shares no arithmetic with it.
27+
*/
28+
function utcWeekday(iso: string): number {
29+
return new Date(`${iso}T00:00:00Z`).getUTCDay();
30+
}
31+
32+
/**
33+
* The column the month grid files a date under.
34+
*
35+
* Every row runs Sunday to Saturday, so a cell's index inside its week is the weekday the calendar
36+
* believes it to be. That column is where the module's weekday arithmetic becomes something a
37+
* person can see, which makes it the place to assert on it.
38+
*/
39+
function gridColumn(iso: string): number {
40+
for (const week of buildMonthView([], iso).weeks) {
41+
const column = week.findIndex((c) => c.iso === iso);
42+
if (column !== -1) return column;
43+
}
44+
throw new Error(`${iso} is missing from its own month grid`);
45+
}
46+
47+
describe("the weekday a date lands on", () => {
48+
it("anchors on the epoch, which was a Thursday", () => {
49+
expect(WEEKDAY_LABELS[gridColumn("1970-01-01")]).toBe("Thu");
50+
});
51+
52+
it("agrees with the calendar on real dates", () => {
53+
expect(WEEKDAY_LABELS[gridColumn("2026-12-01")]).toBe("Tue");
54+
expect(WEEKDAY_LABELS[gridColumn("2026-12-25")]).toBe("Fri");
55+
expect(WEEKDAY_LABELS[gridColumn("2027-01-01")]).toBe("Fri");
56+
expect(WEEKDAY_LABELS[gridColumn("2028-02-29")]).toBe("Tue");
57+
});
58+
59+
it("files every cell of a month under its real weekday", () => {
60+
for (const week of buildMonthView([], "2026-12-01").weeks) {
61+
for (const [column, dayCell] of week.entries()) {
62+
expect(column).toBe(utcWeekday(dayCell.iso));
63+
expect(dayCell.isWeekend).toBe(column === 0 || column === 6);
64+
}
65+
}
66+
});
67+
});
68+
69+
describe("buildMonthView grid shape", () => {
70+
const view = buildMonthView([], "2026-12-01");
71+
72+
it("starts on a Sunday and ends on a Saturday", () => {
73+
const flat = cells(view);
74+
expect(WEEKDAY_LABELS[utcWeekday(flat[0].iso)]).toBe("Sun");
75+
expect(WEEKDAY_LABELS[utcWeekday(flat[flat.length - 1].iso)]).toBe("Sat");
76+
});
77+
78+
it("pads with the neighbouring months so every row has seven cells", () => {
79+
expect(view.weeks.every((w) => w.length === 7)).toBe(true);
80+
expect(cells(view)).toHaveLength(35);
81+
expect(cells(view)[0].iso).toBe("2026-11-29");
82+
expect(cells(view)[34].iso).toBe("2027-01-02");
83+
});
84+
85+
it("flags padding days as out of month and keeps all 31 real days", () => {
86+
expect(cells(view).filter((c) => c.inMonth)).toHaveLength(31);
87+
expect(cell(view, "2026-11-30").inMonth).toBe(false);
88+
expect(cell(view, "2027-01-01").inMonth).toBe(false);
89+
expect(cell(view, "2026-12-01").inMonth).toBe(true);
90+
});
91+
92+
it("accepts any date inside the month, not just the first", () => {
93+
const mid = buildMonthView([], "2026-12-19");
94+
expect(mid.monthIso).toBe("2026-12-01");
95+
expect(mid.label).toBe("December 2026");
96+
});
97+
98+
it("gets February right in a leap year and a common year", () => {
99+
expect(cells(buildMonthView([], "2028-02-01")).filter((c) => c.inMonth)).toHaveLength(29);
100+
expect(cells(buildMonthView([], "2027-02-01")).filter((c) => c.inMonth)).toHaveLength(28);
101+
});
102+
103+
it("marks today, and only today", () => {
104+
const v = buildMonthView([], "2026-12-01", { todayIso: "2026-12-19" });
105+
expect(
106+
cells(v)
107+
.filter((c) => c.isToday)
108+
.map((c) => c.iso),
109+
).toEqual(["2026-12-19"]);
110+
});
111+
});
112+
113+
describe("who is free on a day", () => {
114+
const two: Participant[] = [
115+
{ id: "a", ranges: [r("2026-12-19", "2027-01-04")] },
116+
{ id: "b", ranges: [r("2026-12-24", "2026-12-26")] },
117+
];
118+
119+
it("puts a person on every day of their break and no others", () => {
120+
const v = buildMonthView(two, "2026-12-01");
121+
expect(cell(v, "2026-12-18").freeIds).toEqual([]);
122+
expect(cell(v, "2026-12-19").freeIds).toEqual(["a"]);
123+
expect(cell(v, "2026-12-24").freeIds).toEqual(["a", "b"]);
124+
expect(cell(v, "2026-12-26").freeIds).toEqual(["a", "b"]);
125+
expect(cell(v, "2026-12-27").freeIds).toEqual(["a"]);
126+
});
127+
128+
it("marks all-free only on the days everyone is actually off", () => {
129+
const v = buildMonthView(two, "2026-12-01");
130+
expect(
131+
cells(v)
132+
.filter((c) => c.allFree)
133+
.map((c) => c.iso),
134+
).toEqual(["2026-12-24", "2026-12-25", "2026-12-26"]);
135+
expect(v.allFreeDays).toBe(3);
136+
});
137+
138+
it("renders a single-day break as exactly one day", () => {
139+
const v = buildMonthView(
140+
[
141+
{ id: "a", ranges: [r("2026-12-25", "2026-12-25")] },
142+
{ id: "b", ranges: [r("2026-12-25", "2026-12-25")] },
143+
],
144+
"2026-12-01",
145+
);
146+
expect(
147+
cells(v)
148+
.filter((c) => c.freeCount > 0)
149+
.map((c) => c.iso),
150+
).toEqual(["2026-12-25"]);
151+
expect(cell(v, "2026-12-25").allFree).toBe(true);
152+
});
153+
154+
it("carries a New Year crossing across both month views without dropping a day", () => {
155+
const nye: Participant[] = [
156+
{ id: "a", ranges: [r("2026-12-30", "2027-01-02")] },
157+
{ id: "b", ranges: [r("2026-12-30", "2027-01-02")] },
158+
];
159+
const dec = buildMonthView(nye, "2026-12-01");
160+
const jan = buildMonthView(nye, "2027-01-01");
161+
expect(cell(dec, "2026-12-31").allFree).toBe(true);
162+
expect(cell(jan, "2027-01-01").allFree).toBe(true);
163+
expect(cell(jan, "2027-01-02").allFree).toBe(true);
164+
expect(cell(jan, "2027-01-03").allFree).toBe(false);
165+
// December's own view sees Jan 1 as padding, and must agree about it.
166+
expect(cell(dec, "2027-01-01").allFree).toBe(true);
167+
expect(cell(dec, "2027-01-01").inMonth).toBe(false);
168+
});
169+
170+
it("excludes people who have entered nothing from the denominator", () => {
171+
const withSilent: Participant[] = [...two, { id: "quiet", ranges: [] }];
172+
const v = buildMonthView(withSilent, "2026-12-01");
173+
expect(v.countedIds).toEqual(["a", "b"]);
174+
expect(cell(v, "2026-12-25").allFree).toBe(true);
175+
});
176+
177+
it('never calls one lone person "everyone"', () => {
178+
const solo = buildMonthView(
179+
[{ id: "a", ranges: [r("2026-12-19", "2026-12-28")] }],
180+
"2026-12-01",
181+
);
182+
expect(solo.allFreeDays).toBe(0);
183+
expect(cell(solo, "2026-12-20").freeCount).toBe(1);
184+
expect(cell(solo, "2026-12-20").allFree).toBe(false);
185+
});
186+
187+
it("reports nothing at all when nobody has entered anything", () => {
188+
const empty = buildMonthView([{ id: "a", ranges: [] }], "2026-12-01");
189+
expect(empty.countedIds).toEqual([]);
190+
expect(empty.allFreeDays).toBe(0);
191+
expect(cells(empty).every((c) => c.freeCount === 0)).toBe(true);
192+
});
193+
194+
it("merges one person's touching breaks so the gap day is not a false negative", () => {
195+
const v = buildMonthView(
196+
[
197+
{ id: "a", ranges: [r("2026-12-19", "2026-12-24"), r("2026-12-25", "2026-12-31")] },
198+
{ id: "b", ranges: [r("2026-12-19", "2026-12-31")] },
199+
],
200+
"2026-12-01",
201+
);
202+
expect(cell(v, "2026-12-24").allFree).toBe(true);
203+
expect(cell(v, "2026-12-25").allFree).toBe(true);
204+
});
205+
206+
it("is not moved by the process timezone", () => {
207+
const people: Participant[] = [
208+
{ id: "a", ranges: [r("2026-12-19", "2026-12-19")] },
209+
{ id: "b", ranges: [r("2026-12-19", "2026-12-19")] },
210+
];
211+
const original = process.env.TZ;
212+
const seen: string[] = [];
213+
const naive: number[] = [];
214+
try {
215+
for (const tz of ["UTC", "America/Los_Angeles", "Pacific/Kiritimati", "Asia/Kolkata"]) {
216+
process.env.TZ = tz;
217+
const v = buildMonthView(people, "2026-12-01", { todayIso: "2026-12-19" });
218+
seen.push(
219+
cells(v)
220+
.filter((c) => c.allFree)
221+
.map((c) => c.iso)
222+
.join(","),
223+
);
224+
// Positive control: if this does not vary, the TZ switch is inert and
225+
// the assertion below would pass for the wrong reason.
226+
naive.push(new Date("2026-12-19").getDate());
227+
}
228+
} finally {
229+
process.env.TZ = original;
230+
}
231+
expect(new Set(naive).size).toBeGreaterThan(1);
232+
expect(new Set(seen)).toEqual(new Set(["2026-12-19"]));
233+
});
234+
});
235+
236+
describe("shiftMonth", () => {
237+
it("walks forward and backward over year boundaries", () => {
238+
expect(shiftMonth("2026-12-01", 1)).toBe("2027-01-01");
239+
expect(shiftMonth("2027-01-01", -1)).toBe("2026-12-01");
240+
expect(shiftMonth("2026-08-01", 5)).toBe("2027-01-01");
241+
expect(shiftMonth("2026-01-01", -1)).toBe("2025-12-01");
242+
expect(shiftMonth("2026-06-01", 0)).toBe("2026-06-01");
243+
});
244+
});
245+
246+
describe("pickInitialMonth", () => {
247+
const winter: Participant[] = [
248+
{ id: "a", ranges: [r("2026-12-19", "2027-01-10")] },
249+
{ id: "b", ranges: [r("2026-12-22", "2027-01-05")] },
250+
];
251+
252+
it("opens on the month holding the next all-free stretch", () => {
253+
expect(pickInitialMonth(winter, "2026-08-08")).toBe("2026-12-01");
254+
});
255+
256+
it("opens on the current month when the all-free stretch is already running", () => {
257+
expect(pickInitialMonth(winter, "2026-12-28")).toBe("2026-12-01");
258+
});
259+
260+
it("falls back to the next unfinished break when nobody ever overlaps", () => {
261+
const noOverlap: Participant[] = [
262+
{ id: "a", ranges: [r("2026-10-05", "2026-10-09")] },
263+
{ id: "b", ranges: [r("2026-11-21", "2026-11-29")] },
264+
];
265+
expect(pickInitialMonth(noOverlap, "2026-08-08")).toBe("2026-10-01");
266+
});
267+
268+
it("falls back to the most recent break when everything is in the past", () => {
269+
expect(pickInitialMonth(winter, "2027-06-01")).toBe("2026-12-19".slice(0, 7) + "-01");
270+
});
271+
272+
it("uses today when nobody has entered anything", () => {
273+
expect(pickInitialMonth([{ id: "a", ranges: [] }], "2026-08-08")).toBe("2026-08-01");
274+
expect(pickInitialMonth([], "2026-08-08")).toBe("2026-08-01");
275+
});
276+
277+
it("uses the single person's own break when only one has entered", () => {
278+
expect(
279+
pickInitialMonth([{ id: "a", ranges: [r("2026-11-21", "2026-11-29")] }], "2026-08-08"),
280+
).toBe("2026-11-01");
281+
});
282+
});
283+
284+
describe("toParticipants", () => {
285+
it("groups stored break rows under their owner and keeps people order", () => {
286+
const people = [{ id: "a" }, { id: "b" }, { id: "c" }];
287+
const rows = [
288+
{ userId: "b", startDate: "2026-12-19", endDate: "2026-12-28" },
289+
{ userId: "a", startDate: "2026-11-25", endDate: "2026-11-29" },
290+
{ userId: "b", startDate: "2027-03-14", endDate: "2027-03-22" },
291+
];
292+
const parts = toParticipants(people, rows);
293+
expect(parts.map((p) => p.id)).toEqual(["a", "b", "c"]);
294+
expect(parts[0].ranges).toEqual([r("2026-11-25", "2026-11-29")]);
295+
expect(parts[1].ranges).toHaveLength(2);
296+
expect(parts[2].ranges).toEqual([]);
297+
});
298+
299+
it("ignores break rows whose owner is not in the people list", () => {
300+
const parts = toParticipants(
301+
[{ id: "a" }],
302+
[{ userId: "ghost", startDate: "2026-12-19", endDate: "2026-12-28" }],
303+
);
304+
expect(parts).toEqual([{ id: "a", ranges: [] }]);
305+
});
306+
});

0 commit comments

Comments
 (0)