Merge branch staging into mason
Resolve habit-tracker conflict by keeping the daily tasks countdown header and adopting check-in terminology from staging. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
7
app/(app)/daily/check-in/page.tsx
Normal file
7
app/(app)/daily/check-in/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { routes } from "@/lib/routes";
|
||||||
|
|
||||||
|
export default function DailyCheckInPage() {
|
||||||
|
redirect(`${routes.avatar}#bit-daily-check-in`);
|
||||||
|
}
|
||||||
@@ -3,5 +3,5 @@ import { redirect } from "next/navigation";
|
|||||||
import { routes } from "@/lib/routes";
|
import { routes } from "@/lib/routes";
|
||||||
|
|
||||||
export default function DailyQuizPage() {
|
export default function DailyQuizPage() {
|
||||||
redirect(`${routes.avatar}#bit-daily-quiz`);
|
redirect(`${routes.avatar}#bit-daily-check-in`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -262,6 +262,19 @@
|
|||||||
@apply border-2 border-border bg-card px-2 py-1 font-pixel leading-snug text-card-foreground;
|
@apply border-2 border-border bg-card px-2 py-1 font-pixel leading-snug text-card-foreground;
|
||||||
box-shadow: var(--retro-shadow-sm);
|
box-shadow: var(--retro-shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.shop-item-thumbnail {
|
||||||
|
background: hsl(var(--muted) / 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-item-thumbnail-glow {
|
||||||
|
background: radial-gradient(
|
||||||
|
circle at 50% 58%,
|
||||||
|
hsl(52 55% 90% / 0.55) 0%,
|
||||||
|
hsl(48 30% 88% / 0.18) 45%,
|
||||||
|
transparent 72%
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes emotion-pop {
|
@keyframes emotion-pop {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export default function Home() {
|
|||||||
</h1>
|
</h1>
|
||||||
<p className="max-w-xl text-base text-muted-foreground sm:text-lg">
|
<p className="max-w-xl text-base text-muted-foreground sm:text-lg">
|
||||||
HaBit turns daily check-ins, wellness sliders, and journaling into
|
HaBit turns daily check-ins, wellness sliders, and journaling into
|
||||||
a simple game. Keep streaks alive, take your once-per-day quiz,
|
a simple game. Keep streaks alive, take your once-per-day check-in,
|
||||||
and watch your bit change with you.
|
and watch your bit change with you.
|
||||||
</p>
|
</p>
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
@@ -35,7 +35,7 @@ export default function Home() {
|
|||||||
<div className="space-y-2 text-center">
|
<div className="space-y-2 text-center">
|
||||||
<p className="text-sm font-medium">Your daily loop</p>
|
<p className="text-sm font-medium">Your daily loop</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Habits, wellness, journal, quiz, shop.
|
Habits, wellness, journal, check-in, shop.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from "react";
|
|||||||
|
|
||||||
import { DailyQuizForm } from "@/components/daily-quiz/daily-quiz-form";
|
import { DailyQuizForm } from "@/components/daily-quiz/daily-quiz-form";
|
||||||
import { HabitTracker } from "@/components/habits/habit-tracker";
|
import { HabitTracker } from "@/components/habits/habit-tracker";
|
||||||
|
import { JournalCalendar } from "@/components/journal/journal-calendar";
|
||||||
import { PetHabitat } from "@/components/pet/pet-habitat";
|
import { PetHabitat } from "@/components/pet/pet-habitat";
|
||||||
import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events";
|
import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events";
|
||||||
import {
|
import {
|
||||||
@@ -37,6 +38,14 @@ export function AvatarPageContent() {
|
|||||||
};
|
};
|
||||||
}, [loadCustomization]);
|
}, [loadCustomization]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const hash = window.location.hash.replace("#", "");
|
||||||
|
|
||||||
|
if (hash === "bit-daily-quiz") {
|
||||||
|
window.location.hash = "bit-daily-check-in";
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (loadError) {
|
if (loadError) {
|
||||||
return <p className="text-xs text-red-500">{loadError}</p>;
|
return <p className="text-xs text-red-500">{loadError}</p>;
|
||||||
}
|
}
|
||||||
@@ -66,10 +75,14 @@ export function AvatarPageContent() {
|
|||||||
<div id="bit-daily-tasks">
|
<div id="bit-daily-tasks">
|
||||||
<HabitTracker mode="daily" />
|
<HabitTracker mode="daily" />
|
||||||
</div>
|
</div>
|
||||||
<div id="bit-daily-quiz">
|
<div id="bit-daily-check-in">
|
||||||
<DailyQuizForm />
|
<DailyQuizForm />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="w-full pt-8">
|
||||||
|
<JournalCalendar />
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,21 +94,21 @@ export function AvatarStatusCard({ customization }: AvatarStatusCardProps) {
|
|||||||
Health {condition.health}/{WELLNESS_SCALE_MAX}
|
Health {condition.health}/{WELLNESS_SCALE_MAX}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge variant={hasCheckedInToday ? "default" : "outline"}>
|
<Badge variant={hasCheckedInToday ? "default" : "outline"}>
|
||||||
{hasCheckedInToday ? "Checked in today" : "Quiz pending"}
|
{hasCheckedInToday ? "Checked in today" : "Check-in pending"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-center text-sm text-muted-foreground">
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
{hasCheckedInToday
|
{hasCheckedInToday
|
||||||
? completedTaskCount > 0
|
? completedTaskCount > 0
|
||||||
? `Your bit reacts to today's quiz and ${completedTaskCount} completed task${completedTaskCount === 1 ? "" : "s"}. Meals, hydration, sleep, and relaxing habits boost health and mood.`
|
? `Your bit reacts to today's check-in and ${completedTaskCount} completed task${completedTaskCount === 1 ? "" : "s"}. Meals, hydration, sleep, and relaxing habits boost health and mood.`
|
||||||
: "Your bit is reacting to today's wellness check-in. Complete habits to boost mood, energy, and health."
|
: "Your bit is reacting to today's wellness check-in. Complete habits to boost mood, energy, and health."
|
||||||
: completedTaskCount > 0
|
: completedTaskCount > 0
|
||||||
? "Complete today's quiz for a full wellness read, then keep completing habits to boost your bit."
|
? "Complete today's check-in for a full wellness read, then keep completing habits to boost your bit."
|
||||||
: "Complete today's quiz to update your bit's mood, energy, and health."}
|
: "Complete today's check-in to update your bit's mood, energy, and health."}
|
||||||
</p>
|
</p>
|
||||||
{!hasCheckedInToday ? (
|
{!hasCheckedInToday ? (
|
||||||
<Button asChild className="w-full">
|
<Button asChild className="w-full">
|
||||||
<Link href={`${routes.avatar}#bit-daily-quiz`}>Take today's quiz</Link>
|
<Link href={`${routes.avatar}#bit-daily-check-in`}>Start daily check-in</Link>
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ export function DailyQuizForm() {
|
|||||||
|
|
||||||
if (!isReady) {
|
if (!isReady) {
|
||||||
return (
|
return (
|
||||||
<p className="text-sm text-muted-foreground">Loading today's quiz...</p>
|
<p className="text-sm text-muted-foreground">Loading today's check-in...</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,7 +155,7 @@ export function DailyQuizForm() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-lg font-semibold tracking-tight">
|
<p className="text-lg font-semibold tracking-tight">
|
||||||
Today's quiz is complete
|
Today's check-in is complete
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Your answers are saved below. Come back tomorrow for your next
|
Your answers are saved below. Come back tomorrow for your next
|
||||||
@@ -264,7 +264,7 @@ export function DailyQuizForm() {
|
|||||||
|
|
||||||
{!isCompleted ? (
|
{!isCompleted ? (
|
||||||
<Button className="w-full" disabled={!canSubmit} onClick={handleSubmit}>
|
<Button className="w-full" disabled={!canSubmit} onClick={handleSubmit}>
|
||||||
{isSubmitting ? "Saving..." : "Complete daily quiz"}
|
{isSubmitting ? "Saving..." : "Complete daily check-in"}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,14 +23,14 @@ export function DailyQuizLinkButton() {
|
|||||||
if (!isReady || isCompleted) {
|
if (!isReady || isCompleted) {
|
||||||
return (
|
return (
|
||||||
<Button className="w-full" variant="outline" disabled>
|
<Button className="w-full" variant="outline" disabled>
|
||||||
Open daily quiz
|
Open daily check-in
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button asChild className="w-full" variant="outline">
|
<Button asChild className="w-full" variant="outline">
|
||||||
<Link href={`${routes.avatar}#bit-daily-quiz`}>Open daily quiz</Link>
|
<Link href={`${routes.avatar}#bit-daily-check-in`}>Open daily check-in</Link>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ function DailyTaskList({
|
|||||||
if (tasks.length === 0) {
|
if (tasks.length === 0) {
|
||||||
return (
|
return (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
No daily tasks yet. Complete your daily quiz or update your profile so
|
No daily tasks yet. Complete your daily check-in or update your profile so
|
||||||
AI can suggest habits for you.
|
AI can suggest habits for you.
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
@@ -450,7 +450,7 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
|
|||||||
<Card>
|
<Card>
|
||||||
<DailyTasksCardHeader
|
<DailyTasksCardHeader
|
||||||
description={`Personalized from your focus topics${
|
description={`Personalized from your focus topics${
|
||||||
quizCompletedToday ? ", today's quiz," : ""
|
quizCompletedToday ? ", today's check-in," : ""
|
||||||
} and custom habits.`}
|
} and custom habits.`}
|
||||||
onDayChange={refresh}
|
onDayChange={refresh}
|
||||||
/>
|
/>
|
||||||
@@ -508,17 +508,17 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
|
|||||||
<CardTitle className="text-base">How daily tasks are picked</CardTitle>
|
<CardTitle className="text-base">How daily tasks are picked</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Your tasks combine focus preferences, custom habits, and today's
|
Your tasks combine focus preferences, custom habits, and today's
|
||||||
quiz.
|
check-in.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3 text-sm text-muted-foreground">
|
<CardContent className="space-y-3 text-sm text-muted-foreground">
|
||||||
<p>Focus topics: {focusTopic ?? "None selected"}</p>
|
<p>Focus topics: {focusTopic ?? "None selected"}</p>
|
||||||
<p>
|
<p>
|
||||||
Daily quiz today:{" "}
|
Daily check-in today:{" "}
|
||||||
{quizCompletedToday ? "Completed" : "Not completed yet"}
|
{quizCompletedToday ? "Completed" : "Not completed yet"}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Suggested habits can appear when your quiz shows low energy, high
|
Suggested habits can appear when your check-in shows low energy, high
|
||||||
stress, or poor sleep.
|
stress, or poor sleep.
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -571,7 +571,7 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
|
|||||||
<CardTitle className="text-base">How daily tasks are picked</CardTitle>
|
<CardTitle className="text-base">How daily tasks are picked</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Your tasks combine custom habits and AI suggestions based on your
|
Your tasks combine custom habits and AI suggestions based on your
|
||||||
profile, onboarding, daily quiz, and journal.
|
profile, onboarding, daily check-in, and journal.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3 text-sm text-muted-foreground">
|
<CardContent className="space-y-3 text-sm text-muted-foreground">
|
||||||
@@ -580,7 +580,7 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
|
|||||||
{focusTopic ?? "None selected"}
|
{focusTopic ?? "None selected"}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Daily quiz today:{" "}
|
Daily check-in today:{" "}
|
||||||
{quizCompletedToday ? "Completed" : "Not completed yet"}
|
{quizCompletedToday ? "Completed" : "Not completed yet"}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
@@ -596,7 +596,7 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
|
|||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<DailyTasksCardHeader
|
<DailyTasksCardHeader
|
||||||
description="Personalized from your profile, daily quiz, journal, and custom habits."
|
description="Personalized from your profile, daily check-in, journal, and custom habits."
|
||||||
onDayChange={refresh}
|
onDayChange={refresh}
|
||||||
/>
|
/>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
|
|||||||
329
components/journal/journal-calendar.tsx
Normal file
329
components/journal/journal-calendar.tsx
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ChevronDown, ChevronLeft, ChevronRight, ChevronUp } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { JournalEntryModal } from "@/components/journal/journal-entry-modal";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events";
|
||||||
|
import type { DailyQuizSubmission } from "@/lib/avatar-state";
|
||||||
|
import {
|
||||||
|
getDailyEntryByDate,
|
||||||
|
getDailyEntrySummariesForMonth,
|
||||||
|
getTodayDateKey,
|
||||||
|
type DailyEntrySummary,
|
||||||
|
} from "@/lib/daily-quiz-storage";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||||
|
|
||||||
|
type CalendarCell = {
|
||||||
|
dateKey: string;
|
||||||
|
day: number;
|
||||||
|
inMonth: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildMonthGrid(year: number, month: number): CalendarCell[] {
|
||||||
|
const firstDay = new Date(year, month, 1);
|
||||||
|
const lastDay = new Date(year, month + 1, 0);
|
||||||
|
const grid: CalendarCell[] = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < firstDay.getDay(); index += 1) {
|
||||||
|
const date = new Date(year, month, index - firstDay.getDay() + 1);
|
||||||
|
grid.push({
|
||||||
|
dateKey: date.toLocaleDateString("en-CA"),
|
||||||
|
day: date.getDate(),
|
||||||
|
inMonth: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let day = 1; day <= lastDay.getDate(); day += 1) {
|
||||||
|
const date = new Date(year, month, day);
|
||||||
|
grid.push({
|
||||||
|
dateKey: date.toLocaleDateString("en-CA"),
|
||||||
|
day,
|
||||||
|
inMonth: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let trailingDay = 1;
|
||||||
|
while (grid.length % 7 !== 0) {
|
||||||
|
const date = new Date(year, month + 1, trailingDay);
|
||||||
|
grid.push({
|
||||||
|
dateKey: date.toLocaleDateString("en-CA"),
|
||||||
|
day: date.getDate(),
|
||||||
|
inMonth: false,
|
||||||
|
});
|
||||||
|
trailingDay += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMonthLabel(year: number, month: number) {
|
||||||
|
return new Date(year, month, 1).toLocaleDateString(undefined, {
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JournalCalendar() {
|
||||||
|
const todayKey = getTodayDateKey();
|
||||||
|
const todayDate = new Date();
|
||||||
|
const [visibleYear, setVisibleYear] = useState(todayDate.getFullYear());
|
||||||
|
const [visibleMonth, setVisibleMonth] = useState(todayDate.getMonth());
|
||||||
|
const [summaries, setSummaries] = useState<DailyEntrySummary[]>([]);
|
||||||
|
const [selectedDateKey, setSelectedDateKey] = useState<string | null>(null);
|
||||||
|
const [selectedEntry, setSelectedEntry] = useState<DailyQuizSubmission | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
const [isLoadingMonth, setIsLoadingMonth] = useState(false);
|
||||||
|
const [isLoadingEntry, setIsLoadingEntry] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const summaryByDate = useMemo(
|
||||||
|
() => new Map(summaries.map((summary) => [summary.date, summary])),
|
||||||
|
[summaries],
|
||||||
|
);
|
||||||
|
|
||||||
|
const monthGrid = useMemo(
|
||||||
|
() => buildMonthGrid(visibleYear, visibleMonth),
|
||||||
|
[visibleYear, visibleMonth],
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadMonth = useCallback(async () => {
|
||||||
|
setIsLoadingMonth(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSummaries(
|
||||||
|
await getDailyEntrySummariesForMonth(visibleYear, visibleMonth),
|
||||||
|
);
|
||||||
|
} catch (loadError) {
|
||||||
|
setError(
|
||||||
|
loadError instanceof Error
|
||||||
|
? loadError.message
|
||||||
|
: "Could not load journal history.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingMonth(false);
|
||||||
|
}
|
||||||
|
}, [visibleMonth, visibleYear]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isExpanded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadMonth();
|
||||||
|
}, [isExpanded, loadMonth]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isExpanded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDataUpdated = () => {
|
||||||
|
void loadMonth();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener(HABIT_PET_DATA_UPDATED_EVENT, handleDataUpdated);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(HABIT_PET_DATA_UPDATED_EVENT, handleDataUpdated);
|
||||||
|
};
|
||||||
|
}, [isExpanded, loadMonth]);
|
||||||
|
|
||||||
|
const goToPreviousMonth = () => {
|
||||||
|
if (visibleMonth === 0) {
|
||||||
|
setVisibleYear((current) => current - 1);
|
||||||
|
setVisibleMonth(11);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setVisibleMonth((current) => current - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToNextMonth = () => {
|
||||||
|
if (visibleMonth === 11) {
|
||||||
|
setVisibleYear((current) => current + 1);
|
||||||
|
setVisibleMonth(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setVisibleMonth((current) => current + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDaySelect = async (dateKey: string) => {
|
||||||
|
const summary = summaryByDate.get(dateKey);
|
||||||
|
|
||||||
|
if (!summary) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedDateKey(dateKey);
|
||||||
|
setIsLoadingEntry(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSelectedEntry(await getDailyEntryByDate(dateKey));
|
||||||
|
} catch (loadError) {
|
||||||
|
setSelectedDateKey(null);
|
||||||
|
setError(
|
||||||
|
loadError instanceof Error
|
||||||
|
? loadError.message
|
||||||
|
: "Could not load that journal entry.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingEntry(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeEntryModal = () => {
|
||||||
|
setSelectedEntry(null);
|
||||||
|
setSelectedDateKey(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="min-w-0 flex-1 text-left"
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
onClick={() => setIsExpanded((current) => !current)}
|
||||||
|
>
|
||||||
|
<CardTitle className="text-base">Journal history</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Browse past check-ins and journal entries.
|
||||||
|
</CardDescription>
|
||||||
|
</button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="shrink-0"
|
||||||
|
aria-label={isExpanded ? "Collapse journal history" : "Expand journal history"}
|
||||||
|
onClick={() => setIsExpanded((current) => !current)}
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
{isExpanded ? (
|
||||||
|
<CardContent className="space-y-4 border-t pt-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{formatMonthLabel(visibleYear, visibleMonth)}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Previous month"
|
||||||
|
onClick={goToPreviousMonth}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Next month"
|
||||||
|
onClick={goToNextMonth}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-7 gap-1 text-center text-xs text-muted-foreground">
|
||||||
|
{WEEKDAY_LABELS.map((label) => (
|
||||||
|
<div key={label} className="py-1 font-medium">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-7 gap-1">
|
||||||
|
{monthGrid.map((cell) => {
|
||||||
|
const summary = summaryByDate.get(cell.dateKey);
|
||||||
|
const hasEntry = Boolean(summary);
|
||||||
|
const isToday = cell.dateKey === todayKey;
|
||||||
|
const isSelected = cell.dateKey === selectedDateKey;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={cell.dateKey}
|
||||||
|
type="button"
|
||||||
|
disabled={!hasEntry || isLoadingEntry}
|
||||||
|
aria-label={
|
||||||
|
hasEntry
|
||||||
|
? `View journal entry for ${cell.dateKey}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onClick={() => void handleDaySelect(cell.dateKey)}
|
||||||
|
className={cn(
|
||||||
|
"relative flex aspect-square flex-col items-center justify-center rounded-md text-sm transition-colors",
|
||||||
|
cell.inMonth
|
||||||
|
? "text-foreground"
|
||||||
|
: "text-muted-foreground/45",
|
||||||
|
hasEntry
|
||||||
|
? "cursor-pointer hover:bg-muted/60"
|
||||||
|
: "cursor-default",
|
||||||
|
isToday && "ring-1 ring-primary/50",
|
||||||
|
isSelected && "bg-primary/10 ring-1 ring-primary",
|
||||||
|
!hasEntry && "opacity-70",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span>{cell.day}</span>
|
||||||
|
{hasEntry ? (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn(
|
||||||
|
"absolute bottom-1 h-1.5 w-1.5 rounded-full",
|
||||||
|
summary?.hasJournal ? "bg-primary" : "bg-muted-foreground/50",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-primary" />
|
||||||
|
Journal written
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-muted-foreground/50" />
|
||||||
|
Check-in only
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoadingMonth ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Loading calendar...</p>
|
||||||
|
) : null}
|
||||||
|
{error ? <p className="text-sm text-red-500">{error}</p> : null}
|
||||||
|
</CardContent>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<JournalEntryModal entry={selectedEntry} onClose={closeEntryModal} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
147
components/journal/journal-entry-modal.tsx
Normal file
147
components/journal/journal-entry-modal.tsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
import { WellnessSlider } from "@/components/daily-quiz/wellness-slider";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
formatSleepHours,
|
||||||
|
SLEEP_HOURS_MAX,
|
||||||
|
SLEEP_HOURS_MIN,
|
||||||
|
SLEEP_HOURS_STEP,
|
||||||
|
type DailyQuizSubmission,
|
||||||
|
} from "@/lib/avatar-state";
|
||||||
|
|
||||||
|
type JournalEntryModalProps = {
|
||||||
|
entry: DailyQuizSubmission | null;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatEntryDateLabel(dateKey: string) {
|
||||||
|
const [year, month, day] = dateKey.split("-").map(Number);
|
||||||
|
const date = new Date(year, month - 1, day);
|
||||||
|
|
||||||
|
return date.toLocaleDateString(undefined, {
|
||||||
|
weekday: "long",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JournalEntryModal({ entry, onClose }: JournalEntryModalProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!entry) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [entry, onClose]);
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const journalText = entry.journal.trim();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[70] flex items-end justify-center bg-black/70 p-4 sm:items-center"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="journal-entry-title"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="max-h-[85vh] w-full max-w-lg overflow-y-auto rounded-xl border bg-background p-5 shadow-xl"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
Journal entry
|
||||||
|
</p>
|
||||||
|
<h2
|
||||||
|
id="journal-entry-title"
|
||||||
|
className="truncate text-lg font-semibold tracking-tight"
|
||||||
|
>
|
||||||
|
{formatEntryDateLabel(entry.date)}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="shrink-0"
|
||||||
|
aria-label="Close journal entry"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 space-y-5">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium">Daily journal</p>
|
||||||
|
<p className="whitespace-pre-wrap rounded-lg border bg-muted/20 px-3 py-3 text-sm text-foreground">
|
||||||
|
{journalText || "No journal written."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 rounded-lg border px-3 py-4">
|
||||||
|
<p className="text-sm font-medium">Wellness</p>
|
||||||
|
<WellnessSlider
|
||||||
|
id="journal-feeling"
|
||||||
|
label="How were you feeling?"
|
||||||
|
value={entry.answers.feeling}
|
||||||
|
disabled
|
||||||
|
onChange={() => undefined}
|
||||||
|
/>
|
||||||
|
<WellnessSlider
|
||||||
|
id="journal-stress"
|
||||||
|
label="Stress level"
|
||||||
|
value={entry.answers.stress}
|
||||||
|
disabled
|
||||||
|
onChange={() => undefined}
|
||||||
|
/>
|
||||||
|
<WellnessSlider
|
||||||
|
id="journal-energy"
|
||||||
|
label="Energy level"
|
||||||
|
value={entry.answers.energy}
|
||||||
|
disabled
|
||||||
|
onChange={() => undefined}
|
||||||
|
/>
|
||||||
|
<WellnessSlider
|
||||||
|
id="journal-sleep-length"
|
||||||
|
label="Sleep length"
|
||||||
|
value={entry.answers.sleepLength}
|
||||||
|
min={SLEEP_HOURS_MIN}
|
||||||
|
max={SLEEP_HOURS_MAX}
|
||||||
|
step={SLEEP_HOURS_STEP}
|
||||||
|
formatValue={formatSleepHours}
|
||||||
|
disabled
|
||||||
|
onChange={() => undefined}
|
||||||
|
/>
|
||||||
|
<WellnessSlider
|
||||||
|
id="journal-sleep-quality"
|
||||||
|
label="Sleep quality"
|
||||||
|
value={entry.answers.sleepQuality}
|
||||||
|
disabled
|
||||||
|
onChange={() => undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -247,13 +247,13 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) {
|
|||||||
try {
|
try {
|
||||||
await resetTodaysDailyQuiz();
|
await resetTodaysDailyQuiz();
|
||||||
setQuizResetMessage(
|
setQuizResetMessage(
|
||||||
"Today's quiz was reset. Open the Quiz tab to take it again.",
|
"Today's check-in was reset. Open the Bit page to complete it again.",
|
||||||
);
|
);
|
||||||
} catch (resetError) {
|
} catch (resetError) {
|
||||||
setError(
|
setError(
|
||||||
resetError instanceof Error
|
resetError instanceof Error
|
||||||
? resetError.message
|
? resetError.message
|
||||||
: "Could not reset today's quiz.",
|
: "Could not reset today's check-in.",
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsResettingQuiz(false);
|
setIsResettingQuiz(false);
|
||||||
@@ -337,7 +337,7 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Notification preferences</CardTitle>
|
<CardTitle className="text-base">Notification preferences</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Control reminders for your daily quiz and habits.
|
Control reminders for your daily check-in and habits.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
@@ -425,7 +425,7 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Reset clears today's wellness quiz entry so you can complete it
|
Reset clears today's wellness check-in entry so you can complete it
|
||||||
again immediately. Demo points are for testing the shop only.
|
again immediately. Demo points are for testing the shop only.
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
@@ -449,7 +449,7 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) {
|
|||||||
disabled={isSaving || isResettingQuiz}
|
disabled={isSaving || isResettingQuiz}
|
||||||
onClick={() => void handleResetDailyQuiz()}
|
onClick={() => void handleResetDailyQuiz()}
|
||||||
>
|
>
|
||||||
{isResettingQuiz ? "Resetting..." : "Reset today's quiz"}
|
{isResettingQuiz ? "Resetting..." : "Reset today's check-in"}
|
||||||
</Button>
|
</Button>
|
||||||
{quizResetMessage ? (
|
{quizResetMessage ? (
|
||||||
<p className="text-xs text-emerald-600">{quizResetMessage}</p>
|
<p className="text-xs text-emerald-600">{quizResetMessage}</p>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
} from "@/lib/shop-storage";
|
} from "@/lib/shop-storage";
|
||||||
import {
|
import {
|
||||||
getLayerLabel,
|
getLayerLabel,
|
||||||
|
getShopItemDisplayName,
|
||||||
type ShopItemRecord,
|
type ShopItemRecord,
|
||||||
type ShopLayerId,
|
type ShopLayerId,
|
||||||
} from "@/lib/shop-catalog";
|
} from "@/lib/shop-catalog";
|
||||||
@@ -103,6 +104,8 @@ function ShopItemCard({
|
|||||||
? null
|
? null
|
||||||
: getShopItemThumbnailColors(item, characterColors);
|
: getShopItemThumbnailColors(item, characterColors);
|
||||||
|
|
||||||
|
const displayName = getShopItemDisplayName(item);
|
||||||
|
|
||||||
const actionLabel = (() => {
|
const actionLabel = (() => {
|
||||||
if (isPending) {
|
if (isPending) {
|
||||||
return owned ? "Updating..." : "Processing...";
|
return owned ? "Updating..." : "Processing...";
|
||||||
@@ -123,27 +126,33 @@ function ShopItemCard({
|
|||||||
<Card className="flex h-full flex-col overflow-hidden">
|
<Card className="flex h-full flex-col overflow-hidden">
|
||||||
<CardContent className="flex h-full flex-col gap-3 p-3">
|
<CardContent className="flex h-full flex-col gap-3 p-3">
|
||||||
<div className="flex min-w-0 items-start gap-3">
|
<div className="flex min-w-0 items-start gap-3">
|
||||||
<div className="flex size-12 shrink-0 items-center justify-center overflow-hidden border-2 border-border bg-zinc-950/80 p-1">
|
<div className="shop-item-thumbnail relative flex size-12 shrink-0 items-center justify-center overflow-hidden border-2 border-border p-1">
|
||||||
{room ? (
|
{thumbnailColors || room ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
<div aria-hidden className="shop-item-thumbnail-glow pointer-events-none absolute inset-0" />
|
||||||
<img
|
|
||||||
src={item.image_path || room.sceneImage}
|
|
||||||
alt=""
|
|
||||||
className="size-full object-cover image-pixelated"
|
|
||||||
/>
|
|
||||||
) : thumbnailColors ? (
|
|
||||||
<TintedSpriteIcon
|
|
||||||
src={item.image_path}
|
|
||||||
color={thumbnailColors.color}
|
|
||||||
skinColor={thumbnailColors.skinColor}
|
|
||||||
size={32}
|
|
||||||
/>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
<div className="relative z-10 flex size-full items-center justify-center">
|
||||||
|
{room ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={item.image_path || room.sceneImage}
|
||||||
|
alt=""
|
||||||
|
className="size-full object-cover image-pixelated"
|
||||||
|
/>
|
||||||
|
) : thumbnailColors ? (
|
||||||
|
<TintedSpriteIcon
|
||||||
|
src={item.image_path}
|
||||||
|
color={thumbnailColors.color}
|
||||||
|
skinColor={thumbnailColors.skinColor}
|
||||||
|
size={32}
|
||||||
|
className="drop-shadow-[0_0_3px_hsl(52_55%_90%_/_0.85)]"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-1 items-start justify-between gap-2">
|
<div className="flex min-w-0 flex-1 items-start justify-between gap-2">
|
||||||
<p className="min-w-0 pt-1 text-sm font-medium leading-snug">
|
<p className="min-w-0 pt-1 text-sm font-medium leading-snug">
|
||||||
{item.name}
|
{displayName}
|
||||||
</p>
|
</p>
|
||||||
<ShopItemPriceBadge
|
<ShopItemPriceBadge
|
||||||
price={item.price}
|
price={item.price}
|
||||||
@@ -249,7 +258,7 @@ export function ShopContent() {
|
|||||||
try {
|
try {
|
||||||
const item = await purchaseShopItem(itemId);
|
const item = await purchaseShopItem(itemId);
|
||||||
await equipShopItem(itemId);
|
await equipShopItem(itemId);
|
||||||
toast(`Purchased and equipped ${item.name}.`, "success");
|
toast(`Purchased and equipped ${getShopItemDisplayName(item)}.`, "success");
|
||||||
await refreshShop();
|
await refreshShop();
|
||||||
} catch (purchaseError) {
|
} catch (purchaseError) {
|
||||||
toast(
|
toast(
|
||||||
@@ -274,10 +283,10 @@ export function ShopContent() {
|
|||||||
|
|
||||||
if (item.type !== "room" && isEquipped) {
|
if (item.type !== "room" && isEquipped) {
|
||||||
await unequipShopItem(item.id);
|
await unequipShopItem(item.id);
|
||||||
toast(`Unequipped ${item.name}.`, "default");
|
toast(`Unequipped ${getShopItemDisplayName(item)}.`, "default");
|
||||||
} else if (!isEquipped) {
|
} else if (!isEquipped) {
|
||||||
await equipShopItem(item.id);
|
await equipShopItem(item.id);
|
||||||
toast(`Equipped ${item.name}.`, "success");
|
toast(`Equipped ${getShopItemDisplayName(item)}.`, "success");
|
||||||
}
|
}
|
||||||
|
|
||||||
await refreshShop();
|
await refreshShop();
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
buildShopItemPreviewCustomization,
|
buildShopItemPreviewCustomization,
|
||||||
getShopPreviewRoomId,
|
getShopPreviewRoomId,
|
||||||
} from "@/lib/shop-preview";
|
} from "@/lib/shop-preview";
|
||||||
import type { ShopItemRecord } from "@/lib/shop-catalog";
|
import { getShopItemDisplayName, type ShopItemRecord } from "@/lib/shop-catalog";
|
||||||
import { routes } from "@/lib/routes";
|
import { routes } from "@/lib/routes";
|
||||||
|
|
||||||
type ShopItemPreviewModalProps = {
|
type ShopItemPreviewModalProps = {
|
||||||
@@ -89,7 +89,7 @@ export function ShopItemPreviewModal({
|
|||||||
>
|
>
|
||||||
Preview on your bit
|
Preview on your bit
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 truncate text-sm">{item.name}</p>
|
<p className="mt-1 truncate text-sm">{getShopItemDisplayName(item)}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -61,25 +61,25 @@ function presetFromHex(id: string, name: string, hex: string): ColorPreset {
|
|||||||
|
|
||||||
export const VARIANT_DISPLAY_NAMES: Record<string, string> = {
|
export const VARIANT_DISPLAY_NAMES: Record<string, string> = {
|
||||||
"skin-1": "Default Form",
|
"skin-1": "Default Form",
|
||||||
"pants-1": "Slim Fit Jeans",
|
"pants-1": "Shorts",
|
||||||
"pants-2": "Rolled Cuff Shorts",
|
"pants-2": "Pants",
|
||||||
"pants-3": "Pleated Skirt",
|
"pants-3": "Skirt",
|
||||||
"shoes-1": "Canvas Sneakers",
|
"shoes-1": "Boots",
|
||||||
"shoes-2": "Chunky High-Tops",
|
"shoes-2": "Shoes",
|
||||||
"shoes-3": "Neon Glow Runners",
|
"shoes-3": "Loafers",
|
||||||
"torso-1": "Crew Neck Tee",
|
"torso-1": "T-Shirt",
|
||||||
"torso-2": "Zip-Up Hoodie",
|
"torso-2": "Button-up",
|
||||||
"torso-3": "Button-Up Shirt",
|
"torso-3": "Vest",
|
||||||
"torso-4": "Crimson Blouse",
|
"torso-4": "Robe",
|
||||||
"eyes-1": "Round Gentle Eyes",
|
"eyes-1": "Eyes",
|
||||||
"eyes-2": "Starry Spark Eyes",
|
"eyes-2": "Eyelashes",
|
||||||
"head-1": "Spiked Mohawk",
|
"head-1": "Short",
|
||||||
"head-2": "Cozy Knit Beanie",
|
"head-2": "Short-medium",
|
||||||
"head-3": "Retro Snapback Cap",
|
"head-3": "Mohawk",
|
||||||
"head-4": "Long Hair",
|
"head-4": "Long",
|
||||||
"head-5": "Shoulder-Length Hair",
|
"head-5": "Pixie",
|
||||||
"head-6": "Flowing Side Locks",
|
"head-6": "Middle-part",
|
||||||
"head-7": "Messy Shag Cut",
|
"head-7": "Bun",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getVariantDisplayName(variantId: string, fallbackLabel = variantId) {
|
export function getVariantDisplayName(variantId: string, fallbackLabel = variantId) {
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export async function resetTodaysDailyQuiz() {
|
|||||||
const userId = await getAuthenticatedUserId();
|
const userId = await getAuthenticatedUserId();
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
throw new Error("You must be signed in to reset today's quiz.");
|
throw new Error("You must be signed in to reset today's check-in.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
@@ -193,6 +193,89 @@ export async function saveDailyQuizSubmission(
|
|||||||
return saveDailyEntry(answers, journal);
|
return saveDailyEntry(answers, journal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DailyEntrySummary = {
|
||||||
|
date: string;
|
||||||
|
hasJournal: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DAILY_ENTRY_SELECT =
|
||||||
|
"entry_date, mood, stress, energy, sleep_hours, sleep_quality, journal";
|
||||||
|
|
||||||
|
function formatDateKey(date: Date) {
|
||||||
|
return date.toLocaleDateString("en-CA");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMonthDateBounds(year: number, month: number) {
|
||||||
|
const monthStart = new Date(year, month, 1);
|
||||||
|
const monthEnd = new Date(year, month + 1, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
startKey: formatDateKey(monthStart),
|
||||||
|
endKey: formatDateKey(monthEnd),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDailyEntrySummariesForMonth(
|
||||||
|
year: number,
|
||||||
|
month: number,
|
||||||
|
): Promise<DailyEntrySummary[]> {
|
||||||
|
const userId = await getAuthenticatedUserId();
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { startKey, endKey } = getMonthDateBounds(year, month);
|
||||||
|
const supabase = createClient();
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("daily_entries")
|
||||||
|
.select("entry_date, journal")
|
||||||
|
.eq("user_id", userId)
|
||||||
|
.gte("entry_date", startKey)
|
||||||
|
.lte("entry_date", endKey)
|
||||||
|
.order("entry_date", { ascending: true });
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new Error(error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (data ?? []).map((row) => ({
|
||||||
|
date: row.entry_date as string,
|
||||||
|
hasJournal: Boolean((row.journal as string | null)?.trim()),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDailyEntryByDate(
|
||||||
|
dateKey: string,
|
||||||
|
): Promise<DailyQuizSubmission | null> {
|
||||||
|
const userId = await getAuthenticatedUserId();
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = createClient();
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("daily_entries")
|
||||||
|
.select(DAILY_ENTRY_SELECT)
|
||||||
|
.eq("user_id", userId)
|
||||||
|
.eq("entry_date", dateKey)
|
||||||
|
.order("created_at", { ascending: false })
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new Error(error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = data?.[0];
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapRowToSubmission(row as DailyEntryRow);
|
||||||
|
}
|
||||||
|
|
||||||
export async function getAvatarConditionForToday(): Promise<AvatarCondition | null> {
|
export async function getAvatarConditionForToday(): Promise<AvatarCondition | null> {
|
||||||
const [entry, completedHabitLabels] = await Promise.all([
|
const [entry, completedHabitLabels] = await Promise.all([
|
||||||
getDailyEntryForToday(),
|
getDailyEntryForToday(),
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export function buildDailyReminderNotification(status: DailyReminderStatus) {
|
|||||||
if (!status.quizCompleted && status.incompleteTaskCount > 0) {
|
if (!status.quizCompleted && status.incompleteTaskCount > 0) {
|
||||||
return {
|
return {
|
||||||
title: "Time for your daily check-in",
|
title: "Time for your daily check-in",
|
||||||
body: `Complete today's wellness quiz and ${status.incompleteTaskCount} remaining habit${status.incompleteTaskCount === 1 ? "" : "s"}.`,
|
body: `Complete today's wellness check-in and ${status.incompleteTaskCount} remaining habit${status.incompleteTaskCount === 1 ? "" : "s"}.`,
|
||||||
targetUrl,
|
targetUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -129,7 +129,7 @@ export function buildDailyReminderNotification(status: DailyReminderStatus) {
|
|||||||
if (!status.quizCompleted) {
|
if (!status.quizCompleted) {
|
||||||
return {
|
return {
|
||||||
title: "Time for your daily check-in",
|
title: "Time for your daily check-in",
|
||||||
body: "Complete today's wellness quiz to keep your bit in good spirits.",
|
body: "Complete today's wellness check-in to keep your bit in good spirits.",
|
||||||
targetUrl,
|
targetUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,25 +19,25 @@ export const ROOM_SCENE_IMAGE_PATH = (id: RoomBackgroundId) =>
|
|||||||
export const ROOM_BACKGROUNDS: RoomBackground[] = [
|
export const ROOM_BACKGROUNDS: RoomBackground[] = [
|
||||||
{
|
{
|
||||||
id: "room-1",
|
id: "room-1",
|
||||||
name: "Sunny Field",
|
name: "Plains",
|
||||||
free: true,
|
free: true,
|
||||||
sceneImage: ROOM_SCENE_IMAGE_PATH("room-1"),
|
sceneImage: ROOM_SCENE_IMAGE_PATH("room-1"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "room-2",
|
id: "room-2",
|
||||||
name: "Golden Dusk",
|
name: "Sunset",
|
||||||
free: true,
|
free: true,
|
||||||
sceneImage: ROOM_SCENE_IMAGE_PATH("room-2"),
|
sceneImage: ROOM_SCENE_IMAGE_PATH("room-2"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "room-3",
|
id: "room-3",
|
||||||
name: "Starlit Night",
|
name: "Forest",
|
||||||
free: false,
|
free: false,
|
||||||
sceneImage: ROOM_SCENE_IMAGE_PATH("room-3"),
|
sceneImage: ROOM_SCENE_IMAGE_PATH("room-3"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "room-4",
|
id: "room-4",
|
||||||
name: "Mystic Forest",
|
name: "Night",
|
||||||
free: false,
|
free: false,
|
||||||
sceneImage: ROOM_SCENE_IMAGE_PATH("room-4"),
|
sceneImage: ROOM_SCENE_IMAGE_PATH("room-4"),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export const routes = {
|
|||||||
avatar: "/avatar",
|
avatar: "/avatar",
|
||||||
customize: "/customize",
|
customize: "/customize",
|
||||||
habits: "/habits",
|
habits: "/habits",
|
||||||
|
dailyCheckIn: "/daily/check-in",
|
||||||
dailyQuiz: "/daily/quiz",
|
dailyQuiz: "/daily/quiz",
|
||||||
shop: "/shop",
|
shop: "/shop",
|
||||||
profile: "/profile",
|
profile: "/profile",
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { isShopOnlyStyleVariant } from "@/lib/character/variant-access";
|
import { isShopOnlyStyleVariant } from "@/lib/character/variant-access";
|
||||||
import type { CharacterLayerId } from "@/lib/character/presets";
|
import type { CharacterLayerId } from "@/lib/character/presets";
|
||||||
import { CHARACTER_LAYERS, NONE_VARIANT_ID } from "@/lib/character/presets";
|
import {
|
||||||
import { getShopRoomBackgrounds } from "@/lib/room-backgrounds";
|
CHARACTER_LAYERS,
|
||||||
|
getVariantDisplayName,
|
||||||
|
NONE_VARIANT_ID,
|
||||||
|
} from "@/lib/character/presets";
|
||||||
|
import { getRoomBackground, getShopRoomBackgrounds } from "@/lib/room-backgrounds";
|
||||||
|
|
||||||
export type ShopLayerId = Exclude<CharacterLayerId, "skin">;
|
export type ShopLayerId = Exclude<CharacterLayerId, "skin">;
|
||||||
|
|
||||||
@@ -95,6 +99,14 @@ export function getLayerLabel(layerId: ShopLayerId) {
|
|||||||
return CHARACTER_LAYERS.find((layer) => layer.id === layerId)?.label ?? layerId;
|
return CHARACTER_LAYERS.find((layer) => layer.id === layerId)?.label ?? layerId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getShopItemDisplayName(item: ShopItemRecord): string {
|
||||||
|
if (item.type === "room") {
|
||||||
|
return getRoomBackground(item.id).name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getVariantDisplayName(item.id, item.name);
|
||||||
|
}
|
||||||
|
|
||||||
export function findShopItemById(
|
export function findShopItemById(
|
||||||
items: readonly ShopItemRecord[],
|
items: readonly ShopItemRecord[],
|
||||||
itemId: string,
|
itemId: string,
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
-- Align shop item display names with sprite-based labels.
|
||||||
|
|
||||||
|
update public.shop_items set name = 'Plains' where id = 'room-1';
|
||||||
|
update public.shop_items set name = 'Sunset' where id = 'room-2';
|
||||||
|
update public.shop_items set name = 'Forest' where id = 'room-3';
|
||||||
|
update public.shop_items set name = 'Night' where id = 'room-4';
|
||||||
|
|
||||||
|
update public.shop_items set name = 'Eyes' where id = 'eyes-1';
|
||||||
|
update public.shop_items set name = 'Eyelashes' where id = 'eyes-2';
|
||||||
|
|
||||||
|
update public.shop_items set name = 'Short' where id = 'head-1';
|
||||||
|
update public.shop_items set name = 'Short-medium' where id = 'head-2';
|
||||||
|
update public.shop_items set name = 'Mohawk' where id = 'head-3';
|
||||||
|
update public.shop_items set name = 'Long' where id = 'head-4';
|
||||||
|
update public.shop_items set name = 'Pixie' where id = 'head-5';
|
||||||
|
update public.shop_items set name = 'Middle-part' where id = 'head-6';
|
||||||
|
update public.shop_items set name = 'Bun' where id = 'head-7';
|
||||||
|
|
||||||
|
update public.shop_items set name = 'T-Shirt' where id = 'torso-1';
|
||||||
|
update public.shop_items set name = 'Button-up' where id = 'torso-2';
|
||||||
|
update public.shop_items set name = 'Vest' where id = 'torso-3';
|
||||||
|
update public.shop_items set name = 'Robe' where id = 'torso-4';
|
||||||
|
|
||||||
|
update public.shop_items set name = 'Shorts' where id = 'pants-1';
|
||||||
|
update public.shop_items set name = 'Pants' where id = 'pants-2';
|
||||||
|
update public.shop_items set name = 'Skirt' where id = 'pants-3';
|
||||||
|
|
||||||
|
update public.shop_items set name = 'Boots' where id = 'shoes-1';
|
||||||
|
update public.shop_items set name = 'Shoes' where id = 'shoes-2';
|
||||||
|
update public.shop_items set name = 'Loafers' where id = 'shoes-3';
|
||||||
@@ -1,40 +1,18 @@
|
|||||||
-- Character layer styles sold in the shop (id = variant id, type = layer id).
|
-- Shop-only catalog items (variant ids 3+ and paid rooms).
|
||||||
|
|
||||||
delete from public.user_items
|
|
||||||
where item_id in ('hat_blue', 'glasses_round', 'room_sunset', 'room-space', 'room-neon');
|
|
||||||
|
|
||||||
delete from public.shop_items
|
|
||||||
where id in (
|
|
||||||
'hat_blue',
|
|
||||||
'glasses_round',
|
|
||||||
'room_sunset',
|
|
||||||
'room-space',
|
|
||||||
'room-neon',
|
|
||||||
'pants-1',
|
|
||||||
'pants-2',
|
|
||||||
'shoes-1',
|
|
||||||
'shoes-2',
|
|
||||||
'torso-1',
|
|
||||||
'torso-2',
|
|
||||||
'eyes-1',
|
|
||||||
'eyes-2',
|
|
||||||
'head-1',
|
|
||||||
'head-2'
|
|
||||||
);
|
|
||||||
|
|
||||||
insert into public.shop_items (id, name, type, price, image_path)
|
insert into public.shop_items (id, name, type, price, image_path)
|
||||||
values
|
values
|
||||||
('pants-3', 'Pleated Skirt', 'pants', 30, '/character/pants/pants-3.png'),
|
('head-3', 'Mohawk', 'head', 40, '/character/head/head-3.png'),
|
||||||
('shoes-3', 'Neon Glow Runners', 'shoes', 30, '/character/shoes/shoes-3.png'),
|
('head-4', 'Long', 'head', 45, '/character/head/head-4.png'),
|
||||||
('torso-3', 'Button-Up Shirt', 'torso', 35, '/character/torso/torso-3.png'),
|
('head-5', 'Pixie', 'head', 50, '/character/head/head-5.png'),
|
||||||
('torso-4', 'Crimson Blouse', 'torso', 40, '/character/torso/torso-4.png'),
|
('head-6', 'Middle-part', 'head', 55, '/character/head/head-6.png'),
|
||||||
('head-3', 'Retro Snapback Cap', 'head', 40, '/character/head/head-3.png'),
|
('head-7', 'Bun', 'head', 60, '/character/head/head-7.png'),
|
||||||
('head-4', 'Long Hair', 'head', 45, '/character/head/head-4.png'),
|
('torso-3', 'Vest', 'torso', 35, '/character/torso/torso-3.png'),
|
||||||
('head-5', 'Shoulder-Length Hair', 'head', 50, '/character/head/head-5.png'),
|
('torso-4', 'Robe', 'torso', 40, '/character/torso/torso-4.png'),
|
||||||
('head-6', 'Flowing Side Locks', 'head', 55, '/character/head/head-6.png'),
|
('pants-3', 'Skirt', 'pants', 30, '/character/pants/pants-3.png'),
|
||||||
('head-7', 'Messy Shag Cut', 'head', 60, '/character/head/head-7.png'),
|
('shoes-3', 'Loafers', 'shoes', 30, '/character/shoes/shoes-3.png'),
|
||||||
('room-3', 'Starlit Night', 'room', 40, '/room/room-3.png'),
|
('room-3', 'Forest', 'room', 40, '/room/room-3.png'),
|
||||||
('room-4', 'Mystic Forest', 'room', 50, '/room/room-4.png')
|
('room-4', 'Night', 'room', 50, '/room/room-4.png')
|
||||||
on conflict (id) do update
|
on conflict (id) do update
|
||||||
set
|
set
|
||||||
name = excluded.name,
|
name = excluded.name,
|
||||||
|
|||||||
Reference in New Issue
Block a user