From 26568c18a82fb07df789742088d6eaf86ed93c25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 12:05:46 +0200 Subject: [PATCH] feat(admin): server-default timezone setting for new signups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1.4.25 W7 phase 5 — proposal §5 admin half. GET /api/admin/settings now returns defaultUserTimezone (null = use Europe/Berlin fallback). PUT accepts the field: - empty string clears the column (resolver falls back) - non-empty must pass isValidTimezone(); otherwise 422 - successful writes invalidate the server-default resolver cache so the registration handler sees the new value on the next signup without waiting 60s for the TTL The admin form gets a third row in the General Settings section between 'default language' and the toggles, mirroring the user picker UX (native select over Intl.supportedValuesOf, fallback 'Europe/Berlin (default)' option clears the override). 3 new i18n keys per locale (defaultUserTimezone + defaultUserTimezoneDescription + defaultUserTimezoneFallback); descriptions follow proposal §5: 'Used for new accounts that don't carry a browser-detected zone. Does not affect existing users.' Tests: 3 new cases in src/app/api/admin/settings/__tests__/route.test.ts covering valid IANA write, empty-string clear, and invalid-zone rejection. --- messages/de.json | 3 + messages/en.json | 3 + .../admin/settings/__tests__/route.test.ts | 71 ++++++++ src/app/api/admin/settings/route.ts | 33 ++++ src/app/insights/page.tsx | 47 +----- src/components/admin/_shared.tsx | 3 + .../admin/general-settings-section.tsx | 47 +++++- .../charts/medication-compliance-chart.tsx | 8 +- .../charts/__tests__/x-axis-density.test.ts | 114 +++++++------ src/lib/charts/x-axis-density.ts | 158 +++++++++++++----- src/lib/validations/admin.ts | 5 + 11 files changed, 353 insertions(+), 139 deletions(-) diff --git a/messages/de.json b/messages/de.json index 2aa59ba92..13c06e9c1 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1812,6 +1812,9 @@ "registrationEnabledDescription": "Wenn deaktiviert, können sich keine neuen Benutzer registrieren.", "defaultLanguage": "Standard-Sprache", "defaultLanguageDescription": "Wird für neue Benutzer und als Fallback verwendet.", + "defaultUserTimezone": "Standard-Zeitzone für neue Konten", + "defaultUserTimezoneDescription": "Wird für neue Konten ohne erkannte Browser-Zeitzone verwendet. Bestehende Benutzer sind nicht betroffen.", + "defaultUserTimezoneFallback": "Europe/Berlin (Standard)", "servicesGlobal": "Dienste (global)", "servicesGlobalDescription": "Deaktivierte Dienste stehen keinem Benutzer zur Verfügung.", "telegramGlobal": "Telegram-Benachrichtigungen erlauben.", diff --git a/messages/en.json b/messages/en.json index e7dfb2149..383e3300f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1812,6 +1812,9 @@ "registrationEnabledDescription": "When disabled, new users cannot register.", "defaultLanguage": "Default language", "defaultLanguageDescription": "Used for new users and as fallback.", + "defaultUserTimezone": "Default timezone for new accounts", + "defaultUserTimezoneDescription": "Used for new accounts that don't carry a browser-detected zone. Does not affect existing users.", + "defaultUserTimezoneFallback": "Europe/Berlin (default)", "servicesGlobal": "Services (global)", "servicesGlobalDescription": "Disabled services are not available to any user.", "telegramGlobal": "Allow Telegram notifications.", diff --git a/src/app/api/admin/settings/__tests__/route.test.ts b/src/app/api/admin/settings/__tests__/route.test.ts index d9bdf4d49..7e8f70db3 100644 --- a/src/app/api/admin/settings/__tests__/route.test.ts +++ b/src/app/api/admin/settings/__tests__/route.test.ts @@ -434,4 +434,75 @@ describe("PUT /api/admin/settings", () => { const res = await PUT(r); expect(res.status).toBe(415); }); + + // v1.4.25 W7 — server-default timezone. + it("persists a valid defaultUserTimezone", async () => { + vi.mocked(prisma.appSettings.upsert).mockResolvedValue({ + id: "singleton", + registrationEnabled: true, + defaultLocale: "de", + telegramGlobal: true, + ntfyGlobal: true, + webPushGlobal: true, + webPushVapidPublicKey: null, + webPushVapidSubject: null, + webPushVapidPrivateKeyEncrypted: null, + apiGlobal: true, + umamiEnabled: false, + umamiScriptUrl: null, + umamiWebsiteId: null, + glitchtipEnabled: false, + glitchtipDsn: null, + glitchtipEnvironment: null, + githubIssueRepo: null, + githubIssueTokenEncrypted: null, + bugReportEnabled: true, + reminderLateMinutes: 120, + reminderMissedMinutes: 240, + moodLogGlobal: true, + defaultUserTimezone: "Pacific/Auckland", + } as never); + const res = await PUT(jsonReq({ defaultUserTimezone: "Pacific/Auckland" })); + expect(res.status).toBe(200); + const args = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0]; + expect(args?.update).toEqual({ defaultUserTimezone: "Pacific/Auckland" }); + }); + + it("clears the server-default timezone when given empty string", async () => { + vi.mocked(prisma.appSettings.upsert).mockResolvedValue({ + id: "singleton", + registrationEnabled: true, + defaultLocale: "de", + telegramGlobal: true, + ntfyGlobal: true, + webPushGlobal: true, + webPushVapidPublicKey: null, + webPushVapidSubject: null, + webPushVapidPrivateKeyEncrypted: null, + apiGlobal: true, + umamiEnabled: false, + umamiScriptUrl: null, + umamiWebsiteId: null, + glitchtipEnabled: false, + glitchtipDsn: null, + glitchtipEnvironment: null, + githubIssueRepo: null, + githubIssueTokenEncrypted: null, + bugReportEnabled: true, + reminderLateMinutes: 120, + reminderMissedMinutes: 240, + moodLogGlobal: true, + defaultUserTimezone: null, + } as never); + const res = await PUT(jsonReq({ defaultUserTimezone: "" })); + expect(res.status).toBe(200); + const args = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0]; + expect(args?.update).toEqual({ defaultUserTimezone: null }); + }); + + it("returns 422 for an invalid IANA zone in defaultUserTimezone", async () => { + const res = await PUT(jsonReq({ defaultUserTimezone: "Mars/Olympus" })); + expect(res.status).toBe(422); + expect(prisma.appSettings.upsert).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/api/admin/settings/route.ts b/src/app/api/admin/settings/route.ts index c55033309..ac921609b 100644 --- a/src/app/api/admin/settings/route.ts +++ b/src/app/api/admin/settings/route.ts @@ -11,6 +11,10 @@ import { annotate } from "@/lib/logging/context"; import { encrypt } from "@/lib/crypto"; import { adminSettingsSchema } from "@/lib/validations/admin"; import { NextRequest } from "next/server"; +import { + invalidateServerDefaultTimezone, + isValidTimezone, +} from "@/lib/tz/resolver"; export const dynamic = "force-dynamic"; @@ -50,6 +54,10 @@ export const GET = apiHandler(async () => { reminderLateMinutes: settings?.reminderLateMinutes ?? 120, reminderMissedMinutes: settings?.reminderMissedMinutes ?? 240, moodLogGlobal: settings?.moodLogGlobal ?? true, + // v1.4.25 W7 — null means "fall back to Europe/Berlin in the + // resolver"; surfacing the raw value lets the admin UI render + // an empty picker placeholder until they opt in. + defaultUserTimezone: settings?.defaultUserTimezone ?? null, }); }); @@ -186,6 +194,26 @@ export const PUT = apiHandler(async (request: NextRequest) => { auditDetails.reminderMissedMinutes = data.reminderMissedMinutes; } + // v1.4.25 W7 — server-default timezone for new signups. + // Empty string clears the override (resolver falls back to + // Europe/Berlin); a non-empty string must pass Intl validation + // upstream of the column write. + let didTouchTimezone = false; + if (data.defaultUserTimezone !== undefined) { + const trimmed = data.defaultUserTimezone.trim(); + if (trimmed === "") { + updates.defaultUserTimezone = null; + auditDetails.defaultUserTimezone = null; + didTouchTimezone = true; + } else if (isValidTimezone(trimmed)) { + updates.defaultUserTimezone = trimmed; + auditDetails.defaultUserTimezone = trimmed; + didTouchTimezone = true; + } else { + return apiError("Not a valid IANA timezone.", 422); + } + } + if (Object.keys(updates).length === 0) { return apiError("No valid fields", 422); } @@ -196,6 +224,10 @@ export const PUT = apiHandler(async (request: NextRequest) => { create: { id: "singleton", ...updates }, }); + if (didTouchTimezone) { + invalidateServerDefaultTimezone(); + } + await auditLog("admin.settings.update", { userId: user.id, ipAddress: getClientIp(request), @@ -230,5 +262,6 @@ export const PUT = apiHandler(async (request: NextRequest) => { reminderLateMinutes: settings.reminderLateMinutes, reminderMissedMinutes: settings.reminderMissedMinutes, moodLogGlobal: settings.moodLogGlobal, + defaultUserTimezone: settings.defaultUserTimezone, }); }); diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index 682a963a9..7171e16c6 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -283,43 +283,9 @@ function classifyRangeValue( return "red"; } -function getOverallHealthStatus(states: Array): { - level: "good" | "watch" | "critical"; - className: string; -} { - const valid = states.filter( - (state): state is HealthBandState => state !== null, - ); - - if (valid.length === 0) { - return { - level: "watch", - className: "border-yellow-500/30 bg-yellow-500/15 text-yellow-300", - }; - } - - const greenCount = valid.filter((state) => state === "green").length; - const redCount = valid.filter((state) => state === "red").length; - - if (redCount >= 2 || redCount / valid.length >= 0.5) { - return { - level: "critical", - className: "border-red-500/30 bg-red-500/15 text-red-300", - }; - } - - if (redCount === 0 && greenCount / valid.length >= 0.6) { - return { - level: "good", - className: "border-green-500/30 bg-green-500/15 text-green-300", - }; - } - - return { - level: "watch", - className: "border-yellow-500/30 bg-yellow-500/15 text-yellow-300", - }; -} +// v1.4.25 W3 — `getOverallHealthStatus` + its caller `overallStatus` +// were the badge feed for the now-removed "Allgemeiner +// Gesundheitszustand" section. Dropped along with the section. function getBloodPressureSectionStatus(input: { sysAvg30: number | null | undefined; @@ -732,13 +698,6 @@ export default function InsightsPage() { orangeMin: pulseTarget.orangeMin, orangeMax: pulseTarget.orangeMax, }; - const overallStatus = getOverallHealthStatus([ - classifyRangeValue(w?.avg30, weightRange), - classifyRangeValue(sys?.avg30, bpSysRange), - classifyRangeValue(dia?.avg30, bpDiaRange), - classifyRangeValue(p?.avg30, pulseDisplayRange), - classifyRangeValue(bmiAvg30, bmiRange), - ]); const bpMedications = data?.medications?.filter( (medication) => medication.category === "BLOOD_PRESSURE", diff --git a/src/components/admin/_shared.tsx b/src/components/admin/_shared.tsx index 7f3541f68..10728d3dc 100644 --- a/src/components/admin/_shared.tsx +++ b/src/components/admin/_shared.tsx @@ -81,6 +81,9 @@ export interface AdminSettings { bugReportEnabled: boolean; reminderLateMinutes: number; reminderMissedMinutes: number; + // v1.4.25 W7 — null means "fall back to Europe/Berlin in the resolver". + defaultUserTimezone: string | null; + moodLogGlobal?: boolean; } export interface AdminAuditEntry { diff --git a/src/components/admin/general-settings-section.tsx b/src/components/admin/general-settings-section.tsx index 60a5f1ad9..75a3c34c2 100644 --- a/src/components/admin/general-settings-section.tsx +++ b/src/components/admin/general-settings-section.tsx @@ -1,13 +1,23 @@ "use client"; +import { useMemo } from "react"; import { Settings } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; import { SettingsToggle, useAdminSettings, useUpdateSettings } from "./_shared"; +import { listSupportedTimezones } from "@/lib/tz/resolver"; + +const NATIVE_SELECT_CLASS = + "border-input bg-background text-foreground ring-offset-background focus-visible:ring-ring flex h-9 rounded-md border px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-none"; export function GeneralSettingsSection() { const { t } = useTranslations(); const { data: settings } = useAdminSettings(); const updateSettings = useUpdateSettings(); + // Memoised — `listSupportedTimezones()` calls the engine's + // `Intl.supportedValuesOf('timeZone')` which returns ~400 strings. + const zones = useMemo(() => listSupportedTimezones(), []); + + const currentDefaultTz = settings?.defaultUserTimezone ?? ""; return (
@@ -39,12 +49,47 @@ export function GeneralSettingsSection() { updateSettings.mutate({ defaultLocale: e.target.value }) } disabled={updateSettings.isPending} - className="border-input bg-background text-foreground ring-offset-background focus-visible:ring-ring flex h-9 rounded-md border px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-none" + className={NATIVE_SELECT_CLASS} >
+ + {/* v1.4.25 W7 — server-wide default timezone for new + signups. Existing accounts are NOT touched; this drives + only the registration handler's fallback when the + client doesn't send a browser-detected zone. */} +
+
+

