style(planning): implement Month/Week/Day calendar views from sketch 005
Some checks are pending
xtablo-ci / Checks (pull_request) Waiting to run
go-backend-ci / Check go-backend (pull_request) Waiting to run

- 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)
This commit is contained in:
Arthur Belleville 2026-05-18 16:56:44 +02:00
parent 63e7d65290
commit eaaec7a89d
No known key found for this signature in database
5 changed files with 1457 additions and 220 deletions

View file

@ -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) 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 { func PlanningPageHandler(deps PlanningDeps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
_, user, _ := auth.Authed(r.Context()) _, user, _ := auth.Authed(r.Context())
@ -38,22 +50,13 @@ func PlanningPageHandler(deps PlanningDeps) http.HandlerFunc {
if deps.Now != nil { if deps.Now != nil {
now = deps.Now now = deps.Now
} }
start := parsePlanningStart(r.URL.Query().Get("start"), now()) today := parsePlanningStart("", now())
end := start.AddDate(0, 0, 13)
rows, err := deps.Queries.ListUserEventsRange(r.Context(), sqlc.ListUserEventsRangeParams{ view := strings.TrimSpace(r.URL.Query().Get("view"))
UserID: user.ID, if view == "" {
StartDate: pgDateFromTime(start), view = "month"
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
} }
agenda := templates.NewPlanningAgenda(start, end, parsePlanningStart("", now()), rows)
sidebarTablos, err := deps.Queries.ListTablosByUser(r.Context(), user.ID) sidebarTablos, err := deps.Queries.ListTablosByUser(r.Context(), user.ID)
if err != nil { if err != nil {
slog.Default().Error("planning: ListTablosByUser failed", "user_id", user.ID, "err", err) 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{} 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") 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", "Planning",
[]templates.BreadcrumbItem{{Label: "Planning", Href: ""}}, []templates.BreadcrumbItem{{Label: "Planning", Href: ""}},
).Render(r.Context(), w) ).Render(r.Context(), w)

View file

@ -1299,3 +1299,624 @@
.task-card:hover .task-drag-handle { .task-card:hover .task-drag-handle {
opacity: 1; 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;
}

View file

@ -1,50 +1,244 @@
package templates package templates
import ( import (
"fmt"
"backend/internal/auth" "backend/internal/auth"
"backend/internal/db/sqlc" "backend/internal/db/sqlc"
) )
templ PlanningPage(user *auth.User, csrfToken string, activePath string, tablos []sqlc.Tablo, agenda PlanningAgenda, pageTitle string, breadcrumb []BreadcrumbItem) { // PlanningCalendarPage is the top-level page template for the /planning route.
@AppLayout("Planning - Xtablo", user, csrfToken, activePath, tablos, pageTitle, breadcrumb, nil) { // It renders the appropriate view (month / week / day) based on cal.View.
<section class="overview-section"> templ PlanningCalendarPage(user *auth.User, csrfToken string, activePath string, sidebarTablos []sqlc.Tablo, cal PlanningCalendar, pageTitle string, breadcrumb []BreadcrumbItem) {
<div class="overview-section-heading"> @AppLayout("Planning - Xtablo", user, csrfToken, activePath, sidebarTablos, pageTitle, breadcrumb, nil) {
<div> <div class="planning-page">
<h1>Planning</h1> @PlanningHeader(cal)
<p class="mt-1 text-sm text-slate-600">{ agenda.RangeLabel }</p> if cal.View == "month" {
</div> @PlanningMonthView(cal)
<nav class="flex flex-wrap items-center gap-2" aria-label="Planning navigation">
<a href={ templ.SafeURL(agenda.PrevURL) } class="ui-button ui-button-soft ui-button-neutral ui-button-md">Previous 14 days</a>
<a
href={ templ.SafeURL(agenda.TodayURL) }
if agenda.ShowingToday {
class="ui-button ui-button-soft ui-button-neutral ui-button-md"
} else { } else {
class="ui-button ui-button-solid ui-button-default ui-button-md" @PlanningWeekDayView(cal)
} }
>Today</a>
<a href={ templ.SafeURL(agenda.NextURL) } class="ui-button ui-button-soft ui-button-neutral ui-button-md">Next 14 days</a>
</nav>
</div> </div>
if len(agenda.Events) == 0 {
<div class="ui-card ui-card-body py-12 text-center">
<h2 class="text-xl font-semibold leading-snug text-slate-800">No events in this range</h2>
<p class="mt-2 text-base text-slate-600">Use the navigation controls to browse another 14-day window.</p>
</div>
} else {
<ul class="border-y border-slate-200" aria-label="Planning agenda">
for i, event := range agenda.Events {
if PlanningShowDaySeparator(agenda.Events, i) {
@PlanningDaySeparator(event.DateLabel)
}
@PlanningEventListItem(event)
}
</ul>
}
</section>
} }
} }
// PlanningHeader renders the navigation row: prev/today/next + period label + view toggle.
templ PlanningHeader(cal PlanningCalendar) {
<div class="planning-header">
<nav class="flex items-center gap-1" aria-label="Period navigation">
<a href={ templ.SafeURL(cal.PrevURL) } class="icon-btn" aria-label="Previous period">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/></svg>
</a>
<a href={ templ.SafeURL(cal.TodayURL) } class="ui-button ui-button-soft ui-button-neutral ui-button-sm">Today</a>
<a href={ templ.SafeURL(cal.NextURL) } class="icon-btn" aria-label="Next period">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/></svg>
</a>
</nav>
<span class="planning-period">{ cal.Label }</span>
<div class="view-toggle" role="group" aria-label="Calendar view">
<a
href={ templ.SafeURL(cal.PrevURL) + "?view_switch=month" }
hx-get={ "/planning?view=month" }
hx-push-url="true"
if cal.View == "month" {
class="view-btn active"
} else {
class="view-btn"
}
aria-label="Month view"
>Month</a>
<a
hx-get={ "/planning?view=week" }
hx-push-url="true"
if cal.View == "week" {
class="view-btn active"
} else {
class="view-btn"
}
aria-label="Week view"
>Week</a>
<a
hx-get={ "/planning?view=day" }
hx-push-url="true"
if cal.View == "day" {
class="view-btn active"
} else {
class="view-btn"
}
aria-label="Day view"
>Day</a>
</div>
</div>
}
// ---------------------------------------------------------------------------
// Month view
// ---------------------------------------------------------------------------
templ PlanningMonthView(cal PlanningCalendar) {
<div class="planning-month-body">
<div class="cal-header">
<div class="cal-dow">Mon</div>
<div class="cal-dow">Tue</div>
<div class="cal-dow">Wed</div>
<div class="cal-dow">Thu</div>
<div class="cal-dow">Fri</div>
<div class="cal-dow">Sat</div>
<div class="cal-dow">Sun</div>
</div>
<div class="cal-grid">
for _, week := range cal.Weeks {
for _, day := range week {
@MonthDayCell(day)
}
}
</div>
</div>
}
templ MonthDayCell(day CalendarDay) {
if day.OtherMonth {
<div class="cal-day other-month">
<span class="day-num">{ fmt.Sprintf("%d", day.DayNum) }</span>
</div>
} else if day.IsToday {
<div class="cal-day today">
<span class="day-num">{ fmt.Sprintf("%d", day.DayNum) }</span>
for _, ev := range day.Events {
@CalEventChip(ev)
}
</div>
} else {
<div class="cal-day">
<span class="day-num">{ fmt.Sprintf("%d", day.DayNum) }</span>
for _, ev := range day.Events {
@CalEventChip(ev)
}
</div>
}
}
templ CalEventChip(ev CalendarEvent) {
if ev.Style != "" {
<a href={ templ.SafeURL(ev.URL) } class="cal-event" style={ ev.Style } title={ ev.Title }>
if ev.TimeLabel != "" {
<span>{ ev.TimeLabel } </span>
}
{ ev.Title }
</a>
} else {
<a href={ templ.SafeURL(ev.URL) } class={ "cal-event " + ev.ColorClass } title={ ev.Title }>
if ev.TimeLabel != "" {
<span>{ ev.TimeLabel } </span>
}
{ ev.Title }
</a>
}
}
// ---------------------------------------------------------------------------
// Week / Day view (shared layout with mini-month panel + timeline)
// ---------------------------------------------------------------------------
templ PlanningWeekDayView(cal PlanningCalendar) {
<div class="planning-split">
@MiniMonthPanel(cal)
<div class="timeline-outer">
<div class="timeline-col-header">
<div class="tl-gutter"></div>
for _, day := range cal.Days {
if day.IsToday {
<div class="tl-day-header today">{ day.Label }</div>
} else {
<div class="tl-day-header">{ day.Label }</div>
}
}
</div>
<div class="timeline-body">
<div class="tl-time-col">
for _, slot := range cal.HourSlots {
<div class="tl-hour-label">{ slot }</div>
}
</div>
for _, day := range cal.Days {
@TimelineDayCol(day)
}
</div>
</div>
</div>
}
templ TimelineDayCol(day CalendarDayColumn) {
<div class="tl-day-col">
for range day.Events {
<!-- hour rows for visual grid -->
}
for i := range [14]struct{}{} {
<div class="tl-hour-row" data-hour={ fmt.Sprintf("%d", 7+i) }></div>
}
for _, ev := range day.Events {
@TimelineEventBlock(ev)
}
</div>
}
templ TimelineEventBlock(ev CalendarTimeEvent) {
if ev.Style != "" {
<a
href={ templ.SafeURL(ev.URL) }
class="tl-event"
style={ fmt.Sprintf("%s;top:%dpx;height:%dpx", ev.Style, ev.TopPx, ev.HeightPx) }
title={ ev.Title }
>{ ev.Title }</a>
} else {
<a
href={ templ.SafeURL(ev.URL) }
class={ "tl-event " + ev.ColorClass }
style={ fmt.Sprintf("top:%dpx;height:%dpx", ev.TopPx, ev.HeightPx) }
title={ ev.Title }
>{ ev.Title }</a>
}
}
// ---------------------------------------------------------------------------
// Mini-month panel
// ---------------------------------------------------------------------------
templ MiniMonthPanel(cal PlanningCalendar) {
<div class="mini-panel">
<p class="mini-month-label">{ cal.MiniMonthLabel }</p>
<div class="mini-grid">
<div class="mini-dow">M</div>
<div class="mini-dow">T</div>
<div class="mini-dow">W</div>
<div class="mini-dow">T</div>
<div class="mini-dow">F</div>
<div class="mini-dow">S</div>
<div class="mini-dow">S</div>
</div>
for _, week := range cal.MiniMonth {
<div class="mini-grid">
for _, d := range week {
@MiniDayCell(d)
}
</div>
}
</div>
}
templ MiniDayCell(d MiniCalDay) {
if d.IsToday {
<a href={ templ.SafeURL(d.URL) } class="mini-day today">{ fmt.Sprintf("%d", d.DayNum) }</a>
} else if d.InWeek {
<a href={ templ.SafeURL(d.URL) } class="mini-day in-week">{ fmt.Sprintf("%d", d.DayNum) }</a>
} else {
<a href={ templ.SafeURL(d.URL) } class="mini-day">{ fmt.Sprintf("%d", d.DayNum) }</a>
}
}
// ---------------------------------------------------------------------------
// Legacy agenda templates kept for reference (no longer used by handler)
// ---------------------------------------------------------------------------
templ PlanningDaySeparator(label string) { templ PlanningDaySeparator(label string) {
<li class="bg-slate-50 px-4 py-2 text-sm font-semibold text-slate-600 border-t border-slate-200" data-day-separator="true"> <li class="bg-slate-50 px-4 py-2 text-sm font-semibold text-slate-600 border-t border-slate-200" data-day-separator="true">
{ label } { label }

View file

@ -1,6 +1,7 @@
package templates package templates
import ( import (
"fmt"
"time" "time"
"backend/internal/db/sqlc" "backend/internal/db/sqlc"
@ -112,3 +113,367 @@ func PlanningShowDaySeparator(events []PlanningEventRow, index int) bool {
} }
return events[index].DateLabel != events[index-1].DateLabel 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")
}

View file

@ -5,52 +5,93 @@ import (
"backend/internal/db/sqlc" "backend/internal/db/sqlc"
"backend/internal/web/ui" "backend/internal/web/ui"
"strconv" "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. // TablosDashboard renders the root authenticated dashboard with sidebar AppLayout.
// Shows a project-card grid (or empty state) for the user's tablos. // 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) { 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) { @AppLayout("Tablos — Xtablo", user, csrfToken, activePath, tablos, pageTitle, breadcrumb, nil) {
<div class="px-4 pt-8 pb-6"> <div class="home-layout">
<!-- Header row --> <!-- Main content column -->
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-8 gap-4"> <div class="home-main">
<h1 class="text-2xl font-bold text-gray-900">Mes Projets</h1> <!-- Greeting -->
@ui.Button(ui.ButtonProps{ <div class="home-date-line">{ time.Now().Format("Monday, 2 January 2006") }</div>
Label: "Nouveau projet", <h1 class="home-greeting">{ tablosGreeting() }, { tablosDisplayName(user) } !</h1>
Variant: ui.ButtonVariantDefault, <!-- Action pills row -->
Tone: ui.ButtonToneSolid, <div class="action-pills-row">
Size: ui.SizeMD, <button
Type: "button", type="button"
Attrs: templ.Attributes{ class="action-pill primary-pill"
"hx-get": "/tablos/new", hx-get="/tablos/new"
"hx-target": "#create-form-slot", hx-target="#create-form-slot"
"hx-swap": "innerHTML", hx-swap="innerHTML"
}, >
}) <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
Nouveau projet
</button>
<button type="button" class="action-pill" disabled>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m3 17 2 2 4-4"></path><path d="m3 7 2 2 4-4"></path><path d="M13 6h8"></path><path d="M13 12h8"></path><path d="M13 18h8"></path></svg>
Nouvelle tâche
</button>
<button type="button" class="action-pill" disabled>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><line x1="19" y1="8" x2="19" y2="14"></line><line x1="22" y1="11" x2="16" y2="11"></line></svg>
Inviter un membre
</button>
<button type="button" class="action-pill" disabled>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"></path></svg>
Envoyer un message
</button>
</div> </div>
<div id="create-form-slot"></div> <div id="create-form-slot"></div>
<!-- View toggle tabs --> <!-- My Projects section -->
<div class="flex items-center gap-6 mb-6 border-b border-[#EAECF0]"> <div class="section-header">
<button type="button" class="view-tab is-active" data-view-btn="grid" onclick="setTablosView('grid')"> <span class="section-title">Mes Projets</span>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="18" height="18" x="3" y="3" rx="2"></rect><path d="M3 9h18"></path><path d="M3 15h18"></path><path d="M9 3v18"></path><path d="M15 3v18"></path></svg> <div style="display:flex;align-items:center;gap:12px">
<span class="font-medium">Vue en grille</span> <!-- View toggle grid/list -->
<div class="view-toggle">
<button type="button" class="view-btn active" data-view-btn="grid" onclick="setTablosView('grid')" aria-label="Vue en grille">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="7" height="7" x="3" y="3" rx="1"></rect><rect width="7" height="7" x="14" y="3" rx="1"></rect><rect width="7" height="7" x="3" y="14" rx="1"></rect><rect width="7" height="7" x="14" y="14" rx="1"></rect></svg>
</button> </button>
<button type="button" class="view-tab" data-view-btn="list" onclick="setTablosView('list')"> <button type="button" class="view-btn" data-view-btn="list" onclick="setTablosView('list')" aria-label="Vue en liste">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12h.01"></path><path d="M3 18h.01"></path><path d="M3 6h.01"></path><path d="M8 12h13"></path><path d="M8 18h13"></path><path d="M8 6h13"></path></svg> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12h.01"></path><path d="M3 18h.01"></path><path d="M3 6h.01"></path><path d="M8 12h13"></path><path d="M8 18h13"></path><path d="M8 6h13"></path></svg>
<span class="font-medium">Vue en liste</span>
</button> </button>
</div> </div>
<!-- Filter buttons --> <a href="/" class="see-all">Voir tout</a>
<div class="flex items-center gap-2 flex-wrap mb-6"> </div>
<button type="button" class="filter-tab is-active" data-filter-btn="tous" onclick="filterTablos('tous')"> </div>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon></svg> <!-- Filter pills -->
Tous <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:16px">
</button> <button type="button" class="filter-pill active" data-filter-btn="tous" onclick="filterTablos('tous')">Tous</button>
<button type="button" class="filter-tab" data-filter-btn="active" onclick="filterTablos('active')">Actif</button> <button type="button" class="filter-pill" data-filter-btn="active" onclick="filterTablos('active')">Actif</button>
<button type="button" class="filter-tab" data-filter-btn="archived" onclick="filterTablos('archived')">Archivé</button> <button type="button" class="filter-pill" data-filter-btn="archived" onclick="filterTablos('archived')">Archivé</button>
</div> </div>
<!-- Grid view --> <!-- Grid view -->
<div id="tablos-grid" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 sm:gap-6"> <div id="tablos-grid" class="card-grid">
if len(cards) == 0 { if len(cards) == 0 {
@TablosEmptyState() @TablosEmptyState()
} else { } else {
@ -60,15 +101,15 @@ templ TablosDashboard(user *auth.User, csrfToken string, activePath string, tabl
} }
</div> </div>
<!-- List view (hidden by default) --> <!-- List view (hidden by default) -->
<div id="tablos-table" class="hidden bg-white rounded-xl border border-[#EAECF0] overflow-x-auto"> <div id="tablos-table" class="hidden" style="background:#fff;border:1px solid var(--color-border-default);border-radius:12px;overflow:hidden">
<table class="w-full min-w-[600px]"> <table class="projects-table">
<thead class="bg-gray-50 border-b border-[#EAECF0]"> <thead>
<tr> <tr>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Projet</th> <th>Projet</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Statut</th> <th>Statut</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Créé le</th> <th>Créé le</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Progression</th> <th>Progression</th>
<th class="px-6 py-3 w-12"></th> <th style="width:48px"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -78,13 +119,26 @@ templ TablosDashboard(user *auth.User, csrfToken string, activePath string, tabl
</tbody> </tbody>
</table> </table>
</div> </div>
<!-- My Tasks section (stub) -->
<div style="margin-top:32px">
<div class="section-header">
<span class="section-title">Mes Tâches</span>
</div>
<p style="font-size:.875rem;color:var(--color-text-muted);font-style:italic">Les tâches apparaîtront ici.</p>
</div>
</div>
<!-- Contacts panel (stub) -->
<div class="contacts-panel">
<h2 style="font-size:.875rem;font-weight:600;color:var(--color-text-primary);margin:0 0 12px">Contacts</h2>
<p style="font-size:.8125rem;color:var(--color-text-muted)">Vos contacts apparaîtront ici.</p>
</div>
</div> </div>
<script> <script>
function setTablosView(v) { function setTablosView(v) {
document.getElementById('tablos-grid').classList.toggle('hidden', v === 'list'); document.getElementById('tablos-grid').classList.toggle('hidden', v === 'list');
document.getElementById('tablos-table').classList.toggle('hidden', v === 'grid'); document.getElementById('tablos-table').classList.toggle('hidden', v === 'grid');
document.querySelectorAll('[data-view-btn]').forEach(function(b) { document.querySelectorAll('[data-view-btn]').forEach(function(b) {
b.classList.toggle('is-active', b.dataset.viewBtn === v); b.classList.toggle('active', b.dataset.viewBtn === v);
}); });
} }
function filterTablos(s) { function filterTablos(s) {
@ -95,7 +149,7 @@ templ TablosDashboard(user *auth.User, csrfToken string, activePath string, tabl
el.style.display = (s === 'tous' || el.dataset.displayStatus === s) ? '' : 'none'; el.style.display = (s === 'tous' || el.dataset.displayStatus === s) ? '' : 'none';
}); });
document.querySelectorAll('[data-filter-btn]').forEach(function(b) { document.querySelectorAll('[data-filter-btn]').forEach(function(b) {
b.classList.toggle('is-active', b.dataset.filterBtn === s); b.classList.toggle('active', b.dataset.filterBtn === s);
}); });
} }
document.getElementById('tablos-grid').addEventListener('click', function(e) { document.getElementById('tablos-grid').addEventListener('click', function(e) {
@ -132,57 +186,30 @@ templ TablosEmptyState() {
}) })
} }
// TabloProjectCard renders a single tablo as a dual-element card+row wrapper. // TabloProjectCard renders a single tablo as a card in the grid view.
// The outer article.tablo-card-wrapper contains both a .project-card (grid view)
// and a .tablo-list-row (list view, hidden by default).
// Matches production design: status badge top-left, delete button top-right,
// colored avatar with initial letter, title, date, and progress bar.
// Uses display:contents on the wrapper so it is transparent to grid/flex layout.
// Guards color rendering against null pgtype.Text values (Pitfall 6). // Guards color rendering against null pgtype.Text values (Pitfall 6).
// Uses .Time accessor on pgtype.Timestamptz (Pitfall 6). // Uses .Time accessor on pgtype.Timestamptz (Pitfall 6).
templ TabloProjectCard(card TabloCardView, csrfToken string) { templ TabloProjectCard(card TabloCardView, csrfToken string) {
<article <article
id={ "tablo-" + card.Tablo.ID.String() } id={ "tablo-" + card.Tablo.ID.String() }
class="tablo-card-wrapper" class="project-card"
data-display-status={ card.Tablo.Status } data-display-status={ card.Tablo.Status }
data-href={ "/tablos/" + card.Tablo.ID.String() } data-href={ "/tablos/" + card.Tablo.ID.String() }
> >
<!-- Card view (default: visible in grid layout) --> <!-- Card header: color circle + title + badge + delete -->
<div class="bg-white rounded-xl p-5 border border-[#EAECF0] hover:shadow-md transition-shadow cursor-pointer project-card flex flex-col gap-5"> <div class="card-header">
<!-- Row 1: icon + title + status pill + edit + delete -->
<div class="flex items-center gap-2">
if card.Tablo.Color.Valid && card.Tablo.Color.String != "" { if card.Tablo.Color.Valid && card.Tablo.Color.String != "" {
<div class="w-8 h-8 rounded-lg flex items-center justify-center shrink-0" style={ "background-color: " + card.Tablo.Color.String }> <span class="card-color-circle" style={ "background:" + card.Tablo.Color.String }></span>
<span class="text-white font-bold text-sm">
if len(card.Tablo.Title) > 0 {
{ string([]rune(card.Tablo.Title)[0:1]) }
}
</span>
</div>
} else { } else {
<div class="w-8 h-8 rounded-lg flex items-center justify-center shrink-0 bg-blue-500"> <span class="card-color-circle" style="background:#9ca3af"></span>
<span class="text-white font-bold text-sm">
if len(card.Tablo.Title) > 0 {
{ string([]rune(card.Tablo.Title)[0:1]) }
} }
</span> <span class="card-title">{ card.Tablo.Title }</span>
</div>
}
<h3 class="text-sm font-semibold text-gray-900 flex-1 line-clamp-1 min-w-0">{ card.Tablo.Title }</h3>
if card.Tablo.Status == "archived" { if card.Tablo.Status == "archived" {
<span class="shrink-0 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600 border border-gray-200">Archivé</span> <span class="badge-archived">Archivé</span>
} else { } else {
<span class="shrink-0 px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-600 border border-green-200">Actif</span> <span class="badge-active">Actif</span>
} }
<a <div class="tablo-delete-zone" style="flex-shrink:0;margin-left:4px" onclick="event.stopPropagation()">
href={ templ.SafeURL("/tablos/" + card.Tablo.ID.String()) }
class="shrink-0 inline-flex items-center justify-center w-7 h-7 rounded-md text-gray-400 hover:text-blue-600 hover:bg-blue-50 transition-colors"
aria-label="Edit tablo"
onclick="event.stopPropagation()"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-3.5 h-3.5" aria-hidden="true"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>
</a>
<div class="tablo-delete-zone shrink-0">
@ui.IconButton(ui.IconButtonProps{ @ui.IconButton(ui.IconButtonProps{
Label: "Delete tablo", Label: "Delete tablo",
Icon: "trash", Icon: "trash",
@ -197,21 +224,15 @@ templ TabloProjectCard(card TabloCardView, csrfToken string) {
}) })
</div> </div>
</div> </div>
<!-- Row 2: creation date --> <!-- Meta: date + task count -->
<div class="flex items-center gap-1.5 text-xs text-gray-500"> <div class="card-meta">Créé le { card.Tablo.CreatedAt.Time.Format("2 Jan 2006") } · { strconv.Itoa(card.TotalTasks) } tâches</div>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-3.5 h-3.5 shrink-0" aria-hidden="true"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path></svg> <!-- Progress bar -->
<span>{ card.Tablo.CreatedAt.Time.Format("2 Jan 2006") }</span> <div class="progress-bar-wrap" style="height:4px;background:var(--color-surface-muted);border-radius:9999px;overflow:hidden;margin-bottom:8px">
</div> <div class="progress-bar-fill" style={ "height:100%;background:var(--color-brand-primary);width:" + strconv.Itoa(card.Progress) + "%" }></div>
<!-- Row 3: progress bar -->
<div class="project-card-progress-row">
<div class="flex justify-between items-center mb-1.5">
<span class="text-xs text-gray-500">{ strconv.Itoa(card.DoneTasks) }/{ strconv.Itoa(card.TotalTasks) } tâches</span>
<span class="text-xs font-semibold text-gray-700">{ strconv.Itoa(card.Progress) }%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div class="bg-green-500 h-2 rounded-full" style={ "width: " + strconv.Itoa(card.Progress) + "%" }></div>
</div>
</div> </div>
<div style="display:flex;justify-content:space-between;font-size:.75rem;color:var(--color-text-muted)">
<span>{ strconv.Itoa(card.Progress) }% terminé</span>
<span>{ strconv.Itoa(card.DoneTasks) }/{ strconv.Itoa(card.TotalTasks) } fait</span>
</div> </div>
</article> </article>
} }
@ -219,60 +240,44 @@ templ TabloProjectCard(card TabloCardView, csrfToken string) {
// TabloListRow renders one table row for the list view. // TabloListRow renders one table row for the list view.
templ TabloListRow(card TabloCardView, csrfToken string) { templ TabloListRow(card TabloCardView, csrfToken string) {
<tr <tr
class="border-t border-[#EAECF0] hover:bg-gray-50 transition-colors cursor-pointer"
data-display-status={ card.Tablo.Status } data-display-status={ card.Tablo.Status }
data-href={ "/tablos/" + card.Tablo.ID.String() } data-href={ "/tablos/" + card.Tablo.ID.String() }
> >
<!-- Projet: icon + title --> <!-- Projet: color circle + title -->
<td class="px-6 py-4"> <td>
<div class="flex items-center gap-3"> <div class="proj-name-cell">
if card.Tablo.Color.Valid && card.Tablo.Color.String != "" { if card.Tablo.Color.Valid && card.Tablo.Color.String != "" {
<div class="w-8 h-8 rounded-lg flex items-center justify-center shrink-0 overflow-hidden" style={ "background-color: " + card.Tablo.Color.String }> <span class="card-color-circle" style={ "background:" + card.Tablo.Color.String }></span>
<span class="text-white font-bold text-sm">
if len(card.Tablo.Title) > 0 {
{ string([]rune(card.Tablo.Title)[0:1]) }
}
</span>
</div>
} else { } else {
<div class="w-8 h-8 rounded-lg flex items-center justify-center shrink-0 overflow-hidden bg-blue-500"> <span class="card-color-circle" style="background:#9ca3af"></span>
<span class="text-white font-bold text-sm">
if len(card.Tablo.Title) > 0 {
{ string([]rune(card.Tablo.Title)[0:1]) }
} }
</span> <span style="font-weight:500;color:var(--color-text-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{ card.Tablo.Title }</span>
</div>
}
<span class="font-medium text-gray-900 truncate">{ card.Tablo.Title }</span>
</div> </div>
</td> </td>
<!-- Statut --> <!-- Statut -->
<td class="px-6 py-4 whitespace-nowrap"> <td>
if card.Tablo.Status == "archived" { if card.Tablo.Status == "archived" {
<span class="px-3 py-1 rounded-full text-sm font-medium bg-gray-100 text-gray-600 border border-gray-200">Archivé</span> <span class="badge-archived">Archivé</span>
} else { } else {
<span class="px-3 py-1 rounded-full text-sm font-medium bg-green-50 text-green-600 border border-green-200">Actif</span> <span class="badge-active">Actif</span>
} }
</td> </td>
<!-- Créé le --> <!-- Créé le -->
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600"> <td style="white-space:nowrap;color:var(--color-text-secondary)">
<div class="flex items-center gap-1.5">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-4 h-4 shrink-0" aria-hidden="true"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path></svg>
{ card.Tablo.CreatedAt.Time.Format("2 Jan 2006") } { card.Tablo.CreatedAt.Time.Format("2 Jan 2006") }
</div>
</td> </td>
<!-- Progression --> <!-- Progression -->
<td class="px-6 py-4"> <td>
<div class="flex items-center gap-3"> <div class="progress-with-label">
<div class="flex-1 bg-gray-200 rounded-full h-2 min-w-[80px]"> <div class="progress-bar-wrap">
<div class="bg-green-500 h-2 rounded-full" style={ "width: " + strconv.Itoa(card.Progress) + "%" }></div> <div style={ "height:100%;background:var(--color-brand-primary);border-radius:9999px;width:" + strconv.Itoa(card.Progress) + "%" }></div>
</div> </div>
<span class="text-sm font-semibold text-gray-900 w-8 text-right">{ strconv.Itoa(card.Progress) }%</span> <span class="progress-pct">{ strconv.Itoa(card.Progress) }%</span>
</div> </div>
</td> </td>
<!-- Actions --> <!-- Actions -->
<td class="px-6 py-4 text-right"> <td style="text-align:right">
<div class="tablo-delete-zone inline-flex"> <div class="tablo-delete-zone" style="display:inline-flex" onclick="event.stopPropagation()">
@ui.IconButton(ui.IconButtonProps{ @ui.IconButton(ui.IconButtonProps{
Label: "Delete tablo", Label: "Delete tablo",
Icon: "trash", Icon: "trash",