From 3b370205bb928f9cfd65cf69cf7711712d8bd48d Mon Sep 17 00:00:00 2001 From: DEls Date: Fri, 11 Sep 2026 22:24:37 +0200 Subject: [PATCH] feat(project-calendar): add work-month/week bucketing with tests --- src/lib/project-calendar.test.ts | 149 +++++++++++++++++++++++++++++ src/lib/project-calendar.ts | 157 +++++++++++++++++++++++++++++++ 2 files changed, 306 insertions(+) create mode 100644 src/lib/project-calendar.test.ts create mode 100644 src/lib/project-calendar.ts diff --git a/src/lib/project-calendar.test.ts b/src/lib/project-calendar.test.ts new file mode 100644 index 0000000..5530acf --- /dev/null +++ b/src/lib/project-calendar.test.ts @@ -0,0 +1,149 @@ +// src/lib/project-calendar.test.ts +import { describe, expect, it } from "vitest"; +import { + deriveProjectPeriodLabel, + matchesProjectPeriodFilter, + resolveProjectPeriod, +} from "./project-calendar"; + +describe("resolveProjectPeriod", () => { + it("defaults to ordinary calendar months when no start day is given", () => { + // Site started 2026-01-05. With no work_month_start_day, the cycle + // that contains Jan 5 starts Jan 1 — Month 1 = January. + expect(resolveProjectPeriod("2026-01-20", "2026-01-05")).toEqual({ + projectMonth: 1, + projectWeek: 3, + }); + // March is Month 3 (Jan=1, Feb=2, Mar=3). + expect(resolveProjectPeriod("2026-03-10", "2026-01-05")).toEqual({ + projectMonth: 3, + projectWeek: 2, + }); + }); + + it("uses a custom start day (21st-to-21st cycles)", () => { + const start = "2026-02-21"; + // Still inside the Feb21-Mar20 cycle -> Month 1. + expect(resolveProjectPeriod("2026-03-15", start, 21)).toEqual({ + projectMonth: 1, + projectWeek: 4, + }); + // Mar21 begins the next cycle -> Month 2, day 0 of that cycle -> Week 1. + expect(resolveProjectPeriod("2026-03-21", start, 21)).toEqual({ + projectMonth: 2, + projectWeek: 1, + }); + }); + + it("clamps an overflowing start day to the last real day of a short month", () => { + // Start day 31, site started 2026-01-31. + // Feb 2026 has 28 days, so that cycle's boundary clamps to Feb 28, + // not rolling into March. + const start = "2026-01-31"; + // Still the Jan31-Feb27 cycle -> Month 1. + expect(resolveProjectPeriod("2026-02-15", start, 31)).toEqual({ + projectMonth: 1, + projectWeek: 3, + }); + // Feb 28 begins the next cycle (clamped) -> Month 2. + expect(resolveProjectPeriod("2026-02-28", start, 31)).toEqual({ + projectMonth: 2, + projectWeek: 1, + }); + // March 31 is back to a real 31st -> Month 3, no drift carried over. + expect(resolveProjectPeriod("2026-03-31", start, 31)).toEqual({ + projectMonth: 3, + projectWeek: 1, + }); + }); + + it("caps week numbering at 4 for a cycle longer than 28 days", () => { + // Jan31-Feb27 cycle is 28 days (clamped short) -> day 27 is week 4. + // Use a 31-day cycle instead: start day 1, January itself (31 days). + // Day 29 (0-indexed 28) -> floor(28/7)+1 = 5, capped to 4. + expect(resolveProjectPeriod("2026-01-29", "2026-01-01", 1)).toEqual({ + projectMonth: 1, + projectWeek: 4, + }); + }); + + it("returns null for a date before the site's start", () => { + expect(resolveProjectPeriod("2025-12-31", "2026-01-05")).toBeNull(); + }); + + it("returns null when either date is missing or unparseable", () => { + expect(resolveProjectPeriod("", "2026-01-05")).toBeNull(); + expect(resolveProjectPeriod("2026-01-05", "")).toBeNull(); + expect(resolveProjectPeriod("not-a-date", "2026-01-05")).toBeNull(); + }); + + it("treats an out-of-range start day as calendar-month default", () => { + // 0 and 32 are both invalid 1-31 values -> falls back to day 1. + expect(resolveProjectPeriod("2026-01-20", "2026-01-01", 0)).toEqual({ + projectMonth: 1, + projectWeek: 3, + }); + expect(resolveProjectPeriod("2026-01-20", "2026-01-01", 32)).toEqual({ + projectMonth: 1, + projectWeek: 3, + }); + }); +}); + +describe("deriveProjectPeriodLabel", () => { + const NOW = new Date("2026-03-15T00:00:00.000Z"); + + it("returns null when the site has no job_start_date", () => { + expect(deriveProjectPeriodLabel("2026-03-10", {}, NOW)).toBeNull(); + }); + + it("marks isToday only for an exact date match", () => { + const site = { job_start_date: "2026-01-01" }; + const today = deriveProjectPeriodLabel("2026-03-15", site, NOW); + expect(today?.isToday).toBe(true); + const yesterday = deriveProjectPeriodLabel("2026-03-14", site, NOW); + expect(yesterday?.isToday).toBe(false); + }); + + it("marks isThisWeek and isThisMonth against the site's own cycle", () => { + const site = { job_start_date: "2026-02-21", work_month_start_day: 21 }; + // "Now" (Mar 15) falls in Month 1, Week 4 of this site's cycle. + const sameWeek = deriveProjectPeriodLabel("2026-03-14", site, NOW); + expect(sameWeek?.isThisWeek).toBe(true); + expect(sameWeek?.isThisMonth).toBe(true); + + const earlierThisMonth = deriveProjectPeriodLabel("2026-02-25", site, NOW); + expect(earlierThisMonth?.isThisWeek).toBe(false); + expect(earlierThisMonth?.isThisMonth).toBe(true); + + const lastMonth = deriveProjectPeriodLabel("2026-02-20", site, NOW); + expect(lastMonth).toBeNull(); // before the site's start + }); +}); + +describe("matchesProjectPeriodFilter", () => { + const label = { + projectMonth: 2, + projectWeek: 1, + isToday: false, + isThisWeek: true, + isThisMonth: true, + }; + + it("'all' always matches, even with no period", () => { + expect(matchesProjectPeriodFilter("all", null)).toBe(true); + expect(matchesProjectPeriodFilter("all", label)).toBe(true); + }); + + it("every other filter requires a period", () => { + expect(matchesProjectPeriodFilter("today", null)).toBe(false); + expect(matchesProjectPeriodFilter("week", null)).toBe(false); + expect(matchesProjectPeriodFilter("month", null)).toBe(false); + }); + + it("checks the matching boolean on the label", () => { + expect(matchesProjectPeriodFilter("today", label)).toBe(false); + expect(matchesProjectPeriodFilter("week", label)).toBe(true); + expect(matchesProjectPeriodFilter("month", label)).toBe(true); + }); +}); \ No newline at end of file diff --git a/src/lib/project-calendar.ts b/src/lib/project-calendar.ts new file mode 100644 index 0000000..69c4df2 --- /dev/null +++ b/src/lib/project-calendar.ts @@ -0,0 +1,157 @@ +// src/lib/project-calendar.ts +/** + * A site's "work month" doesn't have to run the 1st-30th. One real site + * runs the 21st through the 20th of the next month; another runs the + * 15th through the 14th. `work_month_start_day` (1-31, unset = 1 = + * ordinary calendar months) is that anchor day. "Month 1" is whichever + * cycle contains the site's `job_start_date` — everything here is pure + * date math, no PocketBase access. + */ + +const MS_PER_DAY = 86_400_000; + +export interface ProjectPeriod { + /** 1-indexed: the cycle containing the site's job_start_date is Month 1. */ + projectMonth: number; + /** 1-indexed, capped at 4. Simple 7-day blocks from the cycle's own start. */ + projectWeek: number; +} + +export interface ProjectPeriodLabel extends ProjectPeriod { + /** The row's date is today (exact calendar-day match). */ + isToday: boolean; + /** The row's date falls in the same project month+week as today, for this site. */ + isThisWeek: boolean; + /** The row's date falls in the same project month as today, for this site. */ + isThisMonth: boolean; +} + +export interface ProjectPeriodSite { + job_start_date?: string | null; + work_month_start_day?: number | null; +} + +export type ProjectPeriodFilterValue = "all" | "today" | "week" | "month"; + +function parseIsoDateUtc(iso: string): number | null { + if (!iso) return null; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return null; + return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); +} + +function daysInMonth(year: number, month0: number): number { + return new Date(Date.UTC(year, month0 + 1, 0)).getUTCDate(); +} + +function clampDay(year: number, month0: number, day: number): number { + return Math.min(day, daysInMonth(year, month0)); +} + +/** The UTC-midnight start of the work-month cycle that contains `dateUtcMs`. */ +function cycleStartContaining(dateUtcMs: number, startDay: number): number { + const d = new Date(dateUtcMs); + const y = d.getUTCFullYear(); + const m = d.getUTCMonth(); + const day = d.getUTCDate(); + const anchorThisMonth = clampDay(y, m, startDay); + if (day >= anchorThisMonth) { + return Date.UTC(y, m, anchorThisMonth); + } + const prevM = m === 0 ? 11 : m - 1; + const prevY = m === 0 ? y - 1 : y; + return Date.UTC(prevY, prevM, clampDay(prevY, prevM, startDay)); +} + +/** The start of the cycle immediately after the one starting at `cycleStartMs`. */ +function nextCycleStart(cycleStartMs: number, startDay: number): number { + const d = new Date(cycleStartMs); + const y = d.getUTCFullYear(); + const m = d.getUTCMonth(); + const nextM = m === 11 ? 0 : m + 1; + const nextY = m === 11 ? y + 1 : y; + return Date.UTC(nextY, nextM, clampDay(nextY, nextM, startDay)); +} + +/** + * Which work-month/week a date falls in, for a site whose work-months + * start on `workMonthStartDay` (default 1 = ordinary calendar months) + * and whose Month 1 is the cycle containing `siteStartDateISO`. + * + * Returns null if either date is missing/unparseable, or if `dateISO` + * is before the site's own start (out of range for this project). + */ +export function resolveProjectPeriod( + dateISO: string, + siteStartDateISO: string, + workMonthStartDay?: number, +): ProjectPeriod | null { + const targetMs = parseIsoDateUtc(dateISO); + const anchorMs = parseIsoDateUtc(siteStartDateISO); + if (targetMs === null || anchorMs === null) return null; + + const startDay = + workMonthStartDay != null && workMonthStartDay >= 1 && workMonthStartDay <= 31 + ? Math.floor(workMonthStartDay) + : 1; + + const anchorCycleStart = cycleStartContaining(anchorMs, startDay); + const targetCycleStart = cycleStartContaining(targetMs, startDay); + if (targetCycleStart < anchorCycleStart) return null; + + let monthsElapsed = 0; + let cursor = anchorCycleStart; + while (cursor < targetCycleStart) { + cursor = nextCycleStart(cursor, startDay); + monthsElapsed++; + } + + const dayOffset = Math.floor((targetMs - targetCycleStart) / MS_PER_DAY); + const projectWeek = Math.min(4, Math.floor(dayOffset / 7) + 1); + + return { projectMonth: monthsElapsed + 1, projectWeek }; +} + +/** + * The full label for one row: its own Month/Week, plus whether it + * matches "today" / "this week" / "this month" for its site right now. + * Returns null if the site has no `job_start_date` set — there is + * nothing to anchor Month 1 to. + */ +export function deriveProjectPeriodLabel( + dateISO: string, + site: ProjectPeriodSite, + now: Date = new Date(), +): ProjectPeriodLabel | null { + const startDate = site.job_start_date; + if (!startDate) return null; + const startDay = site.work_month_start_day ?? undefined; + + const rowPeriod = resolveProjectPeriod(dateISO, startDate, startDay); + if (!rowPeriod) return null; + + const todayISO = now.toISOString().slice(0, 10); + const todayPeriod = resolveProjectPeriod(todayISO, startDate, startDay); + if (!todayPeriod) return null; + + return { + ...rowPeriod, + isToday: dateISO.slice(0, 10) === todayISO, + isThisWeek: + rowPeriod.projectMonth === todayPeriod.projectMonth && + rowPeriod.projectWeek === todayPeriod.projectWeek, + isThisMonth: rowPeriod.projectMonth === todayPeriod.projectMonth, + }; +} + +/** "all" always matches; every other value requires a resolved period. */ +export function matchesProjectPeriodFilter( + filter: ProjectPeriodFilterValue, + period: ProjectPeriodLabel | null, +): boolean { + if (filter === "all") return true; + if (!period) return false; + if (filter === "today") return period.isToday; + if (filter === "week") return period.isThisWeek; + return period.isThisMonth; +} \ No newline at end of file