From d01e14b53eb0829eef01e6a944d9c55074d54c98 Mon Sep 17 00:00:00 2001 From: aryansoni-dev Date: Wed, 24 Jun 2026 12:40:09 +0530 Subject: [PATCH 1/3] Changes : [+] Improved the insights screen. [+] Audited and implemented loading, empty, error, refreshing, and partial-data states across the app. --- app/insights/report/[periodType].tsx | 2 +- .../achievements/AchievementsScreen.tsx | 18 +- components/ai-chat/ai-chat-screen.tsx | 19 +- components/home/home-screen.tsx | 17 +- .../insights/InsightsPeriodNavigator.tsx | 90 +++++ .../insights/InsightsPeriodSelector.tsx | 51 +++ components/insights/InsightsSummaryGrid.tsx | 53 +++ components/insights/JournalingRhythmCard.tsx | 89 +++++ components/insights/MoodDistributionCard.tsx | 94 +++++ components/insights/RecurringThemesCard.tsx | 62 ++++ components/insights/insights-screen.tsx | 322 +++++++++++------- .../journal-editor/journal-editor-screen.tsx | 25 ++ .../journal-history/journal-calendar-view.tsx | 14 + .../journal-history-screen.tsx | 16 +- .../profile/notification-settings-screen.tsx | 63 +++- components/profile/profile-screen.tsx | 58 +++- components/states/ScreenErrorState.tsx | 1 + components/states/StaleDataNotice.tsx | 1 + docs/insights-data-audit.md | 15 + docs/screen-state-audit.md | 86 ++--- hooks/useAIInsightReport.ts | 15 + hooks/useEntryReflection.ts | 23 +- lib/errors/normalizeAppError.ts | 13 + lib/insights/deriveInsights.ts | 214 ++++++++++++ lib/insights/insightPeriodUtils.ts | 133 ++++++++ store/journal-store.ts | 23 +- store/notification-preferences-store.ts | 18 +- store/useAIInsightReportStore.ts | 18 +- store/useAchievementStore.ts | 18 +- store/useChatStore.ts | 18 +- store/useEntryReflectionStore.ts | 18 +- store/useSyncStore.ts | 23 +- types/insights.ts | 47 +++ 33 files changed, 1479 insertions(+), 198 deletions(-) create mode 100644 components/insights/InsightsPeriodNavigator.tsx create mode 100644 components/insights/InsightsPeriodSelector.tsx create mode 100644 components/insights/InsightsSummaryGrid.tsx create mode 100644 components/insights/JournalingRhythmCard.tsx create mode 100644 components/insights/MoodDistributionCard.tsx create mode 100644 components/insights/RecurringThemesCard.tsx create mode 100644 docs/insights-data-audit.md create mode 100644 lib/insights/deriveInsights.ts create mode 100644 lib/insights/insightPeriodUtils.ts create mode 100644 types/insights.ts 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.entries); const hasHydrated = useJournalStore((state) => state.hasHydrated); + const hydrationError = useJournalStore((state) => state.hydrationError); const [selectedFilter, setSelectedFilter] = useState("all"); const [filterToggleWidth, setFilterToggleWidth] = useState(0); @@ -125,6 +127,11 @@ export function AchievementsScreen() { router.replace("/profile-tab"); } + function retryJournalHydration() { + useJournalStore.setState({ hasHydrated: false, hydrationError: null }); + void useJournalStore.persist.rehydrate(); + } + return ( ) : null} + {chatHydrationError ? ( + + ) : null} {visibleMessages.map((chatMessage, index) => ( state.entries); const hasHydrated = useJournalStore((state) => state.hasHydrated); + const hydrationError = useJournalStore((state) => state.hydrationError); const [selectedMood, setSelectedMood] = useState("Happy"); const showHydrationState = useDelayedVisibility(!hasHydrated); const bottomNavHeight = bottomTabBarBaseHeight + insets.bottom; @@ -115,10 +117,17 @@ 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() { + useJournalStore.setState({ hasHydrated: false, hydrationError: null }); + void useJournalStore.persist.rehydrate(); + } + return ( - {!hasHydrated ? ( + {hydrationError ? ( + + ) : !hasHydrated ? ( showHydrationState ? ( 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..6bd4c8f --- /dev/null +++ b/components/insights/InsightsPeriodSelector.tsx @@ -0,0 +1,51 @@ +import { Pressable, Text, View } 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) { + return ( + + {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..64c1299 --- /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..c6943dd --- /dev/null +++ b/components/insights/MoodDistributionCard.tsx @@ -0,0 +1,94 @@ +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} + + ) : ( + + )} + + ); +} + +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..d7b2e8c 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,37 @@ 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 type { AIInsightReport } from "@/types/aiInsightReport"; +import type { InsightsPeriod, ThemeFrequency } from "@/types/insights"; import type { JournalEntry, MoodId } from "@/types/journal"; const primaryColor = "#FF2056"; @@ -89,25 +104,23 @@ 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 hydrationError = useJournalStore((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 +132,34 @@ 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], ); + 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 +183,25 @@ export function InsightsScreen() { weeklyReportState.report, ], ); + const recurringThemes = useMemo( + () => + getRecurringThemes( + derivedInsights.themes, + monthlyReportState.report ?? weeklyReportState.report, + ), + [derivedInsights.themes, monthlyReportState.report, weeklyReportState.report], + ); const hasNoEntries = hasHydrated && entries.length === 0; const newJournalEntryHref = { pathname: "/journal/new", params: { source: "insights" }, } as Href; + function retryJournalHydration() { + useJournalStore.setState({ hasHydrated: false, hydrationError: null }); + void useJournalStore.persist.rehydrate(); + } + return (