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 =
["gray-matter", "npm:4.0.3"],\
["hydra-synth", "npm:1.3.29"],\
["marked", "npm:15.0.11"],\
["minisearch", "npm:7.1.2"],\
["next", "virtual:599f9545309f05166d506895056809d0f5971d8f55d13475681739816b4bb6745eb725b53ed2108e22ea4ce46b8a2c491882d38a9948455117b545da3880eea7#npm:15.3.0"],\
["prismjs", "npm:1.30.0"],\
["react", "npm:18.3.1"],\
......@@ -48,6 +49,8 @@ const RAW_RUNTIME_STATE =
["react-syntax-highlighter", "virtual:599f9545309f05166d506895056809d0f5971d8f55d13475681739816b4bb6745eb725b53ed2108e22ea4ce46b8a2c491882d38a9948455117b545da3880eea7#npm:15.6.1"],\
["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.6"],\
["typescript", "patch:typescript@npm%3A5.8.3#optional!builtin<compat/typescript>::version=5.8.3&hash=d69c25"],\
["vercel", "npm:39.4.2"]\
......@@ -3632,6 +3635,15 @@ const RAW_RUNTIME_STATE =
"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", [\
["npm:1.3.3", {\
"packageLocation": "../../../../.yarn/berry/cache/minizlib-npm-1.3.3-b590e5bfb8-10c0.zip/node_modules/minizlib/",\
......@@ -4129,6 +4141,7 @@ const RAW_RUNTIME_STATE =
["gray-matter", "npm:4.0.3"],\
["hydra-synth", "npm:1.3.29"],\
["marked", "npm:15.0.11"],\
["minisearch", "npm:7.1.2"],\
["next", "virtual:599f9545309f05166d506895056809d0f5971d8f55d13475681739816b4bb6745eb725b53ed2108e22ea4ce46b8a2c491882d38a9948455117b545da3880eea7#npm:15.3.0"],\
["prismjs", "npm:1.30.0"],\
["react", "npm:18.3.1"],\
......@@ -4141,6 +4154,8 @@ const RAW_RUNTIME_STATE =
["react-syntax-highlighter", "virtual:599f9545309f05166d506895056809d0f5971d8f55d13475681739816b4bb6745eb725b53ed2108e22ea4ce46b8a2c491882d38a9948455117b545da3880eea7#npm:15.6.1"],\
["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.6"],\
["typescript", "patch:typescript@npm%3A5.8.3#optional!builtin<compat/typescript>::version=5.8.3&hash=d69c25"],\
["vercel", "npm:39.4.2"]\
......@@ -4922,6 +4937,15 @@ 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"\
}]\
]],\
["source-map-js", [\
["npm:1.2.1", {\
"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 =
"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/",\
......
......@@ -3,6 +3,7 @@ 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';
// Lazy-load heavy tabs if needed (Network uses d3)
const NetworkTab = dynamic(() => import('@/components/dunbar/NetworkTab'), { ssr: false });
......@@ -13,12 +14,14 @@ const EventsTab = dynamic(() => import('@/components/dunbar/EventsTab'), { ssr:
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, setTab }) {
const items = [
{ id: 'friends', label: 'Friends' },
{ id: 'search', label: 'Search' },
{ id: 'events', label: 'Events' },
{ id: 'orbits', label: 'Orbits' },
{ id: 'network', label: 'Network' },
......@@ -48,10 +51,12 @@ export default function DunbarApp() {
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');
if (stored === '1') {
const isDev = process.env.NODE_ENV === 'development' || window.location.hostname === 'localhost';
if (stored === '1' || isDev) {
setAuthed(true);
return;
}
......@@ -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
const openFriendDetail = (friendId) => {
actions.selectFriend(friendId);
......@@ -138,6 +152,7 @@ export default function DunbarApp() {
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>
......@@ -167,12 +182,22 @@ export default function DunbarApp() {
onToggleRel={(a, b) => actions.toggleRelationship(a, b)}
onAddEvent={(payload) => actions.addEvent(payload)}
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}
/>
)}
{tab === 'events' && (
<EventsTab
friends={friends}
......@@ -198,7 +223,7 @@ export default function DunbarApp() {
)}
{tab === 'stats' && (
<StatsTab stats={derived.stats} />
<StatsTab stats={derived.stats} anniversaries={derived.anniversaries} />
)}
</div>
);
......
......@@ -8,6 +8,7 @@ import {
groupEventsByDay,
isoDate,
} from '@/lib/dunbar';
import { extractTags } from '@/lib/dunbar';
export default function EventsTab({ friends, addEvent, eventIndex }) {
// Creation form state
......@@ -55,6 +56,30 @@ export default function EventsTab({ friends, addEvent, eventIndex }) {
// Timeline groups from merged 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 (
<div className={styles.twoCol} style={{ gap: 16 }}>
{/* New Event Builder */}
......@@ -70,6 +95,7 @@ export default function EventsTab({ friends, addEvent, eventIndex }) {
</div>
<div className={styles.row} style={{ marginBottom: 8, flexWrap: 'wrap' }}>
<input
lang="fr-FR"
type="date"
className={styles.input}
value={date}
......@@ -144,10 +170,23 @@ export default function EventsTab({ friends, addEvent, eventIndex }) {
return (
<div key={e.id + e.date} className={styles.timelineEvent}>
<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 ? (
<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 style={{ color: '#888', marginTop: 4, fontSize: 12 }}>
{isoDate(e.date)}
</div>
......
import { useEffect, useMemo, useRef, useState } from 'react';
import styles from '@/styles/dunbar.module.css';
import { isoDate, sortEventsDesc } from '@/lib/dunbar';
import { isoDate, sortEventsDesc, extractTags } from '@/lib/dunbar';
export default function FriendDetail({
......@@ -8,12 +8,148 @@ export default function FriendDetail({
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 [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);
......@@ -82,10 +218,49 @@ export default function FriendDetail({
return (
<div className={styles.card}>
<div className={styles.cardHeader}>
<span>{friend.name}</span>
<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}>
......@@ -115,6 +290,126 @@ export default function FriendDetail({
</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}>
......@@ -124,6 +419,7 @@ export default function FriendDetail({
{/* Add Event */}
<div className={styles.row} style={{ marginBottom: 8, flexWrap: 'wrap' }}>
<input
lang="fr-FR"
type="date"
className={styles.input}
value={date}
......@@ -159,10 +455,23 @@ export default function FriendDetail({
<div className={styles.timelineDate}>
{isoDate(e.date)}
</div>
<div style={{ whiteSpace: 'pre-wrap' }}>{e.notes}</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 && (
......
......@@ -62,7 +62,7 @@ export default function NetworkTab({ friends, toggleRel, openFriendDetail }) {
};
// Simulation setup/refresh
const [physicsOn, setPhysicsOn] = useState(true);
const physicsOn = true;
useEffect(() => {
let stopped = false;
let sim;
......@@ -82,11 +82,7 @@ export default function NetworkTab({ friends, toggleRel, openFriendDetail }) {
.force('center', d3force.forceCenter(0, 0))
.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', () => {
if (stopped) return;
......@@ -110,7 +106,7 @@ export default function NetworkTab({ friends, toggleRel, openFriendDetail }) {
simRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nodes, links, physicsOn]); // rebuild when graph or physics toggles
}, [nodes, links]); // rebuild when graph updates
// Drawing
const requestDraw = () => {
......@@ -180,11 +176,13 @@ export default function NetworkTab({ friends, toggleRel, openFriendDetail }) {
ctx.fill();
}
// Labels (keep readable irrespective of zoom)
ctx.scale(1 / transform.k, 1 / transform.k); // neutralize zoom for label size
// Labels: world coords, offset by node radius; keep pixel size constant with inverse scaling
for (const n of nodes) {
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();
......@@ -237,7 +235,7 @@ export default function NetworkTab({ friends, toggleRel, openFriendDetail }) {
const s = getNodeState(id);
stateRef.current.dragOffset = { x: s.x - x, y: s.y - y };
// Nudge simulation
if (simRef.current && physicsOn) {
if (simRef.current) {
simRef.current.alphaTarget(0.1).restart();
}
}
......@@ -319,21 +317,33 @@ export default function NetworkTab({ friends, toggleRel, openFriendDetail }) {
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) => {
e.preventDefault();
const rect = canvasRef.current.getBoundingClientRect();
const sx = e.clientX - rect.left;
const sy = e.clientY - rect.top;
const factor = Math.exp(-e.deltaY * 0.0015);
const newK = clamp(transform.k * factor, 0.2, 4);
// 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 });
zoomAt(factor, sx, sy);
};
// Overlay draw (draft link)
......@@ -368,18 +378,29 @@ export default function NetworkTab({ friends, toggleRel, openFriendDetail }) {
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 (
<div>
<div className={styles.graphToolbar}>
<button
className={styles.btnSecondary}
onClick={() => setPhysicsOn(v => !v)}
title="Toggle physics simulation"
>
Physics: {physicsOn ? 'On' : 'Off'}
</button>
<button
className={styles.btnSecondary}
onClick={() => setEditMode(v => !v)}
title="Toggle edit mode to create/remove connections"
>
......@@ -393,6 +414,11 @@ export default function NetworkTab({ friends, toggleRel, openFriendDetail }) {
<div
ref={containerRef}
className={styles.canvasWrap}
tabIndex={0}
role="application"
aria-label="Network graph"
onKeyDown={onKeyDown}
style={{ outline: 'none' }}
>
<canvas
ref={canvasRef}
......@@ -403,6 +429,17 @@ export default function NetworkTab({ friends, toggleRel, openFriendDetail }) {
onMouseUp={onMouseUp}
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>
);
......
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 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;
const items = [
{ label: 'Connections', value: stats.connections },
......@@ -9,6 +10,17 @@ export default function StatsTab({ stats }) {
{ label: 'Total Events', value: stats.totalEvents },
{ 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 (
<div className={styles.card}>
<div className={styles.cardHeader}>Statistics</div>
......@@ -20,6 +32,44 @@ export default function StatsTab({ stats }) {
</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>
);
}
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';
......@@ -55,6 +56,22 @@ function reducer(state, action) {
const newFriend = {
id: uuid(),
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(),
events: [],
lastInteraction: null,
......@@ -88,6 +105,19 @@ function reducer(state, action) {
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 };
......@@ -113,8 +143,9 @@ function reducer(state, action) {
}
case 'ADD_EVENT': {
const { date, notes, participants = [], location } = action.payload || {};
const dateISO = date ? new Date(date).toISOString() : null;
if (!dateISO || !notes || !participants.length) return state;
// 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 || !notes || !participants.length) return state;
const eventId = uuid();
// Create one logical event id applied to each participant for deduplication across views
......@@ -122,7 +153,7 @@ function reducer(state, action) {
if (participants.includes(f.id)) {
const ev = {
id: eventId,
date: dateISO,
date: dateStr,
notes: String(notes),
location: location ? String(location) : undefined,
participants: [...participants],
......@@ -135,6 +166,12 @@ function reducer(state, action) {
});
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 };
}
......@@ -200,6 +237,14 @@ export function useDunbarStore() {
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);
......@@ -267,24 +312,36 @@ export function useDunbarStore() {
};
}, [state.friends]);
// Orbits (last 90 days)
// 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 counts = state.friends.map((f) => {
const c = (f.events || []).reduce((acc, e) => {
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();
return acc + (now - t <= ninety ? 1 : 0);
}, 0);
return { id: f.id, name: f.name, count90: c };
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 it of counts) {
if (it.count90 >= 5) inner.push(it.id);
else if (it.count90 >= 2) middle.push(it.id);
else outer.push(it.id);
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]);
......@@ -307,6 +364,15 @@ export function useDunbarStore() {
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,
......@@ -315,6 +381,9 @@ export function useDunbarStore() {
addFriend,
removeFriend,
renameFriend,
setBirthday,
setFriendNotes,
updateFriend,
selectFriend,
toggleRelationship,
addEvent,
......@@ -330,6 +399,7 @@ export function useDunbarStore() {
stats,
orbitBuckets,
eventIndex,
anniversaries,
},
};
}
.container {
max-width: 42rem;
max-width: 113rem;
padding: 0 1rem;
margin: 3rem auto 6rem;
}
......
// 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,
};
}
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);
}
......@@ -62,6 +62,17 @@ export function firstWords(text, n = 3) {
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);
}
// Quick-date helpers (ISO YYYY-MM-DD) — Paris local calendar
export function todayISO() {
return isoDate(new Date(), TIMEZONE);
......@@ -195,6 +206,35 @@ 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: 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: 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,
......@@ -220,6 +260,35 @@ export function normalizeImportedPayload(payload) {
const friends = friendsRaw.map(f => ({
id: f.id,
name: f.name || '',
birthday: f.birthday || null,
notes: f.notes || '',
// rich profile (defaults)
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: 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,
......@@ -235,3 +304,197 @@ export function normalizeImportedPayload(payload) {
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;
}
......@@ -25,6 +25,7 @@
"gray-matter": "^4.0.3",
"hydra-synth": "^1.3.29",
"marked": "^15.0.11",
"minisearch": "^7.1.2",
"next": "^15.3.0",
"prismjs": "^1.30.0",
"react": "^18.2.0",
......@@ -37,6 +38,8 @@
"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"
},
"devDependencies": {
......
......@@ -2,7 +2,7 @@
.container {
padding: 16px;
max-width: 1200px;
max-width: 1400px;
margin: 0 auto;
}
......@@ -141,7 +141,7 @@
.twoCol {
display: grid;
grid-template-columns: 1fr 1.4fr;
grid-template-columns: 1fr 1.8fr;
gap: 16px;
}
......@@ -233,6 +233,24 @@
border-radius: 8px;
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 {
display: inline-block;
......@@ -335,6 +353,7 @@
border-radius: 10px;
overflow: hidden;
background: #fff;
position: relative;
}
.chevron {
......@@ -352,3 +371,37 @@
gap: 10px;
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:
languageName: node
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":
version: 1.3.3
resolution: "minizlib@npm:1.3.3"
......@@ -3511,6 +3518,7 @@ __metadata:
gray-matter: "npm:^4.0.3"
hydra-synth: "npm:^1.3.29"
marked: "npm:^15.0.11"
minisearch: "npm:^7.1.2"
next: "npm:^15.3.0"
prismjs: "npm:^1.30.0"
react: "npm:^18.2.0"
......@@ -3523,6 +3531,8 @@ __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"
typescript: "npm:^5.3.3"
vercel: "npm:^39"
......@@ -4175,6 +4185,13 @@ __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
"source-map-js@npm:^1.0.2":
version: 1.2.1
resolution: "source-map-js@npm:1.2.1"
......@@ -4217,6 +4234,13 @@ __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"
......
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