personal memory agent
0
fork

Configure Feed

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

feat(date-nav): add month picker widget replacing native date picker

- Add month-picker.js with heat map support via data provider registration
- Date-nav arrows switch between day/month navigation when picker is open
- Calendar app registers provider to show per-facet event counts
- Remove calendar month view (_month.html) - picker replaces it
- Remove calendar app_bar.html - month nav now in date-nav
- Calendar index redirects to yesterday instead of showing month view
- Update APPS.md with month picker documentation

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

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

+491 -452
+18 -5
APPS.md
··· 157 157 {% include 'date_nav.html' %} 158 158 ``` 159 159 160 - This provides a unified `← 🗓️ Today →` control with: 160 + This provides a unified `← Date →` control with: 161 161 - Previous/next day buttons 162 - - Native date picker (calendar icon) 163 - - Today button (disabled when viewing current day) 162 + - Month picker dropdown (click date label) 164 163 - Keyboard shortcuts: ←/→ arrows, `t` for today 165 164 166 165 The component reads `day` and `app` from template context to construct navigation URLs. 167 166 167 + **Month Picker:** 168 + 169 + Apps with `date_nav: true` get a month picker dropdown when clicking the date label. To show day-level data indicators (heat map): 170 + 171 + ```javascript 172 + MonthPicker.registerDataProvider('my_app', async (month, facet) => { 173 + // Return {YYYYMMDD: count} for days with data 174 + const resp = await fetch(`/app/my_app/api/stats/${month}`); 175 + return resp.json(); 176 + }); 177 + ``` 178 + 179 + Without a provider, the picker shows a plain calendar grid. 180 + 168 181 **Reference implementations:** 169 - - Date navigation: `apps/todos/app_bar.html`, `apps/tokens/app_bar.html`, `apps/calendar/app_bar.html` 182 + - Date navigation: `apps/todos/app_bar.html`, `apps/tokens/app_bar.html` 170 183 171 - **Implementation source:** `convey/templates/date_nav.html` 184 + **Implementation source:** `convey/templates/date_nav.html`, `convey/static/month-picker.js` 172 185 173 186 ### 5. `background.html` - Background Service 174 187
-345
apps/calendar/_month.html
··· 1 - {# Calendar month overview - integrated with app facet system #} 2 - 3 - <style> 4 - .calendar-container { 5 - display: flex; 6 - flex-direction: column; 7 - /* Fill viewport minus facet bar (48px) and app bar (60px + 12px gap) */ 8 - height: calc(100vh - var(--facet-bar-height, 48px) - var(--app-bar-height, 60px) - 24px); 9 - padding: 12px; 10 - box-sizing: border-box; 11 - } 12 - 13 - #calendar { 14 - width: 100%; 15 - border-collapse: collapse; 16 - flex: 1; 17 - display: flex; 18 - flex-direction: column; 19 - } 20 - 21 - #calendar thead { 22 - flex-shrink: 0; 23 - } 24 - 25 - #calendar tbody { 26 - flex: 1; 27 - display: flex; 28 - flex-direction: column; 29 - } 30 - 31 - #calendar tr { 32 - display: flex; 33 - flex: 1; 34 - } 35 - 36 - #calendar th, #calendar td { 37 - flex: 1; 38 - border: 1px solid #e0e0e0; 39 - vertical-align: top; 40 - padding: 4px; 41 - position: relative; 42 - min-height: 60px; 43 - /* Heat map: intensity 0-1 controls facet color opacity */ 44 - background: color-mix(in srgb, var(--facet-color) calc(var(--intensity, 0) * 25%), transparent); 45 - } 46 - 47 - #calendar th { 48 - background: #f8f9fa; 49 - text-align: center; 50 - padding: 8px 4px; 51 - flex: 1; 52 - display: flex; 53 - align-items: center; 54 - justify-content: center; 55 - } 56 - 57 - #calendar td.today { 58 - /* Layer blue tint on top of heat map */ 59 - background: 60 - linear-gradient(rgba(0,123,255,0.15), rgba(0,123,255,0.15)), 61 - color-mix(in srgb, var(--facet-color) calc(var(--intensity, 0) * 25%), transparent); 62 - box-shadow: inset 0 0 0 2px #007bff; 63 - } 64 - 65 - .day-number { 66 - font-weight: bold; 67 - text-decoration: none; 68 - color: inherit; 69 - display: inline-block; 70 - } 71 - 72 - /* Empty/disabled day cells (zero events or no journal data) */ 73 - #calendar td.empty { 74 - background: 75 - repeating-linear-gradient(45deg, transparent, transparent 4px, rgba(0,0,0,0.03) 4px, rgba(0,0,0,0.03) 8px), 76 - color-mix(in srgb, var(--facet-color) calc(var(--intensity, 0) * 25%), transparent); 77 - } 78 - #calendar td.empty .day-number { opacity: 0.4; color: #999; } 79 - 80 - .event-count { 81 - position: absolute; 82 - bottom: 4px; 83 - right: 6px; 84 - font-size: 0.85em; 85 - color: #666; 86 - } 87 - </style> 88 - 89 - <div class="calendar-container"> 90 - <table id="calendar"> 91 - <thead> 92 - <tr><th>Sun</th><th>Mon</th><th>Tue</th><th>Wed</th><th>Thu</th><th>Fri</th><th>Sat</th></tr> 93 - </thead> 94 - <tbody></tbody> 95 - </table> 96 - </div> 97 - 98 - <script> 99 - let dayStats = {}; 100 - let availableDays = new Set(); 101 - const dayBase = '/app/calendar/'; 102 - let months = []; 103 - let currentIndex = 0; 104 - const now = new Date(); 105 - const CURRENT_YEAR_MONTH = generateYearMonth(now.getFullYear(), now.getMonth()); 106 - const TODAY_DATE_STRING = CURRENT_YEAR_MONTH + String(now.getDate()).padStart(2,'0'); 107 - 108 - // App bar controls 109 - const monthLabel = document.getElementById('month-nav-label'); 110 - const prevBtn = document.getElementById('month-nav-prev'); 111 - const nextBtn = document.getElementById('month-nav-next'); 112 - const todayBtn = document.getElementById('month-nav-today'); 113 - 114 - // Track current displayed month for re-rendering on facet change 115 - let currentMonth = null; 116 - 117 - // Listen for facet changes from facet-bar - just re-render, no refetch needed 118 - window.addEventListener('facet.switch', () => { 119 - if (currentMonth) { 120 - renderMonth(currentMonth); 121 - } 122 - }); 123 - 124 - function loadDays() { 125 - // Fetch available days list (for month navigation and day links) 126 - fetch('/app/calendar/api/days') 127 - .then(r => { 128 - if (!r.ok) throw new Error('Failed to fetch days'); 129 - return r.json(); 130 - }) 131 - .then(days => { 132 - availableDays = new Set(days || []); 133 - const monthSet = new Set(Array.from(availableDays).map(d => d.slice(0,6))); 134 - monthSet.add(CURRENT_YEAR_MONTH); 135 - months = Array.from(monthSet).sort(); 136 - currentIndex = months.indexOf(CURRENT_YEAR_MONTH); 137 - if (currentIndex === -1) { 138 - currentIndex = months.length - 1; 139 - } 140 - showMonth(months[currentIndex]); 141 - }) 142 - .catch(err => { 143 - console.error('Error loading calendar data:', err); 144 - months = [CURRENT_YEAR_MONTH]; 145 - currentIndex = 0; 146 - showMonth(months[currentIndex]); 147 - }); 148 - } 149 - 150 - function loadMonthStats(ym) { 151 - // Fetch stats for specific month (returns facet counts per day) 152 - fetch(`/app/calendar/api/stats/${ym}`) 153 - .then(r => { 154 - if (!r.ok) throw new Error('Failed to fetch stats'); 155 - return r.json(); 156 - }) 157 - .then(stats => { 158 - dayStats = stats || {}; 159 - currentMonth = ym; 160 - renderMonth(ym); 161 - }) 162 - .catch(err => { 163 - console.error('Error loading month stats:', err); 164 - dayStats = {}; 165 - currentMonth = ym; 166 - renderMonth(ym); 167 - }); 168 - } 169 - 170 - // Get event count for a day based on selected facet 171 - function getDayCount(dateStr) { 172 - const facetCounts = dayStats[dateStr] || {}; 173 - if (window.selectedFacet) { 174 - return facetCounts[window.selectedFacet] || 0; 175 - } else { 176 - return Object.values(facetCounts).reduce((sum, c) => sum + c, 0); 177 - } 178 - } 179 - 180 - function createDayContent(dateStr, day) { 181 - const exists = availableDays.has(dateStr); 182 - const count = getDayCount(dateStr); 183 - 184 - let html = ''; 185 - // Only link to days that exist AND have events 186 - if (exists && count > 0) { 187 - html += `<a class='day-number' href='${dayBase}${dateStr}'>${day}</a>`; 188 - html += `<span class='event-count'>${count}</span>`; 189 - } else { 190 - html += `<span class='day-number'>${day}</span>`; 191 - } 192 - 193 - return html; 194 - } 195 - 196 - function showMonth(ym) { 197 - // Update app bar label 198 - const year = parseInt(ym.slice(0,4)); 199 - const month = parseInt(ym.slice(4)) - 1; 200 - const first = new Date(year, month, 1); 201 - if (monthLabel) { 202 - monthLabel.textContent = first.toLocaleString('default', {month: 'long', year: 'numeric'}); 203 - } 204 - 205 - // Show/hide Today button based on current month 206 - if (todayBtn) { 207 - todayBtn.classList.toggle('hidden', ym === CURRENT_YEAR_MONTH); 208 - } 209 - 210 - // Load stats for this month (will call renderMonth when done) 211 - loadMonthStats(ym); 212 - } 213 - 214 - function renderMonth(ym) { 215 - const year = parseInt(ym.slice(0,4)); 216 - const month = parseInt(ym.slice(4)) - 1; 217 - const daysInMonth = new Date(year, month+1, 0).getDate(); 218 - 219 - // Calculate max count for heat map scaling 220 - let maxCount = 0; 221 - for (let day = 1; day <= daysInMonth; day++) { 222 - const dateStr = ym + String(day).padStart(2, '0'); 223 - maxCount = Math.max(maxCount, getDayCount(dateStr)); 224 - } 225 - 226 - const tbody = document.querySelector('#calendar tbody'); 227 - tbody.innerHTML=''; 228 - const startDay = new Date(year, month, 1).getDay(); 229 - let row = document.createElement('tr'); 230 - 231 - // Empty cells for days before month starts 232 - for(let i=0; i<startDay; i++){ 233 - const td = document.createElement('td'); 234 - td.classList.add('empty'); 235 - row.appendChild(td); 236 - } 237 - 238 - // Days of the month 239 - for(let day=1; day<=daysInMonth; day++){ 240 - if((startDay+day-1)%7===0 && day!==1){ 241 - tbody.appendChild(row); 242 - row=document.createElement('tr'); 243 - } 244 - const td = document.createElement('td'); 245 - const dateStr = ym + String(day).padStart(2,'0'); 246 - const count = getDayCount(dateStr); 247 - 248 - // Set heat map intensity (0.2-1 for days with events, 0 for empty) 249 - const rawIntensity = maxCount > 0 ? count / maxCount : 0; 250 - const intensity = count > 0 ? 0.2 + (rawIntensity * 0.8) : 0; 251 - td.style.setProperty('--intensity', intensity); 252 - 253 - // Mark empty cells (no events or no journal data) 254 - const exists = availableDays.has(dateStr); 255 - if (count === 0 || !exists) { 256 - td.classList.add('empty'); 257 - } 258 - 259 - td.innerHTML = createDayContent(dateStr, day); 260 - 261 - if (dateStr === TODAY_DATE_STRING) { 262 - td.classList.add('today'); 263 - } 264 - 265 - row.appendChild(td); 266 - } 267 - 268 - // Fill remaining cells in last row 269 - while(row.children.length<7){ 270 - const td = document.createElement('td'); 271 - td.classList.add('empty'); 272 - row.appendChild(td); 273 - } 274 - tbody.appendChild(row); 275 - } 276 - 277 - 278 - function generateYearMonth(year, month) { 279 - return year + String(month + 1).padStart(2, '0'); 280 - } 281 - 282 - function navigateMonth(direction) { 283 - const newIndex = currentIndex + direction; 284 - 285 - if (newIndex >= 0 && newIndex < months.length) { 286 - currentIndex = newIndex; 287 - showMonth(months[currentIndex]); 288 - return; 289 - } 290 - 291 - // Generate new month if at boundaries 292 - const isNext = direction > 0; 293 - const referenceYm = months[isNext ? months.length - 1 : 0]; 294 - const year = parseInt(referenceYm.slice(0,4)); 295 - const month = parseInt(referenceYm.slice(4)) - 1; 296 - const newDate = new Date(year, month + direction, 1); 297 - const newYm = generateYearMonth(newDate.getFullYear(), newDate.getMonth()); 298 - 299 - if (isNext) { 300 - months.push(newYm); 301 - currentIndex = months.length - 1; 302 - } else { 303 - months.unshift(newYm); 304 - currentIndex = 0; 305 - } 306 - showMonth(months[currentIndex]); 307 - } 308 - 309 - 310 - // Wire up app bar buttons 311 - if (prevBtn) prevBtn.onclick = () => navigateMonth(-1); 312 - if (nextBtn) nextBtn.onclick = () => navigateMonth(1); 313 - if (todayBtn) { 314 - todayBtn.onclick = () => { 315 - currentIndex = months.indexOf(CURRENT_YEAR_MONTH); 316 - if (currentIndex === -1) { 317 - months.push(CURRENT_YEAR_MONTH); 318 - months.sort(); 319 - currentIndex = months.indexOf(CURRENT_YEAR_MONTH); 320 - } 321 - showMonth(CURRENT_YEAR_MONTH); 322 - }; 323 - } 324 - 325 - // Keyboard shortcuts 326 - document.addEventListener('keydown', (e) => { 327 - if (e.target.matches('input, textarea, select')) return; 328 - 329 - if (e.key === 'ArrowLeft') { 330 - e.preventDefault(); 331 - navigateMonth(-1); 332 - } 333 - if (e.key === 'ArrowRight') { 334 - e.preventDefault(); 335 - navigateMonth(1); 336 - } 337 - if (e.key === 't' || e.key === 'T') { 338 - e.preventDefault(); 339 - if (todayBtn) todayBtn.click(); 340 - } 341 - }); 342 - 343 - // Load calendar data on page load 344 - loadDays(); 345 - </script>
-68
apps/calendar/app_bar.html
··· 1 - {% set current_view = view|default('month') %} 2 - 3 - {% if current_view == 'month' %} 4 - {# Month navigation for calendar month view #} 5 - <style> 6 - .month-nav { 7 - display: flex; 8 - align-items: center; 9 - gap: 0.5rem; 10 - } 11 - 12 - .month-nav-btn { 13 - display: flex; 14 - align-items: center; 15 - justify-content: center; 16 - padding: 0.5rem 0.75rem; 17 - background: #f3f4f6; 18 - border: 1px solid #d1d5db; 19 - border-radius: 6px; 20 - color: #374151; 21 - font-size: 1rem; 22 - cursor: pointer; 23 - text-decoration: none; 24 - transition: background 0.15s, border-color 0.15s; 25 - } 26 - 27 - .month-nav-btn:hover { 28 - background: #e5e7eb; 29 - border-color: #9ca3af; 30 - } 31 - 32 - .month-nav-btn:active { 33 - transform: scale(0.98); 34 - } 35 - 36 - .month-nav-btn.today { 37 - background: #2563eb; 38 - border-color: #2563eb; 39 - color: white; 40 - font-weight: 600; 41 - } 42 - 43 - .month-nav-btn.today:hover { 44 - background: #1d4ed8; 45 - border-color: #1d4ed8; 46 - } 47 - 48 - .month-nav-btn.today.hidden { 49 - display: none; 50 - } 51 - 52 - .month-nav-label { 53 - font-size: 1.1rem; 54 - font-weight: 600; 55 - color: #374151; 56 - min-width: 160px; 57 - text-align: center; 58 - } 59 - </style> 60 - 61 - <div class="month-nav" id="month-nav"> 62 - <button class="month-nav-btn prev" id="month-nav-prev" title="Previous month (←)">←</button> 63 - <span class="month-nav-label" id="month-nav-label"></span> 64 - <button class="month-nav-btn next" id="month-nav-next" title="Next month (→)">→</button> 65 - <button class="month-nav-btn today hidden" id="month-nav-today" title="Today (t)">Today</button> 66 - </div> 67 - 68 - {% endif %}
+11 -1
apps/calendar/routes.py
··· 3 3 import json 4 4 import os 5 5 import re 6 + from datetime import datetime 6 7 from typing import Any 7 8 8 - from flask import Blueprint, jsonify, render_template 9 + from flask import Blueprint, jsonify, redirect, render_template, url_for 9 10 10 11 from convey import state 11 12 from convey.utils import DATE_RE, adjacent_days, format_date, format_date_short ··· 16 17 __name__, 17 18 url_prefix="/app/calendar", 18 19 ) 20 + 21 + 22 + @calendar_bp.route("/") 23 + def index(): 24 + """Redirect to yesterday's calendar view.""" 25 + from datetime import timedelta 26 + 27 + yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d") 28 + return redirect(url_for("app:calendar.calendar_day", day=yesterday)) 19 29 20 30 21 31 @calendar_bp.route("/<day>")
+2 -4
apps/calendar/workspace.html
··· 1 1 {# Calendar app workspace - routes to different views based on view parameter #} 2 2 3 - {% set view = view|default('month') %} 3 + {% set view = view|default('day') %} 4 4 5 - {% if view == 'month' %} 6 - {% include 'calendar/_month.html' %} 7 - {% elif view == 'day' %} 5 + {% if view == 'day' %} 8 6 {% include 'calendar/_day.html' %} 9 7 {% elif view == '_dev_screens_list' %} 10 8 {% include 'calendar/_dev_screens_list.html' %}
+114 -2
convey/static/app.css
··· 231 231 top: var(--facet-bar-height); /* Sits directly below facet bar */ 232 232 left: 50%; 233 233 transform: translateX(-50%); 234 - width: 210px; 235 - height: 36px; 234 + width: 300px; 235 + height: 40px; 236 236 display: flex; 237 237 align-items: center; 238 238 justify-content: space-between; ··· 325 325 ::view-transition-old(date-nav), 326 326 ::view-transition-new(date-nav) { 327 327 animation: none; 328 + } 329 + 330 + /* When picker is open, remove bottom border radius for seamless connection */ 331 + .date-nav.picker-open { 332 + border-radius: 0; 333 + box-shadow: none; 334 + } 335 + 336 + .date-nav.picker-open::before { 337 + border-radius: 0; 338 + } 339 + 340 + /* Month Picker Dropdown */ 341 + .month-picker { 342 + position: absolute; 343 + top: 100%; 344 + left: 0; 345 + right: 0; 346 + background: white; 347 + border: 1px solid var(--facet-border, #e0e0e0); 348 + border-top: none; 349 + border-radius: 0 0 12px 12px; 350 + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); 351 + overflow: hidden; 352 + z-index: 1; 353 + 354 + /* Closed state */ 355 + max-height: 0; 356 + opacity: 0; 357 + pointer-events: none; 358 + transition: max-height 0.2s ease, opacity 0.15s ease; 359 + } 360 + 361 + /* Facet tint background */ 362 + .month-picker::before { 363 + content: ''; 364 + position: absolute; 365 + inset: 0; 366 + background: var(--facet-bg, transparent); 367 + border-radius: 0 0 12px 12px; 368 + pointer-events: none; 369 + z-index: -1; 370 + } 371 + 372 + .month-picker.open { 373 + max-height: 280px; 374 + opacity: 1; 375 + pointer-events: auto; 376 + } 377 + 378 + .mp-weekdays { 379 + display: grid; 380 + grid-template-columns: repeat(7, 1fr); 381 + padding: 8px 8px 4px; 382 + font-size: 11px; 383 + font-weight: 600; 384 + color: #9ca3af; 385 + text-align: center; 386 + } 387 + 388 + .mp-grid { 389 + display: grid; 390 + grid-template-columns: repeat(7, 1fr); 391 + gap: 3px; 392 + padding: 4px 8px 8px; 393 + } 394 + 395 + .mp-day { 396 + aspect-ratio: 1; 397 + display: flex; 398 + align-items: center; 399 + justify-content: center; 400 + font-size: 13px; 401 + border-radius: 6px; 402 + cursor: pointer; 403 + transition: background 0.1s; 404 + 405 + /* Heat map: intensity controls facet color opacity */ 406 + background: color-mix(in srgb, var(--facet-color) calc(var(--intensity, 0) * 30%), transparent); 407 + } 408 + 409 + .mp-day:hover:not(.mp-other) { 410 + background: color-mix(in srgb, var(--facet-color) 40%, transparent); 411 + } 412 + 413 + /* Empty days - striped pattern */ 414 + .mp-day.mp-empty { 415 + background: 416 + repeating-linear-gradient(45deg, transparent, transparent 2px, rgba(0,0,0,0.03) 2px, rgba(0,0,0,0.03) 4px), 417 + color-mix(in srgb, var(--facet-color) calc(var(--intensity, 0) * 30%), transparent); 418 + color: #999; 419 + } 420 + 421 + /* Other month days (leading/trailing) */ 422 + .mp-day.mp-other { 423 + visibility: hidden; 424 + } 425 + 426 + /* Today indicator */ 427 + .mp-day.mp-today { 428 + box-shadow: inset 0 0 0 2px #007bff; 429 + } 430 + 431 + /* Selected day */ 432 + .mp-day.mp-selected { 433 + background: var(--facet-color, #3b82f6); 434 + color: white; 435 + font-weight: 600; 436 + } 437 + 438 + .mp-day.mp-selected:hover { 439 + background: var(--facet-color, #3b82f6); 328 440 } 329 441 330 442 /* Status Pane */
+294
convey/static/month-picker.js
··· 1 + /** 2 + * Month Picker Widget 3 + * Dropdown calendar for date navigation with per-app heat map support. 4 + */ 5 + window.MonthPicker = (function() { 6 + // State 7 + let container = null; 8 + let currentMonth = null; // YYYYMM being displayed 9 + let selectedDay = null; // YYYYMMDD from date-nav 10 + let dayLabel = null; // Original day label text 11 + let app = null; // Current app name 12 + let availableDays = new Set(); 13 + let isVisible = false; 14 + 15 + // External elements (set during init) 16 + let labelEl = null; 17 + let prevBtn = null; 18 + let nextBtn = null; 19 + 20 + // Cache: {YYYYMM: {data, facet}} 21 + const cache = {}; 22 + const providers = {}; 23 + 24 + // Constants 25 + const TODAY = getToday(); 26 + const CURRENT_MONTH = TODAY.slice(0, 6); 27 + const WEEKDAYS = ['S', 'M', 'T', 'W', 'T', 'F', 'S']; 28 + 29 + function getToday() { 30 + const now = new Date(); 31 + return now.getFullYear() + 32 + String(now.getMonth() + 1).padStart(2, '0') + 33 + String(now.getDate()).padStart(2, '0'); 34 + } 35 + 36 + function parseYM(ym) { 37 + return { 38 + year: parseInt(ym.slice(0, 4)), 39 + month: parseInt(ym.slice(4)) - 1 40 + }; 41 + } 42 + 43 + function formatYM(year, month) { 44 + const d = new Date(year, month, 1); 45 + return d.getFullYear() + String(d.getMonth() + 1).padStart(2, '0'); 46 + } 47 + 48 + function adjacentMonth(ym, delta) { 49 + const { year, month } = parseYM(ym); 50 + return formatYM(year, month + delta); 51 + } 52 + 53 + function getMonthLabel(ym) { 54 + const { year, month } = parseYM(ym); 55 + const d = new Date(year, month, 1); 56 + const monthName = d.toLocaleString('default', { month: 'short' }); 57 + const yearShort = String(year).slice(2); 58 + return `${monthName} '${yearShort}`; 59 + } 60 + 61 + function getDaysInMonth(ym) { 62 + const { year, month } = parseYM(ym); 63 + return new Date(year, month + 1, 0).getDate(); 64 + } 65 + 66 + function getStartDayOfWeek(ym) { 67 + const { year, month } = parseYM(ym); 68 + return new Date(year, month, 1).getDay(); 69 + } 70 + 71 + // Data fetching 72 + async function fetchAvailableDays() { 73 + try { 74 + const resp = await fetch('/app/calendar/api/days'); 75 + if (resp.ok) { 76 + const days = await resp.json(); 77 + availableDays = new Set(days || []); 78 + } 79 + } catch (e) { 80 + console.warn('[MonthPicker] Failed to fetch available days:', e); 81 + } 82 + } 83 + 84 + async function fetchMonthData(ym) { 85 + const provider = providers[app]; 86 + if (!provider) return null; 87 + 88 + try { 89 + const facet = window.selectedFacet || null; 90 + return await provider(ym, facet); 91 + } catch (e) { 92 + console.warn(`[MonthPicker] Provider error for ${ym}:`, e); 93 + return null; 94 + } 95 + } 96 + 97 + async function getMonthData(ym) { 98 + const facet = window.selectedFacet || null; 99 + const cacheKey = ym; 100 + 101 + if (cache[cacheKey]?.facet === facet) { 102 + return cache[cacheKey].data; 103 + } 104 + 105 + const data = await fetchMonthData(ym); 106 + cache[cacheKey] = { data, facet }; 107 + return data; 108 + } 109 + 110 + function preloadAdjacentMonths(ym) { 111 + const prev = adjacentMonth(ym, -1); 112 + const next = adjacentMonth(ym, 1); 113 + getMonthData(prev); 114 + getMonthData(next); 115 + } 116 + 117 + // Rendering 118 + function render() { 119 + if (!container) return; 120 + 121 + const data = cache[currentMonth]?.data || {}; 122 + const daysInMonth = getDaysInMonth(currentMonth); 123 + const startDay = getStartDayOfWeek(currentMonth); 124 + 125 + // Calculate max for heat map scaling 126 + let maxCount = 0; 127 + for (let d = 1; d <= daysInMonth; d++) { 128 + const dateStr = currentMonth + String(d).padStart(2, '0'); 129 + maxCount = Math.max(maxCount, data[dateStr] || 0); 130 + } 131 + 132 + // Build HTML (no header - nav is in date-nav bar) 133 + let html = ` 134 + <div class="mp-weekdays"> 135 + ${WEEKDAYS.map(d => `<span>${d}</span>`).join('')} 136 + </div> 137 + <div class="mp-grid"> 138 + `; 139 + 140 + // Leading empty cells 141 + for (let i = 0; i < startDay; i++) { 142 + html += `<div class="mp-day mp-other"></div>`; 143 + } 144 + 145 + // Days of month 146 + for (let d = 1; d <= daysInMonth; d++) { 147 + const dateStr = currentMonth + String(d).padStart(2, '0'); 148 + const count = data[dateStr] || 0; 149 + const exists = availableDays.has(dateStr); 150 + 151 + const classes = ['mp-day']; 152 + if (dateStr === TODAY) classes.push('mp-today'); 153 + if (dateStr === selectedDay) classes.push('mp-selected'); 154 + if (count === 0 || !exists) classes.push('mp-empty'); 155 + 156 + const rawIntensity = maxCount > 0 ? count / maxCount : 0; 157 + const intensity = count > 0 ? 0.2 + (rawIntensity * 0.8) : 0; 158 + 159 + html += `<div class="${classes.join(' ')}" data-day="${dateStr}" style="--intensity: ${intensity}">${d}</div>`; 160 + } 161 + 162 + // Trailing empty cells 163 + const totalCells = startDay + daysInMonth; 164 + const remainder = totalCells % 7; 165 + if (remainder > 0) { 166 + for (let i = 0; i < 7 - remainder; i++) { 167 + html += `<div class="mp-day mp-other"></div>`; 168 + } 169 + } 170 + 171 + html += '</div>'; 172 + container.innerHTML = html; 173 + 174 + // Update the date-nav label to show month 175 + if (labelEl) { 176 + labelEl.textContent = getMonthLabel(currentMonth); 177 + } 178 + } 179 + 180 + async function showMonth(ym) { 181 + currentMonth = ym; 182 + await getMonthData(ym); 183 + render(); 184 + preloadAdjacentMonths(ym); 185 + } 186 + 187 + function navigateMonth(delta) { 188 + if (!currentMonth) return; 189 + showMonth(adjacentMonth(currentMonth, delta)); 190 + } 191 + 192 + // Event handlers 193 + function handleClick(e) { 194 + const day = e.target.closest('.mp-day'); 195 + if (day && !day.classList.contains('mp-other')) { 196 + const dateStr = day.dataset.day; 197 + if (dateStr) { 198 + window.location.href = `/app/${app}/${dateStr}`; 199 + } 200 + } 201 + } 202 + 203 + function handleKeydown(e) { 204 + if (!isVisible) return; 205 + 206 + if (e.key === 'Escape') { 207 + e.preventDefault(); 208 + hide(); 209 + } 210 + } 211 + 212 + function handleClickOutside(e) { 213 + if (!isVisible) return; 214 + const dateNav = document.querySelector('.date-nav'); 215 + if (dateNav && !dateNav.contains(e.target)) { 216 + hide(); 217 + } 218 + } 219 + 220 + function handleFacetSwitch() { 221 + if (isVisible && currentMonth) { 222 + showMonth(currentMonth); 223 + } 224 + } 225 + 226 + // Public API 227 + function init(options) { 228 + app = options.app; 229 + selectedDay = options.currentDay; 230 + currentMonth = selectedDay ? selectedDay.slice(0, 6) : CURRENT_MONTH; 231 + 232 + container = document.querySelector(options.container || '.month-picker'); 233 + labelEl = document.getElementById('date-nav-label'); 234 + prevBtn = document.getElementById('date-nav-prev'); 235 + nextBtn = document.getElementById('date-nav-next'); 236 + 237 + if (labelEl) { 238 + dayLabel = labelEl.textContent; 239 + } 240 + 241 + if (!container) { 242 + console.warn('[MonthPicker] Container not found'); 243 + return; 244 + } 245 + 246 + container.addEventListener('click', handleClick); 247 + document.addEventListener('keydown', handleKeydown); 248 + document.addEventListener('click', handleClickOutside); 249 + window.addEventListener('facet.switch', handleFacetSwitch); 250 + 251 + // Prefetch data 252 + fetchAvailableDays().then(() => { 253 + getMonthData(currentMonth); 254 + }); 255 + } 256 + 257 + function show() { 258 + if (!container) return; 259 + isVisible = true; 260 + container.classList.add('open'); 261 + document.querySelector('.date-nav')?.classList.add('picker-open'); 262 + showMonth(currentMonth); 263 + } 264 + 265 + function hide() { 266 + if (!container) return; 267 + isVisible = false; 268 + container.classList.remove('open'); 269 + document.querySelector('.date-nav')?.classList.remove('picker-open'); 270 + 271 + // Restore day label 272 + if (labelEl && dayLabel) { 273 + labelEl.textContent = dayLabel; 274 + } 275 + } 276 + 277 + function toggle() { 278 + isVisible ? hide() : show(); 279 + } 280 + 281 + function registerDataProvider(appName, fn) { 282 + providers[appName] = fn; 283 + } 284 + 285 + return { 286 + init, 287 + show, 288 + hide, 289 + toggle, 290 + isOpen: () => isVisible, 291 + navigateMonth, 292 + registerDataProvider 293 + }; 294 + })();
+52 -27
convey/templates/date_nav.html
··· 2 2 {# Requires: day (YYYYMMDD), day_formatted ("Sat Nov 29"), app (app name) #} 3 3 {% if day %} 4 4 <div class="date-nav"> 5 - <button class="date-nav-arrow" id="date-nav-prev" title="Previous day (&#8592)">&#8249;</button> 5 + <button class="date-nav-arrow" id="date-nav-prev" title="Previous day (←)">‹</button> 6 6 <span class="date-nav-label" id="date-nav-label" title="Pick date">{{ day_formatted }}</span> 7 - <input type="date" id="date-nav-input"> 8 - <button class="date-nav-arrow" id="date-nav-next" title="Next day (&#8594)">&#8250;</button> 7 + <button class="date-nav-arrow" id="date-nav-next" title="Next day (→)">›</button> 8 + <div class="month-picker"></div> 9 9 </div> 10 10 11 + <script src="{{ url_for('root.static', filename='month-picker.js') }}"></script> 11 12 <script> 12 13 (function() { 13 14 const currentDay = '{{ day }}'; 14 15 const app = '{{ app }}'; 15 16 const baseUrl = `/app/${app}/`; 16 - 17 - function toInputFormat(day) { 18 - return `${day.substring(0, 4)}-${day.substring(4, 6)}-${day.substring(6, 8)}`; 19 - } 20 - 21 - function fromInputFormat(str) { 22 - return str.replace(/-/g, ''); 23 - } 24 17 25 18 function adjustDay(day, delta) { 26 19 const year = parseInt(day.substring(0, 4)); ··· 44 37 window.location.href = `${baseUrl}${day}`; 45 38 } 46 39 47 - const picker = document.getElementById('date-nav-input'); 48 - const label = document.getElementById('date-nav-label'); 40 + // Register calendar data provider (if calendar app) 41 + if (app === 'calendar') { 42 + MonthPicker.registerDataProvider('calendar', async (month, facet) => { 43 + const resp = await fetch(`/app/calendar/api/stats/${month}`); 44 + if (!resp.ok) return {}; 45 + const raw = await resp.json(); 46 + const result = {}; 47 + for (const [day, facetCounts] of Object.entries(raw)) { 48 + result[day] = facet 49 + ? (facetCounts[facet] || 0) 50 + : Object.values(facetCounts).reduce((a, b) => a + b, 0); 51 + } 52 + return result; 53 + }); 54 + } 49 55 50 - // Set initial picker value 51 - picker.value = toInputFormat(currentDay); 56 + // Initialize month picker 57 + MonthPicker.init({ 58 + app: app, 59 + currentDay: currentDay, 60 + container: '.month-picker' 61 + }); 52 62 53 - // Click label to open date picker 54 - label.addEventListener('click', () => picker.showPicker()); 63 + // Click label to toggle month picker 64 + document.getElementById('date-nav-label').addEventListener('click', () => { 65 + MonthPicker.toggle(); 66 + }); 55 67 56 - // Date picker change handler 57 - picker.addEventListener('change', (e) => { 58 - const newDay = fromInputFormat(e.target.value); 59 - if (newDay && newDay !== currentDay) { 60 - navigate(newDay); 68 + // Arrow buttons: day nav when closed, month nav when open 69 + document.getElementById('date-nav-prev').addEventListener('click', () => { 70 + if (MonthPicker.isOpen()) { 71 + MonthPicker.navigateMonth(-1); 72 + } else { 73 + navigate(adjustDay(currentDay, -1)); 61 74 } 62 75 }); 63 76 64 - // Button handlers 65 - document.getElementById('date-nav-prev').addEventListener('click', () => navigate(adjustDay(currentDay, -1))); 66 - document.getElementById('date-nav-next').addEventListener('click', () => navigate(adjustDay(currentDay, 1))); 77 + document.getElementById('date-nav-next').addEventListener('click', () => { 78 + if (MonthPicker.isOpen()) { 79 + MonthPicker.navigateMonth(1); 80 + } else { 81 + navigate(adjustDay(currentDay, 1)); 82 + } 83 + }); 67 84 68 85 // Keyboard shortcuts 69 86 document.addEventListener('keydown', (e) => { ··· 71 88 72 89 if (e.key === 'ArrowLeft') { 73 90 e.preventDefault(); 74 - navigate(adjustDay(currentDay, -1)); 91 + if (MonthPicker.isOpen()) { 92 + MonthPicker.navigateMonth(-1); 93 + } else { 94 + navigate(adjustDay(currentDay, -1)); 95 + } 75 96 } 76 97 if (e.key === 'ArrowRight') { 77 98 e.preventDefault(); 78 - navigate(adjustDay(currentDay, 1)); 99 + if (MonthPicker.isOpen()) { 100 + MonthPicker.navigateMonth(1); 101 + } else { 102 + navigate(adjustDay(currentDay, 1)); 103 + } 79 104 } 80 105 if (e.key === 't' || e.key === 'T') { 81 106 e.preventDefault();