xtablo-source/backend/templates/planning_forms.go
Arthur Belleville eaaec7a89d
Some checks are pending
xtablo-ci / Checks (pull_request) Waiting to run
go-backend-ci / Check go-backend (pull_request) Waiting to run
style(planning): implement Month/Week/Day calendar views from sketch 005
- 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)
2026-05-18 16:56:44 +02:00

479 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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:0020: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")
}