mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
fix(jobs): break consolidation import cycle that crashed the worker at boot
The reminder worker failed to start in the production bundle with `ReferenceError: Cannot access 'l' before initialization`, taking every nightly pg-boss job down with it (score jobs + dense-intraday retention). Root cause is a value-level import cycle between `consolidation-base.ts` and `drain-per-sample-cumulative.ts`: each imported values from the other. The bundler merges the tightly-cyclic pair into one module body and hoists `DRAIN_CUMULATIVE_CUTOFF_HOURS = CONSOLIDATION_GRACE_CUTOFF_HOURS` ahead of the constant it reads, so the eager read hits a temporal dead zone. It only surfaced in the production build because dev/test run the modules unbundled. Hoist the shared leaf — `CONSOLIDATION_GRACE_CUTOFF_HOURS`, the `PerSampleRow` shape, and the timezone day-math helpers — into a new dependency-free module `consolidation-tz.ts`. Both sides now import from the leaf, removing the cycle at its root. Re-export the moved names from their former homes so every existing import site resolves unchanged.
This commit is contained in:
1 parent
d105849026
commit
fbab8cfd30
3 files changed
+191
-143
No files matched your search
@@ -19,10 +19,12 @@
|
||||
* supplies its reducer/types and keeps its own public summary shape.
|
||||
*
|
||||
* The timezone day-math helpers (`dayKeyForUserTz`,
|
||||
* `canonicalDailyTimestamp`, `localStartOfDay`, `localDayWindow`) live in
|
||||
* `drain-per-sample-cumulative.ts` and are imported here to avoid a
|
||||
* cyclic dependency — that module is the historical home the other two
|
||||
* already import them from.
|
||||
* `canonicalDailyTimestamp`, `localStartOfDay`, `localDayWindow`), the
|
||||
* `PerSampleRow` shape, and `CONSOLIDATION_GRACE_CUTOFF_HOURS` all live
|
||||
* in the dependency-free leaf `consolidation-tz.ts`. Both this module
|
||||
* and `drain-per-sample-cumulative.ts` import them from there, so there
|
||||
* is no value-level cycle between the two — a cycle would otherwise trip
|
||||
* a module-init TDZ in the production bundle.
|
||||
*/
|
||||
import type {
|
||||
MeasurementType,
|
||||
@@ -31,19 +33,15 @@ import type {
|
||||
} from "@/generated/prisma/client";
|
||||
|
||||
import {
|
||||
CONSOLIDATION_GRACE_CUTOFF_HOURS,
|
||||
canonicalDailyTimestamp,
|
||||
dayKeyForUserTz,
|
||||
type PerSampleRow,
|
||||
} from "./drain-per-sample-cumulative";
|
||||
} from "./consolidation-tz";
|
||||
|
||||
/**
|
||||
* Canonical grace window shared by the cumulative + mean drains. Rows
|
||||
* whose `measuredAt` is newer than `now() - CONSOLIDATION_GRACE_CUTOFF_HOURS`
|
||||
* stay raw so today's still-in-flight watch syncs surface in the live
|
||||
* "today" view. 36 hours covers the previous calendar day plus a
|
||||
* trailing sync window for watches that weren't worn at midnight.
|
||||
*/
|
||||
export const CONSOLIDATION_GRACE_CUTOFF_HOURS = 36;
|
||||
// Re-export the shared grace constant so the established
|
||||
// `consolidation-base` import surface stays unchanged for callers.
|
||||
export { CONSOLIDATION_GRACE_CUTOFF_HOURS };
|
||||
|
||||
/** Fallback timezone for users with no `User.timezone` set. */
|
||||
const DEFAULT_TIMEZONE = "Europe/Berlin";
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Leaf timezone day-math + per-sample-row shape shared by the
|
||||
* consolidation drains.
|
||||
*
|
||||
* This module owns the primitives that both `consolidation-base.ts` (the
|
||||
* shared `runConsolidation` driver) and `drain-per-sample-cumulative.ts`
|
||||
* (the cumulative drain) need. It deliberately imports nothing from
|
||||
* either of those — it is a dependency-free leaf so the two can both
|
||||
* reach the helpers without forming an import cycle.
|
||||
*
|
||||
* History: `dayKeyForUserTz`, `canonicalDailyTimestamp`, `localStartOfDay`,
|
||||
* `localDayWindow`, the grace constant, and the `PerSampleRow` shape used
|
||||
* to live in `drain-per-sample-cumulative.ts`. `consolidation-base.ts`
|
||||
* imported them from there while `drain` imported the runner back — a
|
||||
* value-level cycle. Bundled for production (Turbopack merges tightly
|
||||
* cyclic modules into one body), the eager re-export
|
||||
* `const DRAIN_CUMULATIVE_CUTOFF_HOURS = CONSOLIDATION_GRACE_CUTOFF_HOURS`
|
||||
* was hoisted ahead of the constant it read, throwing a
|
||||
* `ReferenceError: Cannot access '…' before initialization` at worker
|
||||
* boot. Hoisting the shared leaf into this module removes the cycle at
|
||||
* its root; `drain-per-sample-cumulative.ts` re-exports these names so
|
||||
* every existing import site keeps working unchanged.
|
||||
*/
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
|
||||
/**
|
||||
* Canonical grace window shared by the cumulative + mean drains. Rows
|
||||
* whose `measuredAt` is newer than `now() - CONSOLIDATION_GRACE_CUTOFF_HOURS`
|
||||
* stay raw so today's still-in-flight watch syncs surface in the live
|
||||
* "today" view. 36 hours covers the previous calendar day plus a
|
||||
* trailing sync window for watches that weren't worn at midnight.
|
||||
*/
|
||||
export const CONSOLIDATION_GRACE_CUTOFF_HOURS = 36;
|
||||
|
||||
/**
|
||||
* Per-sample row shape the drains scan and bucket. Exposed for unit
|
||||
* testing the bucketing semantics without booting Prisma.
|
||||
*/
|
||||
export interface PerSampleRow {
|
||||
id: string;
|
||||
type: MeasurementType;
|
||||
value: number;
|
||||
measuredAt: Date;
|
||||
externalId: string | null;
|
||||
/**
|
||||
* Optional — only the mean-consolidation pass selects `unit` so it can
|
||||
* read the canonical unit straight off the day's rows rather than
|
||||
* issuing a separate query. The cumulative drain leaves it unselected.
|
||||
*/
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the user's calendar-day key (`YYYY-MM-DD`) for a given
|
||||
* timestamp + timezone. Reuses the same `sv-SE` Intl formatting choice
|
||||
* the mood-entries path locked in v1.4.25 W7b so iOS-side and
|
||||
* server-side day-keys round-trip byte-identically.
|
||||
*/
|
||||
export function dayKeyForUserTz(date: Date, tz: string): string {
|
||||
return new Intl.DateTimeFormat("sv-SE", { timeZone: tz }).format(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the IANA-zone offset (in minutes east of UTC) at a given
|
||||
* instant. Returns 0 for UTC and any zone the shortOffset formatter
|
||||
* can't resolve (defensive — Node 22's full-icu build covers every
|
||||
* zone we care about).
|
||||
*/
|
||||
function tzOffsetMinutesAt(instant: Date, tz: string): number {
|
||||
const fmt = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: tz,
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZoneName: "shortOffset",
|
||||
});
|
||||
const parts = fmt.formatToParts(instant);
|
||||
const tzPart = parts.find((p) => p.type === "timeZoneName")?.value ?? "GMT";
|
||||
const match = tzPart.match(/GMT([+-])(\d{1,2})(?::(\d{2}))?/);
|
||||
if (!match) return 0;
|
||||
const sign = match[1] === "+" ? 1 : -1;
|
||||
const hours = Number.parseInt(match[2], 10);
|
||||
const minutes = match[3] ? Number.parseInt(match[3], 10) : 0;
|
||||
return sign * (hours * 60 + minutes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the canonical timestamp for a calendar-day key in the user's
|
||||
* timezone. Returns the JS-Date instant at the user's local 12:00 noon
|
||||
* — matches the Withings activity sync convention (one daily row per
|
||||
* type, anchored to midday so the row sorts cleanly between same-day
|
||||
* spot samples). The string returned by `toISOString()` is UTC.
|
||||
*/
|
||||
export function canonicalDailyTimestamp(dateKey: string, tz: string): Date {
|
||||
// Compute the UTC offset for noon-local of the given day. We don't
|
||||
// have a lightweight TZ-math library on the server, so the trick is:
|
||||
// build "12:00 UTC of the day", read what wall-clock that shows in
|
||||
// the target zone, then shift by the resulting offset.
|
||||
const utcNoon = new Date(`${dateKey}T12:00:00.000Z`);
|
||||
const offsetMinutes = tzOffsetMinutesAt(utcNoon, tz);
|
||||
// utcNoon represents 12:00 UTC. The user's local clock reads
|
||||
// 12:00 + offsetMinutes at that instant. To anchor at local 12:00,
|
||||
// subtract the offset.
|
||||
return new Date(utcNoon.getTime() - offsetMinutes * 60 * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.37 W10 — Compute the JS-Date instant at the user's local 00:00
|
||||
* for a calendar-day key. Robust on DST transitions because the offset
|
||||
* is read at the UTC-midnight instant of the day, and EU/US DST
|
||||
* transitions happen at local 02:00 / 03:00 — so the offset at UTC
|
||||
* midnight is unambiguous on every day of the year.
|
||||
*
|
||||
* Used by the W7c drill-down branch to resolve [dayStart, dayEnd) on
|
||||
* a 23-h spring-forward or 25-h fall-back day; the previous shape
|
||||
* (`canonicalDailyTimestamp ± 12h`) silently leaked or hid an hour
|
||||
* of samples on two days per year.
|
||||
*
|
||||
* Pair with `localStartOfDay(nextDayKey, tz)` for the right bound so
|
||||
* the returned window covers the true local-day span — 23 h on
|
||||
* spring-forward, 24 h on a regular day, 25 h on fall-back.
|
||||
*/
|
||||
export function localStartOfDay(dateKey: string, tz: string): Date {
|
||||
// Anchor at 00:00 UTC of the day; the offset read at that instant
|
||||
// is the same offset the local clock uses at midnight (DST
|
||||
// transitions in EU/US happen at 02:00 / 03:00 local, not at the
|
||||
// 00:00 boundary). For sub-half-hour zones (Asia/Kathmandu UTC+5:45,
|
||||
// Pacific/Chatham UTC+12:45) the minute component is preserved.
|
||||
const utcMidnight = new Date(`${dateKey}T00:00:00.000Z`);
|
||||
const offsetMinutes = tzOffsetMinutesAt(utcMidnight, tz);
|
||||
// Local 00:00 at this date = UTC midnight - offset.
|
||||
return new Date(utcMidnight.getTime() - offsetMinutes * 60 * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.37 W10 — Resolve the [dayStart, dayEnd) UTC window for a
|
||||
* calendar-day key in the user's IANA timezone. The window honours
|
||||
* DST so the drill-down branch returns the correct 23 / 24 / 25-hour
|
||||
* span for transition days.
|
||||
*
|
||||
* Returns a tuple where `dayEnd` is the local 00:00 of the FOLLOWING
|
||||
* calendar day — `< dayEnd` is the canonical half-open bound used by
|
||||
* the route's `measuredAt: { gte: dayStart, lt: dayEnd }` predicate.
|
||||
*/
|
||||
export function localDayWindow(
|
||||
dateKey: string,
|
||||
tz: string,
|
||||
): { dayStart: Date; dayEnd: Date } {
|
||||
const dayStart = localStartOfDay(dateKey, tz);
|
||||
// Add ONE day to dateKey using UTC arithmetic on a noon anchor (noon
|
||||
// sidesteps every DST edge case for the calendar increment). Then
|
||||
// re-extract the ISO date slice — guaranteed to be the next-day key.
|
||||
const nextUtcNoon = new Date(`${dateKey}T12:00:00.000Z`);
|
||||
nextUtcNoon.setUTCDate(nextUtcNoon.getUTCDate() + 1);
|
||||
const nextDateKey = nextUtcNoon.toISOString().slice(0, 10);
|
||||
const dayEnd = localStartOfDay(nextDateKey, tz);
|
||||
return { dayStart, dayEnd };
|
||||
}
|
||||
@@ -40,11 +40,32 @@ import {
|
||||
hkIdentifierForType,
|
||||
} from "./apple-health-mapping";
|
||||
import {
|
||||
CONSOLIDATION_GRACE_CUTOFF_HOURS,
|
||||
bucketRowsByDay,
|
||||
runConsolidation,
|
||||
type DayWriteOutcome,
|
||||
} from "./consolidation-base";
|
||||
import {
|
||||
CONSOLIDATION_GRACE_CUTOFF_HOURS,
|
||||
canonicalDailyTimestamp,
|
||||
dayKeyForUserTz,
|
||||
localDayWindow,
|
||||
localStartOfDay,
|
||||
type PerSampleRow,
|
||||
} from "./consolidation-tz";
|
||||
|
||||
// Re-export the shared timezone day-math primitives + `PerSampleRow`
|
||||
// shape so every established `drain-per-sample-cumulative` import site
|
||||
// (the measurements route, the Apple-Health importer, the unit tests)
|
||||
// keeps resolving them from here. The definitions moved to the
|
||||
// dependency-free leaf `consolidation-tz.ts` to break the init-order
|
||||
// cycle with `consolidation-base.ts`.
|
||||
export {
|
||||
canonicalDailyTimestamp,
|
||||
dayKeyForUserTz,
|
||||
localDayWindow,
|
||||
localStartOfDay,
|
||||
};
|
||||
export type { PerSampleRow };
|
||||
|
||||
/** Prefix marking an already-collapsed daily-stats row. */
|
||||
const DAILY_STATS_PREFIX = "stats:";
|
||||
@@ -118,135 +139,6 @@ export interface DrainOptions {
|
||||
cutoffHours?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the user's calendar-day key (`YYYY-MM-DD`) for a given
|
||||
* timestamp + timezone. Reuses the same `sv-SE` Intl formatting choice
|
||||
* the mood-entries path locked in v1.4.25 W7b so iOS-side and
|
||||
* server-side day-keys round-trip byte-identically.
|
||||
*/
|
||||
export function dayKeyForUserTz(date: Date, tz: string): string {
|
||||
return new Intl.DateTimeFormat("sv-SE", { timeZone: tz }).format(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the IANA-zone offset (in minutes east of UTC) at a given
|
||||
* instant. Returns 0 for UTC and any zone the shortOffset formatter
|
||||
* can't resolve (defensive — Node 22's full-icu build covers every
|
||||
* zone we care about).
|
||||
*/
|
||||
function tzOffsetMinutesAt(instant: Date, tz: string): number {
|
||||
const fmt = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: tz,
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZoneName: "shortOffset",
|
||||
});
|
||||
const parts = fmt.formatToParts(instant);
|
||||
const tzPart = parts.find((p) => p.type === "timeZoneName")?.value ?? "GMT";
|
||||
const match = tzPart.match(/GMT([+-])(\d{1,2})(?::(\d{2}))?/);
|
||||
if (!match) return 0;
|
||||
const sign = match[1] === "+" ? 1 : -1;
|
||||
const hours = Number.parseInt(match[2], 10);
|
||||
const minutes = match[3] ? Number.parseInt(match[3], 10) : 0;
|
||||
return sign * (hours * 60 + minutes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the canonical timestamp for a calendar-day key in the user's
|
||||
* timezone. Returns the JS-Date instant at the user's local 12:00 noon
|
||||
* — matches the Withings activity sync convention (one daily row per
|
||||
* type, anchored to midday so the row sorts cleanly between same-day
|
||||
* spot samples). The string returned by `toISOString()` is UTC.
|
||||
*/
|
||||
export function canonicalDailyTimestamp(
|
||||
dateKey: string,
|
||||
tz: string,
|
||||
): Date {
|
||||
// Compute the UTC offset for noon-local of the given day. We don't
|
||||
// have a lightweight TZ-math library on the server, so the trick is:
|
||||
// build "12:00 UTC of the day", read what wall-clock that shows in
|
||||
// the target zone, then shift by the resulting offset.
|
||||
const utcNoon = new Date(`${dateKey}T12:00:00.000Z`);
|
||||
const offsetMinutes = tzOffsetMinutesAt(utcNoon, tz);
|
||||
// utcNoon represents 12:00 UTC. The user's local clock reads
|
||||
// 12:00 + offsetMinutes at that instant. To anchor at local 12:00,
|
||||
// subtract the offset.
|
||||
return new Date(utcNoon.getTime() - offsetMinutes * 60 * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.37 W10 — Compute the JS-Date instant at the user's local 00:00
|
||||
* for a calendar-day key. Robust on DST transitions because the offset
|
||||
* is read at the UTC-midnight instant of the day, and EU/US DST
|
||||
* transitions happen at local 02:00 / 03:00 — so the offset at UTC
|
||||
* midnight is unambiguous on every day of the year.
|
||||
*
|
||||
* Used by the W7c drill-down branch to resolve [dayStart, dayEnd) on
|
||||
* a 23-h spring-forward or 25-h fall-back day; the previous shape
|
||||
* (`canonicalDailyTimestamp ± 12h`) silently leaked or hid an hour
|
||||
* of samples on two days per year.
|
||||
*
|
||||
* Pair with `localStartOfDay(nextDayKey, tz)` for the right bound so
|
||||
* the returned window covers the true local-day span — 23 h on
|
||||
* spring-forward, 24 h on a regular day, 25 h on fall-back.
|
||||
*/
|
||||
export function localStartOfDay(dateKey: string, tz: string): Date {
|
||||
// Anchor at 00:00 UTC of the day; the offset read at that instant
|
||||
// is the same offset the local clock uses at midnight (DST
|
||||
// transitions in EU/US happen at 02:00 / 03:00 local, not at the
|
||||
// 00:00 boundary). For sub-half-hour zones (Asia/Kathmandu UTC+5:45,
|
||||
// Pacific/Chatham UTC+12:45) the minute component is preserved.
|
||||
const utcMidnight = new Date(`${dateKey}T00:00:00.000Z`);
|
||||
const offsetMinutes = tzOffsetMinutesAt(utcMidnight, tz);
|
||||
// Local 00:00 at this date = UTC midnight - offset.
|
||||
return new Date(utcMidnight.getTime() - offsetMinutes * 60 * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.37 W10 — Resolve the [dayStart, dayEnd) UTC window for a
|
||||
* calendar-day key in the user's IANA timezone. The window honours
|
||||
* DST so the drill-down branch returns the correct 23 / 24 / 25-hour
|
||||
* span for transition days.
|
||||
*
|
||||
* Returns a tuple where `dayEnd` is the local 00:00 of the FOLLOWING
|
||||
* calendar day — `< dayEnd` is the canonical half-open bound used by
|
||||
* the route's `measuredAt: { gte: dayStart, lt: dayEnd }` predicate.
|
||||
*/
|
||||
export function localDayWindow(
|
||||
dateKey: string,
|
||||
tz: string,
|
||||
): { dayStart: Date; dayEnd: Date } {
|
||||
const dayStart = localStartOfDay(dateKey, tz);
|
||||
// Add ONE day to dateKey using UTC arithmetic on a noon anchor (noon
|
||||
// sidesteps every DST edge case for the calendar increment). Then
|
||||
// re-extract the ISO date slice — guaranteed to be the next-day key.
|
||||
const nextUtcNoon = new Date(`${dateKey}T12:00:00.000Z`);
|
||||
nextUtcNoon.setUTCDate(nextUtcNoon.getUTCDate() + 1);
|
||||
const nextDateKey = nextUtcNoon.toISOString().slice(0, 10);
|
||||
const dayEnd = localStartOfDay(nextDateKey, tz);
|
||||
return { dayStart, dayEnd };
|
||||
}
|
||||
|
||||
/**
|
||||
* Group an array of per-sample rows into per-day buckets keyed by the
|
||||
* user's timezone. Exposed for unit testing the bucketing semantics
|
||||
* without booting Prisma.
|
||||
*/
|
||||
export interface PerSampleRow {
|
||||
id: string;
|
||||
type: MeasurementType;
|
||||
value: number;
|
||||
measuredAt: Date;
|
||||
externalId: string | null;
|
||||
/**
|
||||
* Optional — only the mean-consolidation pass selects `unit` so it can
|
||||
* read the canonical unit straight off the day's rows rather than
|
||||
* issuing a separate query. The cumulative drain leaves it unselected.
|
||||
*/
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export interface BucketedRows {
|
||||
/** Map keyed by `YYYY-MM-DD` (user TZ). */
|
||||
byDay: Map<string, PerSampleRow[]>;
|
||||
|
||||
Reference in new issue
Block a user