Back to blog
FILE 0x0F·A WRIST BUTTON AND A BAR ON THE TV

A wrist button and a bar on the TV

September 21, 2026 · homelab, shortcuts, postgres, kiosk

I'm filling raised garden beds with topsoil. The unit of work is a round: two wheelbarrow loads, twelve shovelfuls each. There are about nineteen rounds left in the last bed and a deadline attached to it.

The count lived in my head, between the wheelbarrow and the next water break, which is exactly where a count goes to die. My phone was inside. I was not going to unlock anything with dirt on my hands.

So: one press of the Action button on my watch = one round. A bar on the TV in the kitchen shows where I am. That's the whole feature.

The count gets exactly one home

The tempting version keeps a tally in the display and lets the button increment it. Don't. The write path (a watch, outdoors, on cell) and the read path (a kiosk browser that reloads itself whenever the build hash changes) will eventually disagree, and the one that disagrees is the one you're standing in front of with a shovel.

Two tables, append-only, in the Postgres instance everything else already uses:

CREATE TABLE memex.haul_job (
    job            TEXT PRIMARY KEY,
    target_rounds  REAL NOT NULL DEFAULT 19,
    cuft_per_round REAL NOT NULL DEFAULT 3.36,
    deadline       DATE NOT NULL,
    active         BOOLEAN NOT NULL DEFAULT TRUE
);

CREATE TABLE memex.haul_rounds (
    oid    BIGSERIAL PRIMARY KEY,
    job    TEXT NOT NULL,
    ts     TIMESTAMPTZ NOT NULL DEFAULT now(),
    day    DATE NOT NULL,
    rounds REAL NOT NULL DEFAULT 1,   -- negative = undo a fat-fingered press
    source TEXT NOT NULL DEFAULT 'watch'
);

The job row is the part I'd argue for. Target, deadline and the volume of a round are data, not constants in a renderer — so when it rained and the target moved, that was an UPDATE, not a deploy. active = false is the off switch for the whole thing, and it's the same one row.

The endpoint takes no body

@app.post("/ingest/haul")
async def ingest_haul(request: Request, authorization: str | None = Header(default=None)):
    _check_ingest_token(authorization)
    n = max(-5.0, min(5.0, float(request.query_params.get("n", 1))))
    data = await asyncio.to_thread(log_round, n, source="watch")
    return JSONResponse({"ok": True, **data})

No body on purpose. The entire point is that a gloved thumb on a watch face logs a round with zero interaction, and every optional field is another way for the press to fail silently outdoors. n exists only so a double press can be undone with n=-1, and it's clamped so a runaway automation can't declare the job finished.

The response includes a say field — one line, short enough for a watch notification:

Round 6 logged. 3 today, 13 to go — 3.3 a day.

That line is the only feedback loop that matters. It answers "did that count?" without raising my wrist twice.

The shortcut is generated, not clicked together

Building a Shortcut by hand in the app and then trying to describe it in a README is a bad trade. A .shortcut file is a plist; write the plist:

actions = [
    {"WFWorkflowActionIdentifier": "is.workflow.actions.downloadurl",
     "WFWorkflowActionParameters": {
         "WFHTTPMethod": "POST", "WFURL": url, "ShowHeaders": True,
         "WFHTTPHeaders": _dict_field([("Authorization", _text(f"Bearer {token}"))]),
         "WFHTTPBodyType": "Form",
         "WFFormValues": _dict_field([("source", _text("watch"))])}},
    {"WFWorkflowActionIdentifier": "is.workflow.actions.getvalueforkey",
     "WFWorkflowActionParameters": {"WFDictionaryKey": "say",
                                    "WFGetDictionaryValueType": "Value"}},
    {"WFWorkflowActionIdentifier": "is.workflow.actions.notification",
     "WFWorkflowActionParameters": {"WFNotificationActionBody": _text_with_var(getval_uuid),
                                    "WFNotificationActionSound": True}},
]

Three actions, the token baked in at build time, and a second file built from the same function with ?n=-1 for undo. Sign it on a Mac (shortcuts sign --mode anyone) and it imports behind a normal confirmation sheet instead of demanding the "Allow Untrusted Shortcuts" toggle.

The gotcha that cost me the most time: WFWorkflowTypes must contain WatchKit, or the shortcut never syncs to the watch — and if it isn't on the watch, it doesn't appear in the Action button picker. The shortcut runs fine on the phone the whole time you're confused.

The bar hides itself after sunset

The kiosk rotates through pages every thirty seconds. A task bar that appears on one of seven pages is not a task bar. So it lives outside the rotating container, pinned under the page indicator, present on every page.

It's an outdoor job, so it has no business glowing at 2am. The obvious implementation reads sunrise/sunset off the weather API the dashboard already calls — which means the day the weather service has a bad morning, a piece of UI silently decides it's night. Whether to draw something is not a question you outsource over the network. It's a dozen lines of arithmetic against a fixed lat/lon:

function isDaylight(now = new Date()) {
  try {
    const { sunrise, sunset } = sunTimes(now, HOME.lat, HOME.lon);
    return now >= sunrise && now <= sunset;
  } catch {
    return true;   // a broken clock must not hide the meter all day
  }
}

Note the catch. The failure mode of a gate should be "the thing is visible when it shouldn't be", never "the thing you built silently isn't there".

One more display decision: discrete pips, not a smooth progress bar. Nineteen is a countable number, and from across a room a cell that fills on each press reads instantly where a bar that grows 5% does not. The pips past the filled ones are outlined amber up to today's share of what's left — so the row answers "am I done for today" without arithmetic.

What I'd do differently

Nothing about the shovel math, which was wrong by about 15% and didn't matter, because the meter counts rounds — my unit, the one I can feel — and only converts to cubic feet for display. If I'd modelled it in cubic feet I'd have been arguing with the database about how full a shovel is.

Pick the unit the human actually produces. Let the display do the conversion.