From 19155b24c002f083638b9fc3ef0c51af0f365b3d Mon Sep 17 00:00:00 2001 From: Sai Kumar Date: Wed, 15 Jul 2026 17:07:30 +1000 Subject: [PATCH 1/4] Enable Reanimated + gesture-handler; animate onboarding Phase 0 (enablement, proven on device): - Add babel.config.js (babel-preset-expo -> react-native-worklets plugin) so Reanimated worklets actually run; add babel-preset-expo devDep. - Wrap the app root in GestureHandlerRootView. - Rule learned: never put a NativeWind className on an Animated.View (NativeWind v5 clobbers the style prop); animated views use inline styles, classes go on inner Views. Phase 1 (onboarding animation pass, zero new runtime deps): - New in-house motion helper (components/motion): FadeInUp entrance + PressableScale press feedback, over Reanimated. - Onboarding: keyed per-step Reanimated FadeIn page transitions; guide bubble + intro text spring/stagger in; goal chips cascade; primary CTAs press-scale. Kept AnimatedCounter / ProgressBar / SVG celebration. Further motion polish (Home/cards/sheets, swipe-to-cancel, gesture sheets, calendar-from-bottom) deferred to the UX-redesign phase. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/_layout.tsx | 29 +++--- app/onboarding.tsx | 140 +++++++++++++------------- babel.config.js | 10 ++ components/motion/index.tsx | 70 +++++++++++++ components/onboarding/GuideBubble.tsx | 57 +++-------- package-lock.json | 105 +++++++++---------- package.json | 1 + 7 files changed, 237 insertions(+), 175 deletions(-) create mode 100644 babel.config.js create mode 100644 components/motion/index.tsx diff --git a/app/_layout.tsx b/app/_layout.tsx index 1c953df..d24fbdd 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -3,6 +3,7 @@ import { useFonts } from "expo-font"; import * as Notifications from "expo-notifications"; import { SplashScreen, Stack, useRouter } from "expo-router"; import { useEffect, useRef } from "react"; +import { GestureHandlerRootView } from "react-native-gesture-handler"; import { ClerkProvider, useAuth, useUser } from "@clerk/expo"; import { tokenCache } from "@clerk/expo/token-cache"; @@ -139,18 +140,20 @@ export default function RootLayout() { } return ( - - - - - - - - - - + + + + + + + + + + + + ); } diff --git a/app/onboarding.tsx b/app/onboarding.tsx index af4a677..a6c182e 100644 --- a/app/onboarding.tsx +++ b/app/onboarding.tsx @@ -1,4 +1,5 @@ import AnimatedCounter from "@/components/AnimatedCounter"; +import { FadeInUp, PressableScale } from "@/components/motion"; import CelebrationOverlay from "@/components/onboarding/CelebrationOverlay"; import GuideBubble from "@/components/onboarding/GuideBubble"; import ProgressBar from "@/components/onboarding/ProgressBar"; @@ -37,6 +38,7 @@ import { View, } from "react-native"; import { SafeAreaView as RNSafeAreaView } from "react-native-safe-area-context"; +import Reanimated, { FadeIn } from "react-native-reanimated"; const SafeAreaView = styled(RNSafeAreaView) as any; @@ -140,23 +142,6 @@ const Onboarding = () => { const cycleFor = (title: string): BillingCycle => cycles[title] ?? "monthly"; - // Fade + spring the active step in whenever `step` changes. - const fade = useRef(new Animated.Value(1)).current; - const slide = useRef(new Animated.Value(0)).current; - useEffect(() => { - fade.setValue(0); - slide.setValue(24); - Animated.parallel([ - Animated.timing(fade, { toValue: 1, duration: 240, useNativeDriver: true }), - Animated.spring(slide, { - toValue: 0, - friction: 7, - tension: 60, - useNativeDriver: true, - }), - ]).start(); - }, [step, fade, slide]); - // The "analyzing" anticipation beat: cycle lines, then reveal the celebration. useEffect(() => { if (step !== "analyzing") return; @@ -274,33 +259,43 @@ const Onboarding = () => { )} - {step === "intro" && ( - - - - Recurrly - SMART BILLING + + + + + Recurrly + SMART BILLING + - - - See what you're really paying for. - - - No bank login. Your data stays on your phone. - + + + + See what you're really paying for. + + + + + No bank login. Your data stays on your phone. + + - setStep("goal")}> - Let's go - + setStep("goal")}> + + Let's go + + )} @@ -309,21 +304,22 @@ const Onboarding = () => { - {GOALS.map((g) => ( - selectGoal(g.key)} - className={clsx( - "rounded-2xl border p-4", - goal === g.key - ? "border-accent bg-accent/10" - : "border-border bg-card", - )} - > - - {g.label} - - + {GOALS.map((g, i) => ( + + selectGoal(g.key)} + className={clsx( + "rounded-2xl border p-4", + goal === g.key + ? "border-accent bg-accent/10" + : "border-border bg-card", + )} + > + + {g.label} + + + ))} @@ -354,9 +350,11 @@ const Onboarding = () => { - setStep("pick")}> - Continue - + setStep("pick")}> + + Continue + + )} @@ -396,13 +394,15 @@ const Onboarding = () => { ); })} - - - {selectedBrands.length > 0 - ? `Continue with ${selectedBrands.length}` - : "Continue"} - - + + + + {selectedBrands.length > 0 + ? `Continue with ${selectedBrands.length}` + : "Continue"} + + + Skip for now @@ -474,11 +474,13 @@ const Onboarding = () => { ))} - - - Add {addableCount} subscription{addableCount === 1 ? "" : "s"} - - + + + + Add {addableCount} subscription{addableCount === 1 ? "" : "s"} + + + Edit or remove anything later. Just tap a subscription. @@ -501,7 +503,7 @@ const Onboarding = () => { )} - + {step === "done" && ( ; +}) => ( + + {children} + +); + +/** + * A Pressable whose content springs down slightly while pressed — the subtle + * tactile feedback every primary button should have. Wrap styled children + * (e.g. a View with the `auth-button` class) so the whole control scales. + */ +export const PressableScale = ({ + children, + onPress, + disabled, + style, +}: { + children: ReactNode; + onPress?: () => void; + disabled?: boolean; + style?: StyleProp; +}) => { + const scale = useSharedValue(1); + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ scale: scale.value }], + })); + + return ( + { + scale.value = withSpring(0.96, { damping: 15, stiffness: 220 }); + }} + onPressOut={() => { + scale.value = withSpring(1, { damping: 12, stiffness: 180 }); + }} + > + {children} + + ); +}; diff --git a/components/onboarding/GuideBubble.tsx b/components/onboarding/GuideBubble.tsx index 9961d1a..3f1b34d 100644 --- a/components/onboarding/GuideBubble.tsx +++ b/components/onboarding/GuideBubble.tsx @@ -1,49 +1,24 @@ import "@/global.css"; -import { useEffect, useRef } from "react"; -import { Animated, Text, View } from "react-native"; +import { Text, View } from "react-native"; +import Animated, { FadeInDown } from "react-native-reanimated"; /** * The branded "guide" presence: a circular brand mark + a speech bubble with a - * single short line. Fades/slides in whenever the line changes, so each step of - * onboarding feels like the guide speaking. No mascot art — restyled later with - * the Midnight Ledger rebrand. - * - * Styling lives on inner Views; the Animated.View only carries the transform - * (NativeWind v5-preview doesn't reliably apply className to Animated.View). + * single short line. Re-keyed by `text` so it springs in fresh on each step. + * No className on the Animated.View (NativeWind clobbers it) — styling lives on + * the inner Views. Restyled later with the Midnight Ledger rebrand. */ -const GuideBubble = ({ text }: { text: string }) => { - const opacity = useRef(new Animated.Value(0)).current; - const translateY = useRef(new Animated.Value(8)).current; - - useEffect(() => { - opacity.setValue(0); - translateY.setValue(8); - Animated.parallel([ - Animated.timing(opacity, { - toValue: 1, - duration: 260, - useNativeDriver: true, - }), - Animated.timing(translateY, { - toValue: 0, - duration: 260, - useNativeDriver: true, - }), - ]).start(); - }, [text, opacity, translateY]); - - return ( - - - - R - - - {text} - +const GuideBubble = ({ text }: { text: string }) => ( + + + + R + + + {text} - - ); -}; + + +); export default GuideBubble; diff --git a/package-lock.json b/package-lock.json index f09bad3..c340b02 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", + "babel-preset-expo": "~54.0.10", "clsx": "^2.1.1", "dayjs": "^1.11.21", "expo": "~54.0.34", @@ -9775,6 +9776,58 @@ "@babel/core": "^7.0.0 || ^8.0.0-0" } }, + "node_modules/babel-preset-expo": { + "version": "54.0.11", + "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-54.0.11.tgz", + "integrity": "sha512-dEpeFDtYEFzmWtWVwvt7sUCZH0fxXPfbJlgXd7XNZSQDa/Ki/hTOj9exMTzqR2oyPHDNcE9VxYCJ4oS6xw4Pjg==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.25.9", + "@babel/plugin-proposal-decorators": "^7.12.9", + "@babel/plugin-proposal-export-default-from": "^7.24.7", + "@babel/plugin-syntax-export-default-from": "^7.24.7", + "@babel/plugin-transform-class-static-block": "^7.27.1", + "@babel/plugin-transform-export-namespace-from": "^7.25.9", + "@babel/plugin-transform-flow-strip-types": "^7.25.2", + "@babel/plugin-transform-modules-commonjs": "^7.24.8", + "@babel/plugin-transform-object-rest-spread": "^7.24.7", + "@babel/plugin-transform-parameters": "^7.24.7", + "@babel/plugin-transform-private-methods": "^7.24.7", + "@babel/plugin-transform-private-property-in-object": "^7.24.7", + "@babel/plugin-transform-runtime": "^7.24.7", + "@babel/preset-react": "^7.22.15", + "@babel/preset-typescript": "^7.23.0", + "@react-native/babel-preset": "0.81.5", + "babel-plugin-react-compiler": "^1.0.0", + "babel-plugin-react-native-web": "~0.21.0", + "babel-plugin-syntax-hermes-parser": "^0.29.1", + "babel-plugin-transform-flow-enums": "^0.0.2", + "debug": "^4.3.4", + "resolve-from": "^5.0.0" + }, + "peerDependencies": { + "@babel/runtime": "^7.20.0", + "expo": "*", + "react-refresh": ">=0.14.0 <1.0.0" + }, + "peerDependenciesMeta": { + "@babel/runtime": { + "optional": true + }, + "expo": { + "optional": true + } + } + }, + "node_modules/babel-preset-expo/node_modules/babel-plugin-react-compiler": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz", + "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.26.0" + } + }, "node_modules/babel-preset-jest": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", @@ -13195,58 +13248,6 @@ "node": ">= 0.6" } }, - "node_modules/expo/node_modules/babel-plugin-react-compiler": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz", - "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", - "license": "MIT", - "dependencies": { - "@babel/types": "^7.26.0" - } - }, - "node_modules/expo/node_modules/babel-preset-expo": { - "version": "54.0.11", - "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-54.0.11.tgz", - "integrity": "sha512-dEpeFDtYEFzmWtWVwvt7sUCZH0fxXPfbJlgXd7XNZSQDa/Ki/hTOj9exMTzqR2oyPHDNcE9VxYCJ4oS6xw4Pjg==", - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.25.9", - "@babel/plugin-proposal-decorators": "^7.12.9", - "@babel/plugin-proposal-export-default-from": "^7.24.7", - "@babel/plugin-syntax-export-default-from": "^7.24.7", - "@babel/plugin-transform-class-static-block": "^7.27.1", - "@babel/plugin-transform-export-namespace-from": "^7.25.9", - "@babel/plugin-transform-flow-strip-types": "^7.25.2", - "@babel/plugin-transform-modules-commonjs": "^7.24.8", - "@babel/plugin-transform-object-rest-spread": "^7.24.7", - "@babel/plugin-transform-parameters": "^7.24.7", - "@babel/plugin-transform-private-methods": "^7.24.7", - "@babel/plugin-transform-private-property-in-object": "^7.24.7", - "@babel/plugin-transform-runtime": "^7.24.7", - "@babel/preset-react": "^7.22.15", - "@babel/preset-typescript": "^7.23.0", - "@react-native/babel-preset": "0.81.5", - "babel-plugin-react-compiler": "^1.0.0", - "babel-plugin-react-native-web": "~0.21.0", - "babel-plugin-syntax-hermes-parser": "^0.29.1", - "babel-plugin-transform-flow-enums": "^0.0.2", - "debug": "^4.3.4", - "resolve-from": "^5.0.0" - }, - "peerDependencies": { - "@babel/runtime": "^7.20.0", - "expo": "*", - "react-refresh": ">=0.14.0 <1.0.0" - }, - "peerDependenciesMeta": { - "@babel/runtime": { - "optional": true - }, - "expo": { - "optional": true - } - } - }, "node_modules/expo/node_modules/brace-expansion": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", diff --git a/package.json b/package.json index 5c1226e..2877cb4 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", + "babel-preset-expo": "~54.0.10", "clsx": "^2.1.1", "dayjs": "^1.11.21", "expo": "~54.0.34", From c3c80e11b51783121cd6631949e0f1512241412d Mon Sep 17 00:00:00 2001 From: Sai Kumar Date: Thu, 16 Jul 2026 13:37:39 +1000 Subject: [PATCH 2/4] "Complete your subscription" nudge: confirm renewal date Quick-add assumes today's date, so renewals are guessed wrong. Add a dateAssumed flag (migration v2, additive) set on quick-add and cleared when the user saves the edit form. Surface it as an animated, honest completion loop: - pulsing "Confirm date" dot on cards; Home nudge card ("N need a renewal date -> Review"); detail banner -> edit with the date field glowing; payment shown as optional; success celebration when the last one is confirmed. - Broaden onboarding category inference so fewer services fall back to "Other" (category is auto-assigned, not nudged). - New migration-runner + rowToSubscription mapping tests (established the DB test harness via factory-mocked native modules). Co-Authored-By: Claude Opus 4.8 (1M context) --- __tests__/migrations.test.ts | 106 +++++++++++++++++++++++++++ app/(tabs)/index.tsx | 39 ++++++++++ app/onboarding.tsx | 3 + app/subscriptions/[id].tsx | 78 +++++++++++++++++++- components/PulsingDot.tsx | 52 +++++++++++++ components/SubscriptionCard.tsx | 13 ++++ components/SubscriptionFormModal.tsx | 31 ++++++-- constants/onboardingBrands.ts | 90 ++++++++++++++++++++++- db/migrations.ts | 10 +++ db/subscriptionsRepo.ts | 10 ++- type.d.ts | 4 + 11 files changed, 424 insertions(+), 12 deletions(-) create mode 100644 __tests__/migrations.test.ts create mode 100644 components/PulsingDot.tsx diff --git a/__tests__/migrations.test.ts b/__tests__/migrations.test.ts new file mode 100644 index 0000000..039b29d --- /dev/null +++ b/__tests__/migrations.test.ts @@ -0,0 +1,106 @@ +// Native modules are mocked with factories (never loading the real ones, which +// pull unresolvable native deps under jest) so importing the db layer is safe — +// migrateIfNeeded takes an injected db, and rowToSubscription is pure. +jest.mock("expo-sqlite", () => ({ openDatabaseSync: jest.fn() })); +jest.mock("expo-crypto", () => ({ randomUUID: jest.fn(() => "test-id") })); + +import { migrateIfNeeded } from "@/db/database"; +import { MIGRATIONS } from "@/db/migrations"; +import { rowToSubscription, type SubscriptionRow } from "@/db/subscriptionsRepo"; + +/** Minimal fake of the SQLiteDatabase surface migrateIfNeeded uses. */ +const makeFakeDb = (startVersion: number) => { + let version = startVersion; + const executed: string[] = []; + const db = { + getFirstSync: (query: string) => + query.includes("user_version") ? { user_version: version } : null, + withTransactionSync: (fn: () => void) => fn(), + execSync: (sql: string) => { + executed.push(sql); + const match = sql.match(/PRAGMA user_version = (\d+)/); + if (match) version = Number(match[1]); + }, + }; + return { db, executed, version: () => version }; +}; + +const run = (start: number) => { + const fake = makeFakeDb(start); + migrateIfNeeded(fake.db as unknown as Parameters[0]); + return fake; +}; + +const latest = MIGRATIONS[MIGRATIONS.length - 1].version; + +describe("MIGRATIONS list", () => { + it("is append-only: strictly ascending versions", () => { + for (let i = 1; i < MIGRATIONS.length; i++) { + expect(MIGRATIONS[i].version).toBeGreaterThan(MIGRATIONS[i - 1].version); + } + }); + + it("adds the date_assumed column at v2", () => { + const v2 = MIGRATIONS.find((m) => m.version === 2); + expect(v2?.sql).toMatch(/date_assumed/); + expect(latest).toBe(2); + }); +}); + +describe("migrateIfNeeded", () => { + it("runs every migration on a fresh db and bumps to the latest version", () => { + const fake = run(0); + expect(fake.version()).toBe(latest); + expect(fake.executed.some((s) => s.includes("CREATE TABLE"))).toBe(true); + expect(fake.executed.some((s) => s.includes("date_assumed"))).toBe(true); + }); + + it("runs only pending migrations when already at v1", () => { + const fake = run(1); + expect(fake.version()).toBe(2); + // v1 (CREATE TABLE) is skipped; v2 (ALTER) runs. + expect(fake.executed.some((s) => s.includes("CREATE TABLE"))).toBe(false); + expect(fake.executed.some((s) => s.includes("date_assumed"))).toBe(true); + }); + + it("is a no-op when already at the latest version", () => { + const fake = run(latest); + expect(fake.executed).toHaveLength(0); + expect(fake.version()).toBe(latest); + }); +}); + +describe("rowToSubscription date_assumed mapping", () => { + const baseRow: SubscriptionRow = { + id: "1", + name: "Netflix", + icon_key: null, + color: null, + plan: null, + category: null, + payment_method: null, + notes: null, + status: "active", + price: 15.49, + currency: "USD", + billing_cycle: "monthly", + custom_interval_days: null, + is_trial: 0, + date_assumed: 0, + trial_end_date: null, + start_date: null, + next_renewal_date: null, + cancelled_at: null, + paused_at: null, + created_at: "2026-01-01T00:00:00.000Z", + updated_at: "2026-01-01T00:00:00.000Z", + deleted_at: null, + }; + + it("maps 0 -> false and 1 -> true", () => { + expect(rowToSubscription(baseRow).dateAssumed).toBe(false); + expect(rowToSubscription({ ...baseRow, date_assumed: 1 }).dateAssumed).toBe( + true, + ); + }); +}); diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index b2d0036..e5e48f4 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,4 +1,6 @@ import AnimatedCounter from "@/components/AnimatedCounter"; +import { FadeInUp, PressableScale } from "@/components/motion"; +import PulsingDot from "@/components/PulsingDot"; import ListHeading from "@/components/ListHeading"; import SubscriptionFormModal from "@/components/SubscriptionFormModal"; import UpcomingSubscriptionCard from "@/components/UpcomingSubscriptionCard"; @@ -47,6 +49,13 @@ export default function App() { [subscriptions], ); + // Active subs whose renewal date is still an onboarding assumption — nudge the + // user to confirm them so reminders are accurate. + const assumedCount = useMemo( + () => activeSubscriptions.filter((sub) => sub.dateAssumed).length, + [activeSubscriptions], + ); + // One-time first-run nudge: gently pulse the "+" until the first sub is added. const [addPulse] = useState(() => new Animated.Value(1)); const nudgeActive = showAddNudge && activeSubscriptions.length === 0; @@ -242,6 +251,36 @@ export default function App() { } /> + {assumedCount > 0 && ( + + router.push("/subscriptions")}> + + + + + {assumedCount} subscription + {assumedCount === 1 ? "" : "s"} need a renewal date + + + Confirm them so reminders land on the right day. + + + + Review › + + + + + )} {subscriptions.length === 0 ? ( diff --git a/app/onboarding.tsx b/app/onboarding.tsx index a6c182e..40bb709 100644 --- a/app/onboarding.tsx +++ b/app/onboarding.tsx @@ -230,6 +230,9 @@ const Onboarding = () => { status: "active", startDate: now, renewalDate: resolveNextRenewal(now, cycle)?.toISOString() ?? undefined, + // Quick-add assumes "today" as the start; flag so we nudge the user to + // confirm the real renewal date for accurate reminders. + dateAssumed: true, }); count += 1; } diff --git a/app/subscriptions/[id].tsx b/app/subscriptions/[id].tsx index 35a4d37..183232a 100644 --- a/app/subscriptions/[id].tsx +++ b/app/subscriptions/[id].tsx @@ -1,6 +1,9 @@ +import { FadeInUp, PressableScale } from "@/components/motion"; +import PulsingDot from "@/components/PulsingDot"; import SubscriptionFormModal from "@/components/SubscriptionFormModal"; import SubscriptionIcon from "@/components/SubscriptionIcon"; import { icons } from "@/constants/icons"; +import { success } from "@/lib/haptics"; import { useCurrency } from "@/context/CurrencyContext"; import { useSubscriptions } from "@/context/SubscriptionsContext"; import "@/global.css"; @@ -38,6 +41,7 @@ const SubscriptionDetail = () => { const posthog = usePostHog(); const { baseCurrency } = useCurrency(); const { + subscriptions, getSubscription, updateSubscription, deleteSubscription, @@ -46,6 +50,8 @@ const SubscriptionDetail = () => { cancelSubscription, } = useSubscriptions(); const [isEditVisible, setEditVisible] = useState(false); + const [highlightDate, setHighlightDate] = useState(false); + const [justCompleted, setJustCompleted] = useState(false); const goBack = () => { if (router.canGoBack()) { @@ -113,8 +119,22 @@ const SubscriptionDetail = () => { posthog.capture(event, { subscription_id: subscription.id }); const handleEdit = (draft: SubscriptionDraft) => { + const wasAssumed = !!subscription.dateAssumed; updateSubscription(subscription.id, draft); captureStatus("subscription_updated"); + // Confirming a quick-add date is a small win; celebrate, and if it was the + // last one still assumed, mark the whole set as accurate. + if (wasAssumed && draft.dateAssumed === false) { + success(); + const remaining = subscriptions.filter( + (s) => + s.status === "active" && s.dateAssumed && s.id !== subscription.id, + ).length; + if (remaining === 0) { + setJustCompleted(true); + setTimeout(() => setJustCompleted(false), 2600); + } + } }; const handlePauseResume = () => { @@ -176,6 +196,17 @@ const SubscriptionDetail = () => { contentContainerClassName="p-5 pb-30" showsVerticalScrollIndicator={false} > + {justCompleted && ( + + + + + All set — your reminders are now accurate. + + + + )} + {/* Hero */} { )} + {subscription.dateAssumed && isActive && ( + + { + setHighlightDate(true); + setEditVisible(true); + }} + > + + + + + Confirm your renewal date + + + So reminders land on the right day. Add a payment method too + (optional). + + + + Fix › + + + + + )} + {/* Details */} @@ -259,7 +326,10 @@ const SubscriptionDetail = () => { setEditVisible(true)} + onPress={() => { + setHighlightDate(false); + setEditVisible(true); + }} > Edit @@ -300,9 +370,13 @@ const SubscriptionDetail = () => { setEditVisible(false)} + onClose={() => { + setEditVisible(false); + setHighlightDate(false); + }} onSubmit={handleEdit} subscription={subscription} + highlightDate={highlightDate} /> ); diff --git a/components/PulsingDot.tsx b/components/PulsingDot.tsx new file mode 100644 index 0000000..ed0069a --- /dev/null +++ b/components/PulsingDot.tsx @@ -0,0 +1,52 @@ +import { useEffect } from "react"; +import Animated, { + Easing, + useAnimatedStyle, + useSharedValue, + withRepeat, + withTiming, +} from "react-native-reanimated"; + +/** + * A small dot that gently pulses (scale + opacity) to draw attention to an + * item that needs action — e.g. a subscription whose renewal date is still + * assumed. Inline styles only (NativeWind clobbers className on Animated.View). + */ +const PulsingDot = ({ + size = 8, + color = "#E0952F", +}: { + size?: number; + color?: string; +}) => { + const p = useSharedValue(0); + + useEffect(() => { + p.value = withRepeat( + withTiming(1, { duration: 900, easing: Easing.inOut(Easing.quad) }), + -1, + true, + ); + }, [p]); + + const style = useAnimatedStyle(() => ({ + opacity: 0.5 + p.value * 0.5, + transform: [{ scale: 0.85 + p.value * 0.35 }], + })); + + return ( + + ); +}; + +export default PulsingDot; diff --git a/components/SubscriptionCard.tsx b/components/SubscriptionCard.tsx index 1ec309c..e865aad 100644 --- a/components/SubscriptionCard.tsx +++ b/components/SubscriptionCard.tsx @@ -1,3 +1,4 @@ +import PulsingDot from "@/components/PulsingDot"; import SubscriptionIcon from "@/components/SubscriptionIcon"; import { useCurrency } from "@/context/CurrencyContext"; import { getDaysUntilRenewal } from "@/lib/billing"; @@ -32,6 +33,7 @@ const SubscriptionCard = ({ status, billingCycle, customIntervalDays, + dateAssumed, }: SubscriptionCardProps) => { const { baseCurrency } = useCurrency(); const fallback = "Not provided"; @@ -84,6 +86,17 @@ const SubscriptionCard = ({ > {metaText} + {dateAssumed && isActive && !expanded ? ( + + + + Confirm date + + + ) : null} diff --git a/components/SubscriptionFormModal.tsx b/components/SubscriptionFormModal.tsx index 3f1b6de..f20584d 100644 --- a/components/SubscriptionFormModal.tsx +++ b/components/SubscriptionFormModal.tsx @@ -61,6 +61,7 @@ const SubscriptionFormModal = ({ onClose, onSubmit, subscription, + highlightDate, }: SubscriptionFormModalProps) => { const isEdit = !!subscription; const { baseCurrency } = useCurrency(); @@ -166,6 +167,9 @@ const SubscriptionFormModal = ({ startDate: startIso, renewalDate: nextRenewal?.toISOString() ?? dayjs().add(1, "month").toISOString(), + // The user picked/confirmed the date here, so it's no longer an + // assumption — clears any quick-add nudge flag. + dateAssumed: false, isTrial, trialEndDate: isTrial ? dayjs().add(parsedTrialDays, "day").toISOString() @@ -290,7 +294,7 @@ const SubscriptionFormModal = ({ - Payment + Payment (optional) - + Started on {Platform.OS === "ios" ? ( // Compact themed pill: opens a popover and dismisses itself @@ -338,9 +355,13 @@ const SubscriptionFormModal = ({ )} )} - - When you first subscribed. We use it to work out your next - renewal. + + {highlightDate + ? "Set the real date this renews so your reminder is accurate." + : "When you first subscribed. We use it to work out your next renewal."} diff --git a/constants/onboardingBrands.ts b/constants/onboardingBrands.ts index e544ddb..e69db46 100644 --- a/constants/onboardingBrands.ts +++ b/constants/onboardingBrands.ts @@ -70,8 +70,94 @@ const DEFAULTS: Record = { const FALLBACK = { price: 9.99, category: "Other" }; +// Category buckets inferred from a brand's title + icon keywords when it isn't +// in DEFAULTS — so far fewer services fall back to "Other". Ordered by +// specificity (AI first); needles are phrases/words chosen to avoid false hits. +const CATEGORY_KEYWORDS: [string, string[]][] = [ + [ + "AI Tools", + [ + "artificial intelligence", + "large language model", + "llm", + "chatbot", + "generative", + "machine learning", + ], + ], + ["Music", ["music", "podcast", "audiobook"]], + [ + "Entertainment", + [ + "streaming", + "video", + "movie", + "television", + "gaming", + "video game", + "anime", + "sports", + ], + ], + [ + "Developer Tools", + [ + "developer", + "version control", + "git", + "devops", + "hosting", + "deployment", + "continuous integration", + "database", + "programming", + ], + ], + ["Design", ["graphic design", "design tool", "prototyping", "photo editing"]], + [ + "Cloud", + [ + "cloud storage", + "file storage", + "backup", + "vpn", + "password manager", + "cybersecurity", + "file hosting", + ], + ], + [ + "Productivity", + [ + "productivity", + "note-taking", + "word processor", + "spreadsheet", + "online learning", + "e-learning", + "collaboration", + "project management", + "email", + ], + ], +]; + +const inferCategory = (icon: { title: string; keywords: string[] }): string => { + const haystack = [icon.title, ...icon.keywords].join(" ").toLowerCase(); + for (const [category, needles] of CATEGORY_KEYWORDS) { + if (needles.some((n) => haystack.includes(n))) return category; + } + return FALLBACK.category; +}; + // One tile per available brand icon, in the curated icon order (popular first). +// Known brands use their DEFAULTS preset; the rest infer a category from +// keywords so onboarding auto-assigns a sensible category per service. export const ONBOARDING_BRANDS: OnboardingBrand[] = BRAND_ICONS.map((icon) => { - const preset = DEFAULTS[icon.title.toLowerCase()] ?? FALLBACK; - return { title: icon.title, price: preset.price, category: preset.category }; + const preset = DEFAULTS[icon.title.toLowerCase()]; + return { + title: icon.title, + price: preset?.price ?? FALLBACK.price, + category: preset?.category ?? inferCategory(icon), + }; }); diff --git a/db/migrations.ts b/db/migrations.ts index 3ceea61..f93977a 100644 --- a/db/migrations.ts +++ b/db/migrations.ts @@ -48,4 +48,14 @@ export const MIGRATIONS: Migration[] = [ ); `, }, + { + // Flags subs whose renewal date was assumed by quick-add onboarding (so we + // can nudge the user to confirm it for accurate reminders). Additive and + // non-destructive: existing rows default to 0 (not flagged). + version: 2, + sql: ` + ALTER TABLE subscriptions + ADD COLUMN date_assumed INTEGER NOT NULL DEFAULT 0; + `, + }, ]; diff --git a/db/subscriptionsRepo.ts b/db/subscriptionsRepo.ts index cbe144d..c390408 100644 --- a/db/subscriptionsRepo.ts +++ b/db/subscriptionsRepo.ts @@ -19,6 +19,7 @@ export interface SubscriptionRow { billing_cycle: string; custom_interval_days: number | null; is_trial: number; + date_assumed: number; trial_end_date: string | null; start_date: string | null; next_renewal_date: string | null; @@ -50,6 +51,7 @@ export const rowToSubscription = (row: SubscriptionRow): Subscription => { customIntervalDays: row.custom_interval_days ?? undefined, billing: getCycleLabel(billingCycle, row.custom_interval_days ?? undefined), isTrial: row.is_trial === 1, + dateAssumed: row.date_assumed === 1, trialEndDate: row.trial_end_date ?? undefined, startDate: row.start_date ?? undefined, renewalDate: row.next_renewal_date ?? undefined, @@ -86,9 +88,9 @@ export const insertSubscription = (input: NewSubscription): Subscription => { `INSERT INTO subscriptions ( id, name, color, plan, category, payment_method, notes, status, price, currency, billing_cycle, custom_interval_days, - is_trial, trial_end_date, start_date, next_renewal_date, + is_trial, date_assumed, trial_end_date, start_date, next_renewal_date, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ id, input.name, @@ -103,6 +105,7 @@ export const insertSubscription = (input: NewSubscription): Subscription => { billingCycle, input.customIntervalDays ?? null, input.isTrial ? 1 : 0, + input.dateAssumed ? 1 : 0, input.trialEndDate ?? null, input.startDate ?? null, input.renewalDate ?? null, @@ -128,6 +131,7 @@ const PATCH_COLUMNS: Record = { billingCycle: "billing_cycle", customIntervalDays: "custom_interval_days", isTrial: "is_trial", + dateAssumed: "date_assumed", trialEndDate: "trial_end_date", startDate: "start_date", renewalDate: "next_renewal_date", @@ -146,7 +150,7 @@ export const updateSubscription = ( if (!(key in patch)) continue; const raw = (patch as Record)[key]; assignments.push(`${column} = ?`); - if (key === "isTrial") { + if (key === "isTrial" || key === "dateAssumed") { values.push(raw ? 1 : 0); } else { values.push((raw as string | number | null | undefined) ?? null); diff --git a/type.d.ts b/type.d.ts index 9e100c3..79245a9 100644 --- a/type.d.ts +++ b/type.d.ts @@ -42,6 +42,8 @@ declare global { color?: string; isTrial?: boolean; trialEndDate?: string; + /** True when the renewal date was assumed by quick-add (not user-confirmed). */ + dateAssumed?: boolean; notes?: string; createdAt?: string; updatedAt?: string; @@ -63,6 +65,8 @@ declare global { onSubmit: (draft: SubscriptionDraft) => void; /** When set, the modal is in edit mode and prefills from this. */ subscription?: Subscription | null; + /** Emphasise the start-date field (opened from a "confirm date" nudge). */ + highlightDate?: boolean; } interface UpcomingSubscription { From a6b0421ccd299c5e620fef390c245c11d2b9625f Mon Sep 17 00:00:00 2001 From: Sai Kumar Date: Thu, 16 Jul 2026 13:38:01 +1000 Subject: [PATCH 3/4] Fix renewal math: a charge due today shows as today resolveNextRenewal rolled to the first occurrence strictly after today, so a charge landing on today was treated as already paid and skipped to the next cycle (e.g. Netflix started Jun 16 showed Aug 16 instead of Jul 16). Since we don't link to payment platforms we can't assume today's charge was taken, so roll forward only past dates strictly before today. Updated the test that encoded the old behavior + added a due-today case. Co-Authored-By: Claude Opus 4.8 (1M context) --- __tests__/billing.test.ts | 16 +++++++++++++--- lib/billing.ts | 10 ++++++---- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/__tests__/billing.test.ts b/__tests__/billing.test.ts index 5a8c953..3957860 100644 --- a/__tests__/billing.test.ts +++ b/__tests__/billing.test.ts @@ -167,9 +167,19 @@ describe("next renewal across all cycles (existing sub, started in the past)", ( it("monthly rolls past the passed 07-08 to 08-08", () => { expect(fmt("monthly")).toBe("2026-08-08"); }); - it("weekly rolls to the next future weekly hit", () => { - // Jun 8 + 7*n: …Jul 6, Jul 13(=today, not after)→ Jul 20 - expect(fmt("weekly")).toBe("2026-07-20"); + it("weekly: a hit that lands on today stays today (not silently rolled)", () => { + // Jun 8 + 7n: … Jul 6, Jul 13 (= today). Due today — we don't assume it was + // paid (no payment link), so it stays today rather than rolling to Jul 20. + expect(fmt("weekly")).toBe("2026-07-13"); + }); + + it("monthly: a charge due today shows as today, not next month", () => { + // start Jun 13, today Jul 13 → the Jul 13 charge is due today. + expect( + resolveNextRenewal("2026-06-13T00:00:00.000Z", "monthly")?.format( + "YYYY-MM-DD", + ), + ).toBe("2026-07-13"); }); it("biweekly", () => { // Jun 8, Jun 22, Jul 6, Jul 20 diff --git a/lib/billing.ts b/lib/billing.ts index 162992f..6de68de 100644 --- a/lib/billing.ts +++ b/lib/billing.ts @@ -85,8 +85,10 @@ export const getNextRenewal = ( * Resolves the next renewal date from a subscription's START (first billing) * date, honoring what the user actually entered: * - Future start date → the first charge is that start date itself. - * - Today or past start date → the first occurrence strictly after today - * (a sub started today renews one interval out; an old sub rolls forward). + * - Today or past start date → the start is a charge that already happened, so + * the next charge is one interval on, rolled forward past any dates strictly + * before today. A charge due **today stays today** — we don't link to + * payments, so we can't assume today's charge was taken; we surface it. * * This is what the form stores as `renewalDate`; `getNextRenewal` is the * lighter primitive used elsewhere to keep a stored date fresh over time. @@ -103,8 +105,8 @@ export const resolveNextRenewal = ( const today = dayjs().startOf("day"); if (start.isAfter(today)) return start; - let next = start; - while (!next.isAfter(today)) { + let next = addInterval(start, cycle, customIntervalDays); + while (next.isBefore(today)) { next = addInterval(next, cycle, customIntervalDays); } return next; From a09ad29a1fd3a56abe3a99b1f2bbf4b6051db6bc Mon Sep 17 00:00:00 2001 From: Sai Kumar Date: Thu, 16 Jul 2026 13:38:08 +1000 Subject: [PATCH 4/4] Fix guest dead-end: add Back affordance to sign-in/sign-up AuthLayout hides the header and the screens had no back control, so a guest who tapped Sign in was trapped (no header, no iOS hardware back). Add a "Back" button that returns to the app (router.back, else replace /). Co-Authored-By: Claude Opus 4.8 (1M context) --- app/(auth)/sign-in.tsx | 16 ++++++++++++++++ app/(auth)/sign-up.tsx | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/app/(auth)/sign-in.tsx b/app/(auth)/sign-in.tsx index 16b241f..7ecf5ca 100644 --- a/app/(auth)/sign-in.tsx +++ b/app/(auth)/sign-in.tsx @@ -141,6 +141,22 @@ export default function SignIn() { > + + router.canGoBack() ? router.back() : router.replace("/") + } + hitSlop={8} + accessibilityRole="button" + accessibilityLabel="Back to app" + > + + ‹ + + + Back + + diff --git a/app/(auth)/sign-up.tsx b/app/(auth)/sign-up.tsx index be84e18..8de4881 100644 --- a/app/(auth)/sign-up.tsx +++ b/app/(auth)/sign-up.tsx @@ -122,6 +122,22 @@ export default function SignUp() { > + + router.canGoBack() ? router.back() : router.replace("/") + } + hitSlop={8} + accessibilityRole="button" + accessibilityLabel="Back to app" + > + + ‹ + + + Back + +