mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
feat(settings): a time-format preference and one home for insight pills
The clock format followed the UI language with no way out — an English interface forced AM/PM everywhere. A new profile preference (automatic, 24-hour, 12-hour) feeds every formatter through the shared locale context, persists on the user row and rides the profile endpoints. The insight pill list in settings gains the same eye toggle the overview tiles have, so ordering and visibility live in one place; the page-side 'manage detail pages' block folds into a link there. Includes the locale bundles for both features and the regenerated API contract.
This commit is contained in:
1 parent
3cf48fa0f1
commit
aabc7915cb
28 files changed
+1137
-462
No files matched your search
@@ -68,20 +68,63 @@ describe("makeFormatters", () => {
|
||||
expect(enFmt.date(sample)).toContain("2026");
|
||||
});
|
||||
|
||||
it("formats 24h time in Europe/Berlin regardless of locale", () => {
|
||||
// v1.15.20 — the hour cycle follows the locale convention by default
|
||||
// (AUTO). Pre-v1.15.20 both locales were pinned to 24h via `hour12:
|
||||
// false`; the explicit preference replaced the pin.
|
||||
it("formats time per the locale convention under AUTO", () => {
|
||||
expect(deFmt.time(sample)).toBe("16:30");
|
||||
expect(enFmt.time(sample)).toBe("16:30");
|
||||
expect(enFmt.time(sample)).toBe("04:30 PM");
|
||||
});
|
||||
|
||||
describe("hour-cycle preference (v1.15.20)", () => {
|
||||
it("AUTO follows the locale default for time + dateTime", () => {
|
||||
const de = makeFormatters("de", undefined, "AUTO");
|
||||
const en = makeFormatters("en", undefined, "AUTO");
|
||||
expect(de.time(sample)).toBe("16:30");
|
||||
expect(en.time(sample)).toBe("04:30 PM");
|
||||
expect(de.dateTime(sample)).toBe("18.04.2026, 16:30");
|
||||
expect(en.dateTime(sample)).toBe("04/18/2026, 04:30 PM");
|
||||
});
|
||||
|
||||
it("H12 forces AM/PM regardless of locale", () => {
|
||||
const de = makeFormatters("de", undefined, "H12");
|
||||
const en = makeFormatters("en", undefined, "H12");
|
||||
expect(de.time(sample)).toContain("PM");
|
||||
expect(en.time(sample)).toBe("04:30 PM");
|
||||
expect(de.dateTime(sample)).toContain("PM");
|
||||
});
|
||||
|
||||
it("H24 forces the 24-hour clock regardless of locale", () => {
|
||||
const de = makeFormatters("de", undefined, "H24");
|
||||
const en = makeFormatters("en", undefined, "H24");
|
||||
expect(de.time(sample)).toBe("16:30");
|
||||
expect(en.time(sample)).toBe("16:30");
|
||||
expect(en.dateTime(sample)).toBe("04/18/2026, 16:30");
|
||||
});
|
||||
|
||||
it("H24 renders midnight as 00, never 24 (h23 cycle)", () => {
|
||||
const midnight = new Date("2026-04-18T22:30:00Z"); // 00:30 Berlin CEST
|
||||
const en = makeFormatters("en", undefined, "H24");
|
||||
expect(en.time(midnight)).toBe("00:30");
|
||||
});
|
||||
|
||||
it("does not affect date-only formatters", () => {
|
||||
const auto = makeFormatters("en", undefined, "AUTO");
|
||||
const h24 = makeFormatters("en", undefined, "H24");
|
||||
expect(auto.date(sample)).toBe(h24.date(sample));
|
||||
expect(auto.dateShort(sample)).toBe(h24.dateShort(sample));
|
||||
});
|
||||
});
|
||||
|
||||
// v1.4.25 W7 — formatters accept a per-user timezone override.
|
||||
it("renders time in the user's tz when userTz is passed", () => {
|
||||
const tokyo = makeFormatters("en", "Asia/Tokyo");
|
||||
const tokyo = makeFormatters("en", "Asia/Tokyo", "H24");
|
||||
// 14:30 UTC = 23:30 Tokyo
|
||||
expect(tokyo.time(sample)).toBe("23:30");
|
||||
});
|
||||
|
||||
it("renders time in Europe/Berlin when userTz is empty", () => {
|
||||
const fallback = makeFormatters("en", "");
|
||||
const fallback = makeFormatters("en", "", "H24");
|
||||
expect(fallback.time(sample)).toBe("16:30");
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* v1.15.20 — pins the shared schedule select for compliance call sites.
|
||||
*
|
||||
* Every surface that loads schedules for a compliance computation routes
|
||||
* through `SCHEDULE_COMPLIANCE_SELECT`. The constant must keep selecting
|
||||
* every field the engine consumes — most importantly `doseWindows`, whose
|
||||
* absence silently degrades a user-configured on-time band back to the
|
||||
* symmetric default on that surface (the v1.15.20 audit gap: only 2 of 8
|
||||
* call sites selected it).
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { SCHEDULE_COMPLIANCE_SELECT } from "../compliance";
|
||||
|
||||
describe("SCHEDULE_COMPLIANCE_SELECT", () => {
|
||||
it("selects every schedule field the compliance engine consumes", () => {
|
||||
expect(SCHEDULE_COMPLIANCE_SELECT).toEqual({
|
||||
id: true,
|
||||
windowStart: true,
|
||||
windowEnd: true,
|
||||
daysOfWeek: true,
|
||||
timesOfDay: true,
|
||||
reminderGraceMinutes: true,
|
||||
rrule: true,
|
||||
rollingIntervalDays: true,
|
||||
scheduleType: true,
|
||||
cyclicOnWeeks: true,
|
||||
cyclicOffWeeks: true,
|
||||
doseWindows: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the configured per-dose windows on the wire", () => {
|
||||
// The one field whose loss is silent: the engine falls back to the
|
||||
// ±1 h default and every number still "looks" right.
|
||||
expect(SCHEDULE_COMPLIANCE_SELECT.doseWindows).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,9 @@ const extendedProfileSchema = profileSchema.extend({
|
||||
.refine(isValidTimezone, "Invalid IANA timezone")
|
||||
.optional(),
|
||||
moodReminderEnabled: z.boolean().optional(),
|
||||
// Hour-cycle display preference. AUTO follows the locale convention,
|
||||
// H12 forces AM/PM, H24 forces 24-hour.
|
||||
timeFormat: z.enum(["AUTO", "H12", "H24"]).optional(),
|
||||
});
|
||||
|
||||
export interface ApplyProfileResult {
|
||||
@@ -35,6 +38,7 @@ export interface ApplyProfileResult {
|
||||
gender: string | null;
|
||||
timezone: string;
|
||||
locale: string | null;
|
||||
timeFormat: "AUTO" | "H12" | "H24";
|
||||
moodReminderEnabled: boolean;
|
||||
// v1.7.0 — patient-identity fields. `insuranceNumber` is returned in
|
||||
// plaintext (decrypted on read by the route) so the client can render
|
||||
@@ -101,6 +105,7 @@ export async function applyProfileUpdate(
|
||||
}
|
||||
if (data.locale !== undefined) updates.locale = data.locale;
|
||||
if (data.timezone !== undefined) updates.timezone = data.timezone;
|
||||
if (data.timeFormat !== undefined) updates.timeFormat = data.timeFormat;
|
||||
if (data.moodReminderEnabled !== undefined) {
|
||||
updates.moodReminderEnabled = data.moodReminderEnabled;
|
||||
}
|
||||
@@ -175,6 +180,7 @@ export async function applyProfileUpdate(
|
||||
gender: updatedUser.gender,
|
||||
timezone: updatedUser.timezone,
|
||||
locale: updatedUser.locale,
|
||||
timeFormat: updatedUser.timeFormat ?? "AUTO",
|
||||
moodReminderEnabled: updatedUser.moodReminderEnabled,
|
||||
fullName: updatedUser.fullName,
|
||||
insurerName: updatedUser.insurerName,
|
||||
|
||||
@@ -42,6 +42,31 @@ export function resolveIntlLocale(locale: Locale): string {
|
||||
return INTL_LOCALE_MAP[locale] ?? "en-US";
|
||||
}
|
||||
|
||||
/**
|
||||
* Hour-cycle display preference (mirrors the `TimeFormatPreference` Prisma
|
||||
* enum). AUTO defers to the locale's own convention (en-US → AM/PM, de-DE →
|
||||
* 24h); H12 forces AM/PM; H24 forces a 24-hour clock ("h23": midnight is
|
||||
* 00:30, never 24:30). Display-time only — stored instants stay UTC.
|
||||
*/
|
||||
export type TimeFormatPreference = "AUTO" | "H12" | "H24";
|
||||
|
||||
/**
|
||||
* Intl options for the requested hour cycle. AUTO contributes nothing so
|
||||
* `Intl.DateTimeFormat` falls back to the locale default.
|
||||
*/
|
||||
function hourCycleOptions(
|
||||
timeFormat: TimeFormatPreference,
|
||||
): Intl.DateTimeFormatOptions {
|
||||
switch (timeFormat) {
|
||||
case "H12":
|
||||
return { hour12: true };
|
||||
case "H24":
|
||||
return { hourCycle: "h23" };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
type DateInput = Date | string | number;
|
||||
|
||||
function asDate(value: DateInput): Date {
|
||||
@@ -59,9 +84,9 @@ export interface Formatters {
|
||||
date: (value: DateInput) => string;
|
||||
/** Short date without year, e.g. "19.02." / "Feb 19". */
|
||||
dateShort: (value: DateInput) => string;
|
||||
/** Full date + time in 24h. */
|
||||
/** Full date + time; hour cycle follows the `timeFormat` preference. */
|
||||
dateTime: (value: DateInput) => string;
|
||||
/** Time only in 24h, e.g. "14:30". */
|
||||
/** Time only, e.g. "14:30" or "2:30 PM" per the `timeFormat` preference. */
|
||||
time: (value: DateInput) => string;
|
||||
/** Short weekday + date, e.g. "Do., 19.02." / "Thu, Feb 19". */
|
||||
dateWithWeekday: (value: DateInput) => string;
|
||||
@@ -69,9 +94,14 @@ export interface Formatters {
|
||||
monthShort: (value: DateInput) => string;
|
||||
}
|
||||
|
||||
export function makeFormatters(locale: Locale, userTz?: string): Formatters {
|
||||
export function makeFormatters(
|
||||
locale: Locale,
|
||||
userTz?: string,
|
||||
timeFormat: TimeFormatPreference = "AUTO",
|
||||
): Formatters {
|
||||
const intlLocale = resolveIntlLocale(locale);
|
||||
const tz = userTz && userTz.length > 0 ? userTz : DISPLAY_TIMEZONE;
|
||||
const hourOpts = hourCycleOptions(timeFormat);
|
||||
|
||||
return {
|
||||
number: (value, fractionDigits) =>
|
||||
@@ -115,7 +145,7 @@ export function makeFormatters(locale: Locale, userTz?: string): Formatters {
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
...hourOpts,
|
||||
}),
|
||||
|
||||
time: (value) =>
|
||||
@@ -123,7 +153,7 @@ export function makeFormatters(locale: Locale, userTz?: string): Formatters {
|
||||
timeZone: tz,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
...hourOpts,
|
||||
}),
|
||||
|
||||
dateWithWeekday: (value) =>
|
||||
|
||||
+5
-1
@@ -24,6 +24,7 @@
|
||||
*/
|
||||
|
||||
import { makeFormatters } from "./format-locale";
|
||||
import { readStoredTimeFormat } from "./time-format";
|
||||
import { locales, type Locale } from "./i18n/config";
|
||||
|
||||
function isLocale(value: string | null | undefined): value is Locale {
|
||||
@@ -45,7 +46,10 @@ function activeLocale(): Locale {
|
||||
}
|
||||
|
||||
function formatters() {
|
||||
return makeFormatters(activeLocale());
|
||||
// Honour the mirrored hour-cycle preference so these legacy helpers render
|
||||
// the same clock as `useFormatters()` call sites. SSR reads AUTO — same
|
||||
// post-hydration caveat as `activeLocale()` above.
|
||||
return makeFormatters(activeLocale(), undefined, readStoredTimeFormat());
|
||||
}
|
||||
|
||||
/** Locale-aware "19.02.2026, 14:30" or "02/19/2026, 2:30 PM". */
|
||||
|
||||
@@ -7,10 +7,19 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
useCallback,
|
||||
useSyncExternalStore,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { locales, defaultLocale, type Locale } from "./config";
|
||||
import { makeFormatters, type Formatters } from "../format-locale";
|
||||
import {
|
||||
makeFormatters,
|
||||
type Formatters,
|
||||
type TimeFormatPreference,
|
||||
} from "../format-locale";
|
||||
import {
|
||||
readStoredTimeFormat,
|
||||
subscribeTimeFormat,
|
||||
} from "../time-format";
|
||||
import { allMessages, resolveKey } from "./shared-resolve";
|
||||
|
||||
interface I18nContextValue {
|
||||
@@ -133,12 +142,32 @@ export function useTranslations() {
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's hour-cycle preference (AUTO / H12 / H24), reactive to changes.
|
||||
* Backed by the localStorage mirror that `useAuth`'s `/api/auth/me` fetch
|
||||
* and the profile time-format select keep in sync with the server value —
|
||||
* no QueryClient required in the tree. SSR resolves AUTO.
|
||||
*/
|
||||
export function useTimeFormatPreference(): TimeFormatPreference {
|
||||
return useSyncExternalStore(
|
||||
subscribeTimeFormat,
|
||||
readStoredTimeFormat,
|
||||
() => "AUTO" as const,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Locale-aware formatters tied to the active UI locale. Use for every number,
|
||||
* date, and time rendered in the UI so regional conventions (70,5 vs 70.5,
|
||||
* 19.02.2026 vs Feb 19, 2026) follow the user's language choice.
|
||||
* 19.02.2026 vs Feb 19, 2026) follow the user's language choice. Times honour
|
||||
* the per-user hour-cycle preference (AUTO follows the locale, H12 / H24 pin
|
||||
* the cycle).
|
||||
*/
|
||||
export function useFormatters(): Formatters {
|
||||
const { locale } = useTranslations();
|
||||
return useMemo(() => makeFormatters(locale), [locale]);
|
||||
const timeFormat = useTimeFormatPreference();
|
||||
return useMemo(
|
||||
() => makeFormatters(locale, undefined, timeFormat),
|
||||
[locale, timeFormat],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Client-side mirror of the per-user hour-cycle preference.
|
||||
*
|
||||
* The server-authoritative value lives on the user row (`users.time_format`,
|
||||
* surfaced by `/api/auth/me` and editable via the profile PATCH). The mirror
|
||||
* follows the `healthlog-locale` pattern: `fetchMe` writes the resolved value
|
||||
* into localStorage so that
|
||||
*
|
||||
* 1. `useFormatters()` can read it through `useSyncExternalStore` without
|
||||
* requiring a QueryClient in the tree, and
|
||||
* 2. the legacy helpers in `src/lib/format.ts` (plain functions, no React
|
||||
* context) render the same hour cycle as hook-based call sites.
|
||||
*
|
||||
* SSR always resolves AUTO (`window` is undefined); the same caveat as the
|
||||
* locale formatters applies — current call sites render their formatted
|
||||
* strings post-fetch, so there is no hydration-mismatch path today.
|
||||
*/
|
||||
|
||||
import type { TimeFormatPreference } from "./format-locale";
|
||||
|
||||
const STORAGE_KEY = "healthlog-time-format";
|
||||
const CHANGE_EVENT = "healthlog:time-format-change";
|
||||
|
||||
export const TIME_FORMAT_PREFERENCES = ["AUTO", "H12", "H24"] as const;
|
||||
|
||||
export function isTimeFormatPreference(
|
||||
value: unknown,
|
||||
): value is TimeFormatPreference {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
(TIME_FORMAT_PREFERENCES as readonly string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
/** Best-effort read of the mirrored preference. AUTO on SSR / no mirror. */
|
||||
export function readStoredTimeFormat(): TimeFormatPreference {
|
||||
if (typeof window === "undefined") return "AUTO";
|
||||
try {
|
||||
const stored = window.localStorage?.getItem(STORAGE_KEY);
|
||||
return isTimeFormatPreference(stored) ? stored : "AUTO";
|
||||
} catch {
|
||||
return "AUTO";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the mirror and notify same-tab subscribers. Cross-tab updates ride
|
||||
* the browser's native `storage` event.
|
||||
*/
|
||||
export function storeTimeFormat(value: TimeFormatPreference): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
const previous = window.localStorage?.getItem(STORAGE_KEY);
|
||||
if (previous === value) return;
|
||||
window.localStorage?.setItem(STORAGE_KEY, value);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
window.dispatchEvent(new Event(CHANGE_EVENT));
|
||||
}
|
||||
|
||||
/** `useSyncExternalStore`-shaped subscription for the mirror. */
|
||||
export function subscribeTimeFormat(onChange: () => void): () => void {
|
||||
if (typeof window === "undefined") return () => {};
|
||||
window.addEventListener(CHANGE_EVENT, onChange);
|
||||
window.addEventListener("storage", onChange);
|
||||
return () => {
|
||||
window.removeEventListener(CHANGE_EVENT, onChange);
|
||||
window.removeEventListener("storage", onChange);
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user