From 3111b6e0114c0ac634c2ad5f919c0a1f9cc9fe65 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sat, 16 May 2026 10:15:38 +0200 Subject: [PATCH] feat(12-02): implement discussion unread badges --- backend/internal/db/queries/tablos.sql | 26 +++++++++++++++ backend/internal/web/handlers_tablos.go | 14 ++++----- backend/templates/discussion_forms.go | 42 +++++++++++++++++++++++++ backend/templates/tablos.templ | 33 ++++++++++++------- 4 files changed, 96 insertions(+), 19 deletions(-) diff --git a/backend/internal/db/queries/tablos.sql b/backend/internal/db/queries/tablos.sql index 606e8a3..c50a8c9 100644 --- a/backend/internal/db/queries/tablos.sql +++ b/backend/internal/db/queries/tablos.sql @@ -4,6 +4,32 @@ FROM tablos WHERE user_id = $1 ORDER BY created_at DESC; +-- name: ListTablosByUserWithDiscussionUnread :many +SELECT tablos.id, + tablos.user_id, + tablos.title, + tablos.description, + tablos.color, + tablos.created_at, + tablos.updated_at, + COALESCE(count(unread_messages.id), 0)::bigint AS discussion_unread_count +FROM tablos +LEFT JOIN discussion_read_states AS read_state + ON read_state.tablo_id = tablos.id + AND read_state.user_id = sqlc.arg(user_id) +LEFT JOIN discussion_messages AS last_read_message + ON last_read_message.id = read_state.last_read_message_id +LEFT JOIN discussion_messages AS unread_messages + ON unread_messages.tablo_id = tablos.id + AND ( + read_state.last_read_message_id IS NULL + OR last_read_message.id IS NULL + OR unread_messages.created_at > last_read_message.created_at + ) +WHERE tablos.user_id = sqlc.arg(user_id) +GROUP BY tablos.id, tablos.user_id, tablos.title, tablos.description, tablos.color, tablos.created_at, tablos.updated_at +ORDER BY tablos.created_at DESC; + -- name: GetTabloByID :one SELECT id, user_id, title, description, color, created_at, updated_at FROM tablos diff --git a/backend/internal/web/handlers_tablos.go b/backend/internal/web/handlers_tablos.go index 3076f2f..d065186 100644 --- a/backend/internal/web/handlers_tablos.go +++ b/backend/internal/web/handlers_tablos.go @@ -40,18 +40,18 @@ func TablosListHandler(deps TablosDeps) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { _, user, _ := auth.Authed(r.Context()) - tablos, err := deps.Queries.ListTablosByUser(r.Context(), user.ID) + tabloRows, err := deps.Queries.ListTablosByUserWithDiscussionUnread(r.Context(), user.ID) if err != nil { slog.Default().Error("tablos list: query failed", "user_id", user.ID, "err", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } - if tablos == nil { - tablos = []sqlc.Tablo{} + if tabloRows == nil { + tabloRows = []sqlc.ListTablosByUserWithDiscussionUnreadRow{} } w.Header().Set("Content-Type", "text/html; charset=utf-8") - _ = templates.TablosDashboard(user, csrf.Token(r), tablos).Render(r.Context(), w) + _ = templates.TablosDashboard(user, csrf.Token(r), templates.TabloCardsFromUnreadRows(tabloRows)).Render(r.Context(), w) } } @@ -422,13 +422,13 @@ func renderTabloCreateError(w http.ResponseWriter, r *http.Request, form templat // Non-HTMX: render full dashboard with errs embedded in the form. // Fetch the user's tablos so the list is still accurate on re-render. _, user, _ := auth.Authed(r.Context()) - tablos, fetchErr := deps.Queries.ListTablosByUser(r.Context(), user.ID) + tabloRows, fetchErr := deps.Queries.ListTablosByUserWithDiscussionUnread(r.Context(), user.ID) if fetchErr != nil { slog.Default().Error("renderTabloCreateError: list fetch failed", "user_id", user.ID, "err", fetchErr) - tablos = []sqlc.Tablo{} + tabloRows = []sqlc.ListTablosByUserWithDiscussionUnreadRow{} } // Render full page — form fragment is not embedded in the full page by default; // for the non-HTMX error case we redirect so the user sees their list intact // and can try again (simpler than threading form state through the full page). - _ = templates.TablosDashboard(user, csrf.Token(r), tablos).Render(r.Context(), w) + _ = templates.TablosDashboard(user, csrf.Token(r), templates.TabloCardsFromUnreadRows(tabloRows)).Render(r.Context(), w) } diff --git a/backend/templates/discussion_forms.go b/backend/templates/discussion_forms.go index 8ed9837..e1c509d 100644 --- a/backend/templates/discussion_forms.go +++ b/backend/templates/discussion_forms.go @@ -31,6 +31,11 @@ type DiscussionTabData struct { Messages []DiscussionMessageView } +type TabloCardView struct { + Tablo sqlc.Tablo + DiscussionUnreadCount int64 +} + func DiscussionPostURL(tabloID uuid.UUID) string { return "/tablos/" + tabloID.String() + "/discussion/messages" } @@ -65,6 +70,43 @@ func DiscussionMessageFromRow(row sqlc.GetDiscussionMessageWithAuthorRow) Discus } } +func TabloCardsFromUnreadRows(rows []sqlc.ListTablosByUserWithDiscussionUnreadRow) []TabloCardView { + cards := make([]TabloCardView, 0, len(rows)) + for _, row := range rows { + cards = append(cards, TabloCardView{ + Tablo: sqlc.Tablo{ + ID: row.ID, + UserID: row.UserID, + Title: row.Title, + Description: row.Description, + Color: row.Color, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + }, + DiscussionUnreadCount: row.DiscussionUnreadCount, + }) + } + return cards +} + +func TabloCardFromTablo(tablo sqlc.Tablo) TabloCardView { + return TabloCardView{Tablo: tablo} +} + +func DiscussionUnreadDisplay(count int64) string { + if count > 99 { + return "99+" + } + return strconv.FormatInt(count, 10) +} + +func DiscussionUnreadAriaLabel(count int64) string { + if count == 1 { + return "1 unread discussion message" + } + return strconv.FormatInt(count, 10) + " unread discussion messages" +} + func DiscussionDateLabel(t time.Time) string { return t.Local().Format("January 2, 2006") } diff --git a/backend/templates/tablos.templ b/backend/templates/tablos.templ index e6145c6..527d2fe 100644 --- a/backend/templates/tablos.templ +++ b/backend/templates/tablos.templ @@ -9,7 +9,7 @@ import ( // TablosDashboard renders the root authenticated dashboard: heading, "New tablo" // button, create-form slot, and the list of tablo cards (or empty state). // UI-SPEC §1 Interaction Contract — GET /. -templ TablosDashboard(user *auth.User, csrfToken string, tablos []sqlc.Tablo) { +templ TablosDashboard(user *auth.User, csrfToken string, tablos []TabloCardView) { @Layout("Tablos — Xtablo", user, csrfToken) {

Your Tablos

@@ -66,32 +66,41 @@ templ TablosEmptyState() { // TabloCard renders a single tablo as a ui.Card on the dashboard. // Guards description and color rendering against null pgtype.Text values (Pitfall 6). // Delegates delete-zone rendering to TabloDeleteButtonFragment (single source of truth). -templ TabloCard(tablo sqlc.Tablo, csrfToken string) { - @ui.Card(templ.Attributes{"id": "tablo-" + tablo.ID.String()}) { +templ TabloCard(card TabloCardView, csrfToken string) { + @ui.Card(templ.Attributes{"id": "tablo-" + card.Tablo.ID.String()}) {
-

{ tablo.Title }

- if tablo.Description.Valid && tablo.Description.String != "" { -

{ tablo.Description.String }

+
+

{ card.Tablo.Title }

+ @DiscussionUnreadBadge(card.DiscussionUnreadCount) +
+ if card.Tablo.Description.Valid && card.Tablo.Description.String != "" { +

{ card.Tablo.Description.String }

} - if tablo.Color.Valid && tablo.Color.String != "" { + if card.Tablo.Color.Valid && card.Tablo.Color.String != "" {
- { tablo.Color.String } + { card.Tablo.Color.String }
}
- @TabloDeleteButtonFragment(tablo, csrfToken) + @TabloDeleteButtonFragment(card.Tablo, csrfToken)
- View + View
} } +templ DiscussionUnreadBadge(count int64) { + if count > 0 { + { DiscussionUnreadDisplay(count) } + } +} + // TabloCreateFormFragment renders the inline create form loaded into #create-form-slot // via HTMX. Falls back to a plain POST /tablos for non-JS paths. // UI-SPEC §2 Interaction Contract — GET /tablos/new + POST /tablos. @@ -162,7 +171,7 @@ templ TabloCreateFormFragment(form TabloCreateForm, errs TabloCreateErrors, csrf // HTMX applies the primary swap (HX-Retarget: #tablos-list, afterbegin) AND // the OOB swap (#create-form-slot → empty) from a single response. templ TabloCardWithOOBFormClear(tablo sqlc.Tablo, csrfToken string) { - @TabloCard(tablo, csrfToken) + @TabloCard(TabloCardFromTablo(tablo), csrfToken)
}