Skip to content

Where this file lives

  • This path (docs/events/events-admin-kpi-dashboard-design.md) is inside the rakletv3 repo so it shows up in Cursor’s file explorer.
  • gstack mirror (optional, for /office-hours and related tooling): %USERPROFILE%\.gstack\projects\rakletv3\events-admin-kpi-dashboard-design-20260401.md
    When you change the approved design, update both or treat the repo file as canonical and re-copy to .gstack if you still use gstack skills there.

Design: Events admin reporting dashboard (org-level KPIs)

Generated by /office-hours on 2026-04-01
Revised: 2026-04-01 (period presets, caching, multi-currency, check-in sources, no CSV, reminders later)
Implement on branch: feature/events-admin-kpis (or current work branch).
Repo: rakletadmin/rakletv3
Status: APPROVED (2026-04-01) — read Codex resolutions below before implementation
Mode: Builder (product feature; intrapreneurship framing)

Problem Statement

Organization admins using Raklet Events see a list of upcoming and past events plus a way to create an event. They do not see program-level health: revenue and attendance over a recent window, without clicking into each event.

Per-event detail already surfaces some of this (registrations, check-in counts, revenue on the event details screen in event-details.html), but the landing experience does not answer "how are we doing overall?"

What Makes This Cool

A single place that answers: Are our events working as a channel? Money and attendance over a chosen time window turn the Events module from a calendar into something you can glance at before drilling down.

Constraints

  • Stack: AngularJS manager app (Raklet.Backend/Content/scripts/core/manager/events/), existing API patterns (EventsService, Raklet.Api app controllers).
  • Data: Event aggregates live on EventDoc (e.g. SoldTicketCount, TotalAmount, per-event Currency in Cosmos). Check-ins include in-product check-in and automated flows (e.g. Zoom-linked); rollups must use the same definitions as event-level reporting.
  • Performance: Summary results must be cached (see below). Aggregation over many events per org must not run uncached on every page load.
  • Reminders: The product does not send event reminders today. Any reminder/invite tiles are post–v1, after there is product to measure.

Premises (locked from product input)

  1. Phasing: Approach A first (KPI strip + summary API on the existing list page). Approach B (dedicated dashboard route, richer charts) later.
  2. Default period: Last 30 days (rolling).
  3. Period options: At minimum last 30 / 60 / 90 days (rolling). Additional presets (e.g. this calendar month, this calendar year) are in scope if implementation stays a small mapping to from/to UTC bounds, not a separate analytics pipeline.
  4. Caching: Server-side cache of summary responses keyed by organisation + period bounds + preset id (and cache schema version). Reasonable TTL (e.g. 5–15 minutes) with optional manual refresh in UI. Invalidate or shorten TTL on high-churn orgs only if needed after load testing.
  5. Multi-currency: Events may use different currencies. The API must not return a single meaningless "total revenue" by summing mixed currencies. Prefer one card per currency (e.g. "Total revenue — USD", "Total revenue — TRY") or a list of { currency, amount } with no fake FX conversion unless the product later adds official rates.
  6. Check-in: Include all check-ins that count in Raklet for the event (manual in admin, and automated where integrated, e.g. Zoom). Spike should confirm how Zoom-attributed check-ins appear in data so totals match event detail.
  7. No CSV export of events from this initiative.
  8. Reminders/notifications as metrics: Out of scope until reminder product exists; can ship as a later dashboard tile.

Approaches Considered

Approach A: Summary API + KPI strip on existing list (v1)

Summary: Cached backend endpoint returns aggregates for organisationId and a resolved date range. Manager UI adds a top section above the event list: period control (30/60/90 + optional calendar presets), KPI cards, refresh.

Effort: M
Risk: Med (aggregation correctness, timezones, multi-currency, cache invalidation strategy)
Pros: Smallest coherent increment; matches "Plan A first."
Cons: Limited space for trends until Approach B.
Reuses: EventDoc fields, manager layout, rakletCurrency pattern from event-details.html.

Approach B: Dedicated manager.events.dashboard state (later)

Summary: New route with room for trend charts, more history. Same APIs, richer UI.

Effort: L
Risk: Med
Pros: IA and growth path after A proves value.
Cons: Extra navigation; ship only after A is stable.

