From 8e9db971d35e371114a02aa8bebe10819b87eaff Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Mar 2026 03:58:07 +0000 Subject: [PATCH] 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 --- backend/package-lock.json | 24 +++- backend/package.json | 3 +- backend/server.js | 60 ++++++++- frontend/src/App.jsx | 37 +++++- frontend/src/App.module.css | 48 +++++++ frontend/src/components/CarManagement.jsx | 117 ++++++++++++++++-- .../src/components/CarManagement.module.css | 19 +++ frontend/src/components/ScheduleView.jsx | 12 +- .../src/components/ScheduleView.module.css | 9 ++ frontend/src/components/TimelineView.jsx | 4 +- frontend/src/hooks/useWebSocket.js | 83 +++++++++++++ frontend/src/utils/carUtils.js | 13 ++ frontend/vite.config.js | 48 ++++++- 13 files changed, 451 insertions(+), 26 deletions(-) create mode 100644 frontend/src/hooks/useWebSocket.js create mode 100644 frontend/src/utils/carUtils.js diff --git a/backend/package-lock.json b/backend/package-lock.json index 3ec556e..ebff5fb 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -12,7 +12,8 @@ "better-sqlite3": "^12.8.0", "cors": "^2.8.6", "express": "^5.2.1", - "express-rate-limit": "^8.3.1" + "express-rate-limit": "^8.3.1", + "ws": "^8.20.0" } }, "node_modules/accepts": { @@ -1309,6 +1310,27 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } } } } diff --git a/backend/package.json b/backend/package.json index 0a93c08..189abef 100644 --- a/backend/package.json +++ b/backend/package.json @@ -16,6 +16,7 @@ "better-sqlite3": "^12.8.0", "cors": "^2.8.6", "express": "^5.2.1", - "express-rate-limit": "^8.3.1" + "express-rate-limit": "^8.3.1", + "ws": "^8.20.0" } } diff --git a/backend/server.js b/backend/server.js index e5e71af..392e090 100644 --- a/backend/server.js +++ b/backend/server.js @@ -3,9 +3,12 @@ const cors = require('cors'); const rateLimit = require('express-rate-limit'); const Database = require('better-sqlite3'); const path = require('path'); +const http = require('http'); +const { WebSocketServer } = require('ws'); const app = express(); const PORT = process.env.PORT || 3001; +const server = http.createServer(app); // Trust the first proxy (nginx) so that express-rate-limit can correctly // identify clients by their real IP from the X-Forwarded-For header. @@ -14,6 +17,10 @@ app.set('trust proxy', 1); app.use(cors()); app.use(express.json()); +// Serve the built frontend (production mode) +const distPath = path.join(__dirname, '..', 'frontend', 'dist'); +app.use(express.static(distPath)); + // Apply rate limiting to all API routes const apiLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes @@ -46,6 +53,18 @@ db.exec(` ); `); +// Migrate: add new car fields if they don't exist yet +const carCols = db.prepare("PRAGMA table_info(cars)").all().map((c) => c.name); +if (!carCols.includes('inspection_expiry')) { + db.exec("ALTER TABLE cars ADD COLUMN inspection_expiry TEXT DEFAULT ''"); +} +if (!carCols.includes('has_etc')) { + db.exec('ALTER TABLE cars ADD COLUMN has_etc INTEGER DEFAULT 0'); +} +if (!carCols.includes('tire_type')) { + db.exec("ALTER TABLE cars ADD COLUMN tire_type TEXT DEFAULT 'ノーマル'"); +} + // Seed some initial cars if none exist const carCount = db.prepare('SELECT COUNT(*) as cnt FROM cars').get(); if (carCount.cnt === 0) { @@ -55,6 +74,22 @@ if (carCount.cnt === 0) { insertCar.run('代車 C', ''); } +// --- WebSocket Server --- +const wss = new WebSocketServer({ server, path: '/ws' }); + +function broadcast(message) { + const data = JSON.stringify(message); + wss.clients.forEach((client) => { + if (client.readyState === client.OPEN) { + client.send(data); + } + }); +} + +wss.on('connection', (ws) => { + ws.on('error', () => {}); // suppress unhandled error events +}); + // --- Cars API --- app.get('/api/cars', (req, res) => { const cars = db.prepare('SELECT * FROM cars ORDER BY id').all(); @@ -62,25 +97,31 @@ app.get('/api/cars', (req, res) => { }); app.post('/api/cars', (req, res) => { - const { name, description = '' } = req.body; + const { name, description = '', inspection_expiry = '', has_etc = 0, tire_type = 'ノーマル' } = req.body; if (!name || !name.trim()) { return res.status(400).json({ error: '車名は必須です' }); } - const result = db.prepare('INSERT INTO cars (name, description) VALUES (?, ?)').run(name.trim(), description); + const result = db.prepare( + 'INSERT INTO cars (name, description, inspection_expiry, has_etc, tire_type) VALUES (?, ?, ?, ?, ?)' + ).run(name.trim(), description, inspection_expiry, has_etc ? 1 : 0, tire_type); const car = db.prepare('SELECT * FROM cars WHERE id = ?').get(result.lastInsertRowid); + broadcast({ type: 'data_changed', entity: 'cars' }); res.status(201).json(car); }); app.put('/api/cars/:id', (req, res) => { - const { name, description } = req.body; + const { name, description, inspection_expiry, has_etc, tire_type } = req.body; if (!name || !name.trim()) { return res.status(400).json({ error: '車名は必須です' }); } - const result = db.prepare('UPDATE cars SET name = ?, description = ? WHERE id = ?').run(name.trim(), description ?? '', req.params.id); + const result = db.prepare( + 'UPDATE cars SET name = ?, description = ?, inspection_expiry = ?, has_etc = ?, tire_type = ? WHERE id = ?' + ).run(name.trim(), description ?? '', inspection_expiry ?? '', has_etc ? 1 : 0, tire_type ?? 'ノーマル', req.params.id); if (result.changes === 0) { return res.status(404).json({ error: '車が見つかりません' }); } const car = db.prepare('SELECT * FROM cars WHERE id = ?').get(req.params.id); + broadcast({ type: 'data_changed', entity: 'cars' }); res.json(car); }); @@ -89,6 +130,7 @@ app.delete('/api/cars/:id', (req, res) => { if (result.changes === 0) { return res.status(404).json({ error: '車が見つかりません' }); } + broadcast({ type: 'data_changed', entity: 'all' }); res.json({ success: true }); }); @@ -110,6 +152,7 @@ app.post('/api/reservations', (req, res) => { 'INSERT INTO reservations (car_id, start_date, end_date, customer_name, notes) VALUES (?, ?, ?, ?, ?)' ).run(car_id, start_date, end_date, customer_name, notes); const reservation = db.prepare('SELECT * FROM reservations WHERE id = ?').get(result.lastInsertRowid); + broadcast({ type: 'data_changed', entity: 'reservations' }); res.status(201).json(reservation); }); @@ -128,6 +171,7 @@ app.put('/api/reservations/:id', (req, res) => { return res.status(404).json({ error: '予約が見つかりません' }); } const reservation = db.prepare('SELECT * FROM reservations WHERE id = ?').get(req.params.id); + broadcast({ type: 'data_changed', entity: 'reservations' }); res.json(reservation); }); @@ -136,9 +180,15 @@ app.delete('/api/reservations/:id', (req, res) => { if (result.changes === 0) { return res.status(404).json({ error: '予約が見つかりません' }); } + broadcast({ type: 'data_changed', entity: 'reservations' }); res.json({ success: true }); }); -app.listen(PORT, () => { +// SPA fallback: serve index.html for any non-API route +app.get('/*path', apiLimiter, (req, res) => { + res.sendFile(path.join(distPath, 'index.html')); +}); + +server.listen(PORT, () => { console.log(`Server running on http://localhost:${PORT}`); }); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 8b729f3..76bf91c 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,16 +1,36 @@ -import { useState } from 'react'; +import { useState, useCallback } from 'react'; import ScheduleView from './components/ScheduleView.jsx'; import CarManagement from './components/CarManagement.jsx'; import TimelineView from './components/TimelineView.jsx'; +import useWebSocket from './hooks/useWebSocket.js'; import styles from './App.module.css'; export default function App() { const [page, setPage] = useState('schedule'); + const [reloadKey, setReloadKey] = useState(0); + + const handleWsMessage = useCallback((msg) => { + if (msg.type === 'data_changed') { + setReloadKey((k) => k + 1); + } + }, []); + + const { status: wsStatus } = useWebSocket(handleWsMessage); return (
-

