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:
parent
6ac23c24ee
commit
409cd4a22f
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
42
frontend/src/utils/logger.ts
Normal file
42
frontend/src/utils/logger.ts
Normal 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);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user