diff --git a/frontend/app/streak/page.tsx b/frontend/app/streak/page.tsx index ca14e6d..b370669 100644 --- a/frontend/app/streak/page.tsx +++ b/frontend/app/streak/page.tsx @@ -1,209 +1,11 @@ "use client"; import React, { useState } from "react"; -import Image from "next/image"; import Link from "next/link"; import { useRouter } from "next/navigation"; import ShareOptionsSheet from "../../components/ShareOptionsSheet"; import ShareStreakCard from "@/components/ShareStreakCard"; - -export interface StreakData { - [date: string]: { - completed: boolean; - inStreak?: boolean; - missed?: boolean; - }; -} - -export interface DayData { - day: string; - completed: boolean; -} - -interface StreakDayIndicatorProps { - status: "empty" | "completed" | "streak" | "missed"; - isToday?: boolean; - inStreakRun?: boolean; -} - -const StreakDayIndicator: React.FC = ({ - status, - isToday = false, - inStreakRun = false, -}) => { - const baseClasses = - "flex items-center justify-center w-[24px] h-[24px] md:w-[28px] md:h-[28px] rounded-full z-10 shrink-0"; - - let statusClasses = ""; - if (status === "empty") statusClasses = "bg-[#E6E6E6]/20"; - else if (status === "completed") statusClasses = "bg-[#FACC15]"; - else if (status === "streak") - statusClasses = "bg-[#FACC15] shadow-lg shadow-[#FACC15]/50"; - else if (status === "missed") statusClasses = "bg-white/30"; - - const todayClasses = isToday - ? "ring-2 ring-white ring-offset-2 ring-offset-[#050C16]" - : ""; - - return ( -
- {inStreakRun && status === "streak" && ( -
- )} -
- {status === "streak" && ( - 🔥 - )} -
-
- ); -}; - -interface StreakCalendarProps { - currentMonth: Date; - streakData: StreakData; - onMonthChange?: (date: Date) => void; -} - -const StreakCalendar: React.FC = ({ - currentMonth, - streakData, - onMonthChange, -}) => { - const [selectedMonth, setSelectedMonth] = useState(currentMonth); - - const weekDays = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; - const monthNames = [ - "January", "February", "March", "April", "May", "June", - "July", "August", "September", "October", "November", "December", - ]; - - const getDaysInMonth = (date: Date) => - new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate(); - - const getFirstDayOfMonth = (date: Date) => { - const firstDay = new Date(date.getFullYear(), date.getMonth(), 1).getDay(); - return firstDay === 0 ? 6 : firstDay - 1; - }; - - const formatDateKey = (year: number, month: number, day: number) => - `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`; - - const isToday = (year: number, month: number, day: number) => { - const today = new Date(); - return ( - year === today.getFullYear() && - month === today.getMonth() && - day === today.getDate() - ); - }; - - const handlePreviousMonth = () => { - const newMonth = new Date(selectedMonth.getFullYear(), selectedMonth.getMonth() - 1); - setSelectedMonth(newMonth); - onMonthChange?.(newMonth); - }; - - const handleNextMonth = () => { - const newMonth = new Date(selectedMonth.getFullYear(), selectedMonth.getMonth() + 1); - setSelectedMonth(newMonth); - onMonthChange?.(newMonth); - }; - - const renderCalendarDays = () => { - const daysInMonth = getDaysInMonth(selectedMonth); - const firstDayOfMonth = getFirstDayOfMonth(selectedMonth); - const year = selectedMonth.getFullYear(); - const month = selectedMonth.getMonth(); - const days = []; - - for (let i = 0; i < firstDayOfMonth; i++) { - days.push(
); - } - - for (let day = 1; day <= daysInMonth; day++) { - const dateKey = formatDateKey(year, month, day); - const dayData = streakData[dateKey]; - const today = isToday(year, month, day); - - let status: "empty" | "completed" | "streak" | "missed" = "empty"; - if (dayData?.missed) status = "missed"; - else if (dayData?.completed) - status = dayData?.inStreak ? "streak" : "completed"; - - days.push( -
- - {day} - - -
- ); - } - - return days; - }; - - return ( -
-
- {/* Month Header */} -
- -

- {monthNames[selectedMonth.getMonth()].slice(0, 3).toUpperCase()}{" "} - {selectedMonth.getFullYear()} -

- -
- - {/* Divider */} -
- - {/* Weekday Labels */} -
- {weekDays.map((day) => ( -
- - {day} - -
- ))} -
- - {/* Calendar Grid */} -
{renderCalendarDays()}
-
-
- ); -}; +import { StreakCalendar, StreakData } from "@/components/StreakCalendar"; interface StreakSummaryCardProps { streakCount: number; @@ -386,6 +188,7 @@ export default function StreakPage() { Streak Calendar diff --git a/frontend/components/StreakCalendar.tsx b/frontend/components/StreakCalendar.tsx index cc02d95..365b021 100644 --- a/frontend/components/StreakCalendar.tsx +++ b/frontend/components/StreakCalendar.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useCallback, useMemo, useState } from "react"; import { StreakDayIndicator } from "./StreakDayIndicator"; export interface StreakData { @@ -9,120 +9,136 @@ export interface StreakData { }; } +type CalendarCell = + | { kind: "empty"; key: string } + | { + kind: "day"; + key: string; + day: number; + dateKey: string; + today: boolean; + status: "empty" | "completed" | "streak" | "missed"; + inStreakRun?: boolean; + }; + +const WEEK_DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] as const; + +const MONTH_NAMES = [ + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December", +]; + +function startOfMonth(d: Date): Date { + return new Date(d.getFullYear(), d.getMonth(), 1); +} + +function addCalendarMonth(date: Date, delta: number): Date { + return new Date(date.getFullYear(), date.getMonth() + delta, 1); +} + +function daysInMonth(year: number, month: number): number { + return new Date(year, month + 1, 0).getDate(); +} + +function firstWeekdayMondayBased(year: number, month: number): number { + const dow = new Date(year, month, 1).getDay(); + return dow === 0 ? 6 : dow - 1; +} + +function formatDateKey(year: number, month: number, day: number): string { + return `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`; +} + interface StreakCalendarProps { currentMonth: Date; streakData: StreakData; onMonthChange?: (date: Date) => void; + variant?: "card" | "panel"; } export const StreakCalendar: React.FC = ({ currentMonth, streakData, onMonthChange, + variant = "card", }) => { - const [selectedMonth, setSelectedMonth] = useState(currentMonth); - - const weekDays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; - - const monthNames = [ - 'January', 'February', 'March', 'April', 'May', 'June', - 'July', 'August', 'September', 'October', 'November', 'December' - ]; - - const getDaysInMonth = (date: Date): number => { - return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate(); - }; - - const getFirstDayOfMonth = (date: Date): number => { - const firstDay = new Date(date.getFullYear(), date.getMonth(), 1).getDay(); - // Convert Sunday (0) to Monday start (6), and others to Monday-first format - return firstDay === 0 ? 6 : firstDay - 1; - }; - - const formatDateKey = (year: number, month: number, day: number): string => { - return `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; - }; - - const isToday = (year: number, month: number, day: number): boolean => { - const today = new Date(); - return ( - year === today.getFullYear() && - month === today.getMonth() && - day === today.getDate() - ); - }; - - const handlePreviousMonth = () => { - const newMonth = new Date(selectedMonth.getFullYear(), selectedMonth.getMonth() - 1); - setSelectedMonth(newMonth); - onMonthChange?.(newMonth); - }; - - const handleNextMonth = () => { - const newMonth = new Date(selectedMonth.getFullYear(), selectedMonth.getMonth() + 1); - setSelectedMonth(newMonth); - onMonthChange?.(newMonth); - }; - - const renderCalendarDays = () => { - const daysInMonth = getDaysInMonth(selectedMonth); - const firstDayOfMonth = getFirstDayOfMonth(selectedMonth); + const [selectedMonth, setSelectedMonth] = useState(() => startOfMonth(currentMonth)); + + const handlePreviousMonth = useCallback(() => { + setSelectedMonth((prev) => { + const next = addCalendarMonth(prev, -1); + onMonthChange?.(next); + return next; + }); + }, [onMonthChange]); + + const handleNextMonth = useCallback(() => { + setSelectedMonth((prev) => { + const next = addCalendarMonth(prev, 1); + onMonthChange?.(next); + return next; + }); + }, [onMonthChange]); + + const calendarCells = useMemo((): CalendarCell[] => { const year = selectedMonth.getFullYear(); const month = selectedMonth.getMonth(); - - const days = []; - - // Add empty cells for days before month starts - for (let i = 0; i < firstDayOfMonth; i++) { - days.push( -
- ); + const dim = daysInMonth(year, month); + const lead = firstWeekdayMondayBased(year, month); + const now = new Date(); + const ty = now.getFullYear(); + const tm = now.getMonth(); + const td = now.getDate(); + + const cells: CalendarCell[] = []; + for (let i = 0; i < lead; i++) { + cells.push({ kind: "empty", key: `empty-${year}-${month}-${i}` }); } - - // Add days of the month - for (let day = 1; day <= daysInMonth; day++) { + for (let day = 1; day <= dim; day++) { const dateKey = formatDateKey(year, month, day); const dayData = streakData[dateKey]; - const today = isToday(year, month, day); - - let status: 'empty' | 'completed' | 'streak' | 'missed' = 'empty'; - if (dayData?.missed) { - status = 'missed'; - } else if (dayData?.completed) { - status = dayData?.inStreak ? 'streak' : 'completed'; - } - - days.push( -
- - {day} - - -
- ); + const today = ty === year && tm === month && td === day; + + let st: "empty" | "completed" | "streak" | "missed" = "empty"; + if (dayData?.missed) st = "missed"; + else if (dayData?.completed) st = dayData?.inStreak ? "streak" : "completed"; + + cells.push({ + kind: "day", + key: dateKey, + day, + dateKey, + today, + status: st, + inStreakRun: dayData?.inStreak, + }); } + return cells; + }, [selectedMonth, streakData]); - return days; - }; + const isPanel = variant === "panel"; + + const monthTitle = isPanel + ? `${MONTH_NAMES[selectedMonth.getMonth()].slice(0, 3).toUpperCase()} ${selectedMonth.getFullYear()}` + : `${MONTH_NAMES[selectedMonth.getMonth()]} ${selectedMonth.getFullYear()}`; + + const outerCardClass = isPanel + ? "bg-[#050C16] border border-[#FACC15]/20 w-full max-w-[566px] rounded-[16px] p-[16px] md:p-[24px]" + : "bg-[#050C16] border border-[#FACC15]/20 w-full max-w-[400px] rounded-[16px] p-[16px] md:p-[24px]"; return (
- {/* Title */} -

- Streak Calendar -

-
- - {/* Month Header with Navigation */} + {!isPanel && ( +

+ Streak Calendar +

+ )} +
-

- {monthNames[selectedMonth.getMonth()]} {selectedMonth.getFullYear()} +

+ {monthTitle}

- {/* Weekday Labels */} + {isPanel &&
} +
- {weekDays.map((day) => ( + {WEEK_DAYS.map((day) => (
- + {day}
))}
- {/* Calendar Grid */}
- {renderCalendarDays()} + {calendarCells.map((cell) => { + if (cell.kind === "empty") { + return
; + } + const dayLabelClass = isPanel + ? cell.today + ? "text-xs font-nunito text-white font-bold" + : "text-xs font-nunito text-[#E6E6E6]/60" + : cell.today + ? "text-xs font-nunito text-white font-bold" + : "text-xs font-nunito text-[#E6E6E6]"; + + return ( +
+ {cell.day} + +
+ ); + })}