Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,15 @@ A baseline security header set (CSP, `X-Frame-Options: DENY`, `Referrer-Policy`,

The `/events` page renders server-supplied JSON payloads. Each payload is serialised through `safeStringify` (`src/lib/format.ts`) with a hard cap (`EVENT_PAYLOAD_MAX_CHARS`, default 5,000 chars) and a visible `…(truncated)` marker. Circular references, `BigInt`, functions, and malformed timestamps are replaced with safe sentinels so a bad payload can't crash the page.

## Services list paging

The `/services` page now uses server-driven pagination with the shared `Spinner`, `EmptyState`, and `Pagination` components.

- Requests are sent as `GET /api/v1/services?page=N&limit=25`.
- The page assumes the backend returns a paged payload with `services` or `items`, plus `page` and `pageCount`.
- If the backend clamps an out-of-range request, the UI follows the server-provided `page` and `pageCount` so the visible indicator stays in sync.
- Service rows link through to `/services/:serviceId` using encoded IDs.

## Commands

| Command | Description |
Expand Down
12 changes: 5 additions & 7 deletions src/app/search/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,10 @@ export default function SearchPage() {
const [q, setQ] = useState("");
const debounced = useDebounce(q, 250);
const [items, setItems] = useState<Service[] | null>(null);
const visibleItems = debounced ? items : null;

useEffect(() => {
if (!debounced) {
setItems(null);
return;
}
if (!debounced) return;
apiGet<{ services: Service[] }>(
`/api/v1/services?q=${encodeURIComponent(debounced)}&limit=50`
)
Expand All @@ -32,12 +30,12 @@ export default function SearchPage() {
>
<h1 className="text-3xl font-semibold tracking-tight">Search</h1>
<SearchBar value={q} onChange={setQ} placeholder="Search services…" />
{items && items.length === 0 && (
{visibleItems && visibleItems.length === 0 && (
<p className="text-sm text-zinc-500">No matches.</p>
)}
{items && items.length > 0 && (
{visibleItems && visibleItems.length > 0 && (
<ul className="divide-y divide-zinc-200 dark:divide-zinc-800">
{items.map((s) => (
{visibleItems.map((s) => (
<li key={s.serviceId} className="py-3 font-mono text-sm">
<a href={`/services/${encodeURIComponent(s.serviceId)}`} className="hover:underline">
{s.serviceId}
Expand Down
144 changes: 144 additions & 0 deletions src/app/services/page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { apiGet } from "../../lib/apiClient";
import ServicesPage from "./page";

jest.mock("../../lib/apiClient", () => ({
apiGet: jest.fn(),
}));

const apiGetMock = apiGet as jest.MockedFunction<typeof apiGet>;

function service(serviceId: string, priceStroops: number) {
return { serviceId, priceStroops };
}

describe("ServicesPage", () => {
beforeEach(() => {
apiGetMock.mockReset();
});

it("renders a spinner while the first page is loading", () => {
apiGetMock.mockReturnValueOnce(new Promise(() => undefined) as never);

render(<ServicesPage />);

expect(screen.getByRole("status")).toBeInTheDocument();
expect(screen.queryByRole("navigation", { name: /pagination/i })).not.toBeInTheDocument();
});

it("shows the empty state with a New service action when there are no services", async () => {
apiGetMock.mockResolvedValueOnce({
services: [],
page: 1,
pageCount: 1,
} as never);

render(<ServicesPage />);

expect(await screen.findByText(/No services registered yet/i)).toBeInTheDocument();
const newServiceLinks = screen.getAllByRole("link", { name: /new service/i });
expect(newServiceLinks).toHaveLength(2);
expect(newServiceLinks.some((link) => link.getAttribute("href") === "/services/new")).toBe(
true
);
expect(screen.queryByRole("navigation", { name: /pagination/i })).not.toBeInTheDocument();
});

it("renders each service row as a link and omits pagination on a single page", async () => {
apiGetMock.mockResolvedValueOnce({
services: [service("svc/1", 42)],
page: 1,
pageCount: 1,
} as never);

render(<ServicesPage />);

const rowLink = await screen.findByRole("link", { name: /svc\/1/i });
expect(rowLink).toHaveAttribute("href", "/services/svc%2F1");
expect(screen.getByText(/42 stroops \/ request/i)).toBeInTheDocument();
expect(screen.queryByRole("navigation", { name: /pagination/i })).not.toBeInTheDocument();
});

it("shows pagination only when there are multiple pages and refetches when Next is clicked", async () => {
apiGetMock
.mockResolvedValueOnce({
services: [service("svc-a", 10)],
page: 1,
pageCount: 2,
} as never)
.mockResolvedValueOnce({
services: [service("svc-b", 20)],
page: 2,
pageCount: 2,
} as never);

render(<ServicesPage />);

expect(await screen.findByText("Page 1 of 2")).toBeInTheDocument();
expect(screen.getByRole("navigation", { name: /pagination/i })).toBeInTheDocument();

fireEvent.click(screen.getByRole("button", { name: /next/i }));

await waitFor(() => {
expect(apiGetMock).toHaveBeenLastCalledWith("/api/v1/services?page=2&limit=25");
});

expect(await screen.findByText("Page 2 of 2")).toBeInTheDocument();
expect(screen.getByRole("link", { name: /svc-b/i })).toHaveAttribute(
"href",
"/services/svc-b"
);
});

it("disables Next on the last page", async () => {
apiGetMock.mockResolvedValueOnce({
services: [service("svc-last", 77)],
page: 2,
pageCount: 2,
} as never);

render(<ServicesPage />);

expect(await screen.findByText("Page 2 of 2")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /next/i })).toBeDisabled();
});

it("clamps an out-of-range page response to the server-provided page", async () => {
apiGetMock
.mockResolvedValueOnce({
services: [service("svc-a", 10)],
page: 1,
pageCount: 2,
} as never)
.mockResolvedValueOnce({
services: [service("svc-b", 20)],
page: 1,
pageCount: 2,
} as never);

render(<ServicesPage />);

expect(await screen.findByText("Page 1 of 2")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /next/i }));

await waitFor(() => {
expect(apiGetMock).toHaveBeenLastCalledWith("/api/v1/services?page=2&limit=25");
});

expect(await screen.findByRole("link", { name: /svc-b/i })).toBeInTheDocument();
expect(screen.queryByRole("link", { name: /svc-a/i })).not.toBeInTheDocument();
expect(screen.getByText("Page 1 of 2")).toBeInTheDocument();
expect(screen.getByRole("link", { name: /svc-b/i })).toHaveAttribute(
"href",
"/services/svc-b"
);
});

it("surfaces backend failures as a role=alert", async () => {
apiGetMock.mockRejectedValueOnce(new Error("backend unavailable"));

render(<ServicesPage />);

expect(await screen.findByRole("alert")).toHaveTextContent("backend unavailable");
});
});
107 changes: 92 additions & 15 deletions src/app/services/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,70 @@
import { useEffect, useState } from "react";
import Link from "next/link";
import { apiGet } from "@/lib/apiClient";
import { EmptyState } from "@/components/EmptyState";
import { Pagination } from "@/components/Pagination";
import { Spinner } from "@/components/Spinner";

type Service = { serviceId: string; priceStroops: number };
type ServicesResponse = {
services?: Service[];
items?: Service[];
page?: number;
pageCount?: number;
};

const PAGE_SIZE = 25;

export default function ServicesPage() {
const [services, setServices] = useState<Service[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [requestedPage, setRequestedPage] = useState(1);
const [pageCount, setPageCount] = useState(1);

const onPageChange = (nextPage: number) => {
setLoading(true);
setError(null);
setServices(null);
setRequestedPage(nextPage);
};

useEffect(() => {
apiGet<{ services: Service[] }>("/api/v1/services")
.then((b) => setServices(b.services))
.catch((e) => setError(e.message ?? "failed to load"));
}, []);
let cancelled = false;

apiGet<ServicesResponse>(
`/api/v1/services?page=${requestedPage}&limit=${PAGE_SIZE}`
)
.then((body) => {
if (cancelled) return;

const nextServices = body.services ?? body.items ?? [];
const nextPageCount = Math.max(body.pageCount ?? 1, 1);
const nextPage = Math.min(
Math.max(body.page ?? requestedPage, 1),
nextPageCount
);

setServices(nextServices);
setPageCount(nextPageCount);
setPage(nextPage);
})
.catch((e) => {
if (cancelled) return;
setError(e.message ?? "failed to load");
setPageCount(1);
})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});

return () => {
cancelled = true;
};
}, [requestedPage]);

return (
<main
Expand All @@ -36,24 +88,49 @@ export default function ServicesPage() {
{error}
</p>
)}
{!services && !error && <p>Loading…</p>}
{services && services.length === 0 && (
<p className="text-sm text-zinc-600 dark:text-zinc-400">
No services registered yet.
</p>
{loading && (
<div className="flex justify-center py-10">
<Spinner label="Loading services" />
</div>
)}
{services && services.length > 0 && (
{!loading && services && services.length === 0 && (
<EmptyState
title="No services registered yet."
description="Create the first service to start tracking request pricing."
action={
<Link
href="/services/new"
className="rounded-full bg-black px-4 py-2 text-sm font-medium text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:bg-white dark:text-black"
>
New service
</Link>
}
/>
)}
{!loading && services && services.length > 0 && (
<ul className="divide-y divide-zinc-200 dark:divide-zinc-800">
{services.map((s) => (
<li key={s.serviceId} className="flex items-center justify-between py-3">
<span className="font-mono text-sm">{s.serviceId}</span>
<span className="text-sm text-zinc-600 dark:text-zinc-400">
{s.priceStroops} stroops / request
</span>
<li key={s.serviceId}>
<Link
href={`/services/${encodeURIComponent(s.serviceId)}`}
className="flex items-center justify-between py-3 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500"
>
<span className="font-mono text-sm">{s.serviceId}</span>
<span className="text-sm text-zinc-600 dark:text-zinc-400">
{s.priceStroops} stroops / request
</span>
</Link>
</li>
))}
</ul>
)}
{!loading && !error && (
<Pagination
page={page}
pageCount={pageCount}
onChange={onPageChange}
/>
)}
</main>
);
}
9 changes: 3 additions & 6 deletions src/components/ThemeToggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,15 @@ import { useEffect, useState } from "react";
import { readTheme, writeTheme, effectiveTheme, type Theme } from "@/lib/theme";

export function ThemeToggle() {
const [theme, setTheme] = useState<Theme>("system");
const [theme, setTheme] = useState<Theme>(() => readTheme());

useEffect(() => {
const t = readTheme();
setTheme(t);
document.documentElement.classList.toggle("dark", effectiveTheme(t) === "dark");
}, []);
document.documentElement.classList.toggle("dark", effectiveTheme(theme) === "dark");
}, [theme]);

const set = (next: Theme) => {
setTheme(next);
writeTheme(next);
document.documentElement.classList.toggle("dark", effectiveTheme(next) === "dark");
};

return (
Expand Down
6 changes: 3 additions & 3 deletions src/components/TimeAgo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,15 @@ function format(deltaMs: number): string {
}

export function TimeAgo({ ts }: { ts: number }) {
const [, force] = useState(0);
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const t = setInterval(() => force((n) => n + 1), 30_000);
const t = setInterval(() => setNow(Date.now()), 30_000);
return () => clearInterval(t);
}, []);
const iso = new Date(ts).toISOString();
return (
<time dateTime={iso} title={iso}>
{format(Date.now() - ts)}
{format(now - ts)}
</time>
);
}
Loading
Loading