Files
galaxy-game/ui/frontend/src/lib/error/classify.ts
T
Ilia Denisov 8dcaf1c6c6
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Failing after 7m13s
feat(ui): error & state UX — error surface, view states, map selection, sheet gestures (F4)
- lib/error/: classify any caught error into a stable ErrorKind from the
  transport signal (HTTP status / Connect Code / fetch TypeError /
  navigator.onLine); map to translated error.* messages via reportError
  (sticky Retry toast for retryable kinds) or errorMessageKey (inline).
  Mail compose now surfaces the translated 403/error inline.
- lib/ui/view-state.svelte: shared loading/empty/error placeholder with
  the right live-region role + optional action; entity tables
  (races/sciences/ship-classes) migrated, rest adopt incrementally.
- map/selection-ring.ts: accent ring around the selected planet, fed into
  the map buildExtras alongside the reach circles.
- lib/ui/sheet-dismiss.ts: tap-outside + drag-handle swipe-down dismissal
  for the planet/ship-group bottom-sheets (hand-rolled pointer events).

Tests: error, view-state, selection-ring, sheet-dismiss (761 total).
Docs: ui/docs/error-state-ux.md (+ index); F4 marked done.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:29:11 +02:00

94 lines
2.5 KiB
TypeScript

/**
* Error classification.
*
* Server `result_code`s are downstream-opaque (only `"ok"` is a stable
* contract — see `docs/ARCHITECTURE.md`), so the client cannot key UX off
* them. Instead every caught error is collapsed into a small, stable set
* of client-side kinds, derived from the transport-level signal that IS
* reliable: HTTP status (`AuthError`), the Connect `Code`, a fetch
* `TypeError`, or the live `navigator.onLine` flag.
*/
import { ConnectError, Code } from "@connectrpc/connect";
import { AuthError } from "../../api/auth";
/** A stable, user-facing classification of any caught error. */
export type ErrorKind =
| "offline"
| "network"
| "auth"
| "forbidden"
| "conflict"
| "notFound"
| "rateLimit"
| "server"
| "unknown";
function fromHttpStatus(status: number): ErrorKind {
if (status === 401) return "auth";
if (status === 403) return "forbidden";
if (status === 404) return "notFound";
if (status === 409) return "conflict";
if (status === 429) return "rateLimit";
if (status >= 500) return "server";
return "unknown";
}
function fromConnectCode(code: Code): ErrorKind {
switch (code) {
case Code.Unauthenticated:
return "auth";
case Code.PermissionDenied:
return "forbidden";
case Code.NotFound:
return "notFound";
case Code.AlreadyExists:
case Code.Aborted:
case Code.FailedPrecondition:
return "conflict";
case Code.ResourceExhausted:
return "rateLimit";
case Code.Unavailable:
return "network";
case Code.Internal:
case Code.DataLoss:
case Code.Unknown:
return "server";
default:
return "unknown";
}
}
function hasNumericStatus(err: unknown): err is { status: number } {
return (
typeof err === "object" &&
err !== null &&
typeof (err as { status?: unknown }).status === "number"
);
}
/** Collapse any caught value into a stable {@link ErrorKind}. */
export function classifyError(err: unknown): ErrorKind {
if (typeof navigator !== "undefined" && navigator.onLine === false) {
return "offline";
}
if (err instanceof AuthError) return fromHttpStatus(err.status);
if (err instanceof ConnectError) return fromConnectCode(err.code);
if (err instanceof TypeError && /fetch|network|load failed/i.test(err.message)) {
return "network";
}
if (hasNumericStatus(err)) return fromHttpStatus(err.status);
return "unknown";
}
const RETRYABLE: ReadonlySet<ErrorKind> = new Set<ErrorKind>([
"offline",
"network",
"server",
]);
/** Whether retrying the same operation could plausibly succeed. */
export function isRetryable(kind: ErrorKind): boolean {
return RETRYABLE.has(kind);
}