Added time sync

This commit is contained in:
Nik Afiq 2025-11-11 18:06:45 +09:00
parent 975c031bd8
commit c2d5d54592
6 changed files with 346 additions and 54 deletions

View File

@ -2,8 +2,11 @@ import React from "react";
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 "./index.css";
const TIME_SYNC_OFF_THRESHOLD = 1000;
export default function App() {
const [open, setOpen] = React.useState(false);
const loc = useLocation();
@ -22,6 +25,7 @@ export default function App() {
<div className={`site ${open ? "sidebar-open" : ""}`}>
{/* Top-left header (outside the card) */}
<header className="site-header">
<button className="burger" aria-label="メニュー" onClick={() => setOpen(v => !v)}>
<svg className="burger-icon" viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
<path d="M4 7h16M4 12h16M4 17h16"
@ -31,6 +35,8 @@ export default function App() {
</button>
<Link to="/" className="brand">Watch Party</Link>
</header>
{/* Time sync banner (checks every 5 min; shows if |skew| > 500ms) */}
<TimeSyncNotice thresholdMs={TIME_SYNC_OFF_THRESHOLD} lang="ja" />
{/* Sidebar (full-height) */}
<aside className={`sidebar ${open ? "open" : ""}`} aria-hidden={!open}>

View File

@ -0,0 +1,62 @@
import { useEffect, useState } from "react";
import { useTimeSkew } from "../hooks/useTimeSkew";
function formatMs(ms: number) {
const sign = ms > 0 ? "+" : "";
return `${sign}${ms}ms`;
}
export default function TimeSyncNotice({
thresholdMs = 500,
endpoint,
intervalMs,
lang = "ja",
}: {
thresholdMs?: number;
endpoint?: string;
intervalMs?: number;
lang?: "ja" | "en";
}) {
// removed `error`
const { skewMs, rttMs, recheck } = useTimeSkew({ endpoint, intervalMs });
const [dismissed, setDismissed] = useState<boolean>(() => {
try { return sessionStorage.getItem("timesync.dismissed") === "1"; } catch { return false; }
});
useEffect(() => {
if (skewMs != null && Math.abs(skewMs) <= thresholdMs && dismissed) {
setDismissed(false);
try { sessionStorage.removeItem("timesync.dismissed"); } catch { }
}
}, [skewMs, thresholdMs, dismissed]);
if (dismissed || skewMs == null || Math.abs(skewMs) <= thresholdMs) return null;
const ahead = skewMs > 0;
const msgJa = ahead
? `端末の時計が正確な時刻より ${formatMs(skewMs)} 進んでいます(往復遅延 ${rttMs ?? "-"}ms`
: `端末の時計が正確な時刻より ${formatMs(-skewMs)} 遅れています(往復遅延 ${rttMs ?? "-"}ms`;
const msgEn = ahead
? `Your device clock is ${formatMs(skewMs)} ahead of the correct time (RTT ${rttMs ?? "-"}ms).`
: `Your device clock is ${formatMs(-skewMs)} behind the correct time (RTT ${rttMs ?? "-"}ms).`;
const onClose = () => {
setDismissed(true);
try { sessionStorage.setItem("timesync.dismissed", "1"); } catch { }
};
return (
<div className="timesync-banner" role="status" aria-live="polite">
<div className="timesync-msg">{lang === "ja" ? msgJa : msgEn}</div>
<div className="timesync-actions">
<button className="timesync-btn" onClick={() => recheck?.()}>
{lang === "ja" ? "再測定" : "Re-check"}
</button>
<button className="timesync-close" onClick={onClose} aria-label={lang === "ja" ? "閉じる" : "Close"}>
×
</button>
</div>
</div>
);
}

View File

@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useServerClock } from "../hooks/useServerClock";
// ===== Config & fallbacks =====
const TIMEZONE = "Asia/Tokyo"; // JST (UTC+09)
@ -6,6 +7,7 @@ const API_URL = "/api/v1/current";
const FALLBACK_START_HOUR = 19;
const FALLBACK_START_MINUTE = 25;
const FALLBACK_END_SECONDS = 300;
const TIME_SYNC_INTERVAL = 60_000;
// ==============================
type ApiSchedule = {
@ -71,44 +73,6 @@ function jstToUtcMs(y: number, m: number, d: number, hh: number, mm: number, ss
return Date.UTC(y, m - 1, d, hh - 9, mm, ss, 0);
}
/** Compact "current time in JST" readout used under the digits */
function NowJst() {
const [time, setTime] = useState(() =>
new Intl.DateTimeFormat("en-GB", {
timeZone: TIMEZONE,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).format(new Date())
);
useEffect(() => {
const fmt = new Intl.DateTimeFormat("en-GB", {
timeZone: TIMEZONE,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
let t: number | null = null;
const tick = () => {
setTime(fmt.format(new Date()));
const msToNext = 1000 - (Date.now() % 1000);
t = window.setTimeout(tick, msToNext + 5);
};
tick();
return () => { if (t) clearTimeout(t); };
}, []);
return (
<div className="timer-now">
<span className="muted"></span>
<span className="now-digits">{time}</span>
</div>
);
}
async function loadSchedule(signal?: AbortSignal) {
const res = await fetch(API_URL, { cache: "no-store", signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
@ -132,6 +96,10 @@ export default function Timer() {
const [phase, setPhase] = useState<"waiting" | "running" | "ended">("waiting");
const [untilStart, setUntilStart] = useState(0);
const timerRef = useRef<number | null>(null);
const { nowMs, ready, error: timeError } = useServerClock({
endpoint: `${import.meta.env.BASE_URL}api/time`, // works under /watch-party/ in prod, /api in dev
refreshMs: TIME_SYNC_INTERVAL,
});
// fetch schedule
useEffect(() => {
@ -161,27 +129,25 @@ export default function Timer() {
// tick per second
useEffect(() => {
if (!ready) return;
const tick = () => {
const nowMs = Date.now();
if (nowMs < startUtcMs) {
const now = nowMs();
if (now < startUtcMs) {
setPhase("waiting");
setUntilStart(Math.ceil((startUtcMs - nowMs) / 1000));
setUntilStart(Math.ceil((startUtcMs - now) / 1000));
setElapsed(0);
} else {
const secs = Math.floor((nowMs - startUtcMs) / 1000);
const secs = Math.floor((now - startUtcMs) / 1000);
const clamped = Math.min(secs, endSeconds);
setElapsed(clamped);
setPhase(clamped >= endSeconds ? "ended" : "running");
}
const msToNext = 1000 - (nowMs % 1000);
const msToNext = 1000 - (now % 1000);
timerRef.current = window.setTimeout(tick, msToNext + 5);
};
tick();
return () => { if (timerRef.current) window.clearTimeout(timerRef.current); };
}, [startUtcMs, endSeconds]);
}, [startUtcMs, endSeconds, nowMs]);
// status labels
const startLabel = useMemo(() => {
@ -190,8 +156,9 @@ export default function Timer() {
});
return fmt.format(new Date(startUtcMs));
}, [startUtcMs]);
const progress = endSeconds > 0 ? Math.min(100, Math.round((elapsed / endSeconds) * 100)) : 0;
const showPrestartCountdown = phase === "waiting" && untilStart > 0 && untilStart <= 10;
const hasHours = endSeconds >= 3600 || elapsed >= 3600;
// nicety: update tab title while running
useEffect(() => {
@ -245,8 +212,6 @@ export default function Timer() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [phase]); // runs when phase flips to "ended"
const showPrestartCountdown = phase === "waiting" && untilStart > 0 && untilStart <= 10;
const hasHours = endSeconds >= 3600 || elapsed >= 3600;
return (
<div className="timer-block">
@ -265,8 +230,10 @@ export default function Timer() {
{phase === "waiting" && `Starts ${startLabel} JST`}
{phase === "ended" && "Ended"}
</span>
<NowJst />
<NowJst getNowMs={nowMs} />
</div>
{/* show time endpoint error */}
{timeError && <div className="timer-status" style={{ color: "#f88" }}>: {timeError}</div>}
{/* Hero digits */}
<div className={`timer-hero ${hasHours ? "has-hours" : ""}`} role="timer" aria-live="off">
@ -309,4 +276,31 @@ export default function Timer() {
)}
</div>
);
}
/** Compact "current time in JST" readout used under the digits */
function NowJst({ getNowMs }: { getNowMs: () => number }) {
const [time, setTime] = useState("");
useEffect(() => {
const fmt = new Intl.DateTimeFormat("en-GB", {
timeZone: TIMEZONE, hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false,
});
let t: number | null = null;
const tick = () => {
const now = new Date(getNowMs());
setTime(fmt.format(now));
const msToNext = 1000 - (getNowMs() % 1000);
t = window.setTimeout(tick, msToNext + 5);
};
tick();
return () => { if (t) clearTimeout(t); };
}, [getNowMs]);
return (
<div className="timer-now">
<span className="muted"></span>
<span className="now-digits">{time}</span>
</div>
);
}

View File

@ -0,0 +1,52 @@
import { useCallback, useEffect, useRef, useState } from "react";
/** Uses /api/time => { now: <ms UTC> } and returns a server-correct "nowMs()" */
export function useServerClock(opts?: {
endpoint?: string;
refreshMs?: number;
enabled?: boolean;
}) {
const {
endpoint = "/api/time",
refreshMs = 60_000,
enabled = true,
} = opts || {};
const [offsetMs, setOffsetMs] = useState<number | null>(null); // client - server
const [error, setError] = useState<string | null>(null);
const timerRef = useRef<number | null>(null);
const syncOnce = useCallback(async () => {
try {
setError(null);
const t0 = Date.now();
const res = await fetch(endpoint, { cache: "no-store" });
const t1 = Date.now();
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);
} catch (e: any) {
setError(e?.message || "time sync failed");
}
}, [endpoint]);
useEffect(() => {
if (!enabled) return;
syncOnce();
if (refreshMs > 0) {
timerRef.current = window.setInterval(syncOnce, refreshMs);
}
return () => { if (timerRef.current) clearInterval(timerRef.current); };
}, [enabled, refreshMs, syncOnce]);
const nowMs = useCallback(() => {
if (offsetMs == null) return Date.now(); // first paint fallback
return Date.now() - offsetMs; // server-correct time
}, [offsetMs]);
const ready = offsetMs != null;
return { nowMs, ready, error, resync: syncOnce, offsetMs };
}

View File

@ -0,0 +1,87 @@
import { useEffect, useRef, useState } from "react";
/**
* Measures client clock skew vs server time {now: <ms since epoch, UTC>}.
* NTP-style estimate:
* t0 = client ms before request
* s = server ms from JSON
* t1 = client ms after response
* offset ((t0 + t1)/2) - s
* Positive offset => client is AHEAD by that many ms.
*/
export function useTimeSkew(opts?: {
endpoint?: string; // e.g., "/api/time" or `${import.meta.env.BASE_URL}api/time`
intervalMs?: number; // how often to recheck; default 5 min
samples?: number; // take N measurements and median; default 1
enabled?: boolean; // allow turning off; default true
}) {
const {
endpoint = "/api/time",
intervalMs = 5 * 60_000,
samples = 1,
enabled = true,
} = opts || {};
const [skewMs, setSkewMs] = useState<number | null>(null);
const [rttMs, setRttMs] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const timerRef = useRef<number | null>(null);
const measureOnce = async () => {
const t0 = Date.now();
const res = await fetch(endpoint, { cache: "no-store" });
const t1 = Date.now();
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 (expecting { now: number })");
}
const serverMs = data.now;
const rtt = t1 - t0;
const offset = Math.round(((t0 + t1) / 2) - serverMs);
return { offset, rtt };
};
const measure = async () => {
try {
setError(null);
if (samples <= 1) {
const { offset, rtt } = await measureOnce();
setSkewMs(offset);
setRttMs(rtt);
return;
}
// multiple samples → median offset, min RTT (more stable)
const offsets: number[] = [];
const rtts: number[] = [];
for (let i = 0; i < samples; i++) {
const { offset, rtt } = await measureOnce();
offsets.push(offset);
rtts.push(rtt);
if (i < samples - 1) await new Promise(r => setTimeout(r, 120)); // tiny gap
}
offsets.sort((a, b) => a - b);
const median = offsets[Math.floor(offsets.length / 2)];
const minRtt = Math.min(...rtts);
setSkewMs(median);
setRttMs(minRtt);
} catch (e: any) {
setError(e?.message || "Time sync failed");
}
};
useEffect(() => {
if (!enabled) return;
measure(); // initial check
if (intervalMs > 0) {
timerRef.current = window.setInterval(measure, intervalMs);
}
return () => {
if (timerRef.current) window.clearInterval(timerRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [endpoint, intervalMs, samples, enabled]);
return { skewMs, rttMs, error, recheck: measure };
}

View File

@ -30,6 +30,7 @@ html, body, #root {
}
/* Button container */
.burger{
grid-area: burger;
display:grid; /* centers SVG */
place-items:center;
width:40px; height:40px; /* good touch target */
@ -51,7 +52,7 @@ html, body, #root {
.burger:hover{ background:rgba(255,255,255,.12); }
.burger:focus-visible{ outline:2px solid var(--accent); outline-offset:2px; }
.brand { color: var(--text); text-decoration:none; font-weight:800; }
.brand { color: var(--text); text-decoration:none; font-weight:800; grid-area: title; justify-self: start; }
.drawer {
position:absolute; left:16px; top:56px; display:flex; flex-direction:column;
gap:8px; padding:12px; border-radius:12px; background:rgba(15,21,32,0.98);
@ -169,14 +170,21 @@ kbd {
/* push-mode: the content will slide right when sidebar is open */
}
.site-header {
.site-header{
position: fixed;
z-index: 50;
top: 0; left: 16px; right: 16px;
display: flex; gap: 12px; align-items: center;
height: var(--header-h);
/* three areas: banner (fills), burger (left), title (left of center) */
display: grid;
grid-template-columns: auto auto 1fr; /* burger | title | banner */
grid-template-areas: "burger title banner";
align-items: center;
column-gap: 12px;
}
.brand {
font-weight: 900;
color: var(--text);
@ -384,6 +392,57 @@ kbd {
.footer a { text-decoration: underline; }
.site-header { position: fixed; z-index: 60; top: 0; left: 16px; right: 16px; height: var(--header-h); display:flex; align-items:center; gap:12px; }
/* Centered badge that sizes to its content */
.timesync-banner{
position: fixed;
z-index: 70;
top: calc(env(safe-area-inset-top, 0px) + var(--header-h) + 6px);
left: 50%;
transform: translateX(-50%);
display: inline-flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
width: max-content; /* shrink to content */
max-width: 92vw; /* keep inside viewport */
white-space: nowrap; /* single line (desktop) */
border-radius: 12px;
background: rgba(255,200,0,.12);
border: 1px solid rgba(255,200,0,.35);
color: #ffe9a6;
box-sizing: border-box;
}
/* message shouldnt force stretch */
.timesync-msg { flex: 0 1 auto; }
/* buttons stay tidy */
.timesync-actions{
margin-left: 8px;
display: inline-flex;
align-items: center;
gap: 8px;
}
.timesync-btn,
.timesync-close{
appearance: none;
border: 1px solid rgba(255,255,255,.18);
background: rgba(255,255,255,.08);
color: inherit;
font: inherit;
line-height: 1;
height: 30px;
padding: 6px 10px;
border-radius: 10px;
cursor: pointer;
}
.timesync-close{ width: 30px; padding: 0; }
@keyframes pop {
from { transform: scale(0.98); opacity: 0.0; }
to { transform: scale(1); opacity: 1.0; }
@ -391,6 +450,38 @@ kbd {
@media (prefers-reduced-motion: reduce) {
.progress-fill { transition: none; }
.countdown-wrap {animation: none;}
.timesync-banner {
flex-wrap: wrap;
gap: 8px 10px;
}
.timesync-actions {
width: 100%;
justify-content: flex-end;
}
}
@media (max-width: 720px){
.site-header{
grid-template-columns: auto 1fr;
grid-template-areas:
"burger title"
"banner banner";
row-gap: 8px;
height: calc(var(--header-h) + 40px);
}
}
@media (max-width: 640px){
.timesync-banner{
display: flex;
flex-wrap: wrap;
row-gap: 6px;
white-space: normal; /* allow wrap */
max-width: min(900px, 92vw);
}
.timesync-msg{ flex: 1 1 100%; } /* text on first row */
.timesync-actions{ margin-left: auto; }
.site-content{
padding-top: calc(var(--header-h) + 72px); /* push card down so it won't sit under the banner */
}
}
@container (max-width: 420px) {
.timer-hero { font-size: clamp(22px, 11cqw, 72px); letter-spacing: 0.25px; }