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/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/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/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..477bad3 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;
@@ -131,6 +144,11 @@ export function AiChatScreen({
};
}, []);
+ useEffect(() => {
+ requestIdRef.current += 1;
+ setIsThinking(false);
+ }, [userId]);
+
useEffect(() => {
if (!shouldUseKeyboardOffset) {
return;
@@ -350,15 +368,15 @@ export function AiChatScreen({
{isOffline ? "Offline" : "Online"}
@@ -398,13 +416,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..6266abe 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..0ab4357 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,8 @@ export function JournalEditorScreen({ entryId }: JournalEditorScreenProps) {
});
showDialog({
confirmText: "OK",
- message: appError.userMessage,
+ message:
+ "We could not save this entry on your device. Please try again before leaving this screen.",
title: "Save failed",
variant: "destructive",
});
@@ -408,11 +412,11 @@ export function JournalEditorScreen({ entryId }: JournalEditorScreenProps) {
if (!hasHydrated) {
return (
-
+
-
- Loading your journal...
-
+ {showHydrationState ? (
+
+ ) : null}
);
}
@@ -426,7 +430,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 +441,7 @@ export function JournalEditorScreen({ entryId }: JournalEditorScreenProps) {
onPress={() => router.replace("/journal-history")}
>
- Back to History
+ Return to History
@@ -826,12 +830,12 @@ function getSaveButtonLabel({
return "Save";
}
- if (!wasSaved && entrySyncStatus !== "synced") {
- return "Save";
+ if (entrySyncStatus === "synced" && wasSaved) {
+ return "Synced";
}
- if (entrySyncStatus === "synced") {
- return "Synced";
+ if (!wasSaved) {
+ return "Save";
}
if (isSyncing) {
diff --git a/components/journal-history/journal-calendar-view.tsx b/components/journal-history/journal-calendar-view.tsx
index 5a38639..5090096 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}
+
@@ -246,12 +266,12 @@ export function JournalCalendarView({
{selectedDay.entryCount > 0 ? (
renderSelectedEntries(selectedDay)
- ) : (
+ ) : 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..5831b53
--- /dev/null
+++ b/constants/theme.ts
@@ -0,0 +1,80 @@
+import type { UserSyncStatus } from "@/types/syncStatus";
+
+export const colors = {
+ brandPrimary: "#FF2056",
+ errorSurface: "#FFF1F5",
+ errorText: "#9F1239",
+ legalBackground: "#FFF7FB",
+ 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",
+} as const;
+
+export const CONNECTION_STATE_COLORS = {
+ offline: {
+ background: colors.offlineSurface,
+ dot: colors.offlineIndicator,
+ text: colors.offlineIcon,
+ },
+ online: {
+ background: colors.onlineSurface,
+ dot: colors.onlineIcon,
+ text: colors.onlineText,
+ },
+} as const;
+
+export const SYNC_STATUS_COLORS: Record<
+ UserSyncStatus,
+ {
+ background: string;
+ text: string;
+ }
+> = {
+ failed: {
+ background: colors.syncFailedSurface,
+ text: colors.syncFailedText,
+ },
+ idle: {
+ background: colors.syncIdleSurface,
+ text: colors.textMuted,
+ },
+ paused: {
+ background: colors.syncIdleSurface,
+ text: colors.textMuted,
+ },
+ saved_locally: {
+ background: colors.syncSavedLocallySurface,
+ text: colors.syncSavedLocallyText,
+ },
+ synced: {
+ background: colors.syncSyncedSurface,
+ text: colors.syncSyncedText,
+ },
+ syncing: {
+ background: colors.syncSyncingSurface,
+ text: colors.syncSyncingText,
+ },
+ waiting_for_network: {
+ background: colors.offlineSurface,
+ text: colors.offlineIcon,
+ },
+};
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
new file mode 100644
index 0000000..849d0ee
--- /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 | 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` | "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 |
+| 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..0dd3d71 100644
--- a/hooks/useEntryReflection.ts
+++ b/hooks/useEntryReflection.ts
@@ -1,9 +1,10 @@
-import { useCallback, useEffect, useMemo, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
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,
);
@@ -44,37 +48,56 @@ 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 (!canUseReflection || !entryId || !userId) {
+ 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);
+ }
}
- }, [canUseReflection, entryId, upsertReflection, userId]);
+ }, [cacheHasHydrated, canUseReflection, entryId, upsertReflection, userId]);
useEffect(() => {
void refresh();
@@ -86,6 +109,11 @@ export function useEntryReflection({
return;
}
+ if (!cacheHasHydrated) {
+ return;
+ }
+
+ const requestGeneration = requestGenerationRef.current;
setIsGenerating(true);
setError(null);
@@ -95,17 +123,34 @@ 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);
+ }
}
},
- [canUseReflection, entryId, isGenerating, upsertReflection, userId],
+ [
+ cacheHasHydrated,
+ canUseReflection,
+ entryId,
+ isGenerating,
+ upsertReflection,
+ userId,
+ ],
);
const generate = useCallback(
@@ -122,7 +167,7 @@ export function useEntryReflection({
error,
generate,
isGenerating,
- isLoading,
+ isLoading: isLoading || (canUseReflection && !cacheHasHydrated),
isStale,
reflection,
refresh,
@@ -131,6 +176,8 @@ export function useEntryReflection({
[
error,
generate,
+ cacheHasHydrated,
+ canUseReflection,
isGenerating,
isLoading,
isStale,
@@ -142,9 +189,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/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..ba995ca 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";
@@ -19,11 +23,37 @@ 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;
recentMessages: RemoteJournalMessage[];
}): Promise => {
+ throwIfFaultEnabled("ai_timeout");
+
+ if (isFaultEnabled("ai_empty_response")) {
+ throw new RemoteJournalAssistantError(
+ "DearDiary AI returned an empty response.",
+ "empty_response",
+ );
+ }
+
+ if (isFaultEnabled("ai_invalid_response")) {
+ throw new RemoteJournalAssistantError(
+ "DearDiary AI returned an invalid response.",
+ "invalid_response",
+ );
+ }
+
const client = getAuthenticatedSupabaseClient();
const { data, error } = await client.functions.invoke("journal-ai-chat", {
body: {
@@ -41,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/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/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/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 2fc721c..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";
@@ -107,7 +112,7 @@ async function runSync({
return {
code: "sync_failed",
localDataPreserved: true,
- retryable: true,
+ retryable: false,
success: false,
};
}
@@ -136,8 +141,19 @@ async function runSync({
if (pendingEntryIds.length > 0) {
useJournalStore.getState().markEntriesPendingSync(userId, pendingEntryIds);
}
+ 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,
@@ -145,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);
@@ -166,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 ||
@@ -201,7 +247,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/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..fea7fd1
--- /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 as string[] | undefined,
+ 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..d76595b 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[],
@@ -363,14 +308,23 @@ 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,
}),
- 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 f39f8b2..795b228 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;
@@ -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: {
@@ -70,17 +74,32 @@ export const useAIInsightReportStore = create()(
}),
{
name: "dear-diary-ai-insight-reports",
+ merge: (persistedState, currentState) => ({
+ ...currentState,
+ reportsByUser: getSanitizedReportsByUser(persistedState),
+ }),
migrate: migrateReportState,
+ onRehydrateStorage: (state) => () => {
+ state?.setHasHydrated(true);
+ },
partialize: (state) => ({ reportsByUser: state.reportsByUser }),
- storage: createJSONStorage(() => AsyncStorage),
+ storage: createJSONStorage(() => createPersistStorage()),
version: reportStorageVersion,
},
),
);
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 = {};
@@ -106,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 44e2375..7d6dfce 100644
--- a/store/useAchievementStore.ts
+++ b/store/useAchievementStore.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 { isNonEmptyString } from "@/lib/validation/persistedDataValidators";
+
const achievementStorageVersion = 2;
type UserAchievementNotifications = {
@@ -143,7 +146,7 @@ export const useAchievementStore = create()(
achievementNotificationsByUserId:
state.achievementNotificationsByUserId,
}),
- storage: createJSONStorage(() => AsyncStorage),
+ storage: createJSONStorage(() => createPersistStorage()),
version: achievementStorageVersion,
},
),
@@ -168,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 {
@@ -181,6 +178,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 c689ab5..436f785 100644
--- a/store/useChatStore.ts
+++ b/store/useChatStore.ts
@@ -1,26 +1,21 @@
-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;
clearMessagesForUser: (userId: string) => void;
getMessagesByUserId: (userId: string) => ChatMessage[];
+ hasHydrated: boolean;
messages: ChatMessage[];
+ setHasHydrated: (hasHydrated: boolean) => void;
};
function migrateChatState(persistedState: unknown) {
@@ -33,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;
}
@@ -87,12 +59,18 @@ 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,
- storage: createJSONStorage(() => AsyncStorage),
+ onRehydrateStorage: (state) => () => {
+ state?.setHasHydrated(true);
+ },
+ partialize: (state) => ({ messages: state.messages }),
+ storage: createJSONStorage(() => createPersistStorage()),
version: chatStorageVersion,
},
),
diff --git a/store/useEntryReflectionStore.ts b/store/useEntryReflectionStore.ts
index 11abe74..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;
@@ -12,8 +16,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 +47,7 @@ export const useEntryReflectionStore = create()(
(reflection) =>
reflection.userId === userId && reflection.entryId === entryId,
),
+ hasHydrated: false,
reflections: [],
removeReflectionForEntry: (userId, entryId) =>
set((state) => ({
@@ -61,11 +68,16 @@ export const useEntryReflectionStore = create()(
reflections: [reflection, ...nextReflections],
};
}),
+ setHasHydrated: (hasHydrated) => set({ hasHydrated }),
}),
{
name: "deardiary-entry-reflections-v1",
migrate: migrateEntryReflectionState,
- storage: createJSONStorage(() => AsyncStorage),
+ onRehydrateStorage: (state) => () => {
+ state?.setHasHydrated(true);
+ },
+ partialize: (state) => ({ reflections: state.reflections }),
+ storage: createJSONStorage(() => createPersistStorage()),
version: entryReflectionStorageVersion,
},
),
@@ -77,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") &&
@@ -90,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 68be59d..ba89996 100644
--- a/store/useSyncStore.ts
+++ b/store/useSyncStore.ts
@@ -1,8 +1,9 @@
-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 { createPersistStorage } from "@/lib/storage/createPersistStorage";
+import { appErrorCodes, type AppErrorCode } from "@/types/appError";
+import { isRecord } from "@/lib/utils/typeGuards";
type SyncState = {
clearSyncError: () => void;
@@ -111,7 +112,7 @@ export const useSyncStore = create()(
lastSyncedAt: state.lastSyncedAt,
lastSyncUserId: state.lastSyncUserId,
}),
- storage: createJSONStorage(() => AsyncStorage),
+ storage: createJSONStorage(() => createPersistStorage()),
},
),
);
@@ -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/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";
+
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;
+};