daily quiz entries store in db correctly

This commit is contained in:
masongga
2026-05-23 19:53:18 -06:00
parent 1ce4d0d68f
commit 8d5ffc6fed
10 changed files with 197 additions and 231 deletions

View File

@@ -29,10 +29,14 @@ export function AvatarStatusCard() {
const [isReady, setIsReady] = useState(false); const [isReady, setIsReady] = useState(false);
useEffect(() => { useEffect(() => {
const checkedIn = hasCompletedDailyQuizToday(); async function loadStatus() {
const checkedIn = await hasCompletedDailyQuizToday();
setHasCheckedInToday(checkedIn); setHasCheckedInToday(checkedIn);
setCondition(getAvatarConditionForToday() ?? defaultAvatarCondition); setCondition((await getAvatarConditionForToday()) ?? defaultAvatarCondition);
setIsReady(true); setIsReady(true);
}
void loadStatus();
}, []); }, []);
if (!isReady) { if (!isReady) {

View File

@@ -16,19 +16,18 @@ import {
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { import {
defaultDailyQuizAnswers, defaultDailyQuizAnswers,
formatSleepHours,
SLEEP_HOURS_MAX,
SLEEP_HOURS_MIN,
SLEEP_HOURS_STEP,
WELLNESS_SCALE_MAX, WELLNESS_SCALE_MAX,
type DailyQuizAnswers, type DailyQuizAnswers,
type DailyQuizSubmission, type DailyQuizSubmission,
} from "@/lib/avatar-state"; } from "@/lib/avatar-state";
import { import {
getDailyQuizSubmission, getDailyEntryForToday,
getTodayDateKey, saveDailyEntry,
saveDailyQuizSubmission,
} from "@/lib/daily-quiz-storage"; } from "@/lib/daily-quiz-storage";
import {
getJournalEntryForToday,
saveJournalEntry,
} from "@/lib/journal-storage";
export function DailyQuizForm() { export function DailyQuizForm() {
const [answers, setAnswers] = useState<DailyQuizAnswers>( const [answers, setAnswers] = useState<DailyQuizAnswers>(
@@ -44,20 +43,24 @@ export function DailyQuizForm() {
useEffect(() => { useEffect(() => {
async function loadDailyCheckIn() { async function loadDailyCheckIn() {
const existingSubmission = getDailyQuizSubmission(); try {
const existingJournal = await getJournalEntryForToday(); const existingEntry = await getDailyEntryForToday();
if (existingSubmission?.date === getTodayDateKey()) { if (existingEntry) {
setSubmission(existingSubmission); setSubmission(existingEntry);
setAnswers(existingSubmission.answers); setAnswers(existingEntry.answers);
setJournal(existingEntry.journal);
} }
} catch (loadError) {
if (existingJournal) { setError(
setJournal(existingJournal.content); loadError instanceof Error
} ? loadError.message
: "Could not load today's check-in.",
);
} finally {
setIsReady(true); setIsReady(true);
} }
}
void loadDailyCheckIn(); void loadDailyCheckIn();
}, []); }, []);
@@ -84,9 +87,8 @@ export function DailyQuizForm() {
setError(null); setError(null);
try { try {
const savedSubmission = saveDailyQuizSubmission(answers); const savedEntry = await saveDailyEntry(answers, journal);
await saveJournalEntry(journal); setSubmission(savedEntry);
setSubmission(savedSubmission);
} catch (submitError) { } catch (submitError) {
setError( setError(
submitError instanceof Error submitError instanceof Error
@@ -117,7 +119,7 @@ export function DailyQuizForm() {
<CardHeader> <CardHeader>
<CardTitle className="text-base">Wellness</CardTitle> <CardTitle className="text-base">Wellness</CardTitle>
<CardDescription> <CardDescription>
Sliders from 1 to 5 for how you&apos;re doing today. Rate your wellness from 1 to 5. Sleep length uses hours.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-5"> <CardContent className="space-y-5">
@@ -146,6 +148,10 @@ export function DailyQuizForm() {
id="sleep-length" id="sleep-length"
label="How long did you sleep?" label="How long did you sleep?"
value={answers.sleepLength} value={answers.sleepLength}
min={SLEEP_HOURS_MIN}
max={SLEEP_HOURS_MAX}
step={SLEEP_HOURS_STEP}
formatValue={formatSleepHours}
disabled={isCompleted} disabled={isCompleted}
onChange={(value) => updateAnswer("sleepLength", value)} onChange={(value) => updateAnswer("sleepLength", value)}
/> />
@@ -162,9 +168,7 @@ export function DailyQuizForm() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">Daily journal</CardTitle> <CardTitle className="text-base">Daily journal</CardTitle>
<CardDescription> <CardDescription>Write about your day.</CardDescription>
Write about your day. Saved separately from your quiz answers.
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<Label htmlFor="journal" className="sr-only"> <Label htmlFor="journal" className="sr-only">

View File

@@ -12,8 +12,12 @@ export function DailyQuizLinkButton() {
const [isReady, setIsReady] = useState(false); const [isReady, setIsReady] = useState(false);
useEffect(() => { useEffect(() => {
setIsCompleted(hasCompletedDailyQuizToday()); async function loadStatus() {
setIsCompleted(await hasCompletedDailyQuizToday());
setIsReady(true); setIsReady(true);
}
void loadStatus();
}, []); }, []);
if (!isReady || isCompleted) { if (!isReady || isCompleted) {

View File

@@ -168,7 +168,7 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
setDailyTasks(tasks); setDailyTasks(tasks);
setCustomHabits(customs); setCustomHabits(customs);
setFocusTopics(getProfilePreferences().focusTopics); setFocusTopics(getProfilePreferences().focusTopics);
setQuizCompletedToday(hasCompletedDailyQuizToday()); setQuizCompletedToday(await hasCompletedDailyQuizToday());
setIsReady(true); setIsReady(true);
} catch (refreshError) { } catch (refreshError) {
setError( setError(

View File

@@ -17,26 +17,49 @@ export type AvatarCondition = {
export type DailyQuizSubmission = { export type DailyQuizSubmission = {
date: string; date: string;
answers: DailyQuizAnswers; answers: DailyQuizAnswers;
journal: string;
condition: AvatarCondition; condition: AvatarCondition;
}; };
export const WELLNESS_SCALE_MAX = 5; 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 = { export const defaultDailyQuizAnswers: DailyQuizAnswers = {
feeling: 3, feeling: 3,
stress: 3, stress: 3,
energy: 3, energy: 3,
sleepLength: 3, sleepLength: DEFAULT_SLEEP_HOURS,
sleepQuality: 3, 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( export function computeAvatarCondition(
answers: DailyQuizAnswers, answers: DailyQuizAnswers,
): AvatarCondition { ): AvatarCondition {
const { feeling, stress, energy, sleepLength, sleepQuality } = answers; const { feeling, stress, energy, sleepLength, sleepQuality } = answers;
const wellnessAverage = (feeling + energy + (WELLNESS_SCALE_MAX + 1 - stress)) / 3; const wellnessAverage = (feeling + energy + (WELLNESS_SCALE_MAX + 1 - stress)) / 3;
const sleepAverage = (sleepLength + sleepQuality) / 2; const sleepScore = sleepHoursToWellnessScore(sleepLength);
const sleepAverage = (sleepScore + sleepQuality) / 2;
const health = Math.round((wellnessAverage + sleepAverage) / 2); const health = Math.round((wellnessAverage + sleepAverage) / 2);
const energyLevel = Math.round((energy + sleepQuality) / 2); const energyLevel = Math.round((energy + sleepQuality) / 2);
@@ -44,7 +67,7 @@ export function computeAvatarCondition(
if (energyLevel >= 4 && feeling >= 4 && health >= 4) { if (energyLevel >= 4 && feeling >= 4 && health >= 4) {
avatarMood = "happy"; avatarMood = "happy";
} else if (energy <= 2 || sleepLength <= 2 || sleepQuality <= 2) { } else if (energy <= 2 || sleepLength < 6 || sleepQuality <= 2) {
avatarMood = "tired"; avatarMood = "tired";
} }
@@ -82,11 +105,19 @@ export function normalizeDailyQuizAnswers(
: value; : value;
}; };
const normalizeSleepLength = (value: number | undefined) => {
if (value === undefined) {
return defaultDailyQuizAnswers.sleepLength;
}
return clampSleepHours(value);
};
return { return {
feeling: scaleValue(answers.feeling ?? answers.mood, defaultDailyQuizAnswers.feeling), feeling: scaleValue(answers.feeling ?? answers.mood, defaultDailyQuizAnswers.feeling),
stress: scaleValue(answers.stress, defaultDailyQuizAnswers.stress), stress: scaleValue(answers.stress, defaultDailyQuizAnswers.stress),
energy: scaleValue(answers.energy, defaultDailyQuizAnswers.energy), energy: scaleValue(answers.energy, defaultDailyQuizAnswers.energy),
sleepLength: scaleValue(answers.sleepLength, defaultDailyQuizAnswers.sleepLength), sleepLength: normalizeSleepLength(answers.sleepLength),
sleepQuality: scaleValue( sleepQuality: scaleValue(
answers.sleepQuality, answers.sleepQuality,
defaultDailyQuizAnswers.sleepQuality, defaultDailyQuizAnswers.sleepQuality,

View File

@@ -6,69 +6,142 @@ import {
type DailyQuizSubmission, type DailyQuizSubmission,
} from "@/lib/avatar-state"; } from "@/lib/avatar-state";
import { notifyHabitPetDataUpdated } from "@/lib/app-events"; import { notifyHabitPetDataUpdated } from "@/lib/app-events";
const STORAGE_KEY = "habit-pet-daily-quiz"; 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()) { export function getTodayDateKey(date = new Date()) {
return date.toISOString().slice(0, 10); return date.toISOString().slice(0, 10);
} }
function normalizeSubmission( function mapRowToSubmission(row: DailyEntryRow): DailyQuizSubmission {
submission: DailyQuizSubmission, const answers = normalizeDailyQuizAnswers({
): DailyQuizSubmission { feeling: row.mood,
const answers = normalizeDailyQuizAnswers(submission.answers); stress: row.stress,
energy: row.energy,
sleepLength: Number(row.sleep_hours),
sleepQuality: row.sleep_quality,
});
return { return {
...submission, date: row.entry_date,
answers, answers,
journal: row.journal ?? "",
condition: computeAvatarCondition(answers), condition: computeAvatarCondition(answers),
}; };
} }
export function getDailyQuizSubmission(): DailyQuizSubmission | null { async function getAuthenticatedUserId() {
if (typeof window === "undefined") { const supabase = createClient();
const {
data: { user },
error,
} = await supabase.auth.getUser();
if (error || !user) {
return null; return null;
} }
const raw = window.localStorage.getItem(STORAGE_KEY); return user.id;
if (!raw) {
return null;
}
try {
return normalizeSubmission(JSON.parse(raw) as DailyQuizSubmission);
} catch {
return null;
}
} }
export function hasCompletedDailyQuizToday() { export async function getDailyEntryForToday(): Promise<DailyQuizSubmission | null> {
const submission = getDailyQuizSubmission(); const userId = await getAuthenticatedUserId();
return submission?.date === getTodayDateKey();
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 function saveDailyQuizSubmission( export async function getDailyQuizSubmission() {
return getDailyEntryForToday();
}
export async function hasCompletedDailyQuizToday() {
const entry = await getDailyEntryForToday();
return entry !== null;
}
export async function saveDailyEntry(
answers: DailyQuizAnswers, answers: DailyQuizAnswers,
): DailyQuizSubmission { journal: string,
const submission: DailyQuizSubmission = { ): Promise<DailyQuizSubmission> {
date: getTodayDateKey(), const userId = await getAuthenticatedUserId();
answers,
condition: computeAvatarCondition(answers),
};
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(submission)); 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(); notifyHabitPetDataUpdated();
return submission; return submission;
} }
export function getAvatarConditionForToday(): AvatarCondition | null { export async function saveDailyQuizSubmission(
const submission = getDailyQuizSubmission(); answers: DailyQuizAnswers,
journal = "",
if (!submission || submission.date !== getTodayDateKey()) { ) {
return null; return saveDailyEntry(answers, journal);
} }
return submission.condition; export async function getAvatarConditionForToday(): Promise<AvatarCondition | null> {
const entry = await getDailyEntryForToday();
return entry?.condition ?? null;
} }

View File

@@ -1,5 +1,5 @@
import type { DailyQuizAnswers } from "@/lib/avatar-state"; import type { DailyQuizAnswers } from "@/lib/avatar-state";
import { getDailyQuizSubmission, getTodayDateKey } from "@/lib/daily-quiz-storage"; import { getDailyEntryForToday } from "@/lib/daily-quiz-storage";
import { import {
catalogHabitIds, catalogHabitIds,
getCatalogEntry, getCatalogEntry,
@@ -23,14 +23,9 @@ export type DailyTask = Habit & {
reason: DailyTaskReason; reason: DailyTaskReason;
}; };
function getQuizAnswersForToday(): DailyQuizAnswers | null { async function getQuizAnswersForToday(): Promise<DailyQuizAnswers | null> {
const submission = getDailyQuizSubmission(); const entry = await getDailyEntryForToday();
return entry?.answers ?? null;
if (!submission || submission.date !== getTodayDateKey()) {
return null;
}
return submission.answers;
} }
function getCatalogTaskReason( function getCatalogTaskReason(
@@ -54,7 +49,7 @@ function getCatalogTaskReason(
export async function getDailyTasks(): Promise<DailyTask[]> { export async function getDailyTasks(): Promise<DailyTask[]> {
const preferences = getProfilePreferences(); const preferences = getProfilePreferences();
const quizAnswers = getQuizAnswersForToday(); const quizAnswers = await getQuizAnswersForToday();
const selectedCatalogIds = habitCatalog const selectedCatalogIds = habitCatalog
.map((entry) => { .map((entry) => {

View File

@@ -50,7 +50,7 @@ export const habitCatalog: HabitCatalogEntry[] = [
label: "Wind down before bed", label: "Wind down before bed",
focusTopics: ["Sleep"], focusTopics: ["Sleep"],
suggestedByQuiz: (answers) => suggestedByQuiz: (answers) =>
answers.sleepLength <= 2 || answers.sleepQuality <= 2, answers.sleepLength < 7 || answers.sleepQuality <= 2,
}, },
]; ];

View File

@@ -1,110 +0,0 @@
import { createClient } from "@/lib/supabase/client";
export type JournalEntry = {
entryDate: string;
content: string;
};
const LOCAL_STORAGE_KEY = "habit-pet-journal";
export function getTodayDateKey(date = new Date()) {
return date.toISOString().slice(0, 10);
}
function getLocalJournalEntry(entryDate = getTodayDateKey()): JournalEntry | null {
if (typeof window === "undefined") {
return null;
}
const raw = window.localStorage.getItem(LOCAL_STORAGE_KEY);
if (!raw) {
return null;
}
try {
const entry = JSON.parse(raw) as JournalEntry;
return entry.entryDate === entryDate ? entry : null;
} catch {
return null;
}
}
function saveLocalJournalEntry(content: string, entryDate = getTodayDateKey()) {
const entry: JournalEntry = { entryDate, content };
window.localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(entry));
return entry;
}
export async function getJournalEntryForToday(): Promise<JournalEntry | null> {
const entryDate = getTodayDateKey();
const supabase = createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return getLocalJournalEntry(entryDate);
}
const { data, error } = await supabase
.from("journal_entries")
.select("entry_date, content")
.eq("user_id", user.id)
.eq("entry_date", entryDate)
.maybeSingle();
if (error) {
console.error("Failed to load journal entry:", error.message);
return getLocalJournalEntry(entryDate);
}
if (!data) {
return null;
}
return {
entryDate: data.entry_date,
content: data.content,
};
}
export async function saveJournalEntry(content: string): Promise<JournalEntry> {
const entryDate = getTodayDateKey();
const supabase = createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return saveLocalJournalEntry(content, entryDate);
}
const { data, error } = await supabase
.from("journal_entries")
.upsert(
{
user_id: user.id,
entry_date: entryDate,
content,
updated_at: new Date().toISOString(),
},
{ onConflict: "user_id,entry_date" },
)
.select("entry_date, content")
.single();
if (error) {
console.error("Failed to save journal entry:", error.message);
return saveLocalJournalEntry(content, entryDate);
}
return {
entryDate: data.entry_date,
content: data.content,
};
}
export function hasJournalEntryToday(entry: JournalEntry | null) {
return entry?.entryDate === getTodayDateKey() && entry.content.trim().length > 0;
}

View File

@@ -1,35 +0,0 @@
create table if not exists public.journal_entries (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users (id) on delete cascade,
entry_date date not null,
content text not null default '',
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (user_id, entry_date)
);
create index if not exists journal_entries_user_id_entry_date_idx
on public.journal_entries (user_id, entry_date desc);
alter table public.journal_entries enable row level security;
create policy "Users can read own journal entries"
on public.journal_entries
for select
using (auth.uid() = user_id);
create policy "Users can insert own journal entries"
on public.journal_entries
for insert
with check (auth.uid() = user_id);
create policy "Users can update own journal entries"
on public.journal_entries
for update
using (auth.uid() = user_id)
with check (auth.uid() = user_id);
create policy "Users can delete own journal entries"
on public.journal_entries
for delete
using (auth.uid() = user_id);