added habits to work in db

This commit is contained in:
masongga
2026-05-23 19:46:33 -06:00
parent 9641b120e4
commit 1ce4d0d68f
4 changed files with 358 additions and 135 deletions

View File

@@ -154,18 +154,34 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
const [newHabitLabel, setNewHabitLabel] = useState(""); const [newHabitLabel, setNewHabitLabel] = useState("");
const [focusTopics, setFocusTopics] = useState<string[]>([]); const [focusTopics, setFocusTopics] = useState<string[]>([]);
const [quizCompletedToday, setQuizCompletedToday] = useState(false); const [quizCompletedToday, setQuizCompletedToday] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isReady, setIsReady] = useState(false); const [isReady, setIsReady] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const refresh = useCallback(() => { const refresh = useCallback(async () => {
setDailyTasks(getDailyTasks()); try {
setCustomHabits(getCustomHabits()); setError(null);
setFocusTopics(getProfilePreferences().focusTopics); const [tasks, customs] = await Promise.all([
setQuizCompletedToday(hasCompletedDailyQuizToday()); getDailyTasks(),
setIsReady(true); getCustomHabits(),
]);
setDailyTasks(tasks);
setCustomHabits(customs);
setFocusTopics(getProfilePreferences().focusTopics);
setQuizCompletedToday(hasCompletedDailyQuizToday());
setIsReady(true);
} catch (refreshError) {
setError(
refreshError instanceof Error
? refreshError.message
: "Could not load habits.",
);
setIsReady(true);
}
}, []); }, []);
useEffect(() => { useEffect(() => {
refresh(); void refresh();
window.addEventListener(HABIT_PET_DATA_UPDATED_EVENT, refresh); window.addEventListener(HABIT_PET_DATA_UPDATED_EVENT, refresh);
return () => { return () => {
@@ -173,17 +189,51 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
}; };
}, [refresh]); }, [refresh]);
const handleToggle = (habitId: string) => { const handleToggle = async (habitId: string) => {
toggleHabitCompletion(habitId); try {
setIsSaving(true);
setError(null);
await toggleHabitCompletion(habitId);
} catch (toggleError) {
setError(
toggleError instanceof Error
? toggleError.message
: "Could not update habit.",
);
} finally {
setIsSaving(false);
}
}; };
const handleAddHabit = () => { const handleAddHabit = async () => {
addHabit(newHabitLabel); try {
setNewHabitLabel(""); setIsSaving(true);
setError(null);
await addHabit(newHabitLabel);
setNewHabitLabel("");
} catch (addError) {
setError(
addError instanceof Error ? addError.message : "Could not add habit.",
);
} finally {
setIsSaving(false);
}
}; };
const handleRemoveHabit = (habitId: string) => { const handleRemoveHabit = async (habitId: string) => {
removeHabit(habitId); try {
setIsSaving(true);
setError(null);
await removeHabit(habitId);
} catch (removeError) {
setError(
removeError instanceof Error
? removeError.message
: "Could not remove habit.",
);
} finally {
setIsSaving(false);
}
}; };
if (!isReady) { if (!isReady) {
@@ -199,6 +249,8 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
if (mode === "manage") { if (mode === "manage") {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{error ? <p className="text-sm text-red-500">{error}</p> : null}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">Custom habits</CardTitle> <CardTitle className="text-base">Custom habits</CardTitle>
@@ -213,15 +265,20 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
<Input <Input
value={newHabitLabel} value={newHabitLabel}
placeholder="Add a custom habit" placeholder="Add a custom habit"
disabled={isSaving}
onChange={(event) => setNewHabitLabel(event.target.value)} onChange={(event) => setNewHabitLabel(event.target.value)}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === "Enter") { if (event.key === "Enter") {
event.preventDefault(); event.preventDefault();
handleAddHabit(); void handleAddHabit();
} }
}} }}
/> />
<Button type="button" onClick={handleAddHabit}> <Button
type="button"
disabled={isSaving || newHabitLabel.trim().length === 0}
onClick={() => void handleAddHabit()}
>
Add Add
</Button> </Button>
</div> </div>
@@ -265,6 +322,7 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{error ? <p className="text-sm text-red-500">{error}</p> : null}
<DailyTaskList tasks={dailyTasks} onToggle={handleToggle} /> <DailyTaskList tasks={dailyTasks} onToggle={handleToggle} />
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -7,7 +7,11 @@ import {
matchesFocusTopics, matchesFocusTopics,
matchesQuizAnswers, matchesQuizAnswers,
} from "@/lib/habit-catalog"; } from "@/lib/habit-catalog";
import { getHabits, saveHabits, type Habit } from "@/lib/habits-storage"; import {
ensureCatalogHabit,
getHabits,
type Habit,
} from "@/lib/habits-storage";
import { import {
getProfilePreferences, getProfilePreferences,
type ProfilePreferences, type ProfilePreferences,
@@ -29,29 +33,6 @@ function getQuizAnswersForToday(): DailyQuizAnswers | null {
return submission.answers; return submission.answers;
} }
function ensureCatalogHabit(entryId: string, habits: Habit[]) {
const entry = getCatalogEntry(entryId);
if (!entry) {
return habits;
}
if (habits.some((habit) => habit.id === entry.id)) {
return habits;
}
return [
...habits,
{
id: entry.id,
label: entry.label,
streak: 0,
lastCompletedDate: null,
isCustom: false,
},
];
}
function getCatalogTaskReason( function getCatalogTaskReason(
entry: NonNullable<ReturnType<typeof getCatalogEntry>>, entry: NonNullable<ReturnType<typeof getCatalogEntry>>,
preferences: ProfilePreferences, preferences: ProfilePreferences,
@@ -71,34 +52,33 @@ function getCatalogTaskReason(
return null; return null;
} }
export function getDailyTasks(): DailyTask[] { export async function getDailyTasks(): Promise<DailyTask[]> {
let habits = getHabits();
const preferences = getProfilePreferences(); const preferences = getProfilePreferences();
const quizAnswers = getQuizAnswersForToday(); const quizAnswers = getQuizAnswersForToday();
const initialHabitCount = habits.length;
const selectedCatalogIds = habitCatalog const selectedCatalogIds = habitCatalog
.map((entry) => { .map((entry) => {
const reason = getCatalogTaskReason(entry, preferences, quizAnswers); const reason = getCatalogTaskReason(entry, preferences, quizAnswers);
return reason ? { id: entry.id, reason } : null; return reason ? { catalogId: entry.id, reason } : null;
}) })
.filter((item): item is { id: string; reason: DailyTaskReason } => item !== null); .filter(
(item): item is { catalogId: string; reason: DailyTaskReason } =>
item !== null,
);
for (const { id } of selectedCatalogIds) { await Promise.all(
habits = ensureCatalogHabit(id, habits); selectedCatalogIds.map(({ catalogId }) => ensureCatalogHabit(catalogId)),
} );
if (habits.length !== initialHabitCount) { const habits = await getHabits();
saveHabits(habits, { notify: false });
}
const customTasks: DailyTask[] = habits const customTasks: DailyTask[] = habits
.filter((habit) => habit.isCustom) .filter((habit) => habit.isCustom)
.map((habit) => ({ ...habit, reason: "custom" as const })); .map((habit) => ({ ...habit, reason: "custom" as const }));
const catalogTasks: DailyTask[] = selectedCatalogIds const catalogTasks: DailyTask[] = selectedCatalogIds
.map(({ id, reason }) => { .map(({ catalogId, reason }) => {
const habit = habits.find((item) => item.id === id); const habit = habits.find((item) => item.catalogId === catalogId);
return habit ? { ...habit, reason } : null; return habit ? { ...habit, reason } : null;
}) })
.filter((task): task is DailyTask => task !== null); .filter((task): task is DailyTask => task !== null);
@@ -106,6 +86,4 @@ export function getDailyTasks(): DailyTask[] {
return [...customTasks, ...catalogTasks]; return [...customTasks, ...catalogTasks];
} }
export function isCatalogHabitId(habitId: string) { export { isCatalogHabitId } from "@/lib/habits-storage";
return catalogHabitIds.has(habitId);
}

View File

@@ -60,6 +60,14 @@ export function getCatalogEntry(habitId: string) {
return habitCatalog.find((habit) => habit.id === habitId); return habitCatalog.find((habit) => habit.id === habitId);
} }
export function getCatalogIdByLabel(label: string) {
return habitCatalog.find((habit) => habit.label === label)?.id ?? null;
}
export function isCatalogLabel(label: string) {
return habitCatalog.some((habit) => habit.label === label);
}
export function matchesFocusTopics( export function matchesFocusTopics(
entry: HabitCatalogEntry, entry: HabitCatalogEntry,
preferences: ProfilePreferences, preferences: ProfilePreferences,

View File

@@ -1,135 +1,314 @@
import { catalogHabitIds, habitCatalog } from "@/lib/habit-catalog"; import {
catalogHabitIds,
getCatalogEntry,
getCatalogIdByLabel,
isCatalogLabel,
} from "@/lib/habit-catalog";
import { notifyHabitPetDataUpdated } from "@/lib/app-events"; import { notifyHabitPetDataUpdated } from "@/lib/app-events";
import { createClient } from "@/lib/supabase/client";
export type Habit = { export type Habit = {
id: string; id: string;
label: string; label: string;
catalogId: string | null;
streak: number; streak: number;
lastCompletedDate: string | null; lastCompletedDate: string | null;
isCustom: boolean; isCustom: boolean;
}; };
const STORAGE_KEY = "habit-pet-habits"; type HabitRow = {
id: string;
name: string;
};
export const defaultHabits: Habit[] = habitCatalog.map((entry) => ({ type HabitLogRow = {
id: entry.id, habit_id: string;
label: entry.label, completed_on: string;
streak: 0, };
lastCompletedDate: null,
isCustom: false,
}));
export function getTodayDateKey(date = new Date()) { export function getTodayDateKey(date = new Date()) {
return date.toISOString().slice(0, 10); return date.toISOString().slice(0, 10);
} }
function getYesterdayDateKey(date = new Date()) { function calculateStreak(completedDates: string[], today = getTodayDateKey()) {
const yesterday = new Date(date); if (completedDates.length === 0) {
yesterday.setDate(yesterday.getDate() - 1); return 0;
return getTodayDateKey(yesterday); }
const dates = new Set(completedDates);
let streak = 0;
const cursor = new Date(`${today}T00:00:00`);
if (!dates.has(today)) {
cursor.setDate(cursor.getDate() - 1);
}
while (dates.has(getTodayDateKey(cursor))) {
streak += 1;
cursor.setDate(cursor.getDate() - 1);
}
return streak;
} }
function normalizeHabit(habit: Partial<Habit> & { id: string; label: string }): Habit { function getLastCompletedDate(completedDates: string[], today = getTodayDateKey()) {
if (completedDates.includes(today)) {
return today;
}
const sortedDates = [...completedDates].sort().reverse();
return sortedDates[0] ?? null;
}
function mapHabitRow(
row: HabitRow,
logsByHabitId: Map<string, string[]>,
today = getTodayDateKey(),
): Habit {
const completedDates = logsByHabitId.get(row.id) ?? [];
return { return {
id: habit.id, id: row.id,
label: habit.label, label: row.name,
streak: habit.streak ?? 0, catalogId: getCatalogIdByLabel(row.name),
lastCompletedDate: habit.lastCompletedDate ?? null, streak: calculateStreak(completedDates, today),
isCustom: habit.isCustom ?? !catalogHabitIds.has(habit.id), lastCompletedDate: getLastCompletedDate(completedDates, today),
isCustom: !isCatalogLabel(row.name),
}; };
} }
async function getAuthenticatedUserId() {
const supabase = createClient();
const {
data: { user },
error,
} = await supabase.auth.getUser();
if (error || !user) {
return null;
}
return user.id;
}
async function fetchHabitLogs(userId: string) {
const supabase = createClient();
const { data, error } = await supabase
.from("habit_logs")
.select("habit_id, completed_on")
.eq("user_id", userId);
if (error) {
throw new Error(error.message);
}
const logsByHabitId = new Map<string, string[]>();
for (const log of (data ?? []) as HabitLogRow[]) {
const existing = logsByHabitId.get(log.habit_id) ?? [];
existing.push(log.completed_on);
logsByHabitId.set(log.habit_id, existing);
}
return logsByHabitId;
}
export function isHabitCompletedToday(habit: Habit, today = getTodayDateKey()) { export function isHabitCompletedToday(habit: Habit, today = getTodayDateKey()) {
return habit.lastCompletedDate === today; return habit.lastCompletedDate === today;
} }
export function getHabits(): Habit[] { export async function getHabits(): Promise<Habit[]> {
if (typeof window === "undefined") { const userId = await getAuthenticatedUserId();
return defaultHabits;
if (!userId) {
return [];
} }
const raw = window.localStorage.getItem(STORAGE_KEY); const supabase = createClient();
const { data, error } = await supabase
.from("habits")
.select("id, name")
.eq("user_id", userId)
.eq("active", true)
.order("created_at", { ascending: true });
if (!raw) { if (error) {
return defaultHabits; throw new Error(error.message);
} }
try { const logsByHabitId = await fetchHabitLogs(userId);
const habits = (JSON.parse(raw) as Habit[]).map(normalizeHabit);
return habits.length > 0 ? habits : defaultHabits; return ((data ?? []) as HabitRow[]).map((row) =>
} catch { mapHabitRow(row, logsByHabitId),
return defaultHabits; );
}
} }
export function saveHabits(habits: Habit[], options?: { notify?: boolean }) { export async function ensureCatalogHabit(catalogId: string): Promise<Habit | null> {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(habits)); const entry = getCatalogEntry(catalogId);
if (options?.notify !== false) { if (!entry) {
notifyHabitPetDataUpdated(); return null;
} }
const userId = await getAuthenticatedUserId();
if (!userId) {
return null;
}
const supabase = createClient();
const { data: existing, error: existingError } = await supabase
.from("habits")
.select("id, name")
.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) {
const { data: inserted, error: insertError } = await supabase
.from("habits")
.insert({
user_id: userId,
name: entry.label,
active: true,
})
.select("id, name")
.single();
if (insertError) {
throw new Error(insertError.message);
}
row = inserted as HabitRow;
}
const logsByHabitId = await fetchHabitLogs(userId);
return mapHabitRow(row, logsByHabitId);
} }
export function toggleHabitCompletion(habitId: string): Habit[] { export async function toggleHabitCompletion(habitId: string): Promise<Habit[]> {
const userId = await getAuthenticatedUserId();
if (!userId) {
return [];
}
const supabase = createClient();
const today = getTodayDateKey(); const today = getTodayDateKey();
const yesterday = getYesterdayDateKey(); const { data: existingLog, error: existingLogError } = await supabase
const habits = getHabits().map((habit) => { .from("habit_logs")
if (habit.id !== habitId) { .select("id")
return habit; .eq("user_id", userId)
.eq("habit_id", habitId)
.eq("completed_on", today)
.maybeSingle();
if (existingLogError) {
throw new Error(existingLogError.message);
}
if (existingLog) {
const { error: deleteError } = await supabase
.from("habit_logs")
.delete()
.eq("id", existingLog.id);
if (deleteError) {
throw new Error(deleteError.message);
} }
} else {
const { error: insertError } = await supabase.from("habit_logs").insert({
user_id: userId,
habit_id: habitId,
completed_on: today,
});
if (isHabitCompletedToday(habit, today)) { if (insertError) {
return { throw new Error(insertError.message);
...habit,
lastCompletedDate: null,
streak: Math.max(0, habit.streak - 1),
};
} }
}
const nextStreak = notifyHabitPetDataUpdated();
habit.lastCompletedDate === yesterday ? habit.streak + 1 : 1; return getHabits();
return {
...habit,
lastCompletedDate: today,
streak: nextStreak,
};
});
saveHabits(habits);
return habits;
} }
export function addHabit(label: string): Habit[] { export async function addHabit(label: string): Promise<Habit[]> {
const trimmedLabel = label.trim(); const trimmedLabel = label.trim();
if (!trimmedLabel) { if (!trimmedLabel) {
return getHabits(); return getHabits();
} }
const habits = [ const userId = await getAuthenticatedUserId();
...getHabits(),
normalizeHabit({
id: crypto.randomUUID(),
label: trimmedLabel,
streak: 0,
lastCompletedDate: null,
isCustom: true,
}),
];
saveHabits(habits); if (!userId) {
return habits; return [];
}
const supabase = createClient();
const { error } = await supabase.from("habits").insert({
user_id: userId,
name: trimmedLabel,
active: true,
});
if (error) {
throw new Error(error.message);
}
notifyHabitPetDataUpdated();
return getHabits();
} }
export function removeHabit(habitId: string): Habit[] { export async function removeHabit(habitId: string): Promise<Habit[]> {
const habits = getHabits().filter( const userId = await getAuthenticatedUserId();
(habit) => habit.id !== habitId || !habit.isCustom,
); if (!userId) {
saveHabits(habits); return [];
return habits; }
const supabase = createClient();
const { data: habit, error: habitError } = await supabase
.from("habits")
.select("id, name")
.eq("id", habitId)
.eq("user_id", userId)
.maybeSingle();
if (habitError) {
throw new Error(habitError.message);
}
if (!habit || isCatalogLabel(habit.name)) {
return getHabits();
}
const { error: deleteError } = await supabase
.from("habits")
.delete()
.eq("id", habitId)
.eq("user_id", userId);
if (deleteError) {
throw new Error(deleteError.message);
}
notifyHabitPetDataUpdated();
return getHabits();
} }
export function getCustomHabits() { export async function getCustomHabits() {
return getHabits().filter((habit) => habit.isCustom); const habits = await getHabits();
return habits.filter((habit) => habit.isCustom);
}
export function isCatalogHabitId(habitId: string) {
return catalogHabitIds.has(habitId);
} }