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
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 {}
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);
}
108 changes: 108 additions & 0 deletions frontend/lib/features/streak/streakSlice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"use client";

import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit";
import { fetchStreak, updateStreak, StreakResponseDto } from "../../api/streakApi";

export interface StreakState {
currentStreak: number;
longestStreak: number;
streakDates: string[];
isLoading: boolean;
error: string | null;
}

const initialState: StreakState = {
currentStreak: 0,
longestStreak: 0,
streakDates: [],
isLoading: false,
error: null,
};

export const fetchStreakThunk = createAsyncThunk(
"streak/fetchStreak",
async (_, { rejectWithValue }) => {
try {
const streak = await fetchStreak();
return streak;
} catch (error) {
return rejectWithValue(
error instanceof Error ? error.message : "Failed to fetch streak"
);
}
}
);

export const updateStreakThunk = createAsyncThunk(
"streak/updateStreak",
async (_, { rejectWithValue }) => {
try {
const streak = await updateStreak();
return streak;
} catch (error) {
return rejectWithValue(
error instanceof Error ? error.message : "Failed to update streak"
);
}
}
);

const streakSlice = createSlice({
name: "streak",
initialState,
reducers: {
resetStreak: (state) => {
state.currentStreak = 0;
state.longestStreak = 0;
state.streakDates = [];
state.error = null;
},
clearStreakError: (state) => {
state.error = null;
},
},
extraReducers: (builder) => {
builder
// Fetch streak
.addCase(fetchStreakThunk.pending, (state) => {
state.isLoading = true;
state.error = null;
})
.addCase(
fetchStreakThunk.fulfilled,
(state, action: PayloadAction<StreakResponseDto | null>) => {
state.isLoading = false;
if (action.payload) {
state.currentStreak = action.payload.currentStreak;
state.longestStreak = action.payload.longestStreak;
state.streakDates = action.payload.streakDates || [];
}
}
)
.addCase(fetchStreakThunk.rejected, (state, action) => {
state.isLoading = false;
state.error = action.payload as string;
})
// Update streak
.addCase(updateStreakThunk.pending, (state) => {
state.isLoading = true;
state.error = null;
})
.addCase(
updateStreakThunk.fulfilled,
(state, action: PayloadAction<StreakResponseDto>) => {
state.isLoading = false;
state.currentStreak = action.payload.currentStreak;
state.longestStreak = action.payload.longestStreak;
state.streakDates = action.payload.streakDates || [];
}
)
.addCase(updateStreakThunk.rejected, (state, action) => {
state.isLoading = false;
state.error = action.payload as string;
});
},
});

export const { resetStreak, clearStreakError } = streakSlice.actions;
export default streakSlice.reducer;
2 changes: 2 additions & 0 deletions frontend/lib/store.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { configureStore } from '@reduxjs/toolkit';
import quizReducer from './features/quiz/quizSlice';
import streakReducer from './features/streak/streakSlice';

export const makeStore = () => {
return configureStore({
reducer: {
quiz: quizReducer,
streak: streakReducer,
},
});
};
Expand Down