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;
+};