~~Approach C: CSV export~~

Explicitly out of scope for this initiative (product decision: no download export here).

What else can go on the dashboard (v1 vs later)

Strong candidates for v1 (A) if data is cheap and cards stay scannable:

Tile Notes
Total revenue Per currency (multiple rows/cards or stacked sub-lines).
Total registrations Sum of sold/registrations across events in range (definition aligned with existing event stats).
Total checked in Same boundary as event detail; include automated where stored as check-in.
Events in period Count of events with activity or dates intersecting range (define rule in spike).
Check-in rate checkedIn / registrations per currency block or globally only if denominator is meaningful; hide if misleading for mixed setups.

Nice-to-have on the same strip (still A, if small):

Tile Notes
Upcoming in next 7 days Count of future events (helps ops).
Average registrations per event Simple ratio in range, with tooltip on methodology.

Later (B or post–B):

  • Time series chart (revenue or registrations by week/month).
  • Reminder/invite sent counts when that product ships.
  • Breakdown by category or tag if product prioritizes it.

Wireframe (rough)

[ Period: [ Last 30 days ▼ ] ]   presets: 30 | 60 | 90 | … optional: This month | This year
                                 [ Refresh ]  (bypasses cache for this request)

+--------------------------------------------------+
| Revenue (USD)     $12,400                        |
| Revenue (TRY)     ₺…                             |   <- one block per currency, no mixed sum
+--------------------------------------------------+
| Registrations     340    | Checked in    298    |
| Events in range   12     | (optional rate)      |
+--------------------------------------------------+

--- Existing: Upcoming | Past | View more ... ---

Ship Approach A with rolling 30 (default) / 60 / 90 days, cached summary API, multi-currency safe revenue display, and check-in rules aligned with Raklet + Zoom. Approach B after A is validated. No CSV. Reminders tile when the feature exists.

Open Questions (remaining for engineering spike)

  1. Exact Cosmos/query path for date-filtered event sets and sums; confirm Zoom check-in field linkage.
  2. Cache store: in-memory ASP.NET cache vs distributed (if multi-instance), TTL, and whether manual refresh uses a cache-bypass query parameter.
  3. Events in range rule: by event start date, by payment date, or hybrid (product call).

Resolved: default period, presets, caching requirement, multi-currency, check-in sources, no export, reminders later.

Success Criteria

Metric Target
Time to first meaningful dashboard Under ~2s p95 with cache hit; cold cache acceptable with loading state
Correctness Per-currency revenue matches manual rollups from event detail for sample org
UX Period changes do not flash wrong numbers (loading skeleton or stale-until-fresh)

Distribution Plan

Ships through existing Raklet Admin deployment pipeline. No separate distribution.

Dependencies

  • Summary endpoint + caching layer + tests.
  • Spike: multi-currency shape, Zoom check-in inclusion, date rules.
  • No dependency on reminder email pipelines for v1.

API (shipped in Approach A)

  • Route: GET v2/organisations/{organisationId}/events/summary
  • Query:
  • preset — rolling: last30 (default), last60, last90; calendar: thisweek, thismonth (= mtd), thisyear, lastweek, lastmonth, lastyear, ytd. Weeks start Monday (UTC).
  • fromUtc + toUtc (ISO 8601, UTC) for custom ranges (both required together).
  • aggregationeventWindow (events whose schedule intersects the period) or paymentWindow / registrationDate (ticket payments in the period). Default: paymentWindow.
  • refresh=true — bypasses initial cache read and forces recomputation.
  • Response DTO:
  • period: { fromUtc, toUtc, preset }
  • revenueByCurrency: [ { currency, amount } ]no single mixed total.
  • totalRegistrations, totalCheckedIn, eventsCount
  • previousPeriod: { … same fields … } — prior window of equal length for trend arrows.
  • Caching: Redis key {orgId}-EventsKpiSummary-v{schema}-{preset}-{mode} with 15-min TTL; ?refresh=true bypasses initial read and triggers recompute; concurrent refresh requests fall through to cache once the lock holder writes.
  • Auth: Same HasOrganisationAccess guard as org Events list.
  • Time-series aggregation (daily/weekly/monthly breakdown): deferred to Approach B.

