mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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.
432 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|