🚗 代車スケジュール管理

+
+ +

🚗 代車スケジュール管理

+
+ + {wsStatus === 'error' && ( +
+ ⚠️ サーバーとの接続が切断されました。ページを再読み込みしてください。 +
+ )} +
- {page === 'schedule' && } - {page === 'timeline' && } - {page === 'cars' && } + {page === 'schedule' && } + {page === 'timeline' && } + {page === 'cars' && }
); diff --git a/frontend/src/App.module.css b/frontend/src/App.module.css index 377a891..bfaba80 100644 --- a/frontend/src/App.module.css +++ b/frontend/src/App.module.css @@ -18,12 +18,59 @@ z-index: 100; } +.headerLeft { + display: flex; + align-items: center; + gap: 10px; +} + .title { font-size: 20px; font-weight: 700; letter-spacing: 0.5px; } +/* WebSocket connection indicator dot */ +.wsIndicator { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; + transition: background 0.3s; +} + +.wsIndicator_connected { + background: #4ade80; /* green */ + box-shadow: 0 0 6px #4ade80; +} + +.wsIndicator_connecting, +.wsIndicator_disconnected { + background: #fbbf24; /* amber */ + animation: pulse 1s infinite; +} + +.wsIndicator_error { + background: #f87171; /* red */ +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +/* Disconnection warning banner */ +.wsError { + background: #fef3c7; + border-bottom: 2px solid #f59e0b; + color: #92400e; + padding: 8px 24px; + font-size: 14px; + font-weight: 500; + text-align: center; +} + .nav { display: flex; gap: 8px; @@ -54,3 +101,4 @@ flex: 1; overflow: hidden; } + diff --git a/frontend/src/components/CarManagement.jsx b/frontend/src/components/CarManagement.jsx index 76f593f..96db33c 100644 --- a/frontend/src/components/CarManagement.jsx +++ b/frontend/src/components/CarManagement.jsx @@ -1,20 +1,27 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { api } from '../api.js'; +import { isInspectionExpirySoon } from '../utils/carUtils.js'; import styles from './CarManagement.module.css'; -export default function CarManagement() { +export default function CarManagement({ reloadKey = 0 }) { const [cars, setCars] = useState([]); const [reservations, setReservations] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [newCarName, setNewCarName] = useState(''); const [newCarDesc, setNewCarDesc] = useState(''); + const [newCarExpiry, setNewCarExpiry] = useState(''); + const [newCarEtc, setNewCarEtc] = useState(false); + const [newCarTire, setNewCarTire] = useState('ノーマル'); const [editingId, setEditingId] = useState(null); const [editName, setEditName] = useState(''); const [editDesc, setEditDesc] = useState(''); + const [editExpiry, setEditExpiry] = useState(''); + const [editEtc, setEditEtc] = useState(false); + const [editTire, setEditTire] = useState('ノーマル'); const [submitting, setSubmitting] = useState(false); - const loadCars = async () => { + const loadCars = useCallback(async () => { try { setLoading(true); const [carsData, resData] = await Promise.all([api.getCars(), api.getReservations()]); @@ -26,20 +33,29 @@ export default function CarManagement() { } finally { setLoading(false); } - }; + }, []); useEffect(() => { loadCars(); - }, []); + }, [loadCars, reloadKey]); const handleAdd = async (e) => { e.preventDefault(); if (!newCarName.trim()) return; try { setSubmitting(true); - await api.createCar({ name: newCarName.trim(), description: newCarDesc.trim() }); + await api.createCar({ + name: newCarName.trim(), + description: newCarDesc.trim(), + inspection_expiry: newCarExpiry, + has_etc: newCarEtc, + tire_type: newCarTire, + }); setNewCarName(''); setNewCarDesc(''); + setNewCarExpiry(''); + setNewCarEtc(false); + setNewCarTire('ノーマル'); await loadCars(); } catch (e) { setError(e.message); @@ -66,19 +82,31 @@ export default function CarManagement() { setEditingId(car.id); setEditName(car.name); setEditDesc(car.description || ''); + setEditExpiry(car.inspection_expiry || ''); + setEditEtc(!!car.has_etc); + setEditTire(car.tire_type || 'ノーマル'); }; const cancelEdit = () => { setEditingId(null); setEditName(''); setEditDesc(''); + setEditExpiry(''); + setEditEtc(false); + setEditTire('ノーマル'); }; const handleUpdate = async (id) => { if (!editName.trim()) return; try { setSubmitting(true); - await api.updateCar(id, { name: editName.trim(), description: editDesc.trim() }); + await api.updateCar(id, { + name: editName.trim(), + description: editDesc.trim(), + inspection_expiry: editExpiry, + has_etc: editEtc, + tire_type: editTire, + }); cancelEdit(); await loadCars(); } catch (e) { @@ -111,6 +139,39 @@ export default function CarManagement() { value={newCarDesc} onChange={(e) => setNewCarDesc(e.target.value)} /> + +
+ + + @@ -129,13 +190,16 @@ export default function CarManagement() { ID 車名 備考 + 車検満了日 + ETC + タイヤ 操作 {cars.length === 0 && ( - 代車がありません + 代車がありません )} {cars.map((car) => ( @@ -159,6 +223,34 @@ export default function CarManagement() { onChange={(e) => setEditDesc(e.target.value)} /> + + setEditExpiry(e.target.value)} + /> + + + + + + +
{/* Day cells */} diff --git a/frontend/src/components/ScheduleView.module.css b/frontend/src/components/ScheduleView.module.css index 7aaffdd..c222856 100644 --- a/frontend/src/components/ScheduleView.module.css +++ b/frontend/src/components/ScheduleView.module.css @@ -182,6 +182,15 @@ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + flex: 1; + min-width: 0; +} + +.carIcons { + display: flex; + gap: 2px; + font-size: 14px; + flex-shrink: 0; } /* Cell area */ diff --git a/frontend/src/components/TimelineView.jsx b/frontend/src/components/TimelineView.jsx index 52b9417..4465751 100644 --- a/frontend/src/components/TimelineView.jsx +++ b/frontend/src/components/TimelineView.jsx @@ -31,7 +31,7 @@ function dateToStr(date) { return format(date, 'yyyy-MM-dd'); } -export default function TimelineView() { +export default function TimelineView({ reloadKey = 0 }) { const [cars, setCars] = useState([]); const [reservations, setReservations] = useState([]); const [loading, setLoading] = useState(true); @@ -75,7 +75,7 @@ export default function TimelineView() { useEffect(() => { loadData(); - }, [loadData]); + }, [loadData, reloadKey]); // Close context menu on click / Escape useEffect(() => { diff --git a/frontend/src/hooks/useWebSocket.js b/frontend/src/hooks/useWebSocket.js new file mode 100644 index 0000000..caf6559 --- /dev/null +++ b/frontend/src/hooks/useWebSocket.js @@ -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 }; +} diff --git a/frontend/src/utils/carUtils.js b/frontend/src/utils/carUtils.js new file mode 100644 index 0000000..cab7d41 --- /dev/null +++ b/frontend/src/utils/carUtils.js @@ -0,0 +1,13 @@ +/** + * Returns true if the given inspection expiry date string is within 1 month + * from today (or already past). + * @param {string} inspectionExpiry - ISO date string (YYYY-MM-DD) or empty + * @returns {boolean} + */ +export function isInspectionExpirySoon(inspectionExpiry) { + if (!inspectionExpiry) return false; + const expiry = new Date(inspectionExpiry); + const oneMonthLater = new Date(); + oneMonthLater.setMonth(oneMonthLater.getMonth() + 1); + return expiry <= oneMonthLater; +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js index c9266a0..096d712 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -1,13 +1,57 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; +import net from 'net'; + +const backendOrigin = process.env.BACKEND_URL || 'http://localhost:3001'; + +/** + * Custom Vite plugin that tunnels WebSocket upgrade requests at /ws to the + * backend via raw TCP. Vite's built-in proxy `ws: true` can silently drop + * upgrade events that Vite's own HMR handler intercepts first. This plugin + * hooks directly onto `httpServer.upgrade` and handles the /ws path before + * Vite gets a chance to claim it. + */ +function wsProxyPlugin() { + return { + name: 'ws-proxy', + configureServer(server) { + server.httpServer?.on('upgrade', (req, socket, head) => { + if (req.url !== '/ws') return; + + const { hostname, port: rawPort } = new URL(backendOrigin); + const port = parseInt(rawPort) || 3001; + + const conn = net.createConnection({ host: hostname, port }); + + conn.on('error', () => socket.destroy()); + socket.on('error', () => conn.destroy()); + + conn.on('connect', () => { + // Replay the original HTTP upgrade request to the backend + const headers = + `${req.method} ${req.url} HTTP/${req.httpVersion}\r\n` + + Object.entries(req.headers) + .map(([k, v]) => `${k}: ${v}`) + .join('\r\n') + + '\r\n\r\n'; + conn.write(headers); + if (head && head.length) conn.write(head); + + // Bidirectional pipe + conn.pipe(socket).pipe(conn); + }); + }); + }, + }; +} export default defineConfig({ - plugins: [react()], + plugins: [react(), wsProxyPlugin()], server: { port: 5173, proxy: { '/api': { - target: process.env.BACKEND_URL || 'http://localhost:3001', + target: backendOrigin, changeOrigin: true, }, },