From af468e91c92a9d784623a3822fa0f868e43b5057 Mon Sep 17 00:00:00 2001 From: aryansoni-dev Date: Mon, 22 Jun 2026 15:14:13 +0530 Subject: [PATCH] Changes : [+] Implemented Reliable Global Error Handling, Offline Awareness, and Sync Status Clarity. [+] Implemented code-rabbit's suggested bug fixes and improvements. --- app/(tabs)/ai-chat/index.tsx | 16 +- app/_layout.tsx | 16 +- app/insights/report/[periodType].tsx | 29 +- app/settings/privacy.tsx | 2 +- components/ai-chat/ai-chat-screen.tsx | 41 ++- components/auth/auth-screen.tsx | 8 +- components/connectivity/OfflineNotice.tsx | 16 + components/errors/FeatureErrorBoundary.tsx | 67 ++++ components/errors/InlineErrorMessage.tsx | 32 ++ components/errors/RootErrorFallback.tsx | 71 ++++ .../journal-editor/journal-editor-screen.tsx | 135 +++++-- components/legal/legal-document-screen.tsx | 119 ++++-- components/profile/profile-screen.tsx | 339 ++++-------------- components/sync/SyncStatusIndicator.tsx | 93 +++++ components/sync/SyncStatusRow.tsx | 152 ++++++++ components/sync/auto-sync-manager.tsx | 105 +++++- hooks/useAIInsightReport.ts | 14 + hooks/useAutoSync.ts | 168 ++------- hooks/useConnectivity.ts | 7 + hooks/useSyncStatus.ts | 144 ++++++++ lib/account/accountDeletionService.ts | 77 +++- lib/account/clearLocalUserData.ts | 2 + lib/errors/normalizeAppError.ts | 215 +++++++++++ lib/errors/reportAppError.ts | 34 ++ lib/sync/requestSync.ts | 299 +++++++++++++++ package-lock.json | 10 + package.json | 1 + providers/ConnectivityProvider.tsx | 119 ++++++ store/useSyncStore.ts | 79 +++- supabase/config.toml | 2 +- supabase/functions/delete-account/index.ts | 106 ++++-- types/appError.ts | 38 ++ types/connectivity.ts | 9 + types/syncResult.ts | 16 + types/syncStatus.ts | 20 ++ 35 files changed, 2069 insertions(+), 532 deletions(-) create mode 100644 components/connectivity/OfflineNotice.tsx create mode 100644 components/errors/FeatureErrorBoundary.tsx create mode 100644 components/errors/InlineErrorMessage.tsx create mode 100644 components/errors/RootErrorFallback.tsx create mode 100644 components/sync/SyncStatusIndicator.tsx create mode 100644 components/sync/SyncStatusRow.tsx create mode 100644 hooks/useConnectivity.ts create mode 100644 hooks/useSyncStatus.ts create mode 100644 lib/errors/normalizeAppError.ts create mode 100644 lib/errors/reportAppError.ts create mode 100644 lib/sync/requestSync.ts create mode 100644 providers/ConnectivityProvider.tsx create mode 100644 types/appError.ts create mode 100644 types/connectivity.ts create mode 100644 types/syncResult.ts create mode 100644 types/syncStatus.ts diff --git a/app/(tabs)/ai-chat/index.tsx b/app/(tabs)/ai-chat/index.tsx index 8091c4a..cbbe274 100644 --- a/app/(tabs)/ai-chat/index.tsx +++ b/app/(tabs)/ai-chat/index.tsx @@ -2,6 +2,7 @@ import { useUser } from "@clerk/expo"; import { Stack } from "expo-router"; import { AiChatScreen } from "@/components/ai-chat/ai-chat-screen"; +import { FeatureErrorBoundary } from "@/components/errors/FeatureErrorBoundary"; export default function AiChatTabScreen() { const { user } = useUser(); @@ -9,11 +10,16 @@ export default function AiChatTabScreen() { return ( <> - + + + ); } diff --git a/app/_layout.tsx b/app/_layout.tsx index 2864eac..a856fb8 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -2,14 +2,16 @@ import "../global.css"; import { ClerkProvider, useAuth } from "@clerk/expo"; import { tokenCache } from "@clerk/expo/token-cache"; -import { Stack } from "expo-router"; +import { Stack, type ErrorBoundaryProps } from "expo-router"; import { useEffect } from "react"; import { AchievementWatcher } from "@/components/achievements/AchievementWatcher"; import { AppLockGate } from "@/components/app-lock/AppLockGate"; +import { RootErrorFallback } from "@/components/errors/RootErrorFallback"; import { setSupabaseAccessTokenProvider } from "@/lib/supabase"; import { AppLockProvider } from "@/providers/AppLockProvider"; import { AppDialogProvider } from "@/providers/AppDialogProvider"; +import { ConnectivityProvider } from "@/providers/ConnectivityProvider"; const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY ?? ""; @@ -20,13 +22,19 @@ if (!publishableKey) { export default function RootLayout() { return ( - - - + + + + + ); } +export function ErrorBoundary({ error, retry }: ErrorBoundaryProps) { + return ; +} + function AppStack() { const { getToken, isLoaded, userId } = useAuth(); diff --git a/app/insights/report/[periodType].tsx b/app/insights/report/[periodType].tsx index f5c8ecf..567c78f 100644 --- a/app/insights/report/[periodType].tsx +++ b/app/insights/report/[periodType].tsx @@ -35,6 +35,7 @@ import { ReportShell, UpdatingBanner, } from "@/components/insights/report/ReportScreenStates"; +import { FeatureErrorBoundary } from "@/components/errors/FeatureErrorBoundary"; import { ReportStatGrid } from "@/components/insights/report/ReportStatGrid"; import { reportColors } from "@/constants/report-theme"; import { useAppDialog } from "@/hooks/useAppDialog"; @@ -118,17 +119,22 @@ export default function AIInsightReportScreen() { return ( - + )} - + + ); diff --git a/app/settings/privacy.tsx b/app/settings/privacy.tsx index 768fbcc..e99eb04 100644 --- a/app/settings/privacy.tsx +++ b/app/settings/privacy.tsx @@ -115,7 +115,7 @@ export default function PrivacySettingsScreen() { router.push(privacyPolicyHref)} + onPress={() => router.push(accountDeletionUrl as Href)} value="View" /> diff --git a/components/ai-chat/ai-chat-screen.tsx b/components/ai-chat/ai-chat-screen.tsx index 62577cc..9796935 100644 --- a/components/ai-chat/ai-chat-screen.tsx +++ b/components/ai-chat/ai-chat-screen.tsx @@ -22,6 +22,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AnimatedIconButton } from "@/components/ui/animated-icon-button"; import { useAppDialog } from "@/hooks/useAppDialog"; +import { useConnectivity } from "@/hooks/useConnectivity"; import { generateLocalJournalResponse } from "@/lib/ai/localJournalAssistant"; import { generateRemoteJournalResponse } from "@/lib/ai/remoteJournalAssistant"; import { useJournalStore } from "@/store/journal-store"; @@ -66,6 +67,7 @@ export function AiChatScreen({ const insets = useSafeAreaInsets(); const { width } = useWindowDimensions(); const { showDialog } = useAppDialog(); + const connectivity = useConnectivity(); const requestIdRef = useRef(0); const scrollViewRef = useRef(null); const [message, setMessage] = useState(""); @@ -117,7 +119,9 @@ export function AiChatScreen({ userId: userId ?? "guest", } satisfies ChatMessage, ]; - const canSendMessage = message.trim().length > 0 && !!userId && !isThinking; + const isOffline = connectivity.status === "offline"; + const canSendMessage = + message.trim().length > 0 && !!userId && !isThinking && !isOffline; const shouldUseKeyboardOffset = process.env.EXPO_OS === "android"; const footerKeyboardOffset = shouldUseKeyboardOffset ? keyboardOffset : 0; @@ -211,6 +215,16 @@ export function AiChatScreen({ return; } + if (isOffline) { + showDialog({ + confirmText: "OK", + message: + "Internet is required for AI Chat. Your journal entries are still available offline.", + title: "Internet required", + }); + return; + } + const userMessage: ChatMessage = { content: trimmedMessage, createdAt: new Date().toISOString(), @@ -334,10 +348,19 @@ export function AiChatScreen({ - - - - Online + + + + {isOffline ? "Offline" : "Online"} @@ -374,6 +397,14 @@ export function AiChatScreen({ + {isOffline ? ( + + + Internet is required for AI Chat. Your journal entries are still + available offline. + + + ) : null} {visibleMessages.map((chatMessage, index) => ( void; diff --git a/components/connectivity/OfflineNotice.tsx b/components/connectivity/OfflineNotice.tsx new file mode 100644 index 0000000..53265b2 --- /dev/null +++ b/components/connectivity/OfflineNotice.tsx @@ -0,0 +1,16 @@ +import { WifiOff } from "lucide-react-native"; +import { Text, View } from "react-native"; + +export function OfflineNotice({ message }: { message?: string }) { + return ( + + + + + + {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 new file mode 100644 index 0000000..a6683d9 --- /dev/null +++ b/components/errors/FeatureErrorBoundary.tsx @@ -0,0 +1,67 @@ +import React, { type ReactNode } from "react"; +import { Pressable, Text, View } from "react-native"; + +import { reportAppError } from "@/lib/errors/reportAppError"; + +type FeatureErrorBoundaryProps = { + children: ReactNode; + fallbackMessage?: string; + featureName: string; + onRetry?: () => void; +}; + +type FeatureErrorBoundaryState = { + error: Error | null; +}; + +export class FeatureErrorBoundary extends React.Component< + FeatureErrorBoundaryProps, + FeatureErrorBoundaryState +> { + state: FeatureErrorBoundaryState = { + error: null, + }; + + static getDerivedStateFromError(error: Error): FeatureErrorBoundaryState { + return { error }; + } + + componentDidCatch(error: Error) { + reportAppError(error, { + feature: this.props.featureName, + operation: "render", + }); + } + + render() { + if (!this.state.error) { + return this.props.children; + } + + 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} + + ); + } +} diff --git a/components/errors/InlineErrorMessage.tsx b/components/errors/InlineErrorMessage.tsx new file mode 100644 index 0000000..7c1ae7a --- /dev/null +++ b/components/errors/InlineErrorMessage.tsx @@ -0,0 +1,32 @@ +import { Pressable, Text, View } from "react-native"; + +import type { AppError } from "@/types/appError"; + +type InlineErrorMessageProps = { + error: AppError; + onRetry?: () => void; +}; + +export function InlineErrorMessage({ error, onRetry }: InlineErrorMessageProps) { + return ( + + + {error.userMessage} + + {error.retryable && onRetry ? ( + + + Retry + + + ) : null} + + ); +} diff --git a/components/errors/RootErrorFallback.tsx b/components/errors/RootErrorFallback.tsx new file mode 100644 index 0000000..3616c29 --- /dev/null +++ b/components/errors/RootErrorFallback.tsx @@ -0,0 +1,71 @@ +import { router } from "expo-router"; +import { useEffect, useRef } from "react"; +import { Pressable, Text, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { reportAppError } from "@/lib/errors/reportAppError"; + +type RootErrorFallbackProps = { + error: Error; + retry?: () => void; +}; + +export function RootErrorFallback({ error, retry }: RootErrorFallbackProps) { + const insets = useSafeAreaInsets(); + const hasReportedRef = useRef(false); + + useEffect(() => { + if (hasReportedRef.current) { + return; + } + + hasReportedRef.current = true; + reportAppError(error, { + feature: "navigation", + operation: "render", + screen: "root", + }); + }, [error]); + + return ( + + + Something went wrong + + + DearDiary ran into an unexpected problem. Your previously saved journal + data should still be available. + + + + {retry ? ( + + + Try again + + + ) : null} + + router.replace("/home-tab")} + > + + Return to Home + + + + + ); +} diff --git a/components/journal-editor/journal-editor-screen.tsx b/components/journal-editor/journal-editor-screen.tsx index 10dd5c0..431f132 100644 --- a/components/journal-editor/journal-editor-screen.tsx +++ b/components/journal-editor/journal-editor-screen.tsx @@ -19,6 +19,7 @@ import { import { useSafeAreaInsets } from "react-native-safe-area-context"; import { EntryAIReflectionCard } from "@/components/journal-editor/entry-ai-reflection-card"; +import { FeatureErrorBoundary } from "@/components/errors/FeatureErrorBoundary"; import { BottomTabBar, bottomTabBarBaseHeight, @@ -29,11 +30,20 @@ import { animatedMoodEmojis } from "@/constants/animated-emojis"; import { journalEditorMoods } from "@/data/journal-editor"; import { useAppDialog } from "@/hooks/useAppDialog"; import { useAutoSync } from "@/hooks/useAutoSync"; +import { useConnectivity } from "@/hooks/useConnectivity"; import { useEntryReflection } from "@/hooks/useEntryReflection"; +import { normalizeAppError } from "@/lib/errors/normalizeAppError"; +import { reportAppError } from "@/lib/errors/reportAppError"; import { formatTagLabel, normalizeTag, normalizeTags } from "@/lib/tags"; import { useJournalStore } from "@/store/journal-store"; import { useEntryReflectionStore } from "@/store/useEntryReflectionStore"; -import type { EntryType, MoodId } from "@/types/journal"; +import { useSyncStore } from "@/store/useSyncStore"; +import type { ConnectivityStatus } from "@/types/connectivity"; +import type { + EntryType, + JournalSyncStatus, + MoodId, +} from "@/types/journal"; const colors = { heading: "#09090B", @@ -60,6 +70,7 @@ export function JournalEditorScreen({ entryId }: JournalEditorScreenProps) { const router = useRouter(); const { runAutoSync } = useAutoSync(); const { showDialog } = useAppDialog(); + const connectivity = useConnectivity(); const scrollViewRef = useRef(null); const writingContentHeightRef = useRef(0); const writingAreaYRef = useRef(0); @@ -84,6 +95,7 @@ export function JournalEditorScreen({ entryId }: JournalEditorScreenProps) { ); const hasHydrated = useJournalStore((state) => state.hasHydrated); const activeUserId = useJournalStore((state) => state.activeUserId); + const isSyncing = useSyncStore((state) => state.isSyncing); const [content, setContent] = useState(""); const [selectedMood, setSelectedMood] = useState("happy"); const [tags, setTags] = useState([]); @@ -118,6 +130,13 @@ export function JournalEditorScreen({ entryId }: JournalEditorScreenProps) { const isMissingEntry = hasHydrated && isEditing && !entry; const canSave = hasPromptTitle || title.trim().length > 0 || content.trim().length > 0; + const saveButtonLabel = getSaveButtonLabel({ + canSave, + connectivityStatus: connectivity.status, + entrySyncStatus: entry?.syncStatus, + isSyncing, + wasSaved, + }); const dateLabel = useMemo(() => { const date = entry?.createdAt ? new Date(entry.createdAt) : new Date(); @@ -250,19 +269,39 @@ export function JournalEditorScreen({ entryId }: JournalEditorScreenProps) { ...(promptTitle ? { prompt: promptTitle } : {}), }; - if (entryId) { - updateEntry(entryId, savedEntry); + try { + if (entryId) { + updateEntry(entryId, savedEntry); + setWasSaved(true); + void runAutoSync("journal_change"); + return; + } + + const newEntry = addEntry(savedEntry); setWasSaved(true); void runAutoSync("journal_change"); - return; - } + router.replace({ + pathname: "/journal/[id]", + params: { id: newEntry.id, source: source ?? "home" }, + }); + } catch (error) { + const appError = normalizeAppError(error, { + operation: "local_save_journal_entry", + }); - const newEntry = addEntry(savedEntry); - void runAutoSync("journal_change"); - router.replace({ - pathname: "/journal/[id]", - params: { id: newEntry.id, source: source ?? "home" }, - }); + reportAppError(appError, { + errorCode: appError.code, + feature: "journal", + operation: "local_save_journal_entry", + screen: "journal_editor", + }); + showDialog({ + confirmText: "OK", + message: appError.userMessage, + title: "Save failed", + variant: "destructive", + }); + } } function handleAddTag(tag: string) { @@ -315,6 +354,15 @@ export function JournalEditorScreen({ entryId }: JournalEditorScreenProps) { } async function syncEntryBeforeReflection() { + if (connectivity.status === "offline") { + showDialog({ + confirmText: "OK", + message: "Connect to the internet to generate a reflection.", + title: "Internet required", + }); + return false; + } + if (!entry || entry.syncStatus === "synced") { return true; } @@ -491,7 +539,7 @@ export function JournalEditorScreen({ entryId }: JournalEditorScreenProps) { className="text-[17px] font-semibold leading-6" style={{ color: canSave ? "white" : colors.placeholder }} > - {wasSaved ? "Saved" : "Save"} + {saveButtonLabel} @@ -680,17 +728,22 @@ export function JournalEditorScreen({ entryId }: JournalEditorScreenProps) { {entry ? ( - - - + + + + + ) : null} @@ -755,3 +808,39 @@ function getDefaultTitle(type: EntryType) { return "Untitled Entry"; } + +function getSaveButtonLabel({ + canSave, + connectivityStatus, + entrySyncStatus, + isSyncing, + wasSaved, +}: { + canSave: boolean; + connectivityStatus: ConnectivityStatus; + entrySyncStatus: JournalSyncStatus | undefined; + isSyncing: boolean; + wasSaved: boolean; +}) { + if (!canSave) { + return "Save"; + } + + if (!wasSaved && entrySyncStatus !== "synced") { + return "Save"; + } + + if (entrySyncStatus === "synced") { + return "Synced"; + } + + if (isSyncing) { + return "Syncing"; + } + + if (connectivityStatus === "offline" || entrySyncStatus === "failed") { + return "Saved locally"; + } + + return wasSaved ? "Saved locally" : "Save"; +} diff --git a/components/legal/legal-document-screen.tsx b/components/legal/legal-document-screen.tsx index 41d21f3..9059e90 100644 --- a/components/legal/legal-document-screen.tsx +++ b/components/legal/legal-document-screen.tsx @@ -15,6 +15,37 @@ 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 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, +] as const; + export function LegalDocumentScreen({ accountDeletionUrl, effectiveDate, @@ -34,9 +65,12 @@ export function LegalDocumentScreen({ } return ( - + - + - + {title} - + - + Version {version} - + Effective date: {effectiveDate} - {sections.map((section) => ( - - + {sections.map((section, sectionIndex) => ( + + {section.title} - {section.body.map((paragraph) => ( + {section.body.map((paragraph, paragraphIndex) => ( {paragraph} @@ -105,11 +170,21 @@ export function LegalDocumentScreen({ {accountDeletionUrl ? ( - - + + External account-deletion page - + {accountDeletionUrl} diff --git a/components/profile/profile-screen.tsx b/components/profile/profile-screen.tsx index 4d947d8..fdf9d84 100644 --- a/components/profile/profile-screen.tsx +++ b/components/profile/profile-screen.tsx @@ -3,7 +3,7 @@ import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; import { LinearGradient } from "expo-linear-gradient"; import { Link, router, type Href } from "expo-router"; import { StatusBar } from "expo-status-bar"; -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { ActivityIndicator, Pressable, @@ -18,6 +18,7 @@ import { BottomTabBar, bottomTabBarBaseHeight, } from "@/components/navigation/bottom-tab-bar"; +import { SyncStatusRow } from "@/components/sync/SyncStatusRow"; import { AnimatedIconButton } from "@/components/ui/animated-icon-button"; import { achievementDefinitions } from "@/data/achievements"; import { @@ -28,6 +29,8 @@ import { type ProfileStat, } from "@/data/profile"; import { useAppDialog } from "@/hooks/useAppDialog"; +import { useConnectivity } from "@/hooks/useConnectivity"; +import { useSyncStatus } from "@/hooks/useSyncStatus"; import { getAccountDeletionFailureMessage } from "@/lib/account/accountDeletionErrors"; import { deleteCurrentAccount } from "@/lib/account/accountDeletionService"; import { getAchievements, getWordCount } from "@/lib/achievements"; @@ -36,10 +39,9 @@ import { exportJournalAsMarkdown, JournalExportError, } from "@/lib/exportJournal"; +import { reportAppError } from "@/lib/errors/reportAppError"; import { setSupabaseAccessTokenProvider } from "@/lib/supabase"; -import { syncAchievementStatesTwoWay } from "@/lib/sync/achievementSync"; -import { syncJournalEntriesTwoWay } from "@/lib/sync/journalTwoWaySync"; -import { syncProfileToCloud } from "@/lib/sync/profileSync"; +import { requestSync } from "@/lib/sync/requestSync"; import { useJournalStore } from "@/store/journal-store"; import { useAccountDeletionStore } from "@/store/useAccountDeletionStore"; import { useAchievementStore } from "@/store/useAchievementStore"; @@ -57,7 +59,6 @@ const achievementsHref = "/achievements" as Href; const privacySettingsHref = "/settings/privacy" as Href; const cloudSyncItemLabel = "Backup & Sync Data"; const deleteAccountItemLabel = "Delete My Data and Account"; -const syncStatusRefreshIntervalMs = 60 * 1000; const moodLabels: Record = { anxious: "Anxious", @@ -82,49 +83,28 @@ export function ProfileScreen() { const { getToken, signOut } = useAuth(); const { user } = useUser(); const { showDialog } = useAppDialog(); + const connectivity = useConnectivity(); const entries = useJournalStore((state) => state.entries); - const allEntries = useJournalStore((state) => state.allEntries); const hasHydrated = useJournalStore((state) => state.hasHydrated); - const markEntriesPendingSync = useJournalStore( - (state) => state.markEntriesPendingSync, - ); - const markEntriesSynced = useJournalStore( - (state) => state.markEntriesSynced, - ); - const markEntriesSyncFailed = useJournalStore( - (state) => state.markEntriesSyncFailed, - ); - const mergeRemoteEntries = useJournalStore( - (state) => state.mergeRemoteEntries, - ); const setActiveUserId = useJournalStore((state) => state.setActiveUserId); const achievementHasHydrated = useAchievementStore( (state) => state.hasHydrated, ); - const mergeNotifiedAchievementIds = useAchievementStore( - (state) => state.mergeNotifiedAchievementIds, - ); - const setAchievementSyncUserId = useAchievementStore( - (state) => state.setAchievementSyncUserId, - ); const isSyncing = useSyncStore((state) => state.isSyncing); const syncHasHydrated = useSyncStore((state) => state.hasHydrated); - const lastSyncedAt = useSyncStore((state) => state.lastSyncedAt); - const lastSyncFailedAt = useSyncStore((state) => state.lastSyncFailedAt); - const lastSyncUserId = useSyncStore((state) => state.lastSyncUserId); + const syncSnapshot = useSyncStatus(user?.id); const deletionStage = useAccountDeletionStore((state) => state.stage); const deletionInProgress = useAccountDeletionStore( (state) => state.deletionInProgress, ); - const setIsSyncing = useSyncStore((state) => state.setIsSyncing); - const setSyncFailure = useSyncStore((state) => state.setSyncFailure); - const setSyncSuccess = useSyncStore((state) => state.setSyncSuccess); + const deletionRequestInFlight = + deletionInProgress && deletionStage !== "failed"; const [deleteConfirmationText, setDeleteConfirmationText] = useState(""); const [isDeleteConfirmationVisible, setIsDeleteConfirmationVisible] = useState(false); const [isExportingJournal, setIsExportingJournal] = useState(false); + const [isManualSyncing, setIsManualSyncing] = useState(false); const [isSigningOut, setIsSigningOut] = useState(false); - const [syncStatusNow, setSyncStatusNow] = useState(() => Date.now()); const bottomNavHeight = bottomTabBarBaseHeight + insets.bottom; const displayName = getDisplayName({ emailAddress: user?.primaryEmailAddress?.emailAddress, @@ -133,14 +113,6 @@ export function ProfileScreen() { }); const profileInitial = displayName.charAt(0).toUpperCase(); - useEffect(() => { - const intervalId = setInterval(() => { - setSyncStatusNow(Date.now()); - }, syncStatusRefreshIntervalMs); - - return () => clearInterval(intervalId); - }, []); - const journalingSince = useMemo( () => getJournalingSinceLabel(entries, hasHydrated), [entries, hasHydrated], @@ -156,38 +128,10 @@ export function ProfileScreen() { return entries.filter((entry) => entry.userId === user.id); }, [entries, user?.id]); - const currentUserSyncEntries = useMemo(() => { - if (!user?.id) { - return []; - } - - return allEntries.filter((entry) => entry.userId === user.id); - }, [allEntries, user?.id]); const accountMenuItems = useMemo( () => - accountItems.map((item) => - item.label === cloudSyncItemLabel - ? { - ...item, - subtitle: getSyncStatusLabel({ - isSyncing, - lastSyncFailedAt: - lastSyncUserId === user?.id ? lastSyncFailedAt : null, - lastSyncedAt: - lastSyncUserId === user?.id ? lastSyncedAt : null, - now: syncStatusNow, - }), - } - : item, - ), - [ - isSyncing, - lastSyncFailedAt, - lastSyncedAt, - lastSyncUserId, - syncStatusNow, - user?.id, - ], + accountItems.filter((item) => item.label !== cloudSyncItemLabel), + [], ); async function handleSignOut() { @@ -197,20 +141,19 @@ export function ProfileScreen() { setIsSigningOut(true); setActiveUserId(null); + if (user?.id) { + useSyncStore.getState().clearSyncStateForUser(user.id); + } setSupabaseAccessTokenProvider(null); try { await signOut(); router.replace("/login"); - } catch (error) { + } catch { setActiveUserId(user?.id ?? null); - const message = - error instanceof Error - ? error.message - : "We could not sign you out. Please try again."; showDialog({ confirmText: "OK", - message, + message: "We could not sign you out. Please try again.", title: "Sign out failed", variant: "destructive", }); @@ -220,7 +163,7 @@ export function ProfileScreen() { } async function handleSyncNow() { - if (isSyncing || deletionInProgress) { + if (isSyncing || isManualSyncing || deletionInProgress) { return; } @@ -236,6 +179,16 @@ export function ProfileScreen() { return; } + if (connectivity.status === "offline") { + showDialog({ + confirmText: "OK", + message: + "You are offline. Your journal changes are saved on this device and will sync when you reconnect.", + title: "Waiting for internet", + }); + return; + } + if (!hasHydrated || !achievementHasHydrated || !syncHasHydrated) { showDialog({ confirmText: "OK", @@ -245,124 +198,35 @@ export function ProfileScreen() { return; } - const pendingEntryIds = currentUserSyncEntries - .filter((entry) => entry.syncStatus !== "synced") - .map((entry) => entry.id); - - setIsSyncing(true); - setAchievementSyncUserId(userId); - setSupabaseAccessTokenProvider(() => getToken()); - markEntriesPendingSync(userId, pendingEntryIds); - + setIsManualSyncing(true); try { - await syncProfileToCloud({ + const result = await requestSync({ avatarUrl: user.imageUrl, + connectivityStatus: connectivity.status, email: user.primaryEmailAddress?.emailAddress, fullName: user.fullName, + getToken, + reason: "manual", userId, }); - const syncResult = await syncJournalEntriesTwoWay({ - localEntries: currentUserSyncEntries, - userId, - }); - - markEntriesSynced(userId, syncResult.syncedEntryIds); - markEntriesSyncFailed(userId, syncResult.failedEntryIds); - - if (!syncResult.pullSucceeded) { - setSyncFailure( - new Date().toISOString(), - "Manual sync failed", - userId, - ); - showDialog({ - confirmText: "OK", - message: - "We couldn't sync your journal right now. Please check your connection and try again.", - title: "Sync failed", - variant: "destructive", - }); - return; - } - - const mergeResult = mergeRemoteEntries( - userId, - syncResult.remoteEntries, - ); - const restoredCount = - mergeResult.addedCount + mergeResult.updatedCount; - let achievementSyncFailed = false; - - try { - const syncedUserEntries = useJournalStore - .getState() - .entries.filter( - (entry) => entry.userId === userId && !entry.deletedAt, - ); - const unlockedAchievementIds = getAchievements( - syncedUserEntries, - getReflectionStreak(syncedUserEntries), - ) - .filter((achievement) => achievement.unlocked) - .map((achievement) => achievement.id); - const notifiedAchievementIds = - useAchievementStore.getState().achievementNotificationsByUserId[ - userId - ]?.notifiedAchievementIds ?? []; - const achievementSyncResult = await syncAchievementStatesTwoWay({ - notifiedAchievementIds, - unlockedAchievementIds, - userId, - }); - - mergeNotifiedAchievementIds( - userId, - achievementSyncResult.pulledNotifiedIds, - ); - achievementSyncFailed = achievementSyncResult.failedCount > 0; - } catch (error) { - achievementSyncFailed = true; - - if (__DEV__) { - console.warn("Achievement sync failed", error); - } - } - - if (syncResult.pushFailedCount > 0) { - setSyncFailure( - new Date().toISOString(), - "Manual sync incomplete", - userId, - ); + if (!result.success) { showDialog({ confirmText: "OK", message: - "Some entries could not be backed up. Please try again.", - title: "Sync incomplete", + result.code === "session_expired" + ? "Your session has expired. Please sign in again. Your changes remain saved on this device." + : "Your changes are saved on this device, but cloud sync could not finish. We'll try again automatically.", + title: + result.code === "session_expired" + ? "Please sign in again" + : "Cloud sync needs attention", variant: "destructive", }); return; } - if (achievementSyncFailed) { - setSyncFailure( - new Date().toISOString(), - "Achievement sync failed", - userId, - ); - showDialog({ - confirmText: "OK", - message: - "Your journal is up to date, but achievements could not be synced right now.", - title: "Journal synced", - variant: "destructive", - }); - return; - } - - if (syncResult.pushedCount === 0 && restoredCount === 0) { - setSyncSuccess(new Date().toISOString(), userId); + if (result.pushed === 0 && result.pulled === 0) { showDialog({ confirmText: "OK", message: "Your journal and achievements are already synced.", @@ -372,36 +236,28 @@ export function ProfileScreen() { return; } - setSyncSuccess(new Date().toISOString(), userId); showDialog({ confirmText: "Done", - message: getSyncResultMessage(syncResult.pushedCount, restoredCount), + message: getSyncResultMessage(result.pushed, result.pulled), subtitle: "Your journal and achievements are up to date.", title: "Sync complete", variant: "success", }); - } catch (error) { - if (__DEV__) { - console.warn("Two-way journal sync failed", error); - } - - markEntriesSyncFailed(userId, pendingEntryIds); - setSyncFailure(new Date().toISOString(), "Manual sync failed", userId); + } catch { showDialog({ confirmText: "OK", message: - "We couldn't sync your journal right now. Please check your connection and try again.", + "Your changes are saved on this device, but cloud sync could not finish. We'll try again automatically.", title: "Sync failed", variant: "destructive", }); } finally { - setAchievementSyncUserId(null); - setIsSyncing(false); + setIsManualSyncing(false); } } function handleDeleteAccountPress() { - if (deletionInProgress) { + if (deletionRequestInFlight) { return; } @@ -440,7 +296,11 @@ export function ProfileScreen() { async function handleConfirmDeleteAccount() { const userId = user?.id; - if (!userId || deleteConfirmationText !== "DELETE") { + if ( + deletionRequestInFlight || + !userId || + deleteConfirmationText !== "DELETE" + ) { return; } @@ -550,7 +410,11 @@ export function ProfileScreen() { await exportJournalAsJson(currentUserEntries); } catch (error) { if (!(error instanceof JournalExportError)) { - console.warn("Journal export failed", error); + reportAppError(error, { + feature: "export", + operation: "journal_export", + screen: "profile", + }); } showExportErrorDialog(error); @@ -801,16 +665,16 @@ export function ProfileScreen() { }} title="Preferences" /> + + + { - if (item.label === cloudSyncItemLabel) { - void handleSyncNow(); - return; - } - if (item.label === "Export Journal") { handleExportJournalPress(); return; @@ -829,10 +693,10 @@ export function ProfileScreen() { {isDeleteConfirmationVisible ? ( { - if (deletionInProgress) { + if (deletionRequestInFlight) { return; } @@ -976,7 +840,7 @@ function MenuIcon({ item }: { item: ProfileMenuItem }) { function DeleteAccountConfirmationCard({ confirmationText, - deletionInProgress, + deletionRequestInFlight, deletionStage, onCancel, onChangeConfirmationText, @@ -984,14 +848,14 @@ function DeleteAccountConfirmationCard({ onExport, }: { confirmationText: string; - deletionInProgress: boolean; + deletionRequestInFlight: boolean; deletionStage: string; onCancel: () => void; onChangeConfirmationText: (value: string) => void; onConfirm: () => void; onExport: () => void; }) { - const canConfirm = confirmationText === "DELETE" && !deletionInProgress; + const canConfirm = confirmationText === "DELETE" && !deletionRequestInFlight; return ( @@ -1031,7 +895,7 @@ function DeleteAccountConfirmationCard({ @@ -1044,9 +908,10 @@ function DeleteAccountConfirmationCard({ Type DELETE to continue - {deletionInProgress ? ( + {deletionRequestInFlight ? ( @@ -1089,9 +954,9 @@ function DeleteAccountConfirmationCard({ @@ -1143,58 +1008,6 @@ function getDisplayName({ return emailName || "DearDiary Friend"; } -function getSyncStatusLabel({ - isSyncing, - lastSyncFailedAt, - lastSyncedAt, - now, -}: { - isSyncing: boolean; - lastSyncFailedAt: string | null; - lastSyncedAt: string | null; - now: number; -}) { - if (isSyncing) { - return "Syncing..."; - } - - if ( - lastSyncFailedAt && - (!lastSyncedAt || - Date.parse(lastSyncFailedAt) > Date.parse(lastSyncedAt)) - ) { - return "Last sync failed"; - } - - if (!lastSyncedAt) { - return "Not synced yet"; - } - - const elapsedMinutes = Math.max( - 0, - Math.floor((now - Date.parse(lastSyncedAt)) / (60 * 1000)), - ); - - if (elapsedMinutes < 1) { - return "Last synced just now"; - } - - if (elapsedMinutes < 60) { - return `Last synced ${elapsedMinutes} min ago`; - } - - const elapsedHours = Math.floor(elapsedMinutes / 60); - - if (elapsedHours < 24) { - return `Last synced ${elapsedHours} hr ago`; - } - - return `Last synced ${new Intl.DateTimeFormat("en-US", { - day: "numeric", - month: "short", - }).format(new Date(lastSyncedAt))}`; -} - function getProfileSummary(entries: JournalEntry[], hasHydrated: boolean) { if (!hasHydrated) { return { diff --git a/components/sync/SyncStatusIndicator.tsx b/components/sync/SyncStatusIndicator.tsx new file mode 100644 index 0000000..80f1327 --- /dev/null +++ b/components/sync/SyncStatusIndicator.tsx @@ -0,0 +1,93 @@ +import { CheckCircle2, Cloud, CloudOff, Loader2, PauseCircle } from "lucide-react-native"; +import { Text, View } from "react-native"; + +import type { UserSyncStatus } from "@/types/syncStatus"; + +type SyncStatusIndicatorProps = { + status: UserSyncStatus; +}; + +const statusTheme: Record< + UserSyncStatus, + { + backgroundColor: string; + color: string; + label: string; + } +> = { + failed: { + backgroundColor: "#FFE8F0", + color: "#BE123C", + label: "Sync failed", + }, + idle: { + backgroundColor: "#F4F4F5", + color: "#71717B", + label: "Checking", + }, + paused: { + backgroundColor: "#F4F4F5", + color: "#71717B", + label: "Paused", + }, + saved_locally: { + backgroundColor: "#F4EFFA", + color: "#6D28D9", + label: "Saved locally", + }, + synced: { + backgroundColor: "#DCFCE7", + color: "#15803D", + label: "Synced", + }, + syncing: { + backgroundColor: "#E0F2FE", + color: "#0369A1", + label: "Syncing", + }, + waiting_for_network: { + backgroundColor: "#FFF7ED", + color: "#C2410C", + label: "Waiting", + }, +}; + +export function SyncStatusIndicator({ status }: SyncStatusIndicatorProps) { + const theme = statusTheme[status]; + const icon = getStatusIcon(status, theme.color); + + return ( + + {icon} + + {theme.label} + + + ); +} + +function getStatusIcon(status: UserSyncStatus, color: string) { + if (status === "synced") { + return ; + } + + if (status === "syncing") { + return ; + } + + if (status === "waiting_for_network" || status === "failed") { + return ; + } + + if (status === "paused") { + return ; + } + + return ; +} diff --git a/components/sync/SyncStatusRow.tsx b/components/sync/SyncStatusRow.tsx new file mode 100644 index 0000000..216a250 --- /dev/null +++ b/components/sync/SyncStatusRow.tsx @@ -0,0 +1,152 @@ +import { ActivityIndicator, Pressable, Text, View } from "react-native"; + +import { SyncStatusIndicator } from "@/components/sync/SyncStatusIndicator"; +import type { SyncStatusSnapshot } from "@/types/syncStatus"; + +type SyncStatusRowProps = { + isRetrying?: boolean; + onRetry: () => void; + snapshot: SyncStatusSnapshot; +}; + +export function SyncStatusRow({ + isRetrying = false, + onRetry, + snapshot, +}: SyncStatusRowProps) { + const copy = getSyncStatusCopy(snapshot); + const showRetry = snapshot.canRetry && !isRetrying; + + return ( + + + + + Data & Sync + + + {copy.description} + + + + + + + + {snapshot.lastSuccessfulSyncAt ? ( + + ) : null} + {snapshot.pendingCount > 0 ? ( + + ) : null} + + + {showRetry || isRetrying ? ( + + {isRetrying ? : null} + + {isRetrying ? "Syncing..." : copy.retryLabel} + + + ) : null} + + ); +} + +function StatusDetail({ label, value }: { label: string; value: string }) { + return ( + + + {label} + + + {value} + + + ); +} + +function getSyncStatusCopy(snapshot: SyncStatusSnapshot) { + if (snapshot.status === "paused") { + return { + description: "Sync is paused while account deletion is in progress.", + retryLabel: "Retry", + title: "Paused", + }; + } + + if (snapshot.status === "syncing") { + return { + description: + snapshot.pendingCount > 0 + ? `${snapshot.pendingCount} changes remaining.` + : "Checking for updates.", + retryLabel: "Sync now", + title: "Syncing...", + }; + } + + if (snapshot.status === "waiting_for_network") { + return { + description: `${snapshot.pendingCount} ${snapshot.pendingCount === 1 ? "change is" : "changes are"} safely stored on this device.`, + retryLabel: "Retry", + title: "Waiting for internet", + }; + } + + if (snapshot.status === "failed") { + return { + description: + "Your changes remain saved on this device. Cloud sync will retry when it can.", + retryLabel: "Retry", + title: "Cloud sync needs attention", + }; + } + + if (snapshot.status === "saved_locally") { + return { + description: "Cloud sync will begin shortly.", + retryLabel: "Sync now", + title: "Saved on this device", + }; + } + + if (snapshot.status === "synced") { + return { + description: "Your journal and achievements are up to date.", + retryLabel: "Sync now", + title: "Synced", + }; + } + + return { + description: "DearDiary will sync after you create or update entries.", + retryLabel: "Sync now", + title: "No changes waiting", + }; +} + +function formatSyncTime(timestamp: string) { + return new Intl.DateTimeFormat("en-US", { + day: "numeric", + hour: "numeric", + minute: "2-digit", + month: "short", + }).format(new Date(timestamp)); +} diff --git a/components/sync/auto-sync-manager.tsx b/components/sync/auto-sync-manager.tsx index 9731d64..063832b 100644 --- a/components/sync/auto-sync-manager.tsx +++ b/components/sync/auto-sync-manager.tsx @@ -2,16 +2,31 @@ import { useAuth } from "@clerk/expo"; import { useEffect, useMemo, useRef } from "react"; import { AppState, type AppStateStatus } from "react-native"; +import { useConnectivity } from "@/hooks/useConnectivity"; import { useAutoSync } from "@/hooks/useAutoSync"; import { useJournalStore } from "@/store/journal-store"; +import { useAccountDeletionStore } from "@/store/useAccountDeletionStore"; +import { useSyncStore } from "@/store/useSyncStore"; const journalChangeDebounceMs = 1500; +const reconnectSyncDelayMs = 1200; +const retryDelaysMs = [5000, 15000]; export function AutoSyncManager() { const { isLoaded, userId } = useAuth(); + const connectivity = useConnectivity(); const { runAutoSync } = useAutoSync(); const appStateRef = useRef(AppState.currentState); + const previousConnectivityStatusRef = useRef(connectivity.status); + const retryAttemptRef = useRef(0); const allEntries = useJournalStore((state) => state.allEntries); + const isSyncing = useSyncStore((state) => state.isSyncing); + const lastSyncedAt = useSyncStore((state) => state.lastSyncedAt); + const lastSyncFailedAt = useSyncStore((state) => state.lastSyncFailedAt); + const lastSyncUserId = useSyncStore((state) => state.lastSyncUserId); + const deletionInProgress = useAccountDeletionStore( + (state) => state.deletionInProgress, + ); const pendingEntryKey = useMemo(() => { if (!userId) { return ""; @@ -20,12 +35,13 @@ export function AutoSyncManager() { return allEntries .filter( (entry) => - entry.userId === userId && entry.syncStatus === "pending", + entry.userId === userId && entry.syncStatus !== "synced", ) .map((entry) => `${entry.id}:${entry.updatedAt}`) .sort() .join("|"); }, [allEntries, userId]); + const hasPendingChanges = pendingEntryKey.length > 0; useEffect(() => { if (!isLoaded || !userId) { @@ -49,7 +65,7 @@ export function AutoSyncManager() { }, [runAutoSync]); useEffect(() => { - if (!userId || !pendingEntryKey) { + if (!userId || !hasPendingChanges) { return; } @@ -58,7 +74,90 @@ export function AutoSyncManager() { }, journalChangeDebounceMs); return () => clearTimeout(timeout); - }, [pendingEntryKey, runAutoSync, userId]); + }, [hasPendingChanges, pendingEntryKey, runAutoSync, userId]); + + useEffect(() => { + const previousStatus = previousConnectivityStatusRef.current; + previousConnectivityStatusRef.current = connectivity.status; + + if ( + previousStatus !== "offline" || + connectivity.status !== "online" || + !userId || + !hasPendingChanges || + isSyncing || + deletionInProgress + ) { + return; + } + + const timeout = setTimeout(() => { + void runAutoSync("reconnect"); + }, reconnectSyncDelayMs); + + return () => clearTimeout(timeout); + }, [ + connectivity.status, + deletionInProgress, + hasPendingChanges, + isSyncing, + runAutoSync, + userId, + ]); + + useEffect(() => { + if (!userId || lastSyncUserId !== userId) { + retryAttemptRef.current = 0; + return; + } + + if ( + !lastSyncedAt || + !lastSyncFailedAt || + Date.parse(lastSyncedAt) < Date.parse(lastSyncFailedAt) + ) { + return; + } + + retryAttemptRef.current = 0; + }, [lastSyncFailedAt, lastSyncedAt, lastSyncUserId, userId]); + + useEffect(() => { + if ( + !userId || + lastSyncUserId !== userId || + !lastSyncFailedAt || + !hasPendingChanges || + isSyncing || + deletionInProgress || + connectivity.status === "offline" + ) { + return; + } + + const delay = retryDelaysMs[retryAttemptRef.current]; + + if (delay === undefined) { + return; + } + + retryAttemptRef.current += 1; + + const timeout = setTimeout(() => { + void runAutoSync("retry"); + }, delay); + + return () => clearTimeout(timeout); + }, [ + connectivity.status, + deletionInProgress, + hasPendingChanges, + isSyncing, + lastSyncFailedAt, + lastSyncUserId, + runAutoSync, + userId, + ]); return null; } diff --git a/hooks/useAIInsightReport.ts b/hooks/useAIInsightReport.ts index 81d909b..f81066f 100644 --- a/hooks/useAIInsightReport.ts +++ b/hooks/useAIInsightReport.ts @@ -10,6 +10,7 @@ import { type ReportPeriod, } from "@/lib/insights/reportPeriods"; import { useAutoSync } from "@/hooks/useAutoSync"; +import { useConnectivity } from "@/hooks/useConnectivity"; import { useAIInsightReportStore } from "@/store/useAIInsightReportStore"; import { useJournalStore } from "@/store/journal-store"; import type { AIInsightReport } from "@/types/aiInsightReport"; @@ -39,6 +40,7 @@ export function useAIInsightReport( const enabled = options.enabled ?? true; const { userId } = useAuth(); const { runAutoSync } = useAutoSync(); + const connectivity = useConnectivity(); const entries = useJournalStore((state) => state.entries); const hasHydrated = useJournalStore((state) => state.hasHydrated); const cacheKey = useMemo(() => getReportCacheKey(period), [period]); @@ -105,6 +107,11 @@ export function useAIInsightReport( return; } + if (connectivity.status === "offline") { + setError("Connect to the internet to refresh this reflection report."); + return; + } + const requestVersion = requestContextVersionRef.current; setIsLoading(true); setError(null); @@ -143,6 +150,7 @@ export function useAIInsightReport( period, removeCachedReport, setCachedReport, + connectivity.status, userId, ]); @@ -167,6 +175,11 @@ export function useAIInsightReport( return; } + if (connectivity.status === "offline") { + setError("Connect to the internet to generate a reflection report."); + return; + } + const requestVersion = requestContextVersionRef.current; requestInFlightRef.current = true; setIsGenerating(true); @@ -217,6 +230,7 @@ export function useAIInsightReport( }, [ cacheKey, + connectivity.status, enabled, isCurrentRequestContext, period, diff --git a/hooks/useAutoSync.ts b/hooks/useAutoSync.ts index d46183e..8bea20a 100644 --- a/hooks/useAutoSync.ts +++ b/hooks/useAutoSync.ts @@ -1,34 +1,30 @@ import { useAuth, useUser } from "@clerk/expo"; import { useCallback } from "react"; -import { getAchievements } from "@/lib/achievements"; -import { - isSupabaseConfigured, - setSupabaseAccessTokenProvider, -} from "@/lib/supabase"; -import { syncAchievementStatesTwoWay } from "@/lib/sync/achievementSync"; -import { syncJournalEntriesTwoWay } from "@/lib/sync/journalTwoWaySync"; -import { syncProfileToCloud } from "@/lib/sync/profileSync"; +import { useConnectivity } from "@/hooks/useConnectivity"; +import { requestSync } from "@/lib/sync/requestSync"; import { useJournalStore } from "@/store/journal-store"; import { useAccountDeletionStore } from "@/store/useAccountDeletionStore"; import { useAchievementStore } from "@/store/useAchievementStore"; import { useSyncStore } from "@/store/useSyncStore"; -import type { JournalEntry } from "@/types/journal"; export type AutoSyncReason = | "app_start" | "foreground" | "journal_change" | "achievement_change" - | "manual_background"; + | "manual_background" + | "reconnect" + | "retry"; const autoSyncCooldownMs = 60 * 1000; -const autoSyncErrorMessage = "Auto sync failed"; export function useAutoSync() { const { getToken, isLoaded, userId: authUserId } = useAuth(); const { user } = useUser(); + const connectivity = useConnectivity(); const journalHasHydrated = useJournalStore((state) => state.hasHydrated); + const activeUserId = useJournalStore((state) => state.activeUserId); const achievementHasHydrated = useAchievementStore( (state) => state.hasHydrated, ); @@ -44,7 +40,8 @@ export function useAutoSync() { authUserId !== user.id || !journalHasHydrated || !achievementHasHydrated || - !syncHasHydrated + !syncHasHydrated || + activeUserId !== user.id ) { return; } @@ -56,7 +53,7 @@ export function useAutoSync() { return; } - if (syncState.isSyncing) { + if (syncState.isSyncing || connectivity.status === "offline") { return; } @@ -67,7 +64,11 @@ export function useAutoSync() { (entry) => entry.syncStatus !== "synced", ); const shouldBypassCooldown = - reason === "journal_change" && hasPendingJournalChanges; + (reason === "journal_change" || + reason === "manual_background" || + reason === "reconnect" || + reason === "retry") && + hasPendingJournalChanges; if ( !shouldBypassCooldown && @@ -77,98 +78,21 @@ export function useAutoSync() { return; } - const now = new Date().toISOString(); - - if (!isSupabaseConfigured) { - syncState.setSyncFailure(now, autoSyncErrorMessage, userId); - return; - } - - syncState.setIsSyncing(true); - useAchievementStore.getState().setAchievementSyncUserId(userId); - setSupabaseAccessTokenProvider(() => getToken()); - - try { - await syncProfileToCloud({ - avatarUrl: user.imageUrl, - email: user.primaryEmailAddress?.emailAddress, - fullName: user.fullName, - userId, - }); - - const journalResult = await syncJournalEntriesTwoWay({ - localEntries: currentUserEntries, - userId, - }); - const journalStore = useJournalStore.getState(); - - journalStore.markEntriesSynced(userId, journalResult.syncedEntryIds); - journalStore.markEntriesSyncFailed( - userId, - journalResult.failedEntryIds, - ); - - if (journalResult.pullSucceeded) { - journalStore.mergeRemoteEntries(userId, journalResult.remoteEntries); - } - - const syncedUserEntries = useJournalStore - .getState() - .entries.filter( - (entry) => entry.userId === userId && !entry.deletedAt, - ); - const unlockedAchievementIds = getAchievements( - syncedUserEntries, - getReflectionStreak(syncedUserEntries), - ) - .filter((achievement) => achievement.unlocked) - .map((achievement) => achievement.id); - const achievementStore = useAchievementStore.getState(); - const notifiedAchievementIds = - achievementStore.achievementNotificationsByUserId[userId] - ?.notifiedAchievementIds ?? []; - const achievementResult = await syncAchievementStatesTwoWay({ - notifiedAchievementIds, - unlockedAchievementIds, - userId, - }); - - achievementStore.mergeNotifiedAchievementIds( - userId, - achievementResult.pulledNotifiedIds, - ); - - if ( - !journalResult.pullSucceeded || - journalResult.pushFailedCount > 0 || - achievementResult.failedCount > 0 - ) { - throw new Error(autoSyncErrorMessage); - } - - useSyncStore - .getState() - .setSyncSuccess(new Date().toISOString(), userId); - } catch (error) { - if (__DEV__) { - console.warn(`Auto sync failed (${reason})`, error); - } - - useSyncStore - .getState() - .setSyncFailure( - new Date().toISOString(), - autoSyncErrorMessage, - userId, - ); - } finally { - useAchievementStore.getState().setAchievementSyncUserId(null); - useSyncStore.getState().setIsSyncing(false); - } + await requestSync({ + avatarUrl: user.imageUrl, + connectivityStatus: connectivity.status, + email: user.primaryEmailAddress?.emailAddress, + fullName: user.fullName, + getToken, + reason: reason === "manual_background" ? "retry" : reason, + userId, + }); }, [ achievementHasHydrated, + activeUserId, authUserId, + connectivity.status, getToken, isLoaded, journalHasHydrated, @@ -197,46 +121,6 @@ function isWithinAutoSyncCooldown(lastSyncedAt: string | null) { ); } -function getReflectionStreak(entries: JournalEntry[]) { - if (entries.length === 0) { - return 0; - } - - const entryDays = new Set( - entries.map((entry) => getLocalDateKey(new Date(entry.createdAt))), - ); - const today = new Date(); - const yesterday = new Date(); - yesterday.setDate(today.getDate() - 1); - - let cursor = startOfLocalDay(today); - - if (!entryDays.has(getLocalDateKey(cursor))) { - if (!entryDays.has(getLocalDateKey(yesterday))) { - return 0; - } - - cursor = startOfLocalDay(yesterday); - } - - let streak = 0; - - while (entryDays.has(getLocalDateKey(cursor))) { - streak += 1; - cursor.setDate(cursor.getDate() - 1); - } - - return streak; -} - -function getLocalDateKey(date: Date) { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - - return `${year}-${month}-${day}`; -} - function startOfLocalDay(date: Date) { return new Date(date.getFullYear(), date.getMonth(), date.getDate()); } diff --git a/hooks/useConnectivity.ts b/hooks/useConnectivity.ts new file mode 100644 index 0000000..51f89fb --- /dev/null +++ b/hooks/useConnectivity.ts @@ -0,0 +1,7 @@ +import { useContext } from "react"; + +import { ConnectivityContext } from "@/providers/ConnectivityProvider"; + +export function useConnectivity() { + return useContext(ConnectivityContext); +} diff --git a/hooks/useSyncStatus.ts b/hooks/useSyncStatus.ts new file mode 100644 index 0000000..a7b49e3 --- /dev/null +++ b/hooks/useSyncStatus.ts @@ -0,0 +1,144 @@ +import { useMemo } from "react"; + +import { useConnectivity } from "@/hooks/useConnectivity"; +import { useJournalStore } from "@/store/journal-store"; +import { useAccountDeletionStore } from "@/store/useAccountDeletionStore"; +import { useSyncStore } from "@/store/useSyncStore"; +import type { SyncStatusSnapshot, UserSyncStatus } from "@/types/syncStatus"; + +export function useSyncStatus(userId: string | null | undefined) { + const connectivity = useConnectivity(); + const allEntries = useJournalStore((state) => state.allEntries); + const journalHasHydrated = useJournalStore((state) => state.hasHydrated); + const syncHasHydrated = useSyncStore((state) => state.hasHydrated); + const isSyncing = useSyncStore((state) => state.isSyncing); + const lastAttemptAt = useSyncStore((state) => state.lastAttemptAt); + const lastSyncErrorCode = useSyncStore((state) => state.lastSyncErrorCode); + const lastSyncFailedAt = useSyncStore((state) => state.lastSyncFailedAt); + const lastSyncedAt = useSyncStore((state) => state.lastSyncedAt); + const lastSyncUserId = useSyncStore((state) => state.lastSyncUserId); + const deletionInProgress = useAccountDeletionStore( + (state) => state.deletionInProgress, + ); + + return useMemo(() => { + const currentUserId = userId ?? null; + + if (!currentUserId || deletionInProgress) { + return createSnapshot({ + status: deletionInProgress ? "paused" : "idle", + }); + } + + if (!journalHasHydrated || !syncHasHydrated) { + return createSnapshot({ status: "idle" }); + } + + const currentUserEntries = allEntries.filter( + (entry) => entry.userId === currentUserId, + ); + const pendingCount = currentUserEntries.filter( + (entry) => entry.syncStatus !== "synced", + ).length; + const failedCount = currentUserEntries.filter( + (entry) => entry.syncStatus === "failed", + ).length; + const metadataBelongsToUser = lastSyncUserId === currentUserId; + const scopedLastSyncedAt = metadataBelongsToUser ? lastSyncedAt : null; + const scopedLastAttemptAt = metadataBelongsToUser ? lastAttemptAt : null; + const scopedLastFailureAt = metadataBelongsToUser ? lastSyncFailedAt : null; + const scopedErrorCode = metadataBelongsToUser ? lastSyncErrorCode : null; + const hasFreshFailure = + Boolean(scopedLastFailureAt) && + (!scopedLastSyncedAt || + Date.parse(scopedLastFailureAt ?? "") > Date.parse(scopedLastSyncedAt)); + const hasRetryableFailure = failedCount > 0 || hasFreshFailure; + const status = getUserSyncStatus({ + connectivityStatus: connectivity.status, + hasRetryableFailure, + isSyncing, + pendingCount, + scopedLastSyncedAt, + }); + + return { + canRetry: + Boolean(currentUserId) && + !isSyncing && + !deletionInProgress && + connectivity.status !== "offline" && + (pendingCount > 0 || hasRetryableFailure), + errorCode: scopedErrorCode, + failedCount, + lastAttemptAt: scopedLastAttemptAt, + lastSuccessfulSyncAt: scopedLastSyncedAt, + pendingCount, + status, + }; + }, [ + allEntries, + connectivity.status, + deletionInProgress, + isSyncing, + journalHasHydrated, + lastAttemptAt, + lastSyncErrorCode, + lastSyncFailedAt, + lastSyncUserId, + lastSyncedAt, + syncHasHydrated, + userId, + ]); +} + +function getUserSyncStatus({ + connectivityStatus, + hasRetryableFailure, + isSyncing, + pendingCount, + scopedLastSyncedAt, +}: { + connectivityStatus: string; + hasRetryableFailure: boolean; + isSyncing: boolean; + pendingCount: number; + scopedLastSyncedAt: string | null; +}): UserSyncStatus { + if (isSyncing) { + return "syncing"; + } + + if (connectivityStatus === "offline" && pendingCount > 0) { + return "waiting_for_network"; + } + + if (hasRetryableFailure) { + return "failed"; + } + + if (pendingCount > 0) { + return "saved_locally"; + } + + if (scopedLastSyncedAt) { + return "synced"; + } + + return "idle"; +} + +function createSnapshot({ + status, +}: { + status: UserSyncStatus; +}): SyncStatusSnapshot { + return { + canRetry: false, + errorCode: null, + failedCount: 0, + lastAttemptAt: null, + lastSuccessfulSyncAt: null, + pendingCount: 0, + status, + }; +} diff --git a/lib/account/accountDeletionService.ts b/lib/account/accountDeletionService.ts index d092877..c790d10 100644 --- a/lib/account/accountDeletionService.ts +++ b/lib/account/accountDeletionService.ts @@ -21,6 +21,7 @@ type DeleteCurrentAccountParams = { }; const expectedConfirmationPhrase = "DELETE"; +const deleteAccountRequestTimeoutMs = 30000; export async function deleteCurrentAccount({ confirmationPhrase, @@ -70,7 +71,14 @@ export async function deleteCurrentAccount({ if (!response.success) { if (response.remoteDataDeleted) { deletionStore.setStage("clearing_local_data"); - await clearLocalUserData(userId); + try { + await clearLocalUserData(userId); + } catch { + return failLocalCleanupAfterRemoteDeletion( + deletionStore, + response.requestId, + ); + } deletionStore.failDeletion(response.code, { keepGuardActive: response.code === "auth_account_deletion_failed", requestId: response.requestId, @@ -85,7 +93,14 @@ export async function deleteCurrentAccount({ } deletionStore.setStage("clearing_local_data"); - await clearLocalUserData(userId); + try { + await clearLocalUserData(userId); + } catch { + return failLocalCleanupAfterRemoteDeletion( + deletionStore, + response.requestId, + ); + } try { await signOut(); @@ -137,15 +152,28 @@ async function invokeDeleteAccountFunction({ }; } - const response = await fetch(`${supabaseUrl}/functions/v1/delete-account`, { - body: JSON.stringify({ confirmationPhrase }), - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - apikey: supabaseAnonKey, - }, - method: "POST", - }); + const abortController = new AbortController(); + const timeoutId = setTimeout(() => { + abortController.abort(); + }, deleteAccountRequestTimeoutMs); + + let response: Response; + + try { + response = await fetch(`${supabaseUrl}/functions/v1/delete-account`, { + body: JSON.stringify({ confirmationPhrase }), + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + apikey: supabaseAnonKey, + }, + method: "POST", + signal: abortController.signal, + }); + } finally { + clearTimeout(timeoutId); + } + const data: unknown = await response.json().catch(() => null); if (isDeleteAccountFunctionResponse(data)) { @@ -160,7 +188,7 @@ async function invokeDeleteAccountFunction({ } function getDeletionErrorResult(error: unknown): AccountDeletionFailureResult { - if (error instanceof TypeError) { + if (error instanceof TypeError || isAbortError(error)) { return { code: "network_unavailable", retryable: true, @@ -175,6 +203,24 @@ function getDeletionErrorResult(error: unknown): AccountDeletionFailureResult { }; } +function failLocalCleanupAfterRemoteDeletion( + deletionStore: ReturnType, + requestId: string | undefined, +): AccountDeletionFailureResult { + deletionStore.failDeletion("local_cleanup_failed", { + keepGuardActive: true, + requestId, + }); + + return { + code: "local_cleanup_failed", + remoteDataDeleted: true, + requestId, + retryable: true, + success: false, + }; +} + function isDeleteAccountFunctionResponse( value: unknown, ): value is DeleteAccountFunctionResponse { @@ -195,6 +241,13 @@ function isDeleteAccountFunctionResponse( ); } +function isAbortError(error: unknown) { + return ( + isRecord(error) && + error.name === "AbortError" + ); +} + function isAccountDeletionFailureCode( value: unknown, ): value is AccountDeletionFailureCode { diff --git a/lib/account/clearLocalUserData.ts b/lib/account/clearLocalUserData.ts index 34638d7..55e5a63 100644 --- a/lib/account/clearLocalUserData.ts +++ b/lib/account/clearLocalUserData.ts @@ -59,6 +59,8 @@ async function clearNotificationState() { name: error instanceof Error ? error.name : "UnknownError", }); } + + throw error; } } diff --git a/lib/errors/normalizeAppError.ts b/lib/errors/normalizeAppError.ts new file mode 100644 index 0000000..f66aab7 --- /dev/null +++ b/lib/errors/normalizeAppError.ts @@ -0,0 +1,215 @@ +import type { + AppError, + AppErrorCategory, + AppErrorCode, + AppErrorSeverity, +} from "@/types/appError"; + +type NormalizeContext = { + fallbackMessage?: string; + operation?: string; +}; + +type ErrorShape = { + code?: unknown; + message?: unknown; + name?: unknown; + status?: unknown; +}; + +export const normalizeAppError = ( + error: unknown, + context?: NormalizeContext, +): AppError => { + const shape = getErrorShape(error); + const message = getLowercaseMessage(shape); + const code = typeof shape?.code === "string" ? shape.code : ""; + const status = typeof shape?.status === "number" ? shape.status : null; + const operation = context?.operation; + + if (isOfflineError(message, code)) { + return createAppError({ + category: "network", + code: "offline", + error, + operation, + retryable: true, + severity: "warning", + userMessage: + "You are offline. Your journal changes will stay on this device and sync when you reconnect.", + }); + } + + if (isTimeoutError(message, code)) { + return createAppError({ + category: "network", + code: "request_timeout", + error, + operation, + retryable: true, + severity: "warning", + userMessage: + "The request took too long. Please check your connection and try again.", + }); + } + + if (status === 401 || message.includes("jwt") || message.includes("session")) { + return createAppError({ + category: "authentication", + code: "session_expired", + error, + operation, + retryable: false, + severity: "warning", + userMessage: "Your session has expired. Please sign in again.", + }); + } + + if (status === 403 || code === "42501" || message.includes("permission")) { + return createAppError({ + category: "authorization", + code: "permission_denied", + error, + operation, + retryable: false, + severity: "error", + userMessage: + "DearDiary could not access this data. Please sign in again and try once more.", + }); + } + + if (status === 404 || code === "PGRST116" || message.includes("not found")) { + return createAppError({ + category: "not_found", + code: "resource_not_found", + error, + operation, + retryable: false, + severity: "warning", + userMessage: "This item could not be found.", + }); + } + + if (status === 429 || message.includes("rate limit")) { + return createAppError({ + category: "rate_limit", + code: "rate_limited", + error, + operation, + retryable: true, + severity: "warning", + userMessage: "DearDiary is receiving too many requests. Please try again shortly.", + }); + } + + if (operation?.includes("local_save") || message.includes("asyncstorage")) { + return createAppError({ + category: "local_storage", + code: "local_save_failed", + error, + operation, + retryable: true, + severity: "error", + userMessage: + "We could not save this entry on your device. Please try again before leaving this screen.", + }); + } + + if (operation?.includes("ai")) { + return createAppError({ + category: "ai", + code: "ai_unavailable", + error, + operation, + retryable: true, + severity: "warning", + userMessage: + "DearDiary could not complete this reflection right now. Your journal entry is safe.", + }); + } + + if (operation?.includes("sync")) { + return createAppError({ + category: "sync", + code: "sync_failed", + error, + operation, + retryable: true, + severity: "warning", + userMessage: + "Your changes are saved on this device, but cloud sync could not finish. We will try again automatically.", + }); + } + + return createAppError({ + category: "unknown", + code: "unexpected_error", + error, + operation, + retryable: true, + severity: "error", + userMessage: + context?.fallbackMessage ?? + "Something unexpected happened. Your locally saved journal data is still available.", + }); +}; + +function createAppError({ + category, + code, + error, + operation, + retryable, + severity, + userMessage, +}: { + category: AppErrorCategory; + code: AppErrorCode; + error: unknown; + operation?: string; + retryable: boolean; + severity: AppErrorSeverity; + userMessage: string; +}): AppError { + return { + category, + cause: error, + code, + operation, + retryable, + severity, + userMessage, + }; +} + +function getErrorShape(error: unknown): ErrorShape | null { + return isRecord(error) ? error : null; +} + +function getLowercaseMessage(shape: ErrorShape | null) { + return typeof shape?.message === "string" ? shape.message.toLowerCase() : ""; +} + +function isOfflineError(message: string, code: string) { + return ( + code === "offline" || + code === "NETWORK_ERROR" || + message.includes("network request failed") || + message.includes("failed to fetch") || + message.includes("offline") + ); +} + +function isTimeoutError(message: string, code: string) { + return ( + code === "request_timeout" || + code === "ETIMEDOUT" || + code === "ECONNABORTED" || + message.includes("timeout") || + message.includes("timed out") + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/lib/errors/reportAppError.ts b/lib/errors/reportAppError.ts new file mode 100644 index 0000000..5e4b0db --- /dev/null +++ b/lib/errors/reportAppError.ts @@ -0,0 +1,34 @@ +import Constants from "expo-constants"; + +import { normalizeAppError } from "@/lib/errors/normalizeAppError"; +import type { AppErrorCode } from "@/types/appError"; + +export type ErrorReportContext = { + errorCode?: AppErrorCode; + feature?: string; + operation?: string; + screen?: string; +}; + +export const reportAppError = ( + error: unknown, + context?: ErrorReportContext, +): void => { + const normalizedError = normalizeAppError(error, { + operation: context?.operation, + }); + const errorCode = context?.errorCode ?? normalizedError.code; + + if (!__DEV__) { + return; + } + + console.warn("DearDiary error report", { + buildVersion: Constants.expoConfig?.version ?? null, + errorCode, + feature: context?.feature ?? null, + operation: context?.operation ?? normalizedError.operation ?? null, + screen: context?.screen ?? null, + timestamp: new Date().toISOString(), + }); +}; diff --git a/lib/sync/requestSync.ts b/lib/sync/requestSync.ts new file mode 100644 index 0000000..2fc721c --- /dev/null +++ b/lib/sync/requestSync.ts @@ -0,0 +1,299 @@ +import { normalizeAppError } from "@/lib/errors/normalizeAppError"; +import { reportAppError } from "@/lib/errors/reportAppError"; +import { getAchievements } from "@/lib/achievements"; +import { + isSupabaseConfigured, + setSupabaseAccessTokenProvider, + type SupabaseAccessTokenProvider, +} from "@/lib/supabase"; +import { syncAchievementStatesTwoWay } from "@/lib/sync/achievementSync"; +import { syncJournalEntriesTwoWay } from "@/lib/sync/journalTwoWaySync"; +import { syncProfileToCloud } from "@/lib/sync/profileSync"; +import { useJournalStore } from "@/store/journal-store"; +import { useAccountDeletionStore } from "@/store/useAccountDeletionStore"; +import { useAchievementStore } from "@/store/useAchievementStore"; +import { useSyncStore } from "@/store/useSyncStore"; +import type { ConnectivityStatus } from "@/types/connectivity"; +import type { JournalEntry } from "@/types/journal"; +import type { SyncResult } from "@/types/syncResult"; + +export type SyncRequestReason = + | "app_start" + | "foreground" + | "journal_change" + | "achievement_change" + | "manual" + | "reconnect" + | "retry"; + +type RequestSyncParams = { + avatarUrl?: string | null; + connectivityStatus?: ConnectivityStatus; + email?: string | null; + fullName?: string | null; + getToken: SupabaseAccessTokenProvider; + reason: SyncRequestReason; + userId: string; +}; + +let activeSync: + | { + promise: Promise; + userId: string; + } + | null = null; + +export function requestSync(params: RequestSyncParams): Promise { + if (activeSync) { + if (activeSync.userId === params.userId) { + return activeSync.promise; + } + + return Promise.resolve({ + code: "sync_failed", + localDataPreserved: true, + retryable: true, + success: false, + }); + } + + const promise = runSync(params).finally(() => { + if (activeSync?.promise === promise) { + activeSync = null; + } + }); + + activeSync = { + promise, + userId: params.userId, + }; + + return promise; +} + +async function runSync({ + avatarUrl, + connectivityStatus, + email, + fullName, + getToken, + reason, + userId, +}: RequestSyncParams): Promise { + const now = new Date().toISOString(); + const syncStore = useSyncStore.getState(); + + if (useAccountDeletionStore.getState().deletionInProgress) { + return { + code: "sync_failed", + localDataPreserved: true, + retryable: false, + success: false, + }; + } + + if (connectivityStatus === "offline") { + syncStore.setSyncFailure(now, "offline", userId); + return { + code: "offline", + localDataPreserved: true, + retryable: true, + success: false, + }; + } + + if (!isSupabaseConfigured) { + syncStore.setSyncFailure(now, "sync_failed", userId); + return { + code: "sync_failed", + localDataPreserved: true, + retryable: true, + success: false, + }; + } + + if (useJournalStore.getState().activeUserId !== userId) { + return { + code: "session_expired", + localDataPreserved: true, + retryable: false, + success: false, + }; + } + + syncStore.setSyncAttempt(now, userId); + syncStore.setIsSyncing(true); + useAchievementStore.getState().setAchievementSyncUserId(userId); + setSupabaseAccessTokenProvider(getToken); + + const currentUserEntries = useJournalStore + .getState() + .allEntries.filter((entry) => entry.userId === userId); + const pendingEntryIds = currentUserEntries + .filter((entry) => entry.syncStatus !== "synced") + .map((entry) => entry.id); + + if (pendingEntryIds.length > 0) { + useJournalStore.getState().markEntriesPendingSync(userId, pendingEntryIds); + } + + try { + await syncProfileToCloud({ + avatarUrl: avatarUrl ?? undefined, + email: email ?? undefined, + fullName: fullName ?? undefined, + userId, + }); + + const journalResult = await syncJournalEntriesTwoWay({ + localEntries: currentUserEntries, + userId, + }); + const journalStore = useJournalStore.getState(); + + journalStore.markEntriesSynced(userId, journalResult.syncedEntryIds); + journalStore.markEntriesSyncFailed(userId, journalResult.failedEntryIds); + + let pulled = 0; + + if (journalResult.pullSucceeded) { + const mergeResult = journalStore.mergeRemoteEntries( + userId, + journalResult.remoteEntries, + ); + pulled = mergeResult.addedCount + mergeResult.updatedCount; + } + + const achievementResult = await syncAchievements(userId); + + if ( + !journalResult.pullSucceeded || + journalResult.pushFailedCount > 0 || + achievementResult.failedCount > 0 + ) { + throw new Error("sync_failed"); + } + + const completedAt = new Date().toISOString(); + + useSyncStore.getState().setSyncSuccess(completedAt, userId); + + return { + completedAt, + conflictsResolved: 0, + pulled, + pushed: journalResult.pushedCount, + success: true, + }; + } catch (error) { + const normalizedError = normalizeAppError(error, { + operation: "sync", + }); + + reportAppError(normalizedError, { + errorCode: normalizedError.code, + feature: "sync", + operation: `sync:${reason}`, + }); + + const failedEntryIds = useJournalStore + .getState() + .allEntries.filter( + (entry) => + entry.userId === userId && + pendingEntryIds.includes(entry.id) && + entry.syncStatus !== "synced", + ) + .map((entry) => entry.id); + + if (failedEntryIds.length > 0) { + useJournalStore.getState().markEntriesSyncFailed(userId, failedEntryIds); + } + + useSyncStore + .getState() + .setSyncFailure(new Date().toISOString(), normalizedError.code, userId); + + return { + code: normalizedError.code, + localDataPreserved: true, + retryable: normalizedError.retryable, + success: false, + }; + } finally { + useAchievementStore.getState().setAchievementSyncUserId(null); + useSyncStore.getState().setIsSyncing(false); + } +} + +async function syncAchievements(userId: string) { + const syncedUserEntries = useJournalStore + .getState() + .entries.filter((entry) => entry.userId === userId && !entry.deletedAt); + const unlockedAchievementIds = getAchievements( + syncedUserEntries, + getReflectionStreak(syncedUserEntries), + ) + .filter((achievement) => achievement.unlocked) + .map((achievement) => achievement.id); + const achievementStore = useAchievementStore.getState(); + const notifiedAchievementIds = + achievementStore.achievementNotificationsByUserId[userId] + ?.notifiedAchievementIds ?? []; + const achievementResult = await syncAchievementStatesTwoWay({ + notifiedAchievementIds, + unlockedAchievementIds, + userId, + }); + + achievementStore.mergeNotifiedAchievementIds( + userId, + achievementResult.pulledNotifiedIds, + ); + + return achievementResult; +} + +function getReflectionStreak(entries: JournalEntry[]) { + if (entries.length === 0) { + return 0; + } + + const entryDays = new Set( + entries.map((entry) => getLocalDateKey(new Date(entry.createdAt))), + ); + const today = new Date(); + const yesterday = new Date(); + yesterday.setDate(today.getDate() - 1); + + let cursor = startOfLocalDay(today); + + if (!entryDays.has(getLocalDateKey(cursor))) { + if (!entryDays.has(getLocalDateKey(yesterday))) { + return 0; + } + + cursor = startOfLocalDay(yesterday); + } + + let streak = 0; + + while (entryDays.has(getLocalDateKey(cursor))) { + streak += 1; + cursor.setDate(cursor.getDate() - 1); + } + + return streak; +} + +function getLocalDateKey(date: Date) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + + return `${year}-${month}-${day}`; +} + +function startOfLocalDay(date: Date) { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()); +} diff --git a/package-lock.json b/package-lock.json index a8637a1..6414a7e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@clerk/expo": "^3.3.1", "@expo/vector-icons": "^15.0.3", "@react-native-async-storage/async-storage": "1.24.0", + "@react-native-community/netinfo": "11.4.1", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", @@ -4068,6 +4069,15 @@ "react-native": "^0.0.0-0 || >=0.60 <1.0" } }, + "node_modules/@react-native-community/netinfo": { + "version": "11.4.1", + "resolved": "https://registry.npmjs.org/@react-native-community/netinfo/-/netinfo-11.4.1.tgz", + "integrity": "sha512-B0BYAkghz3Q2V09BF88RA601XursIEA111tnc2JOaN7axJWmNefmfjZqw/KdSxKZp7CZUuPpjBmz/WCR9uaHYg==", + "license": "MIT", + "peerDependencies": { + "react-native": ">=0.59" + } + }, "node_modules/@react-native/assets-registry": { "version": "0.81.5", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz", diff --git a/package.json b/package.json index b9603cd..a2606fd 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@clerk/expo": "^3.3.1", "@expo/vector-icons": "^15.0.3", "@react-native-async-storage/async-storage": "1.24.0", + "@react-native-community/netinfo": "11.4.1", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", diff --git a/providers/ConnectivityProvider.tsx b/providers/ConnectivityProvider.tsx new file mode 100644 index 0000000..b2fcf1c --- /dev/null +++ b/providers/ConnectivityProvider.tsx @@ -0,0 +1,119 @@ +import NetInfo, { + type NetInfoState, +} from "@react-native-community/netinfo"; +import { + createContext, + type ReactNode, + useEffect, + useMemo, + useRef, + useState, +} from "react"; + +import type { + ConnectivityState, + ConnectivityStatus, +} from "@/types/connectivity"; + +const offlineConfirmationMs = 700; + +const initialConnectivityState: ConnectivityState = { + connectionType: null, + isConnected: null, + isInternetReachable: null, + lastChangedAt: null, + status: "unknown", +}; + +export const ConnectivityContext = createContext( + initialConnectivityState, +); + +export function ConnectivityProvider({ children }: { children: ReactNode }) { + const [connectivity, setConnectivity] = useState( + initialConnectivityState, + ); + const offlineTimeoutRef = useRef | null>(null); + + useEffect(() => { + const clearOfflineTimeout = () => { + if (offlineTimeoutRef.current) { + clearTimeout(offlineTimeoutRef.current); + offlineTimeoutRef.current = null; + } + }; + + const unsubscribe = NetInfo.addEventListener((state) => { + const nextState = getConnectivityState(state); + + if (nextState.status === "offline") { + clearOfflineTimeout(); + offlineTimeoutRef.current = setTimeout(() => { + setConnectivity((currentState) => + areConnectivityStatesEqual(currentState, nextState) + ? currentState + : nextState, + ); + }, offlineConfirmationMs); + return; + } + + clearOfflineTimeout(); + setConnectivity((currentState) => + areConnectivityStatesEqual(currentState, nextState) + ? currentState + : nextState, + ); + }); + + return () => { + clearOfflineTimeout(); + unsubscribe(); + }; + }, []); + + const value = useMemo(() => connectivity, [connectivity]); + + return ( + + {children} + + ); +} + +function getConnectivityState(state: NetInfoState): ConnectivityState { + return { + connectionType: state.type, + isConnected: state.isConnected, + isInternetReachable: state.isInternetReachable, + lastChangedAt: new Date().toISOString(), + status: getConnectivityStatus(state), + }; +} + +function getConnectivityStatus(state: NetInfoState): ConnectivityStatus { + const isConnected = state.isConnected; + const isInternetReachable = state.isInternetReachable; + + if (isConnected === false || isInternetReachable === false) { + return "offline"; + } + + if (isConnected === true) { + return "online"; + } + + return "unknown"; +} + +function areConnectivityStatesEqual( + left: ConnectivityState, + right: ConnectivityState, +) { + return ( + left.connectionType === right.connectionType && + left.isConnected === right.isConnected && + left.isInternetReachable === right.isInternetReachable && + left.status === right.status + ); +} diff --git a/store/useSyncStore.ts b/store/useSyncStore.ts index 554755f..68be59d 100644 --- a/store/useSyncStore.ts +++ b/store/useSyncStore.ts @@ -2,20 +2,25 @@ 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"; + type SyncState = { clearSyncError: () => void; clearSyncStateForUser: (userId: string) => void; hasHydrated: boolean; isSyncing: boolean; + lastAttemptAt: string | null; + lastSyncErrorCode: AppErrorCode | null; lastSyncError: string | null; lastSyncFailedAt: string | null; lastSyncedAt: string | null; lastSyncUserId: string | null; setHasHydrated: (value: boolean) => void; setIsSyncing: (value: boolean) => void; + setSyncAttempt: (timestamp: string, userId: string) => void; setSyncFailure: ( timestamp: string, - errorMessage: string, + errorCode: AppErrorCode, userId: string, ) => void; setSyncSuccess: (timestamp: string, userId: string) => void; @@ -23,7 +28,12 @@ type SyncState = { type PersistedSyncMetadata = Pick< SyncState, - "lastSyncError" | "lastSyncFailedAt" | "lastSyncedAt" | "lastSyncUserId" + | "lastAttemptAt" + | "lastSyncError" + | "lastSyncErrorCode" + | "lastSyncFailedAt" + | "lastSyncedAt" + | "lastSyncUserId" >; export const useSyncStore = create()( @@ -31,6 +41,7 @@ export const useSyncStore = create()( (set) => ({ clearSyncError: () => set({ + lastSyncErrorCode: null, lastSyncError: null, lastSyncFailedAt: null, }), @@ -42,6 +53,8 @@ export const useSyncStore = create()( return { isSyncing: false, + lastAttemptAt: null, + lastSyncErrorCode: null, lastSyncError: null, lastSyncFailedAt: null, lastSyncedAt: null, @@ -50,21 +63,32 @@ export const useSyncStore = create()( }), hasHydrated: false, isSyncing: false, + lastAttemptAt: null, + lastSyncErrorCode: null, lastSyncError: null, lastSyncFailedAt: null, lastSyncedAt: null, lastSyncUserId: null, setHasHydrated: (value) => set({ hasHydrated: value }), setIsSyncing: (value) => set({ isSyncing: value }), - setSyncFailure: (timestamp, errorMessage, userId) => + setSyncAttempt: (timestamp, userId) => set({ - lastSyncError: errorMessage, + lastAttemptAt: timestamp, + lastSyncUserId: userId, + }), + setSyncFailure: (timestamp, errorCode, userId) => + set({ + lastAttemptAt: timestamp, + lastSyncError: getUserSafeSyncErrorLabel(errorCode), + lastSyncErrorCode: errorCode, lastSyncFailedAt: timestamp, lastSyncUserId: userId, }), setSyncSuccess: (timestamp, userId) => set({ + lastAttemptAt: timestamp, lastSyncError: null, + lastSyncErrorCode: null, lastSyncFailedAt: null, lastSyncedAt: timestamp, lastSyncUserId: userId, @@ -80,7 +104,9 @@ export const useSyncStore = create()( state?.setHasHydrated(true); }, partialize: (state) => ({ + lastAttemptAt: state.lastAttemptAt, lastSyncError: state.lastSyncError, + lastSyncErrorCode: state.lastSyncErrorCode, lastSyncFailedAt: state.lastSyncFailedAt, lastSyncedAt: state.lastSyncedAt, lastSyncUserId: state.lastSyncUserId, @@ -98,7 +124,11 @@ function getSanitizedSyncMetadata( } return { + lastAttemptAt: getNullableTimestamp(persistedState.lastAttemptAt), lastSyncError: getNullableString(persistedState.lastSyncError), + lastSyncErrorCode: getNullableAppErrorCode( + persistedState.lastSyncErrorCode, + ), lastSyncFailedAt: getNullableTimestamp(persistedState.lastSyncFailedAt), lastSyncedAt: getNullableTimestamp(persistedState.lastSyncedAt), lastSyncUserId: getNullableString(persistedState.lastSyncUserId), @@ -107,7 +137,9 @@ function getSanitizedSyncMetadata( function createEmptySyncMetadata(): PersistedSyncMetadata { return { + lastAttemptAt: null, lastSyncError: null, + lastSyncErrorCode: null, lastSyncFailedAt: null, lastSyncedAt: null, lastSyncUserId: null, @@ -124,8 +156,47 @@ function getNullableTimestamp(value: unknown) { : null; } +function getNullableAppErrorCode(value: unknown): AppErrorCode | null { + return typeof value === "string" && isAppErrorCode(value) ? value : null; +} + +function getUserSafeSyncErrorLabel(code: AppErrorCode) { + if (code === "offline") { + return "Offline"; + } + + if (code === "session_expired") { + return "Session expired"; + } + + if (code === "permission_denied") { + return "Permission denied"; + } + + return "Sync failed"; +} + +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/config.toml b/supabase/config.toml index b63d660..00d85d1 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -8,4 +8,4 @@ verify_jwt = false verify_jwt = false [functions.delete-account] -verify_jwt = false +verify_jwt = true diff --git a/supabase/functions/delete-account/index.ts b/supabase/functions/delete-account/index.ts index d07926c..520ec10 100644 --- a/supabase/functions/delete-account/index.ts +++ b/supabase/functions/delete-account/index.ts @@ -27,6 +27,8 @@ const corsHeaders = { }; const expectedConfirmationPhrase = "DELETE"; +const clerkApiTimeoutMs = 10000; +const storageListPageLimit = 100; const userOwnedStorageBuckets: string[] = []; Deno.serve(async (request) => { @@ -330,33 +332,51 @@ async function deleteStoragePrefix( path: string, ): Promise<{ ok: true } | { ok: false }> { const prefix = path ? `${userId}/${path}` : userId; - const listResult = await client.storage.from(bucket).list(prefix); + const filesToRemove: string[] = []; + const nestedPaths: string[] = []; + let offset = 0; - if (listResult.error) { - return { ok: false }; - } + while (true) { + const listResult = await client.storage.from(bucket).list(prefix, { + limit: storageListPageLimit, + offset, + }); - const filesToRemove: string[] = []; + if (listResult.error) { + return { ok: false }; + } - for (const item of listResult.data ?? []) { - const itemPath = path ? `${path}/${item.name}` : item.name; + const items = listResult.data ?? []; - if (item.id === null) { - const nestedResult = await deleteStoragePrefix( - client, - bucket, - userId, - itemPath, - ); + for (const item of items) { + const itemPath = path ? `${path}/${item.name}` : item.name; - if (!nestedResult.ok) { - return nestedResult; + if (item.id === null) { + nestedPaths.push(itemPath); + continue; } - continue; + filesToRemove.push(`${userId}/${itemPath}`); + } + + if (items.length < storageListPageLimit) { + break; } - filesToRemove.push(`${userId}/${itemPath}`); + offset += storageListPageLimit; + } + + for (const nestedPath of nestedPaths) { + const nestedResult = await deleteStoragePrefix( + client, + bucket, + userId, + nestedPath, + ); + + if (!nestedResult.ok) { + return nestedResult; + } } if (filesToRemove.length === 0) { @@ -377,27 +397,43 @@ async function deleteClerkUser({ requestId: string; userId: string; }) { - const response = await fetch( - `https://api.clerk.com/v1/users/${encodeURIComponent(userId)}`, - { - headers: { - Authorization: `Bearer ${clerkSecretKey}`, + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), clerkApiTimeoutMs); + + try { + const response = await fetch( + `https://api.clerk.com/v1/users/${encodeURIComponent(userId)}`, + { + headers: { + Authorization: `Bearer ${clerkSecretKey}`, + }, + method: "DELETE", + signal: controller.signal, }, - method: "DELETE", - }, - ); + ); - if (response.ok || response.status === 404) { - return { ok: true }; - } + if (response.ok || response.status === 404) { + return { ok: true }; + } - console.error("delete-account clerk_cleanup_failed", { - requestId, - stage: "auth_account", - status: response.status, - }); + console.error("delete-account clerk_cleanup_failed", { + requestId, + stage: "auth_account", + status: response.status, + }); + + return { ok: false }; + } catch (error) { + console.error("delete-account clerk_cleanup_failed", { + error: error instanceof Error ? error.name : "unknown", + requestId, + stage: "auth_account", + }); - return { ok: false }; + return { ok: false }; + } finally { + clearTimeout(timeout); + } } function getBearerToken(authorization: string) { diff --git a/types/appError.ts b/types/appError.ts new file mode 100644 index 0000000..145eaf6 --- /dev/null +++ b/types/appError.ts @@ -0,0 +1,38 @@ +export type AppErrorCategory = + | "network" + | "authentication" + | "authorization" + | "local_storage" + | "sync" + | "ai" + | "validation" + | "rate_limit" + | "server" + | "not_found" + | "unknown"; + +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 type AppError = { + category: AppErrorCategory; + cause?: unknown; + code: AppErrorCode; + operation?: string; + retryable: boolean; + severity: AppErrorSeverity; + userMessage: string; +}; diff --git a/types/connectivity.ts b/types/connectivity.ts new file mode 100644 index 0000000..4cd45a4 --- /dev/null +++ b/types/connectivity.ts @@ -0,0 +1,9 @@ +export type ConnectivityStatus = "unknown" | "online" | "offline"; + +export type ConnectivityState = { + connectionType: string | null; + isConnected: boolean | null; + isInternetReachable: boolean | null; + lastChangedAt: string | null; + status: ConnectivityStatus; +}; diff --git a/types/syncResult.ts b/types/syncResult.ts new file mode 100644 index 0000000..dcd1a21 --- /dev/null +++ b/types/syncResult.ts @@ -0,0 +1,16 @@ +import type { AppErrorCode } from "@/types/appError"; + +export type SyncResult = + | { + completedAt: string; + conflictsResolved: number; + pulled: number; + pushed: number; + success: true; + } + | { + code: AppErrorCode; + localDataPreserved: boolean; + retryable: boolean; + success: false; + }; diff --git a/types/syncStatus.ts b/types/syncStatus.ts new file mode 100644 index 0000000..7e5a020 --- /dev/null +++ b/types/syncStatus.ts @@ -0,0 +1,20 @@ +import type { AppErrorCode } from "@/types/appError"; + +export type UserSyncStatus = + | "idle" + | "saved_locally" + | "waiting_for_network" + | "syncing" + | "synced" + | "failed" + | "paused"; + +export type SyncStatusSnapshot = { + canRetry: boolean; + errorCode: AppErrorCode | null; + failedCount: number; + lastAttemptAt: string | null; + lastSuccessfulSyncAt: string | null; + pendingCount: number; + status: UserSyncStatus; +};