Skip to content

Commit 274d718

Browse files
authored
Merge pull request #35 from Gourangi4/feat/streak-calendar-24
feat(profile): add 30-day streak calendar with month labels + streak tracking
2 parents a69cb0e + 2508063 commit 274d718

File tree

4 files changed

+233
-12
lines changed

4 files changed

+233
-12
lines changed

package-lock.json

Lines changed: 14 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@
1616
},
1717
"devDependencies": {
1818
"@eslint/js": "^9.9.1",
19-
"@types/react": "^18.3.5",
20-
"@types/react-dom": "^18.3.0",
19+
"@types/react": "^18.3.24",
20+
"@types/react-dom": "^18.3.7",
2121
"@vitejs/plugin-react": "^4.3.1",
2222
"autoprefixer": "^10.4.18",
2323
"eslint": "^9.9.1",

src/components/Profile/Profile.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import React from 'react';
2+
import StreakCalendar from "./StreakCalendar";
23
import { Award, BookOpen, Star, TrendingUp, Calendar, Target } from 'lucide-react';
34

45
export const Profile: React.FC = () => {
@@ -148,6 +149,13 @@ export const Profile: React.FC = () => {
148149
</div>
149150
<p className="text-sm text-gray-600 dark:text-gray-400">250 XP needed for Level 4: Advanced</p>
150151
</div>
152+
{/* 30-Day Streak */}
153+
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
154+
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
155+
30-Day Streak
156+
</h3>
157+
<StreakCalendar storageKey="cit_activeDates" />
158+
</div>
151159

152160
{/* Achievements */}
153161
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
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

Comments
 (0)