TimerDashboard.tsx (6648B)
1 "use client"; 2 3 import { useState, useEffect, useMemo } from 'react'; 4 import { addHours, differenceInMilliseconds } from 'date-fns'; 5 import { formatInTimeZone, fromZonedTime } from 'date-fns-tz'; 6 import CountdownDisplay from './CountdownDisplay'; 7 import { getSessionEndTime } from '@/lib/timeUtils'; 8 9 const JST_TZ = 'Asia/Tokyo'; 10 11 interface TimerDashboardProps { 12 tapHistory: number[]; 13 lastTapTime: Date | null; 14 ticket1Time: Date | null; 15 ticket2Time: Date | null; 16 onTap: () => void; 17 onInvite: (ticketNumber: 1 | 2) => void; 18 isSyncing: boolean; 19 isDataLoaded: boolean; 20 } 21 22 export default function TimerDashboard({ 23 tapHistory, 24 lastTapTime, 25 ticket1Time, 26 ticket2Time, 27 onTap, 28 onInvite, 29 isSyncing, 30 isDataLoaded, 31 }: TimerDashboardProps) { 32 const [now, setNow] = useState(new Date()); 33 34 // デバイスのタイムゾーンを取得 35 const userTimeZone = useMemo(() => { 36 if (typeof window !== 'undefined') { 37 try { 38 return Intl.DateTimeFormat().resolvedOptions().timeZone; 39 } catch (e) { 40 return JST_TZ; 41 } 42 } 43 return JST_TZ; 44 }, []); 45 46 // 日本時間(JST)設定かどうかを判定 47 const isJst = userTimeZone === JST_TZ; 48 49 useEffect(() => { 50 setNow(new Date()); 51 const timer = setInterval(() => setNow(new Date()), 1000); 52 return () => clearInterval(timer); 53 }, []); 54 55 // 次回タップまでの残り時間計算 56 const cafeTapRemaining = useMemo(() => { 57 if (!lastTapTime) return 0; 58 const endTime = getSessionEndTime(lastTapTime); 59 if (!endTime) return 0; 60 return Math.max(0, differenceInMilliseconds(endTime, now)); 61 }, [now, lastTapTime]); 62 63 const canTapCafe = useMemo(() => { 64 if (!isDataLoaded) return false; 65 if (!lastTapTime) return true; 66 return cafeTapRemaining <= 0; 67 }, [isDataLoaded, lastTapTime, cafeTapRemaining]); 68 69 // チケットの残り時間計算 70 const ticket1Remaining = useMemo(() => { 71 if (!ticket1Time) return 0; 72 return differenceInMilliseconds(addHours(ticket1Time, 20), now); 73 }, [now, ticket1Time]); 74 75 const ticket2Remaining = useMemo(() => { 76 if (!ticket2Time) return 0; 77 return differenceInMilliseconds(addHours(ticket2Time, 20), now); 78 }, [now, ticket2Time]); 79 80 // 8スロットの生成ロジック 81 const dailySlots = useMemo(() => { 82 // 1. 日本時間での現在時刻をベースにサイクルの起点(04:00)を算出 83 const nowJstStr = formatInTimeZone(now, JST_TZ, "yyyy-MM-dd'T'HH:mm:ss"); 84 const jstNow = new Date(nowJstStr); 85 86 let cycleStartDate = new Date(jstNow); 87 if (jstNow.getHours() < 4) { 88 cycleStartDate.setDate(cycleStartDate.getDate() - 1); 89 } 90 91 const cycleStartJstStr = `${formatInTimeZone(cycleStartDate, JST_TZ, "yyyy-MM-dd")}T04:00:00`; 92 const cycleStartUtc = fromZonedTime(cycleStartJstStr, JST_TZ); 93 94 return Array.from({ length: 8 }, (_, i) => { 95 const startUtc = addHours(cycleStartUtc, i * 3); 96 const endUtc = addHours(startUtc, 3); 97 98 // 履歴からこの枠に収まるタップを探す 99 const tapEntry = (tapHistory || []).find((t) => { 100 const tapMs = new Date(t).getTime(); 101 return tapMs >= startUtc.getTime() && tapMs < endUtc.getTime(); 102 }); 103 104 const tapMs = tapEntry ? new Date(tapEntry).getTime() : null; 105 106 // 表示用ラベルの決定 タップ済みの場合は実測時間、未タップの場合は枠の開始時間を採用 107 const mainLabel = tapMs 108 ? formatInTimeZone(tapMs, userTimeZone, 'HH:mm') 109 : formatInTimeZone(startUtc, userTimeZone, 'HH:mm'); 110 111 const subLabelJst = tapMs 112 ? formatInTimeZone(tapMs, JST_TZ, 'HH:mm') 113 : formatInTimeZone(startUtc, JST_TZ, 'HH:mm'); 114 115 return { 116 mainLabel, 117 subLabelJst, 118 isCurrent: now >= startUtc && now < endUtc, 119 hasTapped: !!tapEntry 120 }; 121 }); 122 }, [tapHistory, now, userTimeZone]); 123 124 return ( 125 <div className="dashboard-container"> 126 <div className="timer-card"> 127 <div className="flex items-center justify-between mb-2"> 128 <h2 className="timer-card-title">Next Tap</h2> 129 </div> 130 131 <div className="countdown-text-l mb-6"> 132 <CountdownDisplay milliseconds={cafeTapRemaining} /> 133 </div> 134 135 <div className="slot-grid"> 136 {dailySlots.map((slot, i) => ( 137 <div 138 key={i} 139 className={`slot-item ${ 140 slot.hasTapped 141 ? 'slot-tapped' 142 : slot.isCurrent 143 ? 'slot-current' 144 : 'slot-default' 145 }`} 146 > 147 <div className="flex flex-col items-center leading-tight"> 148 <span 149 className="slot-text-main" 150 style={!isJst ? { marginTop: '-0.5cqw' } : {}} 151 > 152 {slot.mainLabel} 153 </span> 154 {!isJst && ( 155 <span className="slot-text-sub whitespace-nowrap"> 156 {slot.subLabelJst} JST 157 </span> 158 )} 159 </div> 160 </div> 161 ))} 162 </div> 163 164 <button 165 onClick={() => onTap()} 166 disabled={!canTapCafe || isSyncing} 167 className="btn-timer btn-timer-tap" 168 > 169 <span>{ !isDataLoaded ? 'Wait...' : isSyncing ? 'Wait...' : 'Tap' }</span> 170 </button> 171 </div> 172 173 <div className="timer-card"> 174 <h2 className="timer-card-title mb-4">Next Call</h2> 175 <div className="grid grid-cols-2 gap-4"> 176 <div className="flex flex-col gap-2"> 177 <div className="countdown-text-s bg-background/50 pt-1 pl-1 rounded"> 178 <CountdownDisplay milliseconds={ticket1Remaining} /> 179 </div> 180 <button 181 onClick={() => onInvite(1)} 182 disabled={!isDataLoaded || ticket1Remaining > 0 || isSyncing} 183 className="btn-timer btn-timer-tap" 184 ><span>{ isSyncing ? 'Wait...' : 'Ticket 1' }</span></button> 185 </div> 186 <div className="flex flex-col gap-2"> 187 <div className="countdown-text-s bg-background/50 pt-1 pl-1 rounded"> 188 <CountdownDisplay milliseconds={ticket2Remaining} /> 189 </div> 190 <button 191 onClick={() => onInvite(2)} 192 disabled={!isDataLoaded || ticket2Remaining > 0 || isSyncing} 193 className="btn-timer btn-timer-tap" 194 ><span>{ isSyncing ? 'Wait...' : 'Ticket 2' }</span></button> 195 </div> 196 </div> 197 </div> 198 </div> 199 ); 200 }