Files
HealthLog/src/components/settings/about-section.tsx
T
Marc-André Bombeck f92e644154 chore(release): v1.4.49 — audit-backlog closure + server reminder suppression + diagnostic endpoint + analytics cold-path fix
Bundles the deferred v1.4.47 Mediums + Lows with the v1.4.48-deferred items
into a single release. Headline: server-side suppression of MEDICATION_REMINDER
APNs for iOS clients that own their local reminders (closes GH #206), a
push_attempts table backing /api/admin/notifications/diagnostic, and a sweep
of v1.4.48 QA forward-findings.

Performance (the user-visible win):
- /api/analytics cold-fallback path splits the heavy single-statement
  aggregate into two parallel queries. The linearly composable columns
  (count / min / max / mean) keep the full-partition scan; the windowed
  7 / 30 / 90-day avg / slope / r² columns take a 90-day outer cap so
  the planner does an index range scan on (user_id, type, measured_at)
  instead of a full-partition sequential scan. Output is bit-identical;
  cold slim slice drops from multi-second to sub-second on tenants with
  large measurement partitions.

Server-side notifications:
- GET + PATCH /api/auth/me/notification-prefs with deep-merge semantics.
  Initial category medication.clientManaged: boolean (default false). Cron
  medicationReminderJob skips dose-due APNs when the flag is true,
  emitting a medication_reminder.suppressed_client_managed wide-event
  annotation per skip.
- push_attempts table backs the diagnostic endpoint. Every APNS /
  Web-Push / Telegram / NTFY sender writes a fire-and-forget row.
  Daily 03:35 Europe/Berlin cleanup prunes rows older than 90 days.
- /api/admin/notifications/diagnostic — admin-only endpoint surfacing
  device APNs token presence (masked), channel config presence, and
  recent push attempts. Registered in OpenAPI.
- Per-channel APNs "Send test" button now fires with
  eventType: "MEDICATION_REMINDER" so the dispatcher's time-sensitive +
  priority-10 branch runs. A successful self-test now exercises the
  exact path production reminders take; an unsuccessful one points the
  user at iOS Settings → Time Sensitive Notifications rather than
  appearing to be a server-side bug.

Withings reliability:
- OAuth callback consumes the nonce atomically (delete-first with
  P2025 catch for the replay branch). The previous read-then-delete
  pattern allowed a race between two concurrent callbacks.
- ?reason=state redirect differentiated into csrf1 / replay / expired /
  cross_user so operators can tell mismatch types apart without DB
  shell access.
- connect endpoint rate-limited to 10/60s/user; row-create failures
  redirect to a consistent error surface.

Observability hardening:
- redactSensitiveFields(body) denylist helper covers password / token /
  secret / apiKey / authorization / csrfState / nonce, recursive,
  case-insensitive. Caught: /api/devices invalid_format issues were
  echoing apnsToken values verbatim into the audit log.
- sanitiseZodIssues(issues, { stripValuesFromMessage: true }) overload
  applied to 14 audit-log sites.

Migrations: 0079 (notification_prefs), 0080 (push_attempts).

Tests: 4815 → 5272 unit + integration. Workout-batch integration suite
that had been red on main since v1.4.45 (three release commits shipped
through it) now green again — updated to match v1.4.42 cross-source
dedup semantics.

Resolves PR #207.
2026-05-23 12:51:41 +02:00

432 lines
16 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
ArrowUpCircle,
BookOpen,
Compass,
ExternalLink,
GitBranch,
Info,
Loader2,
Sparkles,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/hooks/use-auth";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { restartOnboardingTour } from "@/lib/onboarding/tour-restart";
import { queryKeys } from "@/lib/query-keys";
interface VersionPayload {
version: string;
buildSha: string | null;
builtAt: string | null;
license: string;
repository: string;
changelog: string;
docs: string;
}
type CheckUpdatesResult =
| {
status: "up_to_date";
current: string;
latest_tag: string;
checked_at: string;
}
| {
status: "newer_available";
current: string;
latest_tag: string;
html_url: string | null;
published_at: string | null;
checked_at: string;
}
| { status: "unknown"; current: string; reason: string };
const LAST_CHECKED_KEY = "healthlog-about-last-checked-iso";
// One day between auto-checks. We don't want every page-mount to spend a
// GitHub-API rate-limit slot just to confirm the version is unchanged
// 30 seconds after the previous one resolved. The user can always force
// a re-check via the button.
const AUTO_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
function readLastCheckedISO(): string | null {
if (typeof window === "undefined") return null;
try {
return window.localStorage.getItem(LAST_CHECKED_KEY);
} catch {
return null;
}
}
function writeLastCheckedISO(iso: string): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(LAST_CHECKED_KEY, iso);
} catch {
/* storage may be full or disabled — silently skip */
}
}
/**
* v1.4.36 QA H7 — keyboard- and touch-accessible update badge.
*
* Renders as a 44 px-square hit target (anchor when a release URL is
* known, span fallback otherwise) with a focus-visible ring, semantic
* `text-primary` colour, and a real `aria-label` describing the
* available version. Exported so the SSR test can pin the contract
* without driving the parent's auto-check effect.
*/
export function UpdateBadge({
latestTag,
htmlUrl,
ariaLabel,
}: {
latestTag: string;
htmlUrl: string | null;
ariaLabel: string;
}) {
if (htmlUrl) {
return (
<a
href={htmlUrl}
target="_blank"
rel="noopener noreferrer"
aria-label={ariaLabel}
title={ariaLabel}
className="text-primary focus-visible:ring-ring inline-flex min-h-11 min-w-11 items-center justify-center rounded-md hover:opacity-80 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
>
<ArrowUpCircle className="h-4 w-4" />
<span className="sr-only">{latestTag}</span>
</a>
);
}
return (
<span
role="img"
aria-label={ariaLabel}
title={ariaLabel}
className="text-primary inline-flex min-h-11 min-w-11 items-center justify-center"
>
<ArrowUpCircle className="h-4 w-4" />
</span>
);
}
export function AboutSection() {
const { t } = useTranslations();
const fmt = useFormatters();
// Pull the auth user — only needed so the "Replay the tour" button
// can stamp a per-user force-launch marker into sessionStorage. The
// hook is cheap (a cached `/api/auth/me` read) and every settings
// page already lives behind the same auth boundary.
const { user } = useAuth();
const { data: version, isLoading } = useQuery({
queryKey: queryKeys.apiVersion(),
queryFn: async () => {
const res = await fetch("/api/version");
if (!res.ok) throw new Error("version-fetch-failed");
const json = await res.json();
return json.data as VersionPayload;
},
// The endpoint is `force-static` — version doesn't change at runtime.
staleTime: Infinity,
});
// v1.4.47 W5 — onboarding chain-gate: the spotlight tour now auto-
// launches only ≥ 24 h after the wizard finishes (see
// `shouldAutoLaunchTour` in `components/onboarding/tour-launcher.tsx`).
// The carousel + tour no longer stack into ~90 s of forced onboarding
// on first visit. The tradeoff: a first-day user who *does* want the
// tour needs a discoverable manual trigger. Settings → About is the
// surface every user can find (every locale puts "About" right on the
// settings shell), so it carries the replay button next to the
// sources/docs links. Settings → Account still has its own
// "Restart onboarding tour" — both buttons share the same flow.
const [replayingTour, setReplayingTour] = useState(false);
// v1.4.48 M6c — text + type collapsed into one discriminated state so
// the two values can never drift (a "success" message rendered in the
// destructive colour was the latent bug class).
const [tourFeedback, setTourFeedback] = useState<{
text: string;
type: "success" | "error";
} | null>(null);
async function handleReplayTour() {
setReplayingTour(true);
setTourFeedback(null);
// v1.4.48 M6b — both Settings → Account and Settings → About now
// delegate to the shared `restartOnboardingTour()` worker so the
// server flip + force-launch marker + window event live in one
// place. The helper returns a discriminated result; this surface
// only owns the translation + feedback rendering.
const result = await restartOnboardingTour(user?.id);
if (result.ok) {
setTourFeedback({
text: t("onboarding.tour.restartConfirmation"),
type: "success",
});
} else {
setTourFeedback({ text: t(result.messageKey), type: "error" });
}
setReplayingTour(false);
}
// v1.4.36 W4f — the explicit "Check for updates" button is gone;
// only the 24 h auto-check stays. When the auto-check reports
// `newer_available`, a subtle upward-arrow badge appears next to
// the version line with the latest tag in its tooltip.
const [updateResult, setUpdateResult] = useState<CheckUpdatesResult | null>(
null,
);
async function runCheck(): Promise<void> {
try {
const res = await fetch("/api/version/check-updates");
if (!res.ok) {
setUpdateResult({
status: "unknown",
current: version?.version ?? "",
reason: `http_${res.status}`,
});
return;
}
const json = await res.json();
const result = json.data as CheckUpdatesResult;
setUpdateResult(result);
if (result.status !== "unknown") {
writeLastCheckedISO(new Date().toISOString());
}
} catch {
setUpdateResult({
status: "unknown",
current: version?.version ?? "",
reason: "network_error",
});
}
}
// Auto-check on mount when the last successful check is older than the
// refresh interval (or has never run). Keeps the badge fresh without a
// click — but won't spam GitHub on every navigation.
// The setState inside runCheck() is intentional: the check is the
// whole point of the effect, and the staleness gate prevents a
// cascading-render loop because the timestamp resets after the run.
/* eslint-disable react-hooks/exhaustive-deps, react-hooks/set-state-in-effect */
useEffect(() => {
if (!version) return;
const last = readLastCheckedISO();
const lastMs = last ? Date.parse(last) : NaN;
const stale =
Number.isNaN(lastMs) || Date.now() - lastMs > AUTO_CHECK_INTERVAL_MS;
if (stale) {
void runCheck();
}
}, [version?.version]);
/* eslint-enable react-hooks/exhaustive-deps, react-hooks/set-state-in-effect */
return (
<section
aria-labelledby="settings-section-about-title"
className="space-y-6"
>
<header className="space-y-1">
<h1
id="settings-section-about-title"
className="text-2xl font-semibold tracking-tight"
>
{t("settings.sections.about.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("settings.sections.about.description")}
</p>
</header>
{/* Identity card — version + license inline. The v1.4.2 layout
stacked the license under the version inside its own boxed Badge,
which read as a separate field even though the two values belong
on the same row. */}
<div className="bg-card border-border rounded-xl border p-6">
<div className="mb-4 flex items-center gap-2">
<Info className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">HealthLog</h2>
</div>
{isLoading || !version ? (
<div className="flex items-center gap-2">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
<span className="text-muted-foreground text-sm">
{t("common.loading")}
</span>
</div>
) : (
<dl className="flex flex-wrap items-baseline gap-x-6 gap-y-3 text-sm">
<div className="flex items-baseline gap-2">
<dt className="text-muted-foreground text-xs tracking-wide uppercase">
{t("settings.about.version")}
</dt>
<dd className="font-mono font-medium">v{version.version}</dd>
{/* v1.4.36 W4f — subtle upward-arrow badge that
appears only when the 24 h auto-check reports a
newer release. Hover/tap reveals the latest tag via
the title attribute (read by screen readers + as a
native tooltip on desktop, and announced inline as
the link copy on mobile). No bouncing, no badge
counter, no manual recheck button — the badge is
the entire surface. */}
{updateResult?.status === "newer_available" && (
<UpdateBadge
latestTag={updateResult.latest_tag}
htmlUrl={updateResult.html_url}
ariaLabel={t("settings.about.newerAvailable", {
tag: updateResult.latest_tag,
})}
/>
)}
</div>
<div className="flex items-baseline gap-2">
<dt className="text-muted-foreground text-xs tracking-wide uppercase">
{t("settings.about.license")}
</dt>
<dd className="font-mono">{version.license}</dd>
</div>
{version.buildSha && (
<div className="flex items-baseline gap-2">
<dt className="text-muted-foreground text-xs tracking-wide uppercase">
{t("settings.about.gitSha")}
</dt>
<dd className="font-mono">{version.buildSha.slice(0, 7)}</dd>
</div>
)}
{version.builtAt && (
<div className="flex items-baseline gap-2">
<dt className="text-muted-foreground text-xs tracking-wide uppercase">
{t("settings.about.builtAtLabel")}
</dt>
<dd className="text-muted-foreground">
{fmt.date(version.builtAt)}
</dd>
</div>
)}
</dl>
)}
</div>
{/* Sources & docs — every link card was untitled in the v1.4.2
About surface, so the buttons floated alone with no heading
telling the user what the section was for. */}
{version && (
<div className="bg-card border-border rounded-xl border p-6">
<div className="mb-4 flex items-center gap-2">
<BookOpen className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("settings.about.linksHeading")}
</h2>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
<Button asChild variant="outline" size="sm">
<a
href={version.repository}
target="_blank"
rel="noopener noreferrer"
>
<GitBranch className="mr-2 h-4 w-4" />
{t("settings.about.repository")}
<ExternalLink className="ml-2 h-3.5 w-3.5" />
</a>
</Button>
<Button asChild variant="outline" size="sm">
<a
href={version.changelog}
target="_blank"
rel="noopener noreferrer"
>
<Sparkles className="mr-2 h-4 w-4" />
{t("settings.about.changelog")}
<ExternalLink className="ml-2 h-3.5 w-3.5" />
</a>
</Button>
<Button asChild variant="outline" size="sm">
<a href={version.docs} target="_blank" rel="noopener noreferrer">
<BookOpen className="mr-2 h-4 w-4" />
{t("settings.about.docs")}
<ExternalLink className="ml-2 h-3.5 w-3.5" />
</a>
</Button>
</div>
</div>
)}
{/* v1.4.47 W5 — Replay tour card. The dashboard spotlight
tour now auto-launches only ≥ 24 h after the wizard finishes,
so a first-day user who wants to see it still needs a
discoverable manual trigger. About is the surface every user
can find (it's the standard "where am I?" stop in the
settings shell), so the button lives here next to version +
links. Mirrors the stack-on-mobile / right-align-on-desktop
contract used by the Account section's "Restart onboarding
tour" card so the two surfaces feel consistent. */}
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Compass className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("settings.about.tourReplay")}
</h2>
</div>
<p className="text-muted-foreground text-xs">
{t("settings.about.tourReplayHint")}
</p>
</div>
<Button
type="button"
variant="outline"
onClick={handleReplayTour}
disabled={replayingTour}
data-testid="about-replay-tour"
className="w-full shrink-0 sm:w-auto"
>
{replayingTour ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Compass className="mr-2 h-4 w-4" />
)}
{t("settings.about.tourReplay")}
</Button>
</div>
{tourFeedback && (
<p
role="alert"
className={`mt-2 text-xs ${
tourFeedback.type === "success"
? "text-dracula-green"
: "text-destructive"
}`}
>
{tourFeedback.text}
</p>
)}
</div>
{/* v1.4.36 W4f — the dedicated "Updates" card with the
manual "Check for updates" button is gone. The 24 h auto-
check still runs on mount and writes its result into the
local `updateResult` state; the consequence renders as the
subtle upward-arrow badge next to the version line above
rather than as a separate panel. The card-level last-check
timestamp, success / failure states, and the explicit
button were collectively never the point of the surface —
the only signal users ever cared about was "is a newer
release out?", and the badge answers that with no clicks. */}
</section>
);
}