"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 (
{latestTag}
);
}
return (
);
}
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("/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(
null,
);
async function runCheck(): Promise {
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 (
{t("settings.sections.about.title")}
{t("settings.sections.about.description")}
{/* 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. */}
{isLoading || !version ? (
{t("common.loading")}
) : (
{t("settings.about.version")}
v{version.version}
{/* 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" && (
)}
{t("settings.about.license")}
{version.license}
{version.buildSha && (
{t("settings.about.gitSha")}
{version.buildSha.slice(0, 7)}
)}
{version.builtAt && (
{t("settings.about.builtAtLabel")}
{fmt.date(version.builtAt)}
)}
)}
{/* 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 && (
)}
{/* 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. */}
{t("settings.about.tourReplay")}
{t("settings.about.tourReplayHint")}
{tourFeedback && (
{tourFeedback.text}
)}
{/* 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. */}
);
}