personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor(events): migrate live consumers + delete reader functions

Sprint 4 Lode C1. Completes retirement of legacy reader surfaces started
in 9a5d800b (A) and 930f1d36 (B); last-known-good for revert: 930f1d36.

- apps/home: rename `_collect_events` → `_collect_anticipated_activities`;
drop `get_events` call; rename pulse context key `events` →
`anticipated_activities`; update template iteration + empty-state copy.
- apps/activities: delete `/api/day/<day>/events` route + `activities_day_events`;
replace `activities_stats` with activities-sourced stub returning
`{YYYYMMDD: {facet: count}}` via `calendar.monthrange` + `load_activity_records`.
- apps/activities/_day.html: template surgery — replace `#eventsView` with
`#timelineView`, extract timeline rendering into new `renderTimeline()`,
delete event modal + `openModal`, delete all-day event block, delete
processedEvents/column-layout/legend code, drop `/api/day/<day>/events`
fetch. File: 1470 → 974 LOC.
- convey/static/month-picker.js: tooltip wording `events` → `items`.
- think/journal_stats.py: drop `facets/*/events/*.jsonl` mtime block from
`_get_day_mtime`.
- think/indexer/journal.py: delete `get_events`. `_find_signal_files`
event glob retained — historical event files remain searchable via
`sol call journal search`.
- think/event_formatter.py: delete `get_month_event_counts`; `format_events`
remains as the only public export. Registry entry at
`think/formatters.py:142` unchanged.
- tests: delete `tests/test_events.py` (5 tests); delete
`test_get_events*` from `test_journal_index.py` (2 tests); delete
`test_facet_event_mtime_invalidates_cache` from `test_journal_stats.py`
(1 test, covered deleted functionality); rewrite Pulse collector test
in `test_home_yesterdays_processing.py` (anticipated-only assertion);
update monkeypatches in `test_home_skills.py` + `test_home_routines.py`;
add one route-level stats stub test in `tests/test_app_activities.py`;
remove `day-events` entry from `tests/verify_api.py`; regenerate
`tests/baselines/api/activities/stats-month.json` for the new shape.

Keep-list invariants intact:
- `think/formatters.py:142` event formatter registry entry
- `think/event_formatter.py::format_events`
- `think/indexer/journal.py::_find_signal_files` event glob (line ~250)
- `tests/test_formatters.py`

Deferred to Lode D: `think/planner.md` references to `get_events` at
lines 19, 46, 82 (live prompt text, but no runtime tool-wrapper exists —
misleading but non-breaking); wider docs/baselines scrub.

