feat: add inspection_expiry/has_etc/tire_type fields, icons in schedule view, and WebSocket real-time sync

Co-authored-by: pdf114514 <57948770+pdf114514@users.noreply.github.com>
Agent-Logs-Url: https://github.com/pdf114514/CarReservation/sessions/6d0f25ae-6db4-4937-ae2b-6674456a5ca1
This commit is contained in:
copilot-swe-agent[bot]
2026-03-22 03:58:07 +00:00
parent 09872737b7
commit 8e9db971d3
13 changed files with 451 additions and 26 deletions

View File

@@ -0,0 +1,83 @@
import { useEffect, useRef, useState, useCallback } from 'react';
const WS_BASE = (() => {
if (import.meta.env.VITE_WS_URL) return import.meta.env.VITE_WS_URL;
const loc = window.location;
const proto = loc.protocol === 'https:' ? 'wss:' : 'ws:';
// Use the same origin as the page; in development the Vite dev server
// proxies /ws to the backend, and in production nginx does the same.
return `${proto}//${loc.host}/ws`;
})();
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 15000, 30000]; // ms
/**
* Maintains a WebSocket connection with automatic reconnection.
*
* @param {(message: object) => void} onMessage - Called for each parsed JSON message.
* @returns {{ status: 'connecting'|'connected'|'disconnected'|'error' }}
*/
export default function useWebSocket(onMessage) {
const [status, setStatus] = useState('connecting');
const wsRef = useRef(null);
const retryCountRef = useRef(0);
const unmountedRef = useRef(false);
const retryTimerRef = useRef(null);
const onMessageRef = useRef(onMessage);
// Keep onMessage ref up-to-date without re-running the effect
useEffect(() => {
onMessageRef.current = onMessage;
}, [onMessage]);
const connect = useCallback(() => {
if (unmountedRef.current) return;
setStatus('connecting');
const ws = new WebSocket(WS_BASE);
wsRef.current = ws;
ws.onopen = () => {
if (unmountedRef.current) { ws.close(); return; }
retryCountRef.current = 0;
setStatus('connected');
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
onMessageRef.current(msg);
} catch {
// ignore malformed messages
}
};
ws.onclose = () => {
if (unmountedRef.current) return;
const delay = RECONNECT_DELAYS[Math.min(retryCountRef.current, RECONNECT_DELAYS.length - 1)];
retryCountRef.current += 1;
// After exhausting all backoff levels, keep status as 'error'
if (retryCountRef.current > RECONNECT_DELAYS.length) {
setStatus('error');
} else {
setStatus('disconnected');
}
retryTimerRef.current = setTimeout(connect, delay);
};
ws.onerror = () => {
// onclose will fire right after, which handles reconnect
};
}, []);
useEffect(() => {
connect();
return () => {
unmountedRef.current = true;
clearTimeout(retryTimerRef.current);
wsRef.current?.close();
};
}, [connect]);
return { status };
}