ba-cafe

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

HistoryCalendar.tsx (6061B)


      1 import React, { useRef } from 'react';
      2 import { formatInTimeZone, toZonedTime } from 'date-fns-tz';
      3 import { toPng } from 'html-to-image';
      4 import { CALENDAR_LIMITS } from '@/lib/timeUtils';
      5 
      6 const JST_TZ = 'Asia/Tokyo';
      7 const SITE_NAME = "My Tap History  by  Cafe Timer";
      8 const SITE_URL = "https://cafetimer.rabbit1.cc";
      9 
     10 interface HistoryCalendarProps {
     11   tapHistory: number[];
     12   currentDate: Date;
     13   onMonthChange: (year: number, month: number) => void;
     14 }
     15 
     16 const HistoryCalendar: React.FC<HistoryCalendarProps> = ({ tapHistory, currentDate, onMonthChange }) => {
     17   const exportRef = useRef<HTMLDivElement>(null);
     18   const [isExporting, setIsExporting] = React.useState(false);
     19 
     20   const jstDate = toZonedTime(currentDate, JST_TZ);
     21   const year = jstDate.getFullYear();
     22   const month = jstDate.getMonth();
     23 
     24   const canPrev = year > CALENDAR_LIMITS.MIN.getFullYear() || month > CALENDAR_LIMITS.MIN.getMonth();
     25   const canNext = year < CALENDAR_LIMITS.MAX.getFullYear() || month < CALENDAR_LIMITS.MAX.getMonth();
     26 
     27   const getLogicalDateString = (timestamp: number): string => {
     28     const adjustedTime = timestamp - 4 * 60 * 60 * 1000;
     29     return formatInTimeZone(adjustedTime, JST_TZ, 'yyyy-MM-dd');
     30   };
     31 
     32   const tapsByDate = (tapHistory || []).reduce((acc, tap) => {
     33     const dateString = getLogicalDateString(tap);
     34     if (!acc[dateString]) acc[dateString] = [];
     35     acc[dateString].push(tap);
     36     return acc;
     37   }, {} as Record<string, number[]>);
     38 
     39   const firstDayIndex = new Date(year, month, 1).getDay();
     40   const startDay = (firstDayIndex + 6) % 7;
     41   const daysInMonth = new Date(year, month + 1, 0).getDate();
     42 
     43   const handleExportImage = async () => {
     44     if (!exportRef.current) return;
     45     setIsExporting(true);
     46     await new Promise((resolve) => setTimeout(resolve, 200));
     47     try {
     48       const dataUrl = await toPng(exportRef.current, {
     49         canvasWidth: 1440, canvasHeight: 1440, width: 1440, height: 1440,
     50         style: { transform: 'none' }, cacheBust: true,
     51       });
     52       const link = document.createElement('a');
     53       link.download = `History-${year}-${month + 1}.png`;
     54       link.href = dataUrl;
     55       link.click();
     56     } catch (err) {
     57       console.error('Export failed', err);
     58     } finally {
     59       setIsExporting(false);
     60     }
     61   };
     62 
     63   const changeMonth = (offset: number) => {
     64     if (offset < 0 && !canPrev) return;
     65     if (offset > 0 && !canNext) return;
     66 
     67     const nextDate = new Date(currentDate);
     68     nextDate.setMonth(nextDate.getMonth() + offset);
     69     onMonthChange(nextDate.getFullYear(), nextDate.getMonth() + 1);
     70   };
     71 
     72   const calendarDays = [];
     73   for (let i = 0; i < startDay; i++) {
     74     calendarDays.push(<div key={`empty-${i}`} className="p-2 aspect-square"></div>);
     75   }
     76   for (let day = 1; day <= daysInMonth; day++) {
     77     const dateKey = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
     78     const taps = tapsByDate[dateKey] || [];
     79     calendarDays.push(
     80       <div key={day} className={`relative p-2 aspect-square rounded-sm cal-cell ${isExporting ? 'export-cell' : ''} ${taps.length === 0 ? 'bg-tap-0' : `bg-tap-${Math.min(taps.length, 8)}`}`}>
     81         <div className={`absolute top-1 left-2 cal-days ${isExporting ? 'export-text-days' : ''}`}>{day}</div>
     82         <div className={`absolute bottom-1 right-2 cal-tap ${isExporting ? 'export-text-taps' : ''}`}>
     83           {taps.length > 0 ? taps.length : ''}
     84         </div>
     85       </div>
     86     );
     87   }
     88 
     89   return (
     90     <div className="cal-container">
     91       <div className="flex justify-between items-center mb-6">
     92         <button 
     93           onClick={() => changeMonth(-1)} 
     94           disabled={!canPrev}
     95           className={`cal-nav ${!canPrev ? 'opacity-30 cursor-not-allowed' : ''}`}
     96         >
     97           Prev
     98         </button>
     99         <div className="flex flex-col items-center">
    100           <div className="cal-info">{year}. {month + 1}</div>
    101           <button onClick={handleExportImage} className="cal-save-nav uppercase tracking-widest">Save Image</button>
    102         </div>
    103         <button 
    104           onClick={() => changeMonth(1)} 
    105           disabled={!canNext}
    106           className={`cal-nav ${!canNext ? 'opacity-30 cursor-not-allowed' : ''}`}
    107         >
    108           Next
    109         </button>
    110       </div>
    111 
    112       <div ref={exportRef} className={`bg-(--background) ${isExporting ? 'export-container' : 'w-full rounded-2xl overflow-hidden'}`}>
    113         {isExporting && <div className="export-header">{year} / {String(month + 1).padStart(2, '0')}</div>}
    114         <div className={`grid grid-cols-7 ${isExporting ? 'export-grid' : 'gap-2'}`}>
    115           {['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map(d => (
    116             <div key={d} className={`text-center font-bold cal-week ${isExporting ? 'export-week' : 'text-xl pb-4'}`}>{d}</div>
    117           ))}
    118           {calendarDays}
    119         </div>
    120         {isExporting && (
    121           <div className="export-footer">
    122             <div className="export-site-name">{SITE_NAME}</div>
    123             <div className="export-site-url">{SITE_URL}</div>
    124           </div>
    125         )}
    126       </div>
    127 
    128       <style jsx>{`
    129         .export-container { position: fixed; top: 0; left: 0; width: 1440px; height: 1440px; display: flex; flex-direction: column; justify-content: space-between; padding: 80px 100px; z-index: -100; }
    130         .export-header { font-size: 100px !important; font-weight: 800; text-align: center; margin-bottom: 20px; }
    131         .export-grid { gap: 20px !important; }
    132         .export-week { font-size: 32px !important; opacity: 0.6; }
    133         :global(.export-text-days) { font-size: 50px !important; line-height: 1 !important; left: 15px !important; top: 15px !important; }
    134         :global(.export-text-taps) { font-size: 70px !important; line-height: 1 !important; font-weight: 700 !important; right: 20px !important; bottom: 20px !important; }
    135         .export-footer { text-align: center; opacity: 0.5; margin-top: 30px; }
    136         .export-site-name { font-size: 38px; font-weight: 700; }
    137         .export-site-url { font-size: 24px; }
    138       `}</style>
    139     </div>
    140   );
    141 };
    142 
    143 export default HistoryCalendar;