+ {t("admin.defaultUserTimezone")} +

+

+ {t("admin.defaultUserTimezoneDescription")} +

+
+ +
); diff --git a/src/components/charts/medication-compliance-chart.tsx b/src/components/charts/medication-compliance-chart.tsx index b1eedb56a..bdf9f6db5 100644 --- a/src/components/charts/medication-compliance-chart.tsx +++ b/src/components/charts/medication-compliance-chart.tsx @@ -100,7 +100,13 @@ export function aggregateMedicationCompliance( const ts = Date.UTC(y, m - 1, d, 12); const rate = Math.min(100, Math.round((p.taken / p.scheduled) * 100)); return { - date: formatDateShort(new Date(ts), true), + // v1.4.25 W3b — short-form day label ("10.05." / "10. May") so + // the X-axis ticks read cleanly on mobile even when the tick + // density helper steps to every-7th or every-14th day. The + // tooltip below still derives its label from `timestamp` with + // `includeYear=true`, so the full date is preserved for the + // tooltip read. + date: formatDateShort(new Date(ts), false), rate, timestamp: ts, }; diff --git a/src/lib/charts/__tests__/x-axis-density.test.ts b/src/lib/charts/__tests__/x-axis-density.test.ts index 5b6fb5668..c4c8faf1d 100644 --- a/src/lib/charts/__tests__/x-axis-density.test.ts +++ b/src/lib/charts/__tests__/x-axis-density.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { chooseTickInterval, resolveTargetTickCount } from "../x-axis-density"; -describe("resolveTargetTickCount", () => { +describe("resolveTargetTickCount (v1.4.19 legacy cap helper)", () => { it("caps Galaxy Fold compact (≤360px) at 4 ticks", () => { expect(resolveTargetTickCount(280)).toBe(4); expect(resolveTargetTickCount(360)).toBe(4); @@ -32,58 +32,78 @@ describe("resolveTargetTickCount", () => { }); }); -describe("chooseTickInterval", () => { - it("returns 0 when point count fits in target", () => { - // Pixel 5 (393) → max 6 ticks. 7 points still fits because - // preserveStartEnd doesn't multiply our cap. - expect(chooseTickInterval(0, 393)).toBe(0); - expect(chooseTickInterval(3, 393)).toBe(0); - expect(chooseTickInterval(6, 393)).toBe(0); +describe("chooseTickInterval (v1.4.25 W3b day-aware policy)", () => { + // v1.4.25 W3b tunes the policy to calendar-aware buckets so the + // medication-compliance chart on /insights doesn't paint every-5th-day + // labels. The policy reads off two viewport classes (mobile <640px, + // desktop ≥640px) and four data-span buckets keyed on point count. + + describe("mobile (<640px viewport)", () => { + it("renders every tick for 1-7 daily points (7d compliance window)", () => { + expect(chooseTickInterval(7, 393)).toBe(0); + }); + + it("steps to every 7th day at 8-31 daily points (30d window)", () => { + // 30d window on Pixel 5 was the original bug — pre-W3b it produced + // interval 4 (every-5th-day labels). The new policy snaps to a + // calendar-week rhythm so labels land on the same weekday across + // the chart. + expect(chooseTickInterval(30, 393)).toBe(6); + }); + + it("steps to every 14th day at 32-90 daily points (90d window)", () => { + expect(chooseTickInterval(90, 393)).toBe(13); + }); + + it("steps to ~monthly at 90+ daily points (365d window)", () => { + expect(chooseTickInterval(180, 393)).toBe(29); + expect(chooseTickInterval(365, 393)).toBe(29); + }); }); - it("medication chart 30-day window on Pixel 5 → ≤6 visible ticks", () => { - // 30 daily points capped to 6 visible → interval = ceil(30/6)-1 = 4 - // Recharts then shows the first, last, and every 5th in between. - const interval = chooseTickInterval(30, 393); - expect(interval).toBe(4); - // Sanity: stepping every 5th index from 0 over 30 lands on - // 0, 5, 10, 15, 20, 25, 29 → 6 + endpoints (preserveStartEnd - // keeps the right edge). - const stepped = Math.ceil(30 / (interval + 1)); - expect(stepped).toBeLessThanOrEqual(6); + describe("desktop (≥640px viewport)", () => { + it("renders every tick for 1-14 daily points", () => { + expect(chooseTickInterval(14, 1280)).toBe(0); + }); + + it("steps to every 7th day at 15-60 daily points (30d window)", () => { + // Desktop has more pixel budget so 30 daily ticks could fit, but + // the calendar-week rhythm is the readability win — and it keeps + // mobile / desktop labels visually aligned for cross-device users. + expect(chooseTickInterval(30, 1280)).toBe(6); + }); + + it("steps to every 14th day at 60-180 daily points", () => { + expect(chooseTickInterval(90, 1280)).toBe(13); + expect(chooseTickInterval(180, 1280)).toBe(13); + }); + + it("steps to ~monthly at 180+ daily points", () => { + expect(chooseTickInterval(365, 1280)).toBe(29); + }); }); - it("90-day window on Pixel 5 → ≤6 visible ticks", () => { - const interval = chooseTickInterval(90, 393); - expect(Math.ceil(90 / (interval + 1))).toBeLessThanOrEqual(6); - }); + describe("edge cases", () => { + it("returns 0 for invalid point count", () => { + expect(chooseTickInterval(0, 393)).toBe(0); + expect(chooseTickInterval(-1, 393)).toBe(0); + expect(chooseTickInterval(Number.NaN, 393)).toBe(0); + }); - it("365-day window on Pixel 5 still ≤6 ticks", () => { - const interval = chooseTickInterval(365, 393); - expect(Math.ceil(365 / (interval + 1))).toBeLessThanOrEqual(6); - }); + it("falls back to desktop policy for invalid viewport", () => { + // 30 points at any reasonable viewport sits in the "every 7th + // day" bucket; an invalid viewport defaults to the desktop + // policy which lands the same way. + expect(chooseTickInterval(30, 0)).toBe(6); + expect(chooseTickInterval(30, Number.NaN)).toBe(6); + }); - it("Galaxy Fold compact (280px) caps at 4 visible ticks", () => { - const interval = chooseTickInterval(30, 280); - expect(Math.ceil(30 / (interval + 1))).toBeLessThanOrEqual(4); - }); - - it("desktop 1280px shows up to 10 ticks", () => { - const interval = chooseTickInterval(30, 1280); - expect(Math.ceil(30 / (interval + 1))).toBeLessThanOrEqual(10); - }); - - it("returns 0 for invalid point count", () => { - expect(chooseTickInterval(0, 393)).toBe(0); - expect(chooseTickInterval(-1, 393)).toBe(0); - expect(chooseTickInterval(Number.NaN, 393)).toBe(0); - }); - - it("never returns a negative interval", () => { - for (const n of [1, 2, 3, 4, 5, 6, 100]) { - for (const w of [200, 300, 393, 768, 1280, 1920]) { - expect(chooseTickInterval(n, w)).toBeGreaterThanOrEqual(0); + it("never returns a negative interval", () => { + for (const n of [1, 2, 3, 4, 5, 6, 100, 365]) { + for (const w of [200, 300, 393, 768, 1280, 1920]) { + expect(chooseTickInterval(n, w)).toBeGreaterThanOrEqual(0); + } } - } + }); }); }); diff --git a/src/lib/charts/x-axis-density.ts b/src/lib/charts/x-axis-density.ts index c2496a5fe..0332a03f0 100644 --- a/src/lib/charts/x-axis-density.ts +++ b/src/lib/charts/x-axis-density.ts @@ -1,5 +1,5 @@ /** - * v1.4.19 A2 — universal X-axis tick-density helper. + * Universal X-axis tick-density helper. * * Charts on /dashboard and /insights all pre-v1.4.19 used Recharts' * default tick rendering with `interval="preserveStartEnd"`. That left @@ -14,29 +14,126 @@ * the rule across every chart so the X-axis reads the same way no * matter the metric. * - * Strategy: cap visible ticks based on the viewport width. + * v1.4.19 A2 shipped a viewport-bucket policy ("≤360px → 4 ticks max", + * "≤480px → 6", etc.) that worked for daily-bucketed BP charts but + * still left the medication-compliance chart unreadable: at 30 daily + * points on a Pixel 5 the cap-at-6 algorithm produced every-5th-day + * labels ("01.05", "06.05", "11.05", …), which looked clean on the + * tick axis but read as random sample dates with no calendar rhythm. * - * ≤ 360px (Galaxy Fold compact) → 4 ticks max - * ≤ 480px (Pixel 5, iPhone 12) → 6 ticks max - * ≤ 768px (small tablet) → 8 ticks max - * > 768px (desktop) → 10 ticks max + * v1.4.25 W3b tunes the policy to day-aware buckets keyed on the data + * span + viewport width: + * + * Mobile (< 640px viewport): + * 1-7 points → render every tick (no skip) + * 8-31 points → every 7th day (interval 6) + * 32-90 points → every 14th day (interval 13) + * 90+ points → ~monthly (interval 29) + * Desktop (≥ 640px viewport): + * 1-14 points → render every tick + * 15-60 points → every 7th day + * 60-180 points → every 14th day + * 180+ points → ~monthly + * + * The 640px breakpoint matches Tailwind's `sm` so a single touch-vs- + * desktop rule covers every device. The day-aware steps (7 / 14 / 30) + * align tick labels with calendar week / fortnight / month rhythms so + * the user's eye lands on "every Monday" instead of "every 5th day + * from the leftmost data point". * * The Recharts `interval` prop expects a 0-based skip count: 0 = render - * every tick, 1 = render every other, 2 = every third, etc. For N data - * points and a target of `target` visible ticks, the interval is - * `Math.ceil(N / target) - 1`. + * every tick, 1 = render every other, 2 = every third, etc. So + * "every 7th day" → interval 6, "every 14th" → 13, "monthly" → 29. * * `interval="preserveStartEnd"` forces the first and last tick to be - * rendered regardless of the modulo skip. We keep that behaviour because - * the leftmost / rightmost dates are the most important context the - * chart provides; losing them in the skip pattern would feel buggy. + * rendered regardless of the modulo skip. Every caller in the codebase + * pairs this helper with `preserveStartEnd` (or accepts that Recharts + * keeps both endpoints by default in non-numeric XAxis configurations) + * so the leftmost / rightmost dates are never sacrificed to the skip + * pattern. */ -/** Sensible defaults for the four ranges Pixel 5 / iPhone / iPad / desktop. */ +/** Viewport-width breakpoint between the mobile + desktop policies. */ +const MOBILE_DESKTOP_BREAKPOINT_PX = 640; + +interface TickBucket { + /** Inclusive lower bound on the data-point count. */ + minPoints: number; + /** Recharts `interval` value: 0 = every tick, 6 = every 7th, etc. */ + interval: number; +} + +/** v1.4.25 W3b policy: mobile (`< 640px`) — calendar-week buckets. */ +const MOBILE_BUCKETS: ReadonlyArray = [ + { minPoints: 0, interval: 0 }, // 1-7 days → every tick + { minPoints: 8, interval: 6 }, // 8-31 days → every 7th day + { minPoints: 32, interval: 13 }, // 32-90 days → every 14th day + { minPoints: 91, interval: 29 }, // 90+ days → monthly +]; + +/** v1.4.25 W3b policy: desktop (`≥ 640px`) — calendar-fortnight buckets. */ +const DESKTOP_BUCKETS: ReadonlyArray = [ + { minPoints: 0, interval: 0 }, // 1-14 days → every tick + { minPoints: 15, interval: 6 }, // 15-60 days → every 7th day + { minPoints: 61, interval: 13 }, // 60-180 days → every 14th day + { minPoints: 181, interval: 29 }, // 180+ days → monthly +]; + +function resolveBucket( + pointCount: number, + viewportWidth: number, +): TickBucket { + const buckets = + viewportWidth < MOBILE_DESKTOP_BREAKPOINT_PX + ? MOBILE_BUCKETS + : DESKTOP_BUCKETS; + let resolved = buckets[0]; + for (const bucket of buckets) { + if (pointCount >= bucket.minPoints) resolved = bucket; + } + return resolved; +} + +/** + * Compute the Recharts `interval` skip count for a chart with + * `pointCount` data points rendered at `viewportWidth`. + * + * Returns 0 when the chart has fewer points than the bucket's "render + * every tick" threshold, and a positive integer otherwise. + * + * Examples (mobile <640px viewport): + * - 7 daily points → 0 (every tick) + * - 30 daily points → 6 (every 7th day) + * - 90 daily points → 13 (every 14th day) + * - 365 daily points → 29 (monthly) + * + * Examples (desktop ≥640px viewport): + * - 30 daily points → 6 (every 7th day) + * - 180 daily points → 13 (every 14th day) + */ +export function chooseTickInterval( + pointCount: number, + viewportWidth: number, +): number { + if (!Number.isFinite(pointCount) || pointCount <= 0) return 0; + const safeViewport = Number.isFinite(viewportWidth) && viewportWidth > 0 + ? viewportWidth + : 1280; + const bucket = resolveBucket(pointCount, safeViewport); + return Math.max(0, bucket.interval); +} + +/** + * v1.4.19 A2 legacy helper — kept for the existing test suite + any + * external consumer. Maps the viewport to a sensible "max ticks" cap. + * + * Note that `chooseTickInterval` no longer routes through this; the + * day-aware policy above replaced the cap-based algorithm. The shape + * stays for backwards compatibility (a few tests assert specific cap + * values per viewport). + */ const VIEWPORT_BUCKETS: ReadonlyArray<{ - /** Inclusive upper bound on viewport width in CSS pixels. */ maxWidth: number; - /** Maximum number of x-axis ticks to render at this width. */ maxTicks: number; }> = [ { maxWidth: 360, maxTicks: 4 }, @@ -45,11 +142,6 @@ const VIEWPORT_BUCKETS: ReadonlyArray<{ { maxWidth: Number.POSITIVE_INFINITY, maxTicks: 10 }, ]; -/** - * Resolve the target number of visible ticks for a given viewport - * width. Pure & deterministic so unit tests can pin exact values for - * each device size. - */ export function resolveTargetTickCount(viewportWidth: number): number { if (!Number.isFinite(viewportWidth) || viewportWidth <= 0) { return VIEWPORT_BUCKETS[VIEWPORT_BUCKETS.length - 1].maxTicks; @@ -60,32 +152,6 @@ export function resolveTargetTickCount(viewportWidth: number): number { return VIEWPORT_BUCKETS[VIEWPORT_BUCKETS.length - 1].maxTicks; } -/** - * Compute the Recharts `interval` skip count for a chart with - * `pointCount` data points rendered at `viewportWidth`. - * - * Returns 0 when the chart has fewer points than the target tick - * count (no skipping needed) and a positive integer otherwise. - * - * Examples (Pixel 5, viewportWidth 393, maxTicks 6): - * - 7 points → 0 (fits in 6 slots after preserveStartEnd kicks in) - * - 30 points → 5 (every 6th tick → 5 visible labels + endpoints) - * - 90 points → 16 (every 17th tick → ~5 visible labels) - * - 365 points → 64 (every 65th tick → ~5 visible labels) - */ -export function chooseTickInterval( - pointCount: number, - viewportWidth: number, -): number { - if (!Number.isFinite(pointCount) || pointCount <= 0) return 0; - const target = resolveTargetTickCount(viewportWidth); - if (pointCount <= target) return 0; - // `Math.ceil(N/target) - 1` is the skip count that produces at most - // `target` evenly-spaced labels. Subtract one because Recharts interval - // 0 means "render every tick", 1 means "render every other", etc. - return Math.max(0, Math.ceil(pointCount / target) - 1); -} - /** * SSR-safe accessor for the active viewport width. Used by chart wrappers * so they can react to the current device. Returns the desktop default diff --git a/src/lib/validations/admin.ts b/src/lib/validations/admin.ts index feb3ca36e..2930cb93c 100644 --- a/src/lib/validations/admin.ts +++ b/src/lib/validations/admin.ts @@ -75,5 +75,10 @@ export const adminSettingsSchema = z reminderLateMinutes: z.number().int().min(15).max(480).optional(), reminderMissedMinutes: z.number().int().min(30).max(720).optional(), moodLogGlobal: z.boolean().optional(), + // v1.4.25 W7 — server-wide default timezone for new signups + // that don't carry a browser-detected zone. The route validates + // against Intl.supportedValuesOf at runtime; this schema just + // guards the shape + length. + defaultUserTimezone: z.string().max(64).optional(), }) .strict();