Files
HealthLog/src/lib/cycle/day-math.ts
T
Marc-André BombeckandClaude Opus 4.8 c21131e26e feat(cycle): second-audit feature, QoL, and wiring close
Close the second-round audit UI cluster:

- Cycle reminders: add the missing notification-event labels so the
  period-soon / period-confirm toggles read correctly on the
  notifications page, and point to them from cycle settings.
- Per-cycle history: render a recent-cycles list (range, length, period
  length, confirmed-ovulation chip) — the cycles were fetched but only
  the aggregate stats showed.
- Confirmed ovulation: carry it on the prediction DTO and surface it as a
  solid BBT-chart marker + a predictions row + a history chip.
- Log-day sheet: deselecting an enum now clears it (edits route through
  the nullable PATCH); period start/end work on any selected date for
  retroactive correction; "end period" shows only while one is open.
  Extract the payload builders as pure, unit-tested functions.
- Cycle-tracking enable toggle in account settings so any account can opt
  in before the gated page is reachable; wire the cycle data-purge button
  to the hard-delete route and document the route in the OpenAPI spec.
- Calendar legend: add the symptom dot, render fertile as a ring to match
  the grid; tighten the luteal prior bound to the engine clamp; mute the
  BBT ovulation-label colour for contrast.
- Share the cycle-start backward-walk between the wheel and the chart.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:32:25 +02:00

92 lines
3.6 KiB
TypeScript

/**
* Shared day arithmetic for the cycle engine.
*
* This MUST match the iOS Swift re-implementation bit-for-bit. The whole point
* of the cycle prediction engine is that the server (TypeScript) and the device
* (Swift) compute byte-identical predictions from the same canonical inputs so
* the offline calendar renders without a network round-trip and the materialised
* server row matches the device's local recompute (a mismatch is a parity bug,
* not last-write-wins).
*
* The load-bearing convention (algorithm.md §"Notation"):
* - Dates are `YYYY-MM-DD` strings (the MoodEntry / CycleDayLog convention).
* - A date is anchored at noon UTC (`T12:00:00Z`). Anchoring at noon — rather
* than midnight — keeps a whole-day difference exact across every timezone
* and DST transition (a ±1 h shift can never flip the rounded day count).
* - `dayDiff(a, b) = round((ms_a - ms_b) / 86_400_000)`.
* - NEVER diff with wall-clock / local time.
* - Rounding everywhere is round-half-UP to the stated precision:
* `Math.round(x * 10^k) / 10^k` in TS, mirrored by
* `(x * 10^k).rounded(.toNearestOrAwayFromZero) / 10^k` in Swift.
*/
/** Milliseconds in one calendar day. */
export const MS_PER_DAY = 86_400_000;
/** Strict `YYYY-MM-DD` shape guard. */
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
/**
* Parse a `YYYY-MM-DD` string into the epoch-ms of that date anchored at noon
* UTC. Throws on a malformed string so a bad input fails closed rather than
* silently producing `NaN` that would poison every downstream diff.
*/
export function parseDayMs(date: string): number {
if (!ISO_DATE_RE.test(date)) {
throw new Error(
`cycle/day-math: malformed date "${date}" (expected YYYY-MM-DD)`,
);
}
const ms = Date.parse(`${date}T12:00:00Z`);
if (Number.isNaN(ms)) {
throw new Error(`cycle/day-math: unparseable date "${date}"`);
}
return ms;
}
/**
* Whole-day difference `a - b` (positive when `a` is later than `b`).
* Rounds the ms quotient so a noon-UTC anchor makes the result DST-stable.
*/
export function dayDiff(a: string, b: string): number {
return Math.round((parseDayMs(a) - parseDayMs(b)) / MS_PER_DAY);
}
/**
* Return the `YYYY-MM-DD` string `n` days after `date` (`n` may be negative).
* Computed off the noon-UTC anchor so it never crosses a day boundary by error.
*/
export function addDays(date: string, n: number): string {
const ms = parseDayMs(date) + n * MS_PER_DAY;
const d = new Date(ms);
const y = d.getUTCFullYear().toString().padStart(4, "0");
const m = (d.getUTCMonth() + 1).toString().padStart(2, "0");
const day = d.getUTCDate().toString().padStart(2, "0");
return `${y}-${m}-${day}`;
}
/**
* Round-half-up to `k` decimal places. `roundHalf(2.345, 2) === 2.35`,
* `roundHalf(-2.345, 2) === -2.35` (away from zero on a tie), matching Swift's
* `.toNearestOrAwayFromZero`. `k` defaults to 0 (integer rounding).
*/
export function roundHalf(x: number, k = 0): number {
if (!Number.isFinite(x)) return x;
const factor = 10 ** k;
// Math.round is round-half-up toward +Infinity; mirror it away-from-zero for
// negatives so the rule matches Swift's .toNearestOrAwayFromZero exactly.
return x < 0
? -Math.round(-x * factor) / factor
: Math.round(x * factor) / factor;
}
/** `true` when `date` is on or before `other` (string compare is safe for ISO). */
export function isOnOrBefore(date: string, other: string): boolean {
return dayDiff(date, other) <= 0;
}
/** `true` when `date` falls within the inclusive `[start, end]` span. */
export function isWithin(date: string, start: string, end: string): boolean {
return dayDiff(date, start) >= 0 && dayDiff(end, date) >= 0;
}