package templates import ( "fmt" "time" "backend/internal/db/sqlc" ) type PlanningAgenda struct { Start time.Time End time.Time Today time.Time RangeLabel string PrevURL string TodayURL string NextURL string ShowingToday bool Events []PlanningEventRow } type PlanningEventRow struct { Title string DateLabel string TimeRange string TabloTitle string TabloColor string HasColor bool Location string HasLocation bool URL string } func NewPlanningAgenda(start time.Time, end time.Time, today time.Time, rows []sqlc.ListUserEventsRangeRow) PlanningAgenda { events := make([]PlanningEventRow, 0, len(rows)) for _, row := range rows { location := "" if row.Location.Valid { location = row.Location.String } color := "" if row.TabloColor.Valid { color = row.TabloColor.String } events = append(events, PlanningEventRow{ Title: row.Title, DateLabel: PlanningEventDateLabel(row), TimeRange: PlanningEventTimeRange(row), TabloTitle: row.TabloTitle, TabloColor: color, HasColor: color != "", Location: location, HasLocation: location != "", URL: PlanningEventURL(row), }) } return PlanningAgenda{ Start: start, End: end, Today: today, RangeLabel: PlanningRangeLabel(start, end), PrevURL: PlanningURL(start.AddDate(0, 0, -14)), TodayURL: "/planning", NextURL: PlanningURL(start.AddDate(0, 0, 14)), ShowingToday: samePlanningDay(start, today), Events: events, } } func PlanningURL(start time.Time) string { return "/planning?start=" + start.Format("2006-01-02") } func PlanningEventURL(row sqlc.ListUserEventsRangeRow) string { return "/tablos/" + row.TabloID.String() + "/events?month=" + row.EventDate.Time.Format("2006-01") } func PlanningEventDateLabel(row sqlc.ListUserEventsRangeRow) string { if !row.EventDate.Valid { return "" } return row.EventDate.Time.Format("Jan 2, 2006") } func PlanningEventTimeRange(row sqlc.ListUserEventsRangeRow) string { start := FormatEventTime(row.StartTime) if start == "" { return "" } if !row.EndTime.Valid { return start } return start + "-" + FormatEventTime(row.EndTime) } func PlanningRangeLabel(start time.Time, end time.Time) string { if start.Year() == end.Year() { return start.Format("January 2") + " - " + end.Format("January 2, 2006") } return start.Format("January 2, 2006") + " - " + end.Format("January 2, 2006") } func samePlanningDay(a time.Time, b time.Time) bool { return a.Year() == b.Year() && a.Month() == b.Month() && a.Day() == b.Day() } // PlanningShowDaySeparator returns true when the event at index i belongs to a // different day than the previous event — i.e. a date-group header should be // rendered before it. func PlanningShowDaySeparator(events []PlanningEventRow, index int) bool { if index == 0 { return true } return events[index].DateLabel != events[index-1].DateLabel } // --------------------------------------------------------------------------- // Calendar view data structures // --------------------------------------------------------------------------- // CalendarEvent is used in the month grid cells. type CalendarEvent struct { Title string ColorClass string Style string // inline style for background/color when tablo has a color URL string TimeLabel string // "10:00" or "" if all-day } // CalendarDay is one cell in the month grid. type CalendarDay struct { Date time.Time DayNum int IsToday bool OtherMonth bool Events []CalendarEvent } // CalendarTimeEvent is an event placed on the hour-timeline (week/day views). type CalendarTimeEvent struct { Title string ColorClass string Style string URL string TopPx int HeightPx int } // CalendarDayColumn is one day column in the week/day timeline. type CalendarDayColumn struct { Date time.Time Label string // "Mon 18" (week) or "Monday 18 May" (day) IsToday bool Events []CalendarTimeEvent } // MiniCalDay is one cell in the mini-month panel used by week/day views. type MiniCalDay struct { Date time.Time DayNum int IsToday bool InWeek bool // highlighted because it is in the viewed week URL string } // PlanningCalendar holds all data needed to render any of the three views. type PlanningCalendar struct { View string // "month", "week", "day" Label string // period label shown in header PrevURL string TodayURL string NextURL string // Month view Weeks [][]CalendarDay // Week / Day views Days []CalendarDayColumn HourSlots []string // ["07:00", "08:00", ... "20:00"] // Mini-month panel (week/day views) MiniMonth [][]MiniCalDay // 5-6 rows × 7 MiniMonthLabel string // "May 2026" } // --------------------------------------------------------------------------- // URL helpers // --------------------------------------------------------------------------- func PlanningMonthURL(t time.Time) string { return "/planning?view=month&month=" + t.Format("2006-01") } func PlanningWeekURL(t time.Time) string { monday := MondayOf(t) return "/planning?view=week&start=" + monday.Format("2006-01-02") } func PlanningDayURL(t time.Time) string { return "/planning?view=day&date=" + t.Format("2006-01-02") } // MondayOf returns the Monday of the week containing t (ISO week, Mon=first day). func MondayOf(t time.Time) time.Time { wd := int(t.Weekday()) // Sunday=0 if wd == 0 { wd = 7 } return time.Date(t.Year(), t.Month(), t.Day()-wd+1, 0, 0, 0, 0, t.Location()) } // --------------------------------------------------------------------------- // Color helper // --------------------------------------------------------------------------- // calColorStyle returns an inline style string that tints the event chip using // the tablo's hex color. When color is empty the ev-default class is used instead. func calColorStyle(color string) string { if color == "" { return "" } return fmt.Sprintf( "background:color-mix(in srgb,%s 15%%,transparent);color:%s", color, color, ) } // --------------------------------------------------------------------------- // Calendar builder helpers // --------------------------------------------------------------------------- // calEventFromRow converts a raw DB row into a CalendarEvent (month view). func calEventFromRow(row sqlc.ListUserEventsRangeRow) CalendarEvent { color := "" if row.TabloColor.Valid { color = row.TabloColor.String } colorClass := "ev-default" style := calColorStyle(color) if style != "" { colorClass = "" } timeLabel := FormatEventTime(row.StartTime) return CalendarEvent{ Title: row.Title, ColorClass: colorClass, Style: style, URL: PlanningEventURL(row), TimeLabel: timeLabel, } } // calTimeEventFromRow converts a DB row into a CalendarTimeEvent (week/day view). func calTimeEventFromRow(row sqlc.ListUserEventsRangeRow) CalendarTimeEvent { color := "" if row.TabloColor.Valid { color = row.TabloColor.String } colorClass := "ev-default" style := calColorStyle(color) if style != "" { colorClass = "" } // Compute TopPx and HeightPx from start/end times. // Timeline shows 07:00–20:00 (hour offset 0 = 07:00). 48px per hour. topPx := 48 // default: 08:00 heightPx := 24 // default: 30 min if row.StartTime.Valid { totalMicros := row.StartTime.Microseconds hours := int(totalMicros / 3_600_000_000) mins := int((totalMicros % 3_600_000_000) / 60_000_000) topPx = (hours-7)*48 + mins*48/60 if topPx < 0 { topPx = 0 } heightPx = 24 // default 30 min if no end time if row.EndTime.Valid { endMicros := row.EndTime.Microseconds endHours := int(endMicros / 3_600_000_000) endMins := int((endMicros % 3_600_000_000) / 60_000_000) durationMins := (endHours*60 + endMins) - (hours*60 + mins) if durationMins < 0 { durationMins = 30 } heightPx = durationMins * 48 / 60 } } if heightPx < 20 { heightPx = 20 } return CalendarTimeEvent{ Title: row.Title, ColorClass: colorClass, Style: style, URL: PlanningEventURL(row), TopPx: topPx, HeightPx: heightPx, } } // BuildMonthCalendar constructs a PlanningCalendar for the month view. func BuildMonthCalendar(month time.Time, today time.Time, rows []sqlc.ListUserEventsRangeRow) PlanningCalendar { // Index events by date byDate := map[string][]CalendarEvent{} for _, row := range rows { if !row.EventDate.Valid { continue } key := row.EventDate.Time.Format("2006-01-02") byDate[key] = append(byDate[key], calEventFromRow(row)) } // First day of month, last day of month first := time.Date(month.Year(), month.Month(), 1, 0, 0, 0, 0, month.Location()) last := first.AddDate(0, 1, -1) // Start grid from the Monday on or before 1st of month gridStart := MondayOf(first) // Build 6 weeks (42 days) to ensure we always cover the month var weeks [][]CalendarDay cursor := gridStart for w := 0; w < 6; w++ { var week []CalendarDay for d := 0; d < 7; d++ { key := cursor.Format("2006-01-02") week = append(week, CalendarDay{ Date: cursor, DayNum: cursor.Day(), IsToday: samePlanningDay(cursor, today), OtherMonth: cursor.Month() != first.Month(), Events: byDate[key], }) cursor = cursor.AddDate(0, 0, 1) } weeks = append(weeks, week) // Stop after we've passed the last day of the month and completed the week if cursor.After(last) && len(weeks) >= 4 { break } } prevMonth := first.AddDate(0, -1, 0) nextMonth := first.AddDate(0, 1, 0) return PlanningCalendar{ View: "month", Label: first.Format("January 2006"), PrevURL: PlanningMonthURL(prevMonth), TodayURL: PlanningMonthURL(today), NextURL: PlanningMonthURL(nextMonth), Weeks: weeks, } } // buildHourSlots returns a slice of hour label strings from 07:00 to 20:00. func buildHourSlots() []string { slots := make([]string, 14) for i := range slots { slots[i] = fmt.Sprintf("%02d:00", 7+i) } return slots } // BuildWeekCalendar constructs a PlanningCalendar for the week view. func BuildWeekCalendar(weekStart time.Time, today time.Time, rows []sqlc.ListUserEventsRangeRow) PlanningCalendar { // Index events by date byDate := map[string][]sqlc.ListUserEventsRangeRow{} for _, row := range rows { if !row.EventDate.Valid { continue } key := row.EventDate.Time.Format("2006-01-02") byDate[key] = append(byDate[key], row) } days := make([]CalendarDayColumn, 7) for i := 0; i < 7; i++ { d := weekStart.AddDate(0, 0, i) key := d.Format("2006-01-02") events := make([]CalendarTimeEvent, 0, len(byDate[key])) for _, row := range byDate[key] { events = append(events, calTimeEventFromRow(row)) } days[i] = CalendarDayColumn{ Date: d, Label: d.Format("Mon 2"), IsToday: samePlanningDay(d, today), Events: events, } } weekEnd := weekStart.AddDate(0, 0, 6) var label string if weekStart.Month() == weekEnd.Month() { label = fmt.Sprintf("%d–%d %s %d", weekStart.Day(), weekEnd.Day(), weekStart.Format("January"), weekStart.Year()) } else { label = weekStart.Format("2 Jan") + " – " + weekEnd.Format("2 Jan 2006") } prevWeek := weekStart.AddDate(0, 0, -7) nextWeek := weekStart.AddDate(0, 0, 7) mini, miniLabel := buildMiniMonth(weekStart, today, weekStart, weekEnd) return PlanningCalendar{ View: "week", Label: label, PrevURL: PlanningWeekURL(prevWeek), TodayURL: PlanningWeekURL(today), NextURL: PlanningWeekURL(nextWeek), Days: days, HourSlots: buildHourSlots(), MiniMonth: mini, MiniMonthLabel: miniLabel, } } // BuildDayCalendar constructs a PlanningCalendar for the day view. func BuildDayCalendar(date time.Time, today time.Time, rows []sqlc.ListUserEventsRangeRow) PlanningCalendar { events := make([]CalendarTimeEvent, 0, len(rows)) for _, row := range rows { events = append(events, calTimeEventFromRow(row)) } days := []CalendarDayColumn{ { Date: date, Label: date.Format("Monday 2 January"), IsToday: samePlanningDay(date, today), Events: events, }, } prevDay := date.AddDate(0, 0, -1) nextDay := date.AddDate(0, 0, 1) mini, miniLabel := buildMiniMonth(date, today, date, date) return PlanningCalendar{ View: "day", Label: date.Format("Monday 2 January 2006"), PrevURL: PlanningDayURL(prevDay), TodayURL: PlanningDayURL(today), NextURL: PlanningDayURL(nextDay), Days: days, HourSlots: buildHourSlots(), MiniMonth: mini, MiniMonthLabel: miniLabel, } } // buildMiniMonth builds the 7-col mini-month grid for the week/day sidebar panel. // highlightStart/highlightEnd define the range of days that get the in-week class. func buildMiniMonth(anchor time.Time, today time.Time, highlightStart time.Time, highlightEnd time.Time) ([][]MiniCalDay, string) { first := time.Date(anchor.Year(), anchor.Month(), 1, 0, 0, 0, 0, anchor.Location()) last := first.AddDate(0, 1, -1) gridStart := MondayOf(first) var weeks [][]MiniCalDay cursor := gridStart for w := 0; w < 6; w++ { var week []MiniCalDay for d := 0; d < 7; d++ { inWeek := !cursor.Before(highlightStart) && !cursor.After(highlightEnd) week = append(week, MiniCalDay{ Date: cursor, DayNum: cursor.Day(), IsToday: samePlanningDay(cursor, today), InWeek: inWeek, URL: PlanningDayURL(cursor), }) cursor = cursor.AddDate(0, 0, 1) } weeks = append(weeks, week) if cursor.After(last) && len(weeks) >= 4 { break } } return weeks, first.Format("January 2006") }