Public
Backend for TRMNL screenshot plugin
Val Town is a collaborative website to build and scale JavaScript apps.
Deploy APIs, crons, & store data – all from the browser, and deployed in milliseconds.

trmnlImage

Backend for the TRMNL screenshot plugin. TRMNL points at /display and screenshots it onto its 800×480 e-ink panel. Friends and family upload images at /; the most recent upload is what shows on the wall.

Routes

RoutePurpose
GET /Upload form (email required, image required, caption optional).
POST /uploadStores the processed image, caption, and uploader record.
GET /imageServes the current image as binary image/png.
GET /displayThe page TRMNL screenshots — image at 800×480 with optional caption bar. Public link to share with TRMNL.
GET /adminHTTP Basic auth (admin / ADMIN_PASSWORD). Lists every uploader with upload count, last upload, reminder state.
GET /admin/test-email?to=...Sends one reminder email to the given address. No state changes.
GET /admin/run-cronManually triggers the reminder cron. Supports ?dry=1 (don't send/persist) and ?days_ahead=N (simulate future time). Returns JSON summary.
GET /opt-out?email=...Flips reminders to false for an email. Linked from reminder emails.

Public URL: https://joshbeckman--b073704c405011f19b9c42b51c65c3df.web.val.run. Friendlier alias for sharing: http://www.joshbeckman.org/frametags/home-trmnl-small.

Layout

backend/
  index.ts   # Hono HTTP app (all routes above)
  cron.ts    # Daily reminder job (separate cron trigger in Val Town)
frontend/
  index.html # Upload form
  index.tsx  # Client-side resize + upload (no dithering — TRMNL handles B&W)

Image processing

Done client-side in frontend/index.tsx:

  1. Draw the uploaded image onto an 800×480 canvas, scaled to cover.
  2. canvas.toDataURL("image/png") → POST to /upload.

We don't dither to 1-bit ourselves — TRMNL converts the screenshot to B&W on its end, and pre-dithering caused weirdness. We also avoid image-rendering: pixelated on /display for the same reason. The /image endpoint decodes the stored data URL and serves binary PNG so /display references it as a real URL (TRMNL's screenshot pipeline doesn't like huge inline data: URIs).

Storage (Val Town blob)

KeyShape
trmnl_image_data{ image: dataUrl, caption: string } — current image only.
trmnl_uploaders{ [email]: Uploader }
type Uploader = { lastUpload: string; // ISO timestamp count: number; // total uploads reminders: boolean; // opt-in flag, default true lastReminded?: string | null; reminderCount?: number; // how many reminders sent in current cycle }

Uploading resets lastReminded and reminderCount so the cycle starts over.

Reminder cron (backend/cron.ts)

Runs daily. For each uploader with reminders === true and reminderCount < 4, sends a nudge if enough time has passed. The email body lives in sendReminderEmail() in cron.ts/admin/test-email calls the same function. Backoff doubles each reminder:

Reminder #Wait since previous touch
114 days (since last upload)
228 days
356 days
4112 days
5+never — silence

Total: at most 4 emails over ~7 months, then we leave them alone. Each email contains an opt-out link.

Env vars

  • ADMIN_PASSWORD — required for /admin. Username is hardcoded to admin.

Local dev

vt watch # auto-sync edits to Val Town vt tail # stream logs vt browse # open in browser