From bb212760c9d449b8fd0089a34bf1aaf5783e33c0 Mon Sep 17 00:00:00 2001 From: Sai Kumar Date: Thu, 16 Jul 2026 15:21:32 +1000 Subject: [PATCH 01/14] Fix duplicate-CTA visibility + refresh Home/list on focus - "Delete this one" used solid bg-destructive + text-white, but solid bg-destructive doesn't render under NativeWind v5-preview (only the /10 opacity variant does), so the white text was invisible. Use an explicit inline red background + white text. - Home nudge counts (review / duplicates / confirm-date) and the Subscriptions list didn't reflect actions taken on the pushed detail screen until relaunch. Expose refresh() from the context and re-pull on screen focus (useFocusEffect) so counts update at runtime. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/(tabs)/index.tsx | 14 +++++++++++--- app/(tabs)/subscriptions.tsx | 13 ++++++++++--- app/subscriptions/[id].tsx | 10 ++++++++-- context/SubscriptionsContext.tsx | 4 ++++ 4 files changed, 33 insertions(+), 8 deletions(-) diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index cbe12d5..c5b28b5 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -22,7 +22,7 @@ import { formatCurrency } from "@/lib/utils"; import { useClerk, useUser } from "@clerk/expo"; import { styled } from "nativewind"; import { usePostHog } from "posthog-react-native"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Animated, FlatList, @@ -32,7 +32,7 @@ import { Text, View, } from "react-native"; -import { useRouter } from "expo-router"; +import { useFocusEffect, useRouter } from "expo-router"; import { SafeAreaView as RNSafeAreaView } from "react-native-safe-area-context"; const SafeAreaView = styled(RNSafeAreaView) as any; @@ -40,7 +40,15 @@ const SafeAreaView = styled(RNSafeAreaView) as any; export default function App() { const { user, isSignedIn } = useUser(); const { signOut } = useClerk(); - const { subscriptions, addSubscription } = useSubscriptions(); + const { subscriptions, addSubscription, refresh } = useSubscriptions(); + + // Re-pull from the DB whenever Home regains focus, so nudge counts reflect + // actions taken on the detail screen (confirm renewal, delete duplicate, etc). + useFocusEffect( + useCallback(() => { + refresh(); + }, [refresh]), + ); const { baseCurrency } = useCurrency(); const posthog = usePostHog(); const router = useRouter(); diff --git a/app/(tabs)/subscriptions.tsx b/app/(tabs)/subscriptions.tsx index 13c398c..3981262 100644 --- a/app/(tabs)/subscriptions.tsx +++ b/app/(tabs)/subscriptions.tsx @@ -3,9 +3,9 @@ import SubscriptionCard from "@/components/SubscriptionCard"; import { useSubscriptions } from "@/context/SubscriptionsContext"; import "@/global.css"; import { duplicateActiveNames, normalizeName } from "@/lib/duplicates"; -import { useRouter } from "expo-router"; +import { useFocusEffect, useRouter } from "expo-router"; import { styled } from "nativewind"; -import { useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { FlatList, @@ -21,10 +21,17 @@ const SafeAreaView = styled(RNSafeAreaView) as any; const StyledKeyboardAvoidingView = styled(KeyboardAvoidingView) as any; const Subscriptions = () => { - const { subscriptions } = useSubscriptions(); + const { subscriptions, refresh } = useSubscriptions(); const router = useRouter(); const [query, setQuery] = useState(""); + // Reflect actions taken on the detail screen (delete, cancel, confirm). + useFocusEffect( + useCallback(() => { + refresh(); + }, [refresh]), + ); + const duplicateNames = useMemo( () => duplicateActiveNames(subscriptions), [subscriptions], diff --git a/app/subscriptions/[id].tsx b/app/subscriptions/[id].tsx index 9a2f83d..224e5ba 100644 --- a/app/subscriptions/[id].tsx +++ b/app/subscriptions/[id].tsx @@ -254,8 +254,14 @@ const SubscriptionDetail = () => { this one was added by mistake, remove it. - - + + Delete this one diff --git a/context/SubscriptionsContext.tsx b/context/SubscriptionsContext.tsx index 22cc4b2..6b18604 100644 --- a/context/SubscriptionsContext.tsx +++ b/context/SubscriptionsContext.tsx @@ -34,6 +34,8 @@ interface SubscriptionsContextValue { resumeSubscription: (id: string) => Subscription | null; cancelSubscription: (id: string) => Subscription | null; getSubscription: (id: string) => Subscription | undefined; + /** Re-read all subscriptions from the DB (e.g. on screen focus). */ + refresh: () => void; /** Wipes all subscriptions from the device (delete-all / dev reset). */ clearAllData: () => void; } @@ -190,6 +192,7 @@ export const SubscriptionsProvider = ({ resumeSubscription, cancelSubscription, getSubscription, + refresh, clearAllData, }), [ @@ -202,6 +205,7 @@ export const SubscriptionsProvider = ({ resumeSubscription, cancelSubscription, getSubscription, + refresh, clearAllData, ], ); From 07a9fc85b52967b37be23f273318c0ff008ae33c Mon Sep 17 00:00:00 2001 From: Sai Kumar Date: Thu, 16 Jul 2026 15:31:09 +1000 Subject: [PATCH 02/14] Duplicate "Keep it": acknowledge intentional duplicates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A same-name sub can be legitimate (paying for a partner's or child's subscription), so the detail banner now offers a real "Keep it" beside "Delete this one": - "Keep it" sets a persisted duplicate_acknowledged flag (migration v4), which excludes the sub from duplicate detection — the banner, card chip, and Home nudge all stop flagging it. - Copy advises renaming (e.g. "Netflix for Sally") so identical rows don't confuse. - Relabel the delete confirmation's cancel from "Keep it" to "Cancel" so it isn't mistaken for the new acknowledge action. --- __tests__/migrations.test.ts | 8 ++++- app/subscriptions/[id].tsx | 58 ++++++++++++++++++++++-------------- db/migrations.ts | 10 +++++++ db/subscriptionsRepo.ts | 16 +++++++--- lib/duplicates.ts | 2 ++ type.d.ts | 2 ++ 6 files changed, 69 insertions(+), 27 deletions(-) diff --git a/__tests__/migrations.test.ts b/__tests__/migrations.test.ts index b560e05..87e860a 100644 --- a/__tests__/migrations.test.ts +++ b/__tests__/migrations.test.ts @@ -48,7 +48,12 @@ describe("MIGRATIONS list", () => { it("adds the confirmed_through column at v3", () => { const v3 = MIGRATIONS.find((m) => m.version === 3); expect(v3?.sql).toMatch(/confirmed_through/); - expect(latest).toBe(3); + }); + + it("adds the duplicate_acknowledged column at v4", () => { + const v4 = MIGRATIONS.find((m) => m.version === 4); + expect(v4?.sql).toMatch(/duplicate_acknowledged/); + expect(latest).toBe(4); }); }); @@ -96,6 +101,7 @@ describe("rowToSubscription date_assumed mapping", () => { is_trial: 0, date_assumed: 0, confirmed_through: null, + duplicate_acknowledged: 0, trial_end_date: null, start_date: null, next_renewal_date: null, diff --git a/app/subscriptions/[id].tsx b/app/subscriptions/[id].tsx index 224e5ba..b8adcad 100644 --- a/app/subscriptions/[id].tsx +++ b/app/subscriptions/[id].tsx @@ -8,7 +8,7 @@ import { useCurrency } from "@/context/CurrencyContext"; import { useSubscriptions } from "@/context/SubscriptionsContext"; import "@/global.css"; import { getCycleLabel, getNextRenewal, pendingRenewal } from "@/lib/billing"; -import { normalizeName } from "@/lib/duplicates"; +import { duplicateActiveNames, normalizeName } from "@/lib/duplicates"; import { formatCurrency, formatStatusLabel, @@ -128,13 +128,10 @@ const SubscriptionDetail = () => { ) : null; - // Another active sub shares this name → possible accidental duplicate. + // Another active, non-acknowledged sub shares this name → possible duplicate. const isDuplicate = - subscriptions.filter( - (s) => - s.status === "active" && - normalizeName(s.name) === normalizeName(subscription.name), - ).length > 1; + !subscription.duplicateAcknowledged && + duplicateActiveNames(subscriptions).has(normalizeName(subscription.name)); // Opaque id only — no subscription name in analytics. const captureStatus = (event: string) => @@ -173,6 +170,12 @@ const SubscriptionDetail = () => { captureStatus("subscription_cancelled"); }; + const handleKeepDuplicate = () => { + // It's intentional (a partner's / child's) — stop flagging it as a dup. + updateSubscription(subscription.id, { duplicateAcknowledged: true }); + captureStatus("duplicate_kept"); + }; + const handlePauseResume = () => { if (isPaused) { resumeSubscription(subscription.id); @@ -211,7 +214,7 @@ const SubscriptionDetail = () => { "Delete subscription?", `${subscription.name} will be removed from all lists.`, [ - { text: "Keep it", style: "cancel" }, + { text: "Cancel", style: "cancel" }, { text: "Delete", style: "destructive", @@ -250,22 +253,33 @@ const SubscriptionDetail = () => { Possible duplicate - You're tracking another active “{subscription.name}”. If - this one was added by mistake, remove it. + You already track another active “{subscription.name}”. If it's + a separate one — a partner's or child's, say — keep it + and rename it (e.g. “{subscription.name} for Sally”) to tell them + apart. If it was a mistake, delete it. - - - + + + + Keep it + + + + + - Delete this one - - - + + Delete this one + + + + )} diff --git a/db/migrations.ts b/db/migrations.ts index 57c2a4c..d0f4e3d 100644 --- a/db/migrations.ts +++ b/db/migrations.ts @@ -68,4 +68,14 @@ export const MIGRATIONS: Migration[] = [ ADD COLUMN confirmed_through TEXT; `, }, + { + // Set when the user confirms a same-name sub is intentional (a partner's / + // child's), so we stop flagging it as a possible duplicate. Additive; + // existing rows default to 0 (still eligible for duplicate flagging). + version: 4, + sql: ` + ALTER TABLE subscriptions + ADD COLUMN duplicate_acknowledged INTEGER NOT NULL DEFAULT 0; + `, + }, ]; diff --git a/db/subscriptionsRepo.ts b/db/subscriptionsRepo.ts index ef79381..257fa44 100644 --- a/db/subscriptionsRepo.ts +++ b/db/subscriptionsRepo.ts @@ -21,6 +21,7 @@ export interface SubscriptionRow { is_trial: number; date_assumed: number; confirmed_through: string | null; + duplicate_acknowledged: number; trial_end_date: string | null; start_date: string | null; next_renewal_date: string | null; @@ -54,6 +55,7 @@ export const rowToSubscription = (row: SubscriptionRow): Subscription => { isTrial: row.is_trial === 1, dateAssumed: row.date_assumed === 1, confirmedThrough: row.confirmed_through ?? undefined, + duplicateAcknowledged: row.duplicate_acknowledged === 1, trialEndDate: row.trial_end_date ?? undefined, startDate: row.start_date ?? undefined, renewalDate: row.next_renewal_date ?? undefined, @@ -90,9 +92,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, date_assumed, confirmed_through, trial_end_date, start_date, - next_renewal_date, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + is_trial, date_assumed, confirmed_through, duplicate_acknowledged, + trial_end_date, start_date, next_renewal_date, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ id, input.name, @@ -109,6 +111,7 @@ export const insertSubscription = (input: NewSubscription): Subscription => { input.isTrial ? 1 : 0, input.dateAssumed ? 1 : 0, input.confirmedThrough ?? null, + input.duplicateAcknowledged ? 1 : 0, input.trialEndDate ?? null, input.startDate ?? null, input.renewalDate ?? null, @@ -136,6 +139,7 @@ const PATCH_COLUMNS: Record = { isTrial: "is_trial", dateAssumed: "date_assumed", confirmedThrough: "confirmed_through", + duplicateAcknowledged: "duplicate_acknowledged", trialEndDate: "trial_end_date", startDate: "start_date", renewalDate: "next_renewal_date", @@ -154,7 +158,11 @@ export const updateSubscription = ( if (!(key in patch)) continue; const raw = (patch as Record)[key]; assignments.push(`${column} = ?`); - if (key === "isTrial" || key === "dateAssumed") { + if ( + key === "isTrial" || + key === "dateAssumed" || + key === "duplicateAcknowledged" + ) { values.push(raw ? 1 : 0); } else { values.push((raw as string | number | null | undefined) ?? null); diff --git a/lib/duplicates.ts b/lib/duplicates.ts index 23213ce..668da8f 100644 --- a/lib/duplicates.ts +++ b/lib/duplicates.ts @@ -11,6 +11,8 @@ export const duplicateActiveNames = (subs: Subscription[]): Set => { const counts = new Map(); for (const s of subs) { if (s.status !== "active") continue; + // The user confirmed this same-name sub is intentional — don't count it. + if (s.duplicateAcknowledged) continue; const key = normalizeName(s.name); counts.set(key, (counts.get(key) ?? 0) + 1); } diff --git a/type.d.ts b/type.d.ts index 1e71b63..1c0dfdd 100644 --- a/type.d.ts +++ b/type.d.ts @@ -46,6 +46,8 @@ declare global { dateAssumed?: boolean; /** Latest billing occurrence the user has confirmed/we've assumed (ISO). */ confirmedThrough?: string; + /** User confirmed this same-name sub is intentional — stop flagging as dup. */ + duplicateAcknowledged?: boolean; notes?: string; createdAt?: string; updatedAt?: string; From 3eacb1b0dfaeeabab5ecf67854079f4ff953ad07 Mon Sep 17 00:00:00 2001 From: Sai Kumar Date: Thu, 16 Jul 2026 16:07:16 +1000 Subject: [PATCH 03/14] Fix "Yes, renewed" leaving the next-renewal date on today Confirming a renewal advanced confirmedThrough but not the stored renewalDate, so the detail's "Next renewal" stayed on the just-confirmed (today's) date. Also advance renewalDate to the following cycle. --- app/subscriptions/[id].tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/app/subscriptions/[id].tsx b/app/subscriptions/[id].tsx index b8adcad..3f5090f 100644 --- a/app/subscriptions/[id].tsx +++ b/app/subscriptions/[id].tsx @@ -7,7 +7,12 @@ import { success } from "@/lib/haptics"; import { useCurrency } from "@/context/CurrencyContext"; import { useSubscriptions } from "@/context/SubscriptionsContext"; import "@/global.css"; -import { getCycleLabel, getNextRenewal, pendingRenewal } from "@/lib/billing"; +import { + addInterval, + getCycleLabel, + getNextRenewal, + pendingRenewal, +} from "@/lib/billing"; import { duplicateActiveNames, normalizeName } from "@/lib/duplicates"; import { formatCurrency, @@ -158,8 +163,16 @@ const SubscriptionDetail = () => { const handleRenewed = () => { if (!pending) return; + // Advance BOTH: mark this occurrence confirmed, and move the next-renewal + // date to the following cycle so the detail shows the real next date. + const next = addInterval( + pending, + subscription.billingCycle ?? "monthly", + subscription.customIntervalDays, + ); updateSubscription(subscription.id, { confirmedThrough: pending.toISOString(), + renewalDate: next.toISOString(), }); captureStatus("renewal_confirmed"); success(); From afb7e8a480506691560baba9f9936582ab579873 Mon Sep 17 00:00:00 2001 From: Sai Kumar Date: Thu, 16 Jul 2026 16:11:11 +1000 Subject: [PATCH 04/14] Duplicate add flow: nudge rename first; "add anyway" acknowledges Per the agreed journey: the add-time duplicate confirm now makes "Rename it" the primary action and "Add it anyway" secondary. Choosing "Add it anyway" creates the sub with duplicateAcknowledged = true, so a decision the user made isn't re-flagged as a possible duplicate. Co-Authored-By: Claude Opus 4.8 (1M context) --- components/SubscriptionFormModal.tsx | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/components/SubscriptionFormModal.tsx b/components/SubscriptionFormModal.tsx index 88e98e4..c9dabf4 100644 --- a/components/SubscriptionFormModal.tsx +++ b/components/SubscriptionFormModal.tsx @@ -150,7 +150,7 @@ const SubscriptionFormModal = ({ customIntervalDays, ); - const commit = () => { + const commit = (acknowledgeDuplicate = false) => { const startIso = startDate.toISOString(); const nextRenewal = resolveNextRenewal( startIso, @@ -183,6 +183,13 @@ const SubscriptionFormModal = ({ color: category ? CATEGORY_COLORS[category] : DEFAULT_COLOR, }; + // "Add it anyway" past the duplicate warning = an explicit decision, so mark + // it acknowledged (never re-flagged as a duplicate). Only set on that path + // so normal adds/edits don't disturb an existing flag. + if (acknowledgeDuplicate) { + draft.duplicateAcknowledged = true; + } + onSubmit(draft); // Edits close immediately; a new add offers "add another" to keep momentum. if (isEdit) { @@ -264,24 +271,25 @@ const SubscriptionFormModal = ({ Some people have more than one — a partner's or a - child's, say. Add another anyway? Tip: rename it (e.g. - “{trimmedName} for Sally”) so you can tell them apart. + child's, say. Renaming it (e.g. “{trimmedName} for Sally”) + keeps them easy to tell apart. Or add it anyway — we won't + flag it again. { - setShowDuplicatePrompt(false); - commit(); - }} + onPress={() => setShowDuplicatePrompt(false)} > - Add anyway + Rename it setShowDuplicatePrompt(false)} + onPress={() => { + setShowDuplicatePrompt(false); + commit(true); + }} > - Go back & rename + Add it anyway From 76067165e0a883e9d20ef75f389158b3bed0e206 Mon Sep 17 00:00:00 2001 From: Sai Kumar Date: Thu, 16 Jul 2026 16:23:37 +1000 Subject: [PATCH 05/14] Card visuals + warning highlights + faster startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SubscriptionCard: soft, unique per-brand panel tint (colour-coding for scan/recall) plus a coloured left edge on cards needing attention (duplicate > confirm-date > renewed?), so warnings are findable in the list. - Detail hero now uses the same cardTint as the list card for consistency. - Home "Your subscriptions" card: overlapping brand-icon stack previews content and invites the tap; PressableScale for tactile feedback. - lib/brand: add tintColor() + cardTint() helpers. - Startup: hide the splash as soon as fonts load instead of waiting on Clerk auth (guest-first) — removes the visible menu delay on launch/reload. --- app/(tabs)/index.tsx | 50 +++++++++++++++++++++++---------- app/_layout.tsx | 10 ++++--- app/subscriptions/[id].tsx | 3 +- components/SubscriptionCard.tsx | 22 +++++++++++++-- lib/brand.ts | 19 +++++++++++++ 5 files changed, 81 insertions(+), 23 deletions(-) diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index c5b28b5..4556886 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -2,6 +2,7 @@ import AnimatedCounter from "@/components/AnimatedCounter"; import { FadeInUp, PressableScale } from "@/components/motion"; import PulsingDot from "@/components/PulsingDot"; import ListHeading from "@/components/ListHeading"; +import SubscriptionIcon from "@/components/SubscriptionIcon"; import SubscriptionFormModal from "@/components/SubscriptionFormModal"; import UpcomingSubscriptionCard from "@/components/UpcomingSubscriptionCard"; import { icons } from "@/constants/icons"; @@ -375,23 +376,42 @@ export default function App() { ) : ( - router.push("/subscriptions")} - className="flex-row items-center justify-between rounded-2xl border border-border bg-card p-4" - > - - - Your subscriptions - - - {activeSubscriptions.length} active · {subscriptions.length}{" "} - total + router.push("/subscriptions")}> + + + + {activeSubscriptions.slice(0, 4).map((sub, i) => ( + + + + ))} + + + + Your subscriptions + + + {activeSubscriptions.length} active ·{" "} + {subscriptions.length} total + + + + + › - - › - - + )} diff --git a/app/_layout.tsx b/app/_layout.tsx index d24fbdd..3dfb3fe 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -5,7 +5,7 @@ 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 { ClerkProvider, useUser } from "@clerk/expo"; import { tokenCache } from "@clerk/expo/token-cache"; import { PostHogProvider, usePostHog } from "posthog-react-native"; @@ -63,7 +63,6 @@ function PostHogUserIdentifier() { } function RootLayoutContent() { - const { isLoaded: authLoaded } = useAuth(); const router = useRouter(); // Tapping a reminder deep-links to that subscription. Only the live listener @@ -98,10 +97,13 @@ function RootLayoutContent() { if (fontError) { throw fontError; } - if (fontsLoaded && authLoaded) { + // Guest-first: reveal the UI as soon as fonts are ready — don't wait on + // Clerk auth (it resolves in the background; screens show the guest state + // and update when it loads). Avoids a visible startup delay. + if (fontsLoaded) { SplashScreen.hideAsync(); } - }, [fontsLoaded, fontError, authLoaded]); + }, [fontsLoaded, fontError]); if (!fontsLoaded) { return null; diff --git a/app/subscriptions/[id].tsx b/app/subscriptions/[id].tsx index 3f5090f..db59d90 100644 --- a/app/subscriptions/[id].tsx +++ b/app/subscriptions/[id].tsx @@ -13,6 +13,7 @@ import { getNextRenewal, pendingRenewal, } from "@/lib/billing"; +import { cardTint } from "@/lib/brand"; import { duplicateActiveNames, normalizeName } from "@/lib/duplicates"; import { formatCurrency, @@ -300,7 +301,7 @@ const SubscriptionDetail = () => { {/* Hero */} diff --git a/components/SubscriptionCard.tsx b/components/SubscriptionCard.tsx index 5fe9c0b..6963eb7 100644 --- a/components/SubscriptionCard.tsx +++ b/components/SubscriptionCard.tsx @@ -2,6 +2,7 @@ import PulsingDot from "@/components/PulsingDot"; import SubscriptionIcon from "@/components/SubscriptionIcon"; import { useCurrency } from "@/context/CurrencyContext"; import { getDaysUntilRenewal, pendingRenewal } from "@/lib/billing"; +import { cardTint } from "@/lib/brand"; import { formatCurrency, formatStatusLabel, @@ -22,7 +23,6 @@ const SubscriptionCard = ({ name, price, billing, - color, category, plan, renewalDate, @@ -69,6 +69,19 @@ const SubscriptionCard = ({ ? "Cancelled" : renewalCountdown(daysLeft); + // Each card gets a soft, unique wash of its brand colour so the list is + // scannable and memorable (colour-coding). A coloured left edge makes cards + // that need attention pop out pre-attentively (duplicate > confirm-date > + // renewed?). + const tint = cardTint(name); + const warningColor = isDuplicate + ? "#dc2626" + : dateAssumed && isActive + ? "#E0952F" + : pendingCheckin + ? "#EA7A53" + : null; + return ( [ - !expanded && color ? { backgroundColor: color } : null, - pressed && !expanded ? { opacity: 0.85 } : null, + !expanded ? { backgroundColor: tint } : null, + !expanded && warningColor + ? { borderLeftWidth: 5, borderLeftColor: warningColor } + : null, + pressed && !expanded ? { opacity: 0.9 } : null, ]} > diff --git a/lib/brand.ts b/lib/brand.ts index c1eeeec..730eddf 100644 --- a/lib/brand.ts +++ b/lib/brand.ts @@ -108,3 +108,22 @@ export const getIconVisual = (name: string): IconVisual => { monogram, }; }; + +/** Mixes a hex toward white by `whiteMix` (0–1) — for soft, tinted panels. */ +export const tintColor = (hex: string, whiteMix = 0.85): string => { + const clean = hex.replace("#", ""); + if (clean.length !== 6) return hex; + const ch = (i: number) => parseInt(clean.slice(i, i + 2), 16); + const mix = (c: number) => Math.round(c + (255 - c) * whiteMix); + const toHex = (v: number) => v.toString(16).padStart(2, "0"); + return `#${toHex(mix(ch(0)))}${toHex(mix(ch(2)))}${toHex(mix(ch(4)))}`; +}; + +/** + * A soft, distinct panel tint for a subscription — a light wash of its brand + * color. Gives every card its own recognisable color (Netflix pink-ish, + * Spotify green-ish), which aids scanning and recall (colour-coding), while + * staying subtle enough to read text on. + */ +export const cardTint = (name: string): string => + tintColor(getIconVisual(name).background); From 167483dc62057ff028d75efe55dfa82c6361510b Mon Sep 17 00:00:00 2001 From: Sai Kumar Date: Thu, 16 Jul 2026 16:30:31 +1000 Subject: [PATCH 06/14] Preload tab icons + neutral duplicate copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Preload tab-bar icon assets (expo-asset) and gate the splash-hide on them alongside fonts, so the whole bottom bar paints at once. Fixes the "pop-in" where only the active Home pill showed first and the other white glyphs appeared a frame later. - Rewrite the duplicate-warning copy (detail banner + add modal) to drop the presumptuous "a partner's or child's / for Sally" phrasing. Now frames the legitimate case as "a different plan or account" with a neutral rename example ("Netflix – work") — relevant to every user, not just families. --- app.json | 3 ++- app/_layout.tsx | 28 ++++++++++++++++++------- app/subscriptions/[id].tsx | 8 +++---- components/SubscriptionFormModal.tsx | 8 +++---- package-lock.json | 31 ++++++++++++++-------------- package.json | 1 + 6 files changed, 48 insertions(+), 31 deletions(-) diff --git a/app.json b/app.json index 870f555..156902e 100644 --- a/app.json +++ b/app.json @@ -58,7 +58,8 @@ ], "expo-localization", "expo-sqlite", - "@react-native-community/datetimepicker" + "@react-native-community/datetimepicker", + "expo-asset" ], "experiments": { "typedRoutes": true, diff --git a/app/_layout.tsx b/app/_layout.tsx index 3dfb3fe..3a181be 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,8 +1,10 @@ import "@/global.css"; +import { icons } from "@/constants/icons"; +import { Asset } from "expo-asset"; import { useFonts } from "expo-font"; import * as Notifications from "expo-notifications"; import { SplashScreen, Stack, useRouter } from "expo-router"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { ClerkProvider, useUser } from "@clerk/expo"; @@ -93,19 +95,31 @@ function RootLayoutContent() { "sans-light": require("../assets/fonts/PlusJakartaSans-Light.ttf"), }); + // Preload the tab-bar icons so the whole bar paints at once. Otherwise the + // white glyphs decode a frame after the navy bar, and only the active Home + // pill shows first — a visible "pop-in" of the rest of the menu. + const [iconsLoaded, setIconsLoaded] = useState(false); + useEffect(() => { + Asset.loadAsync(Object.values(icons)) + .catch(() => {}) + .finally(() => setIconsLoaded(true)); + }, []); + + const ready = fontsLoaded && iconsLoaded; + useEffect(() => { if (fontError) { throw fontError; } - // Guest-first: reveal the UI as soon as fonts are ready — don't wait on - // Clerk auth (it resolves in the background; screens show the guest state - // and update when it loads). Avoids a visible startup delay. - if (fontsLoaded) { + // Guest-first: reveal the UI as soon as fonts + tab icons are ready — don't + // wait on Clerk auth (it resolves in the background; screens show the guest + // state and update when it loads). Avoids a visible startup delay. + if (ready) { SplashScreen.hideAsync(); } - }, [fontsLoaded, fontError]); + }, [ready, fontError]); - if (!fontsLoaded) { + if (!ready) { return null; } diff --git a/app/subscriptions/[id].tsx b/app/subscriptions/[id].tsx index db59d90..32a253d 100644 --- a/app/subscriptions/[id].tsx +++ b/app/subscriptions/[id].tsx @@ -267,10 +267,10 @@ const SubscriptionDetail = () => { Possible duplicate - You already track another active “{subscription.name}”. If it's - a separate one — a partner's or child's, say — keep it - and rename it (e.g. “{subscription.name} for Sally”) to tell them - apart. If it was a mistake, delete it. + You already track another active “{subscription.name}”. If this + one's meant to be separate — a different plan or account — + keep it and rename it (e.g. “{subscription.name} – work”) so you + can tell them apart. If it was a mistake, delete it. diff --git a/components/SubscriptionFormModal.tsx b/components/SubscriptionFormModal.tsx index c9dabf4..5f640d9 100644 --- a/components/SubscriptionFormModal.tsx +++ b/components/SubscriptionFormModal.tsx @@ -270,10 +270,10 @@ const SubscriptionFormModal = ({ You already track {trimmedName} - Some people have more than one — a partner's or a - child's, say. Renaming it (e.g. “{trimmedName} for Sally”) - keeps them easy to tell apart. Or add it anyway — we won't - flag it again. + If this one's meant to be separate — a different plan or + account — rename it (e.g. “{trimmedName} – work”) so they're + easy to tell apart. Or add it anyway — we won't flag it + again. =8" } }, - "node_modules/expo/node_modules/expo-asset": { - "version": "12.0.13", - "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.13.tgz", - "integrity": "sha512-x/p7WvQUnkn6K43b9eL6SPeq5Vnf1E8BDe9bDrWrvMqzyUvJnUFvl+ctg3034s/+UHe7Ne2pAmc0+yzbl8CrDQ==", - "license": "MIT", - "dependencies": { - "@expo/image-utils": "^0.8.8", - "expo-constants": "~18.0.13" - }, - "peerDependencies": { - "expo": "*", - "react": "*", - "react-native": "*" - } - }, "node_modules/expo/node_modules/expo-keep-awake": { "version": "15.0.8", "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz", diff --git a/package.json b/package.json index 2877cb4..6fad1dd 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "dayjs": "^1.11.21", "expo": "~54.0.34", "expo-application": "~7.0.8", + "expo-asset": "~12.0.13", "expo-constants": "~18.0.13", "expo-crypto": "~15.0.9", "expo-dev-client": "~6.0.21", From ee763de8c269e2c4d1e0e09a901b4b3560acb8b3 Mon Sep 17 00:00:00 2001 From: Sai Kumar Date: Fri, 17 Jul 2026 14:10:44 +1000 Subject: [PATCH 07/14] Cancel celebration + shareable savings card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Celebrate NOT spending — the emotional payoff competitors skip. When a subscription is cancelled (from the detail action or the renewal check-in's "I cancelled"), a mint confetti burst + success haptic reveals what the user just saved, counting up the yearly figure. - components/Confetti.tsx: in-house falling-confetti burst on Reanimated (plain Animated.Views, no new animation library — per the animation-stack decision). - components/CancelCelebration.tsx: overlay with the count-up saving and a self-contained, inline-styled share card captured via react-native-view-shot and shared through expo-sharing (RN Share text fallback). This is the plan's day-one viral loop — every cancellation becomes a shareable stat. - Detail screen: cancelAndCelebrate() snapshots the monthly saving before the status flips, wired into both cancel paths. - Adds expo-sharing + react-native-view-shot. --- app/subscriptions/[id].tsx | 37 ++++- components/CancelCelebration.tsx | 241 +++++++++++++++++++++++++++++++ components/Confetti.tsx | 104 +++++++++++++ package-lock.json | 73 ++++++++++ package.json | 2 + 5 files changed, 452 insertions(+), 5 deletions(-) create mode 100644 components/CancelCelebration.tsx create mode 100644 components/Confetti.tsx diff --git a/app/subscriptions/[id].tsx b/app/subscriptions/[id].tsx index 32a253d..19b9727 100644 --- a/app/subscriptions/[id].tsx +++ b/app/subscriptions/[id].tsx @@ -1,3 +1,4 @@ +import CancelCelebration from "@/components/CancelCelebration"; import { FadeInUp, PressableScale } from "@/components/motion"; import PulsingDot from "@/components/PulsingDot"; import SubscriptionFormModal from "@/components/SubscriptionFormModal"; @@ -10,6 +11,7 @@ import "@/global.css"; import { addInterval, getCycleLabel, + getMonthlyEquivalent, getNextRenewal, pendingRenewal, } from "@/lib/billing"; @@ -60,6 +62,12 @@ const SubscriptionDetail = () => { const [highlightDate, setHighlightDate] = useState(false); const [justCompleted, setJustCompleted] = useState(false); const [checkinSnoozed, setCheckinSnoozed] = useState(false); + // Snapshot of the just-cancelled sub so the celebration survives the status + // change (and any re-render) while the overlay is up. + const [celebration, setCelebration] = useState<{ + name: string; + monthlySaved: number; + } | null>(null); const goBack = () => { if (router.canGoBack()) { @@ -179,11 +187,25 @@ const SubscriptionDetail = () => { success(); }; - const handleRenewalCancelled = () => { + // Cancel + celebrate: capture the monthly saving before the status flips, + // then reveal the "you just saved" moment (celebrate not spending). + const cancelAndCelebrate = () => { + setCelebration({ + name: subscription.name, + monthlySaved: getMonthlyEquivalent( + subscription.price, + subscription.billingCycle ?? "monthly", + subscription.customIntervalDays, + ), + }); cancelSubscription(subscription.id); captureStatus("subscription_cancelled"); }; + const handleRenewalCancelled = () => { + cancelAndCelebrate(); + }; + const handleKeepDuplicate = () => { // It's intentional (a partner's / child's) — stop flagging it as a dup. updateSubscription(subscription.id, { duplicateAcknowledged: true }); @@ -214,10 +236,7 @@ const SubscriptionDetail = () => { { text: "Cancel subscription", style: "destructive", - onPress: () => { - cancelSubscription(subscription.id); - captureStatus("subscription_cancelled"); - }, + onPress: cancelAndCelebrate, }, ], ); @@ -511,6 +530,14 @@ const SubscriptionDetail = () => { subscription={subscription} highlightDate={highlightDate} /> + + setCelebration(null)} + /> ); }; diff --git a/components/CancelCelebration.tsx b/components/CancelCelebration.tsx new file mode 100644 index 0000000..e0d4b92 --- /dev/null +++ b/components/CancelCelebration.tsx @@ -0,0 +1,241 @@ +import AnimatedCounter from "@/components/AnimatedCounter"; +import Confetti from "@/components/Confetti"; +import { FadeInUp, PressableScale } from "@/components/motion"; +import { success } from "@/lib/haptics"; +import { formatCurrency } from "@/lib/utils"; +import * as Sharing from "expo-sharing"; +import { useEffect, useRef } from "react"; +import { Modal, Share, Text, View } from "react-native"; +import { captureRef } from "react-native-view-shot"; + +const MINT = "#4ADE9C"; +const INK = "#0A0E1A"; +const SURFACE = "#131A2E"; +const TEXT = "#F2F5FA"; +const TEXT_DIM = "rgba(242,245,250,0.62)"; + +export interface CancelCelebrationProps { + visible: boolean; + /** Name of the subscription that was cancelled. */ + name: string; + /** Monthly-equivalent amount the user no longer pays. */ + monthlySaved: number; + currency: string; + onClose: () => void; +} + +/** + * The emotional payoff of cancelling: we celebrate NOT spending (the one thing + * every competitor forgets to do). A mint confetti burst + count-up of what the + * user just saved, and a self-contained share card — the viral loop from the + * plan ("every cancelled subscription is a shareable stat"). The card is a + * plain, inline-styled View so `captureRef` renders it identically off-screen. + */ +const CancelCelebration = ({ + visible, + name, + monthlySaved, + currency, + onClose, +}: CancelCelebrationProps) => { + const cardRef = useRef(null); + const yearlySaved = monthlySaved * 12; + + useEffect(() => { + if (visible) success(); + }, [visible]); + + const handleShare = async () => { + try { + const uri = await captureRef(cardRef, { format: "png", quality: 1 }); + if (await Sharing.isAvailableAsync()) { + await Sharing.shareAsync(uri, { + mimeType: "image/png", + dialogTitle: "Share your savings", + }); + } else { + // Fallback for platforms without the native share sheet. + await Share.share({ + message: `I just cancelled ${name} and I'm saving ${formatCurrency( + yearlySaved, + currency, + )}/yr. Tracked with Recurrly.`, + }); + } + } catch { + // User dismissed the sheet or capture failed — nothing to recover. + } + }; + + return ( + + + {visible ? : null} + + + + {/* The shareable card (also the on-screen centerpiece). */} + + + + ✓ + + + + + You just saved + + + + + a year + + + + by cancelling {name} ·{" "} + {formatCurrency(monthlySaved, currency)}/mo + + + + RECURRLY + + + + {/* Actions (outside the captured card). */} + + + + + Share + + + + + + + Done + + + + + + + + + ); +}; + +export default CancelCelebration; diff --git a/components/Confetti.tsx b/components/Confetti.tsx new file mode 100644 index 0000000..f2d29cf --- /dev/null +++ b/components/Confetti.tsx @@ -0,0 +1,104 @@ +import { useMemo } from "react"; +import { Dimensions, View } from "react-native"; +import Animated, { + Easing, + interpolate, + useAnimatedStyle, + useSharedValue, + withDelay, + withTiming, +} from "react-native-reanimated"; + +/** + * In-house confetti — a short burst of falling, spinning pieces driven by + * Reanimated. No new animation library (per the animation-stack decision): + * plain Animated.Views with per-piece shared values. Purely decorative, so it + * self-silences and needs no interaction. Mint-forward palette to match the + * "celebrate not spending" money-accent. + */ + +const { width: SCREEN_W, height: SCREEN_H } = Dimensions.get("window"); + +const COLORS = ["#4ADE9C", "#7DA7F4", "#F4B860", "#EA7A53", "#F2F5FA"]; + +type Piece = { + x: number; + size: number; + color: string; + delay: number; + drift: number; + spins: number; + radius: number; +}; + +const buildPieces = (count: number): Piece[] => + Array.from({ length: count }, () => { + const size = 7 + Math.random() * 7; + return { + x: Math.random() * SCREEN_W, + size, + color: COLORS[Math.floor(Math.random() * COLORS.length)], + delay: Math.random() * 400, + drift: (Math.random() - 0.5) * 120, + spins: 1 + Math.random() * 2, + // Mix of confetti squares and round dots. + radius: Math.random() > 0.5 ? size / 2 : 2, + }; + }); + +const ConfettiPiece = ({ piece, duration }: { piece: Piece; duration: number }) => { + const progress = useSharedValue(0); + progress.value = withDelay( + piece.delay, + withTiming(1, { duration, easing: Easing.out(Easing.quad) }), + ); + + const style = useAnimatedStyle(() => { + const p = progress.value; + return { + transform: [ + { translateY: interpolate(p, [0, 1], [-40, SCREEN_H + 40]) }, + { translateX: interpolate(p, [0, 1], [0, piece.drift]) }, + { rotate: `${p * 360 * piece.spins}deg` }, + ], + // Fade out over the last third of the fall. + opacity: interpolate(p, [0, 0.7, 1], [1, 1, 0]), + }; + }); + + return ( + + ); +}; + +const Confetti = ({ + count = 44, + duration = 2200, +}: { + count?: number; + duration?: number; +}) => { + const pieces = useMemo(() => buildPieces(count), [count]); + return ( + + {pieces.map((piece, i) => ( + + ))} + + ); +}; + +export default Confetti; diff --git a/package-lock.json b/package-lock.json index 3e64aac..113cede 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "expo-notifications": "~0.32.17", "expo-router": "~6.0.23", "expo-secure-store": "~15.0.8", + "expo-sharing": "~14.0.8", "expo-splash-screen": "~31.0.13", "expo-sqlite": "~16.0.10", "expo-status-bar": "~3.0.9", @@ -50,6 +51,7 @@ "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", "react-native-svg": "15.12.1", + "react-native-view-shot": "4.0.3", "react-native-web": "~0.21.0", "react-native-worklets": "0.5.1" }, @@ -9870,6 +9872,15 @@ "license": "MIT", "peer": true }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -10659,6 +10670,15 @@ "hyphenate-style-name": "^1.0.3" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/css-select": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", @@ -12913,6 +12933,15 @@ "node": ">=20.16.0" } }, + "node_modules/expo-sharing": { + "version": "14.0.8", + "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-14.0.8.tgz", + "integrity": "sha512-A1pPr2iBrxypFDCWVAESk532HK+db7MFXbvO2sCV9ienaFXAk7lIBm6bkqgE6vzRd9O3RGdEGzYx80cYlc089Q==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-splash-screen": { "version": "31.0.13", "resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-31.0.13.tgz", @@ -14567,6 +14596,19 @@ "dev": true, "license": "MIT" }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -19548,6 +19590,19 @@ "react-native": "*" } }, + "node_modules/react-native-view-shot": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/react-native-view-shot/-/react-native-view-shot-4.0.3.tgz", + "integrity": "sha512-USNjYmED7C0me02c1DxKA0074Hw+y/nxo+xJKlffMvfUWWzL5ELh/TJA/pTnVqFurIrzthZDPtDM7aBFJuhrHQ==", + "license": "MIT", + "dependencies": { + "html2canvas": "^1.4.1" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-web": { "version": "0.21.2", "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz", @@ -21341,6 +21396,15 @@ "integrity": "sha512-8bw4MY9WjdsD2aMtO0OzOCY3pXGYNx2d2FfHRVUKkiCPDWjKuOlhLVASS+pD7VkLTVjW268LYJHwsnPFlBpbAg==", "peer": true }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -21912,6 +21976,15 @@ "node": ">= 0.4.0" } }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/uuid": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz", diff --git a/package.json b/package.json index 6fad1dd..241de8d 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "expo-notifications": "~0.32.17", "expo-router": "~6.0.23", "expo-secure-store": "~15.0.8", + "expo-sharing": "~14.0.8", "expo-splash-screen": "~31.0.13", "expo-sqlite": "~16.0.10", "expo-status-bar": "~3.0.9", @@ -60,6 +61,7 @@ "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", "react-native-svg": "15.12.1", + "react-native-view-shot": "4.0.3", "react-native-web": "~0.21.0", "react-native-worklets": "0.5.1" }, From e9238ebe7d6f5bc3cf3f31b70015d4919f996b5f Mon Sep 17 00:00:00 2001 From: Sai Kumar Date: Fri, 17 Jul 2026 16:08:08 +1000 Subject: [PATCH 08/14] Address review findings: deep-link buffering, ack-on-rename, test/robustness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _layout: buffer notification deep-links into pending state and flush once the navigator is mounted (ready), instead of pushing immediately — a cold start launched by a reminder tap could push before the Stack mounted. - SubscriptionFormModal: clear a stale duplicateAcknowledged when an edit renames the sub (the ack was granted for the old name); preserve it otherwise. - Confetti: start the fall animation in a mount effect, not during render. - Home avatar stack: use the border-card token instead of a hardcoded hex so the notch separator follows the card color. - migrations test: assert the full duplicate_acknowledged column shape (INTEGER NOT NULL DEFAULT 0) + rowToSubscription 0/1 mapping. Skipped: passing subscription.currency to the cancel celebration — the app formats all amounts with the single app-wide baseCurrency by design (no FX); per-sub currency is never used for display and equals baseCurrency anyway. Co-Authored-By: Claude Opus 4.8 (1M context) --- __tests__/migrations.test.ts | 12 +++++++++++- app/(tabs)/index.tsx | 2 +- app/_layout.tsx | 20 +++++++++++++++----- components/Confetti.tsx | 15 ++++++++++----- components/SubscriptionFormModal.tsx | 9 +++++++++ 5 files changed, 46 insertions(+), 12 deletions(-) diff --git a/__tests__/migrations.test.ts b/__tests__/migrations.test.ts index 87e860a..56d9d89 100644 --- a/__tests__/migrations.test.ts +++ b/__tests__/migrations.test.ts @@ -52,7 +52,9 @@ describe("MIGRATIONS list", () => { it("adds the duplicate_acknowledged column at v4", () => { const v4 = MIGRATIONS.find((m) => m.version === 4); - expect(v4?.sql).toMatch(/duplicate_acknowledged/); + // Full column shape: a boolean-as-INTEGER that defaults to 0 so existing + // rows are non-acknowledged (still eligible for duplicate flagging). + expect(v4?.sql).toMatch(/duplicate_acknowledged\s+INTEGER\s+NOT NULL\s+DEFAULT 0/); expect(latest).toBe(4); }); }); @@ -118,4 +120,12 @@ describe("rowToSubscription date_assumed mapping", () => { true, ); }); + + it("maps duplicate_acknowledged 0 -> false and 1 -> true", () => { + expect(rowToSubscription(baseRow).duplicateAcknowledged).toBe(false); + expect( + rowToSubscription({ ...baseRow, duplicate_acknowledged: 1 }) + .duplicateAcknowledged, + ).toBe(true); + }); }); diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 4556886..bcd651c 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -383,11 +383,11 @@ export default function App() { {activeSubscriptions.slice(0, 4).map((sub, i) => ( diff --git a/app/_layout.tsx b/app/_layout.tsx index 3a181be..1dc49e9 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -67,16 +67,18 @@ function PostHogUserIdentifier() { function RootLayoutContent() { const router = useRouter(); - // Tapping a reminder deep-links to that subscription. Only the live listener - // (fires while the app is running, navigator mounted) — navigation is - // deferred a tick so we never push before the router is ready. + // Tapping a reminder deep-links to that subscription. The response can arrive + // before the navigator is mounted (a cold start launched by the tap), so we + // buffer the route and flush it once `ready` — never push before the router + // has a mounted navigator. + const [pendingSubId, setPendingSubId] = useState(null); useEffect(() => { let active = true; const sub = Notifications.addNotificationResponseReceivedListener( (response) => { const id = response?.notification.request.content.data?.subscriptionId; if (active && typeof id === "string") { - setTimeout(() => router.push(`/subscriptions/${id}`), 0); + setPendingSubId(id); } }, ); @@ -84,7 +86,7 @@ function RootLayoutContent() { active = false; sub.remove(); }; - }, [router]); + }, []); const [fontsLoaded, fontError] = useFonts({ "sans-regular": require("../assets/fonts/PlusJakartaSans-Regular.ttf"), @@ -119,6 +121,14 @@ function RootLayoutContent() { } }, [ready, fontError]); + // Flush a buffered notification deep-link once the navigator is mounted. + useEffect(() => { + if (ready && pendingSubId) { + router.push(`/subscriptions/${pendingSubId}`); + setPendingSubId(null); + } + }, [ready, pendingSubId, router]); + if (!ready) { return null; } diff --git a/components/Confetti.tsx b/components/Confetti.tsx index f2d29cf..f375602 100644 --- a/components/Confetti.tsx +++ b/components/Confetti.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useEffect, useMemo } from "react"; import { Dimensions, View } from "react-native"; import Animated, { Easing, @@ -48,10 +48,15 @@ const buildPieces = (count: number): Piece[] => const ConfettiPiece = ({ piece, duration }: { piece: Piece; duration: number }) => { const progress = useSharedValue(0); - progress.value = withDelay( - piece.delay, - withTiming(1, { duration, easing: Easing.out(Easing.quad) }), - ); + // Start the fall once, on mount — assigning during render would restart the + // animation on every re-render. + useEffect(() => { + progress.value = withDelay( + piece.delay, + withTiming(1, { duration, easing: Easing.out(Easing.quad) }), + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); const style = useAnimatedStyle(() => { const p = progress.value; diff --git a/components/SubscriptionFormModal.tsx b/components/SubscriptionFormModal.tsx index 5f640d9..ceedf0e 100644 --- a/components/SubscriptionFormModal.tsx +++ b/components/SubscriptionFormModal.tsx @@ -188,6 +188,15 @@ const SubscriptionFormModal = ({ // so normal adds/edits don't disturb an existing flag. if (acknowledgeDuplicate) { draft.duplicateAcknowledged = true; + } else if ( + isEdit && + subscription && + normalizeName(subscription.name) !== normalizeName(trimmedName) + ) { + // A prior acknowledgement was granted for the old name. Renaming may + // collide with a different sub, so clear it and let the new name be + // re-evaluated for duplicates. + draft.duplicateAcknowledged = false; } onSubmit(draft); From c38a68cd1679d41d6958a92037d9d2c16345b09c Mon Sep 17 00:00:00 2001 From: Sai Kumar Date: Sun, 19 Jul 2026 12:44:06 +1000 Subject: [PATCH 09/14] Drop per-subscription currency from the app model We format every amount with the single app-wide base currency (no FX), so a per-sub currency was dead weight and a source of ambiguity. Remove it from the Subscription/Draft/UpcomingSubscription types and every read/write: - type.d.ts: drop `currency` from Subscription (cascades to SubscriptionDraft) and UpcomingSubscription. - subscriptionsRepo: drop from SubscriptionRow, rowToSubscription, the INSERT, and PATCH_COLUMNS. The DB column is kept dormant (NOT NULL DEFAULT 'USD') so no destructive migration runs on local-first user data; it can be revived if per-currency ever returns. - Remove the now-dead `currency: baseCurrency` from the add/edit form and onboarding bulk-add, and the unused UpcomingSubscription.currency in Home. - migrations test fixture updated to the new row shape. --- __tests__/migrations.test.ts | 1 - app/(tabs)/index.tsx | 1 - app/onboarding.tsx | 1 - components/SubscriptionFormModal.tsx | 3 --- db/subscriptionsRepo.ts | 10 ++++------ type.d.ts | 4 ++-- 6 files changed, 6 insertions(+), 14 deletions(-) diff --git a/__tests__/migrations.test.ts b/__tests__/migrations.test.ts index 56d9d89..4c5cd7b 100644 --- a/__tests__/migrations.test.ts +++ b/__tests__/migrations.test.ts @@ -97,7 +97,6 @@ describe("rowToSubscription date_assumed mapping", () => { notes: null, status: "active", price: 15.49, - currency: "USD", billing_cycle: "monthly", custom_interval_days: null, is_trial: 0, diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index bcd651c..6f051fa 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -146,7 +146,6 @@ export default function App() { id: subscription.id, name: subscription.name, price: subscription.price, - currency: subscription.currency, daysLeft: getDaysUntilRenewal( subscription.renewalDate ?? subscription.startDate, diff --git a/app/onboarding.tsx b/app/onboarding.tsx index 73e9f06..37c68af 100644 --- a/app/onboarding.tsx +++ b/app/onboarding.tsx @@ -224,7 +224,6 @@ const Onboarding = () => { addSubscription({ name: brand.title, price, - currency: baseCurrency, billingCycle: cycle, category: brand.category, status: "active", diff --git a/components/SubscriptionFormModal.tsx b/components/SubscriptionFormModal.tsx index ceedf0e..041dbd8 100644 --- a/components/SubscriptionFormModal.tsx +++ b/components/SubscriptionFormModal.tsx @@ -1,7 +1,6 @@ import PickerSheet, { type PickerItem } from "@/components/PickerSheet"; import SubscriptionIcon from "@/components/SubscriptionIcon"; import { BRAND_ICONS } from "@/constants/brandIcons"; -import { useCurrency } from "@/context/CurrencyContext"; import { useSubscriptions } from "@/context/SubscriptionsContext"; import "@/global.css"; import { @@ -66,7 +65,6 @@ const SubscriptionFormModal = ({ highlightDate, }: SubscriptionFormModalProps) => { const isEdit = !!subscription; - const { baseCurrency } = useCurrency(); const { subscriptions } = useSubscriptions(); const [name, setName] = useState(""); @@ -161,7 +159,6 @@ const SubscriptionFormModal = ({ const draft: SubscriptionDraft = { name: trimmedName, price: parsedPrice, - currency: baseCurrency, paymentMethod: paymentMethod.trim() || undefined, billingCycle, customIntervalDays, diff --git a/db/subscriptionsRepo.ts b/db/subscriptionsRepo.ts index 257fa44..f68632b 100644 --- a/db/subscriptionsRepo.ts +++ b/db/subscriptionsRepo.ts @@ -15,7 +15,6 @@ export interface SubscriptionRow { notes: string | null; status: string; price: number; - currency: string; billing_cycle: string; custom_interval_days: number | null; is_trial: number; @@ -48,7 +47,6 @@ export const rowToSubscription = (row: SubscriptionRow): Subscription => { notes: row.notes ?? undefined, status: row.status, price: row.price, - currency: row.currency, billingCycle, customIntervalDays: row.custom_interval_days ?? undefined, billing: getCycleLabel(billingCycle, row.custom_interval_days ?? undefined), @@ -88,13 +86,15 @@ export const insertSubscription = (input: NewSubscription): Subscription => { const timestamp = nowIso(); const billingCycle = input.billingCycle ?? "monthly"; + // The `currency` column is retained in the schema (dormant, defaults to + // 'USD') but not written: all amounts use the single app-wide base currency. getDatabase().runSync( `INSERT INTO subscriptions ( id, name, color, plan, category, payment_method, notes, - status, price, currency, billing_cycle, custom_interval_days, + status, price, billing_cycle, custom_interval_days, is_trial, date_assumed, confirmed_through, duplicate_acknowledged, trial_end_date, start_date, next_renewal_date, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ id, input.name, @@ -105,7 +105,6 @@ export const insertSubscription = (input: NewSubscription): Subscription => { input.notes ?? null, input.status ?? "active", input.price, - input.currency ?? "USD", billingCycle, input.customIntervalDays ?? null, input.isTrial ? 1 : 0, @@ -133,7 +132,6 @@ const PATCH_COLUMNS: Record = { notes: "notes", status: "status", price: "price", - currency: "currency", billingCycle: "billing_cycle", customIntervalDays: "custom_interval_days", isTrial: "is_trial", diff --git a/type.d.ts b/type.d.ts index 1c0dfdd..9f779d5 100644 --- a/type.d.ts +++ b/type.d.ts @@ -29,7 +29,8 @@ declare global { status?: SubscriptionStatus | string; startDate?: string; price: number; - currency?: string; + // No per-sub currency: all amounts are entered and shown in the single + // app-wide base currency (see CurrencyContext). No FX/conversion. /** Display label derived from billingCycle (e.g. "Monthly"). */ billing: string; /** Source of truth for renewal math. */ @@ -79,7 +80,6 @@ declare global { id: string; name: string; price: number; - currency?: string; daysLeft: number; } From f50518b57662bccfef9b7d0eaf9e99ba1421a3d9 Mon Sep 17 00:00:00 2001 From: Sai Kumar Date: Sun, 19 Jul 2026 12:55:27 +1000 Subject: [PATCH 10/14] Subscriptions: sort (renewal/cost/name) + multi-category filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plan's "biggest daily-use win we currently lack" — the list only had search. Add lightweight inline controls on the Subscriptions tab: - Sort by Renewal (soonest first), Cost (monthly-equivalent so cycles compare fairly), or Name; tapping the active sort flips direction (↑/↓). Paused and cancelled subs have no active renewal, so they sink to the bottom of the renewal sort — matching the card, which only shows a countdown when active. - Multi-select category chips (derived from the data; shown only when there's more than one category) with a Clear affordance. Composes with search. Chips reuse the card/pill styling and the reliable bg-accent/10 active state. --- app/(tabs)/subscriptions.tsx | 200 ++++++++++++++++++++++++++++++++--- 1 file changed, 183 insertions(+), 17 deletions(-) diff --git a/app/(tabs)/subscriptions.tsx b/app/(tabs)/subscriptions.tsx index 3981262..5faf904 100644 --- a/app/(tabs)/subscriptions.tsx +++ b/app/(tabs)/subscriptions.tsx @@ -2,7 +2,9 @@ import ListHeading from "@/components/ListHeading"; import SubscriptionCard from "@/components/SubscriptionCard"; import { useSubscriptions } from "@/context/SubscriptionsContext"; import "@/global.css"; +import { getDaysUntilRenewal, getMonthlyEquivalent } from "@/lib/billing"; import { duplicateActiveNames, normalizeName } from "@/lib/duplicates"; +import clsx from "clsx"; import { useFocusEffect, useRouter } from "expo-router"; import { styled } from "nativewind"; import { useCallback, useMemo, useState } from "react"; @@ -11,6 +13,8 @@ import { FlatList, KeyboardAvoidingView, Platform, + Pressable, + ScrollView, Text, TextInput, View, @@ -20,10 +24,82 @@ import { SafeAreaView as RNSafeAreaView } from "react-native-safe-area-context"; const SafeAreaView = styled(RNSafeAreaView) as any; const StyledKeyboardAvoidingView = styled(KeyboardAvoidingView) as any; +type SortKey = "renewal" | "cost" | "name"; +type SortDir = "asc" | "desc"; + +const SORTS: { key: SortKey; label: string }[] = [ + { key: "renewal", label: "Renewal" }, + { key: "cost", label: "Cost" }, + { key: "name", label: "Name" }, +]; + +// Sensible default direction per sort: soonest renewal, priciest first, A–Z. +const DEFAULT_DIR: Record = { + renewal: "asc", + cost: "desc", + name: "asc", +}; + +const UNCATEGORIZED = "Uncategorized"; +const categoryOf = (sub: Subscription): string => + sub.category?.trim() || sub.plan?.trim() || UNCATEGORIZED; + +/** Monthly-equivalent cost, so a $120/yr sub compares fairly with a $10/mo one. */ +const monthlyCost = (sub: Subscription): number => + getMonthlyEquivalent( + sub.price, + sub.billingCycle ?? "monthly", + sub.customIntervalDays, + ); + +/** Days until next renewal, or null for subs with no active renewal (paused / + * cancelled), so they sort to the bottom — matching the card, which only shows + * a countdown for active subs. */ +const renewalDays = (sub: Subscription): number | null => { + const isActive = sub.status === "active" || sub.status === undefined; + if (!isActive) return null; + return getDaysUntilRenewal( + sub.renewalDate ?? sub.startDate, + sub.billingCycle ?? "monthly", + sub.customIntervalDays, + ); +}; + +/** A small toggle pill used for both sort and filter controls. */ +const Chip = ({ + label, + active, + onPress, +}: { + label: string; + active: boolean; + onPress: () => void; +}) => ( + + + {label} + + +); + const Subscriptions = () => { const { subscriptions, refresh } = useSubscriptions(); const router = useRouter(); const [query, setQuery] = useState(""); + const [sortKey, setSortKey] = useState("renewal"); + const [sortDir, setSortDir] = useState(DEFAULT_DIR.renewal); + const [selectedCategories, setSelectedCategories] = useState([]); // Reflect actions taken on the detail screen (delete, cancel, confirm). useFocusEffect( @@ -37,24 +113,70 @@ const Subscriptions = () => { [subscriptions], ); - const filteredSubscriptions = useMemo(() => { - const normalizedQuery = query.trim().toLowerCase(); + // Distinct categories present, for the filter row (only worth showing >1). + const categories = useMemo(() => { + const set = new Set(subscriptions.map(categoryOf)); + return Array.from(set).sort((a, b) => a.localeCompare(b)); + }, [subscriptions]); - if (!normalizedQuery) { - return subscriptions; + const handleSort = (key: SortKey) => { + if (key === sortKey) { + setSortDir((d) => (d === "asc" ? "desc" : "asc")); + } else { + setSortKey(key); + setSortDir(DEFAULT_DIR[key]); } + }; - return subscriptions.filter((subscription) => - [ - subscription.name, - subscription.category, - subscription.plan, - subscription.status, - ] - .filter(Boolean) - .some((field) => field!.toLowerCase().includes(normalizedQuery)), + const toggleCategory = (category: string) => { + setSelectedCategories((prev) => + prev.includes(category) + ? prev.filter((c) => c !== category) + : [...prev, category], ); - }, [query, subscriptions]); + }; + + const visibleSubscriptions = useMemo(() => { + const normalizedQuery = query.trim().toLowerCase(); + + const matches = subscriptions.filter((subscription) => { + const inSearch = + !normalizedQuery || + [ + subscription.name, + subscription.category, + subscription.plan, + subscription.status, + ] + .filter(Boolean) + .some((field) => field!.toLowerCase().includes(normalizedQuery)); + + const inCategory = + selectedCategories.length === 0 || + selectedCategories.includes(categoryOf(subscription)); + + return inSearch && inCategory; + }); + + const dir = sortDir === "asc" ? 1 : -1; + return matches.sort((a, b) => { + if (sortKey === "name") { + return a.name.localeCompare(b.name) * dir; + } + if (sortKey === "cost") { + return (monthlyCost(a) - monthlyCost(b)) * dir; + } + // renewal — subs with no active renewal sort to the bottom either way. + const da = renewalDays(a); + const db = renewalDays(b); + if (da === null && db === null) return 0; + if (da === null) return 1; + if (db === null) return -1; + return (da - db) * dir; + }); + }, [query, subscriptions, selectedCategories, sortKey, sortDir]); + + const arrow = sortDir === "asc" ? " ↑" : " ↓"; return ( @@ -74,11 +196,55 @@ const Subscriptions = () => { autoCapitalize="none" autoCorrect={false} clearButtonMode="while-editing" - className="mb-5 rounded-2xl border border-border bg-card px-4 py-3 font-sans-medium text-base text-primary" + className="mb-3 rounded-2xl border border-border bg-card px-4 py-3 font-sans-medium text-base text-primary" /> + + {/* Sort control */} + + + Sort + + {SORTS.map((s) => ( + handleSort(s.key)} + /> + ))} + + + {/* Category filter (only when there's more than one category) */} + {categories.length > 1 ? ( + + {selectedCategories.length > 0 ? ( + setSelectedCategories([])} + /> + ) : null} + {categories.map((category) => ( + toggleCategory(category)} + /> + ))} + + ) : ( + + )} } - data={filteredSubscriptions} + data={visibleSubscriptions} keyExtractor={(item) => item.id} renderItem={({ item }) => ( { keyboardShouldPersistTaps="handled" ListEmptyComponent={ - No subscriptions match your search. + No subscriptions match your filters. } contentContainerClassName="pb-30" From af80c0f435b4b3dfccba0a08cf170ccb6c4d7cc9 Mon Sep 17 00:00:00 2001 From: Sai Kumar Date: Sun, 19 Jul 2026 13:50:37 +1000 Subject: [PATCH 11/14] Onboarding: grouped + searchable brand picker; expand catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pick step showed the whole catalog as one flat 60-tile wall. Now: - Search box filters brands by name. - Brands render under ordered category headings (Entertainment, Gaming, Music, AI Tools, ... Bills & Utilities) so users scan to what they pay for. - Categorize every catalogued brand so the grouped view is tidy — no "Other" bucket (added categories for the Lobe AI tools + Trello/Asana/Netlify/etc. that previously inferred to Other). Expand the catalog with 37 real logos (only brands that still ship a simple-icons logo — Disney+/Hulu/Prime/Amazon/Canva/Adobe were dropped by simple-icons and are intentionally skipped rather than shown as monograms): - Health & Fitness: Peloton, Strava, Fitbit, Headspace - Food & Delivery: HelloFresh, DoorDash, Uber Eats, Deliveroo, Instacart, Just Eat, Zomato, Swiggy - Streaming: Apple TV, Plex, MUBI, Fubo - News & Reading: New York Times, The Guardian - Music: Pandora, iHeartRadio - Cloud & security: LastPass, Bitwarden, Backblaze, MEGA, Surfshark, Mullvad, Box, Proton - Gaming: EA, Ubisoft, NVIDIA (GeForce Now), Humble Bundle (+ moved Steam, PlayStation, Roblox, Epic Games into the new Gaming category) - Shopping: Target - Bills & Utilities: Verizon, Vodafone, O2, Spectrum Catalog: 77 -> 114 brands. Regenerated via generate-brand-icons.mjs. --- app/onboarding.tsx | 116 +++++++--- constants/brandIcons.ts | 377 +++++++++++++++++++++++++++++++ constants/onboardingBrands.ts | 97 +++++++- scripts/generate-brand-icons.mjs | 55 +++++ 4 files changed, 615 insertions(+), 30 deletions(-) diff --git a/app/onboarding.tsx b/app/onboarding.tsx index 37c68af..4e95100 100644 --- a/app/onboarding.tsx +++ b/app/onboarding.tsx @@ -7,7 +7,10 @@ import PickerSheet, { type PickerItem } from "@/components/PickerSheet"; import SubscriptionIcon from "@/components/SubscriptionIcon"; import logoGlow from "@/assets/images/logo-glow.png"; import { CURRENCY_CODES, currencyName } from "@/constants/currencies"; -import { ONBOARDING_BRANDS } from "@/constants/onboardingBrands"; +import { + ONBOARDING_BRANDS, + ONBOARDING_CATEGORY_ORDER, +} from "@/constants/onboardingBrands"; import { useCurrency } from "@/context/CurrencyContext"; import { useSubscriptions } from "@/context/SubscriptionsContext"; import "@/global.css"; @@ -26,7 +29,7 @@ import dayjs from "dayjs"; import { useRouter } from "expo-router"; import { styled } from "nativewind"; import { usePostHog } from "posthog-react-native"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Animated, Easing, @@ -139,9 +142,35 @@ const Onboarding = () => { const [addedCount, setAddedCount] = useState(0); const [celebrateTotal, setCelebrateTotal] = useState(0); const [analyzeLine, setAnalyzeLine] = useState(0); + const [brandQuery, setBrandQuery] = useState(""); const cycleFor = (title: string): BillingCycle => cycles[title] ?? "monthly"; + // Brands grouped under category headings (ordered), filtered by the search + // box — so the picker reads as tidy sections instead of one long wall, and a + // user can jump straight to what they pay for. + const brandGroups = useMemo(() => { + const q = brandQuery.trim().toLowerCase(); + const matches = q + ? ONBOARDING_BRANDS.filter((b) => b.title.toLowerCase().includes(q)) + : ONBOARDING_BRANDS; + + const byCategory = new Map(); + for (const brand of matches) { + const list = byCategory.get(brand.category) ?? []; + list.push(brand); + byCategory.set(brand.category, list); + } + + const rank = (category: string) => { + const i = ONBOARDING_CATEGORY_ORDER.indexOf(category); + return i === -1 ? ONBOARDING_CATEGORY_ORDER.length : i; + }; + return Array.from(byCategory.entries()) + .sort((a, b) => rank(a[0]) - rank(b[0])) + .map(([category, brands]) => ({ category, brands })); + }, [brandQuery]); + // The "analyzing" anticipation beat: cycle lines, then reveal the celebration. useEffect(() => { if (step !== "analyzing") return; @@ -373,32 +402,63 @@ const Onboarding = () => { contentContainerClassName="gap-4 p-6" > - - {ONBOARDING_BRANDS.map((brand) => { - const active = !!selected[brand.title]; - return ( - toggleBrand(brand.title)} - style={{ width: "31%" }} - className={clsx( - "items-center gap-2 rounded-2xl border p-3", - active - ? "border-accent bg-accent/10" - : "border-border bg-card", - )} - > - - - {brand.title} - - - ); - })} - + + {brandGroups.length === 0 ? ( + + No matches. Try another name — you can add it manually later. + + ) : ( + brandGroups.map(({ category, brands }) => ( + + + {category} + + + {brands.map((brand) => { + const active = !!selected[brand.title]; + return ( + toggleBrand(brand.title)} + style={{ width: "31%" }} + className={clsx( + "items-center gap-2 rounded-2xl border p-3", + active + ? "border-accent bg-accent/10" + : "border-border bg-card", + )} + > + + + {brand.title} + + + ); + })} + {/* Keep the last row left-aligned when a group has 3n+1/3n+2 + tiles (space-between would otherwise stretch them). */} + {brands.length % 3 !== 0 ? ( + + ) : null} + {brands.length % 3 === 1 ? ( + + ) : null} + + + )) + )} diff --git a/constants/brandIcons.ts b/constants/brandIcons.ts index dff1d12..adfd6d3 100644 --- a/constants/brandIcons.ts +++ b/constants/brandIcons.ts @@ -577,6 +577,383 @@ export const BRAND_ICONS: BrandIcon[] = [ "google" ] }, + { + "slug": "peloton", + "title": "Peloton", + "hex": "181A1D", + "svg": "", + "keywords": [ + "peloton", + "peloton app" + ] + }, + { + "slug": "strava", + "title": "Strava", + "hex": "FC4C02", + "svg": "", + "keywords": [ + "strava" + ] + }, + { + "slug": "fitbit", + "title": "Fitbit", + "hex": "00B0B9", + "svg": "", + "keywords": [ + "fitbit", + "fitbit premium" + ] + }, + { + "slug": "headspace", + "title": "Headspace", + "hex": "F47D31", + "svg": "", + "keywords": [ + "headspace" + ] + }, + { + "slug": "hellofresh", + "title": "HelloFresh", + "hex": "99CC33", + "svg": "", + "keywords": [ + "hellofresh", + "hello fresh", + "meal kit" + ] + }, + { + "slug": "doordash", + "title": "DoorDash", + "hex": "FF3008", + "svg": "", + "keywords": [ + "doordash", + "dashpass", + "door dash" + ] + }, + { + "slug": "ubereats", + "title": "Uber Eats", + "hex": "06C167", + "svg": "", + "keywords": [ + "uber eats", + "ubereats", + "uber one" + ] + }, + { + "slug": "deliveroo", + "title": "Deliveroo", + "hex": "00CCBC", + "svg": "", + "keywords": [ + "deliveroo", + "deliveroo plus" + ] + }, + { + "slug": "instacart", + "title": "Instacart", + "hex": "43B02A", + "svg": "", + "keywords": [ + "instacart", + "instacart+" + ] + }, + { + "slug": "justeat", + "title": "Just Eat", + "hex": "FF8000", + "svg": "", + "keywords": [ + "just eat", + "justeat" + ] + }, + { + "slug": "zomato", + "title": "Zomato", + "hex": "E23744", + "svg": "", + "keywords": [ + "zomato", + "zomato gold" + ] + }, + { + "slug": "swiggy", + "title": "Swiggy", + "hex": "FC8019", + "svg": "", + "keywords": [ + "swiggy", + "swiggy one" + ] + }, + { + "slug": "target", + "title": "Target", + "hex": "CC0000", + "svg": "", + "keywords": [ + "target", + "target circle", + "circle 360" + ] + }, + { + "slug": "appletv", + "title": "Apple TV", + "hex": "000000", + "svg": "", + "keywords": [ + "apple tv", + "appletv", + "apple tv+", + "apple tv plus" + ] + }, + { + "slug": "plex", + "title": "Plex", + "hex": "EBAF00", + "svg": "", + "keywords": [ + "plex", + "plex pass" + ] + }, + { + "slug": "mubi", + "title": "MUBI", + "hex": "000000", + "svg": "", + "keywords": [ + "mubi" + ] + }, + { + "slug": "fubo", + "title": "Fubo", + "hex": "C83D1E", + "svg": "", + "keywords": [ + "fubo", + "fubotv", + "fubo tv" + ] + }, + { + "slug": "newyorktimes", + "title": "New York Times", + "hex": "000000", + "svg": "", + "keywords": [ + "new york times", + "newyorktimes", + "nyt", + "ny times" + ] + }, + { + "slug": "theguardian", + "title": "The Guardian", + "hex": "052962", + "svg": "", + "keywords": [ + "the guardian", + "theguardian", + "guardian" + ] + }, + { + "slug": "pandora", + "title": "Pandora", + "hex": "224099", + "svg": "", + "keywords": [ + "pandora" + ] + }, + { + "slug": "iheartradio", + "title": "iHeartRadio", + "hex": "C6002B", + "svg": "", + "keywords": [ + "iheartradio", + "iheart", + "iheart radio" + ] + }, + { + "slug": "lastpass", + "title": "LastPass", + "hex": "D32D27", + "svg": "", + "keywords": [ + "lastpass", + "last pass" + ] + }, + { + "slug": "bitwarden", + "title": "Bitwarden", + "hex": "175DDC", + "svg": "", + "keywords": [ + "bitwarden" + ] + }, + { + "slug": "backblaze", + "title": "Backblaze", + "hex": "E21E29", + "svg": "", + "keywords": [ + "backblaze" + ] + }, + { + "slug": "mega", + "title": "MEGA", + "hex": "D9272E", + "svg": "", + "keywords": [ + "mega" + ] + }, + { + "slug": "surfshark", + "title": "Surfshark", + "hex": "1EBFBF", + "svg": "", + "keywords": [ + "surfshark", + "surf shark", + "vpn" + ] + }, + { + "slug": "mullvad", + "title": "Mullvad", + "hex": "294D73", + "svg": "", + "keywords": [ + "mullvad", + "mullvad vpn" + ] + }, + { + "slug": "box", + "title": "Box", + "hex": "0061D5", + "svg": "", + "keywords": [ + "box", + "box.com" + ] + }, + { + "slug": "proton", + "title": "Proton", + "hex": "6D4AFF", + "svg": "", + "keywords": [ + "proton", + "proton mail", + "proton drive", + "proton unlimited" + ] + }, + { + "slug": "ea", + "title": "EA", + "hex": "000000", + "svg": "", + "keywords": [ + "ea", + "ea play", + "ea sports" + ] + }, + { + "slug": "ubisoft", + "title": "Ubisoft", + "hex": "000000", + "svg": "", + "keywords": [ + "ubisoft", + "ubisoft+", + "ubisoft plus" + ] + }, + { + "slug": "nvidia", + "title": "NVIDIA", + "hex": "76B900", + "svg": "", + "keywords": [ + "nvidia", + "geforce now", + "geforce" + ] + }, + { + "slug": "humblebundle", + "title": "Humble Bundle", + "hex": "CC2929", + "svg": "", + "keywords": [ + "humble bundle", + "humblebundle", + "humble choice" + ] + }, + { + "slug": "verizon", + "title": "Verizon", + "hex": "CD040B", + "svg": "", + "keywords": [ + "verizon" + ] + }, + { + "slug": "vodafone", + "title": "Vodafone", + "hex": "E60000", + "svg": "", + "keywords": [ + "vodafone" + ] + }, + { + "slug": "o2", + "title": "O2", + "hex": "0050FF", + "svg": "", + "keywords": [ + "o2" + ] + }, + { + "slug": "spectrum", + "title": "Spectrum", + "hex": "7B16FF", + "svg": "", + "keywords": [ + "spectrum" + ] + }, { "slug": "openai", "title": "OpenAI", diff --git a/constants/onboardingBrands.ts b/constants/onboardingBrands.ts index 1a0dfa1..2e18f50 100644 --- a/constants/onboardingBrands.ts +++ b/constants/onboardingBrands.ts @@ -58,18 +58,111 @@ const DEFAULTS: Record = { nordvpn: { price: 12.99, category: "Cloud" }, expressvpn: { price: 12.95, category: "Cloud" }, protonvpn: { price: 9.99, category: "Cloud" }, - steam: { price: 9.99, category: "Entertainment" }, + steam: { price: 9.99, category: "Gaming" }, discord: { price: 9.99, category: "Entertainment" }, - roblox: { price: 4.99, category: "Entertainment" }, + roblox: { price: 4.99, category: "Gaming" }, + playstation: { price: 9.99, category: "Gaming" }, + "epic games": { price: 11.99, category: "Gaming" }, + ea: { price: 4.99, category: "Gaming" }, + ubisoft: { price: 17.99, category: "Gaming" }, + nvidia: { price: 9.99, category: "Gaming" }, + "humble bundle": { price: 11.99, category: "Gaming" }, coursera: { price: 59, category: "Productivity" }, udemy: { price: 20, category: "Productivity" }, duolingo: { price: 12.99, category: "Productivity" }, skillshare: { price: 14, category: "Productivity" }, icloud: { price: 2.99, category: "Cloud" }, + // AI tools (Lobe-sourced brands + others without a descriptive keyword) + "hugging face": { price: 9, category: "AI Tools" }, + lovable: { price: 20, category: "AI Tools" }, + v0: { price: 20, category: "AI Tools" }, + krea: { price: 10, category: "AI Tools" }, + replicate: { price: 10, category: "AI Tools" }, + ideogram: { price: 8, category: "AI Tools" }, + luma: { price: 10, category: "AI Tools" }, + grok: { price: 8, category: "AI Tools" }, + deepseek: { price: 10, category: "AI Tools" }, + mistral: { price: 15, category: "AI Tools" }, + pika: { price: 10, category: "AI Tools" }, + flux: { price: 10, category: "AI Tools" }, + kling: { price: 10, category: "AI Tools" }, + // Productivity / developer tools without descriptive keywords + trello: { price: 5, category: "Productivity" }, + asana: { price: 10.99, category: "Productivity" }, + linear: { price: 8, category: "Productivity" }, + obsidian: { price: 4, category: "Productivity" }, + evernote: { price: 14.99, category: "Productivity" }, + todoist: { price: 4, category: "Productivity" }, + netlify: { price: 19, category: "Developer Tools" }, + cloudflare: { price: 5, category: "Developer Tools" }, + digitalocean: { price: 12, category: "Developer Tools" }, + replit: { price: 20, category: "Developer Tools" }, + google: { price: 1.99, category: "Cloud" }, + sky: { price: 30, category: "Entertainment" }, + // Health & fitness + peloton: { price: 12.99, category: "Health & Fitness" }, + strava: { price: 11.99, category: "Health & Fitness" }, + fitbit: { price: 9.99, category: "Health & Fitness" }, + headspace: { price: 12.99, category: "Health & Fitness" }, + // Food & delivery + hellofresh: { price: 59.99, category: "Food & Delivery" }, + doordash: { price: 9.99, category: "Food & Delivery" }, + "uber eats": { price: 9.99, category: "Food & Delivery" }, + deliveroo: { price: 3.49, category: "Food & Delivery" }, + instacart: { price: 9.99, category: "Food & Delivery" }, + "just eat": { price: 9.99, category: "Food & Delivery" }, + zomato: { price: 3.99, category: "Food & Delivery" }, + swiggy: { price: 2.99, category: "Food & Delivery" }, + // Shopping / household + target: { price: 10.99, category: "Shopping" }, + // Streaming + "apple tv": { price: 9.99, category: "Entertainment" }, + plex: { price: 4.99, category: "Entertainment" }, + mubi: { price: 12.99, category: "Entertainment" }, + fubo: { price: 79.99, category: "Entertainment" }, + // News & reading + "new york times": { price: 17, category: "News & Reading" }, + "the guardian": { price: 6.99, category: "News & Reading" }, + // Music + pandora: { price: 4.99, category: "Music" }, + iheartradio: { price: 9.99, category: "Music" }, + // Cloud & security + lastpass: { price: 3, category: "Cloud" }, + bitwarden: { price: 1, category: "Cloud" }, + backblaze: { price: 9, category: "Cloud" }, + mega: { price: 11.99, category: "Cloud" }, + surfshark: { price: 12.95, category: "Cloud" }, + mullvad: { price: 5, category: "Cloud" }, + box: { price: 10, category: "Cloud" }, + proton: { price: 9.99, category: "Cloud" }, + // Bills / phone / internet + verizon: { price: 70, category: "Bills & Utilities" }, + vodafone: { price: 30, category: "Bills & Utilities" }, + o2: { price: 20, category: "Bills & Utilities" }, + spectrum: { price: 50, category: "Bills & Utilities" }, }; const FALLBACK = { price: 9.99, category: "Other" }; +// Display order for the grouped onboarding picker (most commonly-paid-for +// first). Categories not listed here render last, before "Other". +export const ONBOARDING_CATEGORY_ORDER = [ + "Entertainment", + "Gaming", + "Music", + "AI Tools", + "Productivity", + "Developer Tools", + "Design", + "Health & Fitness", + "Food & Delivery", + "News & Reading", + "Shopping", + "Cloud", + "Bills & Utilities", + "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. diff --git a/scripts/generate-brand-icons.mjs b/scripts/generate-brand-icons.mjs index 653143e..e110f07 100644 --- a/scripts/generate-brand-icons.mjs +++ b/scripts/generate-brand-icons.mjs @@ -80,6 +80,61 @@ const SIMPLE_WANT = [ { slug: "skillshare" }, { slug: "icloud", keywords: ["apple icloud", "icloud+"] }, { slug: "google" }, + + // --- Health & fitness ----------------------------------------------------- + { slug: "peloton", keywords: ["peloton app"] }, + { slug: "strava" }, + { slug: "fitbit", keywords: ["fitbit premium"] }, + { slug: "headspace" }, + + // --- Food & delivery ------------------------------------------------------ + { slug: "hellofresh", keywords: ["hello fresh", "meal kit"] }, + { slug: "doordash", keywords: ["dashpass", "door dash"] }, + { slug: "ubereats", keywords: ["uber one", "uber eats"] }, + { slug: "deliveroo", keywords: ["deliveroo plus"] }, + { slug: "instacart", keywords: ["instacart+"] }, + { slug: "justeat", keywords: ["just eat"] }, + { slug: "zomato", keywords: ["zomato gold"] }, + { slug: "swiggy", keywords: ["swiggy one"] }, + + // --- Shopping / household ------------------------------------------------- + { slug: "target", keywords: ["target circle", "circle 360"] }, + + // --- More streaming (that still ship a logo) ------------------------------ + { slug: "appletv", keywords: ["apple tv", "apple tv+", "apple tv plus"] }, + { slug: "plex", keywords: ["plex pass"] }, + { slug: "mubi" }, + { slug: "fubo", keywords: ["fubotv", "fubo tv"] }, + + // --- News & reading ------------------------------------------------------- + { slug: "newyorktimes", keywords: ["nyt", "ny times", "new york times"] }, + { slug: "theguardian", keywords: ["guardian"] }, + + // --- More music ----------------------------------------------------------- + { slug: "pandora" }, + { slug: "iheartradio", keywords: ["iheart", "iheart radio"] }, + + // --- Cloud & security ----------------------------------------------------- + { slug: "lastpass", keywords: ["last pass"] }, + { slug: "bitwarden" }, + { slug: "backblaze" }, + { slug: "mega" }, + { slug: "surfshark", keywords: ["surf shark", "vpn"] }, + { slug: "mullvad", keywords: ["mullvad vpn"] }, + { slug: "box", keywords: ["box.com"] }, + { slug: "proton", keywords: ["proton mail", "proton drive", "proton unlimited"] }, + + // --- Gaming --------------------------------------------------------------- + { slug: "ea", keywords: ["ea play", "ea sports"] }, + { slug: "ubisoft", keywords: ["ubisoft+", "ubisoft plus"] }, + { slug: "nvidia", keywords: ["geforce now", "geforce"] }, + { slug: "humblebundle", keywords: ["humble choice", "humble bundle"] }, + + // --- Bills / phone / internet --------------------------------------------- + { slug: "verizon" }, + { slug: "vodafone" }, + { slug: "o2" }, + { slug: "spectrum" }, ]; // --- Lobe: AI-tool brands (mono SVG, no brand hex → name-derived tile) ------- From 7c4a42a94675fa66ac1820c459bbc08e26037540 Mon Sep 17 00:00:00 2001 From: Sai Kumar Date: Sun, 19 Jul 2026 15:35:37 +1000 Subject: [PATCH 12/14] Catalog: cloud/hosting logos, AWS/Azure, generic glyph tiles Add the brands users asked for, using real logos where any available source ships one and lucide glyph tiles where none does. Real logos: - simple-icons: Google Cloud, Hostinger, Namecheap, GoDaddy, Vultr, Render, Railway, Supabase, Firebase, MongoDB, PlanetScale (Developer Tools). - Lobe: AWS, Azure (simple-icons dropped both over trademark; Lobe still ships them). Generic day-to-day category tiles (lucide glyphs, no brand identity) so household bills are one tap: Rent / Housing, Utilities, Mobile / Phone, Internet, Insurance, Gym, Transport, Streaming. Big brands neither source has a logo for get a themed glyph stand-in for this release (distinct tile colours keep them apart): Disney+, Hulu, Prime Video, Peacock, Canva, Adobe, Xbox, Nintendo Switch, Amazon Prime. lucide-static added as a build-time devDependency (same pattern as simple-icons/lobe). Catalog: 114 -> 144; still zero "Other" bucket. Keyword aliases kept specific to avoid greedy substring matches in the name resolver. --- constants/brandIcons.ts | 322 +++++++++++++++++++++++++++++++ constants/onboardingBrands.ts | 33 ++++ package-lock.json | 8 + package.json | 1 + scripts/generate-brand-icons.mjs | 76 ++++++++ 5 files changed, 440 insertions(+) diff --git a/constants/brandIcons.ts b/constants/brandIcons.ts index adfd6d3..2881519 100644 --- a/constants/brandIcons.ts +++ b/constants/brandIcons.ts @@ -954,6 +954,110 @@ export const BRAND_ICONS: BrandIcon[] = [ "spectrum" ] }, + { + "slug": "googlecloud", + "title": "Google Cloud", + "hex": "4285F4", + "svg": "", + "keywords": [ + "google cloud", + "googlecloud", + "gcp", + "google cloud platform" + ] + }, + { + "slug": "hostinger", + "title": "Hostinger", + "hex": "673DE6", + "svg": "", + "keywords": [ + "hostinger" + ] + }, + { + "slug": "namecheap", + "title": "Namecheap", + "hex": "DE3723", + "svg": "", + "keywords": [ + "namecheap" + ] + }, + { + "slug": "godaddy", + "title": "GoDaddy", + "hex": "1BDBDB", + "svg": "", + "keywords": [ + "godaddy" + ] + }, + { + "slug": "vultr", + "title": "Vultr", + "hex": "007BFC", + "svg": "", + "keywords": [ + "vultr" + ] + }, + { + "slug": "render", + "title": "Render", + "hex": "000000", + "svg": "", + "keywords": [ + "render" + ] + }, + { + "slug": "railway", + "title": "Railway", + "hex": "0B0D0E", + "svg": "", + "keywords": [ + "railway" + ] + }, + { + "slug": "supabase", + "title": "Supabase", + "hex": "3FCF8E", + "svg": "", + "keywords": [ + "supabase" + ] + }, + { + "slug": "firebase", + "title": "Firebase", + "hex": "DD2C00", + "svg": "", + "keywords": [ + "firebase" + ] + }, + { + "slug": "mongodb", + "title": "MongoDB", + "hex": "47A248", + "svg": "", + "keywords": [ + "mongodb", + "mongodb atlas", + "atlas" + ] + }, + { + "slug": "planetscale", + "title": "PlanetScale", + "hex": "000000", + "svg": "", + "keywords": [ + "planetscale" + ] + }, { "slug": "openai", "title": "OpenAI", @@ -1124,5 +1228,223 @@ export const BRAND_ICONS: BrandIcon[] = [ "keywords": [ "kling" ] + }, + { + "slug": "aws", + "title": "AWS", + "hex": null, + "svg": "AWS", + "keywords": [ + "aws", + "amazon web services" + ] + }, + { + "slug": "azure", + "title": "Azure", + "hex": null, + "svg": "Azure", + "keywords": [ + "azure", + "microsoft azure" + ] + }, + { + "slug": "glyph-rent-housing", + "title": "Rent / Housing", + "hex": "E0952F", + "svg": " ", + "keywords": [ + "rent / housing", + "glyph-house", + "rent", + "mortgage", + "housing" + ] + }, + { + "slug": "glyph-utilities", + "title": "Utilities", + "hex": "F4B860", + "svg": " ", + "keywords": [ + "utilities", + "glyph-zap", + "utility bill", + "electricity", + "energy bill" + ] + }, + { + "slug": "glyph-mobile-phone", + "title": "Mobile / Phone", + "hex": "7DA7F4", + "svg": " ", + "keywords": [ + "mobile / phone", + "glyph-smartphone", + "mobile plan", + "phone plan", + "cell plan", + "carrier" + ] + }, + { + "slug": "glyph-internet", + "title": "Internet", + "hex": "5AC8C8", + "svg": " ", + "keywords": [ + "internet", + "glyph-wifi", + "broadband", + "home wifi", + "fibre plan" + ] + }, + { + "slug": "glyph-insurance", + "title": "Insurance", + "hex": "16A34A", + "svg": " ", + "keywords": [ + "insurance", + "glyph-shield-check", + "policy" + ] + }, + { + "slug": "glyph-gym", + "title": "Gym", + "hex": "EA7A53", + "svg": " ", + "keywords": [ + "gym", + "glyph-dumbbell", + "fitness membership", + "workout" + ] + }, + { + "slug": "glyph-transport", + "title": "Transport", + "hex": "C58AF9", + "svg": " ", + "keywords": [ + "transport", + "glyph-bus", + "transport pass", + "commute", + "transit", + "metro card" + ] + }, + { + "slug": "glyph-streaming", + "title": "Streaming", + "hex": "F78FB3", + "svg": " ", + "keywords": [ + "streaming", + "glyph-tv" + ] + }, + { + "slug": "glyph-disney", + "title": "Disney+", + "hex": "113CCF", + "svg": " ", + "keywords": [ + "disney+", + "glyph-clapperboard", + "disney", + "disney plus" + ] + }, + { + "slug": "glyph-hulu", + "title": "Hulu", + "hex": "1CE783", + "svg": " ", + "keywords": [ + "hulu", + "glyph-clapperboard" + ] + }, + { + "slug": "glyph-prime-video", + "title": "Prime Video", + "hex": "00A8E1", + "svg": " ", + "keywords": [ + "prime video", + "glyph-clapperboard", + "amazon prime video" + ] + }, + { + "slug": "glyph-peacock", + "title": "Peacock", + "hex": "05A18F", + "svg": " ", + "keywords": [ + "peacock", + "glyph-tv" + ] + }, + { + "slug": "glyph-canva", + "title": "Canva", + "hex": "00C4CC", + "svg": " ", + "keywords": [ + "canva", + "glyph-palette" + ] + }, + { + "slug": "glyph-adobe", + "title": "Adobe", + "hex": "FA0F00", + "svg": " ", + "keywords": [ + "adobe", + "glyph-palette", + "creative cloud", + "photoshop" + ] + }, + { + "slug": "glyph-xbox", + "title": "Xbox", + "hex": "107C10", + "svg": " ", + "keywords": [ + "xbox", + "glyph-gamepad-2", + "game pass" + ] + }, + { + "slug": "glyph-nintendo-switch", + "title": "Nintendo Switch", + "hex": "E60012", + "svg": " ", + "keywords": [ + "nintendo switch", + "glyph-gamepad-2", + "nintendo", + "switch online" + ] + }, + { + "slug": "glyph-amazon-prime", + "title": "Amazon Prime", + "hex": "FF9900", + "svg": " ", + "keywords": [ + "amazon prime", + "glyph-package" + ] } ]; diff --git a/constants/onboardingBrands.ts b/constants/onboardingBrands.ts index 2e18f50..75a58a9 100644 --- a/constants/onboardingBrands.ts +++ b/constants/onboardingBrands.ts @@ -140,6 +140,39 @@ const DEFAULTS: Record = { vodafone: { price: 30, category: "Bills & Utilities" }, o2: { price: 20, category: "Bills & Utilities" }, spectrum: { price: 50, category: "Bills & Utilities" }, + // Cloud & hosting infra + aws: { price: 50, category: "Developer Tools" }, + azure: { price: 50, category: "Developer Tools" }, + "google cloud": { price: 50, category: "Developer Tools" }, + hostinger: { price: 11.99, category: "Developer Tools" }, + namecheap: { price: 10, category: "Developer Tools" }, + godaddy: { price: 12, category: "Developer Tools" }, + vultr: { price: 10, category: "Developer Tools" }, + render: { price: 19, category: "Developer Tools" }, + railway: { price: 5, category: "Developer Tools" }, + supabase: { price: 25, category: "Developer Tools" }, + firebase: { price: 25, category: "Developer Tools" }, + mongodb: { price: 9, category: "Developer Tools" }, + planetscale: { price: 39, category: "Developer Tools" }, + // Generic day-to-day categories (glyph tiles) + "rent / housing": { price: 1200, category: "Bills & Utilities" }, + utilities: { price: 120, category: "Bills & Utilities" }, + "mobile / phone": { price: 40, category: "Bills & Utilities" }, + internet: { price: 50, category: "Bills & Utilities" }, + insurance: { price: 100, category: "Bills & Utilities" }, + gym: { price: 40, category: "Health & Fitness" }, + transport: { price: 80, category: "Bills & Utilities" }, + streaming: { price: 15, category: "Entertainment" }, + // Big brands with no logo yet (glyph stand-in) + "disney+": { price: 13.99, category: "Entertainment" }, + hulu: { price: 17.99, category: "Entertainment" }, + "prime video": { price: 8.99, category: "Entertainment" }, + peacock: { price: 7.99, category: "Entertainment" }, + canva: { price: 12.99, category: "Design" }, + adobe: { price: 22.99, category: "Design" }, + xbox: { price: 16.99, category: "Gaming" }, + "nintendo switch": { price: 3.99, category: "Gaming" }, + "amazon prime": { price: 14.99, category: "Shopping" }, }; const FALLBACK = { price: 9.99, category: "Other" }; diff --git a/package-lock.json b/package-lock.json index 113cede..a5eb729 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,6 +64,7 @@ "eslint-config-expo": "~10.0.0", "jest": "~29.7.0", "jest-expo": "~54.0.16", + "lucide-static": "^1.25.0", "postcss": "^8.5.15", "pre-commit": "^2.0.0", "simple-icons": "^16.25.0", @@ -17332,6 +17333,13 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-static": { + "version": "1.25.0", + "resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.25.0.tgz", + "integrity": "sha512-uJTdyP8vxKELd+Kqk45ahUvOA1rv3Ehy6ZUP7JlBt8UJwmzHgFFts0OZNU3C6/4MDKsssgR9Jrut7GnuqGgYmg==", + "dev": true, + "license": "ISC" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", diff --git a/package.json b/package.json index 241de8d..db941ef 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "eslint-config-expo": "~10.0.0", "jest": "~29.7.0", "jest-expo": "~54.0.16", + "lucide-static": "^1.25.0", "postcss": "^8.5.15", "pre-commit": "^2.0.0", "simple-icons": "^16.25.0", diff --git a/scripts/generate-brand-icons.mjs b/scripts/generate-brand-icons.mjs index e110f07..e3692e5 100644 --- a/scripts/generate-brand-icons.mjs +++ b/scripts/generate-brand-icons.mjs @@ -135,6 +135,19 @@ const SIMPLE_WANT = [ { slug: "vodafone" }, { slug: "o2" }, { slug: "spectrum" }, + + // --- Cloud & hosting infra (dev/freelancer wedge) ------------------------- + { slug: "googlecloud", keywords: ["gcp", "google cloud platform"] }, + { slug: "hostinger" }, + { slug: "namecheap" }, + { slug: "godaddy" }, + { slug: "vultr" }, + { slug: "render" }, + { slug: "railway" }, + { slug: "supabase" }, + { slug: "firebase" }, + { slug: "mongodb", keywords: ["mongodb atlas", "atlas"] }, + { slug: "planetscale" }, ]; // --- Lobe: AI-tool brands (mono SVG, no brand hex → name-derived tile) ------- @@ -158,8 +171,49 @@ const LOBE_WANT = [ { slug: "pika" }, { slug: "flux" }, { slug: "kling" }, + // Cloud providers simple-icons dropped (trademark) — Lobe still ships them. + { slug: "aws", keywords: ["amazon web services"] }, + { slug: "azure", keywords: ["microsoft azure"] }, +]; + +// --- Lucide: generic category glyphs + logo-less big brands ----------------- +// Outline glyphs (currentColor) for day-to-day categories with no brand, and a +// themed stand-in for big brands neither source ships a logo for (this +// release). hex is the tile colour; keywords stay specific to avoid greedy +// substring matches in the name resolver. +const LUCIDE_DIR = join( + here, + "..", + "node_modules", + "lucide-static", + "icons", +); + +const GLYPH_WANT = [ + // Generic day-to-day categories (no brand identity) + { icon: "house", title: "Rent / Housing", hex: "E0952F", keywords: ["rent", "mortgage", "housing"] }, + { icon: "zap", title: "Utilities", hex: "F4B860", keywords: ["utilities", "utility bill", "electricity", "energy bill"] }, + { icon: "smartphone", title: "Mobile / Phone", hex: "7DA7F4", keywords: ["mobile plan", "phone plan", "cell plan", "carrier"] }, + { icon: "wifi", title: "Internet", hex: "5AC8C8", keywords: ["internet", "broadband", "home wifi", "fibre plan"] }, + { icon: "shield-check", title: "Insurance", hex: "16A34A", keywords: ["insurance", "policy"] }, + { icon: "dumbbell", title: "Gym", hex: "EA7A53", keywords: ["gym", "fitness membership", "workout"] }, + { icon: "bus", title: "Transport", hex: "C58AF9", keywords: ["transport pass", "commute", "transit", "metro card"] }, + { icon: "tv", title: "Streaming", hex: "F78FB3", keywords: ["streaming"] }, + // Big brands neither simple-icons nor Lobe ships a logo for — themed glyph. + { icon: "clapperboard", title: "Disney+", hex: "113CCF", keywords: ["disney", "disney plus", "disney+"] }, + { icon: "clapperboard", title: "Hulu", hex: "1CE783", keywords: ["hulu"] }, + { icon: "clapperboard", title: "Prime Video", hex: "00A8E1", keywords: ["prime video", "amazon prime video"] }, + { icon: "tv", title: "Peacock", hex: "05A18F", keywords: ["peacock"] }, + { icon: "palette", title: "Canva", hex: "00C4CC", keywords: ["canva"] }, + { icon: "palette", title: "Adobe", hex: "FA0F00", keywords: ["adobe", "creative cloud", "photoshop"] }, + { icon: "gamepad-2", title: "Xbox", hex: "107C10", keywords: ["xbox", "game pass"] }, + { icon: "gamepad-2", title: "Nintendo Switch", hex: "E60012", keywords: ["nintendo", "switch online"] }, + { icon: "package", title: "Amazon Prime", hex: "FF9900", keywords: ["amazon prime"] }, ]; +const slugify = (s) => + s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); + const bySlug = {}; for (const value of Object.values(si)) { if (value && value.slug) bySlug[value.slug] = value; @@ -206,6 +260,28 @@ for (const want of LOBE_WANT) { }); } +for (const want of GLYPH_WANT) { + let raw; + try { + raw = readFileSync(join(LUCIDE_DIR, `${want.icon}.svg`), "utf8"); + } catch { + missing.push(`lucide:${want.icon}`); + continue; + } + // Drop the license comment and collapse whitespace to a single-line SVG. + const svg = raw + .replace(//g, "") + .replace(/\s+/g, " ") + .trim(); + entries.push({ + slug: `glyph-${slugify(want.title)}`, + title: want.title, + hex: want.hex, + svg, + keywords: buildKeywords(want.title, `glyph-${want.icon}`, want.keywords), + }); +} + if (missing.length) console.warn("Skipped:", missing.join(", ")); const header = `// AUTO-GENERATED by scripts/generate-brand-icons.mjs — do not edit by hand. From 3b419e41e4810d43e4fa90815033cf7fb374fafb Mon Sep 17 00:00:00 2001 From: Sai Kumar Date: Sun, 19 Jul 2026 15:55:23 +1000 Subject: [PATCH 13/14] Uniform brand picker: grouped + searchable in the add flow too The "+" add-subscription sheet listed every brand in raw catalog order (looked random), while onboarding groups them by category with search. Make them match: - Extract groupOnboardingBrands() as the single source of truth for ordering/grouping; onboarding now uses it instead of inline logic. - New BrandPickerSheet renders the same category-grouped, searchable tile grid; the add form's "Browse brands" opens it (single-select) instead of the flat PickerSheet list. - Align the form's category chips + colors with the full onboarding taxonomy (Gaming, Health & Fitness, Food & Delivery, News & Reading, Shopping, Bills & Utilities) so categories are consistent everywhere. --- app/onboarding.tsx | 31 ++---- components/BrandPickerSheet.tsx | 143 +++++++++++++++++++++++++++ components/SubscriptionFormModal.tsx | 38 +++---- constants/onboardingBrands.ts | 32 ++++++ 4 files changed, 195 insertions(+), 49 deletions(-) create mode 100644 components/BrandPickerSheet.tsx diff --git a/app/onboarding.tsx b/app/onboarding.tsx index 4e95100..cf4985c 100644 --- a/app/onboarding.tsx +++ b/app/onboarding.tsx @@ -9,7 +9,7 @@ import logoGlow from "@/assets/images/logo-glow.png"; import { CURRENCY_CODES, currencyName } from "@/constants/currencies"; import { ONBOARDING_BRANDS, - ONBOARDING_CATEGORY_ORDER, + groupOnboardingBrands, } from "@/constants/onboardingBrands"; import { useCurrency } from "@/context/CurrencyContext"; import { useSubscriptions } from "@/context/SubscriptionsContext"; @@ -147,29 +147,12 @@ const Onboarding = () => { const cycleFor = (title: string): BillingCycle => cycles[title] ?? "monthly"; // Brands grouped under category headings (ordered), filtered by the search - // box — so the picker reads as tidy sections instead of one long wall, and a - // user can jump straight to what they pay for. - const brandGroups = useMemo(() => { - const q = brandQuery.trim().toLowerCase(); - const matches = q - ? ONBOARDING_BRANDS.filter((b) => b.title.toLowerCase().includes(q)) - : ONBOARDING_BRANDS; - - const byCategory = new Map(); - for (const brand of matches) { - const list = byCategory.get(brand.category) ?? []; - list.push(brand); - byCategory.set(brand.category, list); - } - - const rank = (category: string) => { - const i = ONBOARDING_CATEGORY_ORDER.indexOf(category); - return i === -1 ? ONBOARDING_CATEGORY_ORDER.length : i; - }; - return Array.from(byCategory.entries()) - .sort((a, b) => rank(a[0]) - rank(b[0])) - .map(([category, brands]) => ({ category, brands })); - }, [brandQuery]); + // box — so the picker reads as tidy sections instead of one long wall. Shared + // with the add-subscription sheet via groupOnboardingBrands. + const brandGroups = useMemo( + () => groupOnboardingBrands(brandQuery), + [brandQuery], + ); // The "analyzing" anticipation beat: cycle lines, then reveal the celebration. useEffect(() => { diff --git a/components/BrandPickerSheet.tsx b/components/BrandPickerSheet.tsx new file mode 100644 index 0000000..3ec2a9c --- /dev/null +++ b/components/BrandPickerSheet.tsx @@ -0,0 +1,143 @@ +import SubscriptionIcon from "@/components/SubscriptionIcon"; +import { groupOnboardingBrands } from "@/constants/onboardingBrands"; +import "@/global.css"; +import clsx from "clsx"; +import { useMemo, useState } from "react"; +import { + KeyboardAvoidingView, + Modal, + Platform, + Pressable, + ScrollView, + Text, + TextInput, + View, +} from "react-native"; + +interface BrandPickerSheetProps { + visible: boolean; + /** Currently-chosen brand title (highlighted), if any. */ + selected?: string; + onSelect: (title: string) => void; + onClose: () => void; +} + +/** + * The add-subscription "browse brands" picker — the same grouped-by-category, + * searchable grid the onboarding picker uses, so brand selection looks and + * behaves identically everywhere (single-select here). Reuses + * groupOnboardingBrands for one source of truth on ordering/grouping. + */ +const BrandPickerSheet = ({ + visible, + selected, + onSelect, + onClose, +}: BrandPickerSheetProps) => { + const [query, setQuery] = useState(""); + const groups = useMemo(() => groupOnboardingBrands(query), [query]); + + const handleClose = () => { + setQuery(""); + onClose(); + }; + + const pick = (title: string) => { + onSelect(title); + handleClose(); + }; + + return ( + + + + + + + Choose subscription + + × + + + + + + + + + {groups.length === 0 ? ( + + No matches. Type the name to add it manually. + + ) : ( + groups.map(({ category, brands }) => ( + + + {category} + + + {brands.map((brand) => ( + pick(brand.title)} + style={{ width: "31%" }} + className={clsx( + "items-center gap-2 rounded-2xl border p-3", + brand.title === selected + ? "border-accent bg-accent/10" + : "border-border bg-card", + )} + > + + + {brand.title} + + + ))} + {brands.length % 3 !== 0 ? ( + + ) : null} + {brands.length % 3 === 1 ? ( + + ) : null} + + + )) + )} + + + + + + ); +}; + +export default BrandPickerSheet; diff --git a/components/SubscriptionFormModal.tsx b/components/SubscriptionFormModal.tsx index 041dbd8..6001622 100644 --- a/components/SubscriptionFormModal.tsx +++ b/components/SubscriptionFormModal.tsx @@ -1,6 +1,6 @@ -import PickerSheet, { type PickerItem } from "@/components/PickerSheet"; +import BrandPickerSheet from "@/components/BrandPickerSheet"; import SubscriptionIcon from "@/components/SubscriptionIcon"; -import { BRAND_ICONS } from "@/constants/brandIcons"; +import { ONBOARDING_CATEGORY_ORDER } from "@/constants/onboardingBrands"; import { useSubscriptions } from "@/context/SubscriptionsContext"; import "@/global.css"; import { @@ -27,36 +27,28 @@ import { View, } from "react-native"; -const CATEGORIES = [ - "Entertainment", - "AI Tools", - "Developer Tools", - "Design", - "Productivity", - "Cloud", - "Music", - "Other", -] as const; +// Same category taxonomy (and order) as the onboarding picker, for uniformity. +const CATEGORIES = ONBOARDING_CATEGORY_ORDER; const CATEGORY_COLORS: Record = { Entertainment: "#f5c542", + Gaming: "#d8c7f0", + Music: "#f5d5b8", "AI Tools": "#b8d4e3", + Productivity: "#b8e8d0", "Developer Tools": "#e8def8", Design: "#f7c8d0", - Productivity: "#b8e8d0", + "Health & Fitness": "#f6c3b0", + "Food & Delivery": "#c3ecc9", + "News & Reading": "#cfe0f5", + Shopping: "#f0d8b0", Cloud: "#c8d8f0", - Music: "#f5d5b8", + "Bills & Utilities": "#f6e0b0", Other: "#e5e0d0", }; const DEFAULT_COLOR = "#e8def8"; -const BRAND_ITEMS: PickerItem[] = BRAND_ICONS.map((brand) => ({ - value: brand.title, - label: brand.title, - keywords: brand.keywords.join(" "), -})); - const SubscriptionFormModal = ({ visible, onClose, @@ -554,15 +546,11 @@ const SubscriptionFormModal = ({ - setShowBrandPicker(false)} - renderLeading={(item) => } /> ); diff --git a/constants/onboardingBrands.ts b/constants/onboardingBrands.ts index 75a58a9..47372ad 100644 --- a/constants/onboardingBrands.ts +++ b/constants/onboardingBrands.ts @@ -297,3 +297,35 @@ export const ONBOARDING_BRANDS: OnboardingBrand[] = BRAND_ICONS.map((icon) => { category: preset?.category ?? inferCategory(icon), }; }); + +export interface OnboardingBrandGroup { + category: string; + brands: OnboardingBrand[]; +} + +/** + * Brands grouped under category headings in display order, optionally filtered + * by a search query (title match). Shared by the onboarding picker and the + * add-subscription "browse brands" sheet so both present brands identically. + */ +export const groupOnboardingBrands = (query = ""): OnboardingBrandGroup[] => { + const q = query.trim().toLowerCase(); + const matches = q + ? ONBOARDING_BRANDS.filter((b) => b.title.toLowerCase().includes(q)) + : ONBOARDING_BRANDS; + + const byCategory = new Map(); + for (const brand of matches) { + const list = byCategory.get(brand.category); + if (list) list.push(brand); + else byCategory.set(brand.category, [brand]); + } + + const rank = (category: string) => { + const i = ONBOARDING_CATEGORY_ORDER.indexOf(category); + return i === -1 ? ONBOARDING_CATEGORY_ORDER.length : i; + }; + return Array.from(byCategory.entries()) + .sort((a, b) => rank(a[0]) - rank(b[0])) + .map(([category, brands]) => ({ category, brands })); +}; From 5829f63e5b274b71caeab428f6152c19e53cb55b Mon Sep 17 00:00:00 2001 From: Sai Kumar Date: Sun, 19 Jul 2026 17:04:38 +1000 Subject: [PATCH 14/14] Trials: anchor billing to trial end + conversion check-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes a trial being tracked as if it renewed one cycle after the start date. A free trial's first charge lands when it converts (the trial-end date), and the user may or may not convert — so: - billing: firstChargeDate() anchors a trial's renewal to its end date; trialPendingConversion() flags a trial that has reached its end; the form now sets renewalDate from the trial end (not start + one cycle) and leaves confirmedThrough unset until conversion. - Detail: at/after trial end, a conversion check-in — "Did your trial convert to paid?" → Yes keeps it (starts the paid cycle from the trial end), No runs the cancel + savings celebration, Not yet snoozes. The renewal check-in is suppressed while on trial. - Card: "Free trial" badge + "Trial ends in N days" meta; a pulsing "Convert?" once ended. - Reminders: during a trial only the T-2/T-0 trial reminders fire (renewal reminders + generic check-in suppressed so they don't cluster on one day); T-0 copy nudges the keep-or-cancel decision. - Reconciler skips trials (no auto-assumed renewals before conversion). - Spend: free trials excluded from the monthly total (Home + Insights) until they convert; countsTowardSpend() + updated Insights disclosure. - Tests: firstChargeDate / trialPendingConversion / countsTowardSpend, and trials suppress renewal reminders. 60 pass. --- __tests__/billing.test.ts | 54 ++++++++++++++++++++ __tests__/reminders.test.ts | 9 +++- app/(tabs)/index.tsx | 24 +++++---- app/subscriptions/[id].tsx | 76 +++++++++++++++++++++++++++- components/Insights.tsx | 20 ++++---- components/SubscriptionCard.tsx | 55 +++++++++++++++++--- components/SubscriptionFormModal.tsx | 19 ++++--- context/SubscriptionsContext.tsx | 3 ++ lib/billing.ts | 29 +++++++++++ lib/reminders.ts | 18 +++++-- 10 files changed, 267 insertions(+), 40 deletions(-) diff --git a/__tests__/billing.test.ts b/__tests__/billing.test.ts index c53f6b1..178c3ec 100644 --- a/__tests__/billing.test.ts +++ b/__tests__/billing.test.ts @@ -1,5 +1,7 @@ import { addInterval, + countsTowardSpend, + firstChargeDate, getCycleLabel, getDaysUntilRenewal, getMonthlyEquivalent, @@ -8,9 +10,20 @@ import { pendingRenewal, reconcileConfirmedThrough, resolveNextRenewal, + trialPendingConversion, } from "@/lib/billing"; import dayjs from "dayjs"; +const sub = (overrides: Partial = {}): Subscription => ({ + id: "s1", + name: "Uber One", + price: 9.99, + billing: "Monthly", + billingCycle: "monthly", + status: "active", + ...overrides, +}); + describe("normalizeBillingCycle", () => { it("maps legacy labels", () => { expect(normalizeBillingCycle("Yearly")).toBe("annual"); @@ -289,3 +302,44 @@ describe("getCycleLabel", () => { expect(getCycleLabel("custom", 45)).toBe("Every 45 days"); }); }); + +describe("trial helpers", () => { + beforeEach(() => { + jest.useFakeTimers().setSystemTime(new Date("2026-07-16T12:00:00Z")); + }); + afterEach(() => jest.useRealTimers()); + + it("firstChargeDate uses trial end for a trial, else the start date", () => { + expect( + firstChargeDate( + sub({ isTrial: true, trialEndDate: "2026-07-23T00:00:00.000Z", startDate: "2026-07-16T00:00:00.000Z" }), + ), + ).toBe("2026-07-23T00:00:00.000Z"); + expect( + firstChargeDate(sub({ isTrial: false, startDate: "2026-07-16T00:00:00.000Z" })), + ).toBe("2026-07-16T00:00:00.000Z"); + }); + + it("trialPendingConversion is true only once the trial end has arrived", () => { + // ends in a week → still running. + expect( + trialPendingConversion(sub({ isTrial: true, trialEndDate: "2026-07-23T00:00:00.000Z" })), + ).toBe(false); + // ended today / in the past → needs a decision. + expect( + trialPendingConversion(sub({ isTrial: true, trialEndDate: "2026-07-16T00:00:00.000Z" })), + ).toBe(true); + expect( + trialPendingConversion(sub({ isTrial: true, trialEndDate: "2026-07-10T00:00:00.000Z" })), + ).toBe(true); + // not a trial → never pending. + expect(trialPendingConversion(sub({ isTrial: false }))).toBe(false); + }); + + it("countsTowardSpend excludes trials and inactive subs", () => { + expect(countsTowardSpend(sub({ isTrial: false, status: "active" }))).toBe(true); + expect(countsTowardSpend(sub({ isTrial: true, status: "active" }))).toBe(false); + expect(countsTowardSpend(sub({ isTrial: false, status: "paused" }))).toBe(false); + expect(countsTowardSpend(sub({ isTrial: false, status: "cancelled" }))).toBe(false); + }); +}); diff --git a/__tests__/reminders.test.ts b/__tests__/reminders.test.ts index a47f1ef..8183751 100644 --- a/__tests__/reminders.test.ts +++ b/__tests__/reminders.test.ts @@ -46,18 +46,23 @@ describe("buildReminders", () => { ]); }); - it("adds T-2 and T-0 trial reminders for trials", () => { + it("adds T-2 and T-0 trial reminders and suppresses renewal ones during a trial", () => { const reminders = buildReminders( baseSub({ isTrial: true, trialEndDate: "2026-07-16T10:00:00.000Z", - renewalDate: "2026-09-01T10:00:00.000Z", + renewalDate: "2026-07-16T10:00:00.000Z", }), "USD", ); const ids = reminders.map((r) => r.id); expect(ids).toContain("sub1::trial_2"); expect(ids).toContain("sub1::trial_0"); + // On a trial we rely on the trial reminders + in-app conversion check-in, + // not the generic renewal reminders — otherwise they'd cluster on one day. + expect(ids).not.toContain("sub1::renewal_3"); + expect(ids).not.toContain("sub1::renewal_1"); + expect(ids).not.toContain("sub1::checkin"); }); it("returns nothing for non-active subscriptions", () => { diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 6f051fa..dbf6f0e 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -123,18 +123,22 @@ export default function App() { // Real monthly outflow across mixed billing cycles (annual/quarterly/etc. // are normalized to a per-month average, so the total reflects everything). + // Free trials cost nothing until they convert, so they don't count toward + // current spend (they surface as a conversion check-in instead). const monthlyTotal = useMemo( () => - activeSubscriptions.reduce( - (total, sub) => - total + - getMonthlyEquivalent( - sub.price, - sub.billingCycle ?? "monthly", - sub.customIntervalDays, - ), - 0, - ), + activeSubscriptions + .filter((sub) => !sub.isTrial) + .reduce( + (total, sub) => + total + + getMonthlyEquivalent( + sub.price, + sub.billingCycle ?? "monthly", + sub.customIntervalDays, + ), + 0, + ), [activeSubscriptions], ); const yearlyTotal = monthlyTotal * 12; diff --git a/app/subscriptions/[id].tsx b/app/subscriptions/[id].tsx index 19b9727..22b8cc5 100644 --- a/app/subscriptions/[id].tsx +++ b/app/subscriptions/[id].tsx @@ -14,6 +14,7 @@ import { getMonthlyEquivalent, getNextRenewal, pendingRenewal, + trialPendingConversion, } from "@/lib/billing"; import { cardTint } from "@/lib/brand"; import { duplicateActiveNames, normalizeName } from "@/lib/duplicates"; @@ -62,6 +63,7 @@ const SubscriptionDetail = () => { const [highlightDate, setHighlightDate] = useState(false); const [justCompleted, setJustCompleted] = useState(false); const [checkinSnoozed, setCheckinSnoozed] = useState(false); + const [trialSnoozed, setTrialSnoozed] = useState(false); // Snapshot of the just-cancelled sub so the celebration survives the status // change (and any re-render) while the overlay is up. const [celebration, setCelebration] = useState<{ @@ -130,10 +132,16 @@ const SubscriptionDetail = () => { const isPaused = subscription.status === "paused"; const isCancelled = subscription.status === "cancelled"; + // A free trial that's reached its end date needs a keep-or-cancel decision. + // Takes priority over the renewal check-in (the trial IS the first charge). + const showTrialConversion = + isActive && trialPendingConversion(subscription) && !trialSnoozed; + // A charge that has come due since the user last confirmed a renewal — drives - // the "did it renew?" check-in. Date-confirm (dateAssumed) takes priority. + // the "did it renew?" check-in. Date-confirm (dateAssumed) takes priority; + // trials use the conversion check-in above instead. const pending = - isActive && !subscription.dateAssumed + isActive && !subscription.dateAssumed && !subscription.isTrial ? pendingRenewal( subscription.startDate, subscription.billingCycle ?? "monthly", @@ -206,6 +214,28 @@ const SubscriptionDetail = () => { cancelAndCelebrate(); }; + // Trial converted to paid: it's now a normal subscription. The trial end was + // the first charge, so confirm through it and set the next renewal one cycle + // on. Clearing isTrial makes it count toward spend. + const handleTrialConvert = () => { + const end = subscription.trialEndDate + ? dayjs(subscription.trialEndDate) + : dayjs(); + const next = addInterval( + end, + subscription.billingCycle ?? "monthly", + subscription.customIntervalDays, + ); + updateSubscription(subscription.id, { + isTrial: false, + trialEndDate: undefined, + confirmedThrough: end.toISOString(), + renewalDate: next.toISOString(), + }); + captureStatus("trial_converted"); + success(); + }; + const handleKeepDuplicate = () => { // It's intentional (a partner's / child's) — stop flagging it as a dup. updateSubscription(subscription.id, { duplicateAcknowledged: true }); @@ -389,6 +419,48 @@ const SubscriptionDetail = () => { )} + {showTrialConversion && ( + + + + Did your {subscription.name} trial convert to paid? + + + The trial ended{" "} + {subscription.trialEndDate + ? dayjs(subscription.trialEndDate).format("MMM D") + : ""} + . Keep it if you're now paying, or cancel to stop tracking + it. + + + + + + Yes, keep it + + + + + + + No, I cancelled + + + + setTrialSnoozed(true)} + className="px-2 py-2" + > + + Not yet + + + + + + )} + {pending && !checkinSnoozed && ( diff --git a/components/Insights.tsx b/components/Insights.tsx index cc09e7d..7f17695 100644 --- a/components/Insights.tsx +++ b/components/Insights.tsx @@ -53,19 +53,21 @@ const Insights = () => { const paused = subscriptions.filter((s) => s.status === "paused"); const cancelled = subscriptions.filter((s) => s.status === "cancelled"); - // Spend matches Home: all active subs (incl. those on trial, disclosed - // below). Savings = the recurring value of what the user has cancelled. - const monthlyTotal = active.reduce((sum, s) => sum + monthlyOf(s), 0); + // Spend matches Home: active subs that are actually being charged. Free + // trials cost nothing until they convert, so they're excluded (surfaced as + // the trial count below). Savings = recurring value of what was cancelled. + const paying = active.filter((s) => !s.isTrial); + const monthlyTotal = paying.reduce((sum, s) => sum + monthlyOf(s), 0); const savedMonthly = cancelled.reduce((sum, s) => sum + monthlyOf(s), 0); // Mutually-exclusive portfolio buckets that reconcile with the History // list: paying + onTrial + paused + cancelled === subscriptions.length. - const trialCount = active.filter((s) => s.isTrial).length; - const payingCount = active.length - trialCount; + const trialCount = active.length - paying.length; + const payingCount = paying.length; - // Top subscriptions by monthly-equivalent cost — each bar is a real active + // Top subscriptions by monthly-equivalent cost — each bar is a paying // subscription, so the chart reconciles with the list and the Home total. - const chart = active + const chart = paying .map((sub) => ({ label: sub.name, amount: monthlyOf(sub) })) .sort((a, b) => b.amount - a.amount) .slice(0, MAX_BARS); @@ -121,8 +123,8 @@ const Insights = () => { {stats.trialCount > 0 && ( - Includes {stats.trialCount} on a free trial — billing hasn't - started yet. + Excludes {stats.trialCount} on a free trial — not billed until they + convert. )} diff --git a/components/SubscriptionCard.tsx b/components/SubscriptionCard.tsx index 6963eb7..d413710 100644 --- a/components/SubscriptionCard.tsx +++ b/components/SubscriptionCard.tsx @@ -9,6 +9,7 @@ import { formatSubscriptionDateTime, } from "@/lib/utils"; import clsx from "clsx"; +import dayjs from "dayjs"; import { Pressable, Text, View } from "react-native"; /** Human renewal countdown for active subs. */ @@ -36,15 +37,26 @@ const SubscriptionCard = ({ dateAssumed, confirmedThrough, isDuplicate, + isTrial, + trialEndDate, }: SubscriptionCardProps) => { const { baseCurrency } = useCurrency(); const fallback = "Not provided"; const isActive = status === "active" || status === undefined; + + // Trial state: whole days to the trial end, and whether it has ended (needs a + // keep-or-cancel decision). A trial's "renewal" IS the conversion date. + const onTrial = isActive && !!isTrial && !!trialEndDate; + const trialDaysLeft = onTrial + ? dayjs(trialEndDate).startOf("day").diff(dayjs().startOf("day"), "day") + : null; + const trialEnded = onTrial && trialDaysLeft !== null && trialDaysLeft <= 0; + // A charge came due since the user last confirmed → "did it renew?" signal - // (date-confirm takes priority, so only when not still assumed). + // (date-confirm takes priority; trials use the conversion signal instead). const pendingCheckin = - isActive && !dateAssumed + isActive && !dateAssumed && !isTrial ? pendingRenewal( startDate, billingCycle ?? "monthly", @@ -59,15 +71,27 @@ const SubscriptionCard = ({ customIntervalDays, ) : null; - const isDueSoon = daysLeft !== null && daysLeft <= 3; + const isDueSoon = !onTrial && daysLeft !== null && daysLeft <= 3; - // Meta line: status for paused/cancelled, renewal countdown for active. + const trialCountdown = + trialDaysLeft === null + ? "" + : trialDaysLeft <= 0 + ? "Trial ends today" + : trialDaysLeft === 1 + ? "Trial ends tomorrow" + : `Trial ends in ${trialDaysLeft} days`; + + // Meta line: status for paused/cancelled, trial countdown while on trial, + // else the renewal countdown. const metaText = status === "paused" ? "Paused" : status === "cancelled" ? "Cancelled" - : renewalCountdown(daysLeft); + : onTrial + ? trialCountdown + : renewalCountdown(daysLeft); // Each card gets a soft, unique wash of its brand colour so the list is // scannable and memorable (colour-coding). A coloured left edge makes cards @@ -78,7 +102,7 @@ const SubscriptionCard = ({ ? "#dc2626" : dateAssumed && isActive ? "#E0952F" - : pendingCheckin + : trialEnded || pendingCheckin ? "#EA7A53" : null; @@ -135,6 +159,25 @@ const SubscriptionCard = ({ Confirm date + ) : trialEnded && !expanded ? ( + + + + Convert? + + + ) : onTrial && !expanded ? ( + + + Free trial + + ) : pendingCheckin && !expanded ? ( diff --git a/components/SubscriptionFormModal.tsx b/components/SubscriptionFormModal.tsx index 6001622..9a4187d 100644 --- a/components/SubscriptionFormModal.tsx +++ b/components/SubscriptionFormModal.tsx @@ -142,8 +142,14 @@ const SubscriptionFormModal = ({ const commit = (acknowledgeDuplicate = false) => { const startIso = startDate.toISOString(); + const trialEndIso = isTrial + ? dayjs().add(parsedTrialDays, "day").toISOString() + : undefined; + // The first charge lands at the trial end (conversion) for a trial, + // otherwise at the start date — so the renewal we track is that date, not + // start + one cycle. const nextRenewal = resolveNextRenewal( - startIso, + trialEndIso ?? startIso, billingCycle, customIntervalDays, ); @@ -162,13 +168,12 @@ const SubscriptionFormModal = ({ // The user picked/confirmed the date here, so it's no longer an // assumption — clears any quick-add nudge flag. dateAssumed: false, - // Re-anchor the renewal check-in to the confirmed first-payment date, so - // charges due since then surface as "did it renew?". - confirmedThrough: startIso, + // Non-trial: the start is the confirmed first payment, so charges due + // since then surface as "did it renew?". Trial: nothing is charged yet — + // leave it unset until the user confirms the trial converted. + confirmedThrough: isTrial ? undefined : startIso, isTrial, - trialEndDate: isTrial - ? dayjs().add(parsedTrialDays, "day").toISOString() - : undefined, + trialEndDate: trialEndIso, color: category ? CATEGORY_COLORS[category] : DEFAULT_COLOR, }; diff --git a/context/SubscriptionsContext.tsx b/context/SubscriptionsContext.tsx index 6b18604..bc8f786 100644 --- a/context/SubscriptionsContext.tsx +++ b/context/SubscriptionsContext.tsx @@ -68,6 +68,9 @@ export const SubscriptionsProvider = ({ let changed = false; for (const s of current) { if (s.status !== "active") continue; + // Trials aren't charged until they convert — don't auto-assume renewals; + // the in-app trial check-in resolves them instead. + if (s.isTrial) continue; const advanced = reconcileConfirmedThrough( s.startDate, s.billingCycle ?? "monthly", diff --git a/lib/billing.ts b/lib/billing.ts index 712249f..890d304 100644 --- a/lib/billing.ts +++ b/lib/billing.ts @@ -126,6 +126,35 @@ export const getDaysUntilRenewal = ( return next.diff(dayjs().startOf("day"), "day"); }; +/** + * A subscription's first real charge date: the trial-end date while it's on a + * free trial (the charge lands when the trial converts), otherwise the start + * date. Anything that anchors billing (renewal display, reminders) uses this so + * a trial's "renewal" is the conversion date, not start + one cycle. + */ +export const firstChargeDate = (sub: Subscription): string | undefined => + sub.isTrial && sub.trialEndDate ? sub.trialEndDate : sub.startDate; + +/** + * True once a free trial has reached or passed its end date and needs a + * keep-or-cancel decision — we can't detect the conversion charge, so we ask. + */ +export const trialPendingConversion = ( + sub: Subscription, + now: dayjs.Dayjs = dayjs(), +): boolean => { + if (!sub.isTrial || !sub.trialEndDate) return false; + const end = dayjs(sub.trialEndDate).startOf("day"); + return end.isValid() && !end.isAfter(now.startOf("day")); +}; + +/** + * Whether a subscription's price counts toward current spend. Active non-trial + * subs count; a free trial costs nothing until the user confirms it converted. + */ +export const countsTowardSpend = (sub: Subscription): boolean => + (sub.status === "active" || sub.status === undefined) && !sub.isTrial; + /** Days after a renewal before the reconciler auto-assumes it renewed. */ export const RENEWAL_GRACE_DAYS = 4; diff --git a/lib/reminders.ts b/lib/reminders.ts index b1c2991..9e52ab7 100644 --- a/lib/reminders.ts +++ b/lib/reminders.ts @@ -42,13 +42,17 @@ export const buildReminders = ( const reminders: PlannedReminder[] = []; const price = formatCurrency(sub.price, baseCurrency); + const onTrial = !!sub.isTrial && !!sub.trialEndDate; const nextRenewal = getNextRenewal( sub.renewalDate ?? sub.startDate, sub.billingCycle ?? "monthly", sub.customIntervalDays, ); - if (nextRenewal) { + // During a free trial we rely on the trial reminders below (T-2/T-0) and the + // in-app conversion check-in, not the generic renewal reminders — otherwise + // both would cluster around the same trial-end date. + if (nextRenewal && !onTrial) { for (const lead of RENEWAL_LEAD_DAYS) { const fireAt = atReminderHour(nextRenewal.subtract(lead, "day")); if (fireAt.isAfter(now)) { @@ -76,7 +80,7 @@ export const buildReminders = ( } } - if (sub.isTrial && sub.trialEndDate) { + if (onTrial) { const trialEnd = dayjs(sub.trialEndDate); if (trialEnd.isValid()) { for (const lead of TRIAL_LEAD_DAYS) { @@ -86,8 +90,14 @@ export const buildReminders = ( id: `${sub.id}::trial_${lead}`, date: fireAt.toDate(), subscriptionId: sub.id, - title: `${sub.name} trial ends ${leadLabel(lead)}`, - body: `Cancel before you're charged ${price}.`, + title: + lead <= 0 + ? `${sub.name} trial ends today` + : `${sub.name} trial ends ${leadLabel(lead)}`, + body: + lead <= 0 + ? `Keep it (you'll be charged ${price}) or cancel — confirm in the app.` + : `Cancel before you're charged ${price}.`, }); } }