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;