feat(12-02): implement discussion unread badges

This commit is contained in:
Arthur Belleville 2026-05-16 10:15:38 +02:00
parent e3c8d51782
commit 3111b6e011
No known key found for this signature in database
4 changed files with 96 additions and 19 deletions

View file

@ -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

View file

@ -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)
}

View file

@ -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")
}

View file

@ -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) {
<div class="flex items-center justify-between mb-6">
<h1 class="text-[28px] font-semibold leading-tight">Your Tablos</h1>
@ -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()}) {
<div class="flex items-start justify-between">
<div>
<h2 class="text-xl font-semibold leading-snug">{ tablo.Title }</h2>
if tablo.Description.Valid && tablo.Description.String != "" {
<p class="mt-2 text-base text-slate-600">{ tablo.Description.String }</p>
<div class="flex flex-wrap items-center gap-2">
<h2 class="text-xl font-semibold leading-snug">{ card.Tablo.Title }</h2>
@DiscussionUnreadBadge(card.DiscussionUnreadCount)
</div>
if card.Tablo.Description.Valid && card.Tablo.Description.String != "" {
<p class="mt-2 text-base text-slate-600">{ card.Tablo.Description.String }</p>
}
if tablo.Color.Valid && tablo.Color.String != "" {
if card.Tablo.Color.Valid && card.Tablo.Color.String != "" {
<div class="mt-2 flex items-center gap-2">
<span
class="inline-block w-2.5 h-2.5 rounded-full"
style={ "background-color: " + tablo.Color.String }
style={ "background-color: " + card.Tablo.Color.String }
></span>
<span class="text-sm text-slate-500">{ tablo.Color.String }</span>
<span class="text-sm text-slate-500">{ card.Tablo.Color.String }</span>
</div>
}
</div>
@TabloDeleteButtonFragment(tablo, csrfToken)
@TabloDeleteButtonFragment(card.Tablo, csrfToken)
</div>
<div class="mt-4">
<a href={ templ.SafeURL("/tablos/" + tablo.ID.String()) } class="text-sm font-medium text-blue-600 hover:underline">View</a>
<a href={ templ.SafeURL("/tablos/" + card.Tablo.ID.String()) } class="text-sm font-medium text-blue-600 hover:underline">View</a>
</div>
}
}
templ DiscussionUnreadBadge(count int64) {
if count > 0 {
<span class="inline-flex min-w-6 items-center justify-center rounded-full border border-blue-200 bg-slate-50 px-2 py-0.5 text-xs font-semibold leading-none text-blue-700" aria-label={ DiscussionUnreadAriaLabel(count) }>{ DiscussionUnreadDisplay(count) }</span>
}
}
// 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)
<div id="create-form-slot" hx-swap-oob="true"></div>
}