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`.
......@@ -36,8 +36,6 @@ const RAW_RUNTIME_STATE =
["@types/node", "npm:24.4.0"],\
["@types/react", "npm:18.3.27"],\
["classnames", "npm:2.5.1"],\
["d3-force", "npm:3.0.0"],\
["d3-zoom", "npm:3.0.0"],\
["date-fns", "npm:3.6.0"],\
["gray-matter", "npm:4.0.3"],\
["hydra-synth", "npm:1.4.0"],\
......@@ -45,7 +43,6 @@ const RAW_RUNTIME_STATE =
["jest-environment-jsdom", "virtual:599f9545309f05166d506895056809d0f5971d8f55d13475681739816b4bb6745eb725b53ed2108e22ea4ce46b8a2c491882d38a9948455117b545da3880eea7#npm:30.2.0"],\
["jest-pnp-resolver", "virtual:599f9545309f05166d506895056809d0f5971d8f55d13475681739816b4bb6745eb725b53ed2108e22ea4ce46b8a2c491882d38a9948455117b545da3880eea7#npm:1.2.3"],\
["marked", "npm:15.0.12"],\
["minisearch", "npm:7.2.0"],\
["next", "virtual:599f9545309f05166d506895056809d0f5971d8f55d13475681739816b4bb6745eb725b53ed2108e22ea4ce46b8a2c491882d38a9948455117b545da3880eea7#npm:15.5.6"],\
["next-router-mock", "virtual:599f9545309f05166d506895056809d0f5971d8f55d13475681739816b4bb6745eb725b53ed2108e22ea4ce46b8a2c491882d38a9948455117b545da3880eea7#npm:1.0.4"],\
["p5", "npm:1.11.3"],\
......@@ -54,8 +51,6 @@ const RAW_RUNTIME_STATE =
["prismjs", "npm:1.30.0"],\
["react", "npm:18.3.1"],\
["react-dom", "virtual:599f9545309f05166d506895056809d0f5971d8f55d13475681739816b4bb6745eb725b53ed2108e22ea4ce46b8a2c491882d38a9948455117b545da3880eea7#npm:18.3.1"],\
["react-force-graph", "virtual:599f9545309f05166d506895056809d0f5971d8f55d13475681739816b4bb6745eb725b53ed2108e22ea4ce46b8a2c491882d38a9948455117b545da3880eea7#npm:1.48.1"],\
["react-force-graph-2d", "virtual:599f9545309f05166d506895056809d0f5971d8f55d13475681739816b4bb6745eb725b53ed2108e22ea4ce46b8a2c491882d38a9948455117b545da3880eea7#npm:1.29.0"],\
["react-icons", "virtual:599f9545309f05166d506895056809d0f5971d8f55d13475681739816b4bb6745eb725b53ed2108e22ea4ce46b8a2c491882d38a9948455117b545da3880eea7#npm:5.5.0"],\
["react-instantsearch", "virtual:599f9545309f05166d506895056809d0f5971d8f55d13475681739816b4bb6745eb725b53ed2108e22ea4ce46b8a2c491882d38a9948455117b545da3880eea7#npm:7.20.0"],\
["react-instantsearch-dom", "virtual:599f9545309f05166d506895056809d0f5971d8f55d13475681739816b4bb6745eb725b53ed2108e22ea4ce46b8a2c491882d38a9948455117b545da3880eea7#npm:6.40.4"],\
......@@ -65,8 +60,6 @@ const RAW_RUNTIME_STATE =
["react-syntax-highlighter", "virtual:599f9545309f05166d506895056809d0f5971d8f55d13475681739816b4bb6745eb725b53ed2108e22ea4ce46b8a2c491882d38a9948455117b545da3880eea7#npm:15.6.6"],\
["remark", "npm:14.0.3"],\
["remark-html", "npm:15.0.2"],\
["snowball-stemmers", "npm:0.6.0"],\
["stopword", "npm:3.1.5"],\
["swiper", "npm:11.2.10"],\
["tailwindcss", "npm:4.2.1"],\
["typescript", "patch:typescript@npm%3A5.9.3#optional!builtin<compat/typescript>::version=5.9.3&hash=d69c25"],\
......@@ -75,58 +68,6 @@ const RAW_RUNTIME_STATE =
"linkType": "SOFT"\
}]\
]],\
["3d-force-graph", [\
["npm:1.79.0", {\
"packageLocation": "../../../../.yarn/berry/cache/3d-force-graph-npm-1.79.0-dcf8376401-10c0.zip/node_modules/3d-force-graph/",\
"packageDependencies": [\
["3d-force-graph", "npm:1.79.0"],\
["accessor-fn", "npm:1.5.3"],\
["kapsule", "npm:1.16.3"],\
["three", "npm:0.181.2"],\
["three-forcegraph", "virtual:dcf83764012554cc781d32ff736f7621810c68c68f8a072267e931061144264f5198a4a885770f014b7ca110fc217374057af64fc9f9dd01f40bd0de37833825#npm:1.43.0"],\
["three-render-objects", "virtual:dcf83764012554cc781d32ff736f7621810c68c68f8a072267e931061144264f5198a4a885770f014b7ca110fc217374057af64fc9f9dd01f40bd0de37833825#npm:1.40.4"]\
],\
"linkType": "HARD"\
}]\
]],\
["3d-force-graph-ar", [\
["npm:1.10.0", {\
"packageLocation": "../../../../.yarn/berry/cache/3d-force-graph-ar-npm-1.10.0-30fc4f97b6-10c0.zip/node_modules/3d-force-graph-ar/",\
"packageDependencies": [\
["3d-force-graph-ar", "npm:1.10.0"],\
["aframe-forcegraph-component", "virtual:30fc4f97b6f70246669c280692127619223a7be31e78adc86cf49e461bf6245a99dcd201b44954d0d8e056dc10f0610810816e62b445dcb9278ca2738ada03fd#npm:3.3.0"],\
["kapsule", "npm:1.16.3"]\
],\
"linkType": "HARD"\
}]\
]],\
["3d-force-graph-vr", [\
["npm:3.1.1", {\
"packageLocation": "../../../../.yarn/berry/cache/3d-force-graph-vr-npm-3.1.1-265bc87af9-10c0.zip/node_modules/3d-force-graph-vr/",\
"packageDependencies": [\
["3d-force-graph-vr", "npm:3.1.1"]\
],\
"linkType": "SOFT"\
}],\
["virtual:4acc0a5386fec8fb24c768ef1e6538356029035fb8d06b9d2d3279efc67f7a4c889be7d50864b5f3af8d2634a472341e66970d2b365c6d5998215f417a73d672#npm:3.1.1", {\
"packageLocation": "./.yarn/__virtual__/3d-force-graph-vr-virtual-672952acf7/5/.yarn/berry/cache/3d-force-graph-vr-npm-3.1.1-265bc87af9-10c0.zip/node_modules/3d-force-graph-vr/",\
"packageDependencies": [\
["3d-force-graph-vr", "virtual:4acc0a5386fec8fb24c768ef1e6538356029035fb8d06b9d2d3279efc67f7a4c889be7d50864b5f3af8d2634a472341e66970d2b365c6d5998215f417a73d672#npm:3.1.1"],\
["@types/aframe", null],\
["accessor-fn", "npm:1.5.3"],\
["aframe", null],\
["aframe-extras", "npm:7.6.0"],\
["aframe-forcegraph-component", "virtual:30fc4f97b6f70246669c280692127619223a7be31e78adc86cf49e461bf6245a99dcd201b44954d0d8e056dc10f0610810816e62b445dcb9278ca2738ada03fd#npm:3.3.0"],\
["kapsule", "npm:1.16.3"],\
["polished", "npm:4.3.1"]\
],\
"packagePeers": [\
"@types/aframe",\
"aframe"\
],\
"linkType": "HARD"\
}]\
]],\
["@adobe/css-tools", [\
["npm:4.4.4", {\
"packageLocation": "../../../../.yarn/berry/cache/@adobe-css-tools-npm-4.4.4-a2900386bf-10c0.zip/node_modules/@adobe/css-tools/",\
......@@ -2599,15 +2540,6 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\
}]\
]],\
["@tweenjs/tween.js", [\
["npm:25.0.0", {\
"packageLocation": "../../../../.yarn/berry/cache/@tweenjs-tween.js-npm-25.0.0-f56533ae35-10c0.zip/node_modules/@tweenjs/tween.js/",\
"packageDependencies": [\
["@tweenjs/tween.js", "npm:25.0.0"]\
],\
"linkType": "HARD"\
}]\
]],\
["@tybys/wasm-util", [\
["npm:0.10.1", {\
"packageLocation": "../../../../.yarn/berry/cache/@tybys-wasm-util-npm-0.10.1-607c8a7e5c-10c0.zip/node_modules/@tybys/wasm-util/",\
......@@ -3405,15 +3337,6 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\
}]\
]],\
["accessor-fn", [\
["npm:1.5.3", {\
"packageLocation": "../../../../.yarn/berry/cache/accessor-fn-npm-1.5.3-89cff1f7d0-10c0.zip/node_modules/accessor-fn/",\
"packageDependencies": [\
["accessor-fn", "npm:1.5.3"]\
],\
"linkType": "HARD"\
}]\
]],\
["acorn", [\
["npm:8.15.0", {\
"packageLocation": "../../../../.yarn/berry/cache/acorn-npm-8.15.0-0764cf600e-10c0.zip/node_modules/acorn/",\
......@@ -3455,41 +3378,6 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\
}]\
]],\
["aframe-extras", [\
["npm:7.6.0", {\
"packageLocation": "../../../../.yarn/berry/cache/aframe-extras-npm-7.6.0-488e736634-10c0.zip/node_modules/aframe-extras/",\
"packageDependencies": [\
["aframe-extras", "npm:7.6.0"],\
["nipplejs", "npm:0.10.2"],\
["three", "npm:0.164.1"],\
["three-pathfinding", "virtual:488e736634bb0a21bba314b8c5c113cb55de70080222907da7a59fb3d018fa8ee0d163ffab3844a1be5c38d810a922f5f9c4d568f91a5d2058b6c79a24998b1a#npm:1.3.0"]\
],\
"linkType": "HARD"\
}]\
]],\
["aframe-forcegraph-component", [\
["npm:3.3.0", {\
"packageLocation": "../../../../.yarn/berry/cache/aframe-forcegraph-component-npm-3.3.0-dbeea04b49-10c0.zip/node_modules/aframe-forcegraph-component/",\
"packageDependencies": [\
["aframe-forcegraph-component", "npm:3.3.0"]\
],\
"linkType": "SOFT"\
}],\
["virtual:30fc4f97b6f70246669c280692127619223a7be31e78adc86cf49e461bf6245a99dcd201b44954d0d8e056dc10f0610810816e62b445dcb9278ca2738ada03fd#npm:3.3.0", {\
"packageLocation": "./.yarn/__virtual__/aframe-forcegraph-component-virtual-5690075d62/5/.yarn/berry/cache/aframe-forcegraph-component-npm-3.3.0-dbeea04b49-10c0.zip/node_modules/aframe-forcegraph-component/",\
"packageDependencies": [\
["aframe-forcegraph-component", "virtual:30fc4f97b6f70246669c280692127619223a7be31e78adc86cf49e461bf6245a99dcd201b44954d0d8e056dc10f0610810816e62b445dcb9278ca2738ada03fd#npm:3.3.0"],\
["@types/aframe", null],\
["aframe", null],\
["three-forcegraph", "virtual:5690075d626e0cc4be3393e95b9266f544e7fe2dff2012aab18519f9fe0c076e6773fde78abdac69aff3bb8724613d7f5fb91b4742e05a3136f701cbac3ca2c3#npm:1.43.0"]\
],\
"packagePeers": [\
"@types/aframe",\
"aframe"\
],\
"linkType": "HARD"\
}]\
]],\
["agent-base", [\
["npm:7.1.4", {\
"packageLocation": "../../../../.yarn/berry/cache/agent-base-npm-7.1.4-cb8b4604d5-10c0.zip/node_modules/agent-base/",\
......@@ -3907,15 +3795,6 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\
}]\
]],\
["bezier-js", [\
["npm:6.1.4", {\
"packageLocation": "../../../../.yarn/berry/cache/bezier-js-npm-6.1.4-30b1021772-10c0.zip/node_modules/bezier-js/",\
"packageDependencies": [\
["bezier-js", "npm:6.1.4"]\
],\
"linkType": "HARD"\
}]\
]],\
["bindings", [\
["npm:1.5.0", {\
"packageLocation": "../../../../.yarn/berry/cache/bindings-npm-1.5.0-77ce1d213c-10c0.zip/node_modules/bindings/",\
......@@ -4097,16 +3976,6 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\
}]\
]],\
["canvas-color-tracker", [\
["npm:1.3.2", {\
"packageLocation": "../../../../.yarn/berry/cache/canvas-color-tracker-npm-1.3.2-d5c8c0c0a5-10c0.zip/node_modules/canvas-color-tracker/",\
"packageDependencies": [\
["canvas-color-tracker", "npm:1.3.2"],\
["tinycolor2", "npm:1.6.0"]\
],\
"linkType": "HARD"\
}]\
]],\
["ccount", [\
["npm:2.0.1", {\
"packageLocation": "../../../../.yarn/berry/cache/ccount-npm-2.0.1-f4b7827860-10c0.zip/node_modules/ccount/",\
......@@ -4440,240 +4309,6 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\
}]\
]],\
["d3-array", [\
["npm:3.2.4", {\
"packageLocation": "../../../../.yarn/berry/cache/d3-array-npm-3.2.4-b427632bcc-10c0.zip/node_modules/d3-array/",\
"packageDependencies": [\
["d3-array", "npm:3.2.4"],\
["internmap", "npm:2.0.3"]\
],\
"linkType": "HARD"\
}]\
]],\
["d3-binarytree", [\
["npm:1.0.2", {\
"packageLocation": "../../../../.yarn/berry/cache/d3-binarytree-npm-1.0.2-21df6470a7-10c0.zip/node_modules/d3-binarytree/",\
"packageDependencies": [\
["d3-binarytree", "npm:1.0.2"]\
],\
"linkType": "HARD"\
}]\
]],\
["d3-color", [\
["npm:3.1.0", {\
"packageLocation": "../../../../.yarn/berry/cache/d3-color-npm-3.1.0-fc73fe3b15-10c0.zip/node_modules/d3-color/",\
"packageDependencies": [\
["d3-color", "npm:3.1.0"]\
],\
"linkType": "HARD"\
}]\
]],\
["d3-dispatch", [\
["npm:3.0.1", {\
"packageLocation": "../../../../.yarn/berry/cache/d3-dispatch-npm-3.0.1-5f44c3166f-10c0.zip/node_modules/d3-dispatch/",\
"packageDependencies": [\
["d3-dispatch", "npm:3.0.1"]\
],\
"linkType": "HARD"\
}]\
]],\
["d3-drag", [\
["npm:3.0.0", {\
"packageLocation": "../../../../.yarn/berry/cache/d3-drag-npm-3.0.0-cf7b48417f-10c0.zip/node_modules/d3-drag/",\
"packageDependencies": [\
["d3-drag", "npm:3.0.0"],\
["d3-dispatch", "npm:3.0.1"],\
["d3-selection", "npm:3.0.0"]\
],\
"linkType": "HARD"\
}]\
]],\
["d3-ease", [\
["npm:3.0.1", {\
"packageLocation": "../../../../.yarn/berry/cache/d3-ease-npm-3.0.1-f8f3709dc7-10c0.zip/node_modules/d3-ease/",\
"packageDependencies": [\
["d3-ease", "npm:3.0.1"]\
],\
"linkType": "HARD"\
}]\
]],\
["d3-force", [\
["npm:3.0.0", {\
"packageLocation": "../../../../.yarn/berry/cache/d3-force-npm-3.0.0-462e87e63b-10c0.zip/node_modules/d3-force/",\
"packageDependencies": [\
["d3-force", "npm:3.0.0"],\
["d3-dispatch", "npm:3.0.1"],\
["d3-quadtree", "npm:3.0.1"],\
["d3-timer", "npm:3.0.1"]\
],\
"linkType": "HARD"\
}]\
]],\
["d3-force-3d", [\
["npm:3.0.6", {\
"packageLocation": "../../../../.yarn/berry/cache/d3-force-3d-npm-3.0.6-a67f7fa842-10c0.zip/node_modules/d3-force-3d/",\
"packageDependencies": [\
["d3-force-3d", "npm:3.0.6"],\
["d3-binarytree", "npm:1.0.2"],\
["d3-dispatch", "npm:3.0.1"],\
["d3-octree", "npm:1.1.0"],\
["d3-quadtree", "npm:3.0.1"],\
["d3-timer", "npm:3.0.1"]\
],\
"linkType": "HARD"\
}]\
]],\
["d3-format", [\
["npm:3.1.0", {\
"packageLocation": "../../../../.yarn/berry/cache/d3-format-npm-3.1.0-dfc19924ca-10c0.zip/node_modules/d3-format/",\
"packageDependencies": [\
["d3-format", "npm:3.1.0"]\
],\
"linkType": "HARD"\
}]\
]],\
["d3-interpolate", [\
["npm:3.0.1", {\
"packageLocation": "../../../../.yarn/berry/cache/d3-interpolate-npm-3.0.1-77ddca7977-10c0.zip/node_modules/d3-interpolate/",\
"packageDependencies": [\
["d3-interpolate", "npm:3.0.1"],\
["d3-color", "npm:3.1.0"]\
],\
"linkType": "HARD"\
}]\
]],\
["d3-octree", [\
["npm:1.1.0", {\
"packageLocation": "../../../../.yarn/berry/cache/d3-octree-npm-1.1.0-8872132c0e-10c0.zip/node_modules/d3-octree/",\
"packageDependencies": [\
["d3-octree", "npm:1.1.0"]\
],\
"linkType": "HARD"\
}]\
]],\
["d3-quadtree", [\
["npm:3.0.1", {\
"packageLocation": "../../../../.yarn/berry/cache/d3-quadtree-npm-3.0.1-6f0eae8c83-10c0.zip/node_modules/d3-quadtree/",\
"packageDependencies": [\
["d3-quadtree", "npm:3.0.1"]\
],\
"linkType": "HARD"\
}]\
]],\
["d3-scale", [\
["npm:4.0.2", {\
"packageLocation": "../../../../.yarn/berry/cache/d3-scale-npm-4.0.2-d17a53447b-10c0.zip/node_modules/d3-scale/",\
"packageDependencies": [\
["d3-scale", "npm:4.0.2"],\
["d3-array", "npm:3.2.4"],\
["d3-format", "npm:3.1.0"],\
["d3-interpolate", "npm:3.0.1"],\
["d3-time", "npm:3.1.0"],\
["d3-time-format", "npm:4.1.0"]\
],\
"linkType": "HARD"\
}]\
]],\
["d3-scale-chromatic", [\
["npm:3.1.0", {\
"packageLocation": "../../../../.yarn/berry/cache/d3-scale-chromatic-npm-3.1.0-4c3af415f5-10c0.zip/node_modules/d3-scale-chromatic/",\
"packageDependencies": [\
["d3-scale-chromatic", "npm:3.1.0"],\
["d3-color", "npm:3.1.0"],\
["d3-interpolate", "npm:3.0.1"]\
],\
"linkType": "HARD"\
}]\
]],\
["d3-selection", [\
["npm:3.0.0", {\
"packageLocation": "../../../../.yarn/berry/cache/d3-selection-npm-3.0.0-39a42b4ca9-10c0.zip/node_modules/d3-selection/",\
"packageDependencies": [\
["d3-selection", "npm:3.0.0"]\
],\
"linkType": "HARD"\
}]\
]],\
["d3-time", [\
["npm:3.1.0", {\
"packageLocation": "../../../../.yarn/berry/cache/d3-time-npm-3.1.0-fb068fd1c9-10c0.zip/node_modules/d3-time/",\
"packageDependencies": [\
["d3-time", "npm:3.1.0"],\
["d3-array", "npm:3.2.4"]\
],\
"linkType": "HARD"\
}]\
]],\
["d3-time-format", [\
["npm:4.1.0", {\
"packageLocation": "../../../../.yarn/berry/cache/d3-time-format-npm-4.1.0-7f352c4634-10c0.zip/node_modules/d3-time-format/",\
"packageDependencies": [\
["d3-time-format", "npm:4.1.0"],\
["d3-time", "npm:3.1.0"]\
],\
"linkType": "HARD"\
}]\
]],\
["d3-timer", [\
["npm:3.0.1", {\
"packageLocation": "../../../../.yarn/berry/cache/d3-timer-npm-3.0.1-45083f465d-10c0.zip/node_modules/d3-timer/",\
"packageDependencies": [\
["d3-timer", "npm:3.0.1"]\
],\
"linkType": "HARD"\
}]\
]],\
["d3-transition", [\
["npm:3.0.1", {\
"packageLocation": "../../../../.yarn/berry/cache/d3-transition-npm-3.0.1-9191e0faaa-10c0.zip/node_modules/d3-transition/",\
"packageDependencies": [\
["d3-transition", "npm:3.0.1"]\
],\
"linkType": "SOFT"\
}],\
["virtual:18f706a42193e946c94fed31b041c1b3edf9dd829f726f09bd72737237836e0ad63cc96e0c6dabb6f7524017d4f379a5cadf8ea3af18ef71d652dae3788717b9#npm:3.0.1", {\
"packageLocation": "./.yarn/__virtual__/d3-transition-virtual-21aab1be90/5/.yarn/berry/cache/d3-transition-npm-3.0.1-9191e0faaa-10c0.zip/node_modules/d3-transition/",\
"packageDependencies": [\
["d3-transition", "virtual:18f706a42193e946c94fed31b041c1b3edf9dd829f726f09bd72737237836e0ad63cc96e0c6dabb6f7524017d4f379a5cadf8ea3af18ef71d652dae3788717b9#npm:3.0.1"],\
["@types/d3-selection", null],\
["d3-color", "npm:3.1.0"],\
["d3-dispatch", "npm:3.0.1"],\
["d3-ease", "npm:3.0.1"],\
["d3-interpolate", "npm:3.0.1"],\
["d3-selection", "npm:3.0.0"],\
["d3-timer", "npm:3.0.1"]\
],\
"packagePeers": [\
"@types/d3-selection",\
"d3-selection"\
],\
"linkType": "HARD"\
}]\
]],\
["d3-zoom", [\
["npm:3.0.0", {\
"packageLocation": "../../../../.yarn/berry/cache/d3-zoom-npm-3.0.0-18f706a421-10c0.zip/node_modules/d3-zoom/",\
"packageDependencies": [\
["d3-zoom", "npm:3.0.0"],\
["d3-dispatch", "npm:3.0.1"],\
["d3-drag", "npm:3.0.0"],\
["d3-interpolate", "npm:3.0.1"],\
["d3-selection", "npm:3.0.0"],\
["d3-transition", "virtual:18f706a42193e946c94fed31b041c1b3edf9dd829f726f09bd72737237836e0ad63cc96e0c6dabb6f7524017d4f379a5cadf8ea3af18ef71d652dae3788717b9#npm:3.0.1"]\
],\
"linkType": "HARD"\
}]\
]],\
["data-bind-mapper", [\
["npm:1.0.3", {\
"packageLocation": "../../../../.yarn/berry/cache/data-bind-mapper-npm-1.0.3-c9650bf860-10c0.zip/node_modules/data-bind-mapper/",\
"packageDependencies": [\
["data-bind-mapper", "npm:1.0.3"],\
["accessor-fn", "npm:1.5.3"]\
],\
"linkType": "HARD"\
}]\
]],\
["data-urls", [\
["npm:5.0.0", {\
"packageLocation": "../../../../.yarn/berry/cache/data-urls-npm-5.0.0-4b58b89bfe-10c0.zip/node_modules/data-urls/",\
......@@ -5526,42 +5161,6 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\
}]\
]],\
["float-tooltip", [\
["npm:1.7.5", {\
"packageLocation": "../../../../.yarn/berry/cache/float-tooltip-npm-1.7.5-91d9e4626c-10c0.zip/node_modules/float-tooltip/",\
"packageDependencies": [\
["float-tooltip", "npm:1.7.5"],\
["d3-selection", "npm:3.0.0"],\
["kapsule", "npm:1.16.3"],\
["preact", "npm:10.27.2"]\
],\
"linkType": "HARD"\
}]\
]],\
["force-graph", [\
["npm:1.51.0", {\
"packageLocation": "../../../../.yarn/berry/cache/force-graph-npm-1.51.0-4b9493dbff-10c0.zip/node_modules/force-graph/",\
"packageDependencies": [\
["force-graph", "npm:1.51.0"],\
["@tweenjs/tween.js", "npm:25.0.0"],\
["accessor-fn", "npm:1.5.3"],\
["bezier-js", "npm:6.1.4"],\
["canvas-color-tracker", "npm:1.3.2"],\
["d3-array", "npm:3.2.4"],\
["d3-drag", "npm:3.0.0"],\
["d3-force-3d", "npm:3.0.6"],\
["d3-scale", "npm:4.0.2"],\
["d3-scale-chromatic", "npm:3.1.0"],\
["d3-selection", "npm:3.0.0"],\
["d3-zoom", "npm:3.0.0"],\
["float-tooltip", "npm:1.7.5"],\
["index-array-by", "npm:1.4.2"],\
["kapsule", "npm:1.16.3"],\
["lodash-es", "npm:4.17.21"]\
],\
"linkType": "HARD"\
}]\
]],\
["foreground-child", [\
["npm:3.3.1", {\
"packageLocation": "../../../../.yarn/berry/cache/foreground-child-npm-3.3.1-b7775fda04-10c0.zip/node_modules/foreground-child/",\
......@@ -6177,15 +5776,6 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\
}]\
]],\
["index-array-by", [\
["npm:1.4.2", {\
"packageLocation": "../../../../.yarn/berry/cache/index-array-by-npm-1.4.2-fb0c86ee5c-10c0.zip/node_modules/index-array-by/",\
"packageDependencies": [\
["index-array-by", "npm:1.4.2"]\
],\
"linkType": "HARD"\
}]\
]],\
["inflight", [\
["npm:1.0.6", {\
"packageLocation": "../../../../.yarn/berry/cache/inflight-npm-1.0.6-ccedb4b908-10c0.zip/node_modules/inflight/",\
......@@ -6274,15 +5864,6 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\
}]\
]],\
["internmap", [\
["npm:2.0.3", {\
"packageLocation": "../../../../.yarn/berry/cache/internmap-npm-2.0.3-d74f5c9998-10c0.zip/node_modules/internmap/",\
"packageDependencies": [\
["internmap", "npm:2.0.3"]\
],\
"linkType": "HARD"\
}]\
]],\
["ip-address", [\
["npm:10.1.0", {\
"packageLocation": "../../../../.yarn/berry/cache/ip-address-npm-10.1.0-d5d5693401-10c0.zip/node_modules/ip-address/",\
......@@ -6573,15 +6154,6 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\
}]\
]],\
["jerrypick", [\
["npm:1.1.2", {\
"packageLocation": "../../../../.yarn/berry/cache/jerrypick-npm-1.1.2-e955b600f7-10c0.zip/node_modules/jerrypick/",\
"packageDependencies": [\
["jerrypick", "npm:1.1.2"]\
],\
"linkType": "HARD"\
}]\
]],\
["jest", [\
["npm:30.2.0", {\
"packageLocation": "../../../../.yarn/berry/cache/jest-npm-30.2.0-895a596e97-10c0.zip/node_modules/jest/",\
......@@ -7308,16 +6880,6 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\
}]\
]],\
["kapsule", [\
["npm:1.16.3", {\
"packageLocation": "../../../../.yarn/berry/cache/kapsule-npm-1.16.3-6595930c60-10c0.zip/node_modules/kapsule/",\
"packageDependencies": [\
["kapsule", "npm:1.16.3"],\
["lodash-es", "npm:4.17.21"]\
],\
"linkType": "HARD"\
}]\
]],\
["kind-of", [\
["npm:6.0.3", {\
"packageLocation": "../../../../.yarn/berry/cache/kind-of-npm-6.0.3-ab15f36220-10c0.zip/node_modules/kind-of/",\
......@@ -7493,15 +7055,6 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\
}]\
]],\
["lodash-es", [\
["npm:4.17.21", {\
"packageLocation": "../../../../.yarn/berry/cache/lodash-es-npm-4.17.21-b45832dfce-10c0.zip/node_modules/lodash-es/",\
"packageDependencies": [\
["lodash-es", "npm:4.17.21"]\
],\
"linkType": "HARD"\
}]\
]],\
["longest-streak", [\
["npm:3.1.0", {\
"packageLocation": "../../../../.yarn/berry/cache/longest-streak-npm-3.1.0-e2ab1c40ee-10c0.zip/node_modules/longest-streak/",\
......@@ -8553,15 +8106,6 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\
}]\
]],\
["minisearch", [\
["npm:7.2.0", {\
"packageLocation": "../../../../.yarn/berry/cache/minisearch-npm-7.2.0-6dca5d0e50-10c0.zip/node_modules/minisearch/",\
"packageDependencies": [\
["minisearch", "npm:7.2.0"]\
],\
"linkType": "HARD"\
}]\
]],\
["minizlib", [\
["npm:1.3.3", {\
"packageLocation": "../../../../.yarn/berry/cache/minizlib-npm-1.3.3-b590e5bfb8-10c0.zip/node_modules/minizlib/",\
......@@ -8761,64 +8305,6 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\
}]\
]],\
["ngraph.events", [\
["npm:1.4.0", {\
"packageLocation": "../../../../.yarn/berry/cache/ngraph.events-npm-1.4.0-c3f56886a0-10c0.zip/node_modules/ngraph.events/",\
"packageDependencies": [\
["ngraph.events", "npm:1.4.0"]\
],\
"linkType": "HARD"\
}]\
]],\
["ngraph.forcelayout", [\
["npm:3.3.1", {\
"packageLocation": "../../../../.yarn/berry/cache/ngraph.forcelayout-npm-3.3.1-670aa0593e-10c0.zip/node_modules/ngraph.forcelayout/",\
"packageDependencies": [\
["ngraph.forcelayout", "npm:3.3.1"],\
["ngraph.events", "npm:1.4.0"],\
["ngraph.merge", "npm:1.0.0"],\
["ngraph.random", "npm:1.2.0"]\
],\
"linkType": "HARD"\
}]\
]],\
["ngraph.graph", [\
["npm:20.1.1", {\
"packageLocation": "../../../../.yarn/berry/cache/ngraph.graph-npm-20.1.1-233881c5f7-10c0.zip/node_modules/ngraph.graph/",\
"packageDependencies": [\
["ngraph.graph", "npm:20.1.1"],\
["ngraph.events", "npm:1.4.0"]\
],\
"linkType": "HARD"\
}]\
]],\
["ngraph.merge", [\
["npm:1.0.0", {\
"packageLocation": "../../../../.yarn/berry/cache/ngraph.merge-npm-1.0.0-5bd1b34a67-10c0.zip/node_modules/ngraph.merge/",\
"packageDependencies": [\
["ngraph.merge", "npm:1.0.0"]\
],\
"linkType": "HARD"\
}]\
]],\
["ngraph.random", [\
["npm:1.2.0", {\
"packageLocation": "../../../../.yarn/berry/cache/ngraph.random-npm-1.2.0-da9391faab-10c0.zip/node_modules/ngraph.random/",\
"packageDependencies": [\
["ngraph.random", "npm:1.2.0"]\
],\
"linkType": "HARD"\
}]\
]],\
["nipplejs", [\
["npm:0.10.2", {\
"packageLocation": "../../../../.yarn/berry/cache/nipplejs-npm-0.10.2-3b066fb42d-10c0.zip/node_modules/nipplejs/",\
"packageDependencies": [\
["nipplejs", "npm:0.10.2"]\
],\
"linkType": "HARD"\
}]\
]],\
["node-fetch", [\
["npm:2.6.7", {\
"packageLocation": "../../../../.yarn/berry/cache/node-fetch-npm-2.6.7-777aa2a6df-10c0.zip/node_modules/node-fetch/",\
......@@ -9378,8 +8864,6 @@ const RAW_RUNTIME_STATE =
["@types/node", "npm:24.4.0"],\
["@types/react", "npm:18.3.27"],\
["classnames", "npm:2.5.1"],\
["d3-force", "npm:3.0.0"],\
["d3-zoom", "npm:3.0.0"],\
["date-fns", "npm:3.6.0"],\
["gray-matter", "npm:4.0.3"],\
["hydra-synth", "npm:1.4.0"],\
......@@ -9387,7 +8871,6 @@ const RAW_RUNTIME_STATE =
["jest-environment-jsdom", "virtual:599f9545309f05166d506895056809d0f5971d8f55d13475681739816b4bb6745eb725b53ed2108e22ea4ce46b8a2c491882d38a9948455117b545da3880eea7#npm:30.2.0"],\
["jest-pnp-resolver", "virtual:599f9545309f05166d506895056809d0f5971d8f55d13475681739816b4bb6745eb725b53ed2108e22ea4ce46b8a2c491882d38a9948455117b545da3880eea7#npm:1.2.3"],\
["marked", "npm:15.0.12"],\
["minisearch", "npm:7.2.0"],\
["next", "virtual:599f9545309f05166d506895056809d0f5971d8f55d13475681739816b4bb6745eb725b53ed2108e22ea4ce46b8a2c491882d38a9948455117b545da3880eea7#npm:15.5.6"],\
["next-router-mock", "virtual:599f9545309f05166d506895056809d0f5971d8f55d13475681739816b4bb6745eb725b53ed2108e22ea4ce46b8a2c491882d38a9948455117b545da3880eea7#npm:1.0.4"],\
["p5", "npm:1.11.3"],\
......@@ -9396,8 +8879,6 @@ const RAW_RUNTIME_STATE =
["prismjs", "npm:1.30.0"],\
["react", "npm:18.3.1"],\
["react-dom", "virtual:599f9545309f05166d506895056809d0f5971d8f55d13475681739816b4bb6745eb725b53ed2108e22ea4ce46b8a2c491882d38a9948455117b545da3880eea7#npm:18.3.1"],\
["react-force-graph", "virtual:599f9545309f05166d506895056809d0f5971d8f55d13475681739816b4bb6745eb725b53ed2108e22ea4ce46b8a2c491882d38a9948455117b545da3880eea7#npm:1.48.1"],\
["react-force-graph-2d", "virtual:599f9545309f05166d506895056809d0f5971d8f55d13475681739816b4bb6745eb725b53ed2108e22ea4ce46b8a2c491882d38a9948455117b545da3880eea7#npm:1.29.0"],\
["react-icons", "virtual:599f9545309f05166d506895056809d0f5971d8f55d13475681739816b4bb6745eb725b53ed2108e22ea4ce46b8a2c491882d38a9948455117b545da3880eea7#npm:5.5.0"],\
["react-instantsearch", "virtual:599f9545309f05166d506895056809d0f5971d8f55d13475681739816b4bb6745eb725b53ed2108e22ea4ce46b8a2c491882d38a9948455117b545da3880eea7#npm:7.20.0"],\
["react-instantsearch-dom", "virtual:599f9545309f05166d506895056809d0f5971d8f55d13475681739816b4bb6745eb725b53ed2108e22ea4ce46b8a2c491882d38a9948455117b545da3880eea7#npm:6.40.4"],\
......@@ -9407,8 +8888,6 @@ const RAW_RUNTIME_STATE =
["react-syntax-highlighter", "virtual:599f9545309f05166d506895056809d0f5971d8f55d13475681739816b4bb6745eb725b53ed2108e22ea4ce46b8a2c491882d38a9948455117b545da3880eea7#npm:15.6.6"],\
["remark", "npm:14.0.3"],\
["remark-html", "npm:15.0.2"],\
["snowball-stemmers", "npm:0.6.0"],\
["stopword", "npm:3.1.5"],\
["swiper", "npm:11.2.10"],\
["tailwindcss", "npm:4.2.1"],\
["typescript", "patch:typescript@npm%3A5.9.3#optional!builtin<compat/typescript>::version=5.9.3&hash=d69c25"],\
......@@ -9417,16 +8896,6 @@ const RAW_RUNTIME_STATE =
"linkType": "SOFT"\
}]\
]],\
["polished", [\
["npm:4.3.1", {\
"packageLocation": "../../../../.yarn/berry/cache/polished-npm-4.3.1-96b1782f82-10c0.zip/node_modules/polished/",\
"packageDependencies": [\
["polished", "npm:4.3.1"],\
["@babel/runtime", "npm:7.28.4"]\
],\
"linkType": "HARD"\
}]\
]],\
["postcss", [\
["npm:8.4.31", {\
"packageLocation": "../../../../.yarn/berry/cache/postcss-npm-8.4.31-385051a82b-10c0.zip/node_modules/postcss/",\
......@@ -9686,59 +9155,6 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\
}]\
]],\
["react-force-graph", [\
["npm:1.48.1", {\
"packageLocation": "../../../../.yarn/berry/cache/react-force-graph-npm-1.48.1-004f922e89-10c0.zip/node_modules/react-force-graph/",\
"packageDependencies": [\
["react-force-graph", "npm:1.48.1"]\
],\
"linkType": "SOFT"\
}],\
["virtual:599f9545309f05166d506895056809d0f5971d8f55d13475681739816b4bb6745eb725b53ed2108e22ea4ce46b8a2c491882d38a9948455117b545da3880eea7#npm:1.48.1", {\
"packageLocation": "./.yarn/__virtual__/react-force-graph-virtual-4acc0a5386/5/.yarn/berry/cache/react-force-graph-npm-1.48.1-004f922e89-10c0.zip/node_modules/react-force-graph/",\
"packageDependencies": [\
["react-force-graph", "virtual:599f9545309f05166d506895056809d0f5971d8f55d13475681739816b4bb6745eb725b53ed2108e22ea4ce46b8a2c491882d38a9948455117b545da3880eea7#npm:1.48.1"],\
["3d-force-graph", "npm:1.79.0"],\
["3d-force-graph-ar", "npm:1.10.0"],\
["3d-force-graph-vr", "virtual:4acc0a5386fec8fb24c768ef1e6538356029035fb8d06b9d2d3279efc67f7a4c889be7d50864b5f3af8d2634a472341e66970d2b365c6d5998215f417a73d672#npm:3.1.1"],\
["@types/react", "npm:18.3.27"],\
["force-graph", "npm:1.51.0"],\
["prop-types", "npm:15.8.1"],\
["react", "npm:18.3.1"],\
["react-kapsule", "virtual:298dd69d098e0e5d18897b06293f6f28547e223d8b3896f1ec2a0a3c455102a82094a2ebfb029d27acaa0ced7dfa6252e19beec30e3c696a4e8fbe805ed4e768#npm:2.5.7"]\
],\
"packagePeers": [\
"@types/react",\
"react"\
],\
"linkType": "HARD"\
}]\
]],\
["react-force-graph-2d", [\
["npm:1.29.0", {\
"packageLocation": "../../../../.yarn/berry/cache/react-force-graph-2d-npm-1.29.0-849c4fba8f-10c0.zip/node_modules/react-force-graph-2d/",\
"packageDependencies": [\
["react-force-graph-2d", "npm:1.29.0"]\
],\
"linkType": "SOFT"\
}],\
["virtual:599f9545309f05166d506895056809d0f5971d8f55d13475681739816b4bb6745eb725b53ed2108e22ea4ce46b8a2c491882d38a9948455117b545da3880eea7#npm:1.29.0", {\
"packageLocation": "./.yarn/__virtual__/react-force-graph-2d-virtual-298dd69d09/5/.yarn/berry/cache/react-force-graph-2d-npm-1.29.0-849c4fba8f-10c0.zip/node_modules/react-force-graph-2d/",\
"packageDependencies": [\
["react-force-graph-2d", "virtual:599f9545309f05166d506895056809d0f5971d8f55d13475681739816b4bb6745eb725b53ed2108e22ea4ce46b8a2c491882d38a9948455117b545da3880eea7#npm:1.29.0"],\
["@types/react", "npm:18.3.27"],\
["force-graph", "npm:1.51.0"],\
["prop-types", "npm:15.8.1"],\
["react", "npm:18.3.1"],\
["react-kapsule", "virtual:298dd69d098e0e5d18897b06293f6f28547e223d8b3896f1ec2a0a3c455102a82094a2ebfb029d27acaa0ced7dfa6252e19beec30e3c696a4e8fbe805ed4e768#npm:2.5.7"]\
],\
"packagePeers": [\
"@types/react",\
"react"\
],\
"linkType": "HARD"\
}]\
]],\
["react-icons", [\
["npm:5.5.0", {\
"packageLocation": "../../../../.yarn/berry/cache/react-icons-npm-5.5.0-906730a3cf-10c0.zip/node_modules/react-icons/",\
......@@ -9915,29 +9331,6 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\
}]\
]],\
["react-kapsule", [\
["npm:2.5.7", {\
"packageLocation": "../../../../.yarn/berry/cache/react-kapsule-npm-2.5.7-6828a31ccd-10c0.zip/node_modules/react-kapsule/",\
"packageDependencies": [\
["react-kapsule", "npm:2.5.7"]\
],\
"linkType": "SOFT"\
}],\
["virtual:298dd69d098e0e5d18897b06293f6f28547e223d8b3896f1ec2a0a3c455102a82094a2ebfb029d27acaa0ced7dfa6252e19beec30e3c696a4e8fbe805ed4e768#npm:2.5.7", {\
"packageLocation": "./.yarn/__virtual__/react-kapsule-virtual-3b52f7ce4f/5/.yarn/berry/cache/react-kapsule-npm-2.5.7-6828a31ccd-10c0.zip/node_modules/react-kapsule/",\
"packageDependencies": [\
["react-kapsule", "virtual:298dd69d098e0e5d18897b06293f6f28547e223d8b3896f1ec2a0a3c455102a82094a2ebfb029d27acaa0ced7dfa6252e19beec30e3c696a4e8fbe805ed4e768#npm:2.5.7"],\
["@types/react", "npm:18.3.27"],\
["jerrypick", "npm:1.1.2"],\
["react", "npm:18.3.1"]\
],\
"packagePeers": [\
"@types/react",\
"react"\
],\
"linkType": "HARD"\
}]\
]],\
["react-markdown", [\
["npm:10.1.0", {\
"packageLocation": "../../../../.yarn/berry/cache/react-markdown-npm-10.1.0-6f8037a507-10c0.zip/node_modules/react-markdown/",\
......@@ -10477,15 +9870,6 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\
}]\
]],\
["snowball-stemmers", [\
["npm:0.6.0", {\
"packageLocation": "../../../../.yarn/berry/cache/snowball-stemmers-npm-0.6.0-a7cb233480-10c0.zip/node_modules/snowball-stemmers/",\
"packageDependencies": [\
["snowball-stemmers", "npm:0.6.0"]\
],\
"linkType": "HARD"\
}]\
]],\
["socks", [\
["npm:2.8.7", {\
"packageLocation": "../../../../.yarn/berry/cache/socks-npm-2.8.7-d1d20aae19-10c0.zip/node_modules/socks/",\
......@@ -10601,15 +9985,6 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\
}]\
]],\
["stopword", [\
["npm:3.1.5", {\
"packageLocation": "../../../../.yarn/berry/cache/stopword-npm-3.1.5-5ae2cdc824-10c0.zip/node_modules/stopword/",\
"packageDependencies": [\
["stopword", "npm:3.1.5"]\
],\
"linkType": "HARD"\
}]\
]],\
["stream-parser", [\
["npm:0.3.1", {\
"packageLocation": "../../../../.yarn/berry/cache/stream-parser-npm-0.3.1-0b70187c85-10c0.zip/node_modules/stream-parser/",\
......@@ -10935,126 +10310,6 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\
}]\
]],\
["three", [\
["npm:0.164.1", {\
"packageLocation": "../../../../.yarn/berry/cache/three-npm-0.164.1-de48c90c28-10c0.zip/node_modules/three/",\
"packageDependencies": [\
["three", "npm:0.164.1"]\
],\
"linkType": "HARD"\
}],\
["npm:0.181.2", {\
"packageLocation": "../../../../.yarn/berry/cache/three-npm-0.181.2-7f0e2b3116-10c0.zip/node_modules/three/",\
"packageDependencies": [\
["three", "npm:0.181.2"]\
],\
"linkType": "HARD"\
}]\
]],\
["three-forcegraph", [\
["npm:1.43.0", {\
"packageLocation": "../../../../.yarn/berry/cache/three-forcegraph-npm-1.43.0-cdfed2cc3e-10c0.zip/node_modules/three-forcegraph/",\
"packageDependencies": [\
["three-forcegraph", "npm:1.43.0"]\
],\
"linkType": "SOFT"\
}],\
["virtual:5690075d626e0cc4be3393e95b9266f544e7fe2dff2012aab18519f9fe0c076e6773fde78abdac69aff3bb8724613d7f5fb91b4742e05a3136f701cbac3ca2c3#npm:1.43.0", {\
"packageLocation": "./.yarn/__virtual__/three-forcegraph-virtual-0cba4479a5/5/.yarn/berry/cache/three-forcegraph-npm-1.43.0-cdfed2cc3e-10c0.zip/node_modules/three-forcegraph/",\
"packageDependencies": [\
["three-forcegraph", "virtual:5690075d626e0cc4be3393e95b9266f544e7fe2dff2012aab18519f9fe0c076e6773fde78abdac69aff3bb8724613d7f5fb91b4742e05a3136f701cbac3ca2c3#npm:1.43.0"],\
["@types/three", null],\
["accessor-fn", "npm:1.5.3"],\
["d3-array", "npm:3.2.4"],\
["d3-force-3d", "npm:3.0.6"],\
["d3-scale", "npm:4.0.2"],\
["d3-scale-chromatic", "npm:3.1.0"],\
["data-bind-mapper", "npm:1.0.3"],\
["kapsule", "npm:1.16.3"],\
["ngraph.forcelayout", "npm:3.3.1"],\
["ngraph.graph", "npm:20.1.1"],\
["three", null],\
["tinycolor2", "npm:1.6.0"]\
],\
"packagePeers": [\
"@types/three",\
"three"\
],\
"linkType": "HARD"\
}],\
["virtual:dcf83764012554cc781d32ff736f7621810c68c68f8a072267e931061144264f5198a4a885770f014b7ca110fc217374057af64fc9f9dd01f40bd0de37833825#npm:1.43.0", {\
"packageLocation": "./.yarn/__virtual__/three-forcegraph-virtual-00930a6527/5/.yarn/berry/cache/three-forcegraph-npm-1.43.0-cdfed2cc3e-10c0.zip/node_modules/three-forcegraph/",\
"packageDependencies": [\
["three-forcegraph", "virtual:dcf83764012554cc781d32ff736f7621810c68c68f8a072267e931061144264f5198a4a885770f014b7ca110fc217374057af64fc9f9dd01f40bd0de37833825#npm:1.43.0"],\
["@types/three", null],\
["accessor-fn", "npm:1.5.3"],\
["d3-array", "npm:3.2.4"],\
["d3-force-3d", "npm:3.0.6"],\
["d3-scale", "npm:4.0.2"],\
["d3-scale-chromatic", "npm:3.1.0"],\
["data-bind-mapper", "npm:1.0.3"],\
["kapsule", "npm:1.16.3"],\
["ngraph.forcelayout", "npm:3.3.1"],\
["ngraph.graph", "npm:20.1.1"],\
["three", "npm:0.181.2"],\
["tinycolor2", "npm:1.6.0"]\
],\
"packagePeers": [\
"@types/three",\
"three"\
],\
"linkType": "HARD"\
}]\
]],\
["three-pathfinding", [\
["npm:1.3.0", {\
"packageLocation": "../../../../.yarn/berry/cache/three-pathfinding-npm-1.3.0-809247c748-10c0.zip/node_modules/three-pathfinding/",\
"packageDependencies": [\
["three-pathfinding", "npm:1.3.0"]\
],\
"linkType": "SOFT"\
}],\
["virtual:488e736634bb0a21bba314b8c5c113cb55de70080222907da7a59fb3d018fa8ee0d163ffab3844a1be5c38d810a922f5f9c4d568f91a5d2058b6c79a24998b1a#npm:1.3.0", {\
"packageLocation": "./.yarn/__virtual__/three-pathfinding-virtual-2b58c58af4/5/.yarn/berry/cache/three-pathfinding-npm-1.3.0-809247c748-10c0.zip/node_modules/three-pathfinding/",\
"packageDependencies": [\
["three-pathfinding", "virtual:488e736634bb0a21bba314b8c5c113cb55de70080222907da7a59fb3d018fa8ee0d163ffab3844a1be5c38d810a922f5f9c4d568f91a5d2058b6c79a24998b1a#npm:1.3.0"],\
["@types/three", null],\
["three", "npm:0.164.1"]\
],\
"packagePeers": [\
"@types/three",\
"three"\
],\
"linkType": "HARD"\
}]\
]],\
["three-render-objects", [\
["npm:1.40.4", {\
"packageLocation": "../../../../.yarn/berry/cache/three-render-objects-npm-1.40.4-44dee0fb6a-10c0.zip/node_modules/three-render-objects/",\
"packageDependencies": [\
["three-render-objects", "npm:1.40.4"]\
],\
"linkType": "SOFT"\
}],\
["virtual:dcf83764012554cc781d32ff736f7621810c68c68f8a072267e931061144264f5198a4a885770f014b7ca110fc217374057af64fc9f9dd01f40bd0de37833825#npm:1.40.4", {\
"packageLocation": "./.yarn/__virtual__/three-render-objects-virtual-9112b756ef/5/.yarn/berry/cache/three-render-objects-npm-1.40.4-44dee0fb6a-10c0.zip/node_modules/three-render-objects/",\
"packageDependencies": [\
["three-render-objects", "virtual:dcf83764012554cc781d32ff736f7621810c68c68f8a072267e931061144264f5198a4a885770f014b7ca110fc217374057af64fc9f9dd01f40bd0de37833825#npm:1.40.4"],\
["@tweenjs/tween.js", "npm:25.0.0"],\
["@types/three", null],\
["accessor-fn", "npm:1.5.3"],\
["float-tooltip", "npm:1.7.5"],\
["kapsule", "npm:1.16.3"],\
["polished", "npm:4.3.1"],\
["three", "npm:0.181.2"]\
],\
"packagePeers": [\
"@types/three",\
"three"\
],\
"linkType": "HARD"\
}]\
]],\
["time-span", [\
["npm:4.0.0", {\
"packageLocation": "../../../../.yarn/berry/cache/time-span-npm-4.0.0-10dd9f31f8-10c0.zip/node_modules/time-span/",\
......@@ -11065,15 +10320,6 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\
}]\
]],\
["tinycolor2", [\
["npm:1.6.0", {\
"packageLocation": "../../../../.yarn/berry/cache/tinycolor2-npm-1.6.0-8df41252c6-10c0.zip/node_modules/tinycolor2/",\
"packageDependencies": [\
["tinycolor2", "npm:1.6.0"]\
],\
"linkType": "HARD"\
}]\
]],\
["tinyglobby", [\
["npm:0.2.15", {\
"packageLocation": "../../../../.yarn/berry/cache/tinyglobby-npm-0.2.15-0e783aadbd-10c0.zip/node_modules/tinyglobby/",\
......
/* 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 { useDunbarStore } from '@/components/dunbar/useDunbarStore';
import dynamic from 'next/dynamic';
import { makeExportPayload } from '@/lib/dunbar';
import { generateDemoPayload } from '@/lib/dunbar-demo';
import { useRouter } from 'next/router';
import { friendSlug, eventSlug, resolveFriendBySlug } from '@/lib/dunbar';
// Lazy-load heavy tabs if needed (Network uses d3)
const NetworkTab = dynamic(() => import('@/components/dunbar/NetworkTab'), { ssr: false });
const OrbitsTab = dynamic(() => import('@/components/dunbar/OrbitsTab'), { ssr: false });
const EventsTab = dynamic(() => import('@/components/dunbar/EventsTab'), { ssr: false });
// Lightweight components
import FriendsList from '@/components/dunbar/FriendsList';
import FriendDetail from '@/components/dunbar/FriendDetail';
import StatsTab from '@/components/dunbar/StatsTab';
import SearchTab from '@/components/dunbar/SearchTab';
const PASSWORD = 'freehugs4all';
function Tabs({ tab, onTabChange }) {
const items = [
{ id: 'friends', label: 'Friends' },
{ id: 'search', label: 'Search' },
{ id: 'events', label: 'Events' },
{ id: 'orbits', label: 'Orbits' },
{ id: 'network', label: 'Network' },
{ id: 'stats', label: 'Stats' },
];
return (
<div className={styles.tabs}>
{items.map((it) => (
<button
key={it.id}
className={`${styles.tabBtn} ${tab === it.id ? styles.tabActive : ''}`}
onClick={() => onTabChange?.(it.id)}
>
{it.label}
</button>
))}
</div>
);
}
export default function DunbarApp() {
const router = useRouter();
const { state, friends, selectedFriendId, actions, derived } = useDunbarStore();
const [tab, setTab] = useState('friends');
const [authed, setAuthed] = useState(false);
const [lockError, setLockError] = useState('');
// Slug resolution banner for not found / collisions
const [notFoundSlug, setNotFoundSlug] = useState('');
const [collisionCandidates, setCollisionCandidates] = useState([]);
const friendsListScrollRef = useRef(0);
const fileInputRef = useRef(null);
// Password gate: prompt once per session
// Dev convenience: auto-unlock on localhost or NODE_ENV=development to enable automated preview
useEffect(() => {
if (typeof window === 'undefined') return;
const stored = window.sessionStorage.getItem('dunbarAuthed');
const isDev = process.env.NODE_ENV === 'development' || window.location.hostname === 'localhost';
if (stored === '1' || isDev) {
setAuthed(true);
return;
}
// show lock UI; user presses "Unlock" to prompt
}, []);
const handleUnlock = () => {
if (typeof window === 'undefined') return;
const ans = window.prompt('Enter password for Dunbar');
if (ans === PASSWORD) {
window.sessionStorage.setItem('dunbarAuthed', '1');
setAuthed(true);
setLockError('');
} else {
setLockError('Wrong password. Try again.');
}
};
const selectedFriend = useMemo(() => friends.find((f) => f.id === selectedFriendId) || null, [friends, selectedFriendId]);
// Export / Import
const onExport = () => {
try {
const payload = makeExportPayload(state);
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'dunbar-export.json';
a.click();
URL.revokeObjectURL(url);
} catch (e) {
// eslint-disable-next-line no-alert
alert('Export failed');
}
};
const onImportClick = () => fileInputRef.current?.click();
const onImportFile = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const text = await file.text();
const json = JSON.parse(text);
actions.loadFromPayload(json);
} catch {
// eslint-disable-next-line no-alert
alert('Invalid import file');
} finally {
e.target.value = '';
}
};
const onDemo = () => {
if (typeof window !== 'undefined') {
const ok = window.confirm('Remplacer les données actuelles par une démo de 50 profils ?');
if (!ok) return;
}
const payload = generateDemoPayload(50);
actions.loadFromPayload(payload);
};
// Navigation from viz → friend detail
const openFriendDetail = (friendId) => {
actions.selectFriend(friendId);
setTab('friends');
const f = friends.find((x) => x.id === friendId);
if (f) {
router.push(`/dunbar/friends/${friendSlug(f)}`, undefined, { shallow: true });
}
};
const openEventDetail = (evLike) => {
// evLike may be from merged index or minimal shape
const id = evLike?.id;
const e = (derived.eventIndex || []).find((x) => x.id === id) || evLike;
if (!e) return;
setTab('events');
actions.selectEvent(e.id);
router.push(`/dunbar/event/${eventSlug(e)}`, undefined, { shallow: true });
};
// Deep-link handling: friend/event + section routes hydrate initial tab/selection
useEffect(() => {
if (!router) return;
const as = router.asPath || '';
// Path-based deep links (only set tab when path encodes a section)
if (/\/dunbar\/friends(\/?$|\/)/.test(as)) {
setTab('friends');
} else if (/\/dunbar\/network(\/?$|\/)/.test(as)) {
setTab('network');
} else if (/\/dunbar\/orbits(\/?$|\/)/.test(as)) {
setTab('orbits');
} else if (/\/dunbar(\/?$)/.test(as)) {
// Root explicit → events
setTab('events');
}
// Note: no path for 'search' or 'stats' on purpose; do not override tab in those cases.
// friend by pretty slug under /dunbar/friends/:slug
const friendPretty = as.match(/\/dunbar\/friends\/([^/?#]+)/);
if (friendPretty) {
const slug = friendPretty[1];
const { match, collisions } = resolveFriendBySlug(friends, slug);
if (match) {
actions.selectFriend(match.id);
setNotFoundSlug('');
setCollisionCandidates([]);
setTab('friends');
} else if (collisions.length > 1) {
// present chooser and suggest deduplication
setNotFoundSlug(slug);
setCollisionCandidates(collisions);
setTab('friends');
} else {
// not found → show banner and stay on friends list
setNotFoundSlug(slug);
setCollisionCandidates([]);
setTab('friends');
}
return;
}
// legacy friend route (/dunbar/friend/:slug-idSuffix) — keep for backward compat
const friendLegacy = as.match(/\/dunbar\/friend\/([^/?#]+)/);
if (friendLegacy) {
const slug = friendLegacy[1];
// Try pretty resolver first (in case suffix-less was typed)
const { match, collisions } = resolveFriendBySlug(friends, slug);
if (match) {
actions.selectFriend(match.id);
setNotFoundSlug('');
setCollisionCandidates([]);
setTab('friends');
return;
}
// Fallback to suffix-based lookup (last 6 chars of id)
const suff = slug.split('-').pop();
const f = friends.find((x) => String(x.id).endsWith(suff));
if (f) {
actions.selectFriend(f.id);
setTab('friends');
return;
}
setNotFoundSlug(slug);
setCollisionCandidates(collisions || []);
setTab('friends');
return;
}
// event route (kept)
const eventMatch = as.match(/\/dunbar\/event\/([^/?#]+)/);
if (eventMatch) {
const slug = eventMatch[1];
const suff = slug.split('-').pop();
const e = (derived.eventIndex || []).find((x) => String(x.id).endsWith(suff));
if (e) {
setTab('events');
actions.selectEvent(e.id);
}
return;
}
}, [router?.asPath, friends, derived.eventIndex, actions]);
if (!authed) {
return (
<div className={styles.lockWrap}>
<h2 className={styles.title}>Dunbar</h2>
<p>Privacy-first relationship navigator. Local-only storage.</p>
<button className={styles.btn} onClick={handleUnlock}>Unlock</button>
{lockError ? <div style={{ color: '#b91c1c', marginTop: 8 }}>{lockError}</div> : null}
</div>
);
}
// Path-only URL sync per spec:
// /dunbar (events) • /dunbar/friends • /dunbar/friends/:slug • /dunbar/orbits • /dunbar/network
// Note: search & stats have no dedicated paths; don't touch URL for them to avoid snap-back.
const handleTabChange = (nextTab) => {
setTab(nextTab);
if (nextTab === 'friends') {
router.replace('/dunbar/friends', undefined, { shallow: true, scroll: false });
} else if (nextTab === 'orbits') {
router.replace('/dunbar/orbits', undefined, { shallow: true, scroll: false });
} else if (nextTab === 'network') {
router.replace('/dunbar/network', undefined, { shallow: true, scroll: false });
} else if (nextTab === 'events') {
router.replace('/dunbar', undefined, { shallow: true, scroll: false });
}
// For 'search' and 'stats' do nothing to URL (stay on current path)
};
return (
<div className={styles.container}>
<div className={styles.header}>
<div className={styles.row}>
<h1 className={styles.title}>Dunbar</h1>
<span className={styles.badge}>{friends.length} friends</span>
</div>
<div className={styles.toolbar}>
<button className={styles.btnSecondary} onClick={onExport}>Export</button>
<button className={styles.btnSecondary} onClick={onImportClick}>Import</button>
<input
ref={fileInputRef}
type="file"
accept="application/json"
onChange={onImportFile}
style={{ display: 'none' }}
/>
<button className={styles.btnSecondary} onClick={onDemo}>Demo</button>
<button className={styles.btnSecondary} onClick={actions.resetData}>Reset Data</button>
</div>
</div>
{/* Not-found / collisions banner (friends slug) */}
{notFoundSlug ? (
<div className={styles.banner} style={{ marginBottom: 8 }}>
{collisionCandidates.length > 1 ? (
<>
Multiple friends share the slug {notFoundSlug}. This is suspicious consider renaming duplicates.
<div className={styles.row} style={{ marginTop: 6, flexWrap: 'wrap' }}>
{collisionCandidates.slice(0, 6).map((f) => (
<button
key={f.id}
className={styles.btnSecondary}
onClick={() => {
actions.selectFriend(f.id);
setNotFoundSlug('');
setCollisionCandidates([]);
// update URL to pretty /dunbar/friends/:slug for the chosen one
router.push(`/dunbar/friends/${friendSlug(f)}`, undefined, { shallow: true });
}}
>
Open {f.name}
</button>
))}
</div>
</>
) : (
<>Friend {notFoundSlug} not found. Showing Friends list.</>
)}
</div>
) : null}
<Tabs tab={tab} onTabChange={handleTabChange} />
{tab === 'friends' && (
<div className={styles.twoCol}>
<div>
<FriendsList
friends={friends}
selectedFriendId={selectedFriendId}
onSelect={(id) => {
actions.selectFriend(id);
const f = friends.find((x) => x.id === id);
if (f) {
// Pretty URL for friend selection within Friends tab (no remount)
router.replace(
`/dunbar/friends/${friendSlug(f)}`,
undefined,
{ shallow: true, scroll: false }
);
}
}}
onAddFriend={(name) => actions.addFriend(name)}
onRemoveFriend={(id) => actions.removeFriend(id)}
onRename={(id, name) => actions.renameFriend(id, name)}
// preserve list scroll when opening/closing detail
onSaveScroll={(y) => (friendsListScrollRef.current = y)}
initialScroll={friendsListScrollRef.current}
getConnectionCount={(id) => derived ? null : null}
/>
</div>
<div>
<FriendDetail
friend={selectedFriend}
friends={friends}
onToggleRel={(a, b) => actions.toggleRelationship(a, b)}
onAddEvent={(payload) => actions.addEvent(payload)}
onUpdateEvent={(id, patch) => actions.updateEvent(id, patch)}
onRename={(id, name) => actions.renameFriend(id, name)}
onSetBirthday={(id, ymd) => actions.setBirthday(id, ymd)}
onSetNotes={(id, notes) => actions.setFriendNotes(id, notes)}
onUpdateFriend={(id, patch) => actions.updateFriend(id, patch)}
// scroll preservation inside relationship list handled internally
/>
</div>
</div>
)}
{tab === 'search' && (
<SearchTab
friends={friends}
openFriend={openFriendDetail}
openEvent={openEventDetail}
/>
)}
{tab === 'events' && (
<EventsTab
friends={friends}
addEvent={(payload) => actions.addEvent(payload)}
updateEvent={(id, patch) => actions.updateEvent(id, patch)}
selectedEventId={state.selectedEventId}
eventIndex={derived.eventIndex}
openEvent={openEventDetail}
/>
)}
{tab === 'orbits' && (
<OrbitsTab
friends={friends}
buckets={derived.orbitBuckets}
openFriendDetail={openFriendDetail}
/>
)}
{tab === 'network' && (
<NetworkTab
friends={friends}
toggleRel={(a, b) => actions.toggleRelationship(a, b)}
openFriendDetail={openFriendDetail}
/>
)}
{tab === 'stats' && (
<StatsTab
stats={derived.stats}
anniversaries={derived.anniversaries}
eventIndex={derived.eventIndex}
openFriend={openFriendDetail}
/>
)}
</div>
);
}
import { useMemo, useState } from 'react';
import styles from '@/styles/dunbar.module.css';
import {
todayISO,
yesterdayISO,
weekAgoISO,
startOfMonthISO,
groupEventsByDay,
isoDate,
} from '@/lib/dunbar';
import { extractTags } from '@/lib/dunbar';
import { detectLang, topKeywordsForDocs, extractTopics } from '@/lib/dunbar-nlp';
export default function EventsTab({ friends, addEvent, updateEvent, selectedEventId, eventIndex, openEvent }) {
// Creation form state
const [date, setDate] = useState(todayISO());
const [notes, setNotes] = useState('');
const [location, setLocation] = useState('');
const [title, setTitle] = useState('');
const [filter, setFilter] = useState('');
const [selected, setSelected] = useState(() => new Set());
const friendMap = useMemo(() => {
const m = new Map();
for (const f of friends) m.set(f.id, f);
return m;
}, [friends]);
const filteredFriends = useMemo(() => {
const q = filter.trim().toLowerCase();
if (!q) return friends;
return friends.filter((f) => f.name.toLowerCase().includes(q));
}, [friends, filter]);
const toggleFriend = (id) => {
const s = new Set(selected);
if (s.has(id)) s.delete(id);
else s.add(id);
setSelected(s);
};
const selectedCount = selected.size;
const canCreate = selectedCount > 0 && notes.trim().length > 0 && title.trim().length > 0;
const createEvent = () => {
if (!canCreate) return;
const dateISO = new Date(date).toISOString();
addEvent({
date: dateISO,
title: title.trim(),
notes: notes.trim(),
location: location.trim() || undefined,
participants: Array.from(selected),
});
// reset minimal fields, keep filter/selection to ease batch creation
setNotes('');
};
// Timeline groups from merged eventIndex
const groups = useMemo(() => groupEventsByDay(eventIndex), [eventIndex]);
// NLP: keywords per event (TF-IDF over corpus) and language guess
const keywordData = useMemo(() => {
const docs = (eventIndex || []).map((e) => ({
id: e.id,
text: `${e.title || ''} ${e.notes || ''}`,
}));
const corpusText = docs.map((d) => d.text).join(' ');
const lang = detectLang(corpusText) || null;
const top = topKeywordsForDocs(docs, { lang, topK: 6 });
const byId = new Map(top.map((d) => [d.id, d.keywords]));
return { byId, lang };
}, [eventIndex]);
// Topics (beta) — computed on demand
const [topics, setTopics] = useState([]);
const [topicsLoading, setTopicsLoading] = useState(false);
const runTopics = async () => {
try {
setTopicsLoading(true);
const docs = (eventIndex || []).map((e) => ({
id: e.id,
text: `${e.title || ''} ${e.notes || ''}`,
}));
const res = await extractTopics(docs, { topics: 5, termsPerTopic: 6, lang: keywordData.lang || null });
setTopics(res);
} finally {
setTopicsLoading(false);
}
};
// Selected event editor state
const selectedEvent = useMemo(
() => (selectedEventId ? (eventIndex || []).find((e) => e.id === selectedEventId) : null),
[eventIndex, selectedEventId]
);
const [edit, setEdit] = useState(() => ({
id: null,
date: todayISO(),
title: '',
notes: '',
location: '',
participants: new Set(),
}));
// Hydrate editor when selected changes
useMemo(() => {
if (!selectedEvent) return edit;
const e = selectedEvent;
setEdit({
id: e.id,
date: e.date,
title: e.title || '',
notes: e.notes || '',
location: e.location || '',
participants: new Set(e.participants || []),
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedEventId]);
const toggleEditParticipant = (id) => {
setEdit((prev) => {
const p = new Set(prev.participants);
if (p.has(id)) p.delete(id);
else p.add(id);
return { ...prev, participants: p };
});
};
const canSaveEdit = !!edit.id && String(edit.title || '').trim() && String(edit.notes || '').trim();
const saveEdit = () => {
if (!canSaveEdit) return;
updateEvent?.(edit.id, {
date: edit.date,
title: edit.title.trim(),
notes: edit.notes.trim(),
location: edit.location.trim() || undefined,
participants: Array.from(edit.participants),
});
};
// 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}</>;
};
return (
<div className={styles.twoCol} style={{ gap: 16 }}>
{/* New Event Builder */}
<div className={styles.card}>
<div className={styles.cardHeader}>
<span>New Event</span>
</div>
<div className={styles.row} style={{ gap: 8, marginBottom: 8, flexWrap: 'wrap' }}>
<button className={styles.btnSecondary} onClick={() => setDate(todayISO())}>Today</button>
<button className={styles.btnSecondary} onClick={() => setDate(yesterdayISO())}>Yesterday</button>
<button className={styles.btnSecondary} onClick={() => setDate(weekAgoISO())}>Week Ago</button>
<button className={styles.btnSecondary} onClick={() => setDate(startOfMonthISO())}>Start of Month</button>
</div>
<div className={styles.row} style={{ marginBottom: 8, flexWrap: 'wrap' }}>
<input
lang="fr-FR"
type="date"
className={styles.input}
value={date}
onChange={(e) => setDate(e.target.value)}
/>
<input
className={styles.input}
placeholder="Location (optional)"
value={location}
onChange={(e) => setLocation(e.target.value)}
style={{ minWidth: 160 }}
/>
</div>
<div className={styles.row} style={{ marginBottom: 8 }}>
<input
className={styles.input}
placeholder="Title (required)"
value={title}
onChange={(e) => setTitle(e.target.value)}
style={{ width: '100%' }}
/>
</div>
<div className={styles.row} style={{ marginBottom: 8 }}>
<textarea
className={styles.textarea}
placeholder="Notes (required)"
value={notes}
onChange={(e) => setNotes(e.target.value)}
style={{ width: '100%' }}
/>
</div>
<div className={styles.card} style={{ marginTop: 8 }}>
<div className={styles.cardHeader}>
<span>Friends ({selectedCount} selected)</span>
<input
className={styles.input}
placeholder="Search friends…"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</div>
<div className={styles.scroll}>
{filteredFriends.map((f) => {
const checked = selected.has(f.id);
return (
<label key={f.id} className={styles.switchRow} style={{ cursor: 'pointer' }}>
<input
type="checkbox"
checked={checked}
onChange={() => toggleFriend(f.id)}
/>
<span style={{ fontWeight: 600, marginLeft: 8 }}>{f.name}</span>
</label>
);
})}
{filteredFriends.length === 0 && (
<div style={{ padding: 8, color: '#666' }}>No matches.</div>
)}
</div>
</div>
<div className={styles.row} style={{ marginTop: 12 }}>
<button className={styles.btn} onClick={createEvent} disabled={!canCreate}>
Create Event
</button>
</div>
</div>
{/* Timeline + Editor */}
<div className={styles.card}>
<div className={styles.cardHeader}>
<span>Timeline</span>
<button
className={styles.btnSecondary}
onClick={runTopics}
disabled={topicsLoading || !(eventIndex || []).length}
title="Compute topics from titles+notes (local)"
>
{topicsLoading ? 'Topics…' : 'Topics (beta)'}
</button>
</div>
{topics && topics.length > 0 ? (
<div className={styles.tagRow} style={{ margin: '8px 0' }}>
{topics.map((t, i) => (
<span key={i} className={styles.tagChip} title={t.terms.map(([term]) => term).join(', ')}>
Topic {i + 1}: {t.terms.slice(0, 3).map(([term]) => term).join(' / ')}
</span>
))}
</div>
) : null}
{/* Event Editor */}
{selectedEvent ? (
<div className={styles.card} style={{ marginBottom: 12 }}>
<div className={styles.cardHeader}><span>Edit Event</span></div>
<div className={styles.row} style={{ marginBottom: 8, flexWrap: 'wrap' }}>
<input
lang="fr-FR"
type="date"
className={styles.input}
value={edit.date}
onChange={(e) => setEdit({ ...edit, date: e.target.value })}
/>
<input
className={styles.input}
placeholder="Location (optional)"
value={edit.location}
onChange={(e) => setEdit({ ...edit, location: e.target.value })}
style={{ minWidth: 160 }}
/>
</div>
<div className={styles.row} style={{ marginBottom: 8 }}>
<input
className={styles.input}
placeholder="Title (required)"
value={edit.title}
onChange={(e) => setEdit({ ...edit, title: e.target.value })}
style={{ width: '100%' }}
/>
</div>
<div className={styles.row} style={{ marginBottom: 8 }}>
<textarea
className={styles.textarea}
placeholder="Notes (required)"
value={edit.notes}
onChange={(e) => setEdit({ ...edit, notes: e.target.value })}
style={{ width: '100%' }}
/>
</div>
<div className={styles.card} style={{ marginTop: 8 }}>
<div className={styles.cardHeader}>
<span>Participants ({edit.participants.size})</span>
<input
className={styles.input}
placeholder="Filter…"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</div>
<div className={styles.scroll}>
{filteredFriends.map((f) => {
const checked = edit.participants.has(f.id);
return (
<label key={f.id} className={styles.switchRow} style={{ cursor: 'pointer' }}>
<input
type="checkbox"
checked={checked}
onChange={() => toggleEditParticipant(f.id)}
/>
<span style={{ fontWeight: 600, marginLeft: 8 }}>{f.name}</span>
</label>
);
})}
</div>
</div>
<div className={styles.row} style={{ marginTop: 12 }}>
<button className={styles.btn} onClick={saveEdit} disabled={!canSaveEdit}>
Save Changes
</button>
</div>
</div>
) : null}
<div className={styles.timeline}>
{groups.map((g) => (
<div key={g.dateKey} className={styles.timelineGroup}>
<div className={styles.timelineDate}>{g.label}</div>
{g.items.map((e) => {
const names = (e.participants || [])
.map((id) => friendMap.get(id)?.name)
.filter(Boolean);
return (
<div key={e.id + e.date} className={styles.timelineEvent}>
<div
style={{ fontWeight: 700, cursor: 'pointer' }}
onClick={() => openEvent?.(e)}
title="Open event details"
>
{e.title || '(untitled)'}
</div>
<div className={styles.itemMeta}>{names.join(', ') || 'Unknown'}</div>
<div style={{ whiteSpace: 'pre-wrap' }}>
{renderNotesWithTags(e.notes || '')}
</div>
{e.location ? (
<div style={{ color: '#555', marginTop: 4 }}>📍 {e.location}</div>
) : null}
{/* Tag chips */}
{(() => {
const tags = extractTags(e.notes || '');
return tags.length ? (
<div className={styles.tagRow}>
{tags.slice(0, 10).map((t) => (
<span key={t} className={styles.tagChip}>#{t}</span>
))}
</div>
) : null;
})()}
{(() => {
const kws = keywordData.byId.get(e.id) || [];
return kws.length ? (
<div className={styles.tagRow} style={{ marginTop: 4 }}>
{kws.slice(0, 6).map(([term]) => (
<span key={term} className={styles.tagChip}>{term}</span>
))}
</div>
) : null;
})()}
<div style={{ color: '#888', marginTop: 4, fontSize: 12 }}>
{isoDate(e.date)}
</div>
</div>
);
})}
</div>
))}
{groups.length === 0 && (
<div style={{ color: '#666' }}>No events yet create one on the left.</div>
)}
</div>
</div>
</div>
);
}
import { useEffect, useMemo, useRef, useState } from 'react';
import styles from '@/styles/dunbar.module.css';
import { isoDate, sortEventsDesc, extractTags, eventSlug } from '@/lib/dunbar';
export default function FriendDetail({
friend,
friends,
onToggleRel, // (aId, bId) => void
onAddEvent, // ({ date, notes, participants[], location? }) => void
onRename, // (id, name) => void
onSetBirthday, // (id, ymd) => void
onSetNotes, // (id, notes) => void
onUpdateFriend, // (id, patch) => void
}) {
const [date, setDate] = useState(() => new Date().toISOString().slice(0, 10));
const [notes, setNotes] = useState('');
const [location, setLocation] = useState('');
const [title, setTitle] = useState('');
const [friendNotes, setFriendNotes] = useState('');
const [birthday, setBirthday] = useState('');
const relScrollRef = useRef(null);
// Inline rename for friend's name
const [editing, setEditing] = useState(false);
const [editName, setEditName] = useState('');
const startEditName = () => {
if (!friend) return;
setEditing(true);
setEditName(friend.name || '');
};
const commitEditName = () => {
if (!friend) return;
const n = (editName || '').trim();
if (n) onRename?.(friend.id, n);
setEditing(false);
};
const cancelEditName = () => {
setEditing(false);
setEditName('');
};
// Sync friend notes and birthday when selection changes
useEffect(() => {
if (!friend) {
setFriendNotes('');
setBirthday('');
return;
}
setFriendNotes(friend.notes || '');
setBirthday(friend.birthday || '');
}, [friend]);
const saveFriendNotes = () => {
if (!friend) return;
onSetNotes?.(friend.id, friendNotes);
};
const saveBirthday = (val) => {
if (!friend) return;
const v = (val || '').slice(0, 10);
setBirthday(v);
onSetBirthday?.(friend.id, v);
};
// Rich profile helpers
const patch = (p) => {
if (!friend) return;
onUpdateFriend?.(friend.id, p);
};
// Important dates
const [idate, setIDate] = useState('');
const [ilabel, setILabel] = useState('');
const addImportantDate = () => {
if (!friend) return;
const d = (idate || '').slice(0, 10);
if (!d) return;
const arr = Array.isArray(friend.importantDates) ? [...friend.importantDates] : [];
arr.push({ date: d, label: ilabel || '' });
patch({ importantDates: arr });
setIDate('');
setILabel('');
};
const removeImportantDate = (idx) => {
if (!friend) return;
const arr = (friend.importantDates || []).filter((_, i) => i !== idx);
patch({ importantDates: arr });
};
// Gifts
const [g, setG] = useState({ date: '', occasion: '', description: '', image: '' });
const addGift = () => {
if (!friend) return;
const arr = Array.isArray(friend.gifts) ? [...friend.gifts] : [];
arr.push({
date: (g.date || '').slice(0, 10),
occasion: g.occasion || '',
description: g.description || '',
image: g.image || '',
});
patch({ gifts: arr });
setG({ date: '', occasion: '', description: '', image: '' });
};
const removeGift = (idx) => {
if (!friend) return;
const arr = (friend.gifts || []).filter((_, i) => i !== idx);
patch({ gifts: arr });
};
// Postcards
const [pc, setPC] = useState({ date: '', location: '', description: '', image: '' });
const addPostcard = () => {
if (!friend) return;
const arr = Array.isArray(friend.postcards) ? [...friend.postcards] : [];
arr.push({
date: (pc.date || '').slice(0, 10),
location: pc.location || '',
description: pc.description || '',
image: pc.image || '',
});
patch({ postcards: arr });
setPC({ date: '', location: '', description: '', image: '' });
};
const removePostcard = (idx) => {
if (!friend) return;
const arr = (friend.postcards || []).filter((_, i) => i !== idx);
patch({ postcards: arr });
};
// 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}</>;
};
// Scroll preservation for toggles: store + restore scrollTop across updates
const beforeToggle = useRef(0);
const restorePending = useRef(false);
useEffect(() => {
if (restorePending.current && relScrollRef.current) {
const st = beforeToggle.current;
// Restore next tick
const id = setTimeout(() => {
try {
relScrollRef.current.scrollTop = st;
} catch {}
restorePending.current = false;
}, 0);
return () => clearTimeout(id);
}
});
const others = useMemo(() => {
if (!friend) return [];
return friends.filter((f) => f.id !== friend.id);
}, [friend, friends]);
const friendRelSet = useMemo(() => {
return friend ? friend.relationships || new Set() : new Set();
}, [friend]);
const eventsDesc = useMemo(() => {
if (!friend) return [];
return sortEventsDesc(friend.events);
}, [friend]);
const onToggle = (targetId) => {
if (!friend) return;
if (relScrollRef.current) beforeToggle.current = relScrollRef.current.scrollTop;
restorePending.current = true;
onToggleRel?.(friend.id, targetId);
};
const submitEvent = () => {
if (!friend) return;
const dateISO = new Date(date).toISOString();
const n = notes.trim();
const t = title.trim();
if (!dateISO || !t || !n) return;
onAddEvent?.({
date: dateISO,
title: t,
notes: n,
location: location.trim() || undefined,
participants: [friend.id],
});
// reset notes/title only; keep date for faster entry
setNotes('');
setTitle('');
};
if (!friend) {
return (
<div className={styles.card}>
<div className={styles.cardHeader}>Friend details</div>
<div style={{ color: '#666' }}>Select a friend from the list to view and edit details.</div>
</div>
);
}
const evCount = Array.isArray(friend.events) ? friend.events.length : 0;
const connCount = friend.relationships ? friend.relationships.size : 0;
return (
<div className={styles.card}>
<div className={styles.cardHeader}>
<span>
{editing ? (
<input
className={styles.input}
value={editName}
autoFocus
onChange={(e) => setEditName(e.target.value)}
onBlur={commitEditName}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
commitEditName();
} else if (e.key === 'Escape') {
e.preventDefault();
cancelEditName();
}
}}
style={{ maxWidth: 260 }}
/>
) : (
<span
title="Click to rename"
onClick={startEditName}
style={{ cursor: 'text', fontWeight: 600 }}
>
{friend.name}
</span>
)}
</span>
<span className={styles.badge}>{evCount} events · {connCount} connections</span>
</div>
<div className={styles.row} style={{ gap: 8, margin: '8px 0', flexWrap: 'wrap' }}>
<label style={{ fontSize: 12, color: '#555' }}>Anniv:</label>
<input
lang="fr-FR"
type="date"
className={styles.input}
value={birthday || ''}
onChange={(e) => saveBirthday(e.target.value)}
/>
</div>
<div className={styles.twoCol} style={{ gap: 12 }}>
{/* Relationships */}
<div className={styles.card}>
<div className={styles.cardHeader}>
<span>Relationships ({others.length})</span>
</div>
<div ref={relScrollRef} className={styles.scroll}>
{others.map((o) => {
const onRel = friendRelSet.has(o.id);
return (
<div key={o.id} className={styles.switchRow}>
<div style={{ minWidth: 160, fontWeight: 600 }}>{o.name}</div>
<div
className={`${styles.switch} ${onRel ? styles.switchOn : ''}`}
onClick={() => onToggle(o.id)}
title={onRel ? 'Connected — click to remove' : 'Not connected — click to connect'}
style={{ cursor: 'pointer' }}
>
<div className={`${styles.knob} ${onRel ? styles.knobOn : ''}`} />
</div>
</div>
);
})}
{others.length === 0 && (
<div style={{ padding: 8, color: '#666' }}>No other friends to connect yet.</div>
)}
</div>
</div>
{/* Friend Notes */}
<div className={styles.card}>
<div className={styles.cardHeader}>
<span>Notes (ami)</span>
</div>
<div className={styles.row} style={{ marginBottom: 8 }}>
<textarea
className={styles.textarea}
placeholder="Notes libres sur l’ami·e (thèmes, centres d’intérêt, etc.)"
value={friendNotes}
onChange={(e) => setFriendNotes(e.target.value)}
onBlur={saveFriendNotes}
style={{ width: '100%' }}
/>
</div>
</div>
{/* Profil */}
<div className={styles.card}>
<div className={styles.cardHeader}><span>Profil</span></div>
{/* Likes/Dislikes */}
<div className={styles.row} style={{ marginBottom: 8, flexWrap: 'wrap' }}>
<input className={styles.input} placeholder="Aime"
value={friend?.likes || ''} onChange={(e) => patch({ likes: e.target.value })} style={{ minWidth: 200 }}/>
<input className={styles.input} placeholder="N'aime pas"
value={friend?.dislikes || ''} onChange={(e) => patch({ dislikes: e.target.value })} style={{ minWidth: 200 }}/>
</div>
{/* Food */}
<div className={styles.row} style={{ marginBottom: 8, flexWrap: 'wrap' }}>
<input className={styles.input} placeholder="Aime (nourriture/boissons)"
value={friend?.foodLikes || ''} onChange={(e) => patch({ foodLikes: e.target.value })} style={{ minWidth: 240 }}/>
<input className={styles.input} placeholder="N'aime pas (nourriture/boissons)"
value={friend?.foodDislikes || ''} onChange={(e) => patch({ foodDislikes: e.target.value })} style={{ minWidth: 240 }}/>
</div>
{/* General info */}
<div className={styles.row} style={{ marginBottom: 8, flexWrap: 'wrap' }}>
<input className={styles.input} placeholder="Mot de passe Wi‑Fi"
value={friend?.wifiPassword || ''} onChange={(e) => patch({ wifiPassword: e.target.value })}/>
<input className={styles.input} placeholder="Modèle de voiture"
value={friend?.carModel || ''} onChange={(e) => patch({ carModel: e.target.value })}/>
<input className={styles.input} placeholder="Lieu de travail"
value={friend?.workplace || ''} onChange={(e) => patch({ workplace: e.target.value })}/>
<input className={styles.input} placeholder="Emploi du temps"
value={friend?.schedule || ''} onChange={(e) => patch({ schedule: e.target.value })}/>
</div>
{/* Future ideas & quotes */}
<div className={styles.row} style={{ marginBottom: 8 }}>
<textarea className={styles.textarea} placeholder="Idées d'activités/sorties futures"
value={friend?.futureIdeas || ''} onChange={(e) => patch({ futureIdeas: e.target.value })} style={{ width: '100%' }}/>
</div>
<div className={styles.row} style={{ marginBottom: 8 }}>
<textarea className={styles.textarea} placeholder="Phrases cultes / blagues"
value={friend?.quotes || ''} onChange={(e) => patch({ quotes: e.target.value })} style={{ width: '100%' }}/>
</div>
{/* Important dates */}
<div className={styles.cardHeader}><span>Dates importantes</span></div>
<div className={styles.row} style={{ marginBottom: 6, flexWrap: 'wrap' }}>
<input lang="fr-FR" type="date" className={styles.input} value={idate} onChange={(e) => setIDate(e.target.value)}/>
<input className={styles.input} placeholder="Label" value={ilabel} onChange={(e) => setILabel(e.target.value)}/>
<button className={styles.btn} onClick={addImportantDate} disabled={!idate}>Ajouter</button>
</div>
<div className={styles.listScroll} style={{ maxHeight: '25vh' }}>
{(friend?.importantDates || []).map((d, idx) => (
<div key={idx} className={styles.switchRow}>
<div style={{ minWidth: 120, fontWeight: 600 }}>{d?.date || ''}</div>
<div style={{ flex: 1 }}>{d?.label || ''}</div>
<button className={styles.btnSecondary} onClick={() => removeImportantDate(idx)}>Supprimer</button>
</div>
))}
{(friend?.importantDates || []).length === 0 && <div style={{ padding: 8, color: '#666' }}>Aucune date.</div>}
</div>
{/* Gifts */}
<div className={styles.cardHeader}><span>Cadeaux offerts</span></div>
<div className={styles.row} style={{ marginBottom: 6, flexWrap: 'wrap' }}>
<input lang="fr-FR" type="date" className={styles.input} value={g.date} onChange={(e) => setG({ ...g, date: e.target.value })}/>
<input className={styles.input} placeholder="Occasion" value={g.occasion} onChange={(e) => setG({ ...g, occasion: e.target.value })}/>
<input className={styles.input} placeholder="Description" value={g.description} onChange={(e) => setG({ ...g, description: e.target.value })} style={{ minWidth: 240 }}/>
<input className={styles.input} placeholder="Image URL" value={g.image} onChange={(e) => setG({ ...g, image: e.target.value })}/>
<button className={styles.btn} onClick={addGift} disabled={!g.date && !g.description}>Ajouter</button>
</div>
<div className={styles.listScroll} style={{ maxHeight: '25vh' }}>
{(friend?.gifts || []).map((x, idx) => (
<div key={idx} className={styles.switchRow}>
<div style={{ minWidth: 110, fontWeight: 600 }}>{x?.date || ''}</div>
<div style={{ flex: 1 }}>{x?.occasion ? `${x.occasion} — ` : ''}{x?.description || ''}</div>
{x?.image ? <a href={x.image} target="_blank" rel="noreferrer" className={styles.itemMeta}>image</a> : null}
<button className={styles.btnSecondary} onClick={() => removeGift(idx)}>Supprimer</button>
</div>
))}
{(friend?.gifts || []).length === 0 && <div style={{ padding: 8, color: '#666' }}>Aucun cadeau.</div>}
</div>
{/* Postcards */}
<div className={styles.cardHeader}><span>Cartes postales</span></div>
<div className={styles.row} style={{ marginBottom: 6, flexWrap: 'wrap' }}>
<input lang="fr-FR" type="date" className={styles.input} value={pc.date} onChange={(e) => setPC({ ...pc, date: e.target.value })}/>
<input className={styles.input} placeholder="Lieu" value={pc.location} onChange={(e) => setPC({ ...pc, location: e.target.value })}/>
<input className={styles.input} placeholder="Description" value={pc.description} onChange={(e) => setPC({ ...pc, description: e.target.value })} style={{ minWidth: 240 }}/>
<input className={styles.input} placeholder="Image URL" value={pc.image} onChange={(e) => setPC({ ...pc, image: e.target.value })}/>
<button className={styles.btn} onClick={addPostcard}>Ajouter</button>
</div>
<div className={styles.listScroll} style={{ maxHeight: '25vh' }}>
{(friend?.postcards || []).map((x, idx) => (
<div key={idx} className={styles.switchRow}>
<div style={{ minWidth: 110, fontWeight: 600 }}>{x?.date || ''}</div>
<div style={{ flex: 1 }}>{x?.location ? `${x.location} — ` : ''}{x?.description || ''}</div>
{x?.image ? <a href={x.image} target="_blank" rel="noreferrer" className={styles.itemMeta}>image</a> : null}
<button className={styles.btnSecondary} onClick={() => removePostcard(idx)}>Supprimer</button>
</div>
))}
{(friend?.postcards || []).length === 0 && <div style={{ padding: 8, color: '#666' }}>Aucune carte.</div>}
</div>
</div>
{/* Events */}
<div className={styles.card}>
<div className={styles.cardHeader}>
<span>Events</span>
</div>
{/* Add Event */}
<div className={styles.row} style={{ marginBottom: 8, flexWrap: 'wrap' }}>
<input
lang="fr-FR"
type="date"
className={styles.input}
value={date}
onChange={(e) => setDate(e.target.value)}
/>
<input
className={styles.input}
placeholder="Location (optional)"
value={location}
onChange={(e) => setLocation(e.target.value)}
style={{ minWidth: 160 }}
/>
</div>
<div className={styles.row} style={{ marginBottom: 8 }}>
<input
className={styles.input}
placeholder="Title (required)"
value={title}
onChange={(e) => setTitle(e.target.value)}
style={{ width: '100%' }}
/>
</div>
<div className={styles.row} style={{ marginBottom: 8 }}>
<textarea
className={styles.textarea}
placeholder="Notes (required)"
value={notes}
onChange={(e) => setNotes(e.target.value)}
style={{ width: '100%' }}
/>
</div>
<div className={styles.row} style={{ marginBottom: 12 }}>
<button className={styles.btn} onClick={submitEvent} disabled={!title.trim() || !notes.trim()}>
Add Event
</button>
</div>
{/* Timeline */}
<div className={styles.timeline}>
{eventsDesc.map((e) => (
<div key={e.id + e.date} className={styles.timelineEvent}>
<div className={styles.timelineDate}>
{isoDate(e.date)}
</div>
<div
style={{ fontWeight: 700, cursor: 'pointer' }}
onClick={() => (window.location.href = `/dunbar/event/${eventSlug(e)}`)}
title="Open event details"
>
{e.title || '(untitled)'}
</div>
<div style={{ whiteSpace: 'pre-wrap' }}>
{renderNotesWithTags(e.notes || '')}
</div>
{e.location ? (
<div style={{ color: '#555', marginTop: 4 }}>📍 {e.location}</div>
) : null}
{/* Tag chips */}
{(() => {
const tags = extractTags(e.notes || '');
return tags.length ? (
<div className={styles.tagRow}>
{tags.slice(0, 10).map((t) => (
<span key={t} className={styles.tagChip}>#{t}</span>
))}
</div>
) : null;
})()}
</div>
))}
{eventsDesc.length === 0 && (
<div style={{ color: '#666' }}>No events yet add your first memory above.</div>
)}
</div>
</div>
</div>
</div>
);
}
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 { useEffect, useMemo, useRef, useState } from 'react';
import ForceGraph2D from 'react-force-graph-2d';
import { degreeMap, edgesFromFriends, clamp } from '@/lib/dunbar';
import styles from '@/styles/dunbar.module.css';
/**
* NetworkGraph — simplified, high-UX graph using react-force-graph-2d
* - Pan: drag background
* - Zoom: wheel
* - Drag node to reposition
* - Hover: native tooltip shows name + degree
* - Click: open profile via onOpenFriend
* - Toolbar actions exposed via imperative methods: resetView(), centerOn(id)
*/
export default function NetworkGraph({ friends, onOpenFriend }) {
const fgRef = useRef(null);
const [selectedId, setSelectedId] = useState(null);
const data = useMemo(() => {
const baseNodes = (friends || []).map((f) => ({ id: String(f.id), name: f.name }));
const baseLinks = edgesFromFriends(friends).map(([a, b]) => ({ source: String(a), target: String(b) }));
// Add center node "YOU" connected to all
const YOU_ID = '__YOU__';
const youNode = { id: YOU_ID, name: 'YOU' };
const youLinks = baseNodes.map((n) => ({ source: YOU_ID, target: n.id }));
const nodes = [youNode, ...baseNodes];
const links = [...youLinks, ...baseLinks];
const deg = degreeMap(friends);
return { nodes, links, deg, YOU_ID };
}, [friends]);
// Node sizing/coloring
const nodeRadius = (id) => {
if (id === data.YOU_ID) return 20;
const deg = data.deg.get(id) || 0;
return clamp(6 + deg * 0.8, 6, 18);
};
const nodeColor = (id) => {
if (id === data.YOU_ID) return '#1f2937'; // slate for YOU
const deg = data.deg.get(id) || 0;
if (deg >= 10) return '#2c5530'; // dark green
if (deg >= 5) return '#5a9960'; // medium green
if (deg >= 1) return '#a0c0a0'; // light green
return '#c0c0c0'; // gray
};
// Helpers exposed to parent via ref? Parent can call through fgRef directly.
const resetView = () => {
// Zoom to fit nicely
try {
fgRef.current?.zoomToFit(400, 40, (node) => true);
} catch {}
};
useEffect(() => {
// Initial settle & fit
const t = setTimeout(resetView, 500);
return () => clearTimeout(t);
}, [friends]);
const selectedNodeRef = useRef(null);
const centerOn = (id) => {
// Prefer the last known selected node object (has x/y from the engine)
const n = selectedNodeRef.current && String(selectedNodeRef.current.id) === String(id)
? selectedNodeRef.current
: null;
// Fallback: try to access nodes from the instance (some builds expose graphData as a property)
const nodesProp = fgRef.current?.graphData?.nodes || fgRef.current?.props?.graphData?.nodes || [];
const node = n || nodesProp.find((nn) => String(nn.id) === String(id)) || (data.nodes || []).find((nn) => String(nn.id) === String(id));
if (!node) return;
try {
const x = Number(node.x) || 0;
const y = Number(node.y) || 0;
fgRef.current?.centerAt(x, y, 400);
fgRef.current?.zoom(1, 400);
} catch {}
};
// Expose actions globally on the instance (optional)
// Consumers can still call via fgRef
// eslint-disable-next-line no-unused-vars
const actions = { resetView, centerOn };
// Compute distance-2/3 “via” info when a node is selected (simple BFS)
const viaInfo = useMemo(() => {
if (!selectedId) return null;
if (selectedId === data.YOU_ID) return null; // skip banner for YOU
const adj = new Map();
for (const n of data.nodes) adj.set(n.id, new Set());
for (const l of data.links) {
const a = String(l.source?.id ?? l.source);
const b = String(l.target?.id ?? l.target);
adj.get(a)?.add(b);
adj.get(b)?.add(a);
}
const start = String(selectedId);
const dist = new Map([[start, 0]]);
const via = new Map(); // nodeId -> first-hop id from start
const q = [start];
while (q.length) {
const cur = q.shift();
const d = dist.get(cur);
if (d >= 3) continue; // stop at distance 3
for (const nb of adj.get(cur) || []) {
if (!dist.has(nb)) {
dist.set(nb, d + 1);
// first-hop determination
via.set(nb, d === 0 ? nb : via.get(cur));
q.push(nb);
}
}
}
const depth2 = Array.from(dist.entries()).filter(([id, d]) => d === 2).map(([id]) => id);
const depth3 = Array.from(dist.entries()).filter(([id, d]) => d === 3).map(([id]) => id);
// Collect representative “via” names
const idToName = new Map(data.nodes.map(n => [String(n.id), n.name]));
const via2 = Array.from(new Set(depth2.map((id) => idToName.get(via.get(id)) || via.get(id)).filter(Boolean)));
const via3 = Array.from(new Set(depth3.map((id) => idToName.get(via.get(id)) || via.get(id)).filter(Boolean)));
return {
deg2: depth2.length,
deg3: depth3.length,
via2,
via3,
selectedName: idToName.get(start) || start,
};
}, [selectedId, data.nodes, data.links]);
const nodeLabel = (n) => {
const deg = data.deg.get(String(n.id)) || 0;
return `${n.name} — deg ${deg}`;
};
// Always-visible initials for better readability at any zoom
const getInitials = (name = '') => {
const parts = String(name).trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return '';
if (parts.length === 1) {
const p = parts[0];
// take first 2 letters if single token
return p.slice(0, 2).toUpperCase();
}
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
};
return (
<div className={styles.card} style={{ padding: 0, position: 'relative' }}>
<div className={styles.graphToolbar} style={{ padding: 8 }}>
<button className={styles.btnSecondary} onClick={() => { setSelectedId(null); resetView(); }}>
Reset
</button>
<button
className={styles.btnSecondary}
onClick={() => selectedId && centerOn(selectedId)}
disabled={!selectedId}
title="Center on selection"
>
Center
</button>
{selectedId ? <span className={styles.badge}>Selected: {data.nodes.find(n => n.id === selectedId)?.name}</span> : null}
</div>
{/* Distance banner */}
{viaInfo ? (
<div className={styles.banner} style={{ margin: '0 8px 8px' }}>
<span style={{ fontWeight: 700 }}>{viaInfo.selectedName}</span>{' '}
· Deg 2: {viaInfo.deg2} {viaInfo.via2.length ? `(via ${viaInfo.via2.slice(0, 3).join(', ')}${viaInfo.via2.length > 3 ? '…' : ''})` : ''}{' '}
· Deg 3: {viaInfo.deg3} {viaInfo.via3.length ? `(via ${viaInfo.via3.slice(0, 3).join(', ')}${viaInfo.via3.length > 3 ? '…' : ''})` : ''}
</div>
) : null}
<ForceGraph2D
ref={fgRef}
graphData={{ nodes: data.nodes, links: data.links }}
nodeRelSize={4}
linkColor={(link) => {
// Dim YOU-links slightly to keep focus on real connections
const a = String(link.source?.id ?? link.source);
const b = String(link.target?.id ?? link.target);
const isYouLink = a === data.YOU_ID || b === data.YOU_ID;
return isYouLink ? 'rgba(200,200,200,0.7)' : '#e0e0e0';
}}
linkWidth={(link) => {
if (!selectedId) return 1;
const a = String(link.source?.id ?? link.source);
const b = String(link.target?.id ?? link.target);
return (a === selectedId || b === selectedId) ? 2 : 1;
}}
cooldownTicks={200}
onEngineStop={() => {
// After layout, slightly zoom to fit
resetView();
}}
onNodeClick={(node) => {
const id = String(node.id);
setSelectedId(id);
selectedNodeRef.current = node;
centerOn(id);
if (id !== data.YOU_ID) onOpenFriend?.(id);
}}
onNodeHover={(node) => {
if (node) {
setSelectedId(String(node.id));
selectedNodeRef.current = node;
}
}}
nodeLabel={nodeLabel}
nodeCanvasObject={(node, ctx, globalScale) => {
const id = String(node.id);
const r = nodeRadius(id);
const isSel = selectedId && selectedId === id;
// Node circle
ctx.beginPath();
ctx.fillStyle = isSel && id !== data.YOU_ID ? '#2c5530' : nodeColor(id);
ctx.arc(node.x || 0, node.y || 0, r, 0, Math.PI * 2);
ctx.fill();
// Full label (scale-aware)
const fontSize = Math.max(6, 12 / Math.sqrt(globalScale));
if (isSel || globalScale < 2 || id === data.YOU_ID) {
ctx.font = `${fontSize}px system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillStyle = id === data.YOU_ID ? '#111' : '#333';
ctx.strokeStyle = '#fff';
ctx.lineWidth = Math.max(2, fontSize / 3);
const text = node.name || '';
ctx.strokeText(text, node.x || 0, (node.y || 0) - (r + 4));
ctx.fillText(text, node.x || 0, (node.y || 0) - (r + 4));
}
// Initials (always visible on top of the node)
const initials = id === data.YOU_ID ? 'YOU' : getInitials(node.name || '');
if (initials) {
const initFont = Math.max(7, r); // scale with node radius
ctx.font = `bold ${initFont}px system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.strokeStyle = 'rgba(255,255,255,0.95)';
ctx.lineWidth = Math.max(2, initFont / 3);
ctx.strokeText(initials, node.x || 0, node.y || 0);
ctx.fillStyle = '#111';
ctx.fillText(initials, node.x || 0, node.y || 0);
}
}}
/>
</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>
);
}
import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
import { normalizeImportedPayload, isoDate } from '@/lib/dunbar';
import { computeUpcomingAnniversaries } from '@/lib/dunbar';
const LS_KEY = 'dunbar-state-v1';
// Helpers
const uuid = () => (typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `id_${Date.now()}_${Math.random().toString(36).slice(2)}`);
const nowISO = () => new Date().toISOString();
function serializeState(state) {
const friends = state.friends.map((f) => ({
...f,
relationships: Array.from(f.relationships || []),
}));
return JSON.stringify({ friends, selectedFriendId: state.selectedFriendId });
}
function deserializeState(json) {
try {
const data = JSON.parse(json);
if (!data || !Array.isArray(data.friends)) return null;
const friends = data.friends.map((f) => ({
...f,
relationships: new Set(f.relationships || []),
events: Array.isArray(f.events) ? f.events : [],
}));
return { friends, selectedFriendId: data.selectedFriendId || null };
} catch {
return null;
}
}
function computeLastInteraction(friend) {
if (!friend.events || friend.events.length === 0) return null;
const max = friend.events.reduce((acc, e) => {
const t = new Date(e.date).getTime();
return Math.max(acc, isNaN(t) ? 0 : t);
}, 0);
return max ? new Date(max).toISOString() : null;
}
const initialState = {
friends: [],
selectedFriendId: null,
selectedEventId: null,
};
function reducer(state, action) {
switch (action.type) {
case 'LOAD': {
return action.payload || state;
}
case 'ADD_FRIEND': {
const name = String(action.payload?.name || '').trim();
if (!name) return state;
const newFriend = {
id: uuid(),
name,
birthday: null,
notes: '',
// rich profile fields
likes: [],
dislikes: [],
foodLikes: '',
foodDislikes: '',
wifiPassword: '',
carModel: '',
workplace: '',
schedule: '',
futureIdeas: [],
quotes: [],
projects: [],
importantDates: [], // [{ date: 'YYYY-MM-DD', label: string }]
gifts: [], // [{ date, occasion, description, image }]
postcards: [], // [{ date, location, description, image }]
relationships: new Set(),
events: [],
lastInteraction: null,
};
return { ...state, friends: [...state.friends, newFriend] };
}
case 'REMOVE_FRIEND': {
const id = action.payload?.id;
if (!id) return state;
const friends = state.friends
.filter((f) => f.id !== id)
.map((f) => {
if (f.relationships?.has(id)) {
const rel = new Set(f.relationships);
rel.delete(id);
return { ...f, relationships: rel };
}
return f;
});
return {
...state,
friends,
selectedFriendId: state.selectedFriendId === id ? null : state.selectedFriendId,
};
}
case 'RENAME_FRIEND': {
const { id, name } = action.payload || {};
if (!id) return state;
const n = String(name || '').trim();
if (!n) return state;
const friends = state.friends.map((f) => (f.id === id ? { ...f, name: n } : f));
return { ...state, friends };
}
case 'SET_BIRTHDAY': {
const { id, birthday } = action.payload || {};
if (!id) return state;
const b = birthday ? String(birthday).slice(0, 10) : null;
const friends = state.friends.map((f) => (f.id === id ? { ...f, birthday: b } : f));
return { ...state, friends };
}
case 'SET_FRIEND_NOTES': {
const { id, notes } = action.payload || {};
if (!id) return state;
const friends = state.friends.map((f) => (f.id === id ? { ...f, notes: String(notes || '') } : f));
return { ...state, friends };
}
case 'SELECT_FRIEND': {
const id = action.payload?.id ?? null;
return { ...state, selectedFriendId: id };
}
case 'SELECT_EVENT': {
const id = action.payload?.id ?? null;
return { ...state, selectedEventId: id };
}
case 'TOGGLE_REL': {
const a = action.payload?.aId;
const b = action.payload?.bId;
if (!a || !b || a === b) return state;
const friends = state.friends.map((f) => {
if (f.id === a) {
const rel = new Set(f.relationships || []);
rel.has(b) ? rel.delete(b) : rel.add(b);
return { ...f, relationships: rel };
}
if (f.id === b) {
const rel = new Set(f.relationships || []);
rel.has(a) ? rel.delete(a) : rel.add(a);
return { ...f, relationships: rel };
}
return f;
});
return { ...state, friends };
}
case 'ADD_EVENT': {
const { date, title, notes, participants = [], location } = action.payload || {};
// Normalize to YYYY-MM-DD (Paris local semantics handled at render/grouping time)
const dateStr = typeof date === 'string' ? date.slice(0, 10) : isoDate(date);
if (!dateStr || !String(title || '').trim() || !String(notes || '').trim() || !participants.length) return state;
const eventId = uuid();
// Create one logical event id applied to each participant for deduplication across views
const friends = state.friends.map((f) => {
if (participants.includes(f.id)) {
const ev = {
id: eventId,
date: dateStr,
title: String(title),
notes: String(notes),
location: location ? String(location) : undefined,
participants: [...participants],
};
const events = Array.isArray(f.events) ? [...f.events, ev] : [ev];
const lastInteraction = computeLastInteraction({ ...f, events });
return { ...f, events, lastInteraction };
}
return f;
});
return { ...state, friends, selectedEventId: eventId };
}
case 'UPDATE_EVENT': {
const { id, patch } = action.payload || {};
if (!id || !patch) return state;
// Find a canonical copy of the event to merge with
let canonical = null;
for (const f of state.friends) {
const found = (f.events || []).find((e) => e.id === id);
if (found) {
canonical = found;
break;
}
}
if (!canonical) return state;
const nextParticipants = Array.isArray(patch.participants)
? [...patch.participants]
: [...(canonical.participants || [])];
// Normalized updated event object
const updated = {
...canonical,
...patch,
participants: nextParticipants,
};
const participantSet = new Set(nextParticipants);
const friends = state.friends.map((f) => {
const hasBefore = (f.events || []).some((e) => e.id === id);
const shouldHave = participantSet.has(f.id);
// Remove if no longer participant
if (hasBefore && !shouldHave) {
const events = (f.events || []).filter((e) => e.id !== id);
const lastInteraction = computeLastInteraction({ ...f, events });
return { ...f, events, lastInteraction };
}
// Add if newly participant
if (!hasBefore && shouldHave) {
const events = Array.isArray(f.events) ? [...f.events, updated] : [updated];
const lastInteraction = computeLastInteraction({ ...f, events });
return { ...f, events, lastInteraction };
}
// Update if present and still participant
if (hasBefore && shouldHave) {
const events = (f.events || []).map((e) => (e.id === id ? updated : e));
const lastInteraction = computeLastInteraction({ ...f, events });
return { ...f, events, lastInteraction };
}
// Neither before nor after → unchanged
return f;
});
return { ...state, friends };
}
case 'UPDATE_FRIEND': {
const { id, patch } = action.payload || {};
if (!id || !patch) return state;
const friends = state.friends.map((f) => (f.id === id ? { ...f, ...patch } : f));
return { ...state, friends };
}
case 'RESET': {
return { ...initialState };
}
default:
return state;
}
}
/**
* Dunbar data store with localStorage persistence and derived helpers.
*/
export function useDunbarStore() {
const didInitRef = useRef(false);
const [state, dispatch] = useReducer(reducer, initialState);
// Load persisted state (client-only)
useEffect(() => {
if (didInitRef.current) return;
didInitRef.current = true;
if (typeof window === 'undefined') return;
const raw = window.localStorage.getItem(LS_KEY);
const loaded = raw ? deserializeState(raw) : null;
if (loaded) {
// Patch lastInteraction on load
const patched = {
...loaded,
friends: loaded.friends.map((f) => ({
...f,
lastInteraction: computeLastInteraction(f),
})),
};
dispatch({ type: 'LOAD', payload: patched });
}
}, []);
// Auto-save to localStorage on change
useEffect(() => {
if (typeof window === 'undefined') return;
try {
const serialized = serializeState(state);
window.localStorage.setItem(LS_KEY, serialized);
} catch {
// ignore
}
}, [state]);
// Actions
const addFriend = useCallback((name) => dispatch({ type: 'ADD_FRIEND', payload: { name } }), []);
const removeFriend = useCallback((id) => dispatch({ type: 'REMOVE_FRIEND', payload: { id } }), []);
const selectFriend = useCallback((id) => dispatch({ type: 'SELECT_FRIEND', payload: { id } }), []);
const toggleRelationship = useCallback((aId, bId) => dispatch({ type: 'TOGGLE_REL', payload: { aId, bId } }), []);
const addEvent = useCallback((payload) => dispatch({ type: 'ADD_EVENT', payload }), []);
const selectEvent = useCallback((id) => dispatch({ type: 'SELECT_EVENT', payload: { id } }), []);
const updateEvent = useCallback((id, patch) => dispatch({ type: 'UPDATE_EVENT', payload: { id, patch } }), []);
const resetData = useCallback(() => {
if (typeof window !== 'undefined') {
const ok = window.confirm('This will clear all Dunbar data. Continue?');
if (!ok) return;
window.localStorage.removeItem(LS_KEY);
}
dispatch({ type: 'RESET' });
}, []);
const renameFriend = useCallback((id, name) => {
dispatch({ type: 'RENAME_FRIEND', payload: { id, name } });
}, []);
const setBirthday = useCallback((id, birthday) => {
dispatch({ type: 'SET_BIRTHDAY', payload: { id, birthday } });
}, []);
const setFriendNotes = useCallback((id, notes) => {
dispatch({ type: 'SET_FRIEND_NOTES', payload: { id, notes } });
}, []);
const loadFromPayload = useCallback((payload) => {
try {
const normalized = normalizeImportedPayload(payload);
const patched = {
...normalized,
friends: normalized.friends.map((f) => ({
...f,
lastInteraction: computeLastInteraction(f),
})),
};
dispatch({ type: 'LOAD', payload: patched });
} catch (e) {
// eslint-disable-next-line no-console
console.error('Dunbar import failed', e);
}
}, []);
// Derived helpers
const friendMap = useMemo(() => {
const m = new Map();
for (const f of state.friends) m.set(f.id, f);
return m;
}, [state.friends]);
const getFriendById = useCallback((id) => friendMap.get(id) || null, [friendMap]);
const getConnectionCount = useCallback(
(id) => {
const f = friendMap.get(id);
return f?.relationships ? f.relationships.size : 0;
},
[friendMap]
);
const getEventCount = useCallback(
(id) => {
const f = friendMap.get(id);
return Array.isArray(f?.events) ? f.events.length : 0;
},
[friendMap]
);
// Stats
const stats = useMemo(() => {
const friends = state.friends;
let edges = 0;
let totalEvents = 0;
const now = Date.now();
const ninety = 90 * 24 * 60 * 60 * 1000;
let activeFriends = 0;
for (const f of friends) {
edges += (f.relationships?.size || 0);
const evCount = Array.isArray(f.events) ? f.events.length : 0;
totalEvents += evCount;
const hasRecent = (f.events || []).some((e) => now - new Date(e.date).getTime() <= ninety);
if (hasRecent) activeFriends += 1;
}
const uniqueConnections = Math.floor(edges / 2);
const avgEvents = friends.length ? +(totalEvents / friends.length).toFixed(2) : 0;
return {
connections: uniqueConnections,
activeFriends,
totalEvents,
avgEventsPerFriend: avgEvents,
};
}, [state.friends]);
// Orbits (close = 5+ in 90d OR 10+ in 365d OR 20+ total)
const orbitBuckets = useMemo(() => {
const now = Date.now();
const ninety = 90 * 24 * 60 * 60 * 1000;
const year = 365 * 24 * 60 * 60 * 1000;
const metrics = state.friends.map((f) => {
const evs = Array.isArray(f.events) ? f.events : [];
let c90 = 0;
let c365 = 0;
for (const e of evs) {
const t = new Date(e.date).getTime();
if (!isNaN(t)) {
const dt = now - t;
if (dt <= ninety) c90 += 1;
if (dt <= year) c365 += 1;
}
}
const total = evs.length;
return { id: f.id, c90, c365, total };
});
const inner = [];
const middle = [];
const outer = [];
for (const m of metrics) {
const isInner = m.c90 >= 5 || m.c365 >= 10 || m.total >= 20;
if (isInner) inner.push(m.id);
else if (m.c90 >= 2) middle.push(m.id);
else outer.push(m.id);
}
return { inner, middle, outer };
}, [state.friends]);
// Event index by id across all friends (for timeline dedup)
const eventIndex = useMemo(() => {
const idx = new Map();
for (const f of state.friends) {
for (const e of f.events || []) {
if (!idx.has(e.id)) {
idx.set(e.id, { ...e, participants: new Set(e.participants || []) });
} else {
const curr = idx.get(e.id);
for (const pid of e.participants || []) curr.participants.add(pid);
}
}
}
// Normalize participants back to array
const merged = Array.from(idx.values()).map((e) => ({ ...e, participants: Array.from(e.participants) }));
return merged;
}, [state.friends]);
// Anniversaries and reminders (next 21 days)
const anniversaries = useMemo(() => {
return computeUpcomingAnniversaries(state.friends, 21);
}, [state.friends]);
const updateFriend = useCallback((id, patch) => {
dispatch({ type: 'UPDATE_FRIEND', payload: { id, patch } });
}, []);
return {
state,
friends: state.friends,
selectedFriendId: state.selectedFriendId,
selectedEventId: state.selectedEventId,
actions: {
addFriend,
removeFriend,
renameFriend,
setBirthday,
setFriendNotes,
updateFriend,
selectFriend,
toggleRelationship,
addEvent,
selectEvent,
updateEvent,
resetData,
loadFromPayload,
},
helpers: {
getFriendById,
getConnectionCount,
getEventCount,
},
derived: {
stats,
orbitBuckets,
eventIndex,
anniversaries,
},
};
}
......@@ -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 Image from 'next/image';
// Variant A: Giant logo as background watermark, very low opacity, centered behind title
export function HeroA() {
return (
<section className="relative h-screen flex items-center justify-center overflow-hidden">
<div className="absolute inset-0 flex items-center justify-center">
<Image
src="/images/parvagues/logo_transparent.png"
alt=""
width={900}
height={900}
className="opacity-[0.07] select-none pointer-events-none"
style={{ filter: 'blur(1px)' }}
priority
/>
</div>
<div className="absolute inset-0" style={{ background: 'radial-gradient(ellipse at center, transparent 30%, var(--surface) 70%)' }} />
<div className="relative z-10 text-center px-6 w-full">
<h1
className="font-display font-extrabold leading-none tracking-tight"
style={{ fontSize: 'clamp(2.5rem, 11vw, 9rem)', textShadow: '0 0 80px rgba(217,0,255,0.2)' }}
>
ParVagues
</h1>
<div className="mx-auto mt-8 mb-8" style={{ width: '6rem', height: '1px', background: 'linear-gradient(to right, transparent, rgba(217,0,255,0.4), transparent)' }} />
<p className="text-base md:text-lg italic leading-relaxed max-w-xl mx-auto" style={{ color: 'var(--text-muted)' }}>
ParVagues, c&apos;est des ondes qui naissent dans un océan binaire pour parfois s&apos;échouer sur vos plages sonores.
</p>
<div className="mt-14 flex flex-col sm:flex-row gap-4 justify-center items-center">
<a href="#tour" className="inline-block px-8 py-3.5 bg-white font-display font-bold text-sm tracking-widest uppercase rounded-full transition-all duration-300" style={{ color: 'var(--surface)' }}>On Tour</a>
<a href="#music" className="inline-block px-8 py-3.5 border border-white/25 text-white font-display font-bold text-sm tracking-widest uppercase rounded-full hover:bg-white/10 transition-all duration-300">Écouter</a>
</div>
</div>
<div className="absolute bottom-10 left-1/2 -translate-x-1/2 z-10">
<div className="animate-pulse" style={{ width: '1px', height: '3rem', background: 'linear-gradient(to bottom, transparent, rgba(255,255,255,0.3))' }} />
</div>
</section>
);
}
// Variant B: Logo top-right, overlapping edge, neon glow, title left-aligned
export function HeroB() {
return (
<section className="relative h-screen flex items-center overflow-hidden">
<div className="absolute -right-20 -top-20 md:right-[-5%] md:top-[-5%] w-[60vw] h-[60vw] max-w-[700px] max-h-[700px]">
<Image
src="/images/parvagues/logo_transparent.png"
alt=""
fill
className="object-contain opacity-[0.08] select-none pointer-events-none"
style={{ filter: 'drop-shadow(0 0 60px rgba(217,0,255,0.15))' }}
priority
/>
</div>
<div className="relative z-10 px-6 md:px-16 max-w-5xl mx-auto w-full">
<h1
className="font-display font-extrabold leading-none tracking-tight text-left"
style={{ fontSize: 'clamp(2.5rem, 11vw, 9rem)', textShadow: '0 0 80px rgba(217,0,255,0.2)' }}
>
ParVagues
</h1>
<div className="mt-8 mb-8" style={{ width: '6rem', height: '1px', background: 'linear-gradient(to right, rgba(217,0,255,0.4), transparent)' }} />
<p className="text-base md:text-lg italic leading-relaxed max-w-xl" style={{ color: 'var(--text-muted)' }}>
ParVagues, c&apos;est des ondes qui naissent dans un océan binaire pour parfois s&apos;échouer sur vos plages sonores.
</p>
<div className="mt-14 flex flex-col sm:flex-row gap-4 items-start">
<a href="#tour" className="inline-block px-8 py-3.5 bg-white font-display font-bold text-sm tracking-widest uppercase rounded-full transition-all duration-300" style={{ color: 'var(--surface)' }}>On Tour</a>
<a href="#music" className="inline-block px-8 py-3.5 border border-white/25 text-white font-display font-bold text-sm tracking-widest uppercase rounded-full hover:bg-white/10 transition-all duration-300">Écouter</a>
</div>
</div>
<div className="absolute bottom-10 left-1/2 -translate-x-1/2 z-10">
<div className="animate-pulse" style={{ width: '1px', height: '3rem', background: 'linear-gradient(to bottom, transparent, rgba(255,255,255,0.3))' }} />
</div>
</section>
);
}
// Variant C: Logo replaces title entirely — big centered logo + subtitle below
export function HeroC() {
return (
<section className="relative h-screen flex items-center justify-center overflow-hidden">
<div className="absolute inset-0" style={{ background: 'radial-gradient(circle at 50% 40%, rgba(217,0,255,0.06) 0%, transparent 60%)' }} />
<div className="relative z-10 text-center px-6 w-full flex flex-col items-center">
<div className="relative w-48 h-48 md:w-72 md:h-72 mb-8">
<Image
src="/images/parvagues/logo_transparent.png"
alt="ParVagues"
fill
className="object-contain"
style={{ filter: 'drop-shadow(0 0 40px rgba(217,0,255,0.25))' }}
priority
/>
</div>
<h1
className="font-display font-extrabold leading-none tracking-[0.15em] uppercase"
style={{ fontSize: 'clamp(1.5rem, 5vw, 3.5rem)', textShadow: '0 0 60px rgba(217,0,255,0.15)' }}
>
ParVagues
</h1>
<div className="mx-auto mt-6 mb-6" style={{ width: '6rem', height: '1px', background: 'linear-gradient(to right, transparent, rgba(217,0,255,0.4), transparent)' }} />
<p className="text-base md:text-lg italic leading-relaxed max-w-xl mx-auto" style={{ color: 'var(--text-muted)' }}>
Des ondes qui naissent dans un océan binaire pour parfois s&apos;échouer sur vos plages sonores.
</p>
<div className="mt-14 flex flex-col sm:flex-row gap-4 justify-center items-center">
<a href="#tour" className="inline-block px-8 py-3.5 bg-white font-display font-bold text-sm tracking-widest uppercase rounded-full transition-all duration-300" style={{ color: 'var(--surface)' }}>On Tour</a>
<a href="#music" className="inline-block px-8 py-3.5 border border-white/25 text-white font-display font-bold text-sm tracking-widest uppercase rounded-full hover:bg-white/10 transition-all duration-300">Écouter</a>
</div>
</div>
<div className="absolute bottom-10 left-1/2 -translate-x-1/2 z-10">
<div className="animate-pulse" style={{ width: '1px', height: '3rem', background: 'linear-gradient(to bottom, transparent, rgba(255,255,255,0.3))' }} />
</div>
</section>
);
}
// Variant D: Full-bleed tiled/scaled logo as texture, neon-tinted, title overlaid
export function HeroD() {
return (
<section className="relative h-screen flex items-center justify-center overflow-hidden">
<div className="absolute inset-0">
<Image
src="/images/parvagues/logo.png"
alt=""
fill
className="object-cover opacity-[0.12] select-none pointer-events-none"
style={{ filter: 'hue-rotate(-20deg) saturate(1.5)' }}
priority
/>
<div className="absolute inset-0" style={{ background: 'linear-gradient(to bottom, var(--surface) 0%, rgba(10,10,10,0.7) 30%, rgba(10,10,10,0.7) 70%, var(--surface) 100%)' }} />
</div>
<div className="relative z-10 text-center px-6 w-full">
<h1
className="font-display font-extrabold leading-none tracking-tight"
style={{ fontSize: 'clamp(2.5rem, 11vw, 9rem)', textShadow: '0 0 80px rgba(217,0,255,0.3), 0 0 160px rgba(217,0,255,0.1)' }}
>
ParVagues
</h1>
<div className="mx-auto mt-8 mb-8" style={{ width: '6rem', height: '1px', background: 'linear-gradient(to right, transparent, rgba(217,0,255,0.4), transparent)' }} />
<p className="text-base md:text-lg italic leading-relaxed max-w-xl mx-auto" style={{ color: 'var(--text-muted)' }}>
ParVagues, c&apos;est des ondes qui naissent dans un océan binaire pour parfois s&apos;échouer sur vos plages sonores.
</p>
<div className="mt-14 flex flex-col sm:flex-row gap-4 justify-center items-center">
<a href="#tour" className="inline-block px-8 py-3.5 bg-white font-display font-bold text-sm tracking-widest uppercase rounded-full transition-all duration-300" style={{ color: 'var(--surface)' }}>On Tour</a>
<a href="#music" className="inline-block px-8 py-3.5 border border-white/25 text-white font-display font-bold text-sm tracking-widest uppercase rounded-full hover:bg-white/10 transition-all duration-300">Écouter</a>
</div>
</div>
<div className="absolute bottom-10 left-1/2 -translate-x-1/2 z-10">
<div className="animate-pulse" style={{ width: '1px', height: '3rem', background: 'linear-gradient(to bottom, transparent, rgba(255,255,255,0.3))' }} />
</div>
</section>
);
}
// Variant E: Split — logo left half, text right half, horizontal layout
export function HeroE() {
return (
<section className="relative h-screen flex items-center overflow-hidden">
<div className="absolute inset-0" style={{ background: 'radial-gradient(ellipse at 25% 50%, rgba(217,0,255,0.05) 0%, transparent 50%)' }} />
<div className="relative z-10 w-full max-w-6xl mx-auto px-6 grid md:grid-cols-2 gap-8 items-center">
<div className="flex justify-center md:justify-end">
<div className="relative w-64 h-64 md:w-96 md:h-96">
<Image
src="/images/parvagues/logo_transparent.png"
alt="ParVagues"
fill
className="object-contain"
style={{ filter: 'drop-shadow(0 0 50px rgba(217,0,255,0.2))' }}
priority
/>
</div>
</div>
<div className="text-center md:text-left">
<h1
className="font-display font-extrabold leading-none tracking-tight"
style={{ fontSize: 'clamp(2.5rem, 8vw, 7rem)', textShadow: '0 0 80px rgba(217,0,255,0.2)' }}
>
ParVagues
</h1>
<div className="mt-6 mb-6 mx-auto md:mx-0" style={{ width: '6rem', height: '1px', background: 'linear-gradient(to right, rgba(217,0,255,0.4), transparent)' }} />
<p className="text-base md:text-lg italic leading-relaxed max-w-md mx-auto md:mx-0" style={{ color: 'var(--text-muted)' }}>
Des ondes qui naissent dans un océan binaire pour parfois s&apos;échouer sur vos plages sonores.
</p>
<div className="mt-10 flex flex-col sm:flex-row gap-4 justify-center md:justify-start">
<a href="#tour" className="inline-block px-8 py-3.5 bg-white font-display font-bold text-sm tracking-widest uppercase rounded-full transition-all duration-300" style={{ color: 'var(--surface)' }}>On Tour</a>
<a href="#music" className="inline-block px-8 py-3.5 border border-white/25 text-white font-display font-bold text-sm tracking-widest uppercase rounded-full hover:bg-white/10 transition-all duration-300">Écouter</a>
</div>
</div>
</div>
<div className="absolute bottom-10 left-1/2 -translate-x-1/2 z-10">
<div className="animate-pulse" style={{ width: '1px', height: '3rem', background: 'linear-gradient(to bottom, transparent, rgba(255,255,255,0.3))' }} />
</div>
</section>
);
}
// Demo dataset generator for Dunbar — 50 French profiles with clusters, relationships, events, birthdays, and rich profile fields.
// Usage:
// import { generateDemoPayload } from '@/lib/dunbar-demo';
// const payload = generateDemoPayload(50);
// actions.loadFromPayload(payload);
function randInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function sample(arr) {
return arr[randInt(0, arr.length - 1)];
}
function pickN(arr, n) {
const a = [...arr];
const out = [];
n = Math.min(n, a.length);
for (let i = 0; i < n; i++) {
const idx = randInt(0, a.length - 1);
out.push(a[idx]);
a.splice(idx, 1);
}
return out;
}
function uid(prefix = 'id') {
return `${prefix}_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`;
}
function toYMD(d) {
const yyyy = d.getUTCFullYear();
const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
const dd = String(d.getUTCDate()).padStart(2, '0');
return `${yyyy}-${mm}-${dd}`;
}
function daysAgo(n) {
const d = new Date();
d.setUTCDate(d.getUTCDate() - n);
// snap to UTC midnight
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
}
// Name pools — French + diverse origins
const FIRST_NAMES = [
'Chloé','Lucas','Inès','Mehdi','Aya','Léa','Adam','Camille','Noah','Fatou','Hugo','Nina','Yanis','Aïcha',
'Théo','Manon','Rayan','Jiwoo','Lina','Sofiane','Océane','Moussa','Clara','Youssef','Tao','Sacha','Zoé',
'Naïm','Imane','Omar','Yara','Rachid','Samira','Lucie','Alexandre','Selma','Pierre','Karim','Leïla','Oce',
'Val','Raph','Kyu','Igor','Sonia','Oceane','Mathis','Maëlys','Nora','Mina','Maya'
];
const LAST_NAMES = [
'Dupont','Martin','Bernard','Durand','Moreau','Lefebvre','Fournier','Mercier','Faure','Andre',
'Benali','El Mansouri','Traoré','Diop','Nguyen','Zhang','Haddad','Khan','Rossi','Gonzalez',
'Peigné','Mahé Millet','Porte','Lefèvre','Petit','Renaud','Barbier','Lemaire','Noël','Boucher'
];
// Parisian clichés and tags
const PLACES = [
'Canal Saint-Martin','Buttes-Chaumont','Montmartre','Bourse de Commerce','Belleville','Le Marais','Station F',
'Parc Monceau','Jardin du Luxembourg','Bercy','Pont des Arts','Bastille','Aligre','Saint-Germain','Parc de la Villette'
];
const ACTIVITIES = [
'apéro','expo','vernissage','vélo','café','pique-nique','concert','ciné','footing','meetup','brunch','fromages','boulot'
];
const TAGS = ['#apero','#expo','#vernissage','#velo','#run','#cafe','#picnic','#concert','#cine','#meetup','#famille','#boulot','#amis','#dodo','#art'];
const QUOTES = [
'Toujours partant·e pour un café !','Jamais sans mon vélo.','Le fromage, c’est la vie.','On se fait un apéro ?',
'Team Boulot-Boulot-Boulot.','Paris sous la pluie, c’est mieux.','Vélib’ et liberté.','Vivement le week-end !'
];
const FOOD_LIKES = ['fromage','pain','viennoiseries','tapas','sushi','ramen','couscous','tajine','falafel','galettes','crêpes','pizza','pasta'];
const FOOD_DISLIKES = ['choux de Bruxelles','réglisse','coriandre','anchois','abats'];
const CAR_MODELS = ['Twingo','Clio','208','Model 3','Zoé','C3','Yaris','Micra','206','Golf','Corsa'];
const WORKPLACES = ['Station F','Quartier Latour-Maubourg','La Défense','Bercy','Montparnasse','Opéra','Bastille','République','Nation'];
function makeNamePool(count) {
const names = new Set();
while (names.size < count) {
names.add(`${sample(FIRST_NAMES)} ${sample(LAST_NAMES)}`);
}
return Array.from(names);
}
function generateClusters(count, minSize = 8, maxSize = 14) {
const clusters = [];
let remaining = count;
while (remaining > 0) {
const size = Math.min(remaining, randInt(minSize, maxSize));
clusters.push(size);
remaining -= size;
}
return clusters;
}
function connectGraph(friendIds, clusters) {
// Build intra/inter cluster connections (undirected)
const edges = new Set();
let index = 0;
const clusterSlices = clusters.map(size => {
const slice = friendIds.slice(index, index + size);
index += size;
return slice;
});
// intra cluster
for (const slice of clusterSlices) {
const p = 0.4 + Math.random() * 0.2; // 0.4..0.6 dense
for (let i = 0; i < slice.length; i++) {
for (let j = i + 1; j < slice.length; j++) {
if (Math.random() < p) {
const a = slice[i], b = slice[j];
const key = a < b ? `${a}::${b}` : `${b}::${a}`;
edges.add(key);
}
}
}
}
// inter cluster sparse bridges
for (let c = 0; c < clusterSlices.length - 1; c++) {
const a = sample(clusterSlices[c]);
const b = sample(clusterSlices[c + 1]);
const key = a < b ? `${a}::${b}` : `${b}::${a}`;
edges.add(key);
}
return Array.from(edges).map(k => k.split('::'));
}
function generateEvents(friendIds, friendById, edges) {
// Create shared event ids; distribute across last ~365 days
const eventsByFriend = new Map(friendIds.map(id => [id, []]));
const E = randInt(friendIds.length * 1.2, friendIds.length * 2.2);
for (let i = 0; i < E; i++) {
const date = toYMD(daysAgo(randInt(0, 360)));
const place = sample(PLACES);
const act = sample(ACTIVITIES);
const note = `${act} ${place} ${sample(TAGS)} ${sample(TAGS)}`;
// choose 1–4 participants: bias to connected pairs
let participants = [];
if (Math.random() < 0.6 && edges.length > 0) {
const [a, b] = sample(edges);
const group = [a, b];
if (Math.random() < 0.5) group.push(sample(friendIds));
if (Math.random() < 0.3) group.push(sample(friendIds));
participants = Array.from(new Set(group));
} else {
participants = pickN(friendIds, randInt(1, 4));
}
const id = uid('ev');
const ev = {
id,
date,
notes: note,
location: place,
participants: participants,
};
for (const pid of participants) {
eventsByFriend.get(pid).push(ev);
}
}
// Sort newest first for lastInteraction logic ease
for (const id of friendIds) {
eventsByFriend.get(id).sort((a, b) => (a.date < b.date ? 1 : -1));
}
return eventsByFriend;
}
export function generateDemoPayload(count = 50) {
const names = makeNamePool(count);
const friends = names.map(n => ({ id: uid('f'), name: n }));
const friendIds = friends.map(f => f.id);
// Split into 3–5 clusters
const clusters = generateClusters(count, 8, 14);
const edges = connectGraph(friendIds, clusters);
// Build friend map shell with rich fields
const friendById = new Map();
for (const f of friends) {
friendById.set(f.id, {
id: f.id,
name: f.name,
birthday: toYMD(daysAgo(randInt(7000, 20000))), // 19–55 years old approx
notes: `Ami·e rencontré·e à ${sample(PLACES)}. ${sample(TAGS)} ${sample(TAGS)}`,
likes: `Aime ${sample(FOOD_LIKES)} et ${sample(ACTIVITIES)}`,
dislikes: `N'aime pas ${sample(FOOD_DISLIKES)}`,
foodLikes: sample(FOOD_LIKES),
foodDislikes: sample(FOOD_DISLIKES),
wifiPassword: Math.random().toString(36).slice(2, 10),
carModel: sample(CAR_MODELS),
workplace: sample(WORKPLACES),
schedule: ['9h-17h','8h-16h','10h-18h','horaires flex'].slice(randInt(0,3)),
futureIdeas: `Aller au ${sample(PLACES)}; ${sample(ACTIVITIES)}; ${sample(ACTIVITIES)}.`,
quotes: sample(QUOTES),
importantDates: [],
gifts: [],
postcards: [],
relationships: new Set(),
events: [],
lastInteraction: null,
});
}
// Relationships
for (const [a, b] of edges) {
friendById.get(a).relationships.add(b);
friendById.get(b).relationships.add(a);
}
// Events
const eventsByFriend = generateEvents(friendIds, friendById, edges);
for (const id of friendIds) {
const list = eventsByFriend.get(id) || [];
friendById.get(id).events = list;
friendById.get(id).lastInteraction = list.length ? list[0].date : null;
}
// Sprinkle important dates / gifts / postcards
for (const id of friendIds) {
const f = friendById.get(id);
// 0–3 dates
const k = randInt(0, 3);
for (let i = 0; i < k; i++) {
f.importantDates.push({
date: toYMD(daysAgo(randInt(0, 365))),
label: sample(['Concert','Déménagement','Nouvel emploi','Voyage','Soirée mémorable']),
});
}
// 0–2 gifts
const gk = randInt(0, 2);
for (let i = 0; i < gk; i++) {
f.gifts.push({
date: toYMD(daysAgo(randInt(0, 365))),
occasion: sample(['Anniversaire','Noël','Remerciement','Fête']),
description: sample(['Livre','Bouteille de vin','Plante verte','Boîte de chocolats','Écharpe']),
image: '',
});
}
// 0–2 postcards
const pk = randInt(0, 2);
for (let i = 0; i < pk; i++) {
f.postcards.push({
date: toYMD(daysAgo(randInt(0, 365))),
location: sample(['Bretagne','Marseille','Lyon','Biarritz','Annecy','Lisbonne','Rome']),
description: sample(['Belle météo','Vieux port','Plage','Musées','Randonnée']),
image: '',
});
}
}
// Convert to export payload shape (relationships: arrays)
const outFriends = friendIds.map(id => {
const f = friendById.get(id);
return {
id: f.id,
name: f.name,
birthday: f.birthday,
notes: f.notes,
likes: f.likes,
dislikes: f.dislikes,
foodLikes: f.foodLikes,
foodDislikes: f.foodDislikes,
wifiPassword: f.wifiPassword,
carModel: f.carModel,
workplace: f.workplace,
schedule: f.schedule,
futureIdeas: f.futureIdeas,
quotes: f.quotes,
importantDates: f.importantDates,
gifts: f.gifts,
postcards: f.postcards,
relationships: Array.from(f.relationships),
events: f.events.map(e => ({
id: e.id,
date: e.date,
notes: e.notes,
location: e.location,
participants: e.participants,
})),
};
});
return {
schema: 'dunbar-v1',
version: '1.0.0',
savedAt: new Date().toISOString(),
selectedFriendId: null,
friends: outFriends,
};
}
/**
* Dunbar NLP utilities (client-only, local-first).
* Zero network calls. Pure functions usable in browser.
*
* Exports:
* - stripDiacritics, normalizeText
* - detectLang
* - tokenize, ngrams
* - removeStopwords
* - computeTfIdf, topKeywordsForDocs
* - extractLocations
* - extractTopics (beta, with graceful fallback)
*
* Notes:
* - We reuse stopword lists from the 'stopword' package (already in deps).
* - We avoid heavy stemming here; Minisearch in dunbar-search.js already does FR stemming for search.
*/
import { fr as FR_LIST, en as EN_LIST } from 'stopword';
// ---------- Normalization ----------
/** Remove diacritics but preserve base letters (é → e). */
export function stripDiacritics(s = '') {
try {
return s.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
} catch {
// Fallback for environments missing normalize()
return s;
}
}
/**
* Normalize text for analysis:
* - lowercases
* - optionally strip diacritics
* - collapses whitespace
* - preserves hashtags when requested
*/
export function normalizeText(text = '', { removeDiacritics = true, preserveHashtags = true } = {}) {
let t = String(text || '').toLowerCase();
if (removeDiacritics) t = stripDiacritics(t);
if (!preserveHashtags) t = t.replace(/#/g, ' ');
// collapse whitespace
t = t.replace(/\s+/g, ' ').trim();
return t;
}
// ---------- Language detection (simple heuristic) ----------
const FR_STOP_SET = new Set(FR_LIST || []);
const EN_STOP_SET = new Set(EN_LIST || []);
/**
* Very light lang detection: compare stopword hits FR vs EN.
* Returns 'fr' | 'en' | null (if undecided).
*/
export function detectLang(text = '') {
const norm = normalizeText(text, { removeDiacritics: true, preserveHashtags: false });
const tokens = (norm.match(/[\p{L}\p{N}]+/gu) || []).filter(Boolean);
let frScore = 0;
let enScore = 0;
for (const t of tokens) {
if (FR_STOP_SET.has(t)) frScore++;
if (EN_STOP_SET.has(t)) enScore++;
}
if (frScore === 0 && enScore === 0) return null;
if (frScore > enScore) return 'fr';
if (enScore > frScore) return 'en';
return null;
}
// ---------- Tokenization, stopwords, n-grams ----------
/**
* Tokenize text:
* - keeps hashtags (#word) if keepHashtags=true
* - returns lowercase tokens
*/
export function tokenize(text = '', { keepHashtags = true, removeDiacritics = true } = {}) {
const hashtags = keepHashtags ? (text.match(/#[\p{L}\p{N}_-]+/gu) || []).map((t) => t.toLowerCase()) : [];
const norm = normalizeText(text.replace(/#/g, ' '), { removeDiacritics, preserveHashtags: false });
const words = (norm.match(/[\p{L}\p{N}][\p{L}\p{N}'’_-]*/gu) || []).map((w) => w.toLowerCase());
// dedupe while preserving order for hashtags
const seen = new Set();
const out = [];
for (const h of hashtags) {
if (!seen.has(h)) {
seen.add(h);
out.push(h);
}
}
for (const w of words) {
if (!seen.has(w)) {
seen.add(w);
out.push(w);
}
}
return out;
}
/** Generate n-grams (array of strings joined by space) from a token array. */
export function ngrams(tokens = [], n = 2) {
const res = [];
for (let i = 0; i <= tokens.length - n; i++) {
res.push(tokens.slice(i, i + n).join(' '));
}
return res;
}
/** Remove stopwords per language (keeps hashtags always). */
export function removeStopwords(tokens = [], lang = null) {
if (!lang) return tokens;
const set = lang === 'fr' ? FR_STOP_SET : lang === 'en' ? EN_STOP_SET : null;
if (!set) return tokens;
return tokens.filter((t) => t.startsWith('#') || !set.has(t));
}
// ---------- TF-IDF + keywords ----------
/**
* Build per-document TF-IDF vectors.
* docs: [{ id, text }]
* returns:
* {
* termsByDoc: Map(id -> Map(term -> tfidf)),
* df: Map(term -> docFreq),
* idf: Map(term -> idf),
* tokensByDoc: Map(id -> tokens),
* }
*/
export function computeTfIdf(
docs = [],
{ lang = null, includeNGrams = false, nGramSizes = [2], maxVocab = 5000 } = {}
) {
const tokensByDoc = new Map();
const termFreqByDoc = new Map();
const df = new Map();
// 1) tokenize + local term frequencies
for (const d of docs) {
const toks = removeStopwords(tokenize(d.text || '', { keepHashtags: true, removeDiacritics: true }), lang);
const withN = [toks];
if (includeNGrams) {
for (const n of nGramSizes) withN.push(ngrams(toks, n));
}
const all = withN.flat();
tokensByDoc.set(d.id, all);
const tf = new Map();
for (const t of all) {
tf.set(t, (tf.get(t) || 0) + 1);
}
termFreqByDoc.set(d.id, tf);
// update document frequency
for (const term of new Set(all)) {
df.set(term, (df.get(term) || 0) + 1);
}
}
// 2) vocabulary capping (optional)
if (maxVocab && df.size > maxVocab) {
// keep most frequent terms across docs
const topTerms = Array.from(df.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, maxVocab)
.map(([t]) => t);
const keep = new Set(topTerms);
for (const [docId, tf] of termFreqByDoc.entries()) {
for (const term of Array.from(tf.keys())) {
if (!keep.has(term)) tf.delete(term);
}
}
for (const term of Array.from(df.keys())) {
if (!keep.has(term)) df.delete(term);
}
}
// 3) compute IDF
const N = docs.length || 1;
const idf = new Map();
for (const [term, dfi] of df.entries()) {
const val = Math.log((N + 1) / (dfi + 1)) + 1; // smooth IDF
idf.set(term, val);
}
// 4) compute TF-IDF per doc (normalize with augmented TF)
const termsByDoc = new Map();
for (const [docId, tf] of termFreqByDoc.entries()) {
let tfMax = 1;
for (const v of tf.values()) tfMax = Math.max(tfMax, v);
const vec = new Map();
for (const [term, freq] of tf.entries()) {
const tfw = 0.5 + (0.5 * freq) / tfMax;
const score = tfw * (idf.get(term) || 0);
vec.set(term, score);
}
termsByDoc.set(docId, vec);
}
return { termsByDoc, df, idf, tokensByDoc };
}
/**
* Get top keywords per doc.
* returns array: [{ id, keywords: Array<[term, score]> }]
*/
export function topKeywordsForDocs(docs = [], { lang = null, topK = 8, includeNGrams = false } = {}) {
const { termsByDoc } = computeTfIdf(docs, { lang, includeNGrams });
const res = [];
for (const d of docs) {
const vec = termsByDoc.get(d.id) || new Map();
const sorted = Array.from(vec.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, topK);
res.push({ id: d.id, keywords: sorted });
}
return res;
}
// ---------- Locations (offline gazetteer) ----------
const MINI_GAZETTEER = [
// Countries (FR/EN names)
{ type: 'country', name: 'france', aliases: ['république française'], iso2: 'FR' },
{ type: 'country', name: 'germany', aliases: ['deutschland', 'allemagne'], iso2: 'DE' },
{ type: 'country', name: 'spain', aliases: ['españa', 'espagne'], iso2: 'ES' },
{ type: 'country', name: 'united states', aliases: ['usa', 'us', 'etats-unis', 'états-unis', 'u.s.'], iso2: 'US' },
{ type: 'country', name: 'united kingdom', aliases: ['uk', 'u.k.', 'royaume-uni', 'britain'], iso2: 'GB' },
{ type: 'country', name: 'italy', aliases: ['italia', 'italie'], iso2: 'IT' },
{ type: 'country', name: 'belgium', aliases: ['belgique'], iso2: 'BE' },
{ type: 'country', name: 'switzerland', aliases: ['schweiz', 'suisse', 'svizzera'], iso2: 'CH' },
// Cities (sample, extend as needed)
{ type: 'city', name: 'paris', country: 'FR', aliases: [] },
{ type: 'city', name: 'lyon', country: 'FR', aliases: [] },
{ type: 'city', name: 'marseille', country: 'FR', aliases: [] },
{ type: 'city', name: 'berlin', country: 'DE', aliases: [] },
{ type: 'city', name: 'barcelona', country: 'ES', aliases: ['barcelone'] },
{ type: 'city', name: 'london', country: 'GB', aliases: ['londres'] },
{ type: 'city', name: 'geneva', country: 'CH', aliases: ['genève', 'geneve'] },
{ type: 'city', name: 'brussels', country: 'BE', aliases: ['bruxelles'] },
];
/** Escape regex special chars */
function escapeRegExp(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Extract location mentions using a small offline gazetteer.
* Returns unique matches: [{ type, name, country?, iso2? }]
*/
export function extractLocations(text = '', { gazetteer = MINI_GAZETTEER } = {}) {
const normText = ' ' + normalizeText(text, { removeDiacritics: true, preserveHashtags: true }) + ' ';
const found = [];
const seen = new Set();
for (const entry of gazetteer) {
const names = [entry.name, ...(entry.aliases || [])]
.map((n) => stripDiacritics(n.toLowerCase().trim()))
.filter(Boolean);
for (const n of names) {
// match on word boundaries in normalized text
const pattern = new RegExp(`(^|\\s)${escapeRegExp(n)}(\\s|[.,;:!?])`, 'i');
if (pattern.test(normText)) {
const key = `${entry.type}:${entry.name}:${entry.country || entry.iso2 || ''}`;
if (!seen.has(key)) {
seen.add(key);
found.push({
type: entry.type,
name: entry.name,
country: entry.country,
iso2: entry.iso2,
});
}
break; // avoid duplicate alias hits
}
}
}
return found;
}
// ---------- Topics (beta) ----------
/**
* Try to extract topics with a dynamic import of 'lda' if available.
* If not, fall back to a simple co-occurrence-based grouping derived from TF-IDF.
*
* docs: [{ id, text }]
* returns: Array<{ terms: Array<[term, score]>, documents: Array<id> }>
*/
export async function extractTopics(
docs = [],
{ topics = 5, termsPerTopic = 6, lang = null } = {}
) {
// Feature flag: disable LDA by default to prevent bundler warnings if package isn't installed
// Enable by setting NEXT_PUBLIC_ENABLE_LDA=true in env and adding `yarn add lda`
if (!process.env.NEXT_PUBLIC_ENABLE_LDA) {
return fallbackTopics(docs, { topics, termsPerTopic, lang });
}
// Attempt dynamic LDA if enabled and available
try {
// Avoid Next/Webpack trying to statically resolve 'lda' during build:
// - Use eval(import)
// - Avoid literal specifier by constructing the string
// eslint-disable-next-line no-eval
const dynamicImport = (0, eval)('import');
const spec = 'ld' + 'a';
const mod = await dynamicImport(spec);
const lda = mod.default || mod;
// 'lda' expects an array of documents (strings). Signature: lda(docs, numberOfTopics, termsPerTopic, alpha?, eta?, random?)
const topicSets = lda(
docs.map((d) => String(d.text || '')),
topics,
termsPerTopic
);
// topicSets: Array of Array<{ term, probability } | [term, prob] >
return topicSets.map((topic) => {
const terms = topic.map((t) => {
if (Array.isArray(t)) return [t[0], t[1]];
if (t && typeof t === 'object') return [t.term, t.probability ?? t.prob];
return [String(t), 0];
});
return { terms, documents: [] };
});
} catch {
// Fallback: build pseudo-topics from TF-IDF heads
return fallbackTopics(docs, { topics, termsPerTopic, lang });
}
}
function fallbackTopics(docs, { topics = 5, termsPerTopic = 6, lang = null } = {}) {
const { termsByDoc } = computeTfIdf(docs, { lang, includeNGrams: false });
// Global scores
const global = new Map();
for (const [, vec] of termsByDoc.entries()) {
for (const [term, score] of vec.entries()) {
global.set(term, (global.get(term) || 0) + score);
}
}
const topTerms = Array.from(global.entries()).sort((a, b) => b[1] - a[1]).slice(0, topics);
const topicsOut = [];
for (const [headTerm, headScore] of topTerms) {
// collect co-occurring terms from docs that contain the head
const co = new Map();
const docIds = new Set();
for (const [docId, vec] of termsByDoc.entries()) {
if (vec.has(headTerm)) {
docIds.add(docId);
for (const [term, s] of vec.entries()) {
if (term === headTerm) continue;
co.set(term, (co.get(term) || 0) + s);
}
}
}
const topCo = Array.from(co.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, Math.max(0, termsPerTopic - 1));
topicsOut.push({
terms: [[headTerm, headScore], ...topCo],
documents: Array.from(docIds),
});
}
return topicsOut;
}
export default {
stripDiacritics,
normalizeText,
detectLang,
tokenize,
ngrams,
removeStopwords,
computeTfIdf,
topKeywordsForDocs,
extractLocations,
extractTopics,
};
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);
}
// Dunbar shared utilities — keep UI components lean and consistent.
// Import via: import { ... } from '@/lib/dunbar';
// Locale & time zone defaults (French, Paris)
export const LOCALE = 'fr-FR';
export const TIMEZONE = 'Europe/Paris';
// ------------------------------
// Internal helpers for timezone-safe local dates
// ------------------------------
function partsInTZ(dateLike, timeZone = TIMEZONE) {
const d = dateLike instanceof Date ? dateLike : new Date(dateLike);
if (isNaN(d.getTime())) return null;
const fmt = new Intl.DateTimeFormat(LOCALE, {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
// fr-FR gives dd/mm/yyyy — use formatToParts to recompose safely
const parts = fmt.formatToParts(d);
const map = Object.fromEntries(parts.map(p => [p.type, p.value]));
// Ensure 2-digit month/day
const yyyy = map.year;
const mm = map.month;
const dd = map.day;
return { yyyy, mm, dd };
}
function ymdInTZ(dateLike, timeZone = TIMEZONE) {
const p = partsInTZ(dateLike, timeZone);
if (!p) return '';
return `${p.yyyy}-${p.mm}-${p.dd}`;
}
// ------------------------------
// Dates & text formatting
// ------------------------------
export function isoDate(dateLike, timeZone = TIMEZONE) {
return ymdInTZ(dateLike, timeZone);
}
export function fullDateLabel(dateLike, locale = LOCALE, timeZone = TIMEZONE) {
try {
const d = dateLike instanceof Date ? dateLike : new Date(dateLike);
if (isNaN(d.getTime())) return '';
return d.toLocaleDateString(locale, {
timeZone,
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
} catch {
return '';
}
}
export function firstWords(text, n = 3) {
if (!text) return '';
const words = String(text).trim().split(/\s+/);
return words.slice(0, n).join(' ');
}
// Extract hashtags (lowercased, without #)
export function extractTags(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);
}
// Slugify helper for URLs
export function slugify(text = '') {
const s = String(text || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
return s || 'x';
}
// Event slug helper: stable and mostly human — title slug + short id suffix
export function eventSlug(evOrTitle, idMaybe) {
if (typeof evOrTitle === 'object' && evOrTitle) {
const title = evOrTitle.title || '';
const id = evOrTitle.id || '';
return `${slugify(title)}-${String(id).slice(-6)}`;
}
const title = String(evOrTitle || '');
const id = String(idMaybe || '');
return `${slugify(title)}-${id.slice(-6)}`;
}
/**
* Friend slug helper:
* New policy: slug is just the kebab-cased name (no id suffix).
* This keeps URLs pretty and stable under /dunbar/friends/:slug
*/
export function friendSlug(friendOrName) {
const name = typeof friendOrName === 'object' && friendOrName ? (friendOrName.name || '') : String(friendOrName || '');
return slugify(name);
}
/**
* Resolve friend by slugified name.
* - Returns { match, collisions }:
* - match: the unique friend if exactly one slug matches; otherwise null
* - collisions: array of friends if multiple share the same slug (sus → prompt user to rename)
*/
export function resolveFriendBySlug(friends = [], slug = '') {
const s = String(slug || '').toLowerCase().trim();
if (!s) return { match: null, collisions: [] };
const matches = friends.filter((f) => slugify(f.name) === s);
if (matches.length === 1) return { match: matches[0], collisions: [] };
if (matches.length > 1) return { match: null, collisions: matches };
return { match: null, collisions: [] };
}
// Quick-date helpers (ISO YYYY-MM-DD) — Paris local calendar
export function todayISO() {
return isoDate(new Date(), TIMEZONE);
}
export function yesterdayISO() {
const d = new Date();
d.setDate(d.getDate() - 1);
return isoDate(d, TIMEZONE);
}
export function weekAgoISO() {
const d = new Date();
d.setDate(d.getDate() - 7);
return isoDate(d, TIMEZONE);
}
export function startOfMonthISO() {
const d = new Date();
d.setDate(1);
return isoDate(d, TIMEZONE);
}
// ------------------------------
// Events helpers
// ------------------------------
// Sort newest first by date
export function sortEventsDesc(events) {
return [...(events || [])].sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
);
}
// Group events (array) by local Paris day (YYYY-MM-DD). Returns [{ dateKey, label, items }]
export function groupEventsByDay(events, locale = LOCALE, timeZone = TIMEZONE) {
const groups = new Map();
for (const e of events || []) {
const key = isoDate(e.date, timeZone);
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(e);
}
const out = [];
for (const [dateKey, items] of groups.entries()) {
out.push({
dateKey,
label: fullDateLabel(dateKey, locale, timeZone),
items: sortEventsDesc(items),
});
}
// Sort groups by day (newest first)
out.sort((a, b) => new Date(b.dateKey).getTime() - new Date(a.dateKey).getTime());
return out;
}
// ------------------------------
// Orbits helpers (positions & colors)
// ------------------------------
export function distributeOnCircle(ids = [], radius = 100, cx = 0, cy = 0, startAngle = 0) {
const n = ids.length || 1;
const step = (2 * Math.PI) / n;
const pos = new Map();
ids.forEach((id, i) => {
const angle = startAngle + i * step;
const x = cx + radius * Math.cos(angle);
const y = cy + radius * Math.sin(angle);
pos.set(id, { x, y, angle });
});
return pos;
}
// Activity color coding based on last-90-days interaction counts
export function colorByActivity(count90) {
if (count90 >= 5) return '#2c5530'; // dark green
if (count90 >= 2) return '#5a9960'; // medium green
return '#a0c0a0'; // light green
}
// ------------------------------
// Network helpers
// ------------------------------
export function degreeMap(friends = []) {
const m = new Map();
for (const f of friends) {
m.set(f.id, (f.relationships && f.relationships.size) || 0);
}
return m;
}
export function edgesFromFriends(friends = []) {
// Build unique undirected edges [a,b] with a < b to avoid duplicates
const seen = new Set();
const edges = [];
for (const f of friends) {
for (const to of f.relationships || []) {
const a = String(f.id);
const b = String(to);
if (a === b) continue;
const key = a < b ? `${a}::${b}` : `${b}::${a}`;
if (seen.has(key)) continue;
seen.add(key);
edges.push([a, b]);
}
}
return edges;
}
// ------------------------------
// Math & drawing helpers
// ------------------------------
export const clamp = (v, min, max) => Math.max(min, Math.min(max, v));
export const lerp = (a, b, t) => a + (b - a) * t;
// Canvas label drawing with white stroke for contrast
export function drawLabel(ctx, text, x, y, color = '#333', fontPx = 12) {
ctx.save();
ctx.font = `${fontPx}px system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif`;
ctx.lineWidth = Math.max(2, fontPx / 4);
ctx.strokeStyle = '#fff';
ctx.strokeText(text, x, y);
ctx.fillStyle = color;
ctx.fillText(text, x, y);
ctx.restore();
}
// ------------------------------
// Versioned import/export helpers
// ------------------------------
export const DATA_VERSION = '1.0.0';
export const DATA_SCHEMA = 'dunbar-v1';
// Prepare JSON-safe snapshot (relationships as arrays)
export function makeExportPayload(state) {
const friends = (state.friends || []).map(f => ({
id: f.id,
name: f.name,
birthday: f.birthday || null,
notes: f.notes || '',
// rich profile
likes: Array.isArray(f.likes) ? f.likes : (f.likes ? String(f.likes).split(/[,\\n]/).map(s => s.trim()).filter(Boolean) : []),
dislikes: Array.isArray(f.dislikes) ? f.dislikes : (f.dislikes ? String(f.dislikes).split(/[,\\n]/).map(s => s.trim()).filter(Boolean) : []),
foodLikes: f.foodLikes || '',
foodDislikes: f.foodDislikes || '',
wifiPassword: f.wifiPassword || '',
carModel: f.carModel || '',
workplace: f.workplace || '',
schedule: f.schedule || '',
futureIdeas: Array.isArray(f.futureIdeas) ? f.futureIdeas : (f.futureIdeas ? String(f.futureIdeas).split(/[,\n]/).map(s => s.trim()).filter(Boolean) : []),
quotes: Array.isArray(f.quotes) ? f.quotes : (f.quotes ? String(f.quotes).split(/[,\n]/).map(s => s.trim()).filter(Boolean) : []),
projects: Array.isArray(f.projects) ? f.projects : (f.projects ? String(f.projects).split(/[,\n]/).map(s => s.trim()).filter(Boolean) : []),
importantDates: Array.isArray(f.importantDates) ? f.importantDates.map(x => ({
date: x?.date || null,
label: x?.label || '',
})) : [],
gifts: Array.isArray(f.gifts) ? f.gifts.map(x => ({
date: x?.date || null,
occasion: x?.occasion || '',
description: x?.description || '',
image: x?.image || '',
})) : [],
postcards: Array.isArray(f.postcards) ? f.postcards.map(x => ({
date: x?.date || null,
location: x?.location || '',
description: x?.description || '',
image: x?.image || '',
})) : [],
relationships: Array.from(f.relationships || []),
events: Array.isArray(f.events) ? f.events.map(ev => ({
id: ev.id,
date: ev.date,
title: ev.title || '',
notes: ev.notes,
location: ev.location,
participants: Array.isArray(ev.participants) ? [...ev.participants] : [],
})) : [],
}));
return {
schema: DATA_SCHEMA,
version: DATA_VERSION,
savedAt: new Date().toISOString(),
selectedFriendId: state.selectedFriendId || null,
friends,
};
}
export function normalizeImportedPayload(payload) {
if (!payload) throw new Error('Empty payload');
// Accept same schema or a minimal legacy shape { friends, selectedFriendId }
const friendsRaw = Array.isArray(payload.friends) ? payload.friends : [];
const friends = friendsRaw.map(f => ({
id: f.id,
name: f.name || '',
birthday: f.birthday || null,
notes: f.notes || '',
// rich profile (defaults)
likes: Array.isArray(f.likes) ? f.likes : (f.likes ? String(f.likes).split(/[,\\n]/).map(s => s.trim()).filter(Boolean) : []),
dislikes: Array.isArray(f.dislikes) ? f.dislikes : (f.dislikes ? String(f.dislikes).split(/[,\\n]/).map(s => s.trim()).filter(Boolean) : []),
foodLikes: f.foodLikes || '',
foodDislikes: f.foodDislikes || '',
wifiPassword: f.wifiPassword || '',
carModel: f.carModel || '',
workplace: f.workplace || '',
schedule: f.schedule || '',
futureIdeas: Array.isArray(f.futureIdeas) ? f.futureIdeas : (f.futureIdeas ? String(f.futureIdeas).split(/[,\n]/).map(s => s.trim()).filter(Boolean) : []),
quotes: Array.isArray(f.quotes) ? f.quotes : (f.quotes ? String(f.quotes).split(/[,\n]/).map(s => s.trim()).filter(Boolean) : []),
projects: Array.isArray(f.projects) ? f.projects : (f.projects ? String(f.projects).split(/[,\n]/).map(s => s.trim()).filter(Boolean) : []),
importantDates: Array.isArray(f.importantDates) ? f.importantDates.map(x => ({
date: x?.date || null,
label: x?.label || '',
})) : [],
gifts: Array.isArray(f.gifts) ? f.gifts.map(x => ({
date: x?.date || null,
occasion: x?.occasion || '',
description: x?.description || '',
image: x?.image || '',
})) : [],
postcards: Array.isArray(f.postcards) ? f.postcards.map(x => ({
date: x?.date || null,
location: x?.location || '',
description: x?.description || '',
image: x?.image || '',
})) : [],
relationships: new Set(Array.isArray(f.relationships) ? f.relationships : []),
events: Array.isArray(f.events) ? f.events.map(ev => ({
id: ev.id,
date: ev.date,
title: ev.title || '',
notes: ev.notes || '',
location: ev.location,
participants: Array.isArray(ev.participants) ? ev.participants : [],
})) : [],
lastInteraction: null, // computed by store
}));
return {
friends,
selectedFriendId: payload.selectedFriendId || null,
};
}
// ------------------------------
// Anniversaries helpers (birthday / half-birthday / 6m-12m since first/last events)
// ------------------------------
function parseYMD(ymd) {
if (!ymd) return null;
// Interpret as UTC midnight to avoid TZ drift across locales
const [y, m, d] = String(ymd).split('-').map(v => parseInt(v, 10));
if (!y || !m || !d) return null;
return new Date(Date.UTC(y, (m - 1), d));
}
function toYMD(date) {
if (!date || isNaN(date.getTime())) return '';
// Keep using local Paris label elsewhere; for storage we use YYYY-MM-DD UTC
const yyyy = date.getUTCFullYear();
const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
const dd = String(date.getUTCDate()).padStart(2, '0');
return `${yyyy}-${mm}-${dd}`;
}
function addMonthsUTC(date, n) {
const d = new Date(date.getTime());
const y = d.getUTCFullYear();
const m = d.getUTCMonth();
const day = d.getUTCDate();
// Move to first of target month then clamp day
const target = new Date(Date.UTC(y, m + n, 1));
const lastDay = new Date(Date.UTC(target.getUTCFullYear(), target.getUTCMonth() + 1, 0)).getUTCDate();
target.setUTCDate(Math.min(day, lastDay));
return target;
}
function daysDiffUTC(a, b) {
const MS = 24 * 60 * 60 * 1000;
const at = Date.UTC(a.getUTCFullYear(), a.getUTCMonth(), a.getUTCDate());
const bt = Date.UTC(b.getUTCFullYear(), b.getUTCMonth(), b.getUTCDate());
return Math.round((bt - at) / MS);
}
function nextMultipleMonthAnniv(baseYMD, stepMonths, today = new Date()) {
const base = parseYMD(baseYMD);
if (!base) return null;
let candidate = new Date(base.getTime());
// Increase by step until >= today
while (candidate < today) {
candidate = addMonthsUTC(candidate, stepMonths);
}
return candidate;
}
function nextBirthday(birthdayYMD, today = new Date()) {
const b = parseYMD(birthdayYMD);
if (!b) return null;
const year = today.getUTCFullYear();
let next = new Date(Date.UTC(year, b.getUTCMonth(), b.getUTCDate()));
if (next < today) {
next = new Date(Date.UTC(year + 1, b.getUTCMonth(), b.getUTCDate()));
}
return next;
}
function nextHalfBirthday(birthdayYMD, today = new Date()) {
const b = parseYMD(birthdayYMD);
if (!b) return null;
const nextB = nextBirthday(birthdayYMD, today);
const half = addMonthsUTC(nextB, -6); // the previous half-birthday relative to next birthday
// Ensure we pick the next upcoming within the next cycle
if (half >= today) return half;
return addMonthsUTC(half, 12);
}
/**
* Compute upcoming anniversaries in the next `windowDays` days.
* Returns sorted array of:
* { date: 'YYYY-MM-DD', friendId, friendName, kind: 'birthday'|'half-birthday'|'first-6m'|'first-12m'|'last-6m'|'last-12m', label }
*/
export function computeUpcomingAnniversaries(friends = [], windowDays = 21) {
const today = new Date();
const items = [];
for (const f of friends || []) {
// birthday + half-birthday
if (f.birthday) {
const nb = nextBirthday(f.birthday, today);
const nh = nextHalfBirthday(f.birthday, today);
if (nb) {
const d = daysDiffUTC(today, nb);
if (d >= 0 && d <= windowDays) {
items.push({
date: toYMD(nb),
friendId: f.id,
friendName: f.name,
kind: 'birthday',
label: `Anniversaire de ${f.name}`,
});
}
}
if (nh) {
const d = daysDiffUTC(today, nh);
if (d >= 0 && d <= windowDays) {
items.push({
date: toYMD(nh),
friendId: f.id,
friendName: f.name,
kind: 'half-birthday',
label: `Demi‑anniversaire de ${f.name}`,
});
}
}
}
// First & last event anchors
if (Array.isArray(f.events) && f.events.length) {
const evs = (f.events || [])
.map(e => ({ ...e, _t: parseYMD(e.date) }))
.filter(e => !!e._t)
.sort((a, b) => a._t - b._t);
const first = evs.length ? evs[0]._t : null;
const last = evs.length ? evs[evs.length - 1]._t : null;
const firstEv = evs.length ? evs[0] : null;
const lastEv = evs.length ? evs[evs.length - 1] : null;
if (first) {
const n6 = nextMultipleMonthAnniv(toYMD(first), 6, today);
const n12 = nextMultipleMonthAnniv(toYMD(first), 12, today);
if (n6) {
const d = daysDiffUTC(today, n6);
if (d >= 0 && d <= windowDays) {
items.push({
date: toYMD(n6),
friendId: f.id,
friendName: f.name,
kind: 'first-6m',
label: `6 mois depuis le 1er événement avec ${f.name}`,
anchorTitle: firstEv ? firstWords(firstEv.notes || '', 5) : '',
anchorTags: firstEv ? extractTags(firstEv.notes || '') : [],
});
}
}
if (n12) {
const d = daysDiffUTC(today, n12);
if (d >= 0 && d <= windowDays) {
items.push({
date: toYMD(n12),
friendId: f.id,
friendName: f.name,
kind: 'first-12m',
label: `1 an depuis le 1er événement avec ${f.name}`,
anchorTitle: firstEv ? firstWords(firstEv.notes || '', 5) : '',
anchorTags: firstEv ? extractTags(firstEv.notes || '') : [],
});
}
}
}
if (last) {
const n6 = nextMultipleMonthAnniv(toYMD(last), 6, today);
const n12 = nextMultipleMonthAnniv(toYMD(last), 12, today);
if (n6) {
const d = daysDiffUTC(today, n6);
if (d >= 0 && d <= windowDays) {
items.push({
date: toYMD(n6),
friendId: f.id,
friendName: f.name,
kind: 'last-6m',
label: `6 mois depuis le dernier événement avec ${f.name}`,
anchorTitle: lastEv ? firstWords(lastEv.notes || '', 5) : '',
anchorTags: lastEv ? extractTags(lastEv.notes || '') : [],
});
}
}
if (n12) {
const d = daysDiffUTC(today, n12);
if (d >= 0 && d <= windowDays) {
items.push({
date: toYMD(n12),
friendId: f.id,
friendName: f.name,
kind: 'last-12m',
label: `1 an depuis le dernier événement avec ${f.name}`,
anchorTitle: lastEv ? firstWords(lastEv.notes || '', 5) : '',
anchorTags: lastEv ? extractTags(lastEv.notes || '') : [],
});
}
}
}
}
}
items.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
return items;
}
......@@ -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).
});
});
......@@ -5,44 +5,6 @@ __metadata:
version: 8
cacheKey: 10c0
"3d-force-graph-ar@npm:^1.10":
version: 1.10.0
resolution: "3d-force-graph-ar@npm:1.10.0"
dependencies:
aframe-forcegraph-component: "npm:3"
kapsule: "npm:^1.16"
checksum: 10c0/c316cac8fae586ce63dd1c5b3f2c1bdbde6d7560c192ad7f7886f1aac030905b214684fa103fcb6ba20827d4c65003eab491c3610e2903bd578239433bcf9856
languageName: node
linkType: hard
"3d-force-graph-vr@npm:^3.1":
version: 3.1.1
resolution: "3d-force-graph-vr@npm:3.1.1"
dependencies:
accessor-fn: "npm:1"
aframe-extras: "npm:^7.2"
aframe-forcegraph-component: "npm:3"
kapsule: "npm:^1.16"
polished: "npm:4"
peerDependencies:
aframe: ^1.5
checksum: 10c0/3e08ba8ef35b4d6267b56e277d9d448f0bae5aaacfd27854703727c95e52bfba16e9b709c5542c6578eba4f0dd7e0cb014c7a7676783e1646413ed78ca6b9af2
languageName: node
linkType: hard
"3d-force-graph@npm:^1.79":
version: 1.79.0
resolution: "3d-force-graph@npm:1.79.0"
dependencies:
accessor-fn: "npm:1"
kapsule: "npm:^1.16"
three: "npm:>=0.118 <1"
three-forcegraph: "npm:1"
three-render-objects: "npm:^1.35"
checksum: 10c0/8dcaeec154318234fa4e18ed213f76dd8de001ed4894d0eee97afd1800a9b81fb20fb0b322a975c26ad61e786fc25233cbce11f52ac2d9cc9d00f5e9452cd5c7
languageName: node
linkType: hard
"@adobe/css-tools@npm:^4.4.0":
version: 4.4.4
resolution: "@adobe/css-tools@npm:4.4.4"
......@@ -445,7 +407,7 @@ __metadata:
languageName: node
linkType: hard
"@babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.17.8, @babel/runtime@npm:^7.27.6, @babel/runtime@npm:^7.3.1":
"@babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.27.6, @babel/runtime@npm:^7.3.1":
version: 7.28.4
resolution: "@babel/runtime@npm:7.28.4"
checksum: 10c0/792ce7af9750fb9b93879cc9d1db175701c4689da890e6ced242ea0207c9da411ccf16dc04e689cc01158b28d7898c40d75598f4559109f761c12ce01e959bf7
......@@ -1805,13 +1767,6 @@ __metadata:
languageName: node
linkType: hard
"@tweenjs/tween.js@npm:18 - 25":
version: 25.0.0
resolution: "@tweenjs/tween.js@npm:25.0.0"
checksum: 10c0/372a85913ad088b8d2720e4a5e90469e411e0757b5f3a52da6a7403f1722236b853bc9c78d9437b1f30db61199efe45e7ec40484def2ab1fe7c2334de0673ef3
languageName: node
linkType: hard
"@tybys/wasm-util@npm:^0.10.0, @tybys/wasm-util@npm:^0.10.1":
version: 0.10.1
resolution: "@tybys/wasm-util@npm:0.10.1"
......@@ -2497,13 +2452,6 @@ __metadata:
languageName: node
linkType: hard
"accessor-fn@npm:1":
version: 1.5.3
resolution: "accessor-fn@npm:1.5.3"
checksum: 10c0/fa7cdbc8dbf09f60b28e51841776540ae4fdd238243e9b37d9ae761abc5b8426a37bdb928824974157391cdd3a44ec41b232014b3a95aff960bf197800993c04
languageName: node
linkType: hard
"acorn-import-attributes@npm:^1.9.5":
version: 1.9.5
resolution: "acorn-import-attributes@npm:1.9.5"
......@@ -2531,28 +2479,6 @@ __metadata:
languageName: node
linkType: hard
"aframe-extras@npm:^7.2":
version: 7.6.0
resolution: "aframe-extras@npm:7.6.0"
dependencies:
nipplejs: "npm:^0.10.2"
three: "npm:^0.164.0"
three-pathfinding: "npm:^1.3.0"
checksum: 10c0/3efef79e6331f89a92820c459bc79a5c19327bdf9b081b975c9cc14e586541e9cd12d06c43425fc14a33159bcd0dd0a51e80a8882667481a3d1b4515518ec075
languageName: node
linkType: hard
"aframe-forcegraph-component@npm:3":
version: 3.3.0
resolution: "aframe-forcegraph-component@npm:3.3.0"
dependencies:
three-forcegraph: "npm:1"
peerDependencies:
aframe: "*"
checksum: 10c0/630e19ff62badfc7b0c372f1fce9b2ef735699d52248b13fc376b43ae1824b8602f670bb0a26abc10cdd0807a6c52935dc541432905e3fe4ebb788163d6298e0
languageName: node
linkType: hard
"agent-base@npm:^7.1.0, agent-base@npm:^7.1.2":
version: 7.1.4
resolution: "agent-base@npm:7.1.4"
......@@ -2849,13 +2775,6 @@ __metadata:
languageName: node
linkType: hard
"bezier-js@npm:3 - 6":
version: 6.1.4
resolution: "bezier-js@npm:6.1.4"
checksum: 10c0/2785010f1f26b5229aa2a11e0b4dbd57476eec02c62385eed11960e420421ad9894f6130349304bbbc90bc5a15ee54109ca43ddd0556dca4d63bd725c7e36b22
languageName: node
linkType: hard
"bindings@npm:^1.4.0":
version: 1.5.0
resolution: "bindings@npm:1.5.0"
......@@ -3018,15 +2937,6 @@ __metadata:
languageName: node
linkType: hard
"canvas-color-tracker@npm:^1.3":
version: 1.3.2
resolution: "canvas-color-tracker@npm:1.3.2"
dependencies:
tinycolor2: "npm:^1.6.0"
checksum: 10c0/fca746cd7b96b139d189415ce3e608d070a3a562f276c5d4b3e1a6d8c7617ffd3108946b54d6365e12741cb5a39c57ee2e2aa537efe79b3771961ebb34aae9e6
languageName: node
linkType: hard
"ccount@npm:^2.0.0":
version: 2.0.1
resolution: "ccount@npm:2.0.1"
......@@ -3304,199 +3214,6 @@ __metadata:
languageName: node
linkType: hard
"d3-array@npm:1 - 3, d3-array@npm:2 - 3, d3-array@npm:2.10.0 - 3":
version: 3.2.4
resolution: "d3-array@npm:3.2.4"
dependencies:
internmap: "npm:1 - 2"
checksum: 10c0/08b95e91130f98c1375db0e0af718f4371ccacef7d5d257727fe74f79a24383e79aba280b9ffae655483ffbbad4fd1dec4ade0119d88c4749f388641c8bf8c50
languageName: node
linkType: hard
"d3-binarytree@npm:1":
version: 1.0.2
resolution: "d3-binarytree@npm:1.0.2"
checksum: 10c0/54224f39fe5754e7cda96eef23b0725c6b7b93bddb82a753560767b34c683f916a572fc03be44187d6b1b7e405d49033fbe1791fbf8519ba36ff30a09575b357
languageName: node
linkType: hard
"d3-color@npm:1 - 3":
version: 3.1.0
resolution: "d3-color@npm:3.1.0"
checksum: 10c0/a4e20e1115fa696fce041fbe13fbc80dc4c19150fa72027a7c128ade980bc0eeeba4bcf28c9e21f0bce0e0dbfe7ca5869ef67746541dcfda053e4802ad19783c
languageName: node
linkType: hard
"d3-dispatch@npm:1 - 3":
version: 3.0.1
resolution: "d3-dispatch@npm:3.0.1"
checksum: 10c0/6eca77008ce2dc33380e45d4410c67d150941df7ab45b91d116dbe6d0a3092c0f6ac184dd4602c796dc9e790222bad3ff7142025f5fd22694efe088d1d941753
languageName: node
linkType: hard
"d3-drag@npm:2 - 3":
version: 3.0.0
resolution: "d3-drag@npm:3.0.0"
dependencies:
d3-dispatch: "npm:1 - 3"
d3-selection: "npm:3"
checksum: 10c0/d2556e8dc720741a443b595a30af403dd60642dfd938d44d6e9bfc4c71a962142f9a028c56b61f8b4790b65a34acad177d1263d66f103c3c527767b0926ef5aa
languageName: node
linkType: hard
"d3-ease@npm:1 - 3":
version: 3.0.1
resolution: "d3-ease@npm:3.0.1"
checksum: 10c0/fec8ef826c0cc35cda3092c6841e07672868b1839fcaf556e19266a3a37e6bc7977d8298c0fcb9885e7799bfdcef7db1baaba9cd4dcf4bc5e952cf78574a88b0
languageName: node
linkType: hard
"d3-force-3d@npm:2 - 3":
version: 3.0.6
resolution: "d3-force-3d@npm:3.0.6"
dependencies:
d3-binarytree: "npm:1"
d3-dispatch: "npm:1 - 3"
d3-octree: "npm:1"
d3-quadtree: "npm:1 - 3"
d3-timer: "npm:1 - 3"
checksum: 10c0/932a7669714c735ed69844ec1c707e84bb78532d0933056768d0cf06524acc91fe35c00a70d6070063c058fc010866116145f6db024cb03fe886b9b417a82eef
languageName: node
linkType: hard
"d3-force@npm:^3.0.0":
version: 3.0.0
resolution: "d3-force@npm:3.0.0"
dependencies:
d3-dispatch: "npm:1 - 3"
d3-quadtree: "npm:1 - 3"
d3-timer: "npm:1 - 3"
checksum: 10c0/220a16a1a1ac62ba56df61028896e4b52be89c81040d20229c876efc8852191482c233f8a52bb5a4e0875c321b8e5cb6413ef3dfa4d8fe79eeb7d52c587f52cf
languageName: node
linkType: hard
"d3-format@npm:1 - 3":
version: 3.1.0
resolution: "d3-format@npm:3.1.0"
checksum: 10c0/049f5c0871ebce9859fc5e2f07f336b3c5bfff52a2540e0bac7e703fce567cd9346f4ad1079dd18d6f1e0eaa0599941c1810898926f10ac21a31fd0a34b4aa75
languageName: node
linkType: hard
"d3-interpolate@npm:1 - 3, d3-interpolate@npm:1.2.0 - 3":
version: 3.0.1
resolution: "d3-interpolate@npm:3.0.1"
dependencies:
d3-color: "npm:1 - 3"
checksum: 10c0/19f4b4daa8d733906671afff7767c19488f51a43d251f8b7f484d5d3cfc36c663f0a66c38fe91eee30f40327443d799be17169f55a293a3ba949e84e57a33e6a
languageName: node
linkType: hard
"d3-octree@npm:1":
version: 1.1.0
resolution: "d3-octree@npm:1.1.0"
checksum: 10c0/36d3075879d64bfc18d28a5fed3ace5640779ae6c84fcd2480a74f374874b1114b02a9a00773e8de2ed83cdabad4ead7c2463f5225bfbc20ce875055ae3b81cf
languageName: node
linkType: hard
"d3-quadtree@npm:1 - 3":
version: 3.0.1
resolution: "d3-quadtree@npm:3.0.1"
checksum: 10c0/18302d2548bfecaef788152397edec95a76400fd97d9d7f42a089ceb68d910f685c96579d74e3712d57477ed042b056881b47cd836a521de683c66f47ce89090
languageName: node
linkType: hard
"d3-scale-chromatic@npm:1 - 3":
version: 3.1.0
resolution: "d3-scale-chromatic@npm:3.1.0"
dependencies:
d3-color: "npm:1 - 3"
d3-interpolate: "npm:1 - 3"
checksum: 10c0/9a3f4671ab0b971f4a411b42180d7cf92bfe8e8584e637ce7e698d705e18d6d38efbd20ec64f60cc0dfe966c20d40fc172565bc28aaa2990c0a006360eed91af
languageName: node
linkType: hard
"d3-scale@npm:1 - 4":
version: 4.0.2
resolution: "d3-scale@npm:4.0.2"
dependencies:
d3-array: "npm:2.10.0 - 3"
d3-format: "npm:1 - 3"
d3-interpolate: "npm:1.2.0 - 3"
d3-time: "npm:2.1.1 - 3"
d3-time-format: "npm:2 - 4"
checksum: 10c0/65d9ad8c2641aec30ed5673a7410feb187a224d6ca8d1a520d68a7d6eac9d04caedbff4713d1e8545be33eb7fec5739983a7ab1d22d4e5ad35368c6729d362f1
languageName: node
linkType: hard
"d3-selection@npm:2 - 3, d3-selection@npm:3":
version: 3.0.0
resolution: "d3-selection@npm:3.0.0"
checksum: 10c0/e59096bbe8f0cb0daa1001d9bdd6dbc93a688019abc97d1d8b37f85cd3c286a6875b22adea0931b0c88410d025563e1643019161a883c516acf50c190a11b56b
languageName: node
linkType: hard
"d3-time-format@npm:2 - 4":
version: 4.1.0
resolution: "d3-time-format@npm:4.1.0"
dependencies:
d3-time: "npm:1 - 3"
checksum: 10c0/735e00fb25a7fd5d418fac350018713ae394eefddb0d745fab12bbff0517f9cdb5f807c7bbe87bb6eeb06249662f8ea84fec075f7d0cd68609735b2ceb29d206
languageName: node
linkType: hard
"d3-time@npm:1 - 3, d3-time@npm:2.1.1 - 3":
version: 3.1.0
resolution: "d3-time@npm:3.1.0"
dependencies:
d3-array: "npm:2 - 3"
checksum: 10c0/a984f77e1aaeaa182679b46fbf57eceb6ebdb5f67d7578d6f68ef933f8eeb63737c0949991618a8d29472dbf43736c7d7f17c452b2770f8c1271191cba724ca1
languageName: node
linkType: hard
"d3-timer@npm:1 - 3":
version: 3.0.1
resolution: "d3-timer@npm:3.0.1"
checksum: 10c0/d4c63cb4bb5461d7038aac561b097cd1c5673969b27cbdd0e87fa48d9300a538b9e6f39b4a7f0e3592ef4f963d858c8a9f0e92754db73116770856f2fc04561a
languageName: node
linkType: hard
"d3-transition@npm:2 - 3":
version: 3.0.1
resolution: "d3-transition@npm:3.0.1"
dependencies:
d3-color: "npm:1 - 3"
d3-dispatch: "npm:1 - 3"
d3-ease: "npm:1 - 3"
d3-interpolate: "npm:1 - 3"
d3-timer: "npm:1 - 3"
peerDependencies:
d3-selection: 2 - 3
checksum: 10c0/4e74535dda7024aa43e141635b7522bb70cf9d3dfefed975eb643b36b864762eca67f88fafc2ca798174f83ca7c8a65e892624f824b3f65b8145c6a1a88dbbad
languageName: node
linkType: hard
"d3-zoom@npm:2 - 3, d3-zoom@npm:^3.0.0":
version: 3.0.0
resolution: "d3-zoom@npm:3.0.0"
dependencies:
d3-dispatch: "npm:1 - 3"
d3-drag: "npm:2 - 3"
d3-interpolate: "npm:1 - 3"
d3-selection: "npm:2 - 3"
d3-transition: "npm:2 - 3"
checksum: 10c0/ee2036479049e70d8c783d594c444fe00e398246048e3f11a59755cd0e21de62ece3126181b0d7a31bf37bcf32fd726f83ae7dea4495ff86ec7736ce5ad36fd3
languageName: node
linkType: hard
"data-bind-mapper@npm:1":
version: 1.0.3
resolution: "data-bind-mapper@npm:1.0.3"
dependencies:
accessor-fn: "npm:1"
checksum: 10c0/242fa247dd3d340694558020b05e6c0d5273b290f3a4f1eae35ac7d4946500df185fa2d72934f0b7a64a81352bfbf85ca3fe1c67cac1709571c3eba373002c38
languageName: node
linkType: hard
"data-urls@npm:^5.0.0":
version: 5.0.0
resolution: "data-urls@npm:5.0.0"
......@@ -4223,40 +3940,6 @@ __metadata:
languageName: node
linkType: hard
"float-tooltip@npm:^1.7":
version: 1.7.5
resolution: "float-tooltip@npm:1.7.5"
dependencies:
d3-selection: "npm:2 - 3"
kapsule: "npm:^1.16"
preact: "npm:10"
checksum: 10c0/2410046593998ce726d108da1f883193f1c6222f0b8cbc4a7bdd019e7e0f17b440460605b617485d456c3eb97df6cb95e08dfcfe513a5c5cef56bbc8dc92d0af
languageName: node
linkType: hard
"force-graph@npm:^1.51":
version: 1.51.0
resolution: "force-graph@npm:1.51.0"
dependencies:
"@tweenjs/tween.js": "npm:18 - 25"
accessor-fn: "npm:1"
bezier-js: "npm:3 - 6"
canvas-color-tracker: "npm:^1.3"
d3-array: "npm:1 - 3"
d3-drag: "npm:2 - 3"
d3-force-3d: "npm:2 - 3"
d3-scale: "npm:1 - 4"
d3-scale-chromatic: "npm:1 - 3"
d3-selection: "npm:2 - 3"
d3-zoom: "npm:2 - 3"
float-tooltip: "npm:^1.7"
index-array-by: "npm:1"
kapsule: "npm:^1.16"
lodash-es: "npm:4"
checksum: 10c0/9d5da8ab573eb886ab7f3be00efc36c35f3cce58b32edfbb1eb655871395abca68e97ffbac061aefcc3408879f024b74db9a170f21be18f7098c28874b703d6b
languageName: node
linkType: hard
"foreground-child@npm:^3.1.0":
version: 3.3.1
resolution: "foreground-child@npm:3.3.1"
......@@ -4843,13 +4526,6 @@ __metadata:
languageName: node
linkType: hard
"index-array-by@npm:1":
version: 1.4.2
resolution: "index-array-by@npm:1.4.2"
checksum: 10c0/70cfb089148678236c620f471f75b3bec85da65f24cd44ea601c1eae8f6e0da5e1899cee08ed3a276bea1943b6f910fe6fa388276bca4667c6738bb44eae08cb
languageName: node
linkType: hard
"inflight@npm:^1.0.4":
version: 1.0.6
resolution: "inflight@npm:1.0.6"
......@@ -4920,13 +4596,6 @@ __metadata:
languageName: node
linkType: hard
"internmap@npm:1 - 2":
version: 2.0.3
resolution: "internmap@npm:2.0.3"
checksum: 10c0/8cedd57f07bbc22501516fbfc70447f0c6812871d471096fad9ea603516eacc2137b633633daf432c029712df0baefd793686388ddf5737e3ea15074b877f7ed
languageName: node
linkType: hard
"ip-address@npm:^10.0.1":
version: 10.1.0
resolution: "ip-address@npm:10.1.0"
......@@ -5177,13 +4846,6 @@ __metadata:
languageName: node
linkType: hard
"jerrypick@npm:^1.1.1":
version: 1.1.2
resolution: "jerrypick@npm:1.1.2"
checksum: 10c0/afb25bfe4a10daf0ba3d17d78e63ef610dc660a1aa7be22fdf439ca010efa86fca0c5e6cbca7b9da9f07c2869ae58e0f5e2433faeba7224ce690938058e4903f
languageName: node
linkType: hard
"jest-changed-files@npm:30.2.0":
version: 30.2.0
resolution: "jest-changed-files@npm:30.2.0"
......@@ -5782,15 +5444,6 @@ __metadata:
languageName: node
linkType: hard
"kapsule@npm:^1.16":
version: 1.16.3
resolution: "kapsule@npm:1.16.3"
dependencies:
lodash-es: "npm:4"
checksum: 10c0/023322fdfa41e98a2b83ee02cd00509f3b5041c542dd4405dc3fee2e8cb0928e24a5e681ea1c3df86c82f5fbfcbe61161bfc9ea16dd9f353d7332b314ce34cf6
languageName: node
linkType: hard
"kind-of@npm:^6.0.0, kind-of@npm:^6.0.2":
version: 6.0.3
resolution: "kind-of@npm:6.0.3"
......@@ -5955,13 +5608,6 @@ __metadata:
languageName: node
linkType: hard
"lodash-es@npm:4":
version: 4.17.21
resolution: "lodash-es@npm:4.17.21"
checksum: 10c0/fb407355f7e6cd523a9383e76e6b455321f0f153a6c9625e21a8827d10c54c2a2341bd2ae8d034358b60e07325e1330c14c224ff582d04612a46a4f0479ff2f2
languageName: node
linkType: hard
"longest-streak@npm:^3.0.0":
version: 3.1.0
resolution: "longest-streak@npm:3.1.0"
......@@ -6971,13 +6617,6 @@ __metadata:
languageName: node
linkType: hard
"minisearch@npm:^7.1.2":
version: 7.2.0
resolution: "minisearch@npm:7.2.0"
checksum: 10c0/64efaf30ead2acb19eb8be49c78352c527812dd2927a0981c9d666339d5d206d132b83038d2bfa756f5161cae5d98f15b07cc7e7b7be6b8278d35d2ecdc2628c
languageName: node
linkType: hard
"minizlib@npm:^1.3.3":
version: 1.3.3
resolution: "minizlib@npm:1.3.3"
......@@ -7159,54 +6798,6 @@ __metadata:
languageName: node
linkType: hard
"ngraph.events@npm:^1.0.0, ngraph.events@npm:^1.4.0":
version: 1.4.0
resolution: "ngraph.events@npm:1.4.0"
checksum: 10c0/bb028299c7fd6732737286f7a7b50ca42121052193ea63c2d4adb0bae292d8948d64be919718ccf6a71eac3f1b93e58cda9a14965c8354da1c2095448e692ccd
languageName: node
linkType: hard
"ngraph.forcelayout@npm:3":
version: 3.3.1
resolution: "ngraph.forcelayout@npm:3.3.1"
dependencies:
ngraph.events: "npm:^1.0.0"
ngraph.merge: "npm:^1.0.0"
ngraph.random: "npm:^1.0.0"
checksum: 10c0/6d1756a132a55c7591966a45c53569e529d721a4a2eb11a37db3905e087091c933cde6fcfb1acfe5c98999566ee042f5dd6c8d612bd7488b0a60e079c51e5284
languageName: node
linkType: hard
"ngraph.graph@npm:20":
version: 20.1.1
resolution: "ngraph.graph@npm:20.1.1"
dependencies:
ngraph.events: "npm:^1.4.0"
checksum: 10c0/d58714c6dba0b7c845e6768daff16769506a4ba91a7afe229be96ad3822310681098394d9892096f0fcedf4e8e0588d9236f29d90862862821e99817c4a715e0
languageName: node
linkType: hard
"ngraph.merge@npm:^1.0.0":
version: 1.0.0
resolution: "ngraph.merge@npm:1.0.0"
checksum: 10c0/30ded37341c597e0f3fd808f139149a42060a6a0f091a2878952389d52c4331049fd61eeba7dc77fcc14aea72a984dee682babb3b097ebc4242fcca8c96d7476
languageName: node
linkType: hard
"ngraph.random@npm:^1.0.0":
version: 1.2.0
resolution: "ngraph.random@npm:1.2.0"
checksum: 10c0/83fcb37a9ad3cfd072c1641242aa8cf3fdaf79076395d61b527cc041c7df530a4cf30ee21484aa3bb47d9e69d3523778b454a7a567174ef90e87869e55d7c63c
languageName: node
linkType: hard
"nipplejs@npm:^0.10.2":
version: 0.10.2
resolution: "nipplejs@npm:0.10.2"
checksum: 10c0/7123558685ccdb8144c9ce04f78db56916f4579b94cb19f5b412535a69981d288252974592692dba6857bd0ad012431156fe1442becd70097df86152ea7fe9ac
languageName: node
linkType: hard
"node-fetch@npm:2.6.7, node-fetch@npm:^2.6.7":
version: 2.6.7
resolution: "node-fetch@npm:2.6.7"
......@@ -7716,8 +7307,6 @@ __metadata:
"@types/node": "npm:24.4.0"
"@types/react": "npm:^18.2.61"
classnames: "npm:^2.5.1"
d3-force: "npm:^3.0.0"
d3-zoom: "npm:^3.0.0"
date-fns: "npm:^3.6.0"
gray-matter: "npm:^4.0.3"
hydra-synth: "npm:^1.3.29"
......@@ -7725,7 +7314,6 @@ __metadata:
jest-environment-jsdom: "npm:^30.1.2"
jest-pnp-resolver: "npm:^1.2.3"
marked: "npm:^15.0.12"
minisearch: "npm:^7.1.2"
next: "npm:^15.3.0"
next-router-mock: "npm:^1.0.2"
p5: "npm:1.11.3"
......@@ -7734,8 +7322,6 @@ __metadata:
prismjs: "npm:^1.30.0"
react: "npm:^18.2.0"
react-dom: "npm:^18.2.0"
react-force-graph: "npm:^1.48.1"
react-force-graph-2d: "npm:^1.29.0"
react-icons: "npm:^5.5.0"
react-instantsearch: "npm:^7.15.7"
react-instantsearch-dom: "npm:^6.40.4"
......@@ -7745,8 +7331,6 @@ __metadata:
react-syntax-highlighter: "npm:^15.5.0"
remark: "npm:^14.0.0"
remark-html: "npm:^15.0.0"
snowball-stemmers: "npm:^0.6.0"
stopword: "npm:^3.1.5"
swiper: "npm:^11.2.6"
tailwindcss: "npm:^4.2.1"
typescript: "npm:^5.3.3"
......@@ -7754,15 +7338,6 @@ __metadata:
languageName: unknown
linkType: soft
"polished@npm:4":
version: 4.3.1
resolution: "polished@npm:4.3.1"
dependencies:
"@babel/runtime": "npm:^7.17.8"
checksum: 10c0/45480d4c7281a134281cef092f6ecc202a868475ff66a390fee6e9261386e16f3047b4de46a2f2e1cf7fb7aa8f52d30b4ed631a1e3bcd6f303ca31161d4f07fe
languageName: node
linkType: hard
"postcss@npm:8.4.31":
version: 8.4.31
resolution: "postcss@npm:8.4.31"
......@@ -7785,7 +7360,7 @@ __metadata:
languageName: node
linkType: hard
"preact@npm:10, preact@npm:^10.10.0":
"preact@npm:^10.10.0":
version: 10.27.2
resolution: "preact@npm:10.27.2"
checksum: 10c0/951b708f7afa34391e054b0f1026430e8f5f6d5de24020beef70288e17067e473b9ee5503a994e0a80ced014826f56708fea5902f80346432c22dfcf3dff4be7
......@@ -7851,7 +7426,7 @@ __metadata:
languageName: node
linkType: hard
"prop-types@npm:15, prop-types@npm:^15.6.2, prop-types@npm:^15.7.2":
"prop-types@npm:^15.6.2, prop-types@npm:^15.7.2":
version: 15.8.1
resolution: "prop-types@npm:15.8.1"
dependencies:
......@@ -7975,35 +7550,6 @@ __metadata:
languageName: node
linkType: hard
"react-force-graph-2d@npm:^1.29.0":
version: 1.29.0
resolution: "react-force-graph-2d@npm:1.29.0"
dependencies:
force-graph: "npm:^1.51"
prop-types: "npm:15"
react-kapsule: "npm:^2.5"
peerDependencies:
react: "*"
checksum: 10c0/d9bfa894712d0997a190e98af93533c3e549a06800a2aed4b5d03b5c4aa76583d68cf22f1cf660c447082b95ba591e00ea1a7757e8faf86a5cc43259c16d7c13
languageName: node
linkType: hard
"react-force-graph@npm:^1.48.1":
version: 1.48.1
resolution: "react-force-graph@npm:1.48.1"
dependencies:
3d-force-graph: "npm:^1.79"
3d-force-graph-ar: "npm:^1.10"
3d-force-graph-vr: "npm:^3.1"
force-graph: "npm:^1.51"
prop-types: "npm:15"
react-kapsule: "npm:^2.5"
peerDependencies:
react: "*"
checksum: 10c0/f31a0f32b8f4065d0ebbc2127fdc94336a6040697d2aec8f8ce8e45de3fec95927ec1f1c742d91b258eface41cf17897e25ad5761cd38619cac6ac9031144133
languageName: node
linkType: hard
"react-icons@npm:^5.5.0":
version: 5.5.0
resolution: "react-icons@npm:5.5.0"
......@@ -8101,17 +7647,6 @@ __metadata:
languageName: node
linkType: hard
"react-kapsule@npm:^2.5":
version: 2.5.7
resolution: "react-kapsule@npm:2.5.7"
dependencies:
jerrypick: "npm:^1.1.1"
peerDependencies:
react: ">=16.13.1"
checksum: 10c0/27ced6562f684c0776e28b81a292ac3998f4e113eef417e493ee684335a3d9a46d776a408ad388e0c450c9955304e65a1e150305d62a7492e7b06d3019ffb75b
languageName: node
linkType: hard
"react-markdown@npm:^10.1.0":
version: 10.1.0
resolution: "react-markdown@npm:10.1.0"
......@@ -8633,13 +8168,6 @@ __metadata:
languageName: node
linkType: hard
"snowball-stemmers@npm:^0.6.0":
version: 0.6.0
resolution: "snowball-stemmers@npm:0.6.0"
checksum: 10c0/36cdd4f24fd0651cfd0e4f9dd96719831b44e5c552b36645a13bc9c395f3aa610f2a8da03ff5027a50d025993d1c5d19ccbebc23bd5d45686eb6aa457e896cd8
languageName: node
linkType: hard
"socks-proxy-agent@npm:^8.0.3":
version: 8.0.5
resolution: "socks-proxy-agent@npm:8.0.5"
......@@ -8738,13 +8266,6 @@ __metadata:
languageName: node
linkType: hard
"stopword@npm:^3.1.5":
version: 3.1.5
resolution: "stopword@npm:3.1.5"
checksum: 10c0/b252d9fa1b3cee80746529465416ca22f60c77ae2076340cb83d3d5329baaa6658e1e641b7cd194257b9d4d2f4f2829210398a1553ab212c5f8d3606e54795df
languageName: node
linkType: hard
"stream-parser@npm:^0.3.1":
version: 0.3.1
resolution: "stream-parser@npm:0.3.1"
......@@ -9026,64 +8547,6 @@ __metadata:
languageName: node
linkType: hard
"three-forcegraph@npm:1":
version: 1.43.0
resolution: "three-forcegraph@npm:1.43.0"
dependencies:
accessor-fn: "npm:1"
d3-array: "npm:1 - 3"
d3-force-3d: "npm:2 - 3"
d3-scale: "npm:1 - 4"
d3-scale-chromatic: "npm:1 - 3"
data-bind-mapper: "npm:1"
kapsule: "npm:^1.16"
ngraph.forcelayout: "npm:3"
ngraph.graph: "npm:20"
tinycolor2: "npm:1"
peerDependencies:
three: ">=0.118.3"
checksum: 10c0/bfcad05e9ac9dae310e1e42cdd3e1cde2b267a88d678f741faaa651daa4a69295253041c2f9040266353ff76f8c2f52247ede8af667aacece707238715459e6b
languageName: node
linkType: hard
"three-pathfinding@npm:^1.3.0":
version: 1.3.0
resolution: "three-pathfinding@npm:1.3.0"
peerDependencies:
three: 0.x.x
checksum: 10c0/a925d70f3f735c06f7f8f2dd97610fa8b49be34ef1cd2eaf663a89d36af9a2980c5bcc827ea950bb975e2d9282735b8296cb6d2139359f880e6bba2a1eac02c4
languageName: node
linkType: hard
"three-render-objects@npm:^1.35":
version: 1.40.4
resolution: "three-render-objects@npm:1.40.4"
dependencies:
"@tweenjs/tween.js": "npm:18 - 25"
accessor-fn: "npm:1"
float-tooltip: "npm:^1.7"
kapsule: "npm:^1.16"
polished: "npm:4"
peerDependencies:
three: ">=0.168"
checksum: 10c0/6beffca8cc8b0df893f3b4f5d04c0930200df04adbc9a4514ed270f4795a8571ede3d3bf545d9a1cbca8ce0390ebf67d1463e066890c97271be3dabbd36fab3e
languageName: node
linkType: hard
"three@npm:>=0.118 <1":
version: 0.181.2
resolution: "three@npm:0.181.2"
checksum: 10c0/b34b6240fbedebc7f8a9317c062f7ee5339de0e56250ba2ae3de52edb9c517dc8e9aaf6fe242e1bfff56808081e095db28be5ef1a05947e0c82aefada95c628b
languageName: node
linkType: hard
"three@npm:^0.164.0":
version: 0.164.1
resolution: "three@npm:0.164.1"
checksum: 10c0/f34dc945444fba814be542a907a2f6f2bed3189315604b8ef936d95513b2a4030807df63dcbb48b658bbe3d3e77a446cf2d164c1c08465578c23d4c278d76bb3
languageName: node
linkType: hard
"time-span@npm:4.0.0":
version: 4.0.0
resolution: "time-span@npm:4.0.0"
......@@ -9093,13 +8556,6 @@ __metadata:
languageName: node
linkType: hard
"tinycolor2@npm:1, tinycolor2@npm:^1.6.0":
version: 1.6.0
resolution: "tinycolor2@npm:1.6.0"
checksum: 10c0/9aa79a36ba2c2a87cb221453465cabacd04b9e35f9694373e846fdc78b1c768110f81e581ea41440106c0f24d9a023891d0887e8075885e790ac40eb0e74a5c1
languageName: node
linkType: hard
"tinyglobby@npm:^0.2.12":
version: 0.2.15
resolution: "tinyglobby@npm:0.2.15"
......
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