Thanos Blog

OuchTracker

06 Apr 2025

A development story about first aid kits, expired bandages, and the software that keeps track of them.

The Idea

It started with a real problem.

Managing first aid kits in an organisation sounds trivial until you’re the person responsible for them. Kits get restocked inconsistently. Items expire unnoticed. No one knows which kit is where, who is responsible for it, or when it was last checked. When an incident happens and someone reaches for the kit, they might find it half-empty or stocked with expired supplies.

The traditional answer is a clipboard, a spreadsheet, or at best a shared Google Doc. None of these scale. None of them send you a warning when something is about to expire. None of them let a warehouse worker scan a QR code, record what they used, and automatically deduct it from stock.

So the idea was simple: build a proper web application for first aid kit management. Role-based, mobile-friendly, offline-resilient, and genuinely useful for real organisations.

The result is OuchTracker.

What It Does

OuchTracker is a full-stack web app with two roles: Admins and Checkers.

Admins get the full picture:

  • Create and manage kits with locations, descriptions and assigned checkers
  • Add, edit and delete items per kit with categories, units, expiry dates, and location-in-kit notes
  • Bulk import items via CSV
  • Generate and download QR codes per kit
  • Export a formatted Bill of Materials (BoM) PDF ready to print and physically insert into the kit
  • View all inspection logs and incident reports across every kit
  • A dashboard showing kits with expiring or expired items at a glance

Checkers see only what’s theirs:

  • Their assigned kits and the items inside
  • A step-through inspection flow go through each item, record quantity found, add notes, submit a log
  • An incident report form select items used, record quantities, submit (stock is automatically deducted)
  • Their own inspection and incident history

Everything is JWT-authenticated with role enforcement on every API endpoint. Dark mode is fully supported and persisted. The app installs as a PWA on any device.

The Stack

The stack was chosen for productivity, type safety, and long-term maintainability:

Layer Technology
   
Frontend Vue 3 · Quasar Framework v2 · TypeScript · Pinia
Backend NestJS v11 · TypeScript
ORM Prisma 7
Database PostgreSQL 16
Auth JWT + bcrypt
Container Docker + Docker Compose
PWA Workbox GenerateSW

Vue 3 + Quasar was the obvious choice for a data-dense admin UI. Quasar ships a complete component library tables, dialogs, drawers, notifications all production-quality, all themeable. It also handles PWA builds, routing, and dark mode natively.

NestJS for the backend because it brings structure to Node. Controllers, services, guards, decorators it forces the kind of separation that makes an API maintainable. The JWT guard and role decorator pattern meant every endpoint was protected with minimal boilerplate.

Prisma as the ORM was a comfortable fit with TypeScript the generated client is fully typed, migrations are tracked in SQL, and the schema file is the single source of truth for the data model.

The Data Model

The core relationships are straightforward:

User ──< KitAssignees >── Kit ──< KitItem
                           │
                           ├──< InspectionLog ──< InspectionLogItem
                           └──< IncidentReport ──< IncidentReportItem

A Kit has many KitItems. Each KitItem belongs only to that kit there is no global item catalog. This was a deliberate decision: different kits in different locations have different needs, and a shared catalog would add friction without much benefit.

When a checker submits an inspection, a snapshot of every item’s state is saved as InspectionLogItem records. This means historical inspections are immutable you can always go back and see exactly what state the kit was in on a given date.

Incident reports work similarly but also deduct quantities from the live KitItem records. One form submission does two things: creates the report and updates the stock.

The Journey

Starting with the basics

The first version was a straightforward CRUD app. Kits, items, users the standard create/read/update/delete cycle. Getting the role-based access control right from the start saved a lot of pain later. A @Roles() decorator on NestJS route handlers, combined with a JwtAuthGuard, meant security was declarative and consistent.

The frontend grew alongside it: a kit list page, a kit detail page with items, a user management page. Quasar’s q-table handled the heavy lifting for sortable, filterable data tables.

The table alignment problem

One of the first real UI headaches was q-table column alignment. Quasar’s q-table component is powerful but opinionated. When you use the #body slot for custom rows, you have to be explicit about which column each q-td maps to or the cells drift out of alignment with their headers.

The naive approach was to use v-for over the columns array with a v-if to filter. This ran into a Vue ESLint rule (vue/no-use-v-if-with-v-for) and produced empty cells. The correct solution was to hardcode each <q-td key="..."> explicitly per column verbose but predictable, and correctly aligned every time.

Drawer and page scroll fighting each other

Making a sidebar layout scroll correctly sounds simple. It is not.

The challenge: the sidebar (drawer) should be fixed and never scroll. The main content area should scroll independently. On paper this is a two-liner in CSS. In practice, Quasar’s layout system manages heights through a chain of nested components, and fighting it naively causes issues the whole page scrolls together, the drawer overflows, or a dead space gap appears at the bottom.

The solution that actually worked was removing Quasar’s layout scrolling entirely and taking manual control:

.q-layout { overflow: hidden !important; }
.q-page-container {
  position: fixed !important;
  top: 50px !important;   /* header height */
  bottom: 0 !important;
  right: 0 !important;
  left: 240px !important; /* drawer width */
  overflow-y: auto !important;
}
@media (max-width: 700px) {
  .q-page-container { left: 0 !important; }
}

The drawer itself was clamped to height: calc(100vh - 50px) so it could never grow taller than the viewport. It sounds blunt, and it is but it works reliably across every page and every screen size.

The PDF problem and why we threw jsPDF away

Exporting a Bill of Materials to PDF was one of the most interesting challenges.

The first attempt used jsPDF with the autotable plugin. jsPDF is a mature library and autotable makes table rendering straightforward. It worked well for simple content. Then the kits started containing items with Greek characters.

jsPDF requires embedded fonts for any character outside the basic Latin set. Without them, Greek letters are silently dropped or replaced with question marks. Embedding a full Unicode font adds hundreds of kilobytes to the bundle and requires careful configuration. Getting it right for one language is annoying. Getting it right for all languages is a real problem.

The insight was that the browser already knows how to render text correctly in any language, in any font, with correct word-wrapping and pagination. That’s exactly what window.print() does.

So the entire PDF approach was rethrown and rebuilt around a single function: buildBomHtml(). It generates a complete, self-contained HTML document as a string with inline CSS, a branded header, a QR code, stats chips (OK / Expiring Soon / Expired), and a category-grouped items table. That HTML is turned into a blob URL and loaded into a hidden <iframe>. Clicking “Print” calls iframe.contentWindow.print().

The result: pixel-perfect rendering, full Unicode support, correct pagination, and the browser’s native print dialog. The user can save it as PDF or send it directly to a printer. No font embedding, no layout engine to fight, no bundle size overhead.

export async function buildBomHtml(kit: Kit, items: KitItem[], isDark = false): Promise<string> {
  const qrDataUrl = await QRCode.toDataURL(qrUrl, { width: 160, margin: 1 });
  // ... generate full HTML with inline styles
  return html;
}

Dark mode in the PDF preview

The BoM preview dialog renders an iframe. When the app is in dark mode, the dialog chrome goes dark but the iframe content is a separate HTML document with its own <body> and background. It stays white.

The fix was to pass isDark as a parameter into buildBomHtml() and conditionally inject dark CSS into the generated HTML:

${isDark ? `
  body { background: #1e1e1e; color: #e0e0e0; }
  ::-webkit-scrollbar-track { background: #2d2d2d; }
  ::-webkit-scrollbar-thumb { background: #555; border-radius: 4px; }
  /* ... table, header, chip overrides ... */
` : ''}

The dark mode flag is read at the moment the export is triggered and baked into the HTML. The preview respects the user’s current preference, and the print output is always light (print media queries override).

The Docker volume trap

Development seeds are essential for a realistic testing environment. The dev seed creates 20 checker users, 10 kits, 300+ items, and a full year of inspections and incidents. After writing the seed, rebuilding the container, and restarting the new seed didn’t run. The old data was still there.

The reason: Docker named volumes persist across docker compose down. The database volume from the previous run was still mounted, and since the seed checked for existing data and bailed early, it never ran again.

The fix for a clean reset:

docker compose -f docker-compose.dev.yml down -v   # -v removes named volumes
docker compose -f docker-compose.dev.yml up --build

A hard lesson about the difference between down and down -v.

Separate seeds for dev and prod

The dev seed is rich lots of fake users, kits, historical data, predictable passwords. That’s exactly what you don’t want in production.

The solution was two separate seed files:

  • seed.ts the full dev seed, runs in the dev container, creates all the sample data
  • seed.prod.ts creates only the admin user, reads credentials from environment variables, throws if SEED_ADMIN_PASSWORD is not set

The prod Dockerfile chains them in order:

CMD npx prisma migrate deploy \
  && npx ts-node prisma/seed.prod.ts \
  && node dist/main

No fake data, no default passwords, and a hard failure if the admin password wasn’t configured.

The QR code workflow

Every kit gets a QR code that links directly to its landing page. Admins generate and download QR codes from the kit list. Checkers (or anyone) can scan the code with their phone, authenticate, and immediately see three options: start an inspection, file an incident report, or view the kit contents.

The QR code itself is generated server-side-free the frontend uses the qrcode library to render a data URL directly in the browser, both for the download dialog and embedded in the BoM PDF.

The rename

The app started as FAK-CRM a functional name, but one that attracted the wrong kind of attention. Midway through development it was renamed to OuchTracker.

This touched more files than expected: package.json, manifest.json, quasar.config.ts, docker-compose files, the release script, seed email domains, the Prisma schema comment, the docs site HTML, the PDF footer, the PWA service worker update message, the app toolbar, the login page, and the favicon set.

The rename also prompted a fresh favicon: a clean white circle with a bold red cross (#c62828), generated programmatically with rsvg-convert in all required sizes from a single SVG source.

What Works Well

Quasar’s component ecosystem is genuinely excellent. The table, dialog, drawer, notification, and form components are production-quality. The dark mode system is consistent across every component without needing any custom CSS beyond a few edge cases.

Prisma + TypeScript makes backend development feel safe. The generated types mean you catch data shape mismatches at compile time, not at runtime in production. Migrations are version-controlled and reproducible.

The HTML print approach for PDFs turned out to be better in every dimension than a canvas/PDF library. Browser rendering is more correct, Unicode just works, print-specific CSS (@media print, @page) gives full control over pagination and margins, and there is zero client-side bundle cost beyond the QR code generator.

Docker Compose for development means the entire stack database, backend, frontend with HMR comes up with one command. New team members don’t need to install Postgres, configure environment variables, or run migrations manually.

What Could Be Better

No automated tests. The seed data is thorough and the app has been tested manually, but there are no unit or integration tests. Adding tests around the inspection submission logic which has several edge cases around item state, expiry recalculation, and stock deduction would give much more confidence during refactoring.

No real-time updates. If two checkers are looking at the same kit simultaneously, stock changes won’t propagate without a manual refresh. WebSockets or server-sent events would fix this cleanly.

Item images. Being able to attach a photo to a kit item would make inspections easier a checker could confirm they’re looking at the right item at a glance.

Notification system. The dashboard shows expiring items, but there is no proactive alerting. Email or push notifications when items are about to expire would close the loop for organisations that don’t check the dashboard daily.

Closing Thoughts

OuchTracker is the kind of project that justifies why full-stack web development is worth learning properly. Every layer of the stack had its moments a schema decision that paid off, a CSS property that finally clicked, a library that turned out to be the wrong tool.

The most satisfying part was the PDF export. What started as a frustrating Unicode encoding problem turned into a better architecture: lean on what the browser already does well, and stop fighting it.

The whole thing runs in three Docker containers, installs as a PWA, works offline, and genuinely solves a real problem. That’s good enough.

Built with Vue 3, Quasar, NestJS, Prisma, and PostgreSQL. — View on GitHub