personal memory agent
0
fork

Configure Feed

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

feat: add date pagination to news app

Convert news app from sequential timeline with "load more" to date-paged
view with app bar navigation. Now matches pattern used by calendar, todos,
and tokens apps.

- Add index route redirecting to today's date
- Add day-based route with adjacent_days navigation
- Create app_bar.html with shared date_nav.html
- Rewrite workspace.html for single-day view
- Support all-facet mode showing multiple newsletters with headers
- Support specific-facet mode showing single newsletter

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

+163 -176
+1
apps/news/app_bar.html
··· 1 + {% include 'date_nav.html' %}
+32 -1
apps/news/routes.py
··· 2 2 3 3 from __future__ import annotations 4 4 5 + import re 6 + from datetime import date 5 7 from typing import Any 6 8 7 - from flask import Blueprint, jsonify, render_template, request 9 + from flask import Blueprint, jsonify, redirect, render_template, request, url_for 10 + 11 + from convey import state 12 + from convey.utils import DATE_RE, adjacent_days, format_date 8 13 9 14 news_bp = Blueprint( 10 15 "app:news", 11 16 __name__, 12 17 url_prefix="/app/news", 13 18 ) 19 + 20 + 21 + @news_bp.route("/") 22 + def news_index() -> Any: 23 + """Redirect to today's news.""" 24 + today = date.today().strftime("%Y%m%d") 25 + return redirect(url_for("app:news.news_day", day=today)) 26 + 27 + 28 + @news_bp.route("/<day>") 29 + def news_day(day: str) -> str: 30 + """News view for a specific day.""" 31 + if not re.fullmatch(DATE_RE.pattern, day): 32 + return "", 404 33 + 34 + prev_day, next_day = adjacent_days(state.journal_root, day) 35 + title = format_date(day) 36 + 37 + return render_template( 38 + "app.html", 39 + app="news", 40 + title=title, 41 + day=day, 42 + prev_day=prev_day, 43 + next_day=next_day, 44 + ) 14 45 15 46 16 47 @news_bp.route("/api/<facet_name>")
+130 -175
apps/news/workspace.html
··· 33 33 color: #999; 34 34 } 35 35 36 - .news-days { 36 + .news-sections { 37 37 display: flex; 38 38 flex-direction: column; 39 39 gap: 2.5em; 40 40 } 41 41 42 - .news-day { 42 + .news-section { 43 43 background: white; 44 44 border-radius: 12px; 45 45 padding: 1.5em; ··· 47 47 border: 1px solid #eef2ff; 48 48 } 49 49 50 - .news-day-header { 51 - font-size: 1.5em; 50 + .news-section-header { 51 + font-size: 1.3em; 52 52 font-weight: 600; 53 53 color: #333; 54 54 margin: 0 0 1em 0; 55 55 padding-bottom: 0.5em; 56 56 border-bottom: 2px solid #eef2ff; 57 + display: flex; 58 + align-items: center; 59 + gap: 0.5em; 60 + } 61 + 62 + .news-section-header .facet-emoji { 63 + font-size: 1.2em; 57 64 } 58 65 59 66 .newsletter-content { ··· 144 151 .newsletter-content a:hover { 145 152 border-bottom-color: #667eea; 146 153 } 147 - 148 - .news-load-more { 149 - text-align: center; 150 - margin-top: 1.5em; 151 - } 152 - 153 - .news-load-more button { 154 - padding: 0.6em 1.5em; 155 - background: #f5f5f5; 156 - border: 1px solid #ddd; 157 - border-radius: 6px; 158 - cursor: pointer; 159 - font-size: 0.95em; 160 - font-weight: 500; 161 - transition: background-color 0.2s; 162 - } 163 - 164 - .news-load-more button:hover:not(:disabled) { 165 - background: #eee; 166 - } 167 - 168 - .news-load-more button:disabled { 169 - opacity: 0.6; 170 - cursor: not-allowed; 171 - } 172 - 173 - .no-facet-message { 174 - text-align: center; 175 - padding: 4em 2em; 176 - color: #666; 177 - } 178 - 179 - .no-facet-message h2 { 180 - color: #999; 181 - margin-bottom: 0.5em; 182 - } 183 154 </style> 184 155 185 156 <div class="news-container"> ··· 190 161 191 162 <div id="news-content" style="display: none;"> 192 163 <div id="news-empty" class="news-empty" style="display: none;"> 193 - <p>No newsletters recorded yet.</p> 194 - </div> 195 - 196 - <div id="no-facet-message" class="no-facet-message" style="display: none;"> 197 - <h2>Select a facet to view newsletters</h2> 198 - <p>Choose a facet from the pills above to see its newsletter feed.</p> 164 + <p>No newsletters for this day.</p> 199 165 </div> 200 166 201 - <div class="news-days" id="news-days"></div> 202 - 203 - <div class="news-load-more" id="news-load-more-container" style="display: none;"> 204 - <button id="news-load-more">Load more newsletters</button> 205 - </div> 167 + <div class="news-sections" id="news-sections"></div> 206 168 </div> 207 169 </div> 208 170 209 171 <script src="{{ vendor_lib('marked') }}"></script> 210 172 <script> 211 - let newsCursor = null; 212 - let newsLoading = false; 213 - let currentFacet = getSelectedFacet(); 173 + (function() { 174 + // Get day from template context 175 + const currentDay = '{{ day }}'; 176 + let currentFacet = window.selectedFacet; 177 + let newsLoading = false; 178 + 179 + // Listen for facet changes 180 + window.addEventListener('facet.switch', (e) => { 181 + currentFacet = e.detail.facet; 182 + loadNews(); 183 + }); 214 184 215 - // Get selected facet from cookie 216 - function getSelectedFacet() { 217 - const cookies = document.cookie.split(';'); 218 - for (let cookie of cookies) { 219 - const [name, value] = cookie.trim().split('='); 220 - if (name === 'selectedFacet') { 221 - return value || null; 222 - } 223 - } 224 - return null; 225 - } 185 + function loadNews() { 186 + if (newsLoading) return; 187 + newsLoading = true; 226 188 227 - // Listen for facet changes 228 - window.addEventListener('facet.switch', (e) => { 229 - currentFacet = e.detail.facet; 230 - resetAndLoad(); 231 - }); 189 + const loadingEl = document.getElementById('news-loading'); 190 + const contentEl = document.getElementById('news-content'); 191 + const sectionsEl = document.getElementById('news-sections'); 192 + const emptyEl = document.getElementById('news-empty'); 232 193 233 - function resetAndLoad() { 234 - newsCursor = null; 235 - document.getElementById('news-days').innerHTML = ''; 236 - loadNews(); 237 - } 194 + loadingEl.style.display = 'block'; 195 + contentEl.style.display = 'none'; 196 + sectionsEl.innerHTML = ''; 238 197 239 - function loadNews() { 240 - if (newsLoading) return; 198 + if (currentFacet) { 199 + // Load news for specific facet 200 + fetchFacetNews(currentFacet) 201 + .then(result => { 202 + loadingEl.style.display = 'none'; 203 + contentEl.style.display = 'block'; 241 204 242 - // Show message if no facet selected 243 - if (!currentFacet) { 244 - document.getElementById('news-loading').style.display = 'none'; 245 - document.getElementById('news-content').style.display = 'block'; 246 - document.getElementById('no-facet-message').style.display = 'block'; 247 - document.getElementById('news-empty').style.display = 'none'; 248 - document.getElementById('news-days').style.display = 'none'; 249 - document.getElementById('news-load-more-container').style.display = 'none'; 250 - return; 251 - } 205 + if (result && result.content) { 206 + emptyEl.style.display = 'none'; 207 + appendNewsSection(result.facet, result.facetData, result.content); 208 + } else { 209 + emptyEl.style.display = 'block'; 210 + } 211 + newsLoading = false; 212 + }) 213 + .catch(error => { 214 + console.error('Error loading news:', error); 215 + loadingEl.innerHTML = `<p style="color: #c33;">Error: ${error.message}</p>`; 216 + newsLoading = false; 217 + }); 218 + } else { 219 + // Load news for all facets 220 + const facets = window.facetsData || []; 221 + if (facets.length === 0) { 222 + loadingEl.style.display = 'none'; 223 + contentEl.style.display = 'block'; 224 + emptyEl.style.display = 'block'; 225 + newsLoading = false; 226 + return; 227 + } 252 228 253 - newsLoading = true; 229 + // Fetch all facets in parallel 230 + Promise.all(facets.map(f => fetchFacetNews(f.name))) 231 + .then(results => { 232 + loadingEl.style.display = 'none'; 233 + contentEl.style.display = 'block'; 254 234 255 - const loadingEl = document.getElementById('news-loading'); 256 - const contentEl = document.getElementById('news-content'); 257 - const loadMoreBtn = document.getElementById('news-load-more'); 235 + // Filter to facets that have content 236 + const withContent = results.filter(r => r && r.content); 258 237 259 - if (newsCursor === null) { 260 - loadingEl.style.display = 'block'; 261 - contentEl.style.display = 'none'; 262 - } else { 263 - loadMoreBtn.disabled = true; 264 - loadMoreBtn.textContent = 'Loading...'; 238 + if (withContent.length === 0) { 239 + emptyEl.style.display = 'block'; 240 + } else { 241 + emptyEl.style.display = 'none'; 242 + withContent.forEach(result => { 243 + appendNewsSection(result.facet, result.facetData, result.content); 244 + }); 245 + } 246 + newsLoading = false; 247 + }) 248 + .catch(error => { 249 + console.error('Error loading news:', error); 250 + loadingEl.innerHTML = `<p style="color: #c33;">Error: ${error.message}</p>`; 251 + newsLoading = false; 252 + }); 253 + } 265 254 } 266 255 267 - const params = new URLSearchParams({ days: '5' }); 268 - if (newsCursor) params.append('cursor', newsCursor); 269 - 270 - fetch(`api/${encodeURIComponent(currentFacet)}?${params}`) 271 - .then(response => response.json()) 272 - .then(data => { 273 - if (data.error) throw new Error(data.error); 256 + function fetchFacetNews(facetName) { 257 + const facetData = (window.facetsData || []).find(f => f.name === facetName); 274 258 275 - loadingEl.style.display = 'none'; 276 - contentEl.style.display = 'block'; 277 - document.getElementById('no-facet-message').style.display = 'none'; 278 - document.getElementById('news-days').style.display = 'flex'; 259 + return fetch(`/app/news/api/${encodeURIComponent(facetName)}?day=${currentDay}`) 260 + .then(response => response.json()) 261 + .then(data => { 262 + if (data.error) throw new Error(data.error); 279 263 280 - const days = data.days || []; 264 + const days = data.days || []; 265 + if (days.length === 0 || !days[0].raw_content) { 266 + return { facet: facetName, facetData, content: null }; 267 + } 281 268 282 - if (days.length === 0 && newsCursor === null) { 283 - document.getElementById('news-empty').style.display = 'block'; 284 - } else { 285 - document.getElementById('news-empty').style.display = 'none'; 286 - days.forEach(appendNewsDay); 287 - } 269 + return { 270 + facet: facetName, 271 + facetData, 272 + content: days[0].raw_content 273 + }; 274 + }); 275 + } 288 276 289 - newsCursor = data.next_cursor || null; 277 + function appendNewsSection(facetName, facetData, content) { 278 + const container = document.getElementById('news-sections'); 290 279 291 - const loadMoreContainer = document.getElementById('news-load-more-container'); 292 - if (data.has_more && newsCursor) { 293 - loadMoreContainer.style.display = 'block'; 294 - loadMoreBtn.disabled = false; 295 - loadMoreBtn.textContent = 'Load more newsletters'; 296 - } else { 297 - loadMoreContainer.style.display = 'none'; 298 - } 280 + const section = document.createElement('section'); 281 + section.className = 'news-section'; 299 282 300 - newsLoading = false; 301 - }) 302 - .catch(error => { 303 - console.error('Error loading news:', error); 304 - loadingEl.innerHTML = `<p style="color: #c33;">Error: ${error.message}</p>`; 305 - newsLoading = false; 306 - }); 307 - } 283 + // Header with facet info (only show in all-facet mode) 284 + if (!currentFacet && facetData) { 285 + const header = document.createElement('h3'); 286 + header.className = 'news-section-header'; 308 287 309 - function appendNewsDay(day) { 310 - const container = document.getElementById('news-days'); 288 + if (facetData.emoji) { 289 + const emoji = document.createElement('span'); 290 + emoji.className = 'facet-emoji'; 291 + emoji.textContent = facetData.emoji; 292 + header.appendChild(emoji); 293 + } 311 294 312 - const section = document.createElement('section'); 313 - section.className = 'news-day'; 295 + const title = document.createElement('span'); 296 + title.textContent = facetData.title || facetName; 297 + header.appendChild(title); 314 298 315 - const header = document.createElement('h3'); 316 - header.className = 'news-day-header'; 317 - header.textContent = formatDateKey(day.date); 318 - section.appendChild(header); 299 + section.appendChild(header); 300 + } 319 301 320 - if (day.raw_content) { 321 302 const contentDiv = document.createElement('div'); 322 303 contentDiv.className = 'newsletter-content'; 323 304 ··· 329 310 mangle: false 330 311 }); 331 312 332 - // Remove top-level heading (we show it in header) 333 - let content = day.raw_content.replace(/^# .+\n+/, ''); 313 + // Remove top-level heading (usually date-based) 314 + let processedContent = content.replace(/^# .+\n+/, ''); 334 315 335 316 // Render markdown 336 - contentDiv.innerHTML = marked.parse(content); 317 + contentDiv.innerHTML = marked.parse(processedContent); 337 318 section.appendChild(contentDiv); 338 - } else { 339 - const emptyNotice = document.createElement('div'); 340 - emptyNotice.className = 'news-empty'; 341 - emptyNotice.textContent = 'No newsletter content for this day.'; 342 - section.appendChild(emptyNotice); 343 - } 344 319 345 - container.appendChild(section); 346 - } 347 - 348 - function formatDateKey(dateKey) { 349 - if (!dateKey || dateKey.length !== 8) return dateKey || 'Unknown date'; 350 - 351 - const year = Number(dateKey.slice(0, 4)); 352 - const month = Number(dateKey.slice(4, 6)) - 1; 353 - const day = Number(dateKey.slice(6, 8)); 354 - 355 - const date = new Date(year, month, day); 356 - if (isNaN(date.getTime())) return dateKey; 320 + container.appendChild(section); 321 + } 357 322 358 - return date.toLocaleDateString(undefined, { 359 - weekday: 'long', 360 - year: 'numeric', 361 - month: 'long', 362 - day: 'numeric' 363 - }); 364 - } 365 - 366 - // Load more button 367 - document.getElementById('news-load-more').addEventListener('click', loadNews); 368 - 369 - // Initial load 370 - loadNews(); 323 + // Initial load 324 + loadNews(); 325 + })(); 371 326 </script>