fixed profile preferences

This commit is contained in:
masongga
2026-05-23 21:32:38 -06:00
parent 4b45ddd182
commit 82097f79ac
7 changed files with 385 additions and 67 deletions

View File

@@ -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) {

View File

@@ -175,7 +175,7 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
const [dailyTasks, setDailyTasks] = useState<DailyTask[]>([]);
const [customHabits, setCustomHabits] = useState<Habit[]>([]);
const [newHabitLabel, setNewHabitLabel] = useState("");
const [focusTopics, setFocusTopics] = useState<string[]>([]);
const [focusTopic, setFocusTopic] = useState<string | null>(null);
const [quizCompletedToday, setQuizCompletedToday] = useState(false);
const [error, setError] = useState<string | null>(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) {
<CardContent className="space-y-3 text-sm text-muted-foreground">
<p>
Focus topics:{" "}
{focusTopics.length > 0 ? focusTopics.join(", ") : "None selected"}
{focusTopic ?? "None selected"}
</p>
<p>
Daily quiz today:{" "}

View File

@@ -33,24 +33,46 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) {
defaultProfilePreferences,
);
const [isReady, setIsReady] = useState(false);
const [error, setError] = useState<string | null>(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<ProfilePreferences>) => {
const updatePreferences = async (updates: Partial<ProfilePreferences>) => {
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 (
<div className="flex flex-col gap-6">
{error ? <p className="text-sm text-red-500">{error}</p> : null}
<Card>
<CardHeader>
<CardTitle className="text-base">Profile information</CardTitle>
@@ -76,21 +100,23 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) {
<Card>
<CardHeader>
<CardTitle className="text-base">Focus topic preferences</CardTitle>
<CardTitle className="text-base">Focus topic preference</CardTitle>
<CardDescription>
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.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<CardContent className="space-y-2">
{focusTopicOptions.map((topic) => (
<div key={topic} className="flex items-center gap-3 rounded-lg border p-3">
<Checkbox
id={`focus-${topic}`}
checked={preferences.focusTopics.includes(topic)}
onCheckedChange={() => toggleFocusTopic(topic)}
/>
<Label htmlFor={`focus-${topic}`}>{topic}</Label>
</div>
<Button
key={topic}
type="button"
variant={preferences.focusTopic === topic ? "default" : "outline"}
className="w-full justify-start"
disabled={isSaving}
onClick={() => void updatePreferences({ focusTopic: topic })}
>
{topic}
</Button>
))}
</CardContent>
</Card>
@@ -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}
</Button>
@@ -127,9 +154,10 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) {
<Label htmlFor="daily-reminder">Daily reminder</Label>
<Checkbox
id="daily-reminder"
disabled={isSaving}
checked={preferences.dailyReminderEnabled}
onCheckedChange={(checked) =>
updatePreferences({ dailyReminderEnabled: checked === true })
void updatePreferences({ dailyReminderEnabled: checked === true })
}
/>
</div>
@@ -138,10 +166,10 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) {
<Input
id="reminder-time"
type="time"
disabled={isSaving || !preferences.dailyReminderEnabled}
value={preferences.dailyReminderTime}
disabled={!preferences.dailyReminderEnabled}
onChange={(event) =>
updatePreferences({ dailyReminderTime: event.target.value })
void updatePreferences({ dailyReminderTime: event.target.value })
}
/>
</div>

View File

@@ -47,7 +47,7 @@ function getCatalogTaskReason(
}
export async function getDailyTasks(): Promise<DailyTask[]> {
const preferences = getProfilePreferences();
const preferences = await getProfilePreferences();
const quizAnswers = await getQuizAnswersForToday();
const selectedCatalogIds = habitCatalog

View File

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

View File

@@ -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>,
): 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<ProfilePreferences> {
try {
return {
...defaultProfilePreferences,
...(JSON.parse(raw) as Partial<ProfilePreferences>),
};
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<ProfilePreferences> {
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<string, unknown> | undefined,
) {
const answers = metadata?.onboarding_answers;
if (!answers || typeof answers !== "object") {
return null;
}
const record = answers as Record<string, string>;
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<string | string[]> = [
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<ProfilePreferences> {
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;
}

View File

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