Skip to content
Merged
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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,8 +157,13 @@ Key design decisions:
message renders, `reset` is called on click, no stack trace appears, and the
component renders standalone without Header/Footer.

## Responsive header navigation

On small screens (below Tailwind `md`), the Header collapses into an accessible disclosure menu with a keyboard-operable toggle (Escape closes; focus returns to the toggle). The inline primary navigation remains for `md` and larger screens.

## Accessibility


### Route loading skeleton

The App Router fallback in [`src/app/loading.tsx`](src/app/loading.tsx) renders an
Expand Down
18 changes: 10 additions & 8 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
- [ ] Update src/app/admin/page.tsx with a real latest-wins stale-status guard using useRef.
- [ ] Ensure load useCallback is stable (no statusSeq deps) and remove eslint-disable hacks for deps.
- [ ] Add/extend unit tests in src/app/admin/page.test.tsx to verify out-of-order status responses are ignored, latest response wins, and toggle refresh works.
- [ ] Add a JSDoc note documenting latest-wins semantics.
- [ ] Run npm run lint, npm run typecheck, npm test, npm run build.
- [ ] Verify tests cover edge cases (slow then fast status, toggle during in-flight status, unmount during fetch, load error).
- [ ] Commit changes with message: refactor(admin): replace dead statusSeq with working latest-wins guard
- [ ] Push branch to GitHub.
# TODO

- [x] Inspect existing numeric validation helper (`src/lib/validateNumber.ts`).
- [x] Verify usage/edit/new pages already import and use the helper.
- [ ] Update helper tests (`src/lib/__tests__/validateNumber.test.ts`) to cover required edge cases for both ranges.
- [ ] Adjust `src/app/usage/page.test.tsx` to assert validation message is surfaced through `TextField` error UI for non-integer requests.
- [ ] Update `README.md` with validation rule summary (price: >=0 int; requests: >=1 int).
- [ ] Run `npm run lint`, `npm run typecheck`, `npm test`, `npm run test:coverage`.
- [ ] Ensure coverage threshold (>=95%) for helper + changed pages.
- [ ] Commit with message: `refactor(forms): extract shared numeric-field validation helper`.

35 changes: 16 additions & 19 deletions src/app/services/[serviceId]/edit/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import { useEffect, useState, use } from "react";
import { useRouter } from "next/navigation";
import { apiGet, apiPatch } from "@/lib/apiClient";
import { TextField } from "@/components/TextField";
import { parseNonNegativeInt } from "@/lib/validateNumber";

type Service = { serviceId: string; priceStroops: number };