Test count: 3234 passed, 1 skipped (net −7 tests from the migration:
−5 `test_events.py`, −2 `test_journal_index.py::test_get_events*`,
−1 `test_journal_stats.py::test_facet_event_mtime_invalidates_cache`,
−1 rewritten-in-place in `test_home_yesterdays_processing.py`,
+1 new `test_app_activities.py` stats route test, +1 existing
`test_home_yesterdays_processing.py` rewrite counted above).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+142 -994
+31 -527
apps/activities/_day.html
··· 1 - {# Calendar day view - events timeline + activities #} 1 + {# Activities day view - timeline + activity cards #} 2 2 3 3 <style> 4 4 .day-view { ··· 56 56 box-shadow: none; 57 57 } 58 58 59 - .modal { 60 - display: none; 61 - position: fixed; 62 - z-index: 1000; 63 - left: 0; 64 - top: 0; 65 - width: 100%; 66 - height: 100%; 67 - background: rgba(0,0,0,0.3); 68 - } 69 - 70 - .modal-content { 71 - background: white; 72 - margin: min(5%, 60px) auto; 73 - padding: 1em; 74 - border-radius: 8px; 75 - max-width: 600px; 76 - position: relative; 77 - } 78 - 79 - .close { 80 - position: absolute; 81 - top: 10px; 82 - right: 15px; 83 - cursor: pointer; 84 - font-size: 24px; 85 - border-radius: 4px; 86 - padding: 4px 8px; 87 - transition: color 0.15s ease, background 0.15s ease; 88 - } 89 - 90 - .modal-content .close:hover { 91 - background: rgba(0, 0, 0, 0.06); 92 - color: #333; 93 - } 94 - 95 - .modal-content .close:active { 96 - background: rgba(0, 0, 0, 0.12); 97 - color: #111; 98 - } 99 - 100 - .occ-field { 101 - margin-bottom: 0.5em; 102 - } 103 - 104 - .occ-field strong { 105 - display: inline-block; 106 - width: 90px; 107 - } 108 - 109 - .occ-desc { 110 - margin-top: 0.5em; 111 - white-space: pre-wrap; 112 - } 113 - 114 59 h1 { 115 60 font-size: 1.5em; 116 61 font-weight: 600; ··· 125 70 max-width: 65ch; 126 71 } 127 72 128 - .events-empty { 73 + .timeline-empty { 129 74 color: #888; 130 75 padding: 1.5em 2em 3em; 131 76 text-align: center; ··· 144 89 margin-top: 0.5em; 145 90 } 146 91 147 - /* Planned/anticipated events - dotted outline, faded */ 148 - .occ-planned { 149 - opacity: 0.6; 150 - background: transparent !important; 151 - border: 2px dotted; 152 - color: #555; 153 - } 154 - 155 - .occ:hover:not(.occ-activity) { 156 - opacity: 1; 157 - filter: brightness(0.92); 158 - } 159 - .occ:active:not(.occ-activity) { 160 - opacity: 1; 161 - filter: brightness(0.85); 162 - } 163 - .occ-planned:hover { 164 - opacity: 0.8; 165 - } 166 - .occ-planned:active { 167 - opacity: 0.9; 168 - } 169 92 .occ-activity:hover { 170 93 opacity: 0.55; 171 94 } ··· 173 96 opacity: 0.7; 174 97 } 175 98 176 - /* All-day section for untimed events */ 177 - .all-day-section { 178 - margin-bottom: 1.5em; 179 - padding: 0.75em 0.75em 1em; 180 - background: #f8f9fa; 181 - border-bottom: 1px solid #e0e0e0; 182 - border-radius: 6px; 183 - } 184 - 185 - .all-day-section h2 { 186 - margin: 0 0 0.5em 0; 187 - font-size: 0.8em; 188 - font-weight: 600; 189 - text-transform: uppercase; 190 - letter-spacing: 0.04em; 191 - color: #888; 192 - } 193 - 194 - .all-day-cards { 195 - display: flex; 196 - flex-wrap: wrap; 197 - gap: 0.75em; 198 - align-items: center; 199 - } 200 - 201 - .all-day-card { 202 - flex: 1 1 0; 203 - min-width: 0; 204 - padding: 0.6em 1.2em; 205 - border-radius: 4px; 206 - font-size: 0.9em; 207 - cursor: pointer; 208 - border-left: 3px solid #dee2e6; 209 - background: white; 210 - transition: background 0.15s ease, box-shadow 0.15s ease; 211 - } 212 - 213 - .all-day-card.planned { 214 - border-left-style: dotted; 215 - opacity: 0.7; 216 - } 217 - 218 - .all-day-card .card-title { 219 - font-weight: 500; 220 - } 221 - 222 - .all-day-card .card-badge { 223 - font-size: 0.75em; 224 - color: #666; 225 - margin-left: 0.5em; 226 - } 227 - 228 - .all-day-card:hover { 229 - background: #f8f9fa; 230 - box-shadow: 0 1px 3px rgba(0,0,0,0.08); 231 - } 232 - 233 - .all-day-card:active { 234 - background: #f0f1f3; 235 - box-shadow: none; 236 - } 237 - 238 - .allday-expand { 239 - display: inline-flex; 240 - align-items: center; 241 - margin-left: auto; 242 - padding: 0.4em 0.75em; 243 - border: 1px solid #ddd; 244 - border-left: 3px solid #dee2e6; 245 - border-radius: 4px; 246 - background: #f8f9fa; 247 - color: #666; 248 - font-size: 0.85em; 249 - cursor: pointer; 250 - transition: background 0.15s ease, color 0.15s ease; 251 - } 252 - 253 - .allday-expand:hover { 254 - background: #e9ecef; 255 - color: #333; 256 - } 257 - 258 - .allday-expand:active { 259 - background: #dee2e6; 260 - color: #111; 261 - } 262 - 263 99 /* Activities section */ 264 100 .activities-section { 265 101 margin-top: 2em; ··· 526 362 outline: 2px solid var(--facet-color, #b06a1a); 527 363 outline-offset: 1px; 528 364 } 529 - .all-day-card:focus-visible, 530 365 .activity-card:focus-visible { 531 366 outline: 2px solid var(--facet-color, #b06a1a); 532 367 outline-offset: 2px; ··· 598 433 background: #d9d9d9; 599 434 color: #333; 600 435 } 601 - .timeline-legend { 602 - display: flex; 603 - flex-wrap: wrap; 604 - gap: 0.5em 1.5em; 605 - padding: 0.5em 0.5em; 606 - border-bottom: 1px solid #e0e0e0; 607 - margin-bottom: 0.5em; 608 - font-size: 0.8em; 609 - color: #666; 610 - } 611 - .legend-item { 612 - display: inline-flex; 613 - align-items: center; 614 - gap: 0.3em; 615 - white-space: nowrap; 616 - } 617 - .legend-dot { 618 - display: inline-block; 619 - width: 0.7em; 620 - height: 0.7em; 621 - transform: translateY(1px); 622 - border-radius: 50%; 623 - flex-shrink: 0; 624 - } 625 - 626 436 /* Responsive: tablet */ 627 437 @media (max-width: 768px) { 628 438 .workspace-content-wide { 629 439 padding: 0.5em; 630 440 } 631 - .all-day-cards, 632 - .allday-overflow { 633 - flex-direction: column; 634 - align-items: stretch; 635 - } 636 - .modal-content { 637 - margin: 2% auto; 638 - max-width: 95%; 639 - } 640 441 } 641 442 642 443 /* Responsive: mobile */ ··· 655 456 .occ { 656 457 min-height: 30px; 657 458 } 658 - .all-day-card { 659 - min-height: 44px; 660 - display: flex; 661 - align-items: center; 662 - } 663 459 .activity-card { 664 460 min-height: 44px; 665 461 } 666 - .modal-content { 667 - margin: 0; 668 - max-width: 100%; 669 - min-height: 100vh; 670 - border-radius: 0; 671 - } 672 462 .activities-breadcrumb { 673 463 font-size: 0.75em; 674 464 gap: 0.3em; 675 465 } 676 466 .day-summary { 677 467 font-size: 0.8em; 678 - } 679 - .timeline-legend { 680 - font-size: 0.7em; 681 468 } 682 469 .activities-section { 683 470 margin-top: 1em; ··· 707 494 <div id="daySummary" class="day-summary"></div> 708 495 709 496 <div id="dayView"> 710 - <div id="eventsView"> 711 - <div class="events-empty">Loading...</div> 497 + <div id="timelineView"> 498 + <div class="timeline-empty">Loading...</div> 712 499 </div> 713 500 <div id="activitiesView"></div> 714 501 </div> 715 502 716 503 <div id="activityDetail" class="activity-detail" style="display:none"></div> 717 - 718 - <div id="eventModal" class="modal" role="dialog" aria-modal="true"> 719 - <div class="modal-content"> 720 - <button type="button" class="close" aria-label="Close">&times;</button> 721 - <div id="modalBody"></div> 722 - </div> 723 - </div> 724 504 </div> 725 505 726 506 <script src="{{ url_for('root.static', filename='colors.js') }}"></script> ··· 784 564 } 785 565 } 786 566 787 - let allEvents = []; 788 567 let allActivities = []; 789 - let events = []; 790 568 let activities = []; 791 - let modalTrigger = null; 792 569 let currentActivityId = null; 793 570 const outputCache = {}; 794 571 ··· 824 601 return `${mins}m`; 825 602 } 826 603 827 - function fmtLength(start, end) { 828 - const s = new Date(start); 829 - const e = end ? new Date(end) : new Date(s.getTime() + 5 * 60000); 830 - return fmtDuration(Math.max(1, Math.round((e - s) / 60000))); 831 - } 832 - 833 604 function levelLabel(avg) { 834 605 if (avg >= 0.75) return 'high'; 835 606 if (avg >= 0.4) return 'medium'; ··· 842 613 return '#95a5a6'; 843 614 } 844 615 845 - function parseCreatedDate(source) { 846 - const match = source && source.match(/^(\d{8})\//); 847 - if (match) { 848 - const d = match[1]; 849 - return `${d.slice(0,4)}-${d.slice(4,6)}-${d.slice(6,8)}`; 850 - } 851 - return null; 852 - } 853 - 854 616 function renderSummary() { 855 617 const el = document.getElementById('daySummary'); 856 - const ec = events.length; 857 618 const ac = activities.length; 858 619 859 - if (!ec && !ac) { 860 - el.textContent = 'no recorded events'; 620 + if (!ac) { 621 + el.textContent = 'no recorded activity'; 861 622 el.style.display = ''; 862 623 return; 863 624 } 864 625 865 626 el.style.display = ''; 866 627 867 - const parts = []; 868 - if (ec) parts.push(ec === 1 ? '1 event' : `${ec} events`); 869 - if (ac) parts.push(ac === 1 ? '1 activity' : `${ac} activities`); 870 - const counts = parts.join(', '); 871 - 872 - const times = [] 873 - .concat(events, activities) 628 + const counts = ac === 1 ? '1 activity' : `${ac} activities`; 629 + const times = activities 874 630 .filter(x => x.startTime) 875 631 .map(x => new Date(x.startTime)); 876 632 ··· 899 655 el.textContent = `${counts} — ${timeLabel}`; 900 656 } 901 657 902 - // ── Events rendering ── 903 - 904 - function renderEvents(list) { 905 - const div = document.getElementById('eventsView'); 906 - if (!list.length && !activities.length) { 907 - div.innerHTML = '<div class="events-empty"><div class="empty-icon"><svg width="32" height="32" viewBox="0 0 32 32" stroke="currentColor" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="6" width="24" height="22" rx="3"/><line x1="10" y1="3" x2="10" y2="9"/><line x1="22" y1="3" x2="22" y2="9"/><line x1="4" y1="14" x2="28" y2="14"/><line x1="11" y1="19" x2="11" y2="19.01"/><line x1="16" y1="19" x2="16" y2="19.01"/><line x1="21" y1="19" x2="21" y2="19.01"/><line x1="11" y1="24" x2="11" y2="24.01"/><line x1="16" y1="24" x2="16" y2="24.01"/></svg></div><div class="empty-heading">no events or activities</div><div class="empty-desc">nothing recorded for this day</div></div>'; 908 - return; 909 - } 658 + function renderTimeline(list) { 659 + const div = document.getElementById('timelineView'); 910 660 if (!list.length) { 911 - div.innerHTML = ''; 661 + div.innerHTML = '<div class="timeline-empty"><div class="empty-icon"><svg width="32" height="32" viewBox="0 0 32 32" stroke="currentColor" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="6" width="24" height="22" rx="3"/><line x1="10" y1="3" x2="10" y2="9"/><line x1="22" y1="3" x2="22" y2="9"/><line x1="4" y1="14" x2="28" y2="14"/><line x1="11" y1="19" x2="11" y2="19.01"/><line x1="16" y1="19" x2="16" y2="19.01"/><line x1="21" y1="19" x2="21" y2="19.01"/><line x1="11" y1="24" x2="11" y2="24.01"/><line x1="16" y1="24" x2="16" y2="24.01"/></svg></div><div class="empty-heading">no activity</div><div class="empty-desc">nothing recorded for this day</div></div>'; 912 662 return; 913 663 } 914 664 915 - const timedEvents = list.filter(o => o.startTime); 916 - const untimedEvents = list.filter(o => !o.startTime); 917 - let html = ''; 918 - 919 - if (untimedEvents.length) { 920 - const MAX_ALLDAY_VISIBLE = 5; 921 - html += '<section class="all-day-section" aria-labelledby="all-day-heading"><h2 id="all-day-heading">all day / scheduled</h2><div class="all-day-cards">'; 922 - untimedEvents.forEach((o, i) => { 923 - const idx = list.indexOf(o); 924 - const isPlanned = o.occurred === false; 925 - const cardClass = isPlanned ? 'all-day-card planned' : 'all-day-card'; 926 - const badge = isPlanned ? '<span class="card-badge">planned</span>' : ''; 927 - const title = escapeHtml(o.title || o.summary || o.agent || 'Event'); 928 - if (i === MAX_ALLDAY_VISIBLE && untimedEvents.length > MAX_ALLDAY_VISIBLE) { 929 - const overflow = untimedEvents.length - MAX_ALLDAY_VISIBLE; 930 - html += `<button type="button" class="allday-expand" data-expanded="false" aria-expanded="false">+${overflow} more</button>`; 931 - html += '<div class="allday-overflow" style="display:none;flex-wrap:wrap;gap:0.75em;width:100%">'; 932 - } 933 - html += `<div class="${cardClass}" data-index="${idx}" tabindex="0" role="button" style="border-color:${o.color || '#6c757d'}">`; 934 - html += `<span class="card-title">${title}</span>${badge}</div>`; 935 - }); 936 - if (untimedEvents.length > MAX_ALLDAY_VISIBLE) { 937 - html += '</div>'; 938 - } 939 - html += '</div></section>'; 940 - } 941 - 942 - // Collect all timed items (events + activities) for unified timeline range 943 - const timedActivities = activities.filter(a => a.startTime); 944 - const allTimed = [...timedEvents, ...timedActivities]; 945 - 946 - if (!allTimed.length) { 947 - div.innerHTML = html; 665 + const timedActivities = list.filter(a => a.startTime); 666 + if (!timedActivities.length) { 667 + div.innerHTML = ''; 948 668 return; 949 669 } 950 670 951 - let start = Infinity, end = -Infinity; 952 - allTimed.forEach(o => { 953 - const s = new Date(o.startTime); 954 - const e = o.endTime ? new Date(o.endTime) : new Date(s.getTime() + 3600000); 955 - const startMinutes = s.getHours() * 60 + s.getMinutes(); 956 - if (startMinutes === 0) return; 957 - start = Math.min(start, startMinutes); 671 + let start = Infinity; 672 + let end = -Infinity; 673 + timedActivities.forEach(a => { 674 + const s = new Date(a.startTime); 675 + const e = a.endTime ? new Date(a.endTime) : new Date(s.getTime() + 5 * 60000); 676 + start = Math.min(start, s.getHours() * 60 + s.getMinutes()); 958 677 end = Math.max(end, e.getHours() * 60 + e.getMinutes()); 959 678 }); 960 679 961 - let startHour = Math.max(Math.floor(start / 60) - 1, 0); 962 - let endHour = Math.min(Math.ceil(end / 60) + 1, 24); 680 + const startHour = Math.max(Math.floor(start / 60) - 1, 0); 681 + const endHour = Math.min(Math.ceil(end / 60) + 1, 24); 963 682 const pxPerHour = 66.7; 964 - const totalMinutes = (endHour - startHour) * 60; 965 - 966 - html += `<section class='day-view' aria-label='timeline' style='height:${(endHour - startHour) * pxPerHour}px'>`; 683 + const totalMinutes = Math.max((endHour - startHour) * 60, 60); 967 684 685 + let html = `<section class='day-view' aria-label='timeline' style='height:${(endHour - startHour) * pxPerHour}px'>`; 968 686 for (let h = startHour; h <= endHour; h++) { 969 687 const label = h === 0 ? '12a' : h < 12 ? h + 'a' : h === 12 ? '12p' : (h - 12) + 'p'; 970 688 html += `<div class='hour-line' style='top:${((h - startHour) / (endHour - startHour)) * 100}%'><span>${label}</span></div>`; 971 689 } 972 690 973 - // Render activity blocks (behind events, full width) 974 691 timedActivities.forEach(a => { 975 692 const s = new Date(a.startTime); 976 693 const e = a.endTime ? new Date(a.endTime) : new Date(s.getTime() + 5 * 60000); ··· 986 703 html += `<a class='occ occ-activity' data-activity-id='${escapeHtml(a.id)}' tabindex='0' role='button' style='top:${top}%;height:${height}%;left:0;width:100%;border-color:${color};background:${color}' title='${escapeHtml(a.icon + " " + a.name)}'>${escapeHtml(a.icon)} ${escapeHtml(a.name)}</a>`; 987 704 }); 988 705 989 - // Process event blocks for column layout 990 - const processedEvents = []; 991 - const agents = []; 992 - 993 - timedEvents.forEach(o => { 994 - const idx = list.indexOf(o); 995 - const startTime = new Date(o.startTime); 996 - const endTime = o.endTime ? new Date(o.endTime) : new Date(startTime.getTime() + 5 * 60000); 997 - let s = startTime.getHours() * 60 + startTime.getMinutes(); 998 - let e = endTime.getHours() * 60 + endTime.getMinutes(); 999 - s = Math.max(s, startHour * 60); 1000 - e = Math.min(e, endHour * 60); 1001 - if (e <= s) return; 1002 - 1003 - const agent = o.agent || 'other'; 1004 - if (!agents.includes(agent)) agents.push(agent); 1005 - 1006 - processedEvents.push({ 1007 - startMinutes: s, endMinutes: e, agent: agent, 1008 - title: escapeHtml(o.title || o.summary || agent), 1009 - color: o.color || '#6c757d', index: idx, 1010 - isPlanned: o.occurred === false 1011 - }); 1012 - }); 1013 - 1014 - // Build color legend 1015 - const legendPairs = []; 1016 - const seenAgents = new Set(); 1017 - processedEvents.forEach(ev => { 1018 - if (!seenAgents.has(ev.agent)) { 1019 - seenAgents.add(ev.agent); 1020 - legendPairs.push({ label: ev.agent, color: ev.color }); 1021 - } 1022 - }); 1023 - if (timedActivities.length > 0) { 1024 - legendPairs.push({ label: 'activity', color: '#6c757d' }); 1025 - } 1026 - let legendHtml = ''; 1027 - if (legendPairs.length > 0) { 1028 - legendHtml += `<div class="timeline-legend" role="list" aria-label="timeline color legend">`; 1029 - legendPairs.forEach(p => { 1030 - legendHtml += `<span class="legend-item" role="listitem"><span class="legend-dot" style="background:${p.color}" aria-hidden="true"></span>${escapeHtml(p.label)}</span>`; 1031 - }); 1032 - legendHtml += `</div>`; 1033 - } 1034 - 1035 - const eventsByAgent = {}; 1036 - agents.forEach(agent => { 1037 - eventsByAgent[agent] = processedEvents.filter(e => e.agent === agent); 1038 - eventsByAgent[agent].sort((a, b) => a.startMinutes - b.startMinutes); 1039 - }); 1040 - 1041 - const agentColumnCounts = {}; 1042 - agents.forEach(agent => { 1043 - const agentEvents = eventsByAgent[agent]; 1044 - const agentColumns = []; 1045 - agentEvents.forEach(event => { 1046 - let columnIndex = 0; 1047 - while (columnIndex < agentColumns.length && 1048 - agentColumns[columnIndex].some(existing => 1049 - event.startMinutes < existing.endMinutes && 1050 - event.endMinutes > existing.startMinutes)) { 1051 - columnIndex++; 1052 - } 1053 - if (columnIndex === agentColumns.length) agentColumns.push([]); 1054 - agentColumns[columnIndex].push(event); 1055 - event.slugColumn = columnIndex; 1056 - }); 1057 - agentColumnCounts[agent] = agentColumns.length; 1058 - }); 1059 - 1060 - let globalColumnOffset = 0; 1061 - const agentColumnOffsets = {}; 1062 - agents.forEach(agent => { 1063 - agentColumnOffsets[agent] = globalColumnOffset; 1064 - globalColumnOffset += agentColumnCounts[agent]; 1065 - }); 1066 - 1067 - const totalColumns = globalColumnOffset; 1068 - const maxColumns = 20; 1069 - const displayColumns = Math.min(totalColumns, maxColumns); 1070 - const columnWidth = displayColumns > 0 ? 100 / displayColumns : 100; 1071 - 1072 - processedEvents.forEach(event => { 1073 - event.column = agentColumnOffsets[event.agent] + event.slugColumn; 1074 - }); 1075 - 1076 - processedEvents.forEach(event => { 1077 - const top = ((event.startMinutes - startHour * 60) / totalMinutes) * 100; 1078 - const height = Math.max((event.endMinutes - event.startMinutes) / totalMinutes * 100, 0.4); 1079 - const displayColumn = event.column % displayColumns; 1080 - const left = displayColumn * columnWidth; 1081 - const width = columnWidth * 0.95; 1082 - const plannedClass = event.isPlanned ? ' occ-planned' : ''; 1083 - const bgStyle = event.isPlanned ? `border-color:${event.color}` : `background:${event.color}`; 1084 - const titlePrefix = event.isPlanned ? '▸ ' : ''; 1085 - html += `<a class='occ${plannedClass}' data-index='${event.index}' tabindex='0' role='button' style='top:${top}%;height:${height}%;left:${left}%;width:${width}%;${bgStyle}' title='${titlePrefix}${event.title}'>${titlePrefix}${event.title}</a>`; 1086 - }); 1087 - 1088 706 html += '</section>'; 1089 - if (legendHtml) html = html.replace("<section class='day-view'", legendHtml + "<section class='day-view'"); 1090 707 div.innerHTML = html; 1091 708 } 1092 709 ··· 1099 716 return; 1100 717 } 1101 718 1102 - const hasTimeline = events.some(e => e.startTime); 719 + const hasTimeline = list.some(a => a.startTime); 1103 720 const sectionClass = hasTimeline ? 'activities-section' : 'activities-section no-timeline'; 1104 721 let html = `<section class="${sectionClass}" aria-labelledby="activities-heading"><h2 id="activities-heading">activities</h2><div class="activity-cards">`; 1105 722 list.forEach(a => { ··· 1123 740 div.innerHTML = html; 1124 741 } 1125 742 1126 - // ── Event modal ── 1127 - 1128 - function openModal(idx) { 1129 - const occ = events[idx]; 1130 - if (!occ) return; 1131 - modalTrigger = document.activeElement; 1132 - const body = document.getElementById('modalBody'); 1133 - const isPlanned = occ.occurred === false; 1134 - const agent = occ.agent ? occ.agent.charAt(0).toUpperCase() + occ.agent.slice(1) : ''; 1135 - const titleText = `${agent ? agent + ': ' : ''}${occ.title || ''}`.trim(); 1136 - const subtitle = occ.subject || occ.summary || ''; 1137 - 1138 - let html = ''; 1139 - if (titleText) html += `<h3>${escapeHtml(titleText)}</h3>`; 1140 - if (subtitle) html += `<h4>${escapeHtml(subtitle)}</h4>`; 1141 - if (occ.startTime) { 1142 - const when = fmtTime(occ.startTime); 1143 - const len = fmtLength(occ.startTime, occ.endTime); 1144 - const timeLabel = isPlanned ? 'planned' : 'started'; 1145 - html += `<div class="occ-field"><strong>${timeLabel}</strong> ${when} - ${len}</div>`; 1146 - } 1147 - if (occ.facet) { 1148 - html += `<div class="occ-field"><strong>facet</strong> ${escapeHtml(occ.facet)}</div>`; 1149 - } 1150 - if (Array.isArray(occ.participants) && occ.participants.length > 0) { 1151 - const participantsLabel = isPlanned ? 'expected' : 'participants'; 1152 - html += `<div class="occ-field"><strong>${participantsLabel}</strong> ${escapeHtml(occ.participants.join(', '))}</div>`; 1153 - } 1154 - if (isPlanned) { 1155 - const createdOn = parseCreatedDate(occ.source); 1156 - if (createdOn) { 1157 - html += `<div class="occ-field"><strong>created</strong> ${createdOn}</div>`; 1158 - } 1159 - } 1160 - if (occ.details) { 1161 - const det = typeof occ.details === 'string' ? occ.details : JSON.stringify(occ.details, null, 2); 1162 - html += `<div class="occ-desc">${escapeHtml(det)}</div>`; 1163 - } 1164 - body.innerHTML = html; 1165 - document.getElementById('eventModal').style.display = 'block'; 1166 - const modal = document.getElementById('eventModal'); 1167 - const eventTitle = occ.title || occ.summary || 'Event details'; 1168 - modal.setAttribute('aria-label', eventTitle); 1169 - modal.querySelector('.close').focus(); 1170 - document.addEventListener('keydown', handleModalKeys); 1171 - } 1172 - 1173 - function closeModal() { 1174 - const modal = document.getElementById('eventModal'); 1175 - modal.style.display = 'none'; 1176 - document.removeEventListener('keydown', handleModalKeys); 1177 - if (modalTrigger && document.contains(modalTrigger)) { 1178 - modalTrigger.focus(); 1179 - } 1180 - modalTrigger = null; 1181 - } 1182 - 1183 - function handleModalKeys(e) { 1184 - if (e.key === 'Escape') { 1185 - closeModal(); 1186 - return; 1187 - } 1188 - if (e.key === 'Tab') { 1189 - const modal = document.getElementById('eventModal'); 1190 - const focusable = [...modal.querySelectorAll('button:not([disabled]), [tabindex="0"]')]; 1191 - if (focusable.length === 0) return; 1192 - const currentIndex = focusable.indexOf(document.activeElement); 1193 - if (e.shiftKey) { 1194 - e.preventDefault(); 1195 - focusable[currentIndex <= 0 ? focusable.length - 1 : currentIndex - 1].focus(); 1196 - } else { 1197 - e.preventDefault(); 1198 - focusable[currentIndex >= focusable.length - 1 ? 0 : currentIndex + 1].focus(); 1199 - } 1200 - } 1201 - } 1202 - 1203 743 // ── Activity detail view ── 1204 744 1205 745 function showActivityDetail(activityId) { ··· 1342 882 // ── Data loading ── 1343 883 1344 884 function applyFilters() { 1345 - events = filterByFacet(allEvents); 1346 885 activities = filterByFacet(allActivities); 1347 886 1348 887 renderSummary(); ··· 1356 895 } 1357 896 } 1358 897 1359 - renderEvents(events); 898 + renderTimeline(activities); 1360 899 renderActivities(activities); 1361 900 } 1362 901 1363 902 function loadData() { 1364 - Promise.all([ 1365 - fetch(`/app/activities/api/day/${day}/events`).then(r => r.json()), 1366 - fetch(`/app/activities/api/day/${day}/activities`).then(r => r.json()), 1367 - ]).then(([evts, acts]) => { 1368 - allEvents = evts || []; 903 + fetch(`/app/activities/api/day/${day}/activities`).then(r => r.json()).then(acts => { 1369 904 allActivities = acts || []; 1370 905 applyFilters(); 1371 906 ··· 1373 908 handleHashChange(); 1374 909 1375 910 // Click handlers 1376 - document.getElementById('eventsView').onclick = e => { 1377 - const expandBtn = e.target.closest('.allday-expand'); 1378 - if (expandBtn) { 1379 - const overflow = expandBtn.nextElementSibling; 1380 - const isExpanded = expandBtn.dataset.expanded === 'true'; 1381 - if (isExpanded) { 1382 - overflow.style.display = 'none'; 1383 - expandBtn.dataset.expanded = 'false'; 1384 - expandBtn.setAttribute('aria-expanded', 'false'); 1385 - expandBtn.textContent = '+' + overflow.children.length + ' more'; 1386 - } else { 1387 - overflow.style.display = 'flex'; 1388 - expandBtn.dataset.expanded = 'true'; 1389 - expandBtn.setAttribute('aria-expanded', 'true'); 1390 - expandBtn.textContent = 'show fewer'; 1391 - } 1392 - return; 1393 - } 911 + document.getElementById('timelineView').onclick = e => { 1394 912 const actEl = e.target.closest('.occ-activity'); 1395 913 if (actEl) { 1396 914 e.preventDefault(); 1397 915 location.hash = 'activity/' + actEl.dataset.activityId; 1398 - return; 1399 - } 1400 - const evtEl = e.target.closest('.occ') || e.target.closest('.all-day-card'); 1401 - if (evtEl && evtEl.dataset.index !== undefined) { 1402 - e.preventDefault(); 1403 - openModal(parseInt(evtEl.dataset.index)); 1404 916 } 1405 917 }; 1406 918 ··· 1412 924 }; 1413 925 1414 926 // Keyboard activation 1415 - document.getElementById('eventsView').onkeydown = e => { 927 + document.getElementById('timelineView').onkeydown = e => { 1416 928 if (e.key === 'Enter' || e.key === ' ') { 1417 - const el = e.target.closest('.occ-activity, .occ, .all-day-card'); 929 + const el = e.target.closest('.occ-activity'); 1418 930 if (el) { 1419 931 e.preventDefault(); 1420 932 el.click(); ··· 1431 943 } 1432 944 }; 1433 945 }).catch(() => { 1434 - document.getElementById('eventsView').innerHTML = '<div class="events-empty">Failed to load data.</div>'; 946 + document.getElementById('timelineView').innerHTML = '<div class="timeline-empty">Failed to load data.</div>'; 1435 947 }); 1436 948 } 1437 949 ··· 1456 968 window.addEventListener('facet.switch', () => { 1457 969 applyFilters(); 1458 970 }); 1459 - 1460 - document.querySelector('#eventModal .close').onclick = () => { 1461 - closeModal(); 1462 - }; 1463 - window.onclick = e => { 1464 - const modal = document.getElementById('eventModal'); 1465 - if (e.target === modal) closeModal(); 1466 - }; 1467 971 1468 972 loadData(); 1469 973 })();
+25 -58
apps/activities/routes.py
··· 3 3 4 4 from __future__ import annotations 5 5 6 + import calendar 6 7 import os 7 - import re 8 8 from datetime import date, datetime 9 9 from typing import Any 10 10 ··· 44 44 ) 45 45 46 46 47 - @activities_bp.route("/api/day/<day>/events") 48 - def activities_day_events(day: str) -> Any: 49 - """Return timeline events for a specific day from facet event logs.""" 50 - if not DATE_RE.fullmatch(day): 51 - return "", 404 47 + def _month_activity_counts(month: str) -> dict[str, dict[str, int]]: 48 + from think.activities import load_activity_records 49 + from think.facets import get_facets 52 50 53 - from think.indexer.journal import get_events 54 - from think.talent import get_talent_configs 51 + year = int(month[:4]) 52 + month_num = int(month[4:6]) 53 + _, days_in_month = calendar.monthrange(year, month_num) 54 + facet_names = list(get_facets().keys()) 55 + stats: dict[str, dict[str, int]] = {} 55 56 56 - generators = get_talent_configs(type="generate") 57 + for day_num in range(1, days_in_month + 1): 58 + day = f"{month}{day_num:02d}" 59 + day_counts: dict[str, int] = {} 60 + for facet in facet_names: 61 + count = len(load_activity_records(facet, day)) 62 + if count: 63 + day_counts[facet] = count 64 + if day_counts: 65 + stats[day] = day_counts 57 66 58 - # Get full event objects from source files 59 - raw_events = get_events(day) 60 - 61 - # Transform events into timeline format 62 - result = [] 63 - for event in raw_events: 64 - agent = event.get("agent", "other") 65 - agent_color = generators.get(agent, {}).get("color", "#6c757d") 66 - 67 - formatted = { 68 - "title": event.get("title", ""), 69 - "summary": event.get("summary", ""), 70 - "subject": event.get("subject", ""), 71 - "details": event.get("details", event.get("description", "")), 72 - "participants": event.get("participants", []), 73 - "agent": agent, 74 - "color": agent_color, 75 - "facet": event.get("facet", ""), 76 - "occurred": event.get("occurred", True), 77 - "source": event.get("source", ""), 78 - } 79 - 80 - # Convert time strings to ISO timestamps 81 - if event.get("start"): 82 - formatted["startTime"] = f"{day[:4]}-{day[4:6]}-{day[6:]}T{event['start']}" 83 - if event.get("end"): 84 - formatted["endTime"] = f"{day[:4]}-{day[4:6]}-{day[6:]}T{event['end']}" 85 - 86 - result.append(formatted) 87 - 88 - return jsonify(result) 67 + return stats 89 68 90 69 91 70 @activities_bp.route("/api/stats/<month>") 92 71 def activities_stats(month: str) -> Any: 93 - """Return event counts per facet for a specific month. 72 + """Return activity counts per facet for a specific month.""" 73 + if len(month) != 6 or not month.isdigit(): 74 + return jsonify({"error": "Invalid month format, expected YYYYMM"}), 400 94 75 95 - Scans event files directly (including future dates) rather than relying 96 - on cached stats.json files which only exist for past days. 97 - 98 - Args: 99 - month: YYYYMM format month string 100 - 101 - Returns: 102 - JSON dict mapping day (YYYYMMDD) to facet counts dict. 103 - Frontend handles filtering by selected facet or summing for all-facet mode. 104 - """ 105 - from think.event_formatter import get_month_event_counts 106 - 107 - # Validate month format (YYYYMM) 108 - if not re.fullmatch(r"\d{6}", month): 76 + try: 77 + return jsonify(_month_activity_counts(month)) 78 + except ValueError: 109 79 return jsonify({"error": "Invalid month format, expected YYYYMM"}), 400 110 - 111 - stats = get_month_event_counts(month) 112 - return jsonify(stats) 113 80 114 81 115 82 @activities_bp.route("/api/day/<day>/activities")
+12 -19
apps/home/routes.py
··· 301 301 return todos 302 302 303 303 304 - def _collect_events(today: str) -> list[dict[str, Any]]: 305 - """Collect calendar events across all facets.""" 304 + def _collect_anticipated_activities(today: str) -> list[dict[str, Any]]: 305 + """Collect anticipated activities across all facets.""" 306 306 from think.activities import load_activity_records 307 - from think.indexer.journal import get_events 308 307 309 308 try: 310 - events = get_events(today) 311 - for event in events: 312 - if event.get("start") is None: 313 - event["start"] = "" 314 - if event.get("end") is None: 315 - event["end"] = "" 316 - 309 + anticipated_activities = [] 317 310 for facet_name in get_facets(): 318 311 for record in load_activity_records(facet_name, today): 319 312 if record.get("source") != "anticipated": ··· 327 320 if name: 328 321 participants.append(name) 329 322 330 - events.append( 323 + anticipated_activities.append( 331 324 { 332 325 "title": record.get("title", ""), 333 326 "start": record.get("start") or "", ··· 337 330 "participants": participants, 338 331 } 339 332 ) 340 - return events 333 + return anticipated_activities 341 334 except Exception: 342 - logger.warning("home: failed to collect events", exc_info=True) 335 + logger.warning("home: failed to collect anticipated activities", exc_info=True) 343 336 return [] 344 337 345 338 ··· 1257 1250 narrative_updated_at = flow_updated_at 1258 1251 pulse_needs = [] 1259 1252 1260 - events = _collect_events(today) 1253 + anticipated_activities = _collect_anticipated_activities(today) 1261 1254 activities = _collect_activities(today) 1262 1255 todos = _collect_todos(today) 1263 1256 entities = _collect_entities_today(today) ··· 1289 1282 unseen_skills = [s for s in skills if not s["seen"]] 1290 1283 show_welcome = ( 1291 1284 narrative_content is None 1292 - and not events 1285 + and not anticipated_activities 1293 1286 and not activities 1294 1287 and not todos 1295 1288 and not entities ··· 1324 1317 skills_content = {s["id"]: s["content"] for s in skills} 1325 1318 1326 1319 today_summary_parts = [] 1327 - if events: 1328 - n = len(events) 1329 - today_summary_parts.append(f"{n} event{'s' if n != 1 else ''}") 1320 + if anticipated_activities: 1321 + n = len(anticipated_activities) 1322 + today_summary_parts.append(f"{n} anticipated activit{'ies' if n != 1 else 'y'}") 1330 1323 if activities: 1331 1324 n = len(activities) 1332 1325 today_summary_parts.append(f"{n} {'activities' if n != 1 else 'activity'}") ··· 1403 1396 "pulse_needs": pulse_needs, 1404 1397 "flow_content": flow_content, 1405 1398 "flow_updated_at": flow_updated_at, 1406 - "events": events, 1399 + "anticipated_activities": anticipated_activities, 1407 1400 "activities": activities, 1408 1401 "todos": todos, 1409 1402 "entities": entities,
+5 -5
apps/home/workspace.html
··· 1188 1188 {% endif %} 1189 1189 1190 1190 <!-- Today --> 1191 - {% if events or activities %} 1191 + {% if anticipated_activities or activities %} 1192 1192 <div class="pulse-today" id="pulse-today" data-section-collapsed="true"> 1193 1193 <div class="pulse-section-toggle" role="button" tabindex="0" aria-expanded="false" onclick="toggleSection(this.parentElement)"> 1194 1194 <h2 class="pulse-section-header">today</h2> 1195 1195 <span class="pulse-section-summary">{{ today_summary }}</span> 1196 1196 </div> 1197 1197 <div class="pulse-section-body"> 1198 - {% if events %} 1198 + {% if anticipated_activities %} 1199 1199 <div class="pulse-events"> 1200 - {% for event in events %} 1200 + {% for event in anticipated_activities %} 1201 1201 {% set is_past = event.get('occurred', false) or (event.get('end', '') and event['end'] < now.strftime('%H:%M:%S')) %} 1202 1202 <div class="pulse-event{% if is_past %} past{% endif %}" role="button" tabindex="0" data-conversation="Tell me about {{ event.get('title', 'Untitled') }} at {{ event.get('start', '')[:5] }}"> 1203 1203 <span class="pulse-event-time">{{ event.get('start', '')[:5] }}</span> ··· 1227 1227 </div> 1228 1228 </div> 1229 1229 {% endif %} 1230 - {% if not events and not activities and not show_welcome %} 1230 + {% if not anticipated_activities and not activities and not show_welcome %} 1231 1231 <div class="pulse-empty-state"> 1232 1232 <h2 class="pulse-section-header">today</h2> 1233 - <div class="pulse-empty-message">no events or activity yet today.</div> 1233 + <div class="pulse-empty-message">no anticipated activities or recent activity yet today.</div> 1234 1234 </div> 1235 1235 {% endif %} 1236 1236
+1 -1
convey/static/month-picker.js
··· 188 188 const rawIntensity = maxCount > 0 ? count / maxCount : 0; 189 189 const intensity = count > 0 ? 0.2 + (rawIntensity * 0.8) : 0; 190 190 const dateLabel = formatDayLabel(dateStr); 191 - const countText = count === 1 ? '1 event' : count > 0 ? `${count} events` : 'no events'; 191 + const countText = count === 1 ? '1 item' : count > 0 ? `${count} items` : 'no items'; 192 192 193 193 let attrs = `class="${classes.join(' ')}" role="gridcell" data-day="${dateStr}" style="--intensity: ${intensity}" aria-label="${dateLabel}, ${countText}"`; 194 194 if (dateStr === TODAY) attrs += ' aria-current="date"';
+2 -19
tests/baselines/api/activities/stats-month.json
··· 1 1 { 2 - "20260304": { 3 - "capulet": 2, 4 - "montague": 2 5 - }, 6 2 "20260305": { 7 3 "montague": 1, 8 4 "verona": 1 9 5 }, 10 6 "20260306": { 11 - "capulet": 1, 12 7 "montague": 1, 13 8 "verona": 1 14 9 }, 15 - "20260307": { 16 - "capulet": 1, 17 - "montague": 2 18 - }, 19 - "20260308": { 20 - "verona": 1 21 - }, 22 10 "20260309": { 23 - "montague": 1, 24 - "verona": 1 25 - }, 26 - "20260310": { 27 - "capulet": 1, 28 - "montague": 1, 29 - "verona": 1 11 + "montague": 2, 12 + "verona": 2 30 13 } 31 14 }
+50
tests/test_app_activities.py
··· 3 3 4 4 """Tests for activities app routes — activities API and output serving.""" 5 5 6 + import json 6 7 import os 7 8 8 9 import pytest ··· 98 99 "/app/activities/api/activity_output/20260214/talents/flow.md" 99 100 ) 100 101 assert resp.status_code == 400 102 + 103 + 104 + class TestActivitiesStatsRoutes: 105 + def test_returns_month_activity_counts(self, tmp_path, monkeypatch): 106 + from flask import Flask 107 + 108 + from convey import state 109 + 110 + journal = tmp_path / "journal" 111 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(journal)) 112 + 113 + for facet in ("work", "personal"): 114 + facet_dir = journal / "facets" / facet 115 + facet_dir.mkdir(parents=True) 116 + (facet_dir / "facet.json").write_text( 117 + json.dumps({"title": facet.title()}), encoding="utf-8" 118 + ) 119 + (facet_dir / "activities").mkdir() 120 + 121 + (journal / "facets" / "work" / "activities" / "20260418.jsonl").write_text( 122 + json.dumps({"id": "coding_1", "activity": "coding", "segments": []}) 123 + + "\n" 124 + + json.dumps({"id": "coding_2", "activity": "coding", "segments": []}) 125 + + "\n", 126 + encoding="utf-8", 127 + ) 128 + (journal / "facets" / "personal" / "activities" / "20260418.jsonl").write_text( 129 + json.dumps({"id": "walk_1", "activity": "walking", "segments": []}) + "\n", 130 + encoding="utf-8", 131 + ) 132 + (journal / "facets" / "work" / "activities" / "20260419.jsonl").write_text( 133 + json.dumps({"id": "coding_3", "activity": "coding", "segments": []}) + "\n", 134 + encoding="utf-8", 135 + ) 136 + 137 + app = Flask(__name__) 138 + app.register_blueprint(activities_bp) 139 + monkeypatch.setattr(state, "journal_root", str(journal), raising=False) 140 + client = app.test_client() 141 + 142 + resp = client.get("/app/activities/api/stats/202604") 143 + 144 + assert resp.status_code == 200 145 + data = resp.get_json() 146 + assert data == { 147 + "20260418": {"personal": 1, "work": 2}, 148 + "20260419": {"work": 1}, 149 + } 150 + assert "20260420" not in data
-132
tests/test_events.py
··· 1 - # SPDX-License-Identifier: AGPL-3.0-only 2 - # Copyright (c) 2026 sol pbc 3 - 4 - import json 5 - 6 - 7 - def test_get_month_event_counts(tmp_path, monkeypatch): 8 - """Test get_month_event_counts scans event files correctly.""" 9 - from think.event_formatter import get_month_event_counts 10 - 11 - journal = tmp_path 12 - 13 - # Create events for two facets in January 2024 14 - work_events = journal / "facets" / "work" / "events" 15 - work_events.mkdir(parents=True) 16 - personal_events = journal / "facets" / "personal" / "events" 17 - personal_events.mkdir(parents=True) 18 - 19 - # Work facet: 2 events on Jan 1, 1 event on Jan 5 20 - (work_events / "20240101.jsonl").write_text( 21 - json.dumps({"title": "Meeting 1", "start": "09:00:00"}) 22 - + "\n" 23 - + json.dumps({"title": "Meeting 2", "start": "14:00:00"}) 24 - + "\n" 25 - ) 26 - (work_events / "20240105.jsonl").write_text( 27 - json.dumps({"title": "Code review", "start": "10:00:00"}) + "\n" 28 - ) 29 - 30 - # Personal facet: 1 event on Jan 1 31 - (personal_events / "20240101.jsonl").write_text( 32 - json.dumps({"title": "Gym session", "start": "18:00:00"}) + "\n" 33 - ) 34 - 35 - monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(journal)) 36 - 37 - result = get_month_event_counts("202401") 38 - 39 - assert "20240101" in result 40 - assert result["20240101"]["work"] == 2 41 - assert result["20240101"]["personal"] == 1 42 - assert "20240105" in result 43 - assert result["20240105"]["work"] == 1 44 - assert "personal" not in result["20240105"] 45 - 46 - 47 - def test_get_month_event_counts_future_dates(tmp_path, monkeypatch): 48 - """Test that future dates without day directories are included.""" 49 - from think.event_formatter import get_month_event_counts 50 - 51 - journal = tmp_path 52 - 53 - # Create events for a future month (no day directories exist) 54 - work_events = journal / "facets" / "work" / "events" 55 - work_events.mkdir(parents=True) 56 - 57 - # Future events (anticipated) 58 - (work_events / "20251215.jsonl").write_text( 59 - json.dumps({"title": "Holiday party", "start": "18:00:00", "occurred": False}) 60 - + "\n" 61 - ) 62 - (work_events / "20251220.jsonl").write_text( 63 - json.dumps({"title": "Year review", "start": "10:00:00", "occurred": False}) 64 - + "\n" 65 - + json.dumps({"title": "Team lunch", "start": "12:00:00", "occurred": False}) 66 - + "\n" 67 - ) 68 - 69 - monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(journal)) 70 - 71 - result = get_month_event_counts("202512") 72 - 73 - assert "20251215" in result 74 - assert result["20251215"]["work"] == 1 75 - assert "20251220" in result 76 - assert result["20251220"]["work"] == 2 77 - 78 - 79 - def test_get_month_event_counts_skips_entries_without_title(tmp_path, monkeypatch): 80 - """Test that entries without title are not counted.""" 81 - from think.event_formatter import get_month_event_counts 82 - 83 - journal = tmp_path 84 - 85 - work_events = journal / "facets" / "work" / "events" 86 - work_events.mkdir(parents=True) 87 - 88 - # Mix of valid and invalid entries 89 - (work_events / "20240101.jsonl").write_text( 90 - json.dumps({"title": "Valid event", "start": "09:00:00"}) 91 - + "\n" 92 - + json.dumps({"start": "10:00:00"}) # No title 93 - + "\n" 94 - + json.dumps({"title": "", "start": "11:00:00"}) # Empty title 95 - + "\n" 96 - + json.dumps({"title": "Another valid", "start": "14:00:00"}) 97 - + "\n" 98 - ) 99 - 100 - monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(journal)) 101 - 102 - result = get_month_event_counts("202401") 103 - 104 - assert result["20240101"]["work"] == 2 # Only 2 valid events 105 - 106 - 107 - def test_get_month_event_counts_empty_month(tmp_path, monkeypatch): 108 - """Test that empty month returns empty dict.""" 109 - from think.event_formatter import get_month_event_counts 110 - 111 - journal = tmp_path 112 - 113 - # Create facets directory but no events for requested month 114 - work_events = journal / "facets" / "work" / "events" 115 - work_events.mkdir(parents=True) 116 - 117 - monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(journal)) 118 - 119 - result = get_month_event_counts("202402") 120 - 121 - assert result == {} 122 - 123 - 124 - def test_get_month_event_counts_empty_journal(tmp_path, monkeypatch): 125 - """Test that empty journal directory returns empty dict.""" 126 - from think.event_formatter import get_month_event_counts 127 - 128 - monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 129 - 130 - result = get_month_event_counts("202401") 131 - 132 - assert result == {}
+3 -1
tests/test_home_routines.py
··· 198 198 monkeypatch.setattr("apps.home.routes._load_stats", lambda today: {}) 199 199 monkeypatch.setattr("apps.home.routes._load_flow_md", lambda today: (None, None)) 200 200 monkeypatch.setattr("apps.home.routes._load_pulse_md", lambda: (None, None, [])) 201 - monkeypatch.setattr("apps.home.routes._collect_events", lambda today: []) 201 + monkeypatch.setattr( 202 + "apps.home.routes._collect_anticipated_activities", lambda today: [] 203 + ) 202 204 monkeypatch.setattr("apps.home.routes._collect_activities", lambda today: []) 203 205 monkeypatch.setattr("apps.home.routes._collect_todos", lambda today: []) 204 206 monkeypatch.setattr("apps.home.routes._collect_entities_today", lambda today: [])
+3 -1
tests/test_home_skills.py
··· 211 211 monkeypatch.setattr("apps.home.routes._load_stats", lambda today: {}) 212 212 monkeypatch.setattr("apps.home.routes._load_flow_md", lambda today: (None, None)) 213 213 monkeypatch.setattr("apps.home.routes._load_pulse_md", lambda: (None, None, [])) 214 - monkeypatch.setattr("apps.home.routes._collect_events", lambda today: []) 214 + monkeypatch.setattr( 215 + "apps.home.routes._collect_anticipated_activities", lambda today: [] 216 + ) 215 217 monkeypatch.setattr("apps.home.routes._collect_activities", lambda today: []) 216 218 monkeypatch.setattr("apps.home.routes._collect_todos", lambda today: []) 217 219 monkeypatch.setattr("apps.home.routes._collect_entities_today", lambda today: [])
+9 -20
tests/test_home_yesterdays_processing.py
··· 17 17 _briefing_freshness, 18 18 _build_pulse_context, 19 19 _collect_activities, 20 - _collect_events, 20 + _collect_anticipated_activities, 21 21 _format_activity_label, 22 22 _format_duration, 23 23 _format_entity_summary, ··· 260 260 assert _summarize_yesterday_processing("20260416", 9) is None 261 261 262 262 263 - def test_collectors_merge_anticipated_events_without_double_counting( 263 + def test_collect_anticipated_activities_surfaces_only_anticipated_records( 264 264 tmp_path, 265 265 monkeypatch, 266 266 ): ··· 273 273 274 274 _write_facet_meta(journal, "work", "Work") 275 275 _write_jsonl( 276 - journal / "facets" / "work" / "events" / f"{today}.jsonl", 277 - [ 278 - { 279 - "type": "meeting", 280 - "title": "Team standup", 281 - "start": "09:00:00", 282 - "end": "09:30:00", 283 - "participants": ["Alice"], 284 - "occurred": True, 285 - } 286 - ], 287 - ) 288 - _write_jsonl( 289 276 journal / "facets" / "work" / "activities" / f"{today}.jsonl", 290 277 [ 291 278 { ··· 326 313 ], 327 314 ) 328 315 329 - events = _collect_events(today) 316 + anticipated_activities = _collect_anticipated_activities(today) 330 317 activities = _collect_activities(today) 331 318 332 - assert [event["title"] for event in events] == ["Team standup", "Mari intro"] 333 - assert events[1]["occurred"] is False 334 - assert events[1]["participants"] == ["Mari Zumbro"] 319 + assert [item["title"] for item in anticipated_activities] == ["Mari intro"] 320 + assert anticipated_activities[0]["occurred"] is False 321 + assert anticipated_activities[0]["participants"] == ["Mari Zumbro"] 335 322 assert [activity["id"] for activity in activities] == ["coding_090000_300"] 336 323 337 324 ··· 576 563 monkeypatch.setattr( 577 564 "apps.home.routes._load_briefing_md", lambda today: ({}, None, []) 578 565 ) 579 - monkeypatch.setattr("apps.home.routes._collect_events", lambda today: []) 566 + monkeypatch.setattr( 567 + "apps.home.routes._collect_anticipated_activities", lambda today: [] 568 + ) 580 569 monkeypatch.setattr("apps.home.routes._collect_activities", lambda today: []) 581 570 monkeypatch.setattr("apps.home.routes._collect_todos", lambda today: []) 582 571 monkeypatch.setattr("apps.home.routes._collect_entities_today", lambda today: [])
-24
tests/test_journal_index.py
··· 535 535 conn.close() 536 536 537 537 538 - def test_get_events(journal_fixture): 539 - """Test get_events returns structured event data.""" 540 - from think.indexer.journal import get_events 541 - 542 - events = get_events("20240101") 543 - assert len(events) == 1 544 - assert events[0]["title"] == "Standup" 545 - assert events[0]["start"] == "09:00:00" 546 - assert events[0]["facet"] == "work" 547 - 548 - 549 - def test_get_events_filter_by_facet(journal_fixture): 550 - """Test get_events with facet filter.""" 551 - from think.indexer.journal import get_events 552 - 553 - # Should find work facet events 554 - events = get_events("20240101", facet="work") 555 - assert len(events) == 1 556 - 557 - # Should not find nonexistent facet 558 - events = get_events("20240101", facet="personal") 559 - assert len(events) == 0 560 - 561 - 562 538 def test_reset_journal_index(journal_fixture): 563 539 """Test resetting the journal index.""" 564 540 from think.indexer.journal import reset_journal_index, scan_journal
-60
tests/test_journal_stats.py
··· 223 223 assert js3.days["20240101"]["transcript_sessions"] == 1 224 224 225 225 226 - def test_facet_event_mtime_invalidates_cache(tmp_path, monkeypatch): 227 - """Modifying a facet event file invalidates that day's cache.""" 228 - stats_mod = importlib.import_module("think.journal_stats") 229 - journal = tmp_path 230 - day = journal / "chronicle" / "20240101" 231 - day.mkdir(parents=True) 232 - 233 - # Create minimal day content 234 - ts_dir = day / "default" / "123456_300" 235 - ts_dir.mkdir(parents=True) 236 - (ts_dir / "audio.jsonl").write_text( 237 - '{"raw": "raw.flac"}\n{"start": "10:00:00", "text": "hello"}\n' 238 - ) 239 - 240 - # Create facet event file 241 - events_dir = journal / "facets" / "work" / "events" 242 - events_dir.mkdir(parents=True) 243 - event = { 244 - "type": "meeting", 245 - "start": "00:00:00", 246 - "end": "00:05:00", 247 - "title": "t", 248 - "summary": "s", 249 - "work": True, 250 - "participants": [], 251 - "details": "", 252 - "facet": "work", 253 - "agent": "meetings", 254 - "occurred": True, 255 - "source": "20240101/talents/meetings.md", 256 - } 257 - (events_dir / "20240101.jsonl").write_text(json.dumps(event)) 258 - 259 - monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(journal)) 260 - 261 - # First scan - creates cache 262 - js1 = stats_mod.JournalStats() 263 - js1.scan(str(journal), use_cache=True) 264 - assert js1.agent_counts["meetings"] == 1 265 - assert (day / "stats.json").exists() 266 - 267 - # Record cache mtime 268 - import time 269 - 270 - cache_mtime = (day / "stats.json").stat().st_mtime 271 - time.sleep(0.05) 272 - 273 - # Modify the facet event file (add a second event) 274 - event2 = dict(event, start="01:00:00", end="01:10:00", agent="summarize") 275 - with open(events_dir / "20240101.jsonl", "a") as f: 276 - f.write("\n" + json.dumps(event2)) 277 - 278 - # Second scan - cache should be invalidated because facet event mtime > cache mtime 279 - js2 = stats_mod.JournalStats() 280 - js2.scan(str(journal), use_cache=True) 281 - assert (day / "stats.json").stat().st_mtime > cache_mtime 282 - assert js2.agent_counts["meetings"] == 1 283 - assert js2.agent_counts["summarize"] == 1 284 - 285 - 286 226 def test_token_usage_new_format(tmp_path, monkeypatch): 287 227 """Test that the new unified token format is properly handled.""" 288 228 stats_mod = importlib.import_module("think.journal_stats")
-7
tests/verify_api.py
··· 90 90 # apps/activities/routes.py 91 91 { 92 92 "app": "activities", 93 - "name": "day-events", 94 - "path": "/app/activities/api/day/20260304/events", 95 - "params": {}, 96 - "status": 200, 97 - }, 98 - { 99 - "app": "activities", 100 93 "name": "stats-month", 101 94 "path": "/app/activities/api/stats/202603", 102 95 "params": {},
+1 -67
think/event_formatter.py
··· 1 1 # SPDX-License-Identifier: AGPL-3.0-only 2 2 # Copyright (c) 2026 sol pbc 3 3 4 - """Event formatting and utilities for the journal. 5 - 6 - This module provides: 7 - - Formatter function for converting event JSONL entries to markdown chunks 8 - - Utilities for scanning event files and counting by facet 9 - """ 4 + """Event formatting for journal event JSONL files.""" 10 5 11 - import json 12 6 import logging 13 7 import re 14 8 from datetime import datetime 15 9 from pathlib import Path 16 10 from typing import Any 17 - 18 - from think.utils import get_journal 19 11 20 12 21 13 def format_events( ··· 172 164 meta["indexer"] = {"agent": "event"} 173 165 174 166 return chunks, meta 175 - 176 - 177 - def get_month_event_counts(month: str) -> dict[str, dict[str, int]]: 178 - """Get event counts per day per facet for a month by scanning event files. 179 - 180 - Scans facets/*/events/*.jsonl, including future dates that don't yet 181 - have day directories. 182 - 183 - Args: 184 - month: YYYYMM format month string 185 - 186 - Returns: 187 - Dict mapping day (YYYYMMDD) to facet counts dict. 188 - Example: {"20250115": {"work": 3, "personal": 1}, ...} 189 - """ 190 - facets_dir = Path(get_journal()) / "facets" 191 - if not facets_dir.is_dir(): 192 - return {} 193 - 194 - stats: dict[str, dict[str, int]] = {} 195 - 196 - for facet_path in facets_dir.iterdir(): 197 - if not facet_path.is_dir(): 198 - continue 199 - 200 - facet_name = facet_path.name 201 - events_dir = facet_path / "events" 202 - 203 - if events_dir.is_dir(): 204 - # Scan all JSONL files matching the requested month 205 - for events_file in events_dir.glob(f"{month}*.jsonl"): 206 - day = events_file.stem 207 - if not re.fullmatch(r"\d{8}", day): 208 - continue 209 - 210 - try: 211 - count = 0 212 - with open(events_file, "r", encoding="utf-8") as f: 213 - for line in f: 214 - line = line.strip() 215 - if not line: 216 - continue 217 - try: 218 - event = json.loads(line) 219 - if event.get("title"): 220 - count += 1 221 - except json.JSONDecodeError: 222 - continue 223 - 224 - if count > 0: 225 - if day not in stats: 226 - stats[day] = {} 227 - stats[day][facet_name] = count 228 - 229 - except (OSError, IOError): 230 - continue 231 - 232 - return stats
-43
think/indexer/journal.py
··· 33 33 find_formattable_files, 34 34 format_file, 35 35 get_formatter, 36 - load_jsonl, 37 36 ) 38 37 from think.markdown import format_markdown 39 38 from think.utils import ( ··· 2002 2001 "days": Counter(r[2] for r in rows if r[2]), 2003 2002 "streams": Counter(r[3] for r in rows if r[3]), 2004 2003 } 2005 - 2006 - 2007 - def get_events( 2008 - day: str, 2009 - facet: str | None = None, 2010 - ) -> list[dict[str, Any]]: 2011 - """Get structured events for a day, re-hydrated from source files. 2012 - 2013 - This function reads source JSONL files directly from 2014 - facets/*/events/{day}.jsonl to return full event objects with all fields 2015 - (title, summary, start, end, participants, etc.). 2016 - 2017 - Args: 2018 - day: Day in YYYYMMDD format 2019 - facet: Optional facet name to filter by 2020 - 2021 - Returns: 2022 - List of event dicts with full structured data 2023 - """ 2024 - events = [] 2025 - facets_dir = Path(get_journal()) / "facets" 2026 - 2027 - if not facets_dir.is_dir(): 2028 - return events 2029 - 2030 - for facet_dir in facets_dir.iterdir(): 2031 - if not facet_dir.is_dir(): 2032 - continue 2033 - 2034 - facet_name = facet_dir.name 2035 - if facet and facet_name.lower() != facet.lower(): 2036 - continue 2037 - 2038 - events_file = facet_dir / "events" / f"{day}.jsonl" 2039 - if events_file.is_file(): 2040 - entries = load_jsonl(str(events_file)) 2041 - for entry in entries: 2042 - # Add facet to event if not present 2043 - entry.setdefault("facet", facet_name) 2044 - events.append(entry) 2045 - 2046 - return events 2047 2004 2048 2005 2049 2006 def _load_index_entity_dicts(
-10
think/journal_stats.py
··· 61 61 files.extend(talents_dir.glob("*/*.json")) 62 62 files.extend(talents_dir.glob("*/*.md")) 63 63 64 - # Check facet event files for this day 65 - journal_root = Path(get_journal()) 66 - day = day_dir.name 67 - facets_dir = journal_root / "facets" 68 - if facets_dir.is_dir(): 69 - for facet_name in os.listdir(facets_dir): 70 - event_file = facets_dir / facet_name / "events" / f"{day}.jsonl" 71 - if event_file.is_file(): 72 - files.append(event_file) 73 - 74 64 if not files: 75 65 return 0.0 76 66 return max(f.stat().st_mtime for f in files)