- Replace 14-day agenda list with three-view calendar (month/week/day) - Add PlanningCalendar, CalendarDay, CalendarTimeEvent, MiniCalDay data structs - Add BuildMonthCalendar, BuildWeekCalendar, BuildDayCalendar builder helpers - Add MondayOf, PlanningMonthURL, PlanningWeekURL, PlanningDayURL URL helpers - Handler now parses ?view=month|week|day with matching date params - Month view: 7-col grid with event chips using tablo color via color-mix() - Week/Day view: split layout with mini-month panel and hour timeline (07-20) - Timeline events positioned via TopPx/HeightPx from start_time microseconds - Append all calendar CSS to app.css (view-toggle, cal-grid, tl-* classes)
479 lines
13 KiB
Go
479 lines
13 KiB
Go
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")
|
||
}
|