Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Dynamic Pricing 2.0 — Demand & Capacity (Frontend PRD)

Companion to Dynamic Pricing — Capacity & Demand (the product PRD). This document captures the frontend side of the Demand & Capacity rule: what the partner sees in book-bite, what the diner sees in hh-pegasus, and the FE engineering contract we will implement.

OwnerFrontend (Partner FE: book-bite; Customer FE: hh-pegasus)
Backend PRDDynamic_Pricing_Capacity_Demand_20260701150000.md
Backend engineering specPricingEngine/013-DEMAND-CAPACITY-CONDITIONS.md
FigmaDynamic-Pricing 2.0 (N8ga1PN2r8ef76438s9OEi), node 139:4137, frame 151:8161
StatusDraft — awaiting backend payload & response finalisation

1. Scope (v1)

Team decision (2026-07-03): the v1 of Demand & Capacity supports only one seat metric:

FieldValueNotes
condition_typetotal_seats_bookedThe single supported metric in v1
relation_type>, >=, =, <, <=, betweenAll six remain available
value / value_min / value_maxpositive integer (count of seats)Stored as integer; see §6 for unit
action_typeincrease / decrease
value_typepercentage / fixed
scope (time)per time window (aggregate across 15-minute slots)Inherited from the underlying Specific Date & Time rule shape; capacity is summed across all 15-minute slots whose start_time falls inside the rule window

Explicitly out of scope for v1:

  • remaining_seats condition type
  • pct_seats_booked / pct_seats_remaining percentage conditions
  • Multi-tier “Action / Subaction” structure described in the Figma text annotation (frame 991:11072)
  • is_anytime / open-ended threshold semantics (these are PRD design tokens; v1 is single threshold per rule)

If/when the team decides to broaden the metric set, the FE form already supports showing more options (§4). The path to broadening is to flip a showAllConditions flag in book-bite/src/pages/pricing-rules/PricingRules.js.


2. Personas & flows

2.1 Partner flow (book-bite, restaurant manager)

  1. Manager opens Pricing Rules for their restaurant (book-bite/src/pages/pricing-rules/PricingRules.vue).
  2. Clicks + Add Rule. A rule-type picker appears in RulesAndRestrictionsForm.vue (filters via RULE_TYPE_LIST.filter(item => item.show)).
  3. Selects “Demand & Capacity-Based”.
  4. Lands on RuleForm.vue (line 3) which renders the matching form template. For Demand & Capacity, this routes to RuleItemDemandAndCapacity.vue → RuleDemandAndCapacity.vue.
  5. Manager configures:
    • Rule name (free text)
    • Date/time window (when this rule is in effect — every day, specific date, specific time, specific day-of-week, etc. — same vocabulary as the Specific Date & Time rule)
    • Capacity condition (the new block at lines 320-378 of RuleDemandAndCapacity.vue):
      • Relation: pick one of > / >= / = / < / <= / between
      • Value: a single number, or two numbers if between
    • Action: increase or decrease by percentage or fixed amount
    • Packages: which packages the rule applies to
  6. Saves. The rule is POSTed to the partner-pricing-engine API (pricing-engine’s partner/v1/pricing_rules_controller.rb).
  7. Manager then sees the new rule card in the rule list (Figma frame 151:8161 shape — icon + name + meta + Edit/Reject/Approve buttons).

2.2 Diner flow (hh-pegasus)

  1. Diner opens a restaurant’s booking page (e.g. /booking/[restaurantId]).
  2. Selects a date.
  3. SelectedDateTime.vue calls getAvailableRulesByTime (line 484) which POSTs to pricing-engine/api/v1/get_available_rules_by_time.
  4. For each 15-minute slot, the response includes an availableTime.price that is already adjusted by any active rules (including Demand & Capacity).
  5. The slot renders in TimePickerButton2.vue with the adjusted price.
  6. Currently the slot shows low-price-font styling (green) for any available slot — see §5.2 for the bug to fix in this same release.

2.3 Where conditions fire (server-side, for FE context)

The Conditions check on real-time aggregate occupancy across the rule’s time window at the moment the diner opens the time picker. For example, a rule “11am–3pm, > 48 pax booked” sums the booked seats from every 15-minute slot between 11:00 and 14:45; if the total exceeds 48, the price adjustment applies to every API slot whose start_time lies inside that window. As other diners book or cancel, the aggregate may cross the threshold and the rule may flip from “fire” to “don’t fire” between two consecutive fetches. The FE does not compute this — the engine does.


