diff --git a/AGENTS.md b/AGENTS.md index ec96444..b6cab1e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -278,3 +278,5 @@ Before every feature: - Do not install new libraries without approval. - Don't change the design of the Nav, only add the routing functionality. + +- Keep 'leading-6' in all components, and fix if you find any un-matching ones. diff --git a/app/insights/report/[periodType].tsx b/app/insights/report/[periodType].tsx index 591dc49..9efc162 100644 --- a/app/insights/report/[periodType].tsx +++ b/app/insights/report/[periodType].tsx @@ -294,7 +294,7 @@ export default function AIInsightReportScreen() { prompt={reportState.report.narrative.reflectionPrompt} /> - ) : reportState.legacyReportAvailable ? ( + ) : reportState.error ? null : reportState.legacyReportAvailable ? ( state.activeUserId); const entries = useJournalStore((state) => state.entries); - const journalHasHydrated = useJournalStore((state) => state.hasHydrated); - const achievementHasHydrated = useAchievementStore( + const journalHasHydrated = useJournalHydrationStore( + (state) => state.hasHydrated, + ); + const achievementHasHydrated = useAchievementHydrationStore( (state) => state.hasHydrated, ); const achievementSyncUserId = useAchievementStore( diff --git a/components/achievements/AchievementsScreen.tsx b/components/achievements/AchievementsScreen.tsx index 92737bc..4d2abbb 100644 --- a/components/achievements/AchievementsScreen.tsx +++ b/components/achievements/AchievementsScreen.tsx @@ -14,11 +14,16 @@ import { import { useSafeAreaInsets } from "react-native-safe-area-context"; import { ScreenEmptyState } from "@/components/states/ScreenEmptyState"; +import { ScreenErrorState } from "@/components/states/ScreenErrorState"; import { ScreenLoadingState } from "@/components/states/ScreenLoadingState"; import { AnimatedIconButton } from "@/components/ui/animated-icon-button"; import { useDelayedVisibility } from "@/hooks/useDelayedVisibility"; import { getAchievements } from "@/lib/achievements"; -import { useJournalStore } from "@/store/journal-store"; +import { + retryJournalStoreHydration, + useJournalHydrationStore, + useJournalStore, +} from "@/store/journal-store"; import type { AchievementCategory, AchievementStatus, @@ -45,7 +50,12 @@ const categoryLabels: Record = { export function AchievementsScreen() { const insets = useSafeAreaInsets(); const entries = useJournalStore((state) => state.entries); - const hasHydrated = useJournalStore((state) => state.hasHydrated); + const hasHydrated = useJournalHydrationStore( + (state) => state.hasHydrated, + ); + const hydrationError = useJournalHydrationStore( + (state) => state.hydrationError, + ); const [selectedFilter, setSelectedFilter] = useState("all"); const [filterToggleWidth, setFilterToggleWidth] = useState(0); @@ -125,6 +135,10 @@ export function AchievementsScreen() { router.replace("/profile-tab"); } + function retryJournalHydration() { + retryJournalStoreHydration(); + } + return ( ) : null} + {chatHydrationError ? ( + + ) : null} {visibleMessages.map((chatMessage, index) => ( = - { - anxious: { backgroundColor: "#F4EFFA", emoji: "😰" }, - calm: { backgroundColor: "#D8EEDB", emoji: "😌" }, - grateful: { backgroundColor: "#DDEFFF", emoji: "🙏" }, - happy: { backgroundColor: "#FFDDE8", emoji: "😊" }, - motivated: { backgroundColor: "#FFE8D8", emoji: "🔥" }, - sad: { backgroundColor: "#DDEFFF", emoji: "😔" }, - }; - -const fallbackMoodVisual = { - backgroundColor: "#F4F4F5", - emoji: "✍️", -}; - export function HomeScreen({ avatarUrl, firstName }: HomeScreenProps) { const insets = useSafeAreaInsets(); const router = useRouter(); const entries = useJournalStore((state) => state.entries); - const hasHydrated = useJournalStore((state) => state.hasHydrated); - const [selectedMood, setSelectedMood] = useState("Happy"); + const hasHydrated = useJournalHydrationStore( + (state) => state.hasHydrated, + ); + const hydrationError = useJournalHydrationStore( + (state) => state.hydrationError, + ); const showHydrationState = useDelayedVisibility(!hasHydrated); const bottomNavHeight = bottomTabBarBaseHeight + insets.bottom; const displayName = firstName?.trim() || "Aryan"; @@ -115,10 +107,16 @@ export function HomeScreen({ avatarUrl, firstName }: HomeScreenProps) { : morningIntentionPrompt; const intentionBody = !hasHydrated ? "Loading intention..." + : hydrationError + ? "Saved intention could not be loaded." : morningIntention ? getEntryPreview(morningIntention) : "Tap to write your intention..."; + function retryJournalHydration() { + retryJournalStoreHydration(); + } + return ( - - - How are you feeling today? - - - {moodOptions.map((mood) => { - const isSelected = selectedMood === mood.label; - - return ( - setSelectedMood(mood.label)} - style={{ - backgroundColor: mood.backgroundColor, - borderColor: isSelected ? "#FFA1B9" : "transparent", - }} - > - {mood.emoji} - - {mood.label} - - - ); - })} - - + @@ -317,7 +282,13 @@ export function HomeScreen({ avatarUrl, firstName }: HomeScreenProps) { - {!hasHydrated ? ( + {hydrationError ? ( + + ) : !hasHydrated ? ( showHydrationState ? ( state.allMoodLogs); + const addMoodLog = useMoodLogStore((state) => state.addMoodLog); + const updateMoodLog = useMoodLogStore((state) => state.updateMoodLog); + const moodLogHasHydrated = useMoodLogStore((state) => state.hasHydrated); + const moodLogHydrationError = useMoodLogStore( + (state) => state.hydrationError, + ); + const journalHasHydrated = useJournalHydrationStore( + (state) => state.hasHydrated, + ); + const activeUserId = useJournalStore((state) => state.activeUserId); + const isSyncing = useSyncStore((state) => state.isSyncing); + const [draftSelectedMoodId, setDraftSelectedMoodId] = + useState(null); + const [isSaving, setIsSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + const [lastSavedMoodId, setLastSavedMoodId] = useState(null); + const hasHydrated = journalHasHydrated && moodLogHasHydrated; + const todayMoodLog = useMemo( + () => getTodayMoodLog(allMoodLogs, activeUserId), + [activeUserId, allMoodLogs], + ); + const savedMoodId = todayMoodLog?.mood ?? null; + const selectedMoodId = draftSelectedMoodId ?? savedMoodId; + const selectedMood = selectedMoodId ? moodMetadata[selectedMoodId] : null; + const hasUnsavedSelection = + Boolean(draftSelectedMoodId) && draftSelectedMoodId !== savedMoodId; + const isSavedSelection = + Boolean(savedMoodId) && selectedMoodId === savedMoodId && !hasUnsavedSelection; + const actionLabel = getActionLabel({ + hasUnsavedSelection, + isSavedSelection, + selectedMoodId, + todayMoodLog, + }); + const actionDisabled = + !hasHydrated || + Boolean(moodLogHydrationError) || + !activeUserId || + !selectedMoodId || + isSavedSelection || + isSaving; + const helperText = getHelperText({ + connectivityStatus: connectivity.status, + moodLog: todayMoodLog, + hasHydrated, + hasUnsavedSelection, + isSaving, + isSyncing, + lastSavedMoodId, + moodLogHydrationError, + saveError, + }); + + function handleSelectMood(moodId: MoodId) { + setDraftSelectedMoodId(moodId); + setLastSavedMoodId(null); + setSaveError(null); + } + + async function handleSaveMood() { + if ( + !hasHydrated || + moodLogHydrationError || + !selectedMoodId || + !activeUserId || + isSaving + ) { + return; + } + + setIsSaving(true); + setSaveError(null); + + try { + if (todayMoodLog) { + updateMoodLog(todayMoodLog.id, activeUserId, { + intensity: null, + mood: selectedMoodId, + note: null, + }); + } else { + addMoodLog(activeUserId, { + intensity: null, + mood: selectedMoodId, + note: null, + }); + } + + setDraftSelectedMoodId(null); + setLastSavedMoodId(selectedMoodId); + void runAutoSync("mood_change"); + } catch (error) { + const appError = normalizeAppError(error, { + fallbackMessage: + "We couldn't save this mood on your device. Please try again.", + operation: "local_save_home_mood_check_in", + }); + + reportAppError(appError, { + errorCode: appError.code, + feature: "home", + operation: "local_save_home_mood_check_in", + }); + setSaveError(appError); + } finally { + setIsSaving(false); + } + } + + return ( + + + + {homeMoodPrompt} + + + Take a quiet moment to check in with yourself. + + + + + + + + {moodLogHydrationError ? ( + + + {helperText} + + + + Retry loading + + + + ) : saveError ? ( + + + We could not save this mood on your device. Please try again. + + + + Retry + + + + ) : helperText ? ( + + {helperText} + + ) : null} + + + + ); +} + +function retryMoodLogHydration() { + void useMoodLogStore.persist.rehydrate(); +} + +function getTodayMoodLog(moodLogs: MoodLog[], userId: string | null) { + if (!userId) { + return undefined; + } + + const todayKey = createLocalDateKey(new Date()); + + return [...moodLogs] + .filter( + (moodLog) => + moodLog.userId === userId && + !moodLog.deletedAt && + createLocalDateKey(new Date(moodLog.createdAt)) === todayKey, + ) + .sort( + (first, second) => + new Date(second.updatedAt).getTime() - new Date(first.updatedAt).getTime(), + )[0]; +} + +function getActionLabel({ + hasUnsavedSelection, + isSavedSelection, + selectedMoodId, + todayMoodLog, +}: { + hasUnsavedSelection: boolean; + isSavedSelection: boolean; + selectedMoodId: MoodId | null; + todayMoodLog: MoodLog | undefined; +}) { + if (!selectedMoodId) { + return "Select a mood"; + } + + if (isSavedSelection) { + return "Mood logged"; + } + + if (todayMoodLog && hasUnsavedSelection) { + return "Update mood"; + } + + return "Log mood"; +} + +function getHelperText({ + connectivityStatus, + moodLog, + hasHydrated, + hasUnsavedSelection, + isSaving, + isSyncing, + lastSavedMoodId, + moodLogHydrationError, + saveError, +}: { + connectivityStatus: string; + moodLog: MoodLog | undefined; + hasHydrated: boolean; + hasUnsavedSelection: boolean; + isSaving: boolean; + isSyncing: boolean; + lastSavedMoodId: MoodId | null; + moodLogHydrationError: AppError | null; + saveError: AppError | null; +}) { + if (!hasHydrated) { + return "Loading your saved check-in..."; + } + + if (moodLogHydrationError) { + return "Saved mood logs could not be loaded. Retry loading before saving again."; + } + + if (saveError) { + return null; + } + + if (isSaving) { + return "Saving on this device..."; + } + + if (hasUnsavedSelection) { + return "Save when this feels closest."; + } + + if (!moodLog || !moodLog.mood) { + return "Your check-in saves on this device first."; + } + + if (connectivityStatus === "offline") { + return "Mood logged on this device. It will sync when you reconnect."; + } + + if (isSyncing) { + return "Mood logged. Syncing when ready..."; + } + + if (moodLog.syncStatus === "failed") { + return "Mood logged on this device. Cloud sync will retry."; + } + + if (moodLog.syncStatus !== "synced") { + return "Mood logged. Waiting to sync."; + } + + if (lastSavedMoodId) { + return "Mood logged for today."; + } + + return "Mood logged for today."; +} diff --git a/components/home/mood/HomeSelectedMood.tsx b/components/home/mood/HomeSelectedMood.tsx new file mode 100644 index 0000000..d9673b0 --- /dev/null +++ b/components/home/mood/HomeSelectedMood.tsx @@ -0,0 +1,80 @@ +import LottieView from "lottie-react-native"; +import { Text, View } from "react-native"; + +import { animatedMoodEmojis } from "@/constants/animated-emojis"; +import type { MoodMetadata } from "@/constants/moods"; + +type HomeSelectedMoodProps = { + isLoading?: boolean; + isSaved: boolean; + mood: MoodMetadata | null; + savedAt?: string | null; +}; + +export function HomeSelectedMood({ + isLoading = false, + isSaved, + mood, + savedAt, +}: HomeSelectedMoodProps) { + if (isLoading) { + return ( + + + ... + + + Loading your saved check-in... + + + ); + } + + if (!mood) { + return ( + + + + + + Choose the feeling that feels closest. + + + ); + } + + return ( + + + + + + + {isSaved ? `Feeling ${mood.label}` : `You selected ${mood.label}`} + + {isSaved && savedAt ? ( + + Logged today at {formatSavedTime(savedAt)} + + ) : null} + + + ); +} + +function formatSavedTime(timestamp: string) { + const locale = Intl.DateTimeFormat().resolvedOptions().locale; + + return new Intl.DateTimeFormat(locale, { + hour: "numeric", + minute: "2-digit", + }).format(new Date(timestamp)); +} diff --git a/components/home/mood/MoodCheckInAction.tsx b/components/home/mood/MoodCheckInAction.tsx new file mode 100644 index 0000000..57984f5 --- /dev/null +++ b/components/home/mood/MoodCheckInAction.tsx @@ -0,0 +1,36 @@ +import { ActivityIndicator, Pressable, Text } from "react-native"; + +type MoodCheckInActionProps = { + disabled: boolean; + isSaving: boolean; + label: string; + onPress: () => void; +}; + +export function MoodCheckInAction({ + disabled, + isSaving, + label, + onPress, +}: MoodCheckInActionProps) { + return ( + + {isSaving ? : null} + + {isSaving ? "Saving..." : label} + + + ); +} diff --git a/components/home/mood/MoodOption.tsx b/components/home/mood/MoodOption.tsx new file mode 100644 index 0000000..f523d4d --- /dev/null +++ b/components/home/mood/MoodOption.tsx @@ -0,0 +1,61 @@ +import { Pressable, Text, View } from "react-native"; + +import type { MoodMetadata } from "@/constants/moods"; + +type MoodOptionProps = { + disabled?: boolean; + isSelected: boolean; + mood: MoodMetadata; + onPress: () => void; +}; + +export function MoodOption({ + disabled = false, + isSelected, + mood, + onPress, +}: MoodOptionProps) { + return ( + + + + {mood.emoji} + + + {mood.label} + + {isSelected ? ( + + ) : null} + + ); +} diff --git a/components/home/mood/MoodSpectrumSelector.tsx b/components/home/mood/MoodSpectrumSelector.tsx new file mode 100644 index 0000000..e4e07ff --- /dev/null +++ b/components/home/mood/MoodSpectrumSelector.tsx @@ -0,0 +1,38 @@ +import { View } from "react-native"; + +import { MoodOption } from "@/components/home/mood/MoodOption"; +import type { MoodMetadata } from "@/constants/moods"; +import type { MoodId } from "@/types/journal"; + +type MoodSpectrumSelectorProps = { + disabled?: boolean; + moods: readonly MoodMetadata[]; + onSelectMood: (moodId: MoodId) => void; + selectedMoodId: MoodId | null; +}; + +export function MoodSpectrumSelector({ + disabled = false, + moods, + onSelectMood, + selectedMoodId, +}: MoodSpectrumSelectorProps) { + return ( + + + {moods.map((mood) => ( + onSelectMood(mood.id)} + /> + ))} + + + ); +} diff --git a/components/insights/InsightsPeriodNavigator.tsx b/components/insights/InsightsPeriodNavigator.tsx new file mode 100644 index 0000000..7401b57 --- /dev/null +++ b/components/insights/InsightsPeriodNavigator.tsx @@ -0,0 +1,90 @@ +import { ChevronLeft, ChevronRight, RotateCcw } from "lucide-react-native"; +import { Pressable, Text, View } from "react-native"; + +type InsightsPeriodNavigatorProps = { + canGoNext: boolean; + label: string; + onGoCurrent: () => void; + onGoNext: () => void; + onGoPrevious: () => void; + showCurrentAction: boolean; +}; + +export function InsightsPeriodNavigator({ + canGoNext, + label, + onGoCurrent, + onGoNext, + onGoPrevious, + showCurrentAction, +}: InsightsPeriodNavigatorProps) { + return ( + + + + + {label} + + + + {showCurrentAction ? ( + + + + Current period + + + ) : null} + + ); +} + +function IconButton({ + accessibilityLabel, + disabled = false, + icon, + onPress, +}: { + accessibilityLabel: string; + disabled?: boolean; + icon: "next" | "previous"; + onPress: () => void; +}) { + const Icon = icon === "previous" ? ChevronLeft : ChevronRight; + + return ( + + + + ); +} diff --git a/components/insights/InsightsPeriodSelector.tsx b/components/insights/InsightsPeriodSelector.tsx new file mode 100644 index 0000000..4ace64a --- /dev/null +++ b/components/insights/InsightsPeriodSelector.tsx @@ -0,0 +1,91 @@ +import { useEffect, useRef, useState } from "react"; +import { + Animated, + Pressable, + Text, + View, + type LayoutChangeEvent, +} from "react-native"; + +import type { InsightsPeriod } from "@/types/insights"; + +const periods: { label: string; value: InsightsPeriod }[] = [ + { label: "Week", value: "week" }, + { label: "Month", value: "month" }, + { label: "Year", value: "year" }, +]; + +type InsightsPeriodSelectorProps = { + onChange: (period: InsightsPeriod) => void; + value: InsightsPeriod; +}; + +export function InsightsPeriodSelector({ + onChange, + value, +}: InsightsPeriodSelectorProps) { + const [containerWidth, setContainerWidth] = useState(0); + const selectedIndex = periods.findIndex((period) => period.value === value); + const thumbPosition = useRef( + new Animated.Value(selectedIndex >= 0 ? selectedIndex : 0), + ).current; + const thumbWidth = + containerWidth > 0 ? (containerWidth - 8) / periods.length : 0; + const thumbTranslateX = thumbPosition.interpolate({ + inputRange: periods.map((_, index) => index), + outputRange: periods.map((_, index) => index * thumbWidth), + }); + + useEffect(() => { + Animated.timing(thumbPosition, { + duration: 220, + toValue: selectedIndex >= 0 ? selectedIndex : 0, + useNativeDriver: true, + }).start(); + }, [selectedIndex, thumbPosition]); + + function handleLayout(event: LayoutChangeEvent) { + setContainerWidth(event.nativeEvent.layout.width); + } + + return ( + + {thumbWidth > 0 ? ( + + ) : null} + {periods.map((period) => { + const isSelected = value === period.value; + + return ( + onChange(period.value)} + > + + {period.label} + + + ); + })} + + ); +} diff --git a/components/insights/InsightsSummaryGrid.tsx b/components/insights/InsightsSummaryGrid.tsx new file mode 100644 index 0000000..0b4d40a --- /dev/null +++ b/components/insights/InsightsSummaryGrid.tsx @@ -0,0 +1,53 @@ +import { Text, View } from "react-native"; + +import { moodMetadata } from "@/constants/moods"; +import type { InsightsSummary } from "@/types/insights"; + +type InsightsSummaryGridProps = { + summary: InsightsSummary; +}; + +export function InsightsSummaryGrid({ summary }: InsightsSummaryGridProps) { + const topMood = summary.topMood ? moodMetadata[summary.topMood] : null; + const cards = [ + { + detail: summary.entryCount === 1 ? "Entry" : "Entries", + label: "Entries", + value: String(summary.entryCount), + visual: "📝", + }, + { + detail: summary.activeDays === 1 ? "Active day" : "Active days", + label: "Active", + value: String(summary.activeDays), + visual: "📅", + }, + { + detail: "Top mood", + label: "Top mood", + value: topMood?.label ?? "No mood yet", + visual: topMood?.emoji ?? "✍️", + }, + ]; + + return ( + + {cards.map((card) => ( + + {card.visual} + + {card.value} + + + {card.detail} + + + ))} + + ); +} diff --git a/components/insights/JournalingRhythmCard.tsx b/components/insights/JournalingRhythmCard.tsx new file mode 100644 index 0000000..62402d9 --- /dev/null +++ b/components/insights/JournalingRhythmCard.tsx @@ -0,0 +1,89 @@ +import { Text, View } from "react-native"; + +import { moodMetadata } from "@/constants/moods"; +import type { WeekdayPattern } from "@/types/insights"; + +type JournalingRhythmCardProps = { + patterns: WeekdayPattern[]; +}; + +export function JournalingRhythmCard({ patterns }: JournalingRhythmCardProps) { + const largestCount = Math.max(...patterns.map((pattern) => pattern.entryCount), 1); + const topPattern = getSingleTopPattern(patterns); + + return ( + + + Journaling Rhythm + + + Entry activity by weekday + + + {patterns.some((pattern) => pattern.entryCount > 0) ? ( + + {patterns.map((pattern) => { + const widthPercentage = Math.max( + 8, + Math.round((pattern.entryCount / largestCount) * 100), + ); + const mood = pattern.dominantMood + ? moodMetadata[pattern.dominantMood] + : null; + + return ( + + + {pattern.label} + + + {pattern.entryCount > 0 ? ( + + ) : null} + + + {pattern.entryCount} + + + {mood?.emoji ?? ""} + + + ); + })} + {topPattern ? ( + + You journal most often on {topPattern.label}s. + + ) : null} + + ) : ( + + Keep journaling to discover which days form your natural rhythm. + + )} + + ); +} + +function getSingleTopPattern(patterns: WeekdayPattern[]) { + const sortedPatterns = [...patterns].sort( + (first, second) => second.entryCount - first.entryCount, + ); + const topPattern = sortedPatterns[0]; + + if (!topPattern || topPattern.entryCount === 0) { + return null; + } + + if (sortedPatterns[1]?.entryCount === topPattern.entryCount) { + return null; + } + + return topPattern; +} diff --git a/components/insights/MoodDistributionCard.tsx b/components/insights/MoodDistributionCard.tsx new file mode 100644 index 0000000..7681c22 --- /dev/null +++ b/components/insights/MoodDistributionCard.tsx @@ -0,0 +1,100 @@ +import type { ReactNode } from "react"; +import { Text, View } from "react-native"; + +import { moodMetadata } from "@/constants/moods"; +import type { MoodCount } from "@/types/insights"; + +type MoodDistributionCardProps = { + entriesWithoutMood: number; + moodDistribution: MoodCount[]; +}; + +export function MoodDistributionCard({ + entriesWithoutMood, + moodDistribution, +}: MoodDistributionCardProps) { + return ( + + {moodDistribution.length > 0 ? ( + + {moodDistribution.map((item) => { + const mood = moodMetadata[item.moodId]; + + return ( + + + {mood.emoji} + + {mood.label} + + + {item.count} + + + {item.percentage}% + + + + + + + ); + })} + {entriesWithoutMood > 0 ? ( + + {entriesWithoutMood}{" "} + {entriesWithoutMood === 1 ? "entry had" : "entries had"} no mood selected. + + ) : null} + + ) : ( + 0 + ? "Entries in this period do not have moods selected yet." + : "Mood distribution will appear after you begin adding moods." + } + /> + )} + + ); +} + +function InsightCard({ + children, + subtitle, + title, +}: { + children: ReactNode; + subtitle: string; + title: string; +}) { + return ( + + + {title} + + + {subtitle} + + {children} + + ); +} + +function EmptyCardText({ text }: { text: string }) { + return ( + + {text} + + ); +} diff --git a/components/insights/RecurringThemesCard.tsx b/components/insights/RecurringThemesCard.tsx new file mode 100644 index 0000000..8bb86d2 --- /dev/null +++ b/components/insights/RecurringThemesCard.tsx @@ -0,0 +1,62 @@ +import { Text, View } from "react-native"; + +import type { ThemeFrequency } from "@/types/insights"; + +type RecurringThemesCardProps = { + themes: ThemeFrequency[]; +}; + +export function RecurringThemesCard({ themes }: RecurringThemesCardProps) { + const largestCount = Math.max(...themes.map((theme) => theme.count), 1); + const subtitle = themes.some((theme) => theme.source === "ai") + ? "Themes identified by DearDiary" + : "Tags from your entries"; + + return ( + + + Recurring Themes + + + {subtitle} + + + {themes.length > 0 ? ( + + {themes.map((theme) => ( + + + + {theme.label} + + + {theme.count} {theme.count === 1 ? "entry" : "entries"} + + + {theme.source === "ai" ? ( + + AI-identified theme + + ) : null} + + + + + ))} + + ) : ( + + Themes will appear as you add tags or generate reports. + + )} + + ); +} diff --git a/components/insights/insights-screen.tsx b/components/insights/insights-screen.tsx index 6266abe..26aff7b 100644 --- a/components/insights/insights-screen.tsx +++ b/components/insights/insights-screen.tsx @@ -1,3 +1,4 @@ +import { useAuth } from "@clerk/expo"; import { LinearGradient as ExpoLinearGradient } from "expo-linear-gradient"; import { Link, useRouter, type Href } from "expo-router"; import { StatusBar } from "expo-status-bar"; @@ -32,23 +33,41 @@ import { BottomTabBar, bottomTabBarBaseHeight, } from "@/components/navigation/bottom-tab-bar"; +import { InsightsPeriodNavigator } from "@/components/insights/InsightsPeriodNavigator"; +import { InsightsPeriodSelector } from "@/components/insights/InsightsPeriodSelector"; +import { InsightsSummaryGrid } from "@/components/insights/InsightsSummaryGrid"; +import { JournalingRhythmCard } from "@/components/insights/JournalingRhythmCard"; +import { MoodDistributionCard } from "@/components/insights/MoodDistributionCard"; +import { RecurringThemesCard } from "@/components/insights/RecurringThemesCard"; import { ScreenEmptyState } from "@/components/states/ScreenEmptyState"; +import { ScreenErrorState } from "@/components/states/ScreenErrorState"; +import { ScreenLoadingState } from "@/components/states/ScreenLoadingState"; import { TabScreenHeader } from "@/components/ui/tab-screen-header"; import { insightCardStyles, - insightStatStyles, type InsightCard, - type InsightStat, type MoodJourneyPoint, } from "@/data/insights"; import { useAIInsightReport } from "@/hooks/useAIInsightReport"; import type { UseAIInsightReportResult } from "@/hooks/useAIInsightReport"; +import { useDelayedVisibility } from "@/hooks/useDelayedVisibility"; +import { deriveInsights } from "@/lib/insights/deriveInsights"; +import { + getInsightDateRange, + isFutureInsightPeriod, + shiftInsightReferenceDate, +} from "@/lib/insights/insightPeriodUtils"; import { getCurrentReportPeriod, type ReportPeriod, } from "@/lib/insights/reportPeriods"; -import { useJournalStore } from "@/store/journal-store"; +import { + retryJournalStoreHydration, + useJournalHydrationStore, + useJournalStore, +} from "@/store/journal-store"; import type { AIInsightReport } from "@/types/aiInsightReport"; +import type { InsightsPeriod, ThemeFrequency } from "@/types/insights"; import type { JournalEntry, MoodId } from "@/types/journal"; const primaryColor = "#FF2056"; @@ -89,25 +108,27 @@ const moodEmoji: Record = { sad: "😔", }; -const moodLabels: Record = { - anxious: "Anxious", - calm: "Calm", - grateful: "Grateful", - happy: "Happy", - motivated: "Motivated", - sad: "Sad", -}; - type ChartPoint = MoodJourneyPoint & { x: number; y: number; }; export function InsightsScreen() { + const { userId } = useAuth(); const insets = useSafeAreaInsets(); const router = useRouter(); const entries = useJournalStore((state) => state.entries); - const hasHydrated = useJournalStore((state) => state.hasHydrated); + const hasHydrated = useJournalHydrationStore( + (state) => state.hasHydrated, + ); + const hydrationError = useJournalHydrationStore( + (state) => state.hydrationError, + ); + const showHydrationState = useDelayedVisibility(!hasHydrated); + const [selectedPeriod, setSelectedPeriod] = useState("week"); + const [selectedReferenceDate, setSelectedReferenceDate] = useState( + () => new Date(), + ); const bottomNavHeight = bottomTabBarBaseHeight + insets.bottom; const todayKey = useTodayKey(); const reportDate = useMemo(() => getLocalDateFromKey(todayKey), [todayKey]); @@ -119,12 +140,41 @@ export function InsightsScreen() { () => getCurrentReportPeriod("monthly", reportDate), [reportDate], ); - const weeklyReportState = useAIInsightReport(weeklyPeriod); - const monthlyReportState = useAIInsightReport(monthlyPeriod); + const weeklyReportState = useAIInsightReport(weeklyPeriod, { + enabled: hasHydrated, + }); + const monthlyReportState = useAIInsightReport(monthlyPeriod, { + enabled: hasHydrated, + }); const insights = useMemo( - () => getLocalInsights(entries, hasHydrated), - [entries, hasHydrated], + () => + getLocalInsights({ + entries, + hasHydrated, + period: selectedPeriod, + referenceDate: selectedReferenceDate, + userId: userId ?? null, + }), + [entries, hasHydrated, selectedPeriod, selectedReferenceDate, userId], + ); + const derivedInsights = useMemo( + () => + deriveInsights({ + entries, + period: selectedPeriod, + referenceDate: selectedReferenceDate, + userId: userId ?? null, + }), + [entries, selectedPeriod, selectedReferenceDate, userId], ); + const nextReferenceDate = useMemo( + () => shiftInsightReferenceDate(selectedPeriod, selectedReferenceDate, 1), + [selectedPeriod, selectedReferenceDate], + ); + const canGoNext = !isFutureInsightPeriod(selectedPeriod, nextReferenceDate); + const showCurrentPeriodAction = + derivedInsights.dateRange.start.getTime() !== + getInsightDateRange(selectedPeriod, new Date()).start.getTime(); const aiInsightCards = useMemo( () => getAIInsightCards({ @@ -148,12 +198,35 @@ export function InsightsScreen() { weeklyReportState.report, ], ); + const matchingPeriodReport = useMemo( + () => + getMatchingReportForSelectedPeriod({ + monthlyReport: monthlyReportState.report, + period: selectedPeriod, + referenceDate: selectedReferenceDate, + weeklyReport: weeklyReportState.report, + }), + [ + monthlyReportState.report, + selectedPeriod, + selectedReferenceDate, + weeklyReportState.report, + ], + ); + const recurringThemes = useMemo( + () => getRecurringThemes(derivedInsights.themes, matchingPeriodReport), + [derivedInsights.themes, matchingPeriodReport], + ); const hasNoEntries = hasHydrated && entries.length === 0; const newJournalEntryHref = { pathname: "/journal/new", params: { source: "insights" }, } as Href; + function retryJournalHydration() { + retryJournalStoreHydration(); + } + return (