From 664cdbe0db0a8a521379f5bbc61ccb224894f20c Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Tue, 13 Oct 2026 13:04:36 +0200 Subject: [PATCH] feat(period): cut weeks and months on each game's reset clock Check-in goals and weekly tasks both need a period longer than a day. Working in game-day keys rather than instants means a week rolls exactly where the game's own day does, per region, with no second clock. Co-Authored-By: Claude Opus 5.5 --- src/shared/period.ts | 71 +++++++++++++++++++++++++++++++++ test/period.test.ts | 94 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 src/shared/period.ts create mode 100644 test/period.test.ts diff --git a/src/shared/period.ts b/src/shared/period.ts new file mode 100644 index 0000000..b063ec1 --- /dev/null +++ b/src/shared/period.ts @@ -0,0 +1,71 @@ +import { z } from "zod"; +import { DAY } from "./time.ts"; + +/** + * Periods longer than a day — a week, a month — in day-key space. + * + * Every key here is a `YYYY-MM-DD` game-day that `dayKey` already cut on a + * game's own reset clock for the reader's region. Working on keys rather than + * instants means a period inherits every per-game, per-region reset rule for + * free: the week rolls when the game's day rolls, and nowhere else. `streakOf` + * and `catchUpDays` walk keys for the same reason. + * + * Pure, and takes "today" as an argument, so it is tested at fixed instants. + */ + +/** ISO weekday: Monday is 1, Sunday is 7. */ +export const WeekdaySchema = z.number().int().min(1).max(7); + +export const PeriodSchema = z.union([ + z.object({ unit: z.literal("week"), startsOn: WeekdaySchema }), + /** Day of month; 31 means "the last day" in a shorter month. */ + z.object({ unit: z.literal("month"), startsOn: z.number().int().min(1).max(31) }), +]); +export type Period = z.infer; + +const msOf = (key: string) => Date.parse(`${key}T00:00:00Z`); +const keyOf = (ms: number) => new Date(ms).toISOString().slice(0, 10); + +/** A key is a date, not an instant, so its weekday is its UTC weekday. */ +export function isoWeekday(key: string): number { + const d = new Date(msOf(key)).getUTCDay(); + return d === 0 ? 7 : d; +} + +export function addDays(key: string, n: number): string { + return keyOf(msOf(key) + n * DAY); +} + +/** The month's start key, with `startsOn` clamped to the month's last day. */ +function monthStart(year: number, month0: number, startsOn: number): string { + // Date.UTC normalises an out-of-range month, so month0 may be -1 or 12. + const first = new Date(Date.UTC(year, month0, 1)); + const y = first.getUTCFullYear(); + const m = first.getUTCMonth(); + const last = new Date(Date.UTC(y, m + 1, 0)).getUTCDate(); + return keyOf(Date.UTC(y, m, Math.min(startsOn, last))); +} + +/** The period containing `today`: its first key, and every key in it, oldest first. */ +export function periodOf( + period: Period, + today: string, +): { start: string; days: string[] } { + let start: string; + let next: string; + if (period.unit === "week") { + start = addDays(today, -((isoWeekday(today) - period.startsOn + 7) % 7)); + next = addDays(start, 7); + } else { + const t = new Date(msOf(today)); + const y = t.getUTCFullYear(); + const m = t.getUTCMonth(); + const here = monthStart(y, m, period.startsOn); + const startMonth = today >= here ? m : m - 1; + start = monthStart(y, startMonth, period.startsOn); + next = monthStart(y, startMonth + 1, period.startsOn); + } + const days: string[] = []; + for (let k = start; k < next; k = addDays(k, 1)) days.push(k); + return { start, days }; +} diff --git a/test/period.test.ts b/test/period.test.ts new file mode 100644 index 0000000..b62f0a8 --- /dev/null +++ b/test/period.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test } from "bun:test"; +import { dayKey, nextResetMs } from "../src/shared/daily.ts"; +import { addDays, isoWeekday, periodOf } from "../src/shared/period.ts"; + +describe("isoWeekday", () => { + test("Monday is 1 and Sunday is 7", () => { + expect(isoWeekday("2026-10-12")).toBe(1); + expect(isoWeekday("2026-10-18")).toBe(7); + }); +}); + +describe("addDays", () => { + test("crosses month and year ends", () => { + expect(addDays("2026-09-30", 1)).toBe("2026-10-01"); + expect(addDays("2026-12-31", 1)).toBe("2027-01-01"); + expect(addDays("2026-03-01", -1)).toBe("2026-02-28"); + }); +}); + +describe("periodOf: weeks", () => { + const week = (startsOn: number) => ({ unit: "week" as const, startsOn }); + + test("a Monday week holds Monday to Sunday", () => { + const p = periodOf(week(1), "2026-10-15"); + expect(p.start).toBe("2026-10-12"); + expect(p.days).toEqual([ + "2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", + "2026-10-16", "2026-10-17", "2026-10-18", + ]); + }); + + test("the start day is its own week's first day", () => { + expect(periodOf(week(1), "2026-10-12").start).toBe("2026-10-12"); + expect(periodOf(week(1), "2026-10-18").start).toBe("2026-10-12"); + }); + + test("every start weekday lands on that weekday, on or before today", () => { + for (let s = 1; s <= 7; s++) { + const p = periodOf(week(s), "2026-10-15"); + expect(isoWeekday(p.start)).toBe(s); + expect(p.start <= "2026-10-15").toBe(true); + expect(p.days).toHaveLength(7); + expect(p.days).toContain("2026-10-15"); + } + }); + + test("a week can span a year end", () => { + expect(periodOf(week(1), "2027-01-01").days[0]).toBe("2026-12-28"); + }); + + test("the week rolls at each region's Genshin reset, not at midnight", () => { + // Monday 2026-10-12's reset: 20:00Z Sunday in Asia, 03:00Z in Europe, + // 09:00Z in America. + for (const region of ["asia", "europe", "america"] as const) { + const reset = nextResetMs(Date.parse("2026-10-11T12:00:00Z"), region, "genshin"); + const before = periodOf(week(1), dayKey(reset - 1, region, "genshin")); + const at = periodOf(week(1), dayKey(reset, region, "genshin")); + expect(before.start).toBe("2026-10-05"); + expect(at.start).toBe("2026-10-12"); + } + }); + + test("a game with a reset override rolls on its own hour", () => { + // hololive Dreams rolls at 05:00 JST (resetHourLocal), not the 04:00 default. + const reset = nextResetMs(Date.parse("2026-10-11T12:00:00Z"), "asia", "holodori"); + expect(new Date(reset).toISOString()).toBe("2026-10-11T20:00:00.000Z"); + expect(periodOf(week(1), dayKey(reset - 1, "asia", "holodori")).start).toBe("2026-10-05"); + expect(periodOf(week(1), dayKey(reset, "asia", "holodori")).start).toBe("2026-10-12"); + }); +}); + +describe("periodOf: months", () => { + const month = (startsOn: number) => ({ unit: "month" as const, startsOn }); + + test("a month from the 1st", () => { + const p = periodOf(month(1), "2026-10-15"); + expect(p.start).toBe("2026-10-01"); + expect(p.days).toHaveLength(31); + expect(p.days.at(-1)).toBe("2026-10-31"); + }); + + test("before this month's start day, the period began last month", () => { + const p = periodOf(month(16), "2026-10-15"); + expect(p.start).toBe("2026-09-16"); + expect(p.days.at(-1)).toBe("2026-10-15"); + }); + + test("a start day past the month's end clamps to its last day", () => { + expect(periodOf(month(31), "2026-09-30").start).toBe("2026-09-30"); + expect(periodOf(month(31), "2026-09-29").start).toBe("2026-08-31"); + expect(periodOf(month(31), "2026-10-05").days[0]).toBe("2026-09-30"); + expect(periodOf(month(30), "2027-02-28").start).toBe("2027-02-28"); + }); +});