← back to blog
building xeve

One Day, Five Bugs, Three Features — What Shipping Fast Actually Looks Like

7 min read

Today was one of those days where you ship a major feature, publish a new package, and then discover that three releases of your Mac app have been silently failing to build — meaning no user has received an update in a week. Here is what happened, what we learned, and why it matters.

155 Files to Add a Toggle Switch

xeve has been dark-only since launch. Every color was hardcoded — bg-[#151517] in Tailwind classes, fill: '#ddd' in Recharts props, #222 in CSS files. We counted: 1,874 hex color references across 148 files.

The insight that made light mode feasible was architectural, not visual. Tailwind CSS v4's @theme directive can reference CSS custom properties. We defined 13 semantic tokens (page, surface, border, text-primary through text-muted, accent) as CSS variables that swap via [data-theme="light"]. Tailwind auto-generates utilities — bg-page, text-text-primary, border-border.

The bulk of the work was mechanical find-and-replace: bg-[#0f0e12] becomes bg-page, text-[#e5e5e5] becomes text-text-primary. We wrote a shell script that ran sed across all TSX files in one pass. The tricky part was Recharts — charts use JavaScript color props, not CSS classes. For those, we built a useThemeColors() hook that reads computed CSS variables and returns them as a plain object.

Lesson: The migration strategy matters more than the color palette. Getting the CSS architecture right meant 90% of the work was automatable. If we had tried to do this with Tailwind's built-in dark mode classes, it would have required dark: prefixes on every single color utility — thousands of manual additions. CSS custom properties with @theme integration turned a month of work into a day.

The Lock Screen That Tracked 8 Hours a Day

After shipping light mode, we noticed something wrong on the dashboard. The Timeline page showed 10 hours and 27 minutes of screen time. The #1 app was "loginwindow" with 8 hours and 16 minutes. That is macOS's lock screen process.

The Mac tracker already had guards against tracking loginwindow — the handleAppSwitch() function explicitly returns early when it sees com.apple.loginwindow. So why was it showing up?

Because the guard only prevents new sessions from being created. Old sessions that were tracked before we added the guard were already in Supabase. When the app starts, it fetches today's historical sessions from the database — and those old loginwindow sessions came right back.

The fix was three layers deep:

  1. Supabase query filter — exclude loginwindow, ScreenSaverEngine, and UserNotificationCenter from historical data fetch
  2. Live session filter — exclude the same apps when aggregating live tracking data
  3. Display filter — exclude them from the menu bar top apps list as a safety net
  4. Web dashboard filter — add .neq('app_name', 'loginwindow') to every app_sessions query across 20 page files

Lesson: Guards that prevent bad data from being created do not fix bad data that already exists. When you add a filter to a write path, add the same filter to every read path. The data in your database is the source of truth, and it has a long memory.

Three Releases Nobody Received

After pushing the loginwindow fix, we tagged macos-v0.4.3 and checked Sparkle auto-updates. The dialog said: "You're up to date! xeve 0.4.0 is currently the newest version available."

Version 0.4.0 was from three days ago. Versions 0.4.1, 0.4.2, and 0.4.3 had all shipped — or so we thought. Checking GitHub Actions told the real story: all three builds had failed.

The error was in NowPlayingTracker.swift, a file that handles media detection for Plex integration:

.eq("user_id", userId)     // Missing argument label
.eq("provider", "plex")    // Missing argument label
.eq("is_active", true)     // Missing argument label

The Supabase Swift SDK changed its API — .eq() now requires a value: label on the second parameter. The code compiled locally because the local Swift Package Manager cache had the old SDK version. CI resolved fresh dependencies and got the new SDK.

Three releases failed. No appcast.xml was generated. Sparkle had nothing new to offer. Every user was stuck on 0.4.0 for a week, and we had no idea until we manually checked "Check for updates."

Lesson: If your CI pipeline fails, you need to know immediately — not when a user reports that updates stopped working. We had no alerting on the release workflow. A failed tag push disappeared silently. We have since realized we need one of two things: a Slack notification on release failure, or a simple health check that verifies the appcast.xml version matches the latest tag.

The deeper lesson: lock your dependencies in CI. Using ^2.49.4 in Package.swift means CI resolves whatever the latest compatible version is. If the SDK ships a breaking change (even a label change qualifies as breaking when the compiler enforces it), your release pipeline breaks with no code change on your end. Pin exact versions or use a lockfile.

Building an MCP Server in 300 Lines

Between the loginwindow fix and the Sparkle debugging, we also shipped an MCP server. The Model Context Protocol lets AI assistants query external data sources — so you can ask Claude "how much did I code today?" and get a real answer from your xeve data.

The entire server is 300 lines of TypeScript with two dependencies: @modelcontextprotocol/sdk and @supabase/supabase-js. It exposes 9 tools that map directly to xeve's existing Supabase RPC functions. No new backend code was needed — the MCP server is a thin wrapper over the same queries the web dashboard uses.

The architecture decision that made this easy: Supabase RPC functions for all aggregations. Because the complex queries (app usage summaries, category breakdowns, correlations) live in PostgreSQL functions, the MCP server just calls supabase.rpc('get_app_usage_summary', { start_time, end_time }). The business logic is in the database, not in any application layer, so any new client gets it for free.

We published it to npm (xeve-mcp-server) and GitHub (xeveio/xeve-mcp-server) in the same session. Total time from "let's add MCP support" to "it's on npm" was about an hour.

Lesson: If your backend logic lives in the database (RPC functions, views, stored procedures), adding new clients is trivially easy. The web dashboard, the MCP server, the iOS app, and the Mac app all call the same functions. No API layer to maintain, no versioning to manage, no GraphQL schema to update. Just one more Supabase client.

The Invisible Access Token Problem

After publishing the MCP server, we realized there was no way for users to get the access token they need to configure it. The README said "go to Settings → API Access" — but that section did not exist on the settings page.

This is a specific instance of a general pattern: features that require configuration steps you have not built yet. The MCP server worked perfectly in development because we had a token from the browser's Supabase session. But a user following the README would hit a dead end.

We added an API Access section to the settings page that shows the user's Supabase access token with show/hide/copy buttons, plus ready-to-paste configuration snippets for Claude Desktop and Claude Code. The token auto-fills in the config when you click "show."

Lesson: Before you ship a developer-facing feature, walk through the README as if you have never seen the product. Every step that says "get your X from Y" must have a working Y. Docs that reference non-existent UI are worse than no docs — they waste the user's time and erode trust.

What a Shipping Day Actually Looks Like

The highlight reel of this day would be: "shipped light mode, published an MCP server to npm, fixed app tracking." The reality included:

  • A sed script that accidentally created a self-referencing variable (const filteredTopApps = filteredTopApps.filter(...)
  • A build that failed because an agent renamed COLORS to BASE_COLORS in one place but not another
  • An anti-FOUC script in a <head> tag that broke Next.js static generation with an opaque webpack error
  • 20 files that needed the same one-line filter added, done with a loop that worked for 18 of them and broke 2
  • An npm publish that failed twice because the token did not have "bypass 2FA" permission
  • Three Sparkle releases that had been silently failing for a week

Software is not the features you ship. It is the bugs you find along the way, the infrastructure that silently breaks, and the assumptions that turn out to be wrong. Today was a good day — not because everything worked, but because we caught the things that did not.

Written by Kevin — builder of xeve

Track your apps, coding, music, and health — all in one place.

try xeve free