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.
- ๐ด An activity is a LENS OVER THE EVENT LOG, not a second log.
match_module/match_kindselect rows out ofevents, so a new activity is populated from your whole existing history the moment you save it โ no backfill โ and removing one deletes nothing, because it never owned those events. The modules never learn that activities exist. - ๐ด An activity with no matcher matches NOTHING.
WHERE 1=1is the natural reading of "no filters" and would let one blank row claim every event in the log, with a streak, a calendar and a total that all look entirely plausible. - A one-tap activity is the degenerate case, not a second system โ it emits
module='activity', kind=<slug>, which its own matcher picks back up. - ๐ด Streaks are judged against a CADENCE.
weekly_ncounts consecutive weeks carrying the target, not consecutive days;dailygraces today andweekly_ngraces the current week, both breaking once that period closes short.nonemeans no streak at all โ never a streak of 0, which would claim a run had been broken. A counter has no run to break. - ๐ด The calendar marks a backdated day with a hollow ring. Latch can log for a day already past, which can repair a streak โ that is the feature โ but a repaired day that looked identical to a lived one would turn the retroactive log into a way to quietly rewrite your own history.
- ๐ด Five stat tokens are GENERATED per activity (
since_/days_/streak_/total_/count_ - slug) and merged into the catalogue at render time, so a new activity is shareable with no code
edit and no restart. They are as-of by construction like every hand-written token, and
test_every_token_is_stable_over_timenow resolves the whole catalogue rather than only the hand-written half โ otherwise nothing would hold a generated token to that contract. - ๐ด
sinceis None when it never happened, never0sโ "never" and "just now" are opposite facts.daysprints a genuine 0, because that one is measurable. - Colour comes from the data. Each activity carries its own, so a day's dots name what you did rather than only how much โ which needs no palette decision, because the colours are rows.
- โ ๏ธ Fixed:
{% from "_event.html" import event_row %}had nowith context, so the macro saw env globals but never per-render values. It only surfaces when there are events to render, so it passed in isolation and failed in the suite. - โ ๏ธ Fixed: the guide's anchor check was a hard-coded tuple โ a second hand-kept list beside
the one it was checking. It now harvests every
/guide/...href out of eight rendered pages (rendering, not grepping, is what makes Jinja-built hrefs checkable), and immediately found a pre-existing broken link whose prose had always lived on another page.
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.
- ๐ด
captureis gone from the Training verification field. It bypasses the picker and opens the camera, so the shot you had already taken of the finished task โ with the phone's own Camera app, which is what anyone actually does โ was unreachable, and the only way to verify was to re-shoot it inside Safari. That contradicted thewhenfield on the same form: retroactive completion was supported while a retroactive photo was not. Without the attribute iOS still offers Take Photo in the sheet, so the camera costs one extra tap and the camera roll becomes reachable at all. The dashboard's one-tap door keepscaptureโ that is a different trade on purpose, and a test asserts the contrast rather than either half. - Every form that does not auto-submit now previews the photo: a thumbnail, the filename
and size, and โป replace. ๐ด It reads
input.files[0], i.e. the file that will actually be POSTed โ after HEIC conversion or a frame grab โ because a preview that can disagree with the upload answers a different question from the one a verification photo is asking. Reading the input is what makes that structural rather than a rule each future branch has to remember. - ๐ด Anchored to the field, not appended to the form. Appending put the thumbnail at the bottom of the Training card, below the submit button and the did it earlier fold, hard against the next form's fail button. A preview that does not sit beside the thing it previews is decoration.
- โ ๏ธ A thumbnail that cannot render removes itself, leaving the filename and the explanation. A broken-image icon reads as a broken upload when the truth is "this browser cannot decode what we are sending as-is" โ the note on that path already says so, and the server's refusal names the real format.
- Fixed: tapping use a frame from it submitted the form immediately. On a task completion that fired the whole completion before the note was typed and before anyone saw the frame chosen for them. It routes through the same terminal function as every other branch now, so the one-tap door still submits and every deliberate form previews.
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.
- ๐ด Rounded at ingest, never at render. The exact coordinate is not written to disk, so
it is not in the database, the backups or
bundle.zip, and a card cannot be sharpened back. Rounding at render would look identical on every card and still ship the doorway inside the export. - ๐ด
place_srcis the presence flag, not the coordinate. A real fix in the Gulf of Guinea rounds to(0.00, 0.00), so a falsiness test onplace_latwould drop a genuine location; a typed label carriessrc='manual'with no coordinate at all.(0, 0)from a GPS chip with no fix is refused before rounding. - ๐ด EXIF first; the browser only for a live upload. A photo backdated to last Tuesday will not take today's position โ the browser reports where the phone is now.
- ๐ด Precision can only coarsen. Display decimals are recovered from the stored value
(
geo.grid_dp) andmin()-ed against any request, because trailing zeros are a precision claim: a 1 km cell printed at three decimals asserts 110 m accuracy it does not have. - Precision is
photo.place_dp(0 region ยท 1 city ยท 2 neighbourhood, default ยท 3 street), clamped so a typo can only blur the grid, never sharpen it. Capture isphoto.place_capture. - Docs are honest about the limit: the grid is fixed, so the cell you appear in most is home. Coarsening is blast-radius reduction, not a disguise.
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.