diff --git a/src/contexts/UserContext.jsx b/src/contexts/UserContext.jsx index aab96d2..1e346a2 100644 --- a/src/contexts/UserContext.jsx +++ b/src/contexts/UserContext.jsx @@ -43,10 +43,17 @@ import { createContext, useContext, useEffect, useState } from 'react' * @typedef {Object} GoalMap - A map of goal IDs to Goal objects. */ +/** + * @typedef {Object} CompletedDays + * @property {string} date - Date in YYYY-MM-DD format. + * @property {number} count - Number of completions for the date. + */ + /** * @typedef {Object} Streak - * @property {Object.} completedDays - Map of dates (as strings) to their completion counts. + * @property {CompletedDays} completedDays - Map of dates (as strings) to their completion counts. * @property {number} count - Current streak count. + * @property {number} todayCount - Today's completion count. */ /** diff --git a/src/hooks/useGoalsUpdater.test.js b/src/hooks/useGoalsUpdater.test.js index 626fafc..8179e1e 100644 --- a/src/hooks/useGoalsUpdater.test.js +++ b/src/hooks/useGoalsUpdater.test.js @@ -21,7 +21,7 @@ describe('useGoalsUpdater', () => { profilePic: 'test-pic-url', name: 'Test User', goals: [], // Start with an empty goals array - streak: { count: 0, completedDays: [] }, + streak: [], } updateProfile = vi.fn(async (updates) => { @@ -115,39 +115,8 @@ describe('useGoalsUpdater', () => { user.goals[goalIndex].microgoals[microGoalIndex].tasks[taskIndex] expect(task.completed).toBe(true) - expect(updateProfile).toHaveBeenCalledWith( - expect.objectContaining({ - goals: user.goals, - streak: expect.objectContaining({ - count: 1, - completedDays: expect.objectContaining({ - [new Date().toISOString().split('T')[0]]: 1, // Check today's date in YYYY-MM-DD format - }), - }), - }) - ) - }) - - it('should toggle microgoal expansion', async () => { - const goalIndex = 0 - const microGoalIndex = 0 - - // Add initial goal and microgoal to user data - user.goals.push({ - name: 'Goal 1', - category: '#000000', - microgoals: [{ name: 'MicroGoal 1', expanded: false, tasks: [] }], - }) - - await goalsUpdater.toggleExpansion(goalIndex, microGoalIndex) - - expect(user.goals[goalIndex].microgoals[microGoalIndex].expanded).toBe(true) - - expect(updateProfile).toHaveBeenCalledWith( - expect.objectContaining({ - goals: user.goals, - }) - ) + // Check user.streak.completedDays length is 1 + expect(Object.keys(user.streak.completedDays)).toHaveLength(1) }) it('should delete a specified task', async () => { diff --git a/src/hooks/useGoalsUpdater/updateHelpers.js b/src/hooks/useGoalsUpdater/updateHelpers.js index dd8648d..83c0b5d 100644 --- a/src/hooks/useGoalsUpdater/updateHelpers.js +++ b/src/hooks/useGoalsUpdater/updateHelpers.js @@ -8,13 +8,12 @@ export const updateGoalsAndStreak = async ( message = 'Update successful.', countChange = 0 ) => { - const updatedStreak = - countChange !== 0 - ? updateStreakDays(userContext.user, countChange) - : userContext.user.streak + // Only update streak if countChange is non-zero const updatedProfile = { goals: updatedGoals, - ...(countChange !== 0 && { streak: updatedStreak }), + ...(countChange !== 0 && { + streak: updateStreakDays(userContext.user.streak, countChange), + }), } try { diff --git a/src/pages/Home.test.jsx b/src/pages/Home.test.jsx index d56dbaa..2c04ae2 100644 --- a/src/pages/Home.test.jsx +++ b/src/pages/Home.test.jsx @@ -3,33 +3,40 @@ import Home from '@/pages/Home' import { act, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, test, vi } from 'vitest' -// Mock `@contexts/UserContext` to control user profile and updates -vi.mock('@/contexts/UserContext', async () => { - const actual = await vi.importActual('@/contexts/UserContext') - let mockUserProfile = { - uid: '123', - profilePic: '', - name: 'Test User', - goals: [], - streak: [], - } +// Define mock data and functions directly +let mockUserProfile = { + uid: '123', + profilePic: '', + name: 'Test User', + goals: [], + streak: { completedDays: {}, count: 0 }, +} - return { - ...actual, - useUser: () => ({ - user: mockUserProfile, - loading: false, - updateProfile: vi.fn((updates) => { - mockUserProfile = { ...mockUserProfile, ...updates } - }), - }), - } +const mockUpdateProfile = vi.fn((updates) => { + mockUserProfile = { ...mockUserProfile, ...updates } }) +// Mock `@contexts/UserContext` to control user profile and updates +vi.mock('@/contexts/UserContext', () => ({ + UserProvider: ({ children }) => <>{children}, + useUser: () => ({ + user: mockUserProfile, + loading: false, + updateProfile: mockUpdateProfile, + }), +})) + describe('Home Screen - No Goals', () => { beforeEach(() => { - // Reset mockUserProfile for each test vi.clearAllMocks() + // Reset mockUserProfile to its initial state before each test + mockUserProfile = { + uid: '123', + profilePic: '', + name: 'Test User', + goals: [], + streak: { completedDays: {}, count: 0 }, + } }) test('Displays only "New Goal" field when there are no goals', async () => { @@ -42,11 +49,10 @@ describe('Home Screen - No Goals', () => { }) // Check if "New Goal" text field is present - const newGoalInput = screen.getByLabelText('New Goal') - expect(newGoalInput).not.to.be.null + expect(screen.getByLabelText('New Goal')).toBeTruthy() // Ensure "New Microgoal" and "New Task" fields are not present initially - expect(screen.queryByLabelText('New Microgoal')).to.be.null - expect(screen.queryByLabelText('New Task')).to.be.null + expect(screen.queryByLabelText('New Microgoal')).toBeNull() + expect(screen.queryByLabelText('New Task')).toBeNull() }) }) diff --git a/src/pages/Streak.jsx b/src/pages/Streak.jsx index c09dc04..888c7c2 100644 --- a/src/pages/Streak.jsx +++ b/src/pages/Streak.jsx @@ -1,3 +1,5 @@ +// @ts-check + import { useUser } from '@/contexts/UserContext' import '@/styles/StreakPage.css' import { getChicagoDate } from '@/utils/streakUtils' @@ -66,9 +68,11 @@ const Streak = () => { true} tileContent={({ date, view }) => view === 'month' ? getTileIcon(date) : null } + className='custom-calendar' /> diff --git a/src/styles/StreakPage.css b/src/styles/StreakPage.css index 7f6a438..0ac2175 100644 --- a/src/styles/StreakPage.css +++ b/src/styles/StreakPage.css @@ -1,31 +1,36 @@ -/* Ensure all calendar dates have the color black */ +/* Ensure all calendar dates have a consistent black color, including weekends */ .react-calendar__month-view__days__day--weekend { - color: black !important; /* Override weekend color */ - } + color: black !important; /* Override the weekend default color */ +} -/* Custom weekday headers */ +/* Style weekday headers with bold, capitalized, and black text */ .react-calendar__month-view__weekdays__weekday { - text-transform: capitalize; - font-weight: bold; - color: black; - font-size: 0.8rem; + text-transform: capitalize; + font-weight: bold; + color: black; + font-size: 0.8rem; } -/* Larger navigation arrows */ +/* Enlarge navigation arrows for better visibility */ .react-calendar__navigation button { - font-size: 1rem; - color: black; + font-size: 1rem; + color: black; } -/* Animation for FireIcon */ -@keyframes burn-animation { - - 0%, - 100% { - transform: scale(1); - } +/* Remove the default background color */ +button.react-calendar__tile.react-calendar__month-view__days__day { + color: inherit; + background-color: #fff; + pointer-events: none; +} - 50% { - transform: scale(1.2); - } -} \ No newline at end of file +/* Animation for FireIcon, creating a pulsing "burn" effect */ +@keyframes burn-animation { + 0%, + 100% { + transform: scale(1); + } + 50% { + transform: scale(1.2); + } +} diff --git a/src/utils/firebase/createUserProfile.js b/src/utils/firebase/createUserProfile.js index 86e2255..5626b4b 100644 --- a/src/utils/firebase/createUserProfile.js +++ b/src/utils/firebase/createUserProfile.js @@ -1,6 +1,7 @@ // @ts-check import { db } from '@/utils/firebaseConfig' +import { calculateStreakCount } from '@/utils/streakUtils' import { doc, getDoc, setDoc, updateDoc } from 'firebase/firestore' /** @@ -18,7 +19,16 @@ export const fetchUserProfile = async (uid) => { return null } - return userSnapshot.data() + const profile = userSnapshot.data() + + const { count, todayCount } = calculateStreakCount( + profile.streak.completedDays + ) + + return { + ...profile, + streak: { ...profile.streak, count, todayCount }, + } } catch (error) { console.error('Error fetching user profile:', error) return null @@ -59,15 +69,6 @@ export const createFirstUserProfile = async (user) => { } } -/** - * Retrieves the user profile from Firestore by UID. - * @param {string} uid - User's UID. - * @returns {Promise} - The user profile data or null if not found. - */ -export const getUserProfile = async (uid) => { - return await fetchUserProfile(uid) -} - /** * Updates the user profile data in Firestore by UID. * @param {string} uid - User's UID. diff --git a/src/utils/streakUtils.js b/src/utils/streakUtils.js index 2ca396b..b4102be 100644 --- a/src/utils/streakUtils.js +++ b/src/utils/streakUtils.js @@ -1,61 +1,63 @@ // @ts-check +import dayjs from 'dayjs' +import timezone from 'dayjs/plugin/timezone' +import utc from 'dayjs/plugin/utc' + +dayjs.extend(timezone) +dayjs.extend(utc) + /** * Gets the current date in Chicago timezone (formatted as YYYY-MM-DD). * @returns {string} The current Chicago date as a string in YYYY-MM-DD format */ export const getChicagoDate = () => { - const chicagoTimeOffset = -6 * 60 // CST is UTC-6 - const chicagoDate = new Date( - new Date().getTime() + chicagoTimeOffset * 60 * 1000 - ) - return chicagoDate.toISOString().split('T')[0] + return dayjs().tz('America/Chicago').format('YYYY-MM-DD') } /** - * Streak data type for a user. - * @typedef {Object} Streak - * @property {Object.} completedDays - Map of date strings to their completion counts. - * @property {number} count - Current streak count. - */ - -/** - * User data type. - * @typedef {Object} User - * @property {Streak} streak - User's streak data. + * Updates the completed days for a user based on the current date. + * @param {import('@/contexts/UserContext').Streak} streak - The user's streak object. + * @param {number} countChange - 1 or -1 to increment or decrement the count for the current date. + * @returns {import('@/contexts/UserContext').Streak} The updated streak object. */ - -/** - * Updates the streak count and completed days for a user. - * @param {User} user - The user object containing streak information. - * @param {number} countChange - The change to apply to the completion count for the current date. - * @returns {{completedDays: Object., count: number}} - Updated completedDays as a map of date-count pairs and the new streak count. - */ -export const updateStreakDays = (user, countChange) => { +export const updateStreakDays = (streak, countChange) => { const currentDate = getChicagoDate() - - // { completedDays: { '2024-11-01': 1, '2024-11-02': 0 }, count: 1 } - const completedDays = user.streak?.completedDays || {} - const count = user.streak?.count || 0 - - // Get the current count for the current date or initialize it to 0 - const currentCount = completedDays[currentDate] || 0 + const completedDays = { ...streak.completedDays } // Update the count for the current date - completedDays[currentDate] = Math.max(0, currentCount + countChange) - - // Adjust the streak count based on changes to the current day's count - let newCount = count - if (currentCount === 0 && countChange > 0) { - // Increment streak if new positive count for the day - newCount++ - } else if (currentCount > 0 && completedDays[currentDate] === 0) { - // Decrement streak if current day count goes to 0 - newCount = Math.max(0, newCount - 1) - } + completedDays[currentDate] = Math.max( + 0, + (completedDays[currentDate] || 0) + countChange + ) + + const { count, todayCount } = calculateStreakCount(completedDays) return { + ...streak, completedDays, - count: newCount, + count, + todayCount, + } +} + +/** + * Calculates the current streak count based on completedDays. + * @param {import ('@/contexts/UserContext').CompletedDays} completedDays - The user's completed days. + * @returns {{ count: number, todayCount: number }} - The current date and streak count. + */ +export const calculateStreakCount = (completedDays) => { + const today = getChicagoDate() + const todayCount = completedDays[today] || 0 + // If the user completed a task today, the streak is at least 1 + let count = todayCount > 0 ? 1 : 0 + + // Start from yesterday to avoid counting today twice + let currentDate = dayjs(today).subtract(1, 'day').format('YYYY-MM-DD') + while (completedDays[currentDate] > 0) { + count++ + currentDate = dayjs(currentDate).subtract(1, 'day').format('YYYY-MM-DD') } + + return { count, todayCount } }