From 76f91374f39c0199f14c055190debf381e81f441 Mon Sep 17 00:00:00 2001 From: Bigjoe Date: Tue, 27 Jan 2026 09:04:44 +0100 Subject: [PATCH] Implement streak tracking with persistence and frontend context --- backend/src/streak/dto/streak-response.dto.ts | 23 +++ backend/src/streak/dto/update-streak.dto.ts | 13 ++ backend/src/streak/entities/streak.entity.ts | 36 ++-- backend/src/streak/streak.controller.ts | 67 +++++++ backend/src/streak/streak.service.ts | 109 +++++++++++ backend/src/streak/strerak.module.ts | 6 +- frontend/app/layout.tsx | 11 +- frontend/app/streak/page.tsx | 74 +++++--- frontend/providers/StreakProvider.tsx | 173 ++++++++++++++++++ 9 files changed, 471 insertions(+), 41 deletions(-) create mode 100644 backend/src/streak/dto/streak-response.dto.ts create mode 100644 backend/src/streak/dto/update-streak.dto.ts create mode 100644 backend/src/streak/streak.controller.ts create mode 100644 backend/src/streak/streak.service.ts create mode 100644 frontend/providers/StreakProvider.tsx diff --git a/backend/src/streak/dto/streak-response.dto.ts b/backend/src/streak/dto/streak-response.dto.ts new file mode 100644 index 0000000..e6f3e9c --- /dev/null +++ b/backend/src/streak/dto/streak-response.dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class StreakResponseDto { + @ApiProperty({ example: 3, description: 'Current active streak length in days' }) + currentStreak: number; + + @ApiProperty({ example: 7, description: 'Longest streak achieved' }) + longestStreak: number; + + @ApiProperty({ + example: '2026-01-26', + nullable: true, + description: 'The most recent activity date in YYYY-MM-DD', + }) + lastActivityDate: string | null; + + @ApiProperty({ + type: [String], + example: ['2026-01-23', '2026-01-24', '2026-01-25'], + description: 'List of completion dates recorded for the user', + }) + streakDates: string[]; +} diff --git a/backend/src/streak/dto/update-streak.dto.ts b/backend/src/streak/dto/update-streak.dto.ts new file mode 100644 index 0000000..10c6fc6 --- /dev/null +++ b/backend/src/streak/dto/update-streak.dto.ts @@ -0,0 +1,13 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString } from 'class-validator'; + +export class UpdateStreakDto { + @ApiPropertyOptional({ + description: + 'IANA timezone identifier used to calculate current day (e.g., "America/New_York").', + example: 'America/Los_Angeles', + }) + @IsOptional() + @IsString() + timeZone?: string; +} diff --git a/backend/src/streak/entities/streak.entity.ts b/backend/src/streak/entities/streak.entity.ts index 24ba59e..9edad30 100644 --- a/backend/src/streak/entities/streak.entity.ts +++ b/backend/src/streak/entities/streak.entity.ts @@ -1,5 +1,6 @@ import { Column, + CreateDateColumn, Entity, Index, JoinColumn, @@ -9,33 +10,46 @@ import { } from 'typeorm'; import { User } from '../../users/user.entity'; -@Entity() -@Index(['userId'], { unique: true }) // one streak row per user +/** + * Streak entity for tracking daily activity. Uses snake_case column names to + * align with the existing migration that creates the `daily_streaks` table. + */ +@Entity({ name: 'daily_streaks' }) +@Index(['userId'], { unique: true }) export class Streak { @PrimaryGeneratedColumn() id: number; - @Column() + @Column({ name: 'user_id' }) userId: number; @OneToOne(() => User, (user) => user.streak, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'userId' }) + @JoinColumn({ name: 'user_id' }) user: User; - @Column('int', { default: 0 }) + @Column('int', { name: 'current_streak', default: 0 }) currentStreak: number; - @Column('int', { default: 0 }) + @Column('int', { name: 'longest_streak', default: 0 }) longestStreak: number; - // "YYYY-MM-DD" is often safer than full timestamps for streak logic - @Column('date', { nullable: true }) - lastActivityDate?: string; + // "YYYY-MM-DD" is used for safer cross-timezone streak calculations + @Column('date', { name: 'last_activity_date', nullable: true }) + lastActivityDate?: string | null; // JSON array of date strings: ["2026-01-20", "2026-01-21", ...] - @Column({ type: 'json', default: () => "'[]'" }) + @Column({ type: 'jsonb', name: 'streak_dates', default: () => "'[]'" }) streakDates: string[]; - @UpdateDateColumn() + @Column('int', { + name: 'last_milestone_reached', + nullable: true, + }) + lastMilestoneReached?: number | null; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) updatedAt: Date; } diff --git a/backend/src/streak/streak.controller.ts b/backend/src/streak/streak.controller.ts new file mode 100644 index 0000000..cc4a8d8 --- /dev/null +++ b/backend/src/streak/streak.controller.ts @@ -0,0 +1,67 @@ +import { + Body, + Controller, + Get, + Headers, + HttpCode, + HttpStatus, + Post, +} from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { Auth } from '../auth/decorators/auth.decorator'; +import { authType } from '../auth/enum/auth-type.enum'; +import { ActiveUser } from '../auth/decorators/activeUser.decorator'; +import { StreakService } from './streak.service'; +import { StreakResponseDto } from './dto/streak-response.dto'; +import { UpdateStreakDto } from './dto/update-streak.dto'; + +@Controller('streaks') +@ApiTags('Streaks') +export class StreakController { + constructor(private readonly streakService: StreakService) {} + + @Get() + @Auth(authType.Bearer) + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Fetch current streak data' }) + @ApiResponse({ status: 200, type: StreakResponseDto }) + async getStreak( + @ActiveUser('sub') userId: string, + ): Promise { + const streak = await this.streakService.getStreak(Number(userId)); + return this.mapToResponse(streak); + } + + @Post('update') + @Auth(authType.Bearer) + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Update streak after completing today’s quest/action', + description: + 'Increments streak if yesterday was completed, resets if a day was missed, and prevents duplicate updates in the same day.', + }) + @ApiResponse({ status: 200, type: StreakResponseDto }) + async updateStreak( + @ActiveUser('sub') userId: string, + @Body() updateDto: UpdateStreakDto, + @Headers('x-timezone') timezoneHeader?: string, + ): Promise { + const tz = updateDto.timeZone || timezoneHeader; + const streak = await this.streakService.updateStreak(Number(userId), tz); + return this.mapToResponse(streak); + } + + private mapToResponse(streak: { + currentStreak: number; + longestStreak: number; + lastActivityDate?: string | null; + streakDates?: string[]; + }): StreakResponseDto { + return { + currentStreak: streak.currentStreak || 0, + longestStreak: streak.longestStreak || 0, + lastActivityDate: streak.lastActivityDate || null, + streakDates: streak.streakDates || [], + }; + } +} diff --git a/backend/src/streak/streak.service.ts b/backend/src/streak/streak.service.ts new file mode 100644 index 0000000..2a44217 --- /dev/null +++ b/backend/src/streak/streak.service.ts @@ -0,0 +1,109 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Streak } from './entities/streak.entity'; + +interface DateContext { + today: string; + yesterday: string; +} + +@Injectable() +export class StreakService { + private readonly logger = new Logger(StreakService.name); + + constructor( + @InjectRepository(Streak) + private readonly streakRepository: Repository, + ) {} + + private getDateContext(timeZone?: string): DateContext { + const now = this.getDateInTimezone(timeZone); + + const today = this.formatDate(now); + const yesterdayDate = new Date(now); + yesterdayDate.setDate(now.getDate() - 1); + const yesterday = this.formatDate(yesterdayDate); + + return { today, yesterday }; + } + + private getDateInTimezone(timeZone?: string): Date { + if (!timeZone) { + return new Date(); + } + + // Convert to the provided timezone, then back to Date for ISO formatting + const localizedString = new Date().toLocaleString('en-US', { timeZone }); + return new Date(localizedString); + } + + private formatDate(date: Date): string { + return date.toISOString().split('T')[0]; + } + + private normalizeDates(dates: string[]): string[] { + const unique = Array.from(new Set(dates)); + return unique.sort(); + } + + async getStreak(userId: number): Promise { + let streak = await this.streakRepository.findOne({ where: { userId } }); + + if (!streak) { + const blankStreak = this.streakRepository.create({ + userId, + currentStreak: 0, + longestStreak: 0, + streakDates: [], + lastActivityDate: null, + }); + streak = await this.streakRepository.save(blankStreak); + } + + return streak; + } + + async updateStreak(userId: number, timeZone?: string): Promise { + const { today, yesterday } = this.getDateContext(timeZone); + + let streak = await this.streakRepository.findOne({ where: { userId } }); + + if (!streak) { + streak = this.streakRepository.create({ + userId, + currentStreak: 1, + longestStreak: 1, + lastActivityDate: today, + streakDates: [today], + }); + return this.streakRepository.save(streak); + } + + // If already updated today, avoid double counting + if (streak.lastActivityDate === today) { + this.logger.debug(`User ${userId} already updated streak for ${today}`); + return streak; + } + + // Determine if yesterday was the last activity + const continuedFromYesterday = streak.lastActivityDate === yesterday; + + const newCurrentStreak = continuedFromYesterday ? streak.currentStreak + 1 : 1; + const updatedStreakDates = continuedFromYesterday + ? this.normalizeDates([...(streak.streakDates || []), today]) + : [today]; + + const longestStreak = + newCurrentStreak > (streak.longestStreak || 0) + ? newCurrentStreak + : streak.longestStreak || 0; + + streak.currentStreak = newCurrentStreak; + streak.longestStreak = longestStreak; + streak.lastActivityDate = today; + streak.streakDates = updatedStreakDates; + + return this.streakRepository.save(streak); + } +} diff --git a/backend/src/streak/strerak.module.ts b/backend/src/streak/strerak.module.ts index 6682d6f..810b802 100644 --- a/backend/src/streak/strerak.module.ts +++ b/backend/src/streak/strerak.module.ts @@ -1,9 +1,13 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Streak } from './entities/streak.entity'; +import { StreakService } from './streak.service'; +import { StreakController } from './streak.controller'; @Module({ imports: [TypeOrmModule.forFeature([Streak])], - exports: [TypeOrmModule], + controllers: [StreakController], + providers: [StreakService], + exports: [TypeOrmModule, StreakService], }) export class StreakModule {} diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 6ad9a15..1518c58 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -4,6 +4,7 @@ import './globals.css'; import { ToastProvider } from '@/components/ui/ToastProvider'; import StoreProvider from '@/providers/storeProvider'; import SideNav from "@/components/SideNav"; +import { StreakProvider } from '@/providers/StreakProvider'; const geistSans = Geist({ variable: '--font-geist-sans', @@ -32,10 +33,12 @@ export default function RootLayout({ > -
- -
{children}
-
+ +
+ +
{children}
+
+
diff --git a/frontend/app/streak/page.tsx b/frontend/app/streak/page.tsx index 90a26be..c243131 100644 --- a/frontend/app/streak/page.tsx +++ b/frontend/app/streak/page.tsx @@ -1,31 +1,55 @@ "use client"; + +import { useMemo } from "react"; +import { useRouter } from "next/navigation"; import { StreakScreen } from "@/components/StreakScreen"; import { DayData } from "@/components/WeeklyCalendar"; -import { useRouter } from "next/navigation"; -import StreakNavbar from "@/components/StreakNavbar"; +import { useStreak } from "@/providers/StreakProvider"; + +const WEEKDAY_LABELS = ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"]; + +function toZonedDate(date: Date, timeZone: string) { + const localized = date.toLocaleString("en-US", { timeZone }); + return new Date(localized); +} + +function isoDateString(date: Date) { + return date.toISOString().split("T")[0]; +} + +function startOfWeekMonday(date: Date) { + const jsDay = date.getDay(); // 0 = Sunday + const diffToMonday = (jsDay + 6) % 7; // convert Sunday=0 -> 6, Monday=1 ->0 + const start = new Date(date); + start.setDate(date.getDate() - diffToMonday); + return start; +} export default function StreakPage() { - const router = useRouter(); - - // Sample data matching the design (4-day streak) - const weekData: DayData[] = [ - { day: "MON", completed: true }, - { day: "TUE", completed: true }, - { day: "WED", completed: true }, - { day: "THU", completed: true }, - { day: "FRI", completed: false }, - { day: "SAT", completed: false }, - { day: "SUN", completed: false }, - ]; - - return ( - <> - {/* */} - router.push("/dashboard")} - /> - - ); + const router = useRouter(); + const { currentStreak, streakDates, isLoading, timeZone } = useStreak(); + + const weekData: DayData[] = useMemo(() => { + const zonedNow = toZonedDate(new Date(), timeZone); + const weekStart = startOfWeekMonday(zonedNow); + + return WEEKDAY_LABELS.map((label, index) => { + const dayDate = new Date(weekStart); + dayDate.setDate(weekStart.getDate() + index); + const dayKey = isoDateString(dayDate); + + return { + day: label, + completed: streakDates.includes(dayKey), + }; + }); + }, [streakDates, timeZone]); + + return ( + router.push("/dashboard")} + /> + ); } diff --git a/frontend/providers/StreakProvider.tsx b/frontend/providers/StreakProvider.tsx new file mode 100644 index 0000000..617ae20 --- /dev/null +++ b/frontend/providers/StreakProvider.tsx @@ -0,0 +1,173 @@ +"use client"; + +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; + +type StreakContextValue = { + currentStreak: number; + longestStreak: number; + streakDates: string[]; + lastActivityDate: string | null; + isLoading: boolean; + updateStreak: () => Promise; + refetch: () => Promise; + timeZone: string; +}; + +type StreakState = { + currentStreak: number; + longestStreak: number; + streakDates: string[]; + lastActivityDate: string | null; +}; + +const DEFAULT_STATE: StreakState = { + currentStreak: 0, + longestStreak: 0, + streakDates: [], + lastActivityDate: null, +}; + +const API_BASE = + process.env.NEXT_PUBLIC_API_BASE_URL || + "https://mindblock-webaapp.onrender.com"; + +const StreakContext = createContext(undefined); + +export const StreakProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const timeZone = useMemo( + () => Intl.DateTimeFormat().resolvedOptions().timeZone, + [], + ); + const [state, setState] = useState(DEFAULT_STATE); + const [isLoading, setIsLoading] = useState(true); + const mountedRef = useRef(true); + + const applyStreak = useCallback( + (payload: StreakState) => { + if (!mountedRef.current) return; + setState(payload); + setIsLoading(false); + }, + [setState], + ); + + const fetchStreak = useCallback(async () => { + const token = typeof window !== "undefined" + ? localStorage.getItem("accessToken") + : null; + + if (!token) { + applyStreak(DEFAULT_STATE); + return; + } + + setIsLoading(true); + + try { + const response = await fetch(`${API_BASE}/streaks`, { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "X-Timezone": timeZone, + }, + cache: "no-store", + }); + + if (!response.ok) { + throw new Error(`Failed to fetch streak: ${response.status}`); + } + + const data = await response.json(); + applyStreak({ + currentStreak: data.currentStreak ?? 0, + longestStreak: data.longestStreak ?? 0, + streakDates: data.streakDates ?? [], + lastActivityDate: data.lastActivityDate ?? null, + }); + } catch (error) { + console.error("Failed to fetch streak", error); + applyStreak(DEFAULT_STATE); + } + }, [applyStreak, timeZone]); + + const updateStreak = useCallback(async () => { + const token = typeof window !== "undefined" + ? localStorage.getItem("accessToken") + : null; + + if (!token) { + console.warn("Attempted to update streak without a token"); + return; + } + + setIsLoading(true); + + try { + const response = await fetch(`${API_BASE}/streaks/update`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "X-Timezone": timeZone, + }, + body: JSON.stringify({ timeZone }), + }); + + if (!response.ok) { + throw new Error(`Failed to update streak: ${response.status}`); + } + + const data = await response.json(); + applyStreak({ + currentStreak: data.currentStreak ?? 0, + longestStreak: data.longestStreak ?? 0, + streakDates: data.streakDates ?? [], + lastActivityDate: data.lastActivityDate ?? null, + }); + } catch (error) { + console.error("Failed to update streak", error); + setIsLoading(false); + } + }, [applyStreak, timeZone]); + + useEffect(() => { + mountedRef.current = true; + fetchStreak(); + return () => { + mountedRef.current = false; + }; + }, [fetchStreak]); + + const value = useMemo( + () => ({ + ...state, + isLoading, + updateStreak, + refetch: fetchStreak, + timeZone, + }), + [state, isLoading, updateStreak, fetchStreak, timeZone], + ); + + return ( + {children} + ); +}; + +export const useStreak = (): StreakContextValue => { + const ctx = useContext(StreakContext); + if (!ctx) { + throw new Error("useStreak must be used within a StreakProvider"); + } + return ctx; +};