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
|
WEB_PORT=3000
|
||||||
PUBLIC_BASE_PATH=/watch-party/
|
PUBLIC_BASE_PATH=/watch-party/
|
||||||
BACKEND_ORIGIN=http://api:8082
|
BACKEND_ORIGIN=http://api:8082
|
||||||
|
FRONTEND_MODE=debug
|
||||||
|
|
||||||
#################
|
#################
|
||||||
## Backend env ##
|
## Backend env ##
|
||||||
|
|||||||
@ -8,6 +8,7 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
PUBLIC_BASE_PATH: ${PUBLIC_BASE_PATH}
|
PUBLIC_BASE_PATH: ${PUBLIC_BASE_PATH}
|
||||||
|
FRONTEND_MODE: ${FRONTEND_MODE:-production}
|
||||||
image: watchparty-frontend:prod
|
image: watchparty-frontend:prod
|
||||||
container_name: watchparty-frontend
|
container_name: watchparty-frontend
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@ -8,6 +8,9 @@ COPY . .
|
|||||||
# NEW: base path for Vite (e.g. /watch-party/)
|
# NEW: base path for Vite (e.g. /watch-party/)
|
||||||
ARG PUBLIC_BASE_PATH=/
|
ARG PUBLIC_BASE_PATH=/
|
||||||
ENV PUBLIC_BASE_PATH=${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
|
RUN npm run build
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useServerClock } from "../hooks/useServerClock";
|
import { useServerClock } from "../hooks/useServerClock";
|
||||||
import { API_ENDPOINT } from "../api/endpoint";
|
import { API_ENDPOINT } from "../api/endpoint";
|
||||||
|
import { logApiError, logApiRequest, logApiResponse } from "../utils/logger";
|
||||||
|
|
||||||
// ===== Config & fallbacks =====
|
// ===== Config & fallbacks =====
|
||||||
const TIMEZONE = "Asia/Tokyo"; // JST (UTC+09)
|
const TIMEZONE = "Asia/Tokyo"; // JST (UTC+09)
|
||||||
@ -30,7 +31,8 @@ function formatHMS(total: number) {
|
|||||||
return h > 0 ? `${HH}:${MM}:${SS}` : `${MM}:${SS}`;
|
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());
|
const m = /^(\d{1,2}):([0-5]\d)(?::([0-5]\d))?$/.exec(s.trim());
|
||||||
if (!m) return null;
|
if (!m) return null;
|
||||||
const hour = Number(m[1]);
|
const hour = Number(m[1]);
|
||||||
@ -40,7 +42,8 @@ function parseStartTime(s: string): { hour: number; minute: number; second: numb
|
|||||||
return { hour, minute, second };
|
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);
|
const parts = s.trim().split(":").map(Number);
|
||||||
if (parts.some((n) => Number.isNaN(n) || n < 0)) return FALLBACK_END_SECONDS;
|
if (parts.some((n) => Number.isNaN(n) || n < 0)) return FALLBACK_END_SECONDS;
|
||||||
if (parts.length === 2) {
|
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) {
|
async function loadSchedule(signal?: AbortSignal) {
|
||||||
|
logApiRequest("loadSchedule", { url: API_URL_CURRENT });
|
||||||
const res = await fetch(API_URL_CURRENT, { cache: "no-store", signal });
|
const res = await fetch(API_URL_CURRENT, { cache: "no-store", signal });
|
||||||
|
logApiResponse("loadSchedule", res);
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
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() {
|
export default function Timer() {
|
||||||
@ -119,9 +131,10 @@ export default function Timer() {
|
|||||||
setLoaded(true);
|
setLoaded(true);
|
||||||
setErrorMsg(null);
|
setErrorMsg(null);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch((e) => {
|
||||||
setLoaded(true);
|
setLoaded(true);
|
||||||
setErrorMsg("Failed to load schedule; using defaults.");
|
setErrorMsg("Failed to load schedule; using defaults.");
|
||||||
|
logApiError("loadSchedule", e);
|
||||||
});
|
});
|
||||||
return () => ac.abort();
|
return () => ac.abort();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@ -195,6 +208,7 @@ export default function Timer() {
|
|||||||
setLoaded(true);
|
setLoaded(true);
|
||||||
// If the new schedule has moved us out of "ended", the ticking effect will handle it.
|
// If the new schedule has moved us out of "ended", the ticking effect will handle it.
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
logApiError("loadSchedule (ended poll)", e);
|
||||||
// keep current UI; try again next tick
|
// keep current UI; try again next tick
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { API_ENDPOINT } from "../api/endpoint";
|
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()" */
|
/** Uses /api/time => { now: <ms UTC> } and returns a server-correct "nowMs()" */
|
||||||
const TIME_URL_ENDPOINT = API_ENDPOINT.v1.TIME;
|
const TIME_URL_ENDPOINT = API_ENDPOINT.v1.TIME;
|
||||||
@ -21,16 +22,20 @@ export function useServerClock(opts?: {
|
|||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
|
logApiRequest("useServerClock", { url: TIME_URL_ENDPOINT });
|
||||||
const res = await fetch(TIME_URL_ENDPOINT, { cache: "no-store" });
|
const res = await fetch(TIME_URL_ENDPOINT, { cache: "no-store" });
|
||||||
const t1 = Date.now();
|
const t1 = Date.now();
|
||||||
|
logApiResponse("useServerClock", res, { roundTripMs: t1 - t0 });
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (typeof data?.now !== "number") throw new Error("Bad time payload");
|
if (typeof data?.now !== "number") throw new Error("Bad time payload");
|
||||||
const s = data.now;
|
const s = data.now;
|
||||||
const offset = Math.round(((t0 + t1) / 2) - s); // (+) client ahead, (-) client behind
|
const offset = Math.round(((t0 + t1) / 2) - s); // (+) client ahead, (-) client behind
|
||||||
setOffsetMs(offset);
|
setOffsetMs(offset);
|
||||||
|
logApiResponse("useServerClock computed", res, { serverNow: s, offset });
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e?.message || "time sync failed");
|
setError(e?.message || "time sync failed");
|
||||||
|
logApiError("useServerClock", e);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { API_ENDPOINT } from "../api/endpoint";
|
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>}.
|
* Measures client clock skew vs server time {now: <ms since epoch, UTC>}.
|
||||||
@ -29,11 +30,13 @@ export function useTimeSkew(opts?: {
|
|||||||
|
|
||||||
const measureOnce = async () => {
|
const measureOnce = async () => {
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
|
logApiRequest("useTimeSkew", { url: TIME_URL_ENDPOINT });
|
||||||
const res = await fetch(TIME_URL_ENDPOINT, {
|
const res = await fetch(TIME_URL_ENDPOINT, {
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
headers: { Accept: "application/json" },
|
headers: { Accept: "application/json" },
|
||||||
});
|
});
|
||||||
const t1 = Date.now();
|
const t1 = Date.now();
|
||||||
|
logApiResponse("useTimeSkew", res, { roundTripMs: t1 - t0 });
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
|
||||||
// Robust parsing: number or string, seconds or ms
|
// Robust parsing: number or string, seconds or ms
|
||||||
@ -71,6 +74,7 @@ export function useTimeSkew(opts?: {
|
|||||||
setRttMs(minRtt);
|
setRttMs(minRtt);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e?.message || "Time sync failed");
|
setError(e?.message || "Time sync failed");
|
||||||
|
logApiError("useTimeSkew", e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState, useRef } from "react";
|
import { useEffect, useMemo, useState, useRef } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { API_ENDPOINT } from "../api/endpoint";
|
import { API_ENDPOINT } from "../api/endpoint";
|
||||||
|
import { logApiError, logApiRequest, logApiResponse } from "../utils/logger";
|
||||||
|
|
||||||
type Show = {
|
type Show = {
|
||||||
id: number;
|
id: number;
|
||||||
@ -73,13 +74,16 @@ export default function ShowsPage() {
|
|||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
logApiRequest("fetch shows", { url: GET_URL });
|
||||||
const res = await fetch(GET_URL, { cache: "no-store" });
|
const res = await fetch(GET_URL, { cache: "no-store" });
|
||||||
|
logApiResponse("fetch shows", res);
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
const data = (await res.json()) as Show[];
|
const data = (await res.json()) as Show[];
|
||||||
data.sort((a, b) => a.id - b.id); // ASC
|
data.sort((a, b) => a.id - b.id); // ASC
|
||||||
setShows(data);
|
setShows(data);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (!cancelled) setError(e.message || "番組一覧の取得に失敗しました。");
|
if (!cancelled) setError(e.message || "番組一覧の取得に失敗しました。");
|
||||||
|
logApiError("fetch shows", e);
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) setLoading(false);
|
if (!cancelled) setLoading(false);
|
||||||
}
|
}
|
||||||
@ -115,11 +119,13 @@ export default function ShowsPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setPosting(true);
|
setPosting(true);
|
||||||
|
logApiRequest("set current episode", { url: POST_URL, payload });
|
||||||
const res = await fetch(POST_URL, {
|
const res = await fetch(POST_URL, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
|
logApiResponse("set current episode", res);
|
||||||
if (!res.ok) throw new Error(`POST 失敗 (${res.status})`);
|
if (!res.ok) throw new Error(`POST 失敗 (${res.status})`);
|
||||||
setRedirectIn(REDIRECT_DELAY_S);
|
setRedirectIn(REDIRECT_DELAY_S);
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
@ -130,6 +136,7 @@ export default function ShowsPage() {
|
|||||||
}, 250);
|
}, 250);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e.message || "現在のエピソード設定に失敗しました。");
|
setError(e.message || "現在のエピソード設定に失敗しました。");
|
||||||
|
logApiError("set current episode", e);
|
||||||
} finally {
|
} finally {
|
||||||
setPosting(false);
|
setPosting(false);
|
||||||
}
|
}
|
||||||
|
|||||||
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