Merge main into owen

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:04:04 -06:00
committed by GitHub
56 changed files with 7454 additions and 125 deletions

9
lib/app-events.ts Normal file
View File

@@ -0,0 +1,9 @@
export const HABIT_PET_DATA_UPDATED_EVENT = "habit-pet-data-updated";
export function notifyHabitPetDataUpdated() {
if (typeof window === "undefined") {
return;
}
window.dispatchEvent(new Event(HABIT_PET_DATA_UPDATED_EVENT));
}

43
lib/auth-helpers.ts Normal file
View File

@@ -0,0 +1,43 @@
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { routes } from "@/lib/routes";
import type { User } from "@supabase/supabase-js";
export function hasCompletedOnboarding(user: User) {
return user.user_metadata?.onboarding_completed === true;
}
export async function getSessionUser() {
const supabase = await createClient();
const {
data: { user },
error,
} = await supabase.auth.getUser();
if (error || !user) {
return null;
}
return user;
}
export async function requireSessionUser() {
const user = await getSessionUser();
if (!user) {
redirect(routes.login);
}
return user;
}
export async function requireOnboardingComplete() {
const user = await requireSessionUser();
if (!hasCompletedOnboarding(user)) {
redirect(routes.onboardingQuiz);
}
return user;
}

126
lib/avatar-state.ts Normal file
View File

@@ -0,0 +1,126 @@
export type AvatarMood = "happy" | "neutral" | "tired";
export type DailyQuizAnswers = {
feeling: number;
stress: number;
energy: number;
sleepLength: number;
sleepQuality: number;
};
export type AvatarCondition = {
mood: AvatarMood;
energy: number;
health: number;
};
export type DailyQuizSubmission = {
date: string;
answers: DailyQuizAnswers;
journal: string;
condition: AvatarCondition;
};
export const WELLNESS_SCALE_MAX = 5;
export const SLEEP_HOURS_MIN = 0.5;
export const SLEEP_HOURS_MAX = 12;
export const SLEEP_HOURS_STEP = 0.5;
export const DEFAULT_SLEEP_HOURS = 7.5;
export const defaultDailyQuizAnswers: DailyQuizAnswers = {
feeling: 3,
stress: 3,
energy: 3,
sleepLength: DEFAULT_SLEEP_HOURS,
sleepQuality: 3,
};
export function clampSleepHours(hours: number) {
const stepped = Math.round(hours / SLEEP_HOURS_STEP) * SLEEP_HOURS_STEP;
return Math.min(SLEEP_HOURS_MAX, Math.max(SLEEP_HOURS_MIN, stepped));
}
export function formatSleepHours(hours: number) {
return hours === 1 ? "1 hour" : `${hours} hours`;
}
function sleepHoursToWellnessScore(hours: number) {
const score =
1 + ((clampSleepHours(hours) - SLEEP_HOURS_MIN) / (SLEEP_HOURS_MAX - SLEEP_HOURS_MIN)) * 4;
return clampStat(Math.round(score));
}
export function computeAvatarCondition(
answers: DailyQuizAnswers,
): AvatarCondition {
const { feeling, stress, energy, sleepLength, sleepQuality } = answers;
const wellnessAverage = (feeling + energy + (WELLNESS_SCALE_MAX + 1 - stress)) / 3;
const sleepScore = sleepHoursToWellnessScore(sleepLength);
const sleepAverage = (sleepScore + sleepQuality) / 2;
const health = Math.round((wellnessAverage + sleepAverage) / 2);
const energyLevel = Math.round((energy + sleepQuality) / 2);
let avatarMood: AvatarMood = "neutral";
if (energyLevel >= 4 && feeling >= 4 && health >= 4) {
avatarMood = "happy";
} else if (energy <= 2 || sleepLength < 6 || sleepQuality <= 2) {
avatarMood = "tired";
}
return {
mood: avatarMood,
energy: clampStat(energyLevel),
health: clampStat(health),
};
}
function clampStat(value: number) {
return Math.min(WELLNESS_SCALE_MAX, Math.max(1, value));
}
export const defaultAvatarCondition: AvatarCondition = {
mood: "neutral",
energy: 3,
health: 3,
};
// Backward compatibility for older saved submissions.
export function normalizeDailyQuizAnswers(
answers: Partial<DailyQuizAnswers> & {
mood?: number;
journal?: string;
},
): DailyQuizAnswers {
const scaleValue = (value: number | undefined, fallback: number) => {
if (value === undefined) {
return fallback;
}
return value > WELLNESS_SCALE_MAX
? Math.max(1, Math.round(value / 2))
: value;
};
const normalizeSleepLength = (value: number | undefined) => {
if (value === undefined) {
return defaultDailyQuizAnswers.sleepLength;
}
return clampSleepHours(value);
};
return {
feeling: scaleValue(answers.feeling ?? answers.mood, defaultDailyQuizAnswers.feeling),
stress: scaleValue(answers.stress, defaultDailyQuizAnswers.stress),
energy: scaleValue(answers.energy, defaultDailyQuizAnswers.energy),
sleepLength: normalizeSleepLength(answers.sleepLength),
sleepQuality: scaleValue(
answers.sleepQuality,
defaultDailyQuizAnswers.sleepQuality,
),
};
}

