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__/migrations.test.ts b/__tests__/migrations.test.ts index b560e05..4c5cd7b 100644 --- a/__tests__/migrations.test.ts +++ b/__tests__/migrations.test.ts @@ -48,7 +48,14 @@ 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); + // 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); }); }); @@ -90,12 +97,12 @@ describe("rowToSubscription date_assumed mapping", () => { notes: null, status: "active", price: 15.49, - currency: "USD", billing_cycle: "monthly", custom_interval_days: null, is_trial: 0, date_assumed: 0, confirmed_through: null, + duplicate_acknowledged: 0, trial_end_date: null, start_date: null, next_renewal_date: null, @@ -112,4 +119,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/__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.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/(tabs)/index.tsx b/app/(tabs)/index.tsx index cbe12d5..dbf6f0e 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"; @@ -22,7 +23,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 +33,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 +41,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(); @@ -114,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; @@ -137,7 +150,6 @@ export default function App() { id: subscription.id, name: subscription.name, price: subscription.price, - currency: subscription.currency, daysLeft: getDaysUntilRenewal( subscription.renewalDate ?? subscription.startDate, @@ -367,23 +379,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/(tabs)/subscriptions.tsx b/app/(tabs)/subscriptions.tsx index 13c398c..5faf904 100644 --- a/app/(tabs)/subscriptions.tsx +++ b/app/(tabs)/subscriptions.tsx @@ -2,15 +2,19 @@ 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 { useRouter } from "expo-router"; +import clsx from "clsx"; +import { useFocusEffect, useRouter } from "expo-router"; import { styled } from "nativewind"; -import { useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { FlatList, KeyboardAvoidingView, Platform, + Pressable, + ScrollView, Text, TextInput, View, @@ -20,34 +24,159 @@ 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 } = useSubscriptions(); + 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( + useCallback(() => { + refresh(); + }, [refresh]), + ); const duplicateNames = useMemo( () => duplicateActiveNames(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 ( @@ -67,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" diff --git a/app/_layout.tsx b/app/_layout.tsx index d24fbdd..1dc49e9 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,11 +1,13 @@ 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, 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,19 +65,20 @@ function PostHogUserIdentifier() { } function RootLayoutContent() { - const { isLoaded: authLoaded } = useAuth(); 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); } }, ); @@ -83,7 +86,7 @@ function RootLayoutContent() { active = false; sub.remove(); }; - }, [router]); + }, []); const [fontsLoaded, fontError] = useFonts({ "sans-regular": require("../assets/fonts/PlusJakartaSans-Regular.ttf"), @@ -94,16 +97,39 @@ 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; } - if (fontsLoaded && authLoaded) { + // 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, authLoaded]); + }, [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 (!fontsLoaded) { + if (!ready) { return null; } diff --git a/app/onboarding.tsx b/app/onboarding.tsx index 73e9f06..cf4985c 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, + groupOnboardingBrands, +} 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,18 @@ 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. 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(() => { if (step !== "analyzing") return; @@ -224,7 +236,6 @@ const Onboarding = () => { addSubscription({ name: brand.title, price, - currency: baseCurrency, billingCycle: cycle, category: brand.category, status: "active", @@ -374,32 +385,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/app/subscriptions/[id].tsx b/app/subscriptions/[id].tsx index 9a2f83d..22b8cc5 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"; @@ -7,8 +8,16 @@ 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 { normalizeName } from "@/lib/duplicates"; +import { + addInterval, + getCycleLabel, + getMonthlyEquivalent, + getNextRenewal, + pendingRenewal, + trialPendingConversion, +} from "@/lib/billing"; +import { cardTint } from "@/lib/brand"; +import { duplicateActiveNames, normalizeName } from "@/lib/duplicates"; import { formatCurrency, formatStatusLabel, @@ -54,6 +63,13 @@ 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<{ + name: string; + monthlySaved: number; + } | null>(null); const goBack = () => { if (router.canGoBack()) { @@ -116,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", @@ -128,13 +150,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) => @@ -161,18 +180,68 @@ 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(); }; - 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(); + }; + + // 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 }); + captureStatus("duplicate_kept"); + }; + const handlePauseResume = () => { if (isPaused) { resumeSubscription(subscription.id); @@ -197,10 +266,7 @@ const SubscriptionDetail = () => { { text: "Cancel subscription", style: "destructive", - onPress: () => { - cancelSubscription(subscription.id); - captureStatus("subscription_cancelled"); - }, + onPress: cancelAndCelebrate, }, ], ); @@ -211,7 +277,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,16 +316,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 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. - - - - Delete this one - - - + + + + + Keep it + + + + + + + Delete this one + + + + )} @@ -267,7 +350,7 @@ const SubscriptionDetail = () => { {/* Hero */} @@ -336,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 && ( @@ -477,6 +602,14 @@ const SubscriptionDetail = () => { subscription={subscription} highlightDate={highlightDate} /> + + setCelebration(null)} + /> ); }; 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/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..f375602 --- /dev/null +++ b/components/Confetti.tsx @@ -0,0 +1,109 @@ +import { useEffect, 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); + // 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; + 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/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 5fe9c0b..d413710 100644 --- a/components/SubscriptionCard.tsx +++ b/components/SubscriptionCard.tsx @@ -2,12 +2,14 @@ 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, 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. */ @@ -22,7 +24,6 @@ const SubscriptionCard = ({ name, price, billing, - color, category, plan, renewalDate, @@ -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,40 @@ 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 + // that need attention pop out pre-attentively (duplicate > confirm-date > + // renewed?). + const tint = cardTint(name); + const warningColor = isDuplicate + ? "#dc2626" + : dateAssumed && isActive + ? "#E0952F" + : trialEnded || 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, ]} > @@ -119,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 88e98e4..9a4187d 100644 --- a/components/SubscriptionFormModal.tsx +++ b/components/SubscriptionFormModal.tsx @@ -1,7 +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 { useCurrency } from "@/context/CurrencyContext"; +import { ONBOARDING_CATEGORY_ORDER } from "@/constants/onboardingBrands"; import { useSubscriptions } from "@/context/SubscriptionsContext"; import "@/global.css"; import { @@ -28,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, @@ -66,7 +57,6 @@ const SubscriptionFormModal = ({ highlightDate, }: SubscriptionFormModalProps) => { const isEdit = !!subscription; - const { baseCurrency } = useCurrency(); const { subscriptions } = useSubscriptions(); const [name, setName] = useState(""); @@ -150,10 +140,16 @@ const SubscriptionFormModal = ({ customIntervalDays, ); - const commit = () => { + 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, ); @@ -161,7 +157,6 @@ const SubscriptionFormModal = ({ const draft: SubscriptionDraft = { name: trimmedName, price: parsedPrice, - currency: baseCurrency, paymentMethod: paymentMethod.trim() || undefined, billingCycle, customIntervalDays, @@ -173,16 +168,31 @@ 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, }; + // "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; + } 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); // Edits close immediately; a new add offers "add another" to keep momentum. if (isEdit) { @@ -263,25 +273,26 @@ const SubscriptionFormModal = ({ You already track {trimmedName} - 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. + 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. { - setShowDuplicatePrompt(false); - commit(); - }} + onPress={() => setShowDuplicatePrompt(false)} > - Add anyway + Rename it setShowDuplicatePrompt(false)} + onPress={() => { + setShowDuplicatePrompt(false); + commit(true); + }} > - Go back & rename + Add it anyway @@ -540,15 +551,11 @@ const SubscriptionFormModal = ({ - setShowBrandPicker(false)} - renderLeading={(item) => } /> ); diff --git a/constants/brandIcons.ts b/constants/brandIcons.ts index dff1d12..2881519 100644 --- a/constants/brandIcons.ts +++ b/constants/brandIcons.ts @@ -577,6 +577,487 @@ 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": "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", @@ -747,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 1a0dfa1..47372ad 100644 --- a/constants/onboardingBrands.ts +++ b/constants/onboardingBrands.ts @@ -58,18 +58,144 @@ 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" }, + // 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" }; +// 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. @@ -171,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 })); +}; diff --git a/context/SubscriptionsContext.tsx b/context/SubscriptionsContext.tsx index 22cc4b2..bc8f786 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; } @@ -66,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", @@ -190,6 +195,7 @@ export const SubscriptionsProvider = ({ resumeSubscription, cancelSubscription, getSubscription, + refresh, clearAllData, }), [ @@ -202,6 +208,7 @@ export const SubscriptionsProvider = ({ resumeSubscription, cancelSubscription, getSubscription, + refresh, clearAllData, ], ); 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..f68632b 100644 --- a/db/subscriptionsRepo.ts +++ b/db/subscriptionsRepo.ts @@ -15,12 +15,12 @@ export interface SubscriptionRow { notes: string | null; status: string; price: number; - currency: string; billing_cycle: string; custom_interval_days: number | null; 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; @@ -47,13 +47,13 @@ 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), 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, @@ -86,12 +86,14 @@ 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, - is_trial, date_assumed, confirmed_through, trial_end_date, start_date, - next_renewal_date, created_at, updated_at + 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ id, @@ -103,12 +105,12 @@ 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, input.dateAssumed ? 1 : 0, input.confirmedThrough ?? null, + input.duplicateAcknowledged ? 1 : 0, input.trialEndDate ?? null, input.startDate ?? null, input.renewalDate ?? null, @@ -130,12 +132,12 @@ const PATCH_COLUMNS: Record = { notes: "notes", status: "status", price: "price", - currency: "currency", billingCycle: "billing_cycle", customIntervalDays: "custom_interval_days", 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 +156,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/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/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); 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/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}.`, }); } } diff --git a/package-lock.json b/package-lock.json index c340b02..a5eb729 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,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", @@ -32,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", @@ -49,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" }, @@ -61,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", @@ -9869,6 +9873,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", @@ -10658,6 +10671,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", @@ -12135,6 +12157,21 @@ "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-constants": { "version": "18.0.13", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz", @@ -12897,6 +12934,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", @@ -13272,21 +13318,6 @@ "node": ">=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", @@ -14566,6 +14597,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", @@ -17289,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", @@ -19547,6 +19598,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", @@ -21340,6 +21404,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", @@ -21911,6 +21984,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 2877cb4..db941ef 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", @@ -42,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", @@ -59,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" }, @@ -71,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 653143e..e3692e5 100644 --- a/scripts/generate-brand-icons.mjs +++ b/scripts/generate-brand-icons.mjs @@ -80,6 +80,74 @@ 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" }, + + // --- 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) ------- @@ -103,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; @@ -151,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. diff --git a/type.d.ts b/type.d.ts index 1e71b63..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. */ @@ -46,6 +47,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; @@ -77,7 +80,6 @@ declare global { id: string; name: string; price: number; - currency?: string; daysLeft: number; }