|
| 1 | +'use client'; |
| 2 | + |
| 3 | +import { useMemo } from 'react'; |
| 4 | +import CalendarHeatmap from 'react-calendar-heatmap'; |
| 5 | +import 'react-calendar-heatmap/dist/styles.css'; |
| 6 | + |
| 7 | +type HeatmapValue = { |
| 8 | + date: string; |
| 9 | + count: number; |
| 10 | +}; |
| 11 | + |
| 12 | +/** 이번 주 토요일 기준으로 endDate 맞추기 (GitHub 느낌) */ |
| 13 | +function endOfWeek(date = new Date()) { |
| 14 | + const d = new Date(date); |
| 15 | + const diff = 6 - d.getDay(); // 0=Sun, 6=Sat |
| 16 | + d.setDate(d.getDate() + diff); |
| 17 | + return d; |
| 18 | +} |
| 19 | + |
| 20 | +/** mock data: 최근 1년 */ |
| 21 | +function buildMockValues(days = 366): HeatmapValue[] { |
| 22 | + const out: HeatmapValue[] = []; |
| 23 | + const today = new Date(); |
| 24 | + |
| 25 | + for (let i = days - 1; i >= 0; i--) { |
| 26 | + const d = new Date(today); |
| 27 | + d.setDate(today.getDate() - i); |
| 28 | + |
| 29 | + const iso = d.toISOString().slice(0, 10); |
| 30 | + |
| 31 | + const r = Math.random(); |
| 32 | + const count = |
| 33 | + r < 0.65 ? 0 : r < 0.85 ? 1 : r < 0.95 ? 2 : r < 0.985 ? 3 : 4; |
| 34 | + |
| 35 | + if (count > 0) out.push({ date: iso, count }); |
| 36 | + } |
| 37 | + |
| 38 | + return out; |
| 39 | +} |
| 40 | + |
| 41 | +export default function GrassHeatmap() { |
| 42 | + const endDate = useMemo(() => endOfWeek(new Date()), []); |
| 43 | + const startDate = useMemo(() => { |
| 44 | + const d = new Date(endDate); |
| 45 | + d.setFullYear(d.getFullYear() - 1); |
| 46 | + d.setDate(d.getDate() + 1); |
| 47 | + return d; |
| 48 | + }, [endDate]); |
| 49 | + |
| 50 | + const values = useMemo(() => buildMockValues(366), []); |
| 51 | + |
| 52 | + return ( |
| 53 | + <> |
| 54 | + {/* 가로 길어질 때 대비 */} |
| 55 | + <div className="overflow-x-auto"> |
| 56 | + <div className="min-w-max"> |
| 57 | + <CalendarHeatmap |
| 58 | + startDate={startDate} |
| 59 | + endDate={endDate} |
| 60 | + values={values} |
| 61 | + gutterSize={2} |
| 62 | + showWeekdayLabels |
| 63 | + classForValue={(value) => { |
| 64 | + if (!value) return 'grass-empty'; |
| 65 | + if (value.count >= 4) return 'grass-4'; |
| 66 | + if (value.count === 3) return 'grass-3'; |
| 67 | + if (value.count === 2) return 'grass-2'; |
| 68 | + return 'grass-1'; |
| 69 | + }} |
| 70 | + tooltipDataAttrs={(value) => { |
| 71 | + if (!value) return { 'data-tip': '기록 없음' }; |
| 72 | + return { 'data-tip': `${value.date} · ${value.count}개 완료` }; |
| 73 | + }} |
| 74 | + /> |
| 75 | + </div> |
| 76 | + </div> |
| 77 | + |
| 78 | + {/* 범례 */} |
| 79 | + <div className="mt-4 flex items-center justify-end gap-2 text-xs text-slate-500"> |
| 80 | + <span>Less</span> |
| 81 | + <span className="h-3 w-3 rounded-sm bg-slate-100" /> |
| 82 | + <span className="h-3 w-3 rounded-sm bg-emerald-200" /> |
| 83 | + <span className="h-3 w-3 rounded-sm bg-emerald-400" /> |
| 84 | + <span className="h-3 w-3 rounded-sm bg-emerald-600" /> |
| 85 | + <span className="h-3 w-3 rounded-sm bg-emerald-800" /> |
| 86 | + <span>More</span> |
| 87 | + </div> |
| 88 | + </> |
| 89 | + ); |
| 90 | +} |
0 commit comments