Merge pull request #5 from RecentRunner/main

Updates the owen branch with the latest changes from main to keep it in sync and resolve any differences before continuing development.
This commit is contained in:
pricewowen
2026-05-23 22:24:26 -06:00
committed by GitHub
9 changed files with 358 additions and 94 deletions

View File

@@ -1,34 +1,5 @@
import { AppShell } from "@/components/layout/app-shell"; import { AppShell } from "@/components/layout/app-shell";
import { Badge } from "@/components/ui/badge"; import { ShopContent } from "@/components/shop/shop-content";
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.",
},
];
export default function ShopPage() { export default function ShopPage() {
return ( return (
@@ -36,31 +7,7 @@ export default function ShopPage() {
title="Shop" title="Shop"
description="Spend points earned from habits and daily check-ins." description="Spend points earned from habits and daily check-ins."
> >
<div className="mb-4 flex items-center justify-between rounded-lg border bg-muted/40 px-4 py-3"> <ShopContent />
<span className="text-sm text-muted-foreground">Your balance</span>
<Badge variant="secondary">120 points</Badge>
</div>
<div className="grid gap-4">
{shopItems.map((item) => (
<Card key={item.id}>
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle className="text-base">{item.name}</CardTitle>
<CardDescription>{item.description}</CardDescription>
</div>
<Badge>{item.price} pts</Badge>
</div>
</CardHeader>
<CardContent>
<Button className="w-full" variant="outline">
Buy
</Button>
</CardContent>
</Card>
))}
</div>
</AppShell> </AppShell>
); );
} }

View File

@@ -28,6 +28,7 @@ import {
toggleHabitCompletion, toggleHabitCompletion,
type Habit, type Habit,
} from "@/lib/habits-storage"; } from "@/lib/habits-storage";
import { formatCoinsDelta } from "@/lib/coins";
import { getProfilePreferences } from "@/lib/profile-preferences-storage"; import { getProfilePreferences } from "@/lib/profile-preferences-storage";
import { hasCompletedDailyQuizToday } from "@/lib/daily-quiz-storage"; import { hasCompletedDailyQuizToday } from "@/lib/daily-quiz-storage";
@@ -187,6 +188,7 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
() => new Set(), () => new Set(),
); );
const [toggleErrors, setToggleErrors] = useState<Record<string, string>>({}); const [toggleErrors, setToggleErrors] = useState<Record<string, string>>({});
const [coinMessage, setCoinMessage] = useState<string | null>(null);
const getIsCompleted = useCallback( const getIsCompleted = useCallback(
(habit: DailyTask) => { (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(() => { useEffect(() => {
void refresh(); void refresh();
@@ -262,7 +278,8 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
void (async () => { void (async () => {
try { try {
const updatedHabits = await toggleHabitCompletion(habitId); const { habits: updatedHabits, coinsDelta } =
await toggleHabitCompletion(habitId);
setDailyTasks((current) => setDailyTasks((current) =>
current.map((task) => { current.map((task) => {
@@ -275,6 +292,7 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
delete next[habitId]; delete next[habitId];
return next; return next;
}); });
setCoinMessage(formatCoinsDelta(coinsDelta));
} catch (toggleError) { } catch (toggleError) {
setOptimisticCompletion((current) => { setOptimisticCompletion((current) => {
const next = { ...current }; const next = { ...current };
@@ -416,6 +434,9 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{error ? <p className="text-sm text-red-500">{error}</p> : null} {error ? <p className="text-sm text-red-500">{error}</p> : null}
{coinMessage ? (
<p className="text-sm font-medium text-emerald-600">{coinMessage}</p>
) : null}
<DailyTaskList <DailyTaskList
tasks={dailyTasks} tasks={dailyTasks}
getIsCompleted={getIsCompleted} getIsCompleted={getIsCompleted}

View File

@@ -0,0 +1,106 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events";
import { getCoins } from "@/lib/avatar-progression-storage";
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.",
},
];
export function ShopContent() {
const [coins, setCoins] = useState<number | null>(null);
const [error, setError] = useState<string | null>(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 (
<>
<div className="mb-4 flex items-center justify-between rounded-lg border bg-muted/40 px-4 py-3">
<span className="text-sm text-muted-foreground">Your balance</span>
<Badge variant="secondary">
{coins === null ? "..." : `${coins} points`}
</Badge>
</div>
{error ? <p className="mb-4 text-sm text-red-500">{error}</p> : null}
<div className="grid gap-4">
{shopItems.map((item) => {
const canAfford = coins !== null && coins >= item.price;
return (
<Card key={item.id}>
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle className="text-base">{item.name}</CardTitle>
<CardDescription>{item.description}</CardDescription>
</div>
<Badge>{item.price} pts</Badge>
</div>
</CardHeader>
<CardContent>
<Button
className="w-full"
variant="outline"
disabled={coins === null || !canAfford}
>
{canAfford ? "Buy" : "Not enough points"}
</Button>
</CardContent>
</Card>
);
})}
</div>
</>
);
}

View File

@@ -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<number> {
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<number> {
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;
}

28
lib/coins.ts Normal file
View File

@@ -0,0 +1,28 @@
export const COINS_PER_TASK_COMPLETE = 50;
export const COINS_PER_TASK_UNCHECK = -50;
export const STREAK_MILESTONE_COINS: Record<number, number> = {
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`;
}

View File

@@ -69,17 +69,20 @@ export async function getDailyEntryForToday(): Promise<DailyQuizSubmission | nul
) )
.eq("user_id", userId) .eq("user_id", userId)
.eq("entry_date", entryDate) .eq("entry_date", entryDate)
.maybeSingle(); .order("created_at", { ascending: false })
.limit(1);
if (error) { if (error) {
throw new Error(error.message); throw new Error(error.message);
} }
if (!data) { const row = data?.[0];
if (!row) {
return null; return null;
} }
return mapRowToSubmission(data as DailyEntryRow); return mapRowToSubmission(row as DailyEntryRow);
} }
export async function getDailyQuizSubmission() { export async function getDailyQuizSubmission() {

View File

@@ -4,7 +4,13 @@ import {
getCatalogIdByLabel, getCatalogIdByLabel,
isCatalogLabel, isCatalogLabel,
} from "@/lib/habit-catalog"; } from "@/lib/habit-catalog";
import { adjustCoins } from "@/lib/avatar-progression-storage";
import { notifyHabitPetDataUpdated } from "@/lib/app-events"; 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"; import { createClient } from "@/lib/supabase/client";
export type Habit = { export type Habit = {
@@ -16,13 +22,22 @@ export type Habit = {
isCustom: boolean; isCustom: boolean;
}; };
export type ToggleHabitResult = {
habits: Habit[];
coinsDelta: number;
};
type HabitRow = { type HabitRow = {
id: string; id: string;
name: string; name: string;
streak: number; streak: number;
last_completed_on: string | null; 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()) { export function getTodayDateKey(date = new Date()) {
return date.toISOString().slice(0, 10); return date.toISOString().slice(0, 10);
} }
@@ -50,12 +65,50 @@ function mapHabitRow(row: HabitRow): Habit {
id: row.id, id: row.id,
label: row.name, label: row.name,
catalogId: getCatalogIdByLabel(row.name), catalogId: getCatalogIdByLabel(row.name),
streak: row.streak, streak: row.streak ?? 0,
lastCompletedDate: row.last_completed_on, lastCompletedDate: row.last_completed_on ?? null,
isCustom: !isCatalogLabel(row.name), isCustom: !isCatalogLabel(row.name),
}; };
} }
function getFirstRow<T>(rows: T[] | null | undefined) {
return rows?.[0] ?? null;
}
function dedupeHabitsByName(rows: HabitRow[]) {
const seen = new Map<string, HabitRow>();
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<HabitRow | null> {
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() { async function getAuthenticatedUserId() {
const supabase = createClient(); const supabase = createClient();
const { const {
@@ -87,13 +140,16 @@ async function reconcileMissedDayStreaks(
await Promise.all( await Promise.all(
habitsToReset.map((habit) => 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) => return habits.map((habit) =>
habitsToReset.some((item) => item.id === habit.id) habitsToReset.some((item) => item.id === habit.id)
? { ...habit, streak: 0 } ? { ...habit, streak: 0, claimed_streak_milestones: [] }
: habit, : habit,
); );
} }
@@ -111,14 +167,13 @@ async function getPreviousCompletedDate(
.eq("habit_id", habitId) .eq("habit_id", habitId)
.neq("completed_on", today) .neq("completed_on", today)
.order("completed_on", { ascending: false }) .order("completed_on", { ascending: false })
.limit(1) .limit(1);
.maybeSingle();
if (error) { if (error) {
throw new Error(error.message); 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()) { export function isHabitCompletedToday(habit: Habit, today = getTodayDateKey()) {
@@ -135,7 +190,7 @@ export async function getHabits(): Promise<Habit[]> {
const supabase = createClient(); const supabase = createClient();
const { data, error } = await supabase const { data, error } = await supabase
.from("habits") .from("habits")
.select("id, name, streak, last_completed_on") .select(habitSelectFields)
.eq("user_id", userId) .eq("user_id", userId)
.eq("active", true) .eq("active", true)
.order("created_at", { ascending: true }); .order("created_at", { ascending: true });
@@ -144,7 +199,9 @@ export async function getHabits(): Promise<Habit[]> {
throw new Error(error.message); throw new Error(error.message);
} }
const rows = await reconcileMissedDayStreaks((data ?? []) as HabitRow[]); const rows = await reconcileMissedDayStreaks(
dedupeHabitsByName((data ?? []) as HabitRow[]),
);
return rows.map(mapHabitRow); return rows.map(mapHabitRow);
} }
@@ -163,19 +220,7 @@ export async function ensureCatalogHabit(catalogId: string): Promise<Habit | nul
} }
const supabase = createClient(); const supabase = createClient();
const { data: existing, error: existingError } = await supabase let row = await findActiveHabitByName(userId, entry.label);
.from("habits")
.select("id, name, streak, last_completed_on")
.eq("user_id", userId)
.eq("name", entry.label)
.eq("active", true)
.maybeSingle();
if (existingError) {
throw new Error(existingError.message);
}
let row = existing as HabitRow | null;
if (!row) { if (!row) {
const { data: inserted, error: insertError } = await supabase const { data: inserted, error: insertError } = await supabase
@@ -186,25 +231,31 @@ export async function ensureCatalogHabit(catalogId: string): Promise<Habit | nul
active: true, active: true,
streak: 0, streak: 0,
}) })
.select("id, name, streak, last_completed_on") .select(habitSelectFields)
.single(); .single();
if (insertError) { if (insertError) {
throw new Error(insertError.message); row = await findActiveHabitByName(userId, entry.label);
}
row = inserted as HabitRow; if (!row) {
throw new Error(insertError.message);
}
} else {
row = inserted as HabitRow;
}
} }
const [reconciledRow] = await reconcileMissedDayStreaks([row]); const [reconciledRow] = await reconcileMissedDayStreaks([row]);
return mapHabitRow(reconciledRow); return mapHabitRow(reconciledRow);
} }
export async function toggleHabitCompletion(habitId: string): Promise<Habit[]> { export async function toggleHabitCompletion(
habitId: string,
): Promise<ToggleHabitResult> {
const userId = await getAuthenticatedUserId(); const userId = await getAuthenticatedUserId();
if (!userId) { if (!userId) {
return []; return { habits: [], coinsDelta: 0 };
} }
const supabase = createClient(); const supabase = createClient();
@@ -212,7 +263,7 @@ export async function toggleHabitCompletion(habitId: string): Promise<Habit[]> {
const { data: habit, error: habitError } = await supabase const { data: habit, error: habitError } = await supabase
.from("habits") .from("habits")
.select("id, name, streak, last_completed_on") .select(habitSelectFields)
.eq("id", habitId) .eq("id", habitId)
.eq("user_id", userId) .eq("user_id", userId)
.maybeSingle(); .maybeSingle();
@@ -226,25 +277,29 @@ export async function toggleHabitCompletion(habitId: string): Promise<Habit[]> {
} }
const [reconciledHabit] = await reconcileMissedDayStreaks([habit as HabitRow]); 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") .from("habit_logs")
.select("id") .select("id")
.eq("user_id", userId) .eq("user_id", userId)
.eq("habit_id", habitId) .eq("habit_id", habitId)
.eq("completed_on", today) .eq("completed_on", today);
.maybeSingle();
if (existingLogError) { if (existingLogError) {
throw new Error(existingLogError.message); throw new Error(existingLogError.message);
} }
const existingLog = getFirstRow(existingLogs as { id: string }[] | null);
let coinsDelta = 0;
if (existingLog) { if (existingLog) {
const { error: deleteError } = await supabase const { error: deleteError } = await supabase
.from("habit_logs") .from("habit_logs")
.delete() .delete()
.eq("id", existingLog.id); .eq("user_id", userId)
.eq("habit_id", habitId)
.eq("completed_on", today);
if (deleteError) { if (deleteError) {
throw new Error(deleteError.message); throw new Error(deleteError.message);
@@ -265,6 +320,8 @@ export async function toggleHabitCompletion(habitId: string): Promise<Habit[]> {
if (updateError) { if (updateError) {
throw new Error(updateError.message); throw new Error(updateError.message);
} }
coinsDelta = COINS_PER_TASK_UNCHECK;
} else { } else {
const { error: insertError } = await supabase.from("habit_logs").insert({ const { error: insertError } = await supabase.from("habit_logs").insert({
user_id: userId, user_id: userId,
@@ -277,12 +334,20 @@ export async function toggleHabitCompletion(habitId: string): Promise<Habit[]> {
} }
const nextStreak = currentStreak + 1; 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 const { error: updateError } = await supabase
.from("habits") .from("habits")
.update({ .update({
streak: nextStreak, streak: nextStreak,
last_completed_on: today, last_completed_on: today,
claimed_streak_milestones: nextClaimedMilestones,
}) })
.eq("id", habitId) .eq("id", habitId)
.eq("user_id", userId); .eq("user_id", userId);
@@ -290,10 +355,15 @@ export async function toggleHabitCompletion(habitId: string): Promise<Habit[]> {
if (updateError) { if (updateError) {
throw new Error(updateError.message); throw new Error(updateError.message);
} }
coinsDelta =
COINS_PER_TASK_COMPLETE + (earnedMilestoneBonus ? milestoneBonus : 0);
} }
await adjustCoins(coinsDelta);
notifyHabitPetDataUpdated(); notifyHabitPetDataUpdated();
return getHabits(); return { habits: await getHabits(), coinsDelta };
} }
export async function addHabit(label: string): Promise<Habit[]> { export async function addHabit(label: string): Promise<Habit[]> {

View File

@@ -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;

View File

@@ -0,0 +1,2 @@
alter table public.habits
add column if not exists claimed_streak_milestones int[] not null default '{}';