147
lib/daily-quiz-storage.ts Normal file
View File

@@ -0,0 +1,147 @@
import {
computeAvatarCondition,
normalizeDailyQuizAnswers,
type AvatarCondition,
type DailyQuizAnswers,
type DailyQuizSubmission,
} from "@/lib/avatar-state";
import { notifyHabitPetDataUpdated } from "@/lib/app-events";
import { createClient } from "@/lib/supabase/client";
type DailyEntryRow = {
entry_date: string;
mood: number;
stress: number;
energy: number;
sleep_hours: number;
sleep_quality: number;
journal: string | null;
};
export function getTodayDateKey(date = new Date()) {
return date.toISOString().slice(0, 10);
}
function mapRowToSubmission(row: DailyEntryRow): DailyQuizSubmission {
const answers = normalizeDailyQuizAnswers({
feeling: row.mood,
stress: row.stress,
energy: row.energy,
sleepLength: Number(row.sleep_hours),
sleepQuality: row.sleep_quality,
});
return {
date: row.entry_date,
answers,
journal: row.journal ?? "",
condition: computeAvatarCondition(answers),
};
}
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 getDailyEntryForToday(): Promise<DailyQuizSubmission | null> {
const userId = await getAuthenticatedUserId();
if (!userId) {
return null;
}
const supabase = createClient();
const entryDate = getTodayDateKey();
const { data, error } = await supabase
.from("daily_entries")
.select(
"entry_date, mood, stress, energy, sleep_hours, sleep_quality, journal",
)
.eq("user_id", userId)
.eq("entry_date", entryDate)
.maybeSingle();
if (error) {
throw new Error(error.message);
}
if (!data) {
return null;
}
return mapRowToSubmission(data as DailyEntryRow);
}
export async function getDailyQuizSubmission() {
return getDailyEntryForToday();
}
export async function hasCompletedDailyQuizToday() {
const entry = await getDailyEntryForToday();
return entry !== null;
}
export async function saveDailyEntry(
answers: DailyQuizAnswers,
journal: string,
): Promise<DailyQuizSubmission> {
const userId = await getAuthenticatedUserId();
if (!userId) {
throw new Error("You must be signed in to save your daily check-in.");
}
const supabase = createClient();
const entryDate = getTodayDateKey();
const normalizedAnswers = normalizeDailyQuizAnswers(answers);
const { data, error } = await supabase
.from("daily_entries")
.upsert(
{
user_id: userId,
entry_date: entryDate,
mood: normalizedAnswers.feeling,
stress: normalizedAnswers.stress,
energy: normalizedAnswers.energy,
sleep_hours: normalizedAnswers.sleepLength,
sleep_quality: normalizedAnswers.sleepQuality,
journal,
},
{ onConflict: "user_id,entry_date" },
)
.select(
"entry_date, mood, stress, energy, sleep_hours, sleep_quality, journal",
)
.single();
if (error) {
throw new Error(error.message);
}
const submission = mapRowToSubmission(data as DailyEntryRow);
notifyHabitPetDataUpdated();
return submission;
}
export async function saveDailyQuizSubmission(
answers: DailyQuizAnswers,
journal = "",
) {
return saveDailyEntry(answers, journal);
}
export async function getAvatarConditionForToday(): Promise<AvatarCondition | null> {
const entry = await getDailyEntryForToday();
return entry?.condition ?? null;
}

83
lib/daily-tasks.ts Normal file
View File

