package handlers import ( "log" "net/http" "strconv" "time" "task-dashboard/internal/config" "task-dashboard/internal/models" ) // TimelineItemView wraps a TimelineItem with how it should render for one // specific day (Today or Tomorrow). MultiDayVariant is "" for a normal // single-day item; "starts"/"ends"/"spans" for a calendar event whose // Start and End fall on different calendar days -- see isMultiDayEvent. type TimelineItemView struct { models.TimelineItem MultiDayVariant string } // dateOnly strips the time-of-day, keeping the calendar day in t's own location. func dateOnly(t time.Time) time.Time { return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) } // isMultiDayEvent reports whether item is a calendar event whose Start and // End fall on different calendar days (in the display timezone). Only // events are ever considered multi-day -- tasks, cards, meals, and Google // Tasks keep their existing single-day rendering unconditionally. func isMultiDayEvent(item models.TimelineItem) bool { if item.Type != models.TimelineItemTypeEvent || item.EndTime == nil { return false } tz := config.GetDisplayTimezone() return !dateOnly(item.EndTime.In(tz)).Equal(dateOnly(item.Time.In(tz))) } // multiDayVariant returns how a multi-day item should render on renderDay: // "starts" if renderDay is the item's start day, "ends" if renderDay is its // end day, "spans" if renderDay falls strictly between them, or "" if // renderDay doesn't fall within the item's span at all. Caller must have // already confirmed isMultiDayEvent(item) so item.EndTime is non-nil. func multiDayVariant(item models.TimelineItem, renderDay time.Time) string { tz := config.GetDisplayTimezone() startDay := dateOnly(item.Time.In(tz)) endDay := dateOnly(item.EndTime.In(tz)) day := dateOnly(renderDay.In(tz)) switch { case day.Equal(startDay): return "starts" case day.Equal(endDay): return "ends" case day.After(startDay) && day.Before(endDay): return "spans" default: return "" } } // TimelineData holds grouped timeline items for the template type TimelineData struct { TodayItems []TimelineItemView TomorrowItems []TimelineItemView LaterItems []TimelineItemView Start time.Time Days int // Section labels with day of week 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 func (h *Handler) HandleTimeline(w http.ResponseWriter, r *http.Request) { // Parse query params startStr := r.URL.Query().Get("start") daysStr := r.URL.Query().Get("days") var start time.Time if startStr != "" { parsed, err := config.ParseDateInDisplayTZ(startStr) if err == nil { start = parsed } else { start = config.Today() } } else { start = config.Today() } days := 3 // Default if daysStr != "" { if d, err := strconv.Atoi(daysStr); err == nil && d > 0 { days = d } } end := start.AddDate(0, 0, days) // Refresh calendar events cache before building timeline if _, err := h.fetchCalendarEvents(r.Context(), false); err != nil { log.Printf("Warning: failed to fetch calendar events: %v", err) } // Refresh meals cache before building timeline if h.planToEatClient != nil { if _, err := h.fetchMeals(r.Context(), false); err != nil { log.Printf("Warning: failed to fetch meals: %v", err) } } // Call BuildTimeline items, err := BuildTimeline(r.Context(), h.store, start, end) if err != nil { JSONError(w, http.StatusInternalServerError, "Failed to build timeline", err) return } // Compute section labels with day of week now := config.Now() today := config.Today() tomorrow := today.AddDate(0, 0, 1) dayAfterTomorrow := today.AddDate(0, 0, 2) // Group items by day section data := TimelineData{ Start: start, Days: days, 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 { if isMultiDayEvent(item) { addedToday := false addedTomorrow := false if v := multiDayVariant(item, today); v != "" { data.TodayItems = append(data.TodayItems, TimelineItemView{item, v}) addedToday = true } if v := multiDayVariant(item, tomorrow); v != "" { data.TomorrowItems = append(data.TomorrowItems, TimelineItemView{item, v}) addedTomorrow = true } if !addedToday && !addedTomorrow { data.LaterItems = append(data.LaterItems, TimelineItemView{item, ""}) } continue } switch item.DaySection { case models.DaySectionToday: data.TodayItems = append(data.TodayItems, TimelineItemView{item, ""}) case models.DaySectionTomorrow: data.TomorrowItems = append(data.TomorrowItems, TimelineItemView{item, ""}) case models.DaySectionLater: data.LaterItems = append(data.LaterItems, TimelineItemView{item, ""}) } } // 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.renderer, "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 []TimelineItemView, currentHour int) (startHour, endHour int) { minHour := 23 maxHour := 0 hasTimedEvents := false for _, item := range items { // Skip all-day/overdue/multi-day items (rendered as all-day-style // rows, not grid slots -- see TimelineItemView.MultiDayVariant) if item.IsAllDay || item.IsOverdue || item.MultiDayVariant != "" { 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 }