Files
HaBit/lib/ai-daily-tasks-server.ts
2026-05-23 23:20:41 -06:00

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,
};
}