diff --git a/e2e/freeze.spec.ts b/e2e/freeze.spec.ts new file mode 100644 index 000000000..5352ed194 --- /dev/null +++ b/e2e/freeze.spec.ts @@ -0,0 +1,43 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Streak Freeze Feature', () => { + test.beforeEach(async ({ page }) => { + // Navigate to home and click sign in + await page.goto('/'); + await page.click('text=Sign in with GitHub'); + + // Wait for dashboard to load + await page.waitForURL('/dashboard'); + await page.waitForSelector('text=Streak Protection'); + }); + + test('shows freeze widget with 1 token available', async ({ page }) => { + // Check the widget displays correctly + await expect(page.locator('text=1 token remaining')).toBeVisible(); + await expect(page.locator('text=Available')).toBeVisible(); + await expect(page.locator('button:has-text("Freeze Streak")')).toBeEnabled(); + }); + + test('applies freeze when button is clicked', async ({ page }) => { + // Click the freeze button + await page.click('button:has-text("Freeze Streak")'); + + // Wait for the success message + await expect(page.locator('text=Streak frozen successfully! ❄️')).toBeVisible(); + + // Verify the widget updates + await expect(page.locator('text=0 tokens remaining')).toBeVisible(); + await expect(page.locator('text=Not available')).toBeVisible(); + await expect(page.locator('text=Last Freeze')).toBeVisible(); + }); + + test('shows freeze history after freezing', async ({ page }) => { + // Click freeze + await page.click('button:has-text("Freeze Streak")'); + await page.waitForSelector('text=Streak frozen successfully! ❄️'); + + // Check history section + await expect(page.locator('text=Recent Freezes')).toBeVisible(); + await expect(page.locator('text=Manual freeze')).toBeVisible(); + }); +}); \ No newline at end of file diff --git a/src/app/api/streak/freeze/apply/route.ts b/src/app/api/streak/freeze/apply/route.ts new file mode 100644 index 000000000..2661b9c24 --- /dev/null +++ b/src/app/api/streak/freeze/apply/route.ts @@ -0,0 +1,95 @@ +import { NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { supabaseAdmin } from '@/lib/supabase'; + +export async function POST() { + try { + // Get the authenticated user + const session = await getServerSession(authOptions); + if (!session?.user?.email) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Get user's current freeze data + const { data: profile, error: fetchError } = await supabaseAdmin + .from('profiles') + .select('freeze_tokens_remaining, last_freeze_date, freeze_history') + .eq('email', session.user.email) + .single(); + + if (fetchError) { + console.error('Error fetching profile:', fetchError); + return NextResponse.json( + { error: 'Failed to fetch user profile' }, + { status: 500 } + ); + } + + // Check if user has tokens available + const tokens = profile?.freeze_tokens_remaining ?? 0; + if (tokens <= 0) { + return NextResponse.json( + { error: 'No freeze tokens available' }, + { status: 400 } + ); + } + + // Check if user has already frozen today + const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD + if (profile?.last_freeze_date) { + const lastFreezeDate = new Date(profile.last_freeze_date) + .toISOString() + .split('T')[0]; + if (lastFreezeDate === today) { + return NextResponse.json( + { error: 'You have already frozen today' }, + { status: 400 } + ); + } + } + + // Create freeze history entry + const freezeEntry = { + date: new Date().toISOString(), + reason: 'Manual freeze' + }; + + const currentHistory = profile?.freeze_history || []; + const updatedHistory = [...currentHistory, freezeEntry]; + + // Update the profile + const { data: updatedProfile, error: updateError } = await supabaseAdmin + .from('profiles') + .update({ + freeze_tokens_remaining: tokens - 1, + last_freeze_date: new Date().toISOString(), + freeze_history: updatedHistory + }) + .eq('email', session.user.email) + .select() + .single(); + + if (updateError) { + console.error('Error updating freeze:', updateError); + return NextResponse.json( + { error: 'Failed to apply freeze' }, + { status: 500 } + ); + } + + return NextResponse.json({ + success: true, + message: 'Streak frozen successfully! ❄️', + tokensRemaining: updatedProfile.freeze_tokens_remaining, + lastFreezeDate: updatedProfile.last_freeze_date, + freezeHistory: updatedProfile.freeze_history + }); + } catch (error) { + console.error('Error in apply freeze API:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/app/api/streak/freeze/status/route.ts b/src/app/api/streak/freeze/status/route.ts new file mode 100644 index 000000000..3f8f213ad --- /dev/null +++ b/src/app/api/streak/freeze/status/route.ts @@ -0,0 +1,77 @@ +import { NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { supabaseAdmin } from '@/lib/supabase'; + +export async function GET() { + try { + // Get the authenticated user + const session = await getServerSession(authOptions); + if (!session?.user?.email) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Get user's freeze data from Supabase + const { data: profile, error } = await supabaseAdmin + .from('profiles') + .select('freeze_tokens_remaining, last_freeze_date, freeze_history') + .eq('email', session.user.email) + .single(); + + if (error) { + console.error('Error fetching freeze data:', error); + return NextResponse.json( + { error: 'Failed to fetch freeze status' }, + { status: 500 } + ); + } + + // Calculate next freeze eligibility date (1 month from last freeze) + let nextEligibleDate: Date | null = null; + if (profile?.last_freeze_date) { + const lastDate = new Date(profile.last_freeze_date); + nextEligibleDate = new Date(lastDate.setMonth(lastDate.getMonth() + 1)); + } + + // Check if tokens should refresh (first of month) + const today = new Date(); + const shouldRefresh = today.getDate() === 1 && + profile?.last_freeze_date && + new Date(profile.last_freeze_date).getMonth() !== today.getMonth(); + + // If it's the first of the month and tokens are low, refresh them + let tokens = profile?.freeze_tokens_remaining ?? 1; + if (shouldRefresh) { + tokens = 2; // Refresh to 2 tokens on the first of each month + // Update tokens in database + await supabaseAdmin + .from('profiles') + .update({ freeze_tokens_remaining: tokens }) + .eq('email', session.user.email); + } + + // Calculate if user can freeze + let canFreeze = false; + if (tokens > 0) { + if (!profile?.last_freeze_date) { + canFreeze = true; + } else if (nextEligibleDate) { + canFreeze = new Date() >= nextEligibleDate; + } + } + + return NextResponse.json({ + tokensRemaining: tokens, + lastFreezeDate: profile?.last_freeze_date || null, + nextEligibleDate: nextEligibleDate ? nextEligibleDate.toISOString() : null, + freezeHistory: profile?.freeze_history || [], + canFreeze: canFreeze + }); + } catch (error) { + console.error('Error in freeze status API:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 24986fa52..98fdcd475 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -15,6 +15,7 @@ import { redirect } from "next/navigation"; import DashboardSSEProvider from "@/components/DashboardSSEProvider"; import { DashboardWidgetA11yProvider } from "@/components/dashboard/DashboardWidgetA11yContext"; import RoastHypeWidget from "./RoastHypeWidget"; +import { FreezeStatusWidget } from '@/components/streak/FreezeStatusWidget'; export default async function DashboardPage() { @@ -142,6 +143,17 @@ export default async function DashboardPage() { + {/* Streak Freeze Section - NEW */} +
+
+
+

