Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(streak-count): Fix streak count calculation #28

Merged
merged 8 commits into from
Nov 8, 2024
9 changes: 8 additions & 1 deletion src/contexts/UserContext.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,17 @@ import { createContext, useContext, useEffect, useState } from 'react'
* @typedef {Object<string, Goal>} 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.<string, number>} 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.
*/

/**
Expand Down
37 changes: 3 additions & 34 deletions src/hooks/useGoalsUpdater.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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 () => {
Expand Down
9 changes: 4 additions & 5 deletions src/hooks/useGoalsUpdater/updateHelpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
56 changes: 31 additions & 25 deletions src/pages/Home.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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()
})
})
4 changes: 4 additions & 0 deletions src/pages/Streak.jsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// @ts-check

import { useUser } from '@/contexts/UserContext'
import '@/styles/StreakPage.css'
import { getChicagoDate } from '@/utils/streakUtils'
Expand Down Expand Up @@ -66,9 +68,11 @@ const Streak = () => {

<Box sx={{ display: 'flex', justifyContent: 'center', mt: 3 }}>
<Calendar
tileDisabled={() => true}
tileContent={({ date, view }) =>
view === 'month' ? getTileIcon(date) : null
}
className='custom-calendar'
/>
</Box>
</Box>
Expand Down
49 changes: 27 additions & 22 deletions src/styles/StreakPage.css
Original file line number Diff line number Diff line change
@@ -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);
}
}
/* Animation for FireIcon, creating a pulsing "burn" effect */
@keyframes burn-animation {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.2);
}
}
21 changes: 11 additions & 10 deletions src/utils/firebase/createUserProfile.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// @ts-check

import { db } from '@/utils/firebaseConfig'
import { calculateStreakCount } from '@/utils/streakUtils'
import { doc, getDoc, setDoc, updateDoc } from 'firebase/firestore'

/**
Expand All @@ -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
Expand Down Expand Up @@ -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<object|null>} - 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.
Expand Down
86 changes: 44 additions & 42 deletions src/utils/streakUtils.js
Original file line number Diff line number Diff line change
@@ -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.<string, number>} 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.<string, number>, 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 }
}
Loading