@@ -0,0 +1,83 @@
import type { DailyQuizAnswers } from "@/lib/avatar-state";
import { getDailyEntryForToday } from "@/lib/daily-quiz-storage";
import {
getCatalogEntry,
habitCatalog,
matchesFocusTopics,
matchesQuizAnswers,
} from "@/lib/habit-catalog";
import {
ensureCatalogHabit,
getHabits,
type Habit,
} from "@/lib/habits-storage";
import {
getProfilePreferences,
type ProfilePreferences,
} from "@/lib/profile-preferences-storage";
export type DailyTaskReason = "custom" | "focus" | "quiz";
export type DailyTask = Habit & {
reason: DailyTaskReason;
};
async function getQuizAnswersForToday(): Promise<DailyQuizAnswers | null> {
const entry = await getDailyEntryForToday();
return entry?.answers ?? null;
}
function getCatalogTaskReason(
entry: NonNullable<ReturnType<typeof getCatalogEntry>>,
preferences: ProfilePreferences,
quizAnswers: DailyQuizAnswers | null,
): DailyTaskReason | null {
const fromQuiz = quizAnswers ? matchesQuizAnswers(entry, quizAnswers) : false;
const fromFocus = matchesFocusTopics(entry, preferences);
if (fromQuiz) {
return "quiz";
}
if (fromFocus) {
return "focus";
}
return null;
}
export async function getDailyTasks(): Promise<DailyTask[]> {
const preferences = await getProfilePreferences();
const quizAnswers = await getQuizAnswersForToday();
const selectedCatalogIds = habitCatalog
.map((entry) => {
const reason = getCatalogTaskReason(entry, preferences, quizAnswers);
return reason ? { catalogId: entry.id, reason } : null;
})
.filter(
(item): item is { catalogId: string; reason: DailyTaskReason } =>
item !== null,
);
await Promise.all(
selectedCatalogIds.map(({ catalogId }) => ensureCatalogHabit(catalogId)),
);
const habits = await getHabits();
const customTasks: DailyTask[] = habits
.filter((habit) => habit.isCustom)
.map((habit) => ({ ...habit, reason: "custom" as const }));
const catalogTasks: DailyTask[] = selectedCatalogIds
.map(({ catalogId, reason }) => {
const habit = habits.find((item) => item.catalogId === catalogId);
return habit ? { ...habit, reason } : null;
})
.filter((task): task is DailyTask => task !== null);
return [...customTasks, ...catalogTasks];
}
export { isCatalogHabitId } from "@/lib/habits-storage";

83
lib/habit-catalog.ts Normal file
View File

@@ -0,0 +1,83 @@
import type { DailyQuizAnswers } from "@/lib/avatar-state";
import type { ProfilePreferences } from "@/lib/profile-preferences-storage";
export type HabitCatalogEntry = {
id: string;
label: string;
focusTopics: string[];
suggestedByQuiz: (answers: DailyQuizAnswers) => boolean;
};
export const habitCatalog: HabitCatalogEntry[] = [
{
id: "walk",
label: "Went for a walk",
focusTopics: ["Movement"],
suggestedByQuiz: (answers) => answers.energy <= 2 || answers.feeling <= 2,
},
{
id: "water",
label: "Drank enough water",
focusTopics: ["Hydration"],
suggestedByQuiz: (answers) => answers.energy <= 3,
},
{
id: "meal",
label: "Ate a proper meal",
focusTopics: ["Hydration"],
suggestedByQuiz: (answers) => answers.feeling <= 2,
},
{
id: "study",
label: "Studied",
focusTopics: ["Mindfulness"],
suggestedByQuiz: () => false,
},
{
id: "clean",
label: "Cleaned room",
focusTopics: ["Mindfulness"],
suggestedByQuiz: (answers) => answers.stress >= 4,
},
{
id: "screen-break",
label: "Took a screen break",
focusTopics: ["Mindfulness", "Movement"],
suggestedByQuiz: (answers) => answers.stress >= 4 || answers.energy <= 2,
},
{
id: "wind-down",
label: "Wind down before bed",
focusTopics: ["Sleep"],
suggestedByQuiz: (answers) =>
answers.sleepLength < 7 || answers.sleepQuality <= 2,
},
];
export const catalogHabitIds = new Set(habitCatalog.map((habit) => habit.id));
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,
) {
return entry.focusTopics.includes(preferences.focusTopic);
}
export function matchesQuizAnswers(
entry: HabitCatalogEntry,
answers: DailyQuizAnswers,
) {
return entry.suggestedByQuiz(answers);
}

