/* global React */
/* =============================================================================
MOMENTUM LOG v5.0 — Practice-Centric Momentum log Phase 1.1
Three Day Cell states · Daily Detail Modal · Registry View · Discovery Engine
========================================================================== */
const { useState, useMemo, useRef, useEffect, useCallback } = React;
// ── Domain map ────────────────────────────────────────────────────────────────
const DOMAIN_CLASS = {
physical: 'dom-physical',
inner: 'dom-inner',
creative: 'dom-creative',
interpersonal: 'dom-interpersonal',
adventure: 'dom-adventure',
admin: 'dom-admin',
};
const DOMAIN_LABEL = {
physical: 'Physical Form',
inner: 'Inner World',
creative: 'Creative',
interpersonal: 'Interpersonal',
adventure: 'Adventure',
admin: 'Life Admin',
};
const AVAILABLE_ICONS = [
'bell','bond','book','cross','dance','fire-1','hold-book','hold-cloud',
'hold-eye','hold-globe','hold-question','leaf','manuscripts','moon',
'red-light','star-maps','support',
];
const _iconHref = (ic) =>
`/dashboard-ui/assets/icons/${AVAILABLE_ICONS.includes(ic) ? ic : 'star-maps'}.svg`;
// ── Date helpers ──────────────────────────────────────────────────────────────
const DAY_NAMES = ['SUN','MON','TUE','WED','THU','FRI','SAT'];
const MONTH_NAMES = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
const today = () => new Date();
const dateKey = (d) => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
const fmtInputDate = (d) => {
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${d.getFullYear()}-${m}-${day}`;
};
const fmtDisplayDate = (isoStr) => {
const d = new Date(isoStr + 'T00:00:00');
return `${MONTH_NAMES[d.getMonth()]} ${d.getDate()}`;
};
const fmtTime = (isoStr) => {
if (!isoStr) return '';
const d = new Date(isoStr);
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
const momentumDays = () => {
const t = today();
const out = [];
for (let i = 6; i >= 0; i--) {
const d = new Date(t);
d.setDate(t.getDate() - i);
out.push(d);
}
return out;
};
const calendarDays = () => {
const t = today();
const todayDay = t.getDay();
const currentMonday = new Date(t);
currentMonday.setDate(t.getDate() - ((todayDay + 6) % 7));
const startMonday = new Date(currentMonday);
startMonday.setDate(currentMonday.getDate() - 21);
return Array.from({ length: 28 }, (_, i) => {
const d = new Date(startMonday);
d.setDate(startMonday.getDate() + i);
return d;
});
};
// ── Icon component (WCAG: accessible tooltip on hover/focus) ─────────────────
const PracticeIcon = ({ icon, domain, name, size = 28, onClick }) => {
const cls = DOMAIN_CLASS[domain] || 'dom-admin';
const url = `url(${_iconHref(icon)})`;
return (
);
};
// ── UndoToast ─────────────────────────────────────────────────────────────────
const UndoToast = ({ message, onUndo, onDismiss }) => {
const [visible, setVisible] = useState(true);
const timerRef = useRef(null);
useEffect(() => {
timerRef.current = setTimeout(() => { setVisible(false); onDismiss(); }, 5000);
return () => clearTimeout(timerRef.current);
}, []);
useEffect(() => {
const handler = (e) => {
if (e.key === 'Escape') { setVisible(false); onDismiss(); }
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, []);
if (!visible) return null;
return (
{message}
);
};
// ── Day Cell ──────────────────────────────────────────────────────────────────
// Three mutually exclusive states: has_entries | empty | no_practices
const DayCell = ({ date, entries, isToday, hasPractices, onAdd, onCellClick }) => {
const [longPress, setLongPress] = useState(false);
const lpTimer = useRef(null);
const handleTouchStart = () => {
lpTimer.current = setTimeout(() => { setLongPress(true); onAdd(date); }, 500);
};
const handleTouchEnd = () => { clearTimeout(lpTimer.current); setLongPress(false); };
const todayStr = dateKey(today());
const cellDate = dateKey(date);
// State: future (calendar days after today — inert)
const isFuture = cellDate > todayStr;
if (isFuture) {
return (
{DAY_NAMES[date.getDay()]}
{date.getDate()}
Future
);
}
// State: no_practices (zero practices registered anywhere)
if (!hasPractices) {
return (
{DAY_NAMES[date.getDay()]}
{date.getDate()}
—
);
}
// State: has_entries (≥1 Practice Log Entry)
if (entries.length > 0) {
return (
onCellClick(date, entries)}
onKeyDown={(e) => e.key === 'Enter' && onCellClick(date, entries)}
tabIndex={0}
role="button"
aria-label={`${fmtDisplayDate(cellDate)}: ${entries.length} practice${entries.length > 1 ? 's' : ''} logged`}
>
{DAY_NAMES[date.getDay()]}
{date.getDate()}
{isToday && today}
{entries.map((e, i) => (
{ ev.stopPropagation(); onCellClick(date, [e]); }}
/>
))}
);
}
// State: empty (Reflective Gap — practices exist, none logged for this date)
return (
onAdd(date)}
style={{ cursor: 'pointer' }}
>
{DAY_NAMES[date.getDay()]}
{date.getDate()}
{isToday && today}
);
};
// ── First-run Onboarding Tile ─────────────────────────────────────────────────
const FirstRunTile = ({ onAdd }) => (
Track what you practice. Tap + to log your first Practice.
Practices are repeated activities — sauna, meditation, writing. Different from tasks.
);
// ── Suggestion Card (Discovery Engine) ───────────────────────────────────────
const SuggestionCard = ({ suggestion, onPromote, onIgnore, onSnooze }) => {
const cls = DOMAIN_CLASS[suggestion.domain] || 'dom-admin';
return (
Discovery Engine · Pattern Detected
{suggestion.candidate_name}
Logged {suggestion.frequency_count}× this week
);
};
// ── Log Entry Form (no notes — PRD §3.3) ─────────────────────────────────────
const LogEntryForm = ({ initialDate, registry, onClose, onSave }) => {
const [dateStr, setDateStr] = useState(fmtInputDate(initialDate || today()));
const [query, setQuery] = useState('');
const [selectedId, setSelectedId] = useState(null);
const [open, setOpen] = useState(false);
const backdropRef = useRef(null);
const inputRef = useRef(null);
useEffect(() => { requestAnimationFrame(() => setOpen(true)); }, []);
useEffect(() => {
if (inputRef.current) inputRef.current.focus();
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = ''; };
}, []);
const handleClose = () => { setOpen(false); setTimeout(onClose, 280); };
useEffect(() => {
const onKey = (e) => { if (e.key === 'Escape') handleClose(); };
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, []);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return registry;
return registry.filter((p) =>
p.name.toLowerCase().includes(q) ||
(DOMAIN_LABEL[p.domain] || '').toLowerCase().includes(q)
);
}, [query, registry]);
const selected = registry.find((p) => p.id === selectedId);
return (
{ if (e.target === backdropRef.current) handleClose(); }}
>
New Entry
Log a practice.
setDateStr(e.target.value)}
/>
setQuery(e.target.value)}
/>
{filtered.length === 0 &&
No matches.
}
{filtered.map((p) => {
const cls = DOMAIN_CLASS[p.domain] || 'dom-admin';
return (
setSelectedId(p.id)}
tabIndex={0}
role="option"
aria-selected={selectedId === p.id}
onKeyDown={(e) => e.key === 'Enter' && setSelectedId(p.id)}
>
{p.name}
{DOMAIN_LABEL[p.domain] || p.domain}
);
})}
);
};
// ── Daily Detail Modal ────────────────────────────────────────────────────────
const DailyDetailModal = ({ practiceId, entryDate, onClose, onMoved, registry }) => {
const [detail, setDetail] = useState(null);
const [editMode, setEditMode] = useState(false);
const [editName, setEditName] = useState('');
const [moveDateStr, setMoveDateStr] = useState('');
const [undoToast, setUndoToast] = useState(null); // { message, entryId, oldDate }
const [open, setOpen] = useState(false);
const backdropRef = useRef(null);
const stripDays = momentumDays().map(d => fmtInputDate(d));
const todayStr = fmtInputDate(today());
useEffect(() => { requestAnimationFrame(() => setOpen(true)); }, []);
useEffect(() => {
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = ''; };
}, []);
useEffect(() => {
fetch(`/api/momentum/detail/${practiceId}/${entryDate}`)
.then((r) => r.ok ? r.json() : null)
.then((d) => {
if (d) {
setDetail(d);
setEditName(d.name);
setMoveDateStr(entryDate);
}
})
.catch(() => {});
}, [practiceId, entryDate]);
const handleClose = () => { setOpen(false); setTimeout(onClose, 280); };
useEffect(() => {
const onKey = (e) => { if (e.key === 'Escape') handleClose(); };
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, []);
const handleSaveName = () => {
if (!editName.trim() || editName.trim() === detail.name) return;
fetch(`/api/momentum/registry/${practiceId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: editName.trim() }),
}).then(() => setDetail((d) => ({ ...d, name: editName.trim() }))).catch(() => {});
};
const handleMove = (entryId) => {
if (!moveDateStr || moveDateStr === entryDate) return;
const originalDate = entryDate;
fetch(`/api/momentum/log/${entryId}/move`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ date: moveDateStr }),
})
.then((r) => r.ok ? r.json() : null)
.then((res) => {
if (!res) return;
setUndoToast({
message: `Moved to ${fmtDisplayDate(moveDateStr)}`,
entryId,
oldDate: originalDate,
newDate: moveDateStr,
});
setDetail((d) => ({ ...d, date: moveDateStr }));
if (onMoved) onMoved();
})
.catch(() => {});
};
const handleUndo = () => {
if (!undoToast) return;
fetch(`/api/momentum/log/${undoToast.entryId}/move`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ date: undoToast.oldDate }),
}).then(() => {
setDetail((d) => ({ ...d, date: undoToast.oldDate }));
if (onMoved) onMoved();
}).catch(() => {});
setUndoToast(null);
};
const cls = detail ? (DOMAIN_CLASS[detail.domain] || 'dom-admin') : '';
return (
{ if (e.target === backdropRef.current) handleClose(); }}
>
{!detail && (
Loading…
)}
{detail && (
{!editMode ? (
{detail.name}
) : (
setEditName(e.target.value)}
onBlur={handleSaveName}
maxLength={60}
style={{ fontSize: 20, fontFamily: 'var(--font-serif-display)', background: 'transparent', border: 'none', borderBottom: '1px solid var(--stone-3)', color: 'var(--ink-1)', width: '100%' }}
aria-label="Edit practice name"
/>
)}
{DOMAIN_LABEL[detail.domain] || detail.domain}
{fmtDisplayDate(entryDate)}
{detail.entries.length > 1 && (
Logged {detail.entries.length}× this day
)}
{/* Edit Mode: Move to Date */}
{editMode && detail.entries.length > 0 && (
)}
{/* Read Mode: Segment History */}
{detail.entries.length === 0 && (
Logged on {fmtDisplayDate(entryDate)} — no journal entry linked.
)}
{detail.entries.map((entry, i) => (
{entry.source === 'manual' && !entry.segment_text && (
Manual log — {fmtTime(entry.created_at)}
)}
{entry.source === 'journal' && entry.segment_text && (
"{entry.segment_text}"
)}
{entry.source === 'journal' && !entry.segment_text && (
Logged on {fmtDisplayDate(entryDate)} — no journal entry linked.
)}
))}
)}
{undoToast && (
setUndoToast(null)}
/>
)}
);
};
// ── Practice Creation Form ────────────────────────────────────────────────────
const PracticeCreationForm = ({ onClose, onCreated }) => {
const [name, setName] = useState('');
const [domain, setDomain] = useState('');
const [icon, setIcon] = useState('');
const [iconQuery, setIconQuery] = useState('');
const [nameError, setNameError] = useState('');
const [open, setOpen] = useState(false);
const backdropRef = useRef(null);
const nameRef = useRef(null);
useEffect(() => { requestAnimationFrame(() => setOpen(true)); }, []);
useEffect(() => { if (nameRef.current) nameRef.current.focus(); document.body.style.overflow = 'hidden'; return () => { document.body.style.overflow = ''; }; }, []);
const handleClose = () => { setOpen(false); setTimeout(onClose, 280); };
useEffect(() => {
const onKey = (e) => { if (e.key === 'Escape') handleClose(); };
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, []);
const filteredIcons = useMemo(() => {
const q = iconQuery.trim().toLowerCase();
if (!q) return AVAILABLE_ICONS;
return AVAILABLE_ICONS.filter((ic) => ic.includes(q));
}, [iconQuery]);
const handleSubmit = () => {
const trimmed = name.trim();
if (!trimmed) { setNameError('Name is required.'); return; }
if (trimmed.length > 60) { setNameError('Max 60 characters.'); return; }
if (!domain) return;
if (!icon) return;
fetch('/api/momentum/registry', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: trimmed, domain, icon }),
})
.then((r) => r.ok ? r.json() : Promise.reject(r))
.then((practice) => { onCreated(practice); handleClose(); })
.catch(() => { setNameError('Could not create practice. Try again.'); });
};
return (
{ if (e.target === backdropRef.current) handleClose(); }}
>
New Practice
Create a practice.
{/* Name */}
{ setName(e.target.value); setNameError(''); }}
placeholder="e.g. Sauna, Meditation, Walk…"
aria-required="true"
aria-describedby={nameError ? 'pf-name-error' : undefined}
/>
{nameError && {nameError}}
{/* Domain */}
{/* Icon */}
setIconQuery(e.target.value)}
aria-label="Search icons"
/>
{filteredIcons.map((ic) => (
))}
{filteredIcons.length === 0 && No icons match.}
);
};
// ── Registry View ─────────────────────────────────────────────────────────────
const RegistryView = ({ registry, onClose, onUpdated }) => {
const [confirmDelete, setConfirmDelete] = useState(null); // practice object
const [editingId, setEditingId] = useState(null);
const [editName, setEditName] = useState('');
const [open, setOpen] = useState(false);
const backdropRef = useRef(null);
useEffect(() => { requestAnimationFrame(() => setOpen(true)); }, []);
useEffect(() => { document.body.style.overflow = 'hidden'; return () => { document.body.style.overflow = ''; }; }, []);
const handleClose = () => { setOpen(false); setTimeout(onClose, 280); };
useEffect(() => {
const onKey = (e) => { if (e.key === 'Escape') handleClose(); };
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, []);
const startEdit = (p) => { setEditingId(p.id); setEditName(p.name); };
const saveEdit = (practiceId) => {
const trimmed = editName.trim();
if (!trimmed) return;
fetch(`/api/momentum/registry/${practiceId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: trimmed }),
}).then(() => { setEditingId(null); onUpdated(); }).catch(() => {});
};
const handleDelete = (practice) => {
fetch(`/api/momentum/registry/${practice.id}`, { method: 'DELETE' })
.then((r) => {
if (!r.ok) return r.json().then((e) => { throw new Error(e.detail); });
setConfirmDelete(null);
onUpdated();
})
.catch((e) => { alert(e.message || 'Delete failed.'); setConfirmDelete(null); });
};
return (
{ if (e.target === backdropRef.current) handleClose(); }}
>
Practice Registry
Manage practices.
{registry.length === 0 && (
No practices yet.
)}
{registry.map((p) => {
const cls = DOMAIN_CLASS[p.domain] || 'dom-admin';
return (
{editingId === p.id ? (
setEditName(e.target.value)}
onBlur={() => saveEdit(p.id)}
onKeyDown={(e) => e.key === 'Enter' && saveEdit(p.id)}
autoFocus
style={{ flex: 1, fontSize: 13 }}
aria-label="Edit practice name"
/>
) : (
{p.name}
{DOMAIN_LABEL[p.domain] || p.domain}
)}
{p.has_linked_action ? (
⚭ Action
) : (
)}
);
})}
{/* Delete confirmation dialog — high friction */}
{confirmDelete && (
Delete {confirmDelete.name}?
This will remove all log history. This cannot be undone.
)}
);
};
// ── Momentum log Container ──────────────────────────────────────────────────────────
const MomentumLogContainer = ({
momentumData, hasPractices, registry,
suggestion, onSuggestionPromote, onSuggestionIgnore, onSuggestionSnooze,
onAdd, onCellClick, onOpenRegistry, expanded, onToggleExpand,
}) => {
const days = expanded ? calendarDays() : momentumDays();
const todayKey = dateKey(today());
return (
{/* Discovery suggestion card — anchored above strip, non-blocking */}
{suggestion && (
)}
{/* no_practices: first-run onboarding tile */}
{!hasPractices ? (
) : (
{days.map((d) => {
const k = dateKey(d);
const entries = momentumData[k] || [];
return (
);
})}
)}
);
};
// ── App Root ──────────────────────────────────────────────────────────────────
const PracticeLogApp = () => {
const [momentumData, setMomentumData] = useState({});
const [hasPractices, setHasPractices] = useState(true);
const [registry, setRegistry] = useState([]);
const [suggestions, setSuggestions] = useState([]);
const [expanded, setExpanded] = useState(false);
// modal state
const [logForm, setLogForm] = useState(null); // { date }
const [detailModal, setDetailModal] = useState(null); // { practiceId, entryDate }
const [createForm, setCreateForm] = useState(false);
const [registryOpen, setRegistryOpen] = useState(false);
const loadMomentumLog = useCallback((isExpanded) => {
const days = isExpanded ? 30 : 7;
fetch(`/api/practice/log?days=${days}`)
.then((r) => r.ok ? r.json() : null)
.then((d) => {
if (!d) return;
setHasPractices(d.has_practices);
setMomentumData(d.entries || {});
})
.catch(() => {});
}, []);
const loadRegistry = useCallback(() => {
fetch('/api/momentum/registry')
.then((r) => r.ok ? r.json() : null)
.then((d) => { if (d) setRegistry(d); })
.catch(() => {});
}, []);
const loadSuggestions = useCallback(() => {
fetch('/api/momentum/suggestions')
.then((r) => r.ok ? r.json() : null)
.then((d) => { if (d) setSuggestions(d); })
.catch(() => {});
}, []);
useEffect(() => {
loadMomentumLog(expanded);
loadRegistry();
loadSuggestions();
}, []);
const handleToggleExpand = () => {
const next = !expanded;
setExpanded(next);
loadMomentumLog(next);
};
const onAdd = (date) => {
if (!hasPractices) { setCreateForm(true); return; }
setLogForm({ date });
};
const onSaveLog = ({ practice_id, date, source }) => {
fetch('/api/practice/log', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ practice_id, date, source }),
})
.then((r) => r.ok ? r.json() : null)
.then(() => { loadMomentumLog(expanded); })
.catch(() => {});
setLogForm(null);
};
const onCellClick = (date, entries) => {
if (entries.length === 0) return;
const first = entries[0];
setDetailModal({ practiceId: first.practice_id, entryDate: dateKey(date) });
};
const activeSuggestion = suggestions[0] || null;
const handlePromote = () => {
if (!activeSuggestion) return;
fetch(`/api/momentum/suggestions/${activeSuggestion.id}/promote`, { method: 'POST' })
.then(() => { loadRegistry(); loadMomentumLog(); loadSuggestions(); })
.catch(() => {});
};
const handleIgnore = () => {
if (!activeSuggestion) return;
fetch(`/api/momentum/suggestions/${activeSuggestion.id}/ignore`, { method: 'POST' })
.then(loadSuggestions).catch(() => {});
};
const handleSnooze = () => {
if (!activeSuggestion) return;
fetch(`/api/momentum/suggestions/${activeSuggestion.id}/snooze`, { method: 'POST' })
.then(loadSuggestions).catch(() => {});
};
return (
{/* — hidden until user/search bar is ready */}
{/* Domain Key */}
Life Domains
{Object.entries(DOMAIN_LABEL).map(([id, label]) => (
{label}
))}
setRegistryOpen(true)}
expanded={expanded}
onToggleExpand={handleToggleExpand}
/>
{/* Log Entry Form (no notes) */}
{logForm && (
setLogForm(null)}
onSave={onSaveLog}
/>
)}
{/* Practice Creation Form */}
{createForm && (
setCreateForm(false)}
onCreated={() => { loadRegistry(); loadMomentumLog(); setCreateForm(false); setHasPractices(true); }}
/>
)}
{/* Daily Detail Modal */}
{detailModal && (
setDetailModal(null)}
onMoved={loadMomentumLog}
/>
)}
{/* Registry View */}
{registryOpen && (
setRegistryOpen(false)}
onUpdated={() => { loadRegistry(); loadMomentumLog(); }}
/>
)}
);
};
window.PracticeLogApp = PracticeLogApp;
window.LogEntryForm = LogEntryForm;
window.DailyDetailModal = DailyDetailModal;