diff --git a/backend/internal/web/handlers_planning.go b/backend/internal/web/handlers_planning.go index 720cb82..bf5303d 100644 --- a/backend/internal/web/handlers_planning.go +++ b/backend/internal/web/handlers_planning.go @@ -31,6 +31,18 @@ func parsePlanningStart(raw string, now time.Time) time.Time { return time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 0, 0, 0, 0, time.Local) } +func parsePlanningMonth(raw string, now time.Time) time.Time { + raw = strings.TrimSpace(raw) + if raw == "" { + return time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.Local) + } + parsed, err := time.Parse("2006-01", raw) + if err != nil { + return time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.Local) + } + return time.Date(parsed.Year(), parsed.Month(), 1, 0, 0, 0, 0, time.Local) +} + func PlanningPageHandler(deps PlanningDeps) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { _, user, _ := auth.Authed(r.Context()) @@ -38,22 +50,13 @@ func PlanningPageHandler(deps PlanningDeps) http.HandlerFunc { if deps.Now != nil { now = deps.Now } - start := parsePlanningStart(r.URL.Query().Get("start"), now()) - end := start.AddDate(0, 0, 13) + today := parsePlanningStart("", now()) - rows, err := deps.Queries.ListUserEventsRange(r.Context(), sqlc.ListUserEventsRangeParams{ - UserID: user.ID, - StartDate: pgDateFromTime(start), - EndDate: pgDateFromTime(end), - }) - if err != nil { - slog.Default().Error("planning: ListUserEventsRange failed", "user_id", user.ID, "err", err) - http.Error(w, "internal server error", http.StatusInternalServerError) - return + view := strings.TrimSpace(r.URL.Query().Get("view")) + if view == "" { + view = "month" } - agenda := templates.NewPlanningAgenda(start, end, parsePlanningStart("", now()), rows) - sidebarTablos, err := deps.Queries.ListTablosByUser(r.Context(), user.ID) if err != nil { slog.Default().Error("planning: ListTablosByUser failed", "user_id", user.ID, "err", err) @@ -64,8 +67,57 @@ func PlanningPageHandler(deps PlanningDeps) http.HandlerFunc { sidebarTablos = []sqlc.Tablo{} } + var cal templates.PlanningCalendar + + switch view { + case "week": + weekStart := templates.MondayOf(parsePlanningStart(r.URL.Query().Get("start"), now())) + weekEnd := weekStart.AddDate(0, 0, 6) + rows, err := deps.Queries.ListUserEventsRange(r.Context(), sqlc.ListUserEventsRangeParams{ + UserID: user.ID, + StartDate: pgDateFromTime(weekStart), + EndDate: pgDateFromTime(weekEnd), + }) + if err != nil { + slog.Default().Error("planning: ListUserEventsRange (week) failed", "user_id", user.ID, "err", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + cal = templates.BuildWeekCalendar(weekStart, today, rows) + + case "day": + date := parsePlanningStart(r.URL.Query().Get("date"), now()) + rows, err := deps.Queries.ListUserEventsRange(r.Context(), sqlc.ListUserEventsRangeParams{ + UserID: user.ID, + StartDate: pgDateFromTime(date), + EndDate: pgDateFromTime(date), + }) + if err != nil { + slog.Default().Error("planning: ListUserEventsRange (day) failed", "user_id", user.ID, "err", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + cal = templates.BuildDayCalendar(date, today, rows) + + default: // "month" + month := parsePlanningMonth(r.URL.Query().Get("month"), now()) + firstOfMonth := time.Date(month.Year(), month.Month(), 1, 0, 0, 0, 0, time.Local) + lastOfMonth := firstOfMonth.AddDate(0, 1, -1) + rows, err := deps.Queries.ListUserEventsRange(r.Context(), sqlc.ListUserEventsRangeParams{ + UserID: user.ID, + StartDate: pgDateFromTime(firstOfMonth), + EndDate: pgDateFromTime(lastOfMonth), + }) + if err != nil { + slog.Default().Error("planning: ListUserEventsRange (month) failed", "user_id", user.ID, "err", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + cal = templates.BuildMonthCalendar(month, today, rows) + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") - _ = templates.PlanningPage(user, csrf.Token(r), "/planning", sidebarTablos, agenda, + _ = templates.PlanningCalendarPage(user, csrf.Token(r), "/planning", sidebarTablos, cal, "Planning", []templates.BreadcrumbItem{{Label: "Planning", Href: ""}}, ).Render(r.Context(), w) diff --git a/backend/internal/web/ui/app.css b/backend/internal/web/ui/app.css index ec87dfc..a7748a6 100644 --- a/backend/internal/web/ui/app.css +++ b/backend/internal/web/ui/app.css @@ -1299,3 +1299,624 @@ .task-card:hover .task-drag-handle { opacity: 1; } + +/* ============================================================ + Section — Planning calendar views (Month / Week / Day) + ============================================================ */ + +/* Page shell */ +.planning-page { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +.planning-header { + align-items: center; + border-bottom: 1px solid var(--color-border-default); + display: flex; + flex-shrink: 0; + gap: 12px; + padding: 16px 24px; +} + +.planning-period { + color: var(--color-text-primary); + flex: 1; + font-size: 0.875rem; + font-weight: 600; +} + +.planning-month-body { + flex: 1; + overflow-y: auto; + padding: 0 24px 24px; +} + +/* Small icon-button for prev/next arrows */ +.icon-btn { + align-items: center; + background: transparent; + border: 1px solid var(--color-border-default); + border-radius: 6px; + color: var(--color-text-muted); + cursor: pointer; + display: inline-flex; + justify-content: center; + padding: 5px 6px; + text-decoration: none; + transition: background 0.15s; +} +.icon-btn:hover { + background: var(--color-surface-muted); +} + +/* View toggle */ +.view-toggle { + border: 1px solid var(--color-border-default); + border-radius: 8px; + display: flex; + overflow: hidden; +} + +.view-btn { + align-items: center; + background: transparent; + border: none; + color: var(--color-text-muted); + cursor: pointer; + display: flex; + font-size: 0.8125rem; + padding: 6px 10px; + text-decoration: none; + transition: background 0.15s; +} + +.view-btn + .view-btn { + border-left: 1px solid var(--color-border-default); +} + +.view-btn.active { + background: color-mix(in srgb, var(--color-brand-primary) 10%, transparent); + color: var(--color-brand-primary); +} + +/* ---- Month view ---- */ + +.cal-header { + display: grid; + grid-template-columns: repeat(7, 1fr); +} + +.cal-dow { + color: var(--color-text-muted); + font-size: 0.75rem; + font-weight: 600; + padding: 8px 0; + text-align: center; +} + +.cal-grid { + border-left: 1px solid var(--color-border-default); + border-top: 1px solid var(--color-border-default); + display: grid; + grid-auto-rows: minmax(80px, auto); + grid-template-columns: repeat(7, 1fr); +} + +.cal-day { + border-bottom: 1px solid var(--color-border-default); + border-right: 1px solid var(--color-border-default); + min-height: 80px; + padding: 6px 8px; +} + +.cal-day.other-month { + background: var(--color-surface-muted); +} + +.cal-day.today .day-num { + align-items: center; + background: var(--color-brand-primary); + border-radius: 50%; + color: #fff; + display: inline-flex; + height: 24px; + justify-content: center; + width: 24px; +} + +.day-num { + display: block; + font-size: 0.8125rem; + font-weight: 500; + margin-bottom: 4px; +} + +.cal-event { + border-radius: 4px; + cursor: pointer; + display: block; + font-size: 0.6875rem; + font-weight: 500; + margin-bottom: 2px; + overflow: hidden; + padding: 2px 6px; + text-decoration: none; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ev-purple { background: #ede9fe; color: #804eec; } +.ev-blue { background: #dbeafe; color: #1d4ed8; } +.ev-amber { background: #fef3c7; color: #b45309; } +.ev-green { background: #d1fae5; color: #065f46; } +.ev-red { background: #fee2e2; color: #b91c1c; } +.ev-default { + background: var(--color-surface-muted); + color: var(--color-text-secondary); +} + +/* ---- Split layout (Week / Day) ---- */ + +.planning-split { + display: flex; + flex: 1; + height: 100%; + overflow: hidden; +} + +/* Mini-month sidebar panel */ +.mini-panel { + border-right: 1px solid var(--color-border-default); + flex-shrink: 0; + min-width: 220px; + overflow-y: auto; + padding: 16px 12px; + width: 220px; +} + +.mini-month-label { + color: var(--color-text-primary); + font-size: 0.8125rem; + font-weight: 600; + margin-bottom: 8px; +} + +.mini-grid { + display: grid; + gap: 1px; + grid-template-columns: repeat(7, 1fr); + margin-bottom: 4px; +} + +.mini-dow { + color: var(--color-text-muted); + font-size: 0.6875rem; + font-weight: 600; + padding: 4px 0; + text-align: center; +} + +.mini-day { + border-radius: 4px; + color: var(--color-text-primary); + cursor: pointer; + display: block; + font-size: 0.75rem; + padding: 4px 2px; + text-align: center; + text-decoration: none; +} + +.mini-day.today { + background: var(--color-brand-primary); + border-radius: 50%; + color: #fff; + font-weight: 600; +} + +.mini-day.in-week { + background: color-mix(in srgb, var(--color-brand-primary) 12%, transparent); + color: var(--color-brand-primary); +} + +/* Timeline (week / day) */ +.timeline-outer { + display: flex; + flex: 1; + flex-direction: column; + overflow: hidden; +} + +.timeline-col-header { + border-bottom: 1px solid var(--color-border-default); + display: flex; +} + +.tl-gutter { + flex-shrink: 0; + width: 48px; +} + +.tl-day-header { + border-left: 1px solid var(--color-border-default); + flex: 1; + font-size: 0.8125rem; + font-weight: 500; + padding: 8px 4px; + text-align: center; +} + +.tl-day-header.today { + color: var(--color-brand-primary); + font-weight: 700; +} + +.timeline-body { + display: flex; + flex: 1; + overflow-y: auto; +} + +.tl-time-col { + flex-shrink: 0; + width: 48px; +} + +.tl-hour-label { + border-bottom: 1px solid var(--color-border-default); + color: var(--color-text-muted); + font-size: 0.6875rem; + height: 48px; + padding-right: 8px; + padding-top: 4px; + text-align: right; +} + +.tl-day-col { + border-left: 1px solid var(--color-border-default); + flex: 1; + position: relative; +} + +.tl-hour-row { + border-bottom: 1px solid var(--color-border-default); + height: 48px; +} + +.tl-event { + border-radius: 6px; + cursor: pointer; + font-size: 0.6875rem; + font-weight: 500; + left: 3px; + min-height: 20px; + overflow: hidden; + padding: 3px 6px; + position: absolute; + right: 3px; + text-decoration: none; +} + +.now-line { + background: var(--color-brand-primary); + height: 2px; + left: 0; + pointer-events: none; + position: absolute; + right: 0; + z-index: 3; +} + +.now-dot { + background: var(--color-brand-primary); + border-radius: 50%; + height: 8px; + left: -4px; + position: absolute; + top: -3px; + width: 8px; +} + +/* ============================================================ + Section 28 — Dashboard home layout (Sketch 001+002) + ============================================================ */ + +/* Two-column layout wrapper rendered inside dashboard-main */ +.home-layout { + display: flex; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.home-main { + flex: 1; + min-width: 0; + overflow-y: auto; + padding: 28px 32px; +} + +.contacts-panel { + width: 220px; + min-width: 220px; + border-left: 1px solid var(--color-border-default); + padding: 24px 16px; + overflow-y: auto; +} + +/* Greeting */ +.home-date-line { + font-size: 0.75rem; + color: var(--color-text-muted); + margin-bottom: 4px; +} + +.home-greeting { + font-size: 1.5rem; + font-weight: 700; + color: var(--color-text-primary); + margin: 0 0 20px; +} + +/* Action pills row */ +.action-pills-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 28px; +} + +.action-pill { + display: flex; + align-items: center; + gap: 6px; + padding: 7px 14px; + border-radius: 9999px; + border: 1px solid var(--color-border-default); + background: #fff; + font-size: 0.8125rem; + font-weight: 500; + color: var(--color-text-secondary); + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s; +} + +.action-pill:hover:not(:disabled) { + background: var(--color-brand-primary); + border-color: var(--color-brand-primary); + color: #fff; +} + +.action-pill:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.action-pill.primary-pill { + background: var(--color-brand-primary); + border-color: var(--color-brand-primary); + color: #fff; +} + +.action-pill.primary-pill:hover { + background: var(--color-brand-primary-hover, #6b3fd9); + border-color: var(--color-brand-primary-hover, #6b3fd9); +} + +/* Section headers */ +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.section-title { + font-size: 1rem; + font-weight: 600; + color: var(--color-text-primary); +} + +.see-all { + font-size: 0.875rem; + color: var(--color-text-brand, var(--color-brand-primary)); + cursor: pointer; + text-decoration: none; +} + +.see-all:hover { + text-decoration: underline; +} + +/* ============================================================ + Section 29 — Project cards (Sketch 004) + ============================================================ */ + +.card-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; + margin-bottom: 0; +} + +/* Override .project-card to match sketch 004 */ +.card-grid .project-card { + background: #fff; + border: 1px solid var(--color-border-default); + border-radius: 12px; + padding: 20px; + cursor: pointer; + transition: box-shadow 0.15s, border-color 0.15s; + display: flex; + flex-direction: column; + gap: 0; +} + +.card-grid .project-card:hover { + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08); + border-color: var(--color-border-strong, #d1d5db); +} + +.card-color-circle { + width: 14px; + height: 14px; + border-radius: 50%; + flex-shrink: 0; + display: inline-block; +} + +.card-header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 8px; +} + +.card-title { + font-size: 0.875rem; + font-weight: 600; + color: var(--color-text-primary); + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.card-meta { + font-size: 0.75rem; + color: var(--color-text-muted); + margin-bottom: 12px; +} + +/* Status badges */ +.badge-active { + background: #ecfdf3; + color: #16a34a; + font-size: 10px; + font-weight: 600; + padding: 2px 8px; + border-radius: 9999px; + white-space: nowrap; + flex-shrink: 0; +} + +.badge-archived { + background: var(--color-surface-muted); + color: var(--color-text-muted); + font-size: 10px; + font-weight: 600; + padding: 2px 8px; + border-radius: 9999px; + white-space: nowrap; + flex-shrink: 0; +} + +/* View toggle grid/list buttons */ +.view-toggle { + display: flex; + border: 1px solid var(--color-border-default); + border-radius: 8px; + overflow: hidden; +} + +.view-btn { + padding: 6px 10px; + border: none; + background: transparent; + cursor: pointer; + color: var(--color-text-muted); + display: flex; + align-items: center; + transition: background 0.15s; +} + +.view-btn:first-child { + border-right: 1px solid var(--color-border-default); +} + +.view-btn.active { + background: color-mix(in srgb, var(--color-brand-primary) 10%, transparent); + color: var(--color-text-brand, var(--color-brand-primary)); +} + +/* Filter pills */ +.filter-pill { + padding: 4px 12px; + border-radius: 9999px; + border: 1px solid var(--color-border-default); + background: #fff; + font-size: 0.8125rem; + cursor: pointer; + color: var(--color-text-secondary); + transition: background 0.15s; +} + +.filter-pill.active { + background: var(--color-brand-primary); + color: #fff; + border-color: var(--color-brand-primary); +} + +/* ============================================================ + Section 30 — Projects list table (Sketch 004) + ============================================================ */ + +.projects-table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; +} + +.projects-table th { + text-align: left; + font-size: 0.75rem; + font-weight: 600; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 8px 16px; + border-bottom: 1px solid var(--color-border-default); + background: var(--color-surface-muted); +} + +.projects-table td { + padding: 13px 16px; + border-bottom: 1px solid var(--color-border-default); + font-size: 0.875rem; + vertical-align: middle; +} + +.projects-table tr:hover td { + background: var(--color-surface-subtle); + cursor: pointer; +} + +.proj-name-cell { + display: flex; + align-items: center; + gap: 10px; +} + +.progress-with-label { + display: flex; + align-items: center; + gap: 8px; +} + +.progress-with-label .progress-bar-wrap { + flex: 1; + height: 4px; + background: var(--color-surface-muted); + border-radius: 9999px; + overflow: hidden; +} + +.progress-pct { + font-size: 0.75rem; + color: var(--color-text-muted); + min-width: 32px; +} diff --git a/backend/templates/planning.templ b/backend/templates/planning.templ index 424115b..4af0115 100644 --- a/backend/templates/planning.templ +++ b/backend/templates/planning.templ @@ -1,50 +1,244 @@ package templates import ( + "fmt" "backend/internal/auth" "backend/internal/db/sqlc" ) -templ PlanningPage(user *auth.User, csrfToken string, activePath string, tablos []sqlc.Tablo, agenda PlanningAgenda, pageTitle string, breadcrumb []BreadcrumbItem) { - @AppLayout("Planning - Xtablo", user, csrfToken, activePath, tablos, pageTitle, breadcrumb, nil) { -
-
-
-

Planning

-

{ agenda.RangeLabel }

-
- -
- if len(agenda.Events) == 0 { -
-

No events in this range

-

Use the navigation controls to browse another 14-day window.

-
+// PlanningCalendarPage is the top-level page template for the /planning route. +// It renders the appropriate view (month / week / day) based on cal.View. +templ PlanningCalendarPage(user *auth.User, csrfToken string, activePath string, sidebarTablos []sqlc.Tablo, cal PlanningCalendar, pageTitle string, breadcrumb []BreadcrumbItem) { + @AppLayout("Planning - Xtablo", user, csrfToken, activePath, sidebarTablos, pageTitle, breadcrumb, nil) { +
+ @PlanningHeader(cal) + if cal.View == "month" { + @PlanningMonthView(cal) } else { - + @PlanningWeekDayView(cal) } -
+ } } +// PlanningHeader renders the navigation row: prev/today/next + period label + view toggle. +templ PlanningHeader(cal PlanningCalendar) { +
+ + { cal.Label } +
+ Month + Week + Day +
+
+} + +// --------------------------------------------------------------------------- +// Month view +// --------------------------------------------------------------------------- + +templ PlanningMonthView(cal PlanningCalendar) { +
+
+
Mon
+
Tue
+
Wed
+
Thu
+
Fri
+
Sat
+
Sun
+
+
+ for _, week := range cal.Weeks { + for _, day := range week { + @MonthDayCell(day) + } + } +
+
+} + +templ MonthDayCell(day CalendarDay) { + if day.OtherMonth { +
+ { fmt.Sprintf("%d", day.DayNum) } +
+ } else if day.IsToday { +
+ { fmt.Sprintf("%d", day.DayNum) } + for _, ev := range day.Events { + @CalEventChip(ev) + } +
+ } else { +
+ { fmt.Sprintf("%d", day.DayNum) } + for _, ev := range day.Events { + @CalEventChip(ev) + } +
+ } +} + +templ CalEventChip(ev CalendarEvent) { + if ev.Style != "" { + + if ev.TimeLabel != "" { + { ev.TimeLabel } + } + { ev.Title } + + } else { + + if ev.TimeLabel != "" { + { ev.TimeLabel } + } + { ev.Title } + + } +} + +// --------------------------------------------------------------------------- +// Week / Day view (shared layout with mini-month panel + timeline) +// --------------------------------------------------------------------------- + +templ PlanningWeekDayView(cal PlanningCalendar) { +
+ @MiniMonthPanel(cal) +
+
+
+ for _, day := range cal.Days { + if day.IsToday { +
{ day.Label }
+ } else { +
{ day.Label }
+ } + } +
+
+
+ for _, slot := range cal.HourSlots { +
{ slot }
+ } +
+ for _, day := range cal.Days { + @TimelineDayCol(day) + } +
+
+
+} + +templ TimelineDayCol(day CalendarDayColumn) { +
+ for range day.Events { + + } + for i := range [14]struct{}{} { +
+ } + for _, ev := range day.Events { + @TimelineEventBlock(ev) + } +
+} + +templ TimelineEventBlock(ev CalendarTimeEvent) { + if ev.Style != "" { + { ev.Title } + } else { + { ev.Title } + } +} + +// --------------------------------------------------------------------------- +// Mini-month panel +// --------------------------------------------------------------------------- + +templ MiniMonthPanel(cal PlanningCalendar) { +
+

{ cal.MiniMonthLabel }

+
+
M
+
T
+
W
+
T
+
F
+
S
+
S
+
+ for _, week := range cal.MiniMonth { +
+ for _, d := range week { + @MiniDayCell(d) + } +
+ } +
+} + +templ MiniDayCell(d MiniCalDay) { + if d.IsToday { + { fmt.Sprintf("%d", d.DayNum) } + } else if d.InWeek { + { fmt.Sprintf("%d", d.DayNum) } + } else { + { fmt.Sprintf("%d", d.DayNum) } + } +} + +// --------------------------------------------------------------------------- +// Legacy agenda templates kept for reference (no longer used by handler) +// --------------------------------------------------------------------------- + templ PlanningDaySeparator(label string) {
  • { label } diff --git a/backend/templates/planning_forms.go b/backend/templates/planning_forms.go index 4eed345..a263f43 100644 --- a/backend/templates/planning_forms.go +++ b/backend/templates/planning_forms.go @@ -1,6 +1,7 @@ package templates import ( + "fmt" "time" "backend/internal/db/sqlc" @@ -112,3 +113,367 @@ func PlanningShowDaySeparator(events []PlanningEventRow, index int) bool { } 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") +} diff --git a/backend/templates/tablos.templ b/backend/templates/tablos.templ index 95a991d..237ad2c 100644 --- a/backend/templates/tablos.templ +++ b/backend/templates/tablos.templ @@ -5,78 +5,132 @@ import ( "backend/internal/db/sqlc" "backend/internal/web/ui" "strconv" + "strings" + "time" ) +// tablosGreeting returns a time-appropriate greeting prefix. +func tablosGreeting() string { + h := time.Now().Hour() + switch { + case h < 12: + return "Bonjour" + case h < 18: + return "Bon après-midi" + default: + return "Bonsoir" + } +} + +// tablosDisplayName returns the best available display name for a user. +// auth.User has no FirstName field, so we use the email prefix. +func tablosDisplayName(user *auth.User) string { + prefix, _, _ := strings.Cut(user.Email, "@") + if prefix == "" { + return user.Email + } + return prefix +} + // TablosDashboard renders the root authenticated dashboard with sidebar AppLayout. // Shows a project-card grid (or empty state) for the user's tablos. templ TablosDashboard(user *auth.User, csrfToken string, activePath string, tablos []sqlc.Tablo, cards []TabloCardView, pageTitle string, breadcrumb []BreadcrumbItem) { @AppLayout("Tablos — Xtablo", user, csrfToken, activePath, tablos, pageTitle, breadcrumb, nil) { -
    - -
    -

    Mes Projets

    - @ui.Button(ui.ButtonProps{ - Label: "Nouveau projet", - Variant: ui.ButtonVariantDefault, - Tone: ui.ButtonToneSolid, - Size: ui.SizeMD, - Type: "button", - Attrs: templ.Attributes{ - "hx-get": "/tablos/new", - "hx-target": "#create-form-slot", - "hx-swap": "innerHTML", - }, - }) -
    -
    - -
    - - -
    - -
    - - - -
    - -
    - if len(cards) == 0 { - @TablosEmptyState() - } else { - for _, card := range cards { - @TabloProjectCard(card, csrfToken) - } - } -
    - - + + + +
    +
    + Mes Tâches +
    +

    Les tâches apparaîtront ici.

    +
    +
    + +
    +

    Contacts

    +

    Vos contacts apparaîtront ici.