2026-09-24 00:08:58 -04:00
|
|
|
/**
|
|
|
|
|
* Pure presence-cycle math for the Schedule tab.
|
|
|
|
|
*
|
|
|
|
|
* Cycle position comes from the anchor date ONLY, mirroring
|
|
|
|
|
* core/presence.py `cycle_week_index`:
|
|
|
|
|
* `((date - anchor).days // 7) % cycle_length_weeks` with floor division,
|
|
|
|
|
* so dates before the anchor walk the cycle backwards. Never ISO week
|
|
|
|
|
* numbers or week parity (D-004).
|
|
|
|
|
*
|
|
|
|
|
* Calendar dates are "YYYY-MM-DD" strings handled as whole UTC days, so no
|
|
|
|
|
* viewer offset or DST change can shift a day.
|
|
|
|
|
*/
|
2026-09-24 20:09:56 -04:00
|
|
|
import type {
|
|
|
|
|
PatternCycle,
|
|
|
|
|
PatternKind,
|
|
|
|
|
PatternWeeks,
|
|
|
|
|
PresenceOverride,
|
|
|
|
|
} from "../api/presence";
|
2026-09-24 00:08:58 -04:00
|
|
|
|
|
|
|
|
const MS_PER_DAY = 86_400_000;
|
|
|
|
|
|
|
|
|
|
export const ALL_WEEKDAYS = [0, 1, 2, 3, 4, 5, 6];
|
|
|
|
|
|
2026-09-24 20:09:56 -04:00
|
|
|
/** Monday=0 weekday numbers in Sunday-first display order. */
|
|
|
|
|
export const DISPLAY_WEEKDAYS = [6, 0, 1, 2, 3, 4, 5];
|
|
|
|
|
|
|
|
|
|
/** Short names indexed by Monday=0 weekday number. */
|
|
|
|
|
export const WEEKDAY_SHORT = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
|
|
|
|
|
2026-09-24 00:08:58 -04:00
|
|
|
/** Days since 1970-01-01 for a "YYYY-MM-DD" calendar date. */
|
|
|
|
|
export function dayNumber(iso: string): number {
|
|
|
|
|
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
|
|
|
|
|
if (!match) throw new Error(`Invalid calendar date: ${iso}`);
|
|
|
|
|
const [, year, month, day] = match;
|
|
|
|
|
return Date.UTC(Number(year), Number(month) - 1, Number(day)) / MS_PER_DAY;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function isoFromDayNumber(days: number): string {
|
|
|
|
|
return new Date(days * MS_PER_DAY).toISOString().slice(0, 10);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Floor modulo: always in 0..divisor-1, like Python's `%`. */
|
|
|
|
|
function mod(value: number, divisor: number): number {
|
|
|
|
|
return ((value % divisor) + divisor) % divisor;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 0 = Monday .. 6 = Sunday (1970-01-01 was a Thursday). */
|
|
|
|
|
export function weekdayOf(iso: string): number {
|
|
|
|
|
return mod(dayNumber(iso) + 3, 7);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** `((date - anchor).days // 7) % cycleLengthWeeks`, Python semantics. */
|
|
|
|
|
export function cycleWeekIndex(anchorDate: string, date: string, cycleLengthWeeks: number): number {
|
|
|
|
|
const days = dayNumber(date) - dayNumber(anchorDate);
|
|
|
|
|
return mod(Math.floor(days / 7), cycleLengthWeeks);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** The viewer's local calendar date. */
|
|
|
|
|
export function localTodayIso(now: Date = new Date()): string {
|
|
|
|
|
const month = String(now.getMonth() + 1).padStart(2, "0");
|
|
|
|
|
const day = String(now.getDate()).padStart(2, "0");
|
|
|
|
|
return `${now.getFullYear()}-${month}-${day}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** "YYYY-MM-01" of the month containing `iso`, shifted by `delta` months. */
|
|
|
|
|
export function monthStart(iso: string, delta = 0): string {
|
|
|
|
|
const [year, month] = iso.split("-").map(Number);
|
|
|
|
|
const date = new Date(Date.UTC(year, month - 1 + delta, 1));
|
|
|
|
|
return date.toISOString().slice(0, 10);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Every date of the month starting at `firstIso`. */
|
|
|
|
|
export function monthDates(firstIso: string): string[] {
|
|
|
|
|
const start = dayNumber(firstIso);
|
|
|
|
|
const end = dayNumber(monthStart(firstIso, 1));
|
|
|
|
|
const dates: string[] = [];
|
|
|
|
|
for (let day = start; day < end; day += 1) dates.push(isoFromDayNumber(day));
|
|
|
|
|
return dates;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-24 20:09:56 -04:00
|
|
|
/** Every week of the cycle covers no day yet (a new pattern). */
|
|
|
|
|
export function emptyPattern(cycleLengthWeeks: number): PatternWeeks {
|
|
|
|
|
const pattern: PatternWeeks = {};
|
|
|
|
|
for (let week = 0; week < cycleLengthWeeks; week += 1) pattern[week] = [];
|
2026-09-24 00:08:58 -04:00
|
|
|
return pattern;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export type DayState = "home" | "away" | "override-home" | "override-away";
|
|
|
|
|
|
2026-09-24 20:09:56 -04:00
|
|
|
/** Does the pattern cover `date` (its cycle week lists the date's weekday)? */
|
|
|
|
|
export function patternIncludes(cycle: PatternCycle, date: string): boolean {
|
|
|
|
|
const week = cycleWeekIndex(cycle.anchor_date, date, cycle.cycle_length_weeks);
|
|
|
|
|
return (cycle.pattern[week] ?? []).includes(weekdayOf(date));
|
2026-09-24 00:08:58 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-09-24 20:09:56 -04:00
|
|
|
* A date's combined state for one child, mirroring core/presence.py
|
|
|
|
|
* `is_present` — first match wins:
|
|
|
|
|
* 1. an override covering the date;
|
|
|
|
|
* 2. away when any `away` pattern covers it;
|
|
|
|
|
* 3. home when any `home` pattern covers it;
|
|
|
|
|
* 4. away when the child has at least one `home` pattern (a home pattern
|
|
|
|
|
* lists the days the child IS here), else home (no patterns, or only
|
|
|
|
|
* `away` ones).
|
2026-09-24 00:08:58 -04:00
|
|
|
*/
|
|
|
|
|
export function dayState(
|
2026-09-24 20:09:56 -04:00
|
|
|
patterns: (PatternCycle & { kind: PatternKind })[],
|
2026-09-24 00:08:58 -04:00
|
|
|
overrides: PresenceOverride[],
|
|
|
|
|
date: string,
|
|
|
|
|
): DayState {
|
|
|
|
|
const override = overrides.find((o) => o.start_date <= date && date <= o.end_date);
|
|
|
|
|
if (override) return override.is_present ? "override-home" : "override-away";
|
2026-09-24 20:09:56 -04:00
|
|
|
if (patterns.some((p) => p.kind === "away" && patternIncludes(p, date))) return "away";
|
|
|
|
|
const homes = patterns.filter((p) => p.kind === "home");
|
|
|
|
|
if (homes.some((p) => patternIncludes(p, date))) return "home";
|
|
|
|
|
return homes.length > 0 ? "away" : "home";
|
2026-09-24 00:08:58 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Flip `weekday` in `week` of the pattern; returns a new pattern. */
|
|
|
|
|
export function togglePatternDay(
|
2026-09-24 20:09:56 -04:00
|
|
|
pattern: PatternWeeks,
|
2026-09-24 00:08:58 -04:00
|
|
|
week: number,
|
|
|
|
|
weekday: number,
|
2026-09-24 20:09:56 -04:00
|
|
|
): PatternWeeks {
|
2026-09-24 00:08:58 -04:00
|
|
|
const days = pattern[week] ?? [];
|
|
|
|
|
const next = days.includes(weekday)
|
|
|
|
|
? days.filter((d) => d !== weekday)
|
|
|
|
|
: [...days, weekday].sort((a, b) => a - b);
|
|
|
|
|
return { ...pattern, [week]: next };
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-24 20:09:56 -04:00
|
|
|
/** Fit the pattern to a new cycle length; added weeks start covering no day. */
|
|
|
|
|
export function resizePattern(pattern: PatternWeeks, cycleLengthWeeks: number): PatternWeeks {
|
|
|
|
|
const next: PatternWeeks = {};
|
2026-09-24 00:08:58 -04:00
|
|
|
for (let week = 0; week < cycleLengthWeeks; week += 1) {
|
2026-09-24 20:09:56 -04:00
|
|
|
next[week] = pattern[week] ? [...pattern[week]] : [];
|
2026-09-24 00:08:58 -04:00
|
|
|
}
|
|
|
|
|
return next;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-24 20:09:56 -04:00
|
|
|
function daysLabel(days: number[]): string {
|
|
|
|
|
if (days.length === 7) return "Every day";
|
|
|
|
|
if (days.length === 0) return "No days";
|
|
|
|
|
return [...days].sort((a, b) => a - b).map((d) => WEEKDAY_SHORT[d]).join(", ");
|
2026-09-24 00:08:58 -04:00
|
|
|
}
|
|
|
|
|
|
2026-09-24 20:09:56 -04:00
|
|
|
/**
|
|
|
|
|
* Plain-language weekday set: `Thu, Fri` for a 1-week cycle, otherwise one
|
|
|
|
|
* entry per cycle week, e.g. `Week 1: Sat, Sun · Week 2: No days`.
|
|
|
|
|
*/
|
|
|
|
|
export function patternDaysLabel(cycle: PatternCycle): string {
|
|
|
|
|
if (cycle.cycle_length_weeks === 1) return daysLabel(cycle.pattern[0] ?? []);
|
|
|
|
|
const weeks: string[] = [];
|
|
|
|
|
for (let week = 0; week < cycle.cycle_length_weeks; week += 1) {
|
|
|
|
|
weeks.push(`Week ${week + 1}: ${daysLabel(cycle.pattern[week] ?? [])}`);
|
2026-09-24 00:08:58 -04:00
|
|
|
}
|
2026-09-24 20:09:56 -04:00
|
|
|
return weeks.join(" · ");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** `Every week`, `Every 2 weeks`. */
|
|
|
|
|
export function cycleLabel(cycleLengthWeeks: number): string {
|
|
|
|
|
return cycleLengthWeeks === 1 ? "Every week" : `Every ${cycleLengthWeeks} weeks`;
|
2026-09-24 00:08:58 -04:00
|
|
|
}
|