diff --git a/.env.example b/.env.example
index a85ed22..612eb06 100644
--- a/.env.example
+++ b/.env.example
@@ -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
\ No newline at end of file
+ADDR=:8082
diff --git a/docker-compose.yml b/docker-compose.yml
index f270e51..88fd746 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -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:
\ No newline at end of file
+ pgdata:
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
index d583112..5c2b718 100644
--- a/frontend/Dockerfile
+++ b/frontend/Dockerfile
@@ -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"]
\ No newline at end of file
+ENTRYPOINT ["/bin/sh","/entrypoint.sh"]
diff --git a/frontend/src/components/Timer.tsx b/frontend/src/components/Timer.tsx
index 75d5d70..329db19 100644
--- a/frontend/src/components/Timer.tsx
+++ b/frontend/src/components/Timer.tsx
@@ -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 }) {
{time}
);
-}
\ No newline at end of file
+}
diff --git a/frontend/src/hooks/useServerClock.ts b/frontend/src/hooks/useServerClock.ts
index 98d119e..c5f15ad 100644
--- a/frontend/src/hooks/useServerClock.ts
+++ b/frontend/src/hooks/useServerClock.ts
@@ -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: } 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 };
-}
\ No newline at end of file
+}
diff --git a/frontend/src/hooks/useTimeSkew.ts b/frontend/src/hooks/useTimeSkew.ts
index cb12355..28b772e 100644
--- a/frontend/src/hooks/useTimeSkew.ts
+++ b/frontend/src/hooks/useTimeSkew.ts
@@ -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: }.
@@ -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 };
-}
\ No newline at end of file
+}
diff --git a/frontend/src/pages/ShowsPage.tsx b/frontend/src/pages/ShowsPage.tsx
index f551934..f2996fb 100644
--- a/frontend/src/pages/ShowsPage.tsx
+++ b/frontend/src/pages/ShowsPage.tsx
@@ -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() {
)}
);
-}
\ No newline at end of file
+}
diff --git a/frontend/src/utils/logger.ts b/frontend/src/utils/logger.ts
new file mode 100644
index 0000000..41783aa
--- /dev/null
+++ b/frontend/src/utils/logger.ts
@@ -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) {
+ if (!enabled) return;
+ logger.debug(`${label}: request`, details || {});
+}
+
+export function logApiResponse(label: string, res: Response, details?: Record) {
+ 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);
+}