Commit fbdcaf0f by PLN (Algolia)

Dunbar: v0.1

parent 40fb459c
...@@ -31,3 +31,4 @@ yarn-error.log* ...@@ -31,3 +31,4 @@ yarn-error.log*
# LLM exchanges # LLM exchanges
code2prompt.json code2prompt.json
.vercel
...@@ -4,6 +4,8 @@ import { useDunbarStore } from '@/components/dunbar/useDunbarStore'; ...@@ -4,6 +4,8 @@ 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'; import { generateDemoPayload } from '@/lib/dunbar-demo';
import { useRouter } from 'next/router';
import { friendSlug, eventSlug } from '@/lib/dunbar';
// 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 });
...@@ -43,6 +45,7 @@ function Tabs({ tab, setTab }) { ...@@ -43,6 +45,7 @@ function Tabs({ tab, setTab }) {
} }
export default function DunbarApp() { export default function DunbarApp() {
const router = useRouter();
const { state, friends, selectedFriendId, actions, derived } = useDunbarStore(); const { state, friends, selectedFriendId, actions, derived } = useDunbarStore();
const [tab, setTab] = useState('friends'); const [tab, setTab] = useState('friends');
const [authed, setAuthed] = useState(false); const [authed, setAuthed] = useState(false);
...@@ -122,10 +125,62 @@ export default function DunbarApp() { ...@@ -122,10 +125,62 @@ export default function DunbarApp() {
const openFriendDetail = (friendId) => { const openFriendDetail = (friendId) => {
actions.selectFriend(friendId); actions.selectFriend(friendId);
setTab('friends'); setTab('friends');
const f = friends.find((x) => x.id === friendId);
if (f) {
router.push(`/dunbar/friend/${friendSlug(f)}`, undefined, { shallow: true });
}
};
const openEventDetail = (evLike) => {
// evLike may be from merged index or minimal shape
const id = evLike?.id;
const e = (derived.eventIndex || []).find((x) => x.id === id) || evLike;
if (!e) return;
setTab('events');
actions.selectEvent(e.id);
router.push(`/dunbar/event/${eventSlug(e)}`, undefined, { shallow: true });
}; };
if (!authed) { if (!authed) {
return ( // Deep-link handling: friend/event/search routes hydrate initial tab/selection
useEffect(() => {
if (!router || !router.asPath) return;
const as = router.asPath || '';
// friend route
const friendMatch = as.match(/\/dunbar\/friend\/([^/?#]+)/);
if (friendMatch) {
const slug = friendMatch[1];
// suffix-based lookup (last 6 chars of id)
const suff = slug.split('-').pop();
const f = friends.find((x) => String(x.id).endsWith(suff)) ||
friends.find((x) => friendSlug(x) === slug);
if (f) {
actions.selectFriend(f.id);
setTab('friends');
}
return;
}
// event route
const eventMatch = as.match(/\/dunbar\/event\/([^/?#]+)/);
if (eventMatch) {
const slug = eventMatch[1];
const suff = slug.split('-').pop();
const e = (derived.eventIndex || []).find((x) => String(x.id).endsWith(suff));
if (e) {
setTab('events');
actions.selectEvent(e.id);
}
return;
}
// search route
const searchMatch = as.match(/\/dunbar\/search/);
if (searchMatch) {
setTab('search');
return;
}
}, [router?.asPath, friends, derived.eventIndex, actions]);
return (
<div className={styles.lockWrap}> <div className={styles.lockWrap}>
<h2 className={styles.title}>Dunbar</h2> <h2 className={styles.title}>Dunbar</h2>
<p>Privacy-first relationship navigator. Local-only storage.</p> <p>Privacy-first relationship navigator. Local-only storage.</p>
...@@ -181,6 +236,7 @@ export default function DunbarApp() { ...@@ -181,6 +236,7 @@ export default function DunbarApp() {
friends={friends} friends={friends}
onToggleRel={(a, b) => actions.toggleRelationship(a, b)} onToggleRel={(a, b) => actions.toggleRelationship(a, b)}
onAddEvent={(payload) => actions.addEvent(payload)} onAddEvent={(payload) => actions.addEvent(payload)}
onUpdateEvent={(id, patch) => actions.updateEvent(id, patch)}
onRename={(id, name) => actions.renameFriend(id, name)} onRename={(id, name) => actions.renameFriend(id, name)}
onSetBirthday={(id, ymd) => actions.setBirthday(id, ymd)} onSetBirthday={(id, ymd) => actions.setBirthday(id, ymd)}
onSetNotes={(id, notes) => actions.setFriendNotes(id, notes)} onSetNotes={(id, notes) => actions.setFriendNotes(id, notes)}
...@@ -195,6 +251,7 @@ export default function DunbarApp() { ...@@ -195,6 +251,7 @@ export default function DunbarApp() {
<SearchTab <SearchTab
friends={friends} friends={friends}
openFriend={openFriendDetail} openFriend={openFriendDetail}
openEvent={openEventDetail}
/> />
)} )}
...@@ -202,7 +259,10 @@ export default function DunbarApp() { ...@@ -202,7 +259,10 @@ export default function DunbarApp() {
<EventsTab <EventsTab
friends={friends} friends={friends}
addEvent={(payload) => actions.addEvent(payload)} addEvent={(payload) => actions.addEvent(payload)}
updateEvent={(id, patch) => actions.updateEvent(id, patch)}
selectedEventId={state.selectedEventId}
eventIndex={derived.eventIndex} eventIndex={derived.eventIndex}
openEvent={openEventDetail}
/> />
)} )}
...@@ -223,7 +283,7 @@ export default function DunbarApp() { ...@@ -223,7 +283,7 @@ export default function DunbarApp() {
)} )}
{tab === 'stats' && ( {tab === 'stats' && (
<StatsTab stats={derived.stats} anniversaries={derived.anniversaries} /> <StatsTab stats={derived.stats} anniversaries={derived.anniversaries} openFriend={openFriendDetail} />
)} )}
</div> </div>
); );
......
...@@ -10,11 +10,12 @@ import { ...@@ -10,11 +10,12 @@ import {
} from '@/lib/dunbar'; } from '@/lib/dunbar';
import { extractTags } from '@/lib/dunbar'; import { extractTags } from '@/lib/dunbar';
export default function EventsTab({ friends, addEvent, eventIndex }) { export default function EventsTab({ friends, addEvent, updateEvent, selectedEventId, eventIndex, openEvent }) {
// Creation form state // Creation form state
const [date, setDate] = useState(todayISO()); const [date, setDate] = useState(todayISO());
const [notes, setNotes] = useState(''); const [notes, setNotes] = useState('');
const [location, setLocation] = useState(''); const [location, setLocation] = useState('');
const [title, setTitle] = useState('');
const [filter, setFilter] = useState(''); const [filter, setFilter] = useState('');
const [selected, setSelected] = useState(() => new Set()); const [selected, setSelected] = useState(() => new Set());
...@@ -38,13 +39,14 @@ export default function EventsTab({ friends, addEvent, eventIndex }) { ...@@ -38,13 +39,14 @@ export default function EventsTab({ friends, addEvent, eventIndex }) {
}; };
const selectedCount = selected.size; const selectedCount = selected.size;
const canCreate = selectedCount > 0 && notes.trim().length > 0; const canCreate = selectedCount > 0 && notes.trim().length > 0 && title.trim().length > 0;
const createEvent = () => { const createEvent = () => {
if (!canCreate) return; if (!canCreate) return;
const dateISO = new Date(date).toISOString(); const dateISO = new Date(date).toISOString();
addEvent({ addEvent({
date: dateISO, date: dateISO,
title: title.trim(),
notes: notes.trim(), notes: notes.trim(),
location: location.trim() || undefined, location: location.trim() || undefined,
participants: Array.from(selected), participants: Array.from(selected),
...@@ -56,6 +58,56 @@ export default function EventsTab({ friends, addEvent, eventIndex }) { ...@@ -56,6 +58,56 @@ 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]);
// Selected event editor state
const selectedEvent = useMemo(
() => (selectedEventId ? (eventIndex || []).find((e) => e.id === selectedEventId) : null),
[eventIndex, selectedEventId]
);
const [edit, setEdit] = useState(() => ({
id: null,
date: todayISO(),
title: '',
notes: '',
location: '',
participants: new Set(),
}));
// Hydrate editor when selected changes
useMemo(() => {
if (!selectedEvent) return edit;
const e = selectedEvent;
setEdit({
id: e.id,
date: e.date,
title: e.title || '',
notes: e.notes || '',
location: e.location || '',
participants: new Set(e.participants || []),
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedEventId]);
const toggleEditParticipant = (id) => {
setEdit((prev) => {
const p = new Set(prev.participants);
if (p.has(id)) p.delete(id);
else p.add(id);
return { ...prev, participants: p };
});
};
const canSaveEdit = !!edit.id && String(edit.title || '').trim() && String(edit.notes || '').trim();
const saveEdit = () => {
if (!canSaveEdit) return;
updateEvent?.(edit.id, {
date: edit.date,
title: edit.title.trim(),
notes: edit.notes.trim(),
location: edit.location.trim() || undefined,
participants: Array.from(edit.participants),
});
};
// Render notes with inline #tags highlighted // Render notes with inline #tags highlighted
const renderNotesWithTags = (text = '') => { const renderNotesWithTags = (text = '') => {
const re = /(#([\p{L}\p{N}_-]+))/gu; const re = /(#([\p{L}\p{N}_-]+))/gu;
...@@ -110,6 +162,15 @@ export default function EventsTab({ friends, addEvent, eventIndex }) { ...@@ -110,6 +162,15 @@ export default function EventsTab({ friends, addEvent, eventIndex }) {
/> />
</div> </div>
<div className={styles.row} style={{ marginBottom: 8 }}> <div className={styles.row} style={{ marginBottom: 8 }}>
<input
className={styles.input}
placeholder="Title (required)"
value={title}
onChange={(e) => setTitle(e.target.value)}
style={{ width: '100%' }}
/>
</div>
<div className={styles.row} style={{ marginBottom: 8 }}>
<textarea <textarea
className={styles.textarea} className={styles.textarea}
placeholder="Notes (required)" placeholder="Notes (required)"
...@@ -156,9 +217,83 @@ export default function EventsTab({ friends, addEvent, eventIndex }) { ...@@ -156,9 +217,83 @@ export default function EventsTab({ friends, addEvent, eventIndex }) {
</div> </div>
</div> </div>
{/* Timeline */} {/* Timeline + Editor */}
<div className={styles.card}> <div className={styles.card}>
<div className={styles.cardHeader}>Timeline</div> <div className={styles.cardHeader}>Timeline</div>
{/* Event Editor */}
{selectedEvent ? (
<div className={styles.card} style={{ marginBottom: 12 }}>
<div className={styles.cardHeader}><span>Edit Event</span></div>
<div className={styles.row} style={{ marginBottom: 8, flexWrap: 'wrap' }}>
<input
lang="fr-FR"
type="date"
className={styles.input}
value={edit.date}
onChange={(e) => setEdit({ ...edit, date: e.target.value })}
/>
<input
className={styles.input}
placeholder="Location (optional)"
value={edit.location}
onChange={(e) => setEdit({ ...edit, location: e.target.value })}
style={{ minWidth: 160 }}
/>
</div>
<div className={styles.row} style={{ marginBottom: 8 }}>
<input
className={styles.input}
placeholder="Title (required)"
value={edit.title}
onChange={(e) => setEdit({ ...edit, title: e.target.value })}
style={{ width: '100%' }}
/>
</div>
<div className={styles.row} style={{ marginBottom: 8 }}>
<textarea
className={styles.textarea}
placeholder="Notes (required)"
value={edit.notes}
onChange={(e) => setEdit({ ...edit, notes: e.target.value })}
style={{ width: '100%' }}
/>
</div>
<div className={styles.card} style={{ marginTop: 8 }}>
<div className={styles.cardHeader}>
<span>Participants ({edit.participants.size})</span>
<input
className={styles.input}
placeholder="Filter…"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</div>
<div className={styles.scroll}>
{filteredFriends.map((f) => {
const checked = edit.participants.has(f.id);
return (
<label key={f.id} className={styles.switchRow} style={{ cursor: 'pointer' }}>
<input
type="checkbox"
checked={checked}
onChange={() => toggleEditParticipant(f.id)}
/>
<span style={{ fontWeight: 600, marginLeft: 8 }}>{f.name}</span>
</label>
);
})}
</div>
</div>
<div className={styles.row} style={{ marginTop: 12 }}>
<button className={styles.btn} onClick={saveEdit} disabled={!canSaveEdit}>
Save Changes
</button>
</div>
</div>
) : null}
<div className={styles.timeline}> <div className={styles.timeline}>
{groups.map((g) => ( {groups.map((g) => (
<div key={g.dateKey} className={styles.timelineGroup}> <div key={g.dateKey} className={styles.timelineGroup}>
...@@ -169,7 +304,14 @@ export default function EventsTab({ friends, addEvent, eventIndex }) { ...@@ -169,7 +304,14 @@ export default function EventsTab({ friends, addEvent, eventIndex }) {
.filter(Boolean); .filter(Boolean);
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
style={{ fontWeight: 700, cursor: 'pointer' }}
onClick={() => openEvent?.(e)}
title="Open event details"
>
{e.title || '(untitled)'}
</div>
<div className={styles.itemMeta}>{names.join(', ') || 'Unknown'}</div>
<div style={{ whiteSpace: 'pre-wrap' }}> <div style={{ whiteSpace: 'pre-wrap' }}>
{renderNotesWithTags(e.notes || '')} {renderNotesWithTags(e.notes || '')}
</div> </div>
......
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import styles from '@/styles/dunbar.module.css'; import styles from '@/styles/dunbar.module.css';
import { isoDate, sortEventsDesc, extractTags } from '@/lib/dunbar'; import { isoDate, sortEventsDesc, extractTags, eventSlug } from '@/lib/dunbar';
export default function FriendDetail({ export default function FriendDetail({
...@@ -16,6 +16,7 @@ export default function FriendDetail({ ...@@ -16,6 +16,7 @@ export default function FriendDetail({
const [date, setDate] = useState(() => new Date().toISOString().slice(0, 10)); const [date, setDate] = useState(() => new Date().toISOString().slice(0, 10));
const [notes, setNotes] = useState(''); const [notes, setNotes] = useState('');
const [location, setLocation] = useState(''); const [location, setLocation] = useState('');
const [title, setTitle] = useState('');
const [friendNotes, setFriendNotes] = useState(''); const [friendNotes, setFriendNotes] = useState('');
const [birthday, setBirthday] = useState(''); const [birthday, setBirthday] = useState('');
const relScrollRef = useRef(null); const relScrollRef = useRef(null);
...@@ -192,15 +193,18 @@ export default function FriendDetail({ ...@@ -192,15 +193,18 @@ export default function FriendDetail({
if (!friend) return; if (!friend) return;
const dateISO = new Date(date).toISOString(); const dateISO = new Date(date).toISOString();
const n = notes.trim(); const n = notes.trim();
if (!dateISO || !n) return; const t = title.trim();
if (!dateISO || !t || !n) return;
onAddEvent?.({ onAddEvent?.({
date: dateISO, date: dateISO,
title: t,
notes: n, notes: n,
location: location.trim() || undefined, location: location.trim() || undefined,
participants: [friend.id], participants: [friend.id],
}); });
// reset notes only; keep date for faster entry // reset notes/title only; keep date for faster entry
setNotes(''); setNotes('');
setTitle('');
}; };
if (!friend) { if (!friend) {
...@@ -434,6 +438,15 @@ export default function FriendDetail({ ...@@ -434,6 +438,15 @@ export default function FriendDetail({
/> />
</div> </div>
<div className={styles.row} style={{ marginBottom: 8 }}> <div className={styles.row} style={{ marginBottom: 8 }}>
<input
className={styles.input}
placeholder="Title (required)"
value={title}
onChange={(e) => setTitle(e.target.value)}
style={{ width: '100%' }}
/>
</div>
<div className={styles.row} style={{ marginBottom: 8 }}>
<textarea <textarea
className={styles.textarea} className={styles.textarea}
placeholder="Notes (required)" placeholder="Notes (required)"
...@@ -443,7 +456,7 @@ export default function FriendDetail({ ...@@ -443,7 +456,7 @@ export default function FriendDetail({
/> />
</div> </div>
<div className={styles.row} style={{ marginBottom: 12 }}> <div className={styles.row} style={{ marginBottom: 12 }}>
<button className={styles.btn} onClick={submitEvent} disabled={!notes.trim()}> <button className={styles.btn} onClick={submitEvent} disabled={!title.trim() || !notes.trim()}>
Add Event Add Event
</button> </button>
</div> </div>
...@@ -455,6 +468,13 @@ export default function FriendDetail({ ...@@ -455,6 +468,13 @@ export default function FriendDetail({
<div className={styles.timelineDate}> <div className={styles.timelineDate}>
{isoDate(e.date)} {isoDate(e.date)}
</div> </div>
<div
style={{ fontWeight: 700, cursor: 'pointer' }}
onClick={() => (window.location.href = `/dunbar/event/${eventSlug(e)}`)}
title="Open event details"
>
{e.title || '(untitled)'}
</div>
<div style={{ whiteSpace: 'pre-wrap' }}> <div style={{ whiteSpace: 'pre-wrap' }}>
{renderNotesWithTags(e.notes || '')} {renderNotesWithTags(e.notes || '')}
</div> </div>
......
...@@ -8,7 +8,7 @@ import { ...@@ -8,7 +8,7 @@ import {
suggestPersons, suggestPersons,
} from '@/lib/dunbar-search'; } from '@/lib/dunbar-search';
export default function SearchTab({ friends, openFriend }) { export default function SearchTab({ friends, openFriend, openEvent }) {
const [q, setQ] = useState(''); const [q, setQ] = useState('');
const [includeTags, setIncludeTags] = useState(new Set()); const [includeTags, setIncludeTags] = useState(new Set());
const [excludeTags, setExcludeTags] = useState(new Set()); const [excludeTags, setExcludeTags] = useState(new Set());
...@@ -183,6 +183,13 @@ export default function SearchTab({ friends, openFriend }) { ...@@ -183,6 +183,13 @@ export default function SearchTab({ friends, openFriend }) {
{(results.events || []).map((e) => ( {(results.events || []).map((e) => (
<div key={e.id} className={styles.timelineEvent}> <div key={e.id} className={styles.timelineEvent}>
<div className={styles.timelineDate}>{e.date}</div> <div className={styles.timelineDate}>{e.date}</div>
<div
style={{ fontWeight: 700, cursor: 'pointer' }}
onClick={() => openEvent?.(e)}
title="Ouvrir l’événement"
>
{e.title || '(untitled)'}
</div>
<div style={{ whiteSpace: 'pre-wrap' }}> <div style={{ whiteSpace: 'pre-wrap' }}>
{renderNotesWithTags(e.notes || '')} {renderNotesWithTags(e.notes || '')}
</div> </div>
......
...@@ -2,7 +2,7 @@ import React from 'react'; ...@@ -2,7 +2,7 @@ import React from 'react';
import styles from '@/styles/dunbar.module.css'; import styles from '@/styles/dunbar.module.css';
import { isoDate } from '@/lib/dunbar'; import { isoDate } from '@/lib/dunbar';
export default function StatsTab({ stats, anniversaries = [] }) { export default function StatsTab({ stats, anniversaries = [], openFriend }) {
if (!stats) return null; if (!stats) return null;
const items = [ const items = [
{ label: 'Connections', value: stats.connections }, { label: 'Connections', value: stats.connections },
...@@ -44,7 +44,13 @@ export default function StatsTab({ stats, anniversaries = [] }) { ...@@ -44,7 +44,13 @@ export default function StatsTab({ stats, anniversaries = [] }) {
<div className={styles.timelineDate}>{day}</div> <div className={styles.timelineDate}>{day}</div>
{items.map((it, idx) => ( {items.map((it, idx) => (
<div key={day + '-' + idx} className={styles.timelineEvent}> <div key={day + '-' + idx} className={styles.timelineEvent}>
<div style={{ fontWeight: 700 }}>{it.friendName}</div> <div
style={{ fontWeight: 700, cursor: 'pointer' }}
title="Ouvrir la fiche ami·e"
onClick={() => openFriend?.(it.friendId)}
>
{it.friendName}
</div>
<div style={{ color: '#555' }}>{it.label}</div> <div style={{ color: '#555' }}>{it.label}</div>
{/* Anchor event preview if provided */} {/* Anchor event preview if provided */}
{it.anchorTitle || (it.anchorTags && it.anchorTags.length > 0) ? ( {it.anchorTitle || (it.anchorTags && it.anchorTags.length > 0) ? (
......
...@@ -43,6 +43,7 @@ function computeLastInteraction(friend) { ...@@ -43,6 +43,7 @@ function computeLastInteraction(friend) {
const initialState = { const initialState = {
friends: [], friends: [],
selectedFriendId: null, selectedFriendId: null,
selectedEventId: null,
}; };
function reducer(state, action) { function reducer(state, action) {
...@@ -59,16 +60,17 @@ function reducer(state, action) { ...@@ -59,16 +60,17 @@ function reducer(state, action) {
birthday: null, birthday: null,
notes: '', notes: '',
// rich profile fields // rich profile fields
likes: '', likes: [],
dislikes: '', dislikes: [],
foodLikes: '', foodLikes: '',
foodDislikes: '', foodDislikes: '',
wifiPassword: '', wifiPassword: '',
carModel: '', carModel: '',
workplace: '', workplace: '',
schedule: '', schedule: '',
futureIdeas: '', futureIdeas: [],
quotes: '', quotes: [],
projects: [],
importantDates: [], // [{ date: 'YYYY-MM-DD', label: string }] importantDates: [], // [{ date: 'YYYY-MM-DD', label: string }]
gifts: [], // [{ date, occasion, description, image }] gifts: [], // [{ date, occasion, description, image }]
postcards: [], // [{ date, location, description, image }] postcards: [], // [{ date, location, description, image }]
...@@ -122,6 +124,10 @@ function reducer(state, action) { ...@@ -122,6 +124,10 @@ function reducer(state, action) {
const id = action.payload?.id ?? null; const id = action.payload?.id ?? null;
return { ...state, selectedFriendId: id }; return { ...state, selectedFriendId: id };
} }
case 'SELECT_EVENT': {
const id = action.payload?.id ?? null;
return { ...state, selectedEventId: id };
}
case 'TOGGLE_REL': { case 'TOGGLE_REL': {
const a = action.payload?.aId; const a = action.payload?.aId;
const b = action.payload?.bId; const b = action.payload?.bId;
...@@ -142,10 +148,10 @@ function reducer(state, action) { ...@@ -142,10 +148,10 @@ function reducer(state, action) {
return { ...state, friends }; return { ...state, friends };
} }
case 'ADD_EVENT': { case 'ADD_EVENT': {
const { date, notes, participants = [], location } = action.payload || {}; const { date, title, notes, participants = [], location } = action.payload || {};
// Normalize to YYYY-MM-DD (Paris local semantics handled at render/grouping time) // Normalize to YYYY-MM-DD (Paris local semantics handled at render/grouping time)
const dateStr = typeof date === 'string' ? date.slice(0, 10) : isoDate(date); const dateStr = typeof date === 'string' ? date.slice(0, 10) : isoDate(date);
if (!dateStr || !notes || !participants.length) return state; if (!dateStr || !String(title || '').trim() || !String(notes || '').trim() || !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
...@@ -154,6 +160,7 @@ function reducer(state, action) { ...@@ -154,6 +160,7 @@ function reducer(state, action) {
const ev = { const ev = {
id: eventId, id: eventId,
date: dateStr, date: dateStr,
title: String(title),
notes: String(notes), notes: String(notes),
location: location ? String(location) : undefined, location: location ? String(location) : undefined,
participants: [...participants], participants: [...participants],
...@@ -164,6 +171,65 @@ function reducer(state, action) { ...@@ -164,6 +171,65 @@ function reducer(state, action) {
} }
return f; return f;
}); });
return { ...state, friends, selectedEventId: eventId };
}
case 'UPDATE_EVENT': {
const { id, patch } = action.payload || {};
if (!id || !patch) return state;
// Find a canonical copy of the event to merge with
let canonical = null;
for (const f of state.friends) {
const found = (f.events || []).find((e) => e.id === id);
if (found) {
canonical = found;
break;
}
}
if (!canonical) return state;
const nextParticipants = Array.isArray(patch.participants)
? [...patch.participants]
: [...(canonical.participants || [])];
// Normalized updated event object
const updated = {
...canonical,
...patch,
participants: nextParticipants,
};
const participantSet = new Set(nextParticipants);
const friends = state.friends.map((f) => {
const hasBefore = (f.events || []).some((e) => e.id === id);
const shouldHave = participantSet.has(f.id);
// Remove if no longer participant
if (hasBefore && !shouldHave) {
const events = (f.events || []).filter((e) => e.id !== id);
const lastInteraction = computeLastInteraction({ ...f, events });
return { ...f, events, lastInteraction };
}
// Add if newly participant
if (!hasBefore && shouldHave) {
const events = Array.isArray(f.events) ? [...f.events, updated] : [updated];
const lastInteraction = computeLastInteraction({ ...f, events });
return { ...f, events, lastInteraction };
}
// Update if present and still participant
if (hasBefore && shouldHave) {
const events = (f.events || []).map((e) => (e.id === id ? updated : e));
const lastInteraction = computeLastInteraction({ ...f, events });
return { ...f, events, lastInteraction };
}
// Neither before nor after → unchanged
return f;
});
return { ...state, friends }; return { ...state, friends };
} }
case 'UPDATE_FRIEND': { case 'UPDATE_FRIEND': {
...@@ -224,6 +290,8 @@ export function useDunbarStore() { ...@@ -224,6 +290,8 @@ export function useDunbarStore() {
const selectFriend = useCallback((id) => dispatch({ type: 'SELECT_FRIEND', payload: { id } }), []); const selectFriend = useCallback((id) => dispatch({ type: 'SELECT_FRIEND', payload: { id } }), []);
const toggleRelationship = useCallback((aId, bId) => dispatch({ type: 'TOGGLE_REL', payload: { aId, bId } }), []); const toggleRelationship = useCallback((aId, bId) => dispatch({ type: 'TOGGLE_REL', payload: { aId, bId } }), []);
const addEvent = useCallback((payload) => dispatch({ type: 'ADD_EVENT', payload }), []); const addEvent = useCallback((payload) => dispatch({ type: 'ADD_EVENT', payload }), []);
const selectEvent = useCallback((id) => dispatch({ type: 'SELECT_EVENT', payload: { id } }), []);
const updateEvent = useCallback((id, patch) => dispatch({ type: 'UPDATE_EVENT', payload: { id, patch } }), []);
const resetData = useCallback(() => { const resetData = useCallback(() => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const ok = window.confirm('This will clear all Dunbar data. Continue?'); const ok = window.confirm('This will clear all Dunbar data. Continue?');
...@@ -377,6 +445,7 @@ export function useDunbarStore() { ...@@ -377,6 +445,7 @@ export function useDunbarStore() {
state, state,
friends: state.friends, friends: state.friends,
selectedFriendId: state.selectedFriendId, selectedFriendId: state.selectedFriendId,
selectedEventId: state.selectedEventId,
actions: { actions: {
addFriend, addFriend,
removeFriend, removeFriend,
...@@ -387,6 +456,8 @@ export function useDunbarStore() { ...@@ -387,6 +456,8 @@ export function useDunbarStore() {
selectFriend, selectFriend,
toggleRelationship, toggleRelationship,
addEvent, addEvent,
selectEvent,
updateEvent,
resetData, resetData,
loadFromPayload, loadFromPayload,
}, },
......
...@@ -12,6 +12,18 @@ export const twitterHandle = "@PaulLouisNech"; ...@@ -12,6 +12,18 @@ export const twitterHandle = "@PaulLouisNech";
export const description = "PLN's Selected Works"; export const description = "PLN's Selected Works";
export default function Layout({ children, home }) { export default function Layout({ children, home }) {
// Simple feedback launcher: prompts for text then opens default mail client
const handleFeedbackMail = () => {
try {
const txt = typeof window !== 'undefined' ? window.prompt('Feedback for Dunbar (will open your email client):', '') : '';
const subject = 'Dunbar feedback';
const url = typeof window !== 'undefined' ? window.location.href : '';
const body = `${txt ? txt + '\\n\\n' : ''}From: ${url}`;
const mailto = `mailto:dunbar@nech.pl?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
if (typeof window !== 'undefined') window.location.href = mailto;
} catch {}
};
return ( return (
<div className={styles.container}> <div className={styles.container}>
<Head> <Head>
...@@ -86,6 +98,16 @@ export default function Layout({ children, home }) { ...@@ -86,6 +98,16 @@ export default function Layout({ children, home }) {
> >
</a> </a>
{' '}|{' '}
<button
type="button"
onClick={handleFeedbackMail}
className={utilStyles.backButton}
style={{ cursor: 'pointer', border: 'none', background: 'transparent', padding: 0 }}
title="Send feedback about Dunbar"
>
Feedback (dunbar@nech.pl)
</button>
</footer> </footer>
</div> </div>
); );
......
...@@ -67,24 +67,33 @@ export function buildSearchData(friends = []) { ...@@ -67,24 +67,33 @@ export function buildSearchData(friends = []) {
// Aggregate tags from events + friend notes + rich profile // Aggregate tags from events + friend notes + rich profile
const aggTags = new Set(); const aggTags = new Set();
const addTagsFrom = (txt) => { const addTagsFromAny = (val) => {
for (const t of extractTagsFromText(txt || '')) aggTags.add(t); if (Array.isArray(val)) {
for (const item of val) {
for (const t of extractTagsFromText(String(item || ''))) aggTags.add(t);
}
} else {
for (const t of extractTagsFromText(String(val || ''))) aggTags.add(t);
}
}; };
addTagsFrom(f.notes); addTagsFromAny(f.notes);
addTagsFrom(f.likes); addTagsFromAny(f.likes);
addTagsFrom(f.dislikes); addTagsFromAny(f.dislikes);
addTagsFrom(f.foodLikes); addTagsFromAny(f.foodLikes);
addTagsFrom(f.foodDislikes); addTagsFromAny(f.foodDislikes);
addTagsFrom(f.futureIdeas); addTagsFromAny(f.futureIdeas);
addTagsFrom(f.quotes); addTagsFromAny(f.quotes);
for (const ev of f.events || []) addTagsFrom(ev.notes); for (const ev of f.events || []) {
addTagsFromAny(ev.notes);
addTagsFromAny(ev.title);
}
// Compose an extended notes blob to improve recall // Compose an extended notes blob to improve recall
const profileBlob = [ const profileBlob = [
f.notes, f.notes,
f.likes, Array.isArray(f.likes) ? f.likes.join(', ') : f.likes,
f.dislikes, Array.isArray(f.dislikes) ? f.dislikes.join(', ') : f.dislikes,
f.foodLikes, f.foodLikes,
f.foodDislikes, f.foodDislikes,
f.futureIdeas, f.futureIdeas,
...@@ -94,7 +103,7 @@ export function buildSearchData(friends = []) { ...@@ -94,7 +103,7 @@ export function buildSearchData(friends = []) {
f.carModel, f.carModel,
] ]
.filter(Boolean) .filter(Boolean)
.join(' \n'); .join(' \\n');
const friendDoc = { const friendDoc = {
id: `friend:${f.id}`, id: `friend:${f.id}`,
...@@ -138,6 +147,7 @@ export function buildSearchData(friends = []) { ...@@ -138,6 +147,7 @@ export function buildSearchData(friends = []) {
id: `event:${e.id}`, id: `event:${e.id}`,
kind: 'event', kind: 'event',
refId: e.id, refId: e.id,
title: e.title || '',
notes: e.notes || '', notes: e.notes || '',
location: e.location || '', location: e.location || '',
tags, tags,
...@@ -188,9 +198,9 @@ export function buildSearchIndexes(friends = []) { ...@@ -188,9 +198,9 @@ export function buildSearchIndexes(friends = []) {
}); });
const eventIndex = makeMiniSearch( const eventIndex = makeMiniSearch(
eventDocs, eventDocs,
['notes', 'location', 'tags', 'participantNames'], ['title', 'notes', 'location', 'tags', 'participantNames'],
['id', 'kind', 'refId', 'tags', 'participantNames', 'date'], ['id', 'kind', 'refId', 'title', 'tags', 'participantNames', 'date'],
{ tags: 2, participantNames: 1.5 } { title: 3, tags: 2, participantNames: 1.5 }
); );
return { friendIndex, eventIndex, tagSet, personSet, friendDocs, eventDocs }; return { friendIndex, eventIndex, tagSet, personSet, friendDocs, eventDocs };
......
...@@ -73,6 +73,41 @@ export function extractTags(text = '') { ...@@ -73,6 +73,41 @@ export function extractTags(text = '') {
return Array.from(tags); return Array.from(tags);
} }
// Slugify helper for URLs
export function slugify(text = '') {
const s = String(text || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
return s || 'x';
}
// Event slug helper: stable and mostly human — title slug + short id suffix
export function eventSlug(evOrTitle, idMaybe) {
if (typeof evOrTitle === 'object' && evOrTitle) {
const title = evOrTitle.title || '';
const id = evOrTitle.id || '';
return `${slugify(title)}-${String(id).slice(-6)}`;
}
const title = String(evOrTitle || '');
const id = String(idMaybe || '');
return `${slugify(title)}-${id.slice(-6)}`;
}
// Friend slug helper: name slug + short id suffix
export function friendSlug(friendOrName, idMaybe) {
if (typeof friendOrName === 'object' && friendOrName) {
const name = friendOrName.name || '';
const id = friendOrName.id || '';
return `${slugify(name)}-${String(id).slice(-6)}`;
}
const name = String(friendOrName || '');
const id = String(idMaybe || '');
return `${slugify(name)}-${id.slice(-6)}`;
}
// Quick-date helpers (ISO YYYY-MM-DD) — Paris local calendar // Quick-date helpers (ISO YYYY-MM-DD) — Paris local calendar
export function todayISO() { export function todayISO() {
return isoDate(new Date(), TIMEZONE); return isoDate(new Date(), TIMEZONE);
...@@ -209,16 +244,17 @@ export function makeExportPayload(state) { ...@@ -209,16 +244,17 @@ export function makeExportPayload(state) {
birthday: f.birthday || null, birthday: f.birthday || null,
notes: f.notes || '', notes: f.notes || '',
// rich profile // rich profile
likes: f.likes || '', likes: Array.isArray(f.likes) ? f.likes : (f.likes ? String(f.likes).split(/[,\\n]/).map(s => s.trim()).filter(Boolean) : []),
dislikes: f.dislikes || '', dislikes: Array.isArray(f.dislikes) ? f.dislikes : (f.dislikes ? String(f.dislikes).split(/[,\\n]/).map(s => s.trim()).filter(Boolean) : []),
foodLikes: f.foodLikes || '', foodLikes: f.foodLikes || '',
foodDislikes: f.foodDislikes || '', foodDislikes: f.foodDislikes || '',
wifiPassword: f.wifiPassword || '', wifiPassword: f.wifiPassword || '',
carModel: f.carModel || '', carModel: f.carModel || '',
workplace: f.workplace || '', workplace: f.workplace || '',
schedule: f.schedule || '', schedule: f.schedule || '',
futureIdeas: f.futureIdeas || '', futureIdeas: Array.isArray(f.futureIdeas) ? f.futureIdeas : (f.futureIdeas ? String(f.futureIdeas).split(/[,\n]/).map(s => s.trim()).filter(Boolean) : []),
quotes: f.quotes || '', quotes: Array.isArray(f.quotes) ? f.quotes : (f.quotes ? String(f.quotes).split(/[,\n]/).map(s => s.trim()).filter(Boolean) : []),
projects: Array.isArray(f.projects) ? f.projects : (f.projects ? String(f.projects).split(/[,\n]/).map(s => s.trim()).filter(Boolean) : []),
importantDates: Array.isArray(f.importantDates) ? f.importantDates.map(x => ({ importantDates: Array.isArray(f.importantDates) ? f.importantDates.map(x => ({
date: x?.date || null, date: x?.date || null,
label: x?.label || '', label: x?.label || '',
...@@ -239,6 +275,7 @@ export function makeExportPayload(state) { ...@@ -239,6 +275,7 @@ export function makeExportPayload(state) {
events: Array.isArray(f.events) ? f.events.map(ev => ({ events: Array.isArray(f.events) ? f.events.map(ev => ({
id: ev.id, id: ev.id,
date: ev.date, date: ev.date,
title: ev.title || '',
notes: ev.notes, notes: ev.notes,
location: ev.location, location: ev.location,
participants: Array.isArray(ev.participants) ? [...ev.participants] : [], participants: Array.isArray(ev.participants) ? [...ev.participants] : [],
...@@ -263,16 +300,17 @@ export function normalizeImportedPayload(payload) { ...@@ -263,16 +300,17 @@ export function normalizeImportedPayload(payload) {
birthday: f.birthday || null, birthday: f.birthday || null,
notes: f.notes || '', notes: f.notes || '',
// rich profile (defaults) // rich profile (defaults)
likes: f.likes || '', likes: Array.isArray(f.likes) ? f.likes : (f.likes ? String(f.likes).split(/[,\\n]/).map(s => s.trim()).filter(Boolean) : []),
dislikes: f.dislikes || '', dislikes: Array.isArray(f.dislikes) ? f.dislikes : (f.dislikes ? String(f.dislikes).split(/[,\\n]/).map(s => s.trim()).filter(Boolean) : []),
foodLikes: f.foodLikes || '', foodLikes: f.foodLikes || '',
foodDislikes: f.foodDislikes || '', foodDislikes: f.foodDislikes || '',
wifiPassword: f.wifiPassword || '', wifiPassword: f.wifiPassword || '',
carModel: f.carModel || '', carModel: f.carModel || '',
workplace: f.workplace || '', workplace: f.workplace || '',
schedule: f.schedule || '', schedule: f.schedule || '',
futureIdeas: f.futureIdeas || '', futureIdeas: Array.isArray(f.futureIdeas) ? f.futureIdeas : (f.futureIdeas ? String(f.futureIdeas).split(/[,\n]/).map(s => s.trim()).filter(Boolean) : []),
quotes: f.quotes || '', quotes: Array.isArray(f.quotes) ? f.quotes : (f.quotes ? String(f.quotes).split(/[,\n]/).map(s => s.trim()).filter(Boolean) : []),
projects: Array.isArray(f.projects) ? f.projects : (f.projects ? String(f.projects).split(/[,\n]/).map(s => s.trim()).filter(Boolean) : []),
importantDates: Array.isArray(f.importantDates) ? f.importantDates.map(x => ({ importantDates: Array.isArray(f.importantDates) ? f.importantDates.map(x => ({
date: x?.date || null, date: x?.date || null,
label: x?.label || '', label: x?.label || '',
...@@ -293,6 +331,7 @@ export function normalizeImportedPayload(payload) { ...@@ -293,6 +331,7 @@ export function normalizeImportedPayload(payload) {
events: Array.isArray(f.events) ? f.events.map(ev => ({ events: Array.isArray(f.events) ? f.events.map(ev => ({
id: ev.id, id: ev.id,
date: ev.date, date: ev.date,
title: ev.title || '',
notes: ev.notes || '', notes: ev.notes || '',
location: ev.location, location: ev.location,
participants: Array.isArray(ev.participants) ? ev.participants : [], participants: Array.isArray(ev.participants) ? ev.participants : [],
......
import Head from 'next/head';
import Layout from '@/components/layout';
import DunbarApp from '@/components/dunbar/DunbarApp';
// Client-only page; do not export getServerSideProps/getStaticProps
export default function DunbarEventPage() {
const desc =
'Dunbar — Event details in the privacy-first relationship navigator prototype. Local-only data, networks, events, and orbits.';
return (
<div className="container">
<Layout>
<Head>
<title>Dunbar Event</title>
<meta name="robots" content="noindex" />
<meta name="description" content={desc} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Dunbar — Event" />
<meta name="twitter:description" content={desc} />
<meta property="og:type" content="website" />
<meta property="og:title" content="Dunbar — Event" />
<meta property="og:description" content={desc} />
</Head>
<DunbarApp />
</Layout>
</div>
);
}
...@@ -9,6 +9,22 @@ export default function DunbarPage() { ...@@ -9,6 +9,22 @@ export default function DunbarPage() {
<Head> <Head>
<title>Dunbar Relationship Navigator</title> <title>Dunbar Relationship Navigator</title>
<meta name="robots" content="noindex" /> <meta name="robots" content="noindex" />
<meta
name="description"
content="Dunbar — a privacy-first relationship navigator prototype. Local-only data, no analytics, organize friends, events, and networks."
/>
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Dunbar — Relationship Navigator" />
<meta
name="twitter:description"
content="Privacy-first relationship navigator prototype. Local-only data, networks, events, and orbits."
/>
<meta property="og:type" content="website" />
<meta property="og:title" content="Dunbar — Relationship Navigator" />
<meta
property="og:description"
content="Privacy-first relationship navigator prototype. Local-only data, networks, events, and orbits."
/>
</Head> </Head>
<DunbarApp /> <DunbarApp />
</Layout> </Layout>
......
...@@ -250,6 +250,17 @@ ...@@ -250,6 +250,17 @@
color: #2c5530; color: #2c5530;
font-size: 0.8rem; font-size: 0.8rem;
line-height: 1.4; line-height: 1.4;
gap: 6px;
}
.tagClose {
appearance: none;
border: none;
background: transparent;
color: #2c5530;
font-weight: 800;
cursor: pointer;
padding: 0;
line-height: 1;
} }
.badge { .badge {
...@@ -399,6 +410,11 @@ ...@@ -399,6 +410,11 @@
justify-content: center; justify-content: center;
font-weight: 700; font-weight: 700;
color: #333; color: #333;
transition: transform 80ms ease, background 120ms ease;
}
.ctrlBtn:active {
transform: scale(0.96);
background: #f6f6f6;
} }
.ctrlWide { .ctrlWide {
......
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