Out of scope for v1

  • CSV export of events.
  • Reminder/invite metrics (no product yet).
  • FX conversion between currencies for a "one number" headline.
  • Approach B (separate dashboard route) until A ships.
  • Real-time push updates (refresh button + TTL is enough).

Next Steps (build order)

  1. Spike: Date rules, multi-currency aggregation, check-in + Zoom, cache key design.
  2. API: Summary endpoint + cache + tests (including cache hit/miss behavior).
  3. UI: Period control (30/60/90 + optional calendar presets), KPI row, refresh, loading/error.
  4. QA: Spot-check vs event detail; multi-currency org; timezone edge.
  5. Later: Approach B (trends, dedicated route); reminder tile when shipped.

Calendar hint (Approach A): still roughly 1–2 weeks with caching and multi-currency, slightly more if distributed cache is required for production topology.

The Assignment

Before locking card order, ask one power admin: which number they look at first (revenue vs headcount vs check-in). Default layout should match that.

What I noticed about how you think

  • You narrowed scope with defaults (30 days) and performance (cache) up front. That saves rework.
  • You were explicit about multi-currency and Zoom so the team cannot ship a naive single total.
  • You separated "we do not send reminders yet" from the dashboard work so engineering does not build ghost metrics.

Reviewer Concerns (resolved in this revision)

  • Prior round: API sketch, out-of-scope, branch note. This revision adds caching, presets, multi-currency policy, removes CSV path, defers reminders to product readiness.

Engineering review (/plan-eng-review, 2026-04-01)

Source: design doc + Raklet.Api\Controllers\App\Events\EventsController.cs, Services\CosmosDbServices\CosmosDbEventService.cs, event-details.controller.js.

Step 0 — Scope challenge

Question Finding
Existing code to reuse? Yes: EventsController under app/organisations/{organisationId}/events already uses CosmosDbEventService, ListAsync, date filters, HasOrganisationAccess. Extend here for summary rather than a parallel public API.
Minimum change set? New GET summary + one aggregation path + cache + Angular EventsService method + list template strip. Defer check-in tile until data path is clear (see below).
Complexity check ~6–8 files (API, service, DTO, resource JS, list HTML/JS, tests). At the skill threshold. Mitigation: keep one new public method on CosmosDbEventService (or a small EventSummaryAggregator in Services if ctor DI gets messy), avoid a second Cosmos service class unless needed.
TODOS.md Deferred items (Angular migration, discovery) are not blockers. No new TODO required unless you want to log distributed cache decision.

Critical data path (confidence: 8/10): Event detail check-in count is computed in the client from the Tickets array (event-details.controller.js around line 192), not from a single EventDoc field. Org-level totalCheckedIn cannot be SUM(EventDoc) alone unless you add a denormalized field or aggregate from ticket payments / ticket infos (SQL/Cosmos) the same way the tickets API does. The spike must name this explicitly; otherwise the dashboard will disagree with event detail.

Revenue / registrations: EventDoc.SoldTicketCount, TotalAmount, Currency are plausible for Cosmos-side rollup after you define which events fall into the date range (predicate must match product intent).

Architecture (ASCII)

[AngularJS events-list]
        | GET summary?preset=last30
        v
[EventsController]  -- auth --> HasOrganisationAccess(Manager)
        |
        +--> [Cache key: orgId + fromUtc + toUtc + schemaVersion]
        |
        v
[Aggregation]
   Cosmos: filter EventDocs by org + date rule
        -> group TotalAmount by Currency
        -> sum SoldTicketCount, count events
   SQL/Cosmos tickets: (if needed for check-in)
        -> SUM check-ins in range / per event ids
        |
        v
[EventsSummaryDto] --> JSON

Production failure: Cold cache + org with many events triggers multi-page Cosmos scan or heavy query. Mitigation: cache + TTL + cap max query cost (document count) or stream aggregate; monitor RU.

Security: Same boundaries as GetEvents; no new public endpoints; no organisationId from client without verify (already pattern on controller).

Code quality

  • DRY: Do not duplicate date preset logic in JS and C#; single resolver (server authoritative; client sends preset or from/to).
  • Error handling: Summary failure should not break list; KPI strip shows error, list still loads (separate $http or promise chain in Angular).
  • Debt: CosmosDbEventService is already large; prefer a focused private or partial class for summary if the method exceeds ~80 lines.