❄️ Streak Protection

+
+
+ +
+
+
diff --git a/src/components/streak/FreezeStatusWidget.tsx b/src/components/streak/FreezeStatusWidget.tsx new file mode 100644 index 000000000..3ff12bbb7 --- /dev/null +++ b/src/components/streak/FreezeStatusWidget.tsx @@ -0,0 +1,182 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useSession } from 'next-auth/react'; + +interface FreezeStatus { + tokensRemaining: number; + lastFreezeDate: string | null; + nextEligibleDate: string | null; + freezeHistory: Array<{ date: string; reason: string }>; + canFreeze: boolean; +} + +export function FreezeStatusWidget() { + const { data: session } = useSession(); + const [freezeStatus, setFreezeStatus] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [applyingFreeze, setApplyingFreeze] = useState(false); + + useEffect(() => { + if (session) { + fetchFreezeStatus(); + } + }, [session]); + + const fetchFreezeStatus = async () => { + try { + setLoading(true); + const response = await fetch('/api/streak/freeze/status'); + if (!response.ok) { + throw new Error('Failed to fetch freeze status'); + } + const data = await response.json(); + setFreezeStatus(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'An error occurred'); + } finally { + setLoading(false); + } + }; + + const handleApplyFreeze = async () => { + if (!freezeStatus?.canFreeze) return; + + try { + setApplyingFreeze(true); + const response = await fetch('/api/streak/freeze/apply', { + method: 'POST', + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to apply freeze'); + } + + const result = await response.json(); + alert(result.message || 'Streak frozen successfully! ❄️'); + + // Refresh freeze status + await fetchFreezeStatus(); + } catch (err) { + alert(err instanceof Error ? err.message : 'An error occurred'); + } finally { + setApplyingFreeze(false); + } + }; + + if (!session) { + return null; + } + + if (loading) { + return ( +
+
+
+
+
+
+
+
+
+ ); + } + + if (error) { + return ( +
+

Error: {error}

+ +
+ ); + } + + if (!freezeStatus) { + return null; + } + + return ( +
+
+

+ ❄️ Streak Freeze +

+ + {freezeStatus.tokensRemaining} token{freezeStatus.tokensRemaining !== 1 ? 's' : ''} remaining + +
+ +
+
+ Status + + {freezeStatus.canFreeze ? '✅ Available' : '❌ Not available'} + +
+ + {freezeStatus.lastFreezeDate && ( +
+ Last Freeze + + {new Date(freezeStatus.lastFreezeDate).toLocaleDateString()} + +
+ )} + + {freezeStatus.nextEligibleDate && ( +
+ Next Eligibility + + {new Date(freezeStatus.nextEligibleDate).toLocaleDateString()} + +
+ )} + + {freezeStatus.freezeHistory.length > 0 && ( +
+

Recent Freezes

+
+ {freezeStatus.freezeHistory.slice(-3).reverse().map((entry, index) => ( +
+ ❄️ {new Date(entry.date).toLocaleDateString()} + {entry.reason} +
+ ))} +
+
+ )} + + +
+
+ ); +} \ No newline at end of file diff --git a/supabase/migrations/20260630120307_add_freeze_enhancements.sql b/supabase/migrations/20260630120307_add_freeze_enhancements.sql new file mode 100644 index 000000000..4782dd2d9 --- /dev/null +++ b/supabase/migrations/20260630120307_add_freeze_enhancements.sql @@ -0,0 +1,19 @@ +-- Add streak freeze enhancement columns to profiles table +ALTER TABLE profiles +ADD COLUMN IF NOT EXISTS freeze_tokens_remaining INTEGER DEFAULT 1, +ADD COLUMN IF NOT EXISTS last_freeze_date TIMESTAMP WITH TIME ZONE, +ADD COLUMN IF NOT EXISTS freeze_history JSONB DEFAULT '[]'::jsonb; + +-- Add a check constraint to ensure tokens don't go negative +ALTER TABLE profiles +ADD CONSTRAINT freeze_tokens_non_negative +CHECK (freeze_tokens_remaining >= 0); + +-- Create an index for faster queries on freeze data +CREATE INDEX IF NOT EXISTS idx_profiles_freeze_tokens +ON profiles(freeze_tokens_remaining, last_freeze_date); + +-- Add comment for documentation +COMMENT ON COLUMN profiles.freeze_tokens_remaining IS 'Number of streak freeze tokens available to the user (refreshes monthly)'; +COMMENT ON COLUMN profiles.last_freeze_date IS 'Date when the user last used a freeze token'; +COMMENT ON COLUMN profiles.freeze_history IS 'JSON array of freeze events: [{date: timestamp, reason: string}]'; \ No newline at end of file