feat(project-calendar): add work-month/week bucketing with tests
This commit is contained in:
parent
daf2344d99
commit
3b370205bb
2 changed files with 306 additions and 0 deletions
149
src/lib/project-calendar.test.ts
Normal file
149
src/lib/project-calendar.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
157
src/lib/project-calendar.ts
Normal file
157
src/lib/project-calendar.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in a new issue