Skip to main content

EP-012A — KPI Dashboard & Revenue Intelligence RTM

EPIC: EP-012A KPI Dashboard & Revenue Intelligence Reconciled: 2026-05-25 Wave 1 of 21 parallel per-EPIC RTMs — follows the EP-002-rtm.md template and the ./PLAYBOOK.md pattern. Source of truth for "% passing": hmp-docs/docs/platform-registry/2026-05-16-13-flow-demo-readiness.md until the next 13-flow re-measure.

Status legend​

StatusMeaning
BuiltCode exists + tests exist + tests pass
Built-UntestedCode exists, no automated test coverage (or test exists but is a stub)
PartialCode exists for some acceptance criteria, gaps for others
StubCode is a placeholder / TODO / returns mock data
Not StartedNo code

Summary​

MetricCount
Total stories (EPIC body)8
Catalog claims "Built"3 (US-012-01, US-012A-01, US-012A-02)
Verified-Built3 (US-012-01, US-012A-01, US-012A-02) — with caveats per drift findings
Built-Untested0
Partial3 (US-012-17, US-012-18, US-012A-04)
Stub0
Not Started2 (US-012A-03 KPI Catalog, US-012A-05 GOPPAR)
Drift findings11 (D-001..D-011; 4 P0 / 5 P1 / 2 P2)
[SME-PROPOSED] refinements16

EPIC vs catalog numbering drift — the EPIC body (epics/EP-012A-kpi-dashboard.md §6) lists 8 stories using the IDs US-012-01, US-012-17, US-012-18, and US-012A-01..05. The top-level catalog (01-epic-catalog.md) has a different 20-row table under EP-012 using IDs US-012-001..020 that pre-dates the EP-012 → EP-012A/B/C/D/E split. The reviews folder reviews/EP-012/US-012-001-review.md..US-012-008-review.md follows the catalog numbering. This RTM follows the EPIC body (the source-of-truth for the sub-EPIC scope) but flags the cross-doc numbering as D-001 (P0) below.


US-012-01 — Real-Time KPI Dashboard​

Catalog status: Built (P0, 8 points) Verified status: Built — with operationally significant caveats per the existing US-012-001-review.md (occupancy denominator does not exclude OOO/OOS rooms; day-over-day delta is absolute not percentage; no Kafka push-refresh consumer; ADR includes complimentary rooms; "Total Revenue" tile absent)

