Konubinix' opinionated web of thoughts

Frise Chrono

Fleeting

I have years of photos and videos sitting in Postgres. What I miss is the shape of time — seeing a whole stretch of months at once, each photo pinned where it actually happened, the way a wall timeline reads at a glance.

Let’s build that frise: a horizontal time ruler with month and year graduations, photos posed by date above it.

Choice of technology

The photos already live in Postgres; the database is the source of truth and the app only reads it (the rare write — a label edit — is fire-and-forget, not a concurrent edit to reconcile). Data comes down, events go up, nothing merges — so I can pick a stack where the bridge between the pieces ships off-the-shelf:

  • PostGraphile exposes Postgres as a GraphQL API.
  • urql is a small GraphQL client whose normalised cache is the read store: PostGraphile fills it, the UI reads from it. Its Preact bindings are the bridge I don’t have to write.
  • Preact + HTM render with hooks and JSX-like tagged templates — no build step, loaded straight from esm.sh through an import map.

The frise itself — the ruler, the month and year ticks, the photos placed by date — stays a bespoke component on plain CSS. A timeline library would drag in its own DOM and its own data model: a second store to reconcile with urql’s, the hand-written bridge again, for a layout that is a few lines of date → x.

One friction worth noting, since comparing the render techs is half the point. Binding a wheel listener here was surprisingly hard. The onWheel prop never fired (onClick=/=onPointerDown bind fine), and an effect-based addEventListener was racy under test. The exact same Shift+wheel lightbox navigation was a first-try, one-line success in memories, where Solid’s solid-js/html binds onWheel synchronously at element creation. Not a verdict — I didn’t root-cause whether it’s htm’s prop handling, Preact’s event system, or the test’s async wheel — but a real point in Solid’s favour for terse event binding, and the kind of difference these three parallel apps exist to reveal. (The frise lightbox already navigates by buttons / arrows / swipe, so S-scroll was only a bonus; it’s deferred — see TODO.)

How the pieces fit

Before the build narrative starts naming components one at a time, here is the whole map in one glance. Two pictures carry it: what talks to what, and which way the data moves.

The component tree and its data sources. Postgres is the source of truth; PostGraphile exposes it at /graphql, urql’s normalised cache holds the read store, and every component reads it through the one usePhotos hook. Images take a separate path: thumbnails and full media load straight from the /ipfs/ gateway, same origin.

One loop, one direction. The App shell holds the window; it flows down to the children as read-only props, and their events flow up through onChange / onPan / onZoom / onOpen. App folds each event into the window, mirrors it into the URL, and the URL seeds the initial state on load. No component drives itself.

Boot into a titled shell

Before a single photo has loaded, the screen must not read as broken. The minimum honest boot is a titled shell: the app mounts and names itself, so an empty screen says “the frise, still loading” rather than “nothing happened”. The loading and empty states proper come once there is a backend to be loading from; this first cycle only pins that the shell mounts and shows its name.

We can assert this by opening the app and looking for the title by its heading role — a stable handle across every later state the frise will grow into.

@testcase
def test_boots_into_titled_shell(page):
    """Opening the app shows the titled shell, so a blank screen never
    reads as broken."""
    open_app(page)
    assert page.get_by_role("heading", name="Frise chrono").is_visible(), \
        "the app title isn't visible after load"
    print("  PASS: boots into titled shell")

Booting needs an import map naming where the libraries come from and a node to mount into. The map carries the render layer — Preact and HTM — and the data layer — urql with its Preact bindings and the graphql peer. Everything is pinned through esm.sh, and the urql packages are pulled with ?external=preact so they share the single Preact instance HTM uses — we don’t want two copies.

<script type="importmap">
  {
    "imports": {
      "preact": "https://esm.sh/preact@10",
      "preact/hooks": "https://esm.sh/preact@10/hooks",
      "htm/preact": "https://esm.sh/htm@3/preact?external=preact",
      "@urql/core": "https://esm.sh/@urql/core@5",
      "@urql/preact": "https://esm.sh/@urql/preact@4?external=preact,@urql/core",
      "graphql": "https://esm.sh/graphql@16"
    }
  }
</script>

<div id="app"></div>

The render and data layers arrive together: Preact’s html=/=render and its hooks (useState, useRef, useLayoutEffect), urql’s Provider and useQuery, and the shared GraphQL client with its auth-store accessors.

import { html, render } from 'htm/preact';
import { useState, useRef, useLayoutEffect, useEffect } from 'preact/hooks';
import { Provider, useQuery, useMutation } from '@urql/preact';
import { client, getAuthNeeded, subscribeAuth } from '../shared/gql.js';

I want two things here: one source of truth for the visible window, and a view I can share and reload into. So a single owner — the App shell — holds the window (opening on the last month) and hands it down to the controls, the photos, and the ruler; their writes come back up through onChange, reads go down. The query bounds are that window with from clamped to MIN_DATE, so the floor holds however far back the viewer scrolls — while the strip itself positions over the true window, so panning into the pre-archive past shows empty space rather than collapsing the span. And every change mirrors into the URL — the window and the search term alike (?from=&to=&q=) — read back on load, so a shared link reopens the same view and survives a reload. One loop, no component drives itself.

We set a search, watch it ride into the URL, and reload straight back into it:

@testcase
def test_query_persists_in_url(page):
    """The search term rides in the shareable URL — set it, and a reload restores it."""
    open_app(page)
    page.fill("[data-search]", "cosmo")
    page.wait_for_function("() => new URLSearchParams(location.search).get('q') === 'cosmo'")
    page.goto(page.url, wait_until="commit")
    page.wait_for_selector("body[data-app-ready='1']", state="attached", timeout=8000)
    assert page.input_value("[data-search]") == "cosmo", \
        f"reload dropped the search: {page.input_value('[data-search]')!r}"
    print("  PASS: query persists in url")

The window pans without walls. Forward, it slides past today into the empty future rather than stopping at the present.

@testcase
def test_pan_reaches_the_future(page):
    """Panning forward slides past today instead of hitting a wall at the present."""
    open_app(page)
    today = page.evaluate("() => new Date().toISOString().slice(0, 10)")
    page.keyboard.press("ArrowRight")                          # pan forward
    page.wait_for_function("() => document.querySelector('[data-win-to]').value > %r" % today)
    print("  PASS: pan reaches the future")

Backward, it slides before the archive’s first photos without the span collapsing at the 2007 floor — an early photo keeps its true place across a window straddling that year.

@testcase
def test_strip_spans_past_2007(page):
    """Panned across the 2007 floor, the strip keeps the true span — an early photo holds
    its real place instead of collapsing against the floored edge."""
    doc = {"cid": "https://ipfs.konubinix.eu/p/zzfloor", "date": "2007-07-01T12:00:00Z", "mimetype": "image/jpeg",
           "thumbnailCid": "https://ipfs.konubinix.eu/p/zzfloor-t", "labels": "zzfloor", "state": "todo", "owner": "konubinix"}
    CREATE = "mutation($p:PhotovideoInput!){ createPhotovideo(input:{photovideo:$p}){ clientMutationId } }"
    DELETE = "mutation($cid:String!){ deletePhotovideo(input:{cid:$cid}){ clientMutationId } }"
    gql(DELETE, {"cid": doc["cid"]}); gql(CREATE, {"p": doc})
    try:
        open_app(page)
        page.fill("[data-search]", "zzfloor")                  # isolate the seeded photo
        page.fill("[data-win-from]", "2005-01-01")             # window straddles the 2007 floor
        page.fill("[data-win-to]", "2009-01-01")
        pin = page.locator("img[src*='zzfloor-t']")
        pin.wait_for()
        sb = page.locator(".strip").bounding_box(); pb = pin.bounding_box()
        frac = (pb["x"] + pb["width"] / 2 - sb["x"]) / sb["width"]
        # 2007-07 over the true [2005,2009] sits at ~0.62; floored to [2007,2009] it hugs ~0.25
        assert frac > 0.45, f"early photo collapsed toward the floored edge: frac={frac:.2f}"
    finally:
        gql(DELETE, {"cid": doc["cid"]})
    print("  PASS: strip spans past 2007")

const isoDay = ms => new Date(ms).toISOString().slice(0, 10);
const defaultWin = () => ({ from: isoDay(Date.now() - 31 * DAY), to: isoDay(Date.now()) });
const winFromUrl = () => {
    const p = new URLSearchParams(location.search);
    const from = p.get('from'), to = p.get('to');
    return (from && to) ? { from, to, search: p.get('q') || '' } : null;
};
function App(){
    const [win, setWin] = useState(() => winFromUrl() || defaultWin());
    const [open, setOpen] = useState(null);
    const authNeeded = useAuthNeeded();   // /graphql 401 → this device needs a grant
    // merge into the current window so navigating time (pan, zoom, span, dates)
    // keeps the active search filter; only reset deliberately clears it.
    const update = w => {
        const next = { ...win, ...w };
        setWin(next);
        const qs = new URLSearchParams({ from: next.from, to: next.to });
        if(next.search) qs.set('q', next.search);
        try { history.replaceState(null, '', '?' + qs); } catch (e) {}
    };
    const panTo = (from, to) => update({ from, to });
    // an open photo is a history entry, so the phone's back button (or browser
    // back) closes it instead of leaving the frise. Push the entry *synchronously*
    // as the photo opens (not in an effect) so a fast Back can't pop past the app.
    const openDoc = doc => { history.pushState({ lightbox: true }, ''); setOpen(doc); };
    useEffect(() => {
        const onPop = () => setOpen(null);
        addEventListener('popstate', onPop);
        return () => removeEventListener('popstate', onPop);
    }, []);
    const vars = { from: win.from > MIN_DATE ? win.from : MIN_DATE, to: win.to, search: win.search };
    // the same windowed, date-ordered list the strip shows (urql dedupes the query),
    // so the open photo can step to its neighbours, wrapping at the ends.
    const { nodes, error } = usePhotos(vars);
    const step = delta => {
        if(!open || !nodes.length) return;
        const i = nodes.findIndex(n => n.cid === open.cid);
        setOpen(nodes[(((i < 0 ? 0 : i) + delta) % nodes.length + nodes.length) % nodes.length]);
    };
    // → / ← step through an open photo, or — with none open — pan the window by a
    // fifth of its width (forward / back): a small nudge for repeated presses, well
    // inside the overscan so the next view is already loaded.
    const onArrow = dir => {
        if(open) return step(dir);
        const a = new Date(win.from).getTime(), b = new Date(win.to).getTime();
        const d = (b - a) * 0.2 * dir;
        panTo(isoDay(a + d), isoDay(b + d));
    };
    // bind the key listener once and dispatch through a ref, so the arrows never act
    // on a stale open/window closure (which would wrap or pan the wrong way).
    const arrowRef = useRef(onArrow); arrowRef.current = onArrow;
    useEffect(() => {
        const onKey = e => {
            if(/^(INPUT|TEXTAREA)$/.test(e.target.tagName)) return;   // let arrows move the text caret
            if(e.key === 'ArrowRight') arrowRef.current(1);
            else if(e.key === 'ArrowLeft') arrowRef.current(-1);
        };
        addEventListener('keydown', onKey);
        return () => removeEventListener('keydown', onKey);
    }, []);
    return html`
      ${authNeeded ? html`<div class="authwall" role="alert">
        <strong>Authorization required.</strong>
        This device can't read your photos yet — open a fresh access link on it.
      </div>` : ''}
      ${error && !authNeeded ? html`<div class="authwall" role="alert" data-error>
        <strong>Archive unreachable.</strong>
        Can't load your photos — the backend may be offline.
      </div>` : ''}
      <h1>Frise chrono</h1>
      <${SearchBox} win=${win} onChange=${update} />
      <${WindowControls} win=${win} onChange=${update} onReset=${() => update({ ...defaultWin(), search: '' })} />
      <${PhotoCount} win=${vars} />
      <${TimelineStrip} win=${win} onOpen=${openDoc}
        onZoom=${(frac, dy) => update(zoomAt(vars, frac, dy))}
        onPan=${panTo} />
      <${Ruler} win=${vars} onZoom=${(a, b) => update({ from: isoDay(a), to: isoDay(b) })} />
      <${Loading} win=${vars} />
      <${EmptyHint} win=${vars} />
      <${Lightbox} key=${open ? open.cid : 'none'} doc=${open} onClose=${() => history.back()}
        onStep=${step}
        onPickLabel=${label => {
            const tok = label.includes(' ') ? `"${label}"` : label;
            const cur = (win.search || '').trim();
            update({ search: cur ? `${cur} ${tok}` : tok });
            history.back();   // close the photo so the filtered frise shows
        }} />
    `;
}

Mounting wraps the app in urql’s Provider so any component can query, and raises a data-app-ready flag so the tests wait on a deterministic signal rather than a guessed delay.

render(html`<${Provider} value=${client}><${App} /></${Provider}>`,
       document.getElementById('app'));
document.body.setAttribute('data-app-ready', '1');
if('serviceWorker' in navigator) navigator.serviceWorker.register('sw.js').catch(() => {});

A dark canvas under it all, so the shell doesn’t flash white before the styles settle.

