mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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>
92 lines
3.6 KiB
TypeScript
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;
|
|
}
|