Test review (coverage diagram)

CODE PATH COVERAGE
==================
[+] GET summary
    |-- [GAP] preset last30/60/90 -> correct UTC bounds (unit)
    |-- [GAP] invalid preset -> 400 (unit/integration)
    |-- [GAP] org A cannot read org B (integration)
    |-- [GAP] revenueByCurrency grouping (unit)
    |-- [GAP] cache hit / refresh bypass (integration or unit with mock cache)
    |-- [GAP] check-in aggregation matches ticket source (integration, once spike locks)

USER FLOW
=========
[+] Change period -> new numbers [->E2E if Selenium covers manager]
[+] Refresh -> cache bypass [GAP UX]

Performance

  • N+1: Avoid per-event API calls from Angular for KPIs; one summary call only.
  • Cosmos: Paged ListAsync over all events in range may be hot; cache is mandatory; consider max continuation or server-side aggregate if Cosmos supports it in your SDK version.
  • Distributed cache: If multiple IIS workers without shared cache, in-memory TTL stale across instances; use Redis or SQL-backed cache only if ops confirms multi-instance.

Recommendations (opinionated)

  1. Spike first: Lock date rule (event start vs end vs payment) and check-in data source before UI promises.
  2. Ship revenue + registrations + event count in v1 if check-in aggregation slips; hide check-in tile until backend matches event detail.
  3. Cache: Prefer existing CacheService / Redis (see Codex resolutions). In-memory only if host constraints; then document.

GSTACK — Eng review report

Review Trigger Runs Status Findings
Eng Review /plan-eng-review 1 complete Check-in path, cache cold path, file count, tests listed

Test plan artifact: docs/events/events-admin-kpi-dashboard-eng-review-test-plan.md (copy in repo; see also .gstack mirror if present).

Outside voice (Codex): Run 2026-04-01. Resolutions below must be satisfied before or during implementation.


Codex outside voice — issues and resolutions (do these first)

These address the independent Codex review (codex exec, read-only). Implementation order is numbered.

1. Period semantics: lifetime counters vs “in period” money (Critical)

Issue: EventDoc.TotalAmount and SoldTicketCount are lifetime counters on the event. Filtering events whose dates fall in the last 30 days and then summing those fields answers: “lifetime totals for events that overlap the window,” not “cash that moved in the last 30 days.”

Resolution:

Mode What it means Data source
A — Event window (MVP default) Pick events whose start/end (per spike rule) intersect [from, to]. Sum lifetime TotalAmount / SoldTicketCount for those events only. Cosmos EventDoc
B — Payment window (later or spike stretch) Sum revenue and sales where payment time[from, to]. TicketPaymentDoc / payment timestamps, not EventDoc lifetime fields

Product rule: Ship Mode A with explicit UI labels, e.g. “Totals for events in this period” (not “Revenue in the last 30 days” unless you implement Mode B). Spike (half day) confirms labels and date predicate. If stakeholders require true “revenue in period,” schedule Mode B as a follow-up milestone.

Track: schemaVersion in cache key bumps when switching Mode A → B.

2. Check-ins: single source of truth (Critical)

Issue: Check-ins can appear on TicketInfos.IsCheckIn, Zoom flows (ParticipantJoinedWebhookInteractor), EventParticipantDoc, and legacy SQL CheckIns. Summing without rules drifts from event detail.

Resolution:

  • v1 KPI strip: Omit org-wide “total checked in” until the spike defines one aggregation query that matches event-details ticket table logic (same API/data as getEventTickets path). Optional: show placeholder or hide tile.
  • Spike deliverable: Written matrix: which sources count, dedupe when Zoom updates ticket vs creates participant without payment, whether SQL CheckIns is in scope for manager dashboard.
  • Tests: Integration cases listed in test plan: manual check-in, Zoom with payment, Zoom without payment (per Codex).

3. Recurring events: double-count trap (High)

Issue: IsRecurringParent, ParentEventId, occurrences can cause parent + child double-count or meaningless parent totals.