Expand All @@ -26,16 +28,17 @@ export default function EditServicePage({
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
const n = Number(price);
if (!Number.isInteger(n) || n < 0) {
setError("Price must be a non-negative integer.");
const parsed = parseNonNegativeInt(price);
if (!parsed.ok) {
setError(parsed.message);
return;
}

setLoading(true);
try {
await apiPatch(
`/api/v1/services/${encodeURIComponent(serviceId)}/price`,
{ priceStroops: n }
{ priceStroops: parsed.value }
);
router.push(`/services/${encodeURIComponent(serviceId)}`);
} catch (err) {
Expand All @@ -54,28 +57,22 @@ export default function EditServicePage({
<h1 className="text-3xl font-semibold tracking-tight">Edit price</h1>
<p className="font-mono text-sm text-zinc-500">{serviceId}</p>
<form onSubmit={onSubmit} className="flex flex-col gap-3">
<label className="flex flex-col gap-1 text-sm">
<span>Price (stroops / request)</span>
<input
required
inputMode="numeric"
value={price}
onChange={(e) => setPrice(e.target.value)}
className="rounded-md border border-zinc-300 px-3 py-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:border-zinc-700 dark:bg-zinc-900"
/>
</label>
<TextField
label="Price (stroops / request)"
inputMode="numeric"
required
value={price}
onChange={(e) => setPrice(e.target.value)}
error={error}
/>
<button
type="submit"
disabled={loading}
className="self-start rounded-full bg-black px-5 py-2 text-sm font-medium text-white disabled:opacity-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500"
>
{loading ? "Saving…" : "Save"}
</button>
{error && (
<p role="alert" className="text-sm text-rose-600">
{error}
</p>
)}

</form>
</main>
);
Expand Down
37 changes: 23 additions & 14 deletions src/app/services/new/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { useState } from "react";
import { useRouter } from "next/navigation";
import { apiPost } from "@/lib/apiClient";
import { PageShell } from "@/components/PageShell";
import { TextField } from "@/components/TextField";
import { parseNonNegativeInt } from "@/lib/validateNumber";

export default function NewServicePage() {
const router = useRouter();
Expand All @@ -15,14 +17,19 @@ export default function NewServicePage() {
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
const n = Number(priceStroops);
if (!Number.isInteger(n) || n < 0) {
setError("Price must be a non-negative integer.");

const parsed = parseNonNegativeInt(priceStroops);
if (!parsed.ok) {
setError(parsed.message);
return;
}

setLoading(true);
try {
await apiPost("/api/v1/services", { serviceId, priceStroops: n });
await apiPost("/api/v1/services", {
serviceId,
priceStroops: parsed.value,
});
router.push("/services");
} catch (err) {
setError((err as Error).message);
Expand All @@ -45,23 +52,24 @@ export default function NewServicePage() {
className="rounded-md border border-zinc-300 px-3 py-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:border-zinc-700 dark:bg-zinc-900"
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span>Price (stroops / request)</span>
<input
required
inputMode="numeric"
value={priceStroops}
onChange={(e) => setPriceStroops(e.target.value)}
className="rounded-md border border-zinc-300 px-3 py-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:border-zinc-700 dark:bg-zinc-900"
/>
</label>

<TextField
label="Price (stroops / request)"
inputMode="numeric"
required
value={priceStroops}
onChange={(e) => setPriceStroops(e.target.value)}
error={error}
/>

<button
type="submit"
disabled={loading}
className="self-start rounded-full bg-black px-5 py-2 text-sm font-medium text-white disabled:opacity-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500"
>
{loading ? "Saving…" : "Register service"}
</button>

{error && (
<p role="alert" className="text-sm text-rose-600">
{error}
Expand All @@ -71,3 +79,4 @@ export default function NewServicePage() {
</PageShell>
);
}

32 changes: 16 additions & 16 deletions src/app/usage/page.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
"use client";

import { Spinner } from "@/components/Spinner";
import { TextField } from "@/components/TextField";
import type { ApiError } from "@/lib/apiClient";
import { apiGet, apiPost } from "@/lib/apiClient";
import type { FormEvent } from "react";
import { useState } from "react";
import { parsePositiveInt } from "@/lib/validateNumber";

type QueryResult = {
agent: string;
Expand Down Expand Up @@ -58,9 +60,10 @@ export default function UsagePage() {
const onRecord = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (isRecording) return;
const requestsNum = Number(requests);
if (!Number.isInteger(requestsNum) || requestsNum <= 0) {
setStatus({ kind: "error", message: "requests must be a positive integer" });
const parsed = parsePositiveInt(requests);
if (!parsed.ok) {
// Surface the validation message through the field error.
setStatus({ kind: "error", message: parsed.message });
return;
}

Expand All @@ -69,7 +72,7 @@ export default function UsagePage() {
const body = await apiPost<{ total: number }>("/api/v1/usage", {
agent,
serviceId,
requests: requestsNum,
requests: parsed.value,
});
setStatus({ kind: "ok", total: body?.total });
} catch (error) {
Expand Down Expand Up @@ -134,18 +137,15 @@ export default function UsagePage() {
className="rounded-md border border-zinc-300 px-3 py-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:border-zinc-700 dark:bg-zinc-900"
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span>Requests</span>
<input
required
type="number"
min="1"
name="requests"
value={requests}
onChange={(e) => setRequests(e.target.value)}
className="rounded-md border border-zinc-300 px-3 py-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:border-zinc-700 dark:bg-zinc-900"
/>
</label>
<TextField
label="Requests"
inputMode="numeric"
required
value={requests}
onChange={(e) => setRequests(e.target.value)}
error={status.kind === "error" ? status.message : undefined}
/>

<button
type="submit"
disabled={isRecording}
Expand Down
5 changes: 5 additions & 0 deletions src/components/EmptyState.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { type ReactNode } from "react";

/**
* EmptyState is a small presentational helper for empty list/detail screens.
*
* It renders a title and optional description and action content.
*/
type Props = {
title: ReactNode;
description?: ReactNode;
Expand Down
Loading
Loading