diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-01-28 23:32:26 -1000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-01-28 23:32:26 -1000 |
| commit | d39220eac03fbc5b714bde989665ed1c92dd24a5 (patch) | |
| tree | 03e1985745043b9af6e7442ac21fdcd5d843146f | |
| parent | 05b1930e04ac222d73ffb2f45c1b1febb69f893d (diff) | |
Expand agent context API with completed log and calendar view
- Add completed_tasks table to log task completions with title, due date,
and completion timestamp
- Extend agent context date range: 7 days back to 14 days forward
- Add completed_log to API response (last 50 completed tasks)
- Add day_section field to timeline items (overdue/today/tomorrow/later)
- Add calendar-style view for today's schedule (6am-10pm hourly grid)
- Add tabbed interface for Timeline vs Completed Log in HTML view
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
| -rw-r--r-- | SESSION_STATE.md | 23 | ||||
| -rw-r--r-- | internal/handlers/agent.go | 101 | ||||
| -rw-r--r-- | internal/handlers/handlers.go | 28 | ||||
| -rw-r--r-- | internal/models/types.go | 10 | ||||
| -rw-r--r-- | internal/store/sqlite.go | 52 | ||||
| -rw-r--r-- | migrations/011_completed_tasks.sql | 12 | ||||
| -rw-r--r-- | web/templates/agent-context.html | 258 |
7 files changed, 402 insertions, 82 deletions
diff --git a/SESSION_STATE.md b/SESSION_STATE.md index b4dd64d..cc637a7 100644 --- a/SESSION_STATE.md +++ b/SESSION_STATE.md @@ -8,14 +8,23 @@ Agent Context API - Refactored for simplicity and clarity - Status: [REVIEW_READY] Phase 1 Complete + Browser-Only Agent Endpoints + Refactored - Phase 2 (Write Operations) and Phase 3 (Create + Management) pending -### Recent Refactoring (agent.go) +### Recent Updates +**Refactoring (agent.go):** - Reused `BuildTimeline()` from timeline_logic.go instead of duplicating fetch logic -- Removed ~60 lines of duplicate timeline building code -- Added section headers for code organization: Constants, Types, Helpers, Auth Handlers, Context Handlers, Middleware, Web Handlers -- Extracted `isSessionExpired()` helper to reduce duplication -- Extracted `renderAgentTemplate()` helper to simplify template rendering -- Moved `AgentRequestPayload` from websocket.go to agent.go (agent-specific type) -- Used `config.Now()` and `config.Today()` for consistent timezone handling +- Added section headers for code organization +- Extracted helpers: `isSessionExpired()`, `renderAgentTemplate()` + +**Enhanced Context API:** +- Extended date range: 7 days back (overdue) to 14 days forward +- Added `completed_log` - tracks completed tasks with title, due date, completion date +- Added `day_section` field to timeline items (overdue/today/tomorrow/later) +- Calendar-style view for today (6am-10pm) in HTML template +- Tabs for Timeline vs Completed Log in web view + +**New Database:** +- `completed_tasks` table (migration 011) - logs completed task history +- `SaveCompletedTask()` / `GetCompletedTasks()` store methods +- `CompletedTask` model type ### Phase 1 Completed Items - [x] Migration `migrations/010_agent_tables.sql` — agents and agent_sessions tables diff --git a/internal/handlers/agent.go b/internal/handlers/agent.go index 6f47524..92f4ce8 100644 --- a/internal/handlers/agent.go +++ b/internal/handlers/agent.go @@ -51,6 +51,15 @@ type agentContextItem struct { Priority int `json:"priority,omitempty"` Completable bool `json:"completable"` URL string `json:"url,omitempty"` + DaySection string `json:"day_section,omitempty"` // "overdue", "today", "tomorrow", "later" +} + +// agentCompletedItem represents a completed task in the log +type agentCompletedItem struct { + Source string `json:"source"` + Title string `json:"title"` + DueDate *time.Time `json:"due_date,omitempty"` + CompletedAt time.Time `json:"completed_at"` } // ----------------------------------------------------------------------------- @@ -83,6 +92,7 @@ func timelineItemToAgentItem(item models.TimelineItem) agentContextItem { Due: &t, Completable: item.Type == models.TimelineItemTypeTask || item.Type == models.TimelineItemTypeCard || item.Type == models.TimelineItemTypeGTask, URL: item.URL, + DaySection: string(item.DaySection), } } @@ -285,10 +295,14 @@ func (h *Handler) HandleAgentContext(w http.ResponseWriter, r *http.Request) { _ = h.store.UpdateAgentLastSeen(session.AgentID) now := config.Now() - startDate := config.Today() - endDate := startDate.Add(7 * 24 * time.Hour) + today := config.Today() + // Extend range: 7 days back (overdue) to 14 days forward + startDate := today.Add(-7 * 24 * time.Hour) + endDate := today.Add(14 * 24 * time.Hour) - timeline := h.buildAgentContext(r.Context(), startDate, endDate) + ctx := r.Context() + timeline := h.buildAgentContext(ctx, startDate, endDate) + completedLog := h.buildCompletedLog(50) // Last 50 completed tasks resp := map[string]interface{}{ "generated_at": now.Format(time.RFC3339), @@ -296,8 +310,9 @@ func (h *Handler) HandleAgentContext(w http.ResponseWriter, r *http.Request) { "start": startDate.Format("2006-01-02"), "end": endDate.Format("2006-01-02"), }, - "timeline": timeline, - "summary": h.buildContextSummary(timeline, startDate), + "timeline": timeline, + "completed_log": completedLog, + "summary": h.buildContextSummary(timeline, today), } w.Header().Set("Content-Type", "application/json") @@ -326,16 +341,18 @@ func (h *Handler) buildAgentContext(ctx context.Context, start, end time.Time) [ // buildContextSummary builds summary statistics for the agent context func (h *Handler) buildContextSummary(items []agentContextItem, today time.Time) map[string]interface{} { bySource := make(map[string]int) - var overdue, todayCount int + bySection := make(map[string]int) endOfToday := today.Add(24 * time.Hour) for _, item := range items { bySource[item.Source]++ - if item.Due != nil { + if item.DaySection != "" { + bySection[item.DaySection]++ + } else if item.Due != nil { if item.Due.Before(today) { - overdue++ + bySection["overdue"]++ } else if item.Due.Before(endOfToday) { - todayCount++ + bySection["today"]++ } } } @@ -343,9 +360,27 @@ func (h *Handler) buildContextSummary(items []agentContextItem, today time.Time) return map[string]interface{}{ "total_items": len(items), "by_source": bySource, - "overdue": overdue, - "today": todayCount, + "by_section": bySection, + } +} + +// buildCompletedLog retrieves recently completed tasks +func (h *Handler) buildCompletedLog(limit int) []agentCompletedItem { + completed, err := h.store.GetCompletedTasks(limit) + if err != nil { + return nil + } + + items := make([]agentCompletedItem, len(completed)) + for i, c := range completed { + items[i] = agentCompletedItem{ + Source: c.Source, + Title: c.Title, + DueDate: c.DueDate, + CompletedAt: c.CompletedAt, + } } + return items } // ----------------------------------------------------------------------------- @@ -489,11 +524,15 @@ func (h *Handler) HandleAgentWebContext(w http.ResponseWriter, r *http.Request) _ = h.store.UpdateAgentLastSeen(session.AgentID) now := config.Now() - startDate := config.Today() - endDate := startDate.Add(7 * 24 * time.Hour) + today := config.Today() + startDate := today.Add(-7 * 24 * time.Hour) + endDate := today.Add(14 * 24 * time.Hour) - timeline := h.buildAgentContext(r.Context(), startDate, endDate) - h.renderAgentContext(w, session, timeline, startDate, endDate, now) + ctx := r.Context() + timeline := h.buildAgentContext(ctx, startDate, endDate) + completedLog := h.buildCompletedLog(50) + + h.renderAgentContextFull(w, session, timeline, completedLog, today, startDate, endDate, now) } // renderAgentError renders an error page for agent web endpoints @@ -547,14 +586,30 @@ func (h *Handler) renderAgentStatus(w http.ResponseWriter, session *models.Agent h.renderAgentTemplate(w, "agent-status.html", data) } -// renderAgentContext renders the context page with timeline data -func (h *Handler) renderAgentContext(w http.ResponseWriter, session *models.AgentSession, timeline []agentContextItem, startDate, endDate, now time.Time) { +// renderAgentContextFull renders the context page with full timeline data and completed log +func (h *Handler) renderAgentContextFull(w http.ResponseWriter, session *models.AgentSession, timeline []agentContextItem, completedLog []agentCompletedItem, today, startDate, endDate, now time.Time) { + // Separate today's items for calendar view + var todayItems []agentContextItem + var otherItems []agentContextItem + endOfToday := today.Add(24 * time.Hour) + + for _, item := range timeline { + if item.Due != nil && !item.Due.Before(today) && item.Due.Before(endOfToday) { + todayItems = append(todayItems, item) + } else { + otherItems = append(otherItems, item) + } + } + h.renderAgentTemplate(w, "agent-context.html", map[string]interface{}{ - "AgentName": session.AgentName, - "GeneratedAt": now.Format(time.RFC3339), - "RangeStart": startDate.Format("2006-01-02"), - "RangeEnd": endDate.Format("2006-01-02"), - "Timeline": timeline, - "Summary": h.buildContextSummary(timeline, startDate), + "AgentName": session.AgentName, + "GeneratedAt": now.Format(time.RFC3339), + "Today": today.Format("2006-01-02"), + "RangeStart": startDate.Format("2006-01-02"), + "RangeEnd": endDate.Format("2006-01-02"), + "TodayItems": todayItems, + "Timeline": otherItems, + "CompletedLog": completedLog, + "Summary": h.buildContextSummary(timeline, today), }) } diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 9fe1b2c..0e5edcc 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -673,8 +673,11 @@ func (h *Handler) handleAtomToggle(w http.ResponseWriter, r *http.Request, compl } if complete { - // Get task title before removing from cache - title := h.getAtomTitle(id, source) + // Get task details before removing from cache + title, dueDate := h.getAtomDetails(id, source) + + // Log to completed tasks + _ = h.store.SaveCompletedTask(source, id, title, dueDate) // Remove from local cache switch source { @@ -706,14 +709,14 @@ func (h *Handler) handleAtomToggle(w http.ResponseWriter, r *http.Request, compl } } -// getAtomTitle retrieves the title for a task/card/bug from the store -func (h *Handler) getAtomTitle(id, source string) string { +// getAtomDetails retrieves title and due date for a task/card/bug from the store +func (h *Handler) getAtomDetails(id, source string) (string, *time.Time) { switch source { case "todoist": if tasks, err := h.store.GetTasks(); err == nil { for _, t := range tasks { if t.ID == id { - return t.Content + return t.Content, t.DueDate } } } @@ -722,7 +725,7 @@ func (h *Handler) getAtomTitle(id, source string) string { for _, b := range boards { for _, c := range b.Cards { if c.ID == id { - return c.Name + return c.Name, c.DueDate } } } @@ -733,13 +736,22 @@ func (h *Handler) getAtomTitle(id, source string) string { if _, err := fmt.Sscanf(id, "bug-%d", &bugID); err == nil { for _, b := range bugs { if b.ID == bugID { - return b.Description + return b.Description, nil } } } } + case "gtasks": + // Google Tasks don't have local cache, return generic title + return "Google Task", nil } - return "Task" + return "Task", nil +} + +// getAtomTitle retrieves the title for a task/card/bug from the store (legacy) +func (h *Handler) getAtomTitle(id, source string) string { + title, _ := h.getAtomDetails(id, source) + return title } // HandleUnifiedAdd creates a task in Todoist or a card in Trello from the Quick Add form diff --git a/internal/models/types.go b/internal/models/types.go index 5214bf8..e28d985 100644 --- a/internal/models/types.go +++ b/internal/models/types.go @@ -197,3 +197,13 @@ const ( AgentTrustRecognized AgentTrustLevel = "recognized" AgentTrustSuspicious AgentTrustLevel = "suspicious" ) + +// CompletedTask represents a logged completed task +type CompletedTask struct { + ID int64 `json:"id"` + Source string `json:"source"` + SourceID string `json:"source_id"` + Title string `json:"title"` + DueDate *time.Time `json:"due_date,omitempty"` + CompletedAt time.Time `json:"completed_at"` +} diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go index b324e9f..48bcae5 100644 --- a/internal/store/sqlite.go +++ b/internal/store/sqlite.go @@ -1141,3 +1141,55 @@ func (s *Store) CheckAgentTrust(name, agentID string) (models.AgentTrustLevel, e return models.AgentTrustNew, nil } + +// Completed tasks log + +// SaveCompletedTask logs a completed task +func (s *Store) SaveCompletedTask(source, sourceID, title string, dueDate *time.Time) error { + var dueDateStr sql.NullString + if dueDate != nil { + dueDateStr = sql.NullString{String: dueDate.Format(time.RFC3339), Valid: true} + } + _, err := s.db.Exec(` + INSERT OR REPLACE INTO completed_tasks (source, source_id, title, due_date, completed_at) + VALUES (?, ?, ?, ?, datetime('now', 'localtime')) + `, source, sourceID, title, dueDateStr) + return err +} + +// GetCompletedTasks retrieves recently completed tasks +func (s *Store) GetCompletedTasks(limit int) ([]models.CompletedTask, error) { + rows, err := s.db.Query(` + SELECT id, source, source_id, title, due_date, completed_at + FROM completed_tasks + ORDER BY completed_at DESC + LIMIT ? + `, limit) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + var tasks []models.CompletedTask + for rows.Next() { + var task models.CompletedTask + var dueDate sql.NullString + var completedAt string + + if err := rows.Scan(&task.ID, &task.Source, &task.SourceID, &task.Title, &dueDate, &completedAt); err != nil { + return nil, err + } + + if dueDate.Valid { + if t, err := time.Parse(time.RFC3339, dueDate.String); err == nil { + task.DueDate = &t + } + } + if t, err := time.Parse("2006-01-02 15:04:05", completedAt); err == nil { + task.CompletedAt = t + } + + tasks = append(tasks, task) + } + return tasks, rows.Err() +} diff --git a/migrations/011_completed_tasks.sql b/migrations/011_completed_tasks.sql new file mode 100644 index 0000000..0b782e9 --- /dev/null +++ b/migrations/011_completed_tasks.sql @@ -0,0 +1,12 @@ +-- Completed tasks log +CREATE TABLE IF NOT EXISTS completed_tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source TEXT NOT NULL, -- 'todoist', 'trello', 'gtasks', 'bug' + source_id TEXT NOT NULL, -- original ID from source + title TEXT NOT NULL, + due_date TEXT, -- original due date + completed_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')), + UNIQUE(source, source_id) +); + +CREATE INDEX IF NOT EXISTS idx_completed_tasks_completed_at ON completed_tasks(completed_at); diff --git a/web/templates/agent-context.html b/web/templates/agent-context.html index 3a4778a..db618ba 100644 --- a/web/templates/agent-context.html +++ b/web/templates/agent-context.html @@ -5,16 +5,34 @@ <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Agent Context - {{.AgentName}}</title> <style> - body { font-family: system-ui, sans-serif; max-width: 800px; margin: 40px auto; padding: 20px; background: #1a1a2e; color: #eee; } + body { font-family: system-ui, sans-serif; max-width: 1000px; margin: 40px auto; padding: 20px; background: #1a1a2e; color: #eee; } .card { background: #16213e; border-radius: 8px; padding: 24px; margin-bottom: 20px; } h1 { color: #e94560; margin-top: 0; } - h2 { color: #0f3460; font-size: 1.2em; margin-top: 24px; } + h2 { color: #4da6ff; font-size: 1.2em; margin-top: 24px; margin-bottom: 16px; } .label { color: #888; font-size: 0.9em; margin-bottom: 4px; } .value { font-family: monospace; background: #0f0f23; padding: 8px 12px; border-radius: 4px; word-break: break-all; margin-bottom: 16px; } - .summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 12px; margin-bottom: 20px; } + .summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); gap: 12px; margin-bottom: 20px; } .summary-item { background: #0f3460; padding: 12px; border-radius: 6px; text-align: center; } .summary-value { font-size: 1.5em; font-weight: bold; color: #e94560; } .summary-label { font-size: 0.8em; color: #888; } + + /* Calendar styles */ + .calendar { position: relative; border-left: 2px solid #333; margin-left: 60px; min-height: 640px; } + .calendar-hour { position: relative; height: 40px; border-bottom: 1px solid #222; } + .calendar-hour-label { position: absolute; left: -60px; top: -8px; font-size: 0.75em; color: #666; width: 50px; text-align: right; } + .calendar-item { position: absolute; left: 10px; right: 10px; background: #0f3460; border-radius: 4px; padding: 6px 10px; font-size: 0.85em; border-left: 3px solid; overflow: hidden; z-index: 1; } + .calendar-item:hover { z-index: 10; background: #1a4a80; } + .calendar-item.source-todoist { border-color: #e44332; } + .calendar-item.source-trello { border-color: #0079bf; } + .calendar-item.source-plantoeat { border-color: #5cb85c; } + .calendar-item.source-calendar { border-color: #9b59b6; } + .calendar-item.source-gtasks { border-color: #f39c12; } + .calendar-item-time { font-size: 0.75em; color: #888; } + .calendar-item-title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .all-day { background: #1a1a2e; padding: 8px; margin-bottom: 10px; border-radius: 4px; } + .all-day-item { display: inline-block; background: #0f3460; padding: 4px 8px; border-radius: 4px; margin: 2px; font-size: 0.85em; border-left: 3px solid #5cb85c; } + + /* Table styles */ table { width: 100%; border-collapse: collapse; margin-top: 16px; } th, td { padding: 10px; text-align: left; border-bottom: 1px solid #333; } th { background: #0f3460; color: #fff; } @@ -23,36 +41,61 @@ .source-todoist { background: #e44332; color: #fff; } .source-trello { background: #0079bf; color: #fff; } .source-plantoeat { background: #5cb85c; color: #fff; } - .due-overdue { color: #dc3545; } - .due-today { color: #ffc107; } - .due-future { color: #28a745; } + .source-calendar { background: #9b59b6; color: #fff; } + .source-gtasks { background: #f39c12; color: #fff; } + .section-overdue { color: #dc3545; } + .section-today { color: #ffc107; } + .section-tomorrow { color: #28a745; } + .section-later { color: #6c757d; } a { color: #4da6ff; } + .completed-item { opacity: 0.7; } + .tabs { display: flex; gap: 4px; margin-bottom: 16px; } + .tab { padding: 8px 16px; background: #0f3460; border: none; color: #fff; cursor: pointer; border-radius: 4px 4px 0 0; } + .tab.active { background: #16213e; } + .tab-content { display: none; } + .tab-content.active { display: block; } </style> </head> <body> <script type="application/json" id="agent-data"> { "generated_at": "{{.GeneratedAt}}", + "today": "{{.Today}}", "range": { "start": "{{.RangeStart}}", "end": "{{.RangeEnd}}" }, "summary": { "total_items": {{with .Summary}}{{.total_items}}{{else}}0{{end}}, - "overdue": {{with .Summary}}{{.overdue}}{{else}}0{{end}}, - "today": {{with .Summary}}{{.today}}{{else}}0{{end}} + "by_section": {{with .Summary}}{{if .by_section}}{"overdue": {{if index .by_section "overdue"}}{{index .by_section "overdue"}}{{else}}0{{end}}, "today": {{if index .by_section "today"}}{{index .by_section "today"}}{{else}}0{{end}}, "tomorrow": {{if index .by_section "tomorrow"}}{{index .by_section "tomorrow"}}{{else}}0{{end}}}{{else}}{}{{end}}{{else}}{}{{end}} }, + "today_items": [{{range $i, $item := .TodayItems}}{{if $i}},{{end}} + { + "id": "{{$item.ID}}", + "source": "{{$item.Source}}", + "type": "{{$item.Type}}", + "title": "{{$item.Title}}", + "due": {{if $item.Due}}"{{$item.Due.Format "2006-01-02T15:04:05Z07:00"}}"{{else}}null{{end}}, + "completable": {{$item.Completable}} + }{{end}} + ], "timeline": [{{range $i, $item := .Timeline}}{{if $i}},{{end}} { "id": "{{$item.ID}}", "source": "{{$item.Source}}", "type": "{{$item.Type}}", "title": "{{$item.Title}}", - "description": "{{$item.Description}}", "due": {{if $item.Due}}"{{$item.Due.Format "2006-01-02T15:04:05Z07:00"}}"{{else}}null{{end}}, - "priority": {{$item.Priority}}, - "completable": {{$item.Completable}}, - "url": "{{$item.URL}}" + "day_section": "{{$item.DaySection}}", + "completable": {{$item.Completable}} + }{{end}} + ], + "completed_log": [{{range $i, $item := .CompletedLog}}{{if $i}},{{end}} + { + "source": "{{$item.Source}}", + "title": "{{$item.Title}}", + "due_date": {{if $item.DueDate}}"{{$item.DueDate.Format "2006-01-02T15:04:05Z07:00"}}"{{else}}null{{end}}, + "completed_at": "{{$item.CompletedAt.Format "2006-01-02T15:04:05Z07:00"}}" }{{end}} ] } @@ -60,7 +103,7 @@ <div class="card"> <h1>Agent Context</h1> - <p>Timeline data for <strong>{{.AgentName}}</strong></p> + <p>Full timeline data for <strong>{{.AgentName}}</strong></p> <div class="label">Generated At</div> <div class="value">{{.GeneratedAt}}</div> @@ -76,46 +119,173 @@ <div class="summary-value">{{with .Summary}}{{.total_items}}{{else}}0{{end}}</div> <div class="summary-label">Total Items</div> </div> + {{with .Summary}}{{with .by_section}} <div class="summary-item"> - <div class="summary-value">{{with .Summary}}{{.overdue}}{{else}}0{{end}}</div> + <div class="summary-value section-overdue">{{if index . "overdue"}}{{index . "overdue"}}{{else}}0{{end}}</div> <div class="summary-label">Overdue</div> </div> <div class="summary-item"> - <div class="summary-value">{{with .Summary}}{{.today}}{{else}}0{{end}}</div> - <div class="summary-label">Due Today</div> + <div class="summary-value section-today">{{if index . "today"}}{{index . "today"}}{{else}}0{{end}}</div> + <div class="summary-label">Today</div> + </div> + <div class="summary-item"> + <div class="summary-value section-tomorrow">{{if index . "tomorrow"}}{{index . "tomorrow"}}{{else}}0{{end}}</div> + <div class="summary-label">Tomorrow</div> + </div> + {{end}}{{end}} + <div class="summary-item"> + <div class="summary-value">{{len .CompletedLog}}</div> + <div class="summary-label">Recently Done</div> </div> </div> </div> <div class="card"> - <h2>Timeline</h2> - {{if .Timeline}} - <table> - <thead> - <tr> - <th>Source</th> - <th>Title</th> - <th>Due</th> - <th>Type</th> - </tr> - </thead> - <tbody> - {{range .Timeline}} - <tr> - <td><span class="source source-{{.Source}}">{{.Source}}</span></td> - <td> - {{if .URL}}<a href="{{.URL}}" target="_blank">{{.Title}}</a>{{else}}{{.Title}}{{end}} - {{if .Description}}<br><small style="color: #888;">{{.Description}}</small>{{end}} - </td> - <td>{{if .Due}}{{.Due.Format "Jan 2, 3:04 PM"}}{{else}}-{{end}}</td> - <td>{{.Type}}</td> - </tr> - {{end}} - </tbody> - </table> - {{else}} - <p>No items in the timeline for this date range.</p> - {{end}} + <h2>Today's Schedule ({{.Today}})</h2> + <div class="all-day" id="all-day-items"></div> + <div class="calendar" id="today-calendar"></div> + <script> + (function() { + const data = document.getElementById('agent-data'); + const items = data ? JSON.parse(data.textContent).today_items || [] : []; + const calendar = document.getElementById('today-calendar'); + const allDay = document.getElementById('all-day-items'); + const startHour = 6; + const endHour = 22; + const hourHeight = 40; + + // Generate hour rows + for (let hour = startHour; hour <= endHour; hour++) { + const row = document.createElement('div'); + row.className = 'calendar-hour'; + row.dataset.hour = hour; + const label = document.createElement('span'); + label.className = 'calendar-hour-label'; + if (hour < 12) label.textContent = hour + 'am'; + else if (hour === 12) label.textContent = '12pm'; + else label.textContent = (hour - 12) + 'pm'; + row.appendChild(label); + calendar.appendChild(row); + } + + if (items.length === 0) { + calendar.innerHTML = '<p style="color: #888; padding: 20px;">No items scheduled for today.</p>'; + allDay.style.display = 'none'; + return; + } + + items.forEach(item => { + if (!item.due) return; + const due = new Date(item.due); + const hour = due.getHours(); + const minutes = due.getMinutes(); + + // All-day items (midnight) go in separate section + if (hour === 0 && minutes === 0) { + const el = document.createElement('span'); + el.className = 'all-day-item source-' + item.source; + el.textContent = item.title; + allDay.appendChild(el); + return; + } + + // Skip items outside 6am-10pm range + if (hour < startHour || hour > endHour) return; + + const el = document.createElement('div'); + el.className = 'calendar-item source-' + item.source; + const top = (hour - startHour) * hourHeight + (minutes / 60) * hourHeight; + el.style.top = top + 'px'; + el.style.height = '34px'; + el.innerHTML = '<div class="calendar-item-time">' + due.toLocaleTimeString('en-US', {hour: 'numeric', minute: '2-digit'}) + '</div><div class="calendar-item-title">' + item.title + '</div>'; + if (item.url) { + el.style.cursor = 'pointer'; + el.onclick = () => window.open(item.url, '_blank'); + } + calendar.appendChild(el); + }); + + // Hide all-day section if empty + if (allDay.children.length === 0) { + allDay.style.display = 'none'; + } + })(); + </script> </div> + + <div class="card"> + <div class="tabs"> + <button class="tab active" onclick="showTab('timeline')">Timeline</button> + <button class="tab" onclick="showTab('completed')">Completed Log</button> + </div> + + <div id="timeline" class="tab-content active"> + <h2>Upcoming & Overdue</h2> + {{if .Timeline}} + <table> + <thead> + <tr> + <th>Source</th> + <th>Title</th> + <th>Due</th> + <th>Section</th> + </tr> + </thead> + <tbody> + {{range .Timeline}} + <tr> + <td><span class="source source-{{.Source}}">{{.Source}}</span></td> + <td> + {{if .URL}}<a href="{{.URL}}" target="_blank">{{.Title}}</a>{{else}}{{.Title}}{{end}} + {{if .Description}}<br><small style="color: #888;">{{.Description}}</small>{{end}} + </td> + <td>{{if .Due}}{{.Due.Format "Jan 2, 3:04 PM"}}{{else}}-{{end}}</td> + <td><span class="section-{{.DaySection}}">{{.DaySection}}</span></td> + </tr> + {{end}} + </tbody> + </table> + {{else}} + <p style="color: #888;">No upcoming or overdue items.</p> + {{end}} + </div> + + <div id="completed" class="tab-content"> + <h2>Recently Completed</h2> + {{if .CompletedLog}} + <table> + <thead> + <tr> + <th>Source</th> + <th>Title</th> + <th>Due Date</th> + <th>Completed</th> + </tr> + </thead> + <tbody> + {{range .CompletedLog}} + <tr class="completed-item"> + <td><span class="source source-{{.Source}}">{{.Source}}</span></td> + <td>{{.Title}}</td> + <td>{{if .DueDate}}{{.DueDate.Format "Jan 2"}}{{else}}-{{end}}</td> + <td>{{.CompletedAt.Format "Jan 2, 3:04 PM"}}</td> + </tr> + {{end}} + </tbody> + </table> + {{else}} + <p style="color: #888;">No completed tasks logged yet.</p> + {{end}} + </div> + </div> + + <script> + function showTab(tabId) { + document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); + document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); + document.querySelector('.tab[onclick*="' + tabId + '"]').classList.add('active'); + document.getElementById(tabId).classList.add('active'); + } + </script> </body> </html> |
