perf: round 7 — photos timeline O(N²)→incremental, range-seek authz duplication, resources row-map clone

Benchmark-gated (equivalence + BEFORE/AFTER; results + reproduce commands in
benches/ROUND7.md):

- Photos timeline re-grouped + re-laid-out the whole accumulated library on
  every 60-item page (both `groups` and `photoRows` were $derived over the
  full list), Σ ≈ O(N²/60) main-thread work during a scroll. Pages arrive
  newest-first so grouping is append-only: the new PhotoTimeline
  (lib/utils/photoTimeline.ts) re-buckets only the fresh page and re-lays-out
  only changed groups, reusing untouched groups' cached rows, falling back to
  a full rebuild on any config/deletion/non-append change. The pure
  buildPhotoRows is the verbatim reference the gate holds it equal to at every
  page. 50×60 drain: 76 500 → 3 000 grouping ops (25.5x), 23.0 → 2.2 ms
  (10.6x).

- Range downloads paid authz + access-notify twice: download_file_impl
  resolves the file via get_file_with_perms, then the Range branch re-ran
  require_file + notify_file_accessed per request. Media/PDF viewers fetch
  exclusively via Range (one request per seek), so every seek in a scrub
  re-authorized an already-cleared file. Now routed through the non-perms
  get_file_range_preloaded (matching the share-landing + WebDAV range paths);
  the unused _with_perms range method is removed. The request-level gate still
  denies before the branch runs (bench asserts member granted, outsider
  denied). Per seek removed: WARM 0.67 µs, COLD 1362.66 µs — a grant-cascade
  drive-resolve query per seek for a shared-drive recipient on a cold cache.

- /api/folders/{id}/resources row→DTO mapping cloned row.name into the DTO
  though the row is owned; folders move it (fixed icons), files compute the
  name-derived icon/category classes first then move it. 500-row page:
  10.004 → 9.004 allocs/row (500 clones removed), output identical.

