diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-02-01 10:52:28 -1000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-02-01 10:52:28 -1000 |
| commit | 1c6552117038cb7c01e016dbf1ac062e1d9f9c73 (patch) | |
| tree | ff65c67a40e08a14f89fe3057a8ac4886d94b75b | |
| parent | e0e0dc11195c0e0516b45975de51df1dc98f83de (diff) | |
Improve timeline view with dynamic bounds, now line, and overlap handling
- Add dynamic calendar clipping: show 1 hour before/after events instead of hardcoded 6am-10pm
- Add "NOW" line indicator showing current time position
- Improve time label readability with larger font and better contrast
- Add overlap detection with column-based indentation for concurrent events
- Apply calendar view to Tomorrow section (matching Today's layout)
- Fix auto-refresh switching to tasks tab (default was 'tasks' instead of 'timeline')
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
| -rw-r--r-- | internal/handlers/handlers.go | 7 | ||||
| -rw-r--r-- | internal/handlers/handlers_test.go | 9 | ||||
| -rw-r--r-- | internal/handlers/timeline.go | 87 | ||||
| -rw-r--r-- | web/static/js/app.js | 2 | ||||
| -rw-r--r-- | web/templates/partials/timeline-tab.html | 281 |
5 files changed, 333 insertions, 53 deletions
diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index bba12ad..c384c48 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -36,8 +36,13 @@ type Handler struct { // New creates a new Handler instance func New(s *store.Store, todoist api.TodoistAPI, trello api.TrelloAPI, planToEat api.PlanToEatAPI, googleCalendar api.GoogleCalendarAPI, googleTasks api.GoogleTasksAPI, cfg *config.Config) *Handler { + // Template functions + funcMap := template.FuncMap{ + "subtract": func(a, b int) int { return a - b }, + } + // Parse templates including partials - tmpl, err := template.ParseGlob(filepath.Join(cfg.TemplateDir, "*.html")) + tmpl, err := template.New("").Funcs(funcMap).ParseGlob(filepath.Join(cfg.TemplateDir, "*.html")) if err != nil { log.Printf("Warning: failed to parse templates: %v", err) } diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go index 3367ef6..96cb911 100644 --- a/internal/handlers/handlers_test.go +++ b/internal/handlers/handlers_test.go @@ -64,11 +64,16 @@ func setupTestDB(t *testing.T) (*store.Store, func()) { func loadTestTemplates(t *testing.T) *template.Template { t.Helper() + // Template functions (must match handlers.go) + funcMap := template.FuncMap{ + "subtract": func(a, b int) int { return a - b }, + } + // Get path relative to project root - tmpl, err := template.ParseGlob(filepath.Join("web", "templates", "*.html")) + tmpl, err := template.New("").Funcs(funcMap).ParseGlob(filepath.Join("web", "templates", "*.html")) if err != nil { // Try from internal/handlers (2 levels up) - tmpl, err = template.ParseGlob(filepath.Join("..", "..", "web", "templates", "*.html")) + tmpl, err = template.New("").Funcs(funcMap).ParseGlob(filepath.Join("..", "..", "web", "templates", "*.html")) if err != nil { t.Logf("Warning: failed to parse templates: %v", err) return nil diff --git a/internal/handlers/timeline.go b/internal/handlers/timeline.go index 5e583d6..fa5bcec 100644 --- a/internal/handlers/timeline.go +++ b/internal/handlers/timeline.go @@ -21,6 +21,19 @@ type TimelineData struct { TodayLabel string // e.g., "Today - Monday" TomorrowLabel string // e.g., "Tomorrow - Tuesday" LaterLabel string // e.g., "Wednesday, Jan 29" + + // Calendar view bounds (1 hour before first event, 1 hour after last) + TodayStartHour int + TodayEndHour int + TodayHours []int // Slice of hours to render + + TomorrowStartHour int + TomorrowEndHour int + TomorrowHours []int + + // Current time for "now" line + NowHour int + NowMinute int } // HandleTimeline renders the timeline view @@ -70,6 +83,8 @@ func (h *Handler) HandleTimeline(w http.ResponseWriter, r *http.Request) { TodayLabel: "Today - " + now.Format("Monday"), TomorrowLabel: "Tomorrow - " + tomorrow.Format("Monday"), LaterLabel: dayAfterTomorrow.Format("Monday, Jan 2") + "+", + NowHour: now.Hour(), + NowMinute: now.Minute(), } for _, item := range items { switch item.DaySection { @@ -82,5 +97,77 @@ func (h *Handler) HandleTimeline(w http.ResponseWriter, r *http.Request) { } } + // Calculate calendar bounds for Today (1 hour buffer before/after timed events) + data.TodayStartHour, data.TodayEndHour = calcCalendarBounds(data.TodayItems, now.Hour()) + for h := data.TodayStartHour; h <= data.TodayEndHour; h++ { + data.TodayHours = append(data.TodayHours, h) + } + + // Calculate calendar bounds for Tomorrow + data.TomorrowStartHour, data.TomorrowEndHour = calcCalendarBounds(data.TomorrowItems, -1) + for h := data.TomorrowStartHour; h <= data.TomorrowEndHour; h++ { + data.TomorrowHours = append(data.TomorrowHours, h) + } + HTMLResponse(w, h.templates, "timeline-tab", data) } + +// calcCalendarBounds returns start/end hours for calendar view based on timed events. +// If currentHour >= 0, it's included in the range (for "now" line visibility). +// Returns hours clamped to 0-23 with 1-hour buffer before/after events. +func calcCalendarBounds(items []models.TimelineItem, currentHour int) (startHour, endHour int) { + minHour := 23 + maxHour := 0 + hasTimedEvents := false + + for _, item := range items { + // Skip all-day/overdue items (midnight with no real time) + if item.IsAllDay || item.IsOverdue { + continue + } + h := item.Time.Hour() + // Skip midnight items unless they have an end time + if h == 0 && item.Time.Minute() == 0 && item.EndTime == nil { + continue + } + hasTimedEvents = true + if h < minHour { + minHour = h + } + endH := h + if item.EndTime != nil { + endH = item.EndTime.Hour() + } + if endH > maxHour { + maxHour = endH + } + } + + // Include current hour if provided + if currentHour >= 0 { + hasTimedEvents = true + if currentHour < minHour { + minHour = currentHour + } + if currentHour > maxHour { + maxHour = currentHour + } + } + + if !hasTimedEvents { + // Default: show 8am-6pm + return 8, 18 + } + + // Add 1 hour buffer, clamp to valid range + startHour = minHour - 1 + if startHour < 0 { + startHour = 0 + } + endHour = maxHour + 1 + if endHour > 23 { + endHour = 23 + } + + return startHour, endHour +} diff --git a/web/static/js/app.js b/web/static/js/app.js index 380bb70..3ecd0a1 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -20,7 +20,7 @@ function getCSRFToken() { // Track current active tab (read from URL for state persistence) const urlParams = new URLSearchParams(window.location.search); -let currentTab = urlParams.get('tab') || 'tasks'; +let currentTab = urlParams.get('tab') || 'timeline'; let autoRefreshTimer = null; // Initialize on page load diff --git a/web/templates/partials/timeline-tab.html b/web/templates/partials/timeline-tab.html index 8745d1d..bdf2007 100644 --- a/web/templates/partials/timeline-tab.html +++ b/web/templates/partials/timeline-tab.html @@ -2,24 +2,51 @@ <style> .calendar-grid { position: relative; - border-left: 2px solid rgba(255,255,255,0.15); - margin-left: 50px; - min-height: 640px; + border-left: 2px solid rgba(255,255,255,0.2); + margin-left: 56px; } .calendar-hour { position: relative; height: 40px; - border-bottom: 1px solid rgba(255,255,255,0.05); + border-bottom: 1px solid rgba(255,255,255,0.08); } .calendar-hour-label { position: absolute; - left: -50px; - top: -8px; - font-size: 0.7em; - color: rgba(255,255,255,0.4); - width: 44px; + left: -56px; + top: -9px; + font-size: 0.8rem; + font-weight: 500; + color: rgba(255,255,255,0.6); + width: 50px; text-align: right; } + .now-line { + position: absolute; + left: -8px; + right: 0; + height: 2px; + background: #ef4444; + z-index: 5; + } + .now-line::before { + content: 'NOW'; + position: absolute; + left: -48px; + top: -7px; + font-size: 0.65rem; + font-weight: 600; + color: #ef4444; + letter-spacing: 0.05em; + } + .now-dot { + position: absolute; + left: -5px; + top: -4px; + width: 10px; + height: 10px; + background: #ef4444; + border-radius: 50%; + } .calendar-event { position: absolute; left: 8px; @@ -125,26 +152,18 @@ {{end}} </div> - <!-- Calendar Grid (6am-10pm) --> - <div class="calendar-grid" id="today-calendar"> + <!-- Calendar Grid (dynamic hours) --> + <div class="calendar-grid" id="today-calendar" data-start-hour="{{.TodayStartHour}}" data-now-hour="{{.NowHour}}" data-now-minute="{{.NowMinute}}"> <!-- Hour rows --> - <div class="calendar-hour" data-hour="6"><span class="calendar-hour-label">6am</span></div> - <div class="calendar-hour" data-hour="7"><span class="calendar-hour-label">7am</span></div> - <div class="calendar-hour" data-hour="8"><span class="calendar-hour-label">8am</span></div> - <div class="calendar-hour" data-hour="9"><span class="calendar-hour-label">9am</span></div> - <div class="calendar-hour" data-hour="10"><span class="calendar-hour-label">10am</span></div> - <div class="calendar-hour" data-hour="11"><span class="calendar-hour-label">11am</span></div> - <div class="calendar-hour" data-hour="12"><span class="calendar-hour-label">12pm</span></div> - <div class="calendar-hour" data-hour="13"><span class="calendar-hour-label">1pm</span></div> - <div class="calendar-hour" data-hour="14"><span class="calendar-hour-label">2pm</span></div> - <div class="calendar-hour" data-hour="15"><span class="calendar-hour-label">3pm</span></div> - <div class="calendar-hour" data-hour="16"><span class="calendar-hour-label">4pm</span></div> - <div class="calendar-hour" data-hour="17"><span class="calendar-hour-label">5pm</span></div> - <div class="calendar-hour" data-hour="18"><span class="calendar-hour-label">6pm</span></div> - <div class="calendar-hour" data-hour="19"><span class="calendar-hour-label">7pm</span></div> - <div class="calendar-hour" data-hour="20"><span class="calendar-hour-label">8pm</span></div> - <div class="calendar-hour" data-hour="21"><span class="calendar-hour-label">9pm</span></div> - <div class="calendar-hour" data-hour="22"><span class="calendar-hour-label">10pm</span></div> + {{range .TodayHours}} + <div class="calendar-hour" data-hour="{{.}}"> + <span class="calendar-hour-label">{{if eq . 0}}12am{{else if lt . 12}}{{.}}am{{else if eq . 12}}12pm{{else}}{{subtract . 12}}pm{{end}}</span> + </div> + {{end}} + <!-- Now line (positioned by JS) --> + <div class="now-line" id="now-line" style="display:none;"> + <div class="now-dot"></div> + </div> <!-- Timed events positioned by JavaScript --> {{range .TodayItems}} @@ -183,40 +202,57 @@ <script> (function() { const calendar = document.getElementById('today-calendar'); + if (!calendar) return; const events = calendar.querySelectorAll('.calendar-event'); const hourHeight = 40; - const startHour = 6; - const endHour = 22; + const startHour = parseInt(calendar.dataset.startHour) || 8; + const nowHour = parseInt(calendar.dataset.nowHour); + const nowMinute = parseInt(calendar.dataset.nowMinute); + // Build event data for overlap detection + const eventData = []; events.forEach(function(el) { const hour = parseInt(el.dataset.hour); const minute = parseInt(el.dataset.minute); const endHourVal = parseInt(el.dataset.endHour); const endMinute = parseInt(el.dataset.endMinute); - - // Skip events outside the visible range - if (hour < startHour || hour > endHour) { - el.remove(); - return; - } - - // Calculate position - const top = (hour - startHour) * hourHeight + (minute / 60) * hourHeight; - - // Calculate height based on duration - let durationMinutes; + const startMin = hour * 60 + minute; + let endMin; if (endHourVal > hour || (endHourVal === hour && endMinute > minute)) { - durationMinutes = (endHourVal - hour) * 60 + (endMinute - minute); + endMin = endHourVal * 60 + endMinute; } else { - durationMinutes = 55; // Default ~1 hour for tasks without duration + endMin = startMin + 55; } + eventData.push({ el, startMin, endMin, column: 0 }); + }); + + // Assign columns for overlapping events + eventData.sort((a, b) => a.startMin - b.startMin); + for (let i = 0; i < eventData.length; i++) { + const ev = eventData[i]; + const overlaps = eventData.filter((other, j) => + j < i && other.endMin > ev.startMin && other.startMin < ev.endMin + ); + const usedCols = overlaps.map(o => o.column); + let col = 0; + while (usedCols.includes(col)) col++; + ev.column = col; + } + + // Position events with column-based indentation + eventData.forEach(function(ev) { + const el = ev.el; + const hour = parseInt(el.dataset.hour); + const minute = parseInt(el.dataset.minute); + const top = (hour - startHour) * hourHeight + (minute / 60) * hourHeight; + const durationMinutes = ev.endMin - ev.startMin; const height = Math.max(28, (durationMinutes / 60) * hourHeight - 4); el.style.top = top + 'px'; el.style.height = height + 'px'; + el.style.left = (8 + ev.column * 16) + 'px'; el.style.display = 'block'; - // Click to open URL const url = el.dataset.url; if (url) { el.style.cursor = 'pointer'; @@ -228,9 +264,19 @@ } }); + // Position the "now" line + const nowLine = document.getElementById('now-line'); + if (nowLine && !isNaN(nowHour)) { + const nowTop = (nowHour - startHour) * hourHeight + (nowMinute / 60) * hourHeight; + if (nowTop >= 0) { + nowLine.style.top = nowTop + 'px'; + nowLine.style.display = 'block'; + } + } + // Hide untimed section if empty const untimedSection = document.getElementById('untimed-section'); - if (untimedSection && untimedSection.children.length === 0) { + if (untimedSection && untimedSection.querySelectorAll('.untimed-item').length === 0) { untimedSection.style.display = 'none'; } })(); @@ -238,7 +284,7 @@ </details> {{end}} - <!-- Tomorrow Section (Expanded) --> + <!-- Tomorrow Section (Calendar View) --> {{if .TomorrowItems}} <details class="group" open> <summary class="text-lg font-semibold mb-3 flex items-center gap-2 text-white/70 cursor-pointer hover:text-white/90 sticky top-0 bg-black/20 backdrop-blur-md py-2 z-10 rounded-lg px-2 list-none"> @@ -248,11 +294,148 @@ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path> </svg> </summary> - <div class="space-y-2 relative pl-4 border-l border-white/10 ml-2"> + + <!-- Overdue + All-Day + Untimed Items Section --> + <div class="mb-4 flex flex-wrap gap-1 p-2 bg-black/20 rounded-lg" id="tomorrow-untimed-section"> {{range .TomorrowItems}} - {{template "timeline-item" .}} + {{if or .IsOverdue .IsAllDay}} + <div class="untimed-item source-{{.Source}} {{if .IsOverdue}}overdue{{end}} {{if .IsCompleted}}opacity-50{{end}}"> + {{if or (eq .Type "task") (eq .Type "card") (eq .Type "gtask")}} + <input type="checkbox" + {{if .IsCompleted}}checked{{end}} + hx-post="{{if .IsCompleted}}/uncomplete-atom{{else}}/complete-atom{{end}}" + hx-vals='{"id": "{{.ID}}", "source": "{{.Source}}"{{if .ListID}}, "listId": "{{.ListID}}"{{end}}}' + hx-target="#tab-content" + hx-swap="innerHTML" + class="h-4 w-4 rounded bg-black/40 border-white/30 text-white/80 focus:ring-white/30 cursor-pointer flex-shrink-0"> + {{end}} + <span class="{{if .IsCompleted}}line-through text-white/50{{end}}">{{.Title}}</span> + {{if .IsOverdue}}<span class="overdue-badge">overdue</span>{{end}} + {{if .URL}} + <a href="{{.URL}}" target="_blank" class="text-white/50 hover:text-white"> + <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path> + </svg> + </a> + {{end}} + </div> + {{end}} {{end}} </div> + + <!-- Calendar Grid (dynamic hours) --> + <div class="calendar-grid" id="tomorrow-calendar" data-start-hour="{{.TomorrowStartHour}}"> + <!-- Hour rows --> + {{range .TomorrowHours}} + <div class="calendar-hour" data-hour="{{.}}"> + <span class="calendar-hour-label">{{if eq . 0}}12am{{else if lt . 12}}{{.}}am{{else if eq . 12}}12pm{{else}}{{subtract . 12}}pm{{end}}</span> + </div> + {{end}} + + <!-- Timed events positioned by JavaScript --> + {{range .TomorrowItems}} + {{if and (not .IsOverdue) (not .IsAllDay)}} + <div class="calendar-event source-{{.Source}} {{if .IsCompleted}}opacity-50{{end}}" + data-hour="{{.Time.Hour}}" + data-minute="{{.Time.Minute}}" + data-end-hour="{{if .EndTime}}{{.EndTime.Hour}}{{else}}{{.Time.Hour}}{{end}}" + data-end-minute="{{if .EndTime}}{{.EndTime.Minute}}{{else}}59{{end}}" + data-id="{{.ID}}" + data-source="{{.Source}}" + data-completed="{{.IsCompleted}}" + data-type="{{.Type}}" + data-url="{{.URL}}" + {{if .ListID}}data-list-id="{{.ListID}}"{{end}} + style="display:none;"> + <div class="flex items-center gap-2"> + {{if or (eq .Type "task") (eq .Type "card") (eq .Type "gtask")}} + <input type="checkbox" + {{if .IsCompleted}}checked{{end}} + hx-post="{{if .IsCompleted}}/uncomplete-atom{{else}}/complete-atom{{end}}" + hx-vals='{"id": "{{.ID}}", "source": "{{.Source}}"{{if .ListID}}, "listId": "{{.ListID}}"{{end}}}' + hx-target="#tab-content" + hx-swap="innerHTML" + onclick="event.stopPropagation();" + class="h-4 w-4 rounded bg-black/40 border-white/30 text-white/80 focus:ring-white/30 cursor-pointer flex-shrink-0"> + {{end}} + <span class="calendar-event-title {{if .IsCompleted}}line-through text-white/50{{end}}">{{.Title}}</span> + </div> + <div class="calendar-event-time">{{.Time.Format "3:04 PM"}}{{if .EndTime}} - {{.EndTime.Format "3:04 PM"}}{{end}}</div> + </div> + {{end}} + {{end}} + </div> + + <script> + (function() { + const calendar = document.getElementById('tomorrow-calendar'); + if (!calendar) return; + const events = calendar.querySelectorAll('.calendar-event'); + const hourHeight = 40; + const startHour = parseInt(calendar.dataset.startHour) || 8; + + // Build event data for overlap detection + const eventData = []; + events.forEach(function(el) { + const hour = parseInt(el.dataset.hour); + const minute = parseInt(el.dataset.minute); + const endHourVal = parseInt(el.dataset.endHour); + const endMinute = parseInt(el.dataset.endMinute); + const startMin = hour * 60 + minute; + let endMin; + if (endHourVal > hour || (endHourVal === hour && endMinute > minute)) { + endMin = endHourVal * 60 + endMinute; + } else { + endMin = startMin + 55; + } + eventData.push({ el, startMin, endMin, column: 0 }); + }); + + // Assign columns for overlapping events + eventData.sort((a, b) => a.startMin - b.startMin); + for (let i = 0; i < eventData.length; i++) { + const ev = eventData[i]; + const overlaps = eventData.filter((other, j) => + j < i && other.endMin > ev.startMin && other.startMin < ev.endMin + ); + const usedCols = overlaps.map(o => o.column); + let col = 0; + while (usedCols.includes(col)) col++; + ev.column = col; + } + + // Position events with column-based indentation + eventData.forEach(function(ev) { + const el = ev.el; + const hour = parseInt(el.dataset.hour); + const minute = parseInt(el.dataset.minute); + const top = (hour - startHour) * hourHeight + (minute / 60) * hourHeight; + const durationMinutes = ev.endMin - ev.startMin; + const height = Math.max(28, (durationMinutes / 60) * hourHeight - 4); + + el.style.top = top + 'px'; + el.style.height = height + 'px'; + el.style.left = (8 + ev.column * 16) + 'px'; + el.style.display = 'block'; + + const url = el.dataset.url; + if (url) { + el.style.cursor = 'pointer'; + el.addEventListener('click', function(e) { + if (e.target.tagName !== 'INPUT') { + window.open(url, '_blank'); + } + }); + } + }); + + // Hide untimed section if empty + const untimedSection = document.getElementById('tomorrow-untimed-section'); + if (untimedSection && untimedSection.querySelectorAll('.untimed-item').length === 0) { + untimedSection.style.display = 'none'; + } + })(); + </script> </details> {{end}} |
