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
43 changes: 43 additions & 0 deletions e2e/freeze.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
95 changes: 95 additions & 0 deletions src/app/api/streak/freeze/apply/route.ts
Original file line number Diff line number Diff line change
@@ -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 }
);
}
}
77 changes: 77 additions & 0 deletions src/app/api/streak/freeze/status/route.ts
Original file line number Diff line number Diff line change
@@ -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 }
);
}
}
12 changes: 12 additions & 0 deletions src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -142,6 +143,17 @@ export default async function DashboardPage() {
</Link>
</section>

{/* Streak Freeze Section - NEW */}
<section className="mt-6">
<div className="flex items-center gap-3 border-b border-white/10 pb-4 mb-6">
<div className="h-8 w-1.5 rounded-full bg-blue-500 shadow-[0_0_15px_rgba(59,130,246,0.5)]"></div>
<h2 className="text-2xl font-bold tracking-tight">❄️ Streak Protection</h2>
</div>
<div className="max-w-md">
<FreezeStatusWidget />
</div>
</section>

<section>
<MilestonePlanner />
</section>
Expand Down
Loading