ba-cafe

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

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 }