Skip to content

Commit af90fa9

Browse files
feat(ui): last-refresh meta + manual refresh on the analytics and maintainer headers (#4705)
Adds a shared RefreshMeta primitive (relative-time label + refresh button reusing StateActionButton/RefreshCw) and a pure relativeTimeFromNow helper, stamps loadedAt in useApiResource on a successful load, and adopts the control on the analytics header pill row and the maintainer dashboard header row. The label re-renders on a coarse 30s tick and the control stays hidden until the first successful load, since StateBoundary already covers loading/error/empty refresh affordances. Scoped to the dashboard headers per the issue — no StateBoundary refactor.
1 parent 99dd29b commit af90fa9

7 files changed

Lines changed: 200 additions & 8 deletions

File tree

apps/gittensory-ui/src/components/site/app-panels/maintainer-panel.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { ActivationPreview } from "@/components/site/app-panels/activation-previ
2121
import { AiReviewSettings } from "@/components/site/app-panels/ai-review-settings";
2222
import { MaintainerSettings } from "@/components/site/app-panels/maintainer-settings";
2323
import { StatCard } from "@/components/site/primitives";
24+
import { RefreshMeta } from "@/components/site/refresh-meta";
2425
import { EmptyState, LoadingState, StateBoundary } from "@/components/site/state-views";
2526
import { apiFetch } from "@/lib/api/request";
2627
import { getApiOrigin } from "@/lib/api/origin";
@@ -209,6 +210,11 @@ function MaintainerDashboardView() {
209210
</div>
210211
) : data ? (
211212
<div className="space-y-6">
213+
{/* Dashboard-level refresh metadata (#2219) — lives here rather than the route's PageHeader
214+
because the maintainer resource is gated behind the session/role check above. */}
215+
<div className="flex items-center justify-end">
216+
<RefreshMeta loadedAt={dashboard.loadedAt} onRefresh={dashboard.reload} />
217+
</div>
212218
<section className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
213219
{data.metrics.map((metric) => (
214220
<StatCard
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { act, fireEvent, render, screen } from "@testing-library/react";
2+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3+
4+
import { RefreshMeta } from "@/components/site/refresh-meta";
5+
import { relativeTimeFromNow } from "@/lib/utils";
6+
7+
const MINUTE_MS = 60_000;
8+
const HOUR_MS = 60 * MINUTE_MS;
9+
const DAY_MS = 24 * HOUR_MS;
10+
11+
describe("relativeTimeFromNow (#2219)", () => {
12+
const now = Date.UTC(2026, 6, 10, 12, 0, 0);
13+
14+
it("labels each bucket at and around its boundary", () => {
15+
// seconds bucket, including the exact lower edge and the last second before a minute
16+
expect(relativeTimeFromNow(now, now)).toBe("just now");
17+
expect(relativeTimeFromNow(now - 59_000, now)).toBe("just now");
18+
// minutes bucket: 60s flips to 1m; 59m59s still reads 59m
19+
expect(relativeTimeFromNow(now - MINUTE_MS, now)).toBe("1m ago");
20+
expect(relativeTimeFromNow(now - (HOUR_MS - 1000), now)).toBe("59m ago");
21+
// hours bucket: 60m flips to 1h; 23h59m still reads 23h
22+
expect(relativeTimeFromNow(now - HOUR_MS, now)).toBe("1h ago");
23+
expect(relativeTimeFromNow(now - (DAY_MS - MINUTE_MS), now)).toBe("23h ago");
24+
// days bucket: 24h flips to 1d and keeps counting
25+
expect(relativeTimeFromNow(now - DAY_MS, now)).toBe("1d ago");
26+
expect(relativeTimeFromNow(now - 3 * DAY_MS - 2 * HOUR_MS, now)).toBe("3d ago");
27+
});
28+
29+
it("clamps a marginally-future timestamp to 'just now' instead of a negative age", () => {
30+
expect(relativeTimeFromNow(now + 5_000, now)).toBe("just now");
31+
});
32+
});
33+
34+
describe("RefreshMeta (#2219)", () => {
35+
beforeEach(() => {
36+
vi.useFakeTimers();
37+
vi.setSystemTime(Date.UTC(2026, 6, 10, 12, 0, 0));
38+
});
39+
afterEach(() => {
40+
vi.useRealTimers();
41+
});
42+
43+
it("renders nothing before the first successful load", () => {
44+
const { container } = render(<RefreshMeta loadedAt={null} onRefresh={() => {}} />);
45+
expect(container.firstChild).toBeNull();
46+
});
47+
48+
it("shows the relative label for the loaded timestamp", () => {
49+
render(<RefreshMeta loadedAt={Date.now() - 3 * MINUTE_MS} onRefresh={() => {}} />);
50+
expect(screen.getByText("last refresh 3m ago")).toBeTruthy();
51+
});
52+
53+
it("advances the label on the interval tick without a reload", () => {
54+
render(<RefreshMeta loadedAt={Date.now()} onRefresh={() => {}} />);
55+
expect(screen.getByText("last refresh just now")).toBeTruthy();
56+
act(() => {
57+
vi.advanceTimersByTime(2 * MINUTE_MS);
58+
});
59+
expect(screen.getByText("last refresh 2m ago")).toBeTruthy();
60+
});
61+
62+
it("invokes onRefresh when the refresh button is clicked", () => {
63+
const onRefresh = vi.fn();
64+
render(<RefreshMeta loadedAt={Date.now()} onRefresh={onRefresh} />);
65+
fireEvent.click(screen.getByRole("button", { name: /refresh/i }));
66+
expect(onRefresh).toHaveBeenCalledTimes(1);
67+
});
68+
69+
it("disables the button while a refresh is in flight", () => {
70+
const onRefresh = vi.fn();
71+
render(<RefreshMeta loadedAt={Date.now()} onRefresh={onRefresh} refreshing />);
72+
const button = screen.getByRole("button", { name: /refresh/i }) as HTMLButtonElement;
73+
expect(button.disabled).toBe(true);
74+
fireEvent.click(button);
75+
expect(onRefresh).not.toHaveBeenCalled();
76+
});
77+
78+
it("clears its interval on unmount", () => {
79+
const clearSpy = vi.spyOn(window, "clearInterval");
80+
const { unmount } = render(<RefreshMeta loadedAt={Date.now()} onRefresh={() => {}} />);
81+
unmount();
82+
expect(clearSpy).toHaveBeenCalled();
83+
});
84+
});
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { RefreshCw } from "lucide-react";
2+
import { useEffect, useState } from "react";
3+
4+
import { StateActionButton } from "@/components/site/state-views";
5+
import { cn, relativeTimeFromNow } from "@/lib/utils";
6+
7+
/**
8+
* Shared "last refresh Xm ago" label + manual refresh button for dashboard headers (#2219).
9+
* Renders nothing until the resource has loaded once (`loadedAt` is null while loading/error —
10+
* those states already have their own retry/refresh affordances in StateBoundary).
11+
*/
12+
export function RefreshMeta({
13+
loadedAt,
14+
onRefresh,
15+
refreshing = false,
16+
className,
17+
}: {
18+
loadedAt: number | null;
19+
onRefresh: () => void;
20+
refreshing?: boolean;
21+
className?: string;
22+
}) {
23+
// Re-render on a coarse tick so the relative label stays honest without a per-second timer.
24+
const [now, setNow] = useState(() => Date.now());
25+
useEffect(() => {
26+
const id = window.setInterval(() => setNow(Date.now()), 30_000);
27+
return () => window.clearInterval(id);
28+
}, []);
29+
30+
if (loadedAt === null) return null;
31+
32+
return (
33+
<div className={cn("flex items-center gap-2", className)}>
34+
<span className="font-mono text-token-2xs text-muted-foreground">
35+
last refresh {relativeTimeFromNow(loadedAt, now)}
36+
</span>
37+
<StateActionButton
38+
onClick={onRefresh}
39+
disabled={refreshing}
40+
icon={<RefreshCw className="size-3 shrink-0" aria-hidden />}
41+
>
42+
Refresh
43+
</StateActionButton>
44+
</div>
45+
);
46+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { renderHook, waitFor } from "@testing-library/react";
2+
import { describe, expect, it, vi } from "vitest";
3+
4+
const { apiFetch } = vi.hoisted(() => ({ apiFetch: vi.fn() }));
5+
vi.mock("@/lib/api/request", () => ({
6+
apiFetch: (...args: unknown[]) => apiFetch(...args),
7+
}));
8+
vi.mock("@/lib/api/origin", () => ({ getApiOrigin: () => "https://api.test" }));
9+
10+
import { useApiResource } from "@/lib/api/use-api-resource";
11+
12+
describe("useApiResource loadedAt (#2219)", () => {
13+
it("stamps loadedAt when a load succeeds, so headers can show 'last refresh'", async () => {
14+
apiFetch.mockResolvedValue({ ok: true, data: { rows: [] }, status: 200, durationMs: 5 });
15+
const before = Date.now();
16+
const { result } = renderHook(() => useApiResource<{ rows: [] }>("/v1/thing", "Thing"));
17+
expect(result.current.loadedAt).toBeNull();
18+
await waitFor(() => expect(result.current.status).toBe("ready"));
19+
expect(result.current.loadedAt).toBeGreaterThanOrEqual(before);
20+
expect(result.current.loadedAt).toBeLessThanOrEqual(Date.now());
21+
});
22+
23+
it("keeps loadedAt null on a failed load", async () => {
24+
apiFetch.mockResolvedValue({ ok: false, message: "boom", status: 500, durationMs: 5 });
25+
const { result } = renderHook(() => useApiResource("/v1/thing", "Thing"));
26+
await waitFor(() => expect(result.current.status).toBe("error"));
27+
expect(result.current.loadedAt).toBeNull();
28+
});
29+
30+
it("keeps loadedAt null when the resource is disabled", async () => {
31+
const { result } = renderHook(() =>
32+
useApiResource("/v1/thing", "Thing", undefined, { enabled: false }),
33+
);
34+
await waitFor(() => expect(result.current.status).toBe("error"));
35+
expect(result.current.error).toBe("disabled");
36+
expect(result.current.loadedAt).toBeNull();
37+
});
38+
});

apps/gittensory-ui/src/lib/api/use-api-resource.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ import { getApiOrigin } from "./origin";
44
import { apiFetch } from "./request";
55

66
type ResourceState<T> =
7-
| { status: "loading"; data: null; error: null }
8-
| { status: "ready"; data: T; error: null }
9-
| { status: "error"; data: null; error: string };
7+
| { status: "loading"; data: null; error: null; loadedAt: null }
8+
| { status: "ready"; data: T; error: null; loadedAt: number }
9+
| { status: "error"; data: null; error: string; loadedAt: null };
1010

1111
type UseApiResourceOptions = {
1212
enabled?: boolean;
@@ -23,14 +23,15 @@ export function useApiResource<T>(
2323
status: "loading",
2424
data: null,
2525
error: null,
26+
loadedAt: null,
2627
});
2728

2829
const load = useCallback(async () => {
2930
if (!enabled) {
30-
setState({ status: "error", data: null, error: "disabled" });
31+
setState({ status: "error", data: null, error: "disabled", loadedAt: null });
3132
return;
3233
}
33-
setState({ status: "loading", data: null, error: null });
34+
setState({ status: "loading", data: null, error: null, loadedAt: null });
3435
const headers: Record<string, string> = { Accept: "application/json" };
3536
if (token) headers.Authorization = `Bearer ${token}`;
3637
const result = await apiFetch<T>(`${getApiOrigin().replace(/\/$/, "")}${path}`, {
@@ -39,15 +40,15 @@ export function useApiResource<T>(
3940
credentials: "include",
4041
});
4142
if (result.ok) {
42-
setState({ status: "ready", data: result.data, error: null });
43+
setState({ status: "ready", data: result.data, error: null, loadedAt: Date.now() });
4344
} else {
44-
setState({ status: "error", data: null, error: result.message });
45+
setState({ status: "error", data: null, error: result.message, loadedAt: null });
4546
}
4647
}, [enabled, label, path, token]);
4748

4849
useEffect(() => {
4950
if (!enabled) {
50-
setState({ status: "error", data: null, error: "disabled" });
51+
setState({ status: "error", data: null, error: "disabled", loadedAt: null });
5152
return;
5253
}
5354
void load();

apps/gittensory-ui/src/lib/utils.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,18 @@ import { twMerge } from "tailwind-merge";
44
export function cn(...inputs: ClassValue[]) {
55
return twMerge(clsx(inputs));
66
}
7+
8+
/**
9+
* Coarse relative-time label for "last refresh" style metadata (#2219). Clock skew or a
10+
* just-written timestamp can land marginally in the future — clamp to "just now" instead
11+
* of rendering a negative age.
12+
*/
13+
export function relativeTimeFromNow(timestampMs: number, nowMs: number): string {
14+
const deltaSeconds = Math.max(0, Math.floor((nowMs - timestampMs) / 1000));
15+
if (deltaSeconds < 60) return "just now";
16+
const minutes = Math.floor(deltaSeconds / 60);
17+
if (minutes < 60) return `${minutes}m ago`;
18+
const hours = Math.floor(minutes / 60);
19+
if (hours < 24) return `${hours}h ago`;
20+
return `${Math.floor(hours / 24)}d ago`;
21+
}

apps/gittensory-ui/src/routes/app.analytics.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createFileRoute } from "@tanstack/react-router";
22

33
import { BoundaryBadge, Stat, StatusPill } from "@/components/site/control-primitives";
4+
import { RefreshMeta } from "@/components/site/refresh-meta";
45
import { StateBoundary } from "@/components/site/state-views";
56
import { TrendChart } from "@/components/site/trend-chart";
67
import {
@@ -163,6 +164,7 @@ function ProductAnalytics() {
163164
</StatusPill>
164165
) : null}
165166
<BoundaryBadge boundary="private-api" />
167+
<RefreshMeta loadedAt={dashboard.loadedAt} onRefresh={dashboard.reload} />
166168
</div>
167169
</header>
168170

0 commit comments

Comments
 (0)