Commit 40fb459c by PLN (Algolia)

feat(Dunbar): Search+details

parent 314d007a
---
description: Prevent mixing Next.js data-fetching strategies per page and enforce client-only for Dunbar
globs:
alwaysApply: true
---
# Next.js data fetching guardrails
- Do not export multiple data fetching methods from the same page:
- Never mix `getServerSideProps` with `getStaticProps`/`getStaticPaths` in a single file.
- Each page must choose exactly one strategy or none.
- Dunbar page policy (`next/pages/dunbar/**`):
- Client-only app. Do NOT export `getServerSideProps`, `getStaticProps`, or `getStaticPaths` from any file under `next/pages/dunbar/**`.
- All data is local (localStorage). Use client-side effects/hooks only.
- Keep Dunbar free of SSR/SSG to avoid hydration and strategy conflicts.
- Content pages policy (posts, poems, talks, hydra, parvagues):
- Static generation is allowed and encouraged on those sections where already used.
- If server-side rendering is introduced on any non-Dunbar page, ensure no SSG export is present in the same file.
# Debugging stale strategy errors
- If you see: “You can not use getStaticProps or getStaticPaths with getServerSideProps” after removing an export:
- Stop the dev server and clear the Next.js cache: remove `next/.next/`
- Restart the dev server to ensure no stale compiled artifacts remain.
# Rationale
- Dunbar is a local-first, privacy-first client app. SSR/SSG unintentionally introduced on that route causes strategy conflicts in Next.js.
- Limiting data fetching exports ensures predictable build/runtime behavior and avoids “stale” mismatches across HMR/webpack caches.
...@@ -36,6 +36,7 @@ const RAW_RUNTIME_STATE = ...@@ -36,6 +36,7 @@ const RAW_RUNTIME_STATE =
["gray-matter", "npm:4.0.3"],\ ["gray-matter", "npm:4.0.3"],\
["hydra-synth", "npm:1.3.29"],\ ["hydra-synth", "npm:1.3.29"],\
["marked", "npm:15.0.11"],\ ["marked", "npm:15.0.11"],\
["minisearch", "npm:7.1.2"],\
["next", "virtual:599f9545309f05166d506895056809d0f5971d8f55d13475681739816b4bb6745eb725b53ed2108e22ea4ce46b8a2c491882d38a9948455117b545da3880eea7#npm:15.3.0"],\ ["next", "virtual:599f9545309f05166d506895056809d0f5971d8f55d13475681739816b4bb6745eb725b53ed2108e22ea4ce46b8a2c491882d38a9948455117b545da3880eea7#npm:15.3.0"],\
["prismjs", "npm:1.30.0"],\ ["prismjs", "npm:1.30.0"],\
["react", "npm:18.3.1"],\ ["react", "npm:18.3.1"],\
...@@ -48,6 +49,8 @@ const RAW_RUNTIME_STATE = ...@@ -48,6 +49,8 @@ const RAW_RUNTIME_STATE =
["react-syntax-highlighter", "virtual:599f9545309f05166d506895056809d0f5971d8f55d13475681739816b4bb6745eb725b53ed2108e22ea4ce46b8a2c491882d38a9948455117b545da3880eea7#npm:15.6.1"],\ ["react-syntax-highlighter", "virtual:599f9545309f05166d506895056809d0f5971d8f55d13475681739816b4bb6745eb725b53ed2108e22ea4ce46b8a2c491882d38a9948455117b545da3880eea7#npm:15.6.1"],\
["remark", "npm:14.0.3"],\ ["remark", "npm:14.0.3"],\
["remark-html", "npm:15.0.2"],\ ["remark-html", "npm:15.0.2"],\
["snowball-stemmers", "npm:0.6.0"],\
["stopword", "npm:3.1.5"],\
["swiper", "npm:11.2.6"],\ ["swiper", "npm:11.2.6"],\
["typescript", "patch:typescript@npm%3A5.8.3#optional!builtin<compat/typescript>::version=5.8.3&hash=d69c25"],\ ["typescript", "patch:typescript@npm%3A5.8.3#optional!builtin<compat/typescript>::version=5.8.3&hash=d69c25"],\
["vercel", "npm:39.4.2"]\ ["vercel", "npm:39.4.2"]\
...@@ -3632,6 +3635,15 @@ const RAW_RUNTIME_STATE = ...@@ -3632,6 +3635,15 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\ "linkType": "HARD"\
}]\ }]\
]],\ ]],\
["minisearch", [\
["npm:7.1.2", {\
"packageLocation": "../../../../.yarn/berry/cache/minisearch-npm-7.1.2-58be9ecf87-10c0.zip/node_modules/minisearch/",\
"packageDependencies": [\
["minisearch", "npm:7.1.2"]\
],\
"linkType": "HARD"\
}]\
]],\
["minizlib", [\ ["minizlib", [\
["npm:1.3.3", {\ ["npm:1.3.3", {\
"packageLocation": "../../../../.yarn/berry/cache/minizlib-npm-1.3.3-b590e5bfb8-10c0.zip/node_modules/minizlib/",\ "packageLocation": "../../../../.yarn/berry/cache/minizlib-npm-1.3.3-b590e5bfb8-10c0.zip/node_modules/minizlib/",\
...@@ -4129,6 +4141,7 @@ const RAW_RUNTIME_STATE = ...@@ -4129,6 +4141,7 @@ const RAW_RUNTIME_STATE =
["gray-matter", "npm:4.0.3"],\ ["gray-matter", "npm:4.0.3"],\
["hydra-synth", "npm:1.3.29"],\ ["hydra-synth", "npm:1.3.29"],\
["marked", "npm:15.0.11"],\ ["marked", "npm:15.0.11"],\
["minisearch", "npm:7.1.2"],\
["next", "virtual:599f9545309f05166d506895056809d0f5971d8f55d13475681739816b4bb6745eb725b53ed2108e22ea4ce46b8a2c491882d38a9948455117b545da3880eea7#npm:15.3.0"],\ ["next", "virtual:599f9545309f05166d506895056809d0f5971d8f55d13475681739816b4bb6745eb725b53ed2108e22ea4ce46b8a2c491882d38a9948455117b545da3880eea7#npm:15.3.0"],\
["prismjs", "npm:1.30.0"],\ ["prismjs", "npm:1.30.0"],\
["react", "npm:18.3.1"],\ ["react", "npm:18.3.1"],\
...@@ -4141,6 +4154,8 @@ const RAW_RUNTIME_STATE = ...@@ -4141,6 +4154,8 @@ const RAW_RUNTIME_STATE =
["react-syntax-highlighter", "virtual:599f9545309f05166d506895056809d0f5971d8f55d13475681739816b4bb6745eb725b53ed2108e22ea4ce46b8a2c491882d38a9948455117b545da3880eea7#npm:15.6.1"],\ ["react-syntax-highlighter", "virtual:599f9545309f05166d506895056809d0f5971d8f55d13475681739816b4bb6745eb725b53ed2108e22ea4ce46b8a2c491882d38a9948455117b545da3880eea7#npm:15.6.1"],\
["remark", "npm:14.0.3"],\ ["remark", "npm:14.0.3"],\
["remark-html", "npm:15.0.2"],\ ["remark-html", "npm:15.0.2"],\
["snowball-stemmers", "npm:0.6.0"],\
["stopword", "npm:3.1.5"],\
["swiper", "npm:11.2.6"],\ ["swiper", "npm:11.2.6"],\
["typescript", "patch:typescript@npm%3A5.8.3#optional!builtin<compat/typescript>::version=5.8.3&hash=d69c25"],\ ["typescript", "patch:typescript@npm%3A5.8.3#optional!builtin<compat/typescript>::version=5.8.3&hash=d69c25"],\
["vercel", "npm:39.4.2"]\ ["vercel", "npm:39.4.2"]\
...@@ -4922,6 +4937,15 @@ const RAW_RUNTIME_STATE = ...@@ -4922,6 +4937,15 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\ "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"\
}]\
]],\
["source-map-js", [\ ["source-map-js", [\
["npm:1.2.1", {\ ["npm:1.2.1", {\
"packageLocation": "../../../../.yarn/berry/cache/source-map-js-npm-1.2.1-b9a47d7e1a-10c0.zip/node_modules/source-map-js/",\ "packageLocation": "../../../../.yarn/berry/cache/source-map-js-npm-1.2.1-b9a47d7e1a-10c0.zip/node_modules/source-map-js/",\
...@@ -4974,6 +4998,15 @@ const RAW_RUNTIME_STATE = ...@@ -4974,6 +4998,15 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\ "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", [\ ["stream-parser", [\
["npm:0.3.1", {\ ["npm:0.3.1", {\
"packageLocation": "../../../../.yarn/berry/cache/stream-parser-npm-0.3.1-0b70187c85-10c0.zip/node_modules/stream-parser/",\ "packageLocation": "../../../../.yarn/berry/cache/stream-parser-npm-0.3.1-0b70187c85-10c0.zip/node_modules/stream-parser/",\
......
...@@ -3,6 +3,7 @@ import styles from '@/styles/dunbar.module.css'; ...@@ -3,6 +3,7 @@ import styles from '@/styles/dunbar.module.css';
import { useDunbarStore } from '@/components/dunbar/useDunbarStore'; import { useDunbarStore } from '@/components/dunbar/useDunbarStore';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { makeExportPayload } from '@/lib/dunbar'; import { makeExportPayload } from '@/lib/dunbar';
import { generateDemoPayload } from '@/lib/dunbar-demo';
// Lazy-load heavy tabs if needed (Network uses d3) // Lazy-load heavy tabs if needed (Network uses d3)
const NetworkTab = dynamic(() => import('@/components/dunbar/NetworkTab'), { ssr: false }); const NetworkTab = dynamic(() => import('@/components/dunbar/NetworkTab'), { ssr: false });
...@@ -13,12 +14,14 @@ const EventsTab = dynamic(() => import('@/components/dunbar/EventsTab'), { ssr: ...@@ -13,12 +14,14 @@ const EventsTab = dynamic(() => import('@/components/dunbar/EventsTab'), { ssr:
import FriendsList from '@/components/dunbar/FriendsList'; import FriendsList from '@/components/dunbar/FriendsList';
import FriendDetail from '@/components/dunbar/FriendDetail'; import FriendDetail from '@/components/dunbar/FriendDetail';
import StatsTab from '@/components/dunbar/StatsTab'; import StatsTab from '@/components/dunbar/StatsTab';
import SearchTab from '@/components/dunbar/SearchTab';
const PASSWORD = 'freehugs4all'; const PASSWORD = 'freehugs4all';
function Tabs({ tab, setTab }) { function Tabs({ tab, setTab }) {
const items = [ const items = [
{ id: 'friends', label: 'Friends' }, { id: 'friends', label: 'Friends' },
{ id: 'search', label: 'Search' },
{ id: 'events', label: 'Events' }, { id: 'events', label: 'Events' },
{ id: 'orbits', label: 'Orbits' }, { id: 'orbits', label: 'Orbits' },
{ id: 'network', label: 'Network' }, { id: 'network', label: 'Network' },
...@@ -48,10 +51,12 @@ export default function DunbarApp() { ...@@ -48,10 +51,12 @@ export default function DunbarApp() {
const fileInputRef = useRef(null); const fileInputRef = useRef(null);
// Password gate: prompt once per session // Password gate: prompt once per session
// Dev convenience: auto-unlock on localhost or NODE_ENV=development to enable automated preview
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
const stored = window.sessionStorage.getItem('dunbarAuthed'); const stored = window.sessionStorage.getItem('dunbarAuthed');
if (stored === '1') { const isDev = process.env.NODE_ENV === 'development' || window.location.hostname === 'localhost';
if (stored === '1' || isDev) {
setAuthed(true); setAuthed(true);
return; return;
} }
...@@ -104,6 +109,15 @@ export default function DunbarApp() { ...@@ -104,6 +109,15 @@ export default function DunbarApp() {
} }
}; };
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 // Navigation from viz → friend detail
const openFriendDetail = (friendId) => { const openFriendDetail = (friendId) => {
actions.selectFriend(friendId); actions.selectFriend(friendId);
...@@ -138,6 +152,7 @@ export default function DunbarApp() { ...@@ -138,6 +152,7 @@ export default function DunbarApp() {
onChange={onImportFile} onChange={onImportFile}
style={{ display: 'none' }} style={{ display: 'none' }}
/> />
<button className={styles.btnSecondary} onClick={onDemo}>Demo</button>
<button className={styles.btnSecondary} onClick={actions.resetData}>Reset Data</button> <button className={styles.btnSecondary} onClick={actions.resetData}>Reset Data</button>
</div> </div>
</div> </div>
...@@ -167,12 +182,22 @@ export default function DunbarApp() { ...@@ -167,12 +182,22 @@ export default function DunbarApp() {
onToggleRel={(a, b) => actions.toggleRelationship(a, b)} onToggleRel={(a, b) => actions.toggleRelationship(a, b)}
onAddEvent={(payload) => actions.addEvent(payload)} onAddEvent={(payload) => actions.addEvent(payload)}
onRename={(id, name) => actions.renameFriend(id, name)} 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 // scroll preservation inside relationship list handled internally
/> />
</div> </div>
</div> </div>
)} )}
{tab === 'search' && (
<SearchTab
friends={friends}
openFriend={openFriendDetail}
/>
)}
{tab === 'events' && ( {tab === 'events' && (
<EventsTab <EventsTab
friends={friends} friends={friends}
...@@ -198,7 +223,7 @@ export default function DunbarApp() { ...@@ -198,7 +223,7 @@ export default function DunbarApp() {
)} )}
{tab === 'stats' && ( {tab === 'stats' && (
<StatsTab stats={derived.stats} /> <StatsTab stats={derived.stats} anniversaries={derived.anniversaries} />
)} )}
</div> </div>
); );
......
...@@ -8,6 +8,7 @@ import { ...@@ -8,6 +8,7 @@ import {
groupEventsByDay, groupEventsByDay,
isoDate, isoDate,
} from '@/lib/dunbar'; } from '@/lib/dunbar';
import { extractTags } from '@/lib/dunbar';
export default function EventsTab({ friends, addEvent, eventIndex }) { export default function EventsTab({ friends, addEvent, eventIndex }) {
// Creation form state // Creation form state
...@@ -55,6 +56,30 @@ export default function EventsTab({ friends, addEvent, eventIndex }) { ...@@ -55,6 +56,30 @@ export default function EventsTab({ friends, addEvent, eventIndex }) {
// Timeline groups from merged eventIndex // Timeline groups from merged eventIndex
const groups = useMemo(() => groupEventsByDay(eventIndex), [eventIndex]); const groups = useMemo(() => groupEventsByDay(eventIndex), [eventIndex]);
// 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 ( return (
<div className={styles.twoCol} style={{ gap: 16 }}> <div className={styles.twoCol} style={{ gap: 16 }}>
{/* New Event Builder */} {/* New Event Builder */}
...@@ -70,6 +95,7 @@ export default function EventsTab({ friends, addEvent, eventIndex }) { ...@@ -70,6 +95,7 @@ export default function EventsTab({ friends, addEvent, eventIndex }) {
</div> </div>
<div className={styles.row} style={{ marginBottom: 8, flexWrap: 'wrap' }}> <div className={styles.row} style={{ marginBottom: 8, flexWrap: 'wrap' }}>
<input <input
lang="fr-FR"
type="date" type="date"
className={styles.input} className={styles.input}
value={date} value={date}
...@@ -144,10 +170,23 @@ export default function EventsTab({ friends, addEvent, eventIndex }) { ...@@ -144,10 +170,23 @@ export default function EventsTab({ friends, addEvent, eventIndex }) {
return ( return (
<div key={e.id + e.date} className={styles.timelineEvent}> <div key={e.id + e.date} className={styles.timelineEvent}>
<div><strong>{names.join(', ') || 'Unknown'}</strong></div> <div><strong>{names.join(', ') || 'Unknown'}</strong></div>
<div style={{ whiteSpace: 'pre-wrap' }}>{e.notes}</div> <div style={{ whiteSpace: 'pre-wrap' }}>
{renderNotesWithTags(e.notes || '')}
</div>
{e.location ? ( {e.location ? (
<div style={{ color: '#555', marginTop: 4 }}>📍 {e.location}</div> <div style={{ color: '#555', marginTop: 4 }}>📍 {e.location}</div>
) : null} ) : 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 style={{ color: '#888', marginTop: 4, fontSize: 12 }}> <div style={{ color: '#888', marginTop: 4, fontSize: 12 }}>
{isoDate(e.date)} {isoDate(e.date)}
</div> </div>
......
...@@ -62,7 +62,7 @@ export default function NetworkTab({ friends, toggleRel, openFriendDetail }) { ...@@ -62,7 +62,7 @@ export default function NetworkTab({ friends, toggleRel, openFriendDetail }) {
}; };
// Simulation setup/refresh // Simulation setup/refresh
const [physicsOn, setPhysicsOn] = useState(true); const physicsOn = true;
useEffect(() => { useEffect(() => {
let stopped = false; let stopped = false;
let sim; let sim;
...@@ -82,11 +82,7 @@ export default function NetworkTab({ friends, toggleRel, openFriendDetail }) { ...@@ -82,11 +82,7 @@ export default function NetworkTab({ friends, toggleRel, openFriendDetail }) {
.force('center', d3force.forceCenter(0, 0)) .force('center', d3force.forceCenter(0, 0))
.force('collide', d3force.forceCollide(18)); .force('collide', d3force.forceCollide(18));
if (!physicsOn) {
sim.alphaTarget(0).stop();
} else {
sim.alpha(0.8).alphaTarget(0.03).restart(); sim.alpha(0.8).alphaTarget(0.03).restart();
}
sim.on('tick', () => { sim.on('tick', () => {
if (stopped) return; if (stopped) return;
...@@ -110,7 +106,7 @@ export default function NetworkTab({ friends, toggleRel, openFriendDetail }) { ...@@ -110,7 +106,7 @@ export default function NetworkTab({ friends, toggleRel, openFriendDetail }) {
simRef.current = null; simRef.current = null;
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [nodes, links, physicsOn]); // rebuild when graph or physics toggles }, [nodes, links]); // rebuild when graph updates
// Drawing // Drawing
const requestDraw = () => { const requestDraw = () => {
...@@ -180,11 +176,13 @@ export default function NetworkTab({ friends, toggleRel, openFriendDetail }) { ...@@ -180,11 +176,13 @@ export default function NetworkTab({ friends, toggleRel, openFriendDetail }) {
ctx.fill(); ctx.fill();
} }
// Labels (keep readable irrespective of zoom) // Labels: world coords, offset by node radius; keep pixel size constant with inverse scaling
ctx.scale(1 / transform.k, 1 / transform.k); // neutralize zoom for label size
for (const n of nodes) { for (const n of nodes) {
const s = getNodeState(n.id); const s = getNodeState(n.id);
drawLabel(ctx, n.name, transform.k * s.x + transform.x, transform.k * s.y + transform.y - 14, '#333'); const r = nodeRadius(n.id);
const fontPx = 12 / transform.k;
const yOff = (r + 6) / transform.k; // a bit above the node
drawLabel(ctx, n.name, s.x, s.y - yOff, '#333', fontPx);
} }
ctx.restore(); ctx.restore();
...@@ -237,7 +235,7 @@ export default function NetworkTab({ friends, toggleRel, openFriendDetail }) { ...@@ -237,7 +235,7 @@ export default function NetworkTab({ friends, toggleRel, openFriendDetail }) {
const s = getNodeState(id); const s = getNodeState(id);
stateRef.current.dragOffset = { x: s.x - x, y: s.y - y }; stateRef.current.dragOffset = { x: s.x - x, y: s.y - y };
// Nudge simulation // Nudge simulation
if (simRef.current && physicsOn) { if (simRef.current) {
simRef.current.alphaTarget(0.1).restart(); simRef.current.alphaTarget(0.1).restart();
} }
} }
...@@ -319,21 +317,33 @@ export default function NetworkTab({ friends, toggleRel, openFriendDetail }) { ...@@ -319,21 +317,33 @@ export default function NetworkTab({ friends, toggleRel, openFriendDetail }) {
requestDraw(); requestDraw();
}; };
const zoomAt = (factor, sx, sy) => {
const rect = canvasRef.current?.getBoundingClientRect();
const cx = rect ? rect.width / 2 : 0;
const cy = rect ? rect.height / 2 : 0;
const px = sx ?? cx;
const py = sy ?? cy;
setTransform((t) => {
const newK = clamp(t.k * factor, 0.2, 4);
const wx0 = (px - t.x) / t.k;
const wy0 = (py - t.y) / t.k;
const x = px - wx0 * newK;
const y = py - wy0 * newK;
return { k: newK, x, y };
});
};
const panBy = (dx, dy) => {
setTransform(t => ({ ...t, x: t.x + dx, y: t.y + dy }));
};
const onWheel = (e) => { const onWheel = (e) => {
e.preventDefault(); e.preventDefault();
const rect = canvasRef.current.getBoundingClientRect(); const rect = canvasRef.current.getBoundingClientRect();
const sx = e.clientX - rect.left; const sx = e.clientX - rect.left;
const sy = e.clientY - rect.top; const sy = e.clientY - rect.top;
const factor = Math.exp(-e.deltaY * 0.0015); const factor = Math.exp(-e.deltaY * 0.0015);
const newK = clamp(transform.k * factor, 0.2, 4); zoomAt(factor, sx, sy);
// zoom to cursor
const wx0 = (sx - transform.x) / transform.k;
const wy0 = (sy - transform.y) / transform.k;
const x = sx - wx0 * newK;
const y = sy - wy0 * newK;
setTransform({ k: newK, x, y });
}; };
// Overlay draw (draft link) // Overlay draw (draft link)
...@@ -368,18 +378,29 @@ export default function NetworkTab({ friends, toggleRel, openFriendDetail }) { ...@@ -368,18 +378,29 @@ export default function NetworkTab({ friends, toggleRel, openFriendDetail }) {
ctx.restore(); ctx.restore();
}); });
// Keyboard navigation scoped to container
const onKeyDown = (e) => {
const PAN = 40;
if (e.key === 'ArrowLeft') setTransform(t => ({ ...t, x: t.x + PAN }));
else if (e.key === 'ArrowRight') setTransform(t => ({ ...t, x: t.x - PAN }));
else if (e.key === 'ArrowUp') setTransform(t => ({ ...t, y: t.y + PAN }));
else if (e.key === 'ArrowDown') setTransform(t => ({ ...t, y: t.y - PAN }));
else if (e.key === '+' || e.key === '=') zoomAt(1.2);
else if (e.key === '-' || e.key === '_') zoomAt(1 / 1.2);
else if (e.key === '0') setTransform({ k: 1, x: 0, y: 0 });
else if (e.key.toLowerCase() === 'e') setEditMode(v => !v);
};
// Focus container on mount so arrows/+/− work immediately
useEffect(() => {
containerRef.current?.focus();
}, []);
return ( return (
<div> <div>
<div className={styles.graphToolbar}> <div className={styles.graphToolbar}>
<button <button
className={styles.btnSecondary} className={styles.btnSecondary}
onClick={() => setPhysicsOn(v => !v)}
title="Toggle physics simulation"
>
Physics: {physicsOn ? 'On' : 'Off'}
</button>
<button
className={styles.btnSecondary}
onClick={() => setEditMode(v => !v)} onClick={() => setEditMode(v => !v)}
title="Toggle edit mode to create/remove connections" title="Toggle edit mode to create/remove connections"
> >
...@@ -393,6 +414,11 @@ export default function NetworkTab({ friends, toggleRel, openFriendDetail }) { ...@@ -393,6 +414,11 @@ export default function NetworkTab({ friends, toggleRel, openFriendDetail }) {
<div <div
ref={containerRef} ref={containerRef}
className={styles.canvasWrap} className={styles.canvasWrap}
tabIndex={0}
role="application"
aria-label="Network graph"
onKeyDown={onKeyDown}
style={{ outline: 'none' }}
> >
<canvas <canvas
ref={canvasRef} ref={canvasRef}
...@@ -403,6 +429,17 @@ export default function NetworkTab({ friends, toggleRel, openFriendDetail }) { ...@@ -403,6 +429,17 @@ export default function NetworkTab({ friends, toggleRel, openFriendDetail }) {
onMouseUp={onMouseUp} onMouseUp={onMouseUp}
onWheel={onWheel} onWheel={onWheel}
/> />
<div className={styles.floatingControls}>
<button className={styles.ctrlBtn} onClick={() => panBy(-40, 0)} aria-label="Pan left"></button>
<button className={styles.ctrlBtn} onClick={() => panBy(0, -40)} aria-label="Pan up"></button>
<button className={styles.ctrlBtn} onClick={() => panBy(40, 0)} aria-label="Pan right"></button>
<button className={styles.ctrlBtn} onClick={() => zoomAt(1 / 1.2)} aria-label="Zoom out"></button>
<button className={styles.ctrlBtn} onClick={() => setTransform({ k: 1, x: 0, y: 0 })} aria-label="Reset"></button>
<button className={styles.ctrlBtn} onClick={() => zoomAt(1.2)} aria-label="Zoom in"></button>
<button className={`${styles.ctrlBtn} ${styles.ctrlWide}`} onClick={() => setEditMode(v => !v)} aria-label="Toggle edit">
{editMode ? 'Edit:ON' : 'Edit:OFF'}
</button>
</div>
</div> </div>
</div> </div>
); );
......
import { useEffect, useMemo, useState } from 'react';
import styles from '@/styles/dunbar.module.css';
import {
buildSearchIndexes,
querySearch,
extractTagsFromText,
suggestTags,
suggestPersons,
} from '@/lib/dunbar-search';
export default function SearchTab({ friends, openFriend }) {
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}</>;
};
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}>{f.name}</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={{ 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 React from 'react';
import styles from '@/styles/dunbar.module.css'; import styles from '@/styles/dunbar.module.css';
import { isoDate } from '@/lib/dunbar';
export default function StatsTab({ stats }) { export default function StatsTab({ stats, anniversaries = [] }) {
if (!stats) return null; if (!stats) return null;
const items = [ const items = [
{ label: 'Connections', value: stats.connections }, { label: 'Connections', value: stats.connections },
...@@ -9,6 +10,17 @@ export default function StatsTab({ stats }) { ...@@ -9,6 +10,17 @@ export default function StatsTab({ stats }) {
{ label: 'Total Events', value: stats.totalEvents }, { label: 'Total Events', value: stats.totalEvents },
{ label: 'Avg Events / Friend', value: stats.avgEventsPerFriend }, { label: 'Avg Events / Friend', value: stats.avgEventsPerFriend },
]; ];
// 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 ( return (
<div className={styles.card}> <div className={styles.card}>
<div className={styles.cardHeader}>Statistics</div> <div className={styles.cardHeader}>Statistics</div>
...@@ -20,6 +32,44 @@ export default function StatsTab({ stats }) { ...@@ -20,6 +32,44 @@ export default function StatsTab({ stats }) {
</div> </div>
))} ))}
</div> </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 }}>{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> </div>
); );
} }
import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react'; import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
import { normalizeImportedPayload } from '@/lib/dunbar'; import { normalizeImportedPayload, isoDate } from '@/lib/dunbar';
import { computeUpcomingAnniversaries } from '@/lib/dunbar';
const LS_KEY = 'dunbar-state-v1'; const LS_KEY = 'dunbar-state-v1';
...@@ -55,6 +56,22 @@ function reducer(state, action) { ...@@ -55,6 +56,22 @@ function reducer(state, action) {
const newFriend = { const newFriend = {
id: uuid(), id: uuid(),
name, name,
birthday: null,
notes: '',
// rich profile fields
likes: '',
dislikes: '',
foodLikes: '',
foodDislikes: '',
wifiPassword: '',
carModel: '',
workplace: '',
schedule: '',
futureIdeas: '',
quotes: '',
importantDates: [], // [{ date: 'YYYY-MM-DD', label: string }]
gifts: [], // [{ date, occasion, description, image }]
postcards: [], // [{ date, location, description, image }]
relationships: new Set(), relationships: new Set(),
events: [], events: [],
lastInteraction: null, lastInteraction: null,
...@@ -88,6 +105,19 @@ function reducer(state, action) { ...@@ -88,6 +105,19 @@ function reducer(state, action) {
const friends = state.friends.map((f) => (f.id === id ? { ...f, name: n } : f)); const friends = state.friends.map((f) => (f.id === id ? { ...f, name: n } : f));
return { ...state, friends }; 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': { case 'SELECT_FRIEND': {
const id = action.payload?.id ?? null; const id = action.payload?.id ?? null;
return { ...state, selectedFriendId: id }; return { ...state, selectedFriendId: id };
...@@ -113,8 +143,9 @@ function reducer(state, action) { ...@@ -113,8 +143,9 @@ function reducer(state, action) {
} }
case 'ADD_EVENT': { case 'ADD_EVENT': {
const { date, notes, participants = [], location } = action.payload || {}; const { date, notes, participants = [], location } = action.payload || {};
const dateISO = date ? new Date(date).toISOString() : null; // Normalize to YYYY-MM-DD (Paris local semantics handled at render/grouping time)
if (!dateISO || !notes || !participants.length) return state; const dateStr = typeof date === 'string' ? date.slice(0, 10) : isoDate(date);
if (!dateStr || !notes || !participants.length) return state;
const eventId = uuid(); const eventId = uuid();
// Create one logical event id applied to each participant for deduplication across views // Create one logical event id applied to each participant for deduplication across views
...@@ -122,7 +153,7 @@ function reducer(state, action) { ...@@ -122,7 +153,7 @@ function reducer(state, action) {
if (participants.includes(f.id)) { if (participants.includes(f.id)) {
const ev = { const ev = {
id: eventId, id: eventId,
date: dateISO, date: dateStr,
notes: String(notes), notes: String(notes),
location: location ? String(location) : undefined, location: location ? String(location) : undefined,
participants: [...participants], participants: [...participants],
...@@ -135,6 +166,12 @@ function reducer(state, action) { ...@@ -135,6 +166,12 @@ function reducer(state, action) {
}); });
return { ...state, friends }; 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': { case 'RESET': {
return { ...initialState }; return { ...initialState };
} }
...@@ -200,6 +237,14 @@ export function useDunbarStore() { ...@@ -200,6 +237,14 @@ export function useDunbarStore() {
dispatch({ type: 'RENAME_FRIEND', payload: { 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) => { const loadFromPayload = useCallback((payload) => {
try { try {
const normalized = normalizeImportedPayload(payload); const normalized = normalizeImportedPayload(payload);
...@@ -267,24 +312,36 @@ export function useDunbarStore() { ...@@ -267,24 +312,36 @@ export function useDunbarStore() {
}; };
}, [state.friends]); }, [state.friends]);
// Orbits (last 90 days) // Orbits (close = 5+ in 90d OR 10+ in 365d OR 20+ total)
const orbitBuckets = useMemo(() => { const orbitBuckets = useMemo(() => {
const now = Date.now(); const now = Date.now();
const ninety = 90 * 24 * 60 * 60 * 1000; const ninety = 90 * 24 * 60 * 60 * 1000;
const counts = state.friends.map((f) => { const year = 365 * 24 * 60 * 60 * 1000;
const c = (f.events || []).reduce((acc, e) => {
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(); const t = new Date(e.date).getTime();
return acc + (now - t <= ninety ? 1 : 0); if (!isNaN(t)) {
}, 0); const dt = now - t;
return { id: f.id, name: f.name, count90: c }; if (dt <= ninety) c90 += 1;
if (dt <= year) c365 += 1;
}
}
const total = evs.length;
return { id: f.id, c90, c365, total };
}); });
const inner = []; const inner = [];
const middle = []; const middle = [];
const outer = []; const outer = [];
for (const it of counts) { for (const m of metrics) {
if (it.count90 >= 5) inner.push(it.id); const isInner = m.c90 >= 5 || m.c365 >= 10 || m.total >= 20;
else if (it.count90 >= 2) middle.push(it.id); if (isInner) inner.push(m.id);
else outer.push(it.id); else if (m.c90 >= 2) middle.push(m.id);
else outer.push(m.id);
} }
return { inner, middle, outer }; return { inner, middle, outer };
}, [state.friends]); }, [state.friends]);
...@@ -307,6 +364,15 @@ export function useDunbarStore() { ...@@ -307,6 +364,15 @@ export function useDunbarStore() {
return merged; return merged;
}, [state.friends]); }, [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 { return {
state, state,
friends: state.friends, friends: state.friends,
...@@ -315,6 +381,9 @@ export function useDunbarStore() { ...@@ -315,6 +381,9 @@ export function useDunbarStore() {
addFriend, addFriend,
removeFriend, removeFriend,
renameFriend, renameFriend,
setBirthday,
setFriendNotes,
updateFriend,
selectFriend, selectFriend,
toggleRelationship, toggleRelationship,
addEvent, addEvent,
...@@ -330,6 +399,7 @@ export function useDunbarStore() { ...@@ -330,6 +399,7 @@ export function useDunbarStore() {
stats, stats,
orbitBuckets, orbitBuckets,
eventIndex, eventIndex,
anniversaries,
}, },
}; };
} }
.container { .container {
max-width: 42rem; max-width: 113rem;
padding: 0 1rem; padding: 0 1rem;
margin: 3rem auto 6rem; margin: 3rem auto 6rem;
} }
......
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 addTagsFrom = (txt) => {
for (const t of extractTagsFromText(txt || '')) aggTags.add(t);
};
addTagsFrom(f.notes);
addTagsFrom(f.likes);
addTagsFrom(f.dislikes);
addTagsFrom(f.foodLikes);
addTagsFrom(f.foodDislikes);
addTagsFrom(f.futureIdeas);
addTagsFrom(f.quotes);
for (const ev of f.events || []) addTagsFrom(ev.notes);
// Compose an extended notes blob to improve recall
const profileBlob = [
f.notes,
f.likes,
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,
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,
['notes', 'location', 'tags', 'participantNames'],
['id', 'kind', 'refId', 'tags', 'participantNames', 'date'],
{ 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);
}
...@@ -25,6 +25,7 @@ ...@@ -25,6 +25,7 @@
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"hydra-synth": "^1.3.29", "hydra-synth": "^1.3.29",
"marked": "^15.0.11", "marked": "^15.0.11",
"minisearch": "^7.1.2",
"next": "^15.3.0", "next": "^15.3.0",
"prismjs": "^1.30.0", "prismjs": "^1.30.0",
"react": "^18.2.0", "react": "^18.2.0",
...@@ -37,6 +38,8 @@ ...@@ -37,6 +38,8 @@
"react-syntax-highlighter": "^15.5.0", "react-syntax-highlighter": "^15.5.0",
"remark": "^14.0.0", "remark": "^14.0.0",
"remark-html": "^15.0.0", "remark-html": "^15.0.0",
"snowball-stemmers": "^0.6.0",
"stopword": "^3.1.5",
"swiper": "^11.2.6" "swiper": "^11.2.6"
}, },
"devDependencies": { "devDependencies": {
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
.container { .container {
padding: 16px; padding: 16px;
max-width: 1200px; max-width: 1400px;
margin: 0 auto; margin: 0 auto;
} }
...@@ -141,7 +141,7 @@ ...@@ -141,7 +141,7 @@
.twoCol { .twoCol {
display: grid; display: grid;
grid-template-columns: 1fr 1.4fr; grid-template-columns: 1fr 1.8fr;
gap: 16px; gap: 16px;
} }
...@@ -233,6 +233,24 @@ ...@@ -233,6 +233,24 @@
border-radius: 8px; border-radius: 8px;
padding: 8px 10px; padding: 8px 10px;
} }
.tagRow {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 6px;
}
.tagChip {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
background: #eef7f0;
border: 1px solid #d6e8da;
color: #2c5530;
font-size: 0.8rem;
line-height: 1.4;
}
.badge { .badge {
display: inline-block; display: inline-block;
...@@ -335,6 +353,7 @@ ...@@ -335,6 +353,7 @@
border-radius: 10px; border-radius: 10px;
overflow: hidden; overflow: hidden;
background: #fff; background: #fff;
position: relative;
} }
.chevron { .chevron {
...@@ -352,3 +371,37 @@ ...@@ -352,3 +371,37 @@
gap: 10px; gap: 10px;
text-align: center; text-align: center;
} }
/* Floating controls for Network */
.floatingControls {
position: absolute;
right: 10px;
bottom: 10px;
display: grid;
grid-template-columns: repeat(3, 40px);
gap: 6px;
background: rgba(255,255,255,0.9);
border: 1px solid #eee;
border-radius: 10px;
padding: 8px;
box-shadow: 0 8px 24px rgba(0,0,0,0.08);
}
.ctrlBtn {
width: 40px;
height: 40px;
border: 1px solid #ddd;
border-radius: 8px;
background: #fff;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 700;
color: #333;
}
.ctrlWide {
grid-column: span 3;
height: 36px;
}
...@@ -3061,6 +3061,13 @@ __metadata: ...@@ -3061,6 +3061,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"minisearch@npm:^7.1.2":
version: 7.1.2
resolution: "minisearch@npm:7.1.2"
checksum: 10c0/039f494927a4a932c168654ba8ff434d2be6af8e3393b29577d54da64cd64d6ae9bfbf33b7cd7bdc1783cb354d67c5dc99bac1305599d8d653933676f2d1a355
languageName: node
linkType: hard
"minizlib@npm:^1.3.3": "minizlib@npm:^1.3.3":
version: 1.3.3 version: 1.3.3
resolution: "minizlib@npm:1.3.3" resolution: "minizlib@npm:1.3.3"
...@@ -3511,6 +3518,7 @@ __metadata: ...@@ -3511,6 +3518,7 @@ __metadata:
gray-matter: "npm:^4.0.3" gray-matter: "npm:^4.0.3"
hydra-synth: "npm:^1.3.29" hydra-synth: "npm:^1.3.29"
marked: "npm:^15.0.11" marked: "npm:^15.0.11"
minisearch: "npm:^7.1.2"
next: "npm:^15.3.0" next: "npm:^15.3.0"
prismjs: "npm:^1.30.0" prismjs: "npm:^1.30.0"
react: "npm:^18.2.0" react: "npm:^18.2.0"
...@@ -3523,6 +3531,8 @@ __metadata: ...@@ -3523,6 +3531,8 @@ __metadata:
react-syntax-highlighter: "npm:^15.5.0" react-syntax-highlighter: "npm:^15.5.0"
remark: "npm:^14.0.0" remark: "npm:^14.0.0"
remark-html: "npm:^15.0.0" remark-html: "npm:^15.0.0"
snowball-stemmers: "npm:^0.6.0"
stopword: "npm:^3.1.5"
swiper: "npm:^11.2.6" swiper: "npm:^11.2.6"
typescript: "npm:^5.3.3" typescript: "npm:^5.3.3"
vercel: "npm:^39" vercel: "npm:^39"
...@@ -4175,6 +4185,13 @@ __metadata: ...@@ -4175,6 +4185,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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
"source-map-js@npm:^1.0.2": "source-map-js@npm:^1.0.2":
version: 1.2.1 version: 1.2.1
resolution: "source-map-js@npm:1.2.1" resolution: "source-map-js@npm:1.2.1"
...@@ -4217,6 +4234,13 @@ __metadata: ...@@ -4217,6 +4234,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "stream-parser@npm:^0.3.1":
version: 0.3.1 version: 0.3.1
resolution: "stream-parser@npm:0.3.1" resolution: "stream-parser@npm:0.3.1"
......
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