added habits to work in db
This commit is contained in:
@@ -154,18 +154,34 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
|
||||
const [newHabitLabel, setNewHabitLabel] = useState("");
|
||||
const [focusTopics, setFocusTopics] = useState<string[]>([]);
|
||||
const [quizCompletedToday, setQuizCompletedToday] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setDailyTasks(getDailyTasks());
|
||||
setCustomHabits(getCustomHabits());
|
||||
setFocusTopics(getProfilePreferences().focusTopics);
|
||||
setQuizCompletedToday(hasCompletedDailyQuizToday());
|
||||
setIsReady(true);
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
const [tasks, customs] = await Promise.all([
|
||||
getDailyTasks(),
|
||||
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(() => {
|
||||
refresh();
|
||||
void refresh();
|
||||
|
||||
window.addEventListener(HABIT_PET_DATA_UPDATED_EVENT, refresh);
|
||||
return () => {
|
||||
@@ -173,17 +189,51 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
const handleToggle = (habitId: string) => {
|
||||
toggleHabitCompletion(habitId);
|
||||
const handleToggle = async (habitId: string) => {
|
||||
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 = () => {
|
||||
addHabit(newHabitLabel);
|
||||
setNewHabitLabel("");
|
||||
const handleAddHabit = async () => {
|
||||
try {
|
||||
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) => {
|
||||
removeHabit(habitId);
|
||||
const handleRemoveHabit = async (habitId: string) => {
|
||||
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) {
|
||||
@@ -199,6 +249,8 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
|
||||
if (mode === "manage") {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{error ? <p className="text-sm text-red-500">{error}</p> : null}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Custom habits</CardTitle>
|
||||
@@ -213,15 +265,20 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
|
||||
<Input
|
||||
value={newHabitLabel}
|
||||
placeholder="Add a custom habit"
|
||||
disabled={isSaving}
|
||||
onChange={(event) => setNewHabitLabel(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
handleAddHabit();
|
||||
void handleAddHabit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button type="button" onClick={handleAddHabit}>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isSaving || newHabitLabel.trim().length === 0}
|
||||
onClick={() => void handleAddHabit()}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
@@ -265,6 +322,7 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{error ? <p className="text-sm text-red-500">{error}</p> : null}
|
||||
<DailyTaskList tasks={dailyTasks} onToggle={handleToggle} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -7,7 +7,11 @@ import {
|
||||
matchesFocusTopics,
|
||||
matchesQuizAnswers,
|
||||
} from "@/lib/habit-catalog";
|
||||
import { getHabits, saveHabits, type Habit } from "@/lib/habits-storage";
|
||||
import {
|
||||
ensureCatalogHabit,
|
||||
getHabits,
|
||||
type Habit,
|
||||
} from "@/lib/habits-storage";
|
||||
import {
|
||||
getProfilePreferences,
|
||||
type ProfilePreferences,
|
||||
@@ -29,29 +33,6 @@ function getQuizAnswersForToday(): DailyQuizAnswers | null {
|
||||
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(
|
||||
entry: NonNullable<ReturnType<typeof getCatalogEntry>>,
|
||||
preferences: ProfilePreferences,
|
||||
@@ -71,34 +52,33 @@ function getCatalogTaskReason(
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getDailyTasks(): DailyTask[] {
|
||||
let habits = getHabits();
|
||||
export async function getDailyTasks(): Promise<DailyTask[]> {
|
||||
const preferences = getProfilePreferences();
|
||||
const quizAnswers = getQuizAnswersForToday();
|
||||
const initialHabitCount = habits.length;
|
||||
|
||||
const selectedCatalogIds = habitCatalog
|
||||
.map((entry) => {
|
||||
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) {
|
||||
habits = ensureCatalogHabit(id, habits);
|
||||
}
|
||||
await Promise.all(
|
||||
selectedCatalogIds.map(({ catalogId }) => ensureCatalogHabit(catalogId)),
|
||||
);
|
||||
|
||||
if (habits.length !== initialHabitCount) {
|
||||
saveHabits(habits, { notify: false });
|
||||
}
|
||||
const habits = await getHabits();
|
||||
|
||||
const customTasks: DailyTask[] = habits
|
||||
.filter((habit) => habit.isCustom)
|
||||
.map((habit) => ({ ...habit, reason: "custom" as const }));
|
||||
|
||||
const catalogTasks: DailyTask[] = selectedCatalogIds
|
||||
.map(({ id, reason }) => {
|
||||
const habit = habits.find((item) => item.id === id);
|
||||
.map(({ catalogId, reason }) => {
|
||||
const habit = habits.find((item) => item.catalogId === catalogId);
|
||||
return habit ? { ...habit, reason } : null;
|
||||
})
|
||||
.filter((task): task is DailyTask => task !== null);
|
||||
@@ -106,6 +86,4 @@ export function getDailyTasks(): DailyTask[] {
|
||||
return [...customTasks, ...catalogTasks];
|
||||
}
|
||||
|
||||
export function isCatalogHabitId(habitId: string) {
|
||||
return catalogHabitIds.has(habitId);
|
||||
}
|
||||
export { isCatalogHabitId } from "@/lib/habits-storage";
|
||||
|
||||
@@ -60,6 +60,14 @@ export function getCatalogEntry(habitId: string) {
|
||||
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(
|
||||
entry: HabitCatalogEntry,
|
||||
preferences: ProfilePreferences,
|
||||
|
||||
@@ -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 { createClient } from "@/lib/supabase/client";
|
||||
|
||||
export type Habit = {
|
||||
id: string;
|
||||
label: string;
|
||||
catalogId: string | null;
|
||||
streak: number;
|
||||
lastCompletedDate: string | null;
|
||||
isCustom: boolean;
|
||||
};
|
||||
|
||||
const STORAGE_KEY = "habit-pet-habits";
|
||||
type HabitRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export const defaultHabits: Habit[] = habitCatalog.map((entry) => ({
|
||||
id: entry.id,
|
||||
label: entry.label,
|
||||
streak: 0,
|
||||
lastCompletedDate: null,
|
||||
isCustom: false,
|
||||
}));
|
||||
type HabitLogRow = {
|
||||
habit_id: string;
|
||||
completed_on: string;
|
||||
};
|
||||
|
||||
export function getTodayDateKey(date = new Date()) {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function getYesterdayDateKey(date = new Date()) {
|
||||
const yesterday = new Date(date);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
return getTodayDateKey(yesterday);
|
||||
function calculateStreak(completedDates: string[], today = getTodayDateKey()) {
|
||||
if (completedDates.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
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 {
|
||||
id: habit.id,
|
||||
label: habit.label,
|
||||
streak: habit.streak ?? 0,
|
||||
lastCompletedDate: habit.lastCompletedDate ?? null,
|
||||
isCustom: habit.isCustom ?? !catalogHabitIds.has(habit.id),
|
||||
id: row.id,
|
||||
label: row.name,
|
||||
catalogId: getCatalogIdByLabel(row.name),
|
||||
streak: calculateStreak(completedDates, today),
|
||||
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()) {
|
||||
return habit.lastCompletedDate === today;
|
||||
}
|
||||
|
||||
export function getHabits(): Habit[] {
|
||||
if (typeof window === "undefined") {
|
||||
return defaultHabits;
|
||||
export async function getHabits(): Promise<Habit[]> {
|
||||
const userId = await getAuthenticatedUserId();
|
||||
|
||||
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) {
|
||||
return defaultHabits;
|
||||
if (error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
try {
|
||||
const habits = (JSON.parse(raw) as Habit[]).map(normalizeHabit);
|
||||
return habits.length > 0 ? habits : defaultHabits;
|
||||
} catch {
|
||||
return defaultHabits;
|
||||
}
|
||||
const logsByHabitId = await fetchHabitLogs(userId);
|
||||
|
||||
return ((data ?? []) as HabitRow[]).map((row) =>
|
||||
mapHabitRow(row, logsByHabitId),
|
||||
);
|
||||
}
|
||||
|
||||
export function saveHabits(habits: Habit[], options?: { notify?: boolean }) {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(habits));
|
||||
export async function ensureCatalogHabit(catalogId: string): Promise<Habit | null> {
|
||||
const entry = getCatalogEntry(catalogId);
|
||||
|
||||
if (options?.notify !== false) {
|
||||
notifyHabitPetDataUpdated();
|
||||
if (!entry) {
|
||||
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 yesterday = getYesterdayDateKey();
|
||||
const habits = getHabits().map((habit) => {
|
||||
if (habit.id !== habitId) {
|
||||
return habit;
|
||||
const { data: existingLog, error: existingLogError } = await supabase
|
||||
.from("habit_logs")
|
||||
.select("id")
|
||||
.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)) {
|
||||
return {
|
||||
...habit,
|
||||
lastCompletedDate: null,
|
||||
streak: Math.max(0, habit.streak - 1),
|
||||
};
|
||||
if (insertError) {
|
||||
throw new Error(insertError.message);
|
||||
}
|
||||
}
|
||||
|
||||
const nextStreak =
|
||||
habit.lastCompletedDate === yesterday ? habit.streak + 1 : 1;
|
||||
|
||||
return {
|
||||
...habit,
|
||||
lastCompletedDate: today,
|
||||
streak: nextStreak,
|
||||
};
|
||||
});
|
||||
|
||||
saveHabits(habits);
|
||||
return habits;
|
||||
notifyHabitPetDataUpdated();
|
||||
return getHabits();
|
||||
}
|
||||
|
||||
export function addHabit(label: string): Habit[] {
|
||||
export async function addHabit(label: string): Promise<Habit[]> {
|
||||
const trimmedLabel = label.trim();
|
||||
|
||||
if (!trimmedLabel) {
|
||||
return getHabits();
|
||||
}
|
||||
|
||||
const habits = [
|
||||
...getHabits(),
|
||||
normalizeHabit({
|
||||
id: crypto.randomUUID(),
|
||||
label: trimmedLabel,
|
||||
streak: 0,
|
||||
lastCompletedDate: null,
|
||||
isCustom: true,
|
||||
}),
|
||||
];
|
||||
const userId = await getAuthenticatedUserId();
|
||||
|
||||
saveHabits(habits);
|
||||
return habits;
|
||||
if (!userId) {
|
||||
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[] {
|
||||
const habits = getHabits().filter(
|
||||
(habit) => habit.id !== habitId || !habit.isCustom,
|
||||
);
|
||||
saveHabits(habits);
|
||||
return habits;
|
||||
export async function removeHabit(habitId: string): Promise<Habit[]> {
|
||||
const userId = await getAuthenticatedUserId();
|
||||
|
||||
if (!userId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
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() {
|
||||
return getHabits().filter((habit) => habit.isCustom);
|
||||
export async function getCustomHabits() {
|
||||
const habits = await getHabits();
|
||||
return habits.filter((habit) => habit.isCustom);
|
||||
}
|
||||
|
||||
export function isCatalogHabitId(habitId: string) {
|
||||
return catalogHabitIds.has(habitId);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user