:root{ --bg:#1b1d2e; --fg:#e8e8f0; }
body{ background:var(--bg); color:var(--fg); font-family:system-ui,sans-serif; margin:0;
      padding-bottom:36px; }   /* clear the fixed date bar so it never covers content */
.authwall{ background:#f9a826; color:#1b1d2e; padding:10px 14px; border-radius:8px;
           margin:0 0 12px; line-height:1.45; }
.authwall strong{ display:block; }

When the device isn’t authorized

We can force this deterministically by routing /graphql to a 401 (a page route wins over the context fixture forward) and checking for an alert that mentions authorization. The 401 travels from the proxy up through urql’s fetch wrapper to the banner:

@testcase
def test_shows_auth_required_when_unauthorized(page):
    """A 401 from /graphql shows a clear 'authorization required' message."""
    page.route("**/graphql", lambda route: route.fulfill(
        status=401, content_type="text/plain", body="Unauthorized"))
    open_app(page)
    page.get_by_role("alert").wait_for(state="visible")
    assert re.search("authoriz", page.get_by_role("alert").inner_text(), re.I), \
        "expected an authorization-required message"
    print("  PASS: shows auth required when unauthorized")

urql doesn’t take a raw fetch, so the 401 is caught in the shared client’s fetch wrapper, which flips a tiny framework-agnostic store; the frise’s useAuthNeeded makes that store Preact-reactive and the App renders the banner from it. It never tries to authenticate — the app can’t mint a grant — it only says a fresh access link is needed on this device.

Photos land on the axis

The shell is honest but empty. Time to bring in the real photos. They live in Postgres and reach us as GraphQL through PostGraphile v5 at /graphql, each carrying a date and a thumbnailCid. The frise’s whole promise is that a photo sits where it happened — so the first thing to pin down is position follows time: read left to right, the thumbnails march forward in date order.

We hardcode no pixel and no particular photo — the data is live. The thumbnails are rendered in date order (the query asks for DATE_ASC), so the property to pin is simply that their horizontal positions, read in document order, never go backwards. If two photos swap, the axis is lying about time.

@testcase
def test_photos_positioned_by_date(page):
    """Thumbnails (rendered in date order) have non-decreasing left positions."""
    open_app(page)
    page.wait_for_selector("[data-photo-date]")
    xs = page.eval_on_selector_all(
        "[data-photo-date]",
        "els => els.map(e => e.getBoundingClientRect().left)")
    assert len(xs) >= 2, "expected several photos on the axis"
    assert xs == sorted(xs), \
        "photos are not laid out left-to-right in date order"
    print("  PASS: photos positioned by date")

One query feeds everything, parameterized by the window — a from=/=to pair of dates. It returns the first 300 photos in the range, in date order. MIN_DATE is the floor the window opens at, so placeholder rows (motivated in Only real dates) never enter. Rows that carry no thumbnailCid are dropped in the component rather than in the query: photovideosSearch is a server-side search function, not a filterable table connection, so its results are pruned client-side.

The box itself is the shared query language, the same parser memories uses. Turning that parse into the query’s filter variables is shared too — searchVars is the data module’s photoVars, handed the window as the date range. So the frise gains the boolean label search and the type:=/=owner:=/=onthisday: filters, in step with memories. The query’s GraphQL declarations and arguments are shared too, and the fields it reads back are the PhotoCore set; the frise splices those in, adding only its own — the cap=/=dsince=/=duntil window args and size=/=cameraType fields.

import { parseQuery, segSpan, dslSuggestions, stripWindowTokens, MIN_DATE } from '../shared/dsl.js';
import { SAMPLE_CAP, photoVars, UPDATE_PHOTO, PHOTO_CORE, PHOTO_FILTER_DECL, PHOTO_FILTER_ARGS, LABEL_COMPLETIONS } from '../shared/data.js';

const PHOTOS = `
  query Photos(${PHOTO_FILTER_DECL}, $cap: Int!, $dsince: Datetime, $duntil: Datetime) {
    photovideosSearch(${PHOTO_FILTER_ARGS}, cap: $cap, dsince: $dsince, duntil: $duntil, first: 8000) {
      nodes { ...PhotoCore size cameraType }
    }
  }
  ${PHOTO_CORE}
`;
const COUNT = `
  query Count(${PHOTO_FILTER_DECL}) {
    photovideosCount(${PHOTO_FILTER_ARGS})
  }
`;
const searchVars = win => photoVars(parseQuery(win.search), { since: win.from, until: win.to });
// tell urql's document cache these queries concern Photovideo even when they come
// back empty, so a label edit (an updatePhotovideo mutation) invalidates and refetches
// them — otherwise an empty search/count records no typename and never refreshes.
const PV_CTX = { additionalTypenames: ['Photovideo'] };

Every component reads through one hook so the query, its variables and the thumbnail filter live in a single place. An empty search means “everything in the window”; a term narrows it — the window (since=/=until) and date order come from the server function either way. The server shows every photo a window holds, up to cap (SAMPLE_CAP); only a denser window samples down (a stable per-photo hash, so panning doesn’t reshuffle them), and PhotoCount asks photovideosCount for the true total. searchVars maps a window to query variables once, shared by all.

function usePhotos(win, opts = {}){
    // opts.cap caps the spread (default SAMPLE_CAP); opts.dsince/duntil set the
    // *density* window the cap is measured against (defaults to the queried window).
    // The strip queries a wide window but measures density over the visible one, so
    // the overscan margins ride along at the visible rate without thinning the view.
    const variables = { ...searchVars(win), cap: opts.cap ?? SAMPLE_CAP,
                        dsince: opts.dsince ?? null, duntil: opts.duntil ?? null };
    const [res] = useQuery({ query: PHOTOS, variables, context: PV_CTX });
    const conn = res.data?.photovideosSearch;
    return {
        nodes: (conn?.nodes ?? []).filter(n => n.thumbnailCid),
        fetching: res.fetching,
        error: res.error,           // backend down / GraphQL error → distinguish from "empty"
    };
}

A thumbnail base turns each /ipfs/... path into a loadable URL — a same-origin relative path, so the frise works wherever it’s served, with no host baked in. The auth gate is the shared client’s; a small Preact hook makes its store reactive for the banner.

const THUMB_BASE = '';   // https://ipfs.konubinix.eu/p/... is served on the same origin as the app
function useAuthNeeded(){
    const [v, setV] = useState(getAuthNeeded());
    useEffect(() => subscribeAuth(setV), []);
    return v;
}

The strip maps time to space across the window: win.from and win.to are the two ends, and each thumbnail sits at its date’s fraction of that span — left as a percentage, centred on the point. The data-photo-date attribute is the seam the test reads, and data-kind (photo or video, from the mimetype) lets a video wear a distinct ring. Clicking a thumbnail opens its full media (see Open a photo).

const OVERSCAN = 1;   // render one extra visible-span of photos on each side of the
                      // window (matching the loader's 100% horizontal margin) so a pan
                      // reveals already-placed, already-loaded thumbnails from off-screen.
function TimelineStrip({ win, onOpen, onZoom, onPan }){
    const min = new Date(win.from).getTime(), max = new Date(win.to).getTime();
    const span = (max - min) || 1;
    // render past the edges: fetch a window one span wider on each side, but measure
    // the sample's density over the *visible* window (dsince/duntil) — so the on-screen
    // photos are never thinned (the bug a single wide query would cause) and the off-
    // window margins ride along at the same rate. One fetch. Map over the visible
    // window: margins land at frac<0 / >1, outside the clip; on-screen pins keep their spots.
    const pad = (max - min) * OVERSCAN, floor = new Date(MIN_DATE).getTime();
    const rwin = { ...win, from: isoDay(Math.max(min - pad, floor)), to: isoDay(max + pad) };
    const { nodes } = usePhotos(rwin, { dsince: win.from, duntil: win.to });
    const ref = useRef();
    const [w, setW] = useState(0);
    useLayoutEffect(() => {
        const el = ref.current;
        if(!el) return;
        const measure = () => setW(el.clientWidth);
        measure();
        const ro = new ResizeObserver(measure);
        ro.observe(el);
        return () => ro.disconnect();
    }, []);
    const pan = usePan(win, w, span, onPan);
    const placed = placeGrid(nodes, min, span, w);
    const lanes = placed.reduce((m, p) => Math.max(m, p.lane), 0) + 1;
    const gaps = gapMarks(placed, w);
    return html`
      <div class="strip-clip">
        <div class="strip" ref=${ref} ...${pan.handlers}
             style=${`height:${nodes.length ? lanes * ROW : ROW * 2}px; transform:translateX(${pan.dx}px); touch-action:pan-y`}
             onWheel=${e => {
                 if(e.ctrlKey){          // C-scroll (and trackpad pinch) → zoom into the pointer
                     e.preventDefault();
                     const r = e.currentTarget.getBoundingClientRect();
                     onZoom((e.clientX - r.left) / r.width, e.deltaY);
                 } else if(e.shiftKey){  // S-scroll → pan the window in time, by the wheel delta
                     e.preventDefault();
                     // take the dominant axis: browsers route Shift+wheel to deltaX on some
                     // setups, deltaY on others — picking the larger magnitude works for both.
                     const wheel = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
                     const dt = (wheel / Math.max(1, w - THUMB)) * span;
                     onPan(isoDay(min + dt), isoDay(max + dt));
                 }
             }}>
          ${gaps.map(f => html`
            <span class="gap" data-gap style=${`left:${xLeft(f)}; top:${lanes * ROW / 2}px`}>…</span>
          `)}
          ${placed.map(({ n, frac, lane }) => html`
            <${Pin} key=${n.cid} n=${n} frac=${frac} lane=${lane} moved=${pan.moved} onOpen=${onOpen}
                    over=${frac < 0 || frac > 1 || n.date < win.from || n.date >= win.to} />
          `)}
        </div>
      </div>
    `;
}

A relative strip with the thumbnails absolutely placed along it, each centred on its date by a half-width shift. The thumbnails are a uniform size: with a fixed width the half-width shift is constant, so two photos sharing a date (there are many — missing dates default to a sentinel) land at exactly the same spot rather than wobbling by their differing aspect ratios.

.strip-clip{ overflow-x:clip; }   /* hide the overscan rendered past the viewport edges */
.strip{ position:relative; margin-top:24px; }
.pin, .pin-os{ position:absolute; top:0; width:64px; height:64px; object-fit:cover; background:#262a40;
      transform:translateX(-50%); border-radius:4px; box-shadow:0 1px 4px #0008; cursor:pointer; }

Load only the pins on screen

The window already bounds how many photos are fetched, but a wide span still strings hundreds — soon thousands — of pins across many lanes, most below the fold or, once you start panning, off the sides. Holding a decoded image for every one of them is what eventually grinds the strip to a halt. So each pin carries its /ipfs/ source only while near the viewport and drops back to a 1×1 blank when it leaves, the way the memories wall does — load and unload, not just deferred-load.

The twist here is the margin. The frise’s gesture is a lateral drag, and an image that only loaded once its edge crossed the viewport would pop in visibly mid-pan. So the observer’s margin is broad horizontally — a full viewport width on each side — and tight vertically: a pin is ready a whole screen before it slides in and isn’t dropped until a whole screen after it slides out, so the load/unload churn happens off-screen and the pan stays fluid. The vertical margin still frees the lanes far below.

To check this, we read the lowest pin (well below the fold, outside the margin): it has no /ipfs/ source, gains one when scrolled into view, and drops it again when scrolled away.

@testcase
def test_pins_load_near_viewport_and_unload(page):
    """A pin holds its /ipfs/ src only while near the viewport — a pin far outside
    drops its image — so the strip scales to thousands of photos without keeping
    every one decoded."""
    open_app(page)
    page.wait_for_selector("img.pin")
    page.wait_for_function("document.querySelectorAll('img.pin').length > 30")
    # the lowest pin by layout (offsetTop is scroll-independent), addressed by index
    idx = page.evaluate("() => { const e = [...document.querySelectorAll('img.pin')];"
        " let bi = 0, bt = -1; e.forEach((x, i) => { if(x.offsetTop > bt){ bt = x.offsetTop; bi = i; } });"
        " return bi; }")
    has_ipfs = f"() => (document.querySelectorAll('img.pin')[{idx}].getAttribute('src') || '').includes('/ipfs/')"
    no_ipfs = f"() => !(document.querySelectorAll('img.pin')[{idx}].getAttribute('src') || '').includes('/ipfs/')"
    page.wait_for_function(no_ipfs)                                    # far below the fold → unloaded
    page.eval_on_selector_all("img.pin", f"e => e[{idx}].scrollIntoView({{ block: 'center' }})")
    page.wait_for_function(has_ipfs)                                   # scrolled in → loaded
    page.eval_on_selector_all("img.pin",                              # bring the top back, low leaves again
        "e => { let bi = 0, bt = Infinity; e.forEach((x, i) => { if(x.offsetTop < bt){ bt = x.offsetTop; bi = i; } });"
        "       e[bi].scrollIntoView({ block: 'center' }); }")
    page.wait_for_function(no_ipfs)                                    # left again → unloaded
    print("  PASS: pins load near the viewport and unload when they leave")

One shared observer serves every pin (a WeakMap from element to its setter). Each Pin is its own component so it can hold a near flag and register/unregister on mount — Preact can’t keep per-item state inside a .map, and the key keeps a pin’s flag tied to its photo across re-renders.

// a 1×1 transparent gif — what a pin shows when its image is unloaded (the .pin
// background fills the square). Swapping back to it releases the decoded image.
const BLANK = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
// one IntersectionObserver gates every pin's <img src>. The margin is broad
// horizontally (a full viewport width each side) so a pin loads well before it
// pans into view and is dropped well after it leaves — the load/unload churn
// happens off-screen, keeping the lateral pan fluid; a tighter 300px vertical
// margin still frees the lanes far below the fold.
const tileObserver = (() => {
    const cbs = new WeakMap();
    const io = new IntersectionObserver(
        entries => entries.forEach(e => cbs.get(e.target)?.(e.isIntersecting)),
        { rootMargin: '300px 100% 300px 100%' });
    return {
        watch(el, set){ cbs.set(el, set); io.observe(el); },
        unwatch(el){ io.unobserve(el); cbs.delete(el); },
    };
})();

// a single timeline pin: an absolutely-placed thumbnail that loads its image only
// while near the viewport (tileObserver), falling back to BLANK otherwise. An
// *overscan* pin — one rendered off-screen (frac<0 / >1) or whose date falls outside
// the visible window — wears a distinct class and drops its data-photo-date, so "the
// photos on the timeline" (the seam every test reads: img.pin / [data-photo-date])
// keeps meaning exactly the on-screen, in-window set.
function Pin({ n, frac, lane, over, moved, onOpen }){
    const [near, setNear] = useState(false);
    const ref = useRef();
    useEffect(() => {
        const el = ref.current;
        if(!el) return;
        tileObserver.watch(el, setNear);
        return () => tileObserver.unwatch(el);
    }, []);
    return html`
      <img class=${over ? 'pin-os' : 'pin'} ref=${ref} title=${n.date.slice(0, 10)}
           ...${over ? {} : { 'data-photo-date': n.date }}
           data-kind=${(n.mimetype || '').startsWith('video') ? 'video' : 'photo'}
           style=${`left:${xLeft(frac)}; top:${lane * ROW}px`}
           src=${near ? THUMB_BASE + n.thumbnailCid : BLANK}
           onClick=${() => { if(moved()) return; onOpen(n); }} />`;
}

Render past the edges

Even with the lazy-loader’s broad margin, a freshly-panned-to photo still has to appear: until you pan, it isn’t in the strip at all, so it pops into the viewport as the window recommits. The cure is to render past the edgesTimelineStrip fetches and lays out a window one visible-span wider on each side, but still maps positions over the visible window, so the extra photos land at frac < 0 or frac > 1 — just outside the viewport — where the strip’s overflow-x: clip hides them. Pan, and they slide in already placed (and already loaded: the overscan is one screen wide, exactly the loader’s horizontal margin). New thumbnails materialise off-screen, not under your eyes.

The sampling makes this subtle. photovideos_search keeps a fixed spread of a window (see A spread, not the first batch) — and the keep-rate is set by the window’s total. Naively querying a window three times wider would set that rate from the wide total, thinning the visible third to a fraction — fewer photos than the count promises and than the lightbox can step to. The fix lives in the function: it takes a separate density window (dsince=/=duntil), and the strip passes the visible window there while querying the wide one. So the keep-rate is the visible window’s, the overscan margins ride along at the same rate, and a single fetch covers both — no thinning. (The shared cap is high enough that any readable window shows every photo it holds; the frise can afford that because its lazy-loader keeps off-screen pins as DOM, not decoded images.) Mapping stays over the visible window, so on-screen photos keep their exact spots (the ruler stays aligned). The margin photos are overscan pins: placed at frac < 0 / >1 (or simply outside the window’s dates), they wear a pin-os class and carry no data-photo-date, so “the photos on the timeline” — the seam the tests read — still means the on-screen, in-window set.

One test checks overscan pins exist outside the viewport (clipped, not spilling into a page scroll); a second guards the bug this design fixes — a window under the cap must show every photo it holds, not an overscan-thinned fraction.

@testcase
def test_strip_renders_past_the_edges(page):
    """The strip renders a window wider than the screen: overscan pins sit beyond the
    visible edges (a pan reveals them already placed), and the overscan is clipped."""
    open_app(page)
    page.fill("[data-win-from]", "2015-01-01")     # mid-archive: populated on both sides
    page.fill("[data-win-to]", "2016-01-01")
    page.wait_for_selector("img.pin")
    # overscan pins exist, sitting fully outside the viewport — ready to pan in
    page.wait_for_function(
        "() => { const vw = innerWidth; const os = [...document.querySelectorAll('img.pin-os')];"
        " return os.length > 0 && os.some(e => { const r = e.getBoundingClientRect();"
        "   return r.right < 0 || r.left > vw; }); }")
    # ...and the overscan is clipped, not widening the page into a scrollbar
    assert page.evaluate("() => document.documentElement.scrollWidth <= innerWidth + 1"), \
        "overscan leaked a horizontal page scroll instead of being clipped"
    print("  PASS: strip renders past the edges")

@testcase
def test_window_under_cap_shows_every_photo(page):
    """A window under the sample cap shows every photo it holds: the overscan fetches
    a wider window, but its thinner ~300 sample must not replace the dense visible one
    (else the frise hides photos the count promises and the lightbox can still step to)."""
    FROM, TO = "2025-03-18", "2025-04-26"
    # ground truth: the thumbnail-bearing photos the server returns for this exact
    # window — what the strip's visible fetch (and the lightbox) sees.
    truth = gql("query($s:String!,$a:Datetime!,$b:Datetime!){"
                "photovideosSearch(search:$s,since:$a,until:$b,first:400){nodes{thumbnailCid}}}",
                {"s": "", "a": FROM, "b": TO})
    expected = sum(1 for n in truth["data"]["photovideosSearch"]["nodes"] if n["thumbnailCid"])
    assert expected >= 30, f"window has too few photos to test sub-sampling ({expected})"
    open_app(page)
    page.fill("[data-win-from]", FROM)
    page.fill("[data-win-to]", TO)
    # the strip must show essentially all of them (a few edge pins may fall just
    # off-screen and reclassify as overscan); the overscan bug showed only ~a third.
    page.wait_for_function(f"() => document.querySelectorAll('img.pin').length >= {expected - 5}")
    shown = page.locator("img.pin").count()
    assert shown >= expected - 5, f"frise shows {shown} of {expected} in-window photos — overscan is sub-sampling"
    print("  PASS: window under cap shows every photo")

A ruler that follows the span

Thumbnails strung along an invisible line read as a pile, not a timeline. What makes it a frise is the ruler underneath — graduations the eye anchors to. The graduation has to follow whatever span is on screen: years across decades, months across a year, days across a week — one ruler, relabelled. And it must stay robust for any span (the current window, once dated photos are floored, can be as tight as a handful of seconds), so the ruler lays a fixed number of evenly-spaced marks across the range and labels each at the unit the span calls for.

We pin that the marks exist, in order, climbing left to right.

@testcase
def test_ruler_in_order(page):
    """The ruler's marks are laid out left-to-right across the span."""
    open_app(page)
    page.wait_for_selector("[data-tick]")
    xs = page.eval_on_selector_all(
        "[data-tick]", "els => els.map(e => e.getBoundingClientRect().left)")
    assert len(xs) >= 2, "expected several ruler marks"
    assert xs == sorted(xs), "ruler marks are not in left-to-right order"
    print("  PASS: ruler in order")

A label formatter picks the unit from the span: the year for multi-year ranges, the month for multi-month, the day for multi-day, the time below that.

const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
const DAY = 864e5, YEAR = 365.25 * DAY;
function tickLabel(t, span){
    const d = new Date(t);
    if(span >= 2 * YEAR) return String(d.getFullYear());
    if(span >= 60 * DAY)  return `${MONTHS[d.getMonth()]} ${d.getFullYear()}`;
    if(span >= 2 * DAY)   return `${d.getDate()} ${MONTHS[d.getMonth()]}`;
    return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`;
}
// rough px a label needs (11px font, nowrap, centred), by the format tickLabel picks.
// The ruler thins to maxMarks = floor(width / tickWidth) so labels never overlap —
// even on a narrow portrait strip, where a year's twelve months won't all fit.
function tickWidth(span){
    if(span >= 2 * YEAR) return 48;   // "2015"
    if(span >= 60 * DAY)  return 72;   // "Jan 2015"
    if(span >= 2 * DAY)   return 60;   // "15 Jan"
    return 52;                          // "12:30"
}

The ruler depends only on the window, not on the photos, so it always shows — even over an empty stretch you can still read the dates and click to zoom while you hunt for pictures. It spans the window and lays marks on calendar boundaries within it — thinned to however many fit the width (tickWidth), so a narrow portrait strip shows a handful of months rather than twelve overlapping ones. Rendered full width under the photos, the marks line up with the thumbnails above. Clicking a mark zooms the window to the interval it opens — from that boundary to the next.

function Ruler({ win, onZoom }){
    const min = new Date(win.from).getTime(), max = new Date(win.to).getTime(), span = (max - min) || 1;
    // measure the strip's width so the ruler can thin its marks to what fits — fewer
    // on a narrow (portrait) screen, so labels don't overlap.
    const ref = useRef();
    const [w, setW] = useState(0);
    useLayoutEffect(() => {
        const el = ref.current;
        if(!el) return;
        const measure = () => setW(el.clientWidth);
        measure();
        const ro = new ResizeObserver(measure);
        ro.observe(el);
        return () => ro.disconnect();
    }, []);
    const maxMarks = Math.max(2, Math.floor(w / tickWidth(span)));
    const ticks = niceTickTimes(min, max, maxMarks);
    return html`
      <div class="ruler" ref=${ref}>
        ${ticks.map((t, i) => html`
          <span class="tick" data-tick="" style=${`left:${xLeft((t - min) / span)}`}
                onClick=${() => onZoom(t, ticks[i + 1] ?? max)}>${tickLabel(t, span)}</span>
        `)}
      </div>
    `;
}

The band is a thin blue strip; each label sits absolutely at its fraction, centred on the year boundary.

/* fixed to the viewport bottom — always visible, however tall the strip grows or
   wherever you've scrolled (sticky un-pinned when a tall window scrolled past). */
.ruler{ position:fixed; left:0; right:0; bottom:0; z-index:10; height:28px;
        background:#2b6cb0; }
.tick{ position:absolute; top:0; transform:translateX(-50%); color:#fff;
       font-size:11px; line-height:28px; white-space:nowrap; cursor:pointer; }

A dense window stacks the photos into a strip taller than the screen, which would scroll the ruler out of reach. position: sticky mostly handled this, but a freshly grown window (panning into a dense stretch) could leave the bar un-pinned until you scrolled. So the ruler is fixed to the viewport bottom — always there, floating over the strip, whatever the scroll or the strip’s height.

@testcase
def test_ruler_stays_in_view(page):
    """With a strip taller than the screen, the ruler stays visible (pinned)."""
    open_app(page)
    page.fill("[data-win-from]", "2015-01-01")
    page.fill("[data-win-to]", "2016-01-01")
    page.wait_for_function(
        "() => { const ds = [...document.querySelectorAll('[data-photo-date]')];"
        " return ds.length > 0 && ds.every(e => e.getAttribute('data-photo-date').startsWith('2015')); }")
    page.wait_for_timeout(700)
    res = page.evaluate(
        "() => { const r = document.querySelector('.ruler').getBoundingClientRect();"
        " return { top: r.top, ih: innerHeight, tall: document.body.scrollHeight > innerHeight + 50 }; }")
    assert res["tall"], "strip wasn't taller than the screen — can't test stickiness"
    assert res["top"] < res["ih"], f"ruler scrolled out of view (top={res['top']}, viewport={res['ih']})"
    print("  PASS: ruler stays in view")

On a phone the whole thing has to fit the narrow width — the controls wrap onto as many rows as they need rather than pushing the page wider than the screen, which would drag a horizontal scrollbar and stop the bottom date bar from sitting flush across the viewport.

We confirm it by shrinking to a phone width and checking nothing overflows sideways and the date bar spans the screen exactly.

@testcase
def test_fits_phone_width(page):
    """At phone width the page doesn't scroll sideways and the date bar fits."""
    page.set_viewport_size({"width": 390, "height": 844})
    open_app(page)
    page.wait_for_selector("img.pin")
    m = page.evaluate(
        "() => { const d = document.documentElement,"
        "   r = document.querySelector('.ruler').getBoundingClientRect();"
        " return { scrollW: d.scrollWidth, clientW: d.clientWidth,"
        "          rl: Math.round(r.left), rr: Math.round(r.right) }; }")
    assert m["scrollW"] <= m["clientW"] + 1, f"page overflows sideways: {m}"
    assert m["rl"] >= -1 and m["rr"] <= m["clientW"] + 1, f"date bar doesn't fit the width: {m}"
    print("  PASS: fits phone width")

Only real dates

The earliest photos are almost all placeholders: rows whose date was never set land on epoch defaults (1970-01-01, 1999-01-01=…) and pile up at the left edge, crowding out anything real and stretching the whole scale back to 1970. The [[id:6b5e4704-44dd-4764-b4f0-fb5606a13c6c][slider]] already drew a line here — its date picker floors at =2007-01-01 — so the frise adopts the same floor and asks the database for dated photos only. The filter is server-side on the indexed date column, so it costs nothing.

This is asserted by pinning that no photo older than the floor reaches the strip.

@testcase
def test_only_real_dates(page):
    """No placeholder-dated (pre-2007) photo reaches the frise."""
    open_app(page)
    page.wait_for_selector("[data-photo-date]")
    dates = page.eval_on_selector_all(
        "[data-photo-date]", "els => els.map(e => e.getAttribute('data-photo-date'))")
    assert dates, "no photos on the axis"
    assert min(dates) >= "2007-01-01", \
        f"a placeholder-dated photo slipped in: {min(dates)}"
    print("  PASS: only real dates")

A window you can move

Three hundred photos out of seventy-five thousand is a peephole. To roam the archive the frise needs a window — a from/to pair the viewer sets — and the query fetches only what falls inside it. The date column is indexed, so a bounded range is cheap, and the ruler we already built relabels itself as the window tightens from years to months to days.

We verify this by moving the window to a single month and checking the frise answers with photos from that month only.

@testcase
def test_window_narrows(page):
    """Setting from/to narrows the frise to photos within the window."""
    open_app(page)
    page.wait_for_selector("[data-photo-date]")
    page.fill("[data-win-from]", "2015-06-01")
    page.fill("[data-win-to]", "2015-06-30")
    page.wait_for_function(
        "() => { const ds = [...document.querySelectorAll('[data-photo-date]')];"
        " return ds.length > 0 && ds.every(e =>"
        " e.getAttribute('data-photo-date').startsWith('2015-06')); }")
    dates = page.eval_on_selector_all(
        "[data-photo-date]", "els => els.map(e => e.getAttribute('data-photo-date'))")
    assert dates, "the window is empty"
    assert all(d.startswith("2015-06") for d in dates), \
        "a photo outside the window slipped in"
    print("  PASS: window narrows")

The window is a from=/=to pair of dates. Two date inputs read and write it; editing either reports the new pair upward, which is all the controls do — the state itself lives in the app root. The data-win-* attributes are the seams the test drives. Dragging and pinching roam freely, but prev=/=next give a precise step — they slide the window by its own width to the period beside it (keeping the zoom). Alongside sit the date inputs, the span shortcuts (below) and reset.

const SPANS = [['day', 1], ['week', 7], ['month', 31], ['year', 366]];
// a magnifying-glass icon with a − (out) or + (in) in the lens
const zoomIcon = plus => html`<svg class="zoomicon" viewBox="0 0 20 20" width="15" height="15"
    fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
  <circle cx="8" cy="8" r="6" /><line x1="12.5" y1="12.5" x2="18.5" y2="18.5" />
  <line x1="5" y1="8" x2="11" y2="8" />${plus ? html`<line x1="8" y1="5" x2="8" y2="11" />` : ''}</svg>`;
function WindowControls({ win, onChange, onReset }){
    const setSpan = days => {
        const mid = (new Date(win.from).getTime() + new Date(win.to).getTime()) / 2;
        onChange({ from: isoDay(mid - days / 2 * DAY), to: isoDay(mid + days / 2 * DAY) });
    };
    const step = dir => {
        const f = new Date(win.from).getTime(), t = new Date(win.to).getTime(), span = (t - f) || DAY;
        onChange({ from: isoDay(f + dir * span), to: isoDay(t + dir * span) });
    };
    const zoom = dir => onChange(zoomAt(win, 0.5, dir));   // dir<0 narrows (in), dir>0 widens (out)
    return html`
      <div class="controls">
        <button type="button" data-prev onClick=${() => step(-1)}>‹ prev</button>
        <label>from <input type="date" data-win-from value=${win.from}
          onInput=${e => { const f = e.target.value; onChange({ from: f, to: f > win.to ? f : win.to }); }} /></label>
        <label>to <input type="date" data-win-to value=${win.to}
          onInput=${e => { const t = e.target.value; onChange({ from: t < win.from ? t : win.from, to: t }); }} /></label>
        <button type="button" data-next onClick=${() => step(1)}>next ›</button>
        <button type="button" data-reset onClick=${onReset}>reset</button>
      </div>
      <div class="controls spans">
        <button type="button" data-zoom-out title="zoom out" onClick=${() => zoom(1)}>${zoomIcon(false)}</button>
        ${SPANS.map(([label, days]) => html`
          <button type="button" data-span=${label} onClick=${() => setSpan(days)}>${label}</button>`)}
        <button type="button" data-zoom-in title="zoom in" onClick=${() => zoom(-1)}>${zoomIcon(true)}</button>
      </div>
    `;
}

.controls{ display:flex; flex-wrap:wrap; gap:8px 16px; align-items:center; margin:12px 0; font-size:14px; }
.controls.spans{ margin-top:0; }   /* the day/week/month/year row sits just under the first */
.controls input{ background:#262a40; color:var(--fg); border:1px solid #3a3f5a;
                 border-radius:4px; padding:3px 6px; }
.controls button{ background:#3a3f5a; color:var(--fg); border:0; border-radius:4px;
                  padding:4px 10px; cursor:pointer; }
.controls button:has(.zoomicon){ padding:4px 8px; }
.zoomicon{ display:block; }

The window is the scale

A subtle wrong turn so far: the photos and the ruler scale to the fetched photos’ own earliest and latest dates, so any batch stretches edge to edge. The honest scale is the window itself: both layers measure against win.from=/=win.to. The give-away is a window that extends past the data — the archive ends in 2026, so a window reaching to 2030 must leave its right quarter empty. If positions scaled to the photos’ own range, the last photo would jump to the right edge instead.

We open such a window and demand the latest photo sit well short of the edge.

@testcase
def test_window_is_the_scale(page):
    """Photos map onto the window, not onto their own sub-range."""
    open_app(page)
    page.wait_for_selector("[data-photo-date]")
    page.fill("[data-win-from]", "2010-01-01")
    page.fill("[data-win-to]", "2030-01-01")   # data ends ~2026 — right ~20% is empty
    # wait until the wide window has loaded (a photo from before 2012 appears),
    # not the default recent month
    page.wait_for_function(
        "() => { const ds = [...document.querySelectorAll('[data-photo-date]')]"
        "   .map(e => e.getAttribute('data-photo-date'));"
        " return ds.length > 0 && Math.min(...ds.map(d => +d.slice(0,4))) < 2012; }")
    strip = page.eval_on_selector(".strip", "el => { const r = el.getBoundingClientRect(); return {left:r.left, width:r.width}; }")
    xs = page.eval_on_selector_all("img.pin", "els => els.map(e => e.getBoundingClientRect().left)")
    assert xs, "no photos in window"
    frac = (max(xs) - strip["left"]) / strip["width"]
    assert frac < 0.92, \
        f"latest photo reaches the window's empty future (frac={frac:.2f}) — scale isn't the window"
    print("  PASS: window is the scale")

Ticks on real boundaries

Evenly-spaced marks read oddly: across a quarter you get Jun · Jun · Jul · Jul · Aug · Sep, the same month labelled twice because two marks fell inside it. A ruler should land on the boundaries themselves — the first of each month, the first of each year — so every label is a real, distinct graduation, the way your reference photo’s Oct Nov Dec sit on month starts.

The check: move to a three-month window, where evenly-spaced marks would repeat a month, and demand every label be distinct.

@testcase
def test_ticks_on_real_boundaries(page):
    """Each tick is a distinct calendar boundary — no repeated labels."""
    open_app(page)
    page.wait_for_selector("[data-tick]")
    page.fill("[data-win-from]", "2015-06-01")
    page.fill("[data-win-to]", "2015-09-01")
    page.wait_for_function(
        "() => { const ds = [...document.querySelectorAll('[data-photo-date]')];"
        " return ds.length > 0 && ds.every(e => e.getAttribute('data-photo-date').startsWith('2015')); }")
    labels = page.eval_on_selector_all("[data-tick]", "els => els.map(e => e.textContent)")
    assert len(labels) >= 2, "expected several ticks"
    assert len(labels) == len(set(labels)), \
        f"duplicate tick labels — not calendar-aligned: {labels}"
    print("  PASS: ticks on real boundaries")

The boundaries depend on the unit the span calls for — years, months, days, or hours. Every branch thins its step (years, days and hours all divide down to ~ten marks) so a wide day doesn’t print twenty-four overlapping HH:MM labels. Each walks real calendar steps from the first boundary at or after min. If a span is so tight it holds fewer than two boundaries, the two ends stand in.

function niceTickTimes(min, max, maxMarks){
    const span = max - min, out = [], cap = Math.max(2, maxMarks || 10);
    const add = t => { if(t >= min && t <= max) out.push(t); };
    if(span >= 2 * YEAR){
        const y0 = new Date(min).getFullYear(), y1 = new Date(max).getFullYear();
        const step = Math.max(1, Math.ceil((y1 - y0 + 1) / cap));
        for(let y = Math.ceil(y0 / step) * step; y <= y1; y += step) add(new Date(y, 0, 1).getTime());
    } else if(span >= 60 * DAY){
        const months = Math.max(1, Math.round(span / (30.4 * DAY)));
        const step = Math.max(1, Math.ceil(months / cap));   // thin months so they fit the width
        const d = new Date(min); d.setDate(1); d.setHours(0, 0, 0, 0);
        if(d.getTime() < min) d.setMonth(d.getMonth() + 1);
        for(; d.getTime() <= max; d.setMonth(d.getMonth() + step)) add(d.getTime());
    } else if(span >= 2 * DAY){
        const step = Math.max(1, Math.ceil(span / DAY / cap));
        const d = new Date(min); d.setHours(0, 0, 0, 0);
        if(d.getTime() < min) d.setDate(d.getDate() + 1);
        for(; d.getTime() <= max; d.setDate(d.getDate() + step)) add(d.getTime());
    } else {
        const step = Math.max(1, Math.ceil(span / 36e5 / cap));   // thin hours to fit the width
        const d = new Date(min); d.setMinutes(0, 0, 0);
        if(d.getTime() < min) d.setHours(d.getHours() + 1);
        for(; d.getTime() <= max; d.setHours(d.getHours() + step)) add(d.getTime());
    }
    if(out.length < 2){ out.length = 0; add(min); add(max); }
    return out;
}

Every branch thins to maxMarksfloor(width / tickWidth) — so the marks always fit. A single day used to print two dozen HH:MM labels jammed together; a year on a portrait phone printed twelve overlapping months. Keying the step off the measured width keeps each scale down to a handful that breathe, at any screen size.

Two tests check it: one frames a busy day, the other a year on a narrow portrait strip — neither may overlap.

@testcase
def test_hour_labels_dont_overlap(page):
    """A one-day window thins its hour ticks so the labels don't collide."""
    open_app(page)
    page.fill("[data-win-from]", "2008-04-11")
    page.fill("[data-win-to]", "2008-04-12")
    page.wait_for_function(
        "() => { const ds = [...document.querySelectorAll('[data-photo-date]')];"
        " return ds.length > 0 && ds.every(e => e.getAttribute('data-photo-date').startsWith('2008-04-11')); }")
    boxes = page.eval_on_selector_all(
        "[data-tick]", "els => els.map(e => { const r = e.getBoundingClientRect(); return [r.left, r.right]; })")
    assert len(boxes) >= 2, f"expected several hour ticks, got {len(boxes)}"
    boxes.sort()
    for (l1, r1), (l2, r2) in zip(boxes, boxes[1:]):
        assert r1 <= l2 + 1, f"hour labels overlap: {boxes}"
    print("  PASS: hour labels don't overlap")

@testcase
def test_month_labels_dont_overlap_in_portrait(page):
    """A year view on a narrow (portrait) strip thins its month marks so the labels
    don't pile on top of each other."""
    page.set_viewport_size({"width": 390, "height": 740})
    open_app(page)
    page.fill("[data-win-from]", "2015-01-01")
    page.fill("[data-win-to]", "2016-01-01")
    page.wait_for_selector("[data-tick]")
    page.wait_for_timeout(300)        # let the ruler measure the portrait width
    boxes = sorted(page.eval_on_selector_all(
        "[data-tick]", "els => els.map(e => { const r = e.getBoundingClientRect(); return [r.left, r.right]; })"))
    assert len(boxes) >= 2, f"expected several month ticks, got {len(boxes)}"
    for (l1, r1), (l2, r2) in zip(boxes, boxes[1:]):
        assert r1 <= l2 + 1, f"month labels overlap in portrait: {boxes}"
    print("  PASS: month labels don't overlap in portrait")

Open on the recent past

Opening on the whole archive — 2007 to today — drops three hundred photos from a single early month into the far-left pixel and calls it a frise. A first view should be useful and tight: the last month, where the freshest photos are, with room to read them. MIN_DATE keeps its job as a hard floor — clamped into every query so the placeholder rows never return however far back the viewer scrolls — but the window now opens on the last month.

We confirm it by checking the default window spans about a month (not two decades).

@testcase
def test_default_window_is_recent_month(page):
    """The app opens on roughly the last month, not the whole archive."""
    open_app(page)
    page.wait_for_selector("[data-photo-date]")
    frm = page.input_value("[data-win-from]")
    to = page.input_value("[data-win-to]")
    from datetime import date
    width = (date.fromisoformat(to) - date.fromisoformat(frm)).days
    assert 27 <= width <= 31, \
        f"default window should span ~1 month, got {frm}..{to} ({width}d)"
    print("  PASS: default window is recent month")

When the window is empty

A window can land on a stretch with nothing in it — the future, a gap in the archive. A blank panel reads as broken, the same trap the boot shell avoided. So when the fetch comes back empty, the frise says so.

@testcase
def test_empty_window_hint(page):
    """A window with no photos shows a hint, not a blank panel."""
    open_app(page)
    page.fill("[data-win-from]", "2099-01-01")
    page.fill("[data-win-to]", "2099-12-31")
    page.wait_for_selector("[data-empty]")
    assert page.locator("[data-empty]").is_visible()
    print("  PASS: empty window hint")

@testcase
def test_backend_unreachable_shows_error(page):
    """A failing backend must read as an error, not as an empty window — otherwise
    'No photos' lies about a server that's simply down."""
    page.route("**/graphql", lambda route: route.abort())   # as if PostGraphile is down
    open_app(page)
    page.wait_for_selector("[data-error]")
    assert page.locator("[data-error]").is_visible()
    assert page.locator("[data-empty]").count() == 0, \
        "claimed the window was empty instead of flagging the backend error"
    print("  PASS: backend unreachable shows error")

When the query returns nothing, a quiet line takes the strip’s place. It reads the same cached query and only speaks once the fetch has settled, so it never flashes during loading. But an empty window and an unreachable backend are different things — both yield zero rows, yet “No photos” lies about a server that’s down. So the hint stays silent on a query error and the app root raises a distinct Archive unreachable banner instead (alongside the auth banner, which already covers 401s).

function EmptyHint({ win }){
    const { nodes, fetching, error } = usePhotos(win);
    if(fetching || error) return '';   // an error isn't an empty window — App flags it instead
    return nodes.length === 0 ? html`<p class="empty" data-empty>No photos in this window.</p>` : '';
}

.empty{ color:#8a8ea5; font-size:14px; margin:24px 4px; }

An empty window is a trap if you can’t leave it: with no thumbnails the strip would collapse to nothing and there’d be nothing to grab and drag back to where the photos are. So even when empty the strip keeps a couple of rows of grabbable height (see TimelineStrip), and a pan there still moves the window — you can always slide out of the void.

We verify this by landing on an empty future window and dragging to confirm the window still moves.

@testcase
def test_can_pan_out_of_empty_window(page):
    """An empty window is still draggable, so you can slide back to the photos."""
    open_app(page)
    page.fill("[data-win-from]", "2099-01-01")
    page.fill("[data-win-to]", "2099-02-01")
    page.wait_for_selector("[data-empty]")
    box = page.locator(".strip").bounding_box()
    assert box and box["height"] > 10, f"empty strip has no grabbable height: {box}"
    drag_strip(page, -250, "() => document.querySelector('[data-win-from]').value !== '2099-01-01'")
    print("  PASS: can pan out of empty window")

And the date bar stays put: even with nothing to show, the ruler still draws its graduations, so you can read where in time you are (and click to zoom) while you hunt — it depends on the window, not the photos.

@testcase
def test_ruler_shows_when_empty(page):
    """The date bar keeps its graduations even when the window holds no photos."""
    open_app(page)
    page.fill("[data-win-from]", "2099-01-01")
    page.fill("[data-win-to]", "2099-02-01")
    page.wait_for_selector("[data-empty]")
    assert page.locator("[data-tick]").count() >= 2, "the date bar vanished over an empty window"
    print("  PASS: ruler shows when empty")

How many are in the window

The strip shows every photo a window holds, up to SAMPLE_CAP; but a year holds thousands more than that. A reader staring at a packed strip can’t tell whether they’re seeing everything or a slice. A count settles it — the true total in the window (from photovideosCount, not the sampled batch), and a note when the total runs past the cap and only a spread sample is drawn.

We assert this by opening a year known to hold thousands and reading the total back.

@testcase
def test_count_readout(page):
    """The count reports the true window total and flags the sampling when it runs past the cap."""
    open_app(page)
    page.fill("[data-win-from]", "2015-01-01")
    page.fill("[data-win-to]", "2016-01-01")
    page.wait_for_function(
        "() => { const e = document.querySelector('[data-count]');"
        " return e && /\\d{4,}\\s*photos/.test(e.textContent)"
        " && e.textContent.includes('spread across the window'); }")
    print("  PASS: count readout")

One catch: panning commits a new window, which refetches the count. If the readout blanked itself while that fetch was in flight, the <p> would leave the document and come back a moment later — and that reflow visibly jumps the strip on every pan. So it holds its last value through a refetch (in a ref) and only ever blanks for a genuinely empty window.

The readout asks photovideosCount for the real total — independent of the sampled strip. It stays silent until the first fetch settles, then states the total and, when the strip shows only a sample, says so. Across later refetches it keeps the last total on screen so the line never collapses; only a truly empty window blanks it.

function PhotoCount({ win }){
    const [res] = useQuery({ query: COUNT, variables: searchVars(win), context: PV_CTX });
    const total = res.data?.photovideosCount;
    const last = useRef(null);
    if(total != null) last.current = total;          // remember the last settled total
    // hold the last count through a refetch (a pan) so the line never leaves the DOM
    // and jolts the strip; blank only before the first result, or a truly empty window.
    const shown = total != null ? total : last.current;
    if(shown == null || shown === 0) return '';
    const note = shown > SAMPLE_CAP ? ` · showing ${SAMPLE_CAP} spread across the window` : '';
    return html`<p class="count" data-count>${shown} photos${note}</p>`;
}

.count{ color:#8a8ea5; font-size:13px; margin:4px; }

A spread, not the first batch

Drawing the first three hundred photos packs them all into the window’s opening days — the rest of the strip sits empty though the window holds a whole year. The server instead samples ~300 photos spread the length of the window: each photo carries a fixed hash in [0,1) and the window keeps those below 300/total. There’s no client code for this: it falls out of photovideos_search (see the schema section). The test only has to prove the spread is real.

To check this, we open a full year and check the sample touches both its start and its far end — a first-batch draw would never reach the late months.

@testcase
def test_sampled_across_window(page):
    """The strip samples across the window — photos reach its far end, not just the start."""
    open_app(page)
    page.fill("[data-win-from]", "2015-01-01")
    page.fill("[data-win-to]", "2016-01-01")
    page.wait_for_function(
        "() => { const ds = [...document.querySelectorAll('[data-photo-date]')];"
        " return ds.length > 30 && ds.every(e => e.getAttribute('data-photo-date').startsWith('2015')); }")
    dates = sorted(page.eval_on_selector_all(
        "[data-photo-date]", "els => els.map(e => e.getAttribute('data-photo-date'))"))
    assert dates[0][:7] <= "2015-03", f"sample doesn't start near the window's beginning: {dates[0]}"
    assert dates[-1][:7] >= "2015-10", f"sample doesn't reach the window's end: {dates[-1]}"
    print("  PASS: sampled across the window")

The sample is also stable: because membership is a fixed per-photo hash threshold, not a window-relative rank, shifting the window a little keeps almost the same photos — so panning slides the strip instead of reshuffling it (the old every-k-th sample swapped nearly everything when one photo entered the edge). The test shifts the window by a month and demands most photos survive.

@testcase
def test_sample_is_stable_across_pan(page):
    """Shifting the window keeps most photos — the sample doesn't reshuffle."""
    grab = lambda: set(page.eval_on_selector_all(
        "[data-photo-date]", "els => els.map(e => e.getAttribute('data-photo-date'))"))
    open_app(page)
    page.fill("[data-win-from]", "2015-01-01")
    page.fill("[data-win-to]", "2016-01-01")
    page.wait_for_function(
        "() => { const ds = [...document.querySelectorAll('[data-photo-date]')];"
        " return ds.length > 50 && ds.every(e => e.getAttribute('data-photo-date').startsWith('2015')); }")
    before = grab()
    page.fill("[data-win-from]", "2015-02-01")
    page.fill("[data-win-to]", "2016-02-01")     # a month forward, like a pan
    page.wait_for_function(
        "() => { const ds = [...document.querySelectorAll('[data-photo-date]')].map(e => e.getAttribute('data-photo-date'));"
        " return ds.length > 50 && ds.some(d => d.startsWith('2016')) && ds.every(d => d >= '2015-02' && d < '2016-03'); }")
    after = grab()
    kept = len(before & after) / max(1, len(before))
    assert kept > 0.6, f"sample reshuffled on a small shift ({kept:.0%} kept) — panning won't be fluid"
    print("  PASS: sample is stable across pan")

Stable membership isn’t enough on its own — a photo must also keep its row. The grid layout (see the lanes plumbing) ties a photo’s column and lane to its date alone, so panning slides the strip without re-stacking it. The test shifts the window and checks the photos that stayed keep their vertical position.

@testcase
def test_lanes_stable_across_pan(page):
    """Panning keeps photos in their row — the grid slides, it doesn't reshuffle."""
    tops = lambda: page.eval_on_selector_all("img.pin",
        "els => Object.fromEntries(els.map(e => [e.getAttribute('data-photo-date'),"
        " Math.round(e.getBoundingClientRect().top)]))")
    open_app(page)
    page.fill("[data-win-from]", "2015-01-01")
    page.fill("[data-win-to]", "2016-01-01")
    page.wait_for_function(
        "() => { const d = [...document.querySelectorAll('[data-photo-date]')];"
        " return d.length > 50 && d.every(e => e.getAttribute('data-photo-date').startsWith('2015')); }")
    before = tops()
    page.fill("[data-win-from]", "2015-01-08")
    page.fill("[data-win-to]", "2016-01-08")     # a week forward, like a pan
    page.wait_for_function(
        "() => { const d = [...document.querySelectorAll('[data-photo-date]')].map(e => e.getAttribute('data-photo-date'));"
        " return d.length > 50 && d.every(x => x >= '2015-01-08'); }")
    page.wait_for_timeout(400)
    after = tops()
    shared = [d for d in before if d in after]
    assert len(shared) > 50, f"too few shared photos to judge ({len(shared)})"
    kept = sum(1 for d in shared if before[d] == after[d]) / len(shared)
    assert kept > 0.85, f"only {kept:.0%} of photos kept their row across a pan — not fluid"
    print("  PASS: lanes stable across pan")

Ellipsis across the empty stretches

Spreading the sample reveals bare patches: a holiday with nothing photographed, a month skipped between two clusters. A wide blank reads as a glitch — “did the frise stop loading?” An ellipsis sitting in the gap answers it: the timeline carries on here, there’s simply nothing to show. gapMarks (see the lanes plumbing) finds every empty stretch between date-consecutive photos wider than two thumbnails and returns its midpoint; the strip drops a at each, centred vertically.

We pin this by opening a window straddling a known bare stretch — summer 2007, empty from late July to early September — and demanding a marker land in it.

@testcase
def test_gaps_marked_with_ellipsis(page):
    """A wide empty stretch between photos is flagged with an ellipsis."""
    open_app(page)
    page.fill("[data-win-from]", "2007-06-01")
    page.fill("[data-win-to]", "2007-11-01")
    page.wait_for_function(
        "() => { const ds = [...document.querySelectorAll('[data-photo-date]')];"
        " return ds.length > 10 && ds.every(e => e.getAttribute('data-photo-date').startsWith('2007')); }")
    assert page.locator("[data-gap]").count() >= 1, "expected at least one ellipsis gap marker"
    print("  PASS: gaps marked with ellipsis")

The marker is inert — pointer-events:none so it never steals a click meant for a thumbnail, dimmed and centred so it whispers rather than shouts.

.gap{ position:absolute; transform:translate(-50%, -50%); color:#6a6e85;
      font-size:22px; letter-spacing:2px; pointer-events:none; user-select:none; }

Photos and videos

The archive mixes photos and videos, and a video’s thumbnail looks like any still — you can’t tell you could press play. The mimetype says which is which, so a video earns a distinct mark.

We confirm it by opening a month known to hold videos and checking at least one thumbnail is marked as one.

@testcase
def test_videos_are_marked(page):
    """Video thumbnails are marked distinctly from photos."""
    open_app(page)
    page.fill("[data-win-from]", "2020-01-01")
    page.fill("[data-win-to]", "2020-02-01")
    page.wait_for_function(
        "() => { const ds = [...document.querySelectorAll('[data-photo-date]')];"
        " return ds.length > 0 && ds.every(e => e.getAttribute('data-photo-date').startsWith('2020')); }")
    assert page.locator('[data-kind="video"]').count() > 0, \
        "no video thumbnails marked in a window known to contain videos"
    print("  PASS: videos marked")

A video wears a distinct ring; the data-kind the strip stamps on each thumbnail is the hook the rule hangs on.

.pin[data-kind="video"], .pin-os[data-kind="video"]{ outline:2px solid #f9a826; outline-offset:-2px; }

Back to the recent past

Once you’ve zoomed into a tight window it’s tedious to type your way back out. A reset button returns to the opening view — the last month — in one click.

We verify this by zooming into a month elsewhere, hitting reset, and checking the window returns to a recent month-wide span.

@testcase
def test_reset_returns_to_default(page):
    """Reset returns the window to the ~1-month opening view."""
    open_app(page)
    page.fill("[data-win-from]", "2015-06-01")
    page.fill("[data-win-to]", "2015-06-30")
    page.wait_for_function(
        "() => document.querySelector('[data-win-from]').value === '2015-06-01'")
    page.click("[data-reset]")
    page.wait_for_function(
        "() => document.querySelector('[data-win-from]').value !== '2015-06-01'")
    frm = page.input_value("[data-win-from]")
    to = page.input_value("[data-win-to]")
    from datetime import date
    width = (date.fromisoformat(to) - date.fromisoformat(frm)).days
    assert 27 <= width <= 31, \
        f"reset should return to a ~month window, got {frm}..{to} ({width}d)"
    print("  PASS: reset returns to default")

Click a graduation to zoom

Typing dates is precise but slow; the obvious gesture is to click the ruler where you want to look. Clicking a graduation zooms the window to the interval that mark opens — click 2012 on a decade and you land in the span up to the next mark.

The check: open a ten-year window, click its first graduation, and check the window has narrowed.

@testcase
def test_click_tick_zooms_in(page):
    """Clicking a ruler graduation narrows the window to that interval."""
    open_app(page)
    page.fill("[data-win-from]", "2010-01-01")
    page.fill("[data-win-to]", "2020-01-01")
    page.wait_for_function(
        "() => document.querySelector('[data-win-to]').value === '2020-01-01'")
    # let the new window's photos load first, so the strip/ruler layout is settled
    # before we click a graduation (clicking mid-load makes the target unstable)
    page.wait_for_function(
        "() => { const ds = [...document.querySelectorAll('[data-photo-date]')]"
        "   .map(e => +e.getAttribute('data-photo-date').slice(0, 4));"
        " return ds.length > 0 && Math.min(...ds) < 2012; }")
    page.locator("[data-tick]").first.click()
    page.wait_for_function(
        "() => document.querySelector('[data-win-to]').value !== '2020-01-01'"
        " || document.querySelector('[data-win-from]').value !== '2010-01-01'")
    frm = page.input_value("[data-win-from]")
    to = page.input_value("[data-win-to]")
    assert int(to[:4]) - int(frm[:4]) < 10, \
        f"window didn't narrow after clicking a graduation: {frm}..{to}"
    print("  PASS: click tick zooms in")

While the data is in flight

Every window change fires a fetch, and over a slow link the strip would blank out with no explanation. A loading line, shown only while a query is in flight, tells the viewer the frise is working rather than broken.

We assert this by slowing the GraphQL endpoint, opening the app, and catching the line while the request is still outstanding.

@testcase
def test_loading_indicator(page):
    """A loading line shows while a query is in flight."""
    def slow(route):
        time.sleep(1.5)
        _forward_graphql(route)   # page route fully handles it (forwards to the fixture)
    page.route("**/graphql", slow)
    try:
        page.goto(BASE_URL, wait_until="commit")
        page.wait_for_selector("[data-loading]")
        assert page.locator("[data-loading]").is_visible()
    finally:
        page.unroute("**/graphql")
    print("  PASS: loading indicator")

A window isn’t assembled until both its photos and their count have landed, so the indicator watches both fetching flags (urql dedupes the count query it shares with PhotoCount, so this adds no request). It shows on the first load and every window change, gone the moment the last of the two settles.

function Loading({ win }){
    const { fetching } = usePhotos(win);
    const [countRes] = useQuery({ query: COUNT, variables: searchVars(win), context: PV_CTX });
    return (fetching || countRes.fetching)
        ? html`<p class="loading" data-loading>Loading…</p>` : '';
}

.loading{ color:#8a8ea5; font-size:14px; margin:24px 4px; }

Open a photo

A thumbnail is a lead, not the thing itself. Clicking one opens it in place — a lightbox showing the full-quality media (web_cid, falling back to the thumbnail) beside what the database knows about it: when it was taken, the filename, the type, the size, the camera. A click on the backdrop closes it; a link offers the raw file.

To check this, we click a thumbnail and check the lightbox carries both the media and its metadata.

@testcase
def test_open_shows_media_and_metadata(page):
    """Clicking a thumbnail opens a lightbox with the media and its metadata."""
    open_app(page)
    page.wait_for_selector("img.pin")
    page.locator("img.pin").first.click(force=True)
    page.wait_for_selector("[data-lightbox]")
    assert page.locator("[data-lightbox] :is(img, video)").count() >= 1, "no media in the open view"
    assert page.locator("[data-lightbox] [data-meta]").count() >= 1, "no metadata shown"
    print("  PASS: open shows media and metadata")

A video opened the same way mustn’t be a frozen still: the open view plays it from its web_cid (the full-quality file), with the thumbnail as the poster so the frame is there before playback starts. Photos stay plain img.

@testcase
def test_video_plays_in_lightbox(page):
    """Opening a video shows a playable <video> sourced from its web_cid."""
    open_app(page)
    page.fill("[data-win-from]", "2020-01-01")
    page.fill("[data-win-to]", "2020-03-01")
    page.wait_for_function(
        "() => { const v = document.querySelector('.pin[data-kind=\"video\"]');"
        " return v && v.getAttribute('data-photo-date').startsWith('2020'); }")
    page.locator('.pin[data-kind="video"]').first.click(force=True)
    page.wait_for_selector("[data-lightbox] video")
    src = page.get_attribute("[data-lightbox] video", "src")
    assert src and "/ipfs/" in src, f"video has no web source: {src!r}"
    print("  PASS: video plays in lightbox")

The open view is also where labels are authored: current words show as chips, an input adds one, and each change is saved straight away through updatePhotovideo. A new word auto-joins the vocabulary (the DB trigger), so it completes next time.

We confirm it by adding a label, seeing the chip, then removing it again — leaving the shared photo as it found it.

@testcase
def test_label_editing(page):
    """Adding a label shows a chip and persists; removing it clears it."""
    open_app(page)
    page.wait_for_selector("img.pin")
    add_label(page, "zztest")
    page.locator('[data-label]', has_text="zztest").locator("[data-label-del]").click(force=True)
    page.wait_for_function(
        "() => ![...document.querySelectorAll('[data-label]')].some(e => e.textContent.includes('zztest'))")
    print("  PASS: label editing")

A label chip has two halves: the word and a ×. Clicking the word searches for it — it appends the label to the search box and closes the photo, so you land on the filtered frise; the =× removes the label. (The two are separate buttons so a test, and a finger, can tell them apart.)

We verify this by adding a label, clicking it, and checking the search picked it up and the view closed — then stripping the label again. The search box is emptied first: a term left behind by an earlier test would filter the strip and change which photo is first, so clearing it makes the test land on the same photo whatever ran before. If the chip never shows after Enter, the failure lists the chips that are present, so a future flake explains itself instead of timing out blind.

@testcase
def test_click_tag_searches(page):
    """Clicking a photo's tag searches for it and closes the open view."""
    open_app(page)
    page.wait_for_selector("img.pin")
    page.fill("[data-search]", "")
    page.wait_for_function("() => !document.querySelector('[data-search]').value")
    page.wait_for_selector("img.pin")
    term = "zzpicktag"
    add_label(page, term)
    page.wait_for_load_state("networkidle")
    try:
        page.locator("[data-label-pick]", has_text=term).click(force=True)
        page.wait_for_selector("[data-lightbox]", state="detached")
        assert term in page.input_value("[data-search]"), \
            f"clicking the tag didn't add it to the search: {page.input_value('[data-search]')!r}"
    finally:
        strip_label(page, term)
    print("  PASS: click tag searches")

The strip already reports a click upward (onOpen); the app root holds the chosen photo and renders this lightbox over everything when one is set. The metadata is whatever the row carries that’s worth showing — built into labelled rows, empty fields dropped.

Each change saves straight away, through the shared updatePhotovideo — frise hands it a patch of just labels, keyed by cid (the table’s primary key in the schema).

A label typed blind breeds near-duplicates — cosmo one day, Cosmo or cosmos the next — and the shared vocabulary frays. So as you type, the input offers the words already in use — your fragment matched anywhere in a word, so smo still finds cosmo — and you reuse one instead of coining a variant. They come from the vocabulary the label edits themselves feed (see the schema), read through the shared LABEL_COMPLETIONS query — memories asks it too, and the search box reuses it later for its own label tokens.

Picking a suggestion fills the input and leaves Enter to commit it — a pick never saves on its own, so a mistaken click leaves the shared photo untouched. We check that typing a fragment surfaces a known label and that picking it lands in the input.

That query is a real round-trip, with a beat you can feel on a cold load — and an empty dropdown during it reads as broken, not thinking. So while the query is in flight the dropdown opens on a pulsing completing… row instead of staying hidden; the matches replace it the moment they land. The search box, reusing the same query, shows the same hint.

@testcase
def test_lightbox_label_completion(page):
    """Typing a fragment in the open view suggests existing labels; picking one fills the input."""
    open_app(page)
    page.wait_for_selector("img.pin")
    page.locator("img.pin").first.click(force=True)
    page.wait_for_selector("[data-label-add]")
    page.fill("[data-label-add]", "cos")
    page.wait_for_selector("[data-completion]")
    items = page.eval_on_selector_all("[data-completion]", "els => els.map(e => e.textContent)")
    assert "cosmo" in items, f"expected a 'cosmo' suggestion, got {items}"
    page.locator("[data-completion]", has_text="cosmo").first.click()
    assert page.input_value("[data-label-add]") == "cosmo", \
        "picking a suggestion didn't fill the label input"
    print("  PASS: lightbox label completion")

@testcase
def test_lightbox_completion_in_flight_hint(page):
    """While the vocabulary query is in flight, the label input shows a 'completing…' hint."""
    open_app(page)
    page.wait_for_selector("img.pin")
    page.locator("img.pin").first.click(force=True)
    page.wait_for_selector("[data-label-add]")
    # hold the completion query open so the in-flight state is observable, not a sub-second flash
    page.route("**/graphql", lambda route:
               route.fallback() if "labelCompletions" not in (route.request.post_data or "") else None)
    page.fill("[data-label-add]", "au")
    page.wait_for_selector("text=completing")
    print("  PASS: lightbox completion in-flight hint")

@testcase
def test_lightbox_completion_keyboard_nav(page):
    """In the open view's label box, ↑ enters at the bottom and ↓ at the top; Enter fills it."""
    open_app(page)
    page.wait_for_selector("img.pin")
    page.locator("img.pin").first.click(force=True)
    page.wait_for_selector("[data-label-add]")
    page.fill("[data-label-add]", "a")
    page.wait_for_selector("[data-completion]")
    items = page.eval_on_selector_all("[data-completion]", "els => els.map(e => e.textContent)")
    assert len(items) >= 2, f"need >=2 completions to test up/down, got {items}"
    def highlighted():
        sel = page.locator("[data-completion][aria-selected='true']")
        return sel.first.text_content() if sel.count() else None
    page.press("[data-label-add]", "ArrowUp")                # from nothing selected, ↑ enters at the bottom
    assert highlighted() == items[-1], f"up from none didn't select the last item: {highlighted()!r}"
    page.press("[data-label-add]", "ArrowDown")              # wraps back to none
    assert highlighted() is None, f"down from the last didn't return to none: {highlighted()!r}"
    page.press("[data-label-add]", "ArrowDown")              # into the list at the top
    assert highlighted() == items[0], f"down didn't enter at the top: {highlighted()!r}"
    page.press("[data-label-add]", "Enter")                  # picks the highlighted → fills the input
    assert page.input_value("[data-label-add]") == items[0], \
        f"Enter didn't fill the highlighted label: {page.input_value('[data-label-add]')!r}"
    print("  PASS: lightbox completion keyboard nav")

function Lightbox({ doc, onClose, onPickLabel, onStep }){
    const [labels, setLabels] = useState(doc?.labels || '');
    const [draft, setDraft] = useState('');     // the half-typed label, with its completion list
    const [showC, setShowC] = useState(false);
    const [activeC, setActiveC] = useState(-1); // keyboard-highlighted suggestion, -1 = none
    const [, runSet] = useMutation(UPDATE_PHOTO);
    const swipeX = useRef(null);
    const [{ data: cData, fetching: cFetching }] = useQuery({ query: LABEL_COMPLETIONS, variables: { prefix: draft },
                                         pause: !draft, context: PV_CTX });
    if(!doc) return '';
    // horizontal swipe on the photo steps prev/next (mobile); the ‹ › buttons and the
    // arrow keys (in App) do the same. >40px so a tap or a tiny wobble isn't a swipe.
    const onDown = e => { swipeX.current = e.clientX; };
    const onUp = e => { if(swipeX.current == null) return;
        const dx = e.clientX - swipeX.current; swipeX.current = null;
        if(Math.abs(dx) > 40) onStep(dx < 0 ? 1 : -1); };
    const phrases = labels.split(';').map(s => s.trim()).filter(Boolean);
    const save = next => { const s = next.join('; '); setLabels(s); runSet({ cid: doc.cid, patch: { labels: s } }); };
    const add = w => { w = w.trim(); if(w && !phrases.includes(w)) save([...phrases, w]); };
    const suggestions = draft ? (cData?.labelCompletions?.nodes ?? []) : [];
    const loadingC = !!draft && cFetching;       // a vocabulary query in flight (the round-trip has a beat)
    const rows = [
        ['date', doc.date && doc.date.slice(0, 19).replace('T', ' ')],
        ['file', doc.filename],
        ['type', doc.mimetype],
        ['size', doc.size && `${Math.round(doc.size / 1024)} KB`],
        ['camera', doc.cameraType],
    ].filter(([, v]) => v);
    const url = THUMB_BASE + (doc.webCid || doc.thumbnailCid);
    const isVideo = (doc.mimetype || '').startsWith('video');
    return html`
      <div class="lightbox" data-lightbox onClick=${onClose}>
        <div class="lightbox-inner" onClick=${e => e.stopPropagation()}>
          <div class="media-wrap" onPointerDown=${onDown} onPointerUp=${onUp}>
            <button class="lb-nav lb-prev" aria-label="previous" onClick=${() => onStep(-1)}>‹</button>
            <button class="lb-nav lb-next" aria-label="next" onClick=${() => onStep(1)}>›</button>
            ${isVideo
              ? html`<video class="media" controls autoplay playsinline
                            poster=${THUMB_BASE + doc.thumbnailCid} src=${url}></video>`
              : html`<img class="media" src=${url} />`}
          </div>
          <div class="side">
            <div class="labels">
              ${phrases.map(p => html`<span class="label" data-label>
                <button class="label-pick" data-label-pick onClick=${() => onPickLabel(p)}>${p}</button>
                <button class="label-del" data-label-del onClick=${() => save(phrases.filter(x => x !== p))}>×</button></span>`)}
            </div>
            <div class="labeladd">
              <input class="label-add" data-label-add placeholder="add label + Enter" value=${draft}
                     onInput=${e => { setDraft(e.target.value); setShowC(true); setActiveC(-1); }}
                     onFocus=${() => setShowC(true)} onBlur=${() => setShowC(false)}
                     onKeyDown=${e => {
                         if(e.key === 'ArrowDown' && suggestions.length){ e.preventDefault(); setActiveC(i => i >= suggestions.length - 1 ? -1 : i + 1); }
                         else if(e.key === 'ArrowUp' && suggestions.length){ e.preventDefault(); setActiveC(i => i < 0 ? suggestions.length - 1 : i - 1); }
                         else if(e.key === 'Enter'){ e.preventDefault();
                             if(activeC >= 0 && suggestions[activeC]){ setDraft(suggestions[activeC]); setShowC(false); setActiveC(-1); }
                             else { add(draft); setDraft(''); setShowC(false); } } }} />
              ${showC && (suggestions.length || loadingC) ? html`
                <ul class="completions">
                  ${loadingC ? html`<li class="completion-loading" aria-disabled="true">completing…</li>` : ''}
                  ${suggestions.map((w, i) => html`<li class=${'completion' + (i === activeC ? ' active' : '')} data-completion
                        aria-selected=${i === activeC ? 'true' : 'false'}
                        onMouseDown=${e => { e.preventDefault(); setDraft(w); setShowC(false); }}>${w}</li>`)}
                </ul>` : ''}
            </div>
            <dl class="meta">
              ${rows.map(([k, v]) => html`<div data-meta><dt>${k}</dt><dd>${v}</dd></div>`)}
            </dl>
            <a class="raw" target="_blank" href=${url} target="_blank">open original ↗</a>
          </div>
        </div>
      </div>
    `;
}

A subtle trap lurks here. urql’s document cache, seeing a search that came back empty, records no Photovideo for it; a later label edit (an updatePhotovideo mutation) then can’t know that query concerns the type it changed, so the photo stays hidden until a hard reload. The cure is additionalTypenames (set in the use-photos plumbing): it tells the cache the photos/count/completion queries are about Photovideo even when empty, so any label edit invalidates and refetches them.

Two checks hold this. The first adds a fresh word to a shared photo and then searches it — the photo must appear — proving the edit persisted and a new query finds it; it strips the word again afterward (strip_label reopens that same first photo) so the archive is left as found. The second is the invalidation proper: against a pair it seeds and deletes, it puts a search on screen, removes the very label that search filters on, and watches that photo leave the same view with no re-search — which can only happen if the edit refetched the live query. A word stranded by a crashed run is swept by the cleanup-test-labels schema block.

@testcase
def test_added_label_is_searchable(page):
    """A freshly added label is immediately findable by searching it."""
    open_app(page)
    page.wait_for_selector("img.pin")
    term = "zzfindme"
    add_label(page, term)
    page.wait_for_load_state("networkidle")    # the save (chip is local) must commit before we search
    page.locator("[data-lightbox]").click(position={"x": 5, "y": 5}, force=True)
    page.wait_for_selector("[data-lightbox]", state="detached")
    try:
        page.fill("[data-search]", term)
        page.wait_for_function(
            "() => document.querySelectorAll('img.pin').length >= 1", timeout=8000)
    finally:
        strip_label(page, term)
    print("  PASS: added label is searchable")

@testcase
def test_label_edit_refreshes_active_search(page):
    """Removing the label a visible search filters on drops that photo from the same
    view — the live query refetches on the edit, with no re-search."""
    pair = [{"cid": "https://ipfs.konubinix.eu/p/zzlivea", "date": "2012-03-01T12:00:00Z", "mimetype": "image/jpeg",
             "thumbnailCid": "https://ipfs.konubinix.eu/p/zzlivea-t", "labels": "zzlive", "state": "todo", "owner": "konubinix"},
            {"cid": "https://ipfs.konubinix.eu/p/zzliveb", "date": "2012-03-02T12:00:00Z", "mimetype": "image/jpeg",
             "thumbnailCid": "https://ipfs.konubinix.eu/p/zzliveb-t", "labels": "zzlive", "state": "todo", "owner": "konubinix"}]
    CREATE = "mutation($p:PhotovideoInput!){ createPhotovideo(input:{photovideo:$p}){ clientMutationId } }"
    DELETE = "mutation($cid:String!){ deletePhotovideo(input:{cid:$cid}){ clientMutationId } }"
    for d in pair: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
    try:
        open_app(page)
        page.get_by_label("from").fill("2011-06-01")
        page.get_by_label("to").fill("2012-12-31")
        page.get_by_placeholder("search…").fill("zzlive")        # the live view both photos share
        edited = page.get_by_role("img", name="2012-03-01")      # the pair, each by its shown date
        partner = page.get_by_role("img", name="2012-03-02")
        edited.wait_for(); partner.wait_for()                    # both match the search
        edited.click(force=True)                                 # open the one we'll edit
        page.get_by_placeholder("add label + Enter").wait_for()  # its editor is up
        page.get_by_role("button", name="×").click()             # drop the label the search filters on
        edited.wait_for(state="detached")                        # same search, refetched: it has left
        partner.wait_for()                                       # its partner stayed
    finally:
        for d in pair: gql(DELETE, {"cid": d["cid"]})
    print("  PASS: label edit refreshes active search")

.lightbox{ position:fixed; inset:0; z-index:30; display:flex; gap:20px; padding:24px;
           align-items:center; justify-content:center; background:#000c; cursor:zoom-out; }
.lightbox-inner{ display:flex; gap:20px; max-width:95vw; max-height:92vh; overflow:auto; cursor:auto;
                 background:#1b1d2e; padding:16px; border-radius:8px; align-items:flex-start; }
.lightbox-inner img, .lightbox-inner video{ max-width:70vw; max-height:84vh; object-fit:contain; border-radius:4px; }
/* pan-y hands horizontal drags to the swipe handler instead of letting the browser
   consume them as a pan (which fires pointercancel and silently kills the swipe). */
.media-wrap{ position:relative; display:flex; touch-action:pan-y; }
.lb-nav{ position:absolute; top:50%; transform:translateY(-50%); z-index:2; width:40px; height:64px;
         border:none; border-radius:8px; background:#262a40cc; color:var(--fg); font-size:28px; cursor:pointer; }
.lb-nav:hover{ background:#33395a; }
.lb-prev{ left:4px; } .lb-next{ right:4px; }
.meta{ margin:0; color:var(--fg); font-size:13px; min-width:180px; }
.meta div{ display:flex; justify-content:space-between; gap:12px; padding:4px 0; border-bottom:1px solid #2a2d44; }
.meta dt{ color:#8a8ea5; } .meta dd{ margin:0; text-align:right; word-break:break-all; }
.raw{ color:#f9a826; }
.side{ display:flex; flex-direction:column; gap:10px; min-width:200px; max-width:300px; }
/* on a phone the image + panel won't fit side by side: stack them and let the
   whole card scroll, so the metadata and label editor are always reachable. */
@media (max-width:640px){
  .lightbox{ padding:8px; }
  .lightbox-inner{ flex-direction:column; align-items:stretch; max-width:96vw; }
  .lightbox-inner img, .lightbox-inner video{ max-width:100%; max-height:55vh; }
  .side{ min-width:0; max-width:none; }
}
.labels{ display:flex; flex-wrap:wrap; gap:6px; }
.label{ display:inline-flex; align-items:center; gap:2px; background:#3a3f5a;
        border-radius:12px; padding:2px 6px; font-size:13px; }
.label button{ background:none; border:0; cursor:pointer; line-height:1; padding:2px; }
.label-pick{ color:var(--fg); font-size:13px; }      /* the label text — click to search it */
.label-pick:hover{ text-decoration:underline; }
.label-del{ color:#cdd; font-size:15px; }            /* the × — click to remove */
.label-add{ width:100%; box-sizing:border-box; background:#262a40; color:var(--fg);
            border:1px solid #3a3f5a; border-radius:6px; padding:6px 8px; }
.labeladd{ position:relative; }                      /* anchor the completion dropdown to the input */
/* a long list still scrolls, but with a thin themed bar — the chunky default one clashes
   with the dark panel (scrollbar-color for Firefox/modern Chromium, ::-webkit for the rest). */
.completions{ position:absolute; z-index:20; top:46px; left:0; right:0; margin:0; padding:4px 0;
              list-style:none; background:#262a40; border:1px solid #3a3f5a; border-radius:6px;
              max-height:240px; overflow:auto; scrollbar-width:thin; scrollbar-color:#4a4f6e transparent; }
.completions::-webkit-scrollbar{ width:8px }
.completions::-webkit-scrollbar-thumb{ background:#4a4f6e; border-radius:8px }
.completions::-webkit-scrollbar-track{ background:transparent }
.labeladd .completions{ top:100%; }                  /* this input has no margin to clear */
.completion{ padding:6px 12px; cursor:pointer; }
.completion:hover, .completion.active{ background:#3a3f5a; }   /* hover or keyboard-highlighted */
/* the vocabulary query has a real round-trip; a pulsing row says "working" so an empty
   wait doesn't read as broken. */
.completion-loading{ padding:6px 12px; color:#8a8ea5; cursor:default;
                     animation:completion-pulse 1s ease-in-out infinite; }
@keyframes completion-pulse{ 0%,100%{ opacity:.45 } 50%{ opacity:.9 } }

We confirm it by opening a photo at phone width and checking the card — and its metadata panel — stay within the screen, so nothing is stranded off-edge with no way to scroll to it.

@testcase
def test_lightbox_fits_phone(page):
    """At phone width the open view stacks and stays on-screen, panel reachable."""
    page.set_viewport_size({"width": 390, "height": 844})
    open_app(page)
    page.wait_for_selector("img.pin")
    page.locator("img.pin").first.click(force=True)
    page.wait_for_selector("[data-lightbox] [data-meta]")
    m = page.evaluate(
        "() => { const i = document.querySelector('.lightbox-inner').getBoundingClientRect(),"
        "   d = document.querySelector('[data-meta]').getBoundingClientRect();"
        " return { iL: i.left, iR: i.right, dL: d.left, dR: d.right, vw: innerWidth }; }")
    assert m["iL"] >= -1 and m["iR"] <= m["vw"] + 1, f"open view wider than the phone: {m}"
    assert m["dL"] >= -1 and m["dR"] <= m["vw"] + 1, f"metadata panel stranded off-screen: {m}"
    print("  PASS: lightbox fits phone")

Step through the open photos

Once a photo is open, the instinct is to move along the timeline without closing and re-aiming at the strip — so the lightbox steps to the previous/next doc in the window’s date order, wrapping at the ends. Three ways in: the ‹ › buttons, the keyboard arrows, and — the one that matters on the meuble’s touchscreen — a horizontal swipe on the photo.

App already holds the open doc; it now also reads the same windowed list the strip shows (usePhotos — urql dedupes the query, so no extra fetch) and exposes a step(±1) that re-anchors open to the neighbour. The list is date-ordered, so stepping reads as moving along time.

The first test drives the buttons and the arrow keys; data is live, so it asserts the media changed (and came back), not a particular src.

@testcase
def test_lightbox_prev_next(page):
    """The ‹ › buttons and the arrow keys step to other photos, wrapping."""
    open_app(page)
    page.wait_for_selector("img.pin")
    page.locator("img.pin").first.click(force=True)
    page.wait_for_selector("[data-lightbox]")
    lb = page.locator("[data-lightbox]")
    media = lb.locator(".media")
    first = media.get_attribute("src")
    # scope to the lightbox: the window controls also carry a "next" button
    lb.get_by_role("button", name="next").click()
    page.wait_for_function(
        "s => document.querySelector('.lightbox .media')?.getAttribute('src') !== s", arg=first)
    page.keyboard.press("ArrowLeft")                 # back to where we started
    page.wait_for_function(
        "s => document.querySelector('.lightbox .media')?.getAttribute('src') === s", arg=first)
    print("  PASS: lightbox prev/next")

The second drives Chromium’s real touch pipeline (CDP), swiping across the photo clear of the centred ‹ › buttons. Without touch-action the browser claims the horizontal drag and fires pointercancel instead of pointerup, so the swipe silently dies — the lesson learned the hard way in memories; the fix (touch-action: pan-y on the media) ships with the feature here.

The swipe is measured against the photo’s own box, so the test first waits for that box to gain a non-zero width. A freshly opened <img> reports a zero-width rect until it has loaded and laid out; measuring it a beat too early once yielded a zero-distance “swipe” that stepped nowhere — the ~20% flake this test used to carry, and one that only bit when the image wasn’t already warm in cache. Each touch point also shares one id so the start, moves and release bind to a single contact. If the photo still doesn’t change, the assertion reports the attempted distance and the unchanged source rather than a bare timeout.

@testcase
def test_lightbox_swipe_touch(page):
    """A real touch swipe on the photo steps to another photo."""
    open_app(page)
    page.wait_for_selector("img.pin")
    page.locator("img.pin").first.click(force=True)
    page.wait_for_selector("[data-lightbox]")
    media = page.locator(".lightbox .media")
    first = media.get_attribute("src")
    page.wait_for_function("() => { const m = document.querySelector('.lightbox .media');"
                           " return m && m.getBoundingClientRect().width > 0; }")
    box = media.bounding_box()
    y = box["y"] + box["height"] * 0.30          # clear of the vertically-centred ‹ › buttons
    x_from = box["x"] + box["width"] * 0.75
    cdp = page.context.new_cdp_session(page)
    cdp.send("Emulation.setTouchEmulationEnabled", {"enabled": True, "maxTouchPoints": 1})
    cdp.send("Input.dispatchTouchEvent", {"type": "touchStart", "touchPoints": [{"x": x_from, "y": y, "id": 0}]})
    for frac in (0.6, 0.45, 0.3, 0.25):
        cdp.send("Input.dispatchTouchEvent",
                 {"type": "touchMove", "touchPoints": [{"x": box["x"] + box["width"] * frac, "y": y, "id": 0}]})
    cdp.send("Input.dispatchTouchEvent", {"type": "touchEnd", "touchPoints": []})
    try:
        page.wait_for_function(
            "s => document.querySelector('.lightbox .media')?.getAttribute('src') !== s", arg=first)
    except Exception:
        now = media.get_attribute("src")
        raise AssertionError(f"swipe of {box['width'] * 0.5:.0f}px did not step the photo "
                             f"(still {now!r}); the touch move/release likely didn't bind to the contact")
    print("  PASS: lightbox swipe (touch)")

Back closes the open photo

On a phone, a full-screen photo is a place — and the instinct to leave it is the back gesture, not hunting for a close button. So opening a photo pushes a history entry; the back button (or browser Back) pops it and closes the view without leaving the frise. Clicking the backdrop closes it the same way — through history — so the entry never lingers. The wiring lives in App (see the shell).

We verify this by opening a photo, pressing Back, and checking the view closed while the frise stayed put.

@testcase
def test_back_button_closes_lightbox(page):
    """The browser/phone back button closes the open photo."""
    open_app(page)
    page.wait_for_selector("img.pin")
    page.locator("img.pin").first.click(force=True)
    page.wait_for_selector("[data-lightbox]")
    page.go_back()                             # the real browser/phone back action
    page.wait_for_selector("[data-lightbox]", state="detached")
    # the frise is still here (back closed the photo, it didn't leave the app);
    # wait for it rather than snapshot, since closing briefly re-renders the strip
    page.wait_for_selector("img.pin")
    print("  PASS: back button closes lightbox")

Hover to read the date

The ruler gives the rough date; for the exact day, hovering a thumbnail should say so. A title carries the day, which the browser shows as a tooltip.

The check: read a thumbnail’s title and confirm it is that photo’s day.

@testcase
def test_hover_shows_date(page):
    """Each thumbnail's title is its day, for an on-hover tooltip."""
    open_app(page)
    page.wait_for_selector("img.pin")
    pin = page.locator("img.pin").first
    assert pin.get_attribute("title") == pin.get_attribute("data-photo-date")[:10], \
        "thumbnail title doesn't carry its day"
    print("  PASS: hover shows date")

A shareable view

A view worth finding is worth sharing. The window lives in the URL — ?from=&to= — so the address bar always describes what’s on screen, a link reopens exactly that span, and a reload doesn’t lose your place.

We assert this by opening a URL carrying a window and checking the frise honours it.

@testcase
def test_window_from_url(page):
    """A window in the URL opens the frise on that span."""
    page.goto(BASE_URL + "?from=2015-06-01&to=2015-09-01", wait_until="commit")
    page.wait_for_selector("body[data-app-ready='1']", state="attached")
    assert page.input_value("[data-win-from]") == "2015-06-01", "URL 'from' ignored"
    assert page.input_value("[data-win-to]") == "2015-09-01", "URL 'to' ignored"
    print("  PASS: window from url")

The window can’t invert

A window with from after to is nonsense — it asks the database for an empty range and reads as a bug. Editing one end past the other should carry the other end along rather than cross it: drag to back before from and from follows.

To check this, we pull to before from and check the window stays ordered.

@testcase
def test_window_cannot_invert(page):
    """Editing one end past the other keeps from <= to."""
    open_app(page)
    page.fill("[data-win-from]", "2015-01-01")
    page.fill("[data-win-to]", "2016-01-01")
    page.wait_for_function(
        "() => document.querySelector('[data-win-to]').value === '2016-01-01'")
    page.fill("[data-win-to]", "2014-01-01")
    page.wait_for_function(
        "() => { const f = document.querySelector('[data-win-from]').value,"
        " t = document.querySelector('[data-win-to]').value; return f <= t; }")
    frm = page.input_value("[data-win-from]")
    to = page.input_value("[data-win-to]")
    assert frm <= to, f"window inverted: {frm}..{to}"
    print("  PASS: window can't invert")

Thumbnails don’t pile up

Pinned by date alone, a busy stretch stacks every thumbnail on the same spot — one photo’s worth of pixels hiding a hundred. The fix is to spend the vertical space: thumbnails that would collide drop into the next lane down, so a dense cluster spreads into a little column instead of a pile.

We confirm it by filling the default (busy) window and demanding no two thumbnails overlap.

@testcase
def test_thumbnails_dont_overlap(page):
    """In a busy window, thumbnails dodge into lanes — none overlap."""
    open_app(page)
    page.wait_for_selector("img.pin")
    # wait for the lane layout to settle (more than one vertical row in use)
    page.wait_for_function(
        "() => new Set([...document.querySelectorAll('img.pin')]"
        ".map(e => Math.round(e.getBoundingClientRect().top))).size > 1")
    overlaps = page.evaluate(
        "() => { const r = [...document.querySelectorAll('img.pin')]"
        ".map(e => e.getBoundingClientRect()); let o = 0;"
        " for(let i=0;i<r.length;i++) for(let j=i+1;j<r.length;j++){ const a=r[i],b=r[j];"
        " if(a.left<b.right && b.left<a.right && a.top<b.bottom && b.top<a.bottom) o++; }"
        " return o; }")
    assert overlaps == 0, f"{overlaps} thumbnail pairs overlap"
    print("  PASS: thumbnails don't overlap")

Greedy lane-packing on exact pixel positions was unstable: when a pan changed the photos at the window edge, the packer re-shuffled lanes and a photo could leap from the top row to the bottom. Instead the strip snaps photos to a global grid of time-columns (a column is one thumbnail wide; the grid is anchored to a fixed epoch, so its lines depend only on the zoom, not on where the window sits). Each photo’s column — and its lane, the order it stacks within that column — depend only on its date, never on its neighbours. So panning slides the whole grid while every photo keeps its row; only the columns at the very edges come and go. Columns are a thumbnail apart, so nothing overlaps.

const THUMB = 64, GAP = 3, ROW = 70;
const GRID0 = Date.UTC(2000, 0, 1);   // fixed origin so column lines don't move when you pan
// shared date→x mapping: inset half a thumbnail each side so edges aren't clipped.
const xLeft = frac => `calc(${frac} * (100% - ${THUMB}px) + ${THUMB / 2}px)`;
function placeGrid(nodes, min, span, w){
    if(!w) return nodes.map(n => ({ n, frac: (new Date(n.date).getTime() - min) / span, lane: 0 }));
    const colTime = span * (THUMB + GAP) / (w - THUMB), used = new Map();
    return nodes.map(n => {
        const ci = Math.round((new Date(n.date).getTime() - GRID0) / colTime);
        const lane = used.get(ci) ?? 0;
        used.set(ci, lane + 1);
        return { n, frac: (GRID0 + ci * colTime - min) / span, lane };
    });
}
// an empty column leaves a horizontal gap wider than a thumbnail; mark its midpoint
// so the eye reads the skipped-over stretch of time.
function gapMarks(placed, w){
    const out = [];
    if(!w) return out;
    for(let i = 1; i < placed.length; i++){
        const a = placed[i - 1].frac, b = placed[i].frac;
        if((b - a) * w > 2 * THUMB) out.push((a + b) / 2);
    }
    return out;
}

Centred on its date, a thumbnail at the very start or end of the window has half its width hanging past the edge, where it gets clipped. The cure is to inset the usable track by half a thumbnail on each side, so a fraction of 0 lands a half-width in and a fraction of 1 a half-width from the end — every thumbnail fully in frame. Photos and ruler ticks share this mapping, so they stay aligned.

@testcase
def test_edge_thumbnails_in_frame(page):
    """A thumbnail at the window's start sits inside the strip, not half-off it."""
    open_app(page)
    page.wait_for_selector("img.pin")
    dates = page.eval_on_selector_all(
        "[data-photo-date]", "els => els.map(e => e.getAttribute('data-photo-date'))")
    lo = min(dates)[:10]
    page.fill("[data-win-from]", lo)
    page.wait_for_function(f"() => document.querySelector('[data-win-from]').value === '{lo}'")
    page.wait_for_timeout(900)
    overshoot = page.evaluate(
        "() => { const s = document.querySelector('.strip').getBoundingClientRect();"
        " const ls = [...document.querySelectorAll('img.pin')].map(e => e.getBoundingClientRect().left);"
        " return ls.length ? s.left - Math.min(...ls) : 0; }")
    assert overshoot <= 1, f"leftmost thumbnail sticks {overshoot:.0f}px out of the strip"
    print("  PASS: edge thumbnails in frame")

Jump to a day, week, month or year

Dragging and pinching roam freely, but often you just want a round window — this whole day, this week, the month, the year. Four shortcut buttons resize the window to exactly that span, kept centred on what you’re already looking at, so the view stays put while the zoom snaps to a natural unit. They live in WindowControls beside the date inputs.

We verify this by framing a year, clicking week, and checking the window collapsed to about a week around the same centre.

@testcase
def test_span_shortcuts(page):
    """A span shortcut resizes the window to that unit, centred on the current view."""
    from datetime import date
    open_app(page)
    page.fill("[data-win-from]", "2015-01-01")
    page.fill("[data-win-to]", "2016-01-01")     # mid-point ≈ 2015-07-02
    page.wait_for_function("() => document.querySelector('[data-win-to]').value === '2016-01-01'")
    page.click('[data-span="week"]')
    page.wait_for_function("() => document.querySelector('[data-win-to]').value !== '2016-01-01'")
    frm = date.fromisoformat(page.input_value("[data-win-from]"))
    to = date.fromisoformat(page.input_value("[data-win-to]"))
    assert (to - frm).days in (7, 8), f"week shortcut gave a {(to - frm).days}-day window"
    assert frm.year == 2015 and 6 <= frm.month <= 7, f"week wasn't centred on the view: {frm}..{to}"
    print("  PASS: span shortcuts")

Step to the period beside it

Sometimes you don’t want to drag — you want the next month, or the year before this one, exactly. prev and next slide the window by its own width, keeping the zoom and stepping to the adjacent period. (Like every navigation, they merge into the window, so the search filter rides along.)

The check: frame a year, step forward by a year, then back, checking the width holds and prev undoes next.

@testcase
def test_next_and_prev_step(page):
    """next slides the window forward by its width; prev steps back."""
    from datetime import date
    open_app(page)
    page.fill("[data-win-from]", "2015-01-01")
    page.fill("[data-win-to]", "2016-01-01")
    page.wait_for_function("() => document.querySelector('[data-win-to]').value === '2016-01-01'")
    page.click("[data-next]")
    page.wait_for_function("() => document.querySelector('[data-win-from]').value === '2016-01-01'")
    frm, to = page.input_value("[data-win-from]"), page.input_value("[data-win-to]")
    width = (date.fromisoformat(to) - date.fromisoformat(frm)).days
    assert frm == "2016-01-01" and width == 365, f"next didn't slide a year forward: {frm}..{to}"
    page.click("[data-prev]")
    page.wait_for_function("() => document.querySelector('[data-win-from]').value === '2015-01-01'")
    assert page.input_value("[data-win-from]") == "2015-01-01", "prev didn't step back"
    print("  PASS: next and prev step")

Zoom in and out

Changing how much time is on screen wants the strip itself, but a dense day stacks into a tall strip whose ruler scrolls off the bottom — so plain wheeling must stay ordinary vertical scroll to reach it. The modifiers split the two navigations: Ctrl+wheel (and a trackpad pinch, which arrives as ctrl) zooms, keeping the date under the pointer fixed so you zoom into where you’re looking; Shift+wheel pans, sliding the window through time by the wheel delta (the keyboard echo of a drag).

Two tests frame a decade: a plain wheel must not move the window (it scrolls); Ctrl+wheel must tighten it; Shift+wheel must slide it without changing its span.

@testcase
def test_ctrl_wheel_zooms(page):
    """Plain wheel scrolls (no zoom); Ctrl+wheel zooms the window in."""
    open_app(page)
    page.fill("[data-win-from]", "2010-01-01")
    page.fill("[data-win-to]", "2020-01-01")
    page.wait_for_function(
        "() => document.querySelector('[data-win-to]').value === '2020-01-01'")
    page.locator(".strip").hover()
    page.mouse.wheel(0, -400)
    page.wait_for_timeout(500)
    assert page.input_value("[data-win-to]") == "2020-01-01", \
        "plain wheel changed the window — it should scroll, not zoom"
    page.keyboard.down("Control")
    page.mouse.wheel(0, -400)
    page.keyboard.up("Control")
    page.wait_for_function(
        "() => document.querySelector('[data-win-to]').value !== '2020-01-01'")
    frm = page.input_value("[data-win-from]")
    to = page.input_value("[data-win-to]")
    assert int(to[:4]) - int(frm[:4]) < 10, f"ctrl+wheel didn't zoom in: {frm}..{to}"
    print("  PASS: ctrl+wheel zooms")

@testcase
def test_shift_wheel_pans(page):
    """Shift+wheel slides the window through time without changing its span."""
    from datetime import date
    open_app(page)
    page.fill("[data-win-from]", "2015-01-01")
    page.fill("[data-win-to]", "2016-01-01")
    page.wait_for_function(
        "() => document.querySelector('[data-win-from]').value === '2015-01-01'")
    page.locator(".strip").hover()
    page.keyboard.down("Shift")
    page.mouse.wheel(0, 400)
    page.keyboard.up("Shift")
    page.wait_for_function(
        "() => document.querySelector('[data-win-from]').value !== '2015-01-01'")
    frm = page.input_value("[data-win-from]")
    to = page.input_value("[data-win-to]")
    span_days = (date.fromisoformat(to) - date.fromisoformat(frm)).days
    assert 300 < span_days < 430, f"shift+wheel changed the span — it should pan, not zoom: {frm}..{to}"
    assert frm > "2015-01-01", f"shift+wheel down should slide the window forward: {frm}"
    print("  PASS: shift+wheel pans")

A two-finger pinch felt wrong here — fingers expect to zoom the photo, not the timeline — so on touch the zoom is two plain buttons instead: − zoom out widens the window, zoom in + narrows it, both centred on the middle of the view. They ride the same zoomAt the wheel uses (with the centre fixed at the midpoint).

We assert this by framing a year, zooming in (the span must shrink), then out (it must grow again).

@testcase
def test_zoom_buttons(page):
    """The zoom-in button narrows the window; zoom-out widens it again."""
    from datetime import date
    span = lambda: (date.fromisoformat(page.input_value("[data-win-to]"))
                    - date.fromisoformat(page.input_value("[data-win-from]"))).days
    open_app(page)
    page.fill("[data-win-from]", "2015-01-01")
    page.fill("[data-win-to]", "2016-01-01")
    page.wait_for_function("() => document.querySelector('[data-win-to]').value === '2016-01-01'")
    page.click("[data-zoom-in]")
    page.wait_for_function("() => document.querySelector('[data-win-to]').value !== '2016-01-01'")
    narrowed = span()
    assert narrowed < 365, f"zoom in didn't narrow the window ({narrowed}d)"
    before = page.input_value("[data-win-to]")
    page.click("[data-zoom-out]")
    page.wait_for_function(f"() => document.querySelector('[data-win-to]').value !== '{before}'")
    assert span() > narrowed, f"zoom out didn't widen the window ({span()}d vs {narrowed}d)"
    print("  PASS: zoom buttons")

Given the fraction of the window the pointer sits at and the wheel direction, the zoom scales the span around that fraction: the date under the pointer keeps its place while the two ends draw in or push out. One notch is a fixed factor.

function zoomAt(win, frac, deltaY){
    const f = new Date(win.from).getTime(), span = new Date(win.to).getTime() - f;
    const center = f + frac * span;
    const ns = span * (deltaY < 0 ? 0.8 : 1 / 0.8);
    return { from: isoDay(center - frac * ns), to: isoDay(center + (1 - frac) * ns) };
}

The strip reports a wheel as a fraction along its width; the app root turns that into a new window.

Drag to pan

Beyond typing dates and nudging the slider, the natural gesture on the frise is to grab it and drag — pull left to travel forward, right to go back, like an endless filmstrip. A horizontal drag pans the window (committed on release, in whole days); a vertical drag is left to the page so you can still scroll a tall stack. A drag isn’t a click, so opening a photo still takes a still tap.

To check this, we drag the strip left and check the window moved forward in time.

@testcase
def test_drag_pans_window(page):
    """Dragging the strip horizontally moves the window in time."""
    open_app(page)
    page.fill("[data-win-from]", "2015-06-01")
    page.fill("[data-win-to]", "2015-07-01")
    page.wait_for_function("() => document.querySelector('[data-win-from]').value === '2015-06-01'")
    page.wait_for_function(
        "() => { const ds = [...document.querySelectorAll('[data-photo-date]')];"
        " return ds.length > 0 && ds.every(e => e.getAttribute('data-photo-date').startsWith('2015-06')); }")
    drag_strip(page, -250, "() => document.querySelector('[data-win-from]').value !== '2015-06-01'")
    after = page.input_value("[data-win-from]")
    assert after > "2015-06-01", f"dragging left should move the window forward: got {after}"
    print("  PASS: drag pans window")

Navigating time must never silently drop the active filter. Because every window change merges into the current window (it doesn’t replace it), the search term survives a pan, a zoom or a span jump — you keep browsing the same filtered set through time. (Only reset clears it, on purpose.)

We pin this by filtering to a label, dragging to pan, and checking the filter is still set.

@testcase
def test_pan_keeps_filter(page):
    """Panning the strip keeps the active search filter."""
    open_app(page)
    page.fill("[data-win-from]", "2015-01-01")
    page.fill("[data-win-to]", "2016-01-01")
    page.fill("[data-search]", "cosmo")
    # wait for the filtered window's photos to settle so the strip is laid out
    page.wait_for_function(
        "() => { const ds = [...document.querySelectorAll('[data-photo-date]')];"
        " return ds.length > 0 && ds.every(e => e.getAttribute('data-photo-date').startsWith('2015')); }")
    drag_strip(page, -250, "() => document.querySelector('[data-win-from]').value !== '2015-01-01'")
    assert page.input_value("[data-search]") == "cosmo", \
        f"panning dropped the search filter: {page.input_value('[data-search]')!r}"
    print("  PASS: pan keeps filter")

The keyboard pans too. With no photo open, and shift the window by a fifth of its width — forward and back in time — a small nudge for repeated presses, well inside the overscan, so the next view is already loaded. (With a photo open the same keys step through it instead; see Open a photo.) Arrows typed into the search or date inputs are left alone, so they still move the text caret.

We confirm it by moving focus off the inputs, tapping and checking the window advanced, then and checking it went back.

@testcase
def test_arrow_keys_pan_window(page):
    """With no photo open, → and ← pan the window forward and back in time."""
    open_app(page)
    page.fill("[data-win-from]", "2015-06-01")
    page.fill("[data-win-to]", "2015-07-01")
    page.wait_for_function("() => document.querySelector('[data-win-from]').value === '2015-06-01'")
    page.locator("h1").click()                       # move focus off the date input
    page.keyboard.press("ArrowRight")
    page.wait_for_function("() => document.querySelector('[data-win-from]').value !== '2015-06-01'")
    fwd = page.input_value("[data-win-from]")
    assert fwd > "2015-06-01", f"→ should pan the window forward: {fwd}"
    page.keyboard.press("ArrowLeft")
    page.wait_for_function(f"() => document.querySelector('[data-win-from]').value < '{fwd}'")
    assert page.input_value("[data-win-from]") < fwd, "← should pan the window back"
    print("  PASS: arrow keys pan the window")

For the drag to feel continuous, releasing it must leave everything exactly where the finger pushed it — no snap. That holds only if the window shifts by the dragged fraction of the same inset track the photos sit on (w - THUMB, not the full w, see the usePan commit below); get it wrong and content jumps a thumbnail’s-worth on release. test_drag_pans_window guards that a drag pans the right way; the inset-matched mapping that makes the release seamless is best judged by hand on a touch device.

The gesture lives in one hook tracking a single pointer. It locks to an axis after a few pixels: horizontal becomes a pan (translate the strip live via dx, and on release shift the window by the dragged fraction of the span); vertical is released to the page. Extra fingers are ignored — zoom is on buttons now (see Zoom in and out), not a pinch. moved() lets the thumbnail suppress the click that would otherwise follow a drag; touch-action: pan-y tells the browser we own the horizontal drag, it keeps vertical scroll.

function usePan(win, w, span, onPan){
    const [dx, setDx] = useState(0);
    const g = useRef({ id: null, x: 0, y: 0, axis: null, moved: false, dx: 0 });
    const fromMs = () => new Date(win.from).getTime();
    const reset = () => { g.current.id = null; g.current.axis = null; g.current.moved = false; setDx(0); };
    const capture = e => { try { e.currentTarget.setPointerCapture(e.pointerId); } catch (_) {} };
    return {
        dx, moved: () => g.current.moved,
        handlers: {
            onPointerDown: e => {
                if(g.current.id !== null) return;   // one finger drives the pan; ignore extra pointers
                Object.assign(g.current, { id: e.pointerId, x: e.clientX, y: e.clientY, axis: null, moved: false, dx: 0 });
            },
            onPointerMove: e => {
                if(e.pointerId !== g.current.id) return;
                const ddx = e.clientX - g.current.x, ddy = e.clientY - g.current.y;
                if(!g.current.axis){
                    if(Math.abs(ddx) < 6 && Math.abs(ddy) < 6) return;
                    g.current.axis = Math.abs(ddx) > Math.abs(ddy) ? 'x' : 'y';
                    if(g.current.axis === 'x') capture(e);   // capture on drag, not tap (would retarget the click)
                }
                if(g.current.axis === 'x'){ e.preventDefault(); g.current.moved = true; g.current.dx = ddx; setDx(ddx); }
            },
            onPointerUp: e => {
                if(e.pointerId !== g.current.id) return;
                if(g.current.axis === 'x' && g.current.moved){
                    // map the drag over the *inset* track (w - THUMB), the same width
                    // xLeft positions photos on, so released content stays under the finger
                    const dt = -(g.current.dx / Math.max(1, w - THUMB)) * span;
                    onPan(isoDay(fromMs() + dt), isoDay(new Date(win.to).getTime() + dt));
                }
                reset();
            },
            onPointerCancel: e => { if(e.pointerId === g.current.id) reset(); },
        },
    };
}

Search the labels

Browsing by time is half the story; the other half is finding — type a word and the frise keeps only the photos whose labels match, still placed on the same window and ruler. The search rides the server function we widened (photovideosSearch(search, since, until)); an empty box means everything.

We verify this by opening a wide window, reading the count, typing a label that exists, and checking the count drops to a smaller, non-zero set.

@testcase
def test_search_narrows_results(page):
    """Typing a label term narrows the frise to matching photos."""
    open_app(page)
    page.fill("[data-win-from]", "2007-01-01")
    page.fill("[data-win-to]", "2026-01-01")
    page.wait_for_function(
        "() => { const e = document.querySelector('[data-count]');"
        " return e && Number((e.textContent.match(/\\d+/) || [0])[0]) > 1000; }")
    before = int(re.search(r"\d+", page.text_content("[data-count]")).group())
    page.fill("[data-search]", "cosmo")
    page.wait_for_function(
        "() => { const e = document.querySelector('[data-count]');"
        " return e && Number((e.textContent.match(/\\d+/) || [9e9])[0]) < " + str(before) + "; }")
    after = int(re.search(r"\d+", page.text_content("[data-count]")).group())
    assert 0 < after < before, f"search didn't narrow results: {before} -> {after}"
    print("  PASS: search narrows results")

That web-search dialect isn’t the frise’s to explain — it’s the shared language’s, and it falls out of the free text reaching websearch_to_tsquery untouched. The frise only has to prove its box wires it.

We assert this by typing or and - forms and watching the count swing between a small set and a large one (the terms are far apart, so the waits can’t race).

@testcase
def test_search_operators(page):
    """OR unions matches; a leading - excludes them again."""
    open_app(page)
    page.fill("[data-win-from]", "2007-01-01")
    page.fill("[data-win-to]", "2027-01-01")
    def wait_count(cmp):
        page.wait_for_function(
            "() => { const e = document.querySelector('[data-count]');"
            " const m = e && e.textContent.match(/\\d+/);"
            " return m && Number(m[0]) " + cmp + "; }")
    page.fill("[data-search]", "paris")
    wait_count("< 800")             # 'paris' alone is a small set
    page.fill("[data-search]", "paris or cosmo")
    wait_count("> 1500")            # OR folds in the much larger 'cosmo' set
    page.fill("[data-search]", "paris or cosmo -cosmo")
    wait_count("< 800")             # the exclusion takes 'cosmo' back out
    print("  PASS: search operators")

Accents split the work between finding a word and matching it. Completion is a discovery aid, so it folds accents — typing competition offers the real compétition to pick (the label_completions f_unaccent + ILIKE). But once a term is committed to the query it means exactly itself: competition matches competition, never compétition, so two labels that differ only by an accent stay distinct sets. The search index keeps accents to make that so. Two seeded photos, zzcafe and zzcafé, pin the matching half; the completion half is checked against a real accented label in the vocabulary.

@testcase
def test_search_is_accent_sensitive(page):
    """A committed term matches exactly: zzcafe finds only zzcafe, never zzcafé."""
    a = {"cid": "https://ipfs.konubinix.eu/p/zzcafe-plain", "date": "2020-06-15T12:00:00Z", "mimetype": "image/jpeg",
         "thumbnailCid": "https://ipfs.konubinix.eu/p/zzcafe-plain-t", "labels": "zzcafe", "state": "todo", "owner": "konubinix"}
    b = {"cid": "https://ipfs.konubinix.eu/p/zzcafe-acc", "date": "2020-06-16T12:00:00Z", "mimetype": "image/jpeg",
         "thumbnailCid": "https://ipfs.konubinix.eu/p/zzcafe-acc-t", "labels": "zzcafé", "state": "todo", "owner": "konubinix"}
    CREATE = "mutation($p:PhotovideoInput!){ createPhotovideo(input:{photovideo:$p}){ clientMutationId } }"
    DELETE = "mutation($cid:String!){ deletePhotovideo(input:{cid:$cid}){ clientMutationId } }"
    for d in (a, b): gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
    try:
        open_app(page)
        page.fill("[data-win-from]", "2020-01-01")
        page.fill("[data-win-to]", "2021-01-01")
        def count_is(n):
            page.wait_for_function(
                "() => { const e = document.querySelector('[data-count]');"
                " const m = e && e.textContent.match(/\\d+/);"
                " return m && Number(m[0]) === " + str(n) + "; }")
        page.fill("[data-search]", "zzcafe"); count_is(1)        # the plain one only — not zzcafé
        page.fill("[data-search]", "zzcafé"); count_is(1)        # the accented one only
        print("  PASS: search is accent-sensitive")
    finally:
        for d in (a, b):
            try: gql(DELETE, {"cid": d["cid"]})
            except Exception: pass

@testcase
def test_completion_still_folds_accents(page):
    """Completion stays accent-insensitive: an unaccented prefix offers the accented label."""
    open_app(page)
    page.fill("[data-search]", "competition")
    page.wait_for_selector("[data-completion]")
    items = page.eval_on_selector_all("[data-completion]", "els => els.map(e => e.textContent)")
    assert "compétition" in items, f"completion should fold accents and offer 'compétition', got {items}"
    print("  PASS: completion still folds accents")

The same box also carries the shared language’s typed filters — type: to one medium, owner: to one person, onthisday to an anniversary, month:=/=day: to a recurring month or day across every year — each compiled by searchVars into the window’s query, so the count and the strip narrow together. A leading - negates a closed-vocab token the way it already negates a label: -type:video keeps every kind but video. Two seeded docs, an image owned by konubinix and a video by aylapomme, separate type:=/=owner: and their negations cleanly.

@testcase
def test_dsl_filters(page):
    """type: and owner: tokens narrow the frise through the shared parser."""
    a = {"cid": "https://ipfs.konubinix.eu/p/zzdsl-img", "date": "2019-06-15T12:00:00Z", "mimetype": "image/jpeg",
         "thumbnailCid": "https://ipfs.konubinix.eu/p/zzdsl-img-t", "labels": "zzdsl", "state": "todo", "owner": "konubinix"}
    b = {"cid": "https://ipfs.konubinix.eu/p/zzdsl-vid", "date": "2019-06-16T12:00:00Z", "mimetype": "video/mp4",
         "thumbnailCid": "https://ipfs.konubinix.eu/p/zzdsl-vid-t", "labels": "zzdsl", "state": "todo", "owner": "aylapomme"}
    CREATE = "mutation($p:PhotovideoInput!){ createPhotovideo(input:{photovideo:$p}){ clientMutationId } }"
    DELETE = "mutation($cid:String!){ deletePhotovideo(input:{cid:$cid}){ clientMutationId } }"
    for d in (a, b): gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
    try:
        open_app(page)
        page.fill("[data-win-from]", "2019-01-01")
        page.fill("[data-win-to]", "2020-01-01")
        def count_is(n):
            page.wait_for_function(
                "() => { const e = document.querySelector('[data-count]');"
                " const m = e && e.textContent.match(/\\d+/);"
                " return m && Number(m[0]) === " + str(n) + "; }")
        page.fill("[data-search]", "zzdsl")
        count_is(2)                                  # both seeded docs match
        page.fill("[data-search]", "zzdsl; type:video")
        count_is(1)                                  # just the video
        page.fill("[data-search]", "zzdsl; owner:konubinix")
        count_is(1)                                  # just konubinix's image
        page.fill("[data-search]", "zzdsl; -type:video")
        count_is(1)                                  # a leading - excludes: every kind but video → the image
        page.fill("[data-search]", "zzdsl; -owner:konubinix")
        count_is(1)                                  # every owner but konubinix → aylapomme's video
        print("  PASS: dsl filters")
    finally:
        for d in (a, b):
            try: gql(DELETE, {"cid": d["cid"]})
            except Exception: pass

month: is the recurring filter in action: it keeps a month across every year the window spans, so it can’t be a date range. Five docs, labelled zzmonth, pin the corners — two Junes in different years, a March, a July, a December — and let us assert each facet: a number or a name picks the month across both Junes; a (N) window widens it (month:6(1) reaches July); and the window wraps at the boundary, so month:1(1) reaches December.

@testcase
def test_month_filter(page):
    """month: keeps a month across years; (N) widens it; the window wraps Dec↔Jan."""
    docs = {
        "jun19": "2019-06-15T12:00:00Z", "jun21": "2021-06-10T12:00:00Z",
        "mar20": "2020-03-20T12:00:00Z", "jul19": "2019-07-15T12:00:00Z",
        "dec20": "2020-12-25T12:00:00Z",
    }
    rows = [{"cid": f"https://ipfs.konubinix.eu/p/zzmonth-{k}", "date": v, "mimetype": "image/jpeg",
             "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzmonth-{k}-t", "labels": "zzmonth",
             "state": "todo", "owner": "konubinix"} for k, v in docs.items()]
    CREATE = "mutation($p:PhotovideoInput!){ createPhotovideo(input:{photovideo:$p}){ clientMutationId } }"
    DELETE = "mutation($cid:String!){ deletePhotovideo(input:{cid:$cid}){ clientMutationId } }"
    for d in rows: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
    try:
        open_app(page)
        page.fill("[data-win-from]", "2019-01-01")
        page.fill("[data-win-to]", "2022-01-01")
        def count_is(n):
            page.wait_for_function(
                "() => { const e = document.querySelector('[data-count]');"
                " const m = e && e.textContent.match(/\\d+/);"
                " return m && Number(m[0]) === " + str(n) + "; }")
        page.fill("[data-search]", "zzmonth"); count_is(5)
        page.fill("[data-search]", "zzmonth; month:6"); count_is(2)       # both Junes, across years
        page.fill("[data-search]", "zzmonth; month:june"); count_is(2)    # the name resolves the same
        page.fill("[data-search]", "zzmonth; month:march"); count_is(1)
        page.fill("[data-search]", "zzmonth; month:6(1)"); count_is(3)    # May–Jul → 2 Junes + July
        page.fill("[data-search]", "zzmonth; month:1(1)"); count_is(1)    # Dec–Feb → December, by the wrap
        print("  PASS: month filter")
    finally:
        for d in rows:
            try: gql(DELETE, {"cid": d["cid"]})
            except Exception: pass

day: is the same idea at day resolution — the generalisation of onthisday. It rides the very aday=/=awin the anniversary view already uses, so it needs no new server arg: an MM-DD (or current) sets the target, a (N) the window. Two June-15s in different years and a June-17 confirm it pins the calendar day across years, and that the window reaches its neighbours.

@testcase
def test_day_filter(page):
    """day:MM-DD keeps that calendar day across years; (N) widens it to neighbours."""
    docs = {"d15a": "2019-06-15T12:00:00Z", "d15b": "2021-06-15T12:00:00Z", "d17": "2020-06-17T12:00:00Z"}
    rows = [{"cid": f"https://ipfs.konubinix.eu/p/zzday-{k}", "date": v, "mimetype": "image/jpeg",
             "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzday-{k}-t", "labels": "zzday",
             "state": "todo", "owner": "konubinix"} for k, v in docs.items()]
    CREATE = "mutation($p:PhotovideoInput!){ createPhotovideo(input:{photovideo:$p}){ clientMutationId } }"
    DELETE = "mutation($cid:String!){ deletePhotovideo(input:{cid:$cid}){ clientMutationId } }"
    for d in rows: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
    try:
        open_app(page)
        page.fill("[data-win-from]", "2019-01-01")
        page.fill("[data-win-to]", "2022-01-01")
        def count_is(n):
            page.wait_for_function(
                "() => { const e = document.querySelector('[data-count]');"
                " const m = e && e.textContent.match(/\\d+/);"
                " return m && Number(m[0]) === " + str(n) + "; }")
        page.fill("[data-search]", "zzday"); count_is(3)
        page.fill("[data-search]", "zzday; day:06-15"); count_is(2)       # both June 15s, across years
        page.fill("[data-search]", "zzday; day:06-16")                    # nothing on the 16th
        page.wait_for_selector("[data-empty]")                           # an empty window drops the count line
        page.fill("[data-search]", "zzday; day:06-15(2)"); count_is(3)    # ±2 days reaches the 17th
        print("  PASS: day filter")
    finally:
        for d in rows:
            try: gql(DELETE, {"cid": d["cid"]})
            except Exception: pass

The box reports its text up through onChange like the other controls; the app root folds it into the window so usePhotos picks it up. Empty is the default — the whole window. Completion works on the ;-segment at the end of the box: a typed token offers the shared language’s suggestions (dslSuggestions — keys, a filter’s values, or a date picker), and otherwise it falls back to the same label vocabulary the open view completes — the same LABEL_COMPLETIONS query the lightbox uses; the query pauses while the box is empty.

The list is keyboard-reachable, so a hand never leaves the keys: enters it at the top and at the bottom — both step through and cycle back out via a “no selection” slot, so either key reaches the words and either escapes them — and Enter picks the highlighted one. With nothing highlighted, Enter keeps its job of committing a since:=/=until: jump. The open view’s label box drives its own list the same way. ↑=/=↓ are free for this because the frise pans on ←=/=→, and arrows in any input move the caret rather than the window.

function SearchBox({ win, onChange }){
    const term = win.search || '';
    const [open, setOpen] = useState(false);
    const [active, setActive] = useState(-1);    // keyboard-highlighted suggestion, -1 = none
    const [segS] = segSpan(term, term.length);
    const seg = term.slice(segS).replace(/^\s*/, '');
    const dslWords = dslSuggestions(seg);
    const tail = (seg.match(/\S*$/) || [''])[0];
    const neg = tail.startsWith('-');
    const prefix = neg ? tail.slice(1) : tail;
    const [{ data, fetching }] = useQuery({ query: LABEL_COMPLETIONS, variables: { prefix },
                                  pause: dslWords.length > 0 || !prefix, context: PV_CTX });
    const words = dslWords.length ? dslWords : (data?.labelCompletions?.nodes ?? []);
    const loading = fetching && !dslWords.length;       // a vocabulary query in flight (DSL tokens are local)
    const pick = w => {
        if(dslWords.length){
            const lead = term.slice(segS).match(/^\s*/)[0];          // keep the "; " spacing
            onChange({ ...win, search: term.slice(0, segS) + lead + w });
        } else {
            const head = term.slice(0, term.length - tail.length);
            onChange({ ...win, search: head + (neg ? '-' : '') + (w.includes(' ') ? `"${w}"` : w) });
        }
        setOpen(false); setActive(-1);
    };
    const commit = () => {
        const { dateBounds } = parseQuery(term);
        if(!dateBounds) return;
        const next = { ...win, search: stripWindowTokens(term) };
        if(dateBounds.from) next.from = dateBounds.from.slice(0, 10);
        if(dateBounds.to) next.to = dateBounds.to.slice(0, 10);
        if(next.from > next.to) dateBounds.from ? (next.to = next.from) : (next.from = next.to);
        onChange(next); setOpen(false);
    };
    return html`
      <div class="searchbox">
        <input class="search" data-search type="search" placeholder="search…"
               value=${term}
               onInput=${e => { setOpen(true); setActive(-1); onChange({ ...win, search: e.target.value }); }}
               onKeyDown=${e => {
                   if(e.key === 'ArrowDown' && words.length){ e.preventDefault(); setActive(i => i >= words.length - 1 ? -1 : i + 1); }
                   else if(e.key === 'ArrowUp' && words.length){ e.preventDefault(); setActive(i => i < 0 ? words.length - 1 : i - 1); }
                   else if(e.key === 'Enter'){ e.preventDefault(); active >= 0 && words[active] ? pick(words[active]) : commit(); } }}
               onFocus=${() => setOpen(true)}
               onBlur=${() => setOpen(false)} />
        ${open && term && (words.length || loading) ? html`
          <ul class="completions">
            ${loading ? html`<li class="completion-loading" aria-disabled="true">completing…</li>` : ''}
            ${words.map((w, i) => html`<li class=${'completion' + (i === active ? ' active' : '')} data-completion
                  aria-selected=${i === active ? 'true' : 'false'}
                  onMouseDown=${e => { e.preventDefault(); pick(w); }}>${w}</li>`)}
          </ul>` : ''}
      </div>
    `;
}

.search{ width:100%; box-sizing:border-box; margin:8px 0; padding:8px 12px; font-size:15px;
         background:#262a40; color:var(--fg); border:1px solid #3a3f5a; border-radius:6px; }
/* the same completion dropdown the open view styles, dropped below the input;
   46px clears the search field's 8px top margin (the open view's input has none). */
.searchbox{ position:relative; }

Typing any fragment of a label offers the labels already in use — drawn from labelCompletions, matched anywhere in the word, so smo finds cosmo — and you reuse a word rather than inventing a near-duplicate. Crucially it completes the token under the cursor, not the whole box: after -danse =, typing =esca still suggests escalade (the leading - is the exclusion operator, set aside for the lookup and put back on the pick). Picking replaces just that token, leaving the rest of the query — operators and earlier terms — intact.

@testcase
def test_label_completion(page):
    """Typing a prefix suggests matching labels; picking one fills the search."""
    open_app(page)
    page.fill("[data-search]", "cos")
    page.wait_for_selector("[data-completion]")
    items = page.eval_on_selector_all("[data-completion]", "els => els.map(e => e.textContent)")
    assert "cosmo" in items, f"expected a 'cosmo' suggestion, got {items}"
    first = page.locator("[data-completion]").first
    word = first.text_content()
    first.click()
    assert page.input_value("[data-search]") == word, "picking a suggestion didn't fill the search"
    print("  PASS: label completion")

@testcase
def test_search_completion_in_flight_hint(page):
    """While the vocabulary query is in flight, the search box shows a 'completing…' hint."""
    open_app(page)
    # hold the completion query open so the in-flight state is observable, not a sub-second flash
    page.route("**/graphql", lambda route:
               route.fallback() if "labelCompletions" not in (route.request.post_data or "") else None)
    page.fill("[data-search]", "au")
    page.wait_for_selector("text=completing")
    print("  PASS: search completion in-flight hint")

@testcase
def test_search_completion_keyboard_nav(page):
    """↓ enters the completion at the top, ↑ at the bottom; both step and wrap through 'none'."""
    open_app(page)
    page.fill("[data-search]", "a")
    page.wait_for_selector("[data-completion]")
    items = page.eval_on_selector_all("[data-completion]", "els => els.map(e => e.textContent)")
    assert len(items) >= 2, f"need >=2 completions to test up/down, got {items}"
    def highlighted():
        sel = page.locator("[data-completion][aria-selected='true']")
        return sel.first.text_content() if sel.count() else None
    page.press("[data-search]", "ArrowUp")            # from nothing selected, ↑ enters at the bottom
    assert highlighted() == items[-1], f"up from none didn't select the last item: {highlighted()!r}"
    page.press("[data-search]", "ArrowDown")          # ↓ from the last wraps back to none
    assert highlighted() is None, f"down from the last didn't return to none: {highlighted()!r}"
    page.press("[data-search]", "ArrowDown")          # then ↓ enters at the top
    assert highlighted() == items[0], f"down didn't enter at the top: {highlighted()!r}"
    page.press("[data-search]", "ArrowUp")            # ↑ back out to none
    assert highlighted() is None, f"up from the first didn't return to none: {highlighted()!r}"
    print("  PASS: search completion keyboard nav")

The same box also completes the language’s tokens: a key prefix offers the key, and an open filter offers its values — typ suggests type:, then type: offers type:image and type:video.

@testcase
def test_dsl_token_completion(page):
    """A key prefix completes to the token, then the token to its values."""
    open_app(page)
    page.fill("[data-search]", "typ")
    page.wait_for_selector("[data-completion]")
    keys = page.eval_on_selector_all("[data-completion]", "els => els.map(e => e.textContent)")
    assert "type:" in keys, f"expected the type: key, got {keys}"
    page.fill("[data-search]", "type:")
    page.wait_for_function(
        "() => [...document.querySelectorAll('[data-completion]')].some(e => e.textContent === 'type:video')")
    vals = page.eval_on_selector_all("[data-completion]", "els => els.map(e => e.textContent)")
    assert "type:image" in vals and "type:video" in vals, f"expected type values, got {vals}"
    print("  PASS: dsl token completion")

@testcase
def test_negated_token_completion(page):
    """A leading - completes the negatable tokens: -typ → -type:, then -type: → -type:video."""
    open_app(page)
    page.fill("[data-search]", "-typ")
    page.wait_for_selector("[data-completion]")
    keys = page.eval_on_selector_all("[data-completion]", "els => els.map(e => e.textContent)")
    assert "-type:" in keys, f"expected the -type: key, got {keys}"
    page.fill("[data-search]", "-type:")
    page.wait_for_function(
        "() => [...document.querySelectorAll('[data-completion]')].some(e => e.textContent === '-type:video')")
    vals = page.eval_on_selector_all("[data-completion]", "els => els.map(e => e.textContent)")
    assert "-type:image" in vals and "-type:video" in vals, f"expected negated type values, got {vals}"
    print("  PASS: negated token completion")

The recurring filters complete the same way: mont offers the month: key, then month:ma narrows its members to march and may (current leads the list for both month: and day:, so today’s is one keystroke away).

@testcase
def test_month_completion(page):
    """month: completes to its month names; a key prefix offers the month: token itself."""
    open_app(page)
    page.fill("[data-search]", "mont")
    page.wait_for_selector("[data-completion]")
    keys = page.eval_on_selector_all("[data-completion]", "els => els.map(e => e.textContent)")
    assert "month:" in keys, f"expected the month: key, got {keys}"
    page.fill("[data-search]", "month:ma")
    page.wait_for_function(
        "() => [...document.querySelectorAll('[data-completion]')].some(e => e.textContent === 'month:march')")
    vals = page.eval_on_selector_all("[data-completion]", "els => els.map(e => e.textContent)")
    assert "month:march" in vals and "month:may" in vals, f"expected month names, got {vals}"
    print("  PASS: month completion")

since:, until: and year: are the one place the frise parts from memories: in a timeline the date range already lives in the window, so in the box they’re commands, not standing filters. Pressing Enter jumps the window to them and consumes the tokens — the other filters stay — so there is one date authority and no stale token to drift when you next pan. Which tokens count as window commands, and the bounds they resolve to, are the shared DSL’s to say (dateBounds and stripWindowTokens), so the box here re-learns no date syntax — a new window token added there works here untouched.

@testcase
def test_since_until_move_the_window(page):
    """Enter on a since:/until: token jumps the window and clears the token."""
    open_app(page)
    page.fill("[data-search]", "zzkeep; since:2020; until:2021")
    page.press("[data-search]", "Enter")
    page.wait_for_function(
        "() => document.querySelector('[data-win-from]').value === '2020-01-01'"
        " && document.querySelector('[data-win-to]').value === '2021-12-31'")
    assert page.input_value("[data-search]") == "zzkeep", \
        f"date tokens weren't consumed: {page.input_value('[data-search]')!r}"
    print("  PASS: since/until move the window")

A year: token is the same kind of command — Enter jumps the window to that whole year.

@testcase
def test_year_token_moves_the_window(page):
    """Enter on a year: token jumps the window to that whole year and clears the token."""
    open_app(page)
    page.fill("[data-search]", "zzkeep; year:2009")
    page.press("[data-search]", "Enter")
    page.wait_for_function(
        "() => document.querySelector('[data-win-from]').value === '2009-01-01'"
        " && document.querySelector('[data-win-to]').value === '2009-12-31'")
    assert page.input_value("[data-search]") == "zzkeep", \
        f"year token wasn't consumed: {page.input_value('[data-search]')!r}"
    print("  PASS: year token moves the window")

The check: type a term after an operator and an earlier word and check the trailing token still completes, replacing only itself.

@testcase
def test_completion_completes_last_token(page):
    """Completion targets the token under the cursor, even after '-danse '."""
    open_app(page)
    page.fill("[data-search]", "-danse esca")
    page.wait_for_selector("[data-completion]")
    items = page.eval_on_selector_all("[data-completion]", "els => els.map(e => e.textContent)")
    assert "Escalade/Accrobranche" in items, f"trailing token didn't complete: {items}"
    page.locator("[data-completion]", has_text="Escalade/Accrobranche").first.click()
    val = page.input_value("[data-search]")
    assert val.startswith("-danse ") and "Escalade/Accrobranche" in val, \
        f"picking clobbered the query instead of just the token: {val!r}"
    print("  PASS: completion completes last token")

Picking a suggestion must also close the dropdown — otherwise the query for the now-complete term returns it again and the list lingers, covering the controls below. The list opens while typing and closes on a pick or a blur.

@testcase
def test_completion_closes_after_pick(page):
    """Picking a completion closes the dropdown."""
    open_app(page)
    page.fill("[data-search]", "cos")
    page.wait_for_selector("[data-completion]")
    page.locator("[data-completion]").first.click()
    page.wait_for_selector("[data-completion]", state="detached")
    assert page.locator("[data-completion]").count() == 0, \
        "completion dropdown stayed open after a pick"
    print("  PASS: completion closes after pick")

A word you’ve just coined should complete next time — that’s the whole point of auto-add. It only works if the vocabulary actually grows when a label is saved, which depends on the label_vocab trigger living on the right table (see the schema section and its apply-vocab-child-triggers block — photovideo is an inheritance parent, so the trigger has to sit on its children or it silently does nothing).

To check this, we invent a label, save it on a photo, then reload — the whole point is that it completes next time, and a fresh boot fetches the grown vocabulary instead of trusting the client cache to have refreshed in the same session (which it sometimes hadn’t, the source of an occasional flake). Then we type the prefix and demand the suggestion appear, and remove the label to leave the archive as found. If the suggestion never shows, the failure lists the completions that did appear.

@testcase
def test_added_label_completes(page):
    """A label added to a photo becomes a completion suggestion (vocab auto-add)."""
    open_app(page)
    page.wait_for_selector("img.pin")
    term = "zzcompleteme"
    add_label(page, term)
    page.wait_for_load_state("networkidle")    # the save (chip is local) must commit before we re-query the vocab
    try:
        open_app(page)                         # next session: the vocab query runs fresh, with the new word in it
        page.fill("[data-search]", "zzcomplet")
        try:
            page.wait_for_function(
                f"() => [...document.querySelectorAll('[data-completion]')].some(e => e.textContent.includes('{term}'))")
        except Exception:
            have = page.eval_on_selector_all("[data-completion]", "els => els.map(e => e.textContent)")
            raise AssertionError(f"saved label {term!r} was never offered as a completion; suggestions: {have}")
    finally:
        strip_label(page, term)
    print("  PASS: added label completes")

Installable as an app

On a phone the frise wants to live on the home screen, not behind a browser tab. A web app manifest (name, fullscreen display, theme colour, an icon) plus a registered service worker is all the browser needs to offer “install”. The head links the manifest and the boot registers sw.js (see the shell). The worker is deliberately minimal — it only catches navigation requests to fall back to the cached shell when offline, leaving every data fetch (GraphQL, /ipfs) untouched on the network — so it adds installability without sitting in the way of anything.

We confirm it by checking the manifest is linked and complete, and that the service worker reaches an active state (with its own timeout, so a missing worker fails fast rather than hanging).

@testcase
def test_installable_pwa(page):
    """A complete manifest is linked and the service worker activates."""
    open_app(page)
    href = page.get_attribute("link[rel=manifest]", "href")
    assert href, "no manifest <link> in the head"
    man = page.evaluate("async h => (await fetch(h)).json()", href)
    for key in ("name", "icons", "start_url"):
        assert man.get(key), f"manifest missing {key}: {man}"
    assert man["display"] == "fullscreen", f"manifest display is {man.get('display')!r}, not fullscreen"
    active = page.evaluate(
        "async () => Promise.race(["
        "  navigator.serviceWorker.ready.then(r => !!r.active),"
        "  new Promise(res => setTimeout(() => res(false), 4000))])")
    assert active, "service worker never reached an active state"
    print("  PASS: installable pwa")

The manifest itself — a fullscreen app on a dark canvas, keyed to the same origin so start_url and scope are relative.

{
  "name": "Frise chrono",
  "short_name": "Frise",
  "start_url": ".",
  "scope": ".",
  "display": "fullscreen",
  "background_color": "#1b1d2e",
  "theme_color": "#1b1d2e",
  "icons": [
    { "src": "icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" }
  ]
}

A vector icon — an amber timeline with three dots — scales to any launcher size.

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
  <rect width="512" height="512" fill="#1b1d2e"/>
  <rect x="64" y="248" width="384" height="16" rx="8" fill="#f9a826"/>
  <circle cx="160" cy="256" r="34" fill="#e8e8f0"/>
  <circle cx="256" cy="256" r="34" fill="#e8e8f0"/>
  <circle cx="352" cy="256" r="34" fill="#e8e8f0"/>
</svg>

The service worker: cache the shell on install, take control at once, and on install/activate skip the usual wait so a fresh worker is active immediately. Its cache name carries the build-hash (frise-<hash>), so a new build is a new cache — and activate deletes every cache but the current one, so old builds don’t linger. Only navigations are served from cache when the network is gone; all other requests pass straight through.

const CACHE = 'frise-nil';
const SHELL = ['./', 'index.html', 'app.js', 'manifest.webmanifest', 'icon.svg'];
self.addEventListener('install', e => {
    self.skipWaiting();
    e.waitUntil(caches.open(CACHE).then(c => c.addAll(SHELL).catch(() => {})));
});
self.addEventListener('activate', e => e.waitUntil(    // drop caches from older builds, then take over
    caches.keys()
        .then(ks => Promise.all(ks.filter(k => k !== CACHE).map(k => caches.delete(k))))
        .then(() => self.clients.claim())));
self.addEventListener('fetch', e => {
    if(e.request.mode !== 'navigate') return;            // data fetches: untouched
    e.respondWith(fetch(e.request).catch(() => caches.match('index.html')));
});

Schema — labels + FTS on the docs DB

Metadata is moving off the key-value tag=/=tagmap model onto a free-text photovideo.labels field with full-text search and completion. This DDL is additive: tag, tagmap, ipfsdocs_tag_view and ipfsdocs_photovideo_view are untouched, so the slider and thumbs keep working. A near-future migration will retire the key-value side (Stage 2).

It seeds labels from existing tag values, builds a generated labels_fts tsvector (labels only — description is the raw file path, whose folder names would otherwise leak into label search) for search, and a label_vocab table for completion. Completion and search treat accents oppositely, on purpose. label_vocab keeps each label verbatim — case and accents intact — and completion folds accents when matching (f_unaccent + ILIKE), so typing aurelie still offers Aurélie to pick. Search is the mirror image: labels_fts and the query keep accents, so a committed term matches exactly — compétition and competition stay distinct sets, and you reach the accented one by completing to it rather than by a fold that would blur the two. PostGraphile exposes photovideosSearch(search) and labelCompletions(prefix) (matching the typed text anywhere in a label); label edits ride the auto updatePhotovideo mutation.

Applied once, atomically, against postgres://postgres@192.168.2.14:5432/docs: psql -v ON_ERROR_STOP=1 -f <this> (idempotent — safe to re-run). Restart PostGraphile afterwards so it re-introspects the new column and functions.

Known follow-ups: photovideos_search takes no orderBy=/date args yet (the frise search cycle will widen it to =photovideos_search(search, from, to) ordered by date); completion splits on spaces only, so slash-joined tag phrases stay whole.

-- additive: legacy tag/tagmap/views untouched
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE EXTENSION IF NOT EXISTS unaccent;
CREATE OR REPLACE FUNCTION f_unaccent(text) RETURNS text
  LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT
  AS $$ SELECT unaccent('unaccent'::regdictionary, $1) $$;

ALTER TABLE photovideo ADD COLUMN IF NOT EXISTS labels text;

-- seed from tags (values only, distinct, semicolon-joined; don't clobber edits)
UPDATE photovideo p SET labels = sub.labels
FROM (SELECT tm.cid, string_agg(DISTINCT t.value->>'value', '; ') AS labels
      FROM tagmap tm JOIN tag t USING (tag_id)
      WHERE t.value->>'value' <> '' GROUP BY tm.cid) sub
WHERE sub.cid = p.cid AND (p.labels IS NULL OR p.labels = '');

-- FTS over labels only (description is the original file path — its folder names
-- would leak incidental words like people/places into label search)
ALTER TABLE photovideo ADD COLUMN IF NOT EXISTS labels_fts tsvector
  GENERATED ALWAYS AS (
    to_tsvector('simple', coalesce(labels,''))
  ) STORED;
CREATE INDEX IF NOT EXISTS photovideo_labels_fts_idx ON photovideo USING gin (labels_fts);

-- completion vocabulary: one row per distinct label, kept *verbatim* — case and accents
-- preserved — so completion can offer "Aurélie", not "aurelie". Matching folds case and
-- accents at query time (f_unaccent + ILIKE); the vocabulary is a few hundred rows, so a
-- scan is cheap and no trigram index is needed (a functional one on f_unaccent(word) can't
-- build anyway — f_unaccent resolves the unaccent dictionary through the search_path, which
-- an index build restricts).
CREATE TABLE IF NOT EXISTS label_vocab (word text PRIMARY KEY, n int NOT NULL DEFAULT 0);
TRUNCATE label_vocab;
INSERT INTO label_vocab(word, n)
SELECT w, count(*) FROM (
  SELECT DISTINCT p.cid, btrim(x) AS w
  FROM photovideo p, unnest(string_to_array(coalesce(p.labels,''), ';')) x
  WHERE btrim(x) <> ''
) s GROUP BY w;

-- keep label_vocab in step with label edits (auto-add inherent), keyed on the verbatim label
CREATE OR REPLACE FUNCTION label_vocab_sync() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE w text;
BEGIN
  IF TG_OP IN ('UPDATE','DELETE') THEN
    FOR w IN SELECT DISTINCT btrim(x) FROM unnest(string_to_array(coalesce(OLD.labels,''),';')) x WHERE btrim(x) <> '' LOOP
      UPDATE label_vocab SET n = n - 1 WHERE word = w;
    END LOOP;
  END IF;
  IF TG_OP IN ('INSERT','UPDATE') THEN
    FOR w IN SELECT DISTINCT btrim(x) FROM unnest(string_to_array(coalesce(NEW.labels,''),';')) x WHERE btrim(x) <> '' LOOP
      INSERT INTO label_vocab(word,n) VALUES (w,1) ON CONFLICT (word) DO UPDATE SET n = label_vocab.n + 1;
    END LOOP;
  END IF;
  DELETE FROM label_vocab WHERE n <= 0;
  RETURN NULL;
END $$;
-- photovideo is an inheritance parent (rows live in the photo/video children); a row
-- trigger on the parent never fires, so it goes on each child where the edits land
DROP TRIGGER IF EXISTS label_vocab_sync_trg ON photovideo;
CREATE TRIGGER label_vocab_sync_trg AFTER INSERT OR UPDATE OF labels OR DELETE ON photo
  FOR EACH ROW EXECUTE FUNCTION label_vocab_sync();
CREATE TRIGGER label_vocab_sync_trg AFTER INSERT OR UPDATE OF labels OR DELETE ON video
  FOR EACH ROW EXECUTE FUNCTION label_vocab_sync();

-- retro-compat: a slider tag added to a photo also lands in labels (add-only)
CREATE OR REPLACE FUNCTION labels_from_tag_insert() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE v text;
BEGIN
  SELECT t.value->>'value' INTO v FROM tag t WHERE t.tag_id = NEW.tag_id;
  IF v IS NULL OR v = '' THEN RETURN NULL; END IF;
  UPDATE photovideo SET labels = btrim(coalesce(NULLIF(labels,'') || '; ', '') || v)
   WHERE cid = NEW.cid
     AND lower(f_unaccent(v)) <> ALL (SELECT lower(f_unaccent(btrim(x))) FROM unnest(string_to_array(coalesce(labels,''), ';')) x);
  RETURN NULL;
END $$;
DROP TRIGGER IF EXISTS labels_from_tag_insert_trg ON tagmap;
CREATE TRIGGER labels_from_tag_insert_trg AFTER INSERT ON tagmap
  FOR EACH ROW EXECUTE FUNCTION labels_from_tag_insert();

-- API exposed by PostGraphile
DROP FUNCTION IF EXISTS photovideos_search(text);
CREATE OR REPLACE FUNCTION photovideos_search(search text, since timestamptz, until timestamptz)
  RETURNS SETOF photovideo LANGUAGE sql STABLE AS $$
  SELECT * FROM photovideo
  WHERE date >= since AND date <= until
    AND (search IS NULL OR btrim(search) = ''
         OR labels_fts @@ websearch_to_tsquery('simple', search))
  ORDER BY date ASC $$;
-- match anywhere in the word (the trigram index serves the infix scan); exposed to
-- GraphQL as the `prefix` arg.
CREATE OR REPLACE FUNCTION label_completions(prefix text) RETURNS SETOF text
  LANGUAGE sql STABLE AS $$
  SELECT word FROM label_vocab
  WHERE f_unaccent(word) ILIKE '%' || f_unaccent(prefix) || '%' ORDER BY n DESC, word LIMIT 20 $$;

Run this block (mcp run_babel_block / C-c C-c) to (re)apply the window-aware search function to the docs DB — Emacs is configured to connect there, so a plain sql block executes against prod with no per-block connection header. Restart PostGraphile afterwards to re-introspect.

DROP FUNCTION IF EXISTS photovideos_search(text);
CREATE OR REPLACE FUNCTION photovideos_search(search text, since timestamptz, until timestamptz)
  RETURNS SETOF photovideo LANGUAGE sql STABLE AS $$
  SELECT * FROM photovideo
  WHERE date >= since AND date <= until
    AND (search IS NULL OR btrim(search) = ''
         OR labels_fts @@ websearch_to_tsquery('simple', search))
  ORDER BY date ASC $$;

And the completion function the same way — run this to (re)apply label_completions to the docs DB. No signature change, so PostGraphile needs no restart; refresh the test copy afterwards (docker rm -f docs-testcopy) so it re-seeds from prod.

CREATE OR REPLACE FUNCTION label_completions(prefix text) RETURNS SETOF text
  LANGUAGE sql STABLE AS $$
  SELECT word FROM label_vocab
  WHERE f_unaccent(word) ILIKE '%' || f_unaccent(prefix) || '%' ORDER BY n DESC, word LIMIT 20 $$;
CREATE FUNCTION

Labels are a ;-delimited list. This normalizes the column to that separator (idempotent — a single run folds any , =-joined value and re-running is a no-op). Re-run =apply-vocab-child-triggers afterwards to rebuild the vocabulary from the normalized labels.

UPDATE photovideo SET labels = replace(labels, ', ', '; ') WHERE labels LIKE '%, %';
UPDATE 14021

Search labels, not file paths. labels_fts once folded in description, but a doc’s description is its original file path: a search for a label like alois matched any photo whose folder happened to read …/chambre Aloïs/…, even when its only label was Travaux. This rebuilds the generated column from labels alone — dropping the index it depends on first, recomputing the column, reindexing. The column is internal (not a PostGraphile arg), so no restart is needed; just refresh the test copy (docker rm -f docs-testcopy).

BEGIN;
DROP INDEX IF EXISTS photovideo_labels_fts_idx;
ALTER TABLE photovideo DROP COLUMN IF EXISTS labels_fts;
ALTER TABLE photovideo ADD COLUMN labels_fts tsvector
  GENERATED ALWAYS AS (to_tsvector('simple', coalesce(labels,''))) STORED;
CREATE INDEX photovideo_labels_fts_idx ON photovideo USING gin (labels_fts);
COMMIT;
BEGIN
DROP INDEX
ALTER TABLE
ALTER TABLE
CREATE INDEX
COMMIT

Labels are a ;-delimited list of phrases. The tags were always phrases (Europa Parc, Famille Sam), so a label is a whole phrase — which may itself contain a comma (I Fly, Aurélie) — and the list separator is the semicolon, the same one memories, the label editor, and the bulk edits below all use. FTS is unaffected (to_tsvector tokenises words regardless). The one-time seed from the tag/tagmap source is split out from the re-runnable vocab rebuild + triggers, so the latter can be re-applied without clobbering live labels.

The seed is destructive and already ran: it overwrites labels from the tag source, so re-running it would discard every edit made since. Kept for provenance / a fresh rebuild.

-- ONE-TIME, DESTRUCTIVE — do NOT re-run on the live DB. Re-seeds labels from tag/tagmap
-- as a ;-joined phrase list, overwriting whatever is there.
UPDATE photovideo p SET labels = sub.labels
FROM (SELECT tm.cid, string_agg(DISTINCT t.value->>'value', '; ' ORDER BY t.value->>'value') AS labels
      FROM tagmap tm JOIN tag t USING (tag_id)
      WHERE t.value->>'value' <> '' GROUP BY tm.cid) sub
WHERE sub.cid = p.cid;

The completion trigger was firing into the void. photovideo is not a real table but an inheritance parent: every row lives in a child table (photo or video). A row trigger on the parent never fires for rows stored in a child, so label_vocab_sync_trg on photovideo was dead — a label added in the open view updated labels (FTS, a generated column, still indexed it, so search worked) but never reached label_vocab, so the completion dropdown never learned the new word. The fix puts the trigger on each child, and rebuilds the vocabulary from the live labels to clear the drift it accumulated while dead. Run this block; no restart needed.

Two pieces are shared with the one-shot migration above — the label_vocab_sync trigger body and the rebuild from current labels. They each get one definition, pulled by noweb wherever they are applied, so the two sites can never drift to different rules (the bug that once let the vocabulary silently fold case and accents). First the sync function, keyed on the verbatim label so the stored word keeps its case and accents:

CREATE OR REPLACE FUNCTION label_vocab_sync() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE w text;
BEGIN
  IF TG_OP IN ('UPDATE','DELETE') THEN
    FOR w IN SELECT DISTINCT btrim(x) FROM unnest(string_to_array(coalesce(OLD.labels,''),';')) x WHERE btrim(x) <> '' LOOP
      UPDATE label_vocab SET n = n - 1 WHERE word = w;
    END LOOP;
  END IF;
  IF TG_OP IN ('INSERT','UPDATE') THEN
    FOR w IN SELECT DISTINCT btrim(x) FROM unnest(string_to_array(coalesce(NEW.labels,''),';')) x WHERE btrim(x) <> '' LOOP
      INSERT INTO label_vocab(word,n) VALUES (w,1) ON CONFLICT (word) DO UPDATE SET n = label_vocab.n + 1;
    END LOOP;
  END IF;
  DELETE FROM label_vocab WHERE n <= 0;
  RETURN NULL;
END $$;

And the rebuild — one verbatim row per distinct label, counted across the docs:

TRUNCATE label_vocab;
INSERT INTO label_vocab(word, n)
SELECT w, count(*) FROM (
  SELECT DISTINCT p.cid, btrim(x) AS w
  FROM photovideo p, unnest(string_to_array(coalesce(p.labels,''), ';')) x
  WHERE btrim(x) <> ''
) s GROUP BY w;

BEGIN;
CREATE OR REPLACE FUNCTION label_vocab_sync() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE w text;
BEGIN
  IF TG_OP IN ('UPDATE','DELETE') THEN
    FOR w IN SELECT DISTINCT btrim(x) FROM unnest(string_to_array(coalesce(OLD.labels,''),';')) x WHERE btrim(x) <> '' LOOP
      UPDATE label_vocab SET n = n - 1 WHERE word = w;
    END LOOP;
  END IF;
  IF TG_OP IN ('INSERT','UPDATE') THEN
    FOR w IN SELECT DISTINCT btrim(x) FROM unnest(string_to_array(coalesce(NEW.labels,''),';')) x WHERE btrim(x) <> '' LOOP
      INSERT INTO label_vocab(word,n) VALUES (w,1) ON CONFLICT (word) DO UPDATE SET n = label_vocab.n + 1;
    END LOOP;
  END IF;
  DELETE FROM label_vocab WHERE n <= 0;
  RETURN NULL;
END $$;
-- rows live in the children; the parent holds none, so its trigger would never fire
DROP TRIGGER IF EXISTS label_vocab_sync_trg ON photovideo;
DROP TRIGGER IF EXISTS label_vocab_sync_trg ON photo;
DROP TRIGGER IF EXISTS label_vocab_sync_trg ON video;
CREATE TRIGGER label_vocab_sync_trg AFTER INSERT OR UPDATE OF labels OR DELETE ON photo
  FOR EACH ROW EXECUTE FUNCTION label_vocab_sync();
CREATE TRIGGER label_vocab_sync_trg AFTER INSERT OR UPDATE OF labels OR DELETE ON video
  FOR EACH ROW EXECUTE FUNCTION label_vocab_sync();
-- the old plain-word trigram index doesn't serve the f_unaccent match and a functional
-- one can't build (search_path), so drop it — the vocabulary is small enough to scan
DROP INDEX IF EXISTS label_vocab_trgm_idx;
TRUNCATE label_vocab;
INSERT INTO label_vocab(word, n)
SELECT w, count(*) FROM (
  SELECT DISTINCT p.cid, btrim(x) AS w
  FROM photovideo p, unnest(string_to_array(coalesce(p.labels,''), ';')) x
  WHERE btrim(x) <> ''
) s GROUP BY w;
COMMIT;
BEGIN
CREATE FUNCTION
DROP TRIGGER
DROP TRIGGER
DROP TRIGGER
CREATE TRIGGER
CREATE TRIGGER
DROP INDEX
TRUNCATE TABLE
INSERT 0 177
COMMIT

Proof the trigger now fires (rolled back — no permanent change): an update through the parent reaches the child trigger and the new word lands in the vocabulary.

BEGIN;
UPDATE photovideo SET labels = btrim(coalesce(labels,'') || ', zzverify')
  WHERE cid = (SELECT cid FROM photovideo ORDER BY date DESC LIMIT 1);
SELECT 'vocab has zzverify = ' || EXISTS(SELECT 1 FROM label_vocab WHERE word='zzverify')::text;
ROLLBACK;
BEGIN
UPDATE 1
?column?
vocab has zzverify = true
ROLLBACK

Housekeeping: the integration tests leave zz... scratch labels behind if they crash mid-run. This block strips any zz-prefixed phrase from every photo (keeping the real ones), nulling labels that become empty — surgical, touching only rows that carry such a token.

UPDATE photovideo p SET labels = (
  SELECT NULLIF(string_agg(btrim(x), ', '), '')
  FROM unnest(string_to_array(coalesce(p.labels,''), ',')) x
  WHERE btrim(x) <> '' AND lower(btrim(x)) NOT LIKE 'zz%'
)
WHERE p.labels ~* '(^|,)[[:space:]]*zz';
UPDATE 1

Sampling. Showing the first 300 clusters everything at the window’s start. Instead photovideos_search returns a ~300-photo sample spread across the window, and a companion photovideos_count reports the true total for the readout. Run this block, then restart PostGraphile (the new count function needs re-introspecting).

The sample must be stable as you pan: an every-k-th-by-rank sample reshuffles completely when a single photo enters the window’s edge (every rank shifts by one), so photos blink in and out while sliding. Instead each photo gets a fixed hash in [0,1) from its cid, and the window keeps those below 300/total. A small pan barely moves that threshold, so the shown photos stay put and the strip slides smoothly — only the photos right at the edge come and go. The hash is independent of date, so the kept ~300 are still spread evenly across the span.

The same window also filters before it samples — by state (the workflow enum todo/next/done/delete) and by kind (the mimetype’s image=/=video prefix). Both must run before the keep-threshold: filtered after the sample, a “todo” view over a mostly-done archive — or a “videos” view — would come back nearly empty. Search, count and the bulk edits below all share one predicate, kept in a single block so they can’t drift apart. states=/=kinds are NULL (= all) unless given.

date >= since AND date <= until
  AND (states IS NULL OR state = ANY(states))
  AND (kinds  IS NULL OR split_part(mimetype, '/', 1) = ANY(kinds))
  AND (owners IS NULL OR owner = ANY(owners))
  AND (aday   IS NULL OR anniv_dist(date, aday) <= awin)
  AND (amonth IS NULL OR month_dist(extract(month from date)::int, amonth) <= mwin)
  AND (search IS NULL OR btrim(search) = ''
       OR labels_fts @@ websearch_to_tsquery('simple', search))

anniv_dist powers the “on this day” view — the circular day-distance between a row’s date and a target month-day (year ignored), so aday + a window awin match the same anniversary across all years. Both dates are projected onto a leap year (2000) so Feb 29 is valid, and the distance wraps at the year boundary (Dec 31 ↔ Jan 1).

CREATE OR REPLACE FUNCTION anniv_dist(d timestamptz, target date) RETURNS int LANGUAGE sql STABLE AS $a$
  SELECT LEAST(diff, 366 - diff) FROM (
    SELECT abs(make_date(2000, extract(month from d)::int,      extract(day from d)::int)
             - make_date(2000, extract(month from target)::int, extract(day from target)::int)) AS diff
  ) s
$a$;

month_dist is its month-scale twin, powering the month: filter: the circular distance between two month numbers, so amonth + a window mwin match the same month across all years and wrap at the boundary (December ↔ January is one step, not eleven).

CREATE OR REPLACE FUNCTION month_dist(m int, target int) RETURNS int LANGUAGE sql IMMUTABLE AS $a$
  SELECT LEAST(abs(m - target), 12 - abs(m - target))
$a$;

Matching and sampling are two jobs, so they are two functions. photovideos_match is the filter alone — the rows matching the search, date range, states, kinds, owners, and the recurring day/month windows — and everything else composes over it rather than re-spelling the filter: photovideos_count counts it, photovideos_search samples it, and memories draws its own spread over it (its note owns that). Defining the filter once is what keeps the count, the sample, and the bulk edits agreeing on a single set.

photovideos_search is the frise’s sample: it keeps the matching rows whose fixed cid-hash falls under cap/total — a stable, evenly-spread ~cap. cap (default 300) is the keep-target; the threshold’s total comes from a separate density window (dsince=/=duntil, default [since,until]) quantised to ~25% buckets, so a small pan keeps the threshold and the strip doesn’t reshuffle. That split is what lets the frise render past the edges without thinning the view: it fetches a window wider than the screen but passes the visible window as the density window, so the keep-rate is the visible window’s — the overscan margins come along at the same rate, and the on-screen photos are never sub-sampled (see Render past the edges). The block is idempotent (drops the older signatures, then replaces the current ones), so re-run it any time; restart PostGraphile afterwards so it re-introspects the args.

BEGIN;
CREATE OR REPLACE FUNCTION anniv_dist(d timestamptz, target date) RETURNS int LANGUAGE sql STABLE AS $a$
  SELECT LEAST(diff, 366 - diff) FROM (
    SELECT abs(make_date(2000, extract(month from d)::int,      extract(day from d)::int)
             - make_date(2000, extract(month from target)::int, extract(day from target)::int)) AS diff
  ) s
$a$;
CREATE OR REPLACE FUNCTION month_dist(m int, target int) RETURNS int LANGUAGE sql IMMUTABLE AS $a$
  SELECT LEAST(abs(m - target), 12 - abs(m - target))
$a$;
DROP FUNCTION IF EXISTS photovideos_search(text, timestamptz, timestamptz);
DROP FUNCTION IF EXISTS photovideos_search(text, timestamptz, timestamptz, state[]);
DROP FUNCTION IF EXISTS photovideos_search(text, timestamptz, timestamptz, state[], text[]);
DROP FUNCTION IF EXISTS photovideos_search(text, timestamptz, timestamptz, state[], text[], date, int);
DROP FUNCTION IF EXISTS photovideos_count(text, timestamptz, timestamptz);
DROP FUNCTION IF EXISTS photovideos_count(text, timestamptz, timestamptz, state[]);
DROP FUNCTION IF EXISTS photovideos_count(text, timestamptz, timestamptz, state[], text[]);
-- amonth/mwin are new trailing args, so the match/search/count signatures change again.
-- SQL-function body deps aren't tracked here, so drop the *current* (pre-month) signatures
-- explicitly (CASCADE won't reach them). Dropping match also strands memories' sample,
-- which composes it — re-run its apply block afterwards.
DROP FUNCTION IF EXISTS photovideos_search(text, timestamptz, timestamptz, state[], text[], date, int, int, timestamptz, timestamptz);
DROP FUNCTION IF EXISTS photovideos_search(text, timestamptz, timestamptz, state[], text[], date, int, int, timestamptz, timestamptz, owner_type[]);
DROP FUNCTION IF EXISTS photovideos_count(text, timestamptz, timestamptz, state[], text[], date, int);
DROP FUNCTION IF EXISTS photovideos_count(text, timestamptz, timestamptz, state[], text[], date, int, owner_type[]);
DROP FUNCTION IF EXISTS photovideos_match(text, timestamptz, timestamptz, state[], text[], date, int);
DROP FUNCTION IF EXISTS photovideos_match(text, timestamptz, timestamptz, state[], text[], date, int, owner_type[]);
CREATE OR REPLACE FUNCTION photovideos_match(search text, since timestamptz, until timestamptz,
                                             states state[] DEFAULT NULL, kinds text[] DEFAULT NULL,
                                             aday date DEFAULT NULL, awin int DEFAULT 1,
                                             owners owner_type[] DEFAULT NULL,
                                             amonth int DEFAULT NULL, mwin int DEFAULT 0)
  RETURNS SETOF photovideo LANGUAGE sql STABLE AS $f$   -- the filter layer, alone (no sampling)
  SELECT * FROM photovideo WHERE date >= since AND date <= until
  AND (states IS NULL OR state = ANY(states))
  AND (kinds  IS NULL OR split_part(mimetype, '/', 1) = ANY(kinds))
  AND (owners IS NULL OR owner = ANY(owners))
  AND (aday   IS NULL OR anniv_dist(date, aday) <= awin)
  AND (amonth IS NULL OR month_dist(extract(month from date)::int, amonth) <= mwin)
  AND (search IS NULL OR btrim(search) = ''
       OR labels_fts @@ websearch_to_tsquery('simple', search))
$f$;
CREATE OR REPLACE FUNCTION photovideos_search(search text, since timestamptz, until timestamptz,
                                              states state[] DEFAULT NULL, kinds text[] DEFAULT NULL,
                                              aday date DEFAULT NULL, awin int DEFAULT 1,
                                              cap int DEFAULT 300,
                                              dsince timestamptz DEFAULT NULL, duntil timestamptz DEFAULT NULL,
                                              owners owner_type[] DEFAULT NULL,
                                              amonth int DEFAULT NULL, mwin int DEFAULT 0)
  RETURNS SETOF photovideo LANGUAGE sql STABLE AS $f$
  -- the sample layer, over the filter: keep the rows whose fixed cid-hash falls under
  -- cap/total. The threshold's total is the *density* window (dsince/duntil, default
  -- [since,until]) quantised to ~25% buckets, so a small pan keeps the threshold stable.
  WITH n AS (
    SELECT power(1.25, round(ln(GREATEST(
             (SELECT count(*) FROM photovideos_match(search, coalesce(dsince, since), coalesce(duntil, until),
                                                     states, kinds, aday, awin, owners, amonth, mwin)), 1)::float8) / ln(1.25)))::float8 AS q
  )
  SELECT pv.* FROM photovideos_match(search, since, until, states, kinds, aday, awin, owners, amonth, mwin) pv, n
  WHERE (hashtext(pv.cid)::bigint & 2147483647)::float8 / 2147483647 < LEAST(1.0, cap::float8 / GREATEST(1.0, n.q))
  ORDER BY pv.date
$f$;
CREATE OR REPLACE FUNCTION photovideos_count(search text, since timestamptz, until timestamptz,
                                             states state[] DEFAULT NULL, kinds text[] DEFAULT NULL,
                                             aday date DEFAULT NULL, awin int DEFAULT 1,
                                             owners owner_type[] DEFAULT NULL,
                                             amonth int DEFAULT NULL, mwin int DEFAULT 0)
  RETURNS integer LANGUAGE sql STABLE AS $f$
  SELECT count(*)::int FROM photovideos_match(search, since, until, states, kinds, aday, awin, owners, amonth, mwin)
$f$;
COMMIT;
BEGIN
CREATE FUNCTION
CREATE FUNCTION
DROP FUNCTION
DROP FUNCTION
DROP FUNCTION
DROP FUNCTION
DROP FUNCTION
DROP FUNCTION
DROP FUNCTION
DROP FUNCTION
DROP FUNCTION
DROP FUNCTION
DROP FUNCTION
DROP FUNCTION
DROP FUNCTION
CREATE FUNCTION
CREATE FUNCTION
CREATE FUNCTION
COMMIT

Slicing by owner. The archive holds more than one person’s photos (the owner column), so the filter takes an owners list: NULL means everyone, otherwise only those owners. It rides inside <<match-filter>> with the rest of the predicate, so a count, a sample, and a bulk edit can never disagree about whose photos are in scope. We check that the per-owner counts partition the unfiltered total across the whole archive.

@testcase
def test_owner_partitions_the_count(page):
    """photovideosCount slices by owner: the per-owner counts sum to the unfiltered total."""
    q = ("query($a:Datetime!,$b:Datetime!,$o:[OwnerType!]){"
         " photovideosCount(search:\"\", since:$a, until:$b, owners:$o) }")
    def count(o):
        return gql(q, {"a": "2007-01-01", "b": "2035-01-01", "o": o})["data"]["photovideosCount"]
    total = count(None)
    parts = {w: count([w]) for w in ("konubinix", "ayla", "aylapomme")}
    assert sum(parts.values()) == total, f"owners don't partition the total: {parts} vs {total}"
    assert parts["konubinix"] > 0 and parts["aylapomme"] > 0, f"both owners expected: {parts}"
    print("  PASS: owner partitions the count")

Bulk edits over the whole match, not just the sample. The thumbs grid only shows a ~300 sample, so a per-cid loop can’t retag the thousands a filter may match. These three VOLATILE functions take the same filter as photovideos_search and update every matching row in one statement, returning the count. Labels are a ;-delimited list (so a tag may itself contain a comma, e.g. I Fly, Aurélie), so add/remove split on ;, dedupe, and re-join — the same separator memories and the label editor use. PostGraphile exposes volatile functions as mutations (photovideosSetState, photovideosAddLabel, photovideosRemoveLabel); restart to pick them up.

Each shares the <<match-filter>> predicate (so a filter change can never desync the search and the bulk edit), then adds its own twist: set_state just writes; add_label appends only where the label isn’t already a ;-token (dedupe); remove_label splits, drops the token, and re-joins (NULLIF leaves an emptied field NULL). Each returns the number of rows it touched.

BEGIN;
DROP FUNCTION IF EXISTS photovideos_set_state(text, timestamptz, timestamptz, state[], text[], state);
DROP FUNCTION IF EXISTS photovideos_add_label(text, timestamptz, timestamptz, state[], text[], text);
DROP FUNCTION IF EXISTS photovideos_remove_label(text, timestamptz, timestamptz, state[], text[], text);
DROP FUNCTION IF EXISTS photovideos_set_state(text, timestamptz, timestamptz, state[], text[], state, date, int);
DROP FUNCTION IF EXISTS photovideos_add_label(text, timestamptz, timestamptz, state[], text[], text, date, int);
DROP FUNCTION IF EXISTS photovideos_remove_label(text, timestamptz, timestamptz, state[], text[], text, date, int);
-- match-filter now references amonth/mwin, so the inlining functions gain those args too;
-- drop the current (pre-month, owner-ending) signatures so the new ones don't overload them.
DROP FUNCTION IF EXISTS photovideos_set_state(text, timestamptz, timestamptz, state[], text[], state, date, int, owner_type[]);
DROP FUNCTION IF EXISTS photovideos_add_label(text, timestamptz, timestamptz, state[], text[], text, date, int, owner_type[]);
DROP FUNCTION IF EXISTS photovideos_remove_label(text, timestamptz, timestamptz, state[], text[], text, date, int, owner_type[]);
CREATE OR REPLACE FUNCTION photovideos_set_state(search text, since timestamptz, until timestamptz,
    states state[], kinds text[], to_state state, aday date DEFAULT NULL, awin int DEFAULT 1,
    owners owner_type[] DEFAULT NULL, amonth int DEFAULT NULL, mwin int DEFAULT 0)
    RETURNS int LANGUAGE plpgsql AS $f$
  DECLARE n int;
  BEGIN
    UPDATE photovideo SET state = to_state WHERE date >= since AND date <= until
  AND (states IS NULL OR state = ANY(states))
  AND (kinds  IS NULL OR split_part(mimetype, '/', 1) = ANY(kinds))
  AND (owners IS NULL OR owner = ANY(owners))
  AND (aday   IS NULL OR anniv_dist(date, aday) <= awin)
  AND (amonth IS NULL OR month_dist(extract(month from date)::int, amonth) <= mwin)
  AND (search IS NULL OR btrim(search) = ''
       OR labels_fts @@ websearch_to_tsquery('simple', search));
    GET DIAGNOSTICS n = ROW_COUNT; RETURN n;
  END $f$;
CREATE OR REPLACE FUNCTION photovideos_add_label(search text, since timestamptz, until timestamptz,
    states state[], kinds text[], label text, aday date DEFAULT NULL, awin int DEFAULT 1,
    owners owner_type[] DEFAULT NULL, amonth int DEFAULT NULL, mwin int DEFAULT 0)
    RETURNS int LANGUAGE plpgsql AS $f$
  DECLARE n int;
  BEGIN
    UPDATE photovideo
    SET labels = CASE WHEN coalesce(btrim(labels), '') = '' THEN label ELSE labels || '; ' || label END
    WHERE date >= since AND date <= until
  AND (states IS NULL OR state = ANY(states))
  AND (kinds  IS NULL OR split_part(mimetype, '/', 1) = ANY(kinds))
  AND (owners IS NULL OR owner = ANY(owners))
  AND (aday   IS NULL OR anniv_dist(date, aday) <= awin)
  AND (amonth IS NULL OR month_dist(extract(month from date)::int, amonth) <= mwin)
  AND (search IS NULL OR btrim(search) = ''
       OR labels_fts @@ websearch_to_tsquery('simple', search))
      AND NOT (label = ANY(regexp_split_to_array(btrim(coalesce(labels, '')), '\s*;\s*')));
    GET DIAGNOSTICS n = ROW_COUNT; RETURN n;
  END $f$;
CREATE OR REPLACE FUNCTION photovideos_remove_label(search text, since timestamptz, until timestamptz,
    states state[], kinds text[], label text, aday date DEFAULT NULL, awin int DEFAULT 1,
    owners owner_type[] DEFAULT NULL, amonth int DEFAULT NULL, mwin int DEFAULT 0)
    RETURNS int LANGUAGE plpgsql AS $f$
  DECLARE n int;
  BEGIN
    UPDATE photovideo
    SET labels = NULLIF(array_to_string(
          array_remove(regexp_split_to_array(btrim(coalesce(labels, '')), '\s*;\s*'), label), '; '), '')
    WHERE date >= since AND date <= until
  AND (states IS NULL OR state = ANY(states))
  AND (kinds  IS NULL OR split_part(mimetype, '/', 1) = ANY(kinds))
  AND (owners IS NULL OR owner = ANY(owners))
  AND (aday   IS NULL OR anniv_dist(date, aday) <= awin)
  AND (amonth IS NULL OR month_dist(extract(month from date)::int, amonth) <= mwin)
  AND (search IS NULL OR btrim(search) = ''
       OR labels_fts @@ websearch_to_tsquery('simple', search))
      AND (label = ANY(regexp_split_to_array(btrim(coalesce(labels, '')), '\s*;\s*')));
    GET DIAGNOSTICS n = ROW_COUNT; RETURN n;
  END $f$;
COMMIT;
BEGIN
DROP FUNCTION
DROP FUNCTION
DROP FUNCTION
DROP FUNCTION
DROP FUNCTION
DROP FUNCTION
DROP FUNCTION
DROP FUNCTION
DROP FUNCTION
CREATE FUNCTION
CREATE FUNCTION
CREATE FUNCTION
COMMIT

Index the date column. Every view of the frise is a date window, so each query filters photovideo on date. But photovideo is an inheritance parent holding no rows — the data lives in its children photo and video — and an index on the parent does not propagate to the children. Without a child index each window does a full sequential scan of ~75k rows (~2s, and the panning feels laggy); with one it drops to a few milliseconds. So the index goes on each child, on date. The tables are small, so a plain build (a brief write lock of ~a second) is fine — the frise only reads. Run once.

CREATE INDEX IF NOT EXISTS photo_date_idx ON photo (date);
CREATE INDEX IF NOT EXISTS video_date_idx ON video (date);
CREATE INDEX
CREATE INDEX

Notes linking here