Resolution: Spike defines exact filter: e.g. exclude IsRecurringParent == true from aggregates and count occurrence EventDoc rows only, or only parent—one rule, documented. Unit tests for parent-only, parent+occurrences, occurrence moved across period boundary (see eng review).

4. Caching: reuse CacheService, invalidation graph (High / Medium)

Issue: Summary cache can stay stale when Zoom updates payments/participants without going through event doc purge paths.

Resolution:

  • Use existing CacheService / Redis (Services/CacheService.cs), not a new MemoryCache layer, unless a specific host cannot use Redis (document exception).
  • Key: {organisationId}-EventsKpiSummary-v{schemaVersion}-{presetOrHashOfFromTo} (exact key; no prefix purge in current API).
  • Refresh button: Bypass cache for this request only, recompute, set same key (not global invalidation).
  • Invalidation: On event create/update/delete, purge summary keys for that org (pattern: document which CacheService.Purge calls exist; add purge for new key prefix if missing). For Zoom webhooks that only touch payments/participants: either purge org summary keys from webhook handler (best effort) or accept shorter TTL for check-in-heavy orgs until purge is wired—document the tradeoff in release notes.

5. Angular: race on period / summary vs list (Medium)

Issue: Multiple parallel loads; changing period quickly can show stale KPIs with a fresh list.

Resolution: Use a request counter or cancelable promise for summary: ignore responses where preset/from/to no longer matches current UI selection. Load summary in a separate chain from list so list never blocks on summary failure.

6. Testability: EventsController news up services (Medium)

Resolution: Prefer extracting IEventSummaryService (or static wrapper behind interface) in Services project, implemented by a class that takes CosmosDbEventService + cache. Controller stays thin; unit tests mock the interface. If MVP must ship fast, minimum is internal protected seam or InternalsVisibleTo for tests—document debt.

7. Tests and rollout (Codex)

  • Recurring: Cases for parent, occurrences, boundary, archive (already in eng review diagram).
  • Mutation paths: Purchase, refund/coupon, manual check-in, Zoom paths—assert cache behavior or documented TTL staleness.
  • Rollout: Manual validation matrix: single-currency, multi-currency, recurring, Zoom ticketed, Zoom no payment—before full flag-on.

Where to read everything (final pass)

Doc Path
Main design (canonical in repo) docs/events/events-admin-kpi-dashboard-design.md
Eng review test plan docs/events/events-admin-kpi-dashboard-eng-review-test-plan.md
Events list test plan (older autoplan slice) docs/events/events-admin-events-list-test-plan.md
gstack mirror (optional) %USERPROFILE%\.gstack\projects\rakletv3\ (dated filenames)

Repo TODOS (deferred scope): TODOS.md


Plan design review (/plan-design-review, 2026-04-01)

Designer binary: not available in-repo (DESIGN_NOT_AVAILABLE). Review uses the existing ASCII wireframe and manager patterns from events-list.html (Bootstrap card, .loader, btn groups, translate keys). No PNG mockups generated; implementers should match surrounding manager density and typography.

Step 0 — Design completeness

Dimension Before After (this section) Notes
Overall (for this plan) 6/10 8/10 Strong product constraints; UI interaction spec was thin.
What 10/10 means here Every KPI has correct labels for Mode A vs B, full state table, a11y for period + live region, responsive row wrap, no misleading “revenue in 30 days” copy.

DESIGN.md: None in repo. Reuse: card / card-header / card-body, existing loader pattern, sr-only labels (see category filter), halflings-icon + btn-success for primary actions. Do not introduce a decorative card grid or marketing-style hero; this is App UI (data-dense, utility copy).

Pass 1 — Information architecture (6 → 8)

Gap: Scan order was implicit. Fix — default hierarchy (v1):

  1. Period control + Refresh (orientation: “you are viewing …”)
  2. Revenue block (per currency, first: orgs often care about money first; if research says otherwise, swap with registrations only)
  3. Registrations / checked in / events in range (secondary row)
  4. Existing list (Upcoming / Past) unchanged below

Constraint worship: If horizontal space is tight, show at most revenue + one secondary row before wrapping; optional tiles (upcoming in 7 days, avg) are tertiary.

Pass 2 — Interaction state coverage (5 → 9)

