diff --git a/app/(app)/shop/page.tsx b/app/(app)/shop/page.tsx index 5a16088..7754210 100644 --- a/app/(app)/shop/page.tsx +++ b/app/(app)/shop/page.tsx @@ -1,34 +1,5 @@ import { AppShell } from "@/components/layout/app-shell"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; - -const shopItems = [ - { - id: "hat", - name: "Pixel cap", - price: 50, - description: "A cozy cap for your habit pet.", - }, - { - id: "bed", - name: "Cloud bed", - price: 120, - description: "Boost recovery after tough days.", - }, - { - id: "snack", - name: "Berry snack", - price: 25, - description: "A small treat for good streaks.", - }, -]; +import { ShopContent } from "@/components/shop/shop-content"; export default function ShopPage() { return ( @@ -36,31 +7,7 @@ export default function ShopPage() { title="Shop" description="Spend points earned from habits and daily check-ins." > -
- Your balance - 120 points -
- -
- {shopItems.map((item) => ( - - -
-
- {item.name} - {item.description} -
- {item.price} pts -
-
- - - -
- ))} -
+ ); } diff --git a/components/habits/habit-tracker.tsx b/components/habits/habit-tracker.tsx index 92dca98..6868c11 100644 --- a/components/habits/habit-tracker.tsx +++ b/components/habits/habit-tracker.tsx @@ -28,6 +28,7 @@ import { toggleHabitCompletion, type Habit, } from "@/lib/habits-storage"; +import { formatCoinsDelta } from "@/lib/coins"; import { getProfilePreferences } from "@/lib/profile-preferences-storage"; import { hasCompletedDailyQuizToday } from "@/lib/daily-quiz-storage"; @@ -187,6 +188,7 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) { () => new Set(), ); const [toggleErrors, setToggleErrors] = useState>({}); + const [coinMessage, setCoinMessage] = useState(null); const getIsCompleted = useCallback( (habit: DailyTask) => { @@ -231,6 +233,20 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) { } }, []); + useEffect(() => { + if (!coinMessage) { + return; + } + + const timeoutId = window.setTimeout(() => { + setCoinMessage(null); + }, 3000); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [coinMessage]); + useEffect(() => { void refresh(); @@ -262,7 +278,8 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) { void (async () => { try { - const updatedHabits = await toggleHabitCompletion(habitId); + const { habits: updatedHabits, coinsDelta } = + await toggleHabitCompletion(habitId); setDailyTasks((current) => current.map((task) => { @@ -275,6 +292,7 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) { delete next[habitId]; return next; }); + setCoinMessage(formatCoinsDelta(coinsDelta)); } catch (toggleError) { setOptimisticCompletion((current) => { const next = { ...current }; @@ -416,6 +434,9 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) { {error ?

{error}

: null} + {coinMessage ? ( +

{coinMessage}

+ ) : null} (null); + const [error, setError] = useState(null); + + const refreshCoins = useCallback(async () => { + try { + setError(null); + setCoins(await getCoins()); + } catch (refreshError) { + setError( + refreshError instanceof Error + ? refreshError.message + : "Could not load your balance.", + ); + setCoins(0); + } + }, []); + + useEffect(() => { + void refreshCoins(); + + window.addEventListener(HABIT_PET_DATA_UPDATED_EVENT, refreshCoins); + return () => { + window.removeEventListener(HABIT_PET_DATA_UPDATED_EVENT, refreshCoins); + }; + }, [refreshCoins]); + + return ( + <> +
+ Your balance + + {coins === null ? "..." : `${coins} points`} + +
+ + {error ?

{error}

: null} + +
+ {shopItems.map((item) => { + const canAfford = coins !== null && coins >= item.price; + + return ( + + +
+
+ {item.name} + {item.description} +
+ {item.price} pts +
+
+ + + +
+ ); + })} +
+ + ); +} diff --git a/lib/avatar-progression-storage.ts b/lib/avatar-progression-storage.ts new file mode 100644 index 0000000..d083bb4 --- /dev/null +++ b/lib/avatar-progression-storage.ts @@ -0,0 +1,74 @@ +import { createClient } from "@/lib/supabase/client"; + +async function getAuthenticatedUserId() { + const supabase = createClient(); + const { + data: { user }, + error, + } = await supabase.auth.getUser(); + + if (error || !user) { + return null; + } + + return user.id; +} + +export async function getCoins(): Promise { + const userId = await getAuthenticatedUserId(); + + if (!userId) { + return 0; + } + + const supabase = createClient(); + const { data, error } = await supabase + .from("avatar_state") + .select("coins") + .eq("user_id", userId) + .maybeSingle(); + + if (error) { + throw new Error(error.message); + } + + if (!data) { + const { error: insertError } = await supabase + .from("avatar_state") + .insert({ user_id: userId, coins: 0 }); + + if (insertError) { + throw new Error(insertError.message); + } + + return 0; + } + + return data.coins ?? 0; +} + +export async function adjustCoins(delta: number): Promise { + const userId = await getAuthenticatedUserId(); + + if (!userId) { + throw new Error("You must be signed in to earn points."); + } + + const currentCoins = await getCoins(); + const nextCoins = Math.max(0, currentCoins + delta); + + const supabase = createClient(); + const { error } = await supabase + .from("avatar_state") + .update({ + coins: nextCoins, + updated_at: new Date().toISOString(), + }) + .eq("user_id", userId); + + if (error) { + throw new Error(error.message); + } + + return nextCoins; +} diff --git a/lib/coins.ts b/lib/coins.ts new file mode 100644 index 0000000..8781247 --- /dev/null +++ b/lib/coins.ts @@ -0,0 +1,28 @@ +export const COINS_PER_TASK_COMPLETE = 50; +export const COINS_PER_TASK_UNCHECK = -50; + +export const STREAK_MILESTONE_COINS: Record = { + 5: 50, + 7: 75, + 10: 100, + 20: 150, + 30: 200, + 50: 350, + 100: 750, +}; + +export const STREAK_MILESTONES = Object.keys(STREAK_MILESTONE_COINS) + .map(Number) + .sort((a, b) => a - b); + +export function getStreakMilestoneBonus(streak: number) { + return STREAK_MILESTONE_COINS[streak] ?? null; +} + +export function formatCoinsDelta(delta: number) { + if (delta === 0) { + return "0 points"; + } + + return `${delta > 0 ? "+" : ""}${delta} points`; +} diff --git a/lib/habits-storage.ts b/lib/habits-storage.ts index ff3f52f..824c5f0 100644 --- a/lib/habits-storage.ts +++ b/lib/habits-storage.ts @@ -4,7 +4,13 @@ import { getCatalogIdByLabel, isCatalogLabel, } from "@/lib/habit-catalog"; +import { adjustCoins } from "@/lib/avatar-progression-storage"; import { notifyHabitPetDataUpdated } from "@/lib/app-events"; +import { + COINS_PER_TASK_COMPLETE, + COINS_PER_TASK_UNCHECK, + getStreakMilestoneBonus, +} from "@/lib/coins"; import { createClient } from "@/lib/supabase/client"; export type Habit = { @@ -16,13 +22,22 @@ export type Habit = { isCustom: boolean; }; +export type ToggleHabitResult = { + habits: Habit[]; + coinsDelta: number; +}; + type HabitRow = { id: string; name: string; streak: number; last_completed_on: string | null; + claimed_streak_milestones?: number[]; }; +const habitSelectFields = + "id, name, streak, last_completed_on, claimed_streak_milestones"; + export function getTodayDateKey(date = new Date()) { return date.toISOString().slice(0, 10); } @@ -81,7 +96,7 @@ async function findActiveHabitByName( const supabase = createClient(); const { data, error } = await supabase .from("habits") - .select("id, name, streak, last_completed_on") + .select(habitSelectFields) .eq("user_id", userId) .eq("name", name) .eq("active", true) @@ -125,13 +140,16 @@ async function reconcileMissedDayStreaks( await Promise.all( habitsToReset.map((habit) => - supabase.from("habits").update({ streak: 0 }).eq("id", habit.id), + supabase + .from("habits") + .update({ streak: 0, claimed_streak_milestones: [] }) + .eq("id", habit.id), ), ); return habits.map((habit) => habitsToReset.some((item) => item.id === habit.id) - ? { ...habit, streak: 0 } + ? { ...habit, streak: 0, claimed_streak_milestones: [] } : habit, ); } @@ -172,7 +190,7 @@ export async function getHabits(): Promise { const supabase = createClient(); const { data, error } = await supabase .from("habits") - .select("id, name, streak, last_completed_on") + .select(habitSelectFields) .eq("user_id", userId) .eq("active", true) .order("created_at", { ascending: true }); @@ -213,7 +231,7 @@ export async function ensureCatalogHabit(catalogId: string): Promise { +export async function toggleHabitCompletion( + habitId: string, +): Promise { const userId = await getAuthenticatedUserId(); if (!userId) { - return []; + return { habits: [], coinsDelta: 0 }; } const supabase = createClient(); @@ -243,7 +263,7 @@ export async function toggleHabitCompletion(habitId: string): Promise { const { data: habit, error: habitError } = await supabase .from("habits") - .select("id, name, streak, last_completed_on") + .select(habitSelectFields) .eq("id", habitId) .eq("user_id", userId) .maybeSingle(); @@ -271,6 +291,7 @@ export async function toggleHabitCompletion(habitId: string): Promise { } const existingLog = getFirstRow(existingLogs as { id: string }[] | null); + let coinsDelta = 0; if (existingLog) { const { error: deleteError } = await supabase @@ -299,6 +320,8 @@ export async function toggleHabitCompletion(habitId: string): Promise { if (updateError) { throw new Error(updateError.message); } + + coinsDelta = COINS_PER_TASK_UNCHECK; } else { const { error: insertError } = await supabase.from("habit_logs").insert({ user_id: userId, @@ -311,12 +334,20 @@ export async function toggleHabitCompletion(habitId: string): Promise { } const nextStreak = currentStreak + 1; + const claimedMilestones = reconciledHabit.claimed_streak_milestones ?? []; + const milestoneBonus = getStreakMilestoneBonus(nextStreak); + const earnedMilestoneBonus = + milestoneBonus !== null && !claimedMilestones.includes(nextStreak); + const nextClaimedMilestones = earnedMilestoneBonus + ? [...claimedMilestones, nextStreak] + : claimedMilestones; const { error: updateError } = await supabase .from("habits") .update({ streak: nextStreak, last_completed_on: today, + claimed_streak_milestones: nextClaimedMilestones, }) .eq("id", habitId) .eq("user_id", userId); @@ -324,10 +355,15 @@ export async function toggleHabitCompletion(habitId: string): Promise { if (updateError) { throw new Error(updateError.message); } + + coinsDelta = + COINS_PER_TASK_COMPLETE + (earnedMilestoneBonus ? milestoneBonus : 0); } + await adjustCoins(coinsDelta); + notifyHabitPetDataUpdated(); - return getHabits(); + return { habits: await getHabits(), coinsDelta }; } export async function addHabit(label: string): Promise { diff --git a/supabase/migrations/20260524170000_habit_streak_milestones.sql b/supabase/migrations/20260524170000_habit_streak_milestones.sql new file mode 100644 index 0000000..6aa7cfc --- /dev/null +++ b/supabase/migrations/20260524170000_habit_streak_milestones.sql @@ -0,0 +1,2 @@ +alter table public.habits + add column if not exists claimed_streak_milestones int[] not null default '{}';