Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions backend/src/streak/dto/streak-response.dto.ts
Original file line number Diff line number Diff line change
@@ -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[];
}
13 changes: 13 additions & 0 deletions backend/src/streak/dto/update-streak.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
36 changes: 25 additions & 11 deletions backend/src/streak/entities/streak.entity.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
Expand All @@ -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;
}
67 changes: 67 additions & 0 deletions backend/src/streak/streak.controller.ts
Original file line number Diff line number Diff line change
@@ -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<StreakResponseDto> {
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<StreakResponseDto> {
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 || [],
};
}
}
109 changes: 109 additions & 0 deletions backend/src/streak/streak.service.ts
Original file line number Diff line number Diff line change
@@ -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<Streak>,
) {}

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<Streak> {
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<Streak> {
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);
}
}
48 changes: 27 additions & 21 deletions frontend/app/streak/page.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,36 @@
"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";

export default function StreakPage() {
const router = useRouter();
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);

// 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 {
day: label,
completed: streakDates.includes(dayKey),
};
});
}, [streakDates, timeZone]);

return (
<>
{/* <StreakNavbar streakCount={3} points={1100} /> */}
<StreakScreen
streakCount={4}
weekData={weekData}
onContinue={() => router.push("/dashboard")}
/>
</>
);
return (
<StreakScreen
streakCount={isLoading ? 0 : currentStreak}
weekData={weekData}
onContinue={() => router.push("/dashboard")}
/>
);
}
Loading
Loading