Feature Loading Empty Error Success Partial
KPI summary (first load) Skeleton or same .loader height as strip; no layout jump N/A (org always has org id) Inline alert in strip only; list still loads Numbers + period label Stale-safe: show last good numbers with subtle “Updating…” or dim until new data matches (request counter; Codex §5)
Period change Same as above Same New numbers
Refresh Button disabled + spinner on button Toast or inline error; numbers unchanged Replaced + optional “Updated …”
Check-in tile (if shipped) Hide tile if API omits metric Show value Show “—” with tooltip if spike incomplete

Empty: “No events in period” is not a KPI empty state if KPIs are org-wide; only show if product defines zero events as zero across all tiles. Prefer numeric zero with clear labels.

Pass 3 — User journey & emotional arc (4 → 7)

Step User does Feels Plan support
1 Opens Events list Wants confidence fast KPI strip in first viewport; no extra scroll on desktop
2 Changes 30→90 days Curious if numbers “make sense” Labels clarify Mode A (“totals for events in this period”) per Codex §1
3 Sees spinner Slight impatience Short loading; no blank flash of wrong period
4 Compares to event detail Trust or doubt Must match event detail definitions; else hide check-in

Pass 4 — AI slop risk (7 → 8)

Litmus: No purple gradients, no three-column “feature” grid, no icon-in-circle decoration for KPIs. Use flat rows, label + number, optional subtle border-top between currency blocks. Copy: utility only (“Totals for events in this period”, “Registrations (lifetime for events in range)”).

Pass 5 — Design system alignment (4 → 7)

Token / pattern Source
Layout Bootstrap grid + card like existing list
Primary action btn-success for Add Event stays dominant; Refresh = btn-default
Numbers Align with event-details / rakletCurrency usage
i18n All new strings via translate

Gap: No repo-wide DESIGN.md; optional follow-up: /design-consultation only if Events module diverges from the rest of manager.

Pass 6 — Responsive & accessibility (3 → 8)

Topic Spec
Mobile KPI strip stacks: period full width; revenue block full width; secondary metrics 2-column or stacked; avoid horizontal scroll
Touch Period control and Refresh ≥ 44px tap height (padding on btn / select)
Keyboard Tab order: period → presets/links → Refresh → first list focusable
Screen readers aria-live="polite" on KPI region when numbers update; period as aria-label including date range text; no duplicate vocalization of loading spinners
Contrast Reuse existing badge/text colors; no new low-contrast gray-on-gray for KPI values

Pass 7 — Unresolved design decisions

Decision If deferred
First KPI priority (revenue vs registrations) Ship default revenue first; one power-admin check per “Assignment”
Skeleton vs loader Engineer picks one consistent with manager; prefer skeleton if strip height is stable
Check-in tile visible Hide until spike proves parity with event detail

NOT in scope (design)

  • Pixel-perfect Figma or PNG handoff (no binary mockups this run).
  • Approach B layout (full dashboard route).
  • Marketing-style onboarding or empty-state illustrations for KPIs.

What already exists (reuse)

  • events-list.html structure, loader, category filter sr-only pattern.
  • event-details.html for currency and stat presentation patterns.

TODOS.md (design-derived follow-ups)

Optional additions (not auto-added; product decides):

  • [ ] Manager-wide DESIGN.md — only if multiple modules need shared KPI patterns.
  • [ ] Post-implementation /design-review on staging for visual QA.

GSTACK REVIEW REPORT

Review Trigger Why Runs Status Findings
CEO Review /plan-ceo-review Scope & strategy 0
Codex Review codex exec / outside voice Independent 2nd opinion 1 complete Codex §1–7 in design doc
Eng Review /plan-eng-review Architecture & tests 1 complete See Engineering review section
Design Review /plan-design-review UI/UX gaps 1 complete IA, state table, a11y, App UI rules; no PNG mockups

VERDICT: Design review complete for Approach A (KPI strip). Run /design-review after implementation for live visual QA. Re-run /plan-eng-review if spike changes API shape or drops check-in tile.

Design review completion summary

Metric Value
Initial design score 6/10
Overall after doc fixes 8/10
Unresolved (need product input) 1 (first KPI priority: revenue vs headcount)
Decisions documented in plan IA order, state table, a11y baseline, slop avoidance
Approved mockups 0 (designer N/A)