372
lib/habits-storage.ts Normal file
View File

@@ -0,0 +1,372 @@
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;
};
type HabitRow = {
id: string;
name: string;
streak: number;
last_completed_on: string | null;
};
export function getTodayDateKey(date = new Date()) {
return date.toISOString().slice(0, 10);
}
function getDayDiff(fromDate: string, toDate: string) {
const from = new Date(`${fromDate}T00:00:00`);
const to = new Date(`${toDate}T00:00:00`);
return Math.round((to.getTime() - from.getTime()) / (1000 * 60 * 60 * 24));
}
function shouldResetStreakForMissedDay(
lastCompletedOn: string | null,
today = getTodayDateKey(),
) {
if (!lastCompletedOn) {
return false;
}
return getDayDiff(lastCompletedOn, today) > 1;
}
function mapHabitRow(row: HabitRow): Habit {
return {
id: row.id,
label: row.name,
catalogId: getCatalogIdByLabel(row.name),
streak: row.streak,
lastCompletedDate: row.last_completed_on,
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 reconcileMissedDayStreaks(
habits: HabitRow[],
today = getTodayDateKey(),
) {
const supabase = createClient();
const habitsToReset = habits.filter(
(habit) =>
habit.streak > 0 &&
shouldResetStreakForMissedDay(habit.last_completed_on, today),
);
if (habitsToReset.length === 0) {
return habits;
}
await Promise.all(
habitsToReset.map((habit) =>
supabase.from("habits").update({ streak: 0 }).eq("id", habit.id),
),
);
return habits.map((habit) =>
habitsToReset.some((item) => item.id === habit.id)
? { ...habit, streak: 0 }
: habit,
);
}
async function getPreviousCompletedDate(
userId: string,
habitId: string,
today = getTodayDateKey(),
) {
const supabase = createClient();
const { data, error } = await supabase
.from("habit_logs")
.select("completed_on")
.eq("user_id", userId)
.eq("habit_id", habitId)
.neq("completed_on", today)
.order("completed_on", { ascending: false })
.limit(1)
.maybeSingle();
if (error) {
throw new Error(error.message);
}
return data?.completed_on ?? null;
}
export function isHabitCompletedToday(habit: Habit, today = getTodayDateKey()) {
return habit.lastCompletedDate === today;
}
export async function getHabits(): Promise<Habit[]> {
const userId = await getAuthenticatedUserId();
if (!userId) {
return [];
}
const supabase = createClient();
const { data, error } = await supabase
.from("habits")
.select("id, name, streak, last_completed_on")
.eq("user_id", userId)
.eq("active", true)
.order("created_at", { ascending: true });
if (error) {
throw new Error(error.message);
}
const rows = await reconcileMissedDayStreaks((data ?? []) as HabitRow[]);
return rows.map(mapHabitRow);
}
export async function ensureCatalogHabit(catalogId: string): Promise<Habit | null> {
const entry = getCatalogEntry(catalogId);
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, 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) {
const { data: inserted, error: insertError } = await supabase
.from("habits")
.insert({
user_id: userId,
name: entry.label,
active: true,
streak: 0,
})
.select("id, name, streak, last_completed_on")
.single();
if (insertError) {
throw new Error(insertError.message);
}
row = inserted as HabitRow;
}
const [reconciledRow] = await reconcileMissedDayStreaks([row]);
return mapHabitRow(reconciledRow);
}
export async function toggleHabitCompletion(habitId: string): Promise<Habit[]> {
const userId = await getAuthenticatedUserId();
if (!userId) {
return [];
}
const supabase = createClient();
const today = getTodayDateKey();
const { data: habit, error: habitError } = await supabase
.from("habits")
.select("id, name, streak, last_completed_on")
.eq("id", habitId)
.eq("user_id", userId)
.maybeSingle();
if (habitError) {
throw new Error(habitError.message);
}
if (!habit) {
throw new Error("Habit not found.");
}
const [reconciledHabit] = await reconcileMissedDayStreaks([habit as HabitRow]);
let currentStreak = reconciledHabit.streak;
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);
}
const nextStreak = Math.max(0, currentStreak - 1);
const previousCompletedDate = await getPreviousCompletedDate(userId, habitId);
const { error: updateError } = await supabase
.from("habits")
.update({
streak: nextStreak,
last_completed_on: previousCompletedDate,
})
.eq("id", habitId)
.eq("user_id", userId);
if (updateError) {
throw new Error(updateError.message);
}
} else {
const { error: insertError } = await supabase.from("habit_logs").insert({
user_id: userId,
habit_id: habitId,
completed_on: today,
});
if (insertError) {
throw new Error(insertError.message);
}
const nextStreak = currentStreak + 1;
const { error: updateError } = await supabase
.from("habits")
.update({
streak: nextStreak,
last_completed_on: today,
})
.eq("id", habitId)
.eq("user_id", userId);
if (updateError) {
throw new Error(updateError.message);
}
}
notifyHabitPetDataUpdated();
return getHabits();
}
export async function addHabit(label: string): Promise<Habit[]> {
const trimmedLabel = label.trim();
if (!trimmedLabel) {
return getHabits();
}
const userId = await getAuthenticatedUserId();
if (!userId) {
return [];
}
const supabase = createClient();
const { error } = await supabase.from("habits").insert({
user_id: userId,
name: trimmedLabel,
active: true,
streak: 0,
});
if (error) {
throw new Error(error.message);
}
notifyHabitPetDataUpdated();
return getHabits();
}
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 async function getCustomHabits() {
const habits = await getHabits();
return habits.filter((habit) => habit.isCustom);
}
export function isCatalogHabitId(habitId: string) {
return catalogHabitIds.has(habitId);
}

View File

@@ -0,0 +1,330 @@
import { notifyHabitPetDataUpdated } from "@/lib/app-events";
import { createClient } from "@/lib/supabase/client";
export type ProfilePreferences = {
focusTopic: string;
avatarVibe: string;
dailyReminderEnabled: boolean;
dailyReminderTime: string;
};
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",
"Movement",
"Hydration",
"Mindfulness",
] as const;
export const avatarVibeOptions = ["Calm", "Energetic", "Curious", "Cozy"] as const;
export const defaultProfilePreferences: ProfilePreferences = {
focusTopic: "Movement",
avatarVibe: "Calm",
dailyReminderEnabled: false,
dailyReminderTime: "20:00",
};
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;
}
return defaultProfilePreferences.focusTopic;
}
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 {
const parsed = JSON.parse(raw) as StoredProfilePreferences;
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed;
}
} catch {
return { focusTopic: raw };
}
return { focusTopic: raw };
}
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;
}

32
lib/routes.ts Normal file
View File

@@ -0,0 +1,32 @@
export const routes = {
home: "/",
login: "/auth/login",
signUp: "/auth/sign-up",
signUpSuccess: "/auth/sign-up-success",
onboardingQuiz: "/onboarding/quiz",
avatar: "/avatar",
habits: "/habits",
dailyQuiz: "/daily/quiz",
shop: "/shop",
profile: "/profile",
} as const;
export type AppRoute = (typeof routes)[keyof typeof routes];
export const appNavItems = [
{ href: routes.avatar, label: "Pet", shortLabel: "Pet" },
{ href: routes.habits, label: "Habits", shortLabel: "Habits" },
{ href: routes.dailyQuiz, label: "Daily Quiz", shortLabel: "Quiz" },
{ href: routes.shop, label: "Shop", shortLabel: "Shop" },
{ href: routes.profile, label: "Profile", shortLabel: "Profile" },
] as const;
export type NavItem = (typeof appNavItems)[number];
export function isAuthPath(pathname: string) {
return pathname.startsWith("/auth");
}
export function isPublicPath(pathname: string) {
return pathname === routes.home || isAuthPath(pathname);
}

View File

@@ -1,7 +1,8 @@
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
import { hasEnvVars } from "../utils";
import { isPublicPath, routes } from "@/lib/routes";
import { getSupabaseEnv } from "@/lib/supabase/env";
import { hasEnvVars } from "../utils";
export async function updateSession(request: NextRequest) {
let supabaseResponse = NextResponse.next({
@@ -50,15 +51,11 @@ export async function updateSession(request: NextRequest) {
const { data } = await supabase.auth.getClaims();
const user = data?.claims;
if (
request.nextUrl.pathname !== "/" &&
!user &&
!request.nextUrl.pathname.startsWith("/login") &&
!request.nextUrl.pathname.startsWith("/auth")
) {
// no user, potentially respond by redirecting the user to the login page
const { pathname } = request.nextUrl;
if (!user && !isPublicPath(pathname)) {
const url = request.nextUrl.clone();
url.pathname = "/auth/login";
url.pathname = routes.login;
return NextResponse.redirect(url);
}