summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--internal/handlers/handlers.go7
-rw-r--r--internal/handlers/handlers_test.go9
-rw-r--r--internal/handlers/timeline.go87
-rw-r--r--web/static/js/app.js2
-rw-r--r--web/templates/partials/timeline-tab.html281
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}}