- TypeScript 74.2%
- CSS 24.6%
- Dockerfile 0.8%
- Shell 0.4%
| data | ||
| docker | ||
| drizzle | ||
| src | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| bun.lock | ||
| docker-compose.prod.yml | ||
| docker-compose.yml | ||
| Dockerfile | ||
| Dockerfile.dev | ||
| drizzle.config.ts | ||
| giiddyup.sh | ||
| package.json | ||
| README.md | ||
| tsconfig.json | ||
LoadLog
A handloading log for tracking reloaded ammunition: reusable bullets and loads (powder / primer / brass recipes), combined into logged handloads with chronograph data. Chronograph velocities can be entered manually or imported from a Garmin Xero (ShotView) CSV export.
Built with:
- Hono — web framework (with JSX server-side rendering)
- Bun — runtime, package manager, bundler, and native SQLite driver
- Drizzle ORM — typed SQL + migrations over SQLite
- Tailwind CSS v4 — styling
- Alpine.js — lightweight frontend interactivity
- Docker — dev and production containers via Compose
Project structure
.
├── src/
│ ├── index.tsx # Hono server entry (Bun.serve export)
│ ├── env.ts # Validated environment access
│ ├── db/
│ │ ├── index.ts # Drizzle + bun:sqlite connection
│ │ ├── schema.ts # Table definitions (edit me!)
│ │ └── migrate.ts # Migration runner
│ ├── routes/
│ │ └── loads.tsx # Feature routes (handloads, loads, chrono import)
│ ├── lib/
│ │ ├── xero.ts # Garmin Xero CSV parser
│ │ └── __tests__/ # Parser tests + real sample CSV fixtures
│ ├── views/
│ │ ├── Layout.tsx # HTML shell (loads css/js)
│ │ ├── Home.tsx # Handload list + "log a handload" form
│ │ ├── EditLoad.tsx # Edit a load recipe
│ │ └── EditEntry.tsx # Edit a handload + chrono import
│ ├── client/
│ │ └── main.ts # Alpine entry (bundled to public/js/app.js)
│ └── styles/
│ └── app.css # Tailwind entry (built to public/css/app.css)
├── public/ # Compiled, statically served assets
├── drizzle/ # Generated SQL migrations
├── data/ # SQLite database (gitignored)
├── docker/entrypoint.sh # Runs migrations, then the server
├── Dockerfile # Production (multi-stage)
├── Dockerfile.dev # Development
├── docker-compose.yml # Dev stack
└── docker-compose.prod.yml # Production stack
Getting started (local, without Docker)
Requires Bun >= 1.2.
bun install
cp .env.example .env
# Generate and apply the initial migration
bun run db:generate
bun run db:migrate
# Start dev server + Tailwind watcher + client bundler (all together)
bun run dev
Open http://localhost:3000.
Getting started (Docker)
Development (hot reload)
Source is bind-mounted; edits reload automatically.
docker compose up --build
Before the first run, generate the initial migration once on the host (
bun install && bun run db:generate) so thedrizzle/folder exists. The container applies migrations automatically on startup.
Production
Multi-stage build, dev dependencies pruned, assets pre-compiled, SQLite stored in a named volume.
docker compose -f docker-compose.prod.yml up --build -d
Scripts
| Script | Purpose |
|---|---|
bun run dev |
Server + Tailwind + client bundler, all in watch mode |
bun run build |
Compile CSS and JS for production |
bun run start |
Run the server (production mode) |
bun run test |
Run the test suite (bun test) |
bun run db:generate |
Generate SQL migrations from schema.ts |
bun run db:migrate |
Apply pending migrations |
bun run db:studio |
Open Drizzle Studio (DB browser) |
Working with the database
- Edit
src/db/schema.ts. - Run
bun run db:generateto create a migration indrizzle/. - Run
bun run db:migrate(or restart the container) to apply it.
Query with the exported db client:
import { db } from "@/db";
import { loads } from "@/db/schema";
const rows = await db.select().from(loads);
Data model
bullets ──┐ shots (per-shot velocities)
├──< log_entries ──┘
loads ───┘ (handloads: round count, notes, chrono summary)
- bullets / loads are reusable components you pick when logging a handload (or create inline on the handload form).
- log_entries is a logged handload referencing one bullet + one load, plus a round count and an optional chronograph summary (avg / SD / extreme spread). Handloads are aggregated per recipe: logging more rounds of the same bullet + load adds to the existing entry (round counts sum, notes are appended) so all data for a given load accumulates in one place.
- shots holds individual velocities imported from a Xero CSV. Deleting a handload cascades to its shots.
Chronograph / Garmin Xero import
On a handload's edit page you can either:
- Enter velocity stats manually (unit, average, SD, extreme spread), or
- Upload a Garmin Xero CSV (the ShotView app export). The parser
(
src/lib/xero.ts) reads each shot, detects the unit (fps or mps) and the decimal style (.or,), stores the individual shots, and computes the average, sample standard deviation and extreme spread. Importing replaces any existing chronograph data for that handload.
Imports add shots to the handload by default (choose "Replace all shots" to start over), so multiple chronograph sessions for the same aggregated load accumulate and the summary is recomputed across every shot. When a handload has imported shots, those shots are the source of truth and the manual velocity fields are disabled; use "Clear all shots" to switch back to manual entry.
Both the English/international and German export formats are supported and
covered by tests using real sample files in src/lib/__tests__/fixtures/.
Notes
- Alpine and Tailwind are bundled locally (no CDN), so the app works offline and in locked-down production environments.
- SQLite uses WAL mode for better read/write concurrency.
- Foreign keys are enforced (
PRAGMA foreign_keys = ON).