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-guestcookie 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
requestAnimationFramecubic 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-motionrespected 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
subscriptionstable (schema below) - Row-Level Security enabled (policies on
user_id) - Google OAuth configured (Auth → Providers → Google)
- The
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
- Supabase Dashboard → Authentication → Providers → Google → Enable
- Create OAuth credentials in Google Cloud Console
- Add the authorized redirect URI:
https://your-project.supabase.co/auth/v1/callback - Paste the Client ID and Secret into Supabase's Google provider settings
- 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.
Log in or sign up for Devpost to join the conversation.