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:
Nik Afiq 2025-12-06 03:03:30 +09:00
parent 51f640bc99
commit 9674fc9cbe
31 changed files with 2120 additions and 596 deletions

1
.gitignore vendored
View File

@ -1,4 +1,5 @@
.env
.cache
.DS_Store
*/dist
*/node_modules

View File

@ -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
View 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

View File

@ -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
View 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.

File diff suppressed because it is too large Load Diff

View File

@ -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"
}
}

View File

@ -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
View 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();
}
}

View File

@ -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
View 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;
};

View 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);
});
});

View 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;
}

View 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>
);
}

View File

@ -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>
);
}
}

View File

@ -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">

View 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>
);
}

View 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
View 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}`;
}

View File

@ -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);
}
}, []);

View File

@ -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 };
}

View File

@ -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; }

View File

@ -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>
);
);

View File

@ -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>}

View 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;
};

View File

@ -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 || {});

View 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);
});
});

View 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;
}

View 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 });
}

View File

@ -0,0 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "CommonJS",
"verbatimModuleSyntax": false
}
}

14
frontend/vitest.config.ts Normal file
View 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",
},
});