feat(t-paliad-149) PR2 step 1/3: backend — migration 061 + CardLayoutService + CardsPreview
Migration 061 (paliad.user_card_layouts): per-user named card layouts.
- Partial unique index on (user_id) WHERE is_default=true keeps "at most
one default per user" honest at the DB level.
- UNIQUE (user_id, name) so the layout dropdown can use names as stable
labels.
- RLS owner-only (mirrors paliad.user_views from t-144).
LayoutSpec (internal/services/layout_spec.go): structured JSON validator
with KnownFactKeys registry (11 fact keys: title-row, type-chip, status-
chip, client-matter, parent-path, deadline-counts, next-events, recent-
verlauf, team-chips, reference, last-activity-at). Validator enforces:
- title-row must be the first VISIBLE fact (always-on, structural)
- no duplicate keys
- count ∈ [1, 5] only on next-events / recent-verlauf
- density ∈ {compact, roomy} (CardDensity, distinct from t-144's
ListDensity which only ranges over comfortable/compact)
- grid_columns ∈ {auto, 2, 3, 4}
DefaultLayoutSpec returns the m-locked rich content set per design §5b.4
(9 facts, roomy density, auto grid, leaf-ish projects only).
CardLayoutService: CRUD with auto-seed (GetDefault creates "Standard"
on first call) + tx-flip-default (setting is_default=true on B clears
A in the same transaction) + ErrUserCardLayoutDefaultGate (deleting
the active default returns 409). isPgUniqueViolation maps the partial
unique index conflict to ErrUserCardLayoutNameTaken.
ProjectService.CardsPreview: per-project event rollups for the Cards view.
4 source SQLs with ROW_NUMBER() OVER PARTITION BY project_id (top 3 each
for upcoming deadlines, upcoming appointments, recent project_events) +
team-chips JOIN. Single round-trip per source, visibility-gated. Returns
map[uuid.UUID]*ProjectCardPreview with last_activity_at computed across
all sources for the orchestrator's card-grid sort.
Handlers: 5 /api/user-card-layouts/* endpoints (GET list, POST create,
PATCH update, DELETE, POST set-default) + GET /api/projects/cards-preview
(narrowable via ?ids=<csv>).
Wired in handlers.go (Services struct + dbServices struct) and
cmd/server/main.go. ErrUserCardLayoutNameTaken / NotFound / DefaultGate
mapped to 409 / 404 / 409 respectively.
Tests:
- layout_spec_test.go (8 cases, pure-Go): valid default, empty rejection,
title-row-first invariant, hidden leading allowed, dup-key rejection,
unknown-key rejection, count-bounds + count-on-wrong-key, density/grid
enum, ParseLayoutSpec round-trip.
- card_layout_service_test.go (6 cases, live-DB): GetDefault auto-seeds
+ idempotent, first Create auto-becomes default, SetDefault clears
prior, Delete refuses active default, Delete non-default works,
duplicate name rejected, Update round-trips layout JSON.
go build / vet / test (short) clean.
Design: docs/design-projects-page-2026-05-07.md §5b.3, §5b.5, §8.2.
This commit is contained in:
@@ -162,6 +162,7 @@ func main() {
|
||||
UserView: services.NewUserViewService(pool),
|
||||
Broadcast: services.NewBroadcastService(pool, mailSvc, users, teamSvc, emailTemplateSvc),
|
||||
Pin: services.NewPinService(pool, projectSvc),
|
||||
CardLayout: services.NewCardLayoutService(pool),
|
||||
}
|
||||
|
||||
// t-paliad-146 — Paliadin PoC. Always wired when DATABASE_URL
|
||||
|
||||
3
internal/db/migrations/061_user_card_layouts.down.sql
Normal file
3
internal/db/migrations/061_user_card_layouts.down.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- Reverse of 061_user_card_layouts.up.sql.
|
||||
|
||||
DROP TABLE IF EXISTS paliad.user_card_layouts;
|
||||
76
internal/db/migrations/061_user_card_layouts.up.sql
Normal file
76
internal/db/migrations/061_user_card_layouts.up.sql
Normal file
@@ -0,0 +1,76 @@
|
||||
-- t-paliad-149 PR 2: per-user named card layouts.
|
||||
--
|
||||
-- Design: docs/design-projects-page-2026-05-07.md §5b.3 (godel,
|
||||
-- m-locked 2026-05-07: full drag-rearrange + named layouts).
|
||||
--
|
||||
-- Stores per-user named card-layout definitions for the /projects Cards
|
||||
-- view. A layout is a `(facts[], density, gridColumns, showAllLevels)`
|
||||
-- bundle plus a name and the is_default flag.
|
||||
--
|
||||
-- The very first time a user opens Cards view, the application layer
|
||||
-- auto-seeds a "Standard" layout (the rich content set per design §5b.4)
|
||||
-- and flips its is_default=true. From there the user can rename, create
|
||||
-- new layouts, drag facts around, switch defaults, and delete (except
|
||||
-- the active default — UI gates).
|
||||
--
|
||||
-- RLS scopes every operation to the calling user; layouts are personal
|
||||
-- working state (no firm-wide / cross-user visibility v1). Partial unique
|
||||
-- index keeps "at most one default per user" honest at the DB level even
|
||||
-- if the application layer's tx-flip-default ever races.
|
||||
|
||||
-- ============================================================================
|
||||
-- 1. paliad.user_card_layouts
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE paliad.user_card_layouts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
|
||||
|
||||
-- Display name. Free-form; user picks the language they think in.
|
||||
-- Renders verbatim in the layout dropdown; no translation.
|
||||
name text NOT NULL,
|
||||
|
||||
-- Exactly one default per user, enforced via partial unique index below.
|
||||
-- Application layer flips this in a transaction (clear old, set new).
|
||||
is_default boolean NOT NULL DEFAULT false,
|
||||
|
||||
-- Layout JSON — see internal/services/layout_spec.go LayoutSpec.
|
||||
-- Validated on write; jsonb here for forward-compat without migrations
|
||||
-- as new fact keys land.
|
||||
layout_json jsonb NOT NULL,
|
||||
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
-- Names are unique per user so the layout dropdown can use names as
|
||||
-- stable labels and the application layer can return ErrUserCardLayoutNameTaken.
|
||||
UNIQUE (user_id, name)
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- 2. Indexes
|
||||
-- ============================================================================
|
||||
|
||||
-- Hot path: list a user's layouts in name order.
|
||||
CREATE INDEX user_card_layouts_user_idx
|
||||
ON paliad.user_card_layouts (user_id, name);
|
||||
|
||||
-- Partial unique index: at most one default layout per user. Keeps the
|
||||
-- invariant honest even if two concurrent PATCH .../set-default calls land
|
||||
-- (the second one's UPDATE will conflict, the application layer retries).
|
||||
CREATE UNIQUE INDEX user_card_layouts_default_idx
|
||||
ON paliad.user_card_layouts (user_id)
|
||||
WHERE is_default = true;
|
||||
|
||||
-- ============================================================================
|
||||
-- 3. RLS
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE paliad.user_card_layouts ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Owner-only access. No global_admin override (mirrors paliad.user_views;
|
||||
-- card layouts are personal working state, not auditable infrastructure).
|
||||
CREATE POLICY user_card_layouts_owner_all
|
||||
ON paliad.user_card_layouts FOR ALL
|
||||
USING (user_id = auth.uid())
|
||||
WITH CHECK (user_id = auth.uid());
|
||||
185
internal/handlers/card_layouts.go
Normal file
185
internal/handlers/card_layouts.go
Normal file
@@ -0,0 +1,185 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// GET /api/user-card-layouts — list the user's named card layouts (default first).
|
||||
func handleListCardLayouts(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.cardLayout == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "card-layout service not configured"})
|
||||
return
|
||||
}
|
||||
rows, err := dbSvc.cardLayout.List(r.Context(), uid)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
type createCardLayoutBody struct {
|
||||
Name string `json:"name"`
|
||||
Layout services.LayoutSpec `json:"layout"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
}
|
||||
|
||||
// POST /api/user-card-layouts — create a new named layout.
|
||||
func handleCreateCardLayout(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.cardLayout == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "card-layout service not configured"})
|
||||
return
|
||||
}
|
||||
var body createCardLayoutBody
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
row, err := dbSvc.cardLayout.Create(r.Context(), uid, services.CreateCardLayoutInput{
|
||||
Name: body.Name,
|
||||
Layout: body.Layout,
|
||||
IsDefault: body.IsDefault,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrUserCardLayoutNameTaken) {
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": "name already exists"})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, row)
|
||||
}
|
||||
|
||||
type updateCardLayoutBody struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Layout *services.LayoutSpec `json:"layout,omitempty"`
|
||||
IsDefault *bool `json:"is_default,omitempty"`
|
||||
}
|
||||
|
||||
// PATCH /api/user-card-layouts/{id} — partial update.
|
||||
func handleUpdateCardLayout(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.cardLayout == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "card-layout service not configured"})
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
var body updateCardLayoutBody
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
row, err := dbSvc.cardLayout.Update(r.Context(), uid, id, services.UpdateCardLayoutInput{
|
||||
Name: body.Name,
|
||||
Layout: body.Layout,
|
||||
IsDefault: body.IsDefault,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrUserCardLayoutNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, services.ErrUserCardLayoutNameTaken) {
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": "name already exists"})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, row)
|
||||
}
|
||||
|
||||
// DELETE /api/user-card-layouts/{id} — remove a named layout. The active
|
||||
// default cannot be deleted (return 409); the UI gates this.
|
||||
func handleDeleteCardLayout(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.cardLayout == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "card-layout service not configured"})
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
if err := dbSvc.cardLayout.Delete(r.Context(), uid, id); err != nil {
|
||||
if errors.Is(err, services.ErrUserCardLayoutNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, services.ErrUserCardLayoutDefaultGate) {
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": "cannot delete default layout — switch defaults first"})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// POST /api/user-card-layouts/{id}/set-default — sugar over PATCH .{is_default:true}.
|
||||
func handleSetDefaultCardLayout(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.cardLayout == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "card-layout service not configured"})
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
row, err := dbSvc.cardLayout.SetDefault(r.Context(), uid, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrUserCardLayoutNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, row)
|
||||
}
|
||||
@@ -67,6 +67,7 @@ type Services struct {
|
||||
UserView *services.UserViewService
|
||||
Broadcast *services.BroadcastService
|
||||
Pin *services.PinService
|
||||
CardLayout *services.CardLayoutService
|
||||
|
||||
// Paliadin is wired only when PALIADIN_ENABLED=true at boot
|
||||
// (PoC; m's laptop only). On prod it stays nil and all /paliadin*
|
||||
@@ -115,6 +116,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
userView: svc.UserView,
|
||||
broadcast: svc.Broadcast,
|
||||
pin: svc.Pin,
|
||||
cardLayout: svc.CardLayout,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,6 +217,12 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("POST /api/projects/{id}/pin", handlePinProject)
|
||||
protected.HandleFunc("DELETE /api/projects/{id}/pin", handleUnpinProject)
|
||||
protected.HandleFunc("GET /api/user-pinned-projects", handleListPinnedProjects)
|
||||
protected.HandleFunc("GET /api/projects/cards-preview", handleProjectsCardsPreview)
|
||||
protected.HandleFunc("GET /api/user-card-layouts", handleListCardLayouts)
|
||||
protected.HandleFunc("POST /api/user-card-layouts", handleCreateCardLayout)
|
||||
protected.HandleFunc("PATCH /api/user-card-layouts/{id}", handleUpdateCardLayout)
|
||||
protected.HandleFunc("DELETE /api/user-card-layouts/{id}", handleDeleteCardLayout)
|
||||
protected.HandleFunc("POST /api/user-card-layouts/{id}/set-default", handleSetDefaultCardLayout)
|
||||
protected.HandleFunc("GET /api/projects/{id}/ancestors", handleListProjectAncestors)
|
||||
protected.HandleFunc("GET /api/projects/{id}/parties", handleListParties)
|
||||
protected.HandleFunc("POST /api/projects/{id}/parties", handleCreateParty)
|
||||
|
||||
@@ -48,6 +48,7 @@ type dbServices struct {
|
||||
userView *services.UserViewService
|
||||
broadcast *services.BroadcastService
|
||||
pin *services.PinService
|
||||
cardLayout *services.CardLayoutService
|
||||
}
|
||||
|
||||
var dbSvc *dbServices
|
||||
@@ -321,6 +322,48 @@ func parseBoolQuery(v string, def bool) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/projects/cards-preview — per-project event rollups for the
|
||||
// Cards view. Returns a flat list of {project_id, next_events,
|
||||
// recent_verlauf, team_initials, team_count, last_activity_at} for every
|
||||
// project the user can see (or the subset given via ?ids=<csv-of-uuids>).
|
||||
//
|
||||
// Visibility-scoped server-side. Caller (Cards mode) lazy-fetches batches
|
||||
// via IntersectionObserver.
|
||||
func handleProjectsCardsPreview(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var ids []uuid.UUID
|
||||
if raw := r.URL.Query().Get("ids"); raw != "" {
|
||||
for _, s := range splitCSV(raw) {
|
||||
u, err := uuid.Parse(s)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid uuid in ?ids"})
|
||||
return
|
||||
}
|
||||
ids = append(ids, u)
|
||||
}
|
||||
}
|
||||
|
||||
previews, err := dbSvc.projects.CardsPreview(r.Context(), uid, ids)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
// Flat array for JSON; the map order is irrelevant to the client (it
|
||||
// keys on project_id when stitching to its tree-id list).
|
||||
out := make([]*services.ProjectCardPreview, 0, len(previews))
|
||||
for _, p := range previews {
|
||||
out = append(out, p)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// splitCSV splits a comma-separated query value into trimmed non-empty
|
||||
// tokens. Empty input → nil so callers can branch on `len(out) > 0`.
|
||||
func splitCSV(s string) []string {
|
||||
|
||||
310
internal/services/card_layout_service.go
Normal file
310
internal/services/card_layout_service.go
Normal file
@@ -0,0 +1,310 @@
|
||||
package services
|
||||
|
||||
// CardLayoutService is the CRUD layer for paliad.user_card_layouts —
|
||||
// per-user named card layouts for the /projects Cards view.
|
||||
//
|
||||
// Design: docs/design-projects-page-2026-05-07.md §5b.3.
|
||||
//
|
||||
// Visibility: every read and write is scoped to the calling user via the
|
||||
// RLS policy `user_card_layouts_owner_all` on auth.uid() = user_id. The
|
||||
// service also AND-joins user_id in the SQL for defense-in-depth (RLS
|
||||
// can be disabled in tests, the code-level check still holds).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// UserCardLayout is the persisted shape of a saved card layout.
|
||||
type UserCardLayout struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
Name string `db:"name" json:"name"`
|
||||
IsDefault bool `db:"is_default" json:"is_default"`
|
||||
LayoutJSON json.RawMessage `db:"layout_json" json:"layout"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// CardLayoutService manages paliad.user_card_layouts.
|
||||
type CardLayoutService struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
// NewCardLayoutService wires the service.
|
||||
func NewCardLayoutService(db *sqlx.DB) *CardLayoutService {
|
||||
return &CardLayoutService{db: db}
|
||||
}
|
||||
|
||||
// ErrUserCardLayoutNameTaken signals "name already exists for this user".
|
||||
// HTTP layer maps to 409.
|
||||
var ErrUserCardLayoutNameTaken = errors.New("card layout name taken")
|
||||
|
||||
// ErrUserCardLayoutNotFound signals "no row matches (id, user_id)". HTTP
|
||||
// layer maps to 404.
|
||||
var ErrUserCardLayoutNotFound = errors.New("card layout not found")
|
||||
|
||||
// ErrUserCardLayoutDefaultGate signals "cannot delete the active default
|
||||
// layout — switch defaults first." HTTP layer maps to 409.
|
||||
var ErrUserCardLayoutDefaultGate = errors.New("cannot delete default card layout")
|
||||
|
||||
// CreateInput is the payload for Create.
|
||||
type CreateCardLayoutInput struct {
|
||||
Name string
|
||||
Layout LayoutSpec
|
||||
IsDefault bool // first layout per user is implicitly the default
|
||||
}
|
||||
|
||||
// UpdateInput is the partial-update payload. All fields nil = no change.
|
||||
type UpdateCardLayoutInput struct {
|
||||
Name *string
|
||||
Layout *LayoutSpec
|
||||
IsDefault *bool
|
||||
}
|
||||
|
||||
// List returns the user's layouts in name order, default first.
|
||||
func (s *CardLayoutService) List(ctx context.Context, userID uuid.UUID) ([]UserCardLayout, error) {
|
||||
out := []UserCardLayout{}
|
||||
err := s.db.SelectContext(ctx, &out, `
|
||||
SELECT id, user_id, name, is_default, layout_json, created_at, updated_at
|
||||
FROM paliad.user_card_layouts
|
||||
WHERE user_id = $1
|
||||
ORDER BY is_default DESC, name ASC
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list card layouts: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Get returns one layout by id (gated on user_id).
|
||||
func (s *CardLayoutService) Get(ctx context.Context, userID, id uuid.UUID) (*UserCardLayout, error) {
|
||||
var l UserCardLayout
|
||||
err := s.db.GetContext(ctx, &l, `
|
||||
SELECT id, user_id, name, is_default, layout_json, created_at, updated_at
|
||||
FROM paliad.user_card_layouts
|
||||
WHERE id = $1 AND user_id = $2
|
||||
`, id, userID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrUserCardLayoutNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get card layout: %w", err)
|
||||
}
|
||||
return &l, nil
|
||||
}
|
||||
|
||||
// GetDefault returns the user's default layout. Auto-seeds the seed
|
||||
// "Standard" layout (DefaultLayoutSpec) on the first call so callers can
|
||||
// always treat it as never-failing for read-only paths.
|
||||
func (s *CardLayoutService) GetDefault(ctx context.Context, userID uuid.UUID) (*UserCardLayout, error) {
|
||||
var l UserCardLayout
|
||||
err := s.db.GetContext(ctx, &l, `
|
||||
SELECT id, user_id, name, is_default, layout_json, created_at, updated_at
|
||||
FROM paliad.user_card_layouts
|
||||
WHERE user_id = $1 AND is_default = true
|
||||
`, userID)
|
||||
if err == nil {
|
||||
return &l, nil
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, fmt.Errorf("get default card layout: %w", err)
|
||||
}
|
||||
// First-ever call for this user — seed the Standard layout.
|
||||
return s.Create(ctx, userID, CreateCardLayoutInput{
|
||||
Name: "Standard",
|
||||
Layout: DefaultLayoutSpec(),
|
||||
IsDefault: true,
|
||||
})
|
||||
}
|
||||
|
||||
// Create writes a new layout. If the user has no rows yet, the new row
|
||||
// becomes the default regardless of input.IsDefault. If input.IsDefault
|
||||
// is true and another default exists, the previous default's flag is
|
||||
// cleared in the same transaction.
|
||||
func (s *CardLayoutService) Create(ctx context.Context, userID uuid.UUID, in CreateCardLayoutInput) (*UserCardLayout, error) {
|
||||
if err := validateLayoutName(in.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := in.Layout.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
layoutBytes, err := json.Marshal(in.Layout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: layout JSON encode: %v", ErrInvalidInput, err)
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create card layout begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck — Commit() supersedes; Rollback() on success is a no-op
|
||||
|
||||
var existingCount int
|
||||
if err := tx.GetContext(ctx, &existingCount, `
|
||||
SELECT COUNT(*) FROM paliad.user_card_layouts WHERE user_id = $1
|
||||
`, userID); err != nil {
|
||||
return nil, fmt.Errorf("count existing layouts: %w", err)
|
||||
}
|
||||
wantDefault := in.IsDefault || existingCount == 0
|
||||
if wantDefault {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE paliad.user_card_layouts SET is_default = false, updated_at = now()
|
||||
WHERE user_id = $1 AND is_default = true
|
||||
`, userID); err != nil {
|
||||
return nil, fmt.Errorf("clear prior default: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var l UserCardLayout
|
||||
err = tx.GetContext(ctx, &l, `
|
||||
INSERT INTO paliad.user_card_layouts (user_id, name, is_default, layout_json)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, user_id, name, is_default, layout_json, created_at, updated_at
|
||||
`, userID, in.Name, wantDefault, json.RawMessage(layoutBytes))
|
||||
if err != nil {
|
||||
if isPgUniqueViolation(err) {
|
||||
return nil, ErrUserCardLayoutNameTaken
|
||||
}
|
||||
return nil, fmt.Errorf("insert card layout: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("create card layout commit: %w", err)
|
||||
}
|
||||
return &l, nil
|
||||
}
|
||||
|
||||
// Update writes one or more partial fields. is_default=true flips the
|
||||
// user's default in a transaction.
|
||||
func (s *CardLayoutService) Update(ctx context.Context, userID, id uuid.UUID, in UpdateCardLayoutInput) (*UserCardLayout, error) {
|
||||
// First ensure the row exists + is owned.
|
||||
if _, err := s.Get(ctx, userID, id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if in.Name != nil {
|
||||
if err := validateLayoutName(*in.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if in.Layout != nil {
|
||||
if err := in.Layout.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update card layout begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck
|
||||
|
||||
if in.IsDefault != nil && *in.IsDefault {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE paliad.user_card_layouts SET is_default = false, updated_at = now()
|
||||
WHERE user_id = $1 AND is_default = true AND id <> $2
|
||||
`, userID, id); err != nil {
|
||||
return nil, fmt.Errorf("clear prior default: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
sets := []string{"updated_at = now()"}
|
||||
args := []any{userID, id}
|
||||
if in.Name != nil {
|
||||
sets = append(sets, fmt.Sprintf("name = $%d", len(args)+1))
|
||||
args = append(args, *in.Name)
|
||||
}
|
||||
if in.Layout != nil {
|
||||
layoutBytes, err := json.Marshal(*in.Layout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: layout JSON encode: %v", ErrInvalidInput, err)
|
||||
}
|
||||
sets = append(sets, fmt.Sprintf("layout_json = $%d", len(args)+1))
|
||||
args = append(args, json.RawMessage(layoutBytes))
|
||||
}
|
||||
if in.IsDefault != nil {
|
||||
sets = append(sets, fmt.Sprintf("is_default = $%d", len(args)+1))
|
||||
args = append(args, *in.IsDefault)
|
||||
}
|
||||
|
||||
q := fmt.Sprintf(`
|
||||
UPDATE paliad.user_card_layouts SET %s
|
||||
WHERE user_id = $1 AND id = $2
|
||||
RETURNING id, user_id, name, is_default, layout_json, created_at, updated_at
|
||||
`, strings.Join(sets, ", "))
|
||||
|
||||
var l UserCardLayout
|
||||
err = tx.GetContext(ctx, &l, q, args...)
|
||||
if err != nil {
|
||||
if isPgUniqueViolation(err) {
|
||||
return nil, ErrUserCardLayoutNameTaken
|
||||
}
|
||||
return nil, fmt.Errorf("update card layout: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("update card layout commit: %w", err)
|
||||
}
|
||||
return &l, nil
|
||||
}
|
||||
|
||||
// SetDefault is sugar over Update with only IsDefault set. It also flips
|
||||
// the prior default in the same transaction.
|
||||
func (s *CardLayoutService) SetDefault(ctx context.Context, userID, id uuid.UUID) (*UserCardLayout, error) {
|
||||
def := true
|
||||
return s.Update(ctx, userID, id, UpdateCardLayoutInput{IsDefault: &def})
|
||||
}
|
||||
|
||||
// Delete removes a layout. Cannot delete the active default — UI gates
|
||||
// this; the service returns ErrUserCardLayoutDefaultGate as a backstop.
|
||||
func (s *CardLayoutService) Delete(ctx context.Context, userID, id uuid.UUID) error {
|
||||
row, err := s.Get(ctx, userID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if row.IsDefault {
|
||||
return ErrUserCardLayoutDefaultGate
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx, `
|
||||
DELETE FROM paliad.user_card_layouts
|
||||
WHERE id = $1 AND user_id = $2
|
||||
`, id, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete card layout: %w", err)
|
||||
}
|
||||
rows, _ := res.RowsAffected()
|
||||
if rows == 0 {
|
||||
return ErrUserCardLayoutNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateLayoutName mirrors the column's NOT NULL + a sane length cap so
|
||||
// the UI dropdown doesn't have 200-char names breaking the layout. Named
|
||||
// distinctly from user_view_service.validateName because the user_view
|
||||
// rules differ (they enforce a slug regex separately).
|
||||
func validateLayoutName(name string) error {
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return fmt.Errorf("%w: name is required", ErrInvalidInput)
|
||||
}
|
||||
if len([]rune(name)) > 80 {
|
||||
return fmt.Errorf("%w: name exceeds 80 characters", ErrInvalidInput)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isPgUniqueViolation reports whether err is a Postgres unique-violation
|
||||
// (SQLSTATE 23505). Used to map "duplicate (user_id, name)" to a clean
|
||||
// 409 ErrUserCardLayoutNameTaken.
|
||||
func isPgUniqueViolation(err error) bool {
|
||||
var pgErr *pq.Error
|
||||
return errors.As(err, &pgErr) && pgErr.Code == "23505"
|
||||
}
|
||||
231
internal/services/card_layout_service_test.go
Normal file
231
internal/services/card_layout_service_test.go
Normal file
@@ -0,0 +1,231 @@
|
||||
package services
|
||||
|
||||
// Live-DB tests for CardLayoutService. Skipped when TEST_DATABASE_URL
|
||||
// is unset.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
)
|
||||
|
||||
type cardLayoutTestEnv struct {
|
||||
t *testing.T
|
||||
pool *sqlx.DB
|
||||
svc *CardLayoutService
|
||||
userID uuid.UUID
|
||||
cleanup func()
|
||||
}
|
||||
|
||||
func setupCardLayoutTest(t *testing.T) *cardLayoutTestEnv {
|
||||
t.Helper()
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
|
||||
}
|
||||
if err := db.ApplyMigrations(url); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
pool, err := sqlx.Connect("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
userID := uuid.New()
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO auth.users (id, email) VALUES ($1, $1::text || '@test.local')
|
||||
ON CONFLICT (id) DO NOTHING`, userID); err != nil {
|
||||
t.Logf("skip auth.users seed: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.users (id, email, display_name, office, global_role)
|
||||
VALUES ($1, $1::text || '@test.local', 'Card Layout Test', 'munich', 'standard')
|
||||
ON CONFLICT (id) DO NOTHING`, userID); err != nil {
|
||||
t.Fatalf("seed paliad.users: %v", err)
|
||||
}
|
||||
|
||||
cleanup := func() {
|
||||
c := context.Background()
|
||||
pool.ExecContext(c, `DELETE FROM paliad.user_card_layouts WHERE user_id = $1`, userID)
|
||||
pool.ExecContext(c, `DELETE FROM paliad.users WHERE id = $1`, userID)
|
||||
pool.ExecContext(c, `DELETE FROM auth.users WHERE id = $1`, userID)
|
||||
pool.Close()
|
||||
}
|
||||
|
||||
return &cardLayoutTestEnv{
|
||||
t: t,
|
||||
pool: pool,
|
||||
svc: NewCardLayoutService(pool),
|
||||
userID: userID,
|
||||
cleanup: cleanup,
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardLayoutService_GetDefaultAutoSeeds(t *testing.T) {
|
||||
env := setupCardLayoutTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
// First call seeds the default.
|
||||
def, err := env.svc.GetDefault(ctx, env.userID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDefault: %v", err)
|
||||
}
|
||||
if def.Name != "Standard" || !def.IsDefault {
|
||||
t.Errorf("seeded default: name=%q is_default=%v; want Standard, true", def.Name, def.IsDefault)
|
||||
}
|
||||
|
||||
// Second call returns the same row, not a new seed.
|
||||
def2, err := env.svc.GetDefault(ctx, env.userID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDefault second: %v", err)
|
||||
}
|
||||
if def2.ID != def.ID {
|
||||
t.Errorf("second GetDefault returned %v; want same id %v", def2.ID, def.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardLayoutService_FirstCreateBecomesDefault(t *testing.T) {
|
||||
env := setupCardLayoutTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
row, err := env.svc.Create(ctx, env.userID, CreateCardLayoutInput{
|
||||
Name: "Mein Erstes",
|
||||
Layout: DefaultLayoutSpec(),
|
||||
IsDefault: false, // even with false, first row becomes default.
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if !row.IsDefault {
|
||||
t.Errorf("first layout is_default=false; want true (auto-flip)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardLayoutService_SetDefaultClearsPrior(t *testing.T) {
|
||||
env := setupCardLayoutTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
a, err := env.svc.Create(ctx, env.userID, CreateCardLayoutInput{Name: "A", Layout: DefaultLayoutSpec()})
|
||||
if err != nil {
|
||||
t.Fatalf("Create A: %v", err)
|
||||
}
|
||||
if !a.IsDefault {
|
||||
t.Fatalf("A is_default=false; want true")
|
||||
}
|
||||
b, err := env.svc.Create(ctx, env.userID, CreateCardLayoutInput{Name: "B", Layout: DefaultLayoutSpec()})
|
||||
if err != nil {
|
||||
t.Fatalf("Create B: %v", err)
|
||||
}
|
||||
if b.IsDefault {
|
||||
t.Fatalf("B is_default=true; want false (A is still default)")
|
||||
}
|
||||
|
||||
// Flip B → default.
|
||||
bAfter, err := env.svc.SetDefault(ctx, env.userID, b.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("SetDefault B: %v", err)
|
||||
}
|
||||
if !bAfter.IsDefault {
|
||||
t.Errorf("after SetDefault B.is_default=false")
|
||||
}
|
||||
|
||||
// A should no longer be default.
|
||||
aAfter, err := env.svc.Get(ctx, env.userID, a.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Get A: %v", err)
|
||||
}
|
||||
if aAfter.IsDefault {
|
||||
t.Errorf("A still is_default=true after B took the flag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardLayoutService_DeleteRefusesActiveDefault(t *testing.T) {
|
||||
env := setupCardLayoutTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
row, err := env.svc.Create(ctx, env.userID, CreateCardLayoutInput{Name: "OnlyOne", Layout: DefaultLayoutSpec()})
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
err = env.svc.Delete(ctx, env.userID, row.ID)
|
||||
if !errors.Is(err, ErrUserCardLayoutDefaultGate) {
|
||||
t.Errorf("Delete default = %v; want ErrUserCardLayoutDefaultGate", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardLayoutService_DeleteNonDefault(t *testing.T) {
|
||||
env := setupCardLayoutTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
_, _ = env.svc.Create(ctx, env.userID, CreateCardLayoutInput{Name: "Default", Layout: DefaultLayoutSpec()})
|
||||
b, err := env.svc.Create(ctx, env.userID, CreateCardLayoutInput{Name: "Throwaway", Layout: DefaultLayoutSpec()})
|
||||
if err != nil {
|
||||
t.Fatalf("Create throwaway: %v", err)
|
||||
}
|
||||
if err := env.svc.Delete(ctx, env.userID, b.ID); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
if _, err := env.svc.Get(ctx, env.userID, b.ID); !errors.Is(err, ErrUserCardLayoutNotFound) {
|
||||
t.Errorf("Get after delete = %v; want ErrUserCardLayoutNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardLayoutService_DuplicateNameRejected(t *testing.T) {
|
||||
env := setupCardLayoutTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := env.svc.Create(ctx, env.userID, CreateCardLayoutInput{Name: "Same", Layout: DefaultLayoutSpec()}); err != nil {
|
||||
t.Fatalf("Create first: %v", err)
|
||||
}
|
||||
_, err := env.svc.Create(ctx, env.userID, CreateCardLayoutInput{Name: "Same", Layout: DefaultLayoutSpec()})
|
||||
if !errors.Is(err, ErrUserCardLayoutNameTaken) {
|
||||
t.Errorf("duplicate name = %v; want ErrUserCardLayoutNameTaken", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardLayoutService_UpdateRoundTrip(t *testing.T) {
|
||||
env := setupCardLayoutTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
row, err := env.svc.Create(ctx, env.userID, CreateCardLayoutInput{Name: "Editable", Layout: DefaultLayoutSpec()})
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
newName := "Renamed"
|
||||
newLayout := DefaultLayoutSpec()
|
||||
newLayout.Density = CardDensityCompact
|
||||
updated, err := env.svc.Update(ctx, env.userID, row.ID, UpdateCardLayoutInput{
|
||||
Name: &newName,
|
||||
Layout: &newLayout,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
if updated.Name != "Renamed" {
|
||||
t.Errorf("name = %q; want Renamed", updated.Name)
|
||||
}
|
||||
|
||||
parsed, err := ParseLayoutSpec(updated.LayoutJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseLayoutSpec on updated: %v", err)
|
||||
}
|
||||
if parsed.Density != CardDensityCompact {
|
||||
t.Errorf("density round-trip = %q; want compact", parsed.Density)
|
||||
}
|
||||
}
|
||||
190
internal/services/layout_spec.go
Normal file
190
internal/services/layout_spec.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package services
|
||||
|
||||
// LayoutSpec — JSON shape for paliad.user_card_layouts.layout_json.
|
||||
//
|
||||
// Design: docs/design-projects-page-2026-05-07.md §5b.3.
|
||||
//
|
||||
// Validation surface (server-side):
|
||||
// - Every fact key must be in KnownFactKeys.
|
||||
// - Each key appears at most once (no duplicates).
|
||||
// - "title-row" must be the first visible fact (always-on, structural).
|
||||
// - count is bounded [1, 5] when set (only meaningful for next-events /
|
||||
// recent-verlauf).
|
||||
// - density ∈ {compact, roomy}.
|
||||
// - gridColumns ∈ {auto, 2, 3, 4}.
|
||||
//
|
||||
// JSON shape mirrors the TypeScript type in
|
||||
// frontend/src/client/projects-cards-types.ts.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// FactKey enumerates the cards facts the user can show / hide / reorder.
|
||||
type FactKey string
|
||||
|
||||
const (
|
||||
FactTitleRow FactKey = "title-row"
|
||||
FactTypeChip FactKey = "type-chip"
|
||||
FactStatusChip FactKey = "status-chip"
|
||||
FactClientMatter FactKey = "client-matter"
|
||||
FactParentPath FactKey = "parent-path"
|
||||
FactDeadlineCounts FactKey = "deadline-counts"
|
||||
FactNextEvents FactKey = "next-events"
|
||||
FactRecentVerlauf FactKey = "recent-verlauf"
|
||||
FactTeamChips FactKey = "team-chips"
|
||||
FactReference FactKey = "reference"
|
||||
FactLastActivityAt FactKey = "last-activity-at"
|
||||
)
|
||||
|
||||
// KnownFactKeys is the registry. Adding a new fact = add a const above
|
||||
// AND append here. Frontend has its own mirror in projects-cards.ts.
|
||||
var KnownFactKeys = []FactKey{
|
||||
FactTitleRow,
|
||||
FactTypeChip,
|
||||
FactStatusChip,
|
||||
FactClientMatter,
|
||||
FactParentPath,
|
||||
FactDeadlineCounts,
|
||||
FactNextEvents,
|
||||
FactRecentVerlauf,
|
||||
FactTeamChips,
|
||||
FactReference,
|
||||
FactLastActivityAt,
|
||||
}
|
||||
|
||||
// CardDensity controls per-card padding + line-height (Kompakt / Geräumig).
|
||||
// Distinct type from t-paliad-144's ListDensity (which only ranges over
|
||||
// {comfortable, compact} and applies to the views.list render shape).
|
||||
type CardDensity string
|
||||
|
||||
const (
|
||||
CardDensityCompact CardDensity = "compact"
|
||||
CardDensityRoomy CardDensity = "roomy"
|
||||
)
|
||||
|
||||
// GridColumns controls the responsive grid. "auto" lets the browser
|
||||
// fit-as-many-as-possible at minmax(280px, 1fr); 2/3/4 force fixed columns.
|
||||
type GridColumns string
|
||||
|
||||
const (
|
||||
GridAuto GridColumns = "auto"
|
||||
GridTwo GridColumns = "2"
|
||||
GridThree GridColumns = "3"
|
||||
GridFour GridColumns = "4"
|
||||
)
|
||||
|
||||
// LayoutFact is a single fact entry in the ordered facts[] array.
|
||||
type LayoutFact struct {
|
||||
Key FactKey `json:"key"`
|
||||
Visible bool `json:"visible"`
|
||||
// Count is meaningful for next-events and recent-verlauf only. nil for
|
||||
// every other key. Bounded [1, 5] when set; default 3 (the seed value).
|
||||
Count *int `json:"count,omitempty"`
|
||||
}
|
||||
|
||||
// LayoutSpec is the persisted card-layout shape.
|
||||
type LayoutSpec struct {
|
||||
Facts []LayoutFact `json:"facts"`
|
||||
Density CardDensity `json:"density"`
|
||||
GridColumns GridColumns `json:"grid_columns"`
|
||||
ShowAllLevels bool `json:"show_all_levels"`
|
||||
}
|
||||
|
||||
// DefaultLayoutSpec returns the seed "Standard" layout per design §5b.4 —
|
||||
// rich content set, all 9 facts visible, roomy density, auto grid.
|
||||
func DefaultLayoutSpec() LayoutSpec {
|
||||
three := 3
|
||||
return LayoutSpec{
|
||||
Facts: []LayoutFact{
|
||||
{Key: FactTitleRow, Visible: true},
|
||||
{Key: FactTypeChip, Visible: true},
|
||||
{Key: FactStatusChip, Visible: true},
|
||||
{Key: FactClientMatter, Visible: true},
|
||||
{Key: FactParentPath, Visible: true},
|
||||
{Key: FactDeadlineCounts, Visible: true},
|
||||
{Key: FactNextEvents, Visible: true, Count: &three},
|
||||
{Key: FactRecentVerlauf, Visible: true, Count: &three},
|
||||
{Key: FactTeamChips, Visible: true},
|
||||
},
|
||||
Density: CardDensityRoomy,
|
||||
GridColumns: GridAuto,
|
||||
ShowAllLevels: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Validate enforces the structural invariants. Returns ErrInvalidInput
|
||||
// wrapped with a precise message on the first violation.
|
||||
func (s LayoutSpec) Validate() error {
|
||||
if len(s.Facts) == 0 {
|
||||
return fmt.Errorf("%w: layout.facts is empty", ErrInvalidInput)
|
||||
}
|
||||
|
||||
// First visible fact must be title-row.
|
||||
firstVisible := -1
|
||||
for i, f := range s.Facts {
|
||||
if f.Visible {
|
||||
firstVisible = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if firstVisible == -1 {
|
||||
return fmt.Errorf("%w: layout has no visible facts", ErrInvalidInput)
|
||||
}
|
||||
if s.Facts[firstVisible].Key != FactTitleRow {
|
||||
return fmt.Errorf("%w: first visible fact must be %q (got %q)",
|
||||
ErrInvalidInput, FactTitleRow, s.Facts[firstVisible].Key)
|
||||
}
|
||||
|
||||
seen := make(map[FactKey]bool, len(s.Facts))
|
||||
for i, f := range s.Facts {
|
||||
if !slices.Contains(KnownFactKeys, f.Key) {
|
||||
return fmt.Errorf("%w: layout.facts[%d].key %q is not a known fact",
|
||||
ErrInvalidInput, i, f.Key)
|
||||
}
|
||||
if seen[f.Key] {
|
||||
return fmt.Errorf("%w: layout.facts has duplicate key %q",
|
||||
ErrInvalidInput, f.Key)
|
||||
}
|
||||
seen[f.Key] = true
|
||||
if f.Count != nil {
|
||||
if f.Key != FactNextEvents && f.Key != FactRecentVerlauf {
|
||||
return fmt.Errorf("%w: layout.facts[%d] count is only valid for next-events / recent-verlauf",
|
||||
ErrInvalidInput, i)
|
||||
}
|
||||
if *f.Count < 1 || *f.Count > 5 {
|
||||
return fmt.Errorf("%w: layout.facts[%d].count %d out of range [1, 5]",
|
||||
ErrInvalidInput, i, *f.Count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch s.Density {
|
||||
case CardDensityCompact, CardDensityRoomy:
|
||||
default:
|
||||
return fmt.Errorf("%w: layout.density %q invalid", ErrInvalidInput, s.Density)
|
||||
}
|
||||
|
||||
switch s.GridColumns {
|
||||
case GridAuto, GridTwo, GridThree, GridFour:
|
||||
default:
|
||||
return fmt.Errorf("%w: layout.grid_columns %q invalid", ErrInvalidInput, s.GridColumns)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ParseLayoutSpec decodes JSON bytes and validates. Used both by the HTTP
|
||||
// handler (request body) and by the service (read-back from the DB column).
|
||||
func ParseLayoutSpec(b []byte) (LayoutSpec, error) {
|
||||
var s LayoutSpec
|
||||
if err := json.Unmarshal(b, &s); err != nil {
|
||||
return LayoutSpec{}, fmt.Errorf("%w: layout JSON decode: %v", ErrInvalidInput, err)
|
||||
}
|
||||
if err := s.Validate(); err != nil {
|
||||
return LayoutSpec{}, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
155
internal/services/layout_spec_test.go
Normal file
155
internal/services/layout_spec_test.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package services
|
||||
|
||||
// Pure-Go validator tests for LayoutSpec. No DB required.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultLayoutSpec_IsValid(t *testing.T) {
|
||||
if err := DefaultLayoutSpec().Validate(); err != nil {
|
||||
t.Fatalf("DefaultLayoutSpec invalid: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayoutSpec_RejectsEmpty(t *testing.T) {
|
||||
s := LayoutSpec{
|
||||
Density: CardDensityRoomy,
|
||||
GridColumns: GridAuto,
|
||||
}
|
||||
err := s.Validate()
|
||||
if !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("empty facts: got %v, want ErrInvalidInput", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayoutSpec_RequiresTitleRowFirst(t *testing.T) {
|
||||
s := LayoutSpec{
|
||||
Facts: []LayoutFact{
|
||||
{Key: FactTypeChip, Visible: true},
|
||||
{Key: FactTitleRow, Visible: true},
|
||||
},
|
||||
Density: CardDensityRoomy,
|
||||
GridColumns: GridAuto,
|
||||
}
|
||||
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("title-row not first: got %v, want ErrInvalidInput", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayoutSpec_AllowsTitleRowAfterHiddenLeading(t *testing.T) {
|
||||
// type-chip is hidden so it doesn't count; title-row is the first VISIBLE.
|
||||
s := LayoutSpec{
|
||||
Facts: []LayoutFact{
|
||||
{Key: FactTypeChip, Visible: false},
|
||||
{Key: FactTitleRow, Visible: true},
|
||||
{Key: FactStatusChip, Visible: true},
|
||||
},
|
||||
Density: CardDensityRoomy,
|
||||
GridColumns: GridAuto,
|
||||
}
|
||||
if err := s.Validate(); err != nil {
|
||||
t.Fatalf("title-row first-visible should be ok; got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayoutSpec_RejectsDuplicateKey(t *testing.T) {
|
||||
s := LayoutSpec{
|
||||
Facts: []LayoutFact{
|
||||
{Key: FactTitleRow, Visible: true},
|
||||
{Key: FactTypeChip, Visible: true},
|
||||
{Key: FactTypeChip, Visible: true},
|
||||
},
|
||||
Density: CardDensityRoomy,
|
||||
GridColumns: GridAuto,
|
||||
}
|
||||
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("duplicate key: got %v, want ErrInvalidInput", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayoutSpec_RejectsUnknownKey(t *testing.T) {
|
||||
s := LayoutSpec{
|
||||
Facts: []LayoutFact{
|
||||
{Key: FactTitleRow, Visible: true},
|
||||
{Key: "made-up-fact", Visible: true},
|
||||
},
|
||||
Density: CardDensityRoomy,
|
||||
GridColumns: GridAuto,
|
||||
}
|
||||
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("unknown key: got %v, want ErrInvalidInput", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayoutSpec_CountBoundsAndPlacement(t *testing.T) {
|
||||
bad := 7
|
||||
s := LayoutSpec{
|
||||
Facts: []LayoutFact{
|
||||
{Key: FactTitleRow, Visible: true},
|
||||
{Key: FactNextEvents, Visible: true, Count: &bad},
|
||||
},
|
||||
Density: CardDensityRoomy,
|
||||
GridColumns: GridAuto,
|
||||
}
|
||||
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("count out-of-range: got %v, want ErrInvalidInput", err)
|
||||
}
|
||||
|
||||
// count on a key that doesn't accept it.
|
||||
good := 3
|
||||
s = LayoutSpec{
|
||||
Facts: []LayoutFact{
|
||||
{Key: FactTitleRow, Visible: true},
|
||||
{Key: FactStatusChip, Visible: true, Count: &good},
|
||||
},
|
||||
Density: CardDensityRoomy,
|
||||
GridColumns: GridAuto,
|
||||
}
|
||||
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("count on wrong key: got %v, want ErrInvalidInput", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayoutSpec_DensityAndGrid(t *testing.T) {
|
||||
s := LayoutSpec{
|
||||
Facts: []LayoutFact{{Key: FactTitleRow, Visible: true}},
|
||||
Density: "spacious", // invalid
|
||||
GridColumns: GridAuto,
|
||||
}
|
||||
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("invalid density: got %v, want ErrInvalidInput", err)
|
||||
}
|
||||
s.Density = CardDensityRoomy
|
||||
s.GridColumns = "5" // invalid
|
||||
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("invalid grid: got %v, want ErrInvalidInput", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLayoutSpec_RoundTrip(t *testing.T) {
|
||||
in := DefaultLayoutSpec()
|
||||
bytes := mustJSON(t, in)
|
||||
out, err := ParseLayoutSpec(bytes)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if len(out.Facts) != len(in.Facts) {
|
||||
t.Errorf("facts len = %d, want %d", len(out.Facts), len(in.Facts))
|
||||
}
|
||||
if out.Density != in.Density || out.GridColumns != in.GridColumns {
|
||||
t.Errorf("density/grid drift: out=(%s, %s) in=(%s, %s)",
|
||||
out.Density, out.GridColumns, in.Density, in.GridColumns)
|
||||
}
|
||||
}
|
||||
|
||||
func mustJSON(t *testing.T, v any) []byte {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -1138,6 +1138,307 @@ func (s *ProjectService) ResolveClientNumber(ctx context.Context, userID, id uui
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Cards preview (t-paliad-149 PR 2)
|
||||
// ============================================================================
|
||||
|
||||
// CardEventPreview is one event row inside a card's "Nächste Termine" or
|
||||
// "Zuletzt" section. Hoverable + clickable in the UI; route is the
|
||||
// computed in-app navigation target.
|
||||
type CardEventPreview struct {
|
||||
Kind string `json:"kind"` // "deadline" | "appointment" | "project_event"
|
||||
ID uuid.UUID `json:"id"`
|
||||
Title string `json:"title"`
|
||||
EventDate time.Time `json:"event_date"`
|
||||
Status *string `json:"status,omitempty"` // populated for kind=deadline
|
||||
ActorName *string `json:"actor_name,omitempty"` // populated for kind=project_event
|
||||
Route string `json:"route"` // /projects/{pid}?focus=...
|
||||
}
|
||||
|
||||
// ProjectCardPreview is the per-project rollup for the Cards view. One row
|
||||
// per visible project; team_initials capped at 3 + team_count for the
|
||||
// total. last_activity_at is the most recent event timestamp (deadline /
|
||||
// appointment / project_event) across own + descendants, used by the
|
||||
// orchestrator to sort cards.
|
||||
type ProjectCardPreview struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
NextEvents []CardEventPreview `json:"next_events"`
|
||||
RecentVerlauf []CardEventPreview `json:"recent_verlauf"`
|
||||
TeamInitials []string `json:"team_initials"`
|
||||
TeamCount int `json:"team_count"`
|
||||
LastActivityAt *time.Time `json:"last_activity_at,omitempty"`
|
||||
}
|
||||
|
||||
// CardsPreview returns the per-project rollup for the Cards view across
|
||||
// every project the user can see. The optional projectIDs slice narrows
|
||||
// the rollup to a subset (used by IntersectionObserver lazy fetches).
|
||||
//
|
||||
// Performance: a single SQL per source (deadlines, appointments, project
|
||||
// events) using ROW_NUMBER() OVER (PARTITION BY project_id ORDER BY
|
||||
// event_date) to slice top-3 each direction without N round-trips. Caller
|
||||
// can wrap in a per-user TTL cache (handler does this v1).
|
||||
func (s *ProjectService) CardsPreview(ctx context.Context, userID uuid.UUID, projectIDs []uuid.UUID) (map[uuid.UUID]*ProjectCardPreview, error) {
|
||||
user, err := s.users.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user == nil {
|
||||
return map[uuid.UUID]*ProjectCardPreview{}, nil
|
||||
}
|
||||
|
||||
// Determine the visible-project set. When projectIDs is non-empty, we
|
||||
// still gate every row through the visibility predicate.
|
||||
out := map[uuid.UUID]*ProjectCardPreview{}
|
||||
|
||||
// Optional narrowing as a SQL ANY clause.
|
||||
narrow := ""
|
||||
args := []any{userID}
|
||||
if len(projectIDs) > 0 {
|
||||
idStrs := make([]string, len(projectIDs))
|
||||
for i, id := range projectIDs {
|
||||
idStrs[i] = id.String()
|
||||
}
|
||||
narrow = " AND p.id = ANY($2::uuid[])"
|
||||
args = append(args, pq.StringArray(idStrs))
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
today := now.Truncate(24 * time.Hour)
|
||||
|
||||
// --- Source 1: upcoming Deadlines (top 3 per project, ascending). ---
|
||||
type rowDeadline struct {
|
||||
ProjectID uuid.UUID `db:"project_id"`
|
||||
ID uuid.UUID `db:"id"`
|
||||
Title string `db:"title"`
|
||||
DueDate time.Time `db:"due_date"`
|
||||
Status string `db:"status"`
|
||||
}
|
||||
var ds []rowDeadline
|
||||
dq := `
|
||||
WITH visible AS (
|
||||
SELECT p.id FROM paliad.projects p
|
||||
WHERE ` + visibilityPredicatePositional("p", 1) + narrow + `
|
||||
), ranked AS (
|
||||
SELECT f.project_id, f.id, f.title, f.due_date, f.status,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY f.project_id
|
||||
ORDER BY f.due_date ASC, f.id ASC
|
||||
) AS rn
|
||||
FROM paliad.deadlines f
|
||||
JOIN visible v ON v.id = f.project_id
|
||||
WHERE f.status = 'pending' AND f.due_date >= $%d::date
|
||||
)
|
||||
SELECT project_id, id, title, due_date, status
|
||||
FROM ranked WHERE rn <= 3
|
||||
`
|
||||
dq = fmt.Sprintf(dq, len(args)+1)
|
||||
args = append(args, today)
|
||||
if err := s.db.SelectContext(ctx, &ds, dq, args...); err != nil {
|
||||
return nil, fmt.Errorf("cards preview deadlines: %w", err)
|
||||
}
|
||||
|
||||
// --- Source 2: upcoming Appointments (top 3 per project, ascending). ---
|
||||
type rowAppointment struct {
|
||||
ProjectID uuid.UUID `db:"project_id"`
|
||||
ID uuid.UUID `db:"id"`
|
||||
Title string `db:"title"`
|
||||
StartsAt time.Time `db:"starts_at"`
|
||||
}
|
||||
var as []rowAppointment
|
||||
aq := `
|
||||
WITH visible AS (
|
||||
SELECT p.id FROM paliad.projects p
|
||||
WHERE ` + visibilityPredicatePositional("p", 1) + narrow + `
|
||||
), ranked AS (
|
||||
SELECT t.project_id, t.id, t.title, t.starts_at,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY t.project_id
|
||||
ORDER BY t.starts_at ASC, t.id ASC
|
||||
) AS rn
|
||||
FROM paliad.appointments t
|
||||
JOIN visible v ON v.id = t.project_id
|
||||
WHERE t.project_id IS NOT NULL AND t.starts_at >= $%d::timestamptz
|
||||
)
|
||||
SELECT project_id, id, title, starts_at
|
||||
FROM ranked WHERE rn <= 3
|
||||
`
|
||||
// args already has [userID, projectIDs?, today]; reuse $%d for now.
|
||||
aArgs := make([]any, len(args))
|
||||
copy(aArgs, args)
|
||||
aArgs[len(aArgs)-1] = now // last arg is the temporal bound
|
||||
aq = fmt.Sprintf(aq, len(aArgs))
|
||||
if err := s.db.SelectContext(ctx, &as, aq, aArgs...); err != nil {
|
||||
return nil, fmt.Errorf("cards preview appointments: %w", err)
|
||||
}
|
||||
|
||||
// --- Source 3: recent project_events (Verlauf, top 3 per project, descending). ---
|
||||
type rowEvent struct {
|
||||
ProjectID uuid.UUID `db:"project_id"`
|
||||
ID uuid.UUID `db:"id"`
|
||||
Title string `db:"title"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
ActorName *string `db:"actor_name"`
|
||||
}
|
||||
var es []rowEvent
|
||||
eq := `
|
||||
WITH visible AS (
|
||||
SELECT p.id FROM paliad.projects p
|
||||
WHERE ` + visibilityPredicatePositional("p", 1) + narrow + `
|
||||
), ranked AS (
|
||||
SELECT pe.project_id, pe.id, pe.title, pe.created_at,
|
||||
u.display_name AS actor_name,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY pe.project_id
|
||||
ORDER BY pe.created_at DESC, pe.id DESC
|
||||
) AS rn
|
||||
FROM paliad.project_events pe
|
||||
JOIN visible v ON v.id = pe.project_id
|
||||
LEFT JOIN paliad.users u ON u.id = pe.created_by
|
||||
)
|
||||
SELECT project_id, id, title, created_at, actor_name
|
||||
FROM ranked WHERE rn <= 3
|
||||
`
|
||||
eArgs := []any{userID}
|
||||
if len(projectIDs) > 0 {
|
||||
idStrs := make([]string, len(projectIDs))
|
||||
for i, id := range projectIDs {
|
||||
idStrs[i] = id.String()
|
||||
}
|
||||
eArgs = append(eArgs, pq.StringArray(idStrs))
|
||||
}
|
||||
if err := s.db.SelectContext(ctx, &es, eq, eArgs...); err != nil {
|
||||
return nil, fmt.Errorf("cards preview project events: %w", err)
|
||||
}
|
||||
|
||||
// --- Source 4: team chips per project (initials + count). ---
|
||||
type rowTeam struct {
|
||||
ProjectID uuid.UUID `db:"project_id"`
|
||||
DisplayName string `db:"display_name"`
|
||||
}
|
||||
var ts []rowTeam
|
||||
tq := `
|
||||
WITH visible AS (
|
||||
SELECT p.id FROM paliad.projects p
|
||||
WHERE ` + visibilityPredicatePositional("p", 1) + narrow + `
|
||||
)
|
||||
SELECT pt.project_id, u.display_name
|
||||
FROM paliad.project_teams pt
|
||||
JOIN visible v ON v.id = pt.project_id
|
||||
JOIN paliad.users u ON u.id = pt.user_id
|
||||
ORDER BY pt.project_id, u.display_name
|
||||
`
|
||||
if err := s.db.SelectContext(ctx, &ts, tq, eArgs...); err != nil {
|
||||
return nil, fmt.Errorf("cards preview teams: %w", err)
|
||||
}
|
||||
|
||||
// Stitch into per-project structs.
|
||||
get := func(pid uuid.UUID) *ProjectCardPreview {
|
||||
if p, ok := out[pid]; ok {
|
||||
return p
|
||||
}
|
||||
p := &ProjectCardPreview{
|
||||
ProjectID: pid,
|
||||
NextEvents: []CardEventPreview{},
|
||||
RecentVerlauf: []CardEventPreview{},
|
||||
TeamInitials: []string{},
|
||||
}
|
||||
out[pid] = p
|
||||
return p
|
||||
}
|
||||
for _, r := range ds {
|
||||
p := get(r.ProjectID)
|
||||
st := r.Status
|
||||
p.NextEvents = append(p.NextEvents, CardEventPreview{
|
||||
Kind: "deadline",
|
||||
ID: r.ID,
|
||||
Title: r.Title,
|
||||
EventDate: r.DueDate,
|
||||
Status: &st,
|
||||
Route: fmt.Sprintf("/projects/%s?focus=%s", r.ProjectID, r.ID),
|
||||
})
|
||||
bumpActivity(p, r.DueDate)
|
||||
}
|
||||
for _, r := range as {
|
||||
p := get(r.ProjectID)
|
||||
p.NextEvents = append(p.NextEvents, CardEventPreview{
|
||||
Kind: "appointment",
|
||||
ID: r.ID,
|
||||
Title: r.Title,
|
||||
EventDate: r.StartsAt,
|
||||
Route: fmt.Sprintf("/projects/%s?focus=%s", r.ProjectID, r.ID),
|
||||
})
|
||||
bumpActivity(p, r.StartsAt)
|
||||
}
|
||||
for _, r := range es {
|
||||
p := get(r.ProjectID)
|
||||
p.RecentVerlauf = append(p.RecentVerlauf, CardEventPreview{
|
||||
Kind: "project_event",
|
||||
ID: r.ID,
|
||||
Title: r.Title,
|
||||
EventDate: r.CreatedAt,
|
||||
ActorName: r.ActorName,
|
||||
Route: fmt.Sprintf("/projects/%s?tab=verlauf&focus=%s", r.ProjectID, r.ID),
|
||||
})
|
||||
bumpActivity(p, r.CreatedAt)
|
||||
}
|
||||
for _, r := range ts {
|
||||
p := get(r.ProjectID)
|
||||
p.TeamCount++
|
||||
if len(p.TeamInitials) < 3 {
|
||||
p.TeamInitials = append(p.TeamInitials, initialsFromName(r.DisplayName))
|
||||
}
|
||||
}
|
||||
|
||||
// Sort NextEvents per project ascending, RecentVerlauf descending,
|
||||
// then truncate to 3 (the SQL caps at 3 per source, but the union of
|
||||
// deadline+appointment can be 6 — re-sort + cap to 3).
|
||||
for _, p := range out {
|
||||
sortByEventDateAsc(p.NextEvents)
|
||||
if len(p.NextEvents) > 3 {
|
||||
p.NextEvents = p.NextEvents[:3]
|
||||
}
|
||||
// RecentVerlauf is single-source already-bounded; nothing else to do.
|
||||
_ = p.RecentVerlauf
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func bumpActivity(p *ProjectCardPreview, ts time.Time) {
|
||||
if p.LastActivityAt == nil || ts.After(*p.LastActivityAt) {
|
||||
t := ts
|
||||
p.LastActivityAt = &t
|
||||
}
|
||||
}
|
||||
|
||||
func sortByEventDateAsc(events []CardEventPreview) {
|
||||
for i := 1; i < len(events); i++ {
|
||||
for j := i; j > 0 && events[j].EventDate.Before(events[j-1].EventDate); j-- {
|
||||
events[j], events[j-1] = events[j-1], events[j]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func initialsFromName(name string) string {
|
||||
parts := strings.Fields(name)
|
||||
if len(parts) == 0 {
|
||||
return "?"
|
||||
}
|
||||
if len(parts) == 1 {
|
||||
r := []rune(parts[0])
|
||||
if len(r) == 0 {
|
||||
return "?"
|
||||
}
|
||||
return strings.ToUpper(string(r[0]))
|
||||
}
|
||||
first := []rune(parts[0])
|
||||
last := []rune(parts[len(parts)-1])
|
||||
if len(first) == 0 || len(last) == 0 {
|
||||
return strings.ToUpper(string(first) + string(last))
|
||||
}
|
||||
return strings.ToUpper(string(first[0]) + string(last[0]))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user