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