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:
Marc-André Bombeck committed 2026-05-14 12:05:46 +02:00
1 parent 65ce9fd4c8
commit 26568c18a8
11 files changed
+353 -139

No files matched your search

+3
View File
@@ -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.",
+3
View File
@@ -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();
});
});
+33
View File
@@ -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,
});
});
+3 -44
View File
@@ -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",
+3
View File
@@ -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,
};
+67 -47
View File
@@ -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);
}
}
}
});
});
});
+112 -46
View File
@@ -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
+5
View File
@@ -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();