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..c8a966b 100644
--- a/app.json
+++ b/app.json
@@ -2,13 +2,14 @@
"expo": {
"name": "DearDiary",
"slug": "dear-diary",
- "version": "1.0.0",
+ "version": "1.0.2",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "deardiary",
"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/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..d59d921 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(
@@ -204,6 +210,7 @@ export function useAIInsightReport(
const requestVersion = requestContextVersionRef.current;
setIsLoading(true);
setError(null);
+ lastFailedOperationRef.current = "refresh";
try {
const result = await fetchAIInsightReport({ period });
@@ -229,6 +236,8 @@ export function useAIInsightReport(
setReport(null);
removeCachedReport(userId, cacheKey);
}
+
+ lastFailedOperationRef.current = null;
} catch (refreshError) {
if (!isCurrentRequestContext(requestVersion)) {
return;
@@ -313,6 +322,7 @@ export function useAIInsightReport(
requestInFlightRef.current = true;
setIsGenerating(true);
setError(null);
+ lastFailedOperationRef.current = regenerate ? "regenerate" : "generate";
try {
const sourcesAreSynced = await syncPeriodSourcesBeforeGeneration({
@@ -349,6 +359,7 @@ export function useAIInsightReport(
setReport(generatedReport);
setLegacyReportAvailable(false);
setCachedReport(userId, cacheKey, generatedReport);
+ lastFailedOperationRef.current = null;
} catch (generationError) {
if (!isCurrentRequestContext(requestVersion)) {
return;
@@ -392,6 +403,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 +426,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);
}
- await runAutoSync("journal_change");
+ if (moodLogIds.length > 0) {
+ useMoodLogStore.getState().markMoodLogsPendingSync(userId, moodLogIds);
+ }
+
+ for (let attempt = 0; attempt < 2; attempt += 1) {
+ await runAutoSync("journal_change");
- return !hasUnsyncedPeriodSources({ period, userId });
+ if (!hasUnsyncedPeriodSources({ period, userId })) {
+ return true;
+ }
+ }
+
+ return false;
}
function hasUnsyncedPeriodSources({
@@ -425,6 +473,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 +485,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 +497,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 +534,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 +632,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..ece1dfb 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) {
@@ -741,10 +723,34 @@ async function generateValidNarrative(
prompt: string,
analytics: ReportAnalytics,
) {
+ let retryReason: "parse_failure" | "truncated" | null = null;
+
for (let attempt = 1; attempt <= maxNarrativeAttempts; attempt += 1) {
- const rawNarrative = await callAIProvider(
- attempt === 1 ? prompt : buildNarrativeRetryPrompt(prompt),
- );
+ let rawNarrative: string;
+ const attemptPrompt =
+ retryReason === "truncated"
+ ? buildNarrativeTruncationRetryPrompt(prompt)
+ : retryReason === "parse_failure"
+ ? buildNarrativeRetryPrompt(prompt)
+ : prompt;
+
+ try {
+ rawNarrative = await callAIProvider(attemptPrompt);
+ } catch (error) {
+ if (
+ error instanceof AIProviderError &&
+ error.code === "provider_response_truncated" &&
+ attempt < maxNarrativeAttempts
+ ) {
+ console.warn("generate-insight-report truncated_ai_response", {
+ attempt,
+ });
+ retryReason = "truncated";
+ continue;
+ }
+
+ throw error;
+ }
const parsed = parseReportNarrative(
rawNarrative,
analytics.dataWasCapped,
@@ -759,6 +765,7 @@ async function generateValidNarrative(
characterCount: rawNarrative.length,
reason: parsed.reason,
});
+ retryReason = "parse_failure";
}
throw new AIProviderError(
@@ -776,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") ??
@@ -802,7 +819,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.",
+);