Commit 50e87a15 by PLN (Algolia)

chore(redesign): Phase 0 — remove Dunbar, fix prod bugs, prune deps

Demolition step ahead of the site rationalization (App Router + shared
design system foundation to follow).

- Remove Dunbar entirely (pages, components, lib, tests); it's folding
  into a separate project. UX learnings preserved at
  docs/dunbar-design-learnings.md.
- Prune 6 now-unused deps: d3-force, d3-zoom, minisearch,
  react-force-graph(-2d), snowball-stemmers, stopword.
- Fix 3 production bugs in layout.js: drop the http://localhost:8097
  React DevTools script, replace the create-next-app template OG image
  with the site profile image, and compute the footer year dynamically.
- Delete dead files: pages/parvagues.js.backup, the unused HeroVariants.
- Add CLAUDE.md documenting the codebase and the redesign state.

Build green (yarn build, 40 routes). Note: this removes all test
coverage (it was all Dunbar's) — to be re-established in Phase 2.
Co-Authored-By: 's avatarClaude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 5fd1374e
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
PLN's personal website (`pln-www`) — a Next.js **Pages Router** app that bundles a personal landing page together with several self-contained mini-apps and generative-art experiments. It grew organically; expect each section to have its own conventions. Sections worth knowing:
- **`/` (index)** — personal landing page (bio, posts, talks).
- **ParVagues** (`/parvagues`, `/parvagues/live/*`, `/parvagues/fiche`) — the most actively developed area: a live-coding musician's site (gig timeline, music/video, technical rider). Has its own component family under `components/parvagues/` and a separate `<Layout>`. Flagship of the in-progress redesign.
- **CosmicFest** (`/cosmicfest`) — an event page; the only remaining consumer of the legacy `components/ParVaguesHeader.js` / `ParVaguesFooter.js` shell.
- **Generative art**`pages/fleurs.js`, `pages/starry-nights.js` (p5.js sketches), and `/hydra/[id]` (Hydra-synth live-coding visuals). These are large single-file canvas sketches.
- **Content sections** — posts, poems, talks, hydras, all Markdown-driven (see Content model).
## Commands
All app commands run from `next/` (the app root — **never** the repo root). **Use Node 20** (`nvm use 20`) — Yarn 4 refuses Node < 18.12, and the system default here is Node 16:
```bash
cd next
nvm use 20
yarn install --frozen-lockfile # deterministic install
yarn dev # dev server on :3000
yarn build && yarn start # production build + serve
yarn test # Jest unit tests (jsdom)
yarn test -- routing.spec # single test file by name pattern
yarn test:watch # Jest watch mode
yarn test:e2e # Playwright e2e (auto-starts dev server)
yarn test:e2e:ui # Playwright UI mode
```
Deploy is Vercel, **preview-first** (Vercel "Root Directory" is `next/`):
```bash
yarn deploy:preview # vercel --yes (every branch/PR)
yarn deploy:prod # vercel --prod --yes (only from main, after review)
yarn preview:env:pull # vercel env pull .env.local (first-time local setup)
```
## Non-obvious conventions (enforced — see `.cursor/rules/`)
- **Yarn 4 only.** `yarn.lock` is the single source of truth. Never create `package-lock.json` / `pnpm-lock.yaml`. Uses Yarn PnP (hence `jest-pnp-resolver` and the `.yarn/` dir).
- **`@/` import alias** maps to `next/` (configured in `next.config.js` webpack and `jest.config.js` moduleNameMapper). Use `@/components/...`, `@/lib/...` instead of deep `../../` paths.
- **Global CSS only from `pages/_app.js`.** Everywhere else use CSS Modules (`*.module.css`) or Tailwind utility classes. Tailwind v4 is active alongside the legacy `.module.css` files — both styling systems coexist.
- **One data-fetching strategy per page.** Never mix `getServerSideProps` with `getStaticProps`/`getStaticPaths` in the same file. If you see the "stale strategy" error after editing exports, delete `next/.next/` and restart.
- **Content in `content/`, assets in `public/`.** Markdown lives under `next/content/SECTION/`; static assets under `next/public/images/SECTION/`, referenced by absolute path (`/images/...`). Don't hotlink persistent assets — copy them in. Avoid build-time network fetches for core UI.
- **Secrets via Vercel env**, never committed. `NEXT_PUBLIC_*` prefix only for browser-safe vars.
## Content model
Markdown sections are loaded server-side at build via `gray-matter` (frontmatter) + `remark`/`remark-html` (body → HTML). The shared loader is `lib/utils.js`:
- `getAllContentData(section, sorted)` — list with frontmatter only (used for index/listing pages).
- `getAllContentIds(section)``getStaticPaths` shape.
- `getContentData(section, id)` — single item with rendered `contentHtml`.
Thin per-section wrappers (`lib/posts.js`, `lib/hydras.js`, `lib/poems.js`, `lib/talks.js`) just bind a section name to these helpers. Dynamic content pages (`pages/post/[id].js`, `pages/poesie/[id].js`, `pages/hydra/[id].js`) pair `getStaticPaths` + `getStaticProps`.
**ParVagues "lives" are a separate, richer model** (`lib/livesData.js`, NOT the generic loader): Markdown files organized by year under `content/lives/YYYY/slug.md`, with optional `content/lives/YYYY/slug/tracks.json` and gig photos under `public/images/parvagues/lives/YYYY/slug/`. Frontmatter carries gig metadata (date, time, location, audio/video/instagram/archive links, tags). `getAllLives()` aggregates across all year folders, sorted newest-first.
## Architecture notes
- **Hydra & p5 sketches**: canvas/WebGL code must be client-only — imported via `next/dynamic` with `ssr: false` (see `pages/hydra/[id].js``components/hydra-view.js`).
- **Layouts are not shared across sections.** There's a top-level `components/layout.js` and a separate `components/parvagues/Layout.js` — pick the one matching the section you're editing.
## Testing
Jest (`jest.config.js`) uses `next/jest` (SWC transform) + jsdom, RTL matchers, and `next-router-mock`. `tests/jest.setup.js` mocks `next/router` and `next/dynamic` (renders null stub). Unit tests in `tests/unit/`, Playwright e2e in `tests/e2e/` (excluded from Jest via `testPathIgnorePatterns`). **The test infra exists but there is currently 0 coverage** — all tests were Dunbar's and were removed with it. Re-establishing a baseline (starting with ParVagues) is part of the redesign.
Note: `tsconfig.json` has `strict: false` — TypeScript is loosely applied; most app code is plain `.js`/`.jsx`.
/* Lightweight mock for react-force-graph-2d to keep unit tests fast and DOM-based.
Uses forwardRef to silence ref warnings from the real component usage. */
import React, { forwardRef } from 'react';
const ForceGraph2D = forwardRef(function ForceGraph2D(props, ref) {
const { graphData } = props || {};
const nodeCount = (graphData && graphData.nodes && graphData.nodes.length) || 0;
const linkCount = (graphData && graphData.links && graphData.links.length) || 0;
return (
<div
ref={ref}
data-testid="force-graph-2d-mock"
data-nodes={nodeCount}
data-links={linkCount}
style={{ border: '1px dashed #ccc', padding: 8 }}
>
ForceGraph2D mock {nodeCount} nodes / {linkCount} links
{/* Expose buttons to simulate callbacks if needed */}
<button
type="button"
data-testid="mock-center"
onClick={() => {
if (typeof props.onNodeClick === 'function' && graphData?.nodes?.length) {
props.onNodeClick(graphData.nodes[0]);
}
}}
>
mock-center-first
</button>
</div>
);
});
export default ForceGraph2D;
import { useEffect, useMemo, useRef, useState } from 'react';
import styles from '@/styles/dunbar.module.css';
import { extractLocations } from '@/lib/dunbar-nlp';
export default function FriendsList({
friends,
selectedFriendId,
onSelect,
onAddFriend,
onRemoveFriend,
onRename, // (id, name) => void
onSaveScroll, // (scrollTop:number) => void
initialScroll = 0,
}) {
const [name, setName] = useState('');
const [filter, setFilter] = useState('');
const [editingId, setEditingId] = useState(null);
const [editName, setEditName] = useState('');
const scrollRef = useRef(null);
// Restore scroll position when mounting / when list changes (preserve UX)
useEffect(() => {
if (!scrollRef.current) return;
// Next tick to allow DOM layout to settle
const id = setTimeout(() => {
try {
scrollRef.current.scrollTop = initialScroll || 0;
} catch {}
}, 0);
return () => clearTimeout(id);
}, [friends, initialScroll]);
const filtered = useMemo(() => {
const q = filter.trim().toLowerCase();
if (!q) return friends;
return friends.filter((f) => f.name.toLowerCase().includes(q));
}, [friends, filter]);
// Offline location mentions per friend (from notes + events)
const locByFriend = useMemo(() => {
const m = new Map();
for (const f of friends) {
let text = '';
text += ' ' + (f.notes || '');
for (const ev of f.events || []) {
text += ' ' + (ev.title || '') + ' ' + (ev.notes || '') + ' ' + (ev.location || '');
}
const locs = extractLocations(text).map((l) => l.name);
const uniq = Array.from(new Set(locs));
m.set(f.id, uniq);
}
return m;
}, [friends]);
const handleAdd = () => {
const n = name.trim();
if (!n) return;
onAddFriend?.(n);
setName('');
};
const onClickItem = (id) => {
// Save current scroll before navigating to detail
if (scrollRef.current) onSaveScroll?.(scrollRef.current.scrollTop);
onSelect?.(id);
};
// Inline rename helpers
const startEdit = (id, currentName) => {
setEditingId(id);
setEditName(currentName || '');
};
const commitEdit = () => {
if (!editingId) return;
const n = editName.trim();
if (n) onRename?.(editingId, n);
setEditingId(null);
setEditName('');
};
const cancelEdit = () => {
setEditingId(null);
setEditName('');
};
return (
<div>
<div className={styles.card} style={{ marginBottom: 12 }}>
<div className={styles.cardHeader}>
<span>Friends ({friends.length})</span>
<div className={styles.row}>
<input
className={styles.input}
placeholder="Filter..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</div>
</div>
<div className={styles.row}>
<input
className={styles.input}
placeholder="Add a friend by name"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleAdd();
}}
/>
<button className={styles.btn} onClick={handleAdd} disabled={!name.trim()}>
Add
</button>
</div>
</div>
<div className={styles.list}>
<div ref={scrollRef} className={styles.listScroll}>
{filtered.map((f) => {
const evCount = Array.isArray(f.events) ? f.events.length : 0;
const connCount = f.relationships ? f.relationships.size : 0;
const isSel = f.id === selectedFriendId;
const isEditing = editingId === f.id;
return (
<div
key={f.id}
className={styles.listItem}
onClick={() => onClickItem(f.id)}
style={isSel ? { background: '#f5fbf7' } : undefined}
>
{isEditing ? (
<input
className={styles.input}
value={editName}
autoFocus
onClick={(e) => e.stopPropagation()}
onChange={(e) => setEditName(e.target.value)}
onBlur={commitEdit}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
commitEdit();
} else if (e.key === 'Escape') {
e.preventDefault();
cancelEdit();
}
}}
style={{ maxWidth: 220 }}
/>
) : (
<div
className={styles.itemTitle}
title="Click to rename"
onClick={(e) => {
e.stopPropagation();
startEdit(f.id, f.name);
}}
style={{ cursor: 'text' }}
>
{f.name}
</div>
)}
<div className={styles.itemMeta}>
&nbsp;·&nbsp;{evCount} events · {connCount} connections
{(() => {
const locs = locByFriend.get(f.id) || [];
return locs.length ? <> · 📍 {locs.slice(0, 2).join(', ')}</> : null;
})()}
</div>
<div className={styles.itemRight} aria-hidden></div>
<button
className={styles.btnSecondary}
style={{ marginLeft: 8 }}
onClick={(e) => {
e.stopPropagation();
const ok = window.confirm(`Remove ${f.name}? This doesn’t delete events from others.`);
if (!ok) return;
onRemoveFriend?.(f.id);
}}
>
Remove
</button>
</div>
);
})}
{filtered.length === 0 && (
<div style={{ padding: 12, color: '#666' }}>
{friends.length === 0
? 'No friends yet — add your first contact above.'
: 'No matches for your filter.'}
</div>
)}
</div>
</div>
</div>
);
}
import dynamic from 'next/dynamic';
import styles from '@/styles/dunbar.module.css';
/**
* NetworkTab — wrapper that mounts the high-UX graph (react-force-graph-2d)
* No edit mode. Hover → tooltip, click → open profile.
*/
const NetworkGraph = dynamic(() => import('@/components/dunbar/NetworkGraph'), { ssr: false });
export default function NetworkTab({ friends, openFriendDetail }) {
return (
<div className={styles.card} style={{ padding: 8 }}>
<div className={styles.cardHeader}>
<span>Network</span>
</div>
<NetworkGraph
friends={friends}
onOpenFriend={(id) => openFriendDetail?.(id)}
/>
</div>
);
}
import { useEffect, useMemo, useRef, useState } from 'react';
import styles from '@/styles/dunbar.module.css';
import Tooltip from '@/components/dunbar/Tooltip';
import {
distributeOnCircle,
colorByActivity,
firstWords,
isoDate,
} from '@/lib/dunbar';
function useSize(ref) {
const [size, setSize] = useState({ w: 800, h: 500 });
useEffect(() => {
if (!ref.current) return;
const el = ref.current;
const ro = new ResizeObserver(() => {
const r = el.getBoundingClientRect();
setSize({ w: Math.max(300, r.width), h: Math.max(300, r.height) });
});
ro.observe(el);
const r = el.getBoundingClientRect();
setSize({ w: Math.max(300, r.width), h: Math.max(300, r.height) });
return () => ro.disconnect();
}, [ref]);
return size;
}
function countRecentEvents(friend, days = 90) {
const now = Date.now();
const win = days * 24 * 60 * 60 * 1000;
let c = 0;
for (const e of friend.events || []) {
const t = new Date(e.date).getTime();
if (!isNaN(t) && now - t <= win) c += 1;
}
return c;
}
export default function OrbitsTab({ friends, buckets, openFriendDetail }) {
const wrapRef = useRef(null);
const { w, h } = useSize(wrapRef);
const cx = w / 2;
const cy = h / 2;
const rOuter = Math.min(w, h) * 0.45;
const rMiddle = Math.min(w, h) * 0.32;
const rInner = Math.min(w, h) * 0.18;
const friendMap = useMemo(() => {
const m = new Map();
for (const f of friends) m.set(f.id, f);
return m;
}, [friends]);
// Positions for each orbit
const posInner = useMemo(() => distributeOnCircle(buckets.inner || [], rInner, cx, cy, -Math.PI / 2), [buckets.inner, cx, cy, rInner]);
const posMiddle = useMemo(() => distributeOnCircle(buckets.middle || [], rMiddle, cx, cy, -Math.PI / 2), [buckets.middle, cx, cy, rMiddle]);
const posOuter = useMemo(() => distributeOnCircle(buckets.outer || [], rOuter, cx, cy, -Math.PI / 2), [buckets.outer, cx, cy, rOuter]);
const [tooltip, setTooltip] = useState({ x: 0, y: 0, show: false, html: null });
const handleEnter = (e, id) => {
const f = friendMap.get(id);
if (!f) return;
const totalEvents = (f.events || []).length;
const connectionCount = f.relationships ? f.relationships.size : 0;
const recent = [...(f.events || [])]
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
.slice(0, 3)
.map((ev) => `${isoDate(ev.date)}: ${firstWords(ev.notes, 3)}…`);
setTooltip({
x: e.clientX,
y: e.clientY,
show: true,
html: (
<div>
<div style={{ fontWeight: 800, marginBottom: 4 }}>{f.name}</div>
<div>Total events: {totalEvents}</div>
<div>Connections: {connectionCount}</div>
{recent.length ? (
<div style={{ marginTop: 6 }}>
{recent.map((line, i) => (
<div key={i} style={{ color: '#555' }}>{line}</div>
))}
</div>
) : null}
</div>
),
});
};
const handleMove = (e) => {
setTooltip((t) => ({ ...t, x: e.clientX, y: e.clientY }));
};
const handleLeave = () => setTooltip((t) => ({ ...t, show: false }));
const renderNodes = (ids, posMap) => {
return ids.map((id) => {
const p = posMap.get(id);
const f = friendMap.get(id);
if (!p || !f) return null;
const c90 = countRecentEvents(f, 90);
const fill = colorByActivity(c90);
return (
<g key={id} transform={`translate(${p.x},${p.y})`} style={{ cursor: 'pointer' }}>
<circle
r={10}
fill={fill}
onMouseEnter={(e) => handleEnter(e, id)}
onMouseMove={handleMove}
onMouseLeave={handleLeave}
onClick={() => openFriendDetail?.(id)}
/>
<text className={styles.nodeLabel} textAnchor="middle" y={-14}>
{f.name}
</text>
</g>
);
});
};
return (
<div ref={wrapRef} className={styles.orbitsWrap}>
<svg width="100%" height="100%" viewBox={`0 0 ${w} ${h}`} role="img" aria-label="Orbits visualization">
{/* Orbits */}
<circle cx={cx} cy={cy} r={rOuter} fill="none" stroke="#e8efe9" />
<circle cx={cx} cy={cy} r={rMiddle} fill="none" stroke="#d7e7db" />
<circle cx={cx} cy={cy} r={rInner} fill="none" stroke="#c9e0cf" />
{/* Labels */}
<text x={cx} y={cy - rInner - 8} className={styles.orbitLabel} textAnchor="middle">Close</text>
<text x={cx} y={cy - rMiddle - 8} className={styles.orbitLabel} textAnchor="middle">Regular</text>
<text x={cx} y={cy - rOuter - 8} className={styles.orbitLabel} textAnchor="middle">Distant</text>
{/* Nodes */}
{renderNodes(buckets.inner || [], posInner)}
{renderNodes(buckets.middle || [], posMiddle)}
{renderNodes(buckets.outer || [], posOuter)}
</svg>
<Tooltip x={tooltip.x} y={tooltip.y} visible={tooltip.show}>
{tooltip.html}
</Tooltip>
</div>
);
}
import { useEffect, useMemo, useState } from 'react';
import styles from '@/styles/dunbar.module.css';
import {
buildSearchIndexes,
querySearch,
extractTagsFromText,
suggestTags,
suggestPersons,
} from '@/lib/dunbar-search';
import { tokenize } from '@/lib/dunbar-nlp';
export default function SearchTab({ friends, openFriend, openEvent }) {
const [q, setQ] = useState('');
const [includeTags, setIncludeTags] = useState(new Set());
const [excludeTags, setExcludeTags] = useState(new Set());
const [includePersons, setIncludePersons] = useState(new Set());
const [excludePersons, setExcludePersons] = useState(new Set());
const [indexes, setIndexes] = useState(null);
// Build indexes when friends change
useEffect(() => {
setIndexes(buildSearchIndexes(friends || []));
}, [friends]);
const onToggleSet = (set, value) => {
const s = new Set(set);
const v = String(value).trim();
if (!v) return set;
if (s.has(v)) s.delete(v);
else s.add(v);
return s;
};
const addIncTag = (t) => setIncludeTags((s) => onToggleSet(s, t));
const addExcTag = (t) => setExcludeTags((s) => onToggleSet(s, t));
const addIncPerson = (p) => setIncludePersons((s) => onToggleSet(s, p));
const addExcPerson = (p) => setExcludePersons((s) => onToggleSet(s, p));
const clearFacets = () => {
setIncludeTags(new Set());
setExcludeTags(new Set());
setIncludePersons(new Set());
setExcludePersons(new Set());
};
const results = useMemo(() => {
if (!indexes) return { friends: [], events: [] };
return querySearch(indexes, q, {
includeTags,
excludeTags,
includePersons,
excludePersons,
});
}, [indexes, q, includeTags, excludeTags, includePersons, excludePersons]);
// Render notes with inline #tags highlighted
const renderNotesWithTags = (text = '') => {
const re = /(#([\p{L}\p{N}_-]+))/gu;
const parts = [];
let lastIndex = 0;
let m;
while ((m = re.exec(text))) {
if (m.index > lastIndex) {
parts.push(<span key={`t-${lastIndex}`}>{text.slice(lastIndex, m.index)}</span>);
}
const full = m[1];
parts.push(
<span key={`tag-${m.index}`} className={styles.tagChip} style={{ marginRight: 6 }}>
{full}
</span>
);
lastIndex = m.index + full.length;
}
if (lastIndex < text.length) {
parts.push(<span key={`t-end`}>{text.slice(lastIndex)}</span>);
}
return <>{parts}</>;
};
// Query tokens for highlight (non-hashtag, length>=3)
const queryTokens = useMemo(
() =>
tokenize(q || '', { keepHashtags: true, removeDiacritics: true }).filter(
(t) => !t.startsWith('#') && t.length >= 3
),
[q]
);
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const renderHighlight = (text = '', tokens = []) => {
if (!tokens || tokens.length === 0) return text;
const pattern = new RegExp(`(${tokens.map(escapeRegExp).join('|')})`, 'gi');
const parts = String(text).split(pattern);
const tokenSet = new Set(tokens.map((t) => t.toLowerCase()));
return (
<>
{parts.map((part, i) =>
tokenSet.has(String(part).toLowerCase()) ? (
<mark key={i} style={{ backgroundColor: '#fff2a8', padding: '0 2px' }}>{part}</mark>
) : (
<span key={i}>{part}</span>
)
)}
</>
);
};
const tagSuggestions = useMemo(() => (indexes ? suggestTags(indexes, q) : []), [indexes, q]);
const personSuggestions = useMemo(() => (indexes ? suggestPersons(indexes, q) : []), [indexes, q]);
return (
<div className={styles.twoCol} style={{ gap: 16 }}>
{/* Facets / Query */}
<div className={styles.card}>
<div className={styles.cardHeader}>
<span>Recherche</span>
<button className={styles.btnSecondary} onClick={clearFacets}>Reset filtres</button>
</div>
<div className={styles.row} style={{ marginBottom: 8, flexWrap: 'wrap' }}>
<input
className={styles.input}
placeholder="Rechercher (texte, #tags, personnes)…"
value={q}
onChange={(e) => setQ(e.target.value)}
style={{ minWidth: 260, flex: 1 }}
/>
</div>
{indexes && (
<div style={{ marginBottom: 8 }}>
<div style={{ fontSize: 12, color: '#666', marginBottom: 4 }}>Suggestions #tags</div>
<div className={styles.row} style={{ flexWrap: 'wrap' }}>
{tagSuggestions.map((t) => (
<button
key={t}
className={styles.btnSecondary}
onClick={() => addIncTag(t)}
title="Inclure ce tag"
>
#{t}
</button>
))}
</div>
<div style={{ fontSize: 12, color: '#666', margin: '8px 0 4px' }}>Suggestions personnes</div>
<div className={styles.row} style={{ flexWrap: 'wrap' }}>
{personSuggestions.map((p) => (
<button
key={p}
className={styles.btnSecondary}
onClick={() => addIncPerson(p)}
title="Inclure cette personne"
>
{p}
</button>
))}
</div>
</div>
)}
{/* Active facets */}
<div style={{ marginTop: 8 }}>
<div className={styles.cardHeader}><span>Filtres actifs</span></div>
<div style={{ fontSize: 12, color: '#333', marginBottom: 4 }}>Inclure</div>
<div className={styles.row} style={{ flexWrap: 'wrap' }}>
{Array.from(includeTags).map((t) => (
<button key={`inc-t-${t}`} className={styles.btnSecondary} onClick={() => addIncTag(t)}>#{t} </button>
))}
{Array.from(includePersons).map((p) => (
<button key={`inc-p-${p}`} className={styles.btnSecondary} onClick={() => addIncPerson(p)}>{p} </button>
))}
</div>
<div style={{ fontSize: 12, color: '#333', margin: '8px 0 4px' }}>Exclure</div>
<div className={styles.row} style={{ flexWrap: 'wrap' }}>
{Array.from(excludeTags).map((t) => (
<button key={`exc-t-${t}`} className={styles.btnSecondary} onClick={() => addExcTag(t)}>#{t} </button>
))}
{Array.from(excludePersons).map((p) => (
<button key={`exc-p-${p}`} className={styles.btnSecondary} onClick={() => addExcPerson(p)}>{p} </button>
))}
</div>
</div>
</div>
{/* Results */}
<div className={styles.card}>
<div className={styles.cardHeader}>
<span>Résultats</span>
</div>
<div className={styles.twoCol} style={{ gap: 12 }}>
<div className={styles.card}>
<div className={styles.cardHeader}><span>Ami·es</span></div>
<div className={styles.listScroll} style={{ maxHeight: '50vh' }}>
{(results.friends || []).map((f) => (
<div key={f.id} className={styles.listItem} onClick={() => openFriend?.(f.refId)}>
<div className={styles.itemTitle}>{renderHighlight(f.name, queryTokens)}</div>
{(f.tags && f.tags.length) ? (
<div className={styles.tagRow}>
{f.tags.slice(0, 8).map((t) => (
<span key={t} className={styles.tagChip}>#{t}</span>
))}
</div>
) : null}
</div>
))}
{(!results.friends || results.friends.length === 0) && (
<div style={{ padding: 8, color: '#666' }}>Aucune correspondance.</div>
)}
</div>
</div>
<div className={styles.card}>
<div className={styles.cardHeader}><span>Événements</span></div>
<div className={styles.listScroll} style={{ maxHeight: '50vh' }}>
{(results.events || []).map((e) => (
<div key={e.id} className={styles.timelineEvent}>
<div className={styles.timelineDate}>{e.date}</div>
<div
style={{ fontWeight: 700, cursor: 'pointer' }}
onClick={() => openEvent?.(e)}
title="Ouvrir l’événement"
>
{renderHighlight(e.title || '(untitled)', queryTokens)}
</div>
<div style={{ whiteSpace: 'pre-wrap' }}>
{renderNotesWithTags(e.notes || '')}
</div>
{e.location ? <div style={{ color: '#555', marginTop: 4 }}>📍 {e.location}</div> : null}
<div className={styles.itemMeta}>Avec {(e.participantNames || []).join(', ')}</div>
{(e.tags && e.tags.length) ? (
<div className={styles.tagRow}>
{e.tags.slice(0, 10).map((t) => (
<span key={t} className={styles.tagChip}>#{t}</span>
))}
</div>
) : null}
</div>
))}
{(!results.events || results.events.length === 0) && (
<div style={{ padding: 8, color: '#666' }}>Aucune correspondance.</div>
)}
</div>
</div>
</div>
</div>
</div>
);
}
import React from 'react';
import styles from '@/styles/dunbar.module.css';
import { isoDate } from '@/lib/dunbar';
import { detectLang, topKeywordsForDocs, extractLocations } from '@/lib/dunbar-nlp';
export default function StatsTab({ stats, anniversaries = [], eventIndex = [], openFriend }) {
if (!stats) return null;
const items = [
{ label: 'Connections', value: stats.connections },
{ label: 'Active Friends (90d)', value: stats.activeFriends },
{ label: 'Total Events', value: stats.totalEvents },
{ label: 'Avg Events / Friend', value: stats.avgEventsPerFriend },
];
// Aggregate text insights (local-only): top keywords and locations across all events
const textInsights = React.useMemo(() => {
const docs = (eventIndex || []).map((e) => ({
id: e.id,
text: `${e.title || ''} ${e.notes || ''} ${e.location || ''}`,
}));
if (!docs.length) return { topTerms: [], topLocations: [] };
const corpusText = docs.map((d) => d.text).join(' ');
const lang = detectLang(corpusText) || null;
// Aggregate keywords by summing TF-IDF heads across docs
const perDoc = topKeywordsForDocs(docs, { lang, topK: 8 });
const agg = new Map();
for (const d of perDoc) {
for (const [term, score] of d.keywords) {
agg.set(term, (agg.get(term) || 0) + score);
}
}
const topTerms = Array.from(agg.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 12)
.map(([term, score]) => ({ term, score }));
// Count location mentions (unique per event)
const locCounts = new Map();
for (const e of eventIndex || []) {
const locs = extractLocations(`${e.title || ''} ${e.notes || ''} ${e.location || ''}`).map((l) => l.name);
for (const name of new Set(locs)) {
locCounts.set(name, (locCounts.get(name) || 0) + 1);
}
}
const topLocations = Array.from(locCounts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 8)
.map(([name, count]) => ({ name, count }));
return { topTerms, topLocations };
}, [eventIndex]);
// Group upcoming anniversaries by date (YYYY-MM-DD)
const grouped = anniversaries.reduce((acc, it) => {
const k = isoDate(it.date);
acc.set(k, [...(acc.get(k) || []), it]);
return acc;
}, new Map());
const annivDays = Array.from(grouped.entries()).sort(
(a, b) => new Date(a[0]).getTime() - new Date(b[0]).getTime()
);
return (
<div className={styles.card}>
<div className={styles.cardHeader}>Statistics</div>
<div className={styles.statsGrid}>
{items.map((it) => (
<div key={it.label} className={styles.statCard}>
<div className={styles.statLabel}>{it.label}</div>
<div className={styles.statValue}>{it.value}</div>
</div>
))}
</div>
{(textInsights.topTerms.length > 0 || textInsights.topLocations.length > 0) && (
<div style={{ marginTop: 16 }}>
<div className={styles.cardHeader}><span>Text Insights</span></div>
{textInsights.topTerms.length > 0 ? (
<div style={{ marginBottom: 8 }}>
<div className={styles.itemMeta} style={{ marginBottom: 4 }}>Top keywords</div>
<div className={styles.tagRow}>
{textInsights.topTerms.map(({ term }) => (
<span key={term} className={styles.tagChip}>{term}</span>
))}
</div>
</div>
) : null}
{textInsights.topLocations.length > 0 ? (
<div>
<div className={styles.itemMeta} style={{ marginBottom: 4 }}>Top locations</div>
<div className={styles.tagRow}>
{textInsights.topLocations.map(({ name, count }) => (
<span key={name} className={styles.tagChip}>📍 {name} × {count}</span>
))}
</div>
</div>
) : null}
</div>
)}
{annivDays.length > 0 && (
<div style={{ marginTop: 16 }}>
<div className={styles.cardHeader}>
<span>À venir (21 jours) Anniversaires</span>
</div>
<div className={styles.timeline}>
{annivDays.map(([day, items]) => (
<div key={day} className={styles.timelineGroup}>
<div className={styles.timelineDate}>{day}</div>
{items.map((it, idx) => (
<div key={day + '-' + idx} className={styles.timelineEvent}>
<div
style={{ fontWeight: 700, cursor: 'pointer' }}
title="Ouvrir la fiche ami·e"
onClick={() => openFriend?.(it.friendId)}
>
{it.friendName}
</div>
<div style={{ color: '#555' }}>{it.label}</div>
{/* Anchor event preview if provided */}
{it.anchorTitle || (it.anchorTags && it.anchorTags.length > 0) ? (
<div style={{ marginTop: 4 }}>
{it.anchorTitle ? (
<div style={{ color: '#333' }} title="Événement d’ancrage">
« {it.anchorTitle} »
</div>
) : null}
{Array.isArray(it.anchorTags) && it.anchorTags.length > 0 ? (
<div className={styles.tagRow}>
{it.anchorTags.slice(0, 8).map((t) => (
<span key={t} className={styles.tagChip}>#{t}</span>
))}
</div>
) : null}
</div>
) : null}
</div>
))}
</div>
))}
</div>
</div>
)}
</div>
);
}
import React from 'react';
import styles from '@/styles/dunbar.module.css';
export default function Tooltip({ x, y, visible, children }) {
if (!visible) return null;
// Keep tooltip within viewport bounds with a small offset
const offset = 12;
const style = {
left: Math.max(8, x + offset),
top: Math.max(8, y + offset),
};
return (
<div className={styles.tooltip} style={style} role="tooltip">
{children}
</div>
);
}
......@@ -4,7 +4,6 @@ import styles from "./layout.module.css";
import utilStyles from "../styles/utils.module.css";
import Link from "next/link";
import Router from 'next/router'
import { useRouter } from 'next/router'
const name = "PLN";
export const siteTitle = "PLN's Works";
......@@ -13,25 +12,9 @@ export const twitterHandle = "@PaulLouisNech";
export const description = "PLN's Selected Works";
export default function Layout({ children, home }) {
const router = useRouter();
const path = router?.asPath || router?.pathname || '';
const isDunbar = path.startsWith('/dunbar');
// Simple feedback launcher: prompts for text then opens default mail client
const handleFeedbackMail = () => {
try {
const txt = typeof window !== 'undefined' ? window.prompt('Feedback for Dunbar (will open your email client):', '') : '';
const subject = 'Dunbar feedback';
const url = typeof window !== 'undefined' ? window.location.href : '';
const body = `${txt ? txt + '\\n\\n' : ''}From: ${url}`;
const mailto = `mailto:dunbar@nech.pl?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
if (typeof window !== 'undefined') window.location.href = mailto;
} catch {}
};
return (
<div className={styles.container}>
<Head>
<script src="http://localhost:8097"></script>
<link rel="icon" href="/favicon.ico" />
<meta name="description" content={description} />
{/* Twitter */}
......@@ -43,12 +26,7 @@ export default function Layout({ children, home }) {
<meta property="og:site_name" content={siteTitle} key="ogsitename" />
<meta property="og:type" content="website" key="ogtype" />
<meta property="og:description" content={description} key="ogdesc" />
<meta
property="og:image"
content={`https://og-image.vercel.app/${encodeURI(
siteTitle
)}.png?theme=dark&md=0&fontSize=75px&images=https%3A%2F%2Fassets.vercel.com%2Fimage%2Fupload%2Ffront%2Fassets%2Fdesign%2Fnextjs-black-logo.svg`}
/>
<meta property="og:image" content={`${siteURL}/images/profile.png`} />
</Head>
<header className={styles.header}>
{home ? (
......@@ -94,7 +72,7 @@ export default function Layout({ children, home }) {
</div>
)}
<footer>
PLN 2025 |
PLN {new Date().getFullYear()} |
<a
href="https://vercel.com?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
target="_blank"
......@@ -102,20 +80,6 @@ export default function Layout({ children, home }) {
>
</a>
{isDunbar && (
<>
{' '}|{' '}
<button
type="button"
onClick={handleFeedbackMail}
className={utilStyles.backButton}
style={{ cursor: 'pointer', border: 'none', background: 'transparent', padding: 0 }}
title="Send feedback about Dunbar"
>
Feedback (dunbar@nech.pl)
</button>
</>
)}
</footer>
</div>
);
......
import MiniSearch from 'minisearch';
import { fr as FR_LIST } from 'stopword';
import { FrenchStemmer } from 'snowball-stemmers';
// Utilities for FR-friendly tokenization and #tag extraction
const FR_STOP = new Set(FR_LIST || []);
let stemmer;
try {
// Prefer constructor form; some builds ship a class
stemmer = new FrenchStemmer();
} catch (e) {
// Fallback: library may export a plain object with stem(), or nothing usable
if (FrenchStemmer && typeof FrenchStemmer.stem === 'function') {
stemmer = FrenchStemmer;
} else {
stemmer = { stem: (w) => w };
}
}
export function extractTagsFromText(text = '') {
const tags = new Set();
const re = /#([\p{L}\p{N}_-]+)/gu;
let m;
while ((m = re.exec(text))) {
tags.add(m[1].toLowerCase());
}
return Array.from(tags);
}
function stripDiacritics(s) {
return s.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
}
export function tokenizeFr(text = '') {
// Keep hashtags verbatim, otherwise split on non-letters/digits
const hashtagTokens = (text.match(/#[\p{L}\p{N}_-]+/gu) || []).map(t => t.toLowerCase());
const raw = stripDiacritics(text.toLowerCase()).replace(/#/g, ' ');
const parts = raw.split(/[^a-z0-9]+/g).filter(Boolean);
// remove stopwords and stem
const filtered = parts.filter(w => !FR_STOP.has(w));
const stemmed = filtered.map(w => {
try {
return stemmer.stem(w);
} catch {
return w;
}
});
return [...new Set([...hashtagTokens, ...stemmed])];
}
// Build docs from friends and events
export function buildSearchData(friends = []) {
const friendDocs = [];
const eventDocs = [];
const tagSet = new Set();
const personSet = new Set();
const friendMap = new Map();
for (const f of friends) friendMap.set(f.id, f);
for (const f of friends) {
personSet.add(f.name);
// Aggregate tags from events + friend notes + rich profile
const aggTags = new Set();
const addTagsFromAny = (val) => {
if (Array.isArray(val)) {
for (const item of val) {
for (const t of extractTagsFromText(String(item || ''))) aggTags.add(t);
}
} else {
for (const t of extractTagsFromText(String(val || ''))) aggTags.add(t);
}
};
addTagsFromAny(f.notes);
addTagsFromAny(f.likes);
addTagsFromAny(f.dislikes);
addTagsFromAny(f.foodLikes);
addTagsFromAny(f.foodDislikes);
addTagsFromAny(f.futureIdeas);
addTagsFromAny(f.quotes);
for (const ev of f.events || []) {
addTagsFromAny(ev.notes);
addTagsFromAny(ev.title);
}
// Compose an extended notes blob to improve recall
const profileBlob = [
f.notes,
Array.isArray(f.likes) ? f.likes.join(', ') : f.likes,
Array.isArray(f.dislikes) ? f.dislikes.join(', ') : f.dislikes,
f.foodLikes,
f.foodDislikes,
f.futureIdeas,
f.quotes,
f.workplace,
f.schedule,
f.carModel,
]
.filter(Boolean)
.join(' \\n');
const friendDoc = {
id: `friend:${f.id}`,
kind: 'friend',
refId: f.id,
name: f.name || '',
notes: profileBlob,
tags: Array.from(aggTags),
lastInteraction: f.lastInteraction || null,
};
friendDocs.push(friendDoc);
for (const t of friendDoc.tags) tagSet.add(t);
}
// Build de-duplicated events index from shared event ids
const dedup = new Map();
for (const f of friends) {
for (const e of f.events || []) {
if (!dedup.has(e.id)) {
dedup.set(e.id, {
...e,
participants: new Set(e.participants || []),
});
} else {
const cur = dedup.get(e.id);
for (const pid of e.participants || []) cur.participants.add(pid);
}
}
}
for (const e of dedup.values()) {
const names = [];
for (const pid of e.participants) {
const p = friendMap.get(pid);
if (p) names.push(p.name);
}
const tags = extractTagsFromText(e.notes || '');
for (const t of tags) tagSet.add(t);
eventDocs.push({
id: `event:${e.id}`,
kind: 'event',
refId: e.id,
title: e.title || '',
notes: e.notes || '',
location: e.location || '',
tags,
participantNames: names,
date: e.date,
});
}
return { friendDocs, eventDocs, tagSet: Array.from(tagSet), personSet: Array.from(personSet) };
}
function makeMiniSearch(docs, fields, storeFields, boosts = {}) {
const ms = new MiniSearch({
fields,
storeFields,
searchOptions: {
prefix: true,
fuzzy: 0.25,
boost: boosts,
extractField: (doc, fieldName) => {
const val = doc[fieldName];
if (Array.isArray(val)) return val.join(' ');
return String(val ?? '');
},
processTerm: (term, _field) => {
// Preserve hashtags as-is; otherwise stemming pipeline
if (term.startsWith('#')) return term;
const t = stripDiacritics(term.toLowerCase());
if (!t || FR_STOP.has(t)) return null;
try {
return stemmer.stem(t);
} catch {
return t;
}
},
},
});
ms.addAll(docs);
return ms;
}
export function buildSearchIndexes(friends = []) {
const { friendDocs, eventDocs, tagSet, personSet } = buildSearchData(friends);
const friendIndex = makeMiniSearch(friendDocs, ['name', 'notes', 'tags'], ['id', 'kind', 'refId', 'name', 'tags'], {
name: 3,
tags: 2,
});
const eventIndex = makeMiniSearch(
eventDocs,
['title', 'notes', 'location', 'tags', 'participantNames'],
['id', 'kind', 'refId', 'title', 'tags', 'participantNames', 'date'],
{ title: 3, tags: 2, participantNames: 1.5 }
);
return { friendIndex, eventIndex, tagSet, personSet, friendDocs, eventDocs };
}
// Faceted search with include/exclude tag/person filters
export function querySearch(indexes, query, facets = {}) {
const {
includeTags = new Set(),
excludeTags = new Set(),
includePersons = new Set(), // names
excludePersons = new Set(),
} = facets;
const q = String(query || '').trim();
const friendRes = q ? indexes.friendIndex.search(q) : indexes.friendDocs;
const eventRes = q ? indexes.eventIndex.search(q) : indexes.eventDocs;
const filterDoc = (doc) => {
const tags = new Set((doc.tags || []).map((t) => t.toLowerCase()));
// Persons only for events
const persons = new Set((doc.participantNames || []).map((n) => n.toLowerCase()));
// Includes
for (const t of includeTags) if (!tags.has(String(t).toLowerCase())) return false;
for (const p of includePersons) if (!persons.has(String(p).toLowerCase())) return false;
// Excludes
for (const t of excludeTags) if (tags.has(String(t).toLowerCase())) return false;
for (const p of excludePersons) if (persons.has(String(p).toLowerCase())) return false;
return true;
};
const friends = friendRes
.map((r) => (r.id ? indexes.friendDocs.find((d) => d.id === r.id) : r))
.filter(Boolean)
.filter(filterDoc);
const events = eventRes
.map((r) => (r.id ? indexes.eventDocs.find((d) => d.id === r.id) : r))
.filter(Boolean)
.filter(filterDoc);
return { friends, events };
}
// Suggestions for tags/persons
export function suggestTags(indexes, prefix = '') {
const p = String(prefix || '').toLowerCase().replace(/^#/, '');
if (!p) return indexes.tagSet.slice(0, 20);
return indexes.tagSet.filter((t) => t.startsWith(p)).slice(0, 20);
}
export function suggestPersons(indexes, prefix = '') {
const p = String(prefix || '').toLowerCase();
if (!p) return indexes.personSet.slice(0, 20);
return indexes.personSet.filter((name) => name.toLowerCase().startsWith(p)).slice(0, 20);
}
......@@ -22,21 +22,16 @@
"dependencies": {
"@tailwindcss/postcss": "^4.2.1",
"classnames": "^2.5.1",
"d3-force": "^3.0.0",
"d3-zoom": "^3.0.0",
"date-fns": "^3.6.0",
"gray-matter": "^4.0.3",
"hydra-synth": "^1.3.29",
"marked": "^15.0.12",
"minisearch": "^7.1.2",
"next": "^15.3.0",
"p5": "1.11.3",
"postcss": "^8.5.8",
"prismjs": "^1.30.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-force-graph": "^1.48.1",
"react-force-graph-2d": "^1.29.0",
"react-icons": "^5.5.0",
"react-instantsearch": "^7.15.7",
"react-instantsearch-dom": "^6.40.4",
......@@ -46,8 +41,6 @@
"react-syntax-highlighter": "^15.5.0",
"remark": "^14.0.0",
"remark-html": "^15.0.0",
"snowball-stemmers": "^0.6.0",
"stopword": "^3.1.5",
"swiper": "^11.2.6",
"tailwindcss": "^4.2.1"
},
......
import Head from 'next/head';
import Layout from '@/components/layout';
import DunbarApp from '@/components/dunbar/DunbarApp';
/**
* Unified Dunbar catch-all page.
* Handles:
* - /dunbar → Events tab (main view)
* - /dunbar/friends → Friends list
* - /dunbar/friends/:slug → Friends detail (resolved by DunbarApp)
* - /dunbar/orbits → Orbits
* - /dunbar/network → Network
*
* DunbarApp parses the current path and selects the correct tab / friend.
* Keeping a single page prevents page-level remounts and preserves SPA feel.
*/
export default function DunbarCatchAllPage() {
return (
<div className="max-w-7xl mx-auto px-4">
<Layout>
<Head>
<title>Dunbar</title>
<meta name="robots" content="noindex" />
<meta name="description" content="Dunbar — privacy-first relationship navigator" />
</Head>
<DunbarApp />
</Layout>
</div>
);
}
import Head from 'next/head';
import Layout from '@/components/layout';
import DunbarApp from '@/components/dunbar/DunbarApp';
// Client-only page; do not export getServerSideProps/getStaticProps
export default function DunbarEventPage() {
const desc =
'Dunbar — Event details in the privacy-first relationship navigator prototype. Local-only data, networks, events, and orbits.';
return (
<div className="max-w-7xl mx-auto px-4">
<Layout>
<Head>
<title>Dunbar Event</title>
<meta name="robots" content="noindex" />
<meta name="description" content={desc} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Dunbar — Event" />
<meta name="twitter:description" content={desc} />
<meta property="og:type" content="website" />
<meta property="og:title" content="Dunbar — Event" />
<meta property="og:description" content={desc} />
</Head>
<DunbarApp />
</Layout>
</div>
);
}
import Image from "next/image";
import Link from "next/link";
import Head from "next/head";
import Layout from "../components/layout";
import utilStyles from "../styles/utils.module.css";
import SyntaxHighlighter from "react-syntax-highlighter";
import React from "react";
import ReactPlayer from "react-player";
export async function getStaticProps(context) {
const tidalSampleUrl =
"https://git.plnech.fr/pln/Tidal/raw/f5bfbc74e68dcaac0f6afa93f2b47d35321274c8/live/dnb/automne_electrique.tidal";
const response = await fetch(tidalSampleUrl);
const source = await response.text();
// Remove working title
const sourceClean = source.split("\n").slice(1).join("\n");
return {
props: {
urlSC: "https://soundcloud.com/parvagues/",
urlTwitch: "https://twitch.tv/parvagues/",
urlTwitchExample: "https://www.twitch.tv/videos/965233250",
urlAutomne: "https://soundcloud.com/parvagues/automne-electrique",
tidalSample: sourceClean,
},
};
}
export default function ParVagues({
urlSC,
urlTwitch,
urlTwitchExample,
tidalSample,
}) {
return (
<Layout>
<Head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ParVagues</title>
</Head>
<div>
<section className={utilStyles.headingMd}>
<h1>I create music with patterns</h1>
<h4>
<i>
ParVagues, c'est des ondes sonores qui naissent dans un océan
binaire pour parfois s'échouer sur vos plages sonores.
</i>
</h4>
{/*<Image
alt="ParVagues performing"
src="/images/ParVagues.jpg"
layout="fill"
width={700}
height={475}
/>*/}
</section>
<section className={utilStyles.headingMd}>
<h5>
A source sample: the code behind <a href="">Automne Électrique</a>:
</h5>
<SyntaxHighlighter
className="source-code"
width="64em"
language="haskell"
wrapLines={true}
>
{tidalSample}
</SyntaxHighlighter>
</section>
<section className={utilStyles.headingMd}>
<h4>
I sometimes post recordings on <a href={urlSC}>SoundCloud</a>
</h4>
<div className="player-wrapper">
<ReactPlayer
className="react-player"
url={urlSC}
width="100%"
height="32em"
controls={true}
config={{
soundcloud: {
options: {
auto_play: false,
},
},
}}
/>
</div>
</section>
<section className={utilStyles.headingMd}>
<h4>
I sometimes do live performances on <a href={urlTwitch}>Twitch</a>
</h4>
<div className="player-wrapper">
<ReactPlayer
className="react-player"
url={urlTwitchExample}
width="100%"
height="32em"
controls={true}
/>
</div>
</section>
</div>
</Layout>
);
}
/// <reference types="@playwright/test" />
import { test, expect } from '@playwright/test';
test.describe('Dunbar navigation (path-only URL sync, SPA feel)', () => {
test('Events -> Stats stays on Stats (no URL change, no snap-back)', async ({ page }) => {
await page.goto('/dunbar'); // Events tab expected by default
// Click Stats
await page.getByRole('button', { name: /stats/i }).click();
// URL should remain /dunbar (stats has no dedicated path)
await expect(page).toHaveURL(/\/dunbar$/);
// URL stability is the contract for search/stats tabs (no dedicated path)
// Visual assertions are left to component-level tests.
});
test('Root -> Search remains Search (no URL change, no snap-back)', async ({ page }) => {
await page.goto('/dunbar');
await page.getByRole('button', { name: /search/i }).click();
// URL remains the same
await expect(page).toHaveURL(/\/dunbar$/);
// URL-only assertion (visual coverage happens in unit/integration)
});
test('/dunbar/orbits -> Events -> Search (Search persists, URL stays /dunbar)', async ({ page }) => {
await page.goto('/dunbar/orbits');
// Orbits initially (empty when no friends)
await page.getByRole('button', { name: /events/i }).click();
await expect(page).toHaveURL(/\/dunbar$/);
// Now click Search; should stay on search, and URL should remain /dunbar
await page.getByRole('button', { name: /search/i }).click();
await expect(page).toHaveURL(/\/dunbar$/);
});
test('/dunbar/friends shallow-select retains /dunbar/friends/:slug', async ({ page }) => {
// Start on list; may be empty in a brand-new session but we still exercise the path
await page.goto('/dunbar/friends');
// If there is a friend, clicking should replace URL to /dunbar/friends/:slug without full reload.
// We try to click the first list item if present.
const listItems = page.locator('[class*="listItem"]');
const count = await listItems.count();
if (count > 0) {
await listItems.nth(0).click();
await expect(page).toHaveURL(/\/dunbar\/friends\/[a-z0-9-]+$/);
} else {
// No data: still valid that URL remains /dunbar/friends and no crash occurs.
await expect(page).toHaveURL(/\/dunbar\/friends$/);
}
});
test('/dunbar/network loads graph and stays on /dunbar/network', async ({ page }) => {
await page.goto('/dunbar/network');
await expect(page).toHaveURL(/\/dunbar\/network$/);
// Graph toolbar visible (Reset button present)
await expect(page.getByRole('button', { name: /Reset/i })).toBeVisible();
});
});
......@@ -3,9 +3,6 @@ import '@testing-library/jest-dom';
// Mock Next.js router for unit/integration tests
jest.mock('next/router', () => require('next-router-mock'));
// Silences React-Force-Graph heavy canvas deps by redirecting to a light stub (see __mocks__)
jest.mock('react-force-graph-2d');
// Mock next/dynamic to avoid async loading/act warnings in unit tests.
// It renders a null stub for dynamically imported components.
jest.mock('next/dynamic', () => {
......
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import mockRouter from 'next-router-mock';
import DunbarApp from '@/components/dunbar/DunbarApp';
// Minimal store mock for Dunbar
jest.mock('@/components/dunbar/useDunbarStore', () => {
return {
useDunbarStore: () => ({
state: { selectedEventId: null },
friends: [],
selectedFriendId: null,
actions: {
loadFromPayload: jest.fn(),
addFriend: jest.fn(),
removeFriend: jest.fn(),
renameFriend: jest.fn(),
toggleRelationship: jest.fn(),
addEvent: jest.fn(),
updateEvent: jest.fn(),
resetData: jest.fn(),
selectFriend: jest.fn(),
setBirthday: jest.fn(),
setFriendNotes: jest.fn(),
updateFriend: jest.fn(),
selectEvent: jest.fn(),
},
derived: {
eventIndex: [],
orbitBuckets: [],
stats: { connections: 0, activeFriends: 0, totalEvents: 0, avgEventsPerFriend: 0 },
anniversaries: [],
},
}),
};
});
describe('DunbarApp routing and tab URL sync (path-only, SPA)', () => {
beforeEach(() => {
// default route to /dunbar (events)
mockRouter.setCurrentUrl('/dunbar');
});
it('opens Events on /dunbar and stays on Search when clicked (no snap-back)', () => {
render(<DunbarApp />);
const searchBtn = screen.getByRole('button', { name: /search/i });
fireEvent.click(searchBtn);
expect(mockRouter.asPath).toBe('/dunbar');
// UI should remain on Search; CSS module class is hashed so we assert URL-only here.
// Visual active-state is covered by E2E.
});
it('Orbits → Events updates URL to /dunbar; then Network updates URL to /dunbar/network', () => {
mockRouter.setCurrentUrl('/dunbar/orbits');
render(<DunbarApp />);
const orbitsBtn = screen.getByRole('button', { name: /orbits/i });
expect(orbitsBtn.className).toMatch(/tabActive/);
const eventsBtn = screen.getByRole('button', { name: /events/i });
fireEvent.click(eventsBtn);
expect(mockRouter.asPath).toBe('/dunbar');
expect(eventsBtn.className).toMatch(/tabActive/);
const networkBtn = screen.getByRole('button', { name: /network/i });
fireEvent.click(networkBtn);
expect(mockRouter.asPath).toBe('/dunbar/network');
expect(networkBtn.className).toMatch(/tabActive/);
});
it('Stats does not alter URL and remains selected', () => {
mockRouter.setCurrentUrl('/dunbar');
render(<DunbarApp />);
const statsBtn = screen.getByRole('button', { name: /stats/i });
fireEvent.click(statsBtn);
expect(mockRouter.asPath).toBe('/dunbar');
// UI should remain on Stats; assert URL-only (visual is validated in E2E).
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment