Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions __tests__/billing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
106 changes: 106 additions & 0 deletions __tests__/migrations.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof migrateIfNeeded>[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,
);
});
});
16 changes: 16 additions & 0 deletions app/(auth)/sign-in.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,22 @@ export default function SignIn() {
>
<ScrollView className="auth-scroll">
<View className="auth-content">
<Pressable
className="mb-2 flex-row items-center gap-1 self-start py-2"
onPress={() =>
router.canGoBack() ? router.back() : router.replace("/")
}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel="Back to app"
Comment on lines +150 to +151

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use an accurate accessibility label for conditional back navigation.

Because the handler may return to another authentication screen, "Back to app" does not describe the actual action.

  • app/(auth)/sign-in.tsx#L150-L151: change the label to "Go back" or "Back".
  • app/(auth)/sign-up.tsx#L131-L132: change the label to "Go back" or "Back".
📍 Affects 2 files
  • app/(auth)/sign-in.tsx#L150-L151 (this comment)
  • app/(auth)/sign-up.tsx#L131-L132
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/`(auth)/sign-in.tsx around lines 150 - 151, Update the conditional back
navigation accessibility labels in app/(auth)/sign-in.tsx lines 150-151 and
app/(auth)/sign-up.tsx lines 131-132 from “Back to app” to an accurate generic
label such as “Go back” or “Back”.

>
<Text className="text-2xl font-sans-medium text-muted-foreground">
</Text>
<Text className="text-sm font-sans-semibold text-muted-foreground">
Back
</Text>
</Pressable>
Comment on lines +144 to +159

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Expose back navigation from both verification screens.

The back button is placed only in each normal form branch, while both verification branches return before reaching it.

  • app/(auth)/sign-in.tsx#L144-L159: also render the control for the needs_client_trust view.
  • app/(auth)/sign-up.tsx#L125-L140: also render the control for the email-verification view.
📍 Affects 2 files
  • app/(auth)/sign-in.tsx#L144-L159 (this comment)
  • app/(auth)/sign-up.tsx#L125-L140
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/`(auth)/sign-in.tsx around lines 144 - 159, The back-navigation control
is unreachable in both verification flows. Update the `needs_client_trust`
branch in `sign-in.tsx` and the email-verification branch in `sign-up.tsx` to
render the same back control, preserving its existing router behavior and
accessibility properties; the existing controls at app/(auth)/sign-in.tsx lines
144-159 and app/(auth)/sign-up.tsx lines 125-140 should remain consistent.

<View className="auth-brand-block">
<View className="auth-logo-wrap">
<View className="auth-logo-mark">
Expand Down
16 changes: 16 additions & 0 deletions app/(auth)/sign-up.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,22 @@ export default function SignUp() {
>
<ScrollView className="auth-scroll">
<View className="auth-content">
<Pressable
className="mb-2 flex-row items-center gap-1 self-start py-2"
onPress={() =>
router.canGoBack() ? router.back() : router.replace("/")
}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel="Back to app"
>
<Text className="text-2xl font-sans-medium text-muted-foreground">
</Text>
<Text className="text-sm font-sans-semibold text-muted-foreground">
Back
</Text>
</Pressable>
<View className="auth-brand-block">
<View className="auth-logo-wrap">
<View className="auth-logo-mark">
Expand Down
39 changes: 39 additions & 0 deletions app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -242,6 +251,36 @@ export default function App() {
}
/>
</View>
{assumedCount > 0 && (
<FadeInUp>
<PressableScale onPress={() => router.push("/subscriptions")}>
<View
className="mb-4 flex-row items-center gap-3 rounded-2xl border p-4"
style={{
borderColor: "#E0952F",
backgroundColor: "rgba(224,149,47,0.08)",
}}
>
<PulsingDot size={10} />
<View className="flex-1">
<Text className="text-sm font-sans-bold text-primary">
{assumedCount} subscription
{assumedCount === 1 ? "" : "s"} need a renewal date
</Text>
<Text className="mt-0.5 text-xs font-sans-medium text-muted-foreground">
Confirm them so reminders land on the right day.
</Text>
</View>
<Text
className="text-base font-sans-bold"
style={{ color: "#E0952F" }}
>
Review ›
</Text>
</View>
</PressableScale>
</FadeInUp>
)}
{subscriptions.length === 0 ? (
<View className="items-center py-6">
<Text className="home-empty-state">
Expand Down
29 changes: 16 additions & 13 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -139,18 +140,20 @@ export default function RootLayout() {
}

return (
<PostHogProvider
apiKey={posthogApiKey}
options={{ host: process.env.EXPO_PUBLIC_POSTHOG_HOST }}
>
<ClerkProvider publishableKey={publishableKey} tokenCache={tokenCache}>
<PostHogUserIdentifier />
<CurrencyProvider>
<SubscriptionsProvider>
<RootLayoutContent />
</SubscriptionsProvider>
</CurrencyProvider>
</ClerkProvider>
</PostHogProvider>
<GestureHandlerRootView style={{ flex: 1 }}>
<PostHogProvider
apiKey={posthogApiKey}
options={{ host: process.env.EXPO_PUBLIC_POSTHOG_HOST }}
>
<ClerkProvider publishableKey={publishableKey} tokenCache={tokenCache}>
<PostHogUserIdentifier />
<CurrencyProvider>
<SubscriptionsProvider>
<RootLayoutContent />
</SubscriptionsProvider>
</CurrencyProvider>
</ClerkProvider>
</PostHogProvider>
</GestureHandlerRootView>
);
}
Loading