personal memory agent
1<style>
2/* Layout */
3.search-container {
4 display: flex;
5 gap: 1.5rem;
6 min-height: calc(100vh - 200px);
7}
8
9/* Sidebar */
10.search-sidebar {
11 width: 200px;
12 flex-shrink: 0;
13 border-right: 1px solid #e5e7eb;
14 padding-left: 0.5rem;
15 padding-right: 0.75rem;
16 padding-top: 0.45rem;
17}
18
19.search-sidebar h3 {
20 font-size: 11px;
21 font-weight: 600;
22 color: #9ca3af;
23 text-transform: uppercase;
24 letter-spacing: 0.05em;
25 margin: 0 0 0.5rem 0;
26}
27
28.filter-section {
29 margin-bottom: 1.25rem;
30}
31
32.filter-section:not(:last-child) {
33 border-bottom: 1px solid #e5e7eb;
34 padding-bottom: 1.25rem;
35}
36
37.filter-list {
38 display: flex;
39 flex-direction: column;
40 gap: 0.25rem;
41}
42
43.filter-item {
44 display: flex;
45 align-items: center;
46 gap: 0.5rem;
47 padding: 0.35rem 0.5rem;
48 border-radius: 4px;
49 cursor: pointer;
50 font-size: 0.85rem;
51 transition: background 0.15s;
52}
53
54.filter-item:hover {
55 background: #f3f4f6;
56}
57
58.filter-item.active {
59 background: #e5e7eb;
60 box-shadow: inset 3px 0 0 var(--facet-color, #b06a1a);
61 font-weight: 500;
62}
63
64.filter-item:active {
65 background: #e5e7eb;
66}
67
68.filter-item input[type="radio"] {
69 margin: 0;
70 accent-color: var(--facet-color, #2563eb);
71}
72
73.filter-item input[type="radio"]:focus-visible {
74 outline: 2px solid var(--facet-color, #b06a1a);
75 outline-offset: 2px;
76}
77
78.filter-label {
79 flex: 1;
80 min-width: 0;
81 overflow: hidden;
82 text-overflow: ellipsis;
83 white-space: nowrap;
84}
85
86.filter-count {
87 font-size: 0.75rem;
88 color: #9ca3af;
89 flex-shrink: 0;
90}
91
92.filter-icon {
93 font-size: 0.85rem;
94}
95
96/* Main content area */
97.search-main {
98 flex: 1;
99 min-width: 0;
100 display: flex;
101 flex-direction: column;
102}
103
104#results-container {
105 flex: 1;
106 display: flex;
107 flex-direction: column;
108}
109
110/* Results summary */
111.search-summary {
112 font-size: 0.85rem;
113 color: #6b7280;
114 margin-bottom: 1rem;
115 font-variant-numeric: tabular-nums;
116}
117
118/* Day cards */
119.day-card {
120 background: white;
121 border-radius: 8px;
122 margin-bottom: 0.75rem;
123 box-shadow: 0 1px 4px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04);
124 overflow: hidden;
125}
126
127.day-header {
128 display: flex;
129 align-items: center;
130 justify-content: space-between;
131 padding: 0.85rem 1rem;
132 background: #f9fafb;
133 border-bottom: 1px solid #e5e7eb;
134 cursor: pointer;
135 transition: background 0.1s ease;
136}
137
138.day-header:hover {
139 background: #f3f4f6;
140}
141
142.day-header:active {
143 background: #e5e7eb;
144}
145
146button.day-header {
147 border: none;
148 font: inherit;
149 text-align: left;
150 width: 100%;
151}
152
153.day-date {
154 font-size: 0.9rem;
155 font-weight: 600;
156 color: #111827;
157}
158
159.day-count {
160 font-size: 0.8rem;
161 color: #6b7280;
162}
163
164.day-results {
165 padding: 0;
166}
167
168/* Result items */
169.result-item {
170 padding: 0.75rem 1rem;
171 border-bottom: 1px solid #e5e7eb;
172 display: block;
173 text-decoration: none;
174 color: inherit;
175 transition: background 0.1s ease;
176}
177
178.result-item:last-child {
179 border-bottom: none;
180}
181
182.result-item:hover {
183 background: #f9fafb;
184}
185
186.result-item:active {
187 background: #f3f4f6;
188}
189
190.result-meta {
191 display: flex;
192 align-items: center;
193 gap: 0.5rem;
194 margin-bottom: 0.35rem;
195 flex-wrap: wrap;
196}
197
198.result-agent {
199 display: flex;
200 align-items: center;
201 gap: 0.25rem;
202 font-size: 0.8rem;
203 font-weight: 400;
204 color: #374151;
205}
206
207.result-agent-icon {
208 font-size: 0.9rem;
209}
210
211.result-facet {
212 font-size: 0.7rem;
213 padding: 0.15rem 0.4rem;
214 border-radius: 3px;
215 background: #f3f4f6;
216 color: #6b7280;
217 letter-spacing: 0.02em;
218}
219
220.result-facet.has-color {
221 color: white;
222}
223
224.result-text {
225 font-size: 0.9rem;
226 color: #4b5563;
227 line-height: 1.5;
228 /* Fill available space */
229 display: -webkit-box;
230 -webkit-line-clamp: 3;
231 -webkit-box-orient: vertical;
232 overflow: hidden;
233}
234
235.result-text strong {
236 color: #111827;
237 background: #fef3c7;
238 padding: 1px 3px;
239 border-radius: 3px;
240 font-weight: 600;
241}
242
243/* Show more button */
244.show-more-btn {
245 display: block;
246 width: 100%;
247 padding: 0.6rem;
248 background: #f9fafb;
249 border: none;
250 border-top: 1px solid #e5e7eb;
251 color: var(--facet-color, #2563eb);
252 font-size: 0.85rem;
253 cursor: pointer;
254 transition: background 0.1s ease;
255 text-align: center;
256}
257
258.show-more-btn:hover {
259 background: #f3f4f6;
260}
261
262.show-more-btn:active {
263 background: #e5e7eb;
264}
265
266.show-more-btn:disabled {
267 color: #9ca3af;
268 cursor: not-allowed;
269}
270
271/* Load more days */
272.load-more-days {
273 display: block;
274 width: 100%;
275 padding: 0.75rem;
276 background: white;
277 border: 1px solid #e5e7eb;
278 border-radius: 8px;
279 color: var(--facet-color, #2563eb);
280 font-size: 0.85rem;
281 cursor: pointer;
282 transition: background 0.1s ease;
283 text-align: center;
284 margin-top: 0;
285}
286
287.load-more-days:hover {
288 background: #f9fafb;
289}
290
291.load-more-days:active {
292 background: #f3f4f6;
293}
294
295/* Search input */
296.search-input-form {
297 margin-bottom: 1rem;
298 margin-left: auto;
299 margin-right: auto;
300 max-width: 640px;
301}
302
303.search-input {
304 width: 100%;
305 padding: 0.75rem 1rem;
306 font-size: 1rem;
307 border: 1px solid #c8c0b8;
308 border-radius: 8px;
309 outline: none;
310 transition: border-color 0.15s, box-shadow 0.15s;
311 box-sizing: border-box;
312}
313
314.search-input:focus {
315 border-color: var(--facet-color, #2563eb);
316 box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
317}
318
319.search-input:hover:not(:focus) {
320 border-color: #b8b0a8;
321}
322
323.search-input::placeholder {
324 color: #aaa;
325}
326
327/* No-results suggestions */
328.search-noresults-suggestions {
329 margin-top: 1rem;
330 font-size: 0.85rem;
331 color: #6b7280;
332 display: flex;
333 flex-direction: column;
334 gap: 0.4rem;
335 align-items: center;
336}
337
338.search-noresults-suggestions button {
339 background: none;
340 border: 1px solid #d1d5db;
341 border-radius: 0.375rem;
342 padding: 0.25rem 0.75rem;
343 min-height: 44px;
344 display: inline-flex;
345 align-items: center;
346 color: #4b5563;
347 font-size: 0.85rem;
348 cursor: pointer;
349 transition: background 0.1s ease, border-color 0.1s ease;
350}
351
352.search-noresults-suggestions button:hover {
353 background: #f3f4f6;
354 border-color: #9ca3af;
355}
356
357.search-noresults-suggestions button:active {
358 background: #e5e7eb;
359 border-color: #6b7280;
360}
361
362.search-agents-empty {
363 color: #9ca3af;
364 font-size: 0.85rem;
365 padding: 0.25rem 0.5rem;
366}
367
368.search-example-chips {
369 display: flex;
370 flex-wrap: wrap;
371 gap: 0.5rem;
372 justify-content: center;
373}
374
375.search-example-chip {
376 display: inline-flex;
377 align-items: center;
378 padding: 0.4em 0.85em;
379 background: #f3f4f6;
380 border: 1px solid #ebe6e1;
381 border-radius: 20px;
382 font-size: 0.85rem;
383 color: #374151;
384 cursor: pointer;
385 transition: all 0.15s;
386 font-family: inherit;
387}
388
389.search-example-chip:hover {
390 background: #e5e7eb;
391 border-color: #ddd8d3;
392}
393
394.search-example-chip:focus-visible {
395 outline: 2px solid var(--facet-color, #b06a1a);
396 outline-offset: 2px;
397}
398
399.search-example-chip:active {
400 background: #d1d5db;
401}
402
403/* Loading state */
404/* Responsive */
405@media (max-width: 768px) {
406 .search-container {
407 flex-direction: column;
408 }
409
410 .search-sidebar {
411 width: 100%;
412 display: flex;
413 gap: 1rem;
414 flex-wrap: wrap;
415 border-right: none;
416 margin-bottom: 0.25rem;
417 padding-right: 0;
418 }
419
420 .search-input-form {
421 margin-bottom: 0.75rem;
422 margin-left: auto;
423 margin-right: auto;
424 }
425
426 .filter-section {
427 flex: 1;
428 min-width: 150px;
429 margin-bottom: 0.5rem;
430 }
431
432 .filter-section:not(:last-child) {
433 border-bottom: none;
434 padding-bottom: 0;
435 }
436
437 .filter-list {
438 flex-direction: row;
439 flex-wrap: wrap;
440 }
441
442 .filter-item {
443 flex: 0 0 auto;
444 }
445}
446</style>
447
448<div class="workspace-content-wide">
449 <div class="search-container">
450 <!-- Sidebar -->
451 <aside class="search-sidebar">
452 <div class="filter-section" id="facet-filters">
453 <h3>facets</h3>
454 <div class="filter-list" id="facet-list">
455 <label class="filter-item active">
456 <input type="radio" name="facet" value="" checked>
457 <span class="filter-label">all</span>
458 </label>
459 </div>
460 </div>
461
462 <div class="filter-section" id="agent-filters">
463 <h3>agents</h3>
464 <div class="filter-list" id="agent-list">
465 <span class="search-agents-empty">agents appear here as your journal grows</span>
466 </div>
467 </div>
468 </aside>
469
470 <!-- Main content -->
471 <main class="search-main">
472 <form class="search-input-form" id="search-input-form" role="search">
473 <label for="search-input" class="visually-hidden">search your journal</label>
474 <input type="search" class="search-input" id="search-input"
475 placeholder="search your journal..." autocomplete="off">
476 </form>
477
478 <div class="search-summary" id="search-summary" role="status" aria-live="polite" style="display: none;"></div>
479 <div id="search-announcer" class="visually-hidden" aria-live="polite"></div>
480
481 <div id="results-container">
482 <div class="surface-state surface-state--empty" id="search-empty">
483 <div class="surface-state-icon" aria-hidden="true">🔍</div>
484 <h2 class="surface-state-heading">search your journal</h2>
485 <p class="surface-state-desc">find conversations, ideas, and moments — try one of these</p>
486 <div class="surface-state-action">
487 <div class="search-example-chips">
488 <button class="search-example-chip" type="button">a recent meeting</button>
489 <button class="search-example-chip" type="button">something i learned</button>
490 <button class="search-example-chip" type="button">project ideas</button>
491 </div>
492 </div>
493 </div>
494 </div>
495
496 <button id="load-more-days" class="load-more-days" style="display: none;">
497 Load more days
498 </button>
499 </main>
500 </div>
501</div>
502
503<script>
504(function() {
505 const searchUrl = '{{ url_for("app:search.search_journal_api") }}';
506 const dayResultsUrl = '{{ url_for("app:search.day_results_api") }}';
507 const activitiesDayBase = '{{ url_for("app:activities.activities_day", day="") }}';
508 const escapeHtml = window.AppServices.escapeHtml;
509
510 // State
511 let currentQuery = '';
512 let currentFacet = '';
513 let currentAgent = '';
514 let dayOffset = 0;
515 let searchController = null;
516 let loadMoreController = null;
517
518 // DOM elements
519 const resultsContainer = document.getElementById('results-container');
520 const searchSummary = document.getElementById('search-summary');
521 const searchEmpty = document.getElementById('search-empty');
522 const loadMoreDaysBtn = document.getElementById('load-more-days');
523 const facetList = document.getElementById('facet-list');
524 const agentList = document.getElementById('agent-list');
525 const searchInput = document.getElementById('search-input');
526 const searchInputForm = document.getElementById('search-input-form');
527 const searchAnnouncer = document.getElementById('search-announcer');
528
529 function bootstrapSearch() {
530 loadFromHash();
531 searchInput.focus();
532 }
533
534 if (document.readyState === 'loading') {
535 document.addEventListener('DOMContentLoaded', bootstrapSearch, { once: true });
536 } else {
537 bootstrapSearch();
538 }
539
540 // Example chip delegation
541 resultsContainer.addEventListener('click', function(e) {
542 const chip = e.target.closest('.search-example-chip');
543 if (!chip) return;
544 searchInput.value = chip.textContent;
545 currentQuery = chip.textContent;
546 dayOffset = 0;
547 updateHash();
548 doSearch(true);
549 });
550
551 window.addEventListener('hashchange', loadFromHash);
552
553 searchInputForm.onsubmit = function(e) {
554 e.preventDefault();
555 const query = searchInput.value.trim();
556 currentQuery = query;
557 dayOffset = 0;
558 updateHash();
559 if (query) {
560 doSearch(true);
561 } else {
562 showEmpty();
563 }
564 };
565
566 // Listen for search submissions from app bar
567 window.addEventListener('search.submit', (e) => {
568 const query = e.detail.query;
569 if (!query) return;
570 currentQuery = query;
571 searchInput.value = query;
572 dayOffset = 0;
573 updateHash();
574 doSearch(true);
575 });
576
577 function loadFromHash() {
578 const hashParams = new URLSearchParams(window.location.hash.slice(1));
579 const q = hashParams.get('q') || '';
580 const f = hashParams.get('facet') || '';
581 const t = hashParams.get('agent') || '';
582
583 if (q !== currentQuery || f !== currentFacet || t !== currentAgent) {
584 currentQuery = q;
585 currentFacet = f;
586 currentAgent = t;
587
588 // Notify app bar of query change
589 window.dispatchEvent(new CustomEvent('search.queryUpdate', {
590 detail: { query: q }
591 }));
592 searchInput.value = q;
593
594 if (q) {
595 doSearch(true);
596 } else {
597 showEmpty();
598 }
599 }
600 }
601
602 function updateHash() {
603 const params = new URLSearchParams();
604 if (currentQuery) params.set('q', currentQuery);
605 if (currentFacet) params.set('facet', currentFacet);
606 if (currentAgent) params.set('agent', currentAgent);
607 window.location.hash = params.toString();
608 }
609
610 // Load more days
611 loadMoreDaysBtn.onclick = function() {
612 dayOffset += 20;
613 doSearch(false);
614 };
615
616 function announce(msg) {
617 searchAnnouncer.textContent = '';
618 setTimeout(() => { searchAnnouncer.textContent = msg; }, 100);
619 }
620
621 // Main search function
622 function doSearch(reset) {
623 if (!currentQuery) {
624 showEmpty();
625 return;
626 }
627
628 searchController?.abort();
629 searchController = new AbortController();
630
631 if (reset) {
632 dayOffset = 0;
633 resultsContainer.innerHTML = window.SurfaceState.loading({ text: 'Searching...' });
634 announce('searching…');
635 }
636
637 const url = new URL(searchUrl, window.location.origin);
638 url.searchParams.set('q', currentQuery);
639 url.searchParams.set('offset', dayOffset);
640 if (currentFacet) url.searchParams.set('facet', currentFacet);
641 if (currentAgent) url.searchParams.set('agent', currentAgent);
642
643 fetch(url, { signal: searchController.signal })
644 .then(r => {
645 if (!r.ok) throw new Error('search returned ' + r.status);
646 return r.json();
647 })
648 .then(data => {
649 if (reset) {
650 resultsContainer.innerHTML = '';
651 renderFilters(data.facets, data.agents);
652 }
653
654 renderResults(data, reset);
655 updateSummary(data);
656 updateLoadMoreButton(data);
657 })
658 .catch(err => {
659 if (err.name === 'AbortError') return;
660 console.error('Search error:', err);
661 resultsContainer.innerHTML = window.SurfaceState.error({
662 icon: '⚠️',
663 heading: 'something went wrong with that search',
664 desc: 'could be a network issue or the server might be busy — give it a moment',
665 action: '<button class="search-error-retry">try again</button>'
666 });
667 resultsContainer.querySelector('.search-error-retry').onclick = () => doSearch(true);
668 announce('search failed, use the try again button to retry');
669 });
670 }
671
672 function showEmpty() {
673 resultsContainer.innerHTML = '';
674 resultsContainer.appendChild(searchEmpty.cloneNode(true));
675 searchSummary.style.display = 'none';
676 loadMoreDaysBtn.style.display = 'none';
677 }
678
679 function renderFilters(facets, agents) {
680 // Render facet filters
681 let facetHtml = `
682 <label class="filter-item ${!currentFacet ? 'active' : ''}">
683 <input type="radio" name="facet" value="" ${!currentFacet ? 'checked' : ''}>
684 <span class="filter-label">all</span>
685 </label>
686 `;
687
688 for (const f of facets) {
689 const isActive = currentFacet === f.name;
690 const emoji = f.emoji ? `<span class="filter-icon">${f.emoji}</span>` : '';
691 facetHtml += `
692 <label class="filter-item ${isActive ? 'active' : ''}">
693 <input type="radio" name="facet" value="${escapeHtml(f.name)}" ${isActive ? 'checked' : ''}>
694 ${emoji}
695 <span class="filter-label">${escapeHtml(f.title || f.name)}</span>
696 <span class="filter-count">${f.count}</span>
697 </label>
698 `;
699 }
700 facetList.innerHTML = facetHtml;
701
702 // Add facet change handlers
703 facetList.querySelectorAll('input[type="radio"]').forEach(input => {
704 input.onchange = function() {
705 currentFacet = this.value;
706 facetList.querySelectorAll('.filter-item').forEach(item => {
707 item.classList.toggle('active', item.querySelector('input').value === currentFacet);
708 });
709 updateHash();
710 doSearch(true);
711 };
712 });
713
714 // Render agent filters (radio buttons for single-select)
715 if (agents.length === 0) {
716 agentList.innerHTML = '<span class="search-agents-empty">agents appear here as your journal grows</span>';
717 } else {
718 let agentHtml = `
719 <label class="filter-item ${!currentAgent ? 'active' : ''}">
720 <input type="radio" name="agent" value="" ${!currentAgent ? 'checked' : ''}>
721 <span class="filter-label">all</span>
722 </label>
723 `;
724 for (const t of agents) {
725 const isActive = currentAgent === t.name;
726 agentHtml += `
727 <label class="filter-item ${isActive ? 'active' : ''}">
728 <input type="radio" name="agent" value="${escapeHtml(t.name)}" ${isActive ? 'checked' : ''}>
729 <span class="filter-icon">${t.icon}</span>
730 <span class="filter-label">${escapeHtml(t.label)}</span>
731 <span class="filter-count">${t.count}</span>
732 </label>
733 `;
734 }
735 agentList.innerHTML = agentHtml;
736
737 // Add agent change handlers
738 agentList.querySelectorAll('input[type="radio"]').forEach(input => {
739 input.onchange = function() {
740 currentAgent = this.value;
741 agentList.querySelectorAll('.filter-item').forEach(item => {
742 item.classList.toggle('active', item.querySelector('input').value === currentAgent);
743 });
744 updateHash();
745 doSearch(true);
746 };
747 });
748 }
749 }
750
751 function renderResults(data, reset) {
752 if (data.days.length === 0 && reset) {
753 const suggestionsHtml = '<div class="search-noresults-suggestions">'
754 + '<span>try different keywords or check your spelling</span>'
755 + (currentFacet ? `<button type="button" data-clear="facet">search all facets instead of ${escapeHtml(currentFacet)}</button>` : '')
756 + (currentAgent ? `<button type="button" data-clear="agent">search all agents instead of ${escapeHtml(currentAgent)}</button>` : '')
757 + '</div>';
758
759 resultsContainer.innerHTML = window.SurfaceState.empty({
760 icon: '🔍',
761 heading: `no results found for "${currentQuery}"`,
762 action: suggestionsHtml
763 });
764
765 const clearFacetBtn = resultsContainer.querySelector('[data-clear="facet"]');
766 if (clearFacetBtn) {
767 clearFacetBtn.onclick = () => {
768 currentFacet = '';
769 updateHash();
770 doSearch(true);
771 };
772 }
773
774 const clearAgentBtn = resultsContainer.querySelector('[data-clear="agent"]');
775 if (clearAgentBtn) {
776 clearAgentBtn.onclick = () => {
777 currentAgent = '';
778 updateHash();
779 doSearch(true);
780 };
781 }
782
783 let msg = 'no results found';
784 if (currentFacet || currentAgent) msg += ', try clearing filters';
785 announce(msg);
786 return;
787 }
788
789 for (const dayData of data.days) {
790 const card = createDayCard(dayData);
791 resultsContainer.appendChild(card);
792 }
793 }
794
795 function createDayCard(dayData) {
796 const card = document.createElement('div');
797 card.className = 'day-card';
798 card.dataset.day = dayData.day;
799
800 const header = document.createElement('button');
801 header.className = 'day-header';
802 header.innerHTML = `
803 <span class="day-date">${escapeHtml(dayData.date)}</span>
804 <span class="day-count">${dayData.total} match${dayData.total !== 1 ? 'es' : ''}</span>
805 `;
806 header.onclick = function() {
807 window.location.href = activitiesDayBase + dayData.day;
808 };
809
810 const resultsDiv = document.createElement('div');
811 resultsDiv.className = 'day-results';
812
813 for (const result of dayData.results) {
814 resultsDiv.appendChild(createResultItem(result));
815 }
816
817 card.appendChild(header);
818 card.appendChild(resultsDiv);
819
820 // Show more button if needed
821 if (dayData.has_more) {
822 const showMoreBtn = document.createElement('button');
823 showMoreBtn.className = 'show-more-btn';
824 showMoreBtn.textContent = `Show ${dayData.total - dayData.showing} more`;
825 showMoreBtn.onclick = function(e) {
826 e.stopPropagation();
827 loadMoreForDay(dayData.day, dayData.showing, resultsDiv, showMoreBtn);
828 };
829 card.appendChild(showMoreBtn);
830 }
831
832 return card;
833 }
834
835 function createResultItem(result) {
836 const item = document.createElement('a');
837 item.className = 'result-item';
838 item.href = activitiesDayBase + result.day;
839
840 // Build facet badge
841 let facetBadge = '';
842 if (result.facet) {
843 const style = result.facet_color
844 ? `background: ${result.facet_color}; color: white;`
845 : '';
846 const hasColor = result.facet_color ? 'has-color' : '';
847 const emoji = result.facet_emoji ? result.facet_emoji + ' ' : '';
848 facetBadge = `<span class="result-facet ${hasColor}" style="${style}">${emoji}#${escapeHtml(result.facet)}</span>`;
849 }
850
851 item.innerHTML = `
852 <div class="result-meta">
853 <span class="result-agent">
854 <span class="result-agent-icon">${result.agent_icon}</span>
855 ${escapeHtml(result.agent_label)}
856 </span>
857 ${facetBadge}
858 </div>
859 <div class="result-text">${result.text}</div>
860 `;
861
862 return item;
863 }
864
865 function loadMoreForDay(day, currentOffset, resultsDiv, btn) {
866 loadMoreController?.abort();
867 loadMoreController = new AbortController();
868 btn.textContent = 'loading...';
869 btn.disabled = true;
870
871 const url = new URL(dayResultsUrl, window.location.origin);
872 url.searchParams.set('q', currentQuery);
873 url.searchParams.set('day', day);
874 url.searchParams.set('offset', currentOffset);
875 url.searchParams.set('limit', 20);
876 if (currentFacet) url.searchParams.set('facet', currentFacet);
877 if (currentAgent) url.searchParams.set('agent', currentAgent);
878
879 fetch(url, { signal: loadMoreController.signal })
880 .then(r => r.json())
881 .then(data => {
882 for (const result of data.results) {
883 resultsDiv.appendChild(createResultItem(result));
884 }
885
886 const newOffset = currentOffset + data.results.length;
887 const remaining = data.total - newOffset;
888
889 if (remaining > 0) {
890 btn.textContent = `Show ${remaining} more`;
891 btn.disabled = false;
892 btn.onclick = function(e) {
893 e.stopPropagation();
894 loadMoreForDay(day, newOffset, resultsDiv, btn);
895 };
896 } else {
897 btn.remove();
898 }
899 })
900 .catch(err => {
901 if (err.name === 'AbortError') return;
902 console.error('Load more error:', err);
903 btn.textContent = 'error - click to retry';
904 btn.disabled = false;
905 });
906 }
907
908 function updateSummary(data) {
909 if (data.total === 0) {
910 searchSummary.style.display = 'none';
911 announce('no results found');
912 return;
913 }
914
915 searchSummary.textContent = `${data.total} results across ${data.total_days} day${data.total_days !== 1 ? 's' : ''}`;
916 searchSummary.style.display = 'block';
917 announce(searchSummary.textContent);
918 }
919
920 function updateLoadMoreButton(data) {
921 const totalDays = data.total_days;
922 const shownDays = dayOffset + data.showing_days;
923
924 if (shownDays < totalDays) {
925 loadMoreDaysBtn.textContent = `Load more days (${shownDays}/${totalDays})`;
926 loadMoreDaysBtn.style.display = 'block';
927 } else {
928 loadMoreDaysBtn.style.display = 'none';
929 }
930 }
931
932})();
933</script>