Deferred with rationale in ROUND7.md: thumbnail ACL-before-304 (security
posture — needs a security review, not a perf tweak), batch_operations
Arc<str>→String widening, list-view O(N²) on smaller lists, and the serial→
join! pairs (decide-by-bench with injected latency, per the round-6 rejection).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aJu9ghvuT8WqC31ZEGTBA
This commit is contained in:
Claude
2026-07-18 13:11:41 +00:00
parent f53fba42a1
commit 7626dc95c1
10 changed files with 1228 additions and 168 deletions
@@ -0,0 +1,163 @@
import { describe, expect, it } from 'vitest';
import type { PhotoItem } from '$lib/api/endpoints/photos';
import {
PhotoTimeline,
buildPhotoRows,
type GroupMode,
type LayoutMode,
type TimelineConfig
} from './photoTimeline';
/**
* Benchmark gate for the incremental photo timeline (PhotoTimeline) that
* replaced the photos view's `groups`→`photoRows` derive chain.
*
* Audit finding: `loadMore` does `items = [...items, ...page]` (60/page), and
* both `groups` (O(N), a `new Date()` per photo) and `photoRows` (O(N) row
* layout) are `$derived` over the whole accumulated list — so paging to photo
* N re-groups + re-lays-out everything loaded so far, Σ ≈ O(N²/60) main-thread
* work during the scroll (the same class ROUND6 fixed for the files listing).
* Since pages arrive newest-first, grouping is append-only; PhotoTimeline
* re-buckets only the fresh page and re-lays-out only the groups that changed.
*
* Gates:
* 1. Equivalence — at EVERY page of the drain, the incremental output is
* deep-equal to the verbatim full-rebuild reference (buildPhotoRows), for
* both layouts; plus config-change, deletion and width=0 fall back to a
* correct full rebuild.
* 2. Perf — grouping work (timestamp reads) collapses from Σ O(N²/60) to O(N)
* across the drain (deterministic count), and wall drops ≥3x.
*/
const DAY = 86_400; // seconds
/** A photo with a descending sort_date and a deterministic aspect ratio. */
function photo(i: number): PhotoItem {
// Newest-first: photo 0 is most recent; ~half a day apart spans ~4 years
// over 3k photos, so month/day buckets are bounded (realistic library).
const sortDate = 1_700_000_000 - i * (DAY / 2);
const w = 200 + ((i * 37) % 400);
const h = 200 + ((i * 53) % 300);
return {
category: 'image',
created_at: sortDate,
icon_class: '',
icon_special_class: '',
id: `p-${i.toString().padStart(6, '0')}`,
mime_type: 'image/jpeg',
modified_at: sortDate,
name: `photo ${i}.jpg`,
created_by: null,
updated_by: null,
folder_id: 'f',
path: `/photo ${i}.jpg`,
size: 1000,
size_formatted: '1 KB',
sort_date: sortDate,
etag: `e${i}`,
content_hash: `h${i}`,
width: w,
height: h
} as PhotoItem;
}
/** Instrumented config: counts every timestamp read (the grouping hot op). */
function makeConfig(
groupMode: GroupMode,
layoutMode: LayoutMode,
width: number,
counter?: { n: number }
): TimelineConfig {
const timestampOf = (p: PhotoItem) => {
if (counter) counter.n++;
const v = p.sort_date || p.created_at || 0;
return v < 1e12 ? v * 1000 : v;
};
// Stable label fn (reference identity matters for the config-unchanged path).
const labelOf = (d: Date, mode: GroupMode) =>
mode === 'year'
? `${d.getFullYear()}`
: mode === 'month'
? `${d.getFullYear()}-${d.getMonth() + 1}`
: `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`;
return { groupMode, layoutMode, width, mobile: false, timestampOf, labelOf };
}
const PAGE = 60;
const PAGES = 50; // 3 000-photo drain
const WIDTH = 1200;
describe('incremental photo timeline (benchmark gate)', () => {
for (const layout of ['square', 'justified'] as LayoutMode[]) {
it(`stays deep-equal to the full rebuild at every page — ${layout}`, () => {
const all = Array.from({ length: PAGE * PAGES }, (_, i) => photo(i));
const cfg = makeConfig('month', layout, WIDTH);
const timeline = new PhotoTimeline();
for (let p = 1; p <= PAGES; p++) {
const cumulative = all.slice(0, p * PAGE);
const incremental = timeline.sync(cumulative, cfg);
const reference = buildPhotoRows(cumulative, cfg);
expect(incremental, `page ${p}`).toEqual(reference);
}
});
}
it('falls back to a correct full rebuild on config change, deletion and width=0', () => {
const all = Array.from({ length: 600 }, (_, i) => photo(i));
const timeline = new PhotoTimeline();
const monthSquare = makeConfig('month', 'square', WIDTH);
// Drain a few pages, then flip layout — must equal a fresh full rebuild.
timeline.sync(all.slice(0, 300), monthSquare);
const justified = makeConfig('month', 'justified', WIDTH);
expect(timeline.sync(all.slice(0, 300), justified)).toEqual(
buildPhotoRows(all.slice(0, 300), justified)
);
// Change group mode.
const yearJust = makeConfig('year', 'justified', WIDTH);
expect(timeline.sync(all.slice(0, 300), yearJust)).toEqual(
buildPhotoRows(all.slice(0, 300), yearJust)
);
// Deletion (list shrinks / prefix changes) → rebuild.
const shrunk = all.slice(0, 300).filter((_, i) => i % 7 !== 0);
expect(timeline.sync(shrunk, yearJust)).toEqual(buildPhotoRows(shrunk, yearJust));
// width=0 yields [] and doesn't wedge the next positive-width sync.
const zero = makeConfig('year', 'justified', 0);
expect(timeline.sync(shrunk, zero)).toEqual([]);
expect(timeline.sync(shrunk, yearJust)).toEqual(buildPhotoRows(shrunk, yearJust));
});
it('collapses grouping work from Σ O(N²/page) to O(N) and runs ≥3x faster', () => {
const N = PAGE * PAGES;
const all = Array.from({ length: N }, (_, i) => photo(i));
// AFTER: incremental — each photo is bucketed exactly once across the drain.
const afterCounter = { n: 0 };
const afterCfg = makeConfig('month', 'square', WIDTH, afterCounter);
const timeline = new PhotoTimeline();
const t1 = performance.now();
for (let p = 1; p <= PAGES; p++) timeline.sync(all.slice(0, p * PAGE), afterCfg);
const afterMs = performance.now() - t1;
// BEFORE: full rebuild per page — re-buckets the whole cumulative list.
const beforeCounter = { n: 0 };
const beforeCfg = makeConfig('month', 'square', WIDTH, beforeCounter);
const t0 = performance.now();
for (let p = 1; p <= PAGES; p++) buildPhotoRows(all.slice(0, p * PAGE), beforeCfg);
const beforeMs = performance.now() - t0;
console.info(
`photo timeline ${PAGES}×${PAGE}: before ${beforeCounter.n} timestamp reads / ${beforeMs.toFixed(1)} ms — after ${afterCounter.n} reads / ${afterMs.toFixed(1)} ms (${(beforeCounter.n / afterCounter.n).toFixed(1)}x fewer reads, ${(beforeMs / afterMs).toFixed(1)}x wall)`
);
// Incremental buckets each photo once: exactly N reads.
expect(afterCounter.n).toBe(N);
// Full rebuild is quadratic: Σ_{p=1..P} p·PAGE.
expect(beforeCounter.n).toBe((PAGES * (PAGES + 1) * PAGE) / 2);
expect(afterCounter.n).toBeLessThan(beforeCounter.n / 5);
expect(afterMs).toBeLessThan(beforeMs / 3);
});
});
+279
View File
@@ -0,0 +1,279 @@
/**
* Photo-timeline grouping + row layout, extracted from the photos view so the
* O(N²) accumulation of its `groups`/`photoRows` derives can be replaced with
* an incremental builder (and unit/benchmark-tested off the Svelte reactive
* graph).
*
* Photos arrive newest-first (`media_sort_date DESC`), so each fetched page
* only ever extends the last date bucket or appends new buckets after it —
* never mutates an earlier group. {@link PhotoTimeline} exploits that: an
* append re-buckets only the new page and recomputes rows only for the groups
* that actually changed, keeping a full scroll O(N) instead of O(N²).
*
* The pure {@link buildPhotoRows} is the verbatim reference (what the old
* `groups`→`photoRows` derive chain produced); the benchmark gate asserts the
* incremental builder stays byte-for-byte equal to it.
*/
import type { PhotoItem } from '$lib/api/endpoints/photos';
export type GroupMode = 'day' | 'month' | 'year';
export type LayoutMode = 'square' | 'justified';
export interface JustifiedTile {
file: PhotoItem;
w: number;
h: number;
}
export type PhotoRow =
| { kind: 'header'; key: string; height: number; label: string; count: number }
| { kind: 'tiles'; key: string; height: number; gap: number; tiles: JustifiedTile[] };
/** Layout constants — mirror the photos view's original values exactly. */
export const SQUARE_GAP = 4; // .25rem, matches the old grid gap
export const SQUARE_MIN = 144; // 9rem minmax floor
export const JUSTIFIED_GAP = 8; // .photos-jrow margin-bottom
export const HEADER_H = 44;
export interface TimelineConfig {
groupMode: GroupMode;
layoutMode: LayoutMode;
/** Usable content width of the grid, in px. */
width: number;
/** `(max-width: 768px)` — selects the 150px vs 200px justified target. */
mobile: boolean;
/** EXIF-aware capture timestamp (ms). Injected so the module stays pure. */
timestampOf: (p: PhotoItem) => number;
/** Locale-aware bucket label for a group's representative date. */
labelOf: (d: Date, mode: GroupMode) => string;
}
interface Group {
key: string;
label: string;
photos: PhotoItem[];
}
/** Year/month/day bucket key for a date under `groupMode` (verbatim). */
export function bucketKey(d: Date, groupMode: GroupMode): string {
const y = d.getFullYear();
if (groupMode === 'year') return `${y}`;
const m = `${d.getMonth() + 1}`.padStart(2, '0');
if (groupMode === 'month') return `${y}-${m}`;
return `${y}-${m}-${`${d.getDate()}`.padStart(2, '0')}`;
}
/**
* Pack files into justified rows (Flickr-style): each full row is scaled to
* fill `width` while preserving every tile's aspect ratio. Missing dimensions
* fall back to 1:1. Verbatim port of the photos view's `justifiedRows`, with
* the `matchMedia` read hoisted to the `mobile` flag so it's testable.
*/
export function justifiedRows(
files: PhotoItem[],
width: number,
mobile: boolean
): Array<{ height: number; tiles: JustifiedTile[] }> {
const gap = 8;
const target = mobile ? 150 : 200;
const rows: Array<{ height: number; tiles: JustifiedTile[] }> = [];
let cur: Array<{ file: PhotoItem; aspect: number }> = [];
let aspectSum = 0;
for (const file of files) {
let aspect = file.width && file.height ? file.width / file.height : 1;
if (!Number.isFinite(aspect) || aspect <= 0) aspect = 1;
aspect = Math.min(Math.max(aspect, 0.4), 3);
cur.push({ file, aspect });
aspectSum += aspect;
const rowWidth = aspectSum * target + (cur.length - 1) * gap;
if (rowWidth >= width) {
const h = (width - (cur.length - 1) * gap) / aspectSum;
rows.push({
height: Math.round(h),
tiles: cur.map((tt) => ({
file: tt.file,
w: Math.max(1, Math.round(tt.aspect * h)),
h: Math.round(h)
}))
});
cur = [];
aspectSum = 0;
}
}
if (cur.length) {
rows.push({
height: target,
tiles: cur.map((tt) => ({
file: tt.file,
w: Math.max(1, Math.round(tt.aspect * target)),
h: target
}))
});
}
return rows;
}
/** Columns + cell size for the square layout at width `W` (verbatim). */
function squareGeometry(W: number): { cols: number; cell: number } {
const cols = Math.max(1, Math.floor((W + SQUARE_GAP) / (SQUARE_MIN + SQUARE_GAP)));
const cell = (W - (cols - 1) * SQUARE_GAP) / cols;
return { cols, cell };
}
/** Flatten one group into its header + tile rows (verbatim per-group body). */
function groupToRows(g: Group, cfg: TimelineConfig, cols: number, cell: number): PhotoRow[] {
const rows: PhotoRow[] = [
{ kind: 'header', key: `h:${g.key}`, height: HEADER_H, label: g.label, count: g.photos.length }
];
if (cfg.layoutMode === 'justified') {
const jrows = justifiedRows(g.photos, cfg.width, cfg.mobile);
for (let ri = 0; ri < jrows.length; ri++) {
rows.push({
kind: 'tiles',
key: `${g.key}:j${ri}`,
height: jrows[ri].height + JUSTIFIED_GAP,
gap: JUSTIFIED_GAP,
tiles: jrows[ri].tiles
});
}
} else {
for (let i = 0; i < g.photos.length; i += cols) {
const tiles = g.photos.slice(i, i + cols).map((file) => ({ file, w: cell, h: cell }));
rows.push({
kind: 'tiles',
key: `${g.key}:s${i}`,
height: cell + SQUARE_GAP,
gap: SQUARE_GAP,
tiles
});
}
}
return rows;
}
/** Bucket `items` into date groups, first-appearance order (verbatim). */
function buildGroups(items: PhotoItem[], cfg: TimelineConfig): Group[] {
const out: Group[] = [];
const index = new Map<string, number>();
for (const p of items) {
const d = new Date(cfg.timestampOf(p));
const key = bucketKey(d, cfg.groupMode);
let i = index.get(key);
if (i === undefined) {
i = out.length;
index.set(key, i);
out.push({ key, label: cfg.labelOf(d, cfg.groupMode), photos: [] });
}
out[i].photos.push(p);
}
return out;
}
/**
* Verbatim reference: the flat `PhotoRow[]` the old `groups`→`photoRows`
* derive chain produced for `items` under `cfg`. Returns `[]` for a
* non-positive width, matching the old guard. The benchmark gate holds the
* incremental builder equal to this.
*/
export function buildPhotoRows(items: PhotoItem[], cfg: TimelineConfig): PhotoRow[] {
if (cfg.width <= 0) return [];
const { cols, cell } = squareGeometry(cfg.width);
const rows: PhotoRow[] = [];
for (const g of buildGroups(items, cfg)) {
rows.push(...groupToRows(g, cfg, cols, cell));
}
return rows;
}
function configEq(a: TimelineConfig, b: TimelineConfig): boolean {
return (
a.groupMode === b.groupMode &&
a.layoutMode === b.layoutMode &&
a.width === b.width &&
a.mobile === b.mobile &&
a.timestampOf === b.timestampOf &&
a.labelOf === b.labelOf
);
}
/**
* Incremental photo-timeline builder. Call {@link sync} with the current item
* list and config on every change; it detects the common case — the list grew
* by appending a page while config is unchanged — and re-buckets only the new
* items + re-lays-out only the groups that changed, reusing every untouched
* group's cached rows. Any other change (config, deletion, filter toggle,
* non-append) falls back to a full rebuild, so the result is always identical
* to {@link buildPhotoRows}.
*/
export class PhotoTimeline {
#cfg: TimelineConfig | null = null;
#groups: Group[] = [];
/** Items already bucketed — the append cursor into the last synced list. */
#groupedItems: PhotoItem[] = [];
/** group.key → its cached rows for the current config. */
#rowCache = new Map<string, PhotoRow[]>();
#geom = { cols: 1, cell: 0 };
/** Whether `next` extends `prev` (same prefix objects + strictly longer). */
#isAppend(prev: PhotoItem[], next: PhotoItem[]): boolean {
if (next.length <= prev.length) return false;
// Prefix identity via the boundary object — O(1), the list is only ever
// mutated by appending or by replacing with a filtered copy.
return prev.length === 0 || next[prev.length - 1] === prev[prev.length - 1];
}
#rebuild(items: PhotoItem[], cfg: TimelineConfig): void {
this.#cfg = cfg;
this.#groups = cfg.width > 0 ? buildGroups(items, cfg) : [];
this.#groupedItems = items;
this.#rowCache.clear();
this.#geom = squareGeometry(cfg.width);
}
#extend(items: PhotoItem[], cfg: TimelineConfig): void {
const fresh = items.slice(this.#groupedItems.length);
// The last existing group may grow, so its cached rows are stale.
if (this.#groups.length > 0) {
this.#rowCache.delete(this.#groups[this.#groups.length - 1].key);
}
for (const p of fresh) {
const d = new Date(cfg.timestampOf(p));
const key = bucketKey(d, cfg.groupMode);
const last = this.#groups[this.#groups.length - 1];
if (last && last.key === key) {
last.photos.push(p);
} else {
this.#groups.push({ key, label: cfg.labelOf(d, cfg.groupMode), photos: [p] });
}
}
this.#groupedItems = items;
}
sync(items: PhotoItem[], cfg: TimelineConfig): PhotoRow[] {
if (cfg.width <= 0) {
// Keep the item cursor so a later positive width rebuilds from scratch.
this.#cfg = cfg;
this.#groups = [];
this.#groupedItems = items;
this.#rowCache.clear();
return [];
}
if (this.#cfg && configEq(this.#cfg, cfg) && this.#isAppend(this.#groupedItems, items)) {
this.#extend(items, cfg);
} else {
this.#rebuild(items, cfg);
}
const { cols, cell } = this.#geom;
const out: PhotoRow[] = [];
for (const g of this.#groups) {
let rows = this.#rowCache.get(g.key);
if (rows === undefined) {
rows = groupToRows(g, cfg, cols, cell);
this.#rowCache.set(g.key, rows);
}
for (const r of rows) out.push(r);
}
return out;
}
}
+35 -139
View File
@@ -17,6 +17,12 @@
import { filterDotfiles } from '$lib/utils/dotfileFilter';
import { dateTimeFormatFor } from '$lib/utils/display';
import { isVideo, photoTimestamp } from '$lib/utils/media';
import {
PhotoTimeline,
type GroupMode,
type LayoutMode,
type PhotoRow
} from '$lib/utils/photoTimeline';
type Tab = 'moments' | 'places' | 'people';
let tab = $state<Tab>('moments');
@@ -49,8 +55,6 @@
/** Usable content width of the grid, for the justified layout. */
let gridWidth = $state(0);
type GroupMode = 'day' | 'month' | 'year';
type LayoutMode = 'square' | 'justified';
const GROUP_KEY = 'oxi-photos-group';
const LAYOUT_KEY = 'oxi-photos-layout';
let groupMode = $state<GroupMode>('month');
@@ -64,18 +68,10 @@
else if (tab === 'people') void peopleView.load();
});
/** EXIF-aware timestamp (seconds → ms), matching the OLD grouping logic. */
function bucketKey(d: Date): string {
const y = d.getFullYear();
if (groupMode === 'year') return `${y}`;
const m = `${d.getMonth() + 1}`.padStart(2, '0');
if (groupMode === 'month') return `${y}-${m}`;
return `${y}-${m}-${`${d.getDate()}`.padStart(2, '0')}`;
}
function bucketLabel(d: Date): string {
if (groupMode === 'year') return `${d.getFullYear()}`;
if (groupMode === 'month')
/** Locale-aware label for a bucket's representative date. */
function bucketLabel(d: Date, mode: GroupMode): string {
if (mode === 'year') return `${d.getFullYear()}`;
if (mode === 'month')
return dateTimeFormatFor(undefined, { year: 'numeric', month: 'long' }).format(d);
return dateTimeFormatFor(undefined, {
weekday: 'long',
@@ -85,132 +81,32 @@
}).format(d);
}
const groups = $derived.by(() => {
const out: Array<{ key: string; label: string; photos: PhotoItem[] }> = [];
// Transient scratch map built inside $derived.by and discarded — not reactive state.
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const index = new Map<string, number>();
for (const p of visibleItems) {
const d = new Date(photoTimestamp(p));
const key = bucketKey(d);
let i = index.get(key);
if (i === undefined) {
i = out.length;
index.set(key, i);
out.push({ key, label: bucketLabel(d), photos: [] });
}
out[i].photos.push(p);
}
return out;
});
interface JustifiedTile {
file: PhotoItem;
w: number;
h: number;
}
/**
* Pack files into justified rows (Flickr-style): each full row is scaled to
* fill `width` while preserving every tile's aspect ratio. Missing dimensions
* fall back to 1:1.
*/
function justifiedRows(
files: PhotoItem[],
width: number
): Array<{ height: number; tiles: JustifiedTile[] }> {
const gap = 8;
const target = window.matchMedia('(max-width: 768px)').matches ? 150 : 200;
const rows: Array<{ height: number; tiles: JustifiedTile[] }> = [];
let cur: Array<{ file: PhotoItem; aspect: number }> = [];
let aspectSum = 0;
for (const file of files) {
let aspect = file.width && file.height ? file.width / file.height : 1;
if (!Number.isFinite(aspect) || aspect <= 0) aspect = 1;
aspect = Math.min(Math.max(aspect, 0.4), 3);
cur.push({ file, aspect });
aspectSum += aspect;
const rowWidth = aspectSum * target + (cur.length - 1) * gap;
if (rowWidth >= width) {
const h = (width - (cur.length - 1) * gap) / aspectSum;
rows.push({
height: Math.round(h),
tiles: cur.map((tt) => ({
file: tt.file,
w: Math.max(1, Math.round(tt.aspect * h)),
h: Math.round(h)
}))
});
cur = [];
aspectSum = 0;
}
}
if (cur.length) {
rows.push({
height: target,
tiles: cur.map((tt) => ({
file: tt.file,
w: Math.max(1, Math.round(tt.aspect * target)),
h: target
}))
});
}
return rows;
}
// ── Virtualized row model ────────────────────────────────────────────────
// Flatten the groups into a single list of fixed-height rows (a date header
// or a strip of sized tiles), so VirtualRows can window the whole timeline —
// only the rows near the viewport are mounted, regardless of library size.
const SQUARE_GAP = 4; // .25rem, matches the old grid gap
const SQUARE_MIN = 144; // 9rem minmax floor
const JUSTIFIED_GAP = 8; // .photos-jrow margin-bottom
const HEADER_H = 44;
type PhotoRow =
| { kind: 'header'; key: string; height: number; label: string; count: number }
| { kind: 'tiles'; key: string; height: number; gap: number; tiles: JustifiedTile[] };
const photoRows = $derived.by<PhotoRow[]>(() => {
const W = gridWidth;
if (W <= 0) return [];
const rows: PhotoRow[] = [];
const cols = Math.max(1, Math.floor((W + SQUARE_GAP) / (SQUARE_MIN + SQUARE_GAP)));
const cell = (W - (cols - 1) * SQUARE_GAP) / cols;
for (const g of groups) {
rows.push({
kind: 'header',
key: `h:${g.key}`,
height: HEADER_H,
label: g.label,
count: g.photos.length
});
if (layoutMode === 'justified') {
const jrows = justifiedRows(g.photos, W);
for (let ri = 0; ri < jrows.length; ri++) {
rows.push({
kind: 'tiles',
key: `${g.key}:j${ri}`,
height: jrows[ri].height + JUSTIFIED_GAP,
gap: JUSTIFIED_GAP,
tiles: jrows[ri].tiles
});
}
} else {
for (let i = 0; i < g.photos.length; i += cols) {
const tiles = g.photos.slice(i, i + cols).map((file) => ({ file, w: cell, h: cell }));
rows.push({
kind: 'tiles',
key: `${g.key}:s${i}`,
height: cell + SQUARE_GAP,
gap: SQUARE_GAP,
tiles
});
}
}
}
return rows;
});
// Flatten the date groups into a single list of fixed-height rows (a header
// or a strip of sized tiles) that VirtualRows windows. Because pages arrive
// newest-first, each append only extends the last group or adds new ones, so
// PhotoTimeline re-buckets only the fresh page and re-lays-out only the
// groups that changed — a full scroll stays O(N), not O(N²) (the old
// `groups`→`photoRows` derive chain re-grouped + re-packed the whole library
// on every 60-item page). See photoGrouping.bench.test.ts.
// `sync` mutates the timeline's (non-reactive) internal group/row caches and
// returns the flat rows. Driven from `$derived.by` for idempotence: if the
// deps re-fire without an actual append, `sync` sees a non-growing list and
// safely full-rebuilds — same output as the pure `buildPhotoRows`.
const timeline = new PhotoTimeline();
const photoRows = $derived.by<PhotoRow[]>(() =>
timeline.sync(visibleItems, {
groupMode,
layoutMode,
width: gridWidth,
mobile:
typeof window !== 'undefined' &&
typeof window.matchMedia === 'function' &&
window.matchMedia('(max-width: 768px)').matches,
timestampOf: photoTimestamp,
labelOf: bucketLabel
})
);
async function loadMore() {
if (loading || exhausted) return;