3. UI surface — Partner (book-bite)

3.1 Rule-type picker

Currently hidden (RULE_TYPE_DEMAND_CAPACITY: show: false in book-bite/src/pages/pricing-rules/PricingRules.js). The pick list filters via RulesAndRestrictionsForm.vue.

FE action: change show: false → show: true once §6 form contract is delivered and verified.

3.2 Form layout

The form is a date/time scaffold plus an inline capacity block:

┌─────────────────────────────────────────────────────────────────┐
│ Rule Name                                                       │
│ [_______________________________________________________]       │
│                                                                 │
│ WHEN  ┌────────────────────────┐                                │
│       │ Date Type ▼            │   (everyday / specific date /  │
│       └────────────────────────┘    specific time / day of week /│
│                                    day+time of week)            │
│                                                                 │
│       [time-window form repeats for the chosen date type]       │
│                                                                 │
│ ─────────────────────────  ✱ Capacity  ──────────────────────── │
│                                                                 │
│ WHEN (capacity)                                                 │
│ ┌─────────────┐  ┌─────────────┐  ┌──────────┐                  │
│ │ Total Seats │  │ [≥]   ▼     │  │  [N]     │                  │  ← relation + value
│ │  Booked     │  │             │  │          │                  │
│ └─────────────┘  └─────────────┘  └──────────┘                  │
│                                                                 │
│ DO THIS                                                          │
│ ┌────────────┐  ┌──────────────┐  ┌────────────┐                │
│ │ Increase ▼ │  │ Percentage ▼ │  │ [5]  %    │                │  ← action + value
│ └────────────┘  └──────────────┘  └────────────┘                │
│                                                                 │
│ Packages (selected via separate component, not in this file)     │
│                                                                 │
│ [Save Rule]   [Cancel]                                          │
└─────────────────────────────────────────────────────────────────┘

The Condition select renders only one option in v1: “Total Seats Booked” (label from $vuetify.pricingRules.totalSeatsBooked). Per the team decision: drop the other 3 condition options entirely from the select list rather than rendering them grayed-out. Reason: keeps the form simple, avoids the trap of selecting a metric that won’t survive v2.

3.3 Existing components to reuse

ConcernFileNotes
Date type / period / day-of-weekRuleDemandAndCapacity.vueReuse as-is — these are inherited from the Specific Date & Time rule shape.
Relation selectRuleDemandAndCapacity.vueAlready wired to RULE_RELATION_LIST. No change.
Value (single number)RuleDemandAndCapacity.vueAlready wired. No change.
Range valueRuleDemandAndCapacity.vueAlready wired for between. No change.
Action / price type / valueRuleDemandAndCapacity.vueAlready wired. No change.

3.4 Components to modify

ComponentChangeWhy
PricingRules.js (RULE_CONDITION_SEAT_LIST)Reduce to a single option { title: 'Total Seats Booked', value: 'total_seats_booked' }. The form’s v-model condition becomes effectively a constant in v1.Team decision: drop unused metrics.
RuleDemandAndCapacity.vue (condition select)The InputSelect for condition is still useful (validates that the rule has a seat condition selected) but reads from the new single-item list. Optionally hide it (set :items="[]" and default the value to total_seats_booked).The select on its own is still a valid UI invariant — it asserts the rule has a capacity metric even if there’s only one metric to choose. We keep it to allow future broadening without rewriting.
PricingRules.js (RULE_TYPE_DEMAND_CAPACITY.show)Flip show: false to show: true.Expose the rule type once the form is verified end-to-end.
RuleDemandAndCapacity.vue (everyday/everyDayOfWeek etc.)Leave alone — these are inherited from Specific Date & Time and are correctly wired.No change needed.

3.5 Validation rules

  • The rule must have at least one valid date/time window (already enforced by the date/time validators).
  • A relation + (single value or two values for between) is required. Already enforced by the :rules prop on each field — no new validation needed.
  • The action + price type + value fields are required and validated.
  • value must be a positive integer. Today’s v-model binds type="number" but doesn’t enforce min="1" — propose adding :min="1" and a value < 1 validator in §6.

3.6 Empty / error / loading states

  • Empty rule name → existing form validation surfaces red text.
  • Missing packages → existing form validation. Books-bite should refuse to save with zero packages selected.
  • Network failure on save → existing partner-portal error toast.
  • Network failure on load (rules list) → existing empty-state.

