Tripwire

Catch forgotten trials and surprise renewals before they hit your card.

Live: tripwire-wimmboo2.vercel.app


What is it?

A subscription tracker that surfaces upcoming renewals and active trials by urgency — overdue, urgent (2 days), soon (7 days), or calm. Each subscription gets a color-coded countdown so you know what's about to charge your card. Works with or without an account.

What can it do?

  • Urgency-first countdown — each row shows a human-readable label ("renews tomorrow", "3 days overdue") with a colored left-edge bar and pill badge
  • Monthly spend normalization — weekly and yearly costs are converted to monthly equivalent so you see a unified total
  • Active trial tracker — subscriptions flagged as free trials are counted separately
  • Google sign-in — OAuth through Supabase Auth with server-side session management
  • Guest mode — skip the sign-in and use the app on-device; subscriptions persist in localStorage with a tw-guest cookie to keep access across reloads
  • Slide-in drawer for add/edit — service name, cost (USD), billing cycle (weekly/monthly/yearly), renewal date picker, and a free trial toggle
  • Delete confirmation dialog — with error handling if the server call fails
  • Animated stat counters — monthly spend smoothly interpolates with requestAnimationFrame cubic easing when values change
  • Staggered list entry — initial render staggers row animations (snaps off after 350ms so edits don't re-animate everything)
  • Accessibility — ARIA roles on modals (dialog, alertdialog), toggles (switch), keyboard Escape to close, focus-visible outlines, prefers-reduced-motion respected throughout

How does it work?

Browser                                 Next.js Server                      Supabase
───────                                 ──────────────                      ────────
  │                                        │                                  │
  │  request /dashboard                    │                                  │
  │ ─────────────────────────────────────> │                                  │
  │                                        │  middleware: refresh session     │
  │                                        │  check tw-guest cookie           │
  │                                        │  redirect /login if no auth      │
  │                                        │                                  │
  │                                        │  page.tsx (Server Component)     │
  │                                        │  createClient() → getUser()      │
  │                                        │  .from("subscriptions") ────────>│
  │                                        │  .eq("user_id", user.id)         │
  │                                        │  .order("renewal_date")          │
  │                                        │  <──────── subscription rows ────│
  │                                        │                                  │
  │  <── HTML with SSG data ────────────── │                                  │
  │                                        │                                  │
  │  DashboardClient hydrates              │                                  │
  │    │                                   │                                  │
  │    │  [add/edit/delete via]            │                                  │
  │    │  Server Actions ────────────────> │  actions.ts                      │
  │    │                                   │  .insert/.update/.delete ───────>│
  │    │                                   │  revalidatePath("/dashboard")    │
  │    │  <── fresh server data ────────── │                                  │

Two data paths, one component. DashboardClient receives subscriptions as a prop from the server component OR reads from localStorage depending on a guestMode flag. Mutations branch at the same boundary: Server Actions for auth'd users, localStorage functions for guests. The middleware checks a tw-guest cookie to allow unauthenticated access to /dashboard.

Why server-first with Server Actions instead of client-side Supabase queries? Server Actions guarantee the auth check happens on the server where the session is validated via cookies (not susceptible to client-side token tampering). revalidatePath triggers an automatic re-fetch — no optimistic UI reconciliation needed. Guest mode is the exception, not the fallback: it's an explicit user choice ("skip for now") that sets a cookie and switches the data source.

Urgency is derived, not stored. daysUntil() compares the renewal date to midnight today. The urgency tier (overdue, urgent, soon, calm) and the human-readable countdown string are computed client-side from that number. No cron jobs or background refreshes needed — it recalculates on every render.

Billing cycle normalization converts weekly (× 52/12) and yearly (÷ 12) costs to a monthly-equivalent figure. The raw cycle and cost are displayed per-row — only the aggregate stat card uses normalized numbers.

What did I personally figure out?

OAuth redirects break on reverse proxies. The callback route (/auth/callback) needs to know the origin to construct the redirect URL. On Render (or any PaaS behind a reverse proxy), request.nextUrl.origin returns the internal host, not the public one. The fix was checking x-forwarded-host and x-forwarded-proto headers first, with a fallback to NEXT_PUBLIC_SITE_URL — the progression is visible in the git history: hardcoded → dynamic origin → x-forwarded header chain.

Supabase SSR cookie types don't align with Next.js 15. The @supabase/ssr setAll callback expects CookieToSet objects but the body calls request.cookies.set() and supabaseResponse.cookies.set() which accept plain CookieOptions. The explicit type annotation in middleware.ts (type CookieToSet = { name: string; value: string; options?: CookieOptions }) was added to fix a build-time implicit any error.

localStorage as guest storage has edge cases. Private browsing can throw on setItem. The localStorage helpers silently swallow the error with a comment noting private mode. The crypto.randomUUID() call in the UUID generator also has a fallback for environments where crypto isn't available (server-rendered hydration).

Staggered animations need a kill switch. Staggering all rows on first load looks good, but re-staggering after every mutation is disorienting. A 350ms setTimeout flips a stagger flag that controls whether AnimatePresence uses initial or mode="popLayout" only. The spend stat also gets a 100ms delay so it animates after the row stagger settles.

Guest mode adds complexity to auth middleware. Three paths to route: no user + no guest cookie → /login; no user + guest cookie → /dashboard; authenticated user on /login or / → /dashboard. The middleware has to check all three before falling through, and the dashboard page itself does a second auth check because the middleware's session refresh is best-effort (it can't block on getUser() if the cookies haven't been set yet).

How do I run it?

Prerequisites

  • Node.js 18+
  • A Supabase project with:
    • The subscriptions table (schema below)
    • Row-Level Security enabled (policies on user_id)
    • Google OAuth configured (Auth → Providers → Google)

Database setup

Run this in the Supabase SQL editor:

create table subscriptions (
  id uuid default gen_random_uuid() primary key,
  user_id uuid not null references auth.users(id) on delete cascade,
  service_name text not null,
  cost numeric(10,2) not null default 0,
  billing_cycle text not null check (billing_cycle in ('weekly', 'monthly', 'yearly')) default 'monthly',
  renewal_date date not null,
  is_trial boolean not null default false,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

-- Row-Level Security
alter table subscriptions enable row level security;

create policy "Users can view their own subscriptions"
  on subscriptions for select
  using (auth.uid() = user_id);

create policy "Users can insert their own subscriptions"
  on subscriptions for insert
  with check (auth.uid() = user_id);

create policy "Users can update their own subscriptions"
  on subscriptions for update
  using (auth.uid() = user_id);

create policy "Users can delete their own subscriptions"
  on subscriptions for delete
  using (auth.uid() = user_id);

Environment

Copy .env.local.example to .env.local and fill in your Supabase project values:

NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key

Important: The code reads NEXT_PUBLIC_SUPABASE_ANON_KEY. Make sure your .env.local uses that exact name, not NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY.

Google OAuth setup

  1. Supabase Dashboard → Authentication → Providers → Google → Enable
  2. Create OAuth credentials in Google Cloud Console
  3. Add the authorized redirect URI: https://your-project.supabase.co/auth/v1/callback
  4. Paste the Client ID and Secret into Supabase's Google provider settings
  5. In Supabase Auth → URL Configuration, add your production URL to Redirect URLs

Install and run

npm install
npm run dev     # http://localhost:3000

For production:

npm run build
npm start

Stack

Layer Technology
Framework Next.js 15 (App Router, Turbopack)
Runtime React 19 (Server + Client Components)
Language TypeScript
Styling Tailwind CSS 3
Animation Framer Motion
Backend/Auth/DB Supabase (Postgres + GoTrue + RLS)
Fonts IBM Plex Sans, Crimson Text
Hosting Vercel

License

MIT — see LICENSE.

Share this project:

Updates