feat(site): filter the wall by every interest an item has
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5feaea30a6
commit
6da355dc39
15 files changed
+93
-31
No files matched your search
@@ -48,15 +48,15 @@ The three card changes are pure Astro/CSS on top of existing islands (`EmbedOnCl
|
||||
**Interfaces:**
|
||||
- Produces: `Item.interests: string[]` (non-empty after a valid load), `Item.interest === Item.interests[0]`.
|
||||
|
||||
- [ ] **Step 1: Failing tests** in `describe('loadContent')`:
|
||||
- [x] **Step 1: Failing tests** in `describe('loadContent')`:
|
||||
- `'accepts an interest list, primary first'`: item `{ interest: ['comics', 'writing'] }` → `items[0].interests` equals `['comics','writing']`, `interest` is `'comics'`.
|
||||
- `'a single interest becomes a one-element list'`: default item → `interests` equals `['writing']`.
|
||||
- `'reports bad interest lists'`: three items, `interest: []`, `['comics','comics']`, `['comics','nowhere']` → each yields an error with field `interest`; messages contain `must not be empty`, `repeats "comics"`, `unknown interest "nowhere"`.
|
||||
- Change `'rejects unsupported contract'` to use `contract: 3`; add `'accepts contract 2'` (no errors, `site.contract === 2`).
|
||||
- [ ] **Step 2:** `bun test test/library/load.test.ts` → the new tests FAIL.
|
||||
- [ ] **Step 3: Implement.** `interestList(v: unknown, report: Report): string[]` in `load.ts`: string → `[v]`; array of non-empty strings → itself (report `must not be empty` / `repeats "<id>"`); anything else → `required`'s message. The unknown-id check in `loadContent` loops over `i.interests`.
|
||||
- [ ] **Step 4:** `bun test` → all PASS. `bun run typecheck` clean.
|
||||
- [ ] **Step 5:** Docs as listed, then commit `feat(library): items take one interest or a list; accept contract 2`.
|
||||
- [x] **Step 2:** `bun test test/library/load.test.ts` → the new tests FAIL.
|
||||
- [x] **Step 3: Implement.** `interestList(v: unknown, report: Report): string[]` in `load.ts`: string → `[v]`; array of non-empty strings → itself (report `must not be empty` / `repeats "<id>"`); anything else → `required`'s message. The unknown-id check in `loadContent` loops over `i.interests`.
|
||||
- [x] **Step 4:** `bun test` → all PASS. `bun run typecheck` clean.
|
||||
- [x] **Step 5:** Docs as listed, then commit `feat(library): items take one interest or a list; accept contract 2`.
|
||||
|
||||
### Task 2: One `data-interests` attribute, chips filter by membership, meta names the extra interests
|
||||
|
||||
@@ -71,11 +71,11 @@ The three card changes are pure Astro/CSS on top of existing islands (`EmbedOnCl
|
||||
- Consumes: `Item.interests` (Task 1).
|
||||
- Produces: `Entry.interests: string[]`, `Entry.also: string[]` (lower-case names of `interests.slice(1)`, minus any equal to `kindLabel(entry)`).
|
||||
|
||||
- [ ] **Step 1: Failing tests.** In build.test `'wall renders one card per personal entry…'`: expect `data-interests="${it.interests.join(' ')}"` and `expect(html).not.toContain('data-interest=')`. New test `'a two-interest item counts under both chips'`: the chips island props in `off-the-clock/index.html` give film-tv and music counts that include `fixture-soundtrack-film`, and the card's meta contains `film & tv` then `music`. e2e `'chips filter the wall by interest'`: read `dataset.interests`; every shown card's list includes `music`; then `film & tv` also shows `fixture-soundtrack-film`; "everything" count equals the number of cards.
|
||||
- [ ] **Step 2:** `bun test test/site/build.test.ts` → FAIL.
|
||||
- [ ] **Step 3: Implement** as in Interfaces; `toEntries(personal, interests)` derives `also` from the `interests` names. Posts: `interests: [interest]`, `also: []`. Live cards: `data-interests={interest}` when set.
|
||||
- [ ] **Step 4:** `bun test` PASS; `bun run typecheck` clean.
|
||||
- [ ] **Step 5:** Commit `feat(site): filter the wall by every interest an item has`.
|
||||
- [x] **Step 1: Failing tests.** In build.test `'wall renders one card per personal entry…'`: expect `data-interests="${it.interests.join(' ')}"` and `expect(html).not.toContain('data-interest=')`. New test `'a two-interest item counts under both chips'`: the chips island props in `off-the-clock/index.html` give film-tv and music counts that include `fixture-soundtrack-film`, and the card's meta contains `film & tv` then `music`. e2e `'chips filter the wall by interest'`: read `dataset.interests`; every shown card's list includes `music`; then `film & tv` also shows `fixture-soundtrack-film`; "everything" count equals the number of cards.
|
||||
- [x] **Step 2:** `bun test test/site/build.test.ts` → FAIL.
|
||||
- [x] **Step 3: Implement** as in Interfaces; `toEntries(personal, interests)` derives `also` from the `interests` names. Posts: `interests: [interest]`, `also: []`. Live cards: `data-interests={interest}` when set.
|
||||
- [x] **Step 4:** `bun test` PASS; `bun run typecheck` clean.
|
||||
- [x] **Step 5:** Commit `feat(site): filter the wall by every interest an item has`.
|
||||
|
||||
### Task 3: Credit line on comics and songs; a single-cover comic shows the whole cover (mockup H)
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ export const P = {
|
||||
draft: 'draft-unfinished-thing',
|
||||
};
|
||||
export const I = {
|
||||
soundtrackFilm: 'fixture-soundtrack-film', // [film-tv, music], spotify media url
|
||||
song: 'example-song', // youtube link, no ai (a "share")
|
||||
byHand: 'own-drawing',
|
||||
unknown: 'fixture-life',
|
||||
|
||||
+15
-6
@@ -11,14 +11,23 @@ test('chips filter the wall by interest', async ({ page }) => {
|
||||
await live(page);
|
||||
const cards = page.locator('[data-wall-card]');
|
||||
const all = await cards.count();
|
||||
const shownIds = () => page.$$eval('[data-wall-card]', (cs) => cs.filter((c) => c.getClientRects().length > 0)
|
||||
.map((c) => ({ id: (c as HTMLElement).dataset.id, interests: ((c as HTMLElement).dataset.interests ?? '').split(' ') })));
|
||||
await page.getByRole('button', { name: /^music/ }).click();
|
||||
const shown = await page.$$eval('[data-wall-card]', (cs) => cs.filter((c) => c.getClientRects().length > 0).map((c) => (c as HTMLElement).dataset.interest));
|
||||
expect(shown.length).toBeGreaterThan(0);
|
||||
expect(shown.length).toBeLessThan(all);
|
||||
expect(new Set(shown)).toEqual(new Set(['music']));
|
||||
const music = await shownIds();
|
||||
expect(music.length).toBeGreaterThan(0);
|
||||
expect(music.length).toBeLessThan(all);
|
||||
for (const c of music) expect(c.interests).toContain('music');
|
||||
expect(music.map((c) => c.id)).toContain(I.soundtrackFilm);
|
||||
// A two-interest card shows under both of its chips.
|
||||
await page.getByRole('button', { name: /^film & tv/ }).click();
|
||||
expect((await shownIds()).map((c) => c.id)).toContain(I.soundtrackFilm);
|
||||
await page.getByRole('button', { name: /^everything/ }).click();
|
||||
const back = await page.$$eval('[data-wall-card]', (cs) => cs.filter((c) => c.getClientRects().length > 0).length);
|
||||
expect(back).toBeGreaterThanOrEqual(shown.length + 1);
|
||||
const back = await shownIds();
|
||||
expect(back.length).toBeGreaterThanOrEqual(music.length + 1);
|
||||
// ...and is counted once under "everything".
|
||||
const everything = await page.getByRole('button', { name: /^everything/ }).locator('sup').textContent();
|
||||
expect(Number(everything)).toBe(back.length);
|
||||
});
|
||||
|
||||
test('opening a card updates the URL; Esc closes it', async ({ page }) => {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
title: Fixture Soundtrack Film
|
||||
date: 2025-07-25
|
||||
kind: film
|
||||
interest: [film-tv, music]
|
||||
media:
|
||||
- { file: fixture-pixel.png, alt: Fixture poster }
|
||||
- { file: fixture-3.svg, alt: Fixture soundtrack cover }
|
||||
- { url: 'https://open.spotify.com/album/fixture0000', alt: Fixture Soundtrack }
|
||||
---
|
||||
A synthetic film that is also a soundtrack.
|
||||
@@ -23,7 +23,7 @@ const ai = mode === 'wall' && entry.ai !== null ? entry.ai : undefined;
|
||||
<article
|
||||
class:list={['card', entry.kind, dark && 'dark', mode === 'page' && 'open page']}
|
||||
data-id={entry.id}
|
||||
data-interest={entry.interest}
|
||||
data-interests={entry.interests.join(' ')}
|
||||
data-wall-card={mode === 'wall' ? '' : undefined}
|
||||
data-ai={ai}
|
||||
style={`--span:${spanOf(entry)}`}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Interest chips in the sticky bar. They filter the wall in the page (cards carry data-interest),
|
||||
// Interest chips in the sticky bar. They filter the wall in the page (cards carry data-interests,
|
||||
// space-separated ids; a card shows under every chip it names),
|
||||
// fold away older years with nothing left, and retitle the wall. Without JavaScript the chips are
|
||||
// hidden by CSS (html:not(.js)) and the whole wall shows.
|
||||
import { useEffect, useState } from 'react';
|
||||
@@ -11,6 +12,8 @@ const ALL = 'all';
|
||||
|
||||
// Cards the visitor's "projects made" preference leaves on the page (or "show all" revealed),
|
||||
// so counts never claim hidden things.
|
||||
const has = (card: HTMLElement, id: string) => (card.dataset.interests ?? '').split(' ').includes(id);
|
||||
|
||||
function visibleCards(): HTMLElement[] {
|
||||
return [...document.querySelectorAll<HTMLElement>('[data-wall-card]')].filter(isShown);
|
||||
}
|
||||
@@ -25,7 +28,7 @@ export default function Chips({ chips, total }: Props) {
|
||||
const recount = () => {
|
||||
const cards = visibleCards();
|
||||
const next: Record<string, number> = { [ALL]: cards.length };
|
||||
for (const c of chips) next[c.id] = cards.filter((k) => k.dataset.interest === c.id).length;
|
||||
for (const c of chips) next[c.id] = cards.filter((k) => has(k, c.id)).length;
|
||||
setCounts(next);
|
||||
};
|
||||
recount();
|
||||
@@ -37,7 +40,7 @@ export default function Chips({ chips, total }: Props) {
|
||||
setOn(id);
|
||||
const all = id === ALL;
|
||||
const cards = [...document.querySelectorAll<HTMLElement>('[data-wall-card]')];
|
||||
for (const c of cards) c.toggleAttribute('data-off', !all && c.dataset.interest !== id);
|
||||
for (const c of cards) c.toggleAttribute('data-off', !all && !has(c, id));
|
||||
const shown = visibleCards().filter((c) => !c.hasAttribute('data-off'));
|
||||
for (const d of document.querySelectorAll<HTMLElement>('.older-year')) {
|
||||
d.toggleAttribute('data-off', !all && !shown.some((c) => d.contains(c)));
|
||||
|
||||
@@ -15,7 +15,7 @@ const things = (n: number) => `${n} ${n === 1 ? 'thing' : 'things'}`;
|
||||
<summary><span>{year} · {things(list.length)}</span><i aria-hidden="true">↓</i></summary>
|
||||
<div class="wall older-wall" data-wall>
|
||||
{list.map((e) => (
|
||||
<article class:list={['card', 'arc', e.kind]} data-id={e.id} data-interest={e.interest} data-wall-card data-ai={e.ai ?? undefined} style="--span:3">
|
||||
<article class:list={['card', 'arc', e.kind]} data-id={e.id} data-interests={e.interests.join(' ')} data-wall-card data-ai={e.ai ?? undefined} style="--span:3">
|
||||
<a class="arc-link" href={`/off-the-clock/${e.id}/`}>
|
||||
{images(e).length > 0 && <span class="th"><Cover entry={e} titled={false} /></span>}
|
||||
<span class="pad">
|
||||
|
||||
@@ -14,7 +14,7 @@ const first = source === 'hardcover' ? (hc.reading[0]?.url ?? hc.recent[0]?.url)
|
||||
const origin = first ? new URL(first) : null;
|
||||
const short = (t: string) => (t.length > 30 ? `${t.slice(0, 28).trimEnd()}…` : t);
|
||||
---
|
||||
<article class="card live" data-live={source} data-wall-card data-interest={interest} style={`--span:${span}`}>
|
||||
<article class="card live" data-live={source} data-wall-card data-interests={interest} style={`--span:${span}`}>
|
||||
<div class="pad">
|
||||
<div class="meta">
|
||||
<span class="live-src"><i aria-hidden="true"></i>{source === 'hardcover' ? 'Hardcover' : 'Trakt'}</span>
|
||||
|
||||
@@ -8,5 +8,6 @@ const sameYear = entry.date.slice(0, 4) === new Date(now).toISOString().slice(0,
|
||||
<div class="meta">
|
||||
{isFresh(entry.date, now) && <span class="new">new</span>}
|
||||
<span>{label ?? kindLabel(entry)}</span><span aria-hidden="true">·</span>
|
||||
{!label && entry.also.map((n) => <><span>{n}</span><span aria-hidden="true">·</span></>)}
|
||||
<time datetime={entry.date}>{sameYear ? shortDate(entry.date) : longDate(entry.date)}</time>
|
||||
</div>
|
||||
@@ -10,7 +10,9 @@ export type Entry = {
|
||||
title: string;
|
||||
date: string;
|
||||
kind: ItemKind;
|
||||
interest: string;
|
||||
interest: string; // primary, interests[0]
|
||||
interests: string[];
|
||||
also: string[]; // lower-case names of the interests after the primary, for the meta line
|
||||
size: 's' | 'm' | 'l';
|
||||
line?: string;
|
||||
media: Media[];
|
||||
@@ -33,13 +35,19 @@ const firstPara = (html: string) => strip(html.match(/<p>([\s\S]*?)<\/p>/i)?.[1]
|
||||
|
||||
export function toEntries(personal: Array<Item | Post>, interests: Interest[]): Entry[] {
|
||||
const known = new Set(interests.map((i) => i.id));
|
||||
const names = new Map(interests.map((i) => [i.id, i.name.toLowerCase()]));
|
||||
return personal.map((e): Entry => {
|
||||
if (!isPost(e)) return { ...e, excerpt: firstPara(e.bodyHtml) };
|
||||
if (!isPost(e)) {
|
||||
const entry: Entry = { ...e, also: [], excerpt: firstPara(e.bodyHtml) };
|
||||
const label = kindLabel(entry);
|
||||
entry.also = e.interests.slice(1).map((id) => names.get(id) ?? id).filter((n) => n !== label);
|
||||
return entry;
|
||||
}
|
||||
const kind = e.kind === 'life' ? 'life' : 'essay';
|
||||
const interest = kind === 'life' && known.has('life') ? 'life' : 'writing';
|
||||
const bodyHtml = e.html; // already made safe in site data
|
||||
return {
|
||||
id: e.slug, title: e.title, date: e.date, kind, interest, size: kind === 'life' ? 's' : 'l', media: [],
|
||||
id: e.slug, title: e.title, date: e.date, kind, interest, interests: [interest], also: [], size: kind === 'life' ? 's' : 'l', media: [],
|
||||
kickstarter: false, ownWork: true, ai: e.ai, bodyHtml, excerpt: e.excerpt.trim() || firstPara(bodyHtml),
|
||||
postUrl: e.url,
|
||||
};
|
||||
|
||||
@@ -23,7 +23,10 @@ if (entries.length > 0 && sources.trakt.recent.length) {
|
||||
}
|
||||
|
||||
const counts = new Map<string, number>();
|
||||
for (const x of [...entries, ...live]) if (x.interest) counts.set(x.interest, (counts.get(x.interest) ?? 0) + 1);
|
||||
// A card counts once under each interest it carries, matching what the chips filter on.
|
||||
const tally = (id: string) => counts.set(id, (counts.get(id) ?? 0) + 1);
|
||||
for (const e of entries) e.interests.forEach(tally);
|
||||
for (const l of live) if (l.interest) tally(l.interest);
|
||||
const chips: Chip[] = interests.filter((i) => counts.has(i.id)).map((i) => ({ ...i, count: counts.get(i.id)! }));
|
||||
|
||||
const leadYears = [...new Set(lead.map((e) => e.date.slice(0, 4)))];
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ const cli = (...args: string[]) => Bun.spawnSync(['bun', 'src/cli.ts', ...args],
|
||||
it('validate passes on the fixture content', () => {
|
||||
const r = cli('validate', 'fixtures/content');
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.stdout.toString()).toContain('Content OK (5 projects, 11 items)');
|
||||
expect(r.stdout.toString()).toContain('Content OK (5 projects, 12 items)');
|
||||
});
|
||||
|
||||
it('validate lists every error and exits 1', () => {
|
||||
|
||||
@@ -14,7 +14,7 @@ describe('loadContent', () => {
|
||||
const { content, errors } = await loadContent(`${FIXTURES}/content`);
|
||||
expect(errors).toEqual([]);
|
||||
expect(content!.projects).toHaveLength(5);
|
||||
expect(content!.items).toHaveLength(11);
|
||||
expect(content!.items).toHaveLength(12);
|
||||
expect(content!.interests.map((i) => i.id)).toEqual([
|
||||
'comics', 'games', 'music', 'film-tv', 'books', 'made-by-me', 'writing', 'life',
|
||||
]);
|
||||
|
||||
+28
-2
@@ -168,8 +168,21 @@ describe('personal space', () => {
|
||||
}
|
||||
const ITEM_IDS = [
|
||||
'fixture-comic', 'example-song', 'sample-game', 'kickstarted-game', 'demo-film', 'fixture-book', 'own-drawing',
|
||||
'commissioned-art', 'edge-long-title', 'fixture-life', 'example-set',
|
||||
'commissioned-art', 'edge-long-title', 'fixture-life', 'example-set', 'fixture-soundtrack-film',
|
||||
];
|
||||
// A whole wall card (opening tag to its closing tag) by entry id.
|
||||
function card(html: string, id: string): string {
|
||||
const m = html.match(new RegExp(`<article\\b[^>]*\\bdata-id="${id}"[\\s\\S]*?</article>`));
|
||||
if (!m) throw new Error(`no card ${id}`);
|
||||
return m[0];
|
||||
}
|
||||
// The counts the server renders into the chips island's props, keyed by interest id.
|
||||
function chipCounts(html: string): Record<string, number> {
|
||||
const props = html.match(/component-url="[^"]*Chips[^"]*"[^>]*props="([^"]*)"/)![1]!.replace(/"/g, '"').replace(/&/g, '&');
|
||||
const out: Record<string, number> = {};
|
||||
for (const m of props.matchAll(/"id":\[0,"([^"]+)"\],"name":\[0,"[^"]*"\],"count":\[0,(\d+)\]/g)) out[m[1]!] = Number(m[2]);
|
||||
return out;
|
||||
}
|
||||
const POST_IDS = ['synthetic-essay', 'synthetic-life-note'];
|
||||
|
||||
test('wall renders one card per personal entry with its kind class', async () => {
|
||||
@@ -179,12 +192,13 @@ describe('personal space', () => {
|
||||
expect([...cards.keys()].sort()).toEqual([...ITEM_IDS, ...POST_IDS].sort());
|
||||
for (const it of content!.items) {
|
||||
expect(cards.get(it.id)).toMatch(new RegExp(`class="card\\b[^"]*\\b${it.kind}\\b`));
|
||||
expect(cards.get(it.id)).toContain(`data-interest="${it.interest}"`);
|
||||
expect(cards.get(it.id)).toContain(`data-interests="${it.interests.join(' ')}"`);
|
||||
expect(existsSync(join(out, 'off-the-clock', it.id, 'index.html'))).toBe(true);
|
||||
}
|
||||
expect(cards.get('synthetic-essay')).toMatch(/class="card\b[^"]*\bessay\b/);
|
||||
expect(cards.get('synthetic-life-note')).toMatch(/class="card\b[^"]*\blife\b/);
|
||||
expect(html).not.toContain('draft-item');
|
||||
expect(html).not.toContain('data-interest=');
|
||||
// Ghost posts get their own page too, with the post body made safe.
|
||||
const post = page(out, 'off-the-clock/synthetic-essay/index.html');
|
||||
expect(post).toContain('<p>Body of an essay.</p>');
|
||||
@@ -192,6 +206,18 @@ describe('personal space', () => {
|
||||
expect(page(out, 'off-the-clock/fixture-comic/index.html')).toMatch(/<h1[^>]*>Fixture Comic<\/h1>/);
|
||||
});
|
||||
|
||||
test('a two-interest item counts under both chips and names both in its meta', () => {
|
||||
const html = page(out, 'off-the-clock/index.html');
|
||||
const counts = chipCounts(html);
|
||||
const cards = [...html.matchAll(/<article\b[^>]*\bdata-wall-card\b[^>]*>/g)].map((m) => m[0]); // live cards have no data-id
|
||||
const under = (id: string) => cards.filter((t) => new RegExp(`data-interests="[^"]*\\b${id}\\b`).test(t)).length;
|
||||
expect(counts['music']).toBe(under('music'));
|
||||
expect(counts['film-tv']).toBe(under('film-tv'));
|
||||
expect(cardTags(html).get('fixture-soundtrack-film')).toContain('data-interests="film-tv music"');
|
||||
expect(card(html, 'fixture-soundtrack-film')).toMatch(/<span>film & tv<\/span><span aria-hidden="true">·<\/span><span>music<\/span>/);
|
||||
expect(card(html, 'demo-film')).not.toContain('<span>music</span>');
|
||||
});
|
||||
|
||||
test('shares carry no data-ai', async () => {
|
||||
const { content } = await loadContent('fixtures/content');
|
||||
const cards = cardTags(page(out, 'off-the-clock/index.html'));
|
||||
|
||||
@@ -3,7 +3,7 @@ import { coverline, pageRef, spreads } from '../../src/site/components/issues/is
|
||||
import type { Entry } from '../../src/site/components/personal/entries.ts';
|
||||
|
||||
const e = (id: string, size: Entry['size']): Entry => ({
|
||||
id, title: id, date: '2026-08-01', kind: 'essay', interest: 'writing', size, media: [],
|
||||
id, title: id, date: '2026-08-01', kind: 'essay', interest: 'writing', interests: ['writing'], also: [], size, media: [],
|
||||
kickstarter: false, ownWork: true, ai: null, bodyHtml: '', excerpt: '',
|
||||
});
|
||||
const rows = (ws: number[]) => ws.reduce((n, w) => n + w, 0);
|
||||
|
||||
Reference in new issue
Block a user