"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import {
ArrowUpCircle,
BookOpen,
ExternalLink,
GitBranch,
Info,
Loader2,
Sparkles,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { SupportInfoCard } from "@/components/settings/support-info-card";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
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 — 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();
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.36 — 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 */
// v1.18.6 — the visible heading + subtitle now come from the section
// frame that wraps this component: `` on the settings
// route and the admin renderer's `SectionFrame` on `/admin/about`. The body
// is the version / license / sources cards.
return (
{/* 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 — 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)}
)}
)}
{/* v1.43 — the text a bug report needs, put together in the browser. */}
{/* 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 && (
{/* /about carries the GeoLite2 attribution its EULA wants
reachable from the running app — this row and the /privacy
footer are its two inbound links. Same-app route, so no
external-link glyph. */}
)}
{/* v1.18.6 — the tour-replay card consolidated onto a single home in
Settings → Advanced ("Modul-Tour neu starten"). The About surface
no longer duplicates it; per-module "Diese Tour zeigen" affordances
on every module page cover in-context re-entry. */}
{/* v1.4.36 — 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. */}