|
| 1 | +import React, { useEffect, useMemo, useState } from "react"; |
| 2 | + |
| 3 | +type StreakCalendarProps = { |
| 4 | + /** localStorage key for ISO-date strings (YYYY-MM-DD) */ |
| 5 | + storageKey?: string; |
| 6 | +}; |
| 7 | + |
| 8 | +/* ---------------- helpers ---------------- */ |
| 9 | +const pad = (n: number) => String(n).padStart(2, "0"); |
| 10 | +const toKey = (d: Date) => |
| 11 | + `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; |
| 12 | + |
| 13 | +const lastNDaysKeys = (n: number) => { |
| 14 | + const today = new Date(); |
| 15 | + today.setHours(0, 0, 0, 0); |
| 16 | + const keys: string[] = []; |
| 17 | + for (let i = n - 1; i >= 0; i--) { |
| 18 | + const dt = new Date(today); |
| 19 | + dt.setDate(today.getDate() - i); |
| 20 | + keys.push(toKey(dt)); |
| 21 | + } |
| 22 | + return keys; // oldest -> newest (today last) |
| 23 | +}; |
| 24 | + |
| 25 | +const readActiveSet = (storageKey: string) => { |
| 26 | + try { |
| 27 | + const raw = localStorage.getItem(storageKey); |
| 28 | + if (!raw) return new Set<string>(); |
| 29 | + const arr = JSON.parse(raw); |
| 30 | + if (!Array.isArray(arr)) return new Set<string>(); |
| 31 | + return new Set<string>(arr.filter((x) => typeof x === "string")); |
| 32 | + } catch { |
| 33 | + return new Set<string>(); |
| 34 | + } |
| 35 | +}; |
| 36 | + |
| 37 | +const countCurrentStreak = (orderedKeys: string[], activeSet: Set<string>) => { |
| 38 | + // orderedKeys oldest -> newest (today is last) |
| 39 | + let count = 0; |
| 40 | + for (let i = orderedKeys.length - 1; i >= 0; i--) { |
| 41 | + if (activeSet.has(orderedKeys[i])) count++; |
| 42 | + else break; |
| 43 | + } |
| 44 | + return count; |
| 45 | +}; |
| 46 | + |
| 47 | +const WEEKDAYS = ["S", "M", "T", "W", "T", "F", "S"]; |
| 48 | +const fmtReadable = (k: string) => |
| 49 | + new Date(k).toLocaleDateString(undefined, { |
| 50 | + weekday: "short", |
| 51 | + month: "short", |
| 52 | + day: "numeric", |
| 53 | + }); |
| 54 | + |
| 55 | +// "Aug 03 → Sep 01" |
| 56 | +const fmtRange = (startKey: string, endKey: string) => { |
| 57 | + const f = (k: string) => |
| 58 | + new Date(k).toLocaleDateString(undefined, { month: "short", day: "2-digit" }); |
| 59 | + return `${f(startKey)} → ${f(endKey)}`; |
| 60 | +}; |
| 61 | + |
| 62 | +// "August 2025" or "Aug–Sep 2025" |
| 63 | +const monthHeaderLabel = (startKey: string, endKey: string) => { |
| 64 | + const s = new Date(startKey); |
| 65 | + const e = new Date(endKey); |
| 66 | + const sameMonth = s.getMonth() === e.getMonth() && s.getFullYear() === e.getFullYear(); |
| 67 | + if (sameMonth) { |
| 68 | + return s.toLocaleString(undefined, { month: "long", year: "numeric" }); |
| 69 | + } |
| 70 | + const left = s.toLocaleString(undefined, { month: "short" }); |
| 71 | + const right = e.toLocaleString(undefined, { month: "short", year: "numeric" }); |
| 72 | + return `${left}–${right}`; |
| 73 | +}; |
| 74 | + |
| 75 | +/* ---------------- component ---------------- */ |
| 76 | +const StreakCalendar: React.FC<StreakCalendarProps> = ({ |
| 77 | + storageKey = "cit_activeDates", |
| 78 | +}) => { |
| 79 | + const [activeSet, setActiveSet] = useState<Set<string>>(new Set()); |
| 80 | + |
| 81 | + useEffect(() => { |
| 82 | + setActiveSet(readActiveSet(storageKey)); |
| 83 | + }, [storageKey]); |
| 84 | + |
| 85 | + const dateKeys = useMemo(() => lastNDaysKeys(30), []); |
| 86 | + const todayKey = dateKeys[dateKeys.length - 1]; |
| 87 | + const startKey = dateKeys[0]; |
| 88 | + const endKey = todayKey; |
| 89 | + |
| 90 | + const currentStreak = useMemo( |
| 91 | + () => countCurrentStreak(dateKeys, activeSet), |
| 92 | + [dateKeys, activeSet] |
| 93 | + ); |
| 94 | + |
| 95 | + return ( |
| 96 | + <div> |
| 97 | + {/* Month label + date range */} |
| 98 | + <div className="mb-1 flex items-center justify-between text-xs"> |
| 99 | + <div className="text-gray-800 dark:text-gray-200 font-medium"> |
| 100 | + {monthHeaderLabel(startKey, endKey)} |
| 101 | + </div> |
| 102 | + <div className="text-gray-500 dark:text-gray-400">{fmtRange(startKey, endKey)}</div> |
| 103 | + </div> |
| 104 | + |
| 105 | + {/* Weekday header */} |
| 106 | +<div className="grid grid-cols-7 gap-2 text-[11px] sm:text-xs text-gray-500 dark:text-gray-400"> |
| 107 | + {WEEKDAYS.map((d, i) => ( |
| 108 | + <div key={`${d}-${i}`} className="text-center"> |
| 109 | + {d} |
| 110 | + </div> |
| 111 | + ))} |
| 112 | +</div> |
| 113 | + |
| 114 | + |
| 115 | + {/* Month markers row: show label where the date is the 1st */} |
| 116 | + <div className="grid grid-cols-7 gap-2 mt-1 mb-2 text-[10px] sm:text-[11px] text-gray-500 dark:text-gray-400"> |
| 117 | + {dateKeys.map((k) => { |
| 118 | + const d = new Date(k); |
| 119 | + return ( |
| 120 | + <div key={`m-${k}`} className="text-center"> |
| 121 | + {d.getDate() === 1 |
| 122 | + ? d.toLocaleString(undefined, { month: "short" }) |
| 123 | + : ""} |
| 124 | + </div> |
| 125 | + ); |
| 126 | + })} |
| 127 | + </div> |
| 128 | + |
| 129 | + {/* Numbered month-style grid for last 30 days */} |
| 130 | + <div |
| 131 | + className="grid grid-cols-7 gap-2" |
| 132 | + role="grid" |
| 133 | + aria-label="Last 30 days activity" |
| 134 | + > |
| 135 | + {dateKeys.map((key) => { |
| 136 | + const d = new Date(key); |
| 137 | + const dayNum = d.getDate(); |
| 138 | + const active = activeSet.has(key); |
| 139 | + const isToday = key === todayKey; |
| 140 | + const isFirstOfMonth = dayNum === 1; |
| 141 | + |
| 142 | + const circleBase = |
| 143 | + "relative h-7 w-7 sm:h-8 sm:w-8 rounded-full flex items-center justify-center " + |
| 144 | + "text-[12px] sm:text-[13px] outline-none transition-transform " + |
| 145 | + "focus:ring-2 focus:ring-offset-1 focus:ring-indigo-500 dark:focus:ring-indigo-400 " + |
| 146 | + "focus:ring-offset-white dark:focus:ring-offset-gray-800"; |
| 147 | + |
| 148 | + const circleState = active |
| 149 | + ? "bg-emerald-500 dark:bg-emerald-400 text-white border border-emerald-600 dark:border-emerald-400" |
| 150 | + : "text-gray-600 dark:text-gray-300 border border-transparent hover:border-gray-300 dark:hover:border-gray-600"; |
| 151 | + |
| 152 | + const todayRing = isToday |
| 153 | + ? "ring-1 ring-indigo-500 dark:ring-indigo-400 ring-offset-1 ring-offset-white dark:ring-offset-gray-800" |
| 154 | + : ""; |
| 155 | + |
| 156 | + const readable = fmtReadable(key); |
| 157 | + |
| 158 | + return ( |
| 159 | + <button |
| 160 | + key={key} |
| 161 | + role="gridcell" |
| 162 | + className={`${circleBase} ${circleState} ${todayRing}`} |
| 163 | + title={`${readable} • ${active ? "Active" : "Inactive"}`} |
| 164 | + aria-label={`${readable} — ${active ? "Active" : "Inactive"}`} |
| 165 | + aria-selected={active} |
| 166 | + tabIndex={0} |
| 167 | + > |
| 168 | + {/* Day number */} |
| 169 | + <span className="leading-none">{dayNum}</span> |
| 170 | + |
| 171 | + {/* Tiny red dot to hint new month when not active */} |
| 172 | + {isFirstOfMonth && !active && ( |
| 173 | + <span className="absolute -bottom-0.5 h-1.5 w-1.5 rounded-full bg-rose-500" /> |
| 174 | + )} |
| 175 | + </button> |
| 176 | + ); |
| 177 | + })} |
| 178 | + </div> |
| 179 | + |
| 180 | + {/* Current streak + legend */} |
| 181 | + <div className="mt-3 flex items-center justify-between text-sm"> |
| 182 | + <div className="text-gray-700 dark:text-gray-300"> |
| 183 | + <span className="font-medium">Current Streak:</span>{" "} |
| 184 | + {currentStreak} {currentStreak === 1 ? "day" : "days"} |
| 185 | + </div> |
| 186 | + <div className="flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400"> |
| 187 | + <span className="inline-flex items-center"> |
| 188 | + <span className="h-3 w-3 rounded-full bg-emerald-500 dark:bg-emerald-400 border border-emerald-600 dark:border-emerald-400 mr-1" /> |
| 189 | + Active |
| 190 | + </span> |
| 191 | + <span className="inline-flex items-center"> |
| 192 | + <span className="h-3 w-3 rounded-full border border-gray-300 dark:border-gray-600 mr-1" /> |
| 193 | + Inactive |
| 194 | + </span> |
| 195 | + <span className="inline-flex items-center"> |
| 196 | + <span className="h-3 w-3 rounded-full border border-gray-300 dark:border-gray-600 ring-1 ring-indigo-500 dark:ring-indigo-400 ring-offset-[2px] ring-offset-white dark:ring-offset-gray-800 mr-1" /> |
| 197 | + Today |
| 198 | + </span> |
| 199 | + </div> |
| 200 | + </div> |
| 201 | + |
| 202 | + <p className="mt-2 text-[11px] text-gray-500 dark:text-gray-400"> |
| 203 | + Stored locally on your device. |
| 204 | + </p> |
| 205 | + </div> |
| 206 | + ); |
| 207 | +}; |
| 208 | + |
| 209 | +export default StreakCalendar; |
0 commit comments