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 '{}';