feat(dashboard): t-paliad-219 Slice A3 — widen windows + add InboxSummary
Two changes to DashboardService for the configurable dashboard:
1) Widen upcoming windows from 7d/LIMIT 10 → 60d/LIMIT 40 for both
loadUpcomingDeadlines and loadUpcomingAppointments. Per design §18
Note B, the per-widget horizon dropdown (7/14/30/60 days) filters
client-side from a single payload — server-side widening preserves
the Q4 "one big payload" pick without forcing per-widget endpoints.
Existing tests pass: the dashboard CTE bucket math is unchanged and
the wider rows-list is a superset of what /api/dashboard returned
before.
2) Add InboxSummary { pending_count, top: []InboxEntry } to DashboardData
for the new inbox-approvals widget (Q3 expansion). Powered by
ApprovalService.PendingCountForUser + ListPendingForApprover with
Limit=InboxTopCap (10). InboxEntry is the minimum needed to render
a clickable preview line: request id, entity_type/title, project,
requester, requested_at.
ApprovalService is wired post-construction via
DashboardService.SetApprovalService to avoid a circular constructor
dependency. When unwired (knowledge-platform-only deployments,
tests), loadInboxSummary is a no-op and the widget renders its
empty state.
3 new pure-function tests: nil-approvals no-op, SetApprovalService
wiring, InboxTopCap sanity.
go build + go vet + go test ./internal/... -short all clean.
This commit is contained in:
@@ -187,6 +187,14 @@ func main() {
|
||||
Export: services.NewExportService(pool, branding.Name),
|
||||
}
|
||||
|
||||
// t-paliad-219 Slice A3 — stitch DashboardService → ApprovalService
|
||||
// for the inbox-approvals widget. Done post-construction to avoid
|
||||
// a circular constructor dependency (ApprovalService doesn't need
|
||||
// the dashboard, and DashboardService can render its other widgets
|
||||
// without approvals — so keeping this a setter keeps both
|
||||
// constructors simple).
|
||||
svcBundle.Dashboard.SetApprovalService(svcBundle.Approval)
|
||||
|
||||
// t-paliad-215 Slice 1 — submission generator. Three services
|
||||
// stitched together by handlers/submissions.go: registry pulls
|
||||
// templates from Gitea (reuses GITEA_TOKEN env), vars builds
|
||||
|
||||
@@ -21,14 +21,24 @@ import (
|
||||
// DashboardService reads paliad.projects/deadlines/appointments/project_events for
|
||||
// the Dashboard page.
|
||||
type DashboardService struct {
|
||||
db *sqlx.DB
|
||||
users *UserService
|
||||
db *sqlx.DB
|
||||
users *UserService
|
||||
approvals *ApprovalService
|
||||
}
|
||||
|
||||
func NewDashboardService(db *sqlx.DB, users *UserService) *DashboardService {
|
||||
return &DashboardService{db: db, users: users}
|
||||
}
|
||||
|
||||
// SetApprovalService wires the inbox-approvals widget data source. Called
|
||||
// post-construction so that DashboardService and ApprovalService can be
|
||||
// stitched together at boot without a circular constructor dependency.
|
||||
// Safe to leave nil — InboxSummary will then carry pending_count=0 and an
|
||||
// empty entries list, and the widget renders its empty state.
|
||||
func (s *DashboardService) SetApprovalService(a *ApprovalService) {
|
||||
s.approvals = a
|
||||
}
|
||||
|
||||
// DashboardData is the full payload returned to the frontend.
|
||||
type DashboardData struct {
|
||||
User *DashboardUser `json:"user"`
|
||||
@@ -38,8 +48,42 @@ type DashboardData struct {
|
||||
UpcomingDeadlines []UpcomingDeadline `json:"upcoming_deadlines"`
|
||||
UpcomingAppointments []UpcomingAppointment `json:"upcoming_appointments"`
|
||||
RecentActivity []ActivityEntry `json:"recent_activity"`
|
||||
InboxSummary InboxSummary `json:"inbox_summary"`
|
||||
}
|
||||
|
||||
// InboxSummary feeds the inbox-approvals widget on the configurable
|
||||
// dashboard (t-paliad-219). PendingCount is the precise number of
|
||||
// approval requests that await this user's approval; Top is a small
|
||||
// preview list (up to InboxTopCap entries) ordered oldest-pending-first
|
||||
// so the most urgent appears first.
|
||||
//
|
||||
// When the ApprovalService dependency is unwired (knowledge-platform-only
|
||||
// deployments, tests), PendingCount=0 and Top=[] so the widget renders
|
||||
// its empty state. The data path is read-only — no writes go through
|
||||
// the dashboard payload.
|
||||
type InboxSummary struct {
|
||||
PendingCount int `json:"pending_count"`
|
||||
Top []InboxEntry `json:"top"`
|
||||
}
|
||||
|
||||
// InboxEntry is a single row in InboxSummary.Top — the minimum needed
|
||||
// to render a clickable preview ("Frist X auf Akte Y, vorgeschlagen am Z").
|
||||
type InboxEntry struct {
|
||||
RequestID uuid.UUID `json:"id"`
|
||||
EntityType string `json:"entity_type"`
|
||||
EntityTitle *string `json:"entity_title,omitempty"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
ProjectTitle string `json:"project_title"`
|
||||
RequestedAt time.Time `json:"requested_at"`
|
||||
RequesterID uuid.UUID `json:"requester_id"`
|
||||
RequesterName string `json:"requester_name"`
|
||||
}
|
||||
|
||||
// InboxTopCap caps the preview list. The widget's count setting tops out
|
||||
// at 10 (see WidgetCatalog inboxCounts); we fetch the cap once and let
|
||||
// the client trim further per the user's setting.
|
||||
const InboxTopCap = 10
|
||||
|
||||
type DashboardUser struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Email string `json:"email"`
|
||||
@@ -146,7 +190,12 @@ func (s *DashboardService) Get(ctx context.Context, userID uuid.UUID) (*Dashboar
|
||||
|
||||
now := time.Now()
|
||||
today := now.Format("2006-01-02")
|
||||
endOfWindow := now.AddDate(0, 0, 7).Format("2006-01-02")
|
||||
// t-paliad-219 §18 Note B: widen the upcoming windows from 7d → 60d
|
||||
// so the per-widget horizon dropdown (7/14/30/60) can filter client-
|
||||
// side without re-querying. LIMIT bumps from 10 to 40 for the same
|
||||
// reason — the widget's count setting tops out at 20 plus headroom
|
||||
// for the agenda widget which can read from the same payload.
|
||||
endOfWindow := now.AddDate(0, 0, 60).Format("2006-01-02")
|
||||
bounds := computeDeadlineBucketBounds(now.UTC())
|
||||
|
||||
if err := s.loadSummary(ctx, data, user, bounds); err != nil {
|
||||
@@ -161,6 +210,9 @@ func (s *DashboardService) Get(ctx context.Context, userID uuid.UUID) (*Dashboar
|
||||
if err := s.loadRecentActivity(ctx, data, user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.loadInboxSummary(ctx, data, user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
annotateUrgency(data.UpcomingDeadlines, now)
|
||||
return data, nil
|
||||
@@ -261,7 +313,7 @@ SELECT f.id,
|
||||
AND f.due_date <= $3::date
|
||||
AND ` + visibilityPredicatePositional("p", 1) + `
|
||||
ORDER BY f.due_date ASC
|
||||
LIMIT 10`
|
||||
LIMIT 40`
|
||||
if err := s.db.SelectContext(ctx, &data.UpcomingDeadlines, query,
|
||||
user.ID, today, endOfWeek); err != nil {
|
||||
return fmt.Errorf("dashboard upcoming deadlines: %w", err)
|
||||
@@ -269,6 +321,45 @@ SELECT f.id,
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadInboxSummary populates DashboardData.InboxSummary — the open-
|
||||
// approval count + top InboxTopCap entries for the inbox-approvals
|
||||
// widget (t-paliad-219). When ApprovalService is unwired (knowledge-
|
||||
// platform-only deployments, tests), the function is a no-op and the
|
||||
// widget renders its empty state.
|
||||
func (s *DashboardService) loadInboxSummary(ctx context.Context, data *DashboardData, user *models.User) error {
|
||||
data.InboxSummary = InboxSummary{Top: []InboxEntry{}}
|
||||
if s.approvals == nil {
|
||||
return nil
|
||||
}
|
||||
cnt, err := s.approvals.PendingCountForUser(ctx, user.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dashboard inbox count: %w", err)
|
||||
}
|
||||
data.InboxSummary.PendingCount = cnt
|
||||
if cnt == 0 {
|
||||
return nil
|
||||
}
|
||||
rows, err := s.approvals.ListPendingForApprover(ctx, user.ID, InboxFilter{Limit: InboxTopCap})
|
||||
if err != nil {
|
||||
return fmt.Errorf("dashboard inbox top: %w", err)
|
||||
}
|
||||
top := make([]InboxEntry, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
top = append(top, InboxEntry{
|
||||
RequestID: r.ID,
|
||||
EntityType: r.EntityType,
|
||||
EntityTitle: r.EntityTitle,
|
||||
ProjectID: r.ProjectID,
|
||||
ProjectTitle: r.ProjectTitle,
|
||||
RequestedAt: r.RequestedAt,
|
||||
RequesterID: r.RequestedBy,
|
||||
RequesterName: r.RequesterName,
|
||||
})
|
||||
}
|
||||
data.InboxSummary.Top = top
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DashboardService) loadUpcomingAppointments(ctx context.Context, data *DashboardData, user *models.User, now time.Time) error {
|
||||
query := `
|
||||
SELECT t.id,
|
||||
@@ -282,13 +373,13 @@ SELECT t.id,
|
||||
FROM paliad.appointments t
|
||||
LEFT JOIN paliad.projects p ON p.id = t.project_id
|
||||
WHERE t.start_at >= $2
|
||||
AND t.start_at < ($2 + interval '7 days')
|
||||
AND t.start_at < ($2 + interval '60 days')
|
||||
AND (
|
||||
(t.project_id IS NULL AND t.created_by = $1)
|
||||
OR (t.project_id IS NOT NULL AND ` + visibilityPredicatePositional("p", 1) + `)
|
||||
)
|
||||
ORDER BY t.start_at ASC
|
||||
LIMIT 10`
|
||||
LIMIT 40`
|
||||
if err := s.db.SelectContext(ctx, &data.UpcomingAppointments, query,
|
||||
user.ID, now); err != nil {
|
||||
return fmt.Errorf("dashboard upcoming appointments: %w", err)
|
||||
|
||||
51
internal/services/dashboard_service_test.go
Normal file
51
internal/services/dashboard_service_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package services
|
||||
|
||||
// Pure-function tests for DashboardService extensions in Slice A3.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
)
|
||||
|
||||
func TestDashboardService_InboxSummary_NilApprovalsIsNoop(t *testing.T) {
|
||||
s := &DashboardService{} // approvals nil
|
||||
data := &DashboardData{}
|
||||
user := &models.User{ID: uuid.New()}
|
||||
if err := s.loadInboxSummary(context.Background(), data, user); err != nil {
|
||||
t.Fatalf("loadInboxSummary with nil approvals returned %v; want nil", err)
|
||||
}
|
||||
if data.InboxSummary.PendingCount != 0 {
|
||||
t.Errorf("PendingCount=%d; want 0", data.InboxSummary.PendingCount)
|
||||
}
|
||||
if data.InboxSummary.Top == nil {
|
||||
t.Errorf("Top is nil; want empty slice")
|
||||
}
|
||||
if len(data.InboxSummary.Top) != 0 {
|
||||
t.Errorf("Top has %d entries; want 0", len(data.InboxSummary.Top))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardService_SetApprovalService_WiringWorks(t *testing.T) {
|
||||
s := &DashboardService{}
|
||||
if s.approvals != nil {
|
||||
t.Fatalf("freshly-constructed DashboardService has non-nil approvals")
|
||||
}
|
||||
a := &ApprovalService{} // empty shell; we only check the pointer wiring
|
||||
s.SetApprovalService(a)
|
||||
if s.approvals != a {
|
||||
t.Errorf("SetApprovalService did not wire the pointer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboxTopCap_NonZero(t *testing.T) {
|
||||
// Sanity guard: if someone zeros this const, the inbox-approvals
|
||||
// widget falls back to an empty top-N silently. Pin it ≥ the
|
||||
// largest catalog count option for the inbox widget (10).
|
||||
if InboxTopCap < 10 {
|
||||
t.Errorf("InboxTopCap=%d; must be ≥ 10 to satisfy widget catalog max count", InboxTopCap)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user