Latch

Blog ยท release

Latch 0.4.0

Activities, a calendar, and the stats that come out of them. A thing you want to count โ€” reflections, sits, walkies โ€” defined in Settings and drawn everywhere.

A verification photo can be one you already took, and you can see it before you send it. Borrowed from the Body Tracker check-in flow, which had solved the same problem.

Share cards. A card about right now, at /share โ€” pick a shape (story 9:16 / square / wide 1.91:1), optionally pick a photo, download or hand it to the share sheet. Stats come from the same token catalogue the stamps and recap cards use (share.tokens); a stat with nothing to say at this instant is omitted, never printed as a zero. With no photo the lower half becomes the event ribbon โ€” a photo-less card is a finished card, not a card with a hole in it. Closes #5.

๐Ÿ”ด The card carries two instants and keeps them visibly apart. The stats are as-of now; the photograph is as-of its shutter. The card's own time sits in the eyebrow and the photo's own time is burned inside the picture (so it survives a crop or a re-share). Blending them would be the P0 overlay's lie in a new place โ€” the caption is therefore not optional.

Coarse location on photos (core/geo.py, migration 5). Opt-in on every render surface: /share?place=1, /media/<id>/stamped?place=1, and a place token that is deliberately absent from every default token list.

Tests. +42 (170 โ†’ 212), led by the one invariant with no symptom when it breaks: a precise coordinate must never reach the database.

Photo intake. HEIC is converted in the browser before upload โ€” on a phone that just works, because iOS decodes its own format. ๐Ÿ”ด Only formats the server cannot read are converted: a JPEG/PNG/WebP/GIF is uploaded byte for byte, because a canvas round-trip destroys EXIF and EXIF is where the capture time and the coarse location come from. "Normalise every upload to JPEG" would have silently stripped GPS off every photo that works today. Every failure path submits the untouched original โ€” a desktop browser that cannot decode HEIC degrades to the server's refusal rather than swallowing the upload.

Refusals name the file. One string used to cover every non-openable byte pile and it named HEIC, so a .mov, a PDF and a truncated download all told the user to convert a HEIC they never uploaded. media.sniff_image() identifies the container from magic bytes (never the multipart Content-Type) and each kind gets its own actionable message. Ambiguous ISO-BMFF brands (isom/mp42/M4A, worn by both video and audio) say unknown rather than guess. A refusal also logs kind/bytes/magic โ€” the only record of what bounces.

A frame out of a video. Picking a clip in a photo field offers ๐Ÿ“ท use a frame from it โ€” one tap pulls a still out and logs that, instead of an upload that travels only to be refused. ๐Ÿ”ด Offered, never automatic, and the line is not squeamishness: HEIC โ†’ JPEG preserves the content, while video โ†’ still discards the motion and the sound that are the reason someone filmed a clip. Doing that silently would substitute a different artefact for the one the user chose.

The browser's location reaches the server. here is now threaded through the check-in and task-completion routes, and requested by intake.js only after a conversion โ€” which is precisely when the photo's own EXIF location was destroyed. No permission prompt on the files that already carry their own coordinate.