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/daily-quiz-storage.ts b/lib/daily-quiz-storage.ts
index fa30185..b1df525 100644
--- a/lib/daily-quiz-storage.ts
+++ b/lib/daily-quiz-storage.ts
@@ -69,17 +69,20 @@ export async function getDailyEntryForToday(): Promise(rows: T[] | null | undefined) {
+ return rows?.[0] ?? null;
+}
+
+function dedupeHabitsByName(rows: HabitRow[]) {
+ const seen = new Map();
+
+ for (const row of rows) {
+ const key = row.name.trim().toLowerCase();
+
+ if (!seen.has(key)) {
+ seen.set(key, row);
+ }
+ }
+
+ return Array.from(seen.values());
+}
+
+async function findActiveHabitByName(
+ userId: string,
+ name: string,
+): Promise {
+ const supabase = createClient();
+ const { data, error } = await supabase
+ .from("habits")
+ .select(habitSelectFields)
+ .eq("user_id", userId)
+ .eq("name", name)
+ .eq("active", true)
+ .order("created_at", { ascending: true });
+
+ if (error) {
+ throw new Error(error.message);
+ }
+
+ return getFirstRow(data as HabitRow[] | null);
+}
+
async function getAuthenticatedUserId() {
const supabase = createClient();
const {
@@ -87,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,
);
}
@@ -111,14 +167,13 @@ async function getPreviousCompletedDate(
.eq("habit_id", habitId)
.neq("completed_on", today)
.order("completed_on", { ascending: false })
- .limit(1)
- .maybeSingle();
+ .limit(1);
if (error) {
throw new Error(error.message);
}
- return data?.completed_on ?? null;
+ return getFirstRow(data as { completed_on: string }[] | null)?.completed_on ?? null;
}
export function isHabitCompletedToday(habit: Habit, today = getTodayDateKey()) {
@@ -135,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 });
@@ -144,7 +199,9 @@ export async function getHabits(): Promise {
throw new Error(error.message);
}
- const rows = await reconcileMissedDayStreaks((data ?? []) as HabitRow[]);
+ const rows = await reconcileMissedDayStreaks(
+ dedupeHabitsByName((data ?? []) as HabitRow[]),
+ );
return rows.map(mapHabitRow);
}
@@ -163,19 +220,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();
@@ -212,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();
@@ -226,25 +277,29 @@ export async function toggleHabitCompletion(habitId: string): Promise {
}
const [reconciledHabit] = await reconcileMissedDayStreaks([habit as HabitRow]);
- let currentStreak = reconciledHabit.streak;
+ const currentStreak = reconciledHabit.streak;
- const { data: existingLog, error: existingLogError } = await supabase
+ const { data: existingLogs, error: existingLogError } = await supabase
.from("habit_logs")
.select("id")
.eq("user_id", userId)
.eq("habit_id", habitId)
- .eq("completed_on", today)
- .maybeSingle();
+ .eq("completed_on", today);
if (existingLogError) {
throw new Error(existingLogError.message);
}
+ const existingLog = getFirstRow(existingLogs as { id: string }[] | null);
+ let coinsDelta = 0;
+
if (existingLog) {
const { error: deleteError } = await supabase
.from("habit_logs")
.delete()
- .eq("id", existingLog.id);
+ .eq("user_id", userId)
+ .eq("habit_id", habitId)
+ .eq("completed_on", today);
if (deleteError) {
throw new Error(deleteError.message);
@@ -265,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,
@@ -277,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);
@@ -290,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/20260524160000_deduplicate_habits.sql b/supabase/migrations/20260524160000_deduplicate_habits.sql
new file mode 100644
index 0000000..4b7bbaa
--- /dev/null
+++ b/supabase/migrations/20260524160000_deduplicate_habits.sql
@@ -0,0 +1,13 @@
+-- Remove duplicate active habits with the same name for a user.
+delete from public.habits duplicate
+using public.habits keeper
+where duplicate.user_id = keeper.user_id
+ and duplicate.name = keeper.name
+ and duplicate.active = true
+ and keeper.active = true
+ and duplicate.id <> keeper.id
+ and duplicate.created_at > keeper.created_at;
+
+create unique index if not exists habits_user_id_name_active_unique
+ on public.habits (user_id, name)
+ where active = true;
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 '{}';