From d4fa6e879fefff8df2cf6462b6aa6bc00bab5680 Mon Sep 17 00:00:00 2001 From: aryansoni-dev Date: Sat, 4 Jul 2026 23:38:57 +0530 Subject: [PATCH 1/2] Bug fixes : stabilize auth, privacy lock, and insight reports - prevent repeated splash screens after authentication - fix Clerk sign-out crashes and upgrade Clerk Expo - show privacy cover only in background or recents - fix report freshness detection and source synchronization - improve AI report regeneration and retry handling - bump app version and refine privacy policy content --- .gitignore | 1 - app.json | 2 +- app/insights/report/[periodType].tsx | 2 +- components/app-lock/AppLockScreen.tsx | 4 +- components/auth/auth-screen.tsx | 10 +-- components/onboarding/app-launch-gate.tsx | 27 ++++++- components/profile/profile-screen.tsx | 5 +- content/legal/privacyPolicy.ts | 5 +- hooks/useAIInsightReport.ts | 80 +++++++++++++++---- hooks/useAppLockLifecycle.ts | 17 +--- hooks/useAutoSync.ts | 2 +- lib/insights/reportSourceSnapshot.ts | 30 +++++++ package-lock.json | 47 ++++++----- package.json | 2 +- .../_shared/buildReportSourceSnapshot.ts | 50 ++++++++++++ .../generate-insight-report/index.ts | 57 +++++++------ tests/report-source-snapshot.test.mjs | 74 +++++++++++++++++ 17 files changed, 315 insertions(+), 100 deletions(-) create mode 100644 lib/insights/reportSourceSnapshot.ts create mode 100644 supabase/functions/_shared/buildReportSourceSnapshot.ts create mode 100644 tests/report-source-snapshot.test.mjs diff --git a/.gitignore b/.gitignore index 5bbd7db..e87996d 100644 --- a/.gitignore +++ b/.gitignore @@ -65,4 +65,3 @@ PROMPTS.md task.md DB.md AI.md -UI/ \ No newline at end of file diff --git a/app.json b/app.json index 5c4d7ad..56ea15b 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "DearDiary", "slug": "dear-diary", - "version": "1.0.0", + "version": "1.0.2", "orientation": "portrait", "icon": "./assets/images/icon.png", "scheme": "deardiary", diff --git a/app/insights/report/[periodType].tsx b/app/insights/report/[periodType].tsx index 6410a4d..690eaf7 100644 --- a/app/insights/report/[periodType].tsx +++ b/app/insights/report/[periodType].tsx @@ -170,7 +170,7 @@ export default function AIInsightReportScreen() { : reportState.error } onRetry={() => { - void reportState.refresh(); + void reportState.retry(); }} retrying={reportState.isLoading} /> diff --git a/components/app-lock/AppLockScreen.tsx b/components/app-lock/AppLockScreen.tsx index 9043eef..b6054f5 100644 --- a/components/app-lock/AppLockScreen.tsx +++ b/components/app-lock/AppLockScreen.tsx @@ -1,4 +1,4 @@ -import { useAuth } from "@clerk/expo"; +import { useClerk } from "@clerk/expo"; import { Feather } from "@expo/vector-icons"; import { useCallback, useEffect, useMemo, useState } from "react"; import { @@ -22,7 +22,7 @@ import { useJournalStore } from "@/store/journal-store"; export function AppLockScreen() { const insets = useSafeAreaInsets(); - const { signOut } = useAuth(); + const { signOut } = useClerk(); const setActiveUserId = useJournalStore((state) => state.setActiveUserId); const { biometricAvailability, diff --git a/components/auth/auth-screen.tsx b/components/auth/auth-screen.tsx index 1559d5e..8f8b533 100644 --- a/components/auth/auth-screen.tsx +++ b/components/auth/auth-screen.tsx @@ -11,7 +11,7 @@ import { LinearGradient } from "expo-linear-gradient"; import type { Href } from "expo-router"; import { Link, router } from "expo-router"; import { StatusBar } from "expo-status-bar"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { ActivityIndicator, Pressable, @@ -87,7 +87,7 @@ export function AuthScreen({ "oauth_google" | "oauth_apple" | null >(null); const isLogin = mode === "login"; - const { isLoaded: isAuthLoaded, isSignedIn } = useAuth(); + const { isLoaded: isAuthLoaded } = useAuth(); const { fetchStatus: signInFetchStatus, signIn } = useSignIn(); const { fetchStatus: signUpFetchStatus, signUp } = useSignUp(); const { startSSOFlow } = useSSO(); @@ -99,12 +99,6 @@ export function AuthScreen({ const emailFeedback = getEmailFeedback(email); const passwordFeedback = getPasswordFeedback(password, isLogin); - useEffect(() => { - if (isSignedIn) { - router.replace(homeHref); - } - }, [isSignedIn]); - function showError(message: string) { showAuthError(showDialog, message); } diff --git a/components/onboarding/app-launch-gate.tsx b/components/onboarding/app-launch-gate.tsx index 53c9f53..c437cfd 100644 --- a/components/onboarding/app-launch-gate.tsx +++ b/components/onboarding/app-launch-gate.tsx @@ -1,3 +1,4 @@ +import { usePathname } from "expo-router"; import { useCallback, useEffect, useState, type ReactNode } from "react"; import { AppPrivacyCover } from "@/components/app-lock/AppPrivacyCover"; @@ -5,9 +6,15 @@ import { SplashScreen } from "@/components/onboarding/splash-screen"; import { useOnboardingStore } from "@/store/onboarding-store"; const hydrationFallbackDelayMs = 1500; +const authCallbackPaths = new Set(["/sso", "/sso-callback"]); + +let hasCompletedLaunchSplash = false; export function AppLaunchGate({ children }: { children: ReactNode }) { - const [hasSplashFinished, setHasSplashFinished] = useState(false); + const pathname = usePathname(); + const [hasSplashFinished, setHasSplashFinished] = useState( + hasCompletedLaunchSplash, + ); const [showHydrationFallback, setShowHydrationFallback] = useState(false); const hasHydrated = useOnboardingStore((state) => state.hasHydrated); @@ -23,14 +30,30 @@ export function AppLaunchGate({ children }: { children: ReactNode }) { return () => clearTimeout(hydrationFallback); }, [hasHydrated]); + useEffect(() => { + if (!authCallbackPaths.has(pathname)) { + return; + } + + hasCompletedLaunchSplash = true; + setHasSplashFinished(true); + }, [pathname]); + const handleSplashAnimationEnd = useCallback(() => { + hasCompletedLaunchSplash = true; setHasSplashFinished(true); }, []); - if (!hasSplashFinished) { + const isAuthCallback = authCallbackPaths.has(pathname); + + if (!hasSplashFinished && !isAuthCallback) { return ; } + if (isAuthCallback) { + return children; + } + if (!hasHydrated) { return showHydrationFallback ? ( diff --git a/components/profile/profile-screen.tsx b/components/profile/profile-screen.tsx index de53b19..cac9ebd 100644 --- a/components/profile/profile-screen.tsx +++ b/components/profile/profile-screen.tsx @@ -1,4 +1,4 @@ -import { useAuth, useUser } from "@clerk/expo"; +import { useAuth, useClerk, useUser } from "@clerk/expo"; import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; import Constants from "expo-constants"; import { LinearGradient } from "expo-linear-gradient"; @@ -88,7 +88,8 @@ const moodEmoji: Record = { export function ProfileScreen() { const insets = useSafeAreaInsets(); - const { getToken, signOut } = useAuth(); + const { getToken } = useAuth(); + const { signOut } = useClerk(); const { user } = useUser(); const { showDialog } = useAppDialog(); const connectivity = useConnectivity(); diff --git a/content/legal/privacyPolicy.ts b/content/legal/privacyPolicy.ts index d58c6cc..aa9fc1d 100644 --- a/content/legal/privacyPolicy.ts +++ b/content/legal/privacyPolicy.ts @@ -14,7 +14,8 @@ export const privacyPolicy = { { body: [ "DearDiary is an AI-powered journaling companion. This draft explains what data the app handles and how account deletion works. It requires legal review before public release.", - `Operator: ${legalPlaceholders.companyName}. Website: ${legalPlaceholders.websiteUrl}.`, + `Operator: ${legalPlaceholders.companyName}.`, + `Website: ${legalPlaceholders.websiteUrl}.` ], title: "Overview", }, @@ -58,7 +59,7 @@ export const privacyPolicy = { }, { body: [ - `For privacy questions or account deletion help, contact ${legalPlaceholders.supportEmail}.`, + `For privacy questions or account deletion help, contact - ${legalPlaceholders.supportEmail}.`, ], title: "Contact", }, diff --git a/hooks/useAIInsightReport.ts b/hooks/useAIInsightReport.ts index d45c6e9..0a77107 100644 --- a/hooks/useAIInsightReport.ts +++ b/hooks/useAIInsightReport.ts @@ -3,6 +3,7 @@ import * as Crypto from "expo-crypto"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { + AIInsightReportServiceError, fetchAIInsightReport, generateAIInsightReport, } from "@/lib/insights/aiInsightReportService"; @@ -12,6 +13,7 @@ import { type ReportPeriod, } from "@/lib/insights/reportPeriods"; import { isAIInsightReport } from "@/lib/insights/aiInsightReportMapper"; +import { buildReportSourceSnapshotInput } from "@/lib/insights/reportSourceSnapshot"; import { useAutoSync } from "@/hooks/useAutoSync"; import { useConnectivity } from "@/hooks/useConnectivity"; import { useAIInsightReportStore } from "@/store/useAIInsightReportStore"; @@ -35,6 +37,7 @@ export type UseAIInsightReportResult = { generate: () => Promise; regenerate: () => Promise; refresh: () => Promise; + retry: () => Promise; }; type UseAIInsightReportOptions = { @@ -89,6 +92,9 @@ export function useAIInsightReport( } | null>(null); const requestContextVersionRef = useRef(0); const requestInFlightRef = useRef(false); + const lastFailedOperationRef = useRef< + "generate" | "refresh" | "regenerate" | null + >(null); const reportRef = useRef(report); const periodEntries = useMemo( @@ -173,6 +179,8 @@ export function useAIInsightReport( }, []); const refresh = useCallback(async () => { + lastFailedOperationRef.current = "refresh"; + if (!enabled) { return; } @@ -229,6 +237,8 @@ export function useAIInsightReport( setReport(null); removeCachedReport(userId, cacheKey); } + + lastFailedOperationRef.current = null; } catch (refreshError) { if (!isCurrentRequestContext(requestVersion)) { return; @@ -276,6 +286,8 @@ export function useAIInsightReport( const requestGeneration = useCallback( async (regenerate: boolean) => { + lastFailedOperationRef.current = regenerate ? "regenerate" : "generate"; + if (!enabled) { return; } @@ -349,6 +361,7 @@ export function useAIInsightReport( setReport(generatedReport); setLegacyReportAvailable(false); setCachedReport(userId, cacheKey, generatedReport); + lastFailedOperationRef.current = null; } catch (generationError) { if (!isCurrentRequestContext(requestVersion)) { return; @@ -392,6 +405,17 @@ export function useAIInsightReport( refresh, regenerate: () => requestGeneration(true), report, + retry: () => { + if (lastFailedOperationRef.current === "regenerate") { + return requestGeneration(true); + } + + if (lastFailedOperationRef.current === "generate") { + return requestGeneration(false); + } + + return refresh(); + }, }; } @@ -404,13 +428,39 @@ async function syncPeriodSourcesBeforeGeneration({ runAutoSync: (reason?: "journal_change") => Promise; userId: string; }) { - if (!hasUnsyncedPeriodSources({ period, userId })) { - return true; + const periodEntries = getEntriesInPeriod( + useJournalStore + .getState() + .allEntries.filter((entry) => entry.userId === userId), + period, + true, + ); + const periodMoodLogs = getMoodLogsInPeriod( + useMoodLogStore.getState().allMoodLogs, + period, + userId, + true, + ); + const entryIds = periodEntries.map((entry) => entry.id); + const moodLogIds = periodMoodLogs.map((moodLog) => moodLog.id); + + if (entryIds.length > 0) { + useJournalStore.getState().markEntriesPendingSync(userId, entryIds); + } + + if (moodLogIds.length > 0) { + useMoodLogStore.getState().markMoodLogsPendingSync(userId, moodLogIds); } - await runAutoSync("journal_change"); + for (let attempt = 0; attempt < 2; attempt += 1) { + await runAutoSync("journal_change"); + + if (!hasUnsyncedPeriodSources({ period, userId })) { + return true; + } + } - return !hasUnsyncedPeriodSources({ period, userId }); + return false; } function hasUnsyncedPeriodSources({ @@ -425,6 +475,7 @@ function hasUnsyncedPeriodSources({ .getState() .allEntries.filter((entry) => entry.userId === userId), period, + true, ).some((entry) => entry.syncStatus !== "synced"); const hasUnsyncedMoodLogs = getMoodLogsInPeriod( useMoodLogStore.getState().allMoodLogs, @@ -436,7 +487,11 @@ function hasUnsyncedPeriodSources({ return hasUnsyncedEntries || hasUnsyncedMoodLogs; } -function getEntriesInPeriod(entries: JournalEntry[], period: ReportPeriod) { +function getEntriesInPeriod( + entries: JournalEntry[], + period: ReportPeriod, + includeDeleted = false, +) { const periodStart = period.start.getTime(); const periodEnd = period.end.getTime(); @@ -444,7 +499,7 @@ function getEntriesInPeriod(entries: JournalEntry[], period: ReportPeriod) { const createdAt = Date.parse(entry.createdAt); return ( - !entry.deletedAt && + (includeDeleted || !entry.deletedAt) && Number.isFinite(createdAt) && createdAt >= periodStart && createdAt <= periodEnd @@ -481,14 +536,7 @@ function getMoodLogsInPeriod( function getLocalSource(entries: JournalEntry[], moodLogs: MoodLog[]) { const entryIds = entries.map((entry) => entry.id).sort(); const moodLogIds = moodLogs.map((moodLog) => moodLog.id).sort(); - const snapshotInput = [ - ...entries.map((entry) => `entry:${entry.id}:${entry.updatedAt}`), - ...moodLogs.map( - (moodLog) => `mood:${moodLog.id}:${moodLog.updatedAt}`, - ), - ] - .sort() - .join("|"); + const snapshotInput = buildReportSourceSnapshotInput(entries, moodLogs); const latestUpdatedAt = [...entries, ...moodLogs] .map((source) => source.updatedAt) @@ -586,6 +634,10 @@ function isTimestampAfter( } function getErrorMessage(error: unknown) { + if (error instanceof AIInsightReportServiceError) { + return error.message; + } + return normalizeAppError(error, { operation: "ai_insight_report", }).userMessage; diff --git a/hooks/useAppLockLifecycle.ts b/hooks/useAppLockLifecycle.ts index 8abfec8..24299c8 100644 --- a/hooks/useAppLockLifecycle.ts +++ b/hooks/useAppLockLifecycle.ts @@ -1,5 +1,5 @@ import { useEffect, useRef, type MutableRefObject } from "react"; -import { AppState, type AppStateStatus } from "react-native"; +import { AppState } from "react-native"; import type { AppLockConfig, AppLockDelay, AppLockStatus } from "@/types/appLock"; @@ -27,7 +27,6 @@ export function useAppLockLifecycle({ setPrivacyCoverVisible, status, }: UseAppLockLifecycleParams) { - const appStateRef = useRef(AppState.currentState); const backgroundedAtRef = useRef(null); const visibilityRef = useRef(AppState.currentState === "active"); const configRef = useRef(config); @@ -97,9 +96,9 @@ export function useAppLockLifecycle({ setPrivacyCoverVisible(false); } + // Android `blur` can fire for dialogs while the app is still active. + // Only real AppState transitions should reveal the privacy cover. const changeSubscription = AppState.addEventListener("change", (nextState) => { - appStateRef.current = nextState; - if (isAuthenticatingRef.current) { return; } @@ -115,19 +114,9 @@ export function useAppLockLifecycle({ handleBecomeVisible(); }); - const blurSubscription = AppState.addEventListener( - "blur", - handleLoseVisibility, - ); - const focusSubscription = AppState.addEventListener( - "focus", - handleBecomeVisible, - ); return () => { - blurSubscription.remove(); changeSubscription.remove(); - focusSubscription.remove(); }; }, [ isAuthenticatingRef, diff --git a/hooks/useAutoSync.ts b/hooks/useAutoSync.ts index 94fd3ef..5664e90 100644 --- a/hooks/useAutoSync.ts +++ b/hooks/useAutoSync.ts @@ -65,7 +65,7 @@ export function useAutoSync() { return; } - if (syncState.isSyncing || connectivity.status === "offline") { + if (connectivity.status === "offline") { return; } diff --git a/lib/insights/reportSourceSnapshot.ts b/lib/insights/reportSourceSnapshot.ts new file mode 100644 index 0000000..444c848 --- /dev/null +++ b/lib/insights/reportSourceSnapshot.ts @@ -0,0 +1,30 @@ +type ReportSource = { + id: string; + updatedAt: string; +}; + +export function buildReportSourceSnapshotInput( + entries: ReportSource[], + moodLogs: ReportSource[], +) { + return [ + ...entries.map( + (entry) => + `entry:${entry.id}:${normalizeReportSourceTimestamp(entry.updatedAt)}`, + ), + ...moodLogs.map( + (moodLog) => + `mood:${moodLog.id}:${normalizeReportSourceTimestamp(moodLog.updatedAt)}`, + ), + ] + .sort() + .join("|"); +} + +export function normalizeReportSourceTimestamp(timestamp: string) { + const parsedTimestamp = Date.parse(timestamp); + + return Number.isFinite(parsedTimestamp) + ? new Date(parsedTimestamp).toISOString() + : timestamp.trim(); +} diff --git a/package-lock.json b/package-lock.json index ef72785..505a6d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "dear-diary", "version": "1.0.0", "dependencies": { - "@clerk/expo": "^3.3.1", + "@clerk/expo": "3.6.5", "@expo/vector-icons": "^15.0.3", "@react-native-async-storage/async-storage": "2.2.0", "@react-native-community/netinfo": "11.4.1", @@ -1614,13 +1614,13 @@ } }, "node_modules/@clerk/clerk-js": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@clerk/clerk-js/-/clerk-js-6.14.0.tgz", - "integrity": "sha512-xreDPw31OIk/VQj36qdgjzc4Rk2HwMar25nOu/ts2gf7PrbhU4XQdrtnt74g4fTmSMp8xeyjzHqa9adDXVjISw==", + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/@clerk/clerk-js/-/clerk-js-6.23.0.tgz", + "integrity": "sha512-rf1gyf9KAeK9cSzkW0Y5BH0Vdb3deLhpMXsiT9uD9bg4CquMM064diLrW5VZrQs/o+RBOI6Uxg4DCyVn5CRV2Q==", "license": "MIT", "dependencies": { "@base-org/account": "2.0.1", - "@clerk/shared": "^4.15.0", + "@clerk/shared": "^4.23.0", "@coinbase/wallet-sdk": "4.3.7", "@solana/wallet-adapter-base": "0.9.27", "@solana/wallet-adapter-react": "0.15.39", @@ -1642,9 +1642,9 @@ } }, "node_modules/@clerk/clerk-js/node_modules/@clerk/shared": { - "version": "4.15.0", - "resolved": "https://registry.npmjs.org/@clerk/shared/-/shared-4.15.0.tgz", - "integrity": "sha512-uX8nfLb69m8mA6KWKWfuPSwoVNDRyUdufeCeTEZsdZxbRUsEYT/c0KWFN28IOQCtK09tpVtzrUHvW44v5Dc5OA==", + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/@clerk/shared/-/shared-4.23.0.tgz", + "integrity": "sha512-v4roxB9AwEVq56luLc9MtQ+RkVR66XZJGAfTbQXD0ArdW+fs1P/FhlKwm6OQfvDFNiCCVZA6GoP648t9dGtfrw==", "license": "MIT", "dependencies": { "@tanstack/query-core": "^5.100.6", @@ -1702,14 +1702,14 @@ "peer": true }, "node_modules/@clerk/expo": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@clerk/expo/-/expo-3.3.1.tgz", - "integrity": "sha512-c4g64z5sgJoGYjK0NeasNwOMy9Di7cEjICq56BHSowdOuB+6UGtWBNw+yHzgS1gxi2kJgl7WQCmmXRsoZNWxAg==", + "version": "3.6.5", + "resolved": "https://registry.npmjs.org/@clerk/expo/-/expo-3.6.5.tgz", + "integrity": "sha512-iOxcdYwvH8Ymme2o0ZgjZ61rqs3ulVVSUDaJ8Nkhjd5PYscwUdNcHVezK4PG6ACJUNNEsjUlZo+lKyPUu/ARZQ==", "license": "MIT", "dependencies": { - "@clerk/clerk-js": "^6.14.0", - "@clerk/react": "^6.7.3", - "@clerk/shared": "^4.15.0", + "@clerk/clerk-js": "^6.23.0", + "@clerk/react": "^6.11.3", + "@clerk/shared": "^4.23.0", "base-64": "^1.0.0", "react-native-url-polyfill": "2.0.0", "tslib": "2.8.1" @@ -1729,7 +1729,7 @@ "expo-web-browser": ">=12.5.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", - "react-native": ">=0.73" + "react-native": ">=0.75" }, "peerDependenciesMeta": { "@clerk/expo-passkeys": { @@ -1755,16 +1755,19 @@ }, "expo-web-browser": { "optional": true + }, + "react-dom": { + "optional": true } } }, "node_modules/@clerk/expo/node_modules/@clerk/react": { - "version": "6.7.3", - "resolved": "https://registry.npmjs.org/@clerk/react/-/react-6.7.3.tgz", - "integrity": "sha512-xdml8bFXbOQ/Egyp7iI1f0ksLjw5nYu2Db+mttHpJzet7PRXQ3jBEEc2c0AYhOJvIYxJifHGBsf75NM8SwlOag==", + "version": "6.11.3", + "resolved": "https://registry.npmjs.org/@clerk/react/-/react-6.11.3.tgz", + "integrity": "sha512-MLtclctwVoB4ECMJvl5ZH48rxLPduVsybZS2YnnljHH1rvxRV67cOAmInafgYgt2/LH63SVwRPIrTabbSx6lFQ==", "license": "MIT", "dependencies": { - "@clerk/shared": "^4.15.0", + "@clerk/shared": "^4.23.0", "tslib": "2.8.1" }, "engines": { @@ -1776,9 +1779,9 @@ } }, "node_modules/@clerk/expo/node_modules/@clerk/shared": { - "version": "4.15.0", - "resolved": "https://registry.npmjs.org/@clerk/shared/-/shared-4.15.0.tgz", - "integrity": "sha512-uX8nfLb69m8mA6KWKWfuPSwoVNDRyUdufeCeTEZsdZxbRUsEYT/c0KWFN28IOQCtK09tpVtzrUHvW44v5Dc5OA==", + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/@clerk/shared/-/shared-4.23.0.tgz", + "integrity": "sha512-v4roxB9AwEVq56luLc9MtQ+RkVR66XZJGAfTbQXD0ArdW+fs1P/FhlKwm6OQfvDFNiCCVZA6GoP648t9dGtfrw==", "license": "MIT", "dependencies": { "@tanstack/query-core": "^5.100.6", diff --git a/package.json b/package.json index ca1c7a1..80ea4b6 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "lint": "expo lint" }, "dependencies": { - "@clerk/expo": "^3.3.1", + "@clerk/expo": "3.6.5", "@expo/vector-icons": "^15.0.3", "@react-native-async-storage/async-storage": "2.2.0", "@react-native-community/netinfo": "11.4.1", diff --git a/supabase/functions/_shared/buildReportSourceSnapshot.ts b/supabase/functions/_shared/buildReportSourceSnapshot.ts new file mode 100644 index 0000000..346945f --- /dev/null +++ b/supabase/functions/_shared/buildReportSourceSnapshot.ts @@ -0,0 +1,50 @@ +import type { + JournalEntryRow, + MoodLogRow, +} from "./buildReportAnalytics.ts"; + +export async function buildReportSourceSnapshot( + entries: JournalEntryRow[], + moodLogs: MoodLogRow[], +) { + const snapshotInput = buildReportSourceSnapshotInput(entries, moodLogs); + const latestUpdatedAt = + [...entries, ...moodLogs] + .map((source) => source.updated_at) + .filter(Boolean) + .sort() + .at(-1) ?? null; + const bytes = new TextEncoder().encode(snapshotInput); + const digest = await crypto.subtle.digest("SHA-256", bytes); + const hash = Array.from(new Uint8Array(digest)) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); + + return { hash, latestUpdatedAt }; +} + +export function buildReportSourceSnapshotInput( + entries: JournalEntryRow[], + moodLogs: MoodLogRow[], +) { + return [ + ...entries.map( + (entry) => + `entry:${entry.id}:${normalizeReportSourceTimestamp(entry.updated_at)}`, + ), + ...moodLogs.map( + (moodLog) => + `mood:${moodLog.id}:${normalizeReportSourceTimestamp(moodLog.updated_at)}`, + ), + ] + .sort() + .join("|"); +} + +function normalizeReportSourceTimestamp(timestamp: string) { + const parsedTimestamp = Date.parse(timestamp); + + return Number.isFinite(parsedTimestamp) + ? new Date(parsedTimestamp).toISOString() + : timestamp.trim(); +} diff --git a/supabase/functions/generate-insight-report/index.ts b/supabase/functions/generate-insight-report/index.ts index e1e4fc5..d61c0a6 100644 --- a/supabase/functions/generate-insight-report/index.ts +++ b/supabase/functions/generate-insight-report/index.ts @@ -6,6 +6,7 @@ import { type MoodLogRow, type ReportAnalytics, } from "../_shared/buildReportAnalytics.ts"; +import { buildReportSourceSnapshot } from "../_shared/buildReportSourceSnapshot.ts"; import { parseReportNarrative, type ReportNarrative, @@ -83,6 +84,7 @@ const maxTitleLength = 200; const maxPromptLength = 300; const maxTagsPerEntry = 10; const maxNarrativeAttempts = 2; +const maxNarrativeTokens = 2200; const reportFormatVersion = 4; const validMoodIds = new Set([ "anxious", @@ -269,7 +271,7 @@ Deno.serve(async (request) => { periodStart, timezone: reportRequest.timezone, }); - const source = await buildSourceSnapshot(entries, moodLogs); + const source = await buildReportSourceSnapshot(entries, moodLogs); const existingReportResult = await fetchExistingReport({ periodEnd: reportRequest.periodEnd, periodStart: reportRequest.periodStart, @@ -657,29 +659,6 @@ async function fetchExistingReport({ }; } -async function buildSourceSnapshot( - entries: JournalEntryRow[], - moodLogs: MoodLogRow[], -) { - const sortedParts = [ - ...entries.map((entry) => `entry:${entry.id}:${entry.updated_at}`), - ...moodLogs.map((moodLog) => `mood:${moodLog.id}:${moodLog.updated_at}`), - ].sort(); - const latestUpdatedAt = - [...entries, ...moodLogs] - .map((source) => source.updated_at) - .filter(Boolean) - .sort() - .at(-1) ?? null; - const bytes = new TextEncoder().encode(sortedParts.join("|")); - const digest = await crypto.subtle.digest("SHA-256", bytes); - const hash = Array.from(new Uint8Array(digest)) - .map((byte) => byte.toString(16).padStart(2, "0")) - .join(""); - - return { hash, latestUpdatedAt }; -} - function buildNarrativePrompt({ analytics, entries, @@ -720,7 +699,10 @@ Return valid JSON with exactly this shape: "nextFocus": "string", "reflectionPrompt": "string or null", "dataQualityNote": "string or null" -}`; +} + +Keep the overview and emotionalJourney to at most two concise sentences each. +Keep every array to at most four concise items.`; } function formatEntryForPrompt(entry: JournalEntryRow) { @@ -742,9 +724,26 @@ async function generateValidNarrative( analytics: ReportAnalytics, ) { for (let attempt = 1; attempt <= maxNarrativeAttempts; attempt += 1) { - const rawNarrative = await callAIProvider( - attempt === 1 ? prompt : buildNarrativeRetryPrompt(prompt), - ); + let rawNarrative: string; + + try { + rawNarrative = await callAIProvider( + attempt === 1 ? prompt : buildNarrativeRetryPrompt(prompt), + ); + } catch (error) { + if ( + error instanceof AIProviderError && + error.code === "provider_response_truncated" && + attempt < maxNarrativeAttempts + ) { + console.warn("generate-insight-report truncated_ai_response", { + attempt, + }); + continue; + } + + throw error; + } const parsed = parseReportNarrative( rawNarrative, analytics.dataWasCapped, @@ -802,7 +801,7 @@ async function callAIProvider(finalPrompt: string) { { content: systemPrompt, role: "system" }, { content: finalPrompt, role: "user" }, ], - max_tokens: 1400, + max_tokens: maxNarrativeTokens, model, temperature: 0.45, }), diff --git a/tests/report-source-snapshot.test.mjs b/tests/report-source-snapshot.test.mjs new file mode 100644 index 0000000..013cc8b --- /dev/null +++ b/tests/report-source-snapshot.test.mjs @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; + +import { buildReportSourceSnapshotInput as buildClientSnapshotInput } from "../lib/insights/reportSourceSnapshot.ts"; +import { buildReportSourceSnapshotInput as buildServerSnapshotInput } from "../supabase/functions/_shared/buildReportSourceSnapshot.ts"; + +const clientInput = buildClientSnapshotInput( + [ + { + id: "entry-b", + updatedAt: "2026-07-04T17:47:54.069Z", + }, + { + id: "entry-a", + updatedAt: "2026-07-04T17:40:00.000Z", + }, + ], + [ + { + id: "mood-a", + updatedAt: "2026-07-04T17:42:00.000Z", + }, + ], +); + +const serverInput = buildServerSnapshotInput( + [ + { + content: "", + created_at: "2026-07-04T10:00:00+00:00", + id: "entry-a", + mood: null, + prompt: null, + tags: [], + title: "", + type: "free_write", + updated_at: "2026-07-04T17:40:00+00:00", + }, + { + content: "", + created_at: "2026-07-04T11:00:00+00:00", + id: "entry-b", + mood: null, + prompt: null, + tags: [], + title: "", + type: "free_write", + updated_at: "2026-07-04T17:47:54.069+00:00", + }, + ], + [ + { + created_at: "2026-07-04T12:00:00+00:00", + id: "mood-a", + mood: "calm", + updated_at: "2026-07-04T17:42:00+00:00", + }, + ], +); + +assert.equal( + clientInput, + serverInput, + "Client and server snapshots should normalize equivalent timestamps identically.", +); + +assert.equal( + clientInput, + [ + "entry:entry-a:2026-07-04T17:40:00.000Z", + "entry:entry-b:2026-07-04T17:47:54.069Z", + "mood:mood-a:2026-07-04T17:42:00.000Z", + ].join("|"), + "Snapshot sources should be sorted deterministically.", +); From cfb7fc125983b5a62ca33aab09fa674ed29945a3 Mon Sep 17 00:00:00 2001 From: aryansoni-dev Date: Sun, 5 Jul 2026 00:30:50 +0530 Subject: [PATCH 2/2] [+] Implemented code-rabbit's suggested patches. --- app.json | 3 ++- hooks/useAIInsightReport.ts | 6 ++--- .../generate-insight-report/index.ts | 24 ++++++++++++++++--- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/app.json b/app.json index 56ea15b..c8a966b 100644 --- a/app.json +++ b/app.json @@ -9,6 +9,7 @@ "userInterfaceStyle": "automatic", "newArchEnabled": true, "ios": { + "buildNumber": "2", "supportsTablet": true }, "android": { @@ -20,7 +21,7 @@ ], "icon": "./assets/images/icon.png", "package": "com.aryan.deardiary", - "versionCode": 1, + "versionCode": 2, "adaptiveIcon": { "backgroundColor": "#FFDDE8", "foregroundImage": "./assets/images/icon.png" diff --git a/hooks/useAIInsightReport.ts b/hooks/useAIInsightReport.ts index 0a77107..d59d921 100644 --- a/hooks/useAIInsightReport.ts +++ b/hooks/useAIInsightReport.ts @@ -179,8 +179,6 @@ export function useAIInsightReport( }, []); const refresh = useCallback(async () => { - lastFailedOperationRef.current = "refresh"; - if (!enabled) { return; } @@ -212,6 +210,7 @@ export function useAIInsightReport( const requestVersion = requestContextVersionRef.current; setIsLoading(true); setError(null); + lastFailedOperationRef.current = "refresh"; try { const result = await fetchAIInsightReport({ period }); @@ -286,8 +285,6 @@ export function useAIInsightReport( const requestGeneration = useCallback( async (regenerate: boolean) => { - lastFailedOperationRef.current = regenerate ? "regenerate" : "generate"; - if (!enabled) { return; } @@ -325,6 +322,7 @@ export function useAIInsightReport( requestInFlightRef.current = true; setIsGenerating(true); setError(null); + lastFailedOperationRef.current = regenerate ? "regenerate" : "generate"; try { const sourcesAreSynced = await syncPeriodSourcesBeforeGeneration({ diff --git a/supabase/functions/generate-insight-report/index.ts b/supabase/functions/generate-insight-report/index.ts index d61c0a6..ece1dfb 100644 --- a/supabase/functions/generate-insight-report/index.ts +++ b/supabase/functions/generate-insight-report/index.ts @@ -723,13 +723,19 @@ async function generateValidNarrative( prompt: string, analytics: ReportAnalytics, ) { + let retryReason: "parse_failure" | "truncated" | null = null; + for (let attempt = 1; attempt <= maxNarrativeAttempts; attempt += 1) { let rawNarrative: string; + const attemptPrompt = + retryReason === "truncated" + ? buildNarrativeTruncationRetryPrompt(prompt) + : retryReason === "parse_failure" + ? buildNarrativeRetryPrompt(prompt) + : prompt; try { - rawNarrative = await callAIProvider( - attempt === 1 ? prompt : buildNarrativeRetryPrompt(prompt), - ); + rawNarrative = await callAIProvider(attemptPrompt); } catch (error) { if ( error instanceof AIProviderError && @@ -739,6 +745,7 @@ async function generateValidNarrative( console.warn("generate-insight-report truncated_ai_response", { attempt, }); + retryReason = "truncated"; continue; } @@ -758,6 +765,7 @@ async function generateValidNarrative( characterCount: rawNarrative.length, reason: parsed.reason, }); + retryReason = "parse_failure"; } throw new AIProviderError( @@ -775,6 +783,16 @@ Keep every narrative field complete and preserve its full detail. Use null instead of an empty string for optional fields.`; } +function buildNarrativeTruncationRetryPrompt(prompt: string) { + return `${prompt} + +Your previous response was cut off because it was too long. +Return one complete JSON object with every requested key. +Stay within the original two-sentence and four-item caps above. +Use shorter wording and fewer items where the journal evidence allows. +Do not add commentary or markdown.`; +} + async function callAIProvider(finalPrompt: string) { const apiKey = Deno.env.get("AI_API_KEY") ??