GoatFlow 0.8.1 shipped this week. The headline is mobile support and PWA push notifications. The interesting engineering was in how we made 66 admin pages mobile-friendly without touching 66 files, why the Push API is pickier about TLS than you’d expect, and a test fixture bug that only manifested when the test suite ran in a specific order.
The Mobile Problem
GoatFlow was built desktop-first. The Tailwind responsive utilities were used in places — sm:flex, lg:grid-cols-2 — but nobody had loaded it on a 375px screen and systematically fixed what broke. Quite a lot broke.
The agent ticket list has eleven columns. On an iPhone SE, that’s a horizontal scroll where you can see maybe two columns at a time, neither of which is the one you wanted. Admin action buttons were 36px — below the 44px WCAG minimum, and genuinely impossible to hit with a thumb on a bumpy bus. Modal dialogs had 48px of horizontal padding, leaving about 279px of usable content width. Cards, tables, headings — all designed for 1440px and merely present on 375px.

The Strategy: CSS Over Templates
The naive approach is opening each template and adding responsive classes. With 66 admin pages, that’s a lot of diffs and a lot of places to get it wrong. Instead, we leaned on the fact that GoatFlow uses a consistent set of CSS component classes — .gk-table, .gk-card-body, .gk-modal-header, .gk-heading, .gk-action-btn — and added a single @media (max-width: 767px) block in input.css.
One file, twelve CSS rules, and every admin page gets tighter table padding, compact card sections, smaller headings, and touch-friendly buttons. No template changes required for those 66 pages.
@media (max-width: 767px) {
.gk-table th { @apply px-2 py-2; }
.gk-table td { @apply px-2 py-2; }
.gk-card-body { @apply p-4; }
.gk-modal-footer { @apply px-4 py-3 flex-col-reverse gap-2; }
.gk-heading { font-size: 1.5rem !important; }
/* ... */
}
For pages that needed structural changes — hiding table columns, collapsing layouts, stacking buttons — we used Tailwind responsive classes directly in the template. The ticket list gained hidden md:table-cell on five columns. The dashboard GridStack got columnOpts breakpoints. Profile pages got flex-col sm:flex-row on their 2FA sections.
The rule of thumb: if the fix is cosmetic (padding, font size, touch targets), do it in CSS. If the fix is structural (hiding content, changing layout direction), do it in the template.
The Lesson
The sm:w-1/2 class on a form field means “half width from 640px up.” On a 375px phone, there’s no sm: prefix applied, so the element is full width. That’s correct and intended. But sm:col-span-2 sm:w-1/2 on a grid item means “span two columns AND be half width” — which results in a weirdly narrow element floating in a two-column space. We’d used this pattern on the profile page’s Title field. The fix was switching to sm:max-w-xs, which caps the width without fighting the grid.
Responsive CSS is deceptively simple until you stack three responsive utilities on one element and try to reason about what happens at each breakpoint.
PWA Push Notifications
The Architecture
The push notification stack has four layers:
- Service worker (
sw.js) — caches static assets, serves an offline fallback, handlespushevents - Client-side manager (
push-manager.js) — fetches VAPID key, subscribes via PushManager, syncs with backend - Backend API — three endpoints for VAPID key retrieval, subscription storage, and unsubscription
- Dispatch integration — hooks into the existing scheduler to send push alongside in-memory reminders
The interesting bit is how it integrates with the existing notification system. GoatFlow already had a PendingReminder hub that dispatches in-memory reminders polled by the frontend every 45 seconds. Push notifications needed to be an additional delivery channel, not a replacement. So the scheduler handler now calls both:
if err := s.reminderHub.Dispatch(ctx, recipients, payload); err != nil {
s.logger.Printf("failed to dispatch: %v", err)
continue
}
if s.pushConfig.VAPIDPublicKey != "" {
push.DispatchPushReminder(ctx, s.db, recipients, payload, s.pushConfig)
}
The push dispatcher looks up subscriptions for the recipient user IDs, sends via webpush-go, and automatically cleans up stale subscriptions when the push service returns HTTP 404 or 410.
The Self-Signed Cert Surprise
We deployed to a Hetzner instance behind Caddy with a self-signed certificate and wondered why the bell icon wouldn’t subscribe. Turns out the Push API requires a trusted HTTPS context — self-signed doesn’t qualify. The browser treats the origin as insecure and PushManager.subscribe() silently rejects.
This isn’t documented prominently anywhere. The MDN docs say “secure context required” and link to the secure context spec, which says “potentially trustworthy origin.” A self-signed cert makes the origin potentially trustworthy but not actually trustworthy unless the CA is in the device trust store.
For dev, you can add your CA to the phone’s trust store or use Chrome’s unsafely-treat-insecure-origin-as-secure flag. For production, use a real certificate. This is one of those things that’s obvious in hindsight but costs you an hour of debugging when you’re staring at a grey bell icon that won’t turn blue.
The Lesson
VAPID keys need to persist across restarts. If the server auto-generates ephemeral keys on boot, every existing push subscription becomes invalid when you redeploy. We auto-generate and log a warning — but for production you need GOATFLOW_PUSH_VAPID_PUBLIC_KEY and GOATFLOW_PUSH_VAPID_PRIVATE_KEY set as environment variables.
The Flaky Test That Wasn’t Random
This was the most instructive bug of the release.
The MCP authorization tests create database fixtures in a shared test database: users, groups, queues, tickets, all in the 80000 ID range. The admin test user (ID 80001) needs three group memberships — admin group (ID 2), support group (80001), and billing group (80002).
The fixture setup runs once per test package. Before each test, a validation check confirms the fixtures still exist (other packages can wipe the shared DB). The check was:
db.QueryRow("SELECT COUNT(*) FROM group_user WHERE user_id = ?",
fixtures.AgentAdmin).Scan(&membershipCount)
if membershipCount == 0 {
fixtures.setup() // recreate
}
The problem: membershipCount == 0 only triggers recreation if all memberships are gone. If another test package deletes the admin group (ID 2) — which contains the FK reference — then only one of the admin’s three memberships disappears. membershipCount is 2, which is > 0, so the check passes. But the list_queues test expects the admin to see queues accessible through admin group membership — and that membership no longer exists.
The fix was two lines:
if membershipCount < 3 { // not just == 0
fixtures.setup()
}
And a guard to ensure the admin group exists before inserting FK references:
var adminGroupExists int
db.QueryRow("SELECT COUNT(*) FROM groups WHERE id = ?",
f.GroupAdmin).Scan(&adminGroupExists)
if adminGroupExists == 0 {
exec("INSERT INTO groups (id, name, ...) VALUES (?, 'admin', ...)",
f.GroupAdmin, now, now)
}
The test passed locally because the packages ran in an order where the admin group wasn’t wiped. CI ran them in a different order. The bug was in the fixture validation, not in the code under test.
The Lesson
Shared mutable state in tests is the same bug as shared mutable state in production — it just manifests as “flaky tests” instead of “data corruption.” The fix is the same too: make your validity checks as paranoid as your production code. Don’t check “does something exist?” — check “does everything I need exist in the right state?”
Coachmarks: Small Feature, 210 Strings
We added six new onboarding tooltips (dashboard widgets, ticket creation, filters, bulk actions, queue overview, push notifications) to the existing theme-switcher tip.
The implementation was trivial — seven GoatCoach.register() calls in base.pongo2. The i18n was not. Seven tips with a title and message each, across 15 languages, is 210 translated strings. The tips use server-side template rendering ({{ t("coachmarks.widgets.title") }}), so the translations need to exist in every language file before the page renders.
We scripted the bulk update with Python, writing native translations for each language into the JSON files. If you ever wonder why “just add a tooltip” takes an hour, it’s because you have 15 language files and a test suite that validates key coverage.
By the Numbers
- 1 CSS media query block fixing 66 admin pages
- 3 push notification API endpoints
- 7 onboarding coachmarks in 15 languages
- 2 flaky test fixtures found and fixed
- 1 hour lost to a self-signed certificate and the Push API
- 0 template changes required for admin mobile optimisation
GoatFlow is open source under Apache-2.0. Source, containers, and Helm charts: github.com/goatkit/goatflow.