mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
The dashboard snapshot fires the moment login succeeds instead of waiting for the page chunk (start ~670 to ~200 ms throttled). Guided follow-ups are personalised from the user's snapshot and an answer gets the coach's streamed reaction before the next question. Archived schedule eras can be corrected through a superseding revision that keeps the original as an audit trail, era writes serialise per medication, and duplicate dose times collapse in validation. A rolling medication whose next dose is tomorrow can no longer read as heavily overdue — the card status derives strictly from the served next-due — and the adherence bars keep a fixed geometry in every state. The server-wide assistant key gains its admin card, the one duplicated coach preference collapses onto a single owner, twenty-seven admin and settings card headers move onto the shared pattern with sixty containers normalised, and the remaining hydration warnings are gone.
424 lines
15 KiB
TypeScript
424 lines
15 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 { SettingsCardHeader } from "@/components/settings/_card-header";
|
|
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";
|
|
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
|
|
|
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 () => {
|
|
return apiGet<VersionPayload>("/api/version");
|
|
},
|
|
// 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 apiFetchRaw("/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="sr-only">
|
|
{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-4 sm:p-6">
|
|
<SettingsCardHeader icon={Info} title="HealthLog" className="mb-4" />
|
|
|
|
{isLoading || !version ? (
|
|
<div className="flex items-center gap-2 pl-7">
|
|
<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 pl-7 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-4 sm:p-6">
|
|
<SettingsCardHeader
|
|
icon={BookOpen}
|
|
title={t("settings.about.linksHeading")}
|
|
className="mb-4"
|
|
/>
|
|
<div className="flex flex-col gap-2 pl-7 sm:flex-row sm:flex-wrap">
|
|
<Button asChild variant="outline" size="sm">
|
|
<a
|
|
href={version.repository}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
>
|
|
<GitBranch className="h-4 w-4" />
|
|
{t("settings.about.repository")}
|
|
<ExternalLink className="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="h-4 w-4" />
|
|
{t("settings.about.changelog")}
|
|
<ExternalLink className="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="h-4 w-4" />
|
|
{t("settings.about.docs")}
|
|
<ExternalLink className="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-4 sm:p-6">
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
|
|
<div className="flex items-start gap-2">
|
|
<Compass className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0" />
|
|
<div className="space-y-1">
|
|
<h2 className="text-lg font-semibold">
|
|
{t("settings.about.tourReplay")}
|
|
</h2>
|
|
<p className="text-muted-foreground text-xs">
|
|
{t("settings.about.tourReplayHint")}
|
|
</p>
|
|
</div>
|
|
</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="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
|
) : (
|
|
<Compass className="h-4 w-4" />
|
|
)}
|
|
{t("settings.about.tourReplay")}
|
|
</Button>
|
|
</div>
|
|
{tourFeedback && (
|
|
<p
|
|
role="alert"
|
|
className={`mt-2 pl-7 text-xs ${
|
|
tourFeedback.type === "success"
|
|
? "text-success"
|
|
: "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>
|
|
);
|
|
}
|