mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
feat(admin): server-default timezone setting for new signups
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.
This commit is contained in:
1 parent
65ce9fd4c8
commit
26568c18a8
11 files changed
+353
-139
No files matched your search
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -283,43 +283,9 @@ function classifyRangeValue(
|
||||
return "red";
|
||||
}
|
||||
|
||||
function getOverallHealthStatus(states: Array<HealthBandState | null>): {
|
||||
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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
@@ -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}
|
||||
>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 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. */}
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{t("admin.defaultUserTimezone")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.defaultUserTimezoneDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<select
|
||||
value={currentDefaultTz}
|
||||
onChange={(e) =>
|
||||
updateSettings.mutate({ defaultUserTimezone: e.target.value })
|
||||
}
|
||||
disabled={updateSettings.isPending}
|
||||
className={NATIVE_SELECT_CLASS}
|
||||
>
|
||||
<option value="">{t("admin.defaultUserTimezoneFallback")}</option>
|
||||
{/* Preserve the stored value even if it's not in the
|
||||
current engine's IANA list (rare — but defensive). */}
|
||||
{currentDefaultTz && !zones.includes(currentDefaultTz) && (
|
||||
<option value={currentDefaultTz}>{currentDefaultTz}</option>
|
||||
)}
|
||||
{zones.map((zone) => (
|
||||
<option key={zone} value={zone}>
|
||||
{zone}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<TickBucket> = [
|
||||
{ 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<TickBucket> = [
|
||||
{ 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
|
||||
|
||||
@@ -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();
|
||||
Reference in new issue
Block a user