Squashed commit of the following:
commit da3f3bff39f09e753c1c52de0c47a12383cd20dc
Author: Nik Afiq <nik.afiq98@ymail.com>
Date: Sat Dec 6 02:49:31 2025 +0900
docs: add README files for frontend and backend with setup instructions and commands
commit df71faa0751d1cef3ad6d50d0293fb8f8239d000
Author: Nik Afiq <nik.afiq98@ymail.com>
Date: Sat Dec 6 02:44:13 2025 +0900
chore: update Vite version and add Vitest configuration
- Updated Vite dependency from rolldown-vite@7.1.12 to ^5.4.11 in package.json.
- Added setup file for Vitest to handle Vite SSR helpers, preventing ReferenceError during unit tests.
- Created a new tsconfig.vitest.json file extending the main tsconfig for Vitest compatibility.
- Added vitest.config.ts to configure Vitest with Node environment and setup files.
commit f3a1710dd0fe12998965992f6b5f8803422087cf
Author: Nik Afiq <nik.afiq98@ymail.com>
Date: Sat Dec 6 02:37:32 2025 +0900
feat: add testing framework and implement unit tests for API and config
- Added Vitest as a testing framework with scripts for running tests.
- Created unit tests for the `fetchSchedule` and `fetchServerNow` functions in `watchparty.test.ts`.
- Implemented unit tests for configuration functions in `config.test.ts`.
- Added utility functions for parsing time in `time.ts` with corresponding tests in `time.test.ts`.
- Updated API error handling to use `unknown` type for better type safety.
- Refactored `TimeSyncNotice` and `Timer` components to improve performance and error handling.
- Enhanced toast notifications by moving related functions to `toastBus.ts`.
- Improved type definitions across various files for better type safety and clarity.
commit 0d436849fc4a0b87d0c73f4fe14fe1e272d47ad9
Author: Nik Afiq <nik.afiq98@ymail.com>
Date: Sat Dec 6 02:30:01 2025 +0900
Refactor components to utilize centralized configuration: update TimeSyncNotice and Timer to use config for intervals; enhance error handling and retry logic in ShowsPage.
commit 8dbd4d207a471d05fab8c6f9cd95e3f4f7ec9099
Author: Nik Afiq <nik.afiq98@ymail.com>
Date: Sat Dec 6 02:22:28 2025 +0900
Refactor API endpoint handling: replace join function with buildApiUrl for cleaner URL construction; update BrowserRouter basename to use config
commit 3e327aa73877034019dffe262580536f4be7c62e
Author: Nik Afiq <nik.afiq98@ymail.com>
Date: Sat Dec 6 02:18:31 2025 +0900
Refactor configuration management: introduce config.ts for centralized app configuration; update API endpoint handling and logger to use new config
commit 131984d1baf368f94a14a62986eaf028ebbd7c86
Author: Nik Afiq <nik.afiq98@ymail.com>
Date: Sat Dec 6 02:11:33 2025 +0900
Refactor API types: move ScheduleResponse and ShowItem types to a new types.ts file; update imports in watchparty and Timer components
commit 8faa4661a9ccc0691490a5766f0eb1d97f24b6e5
Author: Nik Afiq <nik.afiq98@ymail.com>
Date: Sat Dec 6 02:05:42 2025 +0900
Refactor API handling: introduce centralized error handling and logging; replace direct fetch calls with apiFetch in Timer, ShowsPage, and hooks
commit ffde7e89fcab6f48c6023afab73e4b2e1122efa5
Author: Nik Afiq <nik.afiq98@ymail.com>
Date: Sat Dec 6 01:52:36 2025 +0900
Add dist directory to .gitignore
commit 128a5be6eaa16bf4db5f7dd832b1d461fa2b835d
Author: Nik Afiq <nik.afiq98@ymail.com>
Date: Sat Dec 6 01:52:28 2025 +0900
Add toast notifications and debug overlay components; refactor Timer and ShowsPage for error handling
This commit is contained in:
parent
51f640bc99
commit
9674fc9cbe
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,4 +1,5 @@
|
||||
.env
|
||||
.cache
|
||||
.DS_Store
|
||||
*/dist
|
||||
*/node_modules
|
||||
86
README.md
86
README.md
@ -1,12 +1,40 @@
|
||||
# Watch Party (Frontend + Backend)
|
||||
|
||||
Watch Party is a small full-stack app:
|
||||
Watch Party is a small full-stack app for synchronized viewing with a live timer and episode control.
|
||||
|
||||
- **Frontend:** Vite + React + TypeScript, served by **Nginx**
|
||||
- **Backend:** Go (Gin) API + PostgreSQL
|
||||
- **Orchestration:** One **docker-compose** that keeps FE/BE split on the same internal network
|
||||
- **Routing:** Your Debian Nginx is the public entrypoint and reverse-proxies to the Mac mini (where this stack runs)
|
||||
## Features
|
||||
- Live timer with server-synced clock and schedule-driven start/end.
|
||||
- Episode selector (set current episode + optional custom start time).
|
||||
- Time-skew banner (warns when client clock is off).
|
||||
- Debug-only logging overlay and error toasts for API failures (enable with `FRONTEND_MODE=debug` / `VITE_APP_MODE=debug`).
|
||||
|
||||
## Prerequisites
|
||||
- Docker + Docker Compose plugin.
|
||||
- Node.js 20+ (for local frontend dev/test).
|
||||
- Go 1.22+ (for local backend dev/test).
|
||||
- A `.env` file at repo root (see template below).
|
||||
|
||||
## Deploy (compose)
|
||||
```bash
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
```
|
||||
Open: `http://<mac-mini-LAN-IP>:3000/watch-party/`
|
||||
|
||||
Health checks:
|
||||
- Web (nginx): `curl http://<mac-mini-LAN-IP>:3000/`
|
||||
- API via web proxy: `curl http://<mac-mini-LAN-IP>:3000/api/healthz`
|
||||
- API direct (inside network): `curl http://api:8082/healthz`
|
||||
|
||||
Logs:
|
||||
```bash
|
||||
docker compose logs -f migrate
|
||||
docker compose logs -f api
|
||||
docker compose logs -f web
|
||||
docker compose logs -f db
|
||||
```
|
||||
|
||||
Architecture:
|
||||
```
|
||||
router → Debian nginx (TLS) → Mac mini :3000 → [web nginx ↔ api ↔ db]
|
||||
```
|
||||
@ -80,30 +108,6 @@ PGSSLMODE=disable
|
||||
|
||||
---
|
||||
|
||||
## Run (Production-like)
|
||||
|
||||
```bash
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Open: `http://<mac-mini-LAN-IP>:3000/watch-party/`
|
||||
|
||||
Health checks:
|
||||
- Web (nginx): `curl http://<mac-mini-LAN-IP>:3000/`
|
||||
- API through web proxy: `curl http://<mac-mini-LAN-IP>:3000/api/healthz`
|
||||
- API direct (inside network only): `curl http://api:8082/healthz` (from another container)
|
||||
|
||||
Logs:
|
||||
```bash
|
||||
docker compose logs -f migrate # should start, apply SQL, and exit 0
|
||||
docker compose logs -f api
|
||||
docker compose logs -f web
|
||||
docker compose logs -f db
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Debian Nginx (public reverse proxy)
|
||||
|
||||
Terminate TLS on Debian and forward to the Mac mini:
|
||||
@ -129,25 +133,9 @@ Security headers & HSTS on Debian are recommended.
|
||||
---
|
||||
|
||||
## Local Development (hot reload)
|
||||
|
||||
You can dev each side outside Docker:
|
||||
|
||||
- **Frontend**
|
||||
```bash
|
||||
cd frontend
|
||||
npm ci
|
||||
npm run dev # http://localhost:5173
|
||||
```
|
||||
Configure your FE to call the API (e.g., via a local `.env` or vite config).
|
||||
|
||||
- **Backend**
|
||||
```bash
|
||||
cd backend
|
||||
go run ./cmd/server
|
||||
```
|
||||
Make sure Postgres is reachable (either from the compose `db` or your local).
|
||||
|
||||
For a dev-only compose with Vite HMR, create an override (not included here).
|
||||
See per-service READMEs:
|
||||
- Frontend: `frontend/README.md`
|
||||
- Backend: `backend/README.md`
|
||||
|
||||
---
|
||||
|
||||
@ -194,4 +182,4 @@ curl http://<mac-mini-LAN-IP>:3000/api/current
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
MIT
|
||||
|
||||
39
backend/README.md
Normal file
39
backend/README.md
Normal file
@ -0,0 +1,39 @@
|
||||
# Backend (Go + Gin)
|
||||
|
||||
REST API that serves the schedule/time endpoints and current episode control. Built in Go, containerized with a multi-stage Dockerfile.
|
||||
|
||||
## Prerequisites
|
||||
- Go 1.22+
|
||||
- PostgreSQL reachable (uses env-driven DSN pieces)
|
||||
- `golang-migrate` not required locally (migrations shipped in repo)
|
||||
|
||||
## Env
|
||||
The server builds its DSN from:
|
||||
- `PGHOST`, `POSTGRES_PORT`, `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`, `PGSSLMODE`
|
||||
- `ADDR` (listen address, default `:8082`)
|
||||
- `GIN_MODE` (e.g., `release`)
|
||||
|
||||
## Commands
|
||||
```bash
|
||||
# Run locally (expects Postgres per env)
|
||||
go run ./cmd/server
|
||||
|
||||
# Run migrations
|
||||
go run ./cmd/migrate
|
||||
|
||||
# Tests
|
||||
go test ./...
|
||||
```
|
||||
|
||||
## Docker build (used by compose)
|
||||
```bash
|
||||
docker build -t watchparty-backend .
|
||||
```
|
||||
Compose uses the same image for `api` and the one-off `migrate` job.
|
||||
|
||||
## Endpoints (key)
|
||||
- `GET /api/v1/time` — server time for client clock sync
|
||||
- `GET /api/v1/current` — current schedule item
|
||||
- `POST /api/v1/current` — set current episode (expects `{ id, start_time? }`)
|
||||
- `GET /api/v1/shows` — list of episodes
|
||||
- `GET /healthz` — health check
|
||||
@ -11,6 +11,8 @@ ENV PUBLIC_BASE_PATH=${PUBLIC_BASE_PATH}
|
||||
ARG FRONTEND_MODE=production
|
||||
ENV FRONTEND_MODE=${FRONTEND_MODE}
|
||||
ENV VITE_APP_MODE=${FRONTEND_MODE}
|
||||
ARG VITE_BACKEND_ORIGIN=/api
|
||||
ENV VITE_BACKEND_ORIGIN=${VITE_BACKEND_ORIGIN}
|
||||
|
||||
RUN npm run build
|
||||
|
||||
|
||||
43
frontend/README.md
Normal file
43
frontend/README.md
Normal file
@ -0,0 +1,43 @@
|
||||
# Frontend (Vite + React + TS)
|
||||
|
||||
Single-page app served by Nginx in production. Uses Vite for build/dev, React Router, and debug-gated logging/overlay.
|
||||
|
||||
## Prerequisites
|
||||
- Node.js 20+
|
||||
- npm (uses `package-lock.json`)
|
||||
|
||||
## Commands
|
||||
```bash
|
||||
npm ci # install deps
|
||||
npm run dev # start Vite dev server (http://localhost:5173)
|
||||
npm run build # type-check + production build
|
||||
npm run lint # eslint
|
||||
npm test # vitest unit tests (config, parsers, API helpers)
|
||||
```
|
||||
|
||||
## Env/config
|
||||
- `PUBLIC_BASE_PATH` (build arg) → Vite `base`/Router `basename` (default `/watch-party/`, use `/` for local dev).
|
||||
- `FRONTEND_MODE` → `VITE_APP_MODE` (`debug` enables logging/overlay; `production` disables).
|
||||
- `VITE_BACKEND_ORIGIN` (default `/api`) — relative path preferred so Nginx proxy works; avoid baking hosts.
|
||||
- Optional polling overrides:
|
||||
- `VITE_INTERVAL_TIME_SYNC_MS` (default 60000)
|
||||
- `VITE_INTERVAL_TIME_SKEW_MS` (default 300000)
|
||||
- `VITE_INTERVAL_SCHEDULE_POLL_MS` (default 60000)
|
||||
|
||||
## Running locally
|
||||
```bash
|
||||
cd frontend
|
||||
npm ci
|
||||
VITE_APP_MODE=debug npm run dev # enables debug overlay/logging
|
||||
```
|
||||
The dev server proxies `/api` to `http://localhost:8082` (see `vite.config.ts`). Adjust if your backend runs elsewhere.
|
||||
|
||||
## Docker build
|
||||
```bash
|
||||
docker build -t watchparty-frontend \
|
||||
--build-arg PUBLIC_BASE_PATH=/watch-party/ \
|
||||
--build-arg FRONTEND_MODE=production \
|
||||
--build-arg VITE_BACKEND_ORIGIN=/api \
|
||||
.
|
||||
```
|
||||
The runtime Nginx proxy target is controlled separately via `BACKEND_ORIGIN` in the root `.env`/compose.
|
||||
1372
frontend/package-lock.json
generated
1372
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -7,7 +7,9 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.1.1",
|
||||
@ -26,9 +28,7 @@
|
||||
"globals": "^16.4.0",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.44.0",
|
||||
"vite": "npm:rolldown-vite@7.1.12"
|
||||
},
|
||||
"overrides": {
|
||||
"vite": "npm:rolldown-vite@7.1.12"
|
||||
"vite": "^5.4.11",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,6 +3,8 @@ import { Link, NavLink, Route, Routes, useLocation } from "react-router-dom";
|
||||
import Timer from "./components/Timer";
|
||||
import ShowsPage from "./pages/ShowsPage";
|
||||
import TimeSyncNotice from "./components/TimeSyncNotice";
|
||||
import { ToastViewport } from "./components/Toasts";
|
||||
import DebugOverlay from "./components/DebugOverlay";
|
||||
import "./index.css";
|
||||
|
||||
const TIME_SYNC_OFF_THRESHOLD = 100;
|
||||
@ -71,6 +73,8 @@ export default function App() {
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<ToastViewport />
|
||||
<DebugOverlay />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
111
frontend/src/api/client.ts
Normal file
111
frontend/src/api/client.ts
Normal file
@ -0,0 +1,111 @@
|
||||
import { logApiError, logApiRequest, logApiResponse } from "../utils/logger";
|
||||
|
||||
export type ApiErrorShape = {
|
||||
message: string;
|
||||
status?: number;
|
||||
url?: string;
|
||||
data?: unknown;
|
||||
cause?: unknown;
|
||||
};
|
||||
|
||||
export class ApiError extends Error {
|
||||
status?: number;
|
||||
url?: string;
|
||||
data?: unknown;
|
||||
constructor(message: string, opts?: Partial<ApiErrorShape>) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = opts?.status;
|
||||
this.url = opts?.url;
|
||||
this.data = opts?.data;
|
||||
if (opts?.cause !== undefined) {
|
||||
(this as unknown as { cause: unknown }).cause = opts.cause; // best-effort for debugging visibility
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type FetchExpect = "json" | "text" | "void";
|
||||
|
||||
type MaybeAbortSignal = AbortSignal | null | undefined;
|
||||
|
||||
type ApiFetchOptions = RequestInit & {
|
||||
timeoutMs?: number;
|
||||
expect?: FetchExpect;
|
||||
logLabel?: string;
|
||||
};
|
||||
|
||||
function mergeSignal(signal?: MaybeAbortSignal, timeoutMs?: number) {
|
||||
if (!signal && !timeoutMs) return { signal, cleanup: () => {} };
|
||||
|
||||
const ac = new AbortController();
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
ac.abort(signal.reason);
|
||||
} else {
|
||||
const onAbort = () => ac.abort(signal.reason);
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (timeoutMs && timeoutMs > 0) {
|
||||
timeoutId = setTimeout(() => {
|
||||
const err = new DOMException("Request timed out", "AbortError");
|
||||
ac.abort(err);
|
||||
}, timeoutMs);
|
||||
}
|
||||
|
||||
const cleanup = () => { if (timeoutId) clearTimeout(timeoutId); };
|
||||
return { signal: ac.signal, cleanup };
|
||||
}
|
||||
|
||||
async function readBody(res: Response, expect: FetchExpect) {
|
||||
if (expect === "void") return undefined;
|
||||
if (expect === "text") return res.text();
|
||||
// json by default
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(url: string, opts?: ApiFetchOptions): Promise<T> {
|
||||
const { timeoutMs, expect = "json", logLabel, ...init } = opts || {};
|
||||
const { signal: mergedSignal, cleanup } = mergeSignal(init.signal, timeoutMs);
|
||||
const headers = { Accept: "application/json", ...(init.headers || {}) };
|
||||
const label = logLabel || url;
|
||||
|
||||
try {
|
||||
logApiRequest(label, { url, method: init.method || "GET" });
|
||||
const res = await fetch(url, { ...init, headers, signal: mergedSignal || init.signal });
|
||||
logApiResponse(label, res);
|
||||
|
||||
if (!res.ok) {
|
||||
let data: unknown;
|
||||
try { data = await readBody(res, expect); }
|
||||
catch { /* ignore parse errors for error responses */ }
|
||||
throw new ApiError(`HTTP ${res.status}`, { status: res.status, url, data });
|
||||
}
|
||||
|
||||
if (expect === "void") return undefined as T;
|
||||
|
||||
try {
|
||||
const data = (await readBody(res, expect)) as T;
|
||||
return data;
|
||||
} catch (e: unknown) {
|
||||
throw new ApiError("Failed to parse response", { url, cause: e });
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
logApiError(label, err);
|
||||
throw err;
|
||||
}
|
||||
const message = err instanceof Error ? err.message : "Request failed";
|
||||
const status = err && typeof err === "object" && "status" in err
|
||||
? Number((err as { status?: unknown }).status)
|
||||
: undefined;
|
||||
const apiErr = new ApiError(message, { url, cause: err, status });
|
||||
logApiError(label, apiErr);
|
||||
throw apiErr;
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,9 @@
|
||||
import { buildApiUrl } from "../config";
|
||||
|
||||
export const API_ENDPOINT = {
|
||||
v1: {
|
||||
CURRENT: `/api/v1/current`,
|
||||
SHOWS: `/api/v1/shows`,
|
||||
TIME: `/api/v1/time`
|
||||
CURRENT: buildApiUrl("/v1/current"),
|
||||
SHOWS: buildApiUrl("/v1/shows"),
|
||||
TIME: buildApiUrl("/v1/time"),
|
||||
},
|
||||
} as const;
|
||||
} as const;
|
||||
|
||||
23
frontend/src/api/types.ts
Normal file
23
frontend/src/api/types.ts
Normal file
@ -0,0 +1,23 @@
|
||||
// Shared API response shapes for the frontend.
|
||||
|
||||
export type TimeResponse = {
|
||||
now: number | string;
|
||||
};
|
||||
|
||||
export type ScheduleResponse = {
|
||||
ep_num: number;
|
||||
ep_title: string;
|
||||
season_name: string;
|
||||
start_time?: string;
|
||||
playback_length?: string;
|
||||
};
|
||||
|
||||
export type ShowItem = {
|
||||
id: number;
|
||||
ep_num: number;
|
||||
ep_title: string;
|
||||
season_name: string;
|
||||
start_time: string;
|
||||
playback_length: string;
|
||||
date_created: string;
|
||||
};
|
||||
35
frontend/src/api/watchparty.test.ts
Normal file
35
frontend/src/api/watchparty.test.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { describe, expect, it, vi, afterEach } from "vitest";
|
||||
import { fetchSchedule, fetchServerNow } from "./watchparty";
|
||||
|
||||
const mockFetch = (payload: unknown, ok = true, status = 200) => {
|
||||
const response = new Response(JSON.stringify(payload), { status, statusText: ok ? "OK" : "Error" });
|
||||
return vi.spyOn(globalThis, "fetch").mockResolvedValue(response as Response);
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("fetchSchedule", () => {
|
||||
it("normalizes schedule payload", async () => {
|
||||
const spy = mockFetch({
|
||||
ep_num: "2",
|
||||
ep_title: "Title",
|
||||
season_name: "S1",
|
||||
start_time: "10:00",
|
||||
playback_length: "01:00:00",
|
||||
});
|
||||
const res = await fetchSchedule();
|
||||
expect(res.ep_num).toBe(2);
|
||||
expect(res.ep_title).toBe("Title");
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchServerNow", () => {
|
||||
it("converts seconds to ms", async () => {
|
||||
mockFetch({ now: 1700000000 }); // seconds
|
||||
const ms = await fetchServerNow();
|
||||
expect(ms).toBe(1700000000 * 1000);
|
||||
});
|
||||
});
|
||||
97
frontend/src/api/watchparty.ts
Normal file
97
frontend/src/api/watchparty.ts
Normal file
@ -0,0 +1,97 @@
|
||||
import { API_ENDPOINT } from "./endpoint";
|
||||
import { ApiError, apiFetch } from "./client";
|
||||
import type { ScheduleResponse, ShowItem, TimeResponse } from "./types";
|
||||
|
||||
export type { ScheduleResponse, ShowItem, TimeResponse } from "./types";
|
||||
|
||||
function asNumber(v: unknown, fallback = 0) {
|
||||
const n = typeof v === "number" ? v : Number(v);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
function asString(v: unknown, fallback = "") {
|
||||
return typeof v === "string" ? v : fallback;
|
||||
}
|
||||
|
||||
function normalizeSchedule(data: unknown): ScheduleResponse {
|
||||
if (data == null || typeof data !== "object") {
|
||||
throw new ApiError("Bad schedule payload", { url: API_ENDPOINT.v1.CURRENT, data });
|
||||
}
|
||||
const obj = data as Record<string, unknown>;
|
||||
return {
|
||||
ep_num: asNumber(obj.ep_num, 0),
|
||||
ep_title: asString(obj.ep_title, "未設定"),
|
||||
season_name: asString(obj.season_name, "未設定"),
|
||||
start_time: obj.start_time ? asString(obj.start_time) : undefined,
|
||||
playback_length: obj.playback_length ? asString(obj.playback_length) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeShows(data: unknown): ShowItem[] {
|
||||
if (!Array.isArray(data)) {
|
||||
throw new ApiError("Shows payload is not an array", { url: API_ENDPOINT.v1.SHOWS, data });
|
||||
}
|
||||
return data.map((item) => {
|
||||
if (!item || typeof item !== "object") {
|
||||
throw new ApiError("Bad show item", { url: API_ENDPOINT.v1.SHOWS, data: item });
|
||||
}
|
||||
const obj = item as Record<string, unknown>;
|
||||
const id = asNumber(obj.id, NaN);
|
||||
if (!Number.isFinite(id)) {
|
||||
throw new ApiError("Show item missing id", { url: API_ENDPOINT.v1.SHOWS, data: item });
|
||||
}
|
||||
return {
|
||||
id,
|
||||
ep_num: asNumber(obj.ep_num, 0),
|
||||
ep_title: asString(obj.ep_title, "不明"),
|
||||
season_name: asString(obj.season_name, "不明"),
|
||||
start_time: asString(obj.start_time, ""),
|
||||
playback_length: asString(obj.playback_length, ""),
|
||||
date_created: asString(obj.date_created, ""),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchSchedule(signal?: AbortSignal) {
|
||||
const data = await apiFetch<ScheduleResponse>(API_ENDPOINT.v1.CURRENT, {
|
||||
signal,
|
||||
timeoutMs: 12_000,
|
||||
logLabel: "load schedule",
|
||||
});
|
||||
return normalizeSchedule(data);
|
||||
}
|
||||
|
||||
export async function fetchShows(signal?: AbortSignal) {
|
||||
const data = await apiFetch<ShowItem[]>(API_ENDPOINT.v1.SHOWS, {
|
||||
signal,
|
||||
timeoutMs: 12_000,
|
||||
logLabel: "fetch shows",
|
||||
});
|
||||
return normalizeShows(data);
|
||||
}
|
||||
|
||||
export async function postCurrentEpisode(payload: { id: number; start_time?: string }, signal?: AbortSignal) {
|
||||
await apiFetch<void>(API_ENDPOINT.v1.CURRENT, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
signal,
|
||||
timeoutMs: 12_000,
|
||||
expect: "void",
|
||||
logLabel: "set current episode",
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchServerNow(signal?: AbortSignal): Promise<number> {
|
||||
const data = await apiFetch<TimeResponse>(API_ENDPOINT.v1.TIME, {
|
||||
signal,
|
||||
timeoutMs: 8_000,
|
||||
logLabel: "server time",
|
||||
});
|
||||
let n = typeof data?.now === "number" ? data.now : Number(data?.now);
|
||||
if (!Number.isFinite(n)) {
|
||||
throw new ApiError("Bad time payload", { url: API_ENDPOINT.v1.TIME, data });
|
||||
}
|
||||
if (n < 1e12) n = n * 1000; // seconds → ms
|
||||
return n;
|
||||
}
|
||||
89
frontend/src/components/DebugOverlay.tsx
Normal file
89
frontend/src/components/DebugOverlay.tsx
Normal file
@ -0,0 +1,89 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { DebugLog, Level } from "../utils/logger";
|
||||
import { subscribeLogs, logger } from "../utils/logger";
|
||||
|
||||
function fmt(ts: number) {
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleTimeString(undefined, { hour12: false }) + "." + String(d.getMilliseconds()).padStart(3, "0");
|
||||
}
|
||||
|
||||
function levelLabel(l: Level) {
|
||||
if (l === "debug") return "DBG";
|
||||
if (l === "info") return "INF";
|
||||
if (l === "warn") return "WRN";
|
||||
return "ERR";
|
||||
}
|
||||
|
||||
export default function DebugOverlay() {
|
||||
const [open, setOpen] = useState<boolean>(() => {
|
||||
return logger.enabled && (localStorage.getItem("debugOverlay") === "1");
|
||||
});
|
||||
const [logs, setLogs] = useState<DebugLog[]>([]);
|
||||
|
||||
useEffect(() => subscribeLogs(setLogs), []);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key.toLowerCase() === "d" && e.shiftKey && e.ctrlKey) {
|
||||
setOpen((v) => {
|
||||
const next = !v;
|
||||
if (next) localStorage.setItem("debugOverlay", "1");
|
||||
else localStorage.removeItem("debugOverlay");
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
|
||||
const rendered = useMemo(() => logs.slice(-30).reverse(), [logs]);
|
||||
|
||||
if (!logger.enabled) return null;
|
||||
|
||||
return (
|
||||
<div className={`debug-overlay ${open ? "open" : ""}`}>
|
||||
<button
|
||||
className="debug-toggle"
|
||||
onClick={() => {
|
||||
const next = !open;
|
||||
setOpen(next);
|
||||
if (next) localStorage.setItem("debugOverlay", "1");
|
||||
else localStorage.removeItem("debugOverlay");
|
||||
}}
|
||||
>
|
||||
Debug {open ? "On" : "Off"}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="debug-panel">
|
||||
<div className="debug-header">Recent API logs (Ctrl+Shift+D to toggle)</div>
|
||||
<div className="debug-body">
|
||||
{rendered.length === 0 && <div className="debug-empty">No logs yet.</div>}
|
||||
{rendered.map((l) => {
|
||||
let detailText: string | null = null;
|
||||
if (l.details !== undefined) {
|
||||
if (typeof l.details === "string") detailText = l.details;
|
||||
else {
|
||||
try { detailText = JSON.stringify(l.details); }
|
||||
catch { detailText = String(l.details); }
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div key={l.id} className={`debug-row level-${l.level}`}>
|
||||
<span className="debug-ts">{fmt(l.ts)}</span>
|
||||
<span className="debug-level">{levelLabel(l.level)}</span>
|
||||
<span className="debug-label">{l.label}</span>
|
||||
{detailText && (
|
||||
<span className="debug-details">
|
||||
{detailText}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTimeSkew } from "../hooks/useTimeSkew";
|
||||
import { config } from "../config";
|
||||
|
||||
function formatDelay(ms: number, withSign = true) {
|
||||
const sign = withSign ? (ms > 0 ? "+" : ms < 0 ? "−" : "") : "";
|
||||
@ -25,10 +26,13 @@ export default function TimeSyncNotice({
|
||||
dismissTtlMs?: number;
|
||||
storageScope?: "session" | "local";
|
||||
}) {
|
||||
const { skewMs, rttMs, recheck } = useTimeSkew({ intervalMs });
|
||||
const { skewMs, rttMs, recheck, error } = useTimeSkew({ intervalMs: intervalMs ?? config.intervals.timeSkewMs });
|
||||
|
||||
const KEY = "timesync.dismissedUntil";
|
||||
const store = storageScope === "local" ? window.localStorage : window.sessionStorage;
|
||||
const store = useMemo(
|
||||
() => (storageScope === "local" ? window.localStorage : window.sessionStorage),
|
||||
[storageScope],
|
||||
);
|
||||
|
||||
// read dismissed state (true if now < dismissedUntil)
|
||||
const [dismissed, setDismissed] = useState<boolean>(() => {
|
||||
@ -56,36 +60,45 @@ export default function TimeSyncNotice({
|
||||
store.removeItem(KEY);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
// ignore storage errors
|
||||
}
|
||||
return () => { if (id) clearTimeout(id); };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dismissed]);
|
||||
}, [dismissed, store]);
|
||||
|
||||
// if skew returns within threshold, clear dismissal so it can show again later if it drifts
|
||||
useEffect(() => {
|
||||
if (skewMs != null && Math.abs(skewMs) <= thresholdMs && dismissed) {
|
||||
setDismissed(false);
|
||||
try { store.removeItem(KEY); } catch { }
|
||||
try { store.removeItem(KEY); } catch { void 0; }
|
||||
}
|
||||
}, [skewMs, thresholdMs, dismissed]);
|
||||
}, [skewMs, thresholdMs, dismissed, store]);
|
||||
|
||||
if (dismissed || skewMs == null || Math.abs(skewMs) <= thresholdMs) return null;
|
||||
if (dismissed) return null;
|
||||
|
||||
const ahead = skewMs > 0;
|
||||
const msgJa = ahead
|
||||
? `端末の時計が正確な時刻より ${formatDelay(skewMs)} 進んでいます(通信往復遅延 ${rttMs ?? "-"}ms)`
|
||||
: `端末の時計が正確な時刻より ${formatDelay(-skewMs)} 遅れています(通信往復遅延 ${rttMs ?? "-"}ms)`;
|
||||
const msgEn = ahead
|
||||
? `Your device clock is ${formatDelay(skewMs)} ahead of the correct time (RTT ${rttMs ?? "-"}ms).`
|
||||
: `Your device clock is ${formatDelay(-skewMs)} behind the correct time (RTT ${rttMs ?? "-"}ms).`;
|
||||
const hasSkew = skewMs != null;
|
||||
const skewVal = skewMs ?? 0;
|
||||
if (!error && (!hasSkew || Math.abs(skewVal) <= thresholdMs)) return null;
|
||||
|
||||
const ahead = skewVal > 0;
|
||||
const msgJa = error
|
||||
? `時刻同期に失敗しました: ${error}`
|
||||
: ahead
|
||||
? `端末の時計が正確な時刻より ${formatDelay(skewVal)} 進んでいます(通信往復遅延 ${rttMs ?? "-"}ms)`
|
||||
: `端末の時計が正確な時刻より ${formatDelay(-skewVal)} 遅れています(通信往復遅延 ${rttMs ?? "-"}ms)`;
|
||||
const msgEn = error
|
||||
? `Time sync failed: ${error}`
|
||||
: ahead
|
||||
? `Your device clock is ${formatDelay(skewVal)} ahead of the correct time (RTT ${rttMs ?? "-"}ms).`
|
||||
: `Your device clock is ${formatDelay(-skewVal)} behind the correct time (RTT ${rttMs ?? "-"}ms).`;
|
||||
|
||||
const onClose = () => {
|
||||
setDismissed(true);
|
||||
try {
|
||||
const until = Date.now() + dismissTtlMs;
|
||||
store.setItem(KEY, String(until));
|
||||
} catch { }
|
||||
} catch {
|
||||
// ignore storage failures
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@ -101,4 +114,4 @@ export default function TimeSyncNotice({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,25 +1,21 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useServerClock } from "../hooks/useServerClock";
|
||||
import { API_ENDPOINT } from "../api/endpoint";
|
||||
import { logApiError, logApiRequest, logApiResponse } from "../utils/logger";
|
||||
import { logApiError } from "../utils/logger";
|
||||
import { toastError } from "../utils/toastBus";
|
||||
import { fetchSchedule } from "../api/watchparty";
|
||||
import type { ScheduleResponse } from "../api/types";
|
||||
import { config } from "../config";
|
||||
import { parseDurationToSeconds, parseStartTime } from "../utils/time";
|
||||
|
||||
// ===== Config & fallbacks =====
|
||||
const TIMEZONE = "Asia/Tokyo"; // JST (UTC+09)
|
||||
const API_URL_CURRENT = API_ENDPOINT.v1.CURRENT
|
||||
const FALLBACK_START_HOUR = 19;
|
||||
const FALLBACK_START_MINUTE = 25;
|
||||
const FALLBACK_END_SECONDS = 300;
|
||||
const TIME_SYNC_INTERVAL = 60_000;
|
||||
const TIME_SYNC_INTERVAL = config.intervals.timeSyncMs;
|
||||
const SCHEDULE_POLL_INTERVAL = config.intervals.schedulePollMs;
|
||||
// ==============================
|
||||
|
||||
type ApiSchedule = {
|
||||
ep_num: number;
|
||||
ep_title: string;
|
||||
season_name: string;
|
||||
start_time: string; // "HH:MM" or "HH:MM:SS"
|
||||
playback_length: string; // "MM:SS" or "HH:MM:SS"
|
||||
};
|
||||
|
||||
function formatHMS(total: number) {
|
||||
const t = Math.max(0, Math.floor(total));
|
||||
const h = Math.floor(t / 3600);
|
||||
@ -31,31 +27,6 @@ function formatHMS(total: number) {
|
||||
return h > 0 ? `${HH}:${MM}:${SS}` : `${MM}:${SS}`;
|
||||
}
|
||||
|
||||
function parseStartTime(s?: string): { hour: number; minute: number; second: number } | null {
|
||||
if (!s || typeof s !== "string") return null;
|
||||
const m = /^(\d{1,2}):([0-5]\d)(?::([0-5]\d))?$/.exec(s.trim());
|
||||
if (!m) return null;
|
||||
const hour = Number(m[1]);
|
||||
const minute = Number(m[2]);
|
||||
const second = m[3] ? Number(m[3]) : 0;
|
||||
if (hour < 0 || hour > 23) return null;
|
||||
return { hour, minute, second };
|
||||
}
|
||||
|
||||
function parseDurationToSeconds(s?: string): number {
|
||||
if (!s || typeof s !== "string") return FALLBACK_END_SECONDS;
|
||||
const parts = s.trim().split(":").map(Number);
|
||||
if (parts.some((n) => Number.isNaN(n) || n < 0)) return FALLBACK_END_SECONDS;
|
||||
if (parts.length === 2) {
|
||||
const [mm, ss] = parts;
|
||||
return mm * 60 + ss;
|
||||
} else if (parts.length === 3) {
|
||||
const [hh, mm, ss] = parts;
|
||||
return hh * 3600 + mm * 60 + ss;
|
||||
}
|
||||
return FALLBACK_END_SECONDS;
|
||||
}
|
||||
|
||||
function getJstYMD(now = new Date()) {
|
||||
const parts = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: TIMEZONE,
|
||||
@ -78,18 +49,8 @@ function jstToUtcMs(y: number, m: number, d: number, hh: number, mm: number, ss
|
||||
}
|
||||
|
||||
async function loadSchedule(signal?: AbortSignal) {
|
||||
logApiRequest("loadSchedule", { url: API_URL_CURRENT });
|
||||
const res = await fetch(API_URL_CURRENT, { cache: "no-store", signal });
|
||||
logApiResponse("loadSchedule", res);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const payload = (await res.json()) as ApiSchedule;
|
||||
logApiResponse("loadSchedule payload", res, {
|
||||
ep_num: payload?.ep_num,
|
||||
start_time: payload?.start_time,
|
||||
playback_length: payload?.playback_length,
|
||||
season_name: payload?.season_name,
|
||||
});
|
||||
return payload;
|
||||
// Shared fetcher handles logging and error shaping.
|
||||
return fetchSchedule(signal) as Promise<ScheduleResponse>;
|
||||
}
|
||||
|
||||
export default function Timer() {
|
||||
@ -113,29 +74,38 @@ export default function Timer() {
|
||||
refreshMs: TIME_SYNC_INTERVAL,
|
||||
});
|
||||
|
||||
const applySchedule = React.useCallback((data: ScheduleResponse | null) => {
|
||||
if (!data) return;
|
||||
const parsedStart = parseStartTime(data.start_time);
|
||||
const end = parseDurationToSeconds(data.playback_length, FALLBACK_END_SECONDS);
|
||||
const { y, m, d } = getJstYMD();
|
||||
const startMs = parsedStart
|
||||
? jstToUtcMs(y, m, d, parsedStart.hour, parsedStart.minute, parsedStart.second)
|
||||
: startUtcMs; // keep fallback
|
||||
|
||||
setMeta({ ep_num: data.ep_num, ep_title: data.ep_title, season_name: data.season_name });
|
||||
setStartUtcMs(startMs);
|
||||
setEndSeconds(end);
|
||||
setLoaded(true);
|
||||
setErrorMsg(null);
|
||||
}, [startUtcMs]);
|
||||
|
||||
const fetchAndApply = React.useCallback(async (signal?: AbortSignal) => {
|
||||
try {
|
||||
const data = await loadSchedule(signal);
|
||||
applySchedule(data);
|
||||
} catch (e) {
|
||||
setLoaded(true);
|
||||
setErrorMsg("Failed to load schedule; using defaults.");
|
||||
logApiError("loadSchedule", e);
|
||||
toastError("スケジュール取得に失敗しました", e instanceof Error ? e.message : String(e || ""));
|
||||
}
|
||||
}, [applySchedule]);
|
||||
|
||||
// fetch schedule
|
||||
useEffect(() => {
|
||||
const ac = new AbortController();
|
||||
loadSchedule(ac.signal)
|
||||
.then((data) => {
|
||||
const parsedStart = parseStartTime(data.start_time);
|
||||
const end = parseDurationToSeconds(data.playback_length);
|
||||
const { y, m, d } = getJstYMD();
|
||||
const startMs = parsedStart
|
||||
? jstToUtcMs(y, m, d, parsedStart.hour, parsedStart.minute, parsedStart.second)
|
||||
: startUtcMs; // keep fallback
|
||||
|
||||
setMeta({ ep_num: data.ep_num, ep_title: data.ep_title, season_name: data.season_name });
|
||||
setStartUtcMs(startMs);
|
||||
setEndSeconds(end);
|
||||
setLoaded(true);
|
||||
setErrorMsg(null);
|
||||
})
|
||||
.catch((e) => {
|
||||
setLoaded(true);
|
||||
setErrorMsg("Failed to load schedule; using defaults.");
|
||||
logApiError("loadSchedule", e);
|
||||
});
|
||||
fetchAndApply(ac.signal);
|
||||
return () => ac.abort();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // mount only
|
||||
@ -160,7 +130,7 @@ export default function Timer() {
|
||||
};
|
||||
tick();
|
||||
return () => { if (timerRef.current) window.clearTimeout(timerRef.current); };
|
||||
}, [startUtcMs, endSeconds, nowMs]);
|
||||
}, [startUtcMs, endSeconds, nowMs, ready]);
|
||||
|
||||
// status labels
|
||||
const startLabel = useMemo(() => {
|
||||
@ -193,23 +163,9 @@ export default function Timer() {
|
||||
ac?.abort(); // cancel any in-flight
|
||||
ac = new AbortController();
|
||||
try {
|
||||
const data = await loadSchedule(ac.signal);
|
||||
// Recompute start/end from fresh data
|
||||
const parsedStart = parseStartTime(data.start_time);
|
||||
const end = parseDurationToSeconds(data.playback_length);
|
||||
const { y, m, d } = getJstYMD();
|
||||
const nextStartMs = parsedStart
|
||||
? jstToUtcMs(y, m, d, parsedStart.hour, parsedStart.minute, parsedStart.second)
|
||||
: startUtcMs;
|
||||
setMeta({ ep_num: data.ep_num, ep_title: data.ep_title, season_name: data.season_name });
|
||||
setStartUtcMs(nextStartMs);
|
||||
setEndSeconds(end);
|
||||
setErrorMsg(null);
|
||||
setLoaded(true);
|
||||
// If the new schedule has moved us out of "ended", the ticking effect will handle it.
|
||||
} catch (e) {
|
||||
logApiError("loadSchedule (ended poll)", e);
|
||||
// keep current UI; try again next tick
|
||||
await fetchAndApply(ac.signal);
|
||||
} catch {
|
||||
// fetchAndApply already handles logging/toast
|
||||
}
|
||||
};
|
||||
// immediate fetch once the timer ends
|
||||
@ -217,7 +173,7 @@ export default function Timer() {
|
||||
// then poll every 60s
|
||||
const id = window.setInterval(() => {
|
||||
if (!stopped) refetch();
|
||||
}, 60_000);
|
||||
}, SCHEDULE_POLL_INTERVAL);
|
||||
return () => {
|
||||
stopped = true;
|
||||
if (id) window.clearInterval(id);
|
||||
@ -278,7 +234,14 @@ export default function Timer() {
|
||||
{loaded && phase === "ended" && (
|
||||
<div className="timer-status">再生終了。 開始 {startLabel} JST.</div>
|
||||
)}
|
||||
{errorMsg && <div className="timer-status">{errorMsg}</div>}
|
||||
{errorMsg && (
|
||||
<div className="timer-status">
|
||||
{errorMsg}{" "}
|
||||
<button className="link-btn" onClick={() => fetchAndApply()}>
|
||||
再試行
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPrestartCountdown && (
|
||||
<div className="countdown-overlay" role="status" aria-live="assertive">
|
||||
|
||||
42
frontend/src/components/Toasts.tsx
Normal file
42
frontend/src/components/Toasts.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Toast } from "../utils/toastBus";
|
||||
import { dismissToast, subscribeToasts } from "../utils/toastBus";
|
||||
|
||||
type ToastProps = {
|
||||
toast: Toast;
|
||||
};
|
||||
|
||||
function ToastItem({ toast }: ToastProps) {
|
||||
return (
|
||||
<div className={`toast toast-${toast.level}`}>
|
||||
<div className="toast-body">
|
||||
<div className="toast-title">{toast.level === "error" ? "Error" : toast.level === "warn" ? "Warning" : "Info"}</div>
|
||||
<div className="toast-message">{toast.message}</div>
|
||||
{toast.detail && <div className="toast-detail">{toast.detail}</div>}
|
||||
</div>
|
||||
<button className="toast-close" aria-label="Close" onClick={() => dismissToast(toast.id)}>×</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToastViewport() {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
useEffect(() => subscribeToasts(setToasts), []);
|
||||
|
||||
useEffect(() => {
|
||||
const timers = toasts.map((t) => {
|
||||
if (!t.expiresAt) return null;
|
||||
const delay = Math.max(500, t.expiresAt - Date.now());
|
||||
return window.setTimeout(() => dismissToast(t.id), delay);
|
||||
}).filter(Boolean) as number[];
|
||||
return () => timers.forEach((id) => window.clearTimeout(id));
|
||||
}, [toasts]);
|
||||
|
||||
if (toasts.length === 0) return null;
|
||||
return (
|
||||
<div className="toast-viewport" role="status" aria-live="polite">
|
||||
{toasts.map((t) => <ToastItem key={t.id} toast={t} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
frontend/src/config.test.ts
Normal file
30
frontend/src/config.test.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildApiUrl, normalizeApiBase, normalizeBasePath, envNumber } from "./config";
|
||||
|
||||
describe("normalizeBasePath", () => {
|
||||
it("enforces leading and trailing slash", () => {
|
||||
expect(normalizeBasePath("watch")).toBe("/watch/");
|
||||
expect(normalizeBasePath("/watch")).toBe("/watch/");
|
||||
expect(normalizeBasePath("/watch/")).toBe("/watch/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeApiBase", () => {
|
||||
it("enforces leading slash and trims trailing", () => {
|
||||
expect(normalizeApiBase("api")).toBe("/api");
|
||||
expect(normalizeApiBase("/api/")).toBe("/api");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildApiUrl", () => {
|
||||
it("joins base and path without duplicate slashes", () => {
|
||||
expect(buildApiUrl("/v1/test")).toBe("/api/v1/test");
|
||||
expect(buildApiUrl("v1/test")).toBe("/api/v1/test");
|
||||
});
|
||||
});
|
||||
|
||||
describe("envNumber", () => {
|
||||
it("returns fallback on bad input", () => {
|
||||
expect(envNumber("MISSING_KEY", 123)).toBe(123);
|
||||
});
|
||||
});
|
||||
61
frontend/src/config.ts
Normal file
61
frontend/src/config.ts
Normal file
@ -0,0 +1,61 @@
|
||||
type AppConfig = {
|
||||
mode: string;
|
||||
isDebug: boolean;
|
||||
basePath: string;
|
||||
apiBase: string;
|
||||
backendOrigin: string;
|
||||
intervals: {
|
||||
timeSyncMs: number;
|
||||
timeSkewMs: number;
|
||||
schedulePollMs: number;
|
||||
};
|
||||
};
|
||||
|
||||
export function normalizeBasePath(raw?: string) {
|
||||
let v = (raw || "/").trim();
|
||||
if (!v.startsWith("/")) v = `/${v}`;
|
||||
if (!v.endsWith("/")) v = `${v}/`;
|
||||
return v.replace(/\/{2,}/g, "/");
|
||||
}
|
||||
|
||||
export function normalizeApiBase(raw?: string) {
|
||||
let v = (raw || "/api").trim();
|
||||
if (!v.startsWith("/")) v = `/${v}`;
|
||||
return v.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
type ImportMetaEnvish = { env?: Record<string, unknown> };
|
||||
|
||||
export function envNumber(key: string, fallback: number) {
|
||||
const rawEnv = (import.meta as ImportMetaEnvish).env || {};
|
||||
const raw = rawEnv[key];
|
||||
if (raw == null) return fallback;
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) && n > 0 ? n : fallback;
|
||||
}
|
||||
|
||||
const rawMode = (import.meta.env.VITE_APP_MODE || import.meta.env.MODE || "").toString().toLowerCase();
|
||||
const basePath = normalizeBasePath(import.meta.env.BASE_URL);
|
||||
const apiBase = normalizeApiBase(import.meta.env.VITE_BACKEND_ORIGIN);
|
||||
const timeSyncMs = envNumber("VITE_INTERVAL_TIME_SYNC_MS", 60_000);
|
||||
const timeSkewMs = envNumber("VITE_INTERVAL_TIME_SKEW_MS", 5 * 60_000);
|
||||
const schedulePollMs = envNumber("VITE_INTERVAL_SCHEDULE_POLL_MS", 60_000);
|
||||
|
||||
export const config: AppConfig = {
|
||||
mode: rawMode || "production",
|
||||
isDebug: rawMode === "debug",
|
||||
basePath,
|
||||
apiBase,
|
||||
backendOrigin: apiBase, // alias for clarity
|
||||
intervals: {
|
||||
timeSyncMs,
|
||||
timeSkewMs,
|
||||
schedulePollMs,
|
||||
},
|
||||
};
|
||||
|
||||
export function buildApiUrl(path: string) {
|
||||
const p = path.startsWith("/") ? path : `/${path}`;
|
||||
const base = config.apiBase.replace(/\/$/, "");
|
||||
return `${base}${p}`;
|
||||
}
|
||||
@ -1,9 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { API_ENDPOINT } from "../api/endpoint";
|
||||
import { logApiError, logApiRequest, logApiResponse } from "../utils/logger";
|
||||
|
||||
/** Uses /api/time => { now: <ms UTC> } and returns a server-correct "nowMs()" */
|
||||
const TIME_URL_ENDPOINT = API_ENDPOINT.v1.TIME;
|
||||
import { fetchServerNow } from "../api/watchparty";
|
||||
import { logApiError } from "../utils/logger";
|
||||
|
||||
export function useServerClock(opts?: {
|
||||
refreshMs?: number;
|
||||
@ -22,19 +19,13 @@ export function useServerClock(opts?: {
|
||||
try {
|
||||
setError(null);
|
||||
const t0 = Date.now();
|
||||
logApiRequest("useServerClock", { url: TIME_URL_ENDPOINT });
|
||||
const res = await fetch(TIME_URL_ENDPOINT, { cache: "no-store" });
|
||||
const serverNow = await fetchServerNow();
|
||||
const t1 = Date.now();
|
||||
logApiResponse("useServerClock", res, { roundTripMs: t1 - t0 });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
if (typeof data?.now !== "number") throw new Error("Bad time payload");
|
||||
const s = data.now;
|
||||
const offset = Math.round(((t0 + t1) / 2) - s); // (+) client ahead, (-) client behind
|
||||
const offset = Math.round(((t0 + t1) / 2) - serverNow); // (+) client ahead, (-) client behind
|
||||
setOffsetMs(offset);
|
||||
logApiResponse("useServerClock computed", res, { serverNow: s, offset });
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "time sync failed");
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : "time sync failed";
|
||||
setError(msg);
|
||||
logApiError("useServerClock", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { API_ENDPOINT } from "../api/endpoint";
|
||||
import { logApiError, logApiRequest, logApiResponse } from "../utils/logger";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { logApiError } from "../utils/logger";
|
||||
import { fetchServerNow } from "../api/watchparty";
|
||||
|
||||
/**
|
||||
* Measures client clock skew vs server time {now: <ms since epoch, UTC>}.
|
||||
@ -11,7 +11,6 @@ import { logApiError, logApiRequest, logApiResponse } from "../utils/logger";
|
||||
* offset ≈ ((t0 + t1)/2) - s
|
||||
* Positive offset => client is AHEAD by that many ms.
|
||||
*/
|
||||
const TIME_URL_ENDPOINT = API_ENDPOINT.v1.TIME;
|
||||
export function useTimeSkew(opts?: {
|
||||
intervalMs?: number;
|
||||
samples?: number;
|
||||
@ -28,34 +27,16 @@ export function useTimeSkew(opts?: {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
|
||||
const measureOnce = async () => {
|
||||
const measureOnce = useCallback(async () => {
|
||||
const t0 = Date.now();
|
||||
logApiRequest("useTimeSkew", { url: TIME_URL_ENDPOINT });
|
||||
const res = await fetch(TIME_URL_ENDPOINT, {
|
||||
cache: "no-store",
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
const serverMs = await fetchServerNow();
|
||||
const t1 = Date.now();
|
||||
logApiResponse("useTimeSkew", res, { roundTripMs: t1 - t0 });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
|
||||
// Robust parsing: number or string, seconds or ms
|
||||
const data = await res.json() as any;
|
||||
let serverMs = typeof data?.now === "number" ? data.now : Number(data?.now);
|
||||
if (!Number.isFinite(serverMs)) {
|
||||
throw new Error("Bad time payload (expecting { now: number })");
|
||||
}
|
||||
// Heuristic: if it's too small to be ms epoch, treat as seconds
|
||||
if (serverMs < 1e12) { // ~Sat Sep 09 2001 ms epoch
|
||||
serverMs = serverMs * 1000; // seconds -> ms
|
||||
}
|
||||
|
||||
const rtt = t1 - t0;
|
||||
const offset = Math.round(((t0 + t1) / 2) - serverMs);
|
||||
return { offset, rtt };
|
||||
};
|
||||
}, []);
|
||||
|
||||
const measure = async () => {
|
||||
const measure = useCallback(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
const offsets: number[] = [];
|
||||
@ -72,11 +53,12 @@ export function useTimeSkew(opts?: {
|
||||
const minRtt = Math.min(...rtts);
|
||||
setSkewMs(median);
|
||||
setRttMs(minRtt);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Time sync failed");
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : "Time sync failed";
|
||||
setError(msg);
|
||||
logApiError("useTimeSkew", e);
|
||||
}
|
||||
};
|
||||
}, [samples, measureOnce]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
@ -85,7 +67,7 @@ export function useTimeSkew(opts?: {
|
||||
timerRef.current = window.setInterval(measure, intervalMs);
|
||||
}
|
||||
return () => { if (timerRef.current) window.clearInterval(timerRef.current); };
|
||||
}, [intervalMs, samples, enabled]);
|
||||
}, [intervalMs, samples, enabled, measure]);
|
||||
|
||||
return { skewMs, rttMs, error, recheck: measure };
|
||||
}
|
||||
|
||||
@ -492,4 +492,108 @@ kbd {
|
||||
}
|
||||
@supports not (container-type: inline-size) {
|
||||
.timer-hero { font-size: clamp(28px, 8vw, 96px); }
|
||||
}
|
||||
}
|
||||
|
||||
/* Toasts */
|
||||
.toast-viewport {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
z-index: 200;
|
||||
max-width: 320px;
|
||||
}
|
||||
.toast {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
background: rgba(15, 21, 32, 0.94);
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
box-shadow: 0 12px 30px rgba(0,0,0,0.35);
|
||||
}
|
||||
.toast.toast-error { border-color: rgba(255, 99, 99, 0.4); }
|
||||
.toast.toast-warn { border-color: rgba(255, 193, 99, 0.4); }
|
||||
.toast.toast-info { border-color: rgba(121, 192, 255, 0.4); }
|
||||
.toast-body { text-align: left; }
|
||||
.toast-title { font-weight: 700; font-size: 13px; margin-bottom: 2px; }
|
||||
.toast-message { font-size: 13px; color: var(--text); }
|
||||
.toast-detail { font-size: 12px; color: var(--subtle); margin-top: 2px; }
|
||||
.toast-close {
|
||||
background: transparent;
|
||||
color: var(--subtle);
|
||||
border: none;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
align-self: start;
|
||||
}
|
||||
.toast-close:hover { color: var(--text); }
|
||||
|
||||
/* Debug overlay */
|
||||
.debug-overlay {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
left: 16px;
|
||||
z-index: 190;
|
||||
color: var(--text);
|
||||
}
|
||||
.debug-toggle {
|
||||
background: rgba(255,255,255,0.08);
|
||||
color: var(--text);
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
}
|
||||
.debug-toggle:hover { background: rgba(255,255,255,0.12); }
|
||||
.debug-panel {
|
||||
margin-top: 10px;
|
||||
width: min(420px, 90vw);
|
||||
max-height: 50vh;
|
||||
background: rgba(11, 15, 20, 0.96);
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 16px 32px rgba(0,0,0,0.4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.debug-header {
|
||||
padding: 10px 12px;
|
||||
font-weight: 700;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
.debug-body {
|
||||
padding: 8px 12px;
|
||||
overflow: auto;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
.debug-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto 1fr;
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
font-size: 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
.debug-row .debug-label { font-weight: 700; }
|
||||
.debug-row .debug-details { color: var(--subtle); font-weight: 400; }
|
||||
.debug-row.level-error { color: #ffaaaa; }
|
||||
.debug-row.level-warn { color: #ffd27f; }
|
||||
.debug-row.level-info { color: #9bd4ff; }
|
||||
.debug-row.level-debug { color: #b3c4ff; }
|
||||
.debug-empty { color: var(--subtle); font-size: 12px; }
|
||||
|
||||
.link-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
text-decoration: underline;
|
||||
padding: 0 2px;
|
||||
}
|
||||
.link-btn:hover { color: #bfe3ff; }
|
||||
|
||||
@ -3,11 +3,12 @@ import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
import { config } from "./config";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter basename={import.meta.env.BASE_URL}>
|
||||
<BrowserRouter basename={config.basePath}>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
);
|
||||
|
||||
@ -1,20 +1,10 @@
|
||||
import { useEffect, useMemo, useState, useRef } from "react";
|
||||
import React, { useEffect, useMemo, useState, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { API_ENDPOINT } from "../api/endpoint";
|
||||
import { logApiError, logApiRequest, logApiResponse } from "../utils/logger";
|
||||
import { logApiError } from "../utils/logger";
|
||||
import { toastError, toastInfo } from "../utils/toastBus";
|
||||
import { fetchShows, postCurrentEpisode } from "../api/watchparty";
|
||||
import type { ShowItem } from "../api/watchparty";
|
||||
|
||||
type Show = {
|
||||
id: number;
|
||||
ep_num: number;
|
||||
ep_title: string;
|
||||
season_name: string;
|
||||
start_time: string;
|
||||
playback_length: string;
|
||||
date_created: string;
|
||||
};
|
||||
|
||||
const GET_URL = API_ENDPOINT.v1.SHOWS;
|
||||
const POST_URL = API_ENDPOINT.v1.CURRENT;
|
||||
const REDIRECT_DELAY_S = 3;
|
||||
|
||||
const HHMM = /^(\d{1,2}):([0-5]\d)$/;
|
||||
@ -36,7 +26,7 @@ function toHHMM(v: string): string | null {
|
||||
}
|
||||
|
||||
export default function ShowsPage() {
|
||||
const [shows, setShows] = useState<Show[]>([]);
|
||||
const [shows, setShows] = useState<ShowItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [posting, setPosting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@ -68,28 +58,27 @@ export default function ShowsPage() {
|
||||
return v; // fallback (unexpected format)
|
||||
}
|
||||
|
||||
const loadShows = React.useCallback(async () => {
|
||||
setError(null);
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await fetchShows();
|
||||
data.sort((a, b) => a.id - b.id); // ASC
|
||||
setShows(data);
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : "番組一覧の取得に失敗しました。";
|
||||
setError(msg);
|
||||
logApiError("fetch shows", e);
|
||||
toastError("番組一覧の取得に失敗しました", msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 一覧取得
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
logApiRequest("fetch shows", { url: GET_URL });
|
||||
const res = await fetch(GET_URL, { cache: "no-store" });
|
||||
logApiResponse("fetch shows", res);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = (await res.json()) as Show[];
|
||||
data.sort((a, b) => a.id - b.id); // ASC
|
||||
setShows(data);
|
||||
} catch (e: any) {
|
||||
if (!cancelled) setError(e.message || "番組一覧の取得に失敗しました。");
|
||||
logApiError("fetch shows", e);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
loadShows().catch(() => {});
|
||||
}, [loadShows]);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (redirectTid.current) {
|
||||
@ -107,7 +96,7 @@ export default function ShowsPage() {
|
||||
const selected = shows.find(s => s.id === selectedId);
|
||||
if (!selected) { setError("選択中のエピソードが見つかりません。"); return; }
|
||||
|
||||
const payload: any = { id: selectedId };
|
||||
const payload: { id: number; start_time?: string } = { id: selectedId };
|
||||
|
||||
if (startTime.trim()) {
|
||||
const normalized = toHHMMSS(startTime);
|
||||
@ -119,14 +108,8 @@ export default function ShowsPage() {
|
||||
|
||||
try {
|
||||
setPosting(true);
|
||||
logApiRequest("set current episode", { url: POST_URL, payload });
|
||||
const res = await fetch(POST_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
logApiResponse("set current episode", res);
|
||||
if (!res.ok) throw new Error(`POST 失敗 (${res.status})`);
|
||||
await postCurrentEpisode(payload);
|
||||
toastInfo("現在のエピソードを設定しました");
|
||||
setRedirectIn(REDIRECT_DELAY_S);
|
||||
const start = Date.now();
|
||||
redirectTid.current = window.setInterval(() => {
|
||||
@ -134,9 +117,11 @@ export default function ShowsPage() {
|
||||
if (left <= 0) { window.clearInterval(redirectTid.current!); navigate("/", { replace: true }); }
|
||||
else setRedirectIn(left);
|
||||
}, 250);
|
||||
} catch (e: any) {
|
||||
setError(e.message || "現在のエピソード設定に失敗しました。");
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : "現在のエピソード設定に失敗しました。";
|
||||
setError(msg);
|
||||
logApiError("set current episode", e);
|
||||
toastError("現在のエピソード設定に失敗しました", msg);
|
||||
} finally {
|
||||
setPosting(false);
|
||||
}
|
||||
@ -150,7 +135,14 @@ export default function ShowsPage() {
|
||||
</p>
|
||||
|
||||
{loading && <div className="subtle">読み込み中…</div>}
|
||||
{error && <div className="timer-status" style={{ color: "#f88" }}>{error}</div>}
|
||||
{error && (
|
||||
<div className="timer-status" style={{ color: "#f88" }}>
|
||||
{error}{" "}
|
||||
<button className="link-btn" onClick={() => loadShows()}>
|
||||
再試行
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && shows.length === 0 && <div className="subtle">エピソードがありません。</div>}
|
||||
|
||||
|
||||
27
frontend/src/test/setup.ts
Normal file
27
frontend/src/test/setup.ts
Normal file
@ -0,0 +1,27 @@
|
||||
// Vite SSR helpers stubbed for Vitest with rolldown build.
|
||||
// Avoids ReferenceError: __vite_ssr_exportName__ when running unit tests.
|
||||
(globalThis as any).__vite_ssr_exportName__ = (mod: unknown, key: unknown, value: unknown) => {
|
||||
if (!(globalThis as any).__debug_ssr__) {
|
||||
(globalThis as any).__debug_ssr__ = [];
|
||||
}
|
||||
const logArr = (globalThis as any).__debug_ssr__ as unknown[];
|
||||
if (logArr.length < 5) {
|
||||
logArr.push([typeof mod, typeof key, typeof value]);
|
||||
if (logArr.length === 5) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("ssr helper calls", logArr);
|
||||
}
|
||||
}
|
||||
if (mod && typeof mod === "object" && typeof key === "string") {
|
||||
(mod as Record<string, unknown>)[key] = value;
|
||||
return value;
|
||||
}
|
||||
if (typeof mod === "string" && typeof key === "function") {
|
||||
(globalThis as Record<string, unknown>)[mod] = key();
|
||||
return value;
|
||||
}
|
||||
if (typeof key === "string") {
|
||||
(globalThis as Record<string, unknown>)[key] = typeof value === "function" ? value() : value;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
@ -1,24 +1,53 @@
|
||||
const envMode = (import.meta.env.VITE_APP_MODE || import.meta.env.MODE || "").toString().toLowerCase();
|
||||
const enabled = envMode === "debug";
|
||||
import { config } from "../config";
|
||||
|
||||
const envMode = config.mode;
|
||||
const enabled = config.isDebug;
|
||||
const prefix = "[watch-party]";
|
||||
const MAX_LOGS = 50;
|
||||
|
||||
type Level = "debug" | "info" | "warn" | "error";
|
||||
export type Level = "debug" | "info" | "warn" | "error";
|
||||
|
||||
function write(level: Level, ...args: unknown[]) {
|
||||
export type DebugLog = {
|
||||
id: number;
|
||||
ts: number;
|
||||
level: Level;
|
||||
label: string;
|
||||
details?: unknown;
|
||||
};
|
||||
|
||||
let nextId = 1;
|
||||
const buffer: DebugLog[] = [];
|
||||
const subscribers = new Set<(logs: DebugLog[]) => void>();
|
||||
|
||||
function publish(entry: DebugLog) {
|
||||
buffer.push(entry);
|
||||
if (buffer.length > MAX_LOGS) buffer.splice(0, buffer.length - MAX_LOGS);
|
||||
subscribers.forEach((fn) => fn([...buffer]));
|
||||
}
|
||||
|
||||
function write(level: Level, label: string, details?: unknown) {
|
||||
if (!enabled) return;
|
||||
const fn = console[level] || console.log;
|
||||
fn(prefix, ...args);
|
||||
if (details === undefined) fn(prefix, label);
|
||||
else fn(prefix, label, details);
|
||||
publish({ id: nextId++, ts: Date.now(), level, label, details });
|
||||
}
|
||||
|
||||
export const logger = {
|
||||
enabled,
|
||||
mode: envMode,
|
||||
debug: (...args: unknown[]) => write("debug", ...args),
|
||||
info: (...args: unknown[]) => write("info", ...args),
|
||||
warn: (...args: unknown[]) => write("warn", ...args),
|
||||
error: (...args: unknown[]) => write("error", ...args),
|
||||
debug: (label: string, details?: unknown) => write("debug", label, details),
|
||||
info: (label: string, details?: unknown) => write("info", label, details),
|
||||
warn: (label: string, details?: unknown) => write("warn", label, details),
|
||||
error: (label: string, details?: unknown) => write("error", label, details),
|
||||
};
|
||||
|
||||
export function subscribeLogs(fn: (logs: DebugLog[]) => void) {
|
||||
subscribers.add(fn);
|
||||
fn([...buffer]);
|
||||
return () => { subscribers.delete(fn); };
|
||||
}
|
||||
|
||||
export function logApiRequest(label: string, details?: Record<string, unknown>) {
|
||||
if (!enabled) return;
|
||||
logger.debug(`${label}: request`, details || {});
|
||||
|
||||
28
frontend/src/utils/time.test.ts
Normal file
28
frontend/src/utils/time.test.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseDurationToSeconds, parseStartTime } from "./time";
|
||||
|
||||
describe("parseStartTime", () => {
|
||||
it("parses HH:MM and HH:MM:SS", () => {
|
||||
expect(parseStartTime("09:15")).toEqual({ hour: 9, minute: 15, second: 0 });
|
||||
expect(parseStartTime("21:05:30")).toEqual({ hour: 21, minute: 5, second: 30 });
|
||||
});
|
||||
|
||||
it("returns null for invalid", () => {
|
||||
expect(parseStartTime("99:00")).toBeNull();
|
||||
expect(parseStartTime("10:61")).toBeNull();
|
||||
expect(parseStartTime("abc")).toBeNull();
|
||||
expect(parseStartTime(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseDurationToSeconds", () => {
|
||||
it("parses MM:SS and HH:MM:SS", () => {
|
||||
expect(parseDurationToSeconds("05:30")).toBe(330);
|
||||
expect(parseDurationToSeconds("1:02:03")).toBe(3723);
|
||||
});
|
||||
|
||||
it("uses fallback on bad input", () => {
|
||||
expect(parseDurationToSeconds("bad", 10)).toBe(10);
|
||||
expect(parseDurationToSeconds(undefined, 99)).toBe(99);
|
||||
});
|
||||
});
|
||||
28
frontend/src/utils/time.ts
Normal file
28
frontend/src/utils/time.ts
Normal file
@ -0,0 +1,28 @@
|
||||
export type ParsedStart = { hour: number; minute: number; second: number };
|
||||
|
||||
const START_RE = /^(\d{1,2}):([0-5]\d)(?::([0-5]\d))?$/;
|
||||
|
||||
export function parseStartTime(s?: string): ParsedStart | null {
|
||||
if (!s || typeof s !== "string") return null;
|
||||
const m = START_RE.exec(s.trim());
|
||||
if (!m) return null;
|
||||
const hour = Number(m[1]);
|
||||
const minute = Number(m[2]);
|
||||
const second = m[3] ? Number(m[3]) : 0;
|
||||
if (hour < 0 || hour > 23) return null;
|
||||
return { hour, minute, second };
|
||||
}
|
||||
|
||||
export function parseDurationToSeconds(s?: string, fallback = 0): number {
|
||||
if (!s || typeof s !== "string") return fallback;
|
||||
const parts = s.trim().split(":").map(Number);
|
||||
if (parts.some((n) => Number.isNaN(n) || n < 0)) return fallback;
|
||||
if (parts.length === 2) {
|
||||
const [mm, ss] = parts;
|
||||
return mm * 60 + ss;
|
||||
} else if (parts.length === 3) {
|
||||
const [hh, mm, ss] = parts;
|
||||
return hh * 3600 + mm * 60 + ss;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
52
frontend/src/utils/toastBus.ts
Normal file
52
frontend/src/utils/toastBus.ts
Normal file
@ -0,0 +1,52 @@
|
||||
export type ToastLevel = "info" | "warn" | "error";
|
||||
|
||||
export type Toast = {
|
||||
id: number;
|
||||
level: ToastLevel;
|
||||
message: string;
|
||||
detail?: string;
|
||||
expiresAt?: number;
|
||||
};
|
||||
|
||||
const listeners = new Set<(toasts: Toast[]) => void>();
|
||||
const queue: Toast[] = [];
|
||||
let nextId = 1;
|
||||
const MAX_TOASTS = 4;
|
||||
|
||||
function emitUpdate() {
|
||||
listeners.forEach((fn) => fn([...queue]));
|
||||
}
|
||||
|
||||
export function pushToast(toast: Omit<Toast, "id">) {
|
||||
const entry: Toast = {
|
||||
id: nextId++,
|
||||
...toast,
|
||||
};
|
||||
queue.push(entry);
|
||||
if (queue.length > MAX_TOASTS) queue.shift();
|
||||
emitUpdate();
|
||||
}
|
||||
|
||||
export function dismissToast(id: number) {
|
||||
const idx = queue.findIndex((t) => t.id === id);
|
||||
if (idx >= 0) {
|
||||
queue.splice(idx, 1);
|
||||
emitUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeToasts(fn: (toasts: Toast[]) => void) {
|
||||
listeners.add(fn);
|
||||
fn([...queue]);
|
||||
return () => { listeners.delete(fn); };
|
||||
}
|
||||
|
||||
export function toastError(message: string, detail?: string) {
|
||||
const expiresAt = Date.now() + 7000;
|
||||
pushToast({ level: "error", message, detail, expiresAt });
|
||||
}
|
||||
|
||||
export function toastInfo(message: string, detail?: string) {
|
||||
const expiresAt = Date.now() + 4000;
|
||||
pushToast({ level: "info", message, detail, expiresAt });
|
||||
}
|
||||
7
frontend/tsconfig.vitest.json
Normal file
7
frontend/tsconfig.vitest.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "CommonJS",
|
||||
"verbatimModuleSyntax": false
|
||||
}
|
||||
}
|
||||
14
frontend/vitest.config.ts
Normal file
14
frontend/vitest.config.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
globals: true,
|
||||
dir: "src",
|
||||
setupFiles: "./src/test/setup.ts",
|
||||
deps: {
|
||||
registerNodeLoader: true,
|
||||
},
|
||||
tsconfig: "tsconfig.vitest.json",
|
||||
},
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user