diff --git a/docker/frontend/scripts/wall-check.mjs b/docker/frontend/scripts/wall-check.mjs index 7e420fc..3bf2c50 100644 --- a/docker/frontend/scripts/wall-check.mjs +++ b/docker/frontend/scripts/wall-check.mjs @@ -31,8 +31,20 @@ const SAMPLE_PORT = Number(process.env.SAMPLE_PORT ?? 5034); const FRAMES = Number(process.env.FRAMES ?? 300); const FOLDER = 'WallRoll'; // Every frame is the same bytes under a different name: what is being checked is -// how many cards the wall will draw, not what is on them. +// how many cards the wall will draw, not what is on them. The bytes carry no +// EXIF, so a frame's date is its own `lastModified` — spread over the days below, +// because a wall of one day is a wall with one header in it, and the headers are +// half of what this check is for. const names = Array.from({ length: FRAMES }, (_, i) => `frame-${String(i + 1).padStart(4, '0')}.jpg`); +const DAY_MS = 86400000; +const PER_DAY = Number(process.env.PER_DAY ?? 10); +const DAY0 = Date.UTC(2026, 0, 1); +// The day a frame is filed under, off its own name — the same rule on both sides +// of the wire, so the check reads the wall the way the wall was told to sort it. +const dayOfName = (name) => { + const n = Number((name.match(/\d+/) ?? ['1'])[0]); + return DAY0 + Math.floor(Math.max(0, n - 1) / PER_DAY) * DAY_MS; +}; let failures = 0; function step(name, ok, detail = '') { @@ -125,14 +137,17 @@ await context.route('**/api/**', (route) => { // they are put back where the catalogue reads them, because a cloned handle is a // plain object with no `getFile`. await context.addInitScript( - ({ origin, names, folder }) => { + ({ origin, names, folder, days }) => { + // `days` is the day of each frame, index-aligned with `names`: the init + // script is cloned into the page and cannot close over the check's own scope. + const dayOfNameIn = (name) => days[names.indexOf(name)] ?? Date.UTC(2026, 0, 1); const fileHandle = (name) => { const handle = { kind: 'file', name, __fake: true }; Object.defineProperty(handle, 'getFile', { value: async () => new File([await (await fetch(`${origin}/${name}`)).arrayBuffer()], name, { type: 'image/jpeg', - lastModified: Date.UTC(2026, 0, 1), + lastModified: dayOfNameIn(name), }), }); return handle; @@ -155,6 +170,9 @@ await context.addInitScript( }; const root = () => dirHandle(folder, { files: names }); window.showDirectoryPicker = async () => root(); + // The day each frame was given, for the header audit below: the page's own + // clock reads the label, so the day is read back here the same way. + window.__dayOf = (name) => dayOfNameIn(name); const revive = (row) => { if (row?.handle?.__fake && !Object.getOwnPropertyDescriptor(row.handle, 'getFile')) { @@ -178,7 +196,7 @@ await context.addInitScript( }; } }, - { origin: `http://127.0.0.1:${SAMPLE_PORT}`, names, folder: FOLDER } + { origin: `http://127.0.0.1:${SAMPLE_PORT}`, names, folder: FOLDER, days: names.map(dayOfName) } ); const page = await context.newPage(); @@ -208,6 +226,33 @@ await page const counted = await page.$eval('[data-key="lib-count"]', (e) => e.textContent?.trim() ?? ''); step('the roll is read whole', counted.includes(String(FRAMES)), counted); +// The day the header of a card stands under, read off the sections themselves: +// every header must name the day of every card filed under it. A day whose first +// rows are above the window still gets its header where the eye lands (see +// `dayRuns`), so the rule is "a header names its own cards" and not "one header +// per day in the shelf". +const dayAudit = () => + page.evaluate(() => { + const key = (ts) => { + const d = new Date(ts); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + }; + const out = { sections: 0, cards: 0, labelled: 0, bad: [] }; + for (const section of document.querySelectorAll('.adm-day')) { + out.sections++; + const own = (section.getAttribute('data-key') ?? '').replace('lib-day-', ''); + const head = section.querySelector('.adm-day-head')?.textContent?.trim() ?? ''; + if (head) out.labelled++; + const names = [...section.querySelectorAll('[data-key^="lib-photo-"]')].map( + (el) => (el.getAttribute('data-key') ?? '').split('/').pop() + ); + const days = new Set(names.map((name) => key(window.__dayOf(name)))); + if (!names.length || days.size !== 1 || !days.has(own)) out.bad.push({ own, head, days: [...days], names: names.slice(0, 2) }); + } + out.cards = document.querySelectorAll('.adm-card').length; + return out; + }); + await page.click('[data-key="lib-view-grid"]'); await page.waitForSelector('.adm-grid', { timeout: 15_000 }); await page.waitForFunction(() => document.querySelectorAll('.adm-card').length > 0, null, { timeout: 30_000 }); @@ -216,6 +261,22 @@ const wall = await read(); step('the wall draws a screenful of the shelf', wall.cards > 0 && wall.cards < FRAMES, `${wall.cards} of ${FRAMES} cards drawn`); step('and stands in for the rows it is not drawing', wall.wall, 'the shelf scrolls'); +const days1 = await dayAudit(); +step('the wall is cut into the days its frames were shot', days1.sections > 1, `${days1.sections} headers over ${days1.cards} cards`); +step('every header names the day of the cards under it', days1.bad.length === 0, JSON.stringify(days1.bad.slice(0, 2))); +step('and every header carries its day in words', days1.labelled === days1.sections, `${days1.labelled} of ${days1.sections}`); + +// The same wall, scrolled to where no day starts at the fold: the headers have to +// move with it, which is the whole risk of cutting a virtualised wall into days. +await page.evaluate(() => { + const el = document.querySelector('.adm-grid'); + el.scrollTop = Math.round(el.scrollHeight * 0.7); + el.dispatchEvent(new Event('scroll')); +}); +await page.waitForTimeout(1500); +const days2 = await dayAudit(); +step('a scrolled wall still files every card under its own day', days2.sections > 0 && days2.bad.length === 0, JSON.stringify(days2.bad.slice(0, 2))); + // The class of trigger, made to happen: one input the wall reads back off itself, // moving on every pass. await page.evaluate(() => { diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index 20f4a40..81461cc 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -360,9 +360,10 @@ export function Library() { } import { useIsMobile } from './ui/useIsMobile'; +import { dayLabel, dayRuns } from './ui/dateGroups'; function Catalogue({ admin, user }: { admin: boolean; user: User | null }) { - const { t } = useI18n(); + const { t, lang } = useI18n(); const [forceView, setForceViewState] = useState<'auto' | 'mobile' | 'desktop'>(() => { return (localStorage.getItem('rc_force_view') as 'auto' | 'mobile' | 'desktop') || 'auto'; }); @@ -2037,6 +2038,12 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) { () => listed.slice(Math.min(wall.from, listed.length), Math.min(wall.to, listed.length)), [listed, wall.from, wall.to] ); + // The same slice, cut into the days it holds: a wall is read by the day a frame + // was shot, and the line above each run is that day. Cut off the slice as it + // stands rather than sorted into days of its own, so the sort chip keeps the + // order it names; the first run may be a day that started above the window, + // which is its own header drawn where the eye lands (see `dayRuns`). + const paintedDays = useMemo(() => dayRuns(painted, (photo) => photo.taken || photo.addedAt), [painted]); // A tile the studio graded while this screen was dark: the copy in hand is the // negative's, and `onThumbWritten` hands over the one that replaces it. The // counter is what makes the rows look again — the list is unchanged, so nothing @@ -2139,7 +2146,7 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) { // No card drawn: the wall opens on the first screenful, so aim at the row // and let the next pass find it. The row's step is read off a drawn card // when there is one, and guessed at when there is not. - const first = el.querySelector(':scope > .adm-card'); + const first = el.querySelector('.adm-card'); const cols = Math.max(1, getComputedStyle(el).gridTemplateColumns.split(' ').length); const pitch = first ? first.offsetHeight + WALL_GAP : 240; el.scrollTop = Math.max(0, Math.floor(idx / cols) * pitch - (el.clientHeight - pitch) / 2); @@ -2203,7 +2210,7 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) { : null; const mid = el ? el.getBoundingClientRect().top + el.clientHeight / 2 : 0; let best: { id: string; d: number } | null = null; - for (const card of el ? el.querySelectorAll(':scope > .adm-card') : []) { + for (const card of el ? el.querySelectorAll('.adm-card') : []) { const box = card.getBoundingClientRect(); const d = Math.abs(box.top + box.height / 2 - mid); if (!best || d < best.d) best = { id: card.dataset.key!.slice('lib-photo-'.length), d }; @@ -3122,58 +3129,71 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) { of them is a dead screen. */}
{wall.above > 0 ?
-
- {photo.name} - - {photo.star ? `★${photo.star} · ` : ''} - {new Date(photo.taken).toLocaleDateString()} · {t('lib.size', { mb: (photo.size / 1048576).toFixed(1) })} - {edited.has(photo.id) ? ` · ${t('lib.edited')}` : ''} - -
- + ))} {wall.below > 0 ?