From 34299089bdd36d5f23f85fbf72afff70c1fdea21 Mon Sep 17 00:00:00 2001 From: aryansoni-dev Date: Mon, 22 Jun 2026 23:37:39 +0530 Subject: [PATCH 1/3] Changes : [+] Implemented code-rabbit's suggested bug fixes and improvements. [+] Improved the app's error handling and state management for better user experience. --- app/insights/report/[periodType].tsx | 20 +- .../achievements/AchievementsScreen.tsx | 23 +- components/ai-chat/ai-chat-screen.tsx | 38 +++- components/connectivity/OfflineNotice.tsx | 8 +- components/errors/FeatureErrorBoundary.tsx | 32 ++- components/errors/InlineErrorMessage.tsx | 6 +- components/errors/RootErrorFallback.tsx | 2 +- components/home/home-screen.tsx | 33 ++- components/insights/insights-screen.tsx | 24 +- .../insights/report/ReportScreenStates.tsx | 27 ++- .../entry-ai-reflection-card.tsx | 34 ++- .../journal-editor/journal-editor-screen.tsx | 22 +- .../journal-history/journal-calendar-view.tsx | 22 +- .../journal-history-screen.tsx | 97 +++----- components/legal/legal-document-screen.tsx | 215 ++++++++---------- .../profile/notification-settings-screen.tsx | 17 +- components/profile/profile-screen.tsx | 8 +- components/states/FilteredEmptyState.tsx | 22 ++ components/states/InlineRefreshState.tsx | 24 ++ components/states/ScreenEmptyState.tsx | 67 ++++++ components/states/ScreenErrorState.tsx | 48 ++++ components/states/ScreenLoadingState.tsx | 34 +++ components/states/StaleDataNotice.tsx | 37 +++ components/sync/SyncStatusIndicator.tsx | 29 +-- components/sync/SyncStatusRow.tsx | 12 +- components/sync/auto-sync-manager.tsx | 3 +- constants/theme.ts | 67 ++++++ docs/screen-state-audit.md | 58 +++++ global.css | 18 ++ hooks/useAIInsightReport.ts | 31 ++- hooks/useAutoSync.ts | 4 - hooks/useDelayedVisibility.ts | 20 ++ hooks/useEntryReflection.ts | 31 ++- lib/account/accountDeletionService.ts | 5 +- lib/errors/normalizeAppError.ts | 5 +- lib/sync/requestSync.ts | 5 +- lib/ui/deriveHistoryViewState.ts | 46 ++++ lib/utils/typeGuards.ts | 3 + store/useAIInsightReportStore.ts | 7 + store/useChatStore.ts | 8 + store/useEntryReflectionStore.ts | 8 + store/useSyncStore.ts | 24 +- supabase/functions/delete-account/index.ts | 24 +- types/appError.ts | 27 +-- types/uiState.ts | 23 ++ 45 files changed, 977 insertions(+), 341 deletions(-) create mode 100644 components/states/FilteredEmptyState.tsx create mode 100644 components/states/InlineRefreshState.tsx create mode 100644 components/states/ScreenEmptyState.tsx create mode 100644 components/states/ScreenErrorState.tsx create mode 100644 components/states/ScreenLoadingState.tsx create mode 100644 components/states/StaleDataNotice.tsx create mode 100644 constants/theme.ts create mode 100644 docs/screen-state-audit.md create mode 100644 hooks/useDelayedVisibility.ts create mode 100644 lib/ui/deriveHistoryViewState.ts create mode 100644 lib/utils/typeGuards.ts create mode 100644 types/uiState.ts diff --git a/app/insights/report/[periodType].tsx b/app/insights/report/[periodType].tsx index 567c78f..591dc49 100644 --- a/app/insights/report/[periodType].tsx +++ b/app/insights/report/[periodType].tsx @@ -161,13 +161,27 @@ export default function AIInsightReportScreen() { - {reportState.error ? : null} + {reportState.error ? ( + { + void reportState.refresh(); + }} + retrying={reportState.isLoading} + /> + ) : null} - {reportState.isGenerating && reportState.report ? ( + {(reportState.isGenerating || reportState.isLoading) && + reportState.report ? ( ) : null} - {reportState.isLoading && !reportState.report ? ( + {(reportState.isLoading || reportState.isGenerating) && + !reportState.report ? ( ) : reportState.report ? ( <> diff --git a/components/achievements/AchievementsScreen.tsx b/components/achievements/AchievementsScreen.tsx index be47b51..92737bc 100644 --- a/components/achievements/AchievementsScreen.tsx +++ b/components/achievements/AchievementsScreen.tsx @@ -13,7 +13,10 @@ import { } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { ScreenEmptyState } from "@/components/states/ScreenEmptyState"; +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 type { @@ -46,6 +49,7 @@ export function AchievementsScreen() { const [selectedFilter, setSelectedFilter] = useState("all"); const [filterToggleWidth, setFilterToggleWidth] = useState(0); + const showHydrationState = useDelayedVisibility(!hasHydrated); const [slideDirection, setSlideDirection] = useState(1); const thumbAnimationRef = useRef(new Animated.Value(0)); const listAnimationRef = useRef(new Animated.Value(1)); @@ -219,7 +223,24 @@ export function AchievementsScreen() { transform: [{ translateX: listTranslateX }], }} > - {filteredAchievements.map((achievement) => ( + {!hasHydrated ? ( + showHydrationState ? ( + + ) : null + ) : filteredAchievements.length === 0 ? ( + + ) : filteredAchievements.map((achievement) => ( ))} diff --git a/components/ai-chat/ai-chat-screen.tsx b/components/ai-chat/ai-chat-screen.tsx index 9796935..95f23de 100644 --- a/components/ai-chat/ai-chat-screen.tsx +++ b/components/ai-chat/ai-chat-screen.tsx @@ -21,8 +21,10 @@ import { import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AnimatedIconButton } from "@/components/ui/animated-icon-button"; +import { CONNECTION_STATE_COLORS } from "@/constants/theme"; import { useAppDialog } from "@/hooks/useAppDialog"; import { useConnectivity } from "@/hooks/useConnectivity"; +import { useDelayedVisibility } from "@/hooks/useDelayedVisibility"; import { generateLocalJournalResponse } from "@/lib/ai/localJournalAssistant"; import { generateRemoteJournalResponse } from "@/lib/ai/remoteJournalAssistant"; import { useJournalStore } from "@/store/journal-store"; @@ -79,6 +81,7 @@ export function AiChatScreen({ const [keyboardOffset, setKeyboardOffset] = useState(0); const journalEntries = useJournalStore((state) => state.entries); const chatMessages = useChatStore((state) => state.messages); + const chatHasHydrated = useChatStore((state) => state.hasHydrated); const addMessage = useChatStore((state) => state.addMessage); const clearMessagesForUser = useChatStore( (state) => state.clearMessagesForUser, @@ -107,10 +110,12 @@ export function AiChatScreen({ new Date(messageB.createdAt).getTime(), ); }, [chatMessages, userId]); + const showChatHydrationState = useDelayedVisibility(!chatHasHydrated); const visibleMessages = - currentUserMessages.length > 0 + chatHasHydrated && currentUserMessages.length > 0 ? currentUserMessages - : [ + : chatHasHydrated + ? [ { content: "I'm here to sit with you for a moment. No rush, no judgment.", createdAt: new Date().toISOString(), @@ -118,10 +123,18 @@ export function AiChatScreen({ role: "assistant", userId: userId ?? "guest", } satisfies ChatMessage, - ]; + ] + : []; const isOffline = connectivity.status === "offline"; + const connectionStateColors = isOffline + ? CONNECTION_STATE_COLORS.offline + : CONNECTION_STATE_COLORS.online; const canSendMessage = - message.trim().length > 0 && !!userId && !isThinking && !isOffline; + message.trim().length > 0 && + !!userId && + chatHasHydrated && + !isThinking && + !isOffline; const shouldUseKeyboardOffset = process.env.EXPO_OS === "android"; const footerKeyboardOffset = shouldUseKeyboardOffset ? keyboardOffset : 0; @@ -350,15 +363,15 @@ export function AiChatScreen({ {isOffline ? "Offline" : "Online"} @@ -398,13 +411,20 @@ export function AiChatScreen({ {isOffline ? ( - - + + Internet is required for AI Chat. Your journal entries are still available offline. ) : null} + {!chatHasHydrated && showChatHydrationState ? ( + + + Preparing your conversation... + + + ) : null} {visibleMessages.map((chatMessage, index) => ( + - + - + {message ?? "You are offline. Changes will be saved on this device and synced later."} diff --git a/components/errors/FeatureErrorBoundary.tsx b/components/errors/FeatureErrorBoundary.tsx index a6683d9..ab197f5 100644 --- a/components/errors/FeatureErrorBoundary.tsx +++ b/components/errors/FeatureErrorBoundary.tsx @@ -39,28 +39,26 @@ export class FeatureErrorBoundary extends React.Component< } return ( - - + + {this.props.featureName} is unavailable right now. - + {this.props.fallbackMessage ?? "This part of DearDiary ran into a problem. The rest of the screen is still available."} - {this.props.onRetry ? ( - { - this.setState({ error: null }); - this.props.onRetry?.(); - }} - > - - Retry - - - ) : null} + { + this.setState({ error: null }); + this.props.onRetry?.(); + }} + > + + Retry + + ); } diff --git a/components/errors/InlineErrorMessage.tsx b/components/errors/InlineErrorMessage.tsx index 7c1ae7a..48d5f91 100644 --- a/components/errors/InlineErrorMessage.tsx +++ b/components/errors/InlineErrorMessage.tsx @@ -11,9 +11,9 @@ export function InlineErrorMessage({ error, onRetry }: InlineErrorMessageProps) return ( - + {error.userMessage} {error.retryable && onRetry ? ( @@ -22,7 +22,7 @@ export function InlineErrorMessage({ error, onRetry }: InlineErrorMessageProps) className="mt-3 min-h-10 items-center justify-center rounded-full bg-white px-4" onPress={onRetry} > - + Retry diff --git a/components/errors/RootErrorFallback.tsx b/components/errors/RootErrorFallback.tsx index 3616c29..3dddb13 100644 --- a/components/errors/RootErrorFallback.tsx +++ b/components/errors/RootErrorFallback.tsx @@ -47,7 +47,7 @@ export function RootErrorFallback({ error, retry }: RootErrorFallbackProps) { {retry ? ( diff --git a/components/home/home-screen.tsx b/components/home/home-screen.tsx index ef118fb..6c3fdbf 100644 --- a/components/home/home-screen.tsx +++ b/components/home/home-screen.tsx @@ -13,6 +13,7 @@ import { } from "@/components/navigation/bottom-tab-bar"; import { images } from "@/constants/images"; import { moodOptions } from "@/data/home"; +import { useDelayedVisibility } from "@/hooks/useDelayedVisibility"; import { useJournalStore } from "@/store/journal-store"; import type { MoodId, @@ -80,6 +81,7 @@ export function HomeScreen({ avatarUrl, firstName }: HomeScreenProps) { const entries = useJournalStore((state) => state.entries); const hasHydrated = useJournalStore((state) => state.hasHydrated); const [selectedMood, setSelectedMood] = useState("Happy"); + const showHydrationState = useDelayedVisibility(!hasHydrated); const bottomNavHeight = bottomTabBarBaseHeight + insets.bottom; const displayName = firstName?.trim() || "Aryan"; const greeting = useMemo(() => getGreeting(), []); @@ -316,14 +318,18 @@ export function HomeScreen({ avatarUrl, firstName }: HomeScreenProps) { {!hasHydrated ? ( - + showHydrationState ? ( + + ) : null ) : recentJournalEntries.length === 0 ? ( router.push(journalEditorHref)} + title="Your journal begins here" /> ) : ( recentJournalEntries.map((entry) => ( @@ -371,9 +377,13 @@ export function HomeScreen({ avatarUrl, firstName }: HomeScreenProps) { function RecentEntriesEmptyState({ body, + ctaLabel, + onCtaPress, title, }: { body: string; + ctaLabel?: string; + onCtaPress?: () => void; title: string; }) { return ( @@ -394,6 +404,17 @@ function RecentEntriesEmptyState({ {title} {body} + {ctaLabel && onCtaPress ? ( + + + {ctaLabel} + + + ) : null} ); } diff --git a/components/insights/insights-screen.tsx b/components/insights/insights-screen.tsx index a600c8e..fd298bd 100644 --- a/components/insights/insights-screen.tsx +++ b/components/insights/insights-screen.tsx @@ -1,5 +1,5 @@ import { LinearGradient as ExpoLinearGradient } from "expo-linear-gradient"; -import { Link } from "expo-router"; +import { Link, useRouter, type Href } from "expo-router"; import { StatusBar } from "expo-status-bar"; import { BarChart3, CalendarDays, Sparkles } from "lucide-react-native"; import { useEffect, useMemo, useState } from "react"; @@ -32,6 +32,7 @@ import { BottomTabBar, bottomTabBarBaseHeight, } from "@/components/navigation/bottom-tab-bar"; +import { ScreenEmptyState } from "@/components/states/ScreenEmptyState"; import { TabScreenHeader } from "@/components/ui/tab-screen-header"; import { insightCardStyles, @@ -104,6 +105,7 @@ type ChartPoint = MoodJourneyPoint & { export function InsightsScreen() { const insets = useSafeAreaInsets(); + const router = useRouter(); const entries = useJournalStore((state) => state.entries); const hasHydrated = useJournalStore((state) => state.hasHydrated); const bottomNavHeight = bottomTabBarBaseHeight + insets.bottom; @@ -146,6 +148,11 @@ export function InsightsScreen() { weeklyReportState.report, ], ); + const hasNoEntries = hasHydrated && entries.length === 0; + const newJournalEntryHref = { + pathname: "/journal/new", + params: { source: "insights" }, + } as Href; return ( @@ -179,6 +186,17 @@ export function InsightsScreen() { title="Your Insights ✨" /> + {hasNoEntries ? ( + + router.push(newJournalEntryHref)} + title="Your insights will grow with your journal" + /> + + ) : null} + ({ ...card, diff --git a/components/insights/report/ReportScreenStates.tsx b/components/insights/report/ReportScreenStates.tsx index 44c5075..266920b 100644 --- a/components/insights/report/ReportScreenStates.tsx +++ b/components/insights/report/ReportScreenStates.tsx @@ -107,7 +107,15 @@ export function UpdatingBanner({ ); } -export function ErrorBanner({ message }: { message: string }) { +export function ErrorBanner({ + message, + onRetry, + retrying = false, +}: { + message: string; + onRetry?: () => void; + retrying?: boolean; +}) { return ( {message} + {onRetry ? ( + + + {retrying ? "Retrying..." : "Retry"} + + + ) : null} ); } diff --git a/components/journal-editor/entry-ai-reflection-card.tsx b/components/journal-editor/entry-ai-reflection-card.tsx index 9bdab02..c348836 100644 --- a/components/journal-editor/entry-ai-reflection-card.tsx +++ b/components/journal-editor/entry-ai-reflection-card.tsx @@ -48,6 +48,35 @@ export function EntryAIReflectionCard({ const isBusy = isGenerating || isLoading; if (!reflection) { + if (isLoading) { + return ( + + + + + + + + Reflect with AI ✨ + + + + + Checking for an existing reflection... + + + + + + ); + } + return ( - {error} + Could not generate this reflection. {error} ) : null} @@ -153,7 +182,8 @@ export function EntryAIReflectionCard({ className="mt-5 text-[14px] text-[#DC2626]" style={smallTextStyle} > - {error} + Could not update this reflection. Your previous reflection is still + available. {error} ) : null} diff --git a/components/journal-editor/journal-editor-screen.tsx b/components/journal-editor/journal-editor-screen.tsx index 431f132..3b1899f 100644 --- a/components/journal-editor/journal-editor-screen.tsx +++ b/components/journal-editor/journal-editor-screen.tsx @@ -24,6 +24,7 @@ import { BottomTabBar, bottomTabBarBaseHeight, } from "@/components/navigation/bottom-tab-bar"; +import { ScreenLoadingState } from "@/components/states/ScreenLoadingState"; import { TagInputModal } from "@/components/tags/tag-input-modal"; import { AnimatedIconButton } from "@/components/ui/animated-icon-button"; import { animatedMoodEmojis } from "@/constants/animated-emojis"; @@ -31,6 +32,7 @@ import { journalEditorMoods } from "@/data/journal-editor"; import { useAppDialog } from "@/hooks/useAppDialog"; import { useAutoSync } from "@/hooks/useAutoSync"; import { useConnectivity } from "@/hooks/useConnectivity"; +import { useDelayedVisibility } from "@/hooks/useDelayedVisibility"; import { useEntryReflection } from "@/hooks/useEntryReflection"; import { normalizeAppError } from "@/lib/errors/normalizeAppError"; import { reportAppError } from "@/lib/errors/reportAppError"; @@ -104,6 +106,7 @@ export function JournalEditorScreen({ entryId }: JournalEditorScreenProps) { const [wasSaved, setWasSaved] = useState(false); const [isWritingFocused, setIsWritingFocused] = useState(false); const [keyboardHeight, setKeyboardHeight] = useState(0); + const showHydrationState = useDelayedVisibility(!hasHydrated); const bottomNavHeight = bottomTabBarBaseHeight + insets.bottom; const bottomChromeHeight = bottomNavHeight; const isAndroid = process.env.EXPO_OS === "android"; @@ -297,7 +300,10 @@ export function JournalEditorScreen({ entryId }: JournalEditorScreenProps) { }); showDialog({ confirmText: "OK", - message: appError.userMessage, + message: + appError.code === "local_save_failed" + ? "We could not save this entry on your device. Please try again before leaving." + : "We could not save this entry on your device. Please try again before leaving.", title: "Save failed", variant: "destructive", }); @@ -408,11 +414,11 @@ export function JournalEditorScreen({ entryId }: JournalEditorScreenProps) { if (!hasHydrated) { return ( - + ); } @@ -426,7 +432,7 @@ export function JournalEditorScreen({ entryId }: JournalEditorScreenProps) { style={{ paddingBottom: bottomNavHeight }} > - Entry not found + This journal entry could not be found. This journal entry may have been deleted. @@ -437,7 +443,7 @@ export function JournalEditorScreen({ entryId }: JournalEditorScreenProps) { onPress={() => router.replace("/journal-history")} > - Back to History + Return to History @@ -826,7 +832,7 @@ function getSaveButtonLabel({ return "Save"; } - if (!wasSaved && entrySyncStatus !== "synced") { + if (!wasSaved) { return "Save"; } diff --git a/components/journal-history/journal-calendar-view.tsx b/components/journal-history/journal-calendar-view.tsx index 5a38639..5d5a782 100644 --- a/components/journal-history/journal-calendar-view.tsx +++ b/components/journal-history/journal-calendar-view.tsx @@ -8,10 +8,12 @@ import { import { type ReactNode, useMemo, useRef, useState } from "react"; import { Animated, Pressable, Text, View } from "react-native"; +import { ScreenLoadingState } from "@/components/states/ScreenLoadingState"; import { fallbackMoodMetadata, moodMetadata, } from "@/constants/moods"; +import { useDelayedVisibility } from "@/hooks/useDelayedVisibility"; import { buildJournalCalendarMonth } from "@/lib/calendar/buildJournalCalendarMonth"; import { addCalendarMonths, @@ -82,6 +84,7 @@ export function JournalCalendarView({ renderSelectedEntries: (day: CalendarDayStatus) => ReactNode; }) { const todayDateKey = createLocalDateKey(new Date()); + const showHydrationState = useDelayedVisibility(!hasHydrated); const todayButtonScale = useRef(new Animated.Value(1)).current; const [visibleMonth, setVisibleMonth] = useState( () => { @@ -144,7 +147,9 @@ export function JournalCalendarView({ if (!hasHydrated) { return ( - + {showHydrationState ? ( + + ) : null} ); } @@ -233,6 +238,21 @@ export function JournalCalendarView({ day.isCurrentMonth)} /> + {calendarMonth.totalEntries === 0 ? ( + + ) : null} + diff --git a/components/journal-history/journal-history-screen.tsx b/components/journal-history/journal-history-screen.tsx index 3e121be..b25ffae 100644 --- a/components/journal-history/journal-history-screen.tsx +++ b/components/journal-history/journal-history-screen.tsx @@ -19,6 +19,9 @@ import { BottomTabBar, bottomTabBarBaseHeight, } from "@/components/navigation/bottom-tab-bar"; +import { FilteredEmptyState } from "@/components/states/FilteredEmptyState"; +import { ScreenEmptyState } from "@/components/states/ScreenEmptyState"; +import { ScreenLoadingState } from "@/components/states/ScreenLoadingState"; import { AnimatedIconButton } from "@/components/ui/animated-icon-button"; import { TabScreenHeader } from "@/components/ui/tab-screen-header"; import { @@ -27,7 +30,9 @@ import { moodMetadata, } from "@/constants/moods"; import { journalMoodFilters } from "@/data/journal-history"; +import { useDelayedVisibility } from "@/hooks/useDelayedVisibility"; import { formatTagLabel } from "@/lib/tags"; +import { deriveHistoryViewState } from "@/lib/ui/deriveHistoryViewState"; import { useJournalStore } from "@/store/journal-store"; import type { MoodId, @@ -122,6 +127,18 @@ export function JournalHistoryScreen() { () => groupJournalEntries(filteredEntries), [filteredEntries], ); + const hasActiveFilters = searchQuery.trim().length > 0 || selectedMood !== "all"; + const showHydrationState = useDelayedVisibility(!hasHydrated); + const viewState = useMemo( + () => + deriveHistoryViewState({ + entries, + filteredEntries, + hasActiveFilters, + hasHydrated, + }), + [entries, filteredEntries, hasActiveFilters, hasHydrated], + ); const handleViewModeChange = (nextViewMode: JournalHistoryViewMode) => { if (nextViewMode === viewMode) { return; @@ -131,6 +148,11 @@ export function JournalHistoryScreen() { setViewMode(nextViewMode); }; + function clearFilters() { + setSearchQuery(""); + setSelectedMood("all"); + } + useEffect(() => { contentSlideAnimation.stopAnimation(); contentSlideAnimation.setValue(0); @@ -304,31 +326,19 @@ export function JournalHistoryScreen() { - {!hasHydrated ? ( - - ) : journalSections.length === 0 ? ( - 0 || selectedMood !== "all" - ? "Try a different search or mood filter." - : "Create your first reflection to see it here." - } - ctaLabel={ - searchQuery.trim().length > 0 || selectedMood !== "all" - ? undefined - : "Create Entry" - } - onCtaPress={ - searchQuery.trim().length > 0 || selectedMood !== "all" - ? undefined - : () => router.push(newJournalEntryHref) - } - title={ - searchQuery.trim().length > 0 || selectedMood !== "all" - ? "No matching entries" - : "No journal entries yet" - } + {viewState.status === "hydrating" ? ( + showHydrationState ? ( + + ) : null + ) : viewState.emptyReason === "first_use" ? ( + router.push(newJournalEntryHref)} + title="Your journal begins here" /> + ) : viewState.emptyReason === "filtered" ? ( + ) : ( journalSections.map((section, sectionIndex) => ( @@ -386,45 +396,6 @@ export function JournalHistoryScreen() { ); } -function EmptyHistoryMessage({ - body, - ctaLabel, - onCtaPress, - title, -}: { - body?: string; - ctaLabel?: string; - onCtaPress?: () => void; - title: string; -}) { - return ( - - - {title} - - {body ? ( - - {body} - - ) : null} - {ctaLabel && onCtaPress ? ( - - - {ctaLabel} - - - ) : null} - - ); -} - function groupJournalEntries( entries: StoredJournalEntry[], ): JournalHistorySection[] { diff --git a/components/legal/legal-document-screen.tsx b/components/legal/legal-document-screen.tsx index 9059e90..240ad2c 100644 --- a/components/legal/legal-document-screen.tsx +++ b/components/legal/legal-document-screen.tsx @@ -5,6 +5,7 @@ import { ScrollView, Text, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AnimatedIconButton } from "@/components/ui/animated-icon-button"; +import { colors } from "@/constants/theme"; import type { LegalSection } from "@/content/legal/privacyPolicy"; type LegalDocumentScreenProps = { @@ -15,36 +16,17 @@ type LegalDocumentScreenProps = { version: string; }; -const legalDocumentColors = { - background: "#FFF7FB", - bodyText: "#51515B", - cardBackground: "#FFFFFF", - gradientEnd: "#FFFFFF", - gradientStart: "#FFF4FA", - heading: "#27272A", - iconMuted: "#51515B", - mutedText: "#71717B", -} as const; - -const legalDocumentSpacing = { - bottomInsetOffset: 36, - bottomMinimum: 56, - horizontal: 24, - topInsetOffset: 28, - topMinimum: 52, -} as const; - -const legalDocumentLayout = { - headerSpacerSize: 50, - iconSize: 24, -} as const; - +const legalDocumentBottomInsetOffset = 36; +const legalDocumentBottomMinimum = 56; const legalDocumentCardShadow = "0 2px 8px rgba(39, 39, 42, 0.12)"; const legalDocumentIconShadow = "0 2px 6px rgba(39, 39, 42, 0.16)"; const legalDocumentGradientColors = [ - legalDocumentColors.gradientStart, - legalDocumentColors.gradientEnd, + colors.legalGradientStart, + colors.legalGradientEnd, ] as const; +const legalDocumentIconSize = 24; +const legalDocumentTopInsetOffset = 28; +const legalDocumentTopMinimum = 52; export function LegalDocumentScreen({ accountDeletionUrl, @@ -54,6 +36,17 @@ export function LegalDocumentScreen({ version, }: LegalDocumentScreenProps) { const insets = useSafeAreaInsets(); + const visibleSections = + sections.length > 0 + ? sections + : [ + { + body: [ + "This legal document is unavailable in this development build.", + ], + title: "Content unavailable", + }, + ]; function handleBackPress() { if (router.canGoBack()) { @@ -65,10 +58,7 @@ export function LegalDocumentScreen({ } return ( - + - - - - + + + + + - - {title} - + + {title} + - - + + - - - - Version {version} - - + - Effective date: {effectiveDate} - + + Version {version} + + + Effective date: {effectiveDate} + + - - - {sections.map((section, sectionIndex) => ( - + + {visibleSections.map((section, sectionIndex) => ( + + + {section.title} + + {section.body.map((paragraph, paragraphIndex) => ( + + {paragraph} + + ))} + + ))} + + + {accountDeletionUrl ? ( + + + External account-deletion page + - {section.title} + {accountDeletionUrl} - {section.body.map((paragraph, paragraphIndex) => ( - - {paragraph} - - ))} - ))} + ) : null} - - {accountDeletionUrl ? ( - - - External account-deletion page - - - {accountDeletionUrl} - - - ) : null} ); diff --git a/components/profile/notification-settings-screen.tsx b/components/profile/notification-settings-screen.tsx index 1be344e..ced1628 100644 --- a/components/profile/notification-settings-screen.tsx +++ b/components/profile/notification-settings-screen.tsx @@ -204,7 +204,11 @@ export function NotificationSettingsScreen() { Use notifications? - Daily reflection reminders + {hasHydrated + ? isEnabled + ? "Daily reflection reminders are on" + : "No reminders yet" + : "Loading reminder settings..."} {isSaving ? ( @@ -225,6 +229,7 @@ export function NotificationSettingsScreen() { setActivePicker({ @@ -236,6 +241,7 @@ export function NotificationSettingsScreen() { time={morningReminderTime} /> setActivePicker({ @@ -269,10 +275,12 @@ export function NotificationSettingsScreen() { } function ReminderTimeRow({ + disabled = false, label, onPress, time, }: { + disabled?: boolean; label: string; onPress: () => void; time: string; @@ -281,9 +289,14 @@ function ReminderTimeRow({ diff --git a/components/profile/profile-screen.tsx b/components/profile/profile-screen.tsx index fdf9d84..cfcaf47 100644 --- a/components/profile/profile-screen.tsx +++ b/components/profile/profile-screen.tsx @@ -144,10 +144,10 @@ export function ProfileScreen() { if (user?.id) { useSyncStore.getState().clearSyncStateForUser(user.id); } - setSupabaseAccessTokenProvider(null); try { await signOut(); + setSupabaseAccessTokenProvider(null); router.replace("/login"); } catch { setActiveUserId(user?.id ?? null); @@ -363,8 +363,8 @@ export function ProfileScreen() { showDialog({ confirmText: "OK", icon: "📝", - message: "No entries to export.", - title: "No entries to export", + message: "Write your first journal entry before creating an export.", + title: "Nothing to export yet", }); return; } @@ -673,7 +673,9 @@ export function ProfileScreen() { /> { if (item.label === "Export Journal") { handleExportJournalPress(); diff --git a/components/states/FilteredEmptyState.tsx b/components/states/FilteredEmptyState.tsx new file mode 100644 index 0000000..bd0f2ea --- /dev/null +++ b/components/states/FilteredEmptyState.tsx @@ -0,0 +1,22 @@ +import { ScreenEmptyState } from "@/components/states/ScreenEmptyState"; + +type FilteredEmptyStateProps = { + message?: string; + onClearFilters: () => void; + title?: string; +}; + +export function FilteredEmptyState({ + message = "Try another search or clear your filters.", + onClearFilters, + title = "No entries match your search", +}: FilteredEmptyStateProps) { + return ( + + ); +} diff --git a/components/states/InlineRefreshState.tsx b/components/states/InlineRefreshState.tsx new file mode 100644 index 0000000..dc5087e --- /dev/null +++ b/components/states/InlineRefreshState.tsx @@ -0,0 +1,24 @@ +import { ActivityIndicator, Text, View } from "react-native"; + +import { colors } from "@/constants/theme"; + +type InlineRefreshStateProps = { + label?: string; +}; + +export function InlineRefreshState({ + label = "Updating...", +}: InlineRefreshStateProps) { + return ( + + + + {label} + + + ); +} diff --git a/components/states/ScreenEmptyState.tsx b/components/states/ScreenEmptyState.tsx new file mode 100644 index 0000000..661c2a0 --- /dev/null +++ b/components/states/ScreenEmptyState.tsx @@ -0,0 +1,67 @@ +import type { ReactNode } from "react"; +import { Pressable, Text, View } from "react-native"; + +type ScreenEmptyStateProps = { + actionLabel?: string; + compact?: boolean; + icon?: ReactNode; + message: string; + onAction?: () => void; + onSecondaryAction?: () => void; + secondaryActionLabel?: string; + title: string; +}; + +export function ScreenEmptyState({ + actionLabel, + compact = false, + icon, + message, + onAction, + onSecondaryAction, + secondaryActionLabel, + title, +}: ScreenEmptyStateProps) { + return ( + + {icon ? {icon} : null} + + {title} + + + {message} + + {actionLabel && onAction ? ( + + + {actionLabel} + + + ) : null} + {secondaryActionLabel && onSecondaryAction ? ( + + + {secondaryActionLabel} + + + ) : null} + + ); +} diff --git a/components/states/ScreenErrorState.tsx b/components/states/ScreenErrorState.tsx new file mode 100644 index 0000000..1942ce6 --- /dev/null +++ b/components/states/ScreenErrorState.tsx @@ -0,0 +1,48 @@ +import { Pressable, Text, View } from "react-native"; + +import type { AppError } from "@/types/appError"; + +type ScreenErrorStateProps = { + compact?: boolean; + error: AppError; + onRetry?: () => void; + retrying?: boolean; +}; + +export function ScreenErrorState({ + compact = false, + error, + onRetry, + retrying = false, +}: ScreenErrorStateProps) { + return ( + + + Something needs attention + + + {error.userMessage} + + {error.retryable && onRetry ? ( + + + {retrying ? "Retrying..." : "Retry"} + + + ) : null} + + ); +} diff --git a/components/states/ScreenLoadingState.tsx b/components/states/ScreenLoadingState.tsx new file mode 100644 index 0000000..d4c1d07 --- /dev/null +++ b/components/states/ScreenLoadingState.tsx @@ -0,0 +1,34 @@ +import { ActivityIndicator, Text, View } from "react-native"; + +import { colors } from "@/constants/theme"; + +type ScreenLoadingStateProps = { + message?: string; + testID?: string; + title?: string; +}; + +export function ScreenLoadingState({ + message, + testID, + title = "Loading...", +}: ScreenLoadingStateProps) { + return ( + + + + {title} + + {message ? ( + + {message} + + ) : null} + + ); +} diff --git a/components/states/StaleDataNotice.tsx b/components/states/StaleDataNotice.tsx new file mode 100644 index 0000000..1a7fb88 --- /dev/null +++ b/components/states/StaleDataNotice.tsx @@ -0,0 +1,37 @@ +import { Pressable, Text, View } from "react-native"; + +type StaleDataNoticeProps = { + message: string; + onRetry?: () => void; + retrying?: boolean; +}; + +export function StaleDataNotice({ + message, + onRetry, + retrying = false, +}: StaleDataNoticeProps) { + return ( + + + {message} + + {onRetry ? ( + + + {retrying ? "Retrying..." : "Retry"} + + + ) : null} + + ); +} diff --git a/components/sync/SyncStatusIndicator.tsx b/components/sync/SyncStatusIndicator.tsx index 80f1327..6c1e506 100644 --- a/components/sync/SyncStatusIndicator.tsx +++ b/components/sync/SyncStatusIndicator.tsx @@ -1,6 +1,7 @@ import { CheckCircle2, Cloud, CloudOff, Loader2, PauseCircle } from "lucide-react-native"; import { Text, View } from "react-native"; +import { SYNC_STATUS_COLORS } from "@/constants/theme"; import type { UserSyncStatus } from "@/types/syncStatus"; type SyncStatusIndicatorProps = { @@ -16,38 +17,38 @@ const statusTheme: Record< } > = { failed: { - backgroundColor: "#FFE8F0", - color: "#BE123C", + backgroundColor: SYNC_STATUS_COLORS.failed.background, + color: SYNC_STATUS_COLORS.failed.text, label: "Sync failed", }, idle: { - backgroundColor: "#F4F4F5", - color: "#71717B", + backgroundColor: SYNC_STATUS_COLORS.idle.background, + color: SYNC_STATUS_COLORS.idle.text, label: "Checking", }, paused: { - backgroundColor: "#F4F4F5", - color: "#71717B", + backgroundColor: SYNC_STATUS_COLORS.paused.background, + color: SYNC_STATUS_COLORS.paused.text, label: "Paused", }, saved_locally: { - backgroundColor: "#F4EFFA", - color: "#6D28D9", + backgroundColor: SYNC_STATUS_COLORS.saved_locally.background, + color: SYNC_STATUS_COLORS.saved_locally.text, label: "Saved locally", }, synced: { - backgroundColor: "#DCFCE7", - color: "#15803D", + backgroundColor: SYNC_STATUS_COLORS.synced.background, + color: SYNC_STATUS_COLORS.synced.text, label: "Synced", }, syncing: { - backgroundColor: "#E0F2FE", - color: "#0369A1", + backgroundColor: SYNC_STATUS_COLORS.syncing.background, + color: SYNC_STATUS_COLORS.syncing.text, label: "Syncing", }, waiting_for_network: { - backgroundColor: "#FFF7ED", - color: "#C2410C", + backgroundColor: SYNC_STATUS_COLORS.waiting_for_network.background, + color: SYNC_STATUS_COLORS.waiting_for_network.text, label: "Waiting", }, }; diff --git a/components/sync/SyncStatusRow.tsx b/components/sync/SyncStatusRow.tsx index 216a250..64496e1 100644 --- a/components/sync/SyncStatusRow.tsx +++ b/components/sync/SyncStatusRow.tsx @@ -24,10 +24,10 @@ export function SyncStatusRow({ > - + Data & Sync - + {copy.description} @@ -52,10 +52,10 @@ export function SyncStatusRow({ {showRetry || isRetrying ? ( @@ -72,10 +72,10 @@ export function SyncStatusRow({ function StatusDetail({ label, value }: { label: string; value: string }) { return ( - + {label} - + {value} diff --git a/components/sync/auto-sync-manager.tsx b/components/sync/auto-sync-manager.tsx index 063832b..4944f84 100644 --- a/components/sync/auto-sync-manager.tsx +++ b/components/sync/auto-sync-manager.tsx @@ -141,9 +141,8 @@ export function AutoSyncManager() { return; } - retryAttemptRef.current += 1; - const timeout = setTimeout(() => { + retryAttemptRef.current += 1; void runAutoSync("retry"); }, delay); diff --git a/constants/theme.ts b/constants/theme.ts new file mode 100644 index 0000000..eff0b7d --- /dev/null +++ b/constants/theme.ts @@ -0,0 +1,67 @@ +import type { UserSyncStatus } from "@/types/syncStatus"; + +export const colors = { + brandPrimary: "#FF2056", + errorSurface: "#FFF1F5", + errorText: "#9F1239", + legalBackground: "#FFF7FB", + legalCard: "#FFFFFF", + legalGradientEnd: "#FFFFFF", + legalGradientStart: "#FFF4FA", + offlineIcon: "#C2410C", + offlineSurface: "#FFF7ED", + offlineText: "#9A3412", + textMuted: "#71717B", + textPrimary: "#27272A", + textSecondary: "#51515B", +} as const; + +export const CONNECTION_STATE_COLORS = { + offline: { + background: colors.offlineSurface, + dot: "#F97316", + text: colors.offlineIcon, + }, + online: { + background: "#CFF8E6", + dot: "#10B981", + text: "#047857", + }, +} as const; + +export const SYNC_STATUS_COLORS: Record< + UserSyncStatus, + { + background: string; + text: string; + } +> = { + failed: { + background: "#FFE8F0", + text: "#BE123C", + }, + idle: { + background: "#F4F4F5", + text: colors.textMuted, + }, + paused: { + background: "#F4F4F5", + text: colors.textMuted, + }, + saved_locally: { + background: "#F4EFFA", + text: "#6D28D9", + }, + synced: { + background: "#DCFCE7", + text: "#15803D", + }, + syncing: { + background: "#E0F2FE", + text: "#0369A1", + }, + waiting_for_network: { + background: colors.offlineSurface, + text: colors.offlineIcon, + }, +}; diff --git a/docs/screen-state-audit.md b/docs/screen-state-audit.md new file mode 100644 index 0000000..92cc97c --- /dev/null +++ b/docs/screen-state-audit.md @@ -0,0 +1,58 @@ +# DearDiary Screen State Audit + +Audit date: 2026-06-22 + +This audit records the current local-first state signals used by the major +DearDiary screens. Local content should render after the correct user store is +hydrated, while remote refreshes must keep cached content visible. + +| Screen | Hydration | First Empty | Filtered Empty | Refreshing | Offline | Error | Retry | +| --- | --- | --- | --- | --- | --- | --- | --- | +| Home | `journal-store.hasHydrated` | Recent entries show "Your journal begins here" | N/A | Sync does not hide local content | Local cards remain usable | Section-level only | Sync retries through auto-sync | +| Reflect | `journal-store.hasHydrated` for existing prompt lookup | Normal prompt cards remain available | N/A | N/A | Manual prompts remain; AI Chat explains internet need | AI Chat handles request failure | AI Chat send/retry path | +| Journal History | `journal-store.hasHydrated` | "Your journal begins here" | "No entries match your search" + Clear filters | List remains visible during sync | Fully local | Sync errors stay outside list content | Clear filters or sync retry | +| Calendar | `journal-store.hasHydrated` | Grid remains visible with first-use copy | N/A | Grid remains visible during sync | Fully local | Sync errors do not hide calendar | Sync retry outside calendar | +| Journal Editor | `journal-store.hasHydrated` before entry lookup | New editor is normal state | N/A | Local save UI remains in place | Editing remains local; AI generation requires internet | Local save dialog preserves draft | Save again; AI reflection retry | +| Entry AI Reflection | reflection cache hydration + remote refresh | Generate Reflection CTA | N/A | Existing reflection remains visible | Existing reflection remains; generation blocked by editor dialog | Inline safe error copy | Generate/regenerate operation | +| Insights | `journal-store.hasHydrated` and report-cache hydration | "Your insights will grow with your journal" | N/A | Charts stay visible; report cards keep cache | Local charts remain; report generation explains internet need | AI report errors are normalized | Report refresh/generate | +| Weekly Report | journal + report-cache hydration | "No report yet" / insufficient entries | N/A | Existing report remains + updating banner | Cached report remains; generation blocked | Safe retryable banner | Refresh current weekly period | +| Monthly Report | journal + report-cache hydration | "No report yet" / insufficient entries | N/A | Existing report remains + updating banner | Cached report remains; generation blocked | Safe retryable banner | Refresh current monthly period | +| AI Chat | `chat-store.hasHydrated` | Starter prompt after hydration only | N/A | Existing messages remain while sending | Existing messages remain; send is blocked with internet-required copy | Remote failure falls back locally | Send operation is single-flight | +| Achievements | `journal-store.hasHydrated` | Locked achievements show normally | Unlocked filter shows "No achievements unlocked yet" | Local calculation remains visible | Fully local | Sync failure does not hide unlocks | Sync retry from profile | +| Notifications | `notification-preferences-store.hasHydrated` | "No reminders yet" when disabled | N/A | Existing settings remain while saving | Scheduling may fail through existing notification errors | Dialog keeps selected settings visible | Toggle/time change again | +| Export | `journal-store.hasHydrated` | "Nothing to export yet" | N/A | Export row shows busy/disabled state | Local export can proceed if sharing is available | Safe export failure dialog | Export action can be retried | +| Backup & Sync | journal, achievement, and sync stores hydrate | No changes waiting | N/A | Existing profile content remains | Waiting-for-internet state | Safe sync failure copy | `requestSync` single operation | +| Profile | journal + achievement + sync hydration | Loading stats until hydrated | N/A | Profile remains while sync/export/delete run | Local stats and settings remain | Dialogs use user-safe messages | Sync/export/delete operation | +| Legal | Static local constants | Development fallback if constants missing | N/A | N/A | Available signed out/offline | Fallback content prevents blank screen | N/A | + +## Existing Signals + +- Authentication gate: Clerk `isLoaded`, `isSignedIn`, and `userId`. +- Privacy gate: `AppLockGate` waits for App Lock `status` before private content. +- User-scoped journal hydration: `useJournalStore().hasHydrated` plus `activeUserId`. +- Sync metadata hydration: `useSyncStore().hasHydrated`. +- Achievement notification hydration: `useAchievementStore().hasHydrated`. +- Notification preference hydration: `useNotificationPreferencesStore().hasHydrated`. +- Chat hydration: `useChatStore().hasHydrated`. +- Entry reflection cache hydration: `useEntryReflectionStore().hasHydrated`. +- AI report cache hydration: `useAIInsightReportStore().hasHydrated`. +- Connectivity: `useConnectivity()`. +- Normalized errors: `normalizeAppError()` and `AppError.userMessage`. + +## Retry Operations + +- History and Calendar do not fetch remotely; retry belongs to sync status. +- Sync retry calls `requestSync()` through the profile Data & Sync row. +- Entry reflection retry calls `generate()` or `regenerate()` for the current entry. +- Report retry calls `refresh()` for the selected weekly/monthly period. +- Export retry repeats the selected export format. +- Notification retry repeats the toggle or time-change operation. +- AI Chat send is single-flight and preserves existing messages. + +## Remaining Manual Verification + +- Physical Android verification is still required. +- Force-slow AsyncStorage hydration should confirm delayed loaders avoid flicker. +- Force report/reflection failures should confirm cached content remains visible. +- User switching should confirm user-scoped journal, chat, reflection, and report + data never flashes across accounts. diff --git a/global.css b/global.css index 9431a57..5fa7160 100644 --- a/global.css +++ b/global.css @@ -3,3 +3,21 @@ @import "tailwindcss/utilities.css"; @import "nativewind/theme"; + +@theme { + --color-brand-primary: #FF2056; + --color-text-primary: #27272A; + --color-text-secondary: #51515B; + --color-text-muted: #71717B; + --color-error-surface: #FFF1F5; + --color-error-text: #9F1239; + --color-legal-background: #FFF7FB; + --color-legal-card: #FFFFFF; + --color-legal-gradient-end: #FFFFFF; + --color-legal-gradient-start: #FFF4FA; + --color-offline-surface: #FFF7ED; + --color-offline-icon: #C2410C; + --color-offline-text: #9A3412; + --spacing-legal-screen: 24px; + --spacing-legal-header-spacer: 50px; +} diff --git a/hooks/useAIInsightReport.ts b/hooks/useAIInsightReport.ts index f81066f..6f009b0 100644 --- a/hooks/useAIInsightReport.ts +++ b/hooks/useAIInsightReport.ts @@ -5,6 +5,7 @@ import { fetchAIInsightReport, generateAIInsightReport, } from "@/lib/insights/aiInsightReportService"; +import { normalizeAppError } from "@/lib/errors/normalizeAppError"; import { getReportCacheKey, type ReportPeriod, @@ -47,6 +48,9 @@ export function useAIInsightReport( const cachedReport = useAIInsightReportStore((state) => userId ? state.reportsByUser[userId]?.[cacheKey] ?? null : null, ); + const cacheHasHydrated = useAIInsightReportStore( + (state) => state.hasHydrated, + ); const removeCachedReport = useAIInsightReportStore( (state) => state.removeCachedReport, ); @@ -60,6 +64,7 @@ export function useAIInsightReport( const [error, setError] = useState(null); const requestContextVersionRef = useRef(0); const requestInFlightRef = useRef(false); + const reportRef = useRef(report); const periodEntries = useMemo( () => getEntriesInPeriod(entries, period), @@ -73,6 +78,10 @@ export function useAIInsightReport( report && isReportStale(report, localSource), ); + useEffect(() => { + reportRef.current = report; + }, [report]); + useEffect(() => { requestContextVersionRef.current += 1; requestInFlightRef.current = false; @@ -102,6 +111,10 @@ export function useAIInsightReport( return; } + if (!cacheHasHydrated) { + return; + } + if (!userId) { setError("Please sign in again before viewing reflection reports."); return; @@ -128,7 +141,7 @@ export function useAIInsightReport( if (result.report) { setReport(result.report); setCachedReport(userId, cacheKey, result.report); - } else { + } else if (!reportRef.current) { setReport(null); removeCachedReport(userId, cacheKey); } @@ -145,6 +158,7 @@ export function useAIInsightReport( } }, [ cacheKey, + cacheHasHydrated, enabled, isCurrentRequestContext, period, @@ -155,10 +169,10 @@ export function useAIInsightReport( ]); useEffect(() => { - if (enabled && hasHydrated) { + if (enabled && hasHydrated && cacheHasHydrated) { void refresh(); } - }, [enabled, hasHydrated, refresh]); + }, [cacheHasHydrated, enabled, hasHydrated, refresh]); const requestGeneration = useCallback( async (regenerate: boolean) => { @@ -166,7 +180,7 @@ export function useAIInsightReport( return; } - if (requestInFlightRef.current) { + if (!cacheHasHydrated || requestInFlightRef.current) { return; } @@ -230,6 +244,7 @@ export function useAIInsightReport( }, [ cacheKey, + cacheHasHydrated, connectivity.status, enabled, isCurrentRequestContext, @@ -245,7 +260,7 @@ export function useAIInsightReport( error, generate: () => requestGeneration(false), isGenerating, - isLoading, + isLoading: isLoading || (enabled && !cacheHasHydrated), isStale, legacyReportAvailable, refresh, @@ -387,7 +402,7 @@ function isTimestampAfter( } function getErrorMessage(error: unknown) { - return error instanceof Error - ? error.message - : "Reflection report could not be loaded."; + return normalizeAppError(error, { + operation: "ai_insight_report", + }).userMessage; } diff --git a/hooks/useAutoSync.ts b/hooks/useAutoSync.ts index 8bea20a..2e33967 100644 --- a/hooks/useAutoSync.ts +++ b/hooks/useAutoSync.ts @@ -120,7 +120,3 @@ function isWithinAutoSyncCooldown(lastSyncedAt: string | null) { Date.now() - timestamp < autoSyncCooldownMs ); } - -function startOfLocalDay(date: Date) { - return new Date(date.getFullYear(), date.getMonth(), date.getDate()); -} diff --git a/hooks/useDelayedVisibility.ts b/hooks/useDelayedVisibility.ts new file mode 100644 index 0000000..5bdbce2 --- /dev/null +++ b/hooks/useDelayedVisibility.ts @@ -0,0 +1,20 @@ +import { useEffect, useState } from "react"; + +export function useDelayedVisibility(visible: boolean, delayMs = 200): boolean { + const [isVisible, setIsVisible] = useState(false); + + useEffect(() => { + if (!visible) { + setIsVisible(false); + return; + } + + const timer = setTimeout(() => { + setIsVisible(true); + }, delayMs); + + return () => clearTimeout(timer); + }, [delayMs, visible]); + + return isVisible; +} diff --git a/hooks/useEntryReflection.ts b/hooks/useEntryReflection.ts index 3ba1d0b..576868f 100644 --- a/hooks/useEntryReflection.ts +++ b/hooks/useEntryReflection.ts @@ -4,6 +4,7 @@ import { fetchEntryReflection, generateEntryReflection, } from "@/lib/ai/entryReflectionService"; +import { normalizeAppError } from "@/lib/errors/normalizeAppError"; import { useEntryReflectionStore } from "@/store/useEntryReflectionStore"; import type { EntryAIReflection } from "@/types/entryReflection"; @@ -36,6 +37,9 @@ export function useEntryReflection({ ? state.getReflectionByEntryId(userId, entryId) : undefined, ); + const cacheHasHydrated = useEntryReflectionStore( + (state) => state.hasHydrated, + ); const upsertReflection = useEntryReflectionStore( (state) => state.upsertReflection, ); @@ -55,7 +59,7 @@ export function useEntryReflection({ }, [entryId, userId]); const refresh = useCallback(async () => { - if (!canUseReflection || !entryId || !userId) { + if (!cacheHasHydrated || !canUseReflection || !entryId || !userId) { return; } @@ -74,7 +78,7 @@ export function useEntryReflection({ } finally { setIsLoading(false); } - }, [canUseReflection, entryId, upsertReflection, userId]); + }, [cacheHasHydrated, canUseReflection, entryId, upsertReflection, userId]); useEffect(() => { void refresh(); @@ -86,6 +90,10 @@ export function useEntryReflection({ return; } + if (!cacheHasHydrated) { + return; + } + setIsGenerating(true); setError(null); @@ -105,7 +113,14 @@ export function useEntryReflection({ setIsGenerating(false); } }, - [canUseReflection, entryId, isGenerating, upsertReflection, userId], + [ + cacheHasHydrated, + canUseReflection, + entryId, + isGenerating, + upsertReflection, + userId, + ], ); const generate = useCallback( @@ -122,7 +137,7 @@ export function useEntryReflection({ error, generate, isGenerating, - isLoading, + isLoading: isLoading || (canUseReflection && !cacheHasHydrated), isStale, reflection, refresh, @@ -131,6 +146,8 @@ export function useEntryReflection({ [ error, generate, + cacheHasHydrated, + canUseReflection, isGenerating, isLoading, isStale, @@ -142,9 +159,9 @@ export function useEntryReflection({ } function getReflectionErrorMessage(error: unknown) { - return error instanceof Error - ? error.message - : "DearDiary AI is unavailable right now."; + return normalizeAppError(error, { + operation: "entry_ai_reflection", + }).userMessage; } function isReflectionStale( diff --git a/lib/account/accountDeletionService.ts b/lib/account/accountDeletionService.ts index c790d10..f9bf96e 100644 --- a/lib/account/accountDeletionService.ts +++ b/lib/account/accountDeletionService.ts @@ -1,5 +1,6 @@ import { clearLocalUserData } from "@/lib/account/clearLocalUserData"; import { setSupabaseAccessTokenProvider } from "@/lib/supabase"; +import { isRecord } from "@/lib/utils/typeGuards"; import { useAccountDeletionStore } from "@/store/useAccountDeletionStore"; import type { AccountDeletionFailureCode, @@ -262,7 +263,3 @@ function isAccountDeletionFailureCode( value === "unknown" ); } - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} diff --git a/lib/errors/normalizeAppError.ts b/lib/errors/normalizeAppError.ts index f66aab7..7e4f372 100644 --- a/lib/errors/normalizeAppError.ts +++ b/lib/errors/normalizeAppError.ts @@ -4,6 +4,7 @@ import type { AppErrorCode, AppErrorSeverity, } from "@/types/appError"; +import { isRecord } from "@/lib/utils/typeGuards"; type NormalizeContext = { fallbackMessage?: string; @@ -209,7 +210,3 @@ function isTimeoutError(message: string, code: string) { message.includes("timed out") ); } - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} diff --git a/lib/sync/requestSync.ts b/lib/sync/requestSync.ts index 2fc721c..9df4221 100644 --- a/lib/sync/requestSync.ts +++ b/lib/sync/requestSync.ts @@ -107,7 +107,7 @@ async function runSync({ return { code: "sync_failed", localDataPreserved: true, - retryable: true, + retryable: false, success: false, }; } @@ -136,6 +136,7 @@ async function runSync({ if (pendingEntryIds.length > 0) { useJournalStore.getState().markEntriesPendingSync(userId, pendingEntryIds); } + const pendingEntryIdsSet = new Set(pendingEntryIds); try { await syncProfileToCloud({ @@ -201,7 +202,7 @@ async function runSync({ .allEntries.filter( (entry) => entry.userId === userId && - pendingEntryIds.includes(entry.id) && + pendingEntryIdsSet.has(entry.id) && entry.syncStatus !== "synced", ) .map((entry) => entry.id); diff --git a/lib/ui/deriveHistoryViewState.ts b/lib/ui/deriveHistoryViewState.ts new file mode 100644 index 0000000..470ccb1 --- /dev/null +++ b/lib/ui/deriveHistoryViewState.ts @@ -0,0 +1,46 @@ +import type { EmptyStateReason, LoadableStatus } from "@/types/uiState"; + +export type HistoryViewState = { + emptyReason: EmptyStateReason | null; + status: LoadableStatus; +}; + +type DeriveHistoryViewStateParams = { + entries: TEntry[]; + filteredEntries: TEntry[]; + hasActiveFilters: boolean; + hasHydrated: boolean; +}; + +export function deriveHistoryViewState({ + entries, + filteredEntries, + hasActiveFilters, + hasHydrated, +}: DeriveHistoryViewStateParams): HistoryViewState { + if (!hasHydrated) { + return { + emptyReason: null, + status: "hydrating", + }; + } + + if (entries.length === 0) { + return { + emptyReason: "first_use", + status: "empty", + }; + } + + if (filteredEntries.length === 0 && hasActiveFilters) { + return { + emptyReason: "filtered", + status: "empty", + }; + } + + return { + emptyReason: null, + status: "success", + }; +} diff --git a/lib/utils/typeGuards.ts b/lib/utils/typeGuards.ts new file mode 100644 index 0000000..35f1d60 --- /dev/null +++ b/lib/utils/typeGuards.ts @@ -0,0 +1,3 @@ +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/store/useAIInsightReportStore.ts b/store/useAIInsightReportStore.ts index f39f8b2..8426369 100644 --- a/store/useAIInsightReportStore.ts +++ b/store/useAIInsightReportStore.ts @@ -13,8 +13,10 @@ type AIInsightReportState = { reportsByUser: Record; clearReportsForUser: (userId: string) => void; getCachedReport: (userId: string | null, cacheKey: string) => AIInsightReport | null; + hasHydrated: boolean; removeCachedReport: (userId: string, cacheKey: string) => void; setCachedReport: (userId: string, cacheKey: string, report: AIInsightReport) => void; + setHasHydrated: (hasHydrated: boolean) => void; }; export const useAIInsightReportStore = create()( @@ -38,6 +40,7 @@ export const useAIInsightReportStore = create()( return get().reportsByUser[userId]?.[cacheKey] ?? null; }, + hasHydrated: false, removeCachedReport: (userId, cacheKey) => set((state) => { const userReports = state.reportsByUser[userId]; @@ -57,6 +60,7 @@ export const useAIInsightReportStore = create()( }; }), reportsByUser: {}, + setHasHydrated: (hasHydrated) => set({ hasHydrated }), setCachedReport: (userId, cacheKey, report) => set((state) => ({ reportsByUser: { @@ -71,6 +75,9 @@ export const useAIInsightReportStore = create()( { name: "dear-diary-ai-insight-reports", migrate: migrateReportState, + onRehydrateStorage: (state) => () => { + state?.setHasHydrated(true); + }, partialize: (state) => ({ reportsByUser: state.reportsByUser }), storage: createJSONStorage(() => AsyncStorage), version: reportStorageVersion, diff --git a/store/useChatStore.ts b/store/useChatStore.ts index c689ab5..0765ae1 100644 --- a/store/useChatStore.ts +++ b/store/useChatStore.ts @@ -20,7 +20,9 @@ type ChatState = { addMessage: (message: ChatMessage) => void; clearMessagesForUser: (userId: string) => void; getMessagesByUserId: (userId: string) => ChatMessage[]; + hasHydrated: boolean; messages: ChatMessage[]; + setHasHydrated: (hasHydrated: boolean) => void; }; function migrateChatState(persistedState: unknown) { @@ -87,11 +89,17 @@ export const useChatStore = create()( sortMessagesByDate( get().messages.filter((message) => message.userId === userId), ), + hasHydrated: false, messages: [], + setHasHydrated: (hasHydrated) => set({ hasHydrated }), }), { name: "deardiary-chat-store-v1", migrate: migrateChatState, + onRehydrateStorage: (state) => () => { + state?.setHasHydrated(true); + }, + partialize: (state) => ({ messages: state.messages }), storage: createJSONStorage(() => AsyncStorage), version: chatStorageVersion, }, diff --git a/store/useEntryReflectionStore.ts b/store/useEntryReflectionStore.ts index 11abe74..73de276 100644 --- a/store/useEntryReflectionStore.ts +++ b/store/useEntryReflectionStore.ts @@ -12,8 +12,10 @@ type EntryReflectionState = { userId: string, entryId: string, ) => EntryAIReflection | undefined; + hasHydrated: boolean; reflections: EntryAIReflection[]; removeReflectionForEntry: (userId: string, entryId: string) => void; + setHasHydrated: (hasHydrated: boolean) => void; upsertReflection: (reflection: EntryAIReflection) => void; }; @@ -41,6 +43,7 @@ export const useEntryReflectionStore = create()( (reflection) => reflection.userId === userId && reflection.entryId === entryId, ), + hasHydrated: false, reflections: [], removeReflectionForEntry: (userId, entryId) => set((state) => ({ @@ -61,10 +64,15 @@ export const useEntryReflectionStore = create()( reflections: [reflection, ...nextReflections], }; }), + setHasHydrated: (hasHydrated) => set({ hasHydrated }), }), { name: "deardiary-entry-reflections-v1", migrate: migrateEntryReflectionState, + onRehydrateStorage: (state) => () => { + state?.setHasHydrated(true); + }, + partialize: (state) => ({ reflections: state.reflections }), storage: createJSONStorage(() => AsyncStorage), version: entryReflectionStorageVersion, }, diff --git a/store/useSyncStore.ts b/store/useSyncStore.ts index 68be59d..99186f8 100644 --- a/store/useSyncStore.ts +++ b/store/useSyncStore.ts @@ -2,7 +2,8 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; -import type { AppErrorCode } from "@/types/appError"; +import { appErrorCodes, type AppErrorCode } from "@/types/appError"; +import { isRecord } from "@/lib/utils/typeGuards"; type SyncState = { clearSyncError: () => void; @@ -179,24 +180,3 @@ function getUserSafeSyncErrorLabel(code: AppErrorCode) { function isAppErrorCode(value: string): value is AppErrorCode { return appErrorCodes.includes(value as AppErrorCode); } - -function isRecord(value: unknown): value is Record { - return ( - typeof value === "object" && value !== null && !Array.isArray(value) - ); -} - -const appErrorCodes: AppErrorCode[] = [ - "offline", - "request_timeout", - "session_expired", - "permission_denied", - "local_save_failed", - "sync_failed", - "sync_conflict", - "ai_unavailable", - "rate_limited", - "invalid_data", - "resource_not_found", - "unexpected_error", -]; diff --git a/supabase/functions/delete-account/index.ts b/supabase/functions/delete-account/index.ts index 520ec10..2ba6b83 100644 --- a/supabase/functions/delete-account/index.ts +++ b/supabase/functions/delete-account/index.ts @@ -1,4 +1,5 @@ import { createClient } from "@supabase/supabase-js"; +import { isRecord } from "../../../lib/utils/typeGuards.ts"; type AccountDeletionFailureCode = | "unauthenticated" @@ -29,6 +30,7 @@ const corsHeaders = { const expectedConfirmationPhrase = "DELETE"; const clerkApiTimeoutMs = 10000; const storageListPageLimit = 100; +const storageRemoveBatchSize = 1000; const userOwnedStorageBuckets: string[] = []; Deno.serve(async (request) => { @@ -383,9 +385,23 @@ async function deleteStoragePrefix( return { ok: true }; } - const removeResult = await client.storage.from(bucket).remove(filesToRemove); + for ( + let index = 0; + index < filesToRemove.length; + index += storageRemoveBatchSize + ) { + const removeBatch = filesToRemove.slice( + index, + index + storageRemoveBatchSize, + ); + const removeResult = await client.storage.from(bucket).remove(removeBatch); - return removeResult.error ? { ok: false } : { ok: true }; + if (removeResult.error) { + return { ok: false }; + } + } + + return { ok: true }; } async function deleteClerkUser({ @@ -489,7 +505,3 @@ function jsonResponse(body: unknown, status = 200) { status, }); } - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} diff --git a/types/appError.ts b/types/appError.ts index 145eaf6..c3193b0 100644 --- a/types/appError.ts +++ b/types/appError.ts @@ -13,19 +13,20 @@ export type AppErrorCategory = export type AppErrorSeverity = "info" | "warning" | "error" | "fatal"; -export type AppErrorCode = - | "offline" - | "request_timeout" - | "session_expired" - | "permission_denied" - | "local_save_failed" - | "sync_failed" - | "sync_conflict" - | "ai_unavailable" - | "rate_limited" - | "invalid_data" - | "resource_not_found" - | "unexpected_error"; +export const appErrorCodes = [ + "offline", + "request_timeout", + "session_expired", + "permission_denied", + "local_save_failed", + "sync_failed", + "ai_unavailable", + "rate_limited", + "resource_not_found", + "unexpected_error", +] as const; + +export type AppErrorCode = (typeof appErrorCodes)[number]; export type AppError = { category: AppErrorCategory; diff --git a/types/uiState.ts b/types/uiState.ts new file mode 100644 index 0000000..8f5a903 --- /dev/null +++ b/types/uiState.ts @@ -0,0 +1,23 @@ +export type DataFreshness = "unknown" | "fresh" | "stale"; + +export type LoadableStatus = + | "idle" + | "hydrating" + | "loading" + | "refreshing" + | "success" + | "empty" + | "error"; + +export type EmptyStateReason = + | "first_use" + | "filtered" + | "no_data_for_period" + | "not_generated" + | "permission_required" + | "offline_unavailable"; + +export type RetryState = { + available: boolean; + inProgress: boolean; +}; From e17cda9a8ef1e5636297f50d856172fd833bbb58 Mon Sep 17 00:00:00 2001 From: aryansoni-dev Date: Tue, 23 Jun 2026 18:24:46 +0530 Subject: [PATCH 2/3] Changes : [+] Completed the Crash, Recovery, and Edge-Case Hardening Sprint for DearDiary. [+] Implemented code-rabbit's suggested bug fixes & improvements. --- app/(tabs)/journal-editor/index.tsx | 4 +- app/journal/[id].tsx | 3 +- components/ai-chat/ai-chat-screen.tsx | 5 + components/insights/insights-screen.tsx | 4 +- .../journal-editor/journal-editor-screen.tsx | 8 +- .../journal-history/journal-calendar-view.tsx | 4 +- constants/theme.ts | 41 ++- docs/crash-edge-case-test-plan.md | 86 +++++ docs/screen-state-audit.md | 4 +- hooks/useEntryReflection.ts | 36 ++- lib/ai/entryReflectionService.ts | 20 ++ lib/ai/remoteJournalAssistant.ts | 14 + lib/dev/faultInjection.ts | 22 ++ lib/insights/aiInsightReportService.ts | 20 ++ lib/navigation/routeValidators.ts | 20 ++ lib/storage/createPersistStorage.ts | 19 ++ lib/sync/requestSync.ts | 45 +++ lib/validation/activeUser.ts | 16 + lib/validation/persistedDataValidators.ts | 295 ++++++++++++++++++ store/journal-store.ts | 65 +--- store/notification-preferences-store.ts | 32 +- store/onboarding-store.ts | 14 +- store/useAIInsightReportStore.ts | 4 +- store/useAchievementStore.ts | 8 +- store/useChatStore.ts | 38 +-- store/useEntryReflectionStore.ts | 20 +- store/useSyncStore.ts | 4 +- types/faultInjection.ts | 14 + 28 files changed, 719 insertions(+), 146 deletions(-) create mode 100644 docs/crash-edge-case-test-plan.md create mode 100644 lib/dev/faultInjection.ts create mode 100644 lib/navigation/routeValidators.ts create mode 100644 lib/storage/createPersistStorage.ts create mode 100644 lib/validation/activeUser.ts create mode 100644 lib/validation/persistedDataValidators.ts create mode 100644 types/faultInjection.ts diff --git a/app/(tabs)/journal-editor/index.tsx b/app/(tabs)/journal-editor/index.tsx index 0d28352..9759496 100644 --- a/app/(tabs)/journal-editor/index.tsx +++ b/app/(tabs)/journal-editor/index.tsx @@ -1,8 +1,10 @@ import { Redirect, Stack, useLocalSearchParams } from "expo-router"; +import { getSingleRouteParam } from "@/lib/navigation/routeValidators"; + export default function JournalEditorTabScreen() { const { source } = useLocalSearchParams(); - const sourceParam = Array.isArray(source) ? source[0] : source; + const sourceParam = getSingleRouteParam(source); return ( <> diff --git a/app/journal/[id].tsx b/app/journal/[id].tsx index aad83c2..f3c8e08 100644 --- a/app/journal/[id].tsx +++ b/app/journal/[id].tsx @@ -1,10 +1,11 @@ import { Redirect, Stack, useLocalSearchParams } from "expo-router"; import { JournalEditorScreen } from "@/components/journal-editor/journal-editor-screen"; +import { getSafeRouteId } from "@/lib/navigation/routeValidators"; export default function ExistingJournalEntryScreen() { const { id } = useLocalSearchParams(); - const entryId = Array.isArray(id) ? id[0] : id; + const entryId = getSafeRouteId(id); if (!entryId) { return ; diff --git a/components/ai-chat/ai-chat-screen.tsx b/components/ai-chat/ai-chat-screen.tsx index 95f23de..477bad3 100644 --- a/components/ai-chat/ai-chat-screen.tsx +++ b/components/ai-chat/ai-chat-screen.tsx @@ -144,6 +144,11 @@ export function AiChatScreen({ }; }, []); + useEffect(() => { + requestIdRef.current += 1; + setIsThinking(false); + }, [userId]); + useEffect(() => { if (!shouldUseKeyboardOffset) { return; diff --git a/components/insights/insights-screen.tsx b/components/insights/insights-screen.tsx index fd298bd..6266abe 100644 --- a/components/insights/insights-screen.tsx +++ b/components/insights/insights-screen.tsx @@ -515,9 +515,7 @@ function getAIInsightCards({ weeklyReportIsLoading: boolean; }) { const report = monthlyReport ?? weeklyReport; - const isLoading = - (monthlyReportIsLoading && !monthlyReport) || - (weeklyReportIsLoading && !weeklyReport); + const isLoading = !report && (monthlyReportIsLoading || weeklyReportIsLoading); return localCards.map((card) => ({ ...card, diff --git a/components/journal-editor/journal-editor-screen.tsx b/components/journal-editor/journal-editor-screen.tsx index 3b1899f..484ca30 100644 --- a/components/journal-editor/journal-editor-screen.tsx +++ b/components/journal-editor/journal-editor-screen.tsx @@ -301,9 +301,7 @@ export function JournalEditorScreen({ entryId }: JournalEditorScreenProps) { showDialog({ confirmText: "OK", message: - appError.code === "local_save_failed" - ? "We could not save this entry on your device. Please try again before leaving." - : "We could not save this entry on your device. Please try again before leaving.", + "We could not save this entry on your device. Please try again before leaving.", title: "Save failed", variant: "destructive", }); @@ -832,10 +830,6 @@ function getSaveButtonLabel({ return "Save"; } - if (!wasSaved) { - return "Save"; - } - if (entrySyncStatus === "synced") { return "Synced"; } diff --git a/components/journal-history/journal-calendar-view.tsx b/components/journal-history/journal-calendar-view.tsx index 5d5a782..5090096 100644 --- a/components/journal-history/journal-calendar-view.tsx +++ b/components/journal-history/journal-calendar-view.tsx @@ -266,12 +266,12 @@ export function JournalCalendarView({ {selectedDay.entryCount > 0 ? ( renderSelectedEntries(selectedDay) - ) : ( + ) : calendarMonth.totalEntries > 0 ? ( - )} + ) : null} ); diff --git a/constants/theme.ts b/constants/theme.ts index eff0b7d..5831b53 100644 --- a/constants/theme.ts +++ b/constants/theme.ts @@ -8,9 +8,22 @@ export const colors = { legalCard: "#FFFFFF", legalGradientEnd: "#FFFFFF", legalGradientStart: "#FFF4FA", + onlineIcon: "#10B981", + onlineSurface: "#CFF8E6", + onlineText: "#047857", offlineIcon: "#C2410C", + offlineIndicator: "#F97316", offlineSurface: "#FFF7ED", offlineText: "#9A3412", + syncFailedSurface: "#FFE8F0", + syncFailedText: "#BE123C", + syncIdleSurface: "#F4F4F5", + syncSavedLocallySurface: "#F4EFFA", + syncSavedLocallyText: "#6D28D9", + syncSyncedSurface: "#DCFCE7", + syncSyncedText: "#15803D", + syncSyncingSurface: "#E0F2FE", + syncSyncingText: "#0369A1", textMuted: "#71717B", textPrimary: "#27272A", textSecondary: "#51515B", @@ -19,13 +32,13 @@ export const colors = { export const CONNECTION_STATE_COLORS = { offline: { background: colors.offlineSurface, - dot: "#F97316", + dot: colors.offlineIndicator, text: colors.offlineIcon, }, online: { - background: "#CFF8E6", - dot: "#10B981", - text: "#047857", + background: colors.onlineSurface, + dot: colors.onlineIcon, + text: colors.onlineText, }, } as const; @@ -37,28 +50,28 @@ export const SYNC_STATUS_COLORS: Record< } > = { failed: { - background: "#FFE8F0", - text: "#BE123C", + background: colors.syncFailedSurface, + text: colors.syncFailedText, }, idle: { - background: "#F4F4F5", + background: colors.syncIdleSurface, text: colors.textMuted, }, paused: { - background: "#F4F4F5", + background: colors.syncIdleSurface, text: colors.textMuted, }, saved_locally: { - background: "#F4EFFA", - text: "#6D28D9", + background: colors.syncSavedLocallySurface, + text: colors.syncSavedLocallyText, }, synced: { - background: "#DCFCE7", - text: "#15803D", + background: colors.syncSyncedSurface, + text: colors.syncSyncedText, }, syncing: { - background: "#E0F2FE", - text: "#0369A1", + background: colors.syncSyncingSurface, + text: colors.syncSyncingText, }, waiting_for_network: { background: colors.offlineSurface, diff --git a/docs/crash-edge-case-test-plan.md b/docs/crash-edge-case-test-plan.md new file mode 100644 index 0000000..0cbf395 --- /dev/null +++ b/docs/crash-edge-case-test-plan.md @@ -0,0 +1,86 @@ +# Crash and Edge-Case Test Plan + +This plan avoids private journal text, email addresses, full user IDs, and backup contents. Use synthetic entries only. + +## Audit Snapshot + +| Area | Current Finding | Sprint Action | +| --- | --- | --- | +| Persisted stores | Journal, chat, sync, achievement, entry reflection, AI report, notification preferences, and onboarding persist through Zustand and AsyncStorage. | Added shared fault-injectable storage and stricter hydration sanitizers for malformed records and preferences. | +| SecureStore | App Lock config is stored per sanitized user key. | Keep App Lock as the privacy gate; no route structure change. | +| Sync | `requestSync` is the single-flight sync entry point. | Added active-user checks before applying late sync results. | +| AI | Entry reflection, report generation, and AI Chat already use service boundaries. | Added development-only AI fault flags and stale request guards where missing. | +| Navigation | Private routes are behind auth and App Lock providers. | Added route ID validation for journal detail links. | +| Tests | No automated test script exists; `lint` is available. | Use this manual matrix; validation helpers are pure and can be unit-tested later if a test runner is approved. | + +## Fault Injection Usage + +Fault injection is code-controlled and development-only. Edit `enabledFaults` in `lib/dev/faultInjection.ts` while testing, then remove the key before committing or building. Production always returns `false` for every flag. + +Supported flags: + +```txt +async_storage_read_failure +async_storage_write_failure +sync_network_failure +sync_remote_failure +sync_timeout +ai_timeout +ai_empty_response +ai_invalid_response +backup_failure +restore_failure +expired_session +malformed_local_record +``` + +## Manual Matrix + +| ID | Area | Scenario | Preconditions | Steps | Expected | Actual | Status | Fix | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| CE-001 | App Lifecycle | App killed during journal save | Signed in; app lock resolved; one draft open | Edit entry, save, kill app before returning to history, reopen | Last confirmed local entry remains; editor is not locked; retry is possible | Not run on physical device | Not Tested | Manual device test required | +| CE-002 | App Lifecycle | Foreground triggers sync while another sync is active | Signed in with pending changes | Background/foreground while manual sync is running | One sync run applies; no permanent syncing state | Code audit found single-flight guard | Pass | Existing `requestSync` guard | +| CE-003 | Authentication | Sign out during sync | Signed in with pending local entry | Start sync, sign out immediately | User A data remains namespaced; late sync does not update current user state | Code audit found late mutation risk | Fixed | Active-user checks in `requestSync` | +| CE-004 | User Switching | User A to User B while AI Chat response is pending | User A sends AI Chat message | Switch accounts before response returns | User A response is ignored for User B | Code audit found user-change request gap | Fixed | AI Chat request generation bump on `userId` change | +| CE-005 | Local Persistence | AsyncStorage read failure | Enable `async_storage_read_failure` in dev | Cold launch app | App does not crash; persisted store uses safe default state; no cloud overwrite should be started from unhydrated data | Not run on physical device | Not Tested | Fault wrapper added | +| CE-006 | Local Persistence | AsyncStorage write failure | Enable `async_storage_write_failure` in dev | Create/update entry | UI should surface local-save failure where existing flow can observe it; text remains in memory until retry | Not run on physical device | Not Tested | Fault wrapper added; editor-specific persistence status remains a follow-up | +| CE-007 | Corrupted Data | One malformed journal record | Seed AsyncStorage with one invalid record and one valid record | Cold launch | Valid record loads; invalid record is excluded; no crash | Code path verified by sanitizer | Fixed | `normalizePersistedJournalEntries` | +| CE-008 | Corrupted Data | Duplicate local journal IDs | Seed two records with same user and ID | Cold launch history | No duplicate React keys; deterministic latest-updated record is shown | Code path verified by sanitizer | Fixed | Duplicate scoped-ID normalization | +| CE-009 | Corrupted Data | Invalid journal date | Seed record with invalid `createdAt` | Cold launch history/calendar | Store remains usable; invalid record excluded from normal UI | Code path verified by sanitizer | Fixed | Timestamp validation in hydration | +| CE-010 | Corrupted Data | Invalid chat message date | Seed chat message with invalid `createdAt` | Open AI Chat | Chat screen does not crash sorting messages | Code path verified by sanitizer | Fixed | Chat hydration validator | +| CE-011 | Notifications | Invalid reminder time in storage | Seed `99:99` reminder time | Open notification settings | Default reminder time is used; scheduling state remains safe | Code path verified by sanitizer | Fixed | Notification preference merge sanitizer | +| CE-012 | Synchronization | Remote push succeeds before local status update | Pending entry exists; interrupt after push | Retry sync | Stable entry ID prevents duplicate remote record; pending entry remains retryable | Not run against Supabase | Not Tested | Existing deterministic IDs and RPC merge; manual remote test required | +| CE-013 | Synchronization | Pull returns malformed row | Supabase has malformed/incompatible row | Run sync | Sync failure is retryable; local entries remain preserved | Code audit found pull throws before merge | Pass | Existing row parser and catch path | +| CE-014 | Synchronization | Concurrent triggers | Pending entry; reconnect, foreground, manual retry | Trigger all within one second | Only one active sync run executes | Code audit found single-flight guard | Pass | Existing `activeSync` | +| CE-015 | AI | Entry reflection response after leaving screen | Open entry; start reflection; leave screen | Wait for response | No setState after unmount; stale response ignored | Code audit found missing guard | Fixed | Generation guard in `useEntryReflection` | +| CE-016 | AI | Empty AI response | Enable `ai_empty_response` | Generate chat/reflection/report | Previous valid content remains; retry is available | Not run on device | Not Tested | Service fault flags added | +| CE-017 | AI | Repeated generate taps | Report or reflection can generate | Tap generate repeatedly | Duplicate requests do not overwrite newer result | Code audit partially verified | Pass | Existing `isGenerating`/in-flight guards; entry hook now ignores stale work | +| CE-018 | Navigation | Malformed journal deep link | App signed in and unlocked | Open `/journal/%2Fbad` | Redirects safely to journal history; no private data preview behind lock | Code path verified | Fixed | `getSafeRouteId` | +| CE-019 | Navigation | Invalid report period | App signed in and unlocked | Open invalid report period route | Safe "Reflection not found" state | Code audit verified | Pass | Existing period validator | +| CE-020 | App Lock | Deep link while locked | App Lock enabled | Lock app, open private deep link | Auth resolves, App Lock resolves, then private content renders only after unlock | Not run on physical device | Not Tested | Existing root `AppLockGate`; manual test required | +| CE-021 | Dates | Local midnight grouping | Entries at 11:59 PM and 12:01 AM local time | Open calendar/history/streaks | Entries group by local day, not UTC day | Code audit partially verified | Pass | Existing local date key helpers; physical timezone test required | +| CE-022 | Backup/Export | File write/share failure | Device with sharing unavailable or storage issue | Export JSON/Markdown | Export failure dialog appears; app does not crash | Code audit verified | Pass | Existing `JournalExportError` path | +| CE-023 | Restore | Invalid backup JSON | Restore UI unavailable in current codebase | N/A | N/A | No restore implementation found | Blocked | Out of current implemented surface | +| CE-024 | Account Deletion | Double-tap delete | Account deletion dialog open | Confirm twice quickly | Duplicate deletion request is blocked | Code audit verified | Pass | Existing deletion guard | +| CE-025 | Large Content | 10,000+ character journal entry | Signed in | Create long entry, reopen, export | No render crash; export contains full content | Not run on physical device | Not Tested | Manual device test required | +| CE-026 | Privacy | Error logging safety | Trigger sync/AI/export errors | Inspect development logs | Logs contain sanitized codes/context only, no journal content or tokens | Code audit partially verified | Pass | Existing `reportAppError`; no full-store logs added | + +## Regression Checklist + +| Feature | Status | Notes | +| --- | --- | --- | +| Clerk authentication | Not Tested | Verify login, sign-up, sign-out, expired session. | +| Onboarding | Not Tested | Hydration now sanitizes invalid persisted value to default. | +| App Lock | Not Tested | Test PIN, biometrics, background/foreground, locked deep links. | +| Journal CRUD and autosave | Not Tested | Verify create, edit, delete, reopen, and sync status. | +| Journal History, search, tags, calendar | Not Tested | Verify malformed records are not visible and valid records remain. | +| Mood tracking | Not Tested | Verify mood save and filtering. | +| Home and Reflect | Not Tested | Verify routes still open editor and AI Chat. | +| AI Chat | Not Tested | Verify remote response, local fallback, user switch guard. | +| Entry AI reflections | Not Tested | Verify generate, regenerate, stale-entry warning. | +| Weekly/monthly reports | Not Tested | Verify generate, refresh, invalid period route. | +| Achievements | Not Tested | Verify unlock notifications and sync. | +| Notifications | Not Tested | Verify permission denied, scheduling failure, cancel. | +| Export | Not Tested | Verify JSON and Markdown. | +| Account deletion | Not Tested | Verify guarded deletion sequence. | + diff --git a/docs/screen-state-audit.md b/docs/screen-state-audit.md index 92cc97c..849d0ee 100644 --- a/docs/screen-state-audit.md +++ b/docs/screen-state-audit.md @@ -8,9 +8,9 @@ hydrated, while remote refreshes must keep cached content visible. | Screen | Hydration | First Empty | Filtered Empty | Refreshing | Offline | Error | Retry | | --- | --- | --- | --- | --- | --- | --- | --- | -| Home | `journal-store.hasHydrated` | Recent entries show "Your journal begins here" | N/A | Sync does not hide local content | Local cards remain usable | Section-level only | Sync retries through auto-sync | +| Home | `journal-store.hasHydrated` | Recent entries show "Your journal begins here" | N/A | N/A - no refresh UI; parent auto-sync does not block local content | N/A - local-only screen with no offline UI | Pending - root error boundary only | Parent auto-sync only; no screen retry | | Reflect | `journal-store.hasHydrated` for existing prompt lookup | Normal prompt cards remain available | N/A | N/A | Manual prompts remain; AI Chat explains internet need | AI Chat handles request failure | AI Chat send/retry path | -| Journal History | `journal-store.hasHydrated` | "Your journal begins here" | "No entries match your search" + Clear filters | List remains visible during sync | Fully local | Sync errors stay outside list content | Clear filters or sync retry | +| Journal History | `journal-store.hasHydrated` | "Write your first entry and it will appear here." | "No entries match your search" + Clear filters | N/A - no refresh UI; parent auto-sync does not block local content | N/A - local-only screen with no offline UI | Pending - root error boundary only | Clear filters only; no screen sync retry | | Calendar | `journal-store.hasHydrated` | Grid remains visible with first-use copy | N/A | Grid remains visible during sync | Fully local | Sync errors do not hide calendar | Sync retry outside calendar | | Journal Editor | `journal-store.hasHydrated` before entry lookup | New editor is normal state | N/A | Local save UI remains in place | Editing remains local; AI generation requires internet | Local save dialog preserves draft | Save again; AI reflection retry | | Entry AI Reflection | reflection cache hydration + remote refresh | Generate Reflection CTA | N/A | Existing reflection remains visible | Existing reflection remains; generation blocked by editor dialog | Inline safe error copy | Generate/regenerate operation | diff --git a/hooks/useEntryReflection.ts b/hooks/useEntryReflection.ts index 576868f..0dd3d71 100644 --- a/hooks/useEntryReflection.ts +++ b/hooks/useEntryReflection.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { fetchEntryReflection, @@ -48,35 +48,54 @@ export function useEntryReflection({ const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(false); const [isGenerating, setIsGenerating] = useState(false); + const requestGenerationRef = useRef(0); const reflection = remoteReflection ?? cachedReflection ?? null; const isStale = isReflectionStale(reflection, entryUpdatedAt); const canUseReflection = Boolean(enabled && entryId && userId); useEffect(() => { + requestGenerationRef.current += 1; setRemoteReflection(null); setError(null); }, [entryId, userId]); + useEffect(() => { + return () => { + requestGenerationRef.current += 1; + }; + }, []); + const refresh = useCallback(async () => { if (!cacheHasHydrated || !canUseReflection || !entryId || !userId) { return; } + const requestGeneration = requestGenerationRef.current; setIsLoading(true); setError(null); try { const latestReflection = await fetchEntryReflection(entryId); + if (requestGenerationRef.current !== requestGeneration) { + return; + } + if (latestReflection?.userId === userId) { upsertReflection(latestReflection); setRemoteReflection(latestReflection); } } catch (refreshError) { + if (requestGenerationRef.current !== requestGeneration) { + return; + } + setError(getReflectionErrorMessage(refreshError)); } finally { - setIsLoading(false); + if (requestGenerationRef.current === requestGeneration) { + setIsLoading(false); + } } }, [cacheHasHydrated, canUseReflection, entryId, upsertReflection, userId]); @@ -94,6 +113,7 @@ export function useEntryReflection({ return; } + const requestGeneration = requestGenerationRef.current; setIsGenerating(true); setError(null); @@ -103,14 +123,24 @@ export function useEntryReflection({ regenerate, }); + if (requestGenerationRef.current !== requestGeneration) { + return; + } + if (generatedReflection.userId === userId) { upsertReflection(generatedReflection); setRemoteReflection(generatedReflection); } } catch (generationError) { + if (requestGenerationRef.current !== requestGeneration) { + return; + } + setError(getReflectionErrorMessage(generationError)); } finally { - setIsGenerating(false); + if (requestGenerationRef.current === requestGeneration) { + setIsGenerating(false); + } } }, [ diff --git a/lib/ai/entryReflectionService.ts b/lib/ai/entryReflectionService.ts index 87a6dcd..f2e18c6 100644 --- a/lib/ai/entryReflectionService.ts +++ b/lib/ai/entryReflectionService.ts @@ -4,6 +4,10 @@ import { FunctionsRelayError, } from "@supabase/supabase-js"; +import { + isFaultEnabled, + throwIfFaultEnabled, +} from "@/lib/dev/faultInjection"; import { getAuthenticatedSupabaseClient, SupabaseConfigurationError, @@ -43,6 +47,22 @@ export const generateEntryReflection = async (params: { entryId: string; regenerate?: boolean; }): Promise => { + throwIfFaultEnabled("ai_timeout"); + + if (isFaultEnabled("ai_empty_response")) { + throw new EntryReflectionServiceError( + "DearDiary AI returned an empty reflection.", + "empty_response", + ); + } + + if (isFaultEnabled("ai_invalid_response")) { + throw new EntryReflectionServiceError( + "DearDiary AI returned an invalid reflection.", + "invalid_response", + ); + } + const client = getEntryReflectionClient(); const { data, error } = await client.functions.invoke( diff --git a/lib/ai/remoteJournalAssistant.ts b/lib/ai/remoteJournalAssistant.ts index 5dabe66..9006ac2 100644 --- a/lib/ai/remoteJournalAssistant.ts +++ b/lib/ai/remoteJournalAssistant.ts @@ -4,6 +4,10 @@ import { FunctionsRelayError, } from "@supabase/supabase-js"; +import { + isFaultEnabled, + throwIfFaultEnabled, +} from "@/lib/dev/faultInjection"; import { getAuthenticatedSupabaseClient } from "@/lib/supabase"; import type { ClientContext } from "@/lib/ai/chatIntent"; @@ -24,6 +28,16 @@ export const generateRemoteJournalResponse = async (params: { message: string; recentMessages: RemoteJournalMessage[]; }): Promise => { + throwIfFaultEnabled("ai_timeout"); + + if (isFaultEnabled("ai_empty_response")) { + throw new Error("DearDiary AI returned an empty response."); + } + + if (isFaultEnabled("ai_invalid_response")) { + throw new Error("DearDiary AI returned an invalid response."); + } + const client = getAuthenticatedSupabaseClient(); const { data, error } = await client.functions.invoke("journal-ai-chat", { body: { diff --git a/lib/dev/faultInjection.ts b/lib/dev/faultInjection.ts new file mode 100644 index 0000000..391d995 --- /dev/null +++ b/lib/dev/faultInjection.ts @@ -0,0 +1,22 @@ +import type { FaultInjectionKey } from "@/types/faultInjection"; + +const enabledFaults = new Set([ + // Add development-only keys here while manually testing edge cases. +]); + +export const isFaultEnabled = (key: FaultInjectionKey): boolean => { + if (!__DEV__) { + return false; + } + + return enabledFaults.has(key); +}; + +export const throwIfFaultEnabled = (key: FaultInjectionKey): void => { + if (!isFaultEnabled(key)) { + return; + } + + throw new Error(`Development fault injected: ${key}`); +}; + diff --git a/lib/insights/aiInsightReportService.ts b/lib/insights/aiInsightReportService.ts index 889aa23..c90a926 100644 --- a/lib/insights/aiInsightReportService.ts +++ b/lib/insights/aiInsightReportService.ts @@ -4,6 +4,10 @@ import { FunctionsRelayError, } from "@supabase/supabase-js"; +import { + isFaultEnabled, + throwIfFaultEnabled, +} from "@/lib/dev/faultInjection"; import { isAIInsightReport, mapAIInsightReportRow, @@ -75,6 +79,22 @@ export async function generateAIInsightReport(params: { period: ReportPeriod; regenerate?: boolean; }): Promise { + throwIfFaultEnabled("ai_timeout"); + + if (isFaultEnabled("ai_empty_response")) { + throw new AIInsightReportServiceError( + "DearDiary AI returned an empty reflection report.", + "empty_response", + ); + } + + if (isFaultEnabled("ai_invalid_response")) { + throw new AIInsightReportServiceError( + "DearDiary AI returned an invalid reflection report.", + "invalid_response", + ); + } + const client = getReportClient(); const { data, error } = await client.functions.invoke( diff --git a/lib/navigation/routeValidators.ts b/lib/navigation/routeValidators.ts new file mode 100644 index 0000000..392a9ec --- /dev/null +++ b/lib/navigation/routeValidators.ts @@ -0,0 +1,20 @@ +export function getSingleRouteParam(value: string | string[] | undefined) { + return Array.isArray(value) ? value[0] : value; +} + +export function isSafeRouteId(value: unknown): value is string { + return ( + typeof value === "string" && + value.trim().length > 0 && + value.length <= 160 && + !value.includes("/") && + !value.includes("\\") + ); +} + +export function getSafeRouteId(value: string | string[] | undefined) { + const routeId = getSingleRouteParam(value); + + return isSafeRouteId(routeId) ? routeId : null; +} + diff --git a/lib/storage/createPersistStorage.ts b/lib/storage/createPersistStorage.ts new file mode 100644 index 0000000..fbc441b --- /dev/null +++ b/lib/storage/createPersistStorage.ts @@ -0,0 +1,19 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import type { StateStorage } from "zustand/middleware"; + +import { throwIfFaultEnabled } from "@/lib/dev/faultInjection"; + +export function createPersistStorage(): StateStorage { + return { + getItem: (name) => { + throwIfFaultEnabled("async_storage_read_failure"); + return AsyncStorage.getItem(name); + }, + removeItem: (name) => AsyncStorage.removeItem(name), + setItem: (name, value) => { + throwIfFaultEnabled("async_storage_write_failure"); + return AsyncStorage.setItem(name, value); + }, + }; +} + diff --git a/lib/sync/requestSync.ts b/lib/sync/requestSync.ts index 9df4221..c8566d6 100644 --- a/lib/sync/requestSync.ts +++ b/lib/sync/requestSync.ts @@ -1,6 +1,10 @@ import { normalizeAppError } from "@/lib/errors/normalizeAppError"; import { reportAppError } from "@/lib/errors/reportAppError"; import { getAchievements } from "@/lib/achievements"; +import { + isFaultEnabled, + throwIfFaultEnabled, +} from "@/lib/dev/faultInjection"; import { isSupabaseConfigured, setSupabaseAccessTokenProvider, @@ -9,6 +13,7 @@ import { import { syncAchievementStatesTwoWay } from "@/lib/sync/achievementSync"; import { syncJournalEntriesTwoWay } from "@/lib/sync/journalTwoWaySync"; import { syncProfileToCloud } from "@/lib/sync/profileSync"; +import { isActiveUser } from "@/lib/validation/activeUser"; import { useJournalStore } from "@/store/journal-store"; import { useAccountDeletionStore } from "@/store/useAccountDeletionStore"; import { useAchievementStore } from "@/store/useAchievementStore"; @@ -139,6 +144,16 @@ async function runSync({ const pendingEntryIdsSet = new Set(pendingEntryIds); try { + if (isFaultEnabled("expired_session")) { + throw new Error("session expired"); + } + + if (isFaultEnabled("sync_network_failure")) { + throw new Error("Network request failed"); + } + + throwIfFaultEnabled("sync_timeout"); + await syncProfileToCloud({ avatarUrl: avatarUrl ?? undefined, email: email ?? undefined, @@ -146,10 +161,31 @@ async function runSync({ userId, }); + if (!isActiveUser(userId, useJournalStore.getState().activeUserId)) { + return { + code: "session_expired", + localDataPreserved: true, + retryable: false, + success: false, + }; + } + + throwIfFaultEnabled("sync_remote_failure"); + const journalResult = await syncJournalEntriesTwoWay({ localEntries: currentUserEntries, userId, }); + + if (!isActiveUser(userId, useJournalStore.getState().activeUserId)) { + return { + code: "session_expired", + localDataPreserved: true, + retryable: false, + success: false, + }; + } + const journalStore = useJournalStore.getState(); journalStore.markEntriesSynced(userId, journalResult.syncedEntryIds); @@ -167,6 +203,15 @@ async function runSync({ const achievementResult = await syncAchievements(userId); + if (!isActiveUser(userId, useJournalStore.getState().activeUserId)) { + return { + code: "session_expired", + localDataPreserved: true, + retryable: false, + success: false, + }; + } + if ( !journalResult.pullSucceeded || journalResult.pushFailedCount > 0 || diff --git a/lib/validation/activeUser.ts b/lib/validation/activeUser.ts new file mode 100644 index 0000000..57da8cf --- /dev/null +++ b/lib/validation/activeUser.ts @@ -0,0 +1,16 @@ +export function assertActiveUser( + expectedUserId: string, + actualUserId: string | null, +): void { + if (expectedUserId !== actualUserId) { + throw new Error("Active user changed before the operation completed."); + } +} + +export function isActiveUser( + expectedUserId: string, + actualUserId: string | null, +): boolean { + return expectedUserId === actualUserId; +} + diff --git a/lib/validation/persistedDataValidators.ts b/lib/validation/persistedDataValidators.ts new file mode 100644 index 0000000..1b99cd9 --- /dev/null +++ b/lib/validation/persistedDataValidators.ts @@ -0,0 +1,295 @@ +import { isFaultEnabled } from "@/lib/dev/faultInjection"; +import { normalizeTags } from "@/lib/tags"; +import { isRecord } from "@/lib/utils/typeGuards"; +import type { + ChatMessage, + ChatMessageRole, + ChatMessageSource, +} from "@/types/chat"; +import type { + EntryType, + JournalEntry, + JournalSyncStatus, + MoodId, +} from "@/types/journal"; + +type StoredJournalEntry = Omit & { + tags?: string[]; +}; + +type QuarantineDiagnostic = { + area: string; + reason: string; + reference: string; +}; + +const entryTypes: EntryType[] = [ + "free_write", + "daily_prompt", + "morning_intention", + "evening_reflection", + "gratitude", + "ai_reflection", +]; + +const moodIds: MoodId[] = [ + "happy", + "calm", + "sad", + "motivated", + "anxious", + "grateful", +]; + +const syncStatuses: JournalSyncStatus[] = ["failed", "pending", "synced"]; +const chatMessageRoles: ChatMessageRole[] = ["user", "assistant"]; +const chatMessageSources: ChatMessageSource[] = [ + "local", + "remote_ai", + "local_fallback", +]; + +export function normalizePersistedJournalEntries( + values: unknown[], +): JournalEntry[] { + const diagnostics: QuarantineDiagnostic[] = []; + const valuesToNormalize = isFaultEnabled("malformed_local_record") + ? [...values, { id: "" }] + : values; + const entries = valuesToNormalize.reduce( + (validEntries, value, index) => { + const entry = normalizePersistedJournalEntry(value); + + if (!entry) { + diagnostics.push({ + area: "journal", + reason: "invalid_shape", + reference: `index:${index}`, + }); + return validEntries; + } + + validEntries.push(entry); + return validEntries; + }, + [], + ); + + const dedupedEntries = dedupeJournalEntries(entries, diagnostics); + reportQuarantineDiagnostics(diagnostics); + + return dedupedEntries; +} + +export function normalizePersistedChatMessages( + values: unknown[], +): ChatMessage[] { + const diagnostics: QuarantineDiagnostic[] = []; + const messages = values.reduce((validMessages, value, index) => { + const message = normalizePersistedChatMessage(value); + + if (!message) { + diagnostics.push({ + area: "chat", + reason: "invalid_shape", + reference: `index:${index}`, + }); + return validMessages; + } + + validMessages.push(message); + return validMessages; + }, []); + + reportQuarantineDiagnostics(diagnostics); + + return messages; +} + +export function isValidTimestamp(value: unknown): value is string { + return typeof value === "string" && Number.isFinite(Date.parse(value)); +} + +export function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +export function normalizeReminderTime( + value: unknown, + fallback: string, +): string { + if (typeof value !== "string") { + return fallback; + } + + return /^([01]\d|2[0-3]):[0-5]\d$/.test(value) ? value : fallback; +} + +function normalizePersistedJournalEntry( + value: unknown, +): JournalEntry | null { + if (!isStoredJournalEntry(value)) { + return null; + } + + return { + ...value, + deletedAt: value.deletedAt ?? null, + prompt: value.prompt, + syncStatus: value.syncStatus ?? "pending", + tags: normalizeTags(value.tags ?? []), + }; +} + +function isStoredJournalEntry(value: unknown): value is StoredJournalEntry { + if (!isRecord(value)) { + return false; + } + + return ( + isNonEmptyString(value.id) && + isNonEmptyString(value.userId) && + typeof value.title === "string" && + typeof value.content === "string" && + (value.mood === null || + (typeof value.mood === "string" && isMoodId(value.mood))) && + typeof value.type === "string" && + isEntryType(value.type) && + (value.prompt === undefined || typeof value.prompt === "string") && + (value.tags === undefined || + (Array.isArray(value.tags) && + value.tags.every((tag) => typeof tag === "string"))) && + isValidTimestamp(value.createdAt) && + isValidTimestamp(value.updatedAt) && + (value.deletedAt === undefined || + value.deletedAt === null || + isValidTimestamp(value.deletedAt)) && + (value.syncStatus === undefined || isJournalSyncStatus(value.syncStatus)) + ); +} + +function normalizePersistedChatMessage(value: unknown): ChatMessage | null { + if (!isRecord(value)) { + return null; + } + + if ( + !isNonEmptyString(value.id) || + !isNonEmptyString(value.userId) || + typeof value.role !== "string" || + !chatMessageRoles.includes(value.role as ChatMessageRole) || + typeof value.content !== "string" || + !isValidTimestamp(value.createdAt) + ) { + return null; + } + + if ( + value.relatedEntryIds !== undefined && + (!Array.isArray(value.relatedEntryIds) || + !value.relatedEntryIds.every(isNonEmptyString)) + ) { + return null; + } + + if ( + value.source !== undefined && + (typeof value.source !== "string" || + !chatMessageSources.includes(value.source as ChatMessageSource)) + ) { + return null; + } + + return { + content: value.content, + createdAt: value.createdAt, + id: value.id, + relatedEntryIds: value.relatedEntryIds, + role: value.role as ChatMessageRole, + source: value.source as ChatMessageSource | undefined, + userId: value.userId, + }; +} + +function dedupeJournalEntries( + entries: JournalEntry[], + diagnostics: QuarantineDiagnostic[], +): JournalEntry[] { + const entriesByScopedId = new Map(); + + entries.forEach((entry) => { + const scopedId = `${entry.userId}:${entry.id}`; + const existingEntry = entriesByScopedId.get(scopedId); + + if (!existingEntry) { + entriesByScopedId.set(scopedId, entry); + return; + } + + diagnostics.push({ + area: "journal", + reason: "duplicate_id", + reference: hashDiagnosticReference(scopedId), + }); + + entriesByScopedId.set(scopedId, chooseJournalEntryWinner(existingEntry, entry)); + }); + + return Array.from(entriesByScopedId.values()); +} + +function chooseJournalEntryWinner( + firstEntry: JournalEntry, + secondEntry: JournalEntry, +) { + const firstUpdatedAt = Date.parse(firstEntry.updatedAt); + const secondUpdatedAt = Date.parse(secondEntry.updatedAt); + + if (secondUpdatedAt > firstUpdatedAt) { + return secondEntry; + } + + return firstEntry; +} + +function reportQuarantineDiagnostics(diagnostics: QuarantineDiagnostic[]) { + if (!__DEV__ || diagnostics.length === 0) { + return; + } + + const summary = diagnostics.reduce>( + (counts, diagnostic) => { + const key = `${diagnostic.area}:${diagnostic.reason}`; + counts[key] = (counts[key] ?? 0) + 1; + return counts; + }, + {}, + ); + + console.warn("Persisted data quarantine", { + summary, + references: diagnostics.map((diagnostic) => diagnostic.reference).slice(0, 5), + }); +} + +function hashDiagnosticReference(value: string) { + let hash = 0; + + for (let index = 0; index < value.length; index += 1) { + hash = (hash * 31 + value.charCodeAt(index)) >>> 0; + } + + return `ref:${hash.toString(16)}`; +} + +function isEntryType(value: string): value is EntryType { + return entryTypes.includes(value as EntryType); +} + +function isMoodId(value: string): value is MoodId { + return moodIds.includes(value as MoodId); +} + +function isJournalSyncStatus(value: unknown): value is JournalSyncStatus { + return typeof value === "string" && syncStatuses.includes(value as JournalSyncStatus); +} diff --git a/store/journal-store.ts b/store/journal-store.ts index 682a561..463a61e 100644 --- a/store/journal-store.ts +++ b/store/journal-store.ts @@ -1,18 +1,19 @@ -import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; +import { createPersistStorage } from "@/lib/storage/createPersistStorage"; import { mergeJournalEntries, type MergeResult, } from "@/lib/sync/mergeJournalEntries"; import { normalizeTags } from "@/lib/tags"; +import { normalizePersistedJournalEntries } from "@/lib/validation/persistedDataValidators"; import { useAccountDeletionStore } from "@/store/useAccountDeletionStore"; import type { EntryType, JournalEntry, - JournalSyncStatus, MoodId, + JournalSyncStatus, } from "@/types/journal"; const journalStorageVersion = 3; @@ -66,10 +67,6 @@ type JournalState = { updateEntry: (id: string, entry: JournalEntryUpdate) => void; }; -type StoredJournalEntry = Omit & { - tags?: string[]; -}; - function createEntryId() { return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; } @@ -96,67 +93,15 @@ function migrateJournalState(persistedState: unknown) { : []; return { - allEntries: persistedEntries - .filter(isJournalEntry) - .map(normalizeJournalEntry), + allEntries: normalizePersistedJournalEntries(persistedEntries), entries: [], }; } -function normalizeJournalEntry(entry: StoredJournalEntry): JournalEntry { - return { - ...entry, - tags: normalizeTags(entry.tags ?? []), - }; -} - -function isJournalEntry(entry: unknown): entry is StoredJournalEntry { - if (!isRecord(entry)) { - return false; - } - - const prompt = entry.prompt; - const deletedAt = entry.deletedAt; - const syncStatus = entry.syncStatus; - - return ( - typeof entry.id === "string" && - typeof entry.userId === "string" && - typeof entry.title === "string" && - typeof entry.content === "string" && - (entry.mood === null || - (typeof entry.mood === "string" && isMoodId(entry.mood))) && - typeof entry.type === "string" && - isEntryType(entry.type) && - (prompt === undefined || typeof prompt === "string") && - (entry.tags === undefined || - (Array.isArray(entry.tags) && - entry.tags.every((tag) => typeof tag === "string"))) && - typeof entry.createdAt === "string" && - typeof entry.updatedAt === "string" && - (deletedAt === undefined || - deletedAt === null || - typeof deletedAt === "string") && - (syncStatus === undefined || isJournalSyncStatus(syncStatus)) - ); -} - function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } -function isEntryType(value: string): value is EntryType { - return entryTypes.includes(value as EntryType); -} - -function isMoodId(value: string): value is MoodId { - return moodIds.includes(value as MoodId); -} - -function isJournalSyncStatus(value: unknown): value is JournalSyncStatus { - return value === "failed" || value === "pending" || value === "synced"; -} - function setSyncStatusForEntries( entries: JournalEntry[], entryIds: string[], @@ -370,7 +315,7 @@ export const useJournalStore = create()( partialize: (state) => ({ allEntries: state.allEntries, }), - storage: createJSONStorage(() => AsyncStorage), + storage: createJSONStorage(() => createPersistStorage()), version: journalStorageVersion, }, ), diff --git a/store/notification-preferences-store.ts b/store/notification-preferences-store.ts index 06be31d..b5a26c2 100644 --- a/store/notification-preferences-store.ts +++ b/store/notification-preferences-store.ts @@ -1,7 +1,10 @@ -import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; +import { createPersistStorage } from "@/lib/storage/createPersistStorage"; +import { isRecord } from "@/lib/utils/typeGuards"; +import { normalizeReminderTime } from "@/lib/validation/persistedDataValidators"; + export type ReminderKey = "morning" | "evening"; const defaultNotificationPreferences = { @@ -44,6 +47,10 @@ export const useNotificationPreferencesStore = }), { name: "dear-diary-notification-preferences", + merge: (persistedState, currentState) => ({ + ...currentState, + ...getSanitizedNotificationPreferences(persistedState), + }), onRehydrateStorage: (state) => () => { state?.setHasHydrated(true); }, @@ -52,7 +59,28 @@ export const useNotificationPreferencesStore = isEnabled: state.isEnabled, morningReminderTime: state.morningReminderTime, }), - storage: createJSONStorage(() => AsyncStorage), + storage: createJSONStorage(() => createPersistStorage()), }, ), ); + +function getSanitizedNotificationPreferences(persistedState: unknown) { + if (!isRecord(persistedState)) { + return defaultNotificationPreferences; + } + + return { + eveningReminderTime: normalizeReminderTime( + persistedState.eveningReminderTime, + defaultNotificationPreferences.eveningReminderTime, + ), + isEnabled: + typeof persistedState.isEnabled === "boolean" + ? persistedState.isEnabled + : defaultNotificationPreferences.isEnabled, + morningReminderTime: normalizeReminderTime( + persistedState.morningReminderTime, + defaultNotificationPreferences.morningReminderTime, + ), + }; +} diff --git a/store/onboarding-store.ts b/store/onboarding-store.ts index 02b2bcb..d71d81d 100644 --- a/store/onboarding-store.ts +++ b/store/onboarding-store.ts @@ -1,7 +1,9 @@ -import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; +import { createPersistStorage } from "@/lib/storage/createPersistStorage"; +import { isRecord } from "@/lib/utils/typeGuards"; + type OnboardingState = { completeOnboarding: () => void; hasCompletedOnboarding: boolean; @@ -21,13 +23,21 @@ export const useOnboardingStore = create()( }), { name: "dear-diary-onboarding", + merge: (persistedState, currentState) => ({ + ...currentState, + hasCompletedOnboarding: + isRecord(persistedState) && + typeof persistedState.hasCompletedOnboarding === "boolean" + ? persistedState.hasCompletedOnboarding + : currentState.hasCompletedOnboarding, + }), onRehydrateStorage: (state) => () => { state.setHasHydrated(true); }, partialize: (state) => ({ hasCompletedOnboarding: state.hasCompletedOnboarding, }), - storage: createJSONStorage(() => AsyncStorage), + storage: createJSONStorage(() => createPersistStorage()), }, ), ); diff --git a/store/useAIInsightReportStore.ts b/store/useAIInsightReportStore.ts index 8426369..11ca436 100644 --- a/store/useAIInsightReportStore.ts +++ b/store/useAIInsightReportStore.ts @@ -1,8 +1,8 @@ -import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { isAIInsightReport } from "@/lib/insights/aiInsightReportMapper"; +import { createPersistStorage } from "@/lib/storage/createPersistStorage"; import type { AIInsightReport } from "@/types/aiInsightReport"; const reportStorageVersion = 1; @@ -79,7 +79,7 @@ export const useAIInsightReportStore = create()( state?.setHasHydrated(true); }, partialize: (state) => ({ reportsByUser: state.reportsByUser }), - storage: createJSONStorage(() => AsyncStorage), + storage: createJSONStorage(() => createPersistStorage()), version: reportStorageVersion, }, ), diff --git a/store/useAchievementStore.ts b/store/useAchievementStore.ts index 44e2375..d3d06ac 100644 --- a/store/useAchievementStore.ts +++ b/store/useAchievementStore.ts @@ -1,7 +1,9 @@ -import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; +import { createPersistStorage } from "@/lib/storage/createPersistStorage"; +import { isNonEmptyString } from "@/lib/validation/persistedDataValidators"; + const achievementStorageVersion = 2; type UserAchievementNotifications = { @@ -143,7 +145,7 @@ export const useAchievementStore = create()( achievementNotificationsByUserId: state.achievementNotificationsByUserId, }), - storage: createJSONStorage(() => AsyncStorage), + storage: createJSONStorage(() => createPersistStorage()), version: achievementStorageVersion, }, ), @@ -181,6 +183,6 @@ function isUserAchievementNotifications( isRecord(value) && typeof value.hasInitialized === "boolean" && Array.isArray(value.notifiedAchievementIds) && - value.notifiedAchievementIds.every((id) => typeof id === "string") + value.notifiedAchievementIds.every(isNonEmptyString) ); } diff --git a/store/useChatStore.ts b/store/useChatStore.ts index 0765ae1..436f785 100644 --- a/store/useChatStore.ts +++ b/store/useChatStore.ts @@ -1,20 +1,13 @@ -import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; +import { createPersistStorage } from "@/lib/storage/createPersistStorage"; +import { normalizePersistedChatMessages } from "@/lib/validation/persistedDataValidators"; import type { ChatMessage, - ChatMessageRole, - ChatMessageSource, } from "@/types/chat"; const chatStorageVersion = 1; -const chatMessageRoles: ChatMessageRole[] = ["user", "assistant"]; -const chatMessageSources: ChatMessageSource[] = [ - "local", - "remote_ai", - "local_fallback", -]; type ChatState = { addMessage: (message: ChatMessage) => void; @@ -35,33 +28,10 @@ function migrateChatState(persistedState: unknown) { : []; return { - messages: messages.filter(isChatMessage), + messages: normalizePersistedChatMessages(messages), }; } -function isChatMessage(message: unknown): message is ChatMessage { - if (!isRecord(message)) { - return false; - } - - return ( - typeof message.id === "string" && - typeof message.userId === "string" && - typeof message.role === "string" && - chatMessageRoles.includes(message.role as ChatMessageRole) && - typeof message.content === "string" && - typeof message.createdAt === "string" && - (message.relatedEntryIds === undefined || - (Array.isArray(message.relatedEntryIds) && - message.relatedEntryIds.every( - (entryId) => typeof entryId === "string", - ))) && - (message.source === undefined || - (typeof message.source === "string" && - chatMessageSources.includes(message.source as ChatMessageSource))) - ); -} - function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } @@ -100,7 +70,7 @@ export const useChatStore = create()( state?.setHasHydrated(true); }, partialize: (state) => ({ messages: state.messages }), - storage: createJSONStorage(() => AsyncStorage), + storage: createJSONStorage(() => createPersistStorage()), version: chatStorageVersion, }, ), diff --git a/store/useEntryReflectionStore.ts b/store/useEntryReflectionStore.ts index 73de276..dd280f6 100644 --- a/store/useEntryReflectionStore.ts +++ b/store/useEntryReflectionStore.ts @@ -1,7 +1,11 @@ -import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; +import { createPersistStorage } from "@/lib/storage/createPersistStorage"; +import { + isNonEmptyString, + isValidTimestamp, +} from "@/lib/validation/persistedDataValidators"; import type { EntryAIReflection } from "@/types/entryReflection"; const entryReflectionStorageVersion = 1; @@ -73,7 +77,7 @@ export const useEntryReflectionStore = create()( state?.setHasHydrated(true); }, partialize: (state) => ({ reflections: state.reflections }), - storage: createJSONStorage(() => AsyncStorage), + storage: createJSONStorage(() => createPersistStorage()), version: entryReflectionStorageVersion, }, ), @@ -85,9 +89,9 @@ function isEntryAIReflection(value: unknown): value is EntryAIReflection { } return ( - typeof value.id === "string" && - typeof value.userId === "string" && - typeof value.entryId === "string" && + isNonEmptyString(value.id) && + isNonEmptyString(value.userId) && + isNonEmptyString(value.entryId) && typeof value.summary === "string" && Array.isArray(value.emotions) && value.emotions.every((emotion) => typeof emotion === "string") && @@ -98,9 +102,9 @@ function isEntryAIReflection(value: unknown): value is EntryAIReflection { typeof value.followUpQuestion === "string") && (value.suggestion === null || typeof value.suggestion === "string") && (value.model === null || typeof value.model === "string") && - typeof value.sourceEntryUpdatedAt === "string" && - typeof value.createdAt === "string" && - typeof value.updatedAt === "string" + isValidTimestamp(value.sourceEntryUpdatedAt) && + isValidTimestamp(value.createdAt) && + isValidTimestamp(value.updatedAt) ); } diff --git a/store/useSyncStore.ts b/store/useSyncStore.ts index 99186f8..ba89996 100644 --- a/store/useSyncStore.ts +++ b/store/useSyncStore.ts @@ -1,7 +1,7 @@ -import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; +import { createPersistStorage } from "@/lib/storage/createPersistStorage"; import { appErrorCodes, type AppErrorCode } from "@/types/appError"; import { isRecord } from "@/lib/utils/typeGuards"; @@ -112,7 +112,7 @@ export const useSyncStore = create()( lastSyncedAt: state.lastSyncedAt, lastSyncUserId: state.lastSyncUserId, }), - storage: createJSONStorage(() => AsyncStorage), + storage: createJSONStorage(() => createPersistStorage()), }, ), ); diff --git a/types/faultInjection.ts b/types/faultInjection.ts new file mode 100644 index 0000000..ca58db0 --- /dev/null +++ b/types/faultInjection.ts @@ -0,0 +1,14 @@ +export type FaultInjectionKey = + | "async_storage_read_failure" + | "async_storage_write_failure" + | "sync_network_failure" + | "sync_remote_failure" + | "sync_timeout" + | "ai_timeout" + | "ai_empty_response" + | "ai_invalid_response" + | "backup_failure" + | "restore_failure" + | "expired_session" + | "malformed_local_record"; + From b322f66a74f7411228e368f8bfc85ada18178872 Mon Sep 17 00:00:00 2001 From: aryansoni-dev Date: Tue, 23 Jun 2026 18:47:31 +0530 Subject: [PATCH 3/3] [+] Implemented code-rabbit's suggested bug fixes and improvements. --- .../journal-editor/journal-editor-screen.tsx | 8 +++-- lib/ai/remoteJournalAssistant.ts | 30 ++++++++++++++++--- lib/validation/persistedDataValidators.ts | 2 +- store/journal-store.ts | 15 ++++++++-- store/useAIInsightReportStore.ts | 16 ++++++++-- store/useAchievementStore.ts | 7 +---- 6 files changed, 60 insertions(+), 18 deletions(-) diff --git a/components/journal-editor/journal-editor-screen.tsx b/components/journal-editor/journal-editor-screen.tsx index 484ca30..0ab4357 100644 --- a/components/journal-editor/journal-editor-screen.tsx +++ b/components/journal-editor/journal-editor-screen.tsx @@ -301,7 +301,7 @@ export function JournalEditorScreen({ entryId }: JournalEditorScreenProps) { showDialog({ confirmText: "OK", message: - "We could not save this entry on your device. Please try again before leaving.", + "We could not save this entry on your device. Please try again before leaving this screen.", title: "Save failed", variant: "destructive", }); @@ -830,10 +830,14 @@ function getSaveButtonLabel({ return "Save"; } - if (entrySyncStatus === "synced") { + if (entrySyncStatus === "synced" && wasSaved) { return "Synced"; } + if (!wasSaved) { + return "Save"; + } + if (isSyncing) { return "Syncing"; } diff --git a/lib/ai/remoteJournalAssistant.ts b/lib/ai/remoteJournalAssistant.ts index 9006ac2..ba995ca 100644 --- a/lib/ai/remoteJournalAssistant.ts +++ b/lib/ai/remoteJournalAssistant.ts @@ -23,6 +23,16 @@ export type RemoteJournalResponse = { source: "remote_ai"; }; +export class RemoteJournalAssistantError extends Error { + constructor( + message: string, + readonly code: string, + ) { + super(message); + this.name = "RemoteJournalAssistantError"; + } +} + export const generateRemoteJournalResponse = async (params: { clientContext?: ClientContext; message: string; @@ -31,11 +41,17 @@ export const generateRemoteJournalResponse = async (params: { throwIfFaultEnabled("ai_timeout"); if (isFaultEnabled("ai_empty_response")) { - throw new Error("DearDiary AI returned an empty response."); + throw new RemoteJournalAssistantError( + "DearDiary AI returned an empty response.", + "empty_response", + ); } if (isFaultEnabled("ai_invalid_response")) { - throw new Error("DearDiary AI returned an invalid response."); + throw new RemoteJournalAssistantError( + "DearDiary AI returned an invalid response.", + "invalid_response", + ); } const client = getAuthenticatedSupabaseClient(); @@ -55,11 +71,17 @@ export const generateRemoteJournalResponse = async (params: { ); } - throw new Error("DearDiary AI is unavailable."); + throw new RemoteJournalAssistantError( + "DearDiary AI is unavailable.", + "remote_unavailable", + ); } if (!isRemoteJournalResponse(data)) { - throw new Error("DearDiary AI returned an invalid response."); + throw new RemoteJournalAssistantError( + "DearDiary AI returned an invalid response.", + "invalid_response", + ); } return data; diff --git a/lib/validation/persistedDataValidators.ts b/lib/validation/persistedDataValidators.ts index 1b99cd9..fea7fd1 100644 --- a/lib/validation/persistedDataValidators.ts +++ b/lib/validation/persistedDataValidators.ts @@ -204,7 +204,7 @@ function normalizePersistedChatMessage(value: unknown): ChatMessage | null { content: value.content, createdAt: value.createdAt, id: value.id, - relatedEntryIds: value.relatedEntryIds, + relatedEntryIds: value.relatedEntryIds as string[] | undefined, role: value.role as ChatMessageRole, source: value.source as ChatMessageSource | undefined, userId: value.userId, diff --git a/store/journal-store.ts b/store/journal-store.ts index 463a61e..d76595b 100644 --- a/store/journal-store.ts +++ b/store/journal-store.ts @@ -308,9 +308,18 @@ export const useJournalStore = create()( { name: "dear-diary-journal", migrate: migrateJournalState, - onRehydrateStorage: (state) => () => { - state?.setActiveUserId(state.activeUserId); - state?.setHasHydrated(true); + onRehydrateStorage: () => (state) => { + if (!state) { + return; + } + + const allEntries = normalizePersistedJournalEntries(state.allEntries); + + useJournalStore.setState({ + allEntries, + entries: getEntriesForUser(allEntries, state.activeUserId), + hasHydrated: true, + }); }, partialize: (state) => ({ allEntries: state.allEntries, diff --git a/store/useAIInsightReportStore.ts b/store/useAIInsightReportStore.ts index 11ca436..795b228 100644 --- a/store/useAIInsightReportStore.ts +++ b/store/useAIInsightReportStore.ts @@ -74,6 +74,10 @@ export const useAIInsightReportStore = create()( }), { name: "dear-diary-ai-insight-reports", + merge: (persistedState, currentState) => ({ + ...currentState, + reportsByUser: getSanitizedReportsByUser(persistedState), + }), migrate: migrateReportState, onRehydrateStorage: (state) => () => { state?.setHasHydrated(true); @@ -86,8 +90,16 @@ export const useAIInsightReportStore = create()( ); function migrateReportState(persistedState: unknown) { + return { + reportsByUser: getSanitizedReportsByUser(persistedState), + }; +} + +function getSanitizedReportsByUser( + persistedState: unknown, +): Record { if (!isRecord(persistedState) || !isRecord(persistedState.reportsByUser)) { - return { reportsByUser: {} }; + return {}; } const reportsByUser: Record = {}; @@ -113,7 +125,7 @@ function migrateReportState(persistedState: unknown) { }, ); - return { reportsByUser }; + return reportsByUser; } function isRecord(value: unknown): value is Record { diff --git a/store/useAchievementStore.ts b/store/useAchievementStore.ts index d3d06ac..7d6dfce 100644 --- a/store/useAchievementStore.ts +++ b/store/useAchievementStore.ts @@ -2,6 +2,7 @@ import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { createPersistStorage } from "@/lib/storage/createPersistStorage"; +import { isRecord } from "@/lib/utils/typeGuards"; import { isNonEmptyString } from "@/lib/validation/persistedDataValidators"; const achievementStorageVersion = 2; @@ -170,12 +171,6 @@ function getSanitizedAchievementNotifications( ); } -function isRecord(value: unknown): value is Record { - return ( - typeof value === "object" && value !== null && !Array.isArray(value) - ); -} - function isUserAchievementNotifications( value: unknown, ): value is UserAchievementNotifications {