feat(library): read the wall's time by the year and the month, and mint only the frames near the eye

This commit is contained in:
2026-10-13 09:54:47 +07:00
parent 5d9b4c3058
commit ddbde7d426
4 changed files with 244 additions and 99 deletions
+114 -57
View File
@@ -5,8 +5,13 @@
// node holds is a card (the wall is drawn whole — no window over it, so no card
// arriving late and no spacer standing in for one), every card sits under the band
// of its own day, the last frame is reachable by scrolling with nothing left under
// it, and the rail down the wall's right edge — the shelf's own time — reads the
// day the eye is in and takes the wall to the day it is pressed for.
// it, and the rail down the wall's right edge — the shelf's own time, by the year
// and the month and nothing finer — scrolls under the reader's hand, reads the month
// the eye is in and takes the wall to the month or the year it is pressed for.
//
// The wall is also the one screen that reads the whole shelf, so what it mints is
// watched here too: a roll of years is a rail of months, and a rail whose every entry
// is in the box it is read through.
//
// It is drawn whole for a reason this check keeps an eye on: the wall used to be a
// window over the shelf, and a window measured off the window before it cost a
@@ -31,8 +36,8 @@ const APP_PORT = Number(process.env.APP_PORT ?? 5033);
const SAMPLE_PORT = Number(process.env.SAMPLE_PORT ?? 5034);
const FRAMES = Number(process.env.FRAMES ?? 300);
const FOLDER = 'WallRoll';
// The second roll: a shelf of one month, which the rail reads by the day rather
// than by the month — the other half of what the rail is (`rail` in Library.tsx),
// The second roll: a shelf of a single month, which the rail reads with one head and
// one month under it — the other half of what the rail is (`rail` in Library.tsx),
// and the shape a folder per event has. Its frames are two days of twenty.
const SMALL_FOLDER = 'OneDayRoll';
const SMALL_EACH = 20;
@@ -44,7 +49,11 @@ const SMALL_DAYS = 2;
// 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 DAY0 = Date.UTC(2026, 0, 1);
// The roll is spread over years, a shooting day every forty days or so: a shelf of
// months is what the rail is for, and a rail that holds three of them is a rail that
// never has to be scrolled — the years are what make it longer than the wall is tall.
const DAY_SPREAD = 40;
const DAY0 = Date.UTC(2018, 0, 1);
// The days the roll was shot on, and they are not equal: a roll is an afternoon
// of three frames and a wedding of four hundred, and the wall's own model of its
// rows is the thing that has to survive that. A shelf of equal days is a shelf a
@@ -73,7 +82,7 @@ const dayIndex = (n) => {
}
return 0;
};
const dayOfName = (name) => DAY0 + dayIndex(Number((name.match(/\d+/) ?? ['1'])[0]) - 1) * DAY_MS;
const dayOfName = (name) => DAY0 + dayIndex(Number((name.match(/\d+/) ?? ['1'])[0]) - 1) * DAY_SPREAD * DAY_MS;
// The second roll's own names and days: `one-0001` onwards, twenty to a day, over
// the fourth and fifth of March 2026 — one month, two days, which is the shelf the
@@ -100,7 +109,9 @@ const keyOf = (ts) => {
const d = new Date(ts);
return `${monthOf(ts)}-${String(d.getDate()).padStart(2, '0')}`;
};
const yearOf = (ts) => String(new Date(ts).getFullYear());
const MONTHS = [...new Set(names.map((name) => monthOf(dayOf(name))))];
const YEARS = [...new Set(names.map((name) => yearOf(dayOf(name))))];
const SMALL_DAY_KEYS = [...new Set(smallNames.map((name) => keyOf(dayOf(name))))];
// The days the big roll is cut into, which is what the wall draws bands for.
const BIG_DAYS = (() => {
@@ -359,7 +370,7 @@ step('every header names the day of the cards under it', days1.bad.length === 0,
// What the rail is read for: the entries it holds, the entry the eye is in, and where
// a press on one takes the wall. The day at the fold is read off the sections
// themselves — the first one crossing the wall's top band, which is the rule the rail
// itself reads by — and the entry the wall says it is in is read off the accent.
// itself reads by — and the month the wall says it is in is read off the accent.
const railOf = () =>
page.evaluate(() => {
const grid = document.querySelector('.adm-grid');
@@ -377,16 +388,23 @@ const railOf = () =>
}
const on = document.querySelector('.lib-rail-item.on');
return {
kinds: items.map((b) => b.getAttribute('data-kind') ?? ''),
keys: items.map((b) => (b.getAttribute('data-key') ?? '').replace('lib-rail-', '')),
// The day a press on the entry sends the wall to, which the entry carries.
ats: items.map((b) => b.getAttribute('data-at') ?? ''),
labels: items.map((b) => b.textContent.trim()),
onKind: on?.getAttribute('data-kind') ?? null,
on: on ? (on.getAttribute('data-key') ?? '').replace('lib-rail-', '') : null,
fold,
scroll: Math.round(rail?.scrollTop ?? 0),
tops: items.map((b) => b.offsetTop),
heights: items.map((b) => b.offsetHeight),
box: rail?.clientHeight ?? 0,
whole: rail?.scrollHeight ?? 0,
};
});
const monthsOf = (rail) => rail.keys.filter((_, i) => rail.kinds[i] === 'month');
const yearsOf = (rail) => rail.keys.filter((_, i) => rail.kinds[i] === 'year');
const stops = [0.15, 0.35, 0.55, 0.75, 0.95];
const seenMonths = new Set();
let scrolledBad = [];
@@ -401,13 +419,13 @@ for (const at of stops) {
const audit = await dayAudit();
scrolledBad = scrolledBad.concat(audit.bad);
const rail = await railOf();
// The entry the rail says the eye is in has to own the day at the fold — the month
// its key begins with — and the entry has to be inside the rail's own box: a rail
// read for the whole shelf and standing on its own top is a rail that says nothing.
if (!rail.fold || !rail.on || rail.fold.slice(0, 7) !== rail.on.slice(0, 7)) {
railBad.push({ at, fold: rail.fold, on: rail.on });
// The month the rail says the eye is in has to own the day at the fold, and the
// entry has to be inside the rail's own box: a rail read for the whole shelf and
// standing on its own top is a rail that says nothing.
if (!rail.fold || !rail.on || rail.onKind !== 'month' || rail.fold.slice(0, 7) !== rail.on.slice(0, 7)) {
railBad.push({ at, fold: rail.fold, on: rail.on, kind: rail.onKind });
} else {
seenMonths.add(rail.on.slice(0, 7));
seenMonths.add(rail.on);
const i = rail.keys.indexOf(rail.on);
const y = (rail.tops[i] ?? 0) - rail.scroll;
if (y < -2 || y + (rail.heights[i] ?? 0) > rail.box + 2) railBad.push({ at, on: rail.on, y, box: rail.box });
@@ -419,33 +437,72 @@ step(
JSON.stringify(scrolledBad.slice(0, 2))
);
const rail1 = await railOf();
const railMonths = [...new Set(rail1.keys.map((k) => k.slice(0, 7)))].sort();
step('the rail holds the months of the shelf', railMonths.join() === [...MONTHS].sort().join(), `${rail1.keys.join(' ')}`);
const months1 = monthsOf(rail1);
// The rail carries the shelf's own order — the reader's sort, months down the wall as
// the wall holds them — so what is compared here is the set of months, not the order
// twice over.
step(
'the rail holds the shelf by the year and the month, and by nothing finer',
[...months1].sort().join() === [...MONTHS].sort().join() &&
[...yearsOf(rail1)].sort().join() === [...YEARS].sort().join() &&
rail1.kinds.every((k) => k === 'year' || k === 'month'),
`${yearsOf(rail1).length} years, ${months1.length} months of ${MONTHS.length}`
);
step(
'and the entry it reads follows the eye down the shelf',
railBad.length === 0 && seenMonths.size === MONTHS.length,
railBad.length === 0 && seenMonths.size >= 3,
`${seenMonths.size} of ${MONTHS.length} months read, ${JSON.stringify(railBad.slice(0, 2))}`
);
// And the press: the month's own first day is where the wall has to stand, its header
// just under the wall's top edge, and the entry pressed is the one read afterwards.
const jumped = rail1.keys[1];
await page.click(`[data-key="lib-rail-${jumped}"]`);
await page.waitForTimeout(700);
const landed = await page.evaluate((key) => {
const grid = document.querySelector('.adm-grid');
const section = grid.querySelector(`[data-key="lib-day-${key}"]`);
const box = grid.getBoundingClientRect();
const on = document.querySelector('.lib-rail-item.on');
return {
top: section ? Math.round(section.getBoundingClientRect().top - box.top) : null,
on: on ? (on.getAttribute('data-key') ?? '').replace('lib-rail-', '') : null,
};
}, jumped);
step(
'a press on a month takes the wall to that day',
landed.top !== null && Math.abs(landed.top - 6) <= 6 && landed.on === jumped,
`header at ${landed.top}, entry ${landed.on}`
'the rail is longer than the wall is tall, and scrolls under the reader',
rail1.whole > rail1.box,
`${rail1.whole}px of rail in ${rail1.box}px of box`
);
// And the press: the shelf's own first day of the month is where the wall has to
// stand, its header just under the wall's top edge, the entry pressed is the month
// read afterwards, and the rail brings that entry into its own box.
const press = async (key) => {
await page.click(`[data-key="lib-rail-${key}"]`);
await page.waitForTimeout(700);
return page.evaluate((k) => {
const grid = document.querySelector('.adm-grid');
const rail = document.querySelector('.lib-rail');
const button = document.querySelector(`[data-key="lib-rail-${k}"]`);
const at = button?.getAttribute('data-at') ?? null;
const section = at ? grid.querySelector(`[data-key="lib-day-${at}"]`) : null;
const on = document.querySelector('.lib-rail-item.on');
const y = button ? button.offsetTop - rail.scrollTop : null;
return {
at,
top: section ? Math.round(section.getBoundingClientRect().top - grid.getBoundingClientRect().top) : null,
cards: section ? section.querySelectorAll('.adm-card').length : 0,
on: on ? (on.getAttribute('data-key') ?? '').replace('lib-rail-', '') : null,
onKind: on?.getAttribute('data-kind') ?? null,
seen: y !== null && y >= -2 && y + (button?.offsetHeight ?? 0) <= rail.clientHeight + 2,
};
}, key);
};
const jumped = months1[Math.floor(months1.length / 2)];
const landed = await press(jumped);
step(
'a press on a month stands the wall on that month, and the rail on its entry',
landed.top !== null &&
Math.abs(landed.top - 6) <= 6 &&
landed.on === jumped &&
landed.onKind === 'month' &&
landed.seen,
`header at ${landed.top} of ${landed.at}, entry ${landed.on}, in rail ${landed.seen}`
);
// The year is a head and not a month of the shelf: a press on it stands the wall on
// the first month the shelf holds of that year, and the eye then reads that month.
const yearRow = yearsOf(rail1)[1];
const overYear = await press(yearRow);
step(
'a press on a year stands the wall on the first month it holds',
overYear.top !== null && Math.abs(overYear.top - 6) <= 6 && overYear.at?.slice(0, 4) === yearRow && overYear.on === overYear.at?.slice(0, 7),
`year ${yearRow}: header at ${overYear.top} of ${overYear.at}, entry ${overYear.on}`
);
// The shelf walked to its own end, a screen at a time — a jump straight to the bottom
@@ -483,6 +540,21 @@ const walked = await page.evaluate(async () => {
step('the shelf is walked to its own end', walked.reached, `stopped at ${walked.top} of ${walked.room}`);
step('and stands on nothing there', walked.gap !== null && walked.gap <= 16, `${walked.gap}px of nothing under the last card`);
// What the wall mints, read at the far end of the shelf: the tiles are the frames of
// the days around the eye, and a shelf walked end to end is not a shelf of tiles held
// end to end. A frame the wall holds no tile for draws nothing at all — a folder off
// the disk is a file only this page can open — so the count of blob-backed images is
// the count of tiles the wall is holding.
const held = await page.evaluate(() => ({
cards: document.querySelectorAll('.adm-grid .adm-card').length,
tiles: document.querySelectorAll('.adm-grid img[src^="blob:"]').length,
}));
step(
'the wall mints the tiles around the eye and not the whole shelf',
held.tiles > 0 && held.tiles < held.cards / 2,
`${held.tiles} tiles held of ${held.cards} cards`
);
// A wall resized under its own eye: the class of input that cost the screen once, when
// the wall measured itself off itself. It re-columns, the rail re-reads, and the reader
// is still standing in the library after it.
@@ -518,32 +590,17 @@ await page.waitForFunction(
await page.click('[data-key="lib-view-grid"]');
await page.waitForTimeout(1500);
const rail3 = await railOf();
const smallMonth = SMALL_DAY_KEYS[0].slice(0, 7);
step(
'a shelf of one month is read by the day',
[...rail3.keys].sort().join() === [...SMALL_DAY_KEYS].sort().join() && rail3.keys.length === SMALL_DAY_KEYS.length,
'a shelf of one month is still read by the year and the month',
rail3.kinds.join() === 'year,month' && yearsOf(rail3).length === 1 && monthsOf(rail3).join() === smallMonth,
`${rail3.labels.join(' ')} / ${rail3.keys.join(' ')}`
);
const plainDay = SMALL_DAY_KEYS[0];
await page.click(`[data-key="lib-rail-${plainDay}"]`);
await page.waitForTimeout(700);
const small = await page.evaluate((key) => {
const grid = document.querySelector('.adm-grid');
const section = grid.querySelector(`[data-key="lib-day-${key}"]`);
const box = grid.getBoundingClientRect();
const on = document.querySelector('.lib-rail-item.on');
return {
top: section ? Math.round(section.getBoundingClientRect().top - box.top) : null,
cards: section ? section.querySelectorAll('.adm-card').length : 0,
on: on?.textContent.trim() ?? null,
};
}, plainDay);
const overMonth = await press(smallMonth);
step(
'a press on a day takes the wall to it, whole',
small.top !== null &&
Math.abs(small.top - 6) <= 6 &&
small.cards === SMALL_EACH &&
small.on === String(Number(plainDay.slice(8, 10))),
`header at ${small.top}, ${small.cards} cards, entry ${small.on}`
'and a press on it stands the wall on that month, whole',
overMonth.top !== null && Math.abs(overMonth.top - 6) <= 6 && overMonth.cards === SMALL_EACH && overMonth.on === smallMonth,
`header at ${overMonth.top}, ${overMonth.cards} cards, entry ${overMonth.on}`
);
await page.screenshot({ path: process.env.SHOT ?? '/tmp/wall-check.png' }).catch(() => {});
+96 -31
View File
@@ -221,6 +221,10 @@ const WALL_LANES = 2;
// folder opening is the reader waiting on tiles for frames three screens below
// the one they are looking at.
const THUMB_AHEAD = 60;
// What the wall mints before it has been looked at: the head of the shelf, which is
// the order it drew the days in. A first paint with no tiles at all is a rack of
// empty cards, and the first look lands a frame later.
const THUMB_HEAD = 120;
// The hours a frame's own clock can read, and what the time filter offers: the
// whole day in the 24-hour form a camera writes into EXIF.
const HOURS = Array.from({ length: 24 }, (_, i) => i);
@@ -345,13 +349,14 @@ export function Library() {
}
import { useIsMobile } from './ui/useIsMobile';
import { dayLabel, dayRuns, monthKey, monthLabel } from './ui/dateGroups';
import { dayLabel, dayRuns, monthKey, monthLabel, yearKey } from './ui/dateGroups';
// One entry of the rail down the wall's right edge. `key` is what the entry
// stands for — a month (`2026-10`) or a day (`2026-10-04`) — and `at` is the day
// key the wall is sent to when it is pressed, which is the first day of the
// month for a month's entry.
type RailMark = { key: string; at: string; label: string; title: string };
// One entry of the rail down the wall's right edge: a year (`2026`) with the
// months under it (`2026-10`). `at` is the day key the wall is sent to when the
// entry is pressed — the shelf's own first day of that month, and for a year the
// first day of its first month on the shelf. The rail names the time by the year
// and the month and stops there: a day is what the wall's own bands are for.
type RailMark = { kind: 'year' | 'month'; key: string; at: string; label: string; title: string };
function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
const { t, lang } = useI18n();
@@ -2045,22 +2050,31 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
// frame is the grid's, since the grid is the whole shelf.
const gridRef = useRef<HTMLDivElement>(null);
// The rail down the wall's right edge: the shelf's time, one entry a month, or a
// day each when the shelf has not a month's worth of months. It is cut off the
// same runs the wall is drawn in, and an entry is looked up by the day key it
// stands for, so an entry and the header it jumps to are one cut of the shelf and
// not two.
// The rail down the wall's right edge: the shelf's own time, a year and the
// months under it — and nothing finer, since the day a frame belongs to is what
// the wall's bands already say. It is cut off the same runs the wall is drawn in,
// so an entry and the header it jumps to are one cut of the shelf, not two.
const rail = useMemo(() => {
const months: RailMark[] = [];
const each: RailMark[] = [];
const all: RailMark[] = [];
let year = '';
let month = '';
for (const day of days) {
const month = monthKey(day.at);
if (months[months.length - 1]?.key !== month) {
months.push({ key: month, at: day.key, label: monthLabel(day.at, lang), title: dayLabel(day.at, lang) });
const key = monthKey(day.at);
const yr = yearKey(day.at);
// The year stands over its months, and the first day the shelf holds of it
// is where a press on the year lands.
if (yr !== year) {
year = yr;
month = '';
all.push({ kind: 'year', key: yr, at: day.key, label: yr, title: yr });
}
if (key !== month) {
month = key;
const label = monthLabel(day.at, lang);
all.push({ kind: 'month', key, at: day.key, label, title: `${label} ${year}` });
}
each.push({ key: day.key, at: day.key, label: String(new Date(day.at).getDate()), title: dayLabel(day.at, lang) });
}
return months.length > 1 ? months : each;
return all;
}, [days, lang]);
const [railAt, setRailAt] = useState(0);
const railRef = useRef<HTMLElement>(null);
@@ -2093,7 +2107,11 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
}
if (best < 0) return;
const key = (nodes[best].dataset.key ?? '').replace('lib-day-', '');
const found = rail.findIndex((mark) => key.startsWith(mark.key));
// The month the day belongs to is the entry, not the year over it: the
// year is a head, and a head lit for every day of it says less than the
// month does.
let found = rail.findIndex((mark) => mark.kind === 'month' && mark.key === key.slice(0, 7));
if (found < 0) found = rail.findIndex((mark) => mark.kind === 'year' && mark.key === key.slice(0, 4));
setRailAt(found < 0 ? 0 : found);
},
{ root: el, rootMargin: '0px 0px -88% 0px' }
@@ -2142,7 +2160,49 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
}),
[]
);
const urls = useThumbUrls(view === 'preview' ? drawn : listed, frame, tilesAt);
// Which days the eye is near, and the only ones whose tiles are read. A shelf of
// twenty thousand frames is twenty thousand files to open and decode; held all at
// once that is the memory of the machine, and the wall ever shows a screen of it.
// Nothing here is measured — the browser says which day bands its own box holds,
// a screen above and a screen below, and leaves the rest alone.
const [seenDays, setSeenDays] = useState<ReadonlySet<string>>(() => new Set());
useEffect(() => {
if (view !== 'grid') return;
const el = gridRef.current;
if (!el) return;
const io = new IntersectionObserver(
(entries) => {
setSeenDays((prev) => {
const next = new Set(prev);
let touched = false;
for (const entry of entries) {
const key = (entry.target as HTMLElement).dataset.key ?? '';
if (entry.isIntersecting) {
if (!next.has(key)) {
next.add(key);
touched = true;
}
} else if (next.delete(key)) {
touched = true;
}
}
return touched ? next : prev;
});
},
{ root: el, rootMargin: '100% 0px 100% 0px' }
);
for (const node of el.querySelectorAll('.adm-day')) io.observe(node);
return () => io.disconnect();
}, [days, view]);
// What the wall mints for: the frames of the days around the eye, and the head of
// the shelf until the first look has landed.
const nearby = useMemo(() => {
if (view !== 'grid') return drawn;
const seen = days.filter((day) => seenDays.has(`lib-day-${day.key}`));
return seen.length ? seen.flatMap((day) => day.photos) : listed.slice(0, THUMB_HEAD);
}, [view, days, seenDays, listed, drawn]);
const urls = useThumbUrls(nearby, frame, tilesAt);
// Auto-scroll & focus tree sidebar to the active node selected in last session
const treeRef = useRef<HTMLElement>(null);
@@ -3265,18 +3325,22 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
</section>
))}
</div>
{/* The rail beside the wall: the shelf's own time, one
press away from any month of it, and the entry the eye
is in said in the accent. It is read off the days the
wall was cut into (`rail`), so an entry and the header
over the frames it names are one day and not two. */}
{/* The rail beside the wall: the shelf's own time by the
year and the month, one press away from any of them,
and the month the eye is in said in the accent. It
scrolls on its own where the shelf is years long, and
it is read off the days the wall was cut into (`rail`),
so an entry and the header over the frames it names are
one cut of the shelf and not two. */}
<nav className="lib-rail" data-key="lib-rail" ref={railRef} aria-label={t('lib.rail')}>
{rail.map((mark, i) => (
<button
key={mark.at}
key={`${mark.kind}-${mark.key}`}
type="button"
className={`lib-rail-item${i === railAt ? ' on' : ''}`}
data-key={`lib-rail-${mark.at}`}
className={`lib-rail-item lib-rail-${mark.kind}${i === railAt ? ' on' : ''}`}
data-key={`lib-rail-${mark.key}`}
data-kind={mark.kind}
data-at={mark.at}
aria-current={i === railAt ? 'true' : undefined}
title={mark.title}
onClick={() => jumpToTime(mark.at)}
@@ -4071,9 +4135,10 @@ function useThumbUrls(photos: LibraryPhoto[], also?: LibraryPhoto | null, stamp?
}
}
if (Object.keys(syncUrls).length > 0) {
setUrls((prev) => ({ ...prev, ...syncUrls }));
}
// The record is the list in hand and nothing else: a day scrolled away is a day
// whose tiles are let go here, so what a long shelf holds is the screen around
// the eye rather than every frame it has ever drawn.
setUrls(syncUrls);
if (idsToFetch.length === 0) return;
+24 -5
View File
@@ -1490,16 +1490,23 @@ form[data-key='immich-modal'] {
.lib-rail {
position: relative;
flex: 0 0 auto;
width: 86px;
width: 92px;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: none;
/* A shelf of years is longer than the wall is tall, so the rail scrolls under
the reader's own hand — a thin bar of its own rather than the platform's. */
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
padding: 2px 0;
border-left: 1px solid var(--border);
}
.lib-rail::-webkit-scrollbar { display: none; }
/* An entry of the rail: the month or the day, said in the dim voice until the eye
is in it, and in the accent — with the rule that carries the eye — once it is. */
.lib-rail::-webkit-scrollbar { width: 8px; }
.lib-rail::-webkit-scrollbar-track { background: transparent; }
.lib-rail::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
.lib-rail::-webkit-scrollbar-thumb:hover { background: var(--text-dim); }
/* An entry of the rail: the year standing over its months, and a month said in the
dim voice until the eye is in it — in the accent, with the rule that carries the
eye, once it is. */
.lib-rail-item {
display: block;
width: 100%;
@@ -1516,6 +1523,18 @@ form[data-key='immich-modal'] {
cursor: pointer;
}
.lib-rail-item:hover { color: var(--text); background: var(--bg-sunken); }
/* The year is a head and not a month of the shelf: it stands on its own line, above
the months it holds, and its own press takes the wall to the first of them. */
.lib-rail-year {
margin-top: 6px;
padding-top: 6px;
border-top: 1px solid var(--border);
color: var(--text);
font-weight: 700;
letter-spacing: 0.04em;
}
.lib-rail-year:first-child { margin-top: 0; padding-top: 0; border-top: 0; }
.lib-rail-month { padding-left: 14px; }
.lib-rail-item.on { color: var(--accent); border-left-color: var(--accent); font-weight: 600; }
/* What the thumbnail wall is read through: the selects and the count of what is
left. One row above the wall, and it does not scroll with it. */
+10 -6
View File
@@ -21,18 +21,22 @@ export function dayLabel(timestamp: number, lang: Lang): string {
return d.toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' });
}
// The month a day is filed under, and the line it is read by in the rail down the
// wall's right edge. Built on `dayKey`'s own spelling — a month is the first seven
// characters of the day — so the rail's entries and the days it jumps to are cut
// by one rule and not two.
// The month a day is filed under, and the way the rail down the wall's right edge
// is cut. Built on `dayKey`'s own spelling — a month is the first seven characters
// of the day, a year its first four — so the rail's entries and the days it jumps
// to are cut by one rule and not two.
export function monthKey(timestamp: number): string {
return dayKey(timestamp).slice(0, 7);
}
// "thg 10, 2026" / "Oct 2026".
export function yearKey(timestamp: number): string {
return dayKey(timestamp).slice(0, 4);
}
// "thg 10" / "Oct" — the month alone, since the year stands over it.
export function monthLabel(timestamp: number, lang: Lang): string {
const d = new Date(timestamp);
return lang === 'vi' ? `thg ${d.getMonth() + 1}, ${d.getFullYear()}` : d.toLocaleDateString(undefined, { month: 'short', year: 'numeric' });
return lang === 'vi' ? `thg ${d.getMonth() + 1}` : d.toLocaleDateString(undefined, { month: 'short' });
}
export type DayRun<T> = { key: string; at: number; photos: T[] };