Skip to content
Merged
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
25 changes: 25 additions & 0 deletions backend/src/streak/providers/streaks.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Injectable } from '@nestjs/common';
import { UpdateStreakProvider } from './update-streak.provider';
import { Streak } from '../entities/streak.entity';

@Injectable()
export class StreaksService {
constructor(
private readonly updateStreakProvider: UpdateStreakProvider,
) {}

/**
* Get user's current streak
*/
async getStreak(userId: number): Promise<Streak | null> {
return this.updateStreakProvider.getStreak(userId);
}

/**
* Update streak after daily quest completion
* Handles increment, reset, and longest streak tracking
*/
async updateStreak(userId: number): Promise<Streak> {
return this.updateStreakProvider.updateStreak(userId);
}
}
54 changes: 54 additions & 0 deletions backend/src/streak/streaks.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { Controller, Get, Post, UnauthorizedException } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { StreaksService } from './providers/streaks.service';
import { Streak } from './entities/streak.entity';
import { ActiveUser } from '../auth/decorators/activeUser.decorator';
import { ActiveUserData } from '../auth/interfaces/activeInterface';
import { Auth } from '../auth/decorators/auth.decorator';
import { authType } from '../auth/enum/auth-type.enum';

@ApiTags('streaks')
@Controller('streaks')
export class StreaksController {
constructor(private readonly streaksService: StreaksService) {}

@Get()
@Auth(authType.Bearer)
@ApiOperation({ summary: 'Get current user streak' })
@ApiResponse({
status: 200,
description: 'User streak retrieved successfully',
type: Streak,
})
@ApiResponse({
status: 401,
description: 'Unauthorized',
})
async getStreak(@ActiveUser() user: ActiveUserData): Promise<Streak | null> {
if (!user?.sub) {
throw new UnauthorizedException('User not authenticated');
}
const userId = parseInt(user.sub, 10);
return this.streaksService.getStreak(userId);
}

@Post('update')
@Auth(authType.Bearer)
@ApiOperation({ summary: 'Update streak after daily quest completion' })
@ApiResponse({
status: 200,
description: 'Streak updated successfully',
type: Streak,
})
@ApiResponse({
status: 401,
description: 'Unauthorized',
})
async updateStreak(@ActiveUser() user: ActiveUserData): Promise<Streak> {
if (!user?.sub) {
throw new UnauthorizedException('User not authenticated');
}
const userId = parseInt(user.sub, 10);
return this.streaksService.updateStreak(userId);
}
}
7 changes: 5 additions & 2 deletions backend/src/streak/strerak.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Streak } from './entities/streak.entity';
import { UpdateStreakProvider } from './providers/update-streak.provider';
import { StreaksService } from './providers/streaks.service';
import { StreaksController } from './streaks.controller';

@Module({
imports: [TypeOrmModule.forFeature([Streak])],
providers: [UpdateStreakProvider],
exports: [TypeOrmModule, UpdateStreakProvider],
controllers: [StreaksController],
providers: [UpdateStreakProvider, StreaksService],
exports: [TypeOrmModule, UpdateStreakProvider, StreaksService],
})
export class StreakModule {}
45 changes: 33 additions & 12 deletions frontend/app/streak/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,47 @@
import { StreakScreen } from "@/components/StreakScreen";
import { DayData } from "@/components/WeeklyCalendar";
import { useRouter } from "next/navigation";
import { useStreak } from "@/hooks/useStreak";
import { useMemo } from "react";

const DAYS = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"];

function getWeekData(streakDates: string[]): DayData[] {
const today = new Date();
const currentDay = today.getDay(); // 0 = Sunday, 1 = Monday, etc.

// Build array for the current week (Sun-Sat)
return DAYS.map((day, index) => {
// Calculate date for this day of the week
const dayDate = new Date(today);
dayDate.setDate(today.getDate() - (currentDay - index));
const dateString = dayDate.toISOString().split("T")[0];

return {
day,
completed: streakDates.includes(dateString),
};
});
}

export default function StreakPage() {
const router = useRouter();
const { currentStreak, streakDates, isLoading } = useStreak({ autoFetch: true });

const weekData = useMemo(() => getWeekData(streakDates), [streakDates]);

// 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 },
];
if (isLoading) {
return (
<div className="min-h-screen bg-[#050C16] flex items-center justify-center">
<div className="text-white">Loading streak...</div>
</div>
);
}

return (
<>
{/* <StreakNavbar streakCount={3} points={1100} /> */}
<StreakScreen
streakCount={4}
streakCount={currentStreak}
weekData={weekData}
onContinue={() => router.push("/dashboard")}
/>
Expand Down
64 changes: 64 additions & 0 deletions frontend/hooks/useStreak.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"use client";

import { useCallback, useEffect } from "react";
import { useAppDispatch, useAppSelector } from "../lib/reduxHooks";
import {
fetchStreakThunk,
updateStreakThunk,
resetStreak,
clearStreakError,
} from "../lib/features/streak/streakSlice";

export interface UseStreakOptions {
autoFetch?: boolean;
}

export function useStreak(options: UseStreakOptions = {}) {
const { autoFetch = true } = options;

const dispatch = useAppDispatch();
const streakState = useAppSelector((state) => state.streak);

const {
currentStreak,
longestStreak,
streakDates,
isLoading,
error,
} = streakState;

// Auto-fetch streak on mount if requested
useEffect(() => {
if (autoFetch) {
dispatch(fetchStreakThunk());
}
}, [autoFetch, dispatch]);

const fetchStreak = useCallback(() => {
dispatch(fetchStreakThunk());
}, [dispatch]);

const updateStreak = useCallback(async () => {
await dispatch(updateStreakThunk()).unwrap();
}, [dispatch]);

const clearError = useCallback(() => {
dispatch(clearStreakError());
}, [dispatch]);

const reset = useCallback(() => {
dispatch(resetStreak());
}, [dispatch]);

return {
currentStreak,
longestStreak,
streakDates,
isLoading,
error,
fetchStreak,
updateStreak,
clearError,
reset,
};
}
75 changes: 75 additions & 0 deletions frontend/lib/api/streakApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
export interface StreakResponseDto {
id: number;
userId: number;
currentStreak: number;
longestStreak: number;
lastActivityDate?: string;
streakDates: string[];
updatedAt: string;
}

const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? "";

function getAuthHeaders(): Record<string, string> {
if (typeof window === "undefined") {
return {};
}

const token = window.localStorage.getItem("accessToken");

if (!token) {
return {};
}

return {
Authorization: `Bearer ${token}`,
};
}

async function handleResponse<T>(response: Response): Promise<T> {
const contentType = response.headers.get("Content-Type");
const isJson = contentType && contentType.includes("application/json");

const data = isJson ? await response.json() : null;

if (!response.ok) {
const message =
(data && (data.message as string | undefined)) ||
`Request failed with status ${response.status}`;
throw new Error(message);
}

return data as T;
}

export async function fetchStreak(): Promise<StreakResponseDto | null> {
const headers: HeadersInit = {
"Content-Type": "application/json",
...getAuthHeaders(),
};

const response = await fetch(`${API_BASE_URL}/streaks`, {
method: "GET",
headers,
});

if (response.status === 404) {
return null;
}

return handleResponse<StreakResponseDto>(response);
}

export async function updateStreak(): Promise<StreakResponseDto> {
const headers: HeadersInit = {
"Content-Type": "application/json",
...getAuthHeaders(),
};

const response = await fetch(`${API_BASE_URL}/streaks/update`, {
method: "POST",
headers,
});

return handleResponse<StreakResponseDto>(response);
}
Loading