mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
feat(lint): refuse UTC day keys outside the time zone module
`instant.toISOString().slice(0, 10)` and parsing a key back as UTC midnight are the idioms behind every day-boundary fix this year: the evening west of UTC and the small hours east of it land on the neighbouring day. A new rule, healthlog/no-utc-day-key, refuses them in src/ outside src/lib/tz and tests, and refuses a disable directive for itself that is not next-line or gives no reason. The time zone module now names every legitimate form: userDayKey for a person's day, dateOnlyKey / dateOnlyAtNoonUtc / dayKeyAsUtcMidnight for date-only values and day labels, daysBetweenDateKeys / weekdayOfDateKey / isCalendarDateKey / statedDateKey for key arithmetic and parsing. Of the 133 sites the rule found, 84 are gone. Most moved onto a named helper with no change in behaviour; these changed behaviour: - cycle calendar, insights, Coach snapshot and prediction refresh start their lookbacks at the first instant of the user's day - a model-transcribed date like "June 10, 2026" no longer lands a day early in a process east of UTC (documents and lab scans) - a custom report range covers exactly the picked days in the profile zone instead of starting the evening before west of UTC - date-of-birth pickers cap at the user's today, not the UTC one - an allergy onset date is stored at noon UTC like other date-only values - the report's cycle summary, the Coach's usual BP range and the intake projector's rollup recompute use the user's day - vaccination dates render as dates instead of the previous evening 49 remain, each with its reason on the line above: 39 UTC by design (15 file name stamps, rollup and object keys, the UTC-day score engines) and 10 marked baseline. A guard test requires a classified reason on every directive and holds the baseline to a ceiling that only goes down.
This commit is contained in:
1 parent
5820078008
commit
7c5ff89def
105 files changed
+807
-200
No files matched your search
@@ -132,6 +132,7 @@ export const GET = apiHandler(
|
||||
return apiError("Backup payload failed schema validation", 500);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
|
||||
const isoDate = backup.createdAt.toISOString().slice(0, 10); // YYYY-MM-DD
|
||||
const filename = `healthlog-backup-${backup.userId}-${isoDate}.json`;
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ import { moodDateKey } from "@/lib/mood/date-key";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { startOfLocalDayKey } from "@/lib/tz/local-day";
|
||||
|
||||
const DEFAULT_PAST_DAYS = 90;
|
||||
const DEFAULT_FORWARD_DAYS = 180;
|
||||
@@ -148,7 +149,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
deletedAt: null,
|
||||
type: "WRIST_TEMPERATURE",
|
||||
measuredAt: {
|
||||
gte: new Date(Date.parse(`${addDays(today, -90)}T00:00:00Z`)),
|
||||
gte: startOfLocalDayKey(addDays(today, -90), tz),
|
||||
},
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
import { addDays } from "@/lib/cycle/day-math";
|
||||
import { moodDateKey } from "@/lib/mood/date-key";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import { startOfLocalDayKey } from "@/lib/tz/local-day";
|
||||
|
||||
/** Trailing window the phase-contrast walks (days). */
|
||||
const WINDOW_DAYS = 365;
|
||||
@@ -124,7 +125,7 @@ export const GET = apiHandler(async () => {
|
||||
deletedAt: null,
|
||||
type: "WRIST_TEMPERATURE",
|
||||
measuredAt: {
|
||||
gte: new Date(Date.parse(`${addDays(today, -90)}T00:00:00Z`)),
|
||||
gte: startOfLocalDayKey(addDays(today, -90), tz),
|
||||
},
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
@@ -140,7 +141,7 @@ export const GET = apiHandler(async () => {
|
||||
deletedAt: null,
|
||||
type: { in: PHASE_CROSSTAB_METRIC_TYPES },
|
||||
measuredAt: {
|
||||
gte: new Date(Date.parse(`${from}T00:00:00Z`)),
|
||||
gte: startOfLocalDayKey(from, tz),
|
||||
},
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
@@ -166,7 +167,7 @@ export const GET = apiHandler(async () => {
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
moodLoggedAt: { gte: new Date(Date.parse(`${from}T00:00:00Z`)) },
|
||||
moodLoggedAt: { gte: startOfLocalDayKey(from, tz) },
|
||||
},
|
||||
orderBy: { moodLoggedAt: "asc" },
|
||||
select: { score: true, moodLoggedAt: true },
|
||||
|
||||
@@ -172,6 +172,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
},
|
||||
);
|
||||
|
||||
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
// Return the raw binary archive (NOT the apiSuccess envelope). The file is a
|
||||
// self-contained `.hlx` archive openable with the user's passphrase via
|
||||
|
||||
@@ -88,6 +88,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
},
|
||||
);
|
||||
|
||||
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
// Stream the JSON directly (NOT wrapped in the apiSuccess envelope) so
|
||||
// the file is a self-contained backup — admin upload + restore expect
|
||||
|
||||
@@ -228,6 +228,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
const { t } = getServerTranslator(locale);
|
||||
|
||||
|
||||
@@ -208,6 +208,7 @@ function csvResponse(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
prefix: string,
|
||||
): NextResponse {
|
||||
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
return new NextResponse(body, {
|
||||
status: 200,
|
||||
|
||||
@@ -145,6 +145,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
},
|
||||
});
|
||||
|
||||
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
return new NextResponse(body, {
|
||||
status: 200,
|
||||
|
||||
@@ -83,6 +83,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
},
|
||||
});
|
||||
|
||||
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
return new NextResponse(csv, {
|
||||
status: 200,
|
||||
|
||||
@@ -155,6 +155,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
: formatMoodEntriesForExport(decryptedMood, userTz);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
|
||||
const filename = `healthlog-export-${new Date().toISOString().slice(0, 10)}`;
|
||||
|
||||
if (measurementChunks && format === "csv") {
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
|
||||
|
||||
type Severity = "alert" | "caution" | "info" | "good";
|
||||
|
||||
@@ -135,7 +136,7 @@ export const GET = apiHandler(async () => {
|
||||
rollupCoverage,
|
||||
);
|
||||
const pulseData: DataPoint[] = pulseDayMeans.map((p) => ({
|
||||
date: new Date(`${p.day}T00:00:00.000Z`),
|
||||
date: dayKeyAsUtcMidnight(p.day),
|
||||
value: p.mean,
|
||||
}));
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
} from "@/lib/rollups/mood-rollups";
|
||||
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
|
||||
import { dayKeyAsUtcMidnight, dateOnlyKey } from "@/lib/tz/date-only";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -176,7 +177,7 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
|
||||
let moodEntryCount: number;
|
||||
if (moodRollupDayRows.length > 0) {
|
||||
dailyMoodEntries = moodRollupDayRows.map((r) => ({
|
||||
day: r.bucketStart.toISOString().slice(0, 10),
|
||||
day: dateOnlyKey(r.bucketStart),
|
||||
value: Math.round(r.mean * 100) / 100,
|
||||
}));
|
||||
moodDataPoints = moodRollupDayRows.map((r) => ({
|
||||
@@ -526,6 +527,7 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
|
||||
// the scatter and the coefficient rather than dragging continuity down.
|
||||
const loggedDays = new Set<string>();
|
||||
for (const event of bpMedicationEvents) {
|
||||
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: pairs intake days with the DAY rollup buckets in sysByDay, which are UTC days
|
||||
const dayKey = event.scheduledFor.toISOString().slice(0, 10);
|
||||
loggedDays.add(dayKey);
|
||||
if (event.skipped || !event.takenAt) continue;
|
||||
@@ -540,7 +542,7 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
|
||||
pairs.push({
|
||||
a: continuity,
|
||||
b: avgSys,
|
||||
date: new Date(`${dayKey}T00:00:00.000Z`),
|
||||
date: dayKeyAsUtcMidnight(dayKey),
|
||||
});
|
||||
bpMedicationScatterData.push({
|
||||
continuityPct: Math.round(continuity * 100),
|
||||
|
||||
@@ -17,6 +17,7 @@ import { toManagedProfileView } from "@/lib/managed-profiles/lifecycle";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { createManagedProfileSchema } from "@/lib/validations/managed-profiles";
|
||||
import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
|
||||
|
||||
/**
|
||||
* The same ceiling its guardian sibling carries, and for the same reason.
|
||||
@@ -59,7 +60,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
creatorId: user.id,
|
||||
displayName: parsed.data.displayName,
|
||||
dateOfBirth: parsed.data.dateOfBirth
|
||||
? new Date(`${parsed.data.dateOfBirth}T00:00:00.000Z`)
|
||||
? dayKeyAsUtcMidnight(parsed.data.dateOfBirth)
|
||||
: null,
|
||||
locale: parsed.data.locale,
|
||||
timezone: parsed.data.timezone,
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
resolveSlotForWriteByBand,
|
||||
} from "@/lib/medications/scheduling/slot-upsert";
|
||||
import { NextRequest } from "next/server";
|
||||
import { dateOnlyKey } from "@/lib/tz/date-only";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string; eventId: string }> };
|
||||
|
||||
@@ -100,7 +101,7 @@ export const PUT = apiHandler(
|
||||
});
|
||||
if (medication?.startsOn) {
|
||||
const takenDay = dayKeyForUserTz(data.takenAt, user.timezone);
|
||||
const startsOnDay = medication.startsOn.toISOString().slice(0, 10);
|
||||
const startsOnDay = dateOnlyKey(medication.startsOn);
|
||||
if (takenDay < startsOnDay) {
|
||||
annotate({
|
||||
action: {
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
parseNotificationPrefs,
|
||||
resolveNotificationPrefs,
|
||||
} from "@/lib/validations/notification-prefs";
|
||||
import { dayKeyAsUtcMidnight, dateOnlyKey } from "@/lib/tz/date-only";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -93,7 +94,9 @@ export const GET = apiHandler(
|
||||
settings = {
|
||||
displayName: record.displayName,
|
||||
heightCm: record.heightCm,
|
||||
dateOfBirth: record.dateOfBirth?.toISOString().slice(0, 10) ?? null,
|
||||
dateOfBirth: record.dateOfBirth
|
||||
? dateOnlyKey(record.dateOfBirth)
|
||||
: null,
|
||||
gender: record.gender,
|
||||
locale: record.locale,
|
||||
timezone: record.timezone,
|
||||
@@ -195,7 +198,7 @@ export const PATCH = apiHandler(
|
||||
...(patch.dateOfBirth !== undefined
|
||||
? {
|
||||
dateOfBirth: patch.dateOfBirth
|
||||
? new Date(`${patch.dateOfBirth}T00:00:00.000Z`)
|
||||
? dayKeyAsUtcMidnight(patch.dateOfBirth)
|
||||
: null,
|
||||
}
|
||||
: {}),
|
||||
@@ -235,7 +238,9 @@ export const PATCH = apiHandler(
|
||||
}
|
||||
settings = {
|
||||
...record,
|
||||
dateOfBirth: record.dateOfBirth?.toISOString().slice(0, 10) ?? null,
|
||||
dateOfBirth: record.dateOfBirth
|
||||
? dateOnlyKey(record.dateOfBirth)
|
||||
: null,
|
||||
};
|
||||
changed = Object.keys(patch);
|
||||
break;
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { isCalendarDateKey } from "@/lib/tz/date-only";
|
||||
|
||||
/** Sleep is read row-per-stage, so the window is bounded to one year. */
|
||||
const SLEEP_NIGHT_MAX_DAYS = 365;
|
||||
@@ -48,11 +49,7 @@ const querySchema = z.object({
|
||||
date: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/, "date must be YYYY-MM-DD")
|
||||
.refine((s) => {
|
||||
const parsed = new Date(`${s}T00:00:00Z`);
|
||||
if (Number.isNaN(parsed.getTime())) return false;
|
||||
return s === parsed.toISOString().slice(0, 10);
|
||||
}, "date must be a real calendar date (YYYY-MM-DD)")
|
||||
.refine(isCalendarDateKey, "date must be a real calendar date (YYYY-MM-DD)")
|
||||
.optional(),
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user