feat(r2): C4 the Desk — the index triaged by what needs the operator
- list_booths gains open_since (parsed, never compared as text), flags, landed_at (content only; a new, differently named clock, INV-5), viewed_at, and a four-image preview that keeps blur. - The index renders needs you / new since you looked / everything else, always in that order. Needs you includes unreadable marks, so a damaged judgment file cannot hide. Everything else keeps list_booths' order rather than stating a second rule. An empty section renders nothing. - The side column holds live benches (a damaged registry says so), bookmarks from BOOTH_LINKS_BOARD with booth URLs left out (capped at 8), and the pickup form. - test_booth's kept-lane test is rewritten as the contract declared: kept is a fact on each row, not a lane. Two of the new tests were VACUOUS on their first draft, and mutation- checking caught both. The clocks test used a future t0, so a hand-set marker outranked every real write. The look-then-judge test followed the flag's 303, and the resulting GET recorded a fresh look. Both are fixed and now go red under their mutation.
This commit is contained in:
+111
-7
@@ -41,11 +41,13 @@ import os
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import stat
|
||||
import tempfile
|
||||
import time
|
||||
import zipfile
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Sequence
|
||||
from urllib.parse import quote, unquote
|
||||
@@ -254,6 +256,56 @@ def _newest_mtime(path: Path) -> float:
|
||||
return newest
|
||||
|
||||
|
||||
def _content_mtime(path: Path) -> float:
|
||||
"""`landed_at` (R2 C4): the newest mtime among the booth's CONTENT — regular
|
||||
files with no dot-component in their path. Deliberately NOT `_newest_mtime`
|
||||
(INV-5 of r2): a mark, a view, a blur or a keep is activity, never new
|
||||
content, so none of them may make a booth read as newly landed.
|
||||
|
||||
Files only, never directories: creating `.viewed` bumps the booth
|
||||
directory's own mtime, and counting that would make the first look at a
|
||||
booth look like a delivery. An empty booth landed at 0.0. Unknowable reads
|
||||
as NOW, the posture `_newest_mtime` takes and for a milder reason here: a
|
||||
booth we cannot read is shown as new rather than hidden as old.
|
||||
"""
|
||||
newest = 0.0
|
||||
try:
|
||||
for p in path.rglob("*"):
|
||||
rel = p.relative_to(path)
|
||||
if any(part.startswith(".") for part in rel.parts):
|
||||
continue
|
||||
try:
|
||||
st = p.stat()
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
if stat.S_ISREG(st.st_mode) and st.st_mtime > newest:
|
||||
newest = st.st_mtime
|
||||
except OSError:
|
||||
return time.time()
|
||||
return newest
|
||||
|
||||
|
||||
def _viewed_at(path: Path) -> float | None:
|
||||
"""The mtime of the booth's `.viewed` marker (U4), or None if it has never
|
||||
been looked at. `lstat`, like `is_kept`: a planted symlink is read as the
|
||||
marker it claims to be, never followed."""
|
||||
try:
|
||||
return os.lstat(path / VIEW_MARKER).st_mtime
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _stamp(created: str) -> datetime | None:
|
||||
"""A mark's `created` as an aware datetime, or None when it will not parse.
|
||||
Strings are never compared: two ISO stamps with different offsets sort
|
||||
wrong as text. A naive stamp is read as UTC."""
|
||||
try:
|
||||
dt = datetime.fromisoformat(created)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def booth_age_seconds(path: Path, now: float | None = None) -> float:
|
||||
now = time.time() if now is None else now
|
||||
return now - _newest_mtime(path)
|
||||
@@ -384,6 +436,9 @@ def wants_json(accept: str | None) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
# The Desk shows this many bookmarks and links to the board for the rest.
|
||||
BOOKMARKS_SHOWN = 8
|
||||
|
||||
HOLD_UNREADABLE = "unreadable"
|
||||
HOLD_OPEN = "open"
|
||||
|
||||
@@ -529,6 +584,10 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) ->
|
||||
# rather than re-opening .blurred here.
|
||||
thumb_blurred = it.blurred
|
||||
mtime = _newest_mtime(child)
|
||||
# R2 C4: the oldest question still owed an answer, PARSED. Unparseable
|
||||
# stamps are left out, so a booth whose every open pick is unparseable
|
||||
# has no `open_since` and sorts after every booth that has one.
|
||||
stamps = [st for st in (_stamp(m.created) for m in open_marks(marks)) if st]
|
||||
booths.append(
|
||||
{
|
||||
"name": child.name,
|
||||
@@ -555,6 +614,17 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) ->
|
||||
"hold": hold,
|
||||
"expires_in": max(0.0, ttl_seconds - (now - mtime)),
|
||||
"mtime": mtime,
|
||||
# ---- R2 C4, the Desk. All from the pass above; no second read.
|
||||
"open_since": min(stamps) if stamps else None,
|
||||
"flags": sum(1 for m in marks if m.shape == "flag"),
|
||||
# Two clocks, named apart (INV-5): `mtime` is activity,
|
||||
# `landed_at` is content. "New since you looked" reads only the
|
||||
# second, so a flag or a view never makes a booth look new.
|
||||
"landed_at": _content_mtime(child),
|
||||
"viewed_at": _viewed_at(child),
|
||||
# The first four images in item order, as the originals shown
|
||||
# small. Blurred ones stay blurred, the cover's rule.
|
||||
"preview": [(it.url, it.blurred) for it in items if it.kind == "image"][:4],
|
||||
}
|
||||
)
|
||||
# Newest first, NAME as the tie-break. Sorting on mtime alone left equal-mtime
|
||||
@@ -779,6 +849,7 @@ def create_app(
|
||||
sweep_interval_s: int = 900,
|
||||
max_upload_mb: float = 1024.0,
|
||||
max_files: int = 50,
|
||||
links_board: str = "links",
|
||||
) -> FastAPI:
|
||||
data_dir = Path(data_dir).expanduser().resolve()
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -889,19 +960,49 @@ def create_app(
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def index(request: Request):
|
||||
# Two lanes, split here rather than in the template: kept boards are a
|
||||
# different KIND of thing from the ephemeral churn — durable, deliberate,
|
||||
# operator-facing — and burying them in a feed that turns over daily is
|
||||
# exactly how they would get lost, which is the problem they exist to
|
||||
# solve. Kept renders first.
|
||||
"""THE DESK (R2 C4) — the index triaged by what needs the operator.
|
||||
|
||||
Three sections, ALWAYS in this order, each booth in exactly one:
|
||||
needs — an open pick, or marks that cannot be read (somebody has to
|
||||
fix those, so they must not hide further down). Oldest open
|
||||
question first; a booth with no parseable stamp after every
|
||||
booth that has one; name breaks ties.
|
||||
new — content landed since the booth was last looked at, or never
|
||||
looked at. Newest content first; name breaks ties.
|
||||
rest — everything else, in `list_booths`' own order (last activity
|
||||
first, name as the tie-break). No second rule is stated.
|
||||
The kept/ephemeral lanes are gone: 23 of 24 live booths were kept, so
|
||||
the lanes sorted nothing. Kept status still shows on every row.
|
||||
"""
|
||||
everything = list_booths(data_dir, ttl_seconds)
|
||||
needs = [b for b in everything
|
||||
if b["marks_open"] > 0 or b["hold"] == HOLD_UNREADABLE]
|
||||
needs.sort(key=lambda b: ((0, b["open_since"].timestamp())
|
||||
if b["open_since"] else (1, 0.0), b["name"]))
|
||||
in_needs = {b["name"] for b in needs}
|
||||
new = [b for b in everything if b["name"] not in in_needs
|
||||
and (b["viewed_at"] is None or b["landed_at"] > b["viewed_at"])]
|
||||
new.sort(key=lambda b: (-b["landed_at"], b["name"]))
|
||||
in_new = {b["name"] for b in new}
|
||||
rest = [b for b in everything
|
||||
if b["name"] not in in_needs and b["name"] not in in_new]
|
||||
benches, benches_error = read_benches(data_dir)
|
||||
board = data_dir / links_board
|
||||
bookmarks = [row for row in _board_rows(board)
|
||||
if booth_target(row["url"]) is None] if board.is_dir() else []
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"index.html",
|
||||
{
|
||||
**base_ctx,
|
||||
"kept": [b for b in everything if b["kept"]],
|
||||
"booths": [b for b in everything if not b["kept"]],
|
||||
"needs": needs,
|
||||
"new": new,
|
||||
"rest": rest,
|
||||
"benches": [b for b in benches if b.state != "retired"],
|
||||
"benches_error": benches_error,
|
||||
"bookmarks": bookmarks[:BOOKMARKS_SHOWN],
|
||||
"bookmarks_total": len(bookmarks),
|
||||
"board_url": f"/b/{quote(links_board, safe='')}/",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1875,6 +1976,9 @@ def _from_env() -> FastAPI:
|
||||
sweep_interval_s=interval,
|
||||
max_upload_mb=max_mb,
|
||||
max_files=max_n,
|
||||
# The board the CLI's `booth link` writes (scripts/booth reads the same
|
||||
# variable), so the Desk's bookmarks come from where they are written.
|
||||
links_board=os.environ.get("BOOTH_LINKS_BOARD", "links"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user