No new states are required by this PRD.


4. UI surface — Diner (hh-pegasus)

4.1 What the diner sees

v1 expectation: zero behavioural change for the diner. The price they see per slot is whatever the engine returns. The engine will include Demand & Capacity adjustments automatically.

4.2 Components (for the FE engineer’s reference; no new code required)

  • getAvailableRulesByTime.ts — calls the engine’s POST /api/v1/get_available_rules_by_time. Already in place; consumes whatever the engine returns.
  • TimePicker.vue — async loads TimePickerButton2.vue when isDynamicPricingV2 is true.
  • TimePickerButton2.vue — renders startTime + price text per slot.
  • DynamicPricingV2Schema in hh-pegasus/src/types/Pack.ts — the per-rule JSON shape. Verify with backend that the response includes a flag (e.g. isLowPrice / isHighPrice) that the FE can use to choose styling. If not already present, request it from backend.

4.3 Out of scope for v1

  • Adding a “high demand” inline label. Figma’s “Remove High Demand Label” canvas (1706:12664) is an explicit “do not add” signal. We will not introduce a new label in this release.
  • Color changes beyond the existing low-price-font green.

5. Customer-display bug surfaced by this work (fix-in-same-release)

5.1 Bug

TimePickerButton2.vue:

:class="{ 'low-price-font': availableTime.availability, ... }"

This binding applies low-price-font (green) to every available slot, whether or not the price is actually reduced. Once Demand & Capacity adjustments are live, an unsold slot and a sold-out + surcharged slot will look identical — both green.

5.2 Fix (proposed)

Bind the class to a real price-direction flag that the engine provides. Two candidate contracts:

OptionEngine contractFE change
A. Engine emits isLowPrice: boolean (and isHighPrice: boolean) per slotConfirms whether adjusted price is below/above base price:class="{ 'low-price-font': availableTime.isLowPrice }" — remove the availability check
B. Engine returns both basePrice and adjustedPrice per slotFE computes direction locally:class="{ 'low-price-font': availableTime.price < availableTime.basePrice }"

Either is acceptable. Decide based on the final API response shape from backend. Default to Option A — it requires less FE state and the engine already has the comparison context handy.

5.3 Acceptance criteria for the fix

  • An empty + unpriced slot renders with default text color (no green).
  • A reduced slot renders green.
  • A surcharged slot renders red (using the existing but-unused .high-demand-font rule defined at line 84).
  • All three states are verified via a small unit test or manual QA on a staging restaurant with multiple rules configured.

6. Form-to-engine payload contract

Canonical contract (pending BE + FE sign-off). This section documents the agreed wire format for Demand & Capacity rules. The shape mirrors the existing Specific Date & Time nested-attributes pattern. book-bite must map its internal UI values to this contract at the API boundary before POST/PATCH to pricing-engine.

6.1 Canonical enum values (FE posts these in the payload)

ConceptCanonical wire formatSource
rule_type"demand_capacity"book-bite/src/config/pricingRules.config.js exports RULE_TYPE_DEMAND_CAPACITY
condition_type"total_seats_booked" (the only one in v1)kb PRD §1; team decision
relation_typeOne of: "gt", "gte", "eq", "lt", "lte", "between"kb PRD; different from book-bite’s current "greater_than_or_equal" etc. — see §6.2
action_type"increase" / "decrease"kb PRD
value_type"percentage" / "fixed"kb PRD; matches book-bite
valueinteger, basis points for %, cents for fixed (matches pricing-engine convention)kb PRD; different unit from book-bite’s current raw-percent / raw-currency values — see §6.2

6.2 Mapping from book-bite internal form values to canonical wire values

book-bite keeps its existing UI-friendly names internally; the mapping below is applied only at the API boundary when building the pricingRule payload.

Fieldbook-bite internal valueCanonical wire valueMapping
condition_type (v1 only)total_seats_bookedtotal_seats_bookedSame
relation_type: gtgreater_thangtRename
relation_type: gtegreater_than_or_equalgteRename
relation_type: eqequal_toeqRename
relation_type: ltless_thanltRename
relation_type: lteless_than_or_equallteRename
relation_type: betweenbetweenbetweenSame
value (percentage)raw percent as 5 for 5%basis points as 500 for 5%Multiply by 100
value (fixed)raw major-unit amount as 10000 for IDR 10,000cents as 1000000Multiply by 100

Out of scope in v1: pct_seats_booked, pct_seats_remaining, remaining_seats condition types are hidden in the UI and do not need mapping until v2.

6.3 Payload envelope (for POST/PATCH

/api/partner/v1/pricing_rules)

{
  "pricing_rule": {
    "name": "Friday 7pm surge — total seats >= 20",
    "restaurant_id": 12345,
    "rule_type": "demand_capacity",
    "active": true,
    "requires_approval": false,
    "priority": 1,
    "apply_to_all_packages": false,
    // Date/time window that gates rule eligibility (no action/value here):
    "date_time_conditions_attributes": [
      {
        "date_type": "specific_time",
        "start_time": "19:00",
        "end_time": "21:00",
        "days_of_week": [5],                 // Friday
        "is_anytime": false,
        "is_no_end_time": false,
        "condition_items_attributes": [
          { "start_time": "19:00", "end_time": "21:00" }
        ]
      }
    ],
    // NEW block for demand-capacity (v1):
    "demand_capacity_conditions_attributes": [
      {
        "condition_type": "total_seats_booked",
        "action_type": "increase",
        "value_type": "percentage",
        "value": 500,                          // 5% uplift, basis points
        "capacity_condition_items_attributes": [
          { "relation_type": "gte", "value_min": 20 }
        ]
      }
    ]
  }
}

Composition model: date_time_conditions_attributes and demand_capacity_conditions_attributes are parallel, top-level keys on the same pricing_rule. For a demand_capacity rule, the nested date_time_conditions_attributes entry carries only window metadata (date type, times, days of week); it does not carry action_type, value_type, or value. The capacity condition in demand_capacity_conditions_attributes holds the action and adjustment value. See the product PRD for cross-rule semantics with standalone Date & Time rules.

6.4 Predicted response envelope (the part the FE renders)

This is exactly the existing DynamicPricingV2Schema in hh-pegasus/src/types/Pack.ts — already accepted as the canonical shape by getAvailableRulesByTime. No change required to FE rendering when the engine starts emitting Demand & Capacity adjustments; the adjusted price is already returned as price per slot.

6.5 Mapping implementation guidance

The smallest blast radius for v1 is to keep form state in book-bite’s existing verbose names internally and remap only at the API boundary.

Recommended location for the mapper: a new helper such as book-bite/src/pages/pricing-rules/helpers/demandCapacityPayload.js that accepts the demandCapacityBasedRules form state and returns an array of demand_capacity_conditions_attributes ready for pricing-engine.

If the backend contract changes after this PRD is signed, update only the mapper and this table — the form component stays unchanged.


7. Work plan (FE only)

See companion execution plan: docs/FE-PRD-EXECUTION-PLAN.md.

The execution plan is structured as 5 sequenced tasks (T1-T5) with explicit go/no-go gates between each, so the work can be paused or rescoped after any phase.


8. Open questions for the team

  1. Endpoint confirmation: is the form POST/PATCH to pricing-engine/app/controllers/api/partner/v1/pricing_rules_controller.rb, or does the engineer add a demand_capacity_rules controller? Default (matches kb PRD): nested attributes on the same endpoint.
  2. Composes-with date/time: documented in the product PRD (parallel top-level keys; date/time window gates eligibility, capacity threshold gates firing). Confirm in writing before T3 if the backend contract differs.
  3. Diner-side isLowPrice / isHighPrice flag: does the engine emit this, or does FE compute? Default (proposed in §5.2): FE computes, but we may flip to engine-emits if backend prefers.
  4. Figma card reviews: Figma has a Demand & Capacity card design (151:8161) but no detailed form design. Designer should review and contribute the form layout before T3, even as a fast follow-up.

9. Success criteria

This PRD is “done” when:

  • A partner can create a Demand & Capacity rule from book-bite that:
    • saves via the partner API
    • shows in the rule list with a correctly-formatted card
    • takes effect for matching time slots in hh-pegasus
  • The diner sees the adjusted price in the time picker with correct high/low price styling (per §5.2 fix).
  • No regression on Specific Date & Time or AI-Driven rules.

  • Companion product PRD: Dynamic_Pricing_Capacity_Demand_20260701150000.md
  • Backend engineering spec: PricingEngine/013-DEMAND-CAPACITY-CONDITIONS.md
  • FE / Figma alignment audit: docs/FE-FIGMA-ALIGNMENT.md
  • Monorepo learning cheatsheet: docs/LEARNING.md