Squashed commit of the following:

commit 8acd255dd459488ac1d9346780e05dc099ba74b1
Author: Nik Afiq <nik.afiq98@ymail.com>
Date:   Fri Dec 5 21:58:10 2025 +0900

    Fix optional parameters handling in time parsing functions and enhance API response logging

commit 7e9a82a137057c85a909d027c51d71654ec61ac1
Author: Nik Afiq <nik.afiq98@ymail.com>
Date:   Fri Dec 5 21:53:44 2025 +0900

    Handle API schedule loading errors and log them for debugging

commit ec993908043c841b18fcb66916c5f9ce66b9e2f1
Author: Nik Afiq <nik.afiq98@ymail.com>
Date:   Fri Dec 5 21:48:16 2025 +0900

    Implement logging utility and integrate API request/response error handling
This commit is contained in:
Nik Afiq 2025-12-05 22:09:40 +09:00
parent 6ac23c24ee
commit 409cd4a22f
8 changed files with 88 additions and 11 deletions

View File

@ -4,6 +4,7 @@
WEB_PORT=3000
PUBLIC_BASE_PATH=/watch-party/
BACKEND_ORIGIN=http://api:8082
FRONTEND_MODE=debug
#################
## Backend env ##
@ -20,4 +21,4 @@ PGHOST=db
POSTGRES_PORT=5432
PGSSLMODE=disable
GIN_MODE=release
ADDR=:8082
ADDR=:8082

View File

@ -8,6 +8,7 @@ services:
dockerfile: Dockerfile
args:
PUBLIC_BASE_PATH: ${PUBLIC_BASE_PATH}
FRONTEND_MODE: ${FRONTEND_MODE:-production}
image: watchparty-frontend:prod
container_name: watchparty-frontend
environment:
@ -97,4 +98,4 @@ networks:
driver: bridge
volumes:
pgdata:
pgdata:

View File

@ -8,6 +8,9 @@ COPY . .
# NEW: base path for Vite (e.g. /watch-party/)
ARG PUBLIC_BASE_PATH=/
ENV PUBLIC_BASE_PATH=${PUBLIC_BASE_PATH}
ARG FRONTEND_MODE=production
ENV FRONTEND_MODE=${FRONTEND_MODE}
ENV VITE_APP_MODE=${FRONTEND_MODE}
RUN npm run build
@ -20,4 +23,4 @@ COPY ops/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENV BACKEND_ORIGIN=http://localhost:8082
EXPOSE 80
ENTRYPOINT ["/bin/sh","/entrypoint.sh"]
ENTRYPOINT ["/bin/sh","/entrypoint.sh"]

View File

@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useServerClock } from "../hooks/useServerClock";
import { API_ENDPOINT } from "../api/endpoint";
import { logApiError, logApiRequest, logApiResponse } from "../utils/logger";
// ===== Config & fallbacks =====
const TIMEZONE = "Asia/Tokyo"; // JST (UTC+09)
@ -30,7 +31,8 @@ function formatHMS(total: number) {
return h > 0 ? `${HH}:${MM}:${SS}` : `${MM}:${SS}`;
}
function parseStartTime(s: string): { hour: number; minute: number; second: number } | null {
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]);
@ -40,7 +42,8 @@ function parseStartTime(s: string): { hour: number; minute: number; second: numb
return { hour, minute, second };
}
function parseDurationToSeconds(s: string): number {
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) {
@ -75,9 +78,18 @@ 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}`);
return (await res.json()) as ApiSchedule;
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;
}
export default function Timer() {
@ -119,9 +131,10 @@ export default function Timer() {
setLoaded(true);
setErrorMsg(null);
})
.catch(() => {
.catch((e) => {
setLoaded(true);
setErrorMsg("Failed to load schedule; using defaults.");
logApiError("loadSchedule", e);
});
return () => ac.abort();
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -195,6 +208,7 @@ export default function Timer() {
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
}
};
@ -303,4 +317,4 @@ function NowJst({ getNowMs }: { getNowMs: () => number }) {
<span className="now-digits">{time}</span>
</div>
);
}
}

View File

@ -1,5 +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;
@ -21,16 +22,20 @@ 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 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
setOffsetMs(offset);
logApiResponse("useServerClock computed", res, { serverNow: s, offset });
} catch (e: any) {
setError(e?.message || "time sync failed");
logApiError("useServerClock", e);
}
}, []);
@ -50,4 +55,4 @@ export function useServerClock(opts?: {
const ready = offsetMs != null;
return { nowMs, ready, error, resync: syncOnce, offsetMs };
}
}

View File

@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { API_ENDPOINT } from "../api/endpoint";
import { logApiError, logApiRequest, logApiResponse } from "../utils/logger";
/**
* Measures client clock skew vs server time {now: <ms since epoch, UTC>}.
@ -29,11 +30,13 @@ export function useTimeSkew(opts?: {
const measureOnce = 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 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
@ -71,6 +74,7 @@ export function useTimeSkew(opts?: {
setRttMs(minRtt);
} catch (e: any) {
setError(e?.message || "Time sync failed");
logApiError("useTimeSkew", e);
}
};
@ -84,4 +88,4 @@ export function useTimeSkew(opts?: {
}, [intervalMs, samples, enabled]);
return { skewMs, rttMs, error, recheck: measure };
}
}

View File

@ -1,6 +1,7 @@
import { 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";
type Show = {
id: number;
@ -73,13 +74,16 @@ export default function ShowsPage() {
(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);
}
@ -115,11 +119,13 @@ 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})`);
setRedirectIn(REDIRECT_DELAY_S);
const start = Date.now();
@ -130,6 +136,7 @@ export default function ShowsPage() {
}, 250);
} catch (e: any) {
setError(e.message || "現在のエピソード設定に失敗しました。");
logApiError("set current episode", e);
} finally {
setPosting(false);
}
@ -202,4 +209,4 @@ export default function ShowsPage() {
)}
</div>
);
}
}

View File

@ -0,0 +1,42 @@
const envMode = (import.meta.env.VITE_APP_MODE || import.meta.env.MODE || "").toString().toLowerCase();
const enabled = envMode === "debug";
const prefix = "[watch-party]";
type Level = "debug" | "info" | "warn" | "error";
function write(level: Level, ...args: unknown[]) {
if (!enabled) return;
const fn = console[level] || console.log;
fn(prefix, ...args);
}
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),
};
export function logApiRequest(label: string, details?: Record<string, unknown>) {
if (!enabled) return;
logger.debug(`${label}: request`, details || {});
}
export function logApiResponse(label: string, res: Response, details?: Record<string, unknown>) {
if (!enabled) return;
logger.debug(`${label}: response`, {
status: res.status,
statusText: res.statusText,
ok: res.ok,
redirected: res.redirected,
url: res.url,
...details,
});
}
export function logApiError(label: string, error: unknown) {
if (!enabled) return;
logger.error(`${label}: error`, error);
}