From 82097f79ac4ac063664f886144cf72751eefb224 Mon Sep 17 00:00:00 2001 From: masongga Date: Sat, 23 May 2026 21:32:38 -0600 Subject: [PATCH] fixed profile preferences --- app/onboarding/quiz/page.tsx | 27 +- components/habits/habit-tracker.tsx | 6 +- .../profile/profile-preferences-form.tsx | 80 +++-- lib/daily-tasks.ts | 2 +- lib/habit-catalog.ts | 4 +- lib/profile-preferences-storage.ts | 312 +++++++++++++++++- ...60524140000_profiles_focus_topics_text.sql | 21 ++ 7 files changed, 385 insertions(+), 67 deletions(-) create mode 100644 supabase/migrations/20260524140000_profiles_focus_topics_text.sql diff --git a/app/onboarding/quiz/page.tsx b/app/onboarding/quiz/page.tsx index ca0e20d..f8e2997 100644 --- a/app/onboarding/quiz/page.tsx +++ b/app/onboarding/quiz/page.tsx @@ -13,21 +13,25 @@ import { CardTitle, } from "@/components/ui/card"; import { Label } from "@/components/ui/label"; -import { createClient } from "@/lib/supabase/client"; +import { + avatarVibeOptions, + focusTopicOptions, + saveOnboardingPreferences, +} from "@/lib/profile-preferences-storage"; import { routes } from "@/lib/routes"; const questions = [ { id: "focus", prompt: "What habit do you most want to build first?", - options: ["Sleep", "Movement", "Hydration", "Mindfulness"], + options: focusTopicOptions, }, { - id: "pet-name", + id: "avatarVibe", prompt: "Pick a starter vibe for your pet.", - options: ["Calm", "Energetic", "Curious", "Cozy"], + options: avatarVibeOptions, }, -]; +] as const; export default function OnboardingQuizPage() { const router = useRouter(); @@ -60,18 +64,7 @@ export default function OnboardingQuizPage() { setError(null); try { - const supabase = createClient(); - const { error: updateError } = await supabase.auth.updateUser({ - data: { - onboarding_completed: true, - onboarding_answers: answers, - }, - }); - - if (updateError) { - throw updateError; - } - + await saveOnboardingPreferences(answers.focus, answers.avatarVibe); router.push(routes.avatar); router.refresh(); } catch (submitError) { diff --git a/components/habits/habit-tracker.tsx b/components/habits/habit-tracker.tsx index 3ffcd5c..92dca98 100644 --- a/components/habits/habit-tracker.tsx +++ b/components/habits/habit-tracker.tsx @@ -175,7 +175,7 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) { const [dailyTasks, setDailyTasks] = useState([]); const [customHabits, setCustomHabits] = useState([]); const [newHabitLabel, setNewHabitLabel] = useState(""); - const [focusTopics, setFocusTopics] = useState([]); + const [focusTopic, setFocusTopic] = useState(null); const [quizCompletedToday, setQuizCompletedToday] = useState(false); const [error, setError] = useState(null); const [isReady, setIsReady] = useState(false); @@ -218,7 +218,7 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) { ]); setDailyTasks(tasks); setCustomHabits(customs); - setFocusTopics(getProfilePreferences().focusTopics); + setFocusTopic((await getProfilePreferences()).focusTopic); setQuizCompletedToday(await hasCompletedDailyQuizToday()); setIsReady(true); } catch (refreshError) { @@ -389,7 +389,7 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {

Focus topics:{" "} - {focusTopics.length > 0 ? focusTopics.join(", ") : "None selected"} + {focusTopic ?? "None selected"}

Daily quiz today:{" "} diff --git a/components/profile/profile-preferences-form.tsx b/components/profile/profile-preferences-form.tsx index 3542820..f78880a 100644 --- a/components/profile/profile-preferences-form.tsx +++ b/components/profile/profile-preferences-form.tsx @@ -33,24 +33,46 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) { defaultProfilePreferences, ); const [isReady, setIsReady] = useState(false); + const [error, setError] = useState(null); + const [isSaving, setIsSaving] = useState(false); useEffect(() => { - setPreferences(getProfilePreferences()); - setIsReady(true); + async function loadPreferences() { + try { + setPreferences(await getProfilePreferences()); + } catch (loadError) { + setError( + loadError instanceof Error + ? loadError.message + : "Could not load profile preferences.", + ); + } finally { + setIsReady(true); + } + } + + void loadPreferences(); }, []); - const updatePreferences = (updates: Partial) => { + const updatePreferences = async (updates: Partial) => { const nextPreferences = { ...preferences, ...updates }; setPreferences(nextPreferences); - saveProfilePreferences(nextPreferences); - }; + setIsSaving(true); + setError(null); - const toggleFocusTopic = (topic: string) => { - const focusTopics = preferences.focusTopics.includes(topic) - ? preferences.focusTopics.filter((item) => item !== topic) - : [...preferences.focusTopics, topic]; - - updatePreferences({ focusTopics }); + try { + const savedPreferences = await saveProfilePreferences(nextPreferences); + setPreferences(savedPreferences); + } catch (saveError) { + setPreferences(preferences); + setError( + saveError instanceof Error + ? saveError.message + : "Could not save profile preferences.", + ); + } finally { + setIsSaving(false); + } }; if (!isReady) { @@ -61,6 +83,8 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) { return (

+ {error ?

{error}

: null} + Profile information @@ -76,21 +100,23 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) { - Focus topic preferences + Focus topic preference - Choose the areas you want your pet and daily tasks to focus on. + Choose the one area you want your pet and daily tasks to focus on. - + {focusTopicOptions.map((topic) => ( -
- toggleFocusTopic(topic)} - /> - -
+ ))}
@@ -106,8 +132,9 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) { key={vibe} type="button" size="sm" + disabled={isSaving} variant={preferences.avatarVibe === vibe ? "default" : "outline"} - onClick={() => updatePreferences({ avatarVibe: vibe })} + onClick={() => void updatePreferences({ avatarVibe: vibe })} > {vibe} @@ -127,9 +154,10 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) { - updatePreferences({ dailyReminderEnabled: checked === true }) + void updatePreferences({ dailyReminderEnabled: checked === true }) } />
@@ -138,10 +166,10 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) { - updatePreferences({ dailyReminderTime: event.target.value }) + void updatePreferences({ dailyReminderTime: event.target.value }) } /> diff --git a/lib/daily-tasks.ts b/lib/daily-tasks.ts index eec917c..6ed3cbe 100644 --- a/lib/daily-tasks.ts +++ b/lib/daily-tasks.ts @@ -47,7 +47,7 @@ function getCatalogTaskReason( } export async function getDailyTasks(): Promise { - const preferences = getProfilePreferences(); + const preferences = await getProfilePreferences(); const quizAnswers = await getQuizAnswersForToday(); const selectedCatalogIds = habitCatalog diff --git a/lib/habit-catalog.ts b/lib/habit-catalog.ts index ee4db1e..379b205 100644 --- a/lib/habit-catalog.ts +++ b/lib/habit-catalog.ts @@ -72,9 +72,7 @@ export function matchesFocusTopics( entry: HabitCatalogEntry, preferences: ProfilePreferences, ) { - return entry.focusTopics.some((topic) => - preferences.focusTopics.includes(topic), - ); + return entry.focusTopics.includes(preferences.focusTopic); } export function matchesQuizAnswers( diff --git a/lib/profile-preferences-storage.ts b/lib/profile-preferences-storage.ts index 1b4a8e7..39f95ae 100644 --- a/lib/profile-preferences-storage.ts +++ b/lib/profile-preferences-storage.ts @@ -1,13 +1,24 @@ import { notifyHabitPetDataUpdated } from "@/lib/app-events"; +import { createClient } from "@/lib/supabase/client"; export type ProfilePreferences = { - focusTopics: string[]; + focusTopic: string; avatarVibe: string; dailyReminderEnabled: boolean; dailyReminderTime: string; }; -const STORAGE_KEY = "habit-pet-profile-preferences"; +type StoredProfilePreferences = { + focusTopic?: string; + avatarVibe?: string; + dailyReminderEnabled?: boolean; + dailyReminderTime?: string; +}; + +type ProfileRow = { + focus_topics: string | string[] | null; + onboarding_complete: boolean | null; +}; export const focusTopicOptions = [ "Sleep", @@ -19,34 +30,301 @@ export const focusTopicOptions = [ export const avatarVibeOptions = ["Calm", "Energetic", "Curious", "Cozy"] as const; export const defaultProfilePreferences: ProfilePreferences = { - focusTopics: ["Movement"], + focusTopic: "Movement", avatarVibe: "Calm", dailyReminderEnabled: false, dailyReminderTime: "20:00", }; -export function getProfilePreferences(): ProfilePreferences { - if (typeof window === "undefined") { - return defaultProfilePreferences; +function isFocusTopic(value: string) { + return focusTopicOptions.includes(value as (typeof focusTopicOptions)[number]); +} + +function isAvatarVibe(value: string) { + return avatarVibeOptions.includes(value as (typeof avatarVibeOptions)[number]); +} + +function normalizeFocusTopic(value: string | undefined) { + if (value && isFocusTopic(value)) { + return value; } - const raw = window.localStorage.getItem(STORAGE_KEY); + return defaultProfilePreferences.focusTopic; +} - if (!raw) { - return defaultProfilePreferences; +function normalizeAvatarVibe(value: string | undefined) { + if (value && isAvatarVibe(value)) { + return value; } + return defaultProfilePreferences.avatarVibe; +} + +function normalizePreferences( + partial: Partial, +): ProfilePreferences { + return { + focusTopic: normalizeFocusTopic(partial.focusTopic), + avatarVibe: normalizeAvatarVibe(partial.avatarVibe), + dailyReminderEnabled: + partial.dailyReminderEnabled ?? + defaultProfilePreferences.dailyReminderEnabled, + dailyReminderTime: + partial.dailyReminderTime ?? defaultProfilePreferences.dailyReminderTime, + }; +} + +function parseStoredPreferencesJson(raw: string): Partial { try { - return { - ...defaultProfilePreferences, - ...(JSON.parse(raw) as Partial), - }; + const parsed = JSON.parse(raw) as StoredProfilePreferences; + + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed; + } } catch { - return defaultProfilePreferences; + return { focusTopic: raw }; } + + return { focusTopic: raw }; } -export function saveProfilePreferences(preferences: ProfilePreferences) { - window.localStorage.setItem(STORAGE_KEY, JSON.stringify(preferences)); - notifyHabitPetDataUpdated(); +function parseFocusTopicsField( + value: string | string[] | null | undefined, +): Partial { + if (Array.isArray(value)) { + if (value.length === 0) { + return {}; + } + + return parseStoredPreferencesJson(value[0] ?? ""); + } + + if (!value?.trim()) { + return {}; + } + + return parseStoredPreferencesJson(value); +} + +function serializeFocusTopicsField(preferences: ProfilePreferences) { + const payload: StoredProfilePreferences = { + focusTopic: preferences.focusTopic, + avatarVibe: preferences.avatarVibe, + dailyReminderEnabled: preferences.dailyReminderEnabled, + dailyReminderTime: preferences.dailyReminderTime, + }; + + return JSON.stringify(payload); +} + +function isFocusTopicsTypeError(message: string) { + return /array|malformed|invalid input syntax/i.test(message); +} + +function mapRowToPreferences(row: ProfileRow): ProfilePreferences { + return normalizePreferences(parseFocusTopicsField(row.focus_topics)); +} + +function hasStoredPreferences(row: ProfileRow) { + if (Array.isArray(row.focus_topics)) { + return row.focus_topics.some((value) => value.trim().length > 0); + } + + return Boolean(row.focus_topics?.trim()); +} + +async function getAuthenticatedUserId() { + const supabase = createClient(); + const { + data: { user }, + error, + } = await supabase.auth.getUser(); + + if (error || !user) { + return null; + } + + return user.id; +} + +function getOnboardingAnswersFromMetadata( + metadata: Record | undefined, +) { + const answers = metadata?.onboarding_answers; + + if (!answers || typeof answers !== "object") { + return null; + } + + const record = answers as Record; + + return { + focusTopic: record.focus, + avatarVibe: record.avatarVibe ?? record["pet-name"], + }; +} + +async function saveProfileRow( + userId: string, + preferences: ProfilePreferences, + options?: { markOnboardingComplete?: boolean }, +) { + const supabase = createClient(); + const serialized = serializeFocusTopicsField(preferences); + const focusTopicAttempts: Array = [ + serialized, + [serialized], + ]; + const onboardingFields = options?.markOnboardingComplete + ? { onboarding_complete: true } + : {}; + + let lastError: string | null = null; + + for (const focus_topics of focusTopicAttempts) { + const { data: updatedRows, error: updateError } = await supabase + .from("profiles") + .update({ + focus_topics, + ...onboardingFields, + }) + .eq("id", userId) + .select("focus_topics, onboarding_complete"); + + if (!updateError && updatedRows && updatedRows.length > 0) { + return updatedRows[0] as ProfileRow; + } + + if (updateError) { + lastError = updateError.message; + + if (!isFocusTopicsTypeError(updateError.message)) { + throw new Error(updateError.message); + } + } + } + + for (const focus_topics of focusTopicAttempts) { + const { data: insertedRow, error: insertError } = await supabase + .from("profiles") + .insert({ + id: userId, + focus_topics, + ...onboardingFields, + }) + .select("focus_topics, onboarding_complete") + .single(); + + if (!insertError && insertedRow) { + return insertedRow as ProfileRow; + } + + if (insertError) { + lastError = insertError.message; + + if (!isFocusTopicsTypeError(insertError.message)) { + throw new Error(insertError.message); + } + } + } + + throw new Error(lastError ?? "Could not save profile preferences."); +} + +async function syncPreferencesFromMetadata(userId: string) { + const supabase = createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + const onboardingAnswers = getOnboardingAnswersFromMetadata(user?.user_metadata); + + if (!onboardingAnswers?.focusTopic && !onboardingAnswers?.avatarVibe) { + return null; + } + + const preferences = normalizePreferences(onboardingAnswers); + const row = await saveProfileRow(userId, preferences, { + markOnboardingComplete: true, + }); + + return mapRowToPreferences(row); +} + +export async function getProfilePreferences(): Promise { + const userId = await getAuthenticatedUserId(); + + if (!userId) { + return defaultProfilePreferences; + } + + const supabase = createClient(); + const { data, error } = await supabase + .from("profiles") + .select("focus_topics, onboarding_complete") + .eq("id", userId) + .maybeSingle(); + + if (error) { + throw new Error(error.message); + } + + if (!data) { + return defaultProfilePreferences; + } + + const row = data as ProfileRow; + + if (!hasStoredPreferences(row)) { + const synced = await syncPreferencesFromMetadata(userId); + return synced ?? mapRowToPreferences(row); + } + + return mapRowToPreferences(row); +} + +export async function saveProfilePreferences(preferences: ProfilePreferences) { + const userId = await getAuthenticatedUserId(); + + if (!userId) { + throw new Error("You must be signed in to save profile preferences."); + } + + const normalizedPreferences = normalizePreferences(preferences); + await saveProfileRow(userId, normalizedPreferences); + notifyHabitPetDataUpdated(); + return normalizedPreferences; +} + +export async function saveOnboardingPreferences( + focusTopic: string, + avatarVibe: string, +) { + const userId = await getAuthenticatedUserId(); + + if (!userId) { + throw new Error("You must be signed in to save onboarding preferences."); + } + + const supabase = createClient(); + const preferences = normalizePreferences({ focusTopic, avatarVibe }); + + await saveProfileRow(userId, preferences, { markOnboardingComplete: true }); + + const { error: authError } = await supabase.auth.updateUser({ + data: { + onboarding_completed: true, + onboarding_answers: { + focus: preferences.focusTopic, + avatarVibe: preferences.avatarVibe, + }, + }, + }); + + if (authError) { + throw new Error(authError.message); + } + + notifyHabitPetDataUpdated(); + return preferences; } diff --git a/supabase/migrations/20260524140000_profiles_focus_topics_text.sql b/supabase/migrations/20260524140000_profiles_focus_topics_text.sql new file mode 100644 index 0000000..081f677 --- /dev/null +++ b/supabase/migrations/20260524140000_profiles_focus_topics_text.sql @@ -0,0 +1,21 @@ +-- Store profile preferences as JSON text instead of text[]. +-- Safe when the column is already text (no-op cast). + +alter table public.profiles + alter column focus_topics drop default; + +alter table public.profiles + alter column focus_topics type text using ( + case + when focus_topics is null then null + when pg_typeof(focus_topics)::text = 'text[]' then + case + when coalesce(array_length(focus_topics::text[], 1), 0) = 0 then null + else (focus_topics::text[])[1] + end + else focus_topics::text + end + ); + +alter table public.profiles + alter column focus_topics set default null;