Code locations:

  • hmp-analytics-service/src/dashboard/dashboard.controller.ts:98-236 — GET /analytics/dashboard/metrics, /today, /activity, /reservation-stats, /room-stats, /housekeeping-stats, /crm, /notification-counts, /occupancy, /revenue, /activity-feed, plus historical-snapshots (/analytics/historical-snapshots for YoY ARI overlay)
  • hmp-analytics-service/src/dashboard/dashboard.service.ts — orchestrator; reads ADR/RevPAR/todayRevenue from MvDailyKpi (AP-T10-002, single source of truth), live occupancy/in-house via service clients
  • hmp-analytics-service/src/dashboard/service-clients/ — PropertyClient, ReservationClient, HousekeepingClient, FrontdeskClient, CrmClient
  • hmp-analytics-service/src/consumers/reservation.consumer.ts — recomputes MvDailyOccupancy.totalRooms via PropertyServiceClient.getActiveRoomCount (AP-T10-009) + ADR/RevPAR on every reservation upsert (AP-T10-001)
  • hmp-analytics-service/src/consumers/night-audit.consumer.ts — authoritative SET refresh of MvDailyOccupancy + MvDailyKpi on night.audit.completed (AP-T09-002)
  • hmp-analytics-service/src/backfill/mv-backfill.service.ts — nightly 03:00 UTC ADR/RevPAR backfill (AP-T10-012)
  • hmp-web-dashboard/src/components/modules/dashboard/metrics-cards.tsx — 4 KPI cards (Occupancy, ADR, RevPAR, In-House) — uses shadcn Card, not the dark-zone KpiCard
  • hmp-web-dashboard/src/components/modules/dashboard/occupancy-chart.tsx, revenue-chart.tsx, today-overview.tsx, recent-activity.tsx, dashboard-donuts.tsx, notification-counters.tsx
  • hmp-web-dashboard/src/app/(dashboard)/keynova.tsx — KeyNovaDashboard home (still wired when feature flag NEXT_PUBLIC_FLAG_OPERATIONS_HUB_HOME=false); new default is the operations-hub composition
  • hmp-web-dashboard/src/components/modules/operations-hub/tiles/kpi/*.tile.tsx — 17 operations-hub KPI tiles (occupancy, adr, revpar, mpor, walkin-availability, no-shows-today, inhouse-count, arrivals-today, departures-today, queue-pressure, kot-time, covers-today, cash-position, inspection-pass, turnaround, sentiment-24h, parity-violations, pace-7d, ota-contribution, critical-issues, night-audit-eligibility)

Unit + integration tests:

  • hmp-analytics-service/src/dashboard/dashboard.service.metrics-mv.spec.ts — AP-T10-002 single-source-of-truth contract (MvDailyKpi over live folio aggregation)
  • hmp-analytics-service/src/dashboard/dashboard.service.labor.spec.ts — labor-pulse / control-center stub coverage
  • hmp-analytics-service/src/consumers/reservation.consumer.daily-kpi.spec.ts — ADR/RevPAR consumer math
  • hmp-analytics-service/src/consumers/reservation.consumer.occupancy-totalrooms.spec.ts — AP-T10-009 totalRooms-from-property-service guard
  • hmp-analytics-service/src/consumers/night-audit.consumer.spec.ts — AP-T09-002 SET semantics + NightAuditLog upsert
  • hmp-analytics-service/src/backfill/mv-backfill.service.spec.ts — AP-T10-012 cron + SQL idempotency
  • hmp-web-dashboard/src/components/modules/dashboard/__tests__/metrics-cards.test.tsx — 6 specs (render, skeleton, value formatting, change indicator, query key, zero-state)

Flow tests:

  • hmp-infrastructure/testing/flow-tests/src/flows/flow-10-reports.spec.ts :: dashboard-KPI surface — TBD (not enumerated in 13-flow doc; 2026-05-16-13-flow-demo-readiness.md)
  • hmp-infrastructure/testing/flow-tests/src/flows/anomaly-detection.flow.spec.ts — TBD (Plan 3.1 anomaly surface that integrates into KPI screen)
  • hmp-infrastructure/testing/flow-tests/src/flows/flow-9-night-audit.spec.ts :: "night audit refresh of MvDailyKpi" — TBD (2026-05-16-13-flow-demo-readiness.md)

Real-backend smokes:

  • none specific to /analytics/dashboard/metrics (covered via the home-page smoke under (dashboard)/__tests__/dashboard-home.test.tsx)

E2E (Playwright):

  • hmp-web-dashboard/e2e/visual-regression/screenshots/dashboard-home.png — visual regression baseline

Use cases referencing this story:

  • none net new — implicitly referenced by every UC that emits a Kafka event into a Mv* consumer

SME notes:

  • The story body says "data was last refreshed within 30 seconds" (AC Scenario: KPIs update in real-time on new check-in). Implementation reality (per US-012-001-review.md GAP-3/GAP-4): there is no dedicated Kafka consumer that invalidates the Redis cache on reservation.checked_in/out, AND the frontend useQuery has no refetchInterval: 30_000. The 30-second SLA is therefore satisfied only because the MV pipeline updates MvDailyKpi within sub-second of the event and the cache TTL is 30s — i.e. the SLA is met by eventual coincidence, not by design contract. A burst of events with cache-warm slot would silently breach.
  • "Indian lakh notation" (AC ₹48,75,000) — metrics-cards.tsx calls formatCurrency from lib/utils/format. Per US-012-001-review.md GAP-5, the format helper has been seen rendering $ prefix in test snapshots — confirm against current source. This is a P0 India-formatting compliance gap if formatCurrency does not call Intl.NumberFormat('en-IN') or the bespoke lakh/crore grouping.
  • 17 operations-hub tiles ship one component per metric and each fetches its own data; story body does not enumerate them — the dashboard surface has drifted well beyond the 4-KPI definition in the EPIC body. This is the typical "code-vs-story" expansion that the RTM playbook flags as P2 orphaned-code unless the operational risk is high.
  • The KpiCard shared primitive (designed for the dark zone with gold accents per platform CLAUDE.md and hmp-web-dashboard/CLAUDE.md) is NOT used by metrics-cards.tsx — that component still renders plain shadcn Card. Operations-hub tiles also do not all use KpiCard. This is a design-system drift, not a functional bug.

Drift findings:

D-001 — [P0] [Catalog-vs-EPIC-body] EPIC body uses IDs US-012-01 / US-012-17 / US-012-18 / US-012A-01..05. Catalog 01-epic-catalog.md lines 351-371 lists a 20-row EP-012 table with IDs US-012-001..020 that predates the EP-012 → EP-012A/B/C/D/E split. The reviews folder reviews/EP-012/US-012-NNN-review.md follows the catalog. Three referent docs disagree on which ID maps to which story for EP-012A. This is a P0 pilot-blocking ambiguity identical to D-007 of EP-002.

  • [SME-PROPOSED] Adopt the EPIC body's US-012A-NN numbering as canonical for EP-012A. Rewrite the catalog's US-012-001 etc. rows to either (a) tag them as superseded by EP-012A/B/C/D/E sub-EPIC IDs, or (b) keep them only under the unsplit EP-012 parent EPIC entry and add a "see also" pointer to the sub-EPIC tables. Add an alias line > Previously catalogued as US-012-001 prior to EP-012 sub-EPIC split in each EP-012A story body so historic references stay traceable.

D-002 — [P0] [Tests-fail-or-stub] AC Scenario: KPIs update in real-time on new check-in requires the dashboard to update within 30 seconds of a reservation.checkedIn Kafka event. Per US-012-001-review.md GAP-3/GAP-4: there is no dedicated cache-refresh consumer AND the frontend useQuery is missing refetchInterval. The 30s SLA holds only because the MV pipeline + 30s TTL line up coincidentally; no test asserts the end-to-end SLA. A test that pushes a reservation.checkedIn Kafka event and asserts the metrics endpoint reflects the change within 30 seconds would today most likely fail (no consumer warms the cache, no front-end refetch fires).

  • [SME-PROPOSED] Add AC: "Within 30 seconds of reservation.checkedIn / reservation.checkedOut / folio.charge.posted Kafka events, /api/analytics/dashboard/metrics SHALL reflect the change. Verified by an integration test that pushes the event and polls the endpoint with a 30s budget."
  • [SME-PROPOSED] Add AC: "The dashboard MetricsCards useQuery SHALL set refetchInterval: 30000 so a tab open at 14:00 reflects 14:00:30 data without manual refresh."
  • [SME-PROPOSED] Add AC: "A KpiCacheRefreshConsumer SHALL subscribe to reservation.checked_in/out and folio.charge.posted and invalidate the dashboard:metrics:{tenantId}:{propertyId}:{date} Redis key on receipt. Replaces the implicit reliance on the 30s TTL."

D-003 — [P0] [Story-incomplete + India-compliance-critical] AC explicitly requires "revenue is formatted in Indian lakh notation (e.g., ₹48,75,000)". metrics-cards.tsx calls formatCurrency from lib/utils/format; US-012-001-review.md GAP-5 documents that the helper renders $ prefix in test mocks. If the production helper does not use Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR' }) or the bespoke lakh-grouping pipeline, every KPI tile is non-compliant with the AC and with the platform's India-default locale (IN_DEFAULT_LOCALE per analytics-service CLAUDE.md LocaleResolverService).

  • [SME-PROPOSED] Add AC: "Currency values in dashboard KPI tiles SHALL render via Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR', maximumFractionDigits: 0 }) for whole-rupee display, falling back to a tested lakh/crore grouper for figures ≥ ₹1,00,000. Output asserted by unit test (₹48,75,000, ₹1.25 Cr, ₹3,250)."
  • [SME-PROPOSED] Add AC: "The currency-format helper SHALL accept a tenant locale config (per LocaleResolverService of analytics-service); the dashboard formatCurrency SHALL receive the resolved locale via React Query, not hardcode 'en-IN'. Default fallback IN per IN_DEFAULT_LOCALE."

D-004 — [P0] [STR-benchmarking-convention] Per industry STR (Smith Travel Research) convention — the global hotel benchmarking standard the EPIC explicitly references ("compset analysis" in §1) — occupancy SHALL be measured as roomsOccupied / (totalRooms − OOO − OOS), ADR SHALL exclude complimentary / house-use rooms from the denominator, and RevPAR SHALL be roomRevenue / totalSaleableRoomNights. The implementation (per US-012-001-review.md GAP-1 + GAP-6): occupancy denominator uses room.count({ isActive: true }) (does NOT exclude OOO/OOS); ADR denominator uses all CHECKED_IN (does NOT exclude complimentary / zero-rate). Both numbers diverge from STR; a tenant comparing KeyNova RevPAR to a Hotelogix or IDS Next baseline will see an apples-to-oranges mismatch that erodes trust in the platform.

  • [SME-PROPOSED] Add AC: "Occupancy denominator SHALL exclude rooms whose status IN ('OUT_OF_ORDER', 'OUT_OF_SERVICE') for the period. Verified by unit test (3 active rooms, 1 OOO → denominator=2, not 3)."
  • [SME-PROPOSED] Add AC: "ADR denominator SHALL exclude complimentary rooms (rooms where rateAmount = 0 or pricingMode = COMPLIMENTARY or reservationType = HOUSE_USE). Documented STR convention. Verified by unit test (5 paid + 2 comp → ADR = roomRevenue / 5)."
  • [SME-PROPOSED] Add AC: "Day-over-day delta SHALL be expressed as a percentage change ((current − previous) / previous × 100) with explicit divide-by-zero guard (returns null when previous is 0, frontend renders 'n/a'). The current absolute-difference output is renamed to *ChangeAbsolute in the API response; the percentage is *ChangePct."

US-012-17 — Demand Forecasting (7/30/90 day)​

Catalog status: Partial (P2, 13 points) Verified status: Partial — Prophet sidecar shipped + MvForecast MV + /api/internal/analytics/forecast/upsert write surface + ForecastEnricher plugged into the report comparator (MvRevenueManagementKpi → MvForecast swap in Plan 3.2). Dashboard ForecastStrip reads /api/analytics/forecast (LY pacing, 28-day window). Indian holiday calendar (172 entries, 2024-2028) shipped in sidecar. What is NOT shipped: 90-day window (analytics ForecastService caps at 90 days but is wired only for 28-day strip), MAPE accuracy surface, "forecast vs actual" backtesting on the dashboard, festival-multiplier rate-suggest pipeline. Models train weekly (scheduler.py Sun 02:00 UTC) but real-data training only fires once a property has ≥180 days of MvDailyKpi history.

Code locations:

  • hmp-analytics-service/src/forecast/forecast.controller.ts:43-73 — GET /api/analytics/forecast?from=&to= (returns 28-day window with LY pacing)
  • hmp-analytics-service/src/forecast/forecast.service.ts — reads MvDailyOccupancy + MvDailyKpi, applies 364-day LY shift for day-of-week alignment, zero-fills missing dates
  • hmp-analytics-service/src/forecast/forecast.service.spec.ts — unit coverage
  • hmp-analytics-service/src/reporting/comparator/forecast.enricher.ts [CITED-BUT-NOT-FOUND] — Plan 3.2 enricher reading MvForecast (path inferred from CLAUDE.md; verify at next pass)
  • hmp-analytics-service/src/reporting/internal/forecast-upsert.controller.ts [CITED-BUT-NOT-FOUND] — POST /api/internal/analytics/forecast/upsert (INTERNAL_SERVICE auth, ≤10k rows/call); GET /api/internal/analytics/forecast/mv-daily-kpi history fetch for sidecar training
  • hmp-forecast-sidecar/app/forecast/train.py — train_forecast_model(), save_model(), load_latest_model(), infer()
  • hmp-forecast-sidecar/app/forecast/routes.py — POST /forecast/train, GET /forecast
  • hmp-forecast-sidecar/app/forecast/schemas.py — TrainRequest/Response, ForecastResponse
  • hmp-forecast-sidecar/app/scheduler.py — APScheduler weekly retrain (Sun 02:00 UTC) + daily inference (03:00 UTC)
  • hmp-forecast-sidecar/app/routes/forecast.py — legacy POST /forecast/demand, GET /forecast/export
  • hmp-web-dashboard/src/components/modules/front-desk/tape-chart/forecast-strip.tsx — 28-day pacing strip with KPI sidebar (Occ 7d + RevPAR + LY deltas); see hmp-web-dashboard/CLAUDE.md "TC-V2" section
  • hmp-web-dashboard/src/components/modules/revenue-manager/recommendation-summary-strip.tsx — RM advisor approval queue (Phase 1)

Unit + integration tests:

  • hmp-analytics-service/src/forecast/forecast.service.spec.ts — exists per file listing
  • hmp-forecast-sidecar/tests/test_forecast_train.py — train_forecast_model unit
  • hmp-forecast-sidecar/tests/test_scheduler.py — scheduler tick coverage
  • hmp-forecast-sidecar/tests/test_optimization_routes.py — optimization wiring
  • hmp-forecast-sidecar/tests/test_optimization_publish.py — recommendation-producer fan-out

Flow tests:

  • hmp-infrastructure/testing/flow-tests/src/flows/anomaly-detection.flow.spec.ts :: "anomaly detection cron + MvAnomaly upsert" — TBD (related Plan 3.1 surface; not in 13-flow doc)
  • hmp-infrastructure/testing/flow-tests/src/flows/ari-grid.flow.spec.ts :: ForecastStrip integration — TBD

Real-backend smokes: none

Use cases referencing this story: none

SME notes:

  • AC Scenario: View 30-day occupancy forecast requires MAPE (last 30 days): 12.3% displayed on the chart. No code path computes or displays MAPE on the dashboard today. The sidecar exposes prediction confidence bands; the dashboard does not surface them in the forecast strip.
  • AC Scenario: Forecast with Indian festival surge requires (a) Diwali highlighting AND (b) "the system suggests increasing BAR rates by the configured festival multiplier". No "festival multiplier" model exists today — the seasonality story (US-002-007 in EP-002 — D-012) flagged the same gap for India-specific calendar defaults. The Prophet model in the sidecar consumes the 172-entry Indian holiday calendar as a regressor, but the BAR-suggestion ladder is a separate hmp-revenue-strategy decision (port 3017) that today emits RATE_ADJUSTMENT recommendations into the RM advisor approval queue — NOT into the dashboard forecast tile.
  • The 90-day window is honoured by ForecastService.getForecast (caps at 90; rejects beyond). The dashboard only consumes 28 days today (forecast-strip.tsx). The 7/30/90 selector specified in the AC does not exist in the dashboard.
  • Per analytics-service CLAUDE.md MvForecast: "Plan 3.2 — written by hmp-forecast-sidecar via POST /api/internal/analytics/forecast/upsert". The wiring path is real and idempotent (composite unique key on (tenantId, propertyId, date, metric)). But the read surface for the dashboard ForecastStrip is still MvDailyOccupancy + MvDailyKpi (live data + LY shift), NOT MvForecast (Prophet-driven). The two surfaces will diverge — the dashboard forecast strip shows LY-based pacing, the report forecast-pace-report uses Prophet predictions via ForecastEnricher. Tenants will be confused that the home-page "forecast" and the report "forecast" disagree.

Drift findings:

D-005 — [P1] [Story-incomplete + tests-stub] AC requires MAPE display and confidence bands; neither is wired on the dashboard. AC requires a 7/30/90-day window selector; only 28 days is consumed today. No test asserts MAPE rendering or the 90-day path.

  • [SME-PROPOSED] Add AC: "Dashboard forecast chart SHALL include a window selector (7 / 30 / 90 days) backed by /api/analytics/forecast?from=&to= (already supports up to 90 days). Default 30 days; user preference persisted in useAppStore."
  • [SME-PROPOSED] Add AC: "When ≥30 days of MvDailyKpi history exists, the forecast tile SHALL render the rolling 30-day MAPE (mean(|actual − predicted| / actual)) via a new GET /api/analytics/forecast/accuracy?propertyId=&days=30 endpoint backed by MvForecast + MvDailyKpi. Below 30 days of history the tile renders Warming up."
  • [SME-PROPOSED] Add AC: "When MvForecast rows exist for the requested window, the dashboard forecast strip SHALL render confidence bands (confidenceLow..confidenceHigh) on the predicted-occupancy line. Falls back to LY-only pacing for properties without Prophet predictions."

D-006 — [P1] [Cross-service-boundary + dashboard-divergence] The dashboard ForecastStrip reads LY pacing (MvDailyOccupancy + 364-day shift), while the v2 report forecast-pace-report reads Prophet predictions via ForecastEnricher (MvForecast). Two surfaces produce two different numbers for "next week's forecast"; the story body does not delineate which is canonical.

  • [SME-PROPOSED] Add AC: "When MvForecast rows exist for the requested window, the dashboard forecast strip SHALL prefer the Prophet prediction; LY pacing SHALL be rendered as a secondary overlay line labelled 'LY actual', not as the primary forecast value. Below 30 days of history, the strip falls back to LY-only with a tooltip 'Prophet warming up'."

D-007 — [P1] [India-hospitality-critical + cross-EPIC-handoff] AC Forecast with Indian festival surge requires the system to "suggest increasing BAR rates by the configured festival multiplier". The festival-multiplier ladder lives in hmp-revenue-strategy (port 3017) and emits RATE_ADJUSTMENT decisions into the RM advisor approval queue (Phase 1, see hmp-revenue-strategy/src/decisions/), NOT into the EP-012A dashboard. The story does not narrate this cross-EPIC handoff; a tenant reading the EPIC body will expect inline rate-suggest, not an approval-queue redirect.

  • [SME-PROPOSED] Add AC: "When the forecast window includes a row marked as holiday=true (sidecar Indian-holiday regressor), the dashboard SHALL render a chip 'Festival surge expected — review rate recommendations' that links to /revenue-manager/actions?urgency=HIGH (the RM advisor approval queue). The dashboard does NOT mutate rates; rate decisions remain owned by hmp-revenue-strategy."

US-012-18 — Competitive Set Analysis​

Catalog status: Partial (P2, 8 points) Verified status: Partial — per the EPIC body §4.1: GET /compset/analysis is "Partial — Benchmarking UI built; compset data requires manual entry". A grep across hmp-analytics-service/src returned no compset / competitor matches today. The competitor_rates Prisma model referenced in the EPIC's §10 Technical Notes appears to be aspirational (no schema match in current analytics-service prisma/schema.prisma inspection during this RTM pass).

Code locations:

  • hmp-analytics-service/src/.../compset.controller.ts [CITED-BUT-NOT-FOUND] — EPIC body §10 cites the path; not found in the current analytics-service tree
  • hmp-analytics-service/prisma/schema.prisma :: CompetitorRates model [CITED-BUT-NOT-FOUND] — EPIC §10 references; not visible in current CLAUDE.md MV table
  • hmp-revenue-strategy/src/portfolio/ [CITED-BUT-NOT-FOUND] — there is a portfolio folder in revenue-strategy that may carry pre-work; relationship to compset is undocumented
  • hmp-web-dashboard/src/app/(dashboard)/revenue-manager/page.tsx — Revenue Manager landing (no compset panel today per code walk; CLAUDE.md "Revenue Manager — real-API wiring (AP-T11-009, 2026-04-30)" doc enumerates KPI/forecast/channel-mix/RM-actions, NOT compset)
  • FHRAI benchmark cron at hmp-analytics-service/src/cron/ (FhraiBenchmarkCronService, Plan 9 v1 stub) — closest existing analogue (India-public benchmark feed, not compset)

Unit + integration tests: none found

Flow tests: none

Real-backend smokes: none

Use cases referencing this story: none

SME notes:

  • Compset analysis is the canonical Revenue-Management surface that benchmarks the property against a configurable set of competitors (typically 3-5 nearby hotels of similar tier). Industry-standard sources: STR Inc. (paid; the global hospitality benchmark) and OTA Insight / RateGain (rate-parity scrapers). Indian-market reality: very few independent properties subscribe to STR; the realistic ingestion path for Indian properties is OTA-rate scraping (Booking.com / MakeMyTrip / Goibibo) which has rate-parity legal implications (most OTA T&Cs prohibit competitor-rate scraping by other listed hotels).
  • The FHRAI (Federation of Hotel & Restaurant Associations of India) benchmark cron (FhraiBenchmarkCronService in hmp-analytics-service/src/cron/) is the India-specific public alternative — segment-aggregate benchmarks per region (METRO / TIER_1 / TIER_2 / LEISURE / PILGRIMAGE) and tier (5★ / 4★ / BUDGET / BOUTIQUE). Today it is a Plan 9 v1 stub (logs the tick, no real ingest). This is the realistic primary surface for Indian properties.
  • The EPIC body says "weekly trend comparison" (AC-EP-012A-04). No trend MV exists today.

Drift findings:

D-008 — [P1] [Code-without-story OR story-without-code] EPIC §4.1 says GET /compset/analysis is "Built (Partial)"; grep finds no matching controller or service in analytics-service today. Either the code was never landed and the EPIC status row is stale, or the path lives outside analytics-service (e.g. in hmp-revenue-strategy/src/portfolio/) and is uncatalogued. Tenants reading the EPIC will expect a working endpoint.

  • [SME-PROPOSED] Re-verify the implementation status in code (Task 6 verification): run grep -r "compset\|competitor" hmp-analytics-service hmp-revenue-strategy hmp-web-dashboard/src and either (a) update EPIC body status from Partial to Not Started and route the work through FhraiBenchmarkCronService (Plan 9.5) as the canonical compset surface, or (b) document the actual path and tests.
  • [SME-PROPOSED] Add AC: "The compset surface SHALL ingest from at least one of: (i) FHRAI benchmark (canonical for Indian properties, via FhraiBenchmarkCronService); (ii) tenant-supplied compset rates via CSV import / manual entry; (iii) authorised OTA-Insight or RateGain API where the tenant has a subscription. STR Inc. integration is an enterprise-tier add-on, not a default."

US-012A-01 — RevPAR & ADR Trend Analysis​

Catalog status: Built (P1, 8 points) Verified status: Built — RevPAR / ADR computed on every ReservationConsumer upsert (AP-T10-001), refreshed authoritatively by NightAuditConsumer (AP-T09-002), backfilled nightly (AP-T10-012), surfaced via GET /analytics/dashboard/metrics (single source of truth, AP-T10-002), trend lines via GET /analytics/dashboard/revenue?days=N and /occupancy?days=N

Code locations:

  • hmp-analytics-service/src/consumers/reservation.consumer.ts :: onReservationCreated, onModified (delta) — AP-T10-001 + AP-T10-006
  • hmp-analytics-service/src/consumers/night-audit.consumer.ts — AP-T09-002 (authoritative SET on night.audit.completed)
  • hmp-analytics-service/src/dashboard/dashboard.service.ts :: getRevenueTrend(), getOccupancyTrend() — configurable days param (1-365 enforced at controller layer)
  • hmp-analytics-service/src/dashboard/dashboard.controller.ts:208-236 — GET /analytics/dashboard/occupancy?days=N + /revenue?days=N
  • hmp-analytics-service/scripts/backfill-mv-adr-revpar.ts — idempotent two-SQL backfill
  • hmp-analytics-service/src/backfill/mv-backfill.service.ts — @Cron('0 3 * * *') nightly backfill (AP-T10-012)
  • hmp-web-dashboard/src/components/modules/dashboard/revenue-chart.tsx, occupancy-chart.tsx — Recharts trend lines

Unit + integration tests:

  • hmp-analytics-service/src/consumers/reservation.consumer.daily-kpi.spec.ts — ADR/RevPAR math + divide-by-zero guards
  • hmp-analytics-service/src/dashboard/dashboard.service.metrics-mv.spec.ts — AP-T10-002 single-source contract
  • hmp-analytics-service/src/backfill/mv-backfill.service.spec.ts — cron idempotency
  • hmp-analytics-service/src/consumers/night-audit.consumer.spec.ts — SET-not-increment semantics

Flow tests: none specific (covered indirectly by flow-9-night-audit)

Real-backend smokes: none specific

Use cases referencing this story:

  • docs/requirements/use-cases/uc-night-audit.yaml [CITED-BUT-NOT-FOUND] — AP-T09-002 trail; verify

SME notes:

  • Trend windows: the dashboard controller's parseDays enforces 1 ≤ days ≤ 365. The EPIC body says "7-day rolling, 30-day rolling, year-over-year" — story does not enumerate the bucketing (daily granularity? rolling 7-day vs trailing 7-day? STR convention is trailing 7-day = last 7 days inclusive). Today: bucketing is daily; the "rolling 7-day" semantic is computed client-side in revenue-chart.tsx (verify).
  • AC implicit: "year-over-year". The MvDailyKpi table supports the LY path via the /analytics/historical-snapshots?from=&to= endpoint (getHistoricalSnapshots in dashboard.controller.ts). This is what powers the Phase 3.2 ARI Grid YoY overlay; whether the dashboard RevPAR/ADR trend chart renders the YoY overlay is implementation-dependent.
  • ADR / RevPAR math (adr = totalRoomRevenue / roomsSold, revpar = totalRoomRevenue / totalRooms) is per STR convention, which is correct. The divisor (totalRooms) draws from MvDailyOccupancy.totalRooms, which AP-T10-009 wires to PropertyServiceClient.getActiveRoomCount (correct). But getActiveRoomCount does NOT exclude OOO/OOS rooms (the same problem as US-012-01 D-004). The downstream effect: RevPAR is inflated during periods of room-block / renovation (denominator includes rooms that cannot be sold).

Drift findings: (feeds D-004 from US-012-01)

  • [SME-PROPOSED] Add AC: "Trend window aggregations SHALL document the rolling vs trailing convention. Default: trailing N days = [today − N + 1, today] inclusive, per STR. Comparative slot: trailing N days, prior period (e.g. 30-day-ago window) AND same N days last year (364-day shift for day-of-week alignment)."
  • [SME-PROPOSED] Add AC: "PropertyServiceClient.getActiveRoomCount(propertyId) SHALL accept a date parameter and return totalRooms − roomsInBlockingState(date) where blocking states are OUT_OF_ORDER + OUT_OF_SERVICE + LONG_TERM_MAINTENANCE. Fixes the RevPAR-inflation-during-renovation regression."

US-012A-02 — Occupancy Pacing vs. STLY​

Catalog status: Built (P1, 5 points) Verified status: Built — the front-desk tape-chart ForecastStrip (GET /analytics/forecast) renders 28-day pacing with LY (364-day-shift) comparison and pickup delta. The strip is mounted in /front-desk/calendar. Caveat: the EPIC body §4.1 also lists GET /dashboard/pacing as "Built" — that endpoint is NOT present in the analytics-service controller (dashboard.controller.ts lines 98-386 enumerated above); only /forecast exists. EPIC controller listing appears stale.

Code locations:

  • hmp-analytics-service/src/forecast/forecast.controller.ts:43-73 — GET /api/analytics/forecast?from=&to= (the actual pacing endpoint)
  • hmp-analytics-service/src/forecast/forecast.service.ts :: 364-day LY shift, day-of-week alignment, zero-fill missing dates
  • hmp-web-dashboard/src/components/modules/front-desk/tape-chart/forecast-strip.tsx — 28-day strip with KPI sidebar (Occ 7d, RevPAR, LY deltas)
  • hmp-web-dashboard/src/components/modules/front-desk/tape-chart/use-forecast-data.ts — React Query hook

Unit + integration tests:

  • hmp-analytics-service/src/forecast/forecast.service.spec.ts — LY shift correctness + zero-fill + window cap

Flow tests: none specific

Real-backend smokes: none

Use cases referencing this story: none

SME notes:

  • "Pacing" in hospitality has a specific industry meaning: comparing bookings on the books for a future stay date as-of-today vs same-time-last-year (STLY). The current ForecastService returns occupancy actuals (filled from MvDailyOccupancy.occupancyPct) for both this year and last year — which is post-stay actual occupancy, not bookings-on-the-books-as-of-X-days-out. True pacing requires the MvFrontOfficePace table (per analytics CLAUDE.md, Plan 2.1, keyed by (bookedDate, stayDate, daysOut)). The dashboard does not consume this MV today — only forecast-pace-report does.
  • "vs Budget" (AC implicit from §1 "tracking 20% below forecast") is wired through the report comparator's BudgetEnricher (PropertyServiceClient.getBudget), not the dashboard.
  • The forecast strip is on /front-desk/calendar (tape chart), not on the dashboard home. Story body audience is "Revenue Manager" but the surface lives on the front-desk tape-chart screen, which is the wrong primary location for a RM persona.

Drift findings:

D-009 — [P1] [Industry-convention + cross-MV-selection] The current /analytics/forecast returns actual-occupancy-with-LY-overlay, NOT true bookings-on-the-books pacing. STR / industry "pacing" specifically means "bookings as-of-now for future stay date, vs bookings same-day-of-week last year as-of-the-same-days-out". MvFrontOfficePace (Plan 2.1) supports this; the dashboard does not consume it.

  • [SME-PROPOSED] Add AC: "The Pacing endpoint SHALL be split into two: (a) GET /analytics/forecast/actuals — current LY-overlay behaviour (post-stay occupancy comparison); (b) GET /analytics/forecast/pacing — bookings-on-the-books per stay-date, joined to STLY (same daysOut) from MvFrontOfficePace. The dashboard pacing tile reads (b)."
  • [SME-PROPOSED] Add AC: "Pacing strip SHALL also render Pickup since N days ago (default 7) — the delta between bookings-on-the-books today vs N days ago. Industry-standard RM lens. Backed by MvFrontOfficePace daysOut-anchored query."
  • [SME-PROPOSED] Add AC: "Revenue Manager landing (/revenue-manager) SHALL surface the pacing strip as its primary above-the-fold widget. Front-desk tape-chart MAY keep a compact version, but the canonical surface is RM."

US-012A-03 — KPI Catalog & Configuration​

Catalog status: Not Started (P3, 5 points) Verified status: Not Started

Code locations: NONE FOUND. EPIC §4.1 enumerates GET /kpi-catalog and PATCH /kpi-catalog/order as "Not Started"; no implementation found.

Unit + integration tests: none

Flow tests: none

Real-backend smokes: none

Use cases referencing this story: none

SME notes:

  • The operations-hub home (default since 2026-05-02 per dashboard CLAUDE.md) DOES ship a per-effective-role tile-set switcher (useEffectiveRole, RANK, REGISTRY in src/components/modules/operations-hub/). This is a role-pivoted KPI catalogue, NOT a tenant-admin-configurable per-property catalogue. The story's intent — "tenant admin selects which KPIs to show and in what order" — is not satisfied by the role-pivot.
  • UserDashboardPrefs (auth-service, Plan B5 surface) carries lastViewedRole and watchlistPropertyIds per user. The natural place for a per-user KPI catalogue is to add a kpiCatalogOrder JSON field to that table.
  • Realistic Indian-market priority: low (P3 catalog status). Most tenants accept the platform default; only chain owners with multi-property dashboards typically request per-property KPI re-ordering.

Drift findings: (none net new — story-vs-reality matches: Not Started)

  • [SME-PROPOSED] When this is built, scope it through UserDashboardPrefs.kpiCatalogOrder (per-user, per-role) AND a tenant-level TenantConfig row keyed KPI_CATALOG_OVERRIDE (per-tenant override). Defaults flow tenant → role → user. Mirror the existing 3-level cascade pattern used by LocaleResolverService + the rate-limit policy table.

US-012A-04 — Multi-Property Portfolio KPI View​

Catalog status: Partial (P2, 8 points) Verified status: Partial — PortfolioController + PortfolioService shipped (Operations Hub Phase 2 Task 12); endpoint GET /api/dashboard/portfolio returns heatmap (revpar today vs LY) + anomaly leaderboard (top 3 by CRITICAL+HIGH undismissed). Phase 2 v1: heatmap returns placeholder revpar=0, ratio=null when MvDailyKpi row is missing; watchlist is always [] (Phase 3 will wire DashboardPrefsClient.UserDashboardPrefs.watchlistPropertyIds). Dashboard /chain page exists; per-property drill-down depth needs verification.

Code locations:

  • hmp-analytics-service/src/dashboard/portfolio/portfolio.controller.ts — GET /dashboard/portfolio (@Roles('SUPER_ADMIN', 'ADMIN', 'MANAGER', 'OWNER'))
  • hmp-analytics-service/src/dashboard/portfolio/portfolio.service.ts — aggregator over accessible propertyIds, 5-min cache TTL, leaderboard limit 3
  • hmp-analytics-service/src/dashboard/portfolio/dto/portfolio-response.dto.ts — HeatmapEntry, LeaderboardEntry, PortfolioResponse
  • hmp-web-dashboard/src/app/(dashboard)/chain/page.tsx — chain landing
  • hmp-web-dashboard/src/app/(dashboard)/chain/properties/page.tsx — properties list
  • hmp-web-dashboard/src/components/modules/operations-hub/... — portfolio strip widget mounted on operations-hub home (per dashboard CLAUDE.md Phase 2)

Unit + integration tests:

  • hmp-analytics-service/src/dashboard/portfolio/portfolio.controller.spec.ts — JWT property-resolution branches (properties / propertyIds / propertyId)
  • hmp-analytics-service/src/dashboard/portfolio/portfolio.service.spec.ts — aggregator + cache + empty-list path

Flow tests:

  • hmp-infrastructure/testing/flow-tests/src/flows/chain-rollup.flow.spec.ts :: chain KPI rollup — TBD (not in 13-flow doc)

Real-backend smokes: none

Use cases referencing this story: none

SME notes:

  • The story's audience is "Chain Owner"; the controller accepts MANAGER + OWNER roles, which is correct for multi-property managers. Phase 2 v1 explicitly punts watchlist to Phase 3.
  • "Drill down" (AC: clicking a property opens its KPI dashboard) requires the per-property dashboard to support a propertyId query / context switch. The current useAppStore.currentProperty pattern (dashboard CLAUDE.md gotcha 0b) means a drill-down click MUST switch the store, not pass a URL param to the home page — otherwise the home dashboard will continue rendering the previously-active property. No test asserts this end-to-end click-through.
  • "Best-performing property highlighted in green; lowest in amber" (AC) — color-coding logic is rendered in the operations-hub portfolio strip; the KpiCard design-system primitive does not natively support a "rank highlight" variant. Verify the heatmap UI matches the green/amber AC, or refine the AC.

Drift findings:

D-010 — [P2] [Test-vs-code + cross-component-handoff] The drill-down AC requires that clicking a property card on the portfolio view opens THAT property's full KPI dashboard. The dashboard CLAUDE.md (gotcha 0b) is explicit that the property switch is via useAppStore.currentProperty.id, NOT URL param. No automated test exercises the click → store update → home re-render path end-to-end. Risk: a re-architect that adds URL-driven property context (?propertyId=X on /) silently breaks drill-down without a test failing.

  • [SME-PROPOSED] Add AC: "Drill-down click on a portfolio row SHALL call useAppStore().setCurrentProperty(...) AND router.push('/'). Verified by a Playwright e2e test on /chain that clicks a row, asserts navigation to /, and asserts the home metrics endpoint received the new x-property-id header."
  • [SME-PROPOSED] Add AC: "Portfolio view SHALL render Warming up for properties with no MvDailyKpi row for today (heatmap revpar=0, ratio=null). Tooltip explains 'No KPI data yet — backfill in progress'. Empty-state copy ASSERTED by a unit test (matches the analytics-service meta.warmingUpProperties[] contract)."

US-012A-05 — GOPPAR Calculation​

Catalog status: Not Started (P2, 5 points) Verified status: Not Started — grep for GOPPAR/goppar across hmp-analytics-service / hmp-web-dashboard returns NO matches in production source (only in EP-012A-kpi-dashboard.md, multi-repo-architecture.md, and a few guide docs). EPIC §5 gap-row 4 confirms "GOPPAR requires total F&B and ancillary revenue aggregation — Not Started".

Code locations: NONE FOUND. No GOPPAR controller, service, MV, or dashboard tile.

Unit + integration tests: none

Flow tests: none

Real-backend smokes: none

Use cases referencing this story: none

SME notes:

  • GOPPAR (Gross Operating Profit Per Available Room) is the gold-standard hospitality profitability metric, per USALI (Uniform System of Accounts for the Lodging Industry, 11th revised ed.). Definition: (Total Revenue − Total Operating Expenses) / Total Available Room Nights. Requires aggregation across ALL revenue streams (rooms + F&B + spa + banquet + ancillary) AND ALL operating expenses (labor + utilities + maintenance + COGS + general admin).
  • HMP today has the revenue side largely covered via MvDailySalesRegister (revenue source ∈ ROOM/POS/BANQUET/SPA, Plan 2) and the expense side via MvDailyExpense (Plan 2, populated by ExpenseConsumer on expense.recorded from property-service Plan 11). The aggregation MV is missing.
  • USALI compliance is a P1 for chain hotels selling to institutional investors (REIT reporting, hotel-management-company P&Ls). For independent Indian properties (the target Property #1 demographic) GOPPAR is a P2/P3 nice-to-have.

Drift findings:

D-011 — [P2] [Story-incomplete + USALI compliance] When this is built, the EPIC should reference USALI 11th ed. as the canonical definition and enumerate which revenue + expense lines roll into the numerator. Without this, two implementations (us + a future competitor) will produce different GOPPAR numbers for the same property.

  • [SME-PROPOSED] Add AC: "GOPPAR SHALL be computed per USALI 11th ed.: (Σ MvDailySalesRegister.revenue across ROOM+POS+BANQUET+SPA + MvOtherRevenue) − (Σ MvDailyExpense.amount across LABOR+UTILITIES+MAINTENANCE+COGS+GENERAL_ADMIN+TAXES+INSURANCE)) / (Σ MvDailyOccupancy.totalRooms − OOO − OOS for the period). Backed by a new MvGoppar materialized view keyed by (propertyId, period)."
  • [SME-PROPOSED] Add AC: "GOPPAR tile SHALL render Configuration required when MvDailyExpense has no rows for the property in the last 30 days. Links to /settings/expense-tracking for tenant admin to wire up expense categories."