// Achievements — derived live from the signal-event stream.
// Source: GET /api/v1/master-equation/me/events (loaded fresh on view).
const Achievements = ({ openQuickLog }) => {
const [events, setEvents] = React.useState(null);
React.useEffect(() => {
let dead = false;
(async () => {
try {
const r = await fetch(window.CHVault.apiRoot + '/api/v1/master-equation/me/events?limit=1000', {
credentials: 'include',
headers: { 'Accept': 'application/json' },
});
if (!r.ok) throw 0;
const j = await r.json();
if (!dead) setEvents(Array.isArray(j) ? j : (j.events || []));
} catch {
if (!dead) setEvents([]);
}
})();
return () => { dead = true; };
}, []);
if (events === null) {
return (
Achievements
Tallying your practice…
);
}
// ── Derive streaks per axis (consecutive distinct days with ≥1 event). ──
const tintFor = (c) => (window.AXIS_META[c] && window.AXIS_META[c].tint) || '#888';
const byAxis = {};
for (const ev of events) {
const code = axisOfSignal(ev.signal_id);
if (!code || code === '??') continue;
const dayKey = (new Date(ev.recorded_at)).toISOString().slice(0, 10);
(byAxis[code] = byAxis[code] || new Set()).add(dayKey);
}
const streaks = [].map(code => {
const days = Array.from(byAxis[code] || []).sort().reverse(); // newest first
let current = 0, cursor = new Date(); cursor.setHours(0,0,0,0);
for (const d of days) {
if (d === cursor.toISOString().slice(0,10)) { current++; cursor.setDate(cursor.getDate() - 1); }
else break;
}
return { code, name: (window.AXIS_META[code] && window.AXIS_META[code].name) || code, tint: tintFor(code), current, total: (byAxis[code] || new Set()).size };
});
// ── Lifetime stats ──
const total = events.length;
const earliest = events.length ? new Date(events[events.length-1].recorded_at) : null;
const daysSince = earliest ? Math.max(1, Math.ceil((Date.now() - earliest.getTime()) / 86400000)) : 0;
const sources = {};
events.forEach(e => sources[e.source || 'web'] = (sources[e.source || 'web'] || 0) + 1);
const lifetime = [];
return (
Achievements
The ledger of your practice.
Derived live from your signal events — no canned badges. Each streak is consecutive days with at least one observation in that axis.
Active streaks
Lifetime
{lifetime.map((s, i) => (
))}
{events.length === 0 && (
No signal events recorded yet. Log a meal, a focus block, a walk, or a meditation and your first achievement will appear here in seconds.
)}
);
};
function axisOfSignal(signal_id) {
if (!signal_id) return '??';
const prefix = String(signal_id).split('_')[0].toUpperCase();
return ['PO','NM','ER','SC','RS','ES','TA','PV'].includes(prefix) ? prefix : '??';
}
window.Achievements = Achievements;