ba-cafe

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README | LICENSE

commit 45411bdb0d4619ee2d79acc62bd36573ac52467c
parent e40f3a31a2051305ff5f6f201f865d2dbf75b0e1
Author: Sunny <122193933+Sunny-JP@users.noreply.github.com>
Date:   Thu, 29 Jan 2026 00:39:03 +0900

Merge pull request #33 from Sunny-JP/v4-sunny-dev

update api
Diffstat:
Msrc/app/api/tap/route.ts | 88++++++++++++++++++++++++++++++++++---------------------------------------------
Msrc/components/CountdownDisplay.tsx | 30+++++++++++++++---------------
Msrc/components/Header.tsx | 42+++++++++++++++++++++---------------------
Msrc/components/OneSignalInit.tsx | 20+++++++++++---------
Msrc/components/Settings.tsx | 289++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Msrc/components/ThemeToggleButton.tsx | 30+++++++++++++++---------------
Msrc/components/TimerDashboard.tsx | 344++++++++++++++++++++++++++++++++++++++++----------------------------------------
7 files changed, 426 insertions(+), 417 deletions(-)

diff --git a/src/app/api/tap/route.ts b/src/app/api/tap/route.ts @@ -29,67 +29,55 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } - const { data: profile } = await supabase - .from('profiles') - .select('tap_history') - .eq('id', user.id) - .single(); - +// --- DB更新処理 --- + const { data: profile } = await supabase.from('profiles').select('tap_history').eq('id', user.id).single(); let newHistory = [...(profile?.tap_history || [])]; if (tapTime) newHistory.push(tapTime); - const upsertData: any = { - id: user.id, - updated_at: new Date().toISOString() - }; - + const upsertData: any = { id: user.id, updated_at: new Date().toISOString() }; if (tapTime) upsertData.tap_history = newHistory; + // onesignalIdが空で送られてきた場合に既存のIDを消さないように制御 if (onesignalId) upsertData.onesignal_id = onesignalId; + if (ticket1Time !== undefined) upsertData.ticket1_time = ticket1Time ? new Date(ticket1Time).toISOString() : null; if (ticket2Time !== undefined) upsertData.ticket2_time = ticket2Time ? new Date(ticket2Time).toISOString() : null; - const { error: upsertError } = await supabase - .from('profiles') - .upsert(upsertData); + await supabase.from('profiles').upsert(upsertData); - if (upsertError) { - console.error("DB Upsert Error:", upsertError); - throw new Error(upsertError.message); - } + // --- 通知予約処理 --- + if (tapTime && shouldScheduleNotification(new Date(tapTime))) { + const sendAfter = new Date(tapTime); + sendAfter.setHours(sendAfter.getHours() + 3); - if (tapTime) { - if (shouldScheduleNotification(new Date(tapTime))) { - const sendAfter = new Date(tapTime); - sendAfter.setHours(sendAfter.getHours() + 3); // Production - // sendAfter.setSeconds(sendAfter.getSeconds() + 180); // Testing - - const randomMsg = messages - ? messages[Math.floor(Math.random() * messages.length)] - : { title: "Cafe Timer", body: "カフェ業務の時間です" }; - - const notificationPayload = { - app_id: process.env.NEXT_PUBLIC_ONESIGNAL_APP_ID, - include_aliases: { - external_id: [user.id] - }, - target_channel: "push", - contents: { en: randomMsg.body, ja: randomMsg.body }, - headings: { en: randomMsg.title, ja: randomMsg.title }, - send_after: sendAfter.toISOString(), - }; + const randomMsg = messages + ? messages[Math.floor(Math.random() * messages.length)] + : { title: "Cafe Timer", body: "カフェ業務の時間です" }; + + const notificationPayload = { + app_id: process.env.NEXT_PUBLIC_ONESIGNAL_APP_ID, + // Subscription IDではなく External ID (user.id) で送る + // これにより、OneSignal側で複数のデバイスが紐付いていても全てに届く + include_aliases: { + external_id: [user.id] + }, + target_channel: "push", + contents: { en: randomMsg.body, ja: randomMsg.body }, + headings: { en: randomMsg.title, ja: randomMsg.title }, + send_after: sendAfter.toISOString(), + }; - const osRes = await fetch("https://onesignal.com/api/v1/notifications", { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `Basic ${process.env.ONESIGNAL_REST_API_KEY}` - }, - body: JSON.stringify(notificationPayload) - }); - - if (!osRes.ok) { - console.error("OneSignal Error:", await osRes.text()); - } + const osRes = await fetch("https://onesignal.com/api/v1/notifications", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Basic ${process.env.ONESIGNAL_REST_API_KEY}` + }, + body: JSON.stringify(notificationPayload) + }); + + if (!osRes.ok) { + const errorDetail = await osRes.text(); + console.error("OneSignal API Error:", errorDetail); } } diff --git a/src/components/CountdownDisplay.tsx b/src/components/CountdownDisplay.tsx @@ -7,23 +7,23 @@ import { Lekton } from "next/font/google"; const LektonFont = Lekton({ weight: "700", subsets: ["latin"] }); interface CountdownDisplayProps { - milliseconds: number; + milliseconds: number; } export default function CountdownDisplay({ milliseconds }: CountdownDisplayProps) { - const { hours, minutes, seconds } = useMemo(() => { - const totalSeconds = Math.max(0, Math.floor(milliseconds / 1000)); - const hours = String(Math.floor(totalSeconds / 3600)).padStart(2, '0'); - const minutes = String(Math.floor((totalSeconds % 3600) / 60)).padStart(2, '0'); - const seconds = String(totalSeconds % 60).padStart(2, '0'); - return { hours, minutes, seconds }; - }, [milliseconds]); + const { hours, minutes, seconds } = useMemo(() => { + const totalSeconds = Math.max(0, Math.floor(milliseconds / 1000)); + const hours = String(Math.floor(totalSeconds / 3600)).padStart(2, '0'); + const minutes = String(Math.floor((totalSeconds % 3600) / 60)).padStart(2, '0'); + const seconds = String(totalSeconds % 60).padStart(2, '0'); + return { hours, minutes, seconds }; + }, [milliseconds]); - return ( - <div className={`countdown-text ${LektonFont.className}`}> - <span>{hours}</span>: - <span>{minutes}</span>: - <span>{seconds}</span> - </div> - ); + return ( + <div className={`countdown-text ${LektonFont.className}`}> + <span>{hours}</span>: + <span>{minutes}</span>: + <span>{seconds}</span> + </div> + ); } \ No newline at end of file diff --git a/src/components/Header.tsx b/src/components/Header.tsx @@ -3,28 +3,28 @@ import ThemeToggleButton from './ThemeToggleButton'; interface HeaderProps { - onMenuClick?: () => void; - isLoggedIn?: boolean; + onMenuClick?: () => void; + isLoggedIn?: boolean; } export default function Header({ onMenuClick, isLoggedIn = false }: HeaderProps) { - return ( - <header className="fixed top-0 left-0 right-0 z-20 flex items-center h-16 justify-between px-6 shadow-sm border-b"> - <h1>Cafe Timer</h1> - <div className="flex items-center gap-4"> - <ThemeToggleButton /> - {isLoggedIn && ( - <button - onClick={onMenuClick} - className="p-2 rounded-full" - aria-label="メニューを開く" - > - <svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> - <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" /> - </svg> - </button> - )} - </div> - </header> - ); + return ( + <header className="fixed top-0 left-0 right-0 z-20 flex items-center h-16 justify-between px-6 shadow-sm border-b"> + <h1>Cafe Timer</h1> + <div className="flex items-center gap-4"> + <ThemeToggleButton /> + {isLoggedIn && ( + <button + onClick={onMenuClick} + className="p-2 rounded-full" + aria-label="メニューを開く" + > + <svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" /> + </svg> + </button> + )} + </div> + </header> + ); } \ No newline at end of file diff --git a/src/components/OneSignalInit.tsx b/src/components/OneSignalInit.tsx @@ -1,28 +1,30 @@ "use client"; import { useEffect } from 'react'; import OneSignal from 'react-onesignal'; - -let isInitialized = false; +import { supabase } from "@/hooks/useAuth"; export default function OneSignalInit() { useEffect(() => { - if (isInitialized) return; - - isInitialized = true; - const initOneSignal = async () => { try { await OneSignal.init({ appId: process.env.NEXT_PUBLIC_ONESIGNAL_APP_ID!, allowLocalhostAsSecureOrigin: true, + // サービスワーカーのパスをルートに固定して認識を安定させる serviceWorkerPath: 'OneSignalSDKWorker.js', - welcomeNotification: { title: "Cafe Timer", - message: "先生、通知設定が完了しました!これでお仕事の時間をお知らせします。", + message: "先生、通知設定が完了しました!", }, }); - console.log("OneSignal Initialized"); + + // 初期化直後にログイン状態を確認 + const { data: { user } } = await supabase.auth.getUser(); + if (user) { + // OneSignalのExternal IDとしてSupabaseのUser IDをセット + // これによりDBのIDと通知先が強固に紐付く + await OneSignal.login(user.id); + } } catch (error) { console.error("OneSignal init error", error); } diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx @@ -1,160 +1,179 @@ "use client"; -import { useState, useEffect } from 'react'; +import { useState } from 'react'; import Link from 'next/link'; import { useAuth, supabase } from "@/hooks/useAuth"; import OneSignal from 'react-onesignal'; const LogoutIcon = ({ className = 'h-5 w-5 mr-2' }: { className?: string }) => ( - <svg xmlns="http://www.w3.org/2000/svg" className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> - <path strokeLinecap="round" strokeLinejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" /> - </svg> + <svg xmlns="http://www.w3.org/2000/svg" className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> + <path strokeLinecap="round" strokeLinejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" /> + </svg> ); const MenuItemIcon = () => ( - <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-3 text-gray-500" fill="none" viewBox="0 1 24 24" stroke="currentColor" strokeWidth={2}> - <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /> - </svg> + <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-3 text-gray-500" fill="none" viewBox="0 1 24 24" stroke="currentColor" strokeWidth={2}> + <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /> + </svg> ); const TrashIcon = ({ className = 'h-5 w-5 mr-2' }: { className?: string }) => ( - <svg xmlns="http://www.w3.org/2000/svg" className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> - <path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M9 7V5a2 2 0 012-2h2a2 2 0 012 2v2M7 7h10" /> - </svg> + <svg xmlns="http://www.w3.org/2000/svg" className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> + <path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M9 7V5a2 2 0 012-2h2a2 2 0 012 2v2M7 7h10" /> + </svg> ); const BellIcon = ({ className = 'h-5 w-5 mr-2' }: { className?: string }) => ( - <svg xmlns="http://www.w3.org/2000/svg" className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> - <path strokeLinecap="round" strokeLinejoin="round" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" /> - </svg> + <svg xmlns="http://www.w3.org/2000/svg" className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> + <path strokeLinecap="round" strokeLinejoin="round" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" /> + </svg> ); const Settings = () => { - const { isLoggedIn, logout, avatarUrl, displayName } = useAuth(); - const [isDeleting, setIsDeleting] = useState(false); - - const menuItems = [ - { label: 'About', path: '/about' }, - { label: '使い方ガイド', path: '/guide' }, - { label: '利用規約', path: '/terms' }, - { label: 'プライバシーポリシー', path: '/privacy' }, - { label: '運営者情報', path: '/operator' }, - ]; - - const handleNotificationClick = async () => { - try { - if (!OneSignal.User) { - alert("通知システムが読み込まれていません。\n広告ブロッカーをOFFにしてリロードしてください。"); - return; - } - - if (Notification.permission === 'denied') { - alert("通知がブロックされています。ブラウザの設定で許可してください。"); - return; - } - - const isOptedIn = OneSignal.User.PushSubscription.optedIn; - - if (isOptedIn) { - await OneSignal.User.PushSubscription.optOut(); - alert("通知をOFFにしました。"); - } else { - await OneSignal.Notifications.requestPermission(); - await OneSignal.User.PushSubscription.optIn(); - alert("通知をONにしました!"); - } - - const currentSubscriptionId = OneSignal.User.PushSubscription.id; - - await fetch('/api/tap', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token}` - }, - body: JSON.stringify({ - onesignalId: currentSubscriptionId - }) - }); - - } catch (e: any) { - console.error("Notification Setup Error:", e); - alert(`設定エラーが発生しました:\n${e.message || e}`); + const { isLoggedIn, logout, avatarUrl, displayName } = useAuth(); + const [isDeleting, setIsDeleting] = useState(false); + const [isPushLoading, setIsPushLoading] = useState(false); + + const menuItems = [ + { label: 'About', path: '/about' }, + { label: '使い方ガイド', path: '/guide' }, + { label: '利用規約', path: '/terms' }, + { label: 'プライバシーポリシー', path: '/privacy' }, + { label: '運営者情報', path: '/operator' }, + ]; + + const handleNotificationClick = async () => { + if (isPushLoading) return; + setIsPushLoading(true); + + try { + if (!OneSignal.User) { + throw new Error("通知システムが未ロードです。広告ブロックを確認してください。"); } - }; - const handleLogoutClick = async () => { - if (window.confirm("ログアウトしますか?")) { - await logout(); - window.location.href = '/'; + const isAllowed = await OneSignal.Notifications.requestPermission(); + if (!isAllowed) { + alert("通知がブロックされています。ブラウザの設定で許可してください。"); + return; } - }; - - const handleDeleteData = async () => { - if (!window.confirm("本当に全データを削除しますか?")) return; - setIsDeleting(true); - try { - const { data: { user } } = await supabase.auth.getUser(); - if (user) { - await supabase.from('profiles').delete().eq('id', user.id); - await logout(); - alert("データを削除しました。"); - window.location.href = "/"; - } - } catch (err: any) { - console.error(err); - alert("削除に失敗しました: " + err.message); - } finally { - setIsDeleting(false); + + const { data: { session } } = await supabase.auth.getSession(); + if (!session?.user) throw new Error("セッションが見つかりません。"); + + // External IDの紐付け + await OneSignal.login(session.user.id); + + const isOptedIn = OneSignal.User.PushSubscription.optedIn; + if (isOptedIn) { + await OneSignal.User.PushSubscription.optOut(); + alert("通知をOFFにしました。"); + } else { + await OneSignal.User.PushSubscription.optIn(); + alert("通知をONにしました!"); } - }; - - return ( - <div className="p-4"> - <div className="space-y-4"> - <ul className="space-y-1"> - {menuItems.map((item) => ( - <li key={item.label}> - <Link href={item.path} className="btn-setting flex items-center p-2 rounded transition-colors"> - <MenuItemIcon /> - <span>{item.label}</span> - </Link> - </li> - ))} - </ul> - - <div className="mt-8 border-t pt-4"> - {isLoggedIn && ( - <> - <div className="flex items-center gap-4 mb-4 p-2 rounded-lg"> - {avatarUrl ? ( - <img src={avatarUrl} alt="avatar" className="w-10 h-10 rounded-full"/> - ) : ( - <div className="w-10 h-10 rounded-full flex items-center justify-center"> - <span className="text-xl">?</span> - </div> - )} - <div className="flex flex-col"> - <span className="font-semibold">{displayName}</span> - </div> - </div> - - <div className="grid grid-cols-1 sm:grid-cols-3 gap-4"> - <button onClick={handleNotificationClick} className="btn-setting flex items-center justify-center p-2 rounded transition-opacity active:opacity-70"> - <BellIcon /> - <span>通知設定</span> - </button> - <button onClick={handleDeleteData} disabled={isDeleting} className="btn-setting flex items-center justify-center p-2 rounded transition-opacity active:opacity-70"> - {isDeleting ? (<><TrashIcon className="mr-2 opacity-50" /><span>削除中…</span></>) : (<><TrashIcon /><span>データ削除</span></>)} - </button> - <button onClick={handleLogoutClick} className="btn-setting flex items-center justify-center p-2 rounded transition-opacity active:opacity-70"> - <LogoutIcon /> - <span>ログアウト</span> - </button> - </div> - </> - )} + + const currentSubscriptionId = OneSignal.User.PushSubscription.id; + + await fetch('/api/tap', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${session.access_token}` + }, + body: JSON.stringify({ onesignalId: currentSubscriptionId }) + }); + + } catch (e: any) { + console.error("Notification Setup Error:", e); + alert(e.message); + } finally { + setIsPushLoading(false); + } + }; + + const handleLogoutClick = async () => { + if (window.confirm("ログアウトしますか?")) { + await logout(); + window.location.href = '/'; + } + }; + + const handleDeleteData = async () => { + if (!window.confirm("本当に全データを削除しますか?")) return; + setIsDeleting(true); + try { + const { data: { user } } = await supabase.auth.getUser(); + if (user) { + await supabase.from('profiles').delete().eq('id', user.id); + await logout(); + alert("データを削除しました。"); + window.location.href = "/"; + } + } catch (err: any) { + console.error(err); + alert("削除に失敗しました: " + err.message); + } finally { + setIsDeleting(false); + } + }; + + return ( + <div className="p-4"> + <div className="space-y-4"> + <ul className="space-y-1"> + {menuItems.map((item) => ( + <li key={item.label}> + <Link href={item.path} className="btn-setting flex items-center p-2 rounded transition-colors"> + <MenuItemIcon /> + <span>{item.label}</span> + </Link> + </li> + ))} + </ul> + + <div className="mt-8 border-t pt-4"> + {isLoggedIn && ( + <> + <div className="flex items-center gap-4 mb-4 p-2 rounded-lg"> + {avatarUrl ? ( + <img src={avatarUrl} alt="avatar" className="w-10 h-10 rounded-full"/> + ) : ( + <div className="w-10 h-10 rounded-full flex items-center justify-center bg-gray-200"> + <span className="text-xl">?</span> + </div> + )} + <div className="flex flex-col"> + <span className="font-semibold">{displayName}</span> </div> - </div> + </div> + + <div className="grid grid-cols-1 sm:grid-cols-3 gap-4"> + <button + onClick={handleNotificationClick} + disabled={isPushLoading} + className="btn-setting flex items-center justify-center p-2 rounded transition-opacity active:opacity-70 disabled:opacity-50" + > + <BellIcon /> + <span>{isPushLoading ? '設定中...' : '通知設定'}</span> + </button> + <button + onClick={handleDeleteData} + disabled={isDeleting} + className="btn-setting flex items-center justify-center p-2 rounded transition-opacity active:opacity-70" + > + {isDeleting ? (<><TrashIcon className="mr-2 opacity-50" /><span>削除中…</span></>) : (<><TrashIcon /><span>データ削除</span></>)} + </button> + <button + onClick={handleLogoutClick} + className="btn-setting flex items-center justify-center p-2 rounded transition-opacity active:opacity-70" + > + <LogoutIcon /> + <span>ログアウト</span> + </button> + </div> + </> + )} </div> - ); + </div> + </div> + ); }; export default Settings; \ No newline at end of file diff --git a/src/components/ThemeToggleButton.tsx b/src/components/ThemeToggleButton.tsx @@ -5,28 +5,28 @@ export const runtime = "edge"; import { useTheme } from '@/hooks/useTheme'; const SunIcon = () => ( - <svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> - <path strokeLinecap="round" strokeLinejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" /> - </svg> + <svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> + <path strokeLinecap="round" strokeLinejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" /> + </svg> ); const MoonIcon = () => ( - <svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> - <path strokeLinecap="round" strokeLinejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" /> - </svg> + <svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> + <path strokeLinecap="round" strokeLinejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" /> + </svg> ); export default function ThemeToggleButton() { - const { theme, setTheme } = useTheme(); + const { theme, setTheme } = useTheme(); - const toggleTheme = () => { - setTheme(theme === 'light' ? 'dark' : 'light'); - }; + const toggleTheme = () => { + setTheme(theme === 'light' ? 'dark' : 'light'); + }; - return ( - <button onClick={toggleTheme} className="p-2 rounded-md hover:bg-secondary"> - {theme === 'light' ? <MoonIcon /> : <SunIcon />} - </button> - ); + return ( + <button onClick={toggleTheme} className="p-2 rounded-md hover:bg-secondary"> + {theme === 'light' ? <MoonIcon /> : <SunIcon />} + </button> + ); } \ No newline at end of file diff --git a/src/components/TimerDashboard.tsx b/src/components/TimerDashboard.tsx @@ -9,188 +9,188 @@ import { getSessionEndTime } from '@/lib/timeUtils'; const JST_TZ = 'Asia/Tokyo'; interface TimerDashboardProps { - tapHistory: number[]; - lastTapTime: Date | null; - ticket1Time: Date | null; - ticket2Time: Date | null; - onTap: () => void; - onInvite: (ticketNumber: 1 | 2) => void; - isSyncing: boolean; - isDataLoaded: boolean; + tapHistory: number[]; + lastTapTime: Date | null; + ticket1Time: Date | null; + ticket2Time: Date | null; + onTap: () => void; + onInvite: (ticketNumber: 1 | 2) => void; + isSyncing: boolean; + isDataLoaded: boolean; } export default function TimerDashboard({ - tapHistory, - lastTapTime, - ticket1Time, - ticket2Time, - onTap, - onInvite, - isSyncing, - isDataLoaded, + tapHistory, + lastTapTime, + ticket1Time, + ticket2Time, + onTap, + onInvite, + isSyncing, + isDataLoaded, }: TimerDashboardProps) { - const [now, setNow] = useState(new Date()); - - const userTimeZone = useMemo(() => { - if (typeof window !== 'undefined') { - try { - return Intl.DateTimeFormat().resolvedOptions().timeZone; - } catch (e) { - return JST_TZ; - } - } + const [now, setNow] = useState(new Date()); + + const userTimeZone = useMemo(() => { + if (typeof window !== 'undefined') { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone; + } catch (e) { return JST_TZ; - }, []); - - const isJst = userTimeZone === JST_TZ; - - useEffect(() => { - setNow(new Date()); - const timer = setInterval(() => setNow(new Date()), 1000); - return () => clearInterval(timer); - }, []); - - const cafeTapRemaining = useMemo(() => { - if (!lastTapTime) return 0; - const endTime = getSessionEndTime(lastTapTime); - if (!endTime) return 0; - return Math.max(0, differenceInMilliseconds(endTime, now)); - }, [now, lastTapTime]); - - const canTapCafe = useMemo(() => { - if (!isDataLoaded) return false; - if (!lastTapTime) return true; - return cafeTapRemaining <= 0; - }, [isDataLoaded, lastTapTime, cafeTapRemaining]); - - const ticket1Remaining = useMemo(() => { - if (!ticket1Time) return 0; - return differenceInMilliseconds(addHours(ticket1Time, 20), now); - }, [now, ticket1Time]); - const ticket2Remaining = useMemo(() => { - if (!ticket2Time) return 0; - return differenceInMilliseconds(addHours(ticket2Time, 20), now); - }, [now, ticket2Time]); - - const dailySlots = useMemo(() => { - let cycleStart = new Date(now); - const currentHourJst = parseInt(formatInTimeZone(now, JST_TZ, 'H'), 10); - if (currentHourJst < 4) { - cycleStart = addHours(cycleStart, -24); - } - const cycleStartStr = formatInTimeZone(cycleStart, JST_TZ, "yyyy-MM-dd'T'04:00:00"); - cycleStart = new Date(cycleStartStr); - - return Array.from({ length: 8 }, (_, i) => { - const start = addHours(cycleStart, i * 3); - const end = addHours(start, 3); - - const timeJst = formatInTimeZone(start, JST_TZ, 'HH:mm'); - const timeLocal = formatInTimeZone(start, userTimeZone, 'HH:mm'); - - let mainLabel = timeJst; - let subLabel = null; - - if (!isJst) { - mainLabel = timeLocal; - subLabel = `${timeJst} JST`; - } - const tapEntry = (tapHistory || []).find((t) => { - return t >= start.getTime() && t < end.getTime(); - }); - const tapTimeLocal = tapEntry ? formatInTimeZone(tapEntry, userTimeZone, 'HH:mm') : null; - const tapTimeJst = tapEntry ? formatInTimeZone(tapEntry, JST_TZ, 'HH:mm') : null; - return { - mainLabel, - subLabel, - tapTimeLocal, - tapTimeJst, - isCurrent: now >= start && now < end, - hasTapped: !!tapEntry - }; - }); - }, [tapHistory, now, userTimeZone, isJst]); - - return ( - <div className="dashboard-container"> - <div className="timer-card"> - <div className="flex items-center justify-between mb-4"> - <h2 className="timer-card-title">Next Tap</h2> - </div> + } + } + return JST_TZ; + }, []); + + const isJst = userTimeZone === JST_TZ; + + useEffect(() => { + setNow(new Date()); + const timer = setInterval(() => setNow(new Date()), 1000); + return () => clearInterval(timer); + }, []); + + const cafeTapRemaining = useMemo(() => { + if (!lastTapTime) return 0; + const endTime = getSessionEndTime(lastTapTime); + if (!endTime) return 0; + return Math.max(0, differenceInMilliseconds(endTime, now)); + }, [now, lastTapTime]); + + const canTapCafe = useMemo(() => { + if (!isDataLoaded) return false; + if (!lastTapTime) return true; + return cafeTapRemaining <= 0; + }, [isDataLoaded, lastTapTime, cafeTapRemaining]); + + const ticket1Remaining = useMemo(() => { + if (!ticket1Time) return 0; + return differenceInMilliseconds(addHours(ticket1Time, 20), now); + }, [now, ticket1Time]); + const ticket2Remaining = useMemo(() => { + if (!ticket2Time) return 0; + return differenceInMilliseconds(addHours(ticket2Time, 20), now); + }, [now, ticket2Time]); + + const dailySlots = useMemo(() => { + let cycleStart = new Date(now); + const currentHourJst = parseInt(formatInTimeZone(now, JST_TZ, 'H'), 10); + if (currentHourJst < 4) { + cycleStart = addHours(cycleStart, -24); + } + const cycleStartStr = formatInTimeZone(cycleStart, JST_TZ, "yyyy-MM-dd'T'04:00:00"); + cycleStart = new Date(cycleStartStr); + + return Array.from({ length: 8 }, (_, i) => { + const start = addHours(cycleStart, i * 3); + const end = addHours(start, 3); + + const timeJst = formatInTimeZone(start, JST_TZ, 'HH:mm'); + const timeLocal = formatInTimeZone(start, userTimeZone, 'HH:mm'); + + let mainLabel = timeJst; + let subLabel = null; + + if (!isJst) { + mainLabel = timeLocal; + subLabel = `${timeJst} JST`; + } + const tapEntry = (tapHistory || []).find((t) => { + return t >= start.getTime() && t < end.getTime(); + }); + const tapTimeLocal = tapEntry ? formatInTimeZone(tapEntry, userTimeZone, 'HH:mm') : null; + const tapTimeJst = tapEntry ? formatInTimeZone(tapEntry, JST_TZ, 'HH:mm') : null; + return { + mainLabel, + subLabel, + tapTimeLocal, + tapTimeJst, + isCurrent: now >= start && now < end, + hasTapped: !!tapEntry + }; + }); + }, [tapHistory, now, userTimeZone, isJst]); + + return ( + <div className="dashboard-container"> + <div className="timer-card"> + <div className="flex items-center justify-between mb-4"> + <h2 className="timer-card-title">Next Tap</h2> + </div> - <div className="countdown-text-l mb-6"> - <CountdownDisplay milliseconds={cafeTapRemaining} /> - </div> + <div className="countdown-text-l mb-6"> + <CountdownDisplay milliseconds={cafeTapRemaining} /> + </div> - <div className="slot-grid"> - {dailySlots.map((slot, i) => ( - <div - key={i} - className={`slot-item ${ - slot.hasTapped - ? 'slot-tapped' - : slot.isCurrent - ? 'slot-current' - : 'slot-default' - }`} - > - {slot.hasTapped ? ( - <div className="flex flex-col items-center leading-tight"> - <span className="slot-text-main">{slot.tapTimeLocal}</span> - {!isJst && ( - <span className="slot-text-sub whitespace-nowrap"> - ({slot.tapTimeJst} JST) - </span> - )} - </div> - ) : ( - <div className="flex flex-col items-center leading-tight"> - <span className="slot-text-main">{slot.mainLabel}</span> - {slot.subLabel && ( - <span className="slot-text-sub whitespace-nowrap"> - ({slot.subLabel}) - </span> - )} - </div> - )} - </div> - ))} + <div className="slot-grid"> + {dailySlots.map((slot, i) => ( + <div + key={i} + className={`slot-item ${ + slot.hasTapped + ? 'slot-tapped' + : slot.isCurrent + ? 'slot-current' + : 'slot-default' + }`} + > + {slot.hasTapped ? ( + <div className="flex flex-col items-center leading-tight"> + <span className="slot-text-main">{slot.tapTimeLocal}</span> + {!isJst && ( + <span className="slot-text-sub whitespace-nowrap"> + ({slot.tapTimeJst} JST) + </span> + )} </div> - - <button - onClick={() => onTap()} - disabled={!canTapCafe || isSyncing} - className="btn-timer btn-timer-tap" - > - { !isDataLoaded ? 'Wait...' : isSyncing ? 'Wait...' : 'Tap' } - </button> + ) : ( + <div className="flex flex-col items-center leading-tight"> + <span className="slot-text-main">{slot.mainLabel}</span> + {slot.subLabel && ( + <span className="slot-text-sub whitespace-nowrap"> + ({slot.subLabel}) + </span> + )} + </div> + )} </div> + ))} + </div> - <div className="timer-card"> - <h2 className="timer-card-title mb-4">Next Call</h2> - <div className="grid grid-cols-2 gap-4"> - <div className="flex flex-col gap-2"> - <div className="countdown-text-s bg-background/50 p-2 rounded"> - <CountdownDisplay milliseconds={ticket1Remaining} /> - </div> - <button - onClick={() => onInvite(1)} - disabled={!isDataLoaded || ticket1Remaining > 0 || isSyncing} - className="btn-timer btn-timer-tap" - >{ isSyncing ? 'Wait...' : 'Ticket 1' }</button> - </div> - <div className="flex flex-col gap-2"> - <div className="countdown-text-s bg-background/50 p-2 rounded"> - <CountdownDisplay milliseconds={ticket2Remaining} /> - </div> - <button - onClick={() => onInvite(2)} - disabled={!isDataLoaded || ticket2Remaining > 0 || isSyncing} - className="btn-timer btn-timer-tap" - >{ isSyncing ? 'Wait...' : 'Ticket 2' }</button> - </div> - </div> + <button + onClick={() => onTap()} + disabled={!canTapCafe || isSyncing} + className="btn-timer btn-timer-tap" + > + { !isDataLoaded ? 'Wait...' : isSyncing ? 'Wait...' : 'Tap' } + </button> + </div> + + <div className="timer-card"> + <h2 className="timer-card-title mb-4">Next Call</h2> + <div className="grid grid-cols-2 gap-4"> + <div className="flex flex-col gap-2"> + <div className="countdown-text-s bg-background/50 p-2 rounded"> + <CountdownDisplay milliseconds={ticket1Remaining} /> + </div> + <button + onClick={() => onInvite(1)} + disabled={!isDataLoaded || ticket1Remaining > 0 || isSyncing} + className="btn-timer btn-timer-tap" + >{ isSyncing ? 'Wait...' : 'Ticket 1' }</button> + </div> + <div className="flex flex-col gap-2"> + <div className="countdown-text-s bg-background/50 p-2 rounded"> + <CountdownDisplay milliseconds={ticket2Remaining} /> </div> + <button + onClick={() => onInvite(2)} + disabled={!isDataLoaded || ticket2Remaining > 0 || isSyncing} + className="btn-timer btn-timer-tap" + >{ isSyncing ? 'Wait...' : 'Ticket 2' }</button> + </div> </div> - ); + </div> + </div> + ); } \ No newline at end of file