Skip to content

Commit 2508063

Browse files
committed
fix(streak-calendar): add unique keys for weekday header to remove duplicate key warning
1 parent 03e87cd commit 2508063

File tree

1 file changed

+98
-24
lines changed

1 file changed

+98
-24
lines changed

src/components/Profile/StreakCalendar.tsx

Lines changed: 98 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import React, { useEffect, useMemo, useState } from "react";
22

33
type StreakCalendarProps = {
4-
/** localStorage key for ISO-date strings */
4+
/** localStorage key for ISO-date strings (YYYY-MM-DD) */
55
storageKey?: string;
66
};
77

8+
/* ---------------- helpers ---------------- */
89
const pad = (n: number) => String(n).padStart(2, "0");
910
const toKey = (d: Date) =>
1011
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
@@ -18,7 +19,7 @@ const lastNDaysKeys = (n: number) => {
1819
dt.setDate(today.getDate() - i);
1920
keys.push(toKey(dt));
2021
}
21-
return keys;
22+
return keys; // oldest -> newest (today last)
2223
};
2324

2425
const readActiveSet = (storageKey: string) => {
@@ -43,83 +44,156 @@ const countCurrentStreak = (orderedKeys: string[], activeSet: Set<string>) => {
4344
return count;
4445
};
4546

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 ---------------- */
4676
const StreakCalendar: React.FC<StreakCalendarProps> = ({
4777
storageKey = "cit_activeDates",
4878
}) => {
4979
const [activeSet, setActiveSet] = useState<Set<string>>(new Set());
5080

51-
// read once on mount
5281
useEffect(() => {
5382
setActiveSet(readActiveSet(storageKey));
5483
}, [storageKey]);
5584

5685
const dateKeys = useMemo(() => lastNDaysKeys(30), []);
5786
const todayKey = dateKeys[dateKeys.length - 1];
87+
const startKey = dateKeys[0];
88+
const endKey = todayKey;
89+
5890
const currentStreak = useMemo(
5991
() => countCurrentStreak(dateKeys, activeSet),
6092
[dateKeys, activeSet]
6193
);
6294

6395
return (
6496
<div>
65-
{/* 7 columns grid; 30 cells -> last row will have 2 cells */}
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 */}
66130
<div
67-
className="grid grid-cols-7 gap-1"
131+
className="grid grid-cols-7 gap-2"
68132
role="grid"
69133
aria-label="Last 30 days activity"
70134
>
71135
{dateKeys.map((key) => {
136+
const d = new Date(key);
137+
const dayNum = d.getDate();
72138
const active = activeSet.has(key);
73139
const isToday = key === todayKey;
140+
const isFirstOfMonth = dayNum === 1;
74141

75-
const base =
76-
"w-3.5 h-3.5 sm:w-4 sm:h-4 rounded-[4px] border outline-none " +
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 " +
77145
"focus:ring-2 focus:ring-offset-1 focus:ring-indigo-500 dark:focus:ring-indigo-400 " +
78-
"focus:ring-offset-white dark:focus:ring-offset-gray-800 transition-transform";
79-
const state = active
80-
? "bg-emerald-500 dark:bg-emerald-400 border-emerald-600 dark:border-emerald-400"
81-
: "bg-transparent border-gray-300 dark:border-gray-600";
82-
const ring = isToday
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
83153
? "ring-1 ring-indigo-500 dark:ring-indigo-400 ring-offset-1 ring-offset-white dark:ring-offset-gray-800"
84154
: "";
85-
const readable = new Date(key).toLocaleDateString(undefined, {
86-
weekday: "short",
87-
month: "short",
88-
day: "numeric",
89-
});
155+
156+
const readable = fmtReadable(key);
90157

91158
return (
92159
<button
93160
key={key}
94161
role="gridcell"
95-
className={`${base} ${state} ${ring}`}
162+
className={`${circleBase} ${circleState} ${todayRing}`}
96163
title={`${readable}${active ? "Active" : "Inactive"}`}
97164
aria-label={`${readable}${active ? "Active" : "Inactive"}`}
98165
aria-selected={active}
99166
tabIndex={0}
100-
/>
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>
101176
);
102177
})}
103178
</div>
104179

180+
{/* Current streak + legend */}
105181
<div className="mt-3 flex items-center justify-between text-sm">
106182
<div className="text-gray-700 dark:text-gray-300">
107183
<span className="font-medium">Current Streak:</span>{" "}
108184
{currentStreak} {currentStreak === 1 ? "day" : "days"}
109185
</div>
110-
111-
{/* tiny legend */}
112186
<div className="flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400">
113187
<span className="inline-flex items-center">
114-
<span className="w-3 h-3 rounded-[3px] bg-emerald-500 dark:bg-emerald-400 border border-emerald-600 dark:border-emerald-400 mr-1" />
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" />
115189
Active
116190
</span>
117191
<span className="inline-flex items-center">
118-
<span className="w-3 h-3 rounded-[3px] border border-gray-300 dark:border-gray-600 mr-1" />
192+
<span className="h-3 w-3 rounded-full border border-gray-300 dark:border-gray-600 mr-1" />
119193
Inactive
120194
</span>
121195
<span className="inline-flex items-center">
122-
<span className="w-3 h-3 rounded-[3px] border border-gray-300 dark:border-gray-600 ring-1 ring-indigo-500 dark:ring-indigo-400 ring-offset-1 ring-offset-white dark:ring-offset-gray-800 mr-1" />
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" />
123197
Today
124198
</span>
125199
</div>

0 commit comments

Comments
 (0)