408 lines
9.9 KiB
TypeScript
408 lines
9.9 KiB
TypeScript
import {
|
|
AI_HABIT_NAME_PREFIX,
|
|
isAiGeneratedHabitName,
|
|
toStoredAiHabitName,
|
|
} from "@/lib/ai-habit-utils";
|
|
import {
|
|
buildAiTaskContext,
|
|
buildAiTaskInputHash,
|
|
type AiTaskContext,
|
|
type OnboardingAnswers,
|
|
} from "@/lib/ai-task-context";
|
|
import {
|
|
getAiTaskGenerationMetadata,
|
|
mergeAiTaskGenerationMetadata,
|
|
} from "@/lib/ai-task-metadata";
|
|
import { buildFallbackAiTaskLabels } from "@/lib/ai-task-fallback";
|
|
import { isCatalogLabel } from "@/lib/habit-catalog";
|
|
import { generateAiTaskLabels } from "@/lib/openrouter";
|
|
import {
|
|
defaultProfilePreferences,
|
|
type ProfilePreferences,
|
|
} from "@/lib/profile-preferences-storage";
|
|
import { getTodayDateKey } from "@/lib/daily-quiz-storage";
|
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
|
|
type DailyEntryRow = {
|
|
mood: number;
|
|
stress: number;
|
|
energy: number;
|
|
sleep_hours: number;
|
|
sleep_quality: number;
|
|
journal: string | null;
|
|
};
|
|
|
|
function parseProfilePreferences(
|
|
focusTopics: string | string[] | null | undefined,
|
|
): ProfilePreferences {
|
|
const raw = Array.isArray(focusTopics) ? (focusTopics[0] ?? "") : (focusTopics ?? "");
|
|
|
|
if (!raw.trim()) {
|
|
return defaultProfilePreferences;
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(raw) as Partial<ProfilePreferences>;
|
|
|
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
return {
|
|
focusTopic: parsed.focusTopic ?? defaultProfilePreferences.focusTopic,
|
|
avatarVibe: parsed.avatarVibe ?? defaultProfilePreferences.avatarVibe,
|
|
dailyReminderEnabled:
|
|
parsed.dailyReminderEnabled ??
|
|
defaultProfilePreferences.dailyReminderEnabled,
|
|
dailyReminderTime:
|
|
parsed.dailyReminderTime ??
|
|
defaultProfilePreferences.dailyReminderTime,
|
|
};
|
|
}
|
|
} catch {
|
|
return {
|
|
...defaultProfilePreferences,
|
|
focusTopic: raw,
|
|
};
|
|
}
|
|
|
|
return defaultProfilePreferences;
|
|
}
|
|
|
|
function getOnboardingAnswersFromMetadata(
|
|
metadata: Record<string, unknown> | undefined,
|
|
): OnboardingAnswers {
|
|
const answers = metadata?.onboarding_answers;
|
|
|
|
if (!answers || typeof answers !== "object") {
|
|
return {
|
|
focusTopic: null,
|
|
avatarVibe: null,
|
|
};
|
|
}
|
|
|
|
const record = answers as Record<string, string>;
|
|
|
|
return {
|
|
focusTopic: record.focus ?? null,
|
|
avatarVibe: record.avatarVibe ?? record["pet-name"] ?? null,
|
|
};
|
|
}
|
|
|
|
async function loadAiTaskContext(
|
|
supabase: SupabaseClient,
|
|
userId: string,
|
|
userMetadata: Record<string, unknown> | undefined,
|
|
): Promise<AiTaskContext> {
|
|
const { data: profile, error: profileError } = await supabase
|
|
.from("profiles")
|
|
.select("focus_topics")
|
|
.eq("id", userId)
|
|
.maybeSingle();
|
|
|
|
if (profileError) {
|
|
throw new Error(profileError.message);
|
|
}
|
|
|
|
const preferences = parseProfilePreferences(profile?.focus_topics);
|
|
const onboarding = getOnboardingAnswersFromMetadata(userMetadata);
|
|
const entryDate = getTodayDateKey();
|
|
|
|
const { data: dailyEntries, error: dailyError } = await supabase
|
|
.from("daily_entries")
|
|
.select("mood, stress, energy, sleep_hours, sleep_quality, journal")
|
|
.eq("user_id", userId)
|
|
.eq("entry_date", entryDate)
|
|
.order("created_at", { ascending: false })
|
|
.limit(1);
|
|
|
|
if (dailyError) {
|
|
throw new Error(dailyError.message);
|
|
}
|
|
|
|
const dailyEntry = dailyEntries?.[0] as DailyEntryRow | undefined;
|
|
|
|
return buildAiTaskContext({
|
|
preferences,
|
|
onboarding,
|
|
dailyQuiz: dailyEntry
|
|
? {
|
|
feeling: dailyEntry.mood,
|
|
stress: dailyEntry.stress,
|
|
energy: dailyEntry.energy,
|
|
sleepLength: Number(dailyEntry.sleep_hours),
|
|
sleepQuality: dailyEntry.sleep_quality,
|
|
}
|
|
: null,
|
|
journal: dailyEntry?.journal ?? null,
|
|
});
|
|
}
|
|
|
|
async function deactivateLegacyCatalogHabits(
|
|
supabase: SupabaseClient,
|
|
userId: string,
|
|
) {
|
|
const { data, error } = await supabase
|
|
.from("habits")
|
|
.select("id, name")
|
|
.eq("user_id", userId)
|
|
.eq("active", true);
|
|
|
|
if (error) {
|
|
throw new Error(error.message);
|
|
}
|
|
|
|
const legacyIds = (data ?? [])
|
|
.filter(
|
|
(habit) =>
|
|
!isAiGeneratedHabitName(habit.name) && isCatalogLabel(habit.name),
|
|
)
|
|
.map((habit) => habit.id);
|
|
|
|
if (legacyIds.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const { error: updateError } = await supabase
|
|
.from("habits")
|
|
.update({ active: false })
|
|
.in("id", legacyIds);
|
|
|
|
if (updateError) {
|
|
throw new Error(updateError.message);
|
|
}
|
|
}
|
|
|
|
async function syncAiDailyTasks(
|
|
supabase: SupabaseClient,
|
|
userId: string,
|
|
taskLabels: string[],
|
|
) {
|
|
const storedNames = taskLabels.map(toStoredAiHabitName);
|
|
|
|
const { data: existingAiHabits, error: existingError } = await supabase
|
|
.from("habits")
|
|
.select("id, name, active")
|
|
.eq("user_id", userId)
|
|
.like("name", `${AI_HABIT_NAME_PREFIX}%`);
|
|
|
|
if (existingError) {
|
|
throw new Error(existingError.message);
|
|
}
|
|
|
|
const existingByName = new Map(
|
|
(existingAiHabits ?? []).map((habit) => [habit.name, habit]),
|
|
);
|
|
|
|
for (const habit of existingAiHabits ?? []) {
|
|
if (storedNames.includes(habit.name)) {
|
|
if (!habit.active) {
|
|
const { error } = await supabase
|
|
.from("habits")
|
|
.update({ active: true })
|
|
.eq("id", habit.id);
|
|
|
|
if (error) {
|
|
throw new Error(error.message);
|
|
}
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
if (habit.active) {
|
|
const { error } = await supabase
|
|
.from("habits")
|
|
.update({ active: false })
|
|
.eq("id", habit.id);
|
|
|
|
if (error) {
|
|
throw new Error(error.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const storedName of storedNames) {
|
|
const existing = existingByName.get(storedName);
|
|
|
|
if (existing) {
|
|
if (!existing.active) {
|
|
const { error } = await supabase
|
|
.from("habits")
|
|
.update({ active: true })
|
|
.eq("id", existing.id);
|
|
|
|
if (error) {
|
|
throw new Error(error.message);
|
|
}
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
const { error: insertError } = await supabase.from("habits").insert({
|
|
user_id: userId,
|
|
name: storedName,
|
|
active: true,
|
|
streak: 0,
|
|
});
|
|
|
|
if (!insertError) {
|
|
continue;
|
|
}
|
|
|
|
const { data: racedHabit, error: racedError } = await supabase
|
|
.from("habits")
|
|
.select("id, active")
|
|
.eq("user_id", userId)
|
|
.eq("name", storedName)
|
|
.maybeSingle();
|
|
|
|
if (racedError) {
|
|
throw new Error(racedError.message);
|
|
}
|
|
|
|
if (!racedHabit) {
|
|
throw new Error(insertError.message);
|
|
}
|
|
|
|
if (!racedHabit.active) {
|
|
const { error } = await supabase
|
|
.from("habits")
|
|
.update({ active: true })
|
|
.eq("id", racedHabit.id);
|
|
|
|
if (error) {
|
|
throw new Error(error.message);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function saveGenerationMetadata(
|
|
supabase: SupabaseClient,
|
|
userId: string,
|
|
preferences: ProfilePreferences,
|
|
focusTopics: string | string[] | null | undefined,
|
|
generatedOn: string,
|
|
inputHash: string,
|
|
) {
|
|
const serialized = mergeAiTaskGenerationMetadata(preferences, {
|
|
aiTasksGeneratedOn: generatedOn,
|
|
aiTasksInputHash: inputHash,
|
|
});
|
|
const attempts: Array<string | string[]> = [serialized, [serialized]];
|
|
|
|
for (const focus_topics of attempts) {
|
|
const { error } = await supabase
|
|
.from("profiles")
|
|
.update({ focus_topics })
|
|
.eq("id", userId);
|
|
|
|
if (!error) {
|
|
return;
|
|
}
|
|
|
|
if (!/array|malformed|invalid input syntax/i.test(error.message)) {
|
|
throw new Error(error.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
export type GenerateDailyTasksResult = {
|
|
generated: boolean;
|
|
tasks: string[];
|
|
source: "openrouter" | "fallback" | "cache";
|
|
};
|
|
|
|
export async function generateAndSyncDailyTasks(
|
|
supabase: SupabaseClient,
|
|
userId: string,
|
|
userMetadata: Record<string, unknown> | undefined,
|
|
): Promise<GenerateDailyTasksResult> {
|
|
const context = await loadAiTaskContext(supabase, userId, userMetadata);
|
|
const inputHash = buildAiTaskInputHash(context);
|
|
|
|
const { data: profile, error: profileError } = await supabase
|
|
.from("profiles")
|
|
.select("focus_topics")
|
|
.eq("id", userId)
|
|
.maybeSingle();
|
|
|
|
if (profileError) {
|
|
throw new Error(profileError.message);
|
|
}
|
|
|
|
const preferences = parseProfilePreferences(profile?.focus_topics);
|
|
const metadata = getAiTaskGenerationMetadata(profile?.focus_topics);
|
|
|
|
if (
|
|
metadata.aiTasksGeneratedOn === context.date &&
|
|
metadata.aiTasksInputHash === inputHash
|
|
) {
|
|
const { data: activeAiHabits, error: activeError } = await supabase
|
|
.from("habits")
|
|
.select("name")
|
|
.eq("user_id", userId)
|
|
.eq("active", true)
|
|
.like("name", `${AI_HABIT_NAME_PREFIX}%`);
|
|
|
|
if (activeError) {
|
|
throw new Error(activeError.message);
|
|
}
|
|
|
|
return {
|
|
generated: false,
|
|
source: "cache",
|
|
tasks: (activeAiHabits ?? []).map((habit) =>
|
|
habit.name.slice(AI_HABIT_NAME_PREFIX.length),
|
|
),
|
|
};
|
|
}
|
|
|
|
let taskLabels: string[] = [];
|
|
let source: GenerateDailyTasksResult["source"] = "fallback";
|
|
|
|
try {
|
|
taskLabels = await generateAiTaskLabels(context);
|
|
source = "openrouter";
|
|
} catch {
|
|
taskLabels = buildFallbackAiTaskLabels(context);
|
|
source = "fallback";
|
|
}
|
|
|
|
try {
|
|
await deactivateLegacyCatalogHabits(supabase, userId);
|
|
await syncAiDailyTasks(supabase, userId, taskLabels);
|
|
await saveGenerationMetadata(
|
|
supabase,
|
|
userId,
|
|
preferences,
|
|
profile?.focus_topics,
|
|
context.date,
|
|
inputHash,
|
|
);
|
|
} catch (syncError) {
|
|
taskLabels = buildFallbackAiTaskLabels(context);
|
|
source = "fallback";
|
|
|
|
await deactivateLegacyCatalogHabits(supabase, userId);
|
|
await syncAiDailyTasks(supabase, userId, taskLabels);
|
|
await saveGenerationMetadata(
|
|
supabase,
|
|
userId,
|
|
preferences,
|
|
profile?.focus_topics,
|
|
context.date,
|
|
inputHash,
|
|
);
|
|
|
|
if (syncError instanceof Error) {
|
|
console.error("AI task sync recovered with fallback:", syncError.message);
|
|
}
|
|
}
|
|
|
|
return {
|
|
generated: true,
|
|
source,
|
|
tasks: taskLabels,
|
|
};
|
|
}
|