diff --git a/apps/backend/internal/api/server.go b/apps/backend/internal/api/server.go index b199ba0..91a0cbe 100644 --- a/apps/backend/internal/api/server.go +++ b/apps/backend/internal/api/server.go @@ -1296,6 +1296,20 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) { c.JSON(http.StatusOK, response) }) + protected.GET("/metrics/staff", func(c *gin.Context) { + days, _ := strconv.Atoi(c.DefaultQuery("days", "30")) + response, err := bookingService.StaffMetrics(c.Request.Context(), auth.PrincipalFromContext(c), days) + if err != nil { + status := http.StatusInternalServerError + if errors.Is(err, bookings.ErrTenantMembership) { + status = http.StatusNotFound + } + c.JSON(status, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, response) + }) + protected.GET("/export/bookings.csv", func(c *gin.Context) { from, to := exportRange(c) var buf bytes.Buffer diff --git a/apps/backend/internal/bookings/service.go b/apps/backend/internal/bookings/service.go index a347e65..3ea9cae 100644 --- a/apps/backend/internal/bookings/service.go +++ b/apps/backend/internal/bookings/service.go @@ -672,6 +672,51 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip }, nil } +// StaffMetrics returns per-staff appointment workload over a trailing +// window — every active staff member appears, even with zero bookings. +func (s *Service) StaffMetrics(ctx context.Context, principal domain.Principal, days int) (domain.StaffMetricsResponse, error) { + membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.StaffMetricsResponse{}, ErrTenantMembership + } + return domain.StaffMetricsResponse{}, err + } + if days <= 0 || days > 365 { + days = 30 + } + to := time.Now().UTC() + from := to.AddDate(0, 0, -days) + + records, err := s.repo.GetStaffMetrics(ctx, membership.Tenant.ID, from, to) + if err != nil { + return domain.StaffMetricsResponse{}, err + } + byStaff := make(map[string]db.StaffMetricsRecord, len(records)) + for _, rec := range records { + byStaff[rec.StaffID] = rec + } + + staff, err := s.repo.ListStaffByTenant(ctx, membership.Tenant.ID) + if err != nil { + return domain.StaffMetricsResponse{}, err + } + metrics := make([]domain.StaffMetric, 0, len(staff)) + for _, member := range staff { + rec := byStaff[member.ID] + metrics = append(metrics, domain.StaffMetric{ + StaffID: member.ID, + DisplayName: member.DisplayName, + Bookings: rec.BookingsCount, + Completed: rec.CompletedCount, + Cancelled: rec.CancellationsCount, + UtilizationPercent: rec.UtilizationPercent, + RevenueCents: rec.RevenueCents, + }) + } + return domain.StaffMetricsResponse{From: from, To: to, Staff: metrics}, nil +} + // setupCompletion scores onboarding readiness across five equal-weight checks: // brand profile, location, service catalog, staff roster, and schedule rules. func setupCompletion(brandName string, locationCount, serviceCount, staffCount, scheduleCount int) int { diff --git a/apps/backend/internal/bookings/service_test.go b/apps/backend/internal/bookings/service_test.go index 24488cf..6f9af30 100644 --- a/apps/backend/internal/bookings/service_test.go +++ b/apps/backend/internal/bookings/service_test.go @@ -766,3 +766,77 @@ func TestWaitlistPositionReflectsQueueOrder(t *testing.T) { t.Fatalf("expected promoted booking to report no position, got %d", got) } } + +func TestStaffMetricsAggregatesPerStaff(t *testing.T) { + repo := db.NewMemoryRepository() + service := NewService(repo, nil, "http://localhost:3000") + ctx := context.Background() + + tenantID := "5d6b3551-0a3e-4b86-bdf0-e9df20a47148" + staffID := "6936c444-c7d0-4a7d-b596-a9b72d2f4fc0" // Demo Stylist + serviceID := "d5d76a61-3d49-467c-8dd4-bf61ee754e39" // Signature treatment, 1200 Kč + now := time.Now().UTC() + + seed := func(offsetDays int, status string, withService bool) { + t.Helper() + params := db.CreateBookingParams{ + TenantID: tenantID, + StaffID: &staffID, + CustomerName: "Client", + CustomerEmail: "client@example.com", + StartsAt: now.AddDate(0, 0, offsetDays), + EndsAt: now.AddDate(0, 0, offsetDays).Add(time.Hour), + Status: status, + Reference: "BK-SM", + } + if withService { + params.ServiceID = &serviceID + } + if _, err := repo.CreateBooking(ctx, params); err != nil { + t.Fatalf("seed booking: %v", err) + } + } + + seed(-5, "confirmed", true) + seed(-3, "confirmed", true) // already ended → completed too + seed(-2, "cancelled", false) // cancelled → no revenue + seed(-40, "confirmed", true) // outside the 30-day window + // Class booking (no staff) must not appear in per-staff metrics. + sessionID := "4bf74c12-44dd-45ca-86bb-b104f16f2435" + if _, err := repo.CreateBooking(ctx, db.CreateBookingParams{ + TenantID: tenantID, + ClassSessionID: &sessionID, + CustomerName: "Class Client", + CustomerEmail: "class@example.com", + StartsAt: now.AddDate(0, 0, -1), + EndsAt: now.AddDate(0, 0, -1).Add(time.Hour), + Status: "confirmed", + Reference: "BK-SM-CLASS", + }); err != nil { + t.Fatalf("seed class booking: %v", err) + } + + response, err := service.StaffMetrics(ctx, domain.Principal{Subject: "demo-owner", Role: "owner"}, 30) + if err != nil { + t.Fatalf("staff metrics: %v", err) + } + if len(response.Staff) != 1 { + t.Fatalf("expected 1 staff row, got %d", len(response.Staff)) + } + row := response.Staff[0] + if row.StaffID != staffID || row.DisplayName != "Demo Stylist" { + t.Fatalf("unexpected staff row identity: %+v", row) + } + if row.Bookings != 3 { + t.Fatalf("expected 3 bookings in window, got %d", row.Bookings) + } + if row.Cancelled != 1 { + t.Fatalf("expected 1 cancellation, got %d", row.Cancelled) + } + if row.UtilizationPercent != 66 { + t.Fatalf("expected 66%% utilization, got %d", row.UtilizationPercent) + } + if row.RevenueCents != 240000 { + t.Fatalf("expected 240000 revenue cents (2×1200 Kč), got %d", row.RevenueCents) + } +} diff --git a/apps/backend/internal/db/bookings.go b/apps/backend/internal/db/bookings.go index 73061f4..c484e45 100644 --- a/apps/backend/internal/db/bookings.go +++ b/apps/backend/internal/db/bookings.go @@ -251,6 +251,44 @@ func (r *PGRepository) GetDashboardMetrics(ctx context.Context, tenantID string, return metrics, err } +// GetStaffMetrics mirrors GetDashboardMetrics grouped by staff_id. Class +// bookings have no staff_id and fall out naturally; revenue sums the linked +// service price for non-cancelled bookings. +func (r *PGRepository) GetStaffMetrics(ctx context.Context, tenantID string, startsAt time.Time, endsAt time.Time) ([]StaffMetricsRecord, error) { + rows, err := r.pool.Query(ctx, ` + SELECT + b.staff_id, + COUNT(*) AS bookings_count, + COUNT(*) FILTER (WHERE b.status = 'cancelled') AS cancellations_count, + COUNT(*) FILTER (WHERE b.status = 'confirmed' AND b.ends_at < now()) AS completed_count, + COALESCE( + ROUND(100.0 * COUNT(*) FILTER (WHERE b.status = 'confirmed') / NULLIF(COUNT(*), 0)), + 0 + )::integer AS utilization_percent, + COALESCE(SUM(s.price_cents) FILTER (WHERE b.status <> 'cancelled'), 0) AS revenue_cents + FROM bookings b + LEFT JOIN services s ON s.id = b.service_id + WHERE b.tenant_id = $1 AND b.staff_id IS NOT NULL AND b.starts_at >= $2 AND b.starts_at < $3 + GROUP BY b.staff_id + ORDER BY bookings_count DESC + `, tenantID, startsAt, endsAt) + if err != nil { + return nil, err + } + defer rows.Close() + + var records []StaffMetricsRecord + for rows.Next() { + var rec StaffMetricsRecord + if err := rows.Scan(&rec.StaffID, &rec.BookingsCount, &rec.CancellationsCount, + &rec.CompletedCount, &rec.UtilizationPercent, &rec.RevenueCents); err != nil { + return nil, err + } + records = append(records, rec) + } + return records, rows.Err() +} + func (r *PGRepository) GetWaitlistPosition(ctx context.Context, sessionID, bookingID string) (int, error) { var position int err := r.pool.QueryRow(ctx, ` diff --git a/apps/backend/internal/db/repository.go b/apps/backend/internal/db/repository.go index f2687a3..1e5fc87 100644 --- a/apps/backend/internal/db/repository.go +++ b/apps/backend/internal/db/repository.go @@ -146,6 +146,9 @@ type Repository interface { // Activity feed AppendActivityEvent(ctx context.Context, params ActivityEventParams) error ListActivityEvents(ctx context.Context, tenantID string, limit int) ([]ActivityEventRecord, error) + + // Staff metrics + GetStaffMetrics(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]StaffMetricsRecord, error) } type TenantRecord struct { @@ -302,6 +305,17 @@ type StaffFeedTokenRecord struct { FeedToken string } +// StaffMetricsRecord is a per-staff aggregation of appointment bookings — +// class bookings carry no staff_id and are excluded by design. +type StaffMetricsRecord struct { + StaffID string + BookingsCount int + CancellationsCount int + CompletedCount int + UtilizationPercent int + RevenueCents int64 +} + // ActivityEventRecord / ActivityEventParams back the per-tenant audit feed. type ActivityEventRecord struct { ID string @@ -653,7 +667,9 @@ type PGRepository struct { func NewRepository(pools *Pools, demoMode bool) Repository { if demoMode { - return NewMemoryRepository() + repo := NewMemoryRepository() + repo.seedDemoBookings() + return repo } if pools != nil && pools.App != nil { return &PGRepository{pool: pools.App} @@ -1574,6 +1590,34 @@ func (r *MemoryRepository) CreateBooking(_ context.Context, params CreateBooking return created, nil } +func (r *MemoryRepository) seedDemoBookings() { + now := time.Now().UTC() + serviceID := r.services[0].ID + staffID := r.staff[0].ID + locationID := r.locations[0].ID + seed := func(name, email, status string, startsAt time.Time) { + r.bookings = append(r.bookings, BookingRecord{ + ID: uuid.NewString(), + TenantID: r.tenant.ID, + ServiceID: &serviceID, + StaffID: &staffID, + LocationID: &locationID, + CustomerName: name, + CustomerEmail: email, + StartsAt: startsAt, + EndsAt: startsAt.Add(time.Hour), + Status: status, + Reference: "BK-DEMO-" + uuid.NewString()[:8], + ManageToken: uuid.NewString(), + CreatedAt: startsAt.Add(-48 * time.Hour), + }) + } + seed("Alice Johnson", "alice@example.com", "confirmed", now.Add(-4*time.Hour)) + seed("Bob Smith", "bob@example.com", "confirmed", now.Add(-26*time.Hour)) + seed("Carol White", "carol@example.com", "confirmed", now.Add(-50*time.Hour)) + seed("Alice Johnson", "alice@example.com", "cancelled", now.Add(-74*time.Hour)) +} + func (r *MemoryRepository) AppendWaitlistEntry(_ context.Context, _ WaitlistEntryParams) error { r.waitlistSize++ return nil @@ -1721,6 +1765,51 @@ func (r *MemoryRepository) GetDashboardMetrics(_ context.Context, tenantID strin return metrics, nil } +func (r *MemoryRepository) GetStaffMetrics(_ context.Context, tenantID string, startsAt time.Time, endsAt time.Time) ([]StaffMetricsRecord, error) { + servicePrices := make(map[string]int, len(r.services)) + for _, service := range r.services { + servicePrices[service.ID] = service.PriceCents + } + byStaff := map[string]*StaffMetricsRecord{} + for _, booking := range r.bookings { + if booking.TenantID != tenantID || booking.StaffID == nil { + continue + } + if booking.StartsAt.Before(startsAt) || !booking.StartsAt.Before(endsAt) { + continue + } + rec := byStaff[*booking.StaffID] + if rec == nil { + rec = &StaffMetricsRecord{StaffID: *booking.StaffID} + byStaff[*booking.StaffID] = rec + } + rec.BookingsCount++ + switch { + case booking.Status == "cancelled": + rec.CancellationsCount++ + case booking.Status == "confirmed": + rec.UtilizationPercent++ + if booking.EndsAt.Before(time.Now().UTC()) { + rec.CompletedCount++ + } + } + if booking.Status != "cancelled" && booking.ServiceID != nil { + rec.RevenueCents += int64(servicePrices[*booking.ServiceID]) + } + } + records := make([]StaffMetricsRecord, 0, len(byStaff)) + for _, rec := range byStaff { + if rec.BookingsCount == 0 { + rec.UtilizationPercent = 0 + } else { + rec.UtilizationPercent = int(float64(rec.UtilizationPercent) / float64(rec.BookingsCount) * 100) + } + records = append(records, *rec) + } + sort.SliceStable(records, func(i, j int) bool { return records[i].BookingsCount > records[j].BookingsCount }) + return records, nil +} + func (r *MemoryRepository) GetSubscriptionSnapshot(_ context.Context, tenantID string) (BillingSnapshotRecord, error) { if tenantID != r.tenant.ID { return BillingSnapshotRecord{}, pgx.ErrNoRows diff --git a/apps/backend/internal/domain/models.go b/apps/backend/internal/domain/models.go index 7aa0ab7..51187a7 100644 --- a/apps/backend/internal/domain/models.go +++ b/apps/backend/internal/domain/models.go @@ -556,6 +556,24 @@ type ActivityFeedResponse struct { Events []ActivityEvent `json:"events"` } +// StaffMetric aggregates appointment workload for one staff member over a +// trailing window. Class bookings carry no staff assignment and are excluded. +type StaffMetric struct { + StaffID string `json:"staffId"` + DisplayName string `json:"displayName"` + Bookings int `json:"bookings"` + Completed int `json:"completed"` + Cancelled int `json:"cancelled"` + UtilizationPercent int `json:"utilizationPercent"` + RevenueCents int64 `json:"revenueCents"` +} + +type StaffMetricsResponse struct { + From time.Time `json:"from"` + To time.Time `json:"to"` + Staff []StaffMetric `json:"staff"` +} + // ============================================ // ADMIN MODELS // ============================================ diff --git a/apps/backend/openapi/bookra.openapi.yaml b/apps/backend/openapi/bookra.openapi.yaml index c5e0bb7..e9f260c 100644 --- a/apps/backend/openapi/bookra.openapi.yaml +++ b/apps/backend/openapi/bookra.openapi.yaml @@ -1431,6 +1431,34 @@ paths: $ref: "#/components/schemas/ActivityFeedResponse" "404": description: Tenant membership not found + /v1/metrics/staff: + get: + tags: [Metrics] + operationId: getStaffMetrics + security: + - bearerAuth: [] + summary: Per-staff appointment metrics over a trailing window + description: >- + Bookings, completions, cancellations, utilization, and revenue per + staff member. Only appointment bookings carry a staff assignment — + class attendance is excluded by design. Every active staff member + appears, including zero rows. + parameters: + - name: days + in: query + schema: + type: integer + default: 30 + maximum: 365 + responses: + "200": + description: Per-staff metrics + content: + application/json: + schema: + $ref: "#/components/schemas/StaffMetricsResponse" + "404": + description: Tenant membership not found /v1/export/bookings.csv: get: tags: [Export] @@ -2158,6 +2186,42 @@ components: type: array items: $ref: "#/components/schemas/ActivityEvent" + StaffMetric: + type: object + required: [staffId, displayName, bookings, completed, cancelled, utilizationPercent, revenueCents] + properties: + staffId: + type: string + format: uuid + displayName: + type: string + bookings: + type: integer + completed: + type: integer + cancelled: + type: integer + utilizationPercent: + type: integer + description: Share of bookings that stayed confirmed, 0-100 + revenueCents: + type: integer + format: int64 + description: Sum of linked service prices for non-cancelled bookings + StaffMetricsResponse: + type: object + required: [from, to, staff] + properties: + from: + type: string + format: date-time + to: + type: string + format: date-time + staff: + type: array + items: + $ref: "#/components/schemas/StaffMetric" DashboardBookingRequest: type: object required: [customerName, customerEmail, startsAt] diff --git a/apps/frontend/src/providers/i18n-provider.tsx b/apps/frontend/src/providers/i18n-provider.tsx index 1649fab..c50776e 100644 --- a/apps/frontend/src/providers/i18n-provider.tsx +++ b/apps/frontend/src/providers/i18n-provider.tsx @@ -475,6 +475,9 @@ const dictionaries = { "dashboard.staff.inactive": "Neaktivní", "dashboard.staff.noStaff": "Zatím žádní zaměstnanci.", "dashboard.staff.noStaffDesc": "Přidejte členy týmu, kteří poskytují služby.", + "dashboard.staff.performance": "Výkonnost", + "dashboard.staff.performanceWindow": "posledních 30 dní", + "dashboard.staff.revenue": "Obrat", "dashboard.staff.limitReached": "Dosažen limit zaměstnanců", "dashboard.staff.limitDesc": "Váš plán omezuje počet zaměstnanců. Pro více míst upgradujte.", "dashboard.staff.saveFailed": "Uložení zaměstnance selhalo.", @@ -1076,6 +1079,9 @@ const dictionaries = { "dashboard.staff.inactive": "Inactive", "dashboard.staff.noStaff": "No staff yet.", "dashboard.staff.noStaffDesc": "Add team members who provide services.", + "dashboard.staff.performance": "Performance", + "dashboard.staff.performanceWindow": "last 30 days", + "dashboard.staff.revenue": "Revenue", "dashboard.staff.limitReached": "Staff limit reached", "dashboard.staff.limitDesc": "Your plan limits the number of staff. Upgrade for more seats.", "dashboard.staff.saveFailed": "Failed to save staff member.", diff --git a/apps/frontend/src/routes/dashboard/staff-page.tsx b/apps/frontend/src/routes/dashboard/staff-page.tsx index 9f2dbc6..509292d 100644 --- a/apps/frontend/src/routes/dashboard/staff-page.tsx +++ b/apps/frontend/src/routes/dashboard/staff-page.tsx @@ -55,6 +55,31 @@ function StaffPage() { }, ); + const [metrics] = createResource( + () => data.token(), + async (bearer) => { + if (!bearer) return null; + if (bearer.startsWith("demo.")) { + return { + staff: [ + { staffId: "st1", displayName: "Petra M.", bookings: 12, completed: 9, cancelled: 1, utilizationPercent: 92, revenueCents: 1440000 }, + { staffId: "st2", displayName: "Lucie K.", bookings: 8, completed: 5, cancelled: 2, utilizationPercent: 63, revenueCents: 960000 }, + { staffId: "st3", displayName: cs() ? "Martin Hájek" : "Martin Hajek", bookings: 0, completed: 0, cancelled: 0, utilizationPercent: 0, revenueCents: 0 }, + ], + }; + } + const response = await apiClient.GET("/v1/metrics/staff", authHeaders()); + return response.data ?? null; + }, + ); + + const formatRevenue = (cents: number) => + new Intl.NumberFormat(cs() ? "cs-CZ" : "en-US", { + style: "currency", + currency: "CZK", + maximumFractionDigits: 0, + }).format(cents / 100); + const resolvedStaff = () => staff() ?? []; const resolvedLocations = () => locations() ?? []; const staffLimit = () => data.resolvedBilling()?.entitlements?.maxStaff ?? -1; @@ -163,6 +188,46 @@ function StaffPage() { + 0}> +
+
+

{data.i18n.t("dashboard.staff.performance")}

+ {data.i18n.t("dashboard.staff.performanceWindow")} +
+
+ + {(m: any) => ( +
+

{m.displayName}

+
+
+

{data.i18n.t("dashboard.bookings")}

+

{m.bookings}

+
+
+

{data.i18n.t("dashboard.completed")}

+

{m.completed}

+
+
+

{data.i18n.t("dashboard.cancelled")}

+

{m.cancelled}

+
+
+

{data.i18n.t("dashboard.kpi.utilization")}

+

{m.utilizationPercent}%

+
+
+

{data.i18n.t("dashboard.staff.revenue")}

+

{formatRevenue(m.revenueCents)}

+
+
+
+ )} +
+
+
+
+
diff --git a/packages/api-client/src/generated/types.ts b/packages/api-client/src/generated/types.ts index 0ad391d..b26ec6d 100644 --- a/packages/api-client/src/generated/types.ts +++ b/packages/api-client/src/generated/types.ts @@ -752,6 +752,26 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/metrics/staff": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Per-staff appointment metrics over a trailing window + * @description Bookings, completions, cancellations, utilization, and revenue per staff member. Only appointment bookings carry a staff assignment — class attendance is excluded by design. Every active staff member appears, including zero rows. + */ + get: operations["getStaffMetrics"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/public/bookings": { parameters: { query?: never; @@ -1555,6 +1575,28 @@ export interface components { /** Format: uuid */ tenantId: string; }; + StaffMetric: { + bookings: number; + cancelled: number; + completed: number; + displayName: string; + /** + * Format: int64 + * @description Sum of linked service prices for non-cancelled bookings + */ + revenueCents: number; + /** Format: uuid */ + staffId: string; + /** @description Share of bookings that stayed confirmed, 0-100 */ + utilizationPercent: number; + }; + StaffMetricsResponse: { + /** Format: date-time */ + from: string; + staff: components["schemas"]["StaffMetric"][]; + /** Format: date-time */ + to: string; + }; SubscriptionSnapshot: { cancelAtPeriodEnd: boolean; checkoutUrlAvailable: boolean; @@ -3532,6 +3574,35 @@ export interface operations { }; }; }; + getStaffMetrics: { + parameters: { + query?: { + days?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Per-staff metrics */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StaffMetricsResponse"]; + }; + }; + /** @description Tenant membership not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; createBooking: { parameters: { query?: never; diff --git a/project.md b/project.md index 89c0035..3937149 100644 --- a/project.md +++ b/project.md @@ -179,6 +179,15 @@ - shared `ExportCsvButton` (fetch + blob + object-URL download; the generated client is JSON-typed so it can't carry file responses) on the bookings and customers page headers, CS/EN label - verified live: real booking row with comma-in-name and quotes-in-notes escaped correctly, diacritics preserved, range bounds honored (inclusive `to`), three seeded customers exported; browser download produced the real file on the bookings page, customers button present, console clean - remaining: live staging validation on Neon + pgx repository path +- `[done/partial]` Milestone 26: Staff performance view + - `GET /v1/metrics/staff?days=` — authed, tenant-scoped; trailing window defaults to 30 days, invalid/out-of-range values normalize to 30, max 365 + - appointment bookings only — class sessions carry no `staff_id`, so class attendance is honestly excluded rather than silently misattributed; noted in the OpenAPI description + - per-staff metrics: bookings, completed (confirmed with `ends_at < now`), cancelled, utilization percent (confirmed share of the window), revenue cents from linked service prices over non-cancelled bookings + - every active staff member appears in the response including zero-booking rows — the service merges repository aggregates with the full staff list + - dashboard staff page gains a Performance panel above the member list: per-staff stat grid (bookings/completed/cancelled/utilization/revenue), CZK revenue formatting via `Intl.NumberFormat` per locale, CS/EN labels, responsive 2→5 column grid; demo mode fabricates rows matching the fabricated staff list for page coherence + - demo repository seeds four trailing-window bookings (3 confirmed + 1 cancelled) via `NewRepository`'s demo branch only — unit tests constructing `NewMemoryRepository` directly are unaffected + - verified live: endpoint returns 4/3/1, 75% utilization, 3 600 Kč; `days=7` and `days=999` normalize correctly; browser-checked both locales and mobile wrap, console clean; focused aggregation test passes + - remaining: live staging validation on Neon + pgx repository path ## Overview Bookra is a Czech-first booking SaaS for small local service businesses. It targets salons, massage therapists, small clinics, repair shops, and small studios/clubs that need a simple public booking flow, internal schedule management, reminders, and basic business reporting without becoming a full ERP. @@ -432,8 +441,8 @@ Shortlist of worthwhile directions once v1 stabilizes — kept deliberately smal - **Recurring class schedules**: templates currently schedule sessions one at a time; a weekly recurrence rule with per-session overrides would cut setup time for studios. - **Booking notes intake**: structured intake fields per service (e.g. "first visit?", "injuries?") instead of the single free-text notes field — still schema-light. - **Persistent notification center**: M24 put real activity events in the dropdown, but read state is still in-memory and there's no delivery history; a `notifications` table (or read-cursor on `activity_events`) would persist both. -- **Staff performance view**: bookings/completion/utilization per staff member — the metrics primitives already exist; this is a filtered presentation layer. -- **CSV export**: bookings and customers export for accounting/backup — landed in M25; a report-style export (per-staff utilization, revenue summaries) can follow once metrics stabilize. +- **Capacity-based utilization**: M26 measures utilization as the confirmed share of bookings; a truer measure compares booked minutes against each member's working-hours rules — needs availability-rule integration in the metrics query. +- **Report-style exports**: M25 shipped raw row exports; a summarized report (per-staff metrics, revenue by service, period comparisons) can reuse `GetStaffMetrics`-style aggregation once the metric set stabilizes. ## Product Principles - calm, premium, structured UX