A modern web chart for visualizing measured t0.1 flash duration across power settings for every flash unit I've tested (plus readings I've collected from elsewhere).
Inspired in function by photonstophotos.net/Charts/PDR, but with a cleaner UI and a small admin panel so I can add flashes and readings without editing files.
Live at flashduration.bgateb.com.
- Next.js 15 (App Router) + TypeScript + React 19
- MySQL via
mysql2/promise - Tailwind CSS + shadcn-style components
- Recharts for the line chart
- iron-session for single-password admin auth
- zod for input validation
Two tables (see db/schema.sql):
flashes— manufacturer, model, type, slug, firmware, rated_ws, tested_on, notesreadings— flash_id, mode, stops_below_full, t_one_tenth_seconds, color_temp_k, notes
Power is stored canonically as stops below full (a real number ≤ 0). Fractional labels (1/32, etc.) are derived in code so the UI can toggle between the two representations without losing precision.
mode lives on the reading, not the flash, so a single physical unit can hold multiple test configurations (e.g. Normal / Freeze / Color / Action) without being duplicated. Each mode renders as its own line on the chart, color-matched to the flash but with a distinct dash pattern.
rated_ws (watt-seconds) on the flash powers the Absolute Ws compare mode, which lets you compare flashes of different max output at roughly the same effective power.
type is one of Pack + Head, Speedlight, Monobloc, Battery-powered Monobloc — used for filtering on the public chart.
cd apps/flashduration
npm installCreate a database and user:
CREATE DATABASE flashduration CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'flashduration'@'localhost' IDENTIFIED BY 'your-password';
GRANT ALL PRIVILEGES ON flashduration.* TO 'flashduration'@'localhost';
FLUSH PRIVILEGES;Apply the schema (fresh install):
mysql -u flashduration -p flashduration < db/schema.sqlIf you're upgrading an existing install, apply the db/migrations/ files in order instead — they cover adding rated_ws, moving mode from flashes to readings, and adding type.
cp .env.local.example .env.localThen edit .env.local:
MYSQL_HOST,MYSQL_PORT,MYSQL_USER,MYSQL_PASSWORD,MYSQL_DATABASEADMIN_PASSWORD— chosen by you, used at/loginSESSION_SECRET— must be 32+ chars. Generate with:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"npm run devVisit:
- http://localhost:3000 — public chart
- http://localhost:3000/admin — admin (prompted for password)
- Toggle individual flashes on/off via the left picker (or the mobile filter drawer).
- Filter the visible set by Brand, Type, Power range, and Effective Ws output — useful for narrowing down to "all monoblocs around 400Ws" without manually un-checking dozens of units.
- Switch the X-axis between fractional power (
1/1,1/2, …) and stops below full. - Switch the Y-axis between
1/Xsand seconds. - Toggle Absolute Ws to compare flashes with different max power against each other at roughly equivalent output (uses
rated_ws). - Toggle dark mode via the header button.
- The active selection and filters are reflected in the URL — copy the address bar to share a specific view.
- Log in at
/loginwithADMIN_PASSWORD. - Click Add flash — enter manufacturer, model, type, and (optionally) rated Ws and firmware.
- On the flash's edit page, add readings under one or more mode tabs (Normal / Freeze / Color / etc.). Each reading accepts flexible input:
- Power:
1/1,1/32, or stops (-5). All normalize to stops. - Duration (t0.1):
1/4000,0.00025,0.25ms,250µs. All normalize to seconds. - Color temp: Kelvin, optional.
- Notes: free-form, optional.
- Power:
- Modes can be renamed (including
Normal) without losing readings.
npm run build
npm startThe repo ships with two helper scripts for the VPS at ~/flashduration.bgateb.com (Ubuntu 22, Node 20 via nvm, pm2 already installed, Apache serving the subdomain).
-
Push this code to a GitHub repo. From your Mac:
git remote add origin git@github.com:<you>/<repo>.git git push -u origin main
-
Run the first-run setup script against the VPS:
FLASHDURATION_REPO=git@github.com:<you>/<repo>.git ./scripts/first-run-setup.sh
On the first pass it generates a read-only SSH deploy key on the VPS and prints the public key. Add it to the GitHub repo under Settings → Deploy keys → Add deploy key (read-only), then re-run.
-
When the script prompts that
.env.localis missing, edit it on the VPS (ssh vps, then edit~/flashduration.bgateb.com/.env.local) with your MySQL creds,ADMIN_PASSWORD, and a 32+ charSESSION_SECRET, then re-run the script. -
Apply the MySQL schema once (the VPS has the
mysqlclient):ssh vps 'cd ~/flashduration.bgateb.com && \ mysql -h "$MYSQL_HOST" -u "$MYSQL_USER" -p "$MYSQL_DATABASE" < db/schema.sql'
-
Disable ModSecurity for the subdomain in the DreamHost panel (
Websites → ModSecurity → (subdomain) → Disable). DreamHost's mod_security3 + OWASP CRS silently 404s some Next.js chunk URLs and can't be disabled from.htaccess. The denied-files rules in vps/htaccess.example provide belt-and-suspenders coverage. -
If the subdomain doesn't route to the app (502 / timeout):
- Confirm
mod_proxy_httpis active on the VPS (the default.htaccessfrom vps/htaccess.example uses it), or - In the DreamHost panel, enable Proxy Server for
flashduration.bgateb.compointing athttp://127.0.0.1:3000/, and remove the.htaccess.
- Confirm
-
To make pm2 resurrect on reboot, SSH in once and run
pm2 startup, then follow thesudoline it prints.
From your Mac, inside apps/flashduration/:
./scripts/deploy.shThat pushes the current branch to GitHub, SSHes into the VPS, pulls, runs npm ci && npm run build, and pm2 reload flashduration. If .htaccess is missing on the VPS (e.g. blown away by a panel change) it's restored from vps/htaccess.example automatically. Pass --skip-push if you've already pushed.
When the schema changes, apply the new migration on the VPS before deploying:
ssh vps 'cd ~/flashduration.bgateb.com && \
mysql -h "$MYSQL_HOST" -u "$MYSQL_USER" -p "$MYSQL_DATABASE" < db/migrations/000X_*.sql'| Variable | Default | Purpose |
|---|---|---|
FLASHDURATION_HOST |
vps |
SSH host alias |
FLASHDURATION_DIR |
/home/bgatebvps/flashduration.bgateb.com |
Deploy path on VPS |
FLASHDURATION_BRANCH |
main |
Branch to deploy |
FLASHDURATION_REPO |
(required for first-run) | Repo URL, git@github.com:… |
FLASHDURATION_PM2_NAME |
flashduration |
pm2 app name |
apps/flashduration/
├── db/
│ ├── schema.sql ← run against MySQL once
│ └── migrations/ ← incremental ALTERs for upgrades
├── scripts/
│ ├── deploy.sh
│ ├── first-run-setup.sh
│ ├── import-hve-elinchrom.mjs ← bulk-import Hans van Eijsden Elinchrom data
│ └── fix-hve-stops.mjs
├── vps/
│ └── htaccess.example ← Apache proxy + denied-files config
├── ecosystem.config.cjs ← pm2 process definition
├── src/
│ ├── app/
│ │ ├── page.tsx ← public chart
│ │ ├── api/
│ │ │ ├── chart-data/route.ts
│ │ │ ├── flashes/route.ts
│ │ │ ├── flashes/[id]/route.ts
│ │ │ ├── readings/route.ts
│ │ │ ├── readings/[id]/route.ts
│ │ │ ├── readings/rename-mode/route.ts
│ │ │ └── auth/route.ts
│ │ ├── login/page.tsx ← password login
│ │ └── admin/
│ │ ├── layout.tsx ← admin header + auth gate
│ │ ├── page.tsx ← flash list (with brand/type/power filters)
│ │ └── flashes/
│ │ ├── new/page.tsx
│ │ └── edit/page.tsx ← edit flash + readings (uses ?id=)
│ ├── components/
│ │ ├── FlashChart.tsx
│ │ ├── FlashChartView.tsx ← URL-stateful chart container
│ │ ├── FlashFilters.tsx ← brand/type/range filter UI
│ │ ├── FlashPicker.tsx
│ │ ├── LoginForm.tsx
│ │ ├── LogoutButton.tsx
│ │ ├── ThemeToggle.tsx ← dark mode toggle
│ │ ├── admin/FlashForm.tsx
│ │ ├── admin/ReadingsEditor.tsx ← mode tabs + per-mode reading rows
│ │ └── ui/… ← button, input, card, sheet (mobile drawer), etc.
│ └── lib/
│ ├── db.ts ← mysql2 pool
│ ├── queries.ts
│ ├── session.ts ← iron-session config
│ ├── api.ts ← guardAdmin helper
│ ├── filters.ts ← multi-select + range filter primitives
│ ├── power.ts ← stops ↔ fraction, effective Ws math
│ ├── duration.ts ← seconds ↔ 1/Xs
│ ├── slug.ts
│ ├── colors.ts
│ ├── cn.ts
│ └── types.ts
└── .env.local.example
The flash edit page lives at /admin/flashes/edit?id=X rather than /admin/flashes/[id] because Apache on DreamHost rejects URL-encoded brackets (%5B/%5D) in dynamic-segment chunk URLs.
- t0.5 duration (only t0.1 captured — easy additive change later)
- Multiple users / full OAuth (single-password admin only)
- CSV import (a one-off Elinchrom importer exists in scripts/ but there's no general UI)
- PNG / SVG export of the chart