Build an Uber-Style Surge Pricing System

Build a browser lab that compares periodic and event-driven surge pricing.

Introduction

30 Second Summary

A busy concert can flood one neighborhood with ride requests while the rest of the city stays calm. If prices update too slowly, riders keep seeing quotes based on a market that has already changed.

In this project, you will build a local browser lab for surge pricing that compares a periodic refresh with event-driven processing during a deterministic concert spike. You will turn the results into an interview-ready system-design brief that explains how the design could evolve for a large real-time marketplace.

What You'll Build

Your finished dashboard plays a concert spike across three geographic cells while side-by-side metrics expose each stale quote as the scenario runs.

By the end of this project, you'll have:

  • A visual browser simulator where Stadium demand surges while Downtown and Riverside remain stable.
  • A deterministic comparison that measures 4 stale periodic quotes with 7.4 cumulative pricing error against an event-driven result with zero stale quotes.
  • An interview-ready architecture defense covering geographic partitioning, hot cells, event ordering, cache freshness, failure recovery, graceful degradation, and observability.
  • Secret Mission: Deliver a 90-second defense that handles duplicate and late events without corrupting a cell's price.

Are there any prerequisites?

You need a Mac with Visual Studio Code and Safari plus basic familiarity with HTML, CSS, and JavaScript. Every file stays local, so you need no external packages or cloud accounts.

Before We Start

This checkpoint defines the project as an educational system-design experiment with no claim about Uber's private pricing algorithm. The simulator compares four-tick periodic pricing refreshes with event-driven updates during a Stadium demand spike because stale quotes can serve an incorrect multiplier.

Set Up the Local Lab

Your pricing comparison needs a stable local home before demand snapshots can become a browser experiment. Keeping the lab dependency-free lets you focus on measurable system behavior.

In this step, you set up four starter files in Visual Studio Code. You also open the first visible page in Safari.

In this step, get ready to:
  • Create a local workspace for the surge-pricing lab.
  • Create the four starter files for the simulator.
  • Open the starter page in Safari.
Open the local workspace

A named workspace keeps the simulator page beside its styling, behavior, and design notes. Visual Studio Code displays these files together in the Explorer view.

  • Use macOS search to launch Visual Studio Code.
  • Click File in the top menu bar.
  • Select Open Folder....
  • Select Desktop in the folder picker.
  • Use the folder-creation control to create surge-pricing-lab on your Desktop.
  • Select the surge-pricing-lab folder.
  • Click Open.

You'll see surge-pricing-lab at the top of the Explorer view. That's a solid start: every lab artifact now has one predictable location.

Create the starter files

The lab separates page structure, presentation, browser behavior, and architecture reasoning into dedicated files. This keeps each later change focused.

  • Use New File... in the Explorer view to create index.html.
  • Use New File... in the Explorer view to create styles.css.

You'll see index.html plus styles.css in the Explorer view.

  • Use New File... in the Explorer view to create app.js.
  • Use New File... in the Explorer view to create SYSTEM_DESIGN.md.

The Explorer view now lists all four files. Keep styles.css plus app.js empty for this step.

The Markdown design brief needs a clear starting point. Its heading identifies the experiment before you add architecture decisions later.

  • Add the design brief title to SYSTEM_DESIGN.md by pasting this line:
# Surge Pricing Freshness Lab: System Design

What Does This Heading Do?

  • The # prefix creates the document's main heading.
  • The title keeps the architecture brief tied to the freshness experiment.
  • Save SYSTEM_DESIGN.md.
  • Confirm the editor shows Surge Pricing Freshness Lab: System Design as the document heading.

Heading Missing From the Design Brief?

Check that the file is named SYSTEM_DESIGN.md. Confirm that its first line starts with one # character.

Help me check my design brief heading.

Create the starter page

HTML gives the browser its document structure. The index.html file contains the heading that Safari displays.

CSS controls the page's presentation. JavaScript provides the simulator behavior.

  • Replace the empty index.html file with this starter page:
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Surge Pricing Freshness Lab</title>
  <link rel="stylesheet" href="styles.css">
  <script src="app.js" defer></script>
</head>
<body>
  <h1>Uber-Style Surge Pricing Freshness Lab</h1>
</body>
</html>

What Does This Page Set Up?

  • The document metadata gives the browser a page title.
  • The viewport metadata prepares the page for different screen sizes.
  • The stylesheet link connects styles.css to the page.
  • The script element loads app.js after the HTML has been parsed because it uses defer.
  • The h1 element creates the visible lab heading.
  • Save index.html.

Finder lets you choose which app opens a local file. Before you open the page, what heading do you expect Safari to display?

  • Select Finder in the Dock.
  • Select Desktop in the Finder sidebar.
  • Open the surge-pricing-lab folder.
  • Select index.html.
  • Click File in the macOS menu bar.
  • Select Open With.
  • Choose Safari.

You'll see Uber-Style Surge Pricing Freshness Lab on the local page. That first visible result proves Safari can load the workspace you will build on.

Heading Missing in Safari?

Confirm that Finder opened index.html from the surge-pricing-lab folder. Check that you saved the file after adding the heading.

Help me find why my local heading is missing.

✔️ Awesome, I've got everything!

Great. Double check that all four files are saved inside surge-pricing-lab. Safari should show the local lab heading.

ⓧ I'd like to double check the full code

Your complete SYSTEM_DESIGN.md file should match this:

# Surge Pricing Freshness Lab: System Design

What Should Match?

The file contains one main heading. Keep one blank line after it for the sections you add later.

Your app.js file should contain no code. Your styles.css file should also contain no code.

Your complete index.html file should match this:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Surge Pricing Freshness Lab</title>
  <link rel="stylesheet" href="styles.css">
  <script src="app.js" defer></script>
</head>
<body>
  <h1>Uber-Style Surge Pricing Freshness Lab</h1>
</body>
</html>

What Should Match?

Check the stylesheet link plus the deferred script element. Confirm that the body contains the lab heading.

Your local lab is ready. Next, you turn this single heading into a dashboard with three separate marketplace cells.

Build the Geographic Cell Dashboard

Your starter page proves that the local files connect correctly. The next challenge is showing how one demand spike can affect a single geographic cell while nearby cells remain stable.

You will turn the page into a dashboard that keeps every changing value addressable. This visible foundation lets the pricing experiment update the right cell without mixing locations together.

In this step, get ready to:
  • Build separate cards for Stadium, Downtown, and Riverside.
  • Style the dashboard controls, pricing metrics, and comparison log.
  • Label the multiplier as an educational model with clear bounds.
Build the dashboard structure

Each changing number needs a unique element ID so JavaScript can update it later. The cards also make geographic isolation visible before pricing logic enters the experiment.

  • Switch back to index.html in the Explorer view.
  • Replace the existing h1 element with this hero and scenario control structure:
  <main class="page-shell">
    <header class="hero">
      <p class="eyebrow">Local system design lab</p>
      <h1>Uber-Style Surge Pricing Freshness Lab</h1>
      <p class="hero-copy">
        Compare a four-tick scheduled refresh with an event-driven update during a deterministic concert spike.
      </p>
    </header>

    <section class="panel controls" aria-labelledby="scenario-title">
      <div>
        <p class="section-kicker">Deterministic scenario</p>
        <h2 id="scenario-title">Concert demand spike</h2>
        <p id="scenario-status" aria-live="polite"></p>
      </div>
      <div class="button-row">
        <button id="next-button" type="button">Advance One Tick</button>
        <button id="run-button" type="button">Run Full Scenario</button>
        <button id="reset-button" class="secondary" type="button">Reset</button>
      </div>
    </section>
  </main>

What does this structure do?

  • The hero introduces the deterministic comparison that the dashboard demonstrates.
  • The scenario-status element provides a dedicated place for progress messages.
  • The three button IDs let the next step connect each control to a specific JavaScript action.
  • Save index.html.
  • Return to Safari.
  • Refresh the local page.

You should see the original heading with a concert scenario section beneath it. The three controls appear as plain browser buttons for now.

Missing the scenario controls?

Check that the new markup sits between <body> and </body>. Confirm that every opening element has a matching closing element.

Ask for help if the page is blank. Help me find the HTML structure error in my dashboard markup.

  • In index.html, find the closing </section> for the scenario controls.
  • Add the Stadium cell section below that closing element:
    <section aria-labelledby="cells-title">
      <p class="section-kicker">Geographic partition view</p>
      <h2 id="cells-title">Marketplace cells</h2>
      <div class="zone-grid">
        <article class="zone-card stadium">
          <p class="cell-id">Cell A</p>
          <h3>Stadium</h3>
          <dl>
            <div><dt>Demand</dt><dd id="stadium-demand">0</dd></div>
            <div><dt>Supply</dt><dd id="stadium-supply">0</dd></div>
            <div><dt>Target</dt><dd id="stadium-target">1.0x</dd></div>
          </dl>
        </article>
      </div>
    </section>

How does a cell stay addressable?

The Stadium card gives demand, supply, and target their own IDs. A DOM lookup can therefore update one value without replacing the whole card.

  • Save index.html.
  • Refresh the page in Safari.

You should see a Stadium section with demand and supply set to 0. Its target starts at 1.0x.

Can't see the Stadium cell?

Make sure the new section sits inside main before its closing tag. Check that zone-grid contains the complete Stadium article.

Get help checking the insertion point. Help me place my Stadium section inside the page shell.

  • In the zone-grid container, find the closing Stadium </article> element.
  • Add the Downtown card immediately below it:
        <article class="zone-card">
          <p class="cell-id">Cell B</p>
          <h3>Downtown</h3>
          <dl>
            <div><dt>Demand</dt><dd id="downtown-demand">0</dd></div>
            <div><dt>Supply</dt><dd id="downtown-supply">0</dd></div>
            <div><dt>Target</dt><dd id="downtown-target">1.0x</dd></div>
          </dl>
        </article>

What does the second card prove?

Downtown has an independent set of IDs. Future updates can change Stadium while leaving Downtown untouched.

  • Save index.html.
  • Refresh the page in Safari.

You should see Downtown beneath Stadium with its own demand, supply, and target values.

Is Downtown outside the cell section?

Check that the Downtown article appears before the closing tag for zone-grid. Moving it inside that container keeps all cell cards in one layout.

Ask for help with the nesting. Help me check why my Downtown card appears outside the cell grid.

  • Find the closing Downtown </article> element.
  • Add the Riverside card immediately below it:
        <article class="zone-card">
          <p class="cell-id">Cell C</p>
          <h3>Riverside</h3>
          <dl>
            <div><dt>Demand</dt><dd id="riverside-demand">0</dd></div>
            <div><dt>Supply</dt><dd id="riverside-supply">0</dd></div>
            <div><dt>Target</dt><dd id="riverside-target">1.0x</dd></div>
          </dl>
        </article>

Why use three cells?

Three cards make geographic isolation observable. The later concert spike affects Stadium while Downtown and Riverside retain their own marketplace state.

  • Save index.html.
  • Refresh the page in Safari.

You should now see Stadium, Downtown, and Riverside as three separate marketplace cells.

Missing one of the cells?

Confirm that every card uses a complete article element. Check that the Downtown and Riverside IDs use their matching location prefixes.

Get help comparing the cards. Help me find the missing or incorrectly nested geographic cell.

The cells show the marketplace state. The comparison area now needs two distinct designs so the same scenario can expose their freshness difference.

  • In index.html, find the closing element for the marketplace cell section.
  • Add the periodic comparison card below it:
    <section aria-labelledby="comparison-title">
      <p class="section-kicker">Before and after</p>
      <h2 id="comparison-title">Pricing freshness comparison</h2>
      <div class="comparison-grid">
        <article class="design-card periodic-card">
          <p class="design-label">Periodic refresh</p>
          <h3 id="periodic-quote">1.0x</h3>
          <dl class="metrics">
            <div><dt>Quote age</dt><dd id="periodic-age">0 ticks</dd></div>
            <div><dt>Stale quotes</dt><dd id="periodic-stale">0</dd></div>
            <div><dt>Cumulative error</dt><dd id="periodic-error">0.0</dd></div>
          </dl>
        </article>
      </div>
    </section>

What does the periodic card measure?

  • Quote age records how long the displayed multiplier has been waiting for a refresh.
  • Stale quotes count the ticks where the displayed quote differs from the current target.
  • Cumulative error adds the size of those differences across the scenario.
  • Save index.html.
  • Refresh the page in Safari.

You should see a Periodic refresh card with a 1.0x quote and three zeroed metrics.

Missing the periodic metrics?

Check that the metrics list contains all three rows. Confirm that each row has one label and one value.

Ask for help with the metric structure. Help me repair my periodic comparison card.

  • In the comparison-grid container, find the closing periodic card </article> element.
  • Add the event-driven card immediately below it:
        <article class="design-card event-card">
          <p class="design-label">Event-driven update</p>
          <h3 id="event-quote">1.0x</h3>
          <dl class="metrics">
            <div><dt>Quote age</dt><dd id="event-age">0 ticks</dd></div>
            <div><dt>Stale quotes</dt><dd id="event-stale">0</dd></div>
            <div><dt>Cumulative error</dt><dd id="event-error">0.0</dd></div>
          </dl>
        </article>

Why match the metric shapes?

Both designs expose the same metrics. Matching shapes create a fair comparison because each path receives the same scenario and reports the same measurements.

  • Save index.html.
  • Refresh the page in Safari.

You should see the periodic and event-driven cards as separate comparison areas.

Is the event-driven card outside the comparison?

Move the event-driven article before the closing tag for comparison-grid. Confirm that its IDs begin with event-.

Get help checking the card placement. Help me place the event-driven card beside the periodic card.

  • Find the closing element for the pricing comparison section.
  • Add the comparison log panel below it:
    <section class="panel" aria-labelledby="log-title">
      <p class="section-kicker">Quantitative evidence</p>
      <h2 id="log-title">Tick comparison log</h2>
      <pre id="comparison-log" aria-live="polite"></pre>
    </section>

What is the comparison log for?

The pre element preserves spacing between future log columns. Its unique ID gives JavaScript one destination for deterministic tick evidence.

  • Save index.html.
  • Refresh the page in Safari.

You should see a Tick comparison log heading below the two pricing cards.

Can't see the log heading?

Confirm that the log section appears inside main. Check that the pre element uses the comparison-log ID.

Ask for help locating the markup issue. Help me fix my comparison log container.

Style the dashboard

The markup separates each responsibility. The stylesheet now turns those sections into a responsive dashboard with clear visual states.

  • Switch back to the existing styles.css tab.
  • Add the theme and page foundations by pasting this code:
:root {
  color-scheme: dark;
  font-family: system-ui, sans-serif;
  background: #07111f;
  color: #edf5ff;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  min-height: 100vh;
  background:
    radial-gradient(circle at top left, rgba(56, 189, 248, 0.16), transparent 32rem),
    #07111f;
}

button {
  font: inherit;
}

What do the foundation styles control?

The root rules establish the dark palette and shared typeface. The universal sizing rule keeps borders and padding inside each element's declared width.

  • Save styles.css.
  • Return to Safari.
  • Refresh the page.

You should see a dark navy background with light text and a blue glow in the top-left corner.

Still seeing a white page?

Confirm that index.html still links styles.css. Check that the stylesheet was saved inside the surge-pricing-lab folder.

Get help checking the stylesheet connection. Help me find why my local HTML page is not loading styles.css.

  • Add the page width and card foundations to the bottom of styles.css by pasting this code:
.page-shell {
  width: min(1120px, calc(100% - 32px));
  margin: 0 auto;
  padding: 48px 0 72px;
}

.hero,
.panel,
.zone-card,
.design-card {
  border: 1px solid #26364a;
  border-radius: 18px;
  background: rgba(12, 25, 43, 0.92);
  box-shadow: 0 18px 50px rgba(0, 0, 0, 0.22);
}

.hero {
  padding: 32px;
  margin-bottom: 24px;
}

How do the shared card styles help?

The page shell limits the content width on large screens. The shared border and background rules give every major panel a consistent visual boundary.

  • Save styles.css.
  • Refresh the page in Safari.

You should see the dashboard centered inside the browser. The hero and log panel now have rounded dark surfaces.

Are the panels still full width?

Check that the HTML wrapper uses class="page-shell". Confirm that the class selector in styles.css begins with a full stop.

Ask for help matching the class names. Help me connect my page-shell and card CSS to the HTML classes.

  • Add the heading and label typography to the bottom of styles.css by pasting this code:
h1,
h2,
h3,
p {
  margin-top: 0;
}

h1 {
  max-width: 760px;
  margin-bottom: 12px;
  font-size: clamp(2rem, 5vw, 4rem);
  line-height: 1.02;
}

h2 {
  margin-bottom: 16px;
}

.eyebrow,
.section-kicker,
.cell-id,
.design-label {
  color: #7dd3fc;
  font-size: 0.76rem;
  font-weight: 800;
  letter-spacing: 0.12em;
  text-transform: uppercase;
}

What creates the dashboard hierarchy?

The large heading anchors the page. The smaller uppercase labels distinguish context such as the scenario, cell ID, and design type.

  • Save styles.css.
  • Refresh the page in Safari.

You should see a larger main heading with compact blue labels above the major dashboard sections.

Do the labels look unchanged?

Verify that the HTML uses eyebrow, section-kicker, cell-id, and design-label as class names.

Get help comparing the selectors. Help me find why my dashboard labels are not using the shared typography styles.

  • Add the hero copy and control panel layout to the bottom of styles.css by pasting this code:
.hero-copy {
  max-width: 760px;
  color: #c8d7e8;
  font-size: 1.08rem;
}

.disclaimer {
  margin-bottom: 0;
  padding: 12px 14px;
  border-left: 3px solid #fbbf24;
  color: #fde68a;
  background: rgba(251, 191, 36, 0.08);
}

.controls {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 24px;
  padding: 24px;
  margin-bottom: 32px;
}

Why separate the controls from the hero?

The control panel gives the scenario actions their own working area. The disclaimer style is ready to highlight the model boundary when you add the note.

  • Save styles.css.
  • Refresh the page in Safari.

You should see the scenario title and buttons share a padded panel. The control groups sit on opposite sides of the row.

Are the controls still stacked?

Confirm that the scenario section uses both panel and controls in its class attribute. Check that the display value is flex.

Ask for help with the control layout. Help me fix the flex layout for my scenario controls.

  • Add the scenario status and button styles to the bottom of styles.css by pasting this code:
#scenario-status {
  margin-bottom: 0;
  color: #a8bad0;
}

.button-row {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
}

button {
  border: 0;
  border-radius: 10px;
  padding: 11px 15px;
  color: #06111e;
  background: #7dd3fc;
  cursor: pointer;
  font-weight: 800;
}

button:hover {
  background: #bae6fd;
}

button.secondary {
  color: #dbeafe;
  background: #24364e;
}

How do the controls show priority?

The blue buttons highlight the two scenario actions. The darker secondary style gives Reset a lower visual priority while keeping it available.

  • Save styles.css.
  • Refresh the page in Safari.

You should see blue action buttons and a darker Reset button. The buttons wrap if the available width becomes narrow.

Do all buttons look identical?

Check that the Reset button includes class="secondary". Confirm that the button.secondary rule appears after the general button rule.

Get help with the selector order. Help me fix the primary and secondary button styles.

  • Add the responsive card grids to the bottom of styles.css by pasting this code:
.zone-grid,
.comparison-grid {
  display: grid;
  gap: 16px;
}

.zone-grid {
  grid-template-columns: repeat(3, 1fr);
  margin-bottom: 36px;
}

.comparison-grid {
  grid-template-columns: repeat(2, 1fr);
  margin-bottom: 32px;
}

.zone-card,
.design-card {
  padding: 22px;
}

.zone-card.stadium {
  border-color: #f97316;
  background: linear-gradient(145deg, rgba(124, 45, 18, 0.54), rgba(12, 25, 43, 0.94));
}

How does the layout reinforce isolation?

The three-column grid gives every location equal space. Stadium receives a warm border so the later demand spike has a clear geographic focus.

  • Save styles.css.
  • Refresh the page in Safari.

You should see three cell cards in one row. The two pricing cards should appear side by side beneath them.

Are the cards still in one column?

Confirm that the cell container uses zone-grid. Confirm that the pricing container uses comparison-grid.

Ask for help matching the grid classes. Help me fix the geographic cell and pricing comparison grids.

  • Add the cell and metric row styles to the bottom of styles.css by pasting this code:
.zone-card h3,
.design-card h3 {
  margin-bottom: 18px;
  font-size: 1.65rem;
}

dl {
  margin: 0;
}

.zone-card dl,
.metrics {
  display: grid;
  gap: 10px;
}

.zone-card dl div,
.metrics div {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding-top: 10px;
  border-top: 1px solid #26364a;
}

What makes the metrics scannable?

Each label sits opposite its value. The separating line turns every demand, supply, target, and freshness measure into a distinct row.

  • Save styles.css.
  • Refresh the page in Safari.

You should see aligned labels and values inside every cell and pricing card.

Are the metric values misaligned?

Check that every metric row is a div inside a dl element. Confirm that the pricing lists use the metrics class.

Get help checking the row structure. Help me align the labels and values in my dashboard cards.

  • Add the pricing state colors to the bottom of styles.css by pasting this code:
dt {
  color: #9eb0c6;
}

dd {
  margin: 0;
  font-weight: 800;
}

.periodic-card {
  border-color: #fb7185;
}

.event-card {
  border-color: #34d399;
}

.periodic-card h3 {
  color: #fda4af;
}

.event-card h3 {
  color: #6ee7b7;
}

Why use different pricing colors?

The pink periodic state and green event-driven state remain easy to distinguish across every tick. That color mapping helps the comparison stay readable as the values change.

  • Save styles.css.
  • Refresh the page in Safari.

You should see a pink border and quote on the periodic card. The event-driven card should use green.

Are the pricing colors missing?

Confirm that the periodic article uses periodic-card. Confirm that the event-driven article uses event-card.

Ask for help matching the state selectors. Help me fix the periodic and event-driven card colors.

  • Add the comparison log styles to the bottom of styles.css by pasting this code:
.panel {
  padding: 24px;
}

#comparison-log {
  margin: 0;
  min-height: 220px;
  overflow-x: auto;
  padding: 18px;
  border-radius: 12px;
  color: #d9f2ff;
  background: #030712;
  font: 0.88rem/1.75 ui-monospace, monospace;
  white-space: pre;
}

Why use a monospace log?

Monospace characters occupy consistent widths. Future tick values can line up in columns so differences remain easy to compare.

  • Save styles.css.
  • Refresh the page in Safari.

You should see a dark inset log area with enough height for the upcoming tick history.

Is the log area invisible?

Check that the pre element uses id="comparison-log". Confirm that the ID selector begins with # in the stylesheet.

Get help connecting the log selector. Help me find why the comparison log styles are not applied.

  • Add the mobile breakpoint to the bottom of styles.css by pasting this code:
@media (max-width: 760px) {
  .controls {
    align-items: flex-start;
    flex-direction: column;
  }

  .zone-grid,
  .comparison-grid {
    grid-template-columns: 1fr;
  }
}

What changes on a narrow screen?

The controls stack vertically when space becomes limited. Both card grids collapse to one column so labels and values stay readable.

  • Save styles.css.
  • Make the Safari window narrower.

You should see the control groups and cards stack into a single column when the window becomes narrow.

Does the layout overflow on a narrow screen?

Confirm that the media rule closes with its final brace. Check that both zone-grid and comparison-grid appear inside the rule.

Ask for help checking the breakpoint. Help me fix the mobile layout for my dashboard grids.

✔️ Awesome, I've got everything!

Great. Your styles.css file now gives the dashboard its dark responsive layout.

ⓧ I'd like to double check the full code

Compare your saved styles.css file with this complete version:

:root {
  color-scheme: dark;
  font-family: system-ui, sans-serif;
  background: #07111f;
  color: #edf5ff;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  min-height: 100vh;
  background:
    radial-gradient(circle at top left, rgba(56, 189, 248, 0.16), transparent 32rem),
    #07111f;
}

button {
  font: inherit;
}

.page-shell {
  width: min(1120px, calc(100% - 32px));
  margin: 0 auto;
  padding: 48px 0 72px;
}

.hero,
.panel,
.zone-card,
.design-card {
  border: 1px solid #26364a;
  border-radius: 18px;
  background: rgba(12, 25, 43, 0.92);
  box-shadow: 0 18px 50px rgba(0, 0, 0, 0.22);
}

.hero {
  padding: 32px;
  margin-bottom: 24px;
}

h1,
h2,
h3,
p {
  margin-top: 0;
}

h1 {
  max-width: 760px;
  margin-bottom: 12px;
  font-size: clamp(2rem, 5vw, 4rem);
  line-height: 1.02;
}

h2 {
  margin-bottom: 16px;
}

.eyebrow,
.section-kicker,
.cell-id,
.design-label {
  color: #7dd3fc;
  font-size: 0.76rem;
  font-weight: 800;
  letter-spacing: 0.12em;
  text-transform: uppercase;
}

.hero-copy {
  max-width: 760px;
  color: #c8d7e8;
  font-size: 1.08rem;
}

.disclaimer {
  margin-bottom: 0;
  padding: 12px 14px;
  border-left: 3px solid #fbbf24;
  color: #fde68a;
  background: rgba(251, 191, 36, 0.08);
}

.controls {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 24px;
  padding: 24px;
  margin-bottom: 32px;
}

#scenario-status {
  margin-bottom: 0;
  color: #a8bad0;
}

.button-row {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
}

button {
  border: 0;
  border-radius: 10px;
  padding: 11px 15px;
  color: #06111e;
  background: #7dd3fc;
  cursor: pointer;
  font-weight: 800;
}

button:hover {
  background: #bae6fd;
}

button.secondary {
  color: #dbeafe;
  background: #24364e;
}

.zone-grid,
.comparison-grid {
  display: grid;
  gap: 16px;
}

.zone-grid {
  grid-template-columns: repeat(3, 1fr);
  margin-bottom: 36px;
}

.comparison-grid {
  grid-template-columns: repeat(2, 1fr);
  margin-bottom: 32px;
}

.zone-card,
.design-card {
  padding: 22px;
}

.zone-card.stadium {
  border-color: #f97316;
  background: linear-gradient(145deg, rgba(124, 45, 18, 0.54), rgba(12, 25, 43, 0.94));
}

.zone-card h3,
.design-card h3 {
  margin-bottom: 18px;
  font-size: 1.65rem;
}

dl {
  margin: 0;
}

.zone-card dl,
.metrics {
  display: grid;
  gap: 10px;
}

.zone-card dl div,
.metrics div {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding-top: 10px;
  border-top: 1px solid #26364a;
}

dt {
  color: #9eb0c6;
}

dd {
  margin: 0;
  font-weight: 800;
}

.periodic-card {
  border-color: #fb7185;
}

.event-card {
  border-color: #34d399;
}

.periodic-card h3 {
  color: #fda4af;
}

.event-card h3 {
  color: #6ee7b7;
}

.panel {
  padding: 24px;
}

#comparison-log {
  margin: 0;
  min-height: 220px;
  overflow-x: auto;
  padding: 18px;
  border-radius: 12px;
  color: #d9f2ff;
  background: #030712;
  font: 0.88rem/1.75 ui-monospace, monospace;
  white-space: pre;
}

@media (max-width: 760px) {
  .controls {
    align-items: flex-start;
    flex-direction: column;
  }

  .zone-grid,
  .comparison-grid {
    grid-template-columns: 1fr;
  }
}
State the educational formula boundary

A clear boundary keeps the experiment honest. This dashboard uses a simple demand-to-supply ratio so freshness remains measurable without claiming to reproduce Uber's private pricing logic.

  • Switch back to index.html.
  • Find the closing </p> element for hero-copy.
  • Add the educational formula note immediately below it:
      <p class="disclaimer">
        Educational model only. The target multiplier is demand divided by supply, bounded from 1.0x to 3.0x. It is not Uber's pricing formula.
      </p>

Why state the formula boundary?

The note identifies the exact rule used by this simulator. It also separates the educational multiplier from Uber's private implementation.

  • Save index.html.

Before you refresh, which parts of the dashboard do you expect to appear as separate cards?

  • Refresh the local page in Safari.

You should see Stadium, Downtown, and Riverside as separate cells. You should also see periodic and event-driven comparison cards beside each other on a wide screen.

The yellow note should state that the demand-to-supply multiplier is bounded from 1.0x to 3.0x. It should also state that this is not Uber's formula.

Is the formula note unstyled?

Confirm that the paragraph uses class="disclaimer". Check that it appears inside the hero before the closing </header> element.

Ask for help checking the final hero structure. Help me fix the placement or styling of my educational formula disclaimer.

✔️ Awesome, I've got everything!

That's the dashboard foundation complete. Save index.html before moving on.

ⓧ I'd like to double check the full code

Compare your saved index.html file with this complete version:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Surge Pricing Freshness Lab</title>
  <link rel="stylesheet" href="styles.css">
  <script src="app.js" defer></script>
</head>
<body>
  <main class="page-shell">
    <header class="hero">
      <p class="eyebrow">Local system design lab</p>
      <h1>Uber-Style Surge Pricing Freshness Lab</h1>
      <p class="hero-copy">
        Compare a four-tick scheduled refresh with an event-driven update during a deterministic concert spike.
      </p>
      <p class="disclaimer">
        Educational model only. The target multiplier is demand divided by supply, bounded from 1.0x to 3.0x. It is not Uber's pricing formula.
      </p>
    </header>

    <section class="panel controls" aria-labelledby="scenario-title">
      <div>
        <p class="section-kicker">Deterministic scenario</p>
        <h2 id="scenario-title">Concert demand spike</h2>
        <p id="scenario-status" aria-live="polite"></p>
      </div>
      <div class="button-row">
        <button id="next-button" type="button">Advance One Tick</button>
        <button id="run-button" type="button">Run Full Scenario</button>
        <button id="reset-button" class="secondary" type="button">Reset</button>
      </div>
    </section>

    <section aria-labelledby="cells-title">
      <p class="section-kicker">Geographic partition view</p>
      <h2 id="cells-title">Marketplace cells</h2>
      <div class="zone-grid">
        <article class="zone-card stadium">
          <p class="cell-id">Cell A</p>
          <h3>Stadium</h3>
          <dl>
            <div><dt>Demand</dt><dd id="stadium-demand">0</dd></div>
            <div><dt>Supply</dt><dd id="stadium-supply">0</dd></div>
            <div><dt>Target</dt><dd id="stadium-target">1.0x</dd></div>
          </dl>
        </article>

        <article class="zone-card">
          <p class="cell-id">Cell B</p>
          <h3>Downtown</h3>
          <dl>
            <div><dt>Demand</dt><dd id="downtown-demand">0</dd></div>
            <div><dt>Supply</dt><dd id="downtown-supply">0</dd></div>
            <div><dt>Target</dt><dd id="downtown-target">1.0x</dd></div>
          </dl>
        </article>

        <article class="zone-card">
          <p class="cell-id">Cell C</p>
          <h3>Riverside</h3>
          <dl>
            <div><dt>Demand</dt><dd id="riverside-demand">0</dd></div>
            <div><dt>Supply</dt><dd id="riverside-supply">0</dd></div>
            <div><dt>Target</dt><dd id="riverside-target">1.0x</dd></div>
          </dl>
        </article>
      </div>
    </section>

    <section aria-labelledby="comparison-title">
      <p class="section-kicker">Before and after</p>
      <h2 id="comparison-title">Pricing freshness comparison</h2>
      <div class="comparison-grid">
        <article class="design-card periodic-card">
          <p class="design-label">Periodic refresh</p>
          <h3 id="periodic-quote">1.0x</h3>
          <dl class="metrics">
            <div><dt>Quote age</dt><dd id="periodic-age">0 ticks</dd></div>
            <div><dt>Stale quotes</dt><dd id="periodic-stale">0</dd></div>
            <div><dt>Cumulative error</dt><dd id="periodic-error">0.0</dd></div>
          </dl>
        </article>

        <article class="design-card event-card">
          <p class="design-label">Event-driven update</p>
          <h3 id="event-quote">1.0x</h3>
          <dl class="metrics">
            <div><dt>Quote age</dt><dd id="event-age">0 ticks</dd></div>
            <div><dt>Stale quotes</dt><dd id="event-stale">0</dd></div>
            <div><dt>Cumulative error</dt><dd id="event-error">0.0</dd></div>
          </dl>
        </article>
      </div>
    </section>

    <section class="panel" aria-labelledby="log-title">
      <p class="section-kicker">Quantitative evidence</p>
      <h2 id="log-title">Tick comparison log</h2>
      <pre id="comparison-log" aria-live="polite"></pre>
    </section>
  </main>
</body>
</html>

Your browser now shows the full experiment surface with isolated cells and two pricing paths. Next up, you will make the periodic path serve stale quotes during the concert spike and measure the error it creates.

Measure the Periodic Refresh Failure

The dashboard in Safari now separates Stadium from the two stable geographic cells. A periodic refresh can look reliable while demand is calm.

The concert spike lands between scheduled refreshes. In this step, you will measure how long Stadium keeps serving an old quote.

In this step, get ready to:
  • Define the fixed eight-tick concert scenario.
  • Process Stadium prices on a four-tick refresh schedule.
  • Measure four stale quotes with 7.4 cumulative pricing error.
Define the deterministic scenario

A deterministic scenario feeds the same snapshots into every run. This keeps timing as the only changing factor in the comparison.

  • Switch back to Visual Studio Code.
  • Select app.js in the Explorer sidebar.
  • Add the four-tick cadence plus the eight snapshots by pasting this code:
const REFRESH_EVERY_TICKS = 4;

const scenario = [
  { tick: 0, phase: "Baseline", stadium: { demand: 8, supply: 10 }, downtown: { demand: 7, supply: 8 }, riverside: { demand: 4, supply: 6 } },
  { tick: 1, phase: "Pre-concert", stadium: { demand: 10, supply: 10 }, downtown: { demand: 7, supply: 8 }, riverside: { demand: 4, supply: 6 } },
  { tick: 2, phase: "Concert spike", stadium: { demand: 30, supply: 10 }, downtown: { demand: 7, supply: 8 }, riverside: { demand: 4, supply: 6 } },
  { tick: 3, phase: "Spike continues", stadium: { demand: 30, supply: 10 }, downtown: { demand: 7, supply: 8 }, riverside: { demand: 4, supply: 6 } },
  { tick: 4, phase: "Scheduled refresh", stadium: { demand: 30, supply: 10 }, downtown: { demand: 7, supply: 8 }, riverside: { demand: 4, supply: 6 } },
  { tick: 5, phase: "Peak demand", stadium: { demand: 30, supply: 10 }, downtown: { demand: 7, supply: 8 }, riverside: { demand: 4, supply: 6 } },
  { tick: 6, phase: "Drivers arrive", stadium: { demand: 18, supply: 14 }, downtown: { demand: 7, supply: 8 }, riverside: { demand: 4, supply: 6 } },
  { tick: 7, phase: "Recovery", stadium: { demand: 18, supply: 14 }, downtown: { demand: 7, supply: 8 }, riverside: { demand: 4, supply: 6 } }
];

What does this scenario model?

  • The first two snapshots establish balanced Stadium demand.
  • Ticks 2 and 3 raise Stadium demand from 10 requests to 30 requests.
  • Tick 4 reaches the next scheduled refresh boundary.
  • Ticks 6 and 7 model recovery as more drivers become available.
  • Save app.js.
  • Return to Safari.
  • Refresh the dashboard.
  • Open Web Inspector by pressing Cmd+Option+I.
  • Use the console prompt to evaluate the number of snapshots with:
scenario.length

What does this check prove?

You should see 8 in the console. The browser has loaded every snapshot from app.js.

Web Inspector did not open?

In Safari, select Safari from the menu bar. Select Settings.

Select Advanced and enable the web developer features. Try Cmd+Option+I again.

Ask for help with opening Safari Web Inspector.

The dashboard already gives each dynamic value a unique element ID. A single element map keeps those references together for every tick.

  • Return to app.js.
  • Place your cursor on a blank line below scenario.
  • Connect the script to the existing dashboard elements by pasting:
const elements = {
  status: document.getElementById("scenario-status"),
  nextButton: document.getElementById("next-button"),
  runButton: document.getElementById("run-button"),
  resetButton: document.getElementById("reset-button"),
  stadiumDemand: document.getElementById("stadium-demand"),
  stadiumSupply: document.getElementById("stadium-supply"),
  stadiumTarget: document.getElementById("stadium-target"),
  downtownDemand: document.getElementById("downtown-demand"),
  downtownSupply: document.getElementById("downtown-supply"),
  downtownTarget: document.getElementById("downtown-target"),
  riversideDemand: document.getElementById("riverside-demand"),
  riversideSupply: document.getElementById("riverside-supply"),
  riversideTarget: document.getElementById("riverside-target"),
  periodicQuote: document.getElementById("periodic-quote"),
  periodicAge: document.getElementById("periodic-age"),
  periodicStale: document.getElementById("periodic-stale"),
  periodicError: document.getElementById("periodic-error"),
  eventQuote: document.getElementById("event-quote"),
  eventAge: document.getElementById("event-age"),
  eventStale: document.getElementById("event-stale"),
  eventError: document.getElementById("event-error"),
  log: document.getElementById("comparison-log")
};

What does this element map do?

Each property stores one existing dashboard element. The processor can update a named property without searching the page again.

Every ID matches the markup you created earlier. A mismatched ID returns no usable element.

  • Save app.js.
  • Return to Safari.
  • Refresh the dashboard.
  • Confirm the first control was found by evaluating:
elements.nextButton

What should you see?

The console should show the button with the next-button ID. This proves the element map can reach the scenario controls.

Seeing an undefined element?

Check that the property is spelled nextButton. Confirm that index.html still contains id="next-button".

Ask for help with checking the element map.

The simulation state tracks where the run has reached. Separate counters capture how often the served quote differs from the current target.

  • Return to app.js.
  • Add the state variables plus the pricing helpers below elements by pasting:
let cursor = 0;
let periodicQuote = 1;
let periodicUpdatedAt = 0;
let periodicStaleQuotes = 0;
let periodicCumulativeError = 0;
let eventQuote = 1;
let eventStaleQuotes = 0;
let eventCumulativeError = 0;

function calculateTargetMultiplier(demand, supply) {
  if (supply <= 0) return 3;
  const ratio = demand / supply;
  if (ratio < 1) return 1;
  if (ratio > 3) return 3;
  return ratio;
}

function absoluteDifference(left, right) {
  const difference = left - right;
  return difference < 0 ? -difference : difference;
}

function formatMultiplier(value) {
  return value.toFixed(1) + "x";
}

function formatError(value) {
  return value.toFixed(1);
}

How is the result measured?

  • The pricing helper divides demand by supply.
  • The lower bound keeps the target at least 1.0x.
  • The upper bound keeps the target at most 3.0x.
  • The difference helper turns each quote mismatch into a positive error value.
  • Save app.js.
  • Return to Safari.
  • Refresh the dashboard.
  • Check the initial error formatting by evaluating:
formatError(periodicCumulativeError)

What does the formatted value show?

You should see 0.0 in the console. The periodic path starts without any accumulated error.

Seeing a JavaScript syntax error?

Check the closing braces for all four helper functions. Confirm that each state variable appears above the first function.

Ask for help with finding the syntax problem.

Calculate and render periodic freshness

The periodic quote changes only when snapshot.tick % REFRESH_EVERY_TICKS === 0 is true. Every snapshot between those boundaries increases the quote age.

  • Return to app.js.
  • Add the cell renderer below the formatting helpers by pasting:
function renderCell(prefix, cell) {
  const target = calculateTargetMultiplier(cell.demand, cell.supply);
  elements[prefix + "Demand"].textContent = cell.demand;
  elements[prefix + "Supply"].textContent = cell.supply;
  elements[prefix + "Target"].textContent = formatMultiplier(target);
}

What does the renderer do?

The prefix selects Stadium, Downtown, or Riverside properties from the element map. The same function then writes demand, supply, and the formatted target into that cell.

  • Save app.js.
  • Return to Safari.
  • Refresh the dashboard.
  • Confirm the renderer loaded by evaluating:
renderCell

What should the console show?

The console should display the renderCell function. This confirms Safari loaded the renderer without a syntax error.

Renderer missing from the console?

Confirm that renderCell sits outside the previous helper function. Check for a missing closing brace above it.

Ask for help with checking the renderer placement.

The tick processor coordinates one complete snapshot. It calculates the target before deciding whether the scheduled quote is allowed to refresh.

  • Paste the first half of processNextTick() below renderCell(). This segment intentionally stays open for the next block:
function processNextTick() {
  if (cursor >= scenario.length) {
    elements.status.textContent = "Scenario complete. Reset to replay the deterministic comparison.";
    return;
  }

  const snapshot = scenario[cursor];
  const target = calculateTargetMultiplier(snapshot.stadium.demand, snapshot.stadium.supply);

  if (snapshot.tick % REFRESH_EVERY_TICKS === 0) {
    periodicQuote = target;
    periodicUpdatedAt = snapshot.tick;
  }

  eventQuote = target;
  const periodicError = absoluteDifference(periodicQuote, target);
  const eventError = absoluteDifference(eventQuote, target);

  if (periodicError > 0.0001) periodicStaleQuotes += 1;
  if (eventError > 0.0001) eventStaleQuotes += 1;

  periodicCumulativeError += periodicError;
  eventCumulativeError += eventError;

What happens in the first half?

  • The cursor selects the next snapshot.
  • The target uses the current Stadium demand and supply.
  • The periodic quote refreshes only at a four-tick boundary.
  • A nonzero difference increments the stale-quote counter.
  • Complete the open processNextTick() function by pasting the second half immediately after part one:
  renderCell("stadium", snapshot.stadium);
  renderCell("downtown", snapshot.downtown);
  renderCell("riverside", snapshot.riverside);

  elements.periodicQuote.textContent = formatMultiplier(periodicQuote);
  elements.periodicAge.textContent = (snapshot.tick - periodicUpdatedAt) + " ticks";
  elements.periodicStale.textContent = periodicStaleQuotes;
  elements.periodicError.textContent = formatError(periodicCumulativeError);
  elements.eventQuote.textContent = formatMultiplier(eventQuote);
  elements.eventAge.textContent = "0 ticks";
  elements.eventStale.textContent = eventStaleQuotes;
  elements.eventError.textContent = formatError(eventCumulativeError);

  elements.log.textContent +=
    snapshot.tick + "\t" + snapshot.phase + "\t" + snapshot.stadium.demand + "\t" +
    snapshot.stadium.supply + "\t" + formatMultiplier(target) + "\t" +
    formatMultiplier(periodicQuote) + "\t" + formatError(periodicError) + "\t" +
    formatMultiplier(eventQuote) + "\t" + formatError(eventError) + "\n";

  cursor += 1;

  if (cursor === scenario.length) {
    elements.status.textContent =
      "Complete: periodic refresh served 4 stale quotes with 7.4 cumulative error; event-driven served 0 stale quotes with 0.0 error.";
  } else {
    elements.status.textContent = "Processed tick " + snapshot.tick + ": " + snapshot.phase + ".";
  }
}

What happens in the second half?

The renderer updates all three cells plus both comparison cards. The log records the target, served quote, and error for the current Stadium snapshot.

The cursor advances after rendering. The final status summarizes the deterministic totals once all eight snapshots are processed.

  • Save app.js.
  • Return to Safari.
  • Refresh the dashboard.
  • Process the baseline snapshot from the console with:
processNextTick();

What should the baseline show?

You should see Stadium demand at 8 with supply at 10. The target and periodic quote should both show 1.0x.

That visible update proves the complete tick processor is running. Your lab can now turn each snapshot into measurable pricing state.

Dashboard values did not update?

Check the console for a message that identifies a missing element. Compare that element ID with the corresponding ID in index.html.

Confirm that both halves of processNextTick() are inside the same pair of outer braces.

Ask for help with debugging the tick processor.

Run the periodic failure experiment

The processor can handle one snapshot. The remaining controls make the experiment repeatable from a clean baseline.

  • Return to app.js.
  • Add the full-scenario runner below processNextTick() by pasting:
function runFullScenario() {
  while (cursor < scenario.length) processNextTick();
}

What does the runner do?

The loop processes snapshots until the cursor reaches the scenario length. Every run uses the same eight inputs in the same order.

  • Add the reset function below runFullScenario() by pasting:
function resetScenario() {
  cursor = 0;
  periodicQuote = 1;
  periodicUpdatedAt = 0;
  periodicStaleQuotes = 0;
  periodicCumulativeError = 0;
  eventQuote = 1;
  eventStaleQuotes = 0;
  eventCumulativeError = 0;
  elements.log.textContent =
    "Tick\tPhase\tDemand\tSupply\tTarget\tPeriodic\tP Error\tEvent\tE Error\n";
  processNextTick();
}

Why reset every metric?

Resetting clears the previous cursor plus every accumulated counter. It also restores the log header before processing the baseline snapshot.

This makes repeated runs directly comparable. Old errors cannot leak into the next result.

  • Save app.js.
  • Return to Safari.
  • Refresh the dashboard.
  • Test the clean baseline from the console with:
resetScenario();

What should reset change?

You should see the baseline snapshot again. The stale-quote count should show 0 and cumulative error should show 0.0.

Old results still visible?

Confirm that every counter assignment inside resetScenario() uses 0. Check that the function calls processNextTick() after rebuilding the log header.

Ask for help with resetting the simulation state.

  • Connect the three existing buttons below resetScenario() by pasting:
elements.nextButton.addEventListener("click", processNextTick);
elements.runButton.addEventListener("click", runFullScenario);
elements.resetButton.addEventListener("click", resetScenario);

resetScenario();

How do the controls work?

  • The next button processes one snapshot.
  • The run button processes every remaining snapshot.
  • The reset button restores the deterministic baseline.
  • The final function call initializes the dashboard when the page loads.
  • Save app.js.
  • Return to Safari.
  • Refresh the dashboard.

Before you advance to tick 2, do you expect the periodic quote to react immediately when Stadium demand triples?

  • Select Reset.
  • Select Advance One Tick twice.

At tick 2, you should see Stadium demand at 30 with a 3.0x target. The periodic quote still shows 1.0x.

  • Select Advance One Tick once more.

At tick 3, the periodic quote still shows 1.0x while the target remains 3.0x. The scheduled design has now served two stale quotes.

Why is the periodic quote stale?

The last scheduled update occurred at tick 0. Tick 2 changes the target between refresh boundaries, so the served quote cannot react until tick 4.

The same delay appears during recovery. Ticks 6 and 7 retain the old peak quote after driver supply improves.

Before you run the complete scenario, which ticks do you expect to contribute to the final stale-quote total?

  • Select Reset.
  • Select Run Full Scenario.

You should see 4 stale quotes plus 7.4 cumulative pricing error on the periodic card. The log shows mismatches at ticks 2, 3, 6, and 7.

Do the totals differ?

Select Reset before each complete run. Starting from a partially processed scenario changes which snapshots the run button has left to process.

Check that the periodic refresh condition uses snapshot.tick % REFRESH_EVERY_TICKS === 0. Confirm that REFRESH_EVERY_TICKS is set to 4.

Ask for help with debugging the periodic totals.

✔️ Awesome, I've got everything!

Your deterministic scenario now reports four stale periodic quotes with 7.4 cumulative pricing error. Double check that you saved app.js.

ⓧ I'd like to double check the full code

Compare your complete app.js file with this reference:

const REFRESH_EVERY_TICKS = 4;

const scenario = [
  { tick: 0, phase: "Baseline", stadium: { demand: 8, supply: 10 }, downtown: { demand: 7, supply: 8 }, riverside: { demand: 4, supply: 6 } },
  { tick: 1, phase: "Pre-concert", stadium: { demand: 10, supply: 10 }, downtown: { demand: 7, supply: 8 }, riverside: { demand: 4, supply: 6 } },
  { tick: 2, phase: "Concert spike", stadium: { demand: 30, supply: 10 }, downtown: { demand: 7, supply: 8 }, riverside: { demand: 4, supply: 6 } },
  { tick: 3, phase: "Spike continues", stadium: { demand: 30, supply: 10 }, downtown: { demand: 7, supply: 8 }, riverside: { demand: 4, supply: 6 } },
  { tick: 4, phase: "Scheduled refresh", stadium: { demand: 30, supply: 10 }, downtown: { demand: 7, supply: 8 }, riverside: { demand: 4, supply: 6 } },
  { tick: 5, phase: "Peak demand", stadium: { demand: 30, supply: 10 }, downtown: { demand: 7, supply: 8 }, riverside: { demand: 4, supply: 6 } },
  { tick: 6, phase: "Drivers arrive", stadium: { demand: 18, supply: 14 }, downtown: { demand: 7, supply: 8 }, riverside: { demand: 4, supply: 6 } },
  { tick: 7, phase: "Recovery", stadium: { demand: 18, supply: 14 }, downtown: { demand: 7, supply: 8 }, riverside: { demand: 4, supply: 6 } }
];

const elements = {
  status: document.getElementById("scenario-status"),
  nextButton: document.getElementById("next-button"),
  runButton: document.getElementById("run-button"),
  resetButton: document.getElementById("reset-button"),
  stadiumDemand: document.getElementById("stadium-demand"),
  stadiumSupply: document.getElementById("stadium-supply"),
  stadiumTarget: document.getElementById("stadium-target"),
  downtownDemand: document.getElementById("downtown-demand"),
  downtownSupply: document.getElementById("downtown-supply"),
  downtownTarget: document.getElementById("downtown-target"),
  riversideDemand: document.getElementById("riverside-demand"),
  riversideSupply: document.getElementById("riverside-supply"),
  riversideTarget: document.getElementById("riverside-target"),
  periodicQuote: document.getElementById("periodic-quote"),
  periodicAge: document.getElementById("periodic-age"),
  periodicStale: document.getElementById("periodic-stale"),
  periodicError: document.getElementById("periodic-error"),
  eventQuote: document.getElementById("event-quote"),
  eventAge: document.getElementById("event-age"),
  eventStale: document.getElementById("event-stale"),
  eventError: document.getElementById("event-error"),
  log: document.getElementById("comparison-log")
};

let cursor = 0;
let periodicQuote = 1;
let periodicUpdatedAt = 0;
let periodicStaleQuotes = 0;
let periodicCumulativeError = 0;
let eventQuote = 1;
let eventStaleQuotes = 0;
let eventCumulativeError = 0;

function calculateTargetMultiplier(demand, supply) {
  if (supply <= 0) return 3;
  const ratio = demand / supply;
  if (ratio < 1) return 1;
  if (ratio > 3) return 3;
  return ratio;
}

function absoluteDifference(left, right) {
  const difference = left - right;
  return difference < 0 ? -difference : difference;
}

function formatMultiplier(value) {
  return value.toFixed(1) + "x";
}

function formatError(value) {
  return value.toFixed(1);
}

function renderCell(prefix, cell) {
  const target = calculateTargetMultiplier(cell.demand, cell.supply);
  elements[prefix + "Demand"].textContent = cell.demand;
  elements[prefix + "Supply"].textContent = cell.supply;
  elements[prefix + "Target"].textContent = formatMultiplier(target);
}

function processNextTick() {
  if (cursor >= scenario.length) {
    elements.status.textContent = "Scenario complete. Reset to replay the deterministic comparison.";
    return;
  }

  const snapshot = scenario[cursor];
  const target = calculateTargetMultiplier(snapshot.stadium.demand, snapshot.stadium.supply);

  if (snapshot.tick % REFRESH_EVERY_TICKS === 0) {
    periodicQuote = target;
    periodicUpdatedAt = snapshot.tick;
  }

  eventQuote = target;
  const periodicError = absoluteDifference(periodicQuote, target);
  const eventError = absoluteDifference(eventQuote, target);

  if (periodicError > 0.0001) periodicStaleQuotes += 1;
  if (eventError > 0.0001) eventStaleQuotes += 1;

  periodicCumulativeError += periodicError;
  eventCumulativeError += eventError;

  renderCell("stadium", snapshot.stadium);
  renderCell("downtown", snapshot.downtown);
  renderCell("riverside", snapshot.riverside);

  elements.periodicQuote.textContent = formatMultiplier(periodicQuote);
  elements.periodicAge.textContent = (snapshot.tick - periodicUpdatedAt) + " ticks";
  elements.periodicStale.textContent = periodicStaleQuotes;
  elements.periodicError.textContent = formatError(periodicCumulativeError);
  elements.eventQuote.textContent = formatMultiplier(eventQuote);
  elements.eventAge.textContent = "0 ticks";
  elements.eventStale.textContent = eventStaleQuotes;
  elements.eventError.textContent = formatError(eventCumulativeError);

  elements.log.textContent +=
    snapshot.tick + "\t" + snapshot.phase + "\t" + snapshot.stadium.demand + "\t" +
    snapshot.stadium.supply + "\t" + formatMultiplier(target) + "\t" +
    formatMultiplier(periodicQuote) + "\t" + formatError(periodicError) + "\t" +
    formatMultiplier(eventQuote) + "\t" + formatError(eventError) + "\n";

  cursor += 1;

  if (cursor === scenario.length) {
    elements.status.textContent =
      "Complete: periodic refresh served 4 stale quotes with 7.4 cumulative error; event-driven served 0 stale quotes with 0.0 error.";
  } else {
    elements.status.textContent = "Processed tick " + snapshot.tick + ": " + snapshot.phase + ".";
  }
}

function runFullScenario() {
  while (cursor < scenario.length) processNextTick();
}

function resetScenario() {
  cursor = 0;
  periodicQuote = 1;
  periodicUpdatedAt = 0;
  periodicStaleQuotes = 0;
  periodicCumulativeError = 0;
  eventQuote = 1;
  eventStaleQuotes = 0;
  eventCumulativeError = 0;
  elements.log.textContent =
    "Tick\tPhase\tDemand\tSupply\tTarget\tPeriodic\tP Error\tEvent\tE Error\n";
  processNextTick();
}

elements.nextButton.addEventListener("click", processNextTick);
elements.runButton.addEventListener("click", runFullScenario);
elements.resetButton.addEventListener("click", resetScenario);

resetScenario();

You have turned a vague freshness concern into four stale quotes plus 7.4 multiplier-points of error. Next, you will use the second comparison lane to explain how event-driven updates close that stale interval.

Add Event-Driven Pricing Updates

Your periodic path has made the pricing freshness gap measurable. A fixed refresh interval leaves the Stadium quote stale when demand or supply changes between scheduled updates.

Now you will add event-driven processing to the same scenario. Each incoming Stadium snapshot becomes the trigger for a fresh quote.

In this step, get ready to:
  • Recompute the event-driven quote for every incoming Stadium snapshot.
  • Render the event-driven quote with its freshness metrics.
  • Compare both designs through the completed scenario log.
Recompute the quote for every snapshot

The periodic path uses the tick number as its update trigger. The event-driven path uses the arrival of each snapshot as its trigger.

  • In app.js, find the processNextTick() function.
  • Locate the closing brace beneath periodicUpdatedAt = snapshot.tick;.
  • Add the event-driven assignment immediately below that brace by entering this line:
  eventQuote = target;

What does this update do?

The target variable holds the multiplier calculated from the current Stadium snapshot. Assigning it to eventQuote keeps the event-driven result aligned with the latest demand and supply values.

  • Save app.js.
  • Switch back to Safari.
  • Refresh the local lab.

Before you process the concert spike, which quote do you expect to react first?

  • Select Reset.
  • Select Advance One Tick.
  • Select Advance One Tick again.

You will see the event-driven quote reach 3.0x at tick 2. The periodic quote remains at 1.0x until its scheduled refresh.

Event quote still showing 1.0x?

Check that eventQuote = target; sits outside the periodic if block. A line inside that block still updates only every four ticks.

Confirm that you saved app.js before refreshing Safari. Help me check why eventQuote is not updating for every snapshot.

Render the event-driven metrics

A fresh calculation only becomes useful when the dashboard exposes its result. The event card also needs quote age, stale-quote count, and cumulative error so both designs use the same evidence.

  • Return to app.js in Visual Studio Code.
  • Find the line that renders periodicCumulativeError.
  • Add the event-driven rendering lines immediately below it by entering this code:
  elements.eventQuote.textContent = formatMultiplier(eventQuote);
  elements.eventAge.textContent = "0 ticks";
  elements.eventStale.textContent = eventStaleQuotes;
  elements.eventError.textContent = formatError(eventCumulativeError);

What do these metrics show?

  • The event-driven quote displays the multiplier produced from the current snapshot.
  • The quote age stays at 0 ticks because every snapshot refreshes the price.
  • The stale count records snapshots where the published quote differs from the current target.
  • The cumulative error measures the total absolute difference across processed snapshots.
  • Save app.js.
  • Return to Safari.
  • Refresh the local lab.
  • Select Advance One Tick.
  • Select Advance One Tick again.

The event-driven card now shows 3.0x with an age of 0 ticks. Its stale count stays at 0. Its cumulative error stays at 0.0.

Event metrics not changing?

Check that each event metric uses an ID already stored in the elements object. Confirm that eventQuote is passed through formatMultiplier() before it reaches the card.

Make sure the four rendering lines remain inside processNextTick(). Help me debug the event-driven metrics in processNextTick.

Compare both paths in the event log

The comparison log gives both designs identical input values. Each row records the shared target before placing the periodic result beside the event-driven result.

  • In processNextTick(), find the block that appends a row to elements.log.textContent.
  • Replace that log block with this completed comparison block:
  elements.log.textContent +=
    snapshot.tick + "\t" + snapshot.phase + "\t" + snapshot.stadium.demand + "\t" +
    snapshot.stadium.supply + "\t" + formatMultiplier(target) + "\t" +
    formatMultiplier(periodicQuote) + "\t" + formatError(periodicError) + "\t" +
    formatMultiplier(eventQuote) + "\t" + formatError(eventError) + "\n";

How does the log prove freshness?

  • Every row starts with the tick, phase, demand, supply, and current target.
  • The periodic columns show the scheduled quote with its error for that tick.
  • The event columns show the snapshot-triggered quote with its error for the same tick.
  • Matching inputs make update strategy the only difference between the two paths.
  • Save app.js.
  • Return to Safari.
  • Refresh the local lab.

Before you run the complete scenario, which path do you expect to record pricing error at ticks 2, 3, 6, and 7?

  • Select Reset.
  • Select Run Full Scenario.
  • Read the completed periodic metrics.
  • Read the completed event-driven metrics.
  • Inspect the log rows for ticks 2, 3, 6, and 7.

You will see 4 stale periodic quotes with 7.4 cumulative error. The event-driven path reports 0 stale quotes with 0.0 cumulative error.

The four highlighted ticks show a non-zero periodic error. Their event-driven errors remain at 0.0 because each snapshot refreshes the quote immediately.

Final totals do not match?

Select Reset before replaying the scenario. Running the full scenario from a partially processed state changes which rows remain to be processed.

Check that eventQuote = target; runs before eventError is calculated. Help me trace incorrect totals in the deterministic comparison.

✔️ Awesome, I've got everything!

Great work. Double check that app.js is saved and that the completed scenario shows both pricing paths.

ⓧ I'd like to double check the full code

Compare your complete app.js file with this version:

const REFRESH_EVERY_TICKS = 4;

const scenario = [
  { tick: 0, phase: "Baseline", stadium: { demand: 8, supply: 10 }, downtown: { demand: 7, supply: 8 }, riverside: { demand: 4, supply: 6 } },
  { tick: 1, phase: "Pre-concert", stadium: { demand: 10, supply: 10 }, downtown: { demand: 7, supply: 8 }, riverside: { demand: 4, supply: 6 } },
  { tick: 2, phase: "Concert spike", stadium: { demand: 30, supply: 10 }, downtown: { demand: 7, supply: 8 }, riverside: { demand: 4, supply: 6 } },
  { tick: 3, phase: "Spike continues", stadium: { demand: 30, supply: 10 }, downtown: { demand: 7, supply: 8 }, riverside: { demand: 4, supply: 6 } },
  { tick: 4, phase: "Scheduled refresh", stadium: { demand: 30, supply: 10 }, downtown: { demand: 7, supply: 8 }, riverside: { demand: 4, supply: 6 } },
  { tick: 5, phase: "Peak demand", stadium: { demand: 30, supply: 10 }, downtown: { demand: 7, supply: 8 }, riverside: { demand: 4, supply: 6 } },
  { tick: 6, phase: "Drivers arrive", stadium: { demand: 18, supply: 14 }, downtown: { demand: 7, supply: 8 }, riverside: { demand: 4, supply: 6 } },
  { tick: 7, phase: "Recovery", stadium: { demand: 18, supply: 14 }, downtown: { demand: 7, supply: 8 }, riverside: { demand: 4, supply: 6 } }
];

const elements = {
  status: document.getElementById("scenario-status"),
  nextButton: document.getElementById("next-button"),
  runButton: document.getElementById("run-button"),
  resetButton: document.getElementById("reset-button"),
  stadiumDemand: document.getElementById("stadium-demand"),
  stadiumSupply: document.getElementById("stadium-supply"),
  stadiumTarget: document.getElementById("stadium-target"),
  downtownDemand: document.getElementById("downtown-demand"),
  downtownSupply: document.getElementById("downtown-supply"),
  downtownTarget: document.getElementById("downtown-target"),
  riversideDemand: document.getElementById("riverside-demand"),
  riversideSupply: document.getElementById("riverside-supply"),
  riversideTarget: document.getElementById("riverside-target"),
  periodicQuote: document.getElementById("periodic-quote"),
  periodicAge: document.getElementById("periodic-age"),
  periodicStale: document.getElementById("periodic-stale"),
  periodicError: document.getElementById("periodic-error"),
  eventQuote: document.getElementById("event-quote"),
  eventAge: document.getElementById("event-age"),
  eventStale: document.getElementById("event-stale"),
  eventError: document.getElementById("event-error"),
  log: document.getElementById("comparison-log")
};

let cursor = 0;
let periodicQuote = 1;
let periodicUpdatedAt = 0;
let periodicStaleQuotes = 0;
let periodicCumulativeError = 0;
let eventQuote = 1;
let eventStaleQuotes = 0;
let eventCumulativeError = 0;

function calculateTargetMultiplier(demand, supply) {
  if (supply <= 0) return 3;
  const ratio = demand / supply;
  if (ratio < 1) return 1;
  if (ratio > 3) return 3;
  return ratio;
}

function absoluteDifference(left, right) {
  const difference = left - right;
  return difference < 0 ? -difference : difference;
}

function formatMultiplier(value) {
  return value.toFixed(1) + "x";
}

function formatError(value) {
  return value.toFixed(1);
}

function renderCell(prefix, cell) {
  const target = calculateTargetMultiplier(cell.demand, cell.supply);
  elements[prefix + "Demand"].textContent = cell.demand;
  elements[prefix + "Supply"].textContent = cell.supply;
  elements[prefix + "Target"].textContent = formatMultiplier(target);
}

function processNextTick() {
  if (cursor >= scenario.length) {
    elements.status.textContent = "Scenario complete. Reset to replay the deterministic comparison.";
    return;
  }

  const snapshot = scenario[cursor];
  const target = calculateTargetMultiplier(snapshot.stadium.demand, snapshot.stadium.supply);

  if (snapshot.tick % REFRESH_EVERY_TICKS === 0) {
    periodicQuote = target;
    periodicUpdatedAt = snapshot.tick;
  }

  eventQuote = target;
  const periodicError = absoluteDifference(periodicQuote, target);
  const eventError = absoluteDifference(eventQuote, target);

  if (periodicError > 0.0001) periodicStaleQuotes += 1;
  if (eventError > 0.0001) eventStaleQuotes += 1;

  periodicCumulativeError += periodicError;
  eventCumulativeError += eventError;

  renderCell("stadium", snapshot.stadium);
  renderCell("downtown", snapshot.downtown);
  renderCell("riverside", snapshot.riverside);

  elements.periodicQuote.textContent = formatMultiplier(periodicQuote);
  elements.periodicAge.textContent = (snapshot.tick - periodicUpdatedAt) + " ticks";
  elements.periodicStale.textContent = periodicStaleQuotes;
  elements.periodicError.textContent = formatError(periodicCumulativeError);
  elements.eventQuote.textContent = formatMultiplier(eventQuote);
  elements.eventAge.textContent = "0 ticks";
  elements.eventStale.textContent = eventStaleQuotes;
  elements.eventError.textContent = formatError(eventCumulativeError);

  elements.log.textContent +=
    snapshot.tick + "\t" + snapshot.phase + "\t" + snapshot.stadium.demand + "\t" +
    snapshot.stadium.supply + "\t" + formatMultiplier(target) + "\t" +
    formatMultiplier(periodicQuote) + "\t" + formatError(periodicError) + "\t" +
    formatMultiplier(eventQuote) + "\t" + formatError(eventError) + "\n";

  cursor += 1;

  if (cursor === scenario.length) {
    elements.status.textContent =
      "Complete: periodic refresh served 4 stale quotes with 7.4 cumulative error; event-driven served 0 stale quotes with 0.0 error.";
  } else {
    elements.status.textContent = "Processed tick " + snapshot.tick + ": " + snapshot.phase + ".";
  }
}

function runFullScenario() {
  while (cursor < scenario.length) processNextTick();
}

function resetScenario() {
  cursor = 0;
  periodicQuote = 1;
  periodicUpdatedAt = 0;
  periodicStaleQuotes = 0;
  periodicCumulativeError = 0;
  eventQuote = 1;
  eventStaleQuotes = 0;
  eventCumulativeError = 0;
  elements.log.textContent =
    "Tick\tPhase\tDemand\tSupply\tTarget\tPeriodic\tP Error\tEvent\tE Error\n";
  processNextTick();
}

elements.nextButton.addEventListener("click", processNextTick);
elements.runButton.addEventListener("click", runFullScenario);
elements.resetButton.addEventListener("click", resetScenario);

resetScenario();

Both pricing paths now process the same marketplace snapshots with measurable freshness results. Next up, you will turn this local evidence into a system-design defense for partitioning, event integrity, failures, and scale.

Defend the Design at Scale

Your simulator now proves that the periodic path serves four stale quotes with 7.4 cumulative error. The event-driven design removes that freshness gap for the same deterministic inputs.

A local simulator proves the problem. A strong system-design answer must also explain how the data path stays correct under partitioning. It must cover traffic skew. It must handle retries and failures. It must make observability part of the design.

In this step, get ready to:
  • Document the requirements and measured evidence from the simulator.
  • Design the production data path for geographic cells and event integrity.
  • Define fallback behavior and an evolution path toward regional scale.
Frame the experiment

A system-design document starts by defining its boundary. Functional requirements describe what the system does. Non-functional requirements describe the qualities that keep it dependable.

  • Return to SYSTEM_DESIGN.md in Visual Studio Code.
  • Place the following content below the existing # Surge Pricing Freshness Lab: System Design heading:
## Scope and honesty boundary

This project explains a real-time marketplace pricing pattern. It does not claim to reproduce Uber's private pricing formula. The local multiplier is deliberately simple so the architecture and freshness behavior remain observable.

Public Uber engineering references describe supply and demand measured in H3 hexagons and passed into a pricing model. They also describe Gairos as a real-time data processing, storage, and querying platform used by dynamic pricing and forecasting services. The architecture below uses those public ideas as reference points, then makes its own educational design decisions explicit.

## Functional requirements

- Accept rider-demand and available-driver updates for a geographic cell.
- Maintain a current supply-and-demand view per cell.
- Calculate and publish a price result for the affected cell.
- Serve a current quote with freshness metadata.
- Preserve enough event history to replay, audit, and correct state.

## Non-functional requirements

- Keep pricing freshness measurable end to end.
- Contain a failure or traffic spike to the affected cells where possible.
- Make duplicate, late, and out-of-order handling deterministic.
- Degrade safely when streams, state, the pricing model, or the cache are unavailable.
- Expose enough telemetry to identify lag, skew, stale quotes, and fallback use.

What does this section establish?

  • The honesty boundary separates this educational multiplier from Uber's private pricing formula.
  • The functional requirements define the system's responsibilities for cell state and quotes.
  • The non-functional requirements make freshness and deterministic event handling explicit design goals.
  • Save SYSTEM_DESIGN.md.
  • Confirm the editor now shows dedicated sections for the honesty boundary and both requirement types.

Missing one of the requirement sections?

Check that you pasted below the existing title. Compare the three ## headings with the snippet.

Ask for help checking the section structure:

The simulator gives your design argument measurable evidence. Its deterministic inputs isolate refresh strategy as the reason the two paths produce different freshness results.

  • Add the measured experiment results below the non-functional requirements:
## What the local experiment proves

The local periodic path refreshes every four ticks. The Stadium demand spike begins at tick 2, so the old 1.0x quote remains visible while the current educational target is 3.0x at ticks 2 and 3. After the scheduled refresh, driver supply improves at tick 6, but the periodic quote remains 3.0x while the target falls to about 1.3x at ticks 6 and 7.

For the complete scenario:

- Periodic refresh: 4 stale quotes and 7.4 multiplier-points of cumulative absolute error.
- Event-driven update: 0 stale quotes and 0.0 cumulative absolute error.

This does not prove that every production system should update on every raw event. It proves that update cadence must be chosen from a freshness objective, not convenience.

Why use measured evidence?

  • The four stale quotes identify the ticks where the periodic result differs from current cell state.
  • The 7.4 multiplier-point total measures the size of that difference across the scenario.
  • The final paragraph keeps the conclusion focused on freshness objectives.
  • Save SYSTEM_DESIGN.md.
  • Confirm the evidence section names ticks 2, 3, 6, and 7.

Do the results differ from the dashboard?

The document must use the deterministic result already shown by the completed simulator. Check for four stale quotes and 7.4 cumulative error in both places.

Ask for help reconciling the evidence:

A data-flow diagram turns the browser experiment into a system-design explanation. Both paths consume the same snapshots before diverging at their refresh trigger.

  • Add the local architecture below the experiment evidence:
## Local architecture

```text
Deterministic snapshots
        |
        v
Cell state updater keyed by cell_id
        |
        +-----------------------------+
        |                             |
        v                             v
Four-tick scheduler            Event-driven trigger
        |                             |
        v                             v
Educational pricing function   Educational pricing function
        |                             |
        +--------------+--------------+
                       |
                       v
             Browser metrics and log
```

The two paths consume identical inputs. That keeps the comparison deterministic and isolates refresh strategy as the independent variable.

How does the local flow work?

  • The cell state updater receives each deterministic snapshot.
  • The scheduled path waits for its four-tick boundary.
  • The event-driven path reacts to each new snapshot.
  • Both paths feed the same browser metrics for a controlled comparison.
  • Save SYSTEM_DESIGN.md.
  • Confirm the local diagram visibly splits into scheduled and event-driven branches.

Does the diagram look misaligned?

Keep the diagram inside its fenced text block. Preserve every leading space so the two branches remain aligned.

Ask for help checking the diagram spacing:

The production path keeps the same logical stages while replacing browser memory with durable services. This gives each responsibility an independent scaling boundary.

  • Add the evolution architecture below the local architecture:
## Evolution architecture

```text
Rider and driver producers
        |
        v
Ingestion API and durable event log
        |
        v
Partition by cell_id
        |
        v
Windowed supply and demand aggregator
        |
        +---------------------> Replay and correction store
        |
        v
Pricing model service
        |
        v
Price cache with computed_at and input watermark
        |
        v
Fare estimate API
        |
        v
Rider and driver experiences

Every stage -> metrics, traces, structured logs, and alerts
```

What changes at production scale?

  • The durable event log makes input replayable after failures.
  • The aggregator owns cell-partitioned marketplace state.
  • The replay store supports corrections without silently overwriting current state.
  • Freshness metadata follows the result into the price cache.
  • Save SYSTEM_DESIGN.md.
  • Confirm the production diagram runs from producers to rider and driver experiences.

Is a production stage missing?

Compare the flow from the durable event log through the Fare Estimate API. Check the separate branch for replay and correction.

Ask for help reviewing the flow:

Design the production data path

Geographic cells give each local marketplace a state boundary. The partition key keeps related updates together while limiting coordination across unrelated areas.

  • Add the geographic partitioning section below the evolution architecture:
## Geographic partitioning

Use the geographic cell identifier as the primary partition key. Events for one cell reach the same logical state owner, which reduces cross-partition coordination and makes per-cell ordering achievable.

Cell resolution is a trade-off. Coarse cells are cheaper to process but can hide a stadium spike inside a larger neighborhood. Fine cells improve locality but increase partition count, sparse state, boundary effects, and operational overhead. Keep the resolution configurable by market and workload rather than hard-coding one global choice.

Why partition by cell?

  • Updates for one cell reach the same logical state owner.
  • Independent cells avoid unnecessary coordination.
  • Configurable resolution lets each market balance locality against operational overhead.
  • Save SYSTEM_DESIGN.md.
  • Confirm the section identifies the geographic cell as the primary partition key.

Is the partition key unclear?

Check that the section names the cell identifier before discussing cell resolution. The key determines which events share a logical state owner.

Ask for help clarifying the partition boundary:

Traffic is rarely distributed evenly across a city. A concert can make one cell a hot key while nearby cells remain quiet.

  • Add the hot-cell mitigation section below geographic partitioning:
## Hot-cell handling

A concert, airport disruption, or weather event can turn one cell into a hot key while nearby cells remain quiet. Monitor event rate, processing time, queue lag, state size, and quote traffic by cell.

Mitigations, in escalation order:

1. Combine raw updates locally before publishing aggregate deltas.
2. Add consumers and isolate the hot partition from unrelated cells.
3. Split a hot cell into finer subcells or use a temporary composite key such as cell plus time bucket.
4. Recombine subcell results through a controlled aggregation stage before pricing.
5. Apply backpressure or rate limits to non-critical consumers so pricing freshness retains priority.

Splitting improves throughput but makes ordering and recombination harder, so it should be triggered by measured skew rather than used everywhere.

How does the design handle a hot cell?

  • Per-cell metrics reveal skew before it affects every partition.
  • The mitigations escalate from local aggregation to temporary partition splitting.
  • Controlled recombination preserves one pricing result for the affected area.
  • Save SYSTEM_DESIGN.md.
  • Confirm the section lists five mitigations in escalation order.

Are the hot-cell mitigations out of order?

Start with local aggregation. Escalate to isolation before splitting the cell. Keep recombination after the split.

Ask for help checking the escalation path:

Per-cell ordering gives the state updater a coherent history without requiring an expensive global order. A sequence number defines which update is newer within each producer-and-cell stream.

  • Add the event-ordering policy below hot-cell handling:
## Event ordering

Require a monotonic sequence within each producer-and-cell stream. Partition by cell ID, track the last accepted sequence, and include event time separately from arrival time.

The state updater should define its policy before deployment:

- A newer sequence advances current state.
- A repeated sequence is treated as a duplicate.
- A lower sequence that arrives inside the allowed lateness window can trigger a correction.
- A lower sequence outside that window is quarantined or retained for offline reconciliation.

Global ordering is unnecessary and expensive. The pricing decision needs a coherent order for each cell.

Why use per-cell ordering?

  • A monotonic sequence identifies the latest accepted update for a cell.
  • Separate event and arrival times expose delayed delivery.
  • The policy gives lower sequences a deterministic correction or quarantine outcome.
  • Save SYSTEM_DESIGN.md.
  • Confirm the policy defines outcomes for newer and repeated sequences.

Does the policy imply global ordering?

Keep the sequence scoped to each producer-and-cell stream. The final paragraph should explicitly reject global ordering.

Ask for help checking the ordering scope:

Retries can deliver the same event more than once. Late delivery can also place an old update behind the cell's current watermark. Explicit rules prevent either case from corrupting current state.

  • Add the late-event and deduplication rules below event ordering:
## Late and duplicate events

Every event needs a stable `event_id`. Keep a bounded deduplication record per partition so retrying an accepted event does not apply its demand or supply delta twice.

Use a watermark to represent how far event-time processing has progressed. A late event inside the correction window can recompute the affected window and publish a new version. An event outside the window should not silently mutate current state. Route it to reconciliation, record the reason, and alert if the rate increases.

Replay must use the same idempotency and version rules as live processing. Otherwise recovery can produce a different price history from the original run.

How is event integrity preserved?

  • A stable event_id prevents a retried delta from being applied twice.
  • The watermark separates correctable late events from events that require reconciliation.
  • Shared live and replay rules keep recovery deterministic.
  • Save SYSTEM_DESIGN.md.
  • Confirm the section gives duplicate and late events separate outcomes.

Could a late event overwrite current state?

Check that events outside the correction window go to reconciliation. They must not silently mutate the current cell state.

Ask for help reviewing the rule:

A cache can serve quotes quickly. Freshness metadata lets the API decide whether a cached result is still safe to return.

  • Add the cache-freshness contract below the event-integrity rules:
## Cache freshness

A cached price should include:

- `cell_id`
- price result
- `computed_at`
- input watermark or state version
- model version
- expiration time

The Fare Estimate API should validate age and version before returning the value. Event-driven writes can update or invalidate the affected cell immediately. Time-based expiration remains a safety net, not the primary freshness mechanism.

Track both cache hit rate and cache age. A high hit rate is harmful if the hits are stale.

Why attach freshness metadata?

  • The cell identifier connects the cached result to its geographic state.
  • The computation time and input version reveal the result's age.
  • The model version makes pricing changes traceable across deployments.
  • The expiration time supplies a final safety boundary.
  • Save SYSTEM_DESIGN.md.
  • Confirm the cache entry contains all six listed fields.

Is cache freshness reduced to expiration?

Keep computed_at plus the input watermark or state version. Expiration alone cannot explain which state produced the price.

Ask for help checking the metadata contract:

Plan safe operation and evolution

A production design needs an explicit response when fresh pricing data is unavailable. Preserving the age of old data keeps degraded behavior visible to callers and operators.

  • Add the stale-data fallback policy below cache freshness:
## Stale-data fallback

Define a maximum acceptable age for the last-known-good price. If the cache entry is inside that bound, return it with a degraded-state marker and record fallback use. If it is outside the bound, use a product-approved safe default or decline to issue a dynamic quote.

Do not invent a fresh-looking timestamp for old data. Preserve the original computation time so downstream services and operators can see the degradation.

What makes the fallback safe?

  • A maximum age limits how long the system can reuse an old result.
  • A degraded-state marker exposes fallback use to downstream callers.
  • The original timestamp keeps stale data visibly stale.
  • Save SYSTEM_DESIGN.md.
  • Confirm the policy distinguishes results inside and outside the maximum age.

Does the fallback hide stale data?

Check that the original computation time remains attached to the result. The fallback must also record its degraded state.

Ask for help auditing the fallback:

Failure handling maps each dependency outage to a bounded response. Durable replay and idempotency let the system recover without creating a different price history.

  • Add the failure-handling decisions below stale-data fallback:
## Failure handling

- Ingestion unavailable: reject or durably buffer producers according to the accepted loss policy.
- Consumer lag: autoscale, isolate hot cells, and alert on end-to-end freshness before queue depth alone.
- Aggregator restart: restore partition state from a checkpoint and replay the durable log idempotently.
- Pricing model timeout: use a bounded timeout and the last-known-good result only within the freshness limit.
- Cache unavailable: query the authoritative pricing result under a strict timeout or use the declared fallback.
- Bad deployment: support versioned events, canary rollout, rapid rollback, and side-by-side output comparison.
- Poison event: quarantine it with its schema version and producer identity instead of blocking the partition indefinitely.

How are failures contained?

  • Each failure names a bounded action instead of relying on an undefined retry.
  • Checkpoint restoration gives the aggregator a known recovery point.
  • Quarantine prevents one poison event from blocking its partition indefinitely.
  • Save SYSTEM_DESIGN.md.
  • Confirm the section covers seven distinct failure modes.

Is a failure response too vague?

Each bullet should name the failed component and the system response. Preserve the freshness limit wherever an old price can be reused.

Ask for help testing the failure decisions:

Operational metrics connect internal processing to the quote a rider receives. End-to-end freshness is the primary signal because queue depth alone cannot reveal every stale result.

  • Add the observability section below failure handling:
## Observability

Measure the user-facing outcome and each stage that contributes to it:

- End-to-end pricing freshness from event time to quote availability
- Processing lag and queue depth by partition
- Event throughput and skew by cell
- Duplicate, late, rejected, and replayed event rates
- Watermark delay and correction count
- Pricing computation latency and error rate
- Cache age, hit rate, miss rate, and invalidation delay
- Stale fallback rate and age of the served fallback
- Difference between newly computed and previously served prices
- Availability and tail latency for the Fare Estimate API

Trace a quote back to the cell-state version, event watermark, and model version that produced it. This makes a surprising price explainable rather than merely observable.

What should operators measure?

  • Freshness measures the delay from event time to quote availability.
  • Partition metrics expose skew and hot cells.
  • Event-integrity counters reveal duplicate and late-delivery pressure.
  • Trace metadata connects a quote to its state and model versions.
  • Save SYSTEM_DESIGN.md.
  • Confirm the metrics cover freshness and event integrity.

Are the metrics limited to infrastructure health?

Keep the end-to-end pricing freshness metric first. Include the difference between newly computed and previously served prices.

Ask for help reviewing the signals:

The periodic and event-driven designs trade implementation simplicity for freshness. A hybrid can keep a freshness objective while reducing unnecessary recomputation.

  • Add the trade-off table below observability:
## Periodic versus event-driven trade-off

| Decision | Periodic refresh | Event-driven update |
| --- | --- | --- |
| Complexity | Lower | Higher |
| Reaction to sudden demand | Delayed until refresh | Triggered by new state |
| Idle work | Recomputes even without change | Processes only incoming change |
| Burst behavior | Predictable schedule | Requires backpressure and hot-key handling |
| Event integrity | Simpler snapshot semantics | Requires ordering, deduplication, and lateness policy |
| Best fit | Slow-changing or tolerant workloads | Freshness-sensitive marketplace decisions |

A hybrid can aggregate events into short windows and publish only meaningful changes. This limits churn while keeping a measurable freshness objective.

How should you explain the trade-off?

  • Periodic refresh lowers processing complexity at the cost of delayed reactions.
  • Event-driven updates improve freshness while requiring stronger integrity controls.
  • A hybrid preserves a measurable objective while limiting churn.
  • Save SYSTEM_DESIGN.md.
  • Confirm the table compares six decisions across both designs.

Is the trade-off one-sided?

Keep both the lower complexity of periodic refresh and the stronger freshness of event-driven updates. The table should show a cost for each choice.

Ask for help balancing the comparison:

The scale-evolution plan keeps each transition tied to a limitation in the browser demo. It adds durability and service boundaries before increasing model complexity.

  • Add the evolution plan below the trade-off table:
## Evolution from local demo to large scale

1. Replace the in-browser scenario array with producer events and a durable replayable log.
2. Replace one JavaScript state object with partitioned, checkpointed cell state.
3. Separate aggregation from the pricing model so each can scale and deploy independently.
4. Add a distributed cache whose entries carry computation time, state version, and model version.
5. Isolate markets or regions so a hot event, failed deployment, or dependency outage has a bounded blast radius.
6. Add replay, correction, canary, and shadow-comparison workflows before increasing model complexity.

Why evolve in this order?

  • The durable log supplies replay before state becomes distributed.
  • Partitioned state creates a scaling boundary for cell aggregation.
  • Regional isolation limits the effect of traffic spikes and failed deployments.
  • Replay and comparison workflows protect later model changes.
  • Save SYSTEM_DESIGN.md.
  • Confirm the evolution plan contains six ordered stages.

Does the plan increase model complexity too early?

Keep durability and partitioned state ahead of model changes. The final stage adds replay and comparison workflows before increasing complexity.

Ask for help checking the sequence:

The final section turns the document into an interview narrative. It moves from requirements to architecture before closing on the central complexity trade-off.

  • Finish SYSTEM_DESIGN.md with the interview explanation:
## Interview explanation

Start with the marketplace problem: local demand can jump faster than available supply, so a quote depends on fresh cell state. State the requirements and freshness objective. Draw the event path from producers through cell-partitioned aggregation, pricing, cache, and quote API. Use the simulator's four stale quotes to justify event-driven updates. Then deep-dive on the hardest parts: hot cells, per-cell ordering, duplicate and late events, cache age, safe fallback, and end-to-end observability. Close by naming the trade-off: lower latency and better freshness require more stateful-streaming complexity.

How does the defense flow?

  • Start with the marketplace problem and freshness objective.
  • Use the simulator evidence to justify the event-driven path.
  • Deep-dive on state integrity and safe degradation.
  • Close with the complexity required to achieve lower latency.
  • Save SYSTEM_DESIGN.md.

Does the defense read like a feature list?

Lead with the freshness problem. Use the four stale quotes as evidence before discussing production mechanisms.

Ask for help rehearsing the argument:

Before you check the finished document, which sections do you expect to connect the four stale quotes to production-scale correctness?

  • Review the headings in SYSTEM_DESIGN.md.
  • Confirm the document covers partitioning and hot-cell handling.
  • Confirm the document covers ordering and event integrity.
  • Confirm the document covers cache freshness and stale-data fallback.
  • Confirm the document covers failure handling and observability.
  • Confirm the document ends with scale evolution and an interview explanation.

You should see a complete architecture flow plus dedicated sections for every production concern. The document now connects the simulator's measured freshness gap to a defensible large-scale design.

✔️ Awesome, I've got everything!

Great. Double check that you saved SYSTEM_DESIGN.md and can trace the argument from requirements through scale evolution.

ⓧ I'd like to double check the full code

Compare your complete SYSTEM_DESIGN.md with this reference:

# Surge Pricing Freshness Lab: System Design

## Scope and honesty boundary

This project explains a real-time marketplace pricing pattern. It does not claim to reproduce Uber's private pricing formula. The local multiplier is deliberately simple so the architecture and freshness behavior remain observable.

Public Uber engineering references describe supply and demand measured in H3 hexagons and passed into a pricing model. They also describe Gairos as a real-time data processing, storage, and querying platform used by dynamic pricing and forecasting services. The architecture below uses those public ideas as reference points, then makes its own educational design decisions explicit.

## Functional requirements

- Accept rider-demand and available-driver updates for a geographic cell.
- Maintain a current supply-and-demand view per cell.
- Calculate and publish a price result for the affected cell.
- Serve a current quote with freshness metadata.
- Preserve enough event history to replay, audit, and correct state.

## Non-functional requirements

- Keep pricing freshness measurable end to end.
- Contain a failure or traffic spike to the affected cells where possible.
- Make duplicate, late, and out-of-order handling deterministic.
- Degrade safely when streams, state, the pricing model, or the cache are unavailable.
- Expose enough telemetry to identify lag, skew, stale quotes, and fallback use.

## What the local experiment proves

The local periodic path refreshes every four ticks. The Stadium demand spike begins at tick 2, so the old 1.0x quote remains visible while the current educational target is 3.0x at ticks 2 and 3. After the scheduled refresh, driver supply improves at tick 6, but the periodic quote remains 3.0x while the target falls to about 1.3x at ticks 6 and 7.

For the complete scenario:

- Periodic refresh: 4 stale quotes and 7.4 multiplier-points of cumulative absolute error.
- Event-driven update: 0 stale quotes and 0.0 cumulative absolute error.

This does not prove that every production system should update on every raw event. It proves that update cadence must be chosen from a freshness objective, not convenience.

## Local architecture

```text
Deterministic snapshots
        |
        v
Cell state updater keyed by cell_id
        |
        +-----------------------------+
        |                             |
        v                             v
Four-tick scheduler            Event-driven trigger
        |                             |
        v                             v
Educational pricing function   Educational pricing function
        |                             |
        +--------------+--------------+
                       |
                       v
             Browser metrics and log
```

The two paths consume identical inputs. That keeps the comparison deterministic and isolates refresh strategy as the independent variable.

## Evolution architecture

```text
Rider and driver producers
        |
        v
Ingestion API and durable event log
        |
        v
Partition by cell_id
        |
        v
Windowed supply and demand aggregator
        |
        +---------------------> Replay and correction store
        |
        v
Pricing model service
        |
        v
Price cache with computed_at and input watermark
        |
        v
Fare estimate API
        |
        v
Rider and driver experiences

Every stage -> metrics, traces, structured logs, and alerts
```

## Geographic partitioning

Use the geographic cell identifier as the primary partition key. Events for one cell reach the same logical state owner, which reduces cross-partition coordination and makes per-cell ordering achievable.

Cell resolution is a trade-off. Coarse cells are cheaper to process but can hide a stadium spike inside a larger neighborhood. Fine cells improve locality but increase partition count, sparse state, boundary effects, and operational overhead. Keep the resolution configurable by market and workload rather than hard-coding one global choice.

## Hot-cell handling

A concert, airport disruption, or weather event can turn one cell into a hot key while nearby cells remain quiet. Monitor event rate, processing time, queue lag, state size, and quote traffic by cell.

Mitigations, in escalation order:

1. Combine raw updates locally before publishing aggregate deltas.
2. Add consumers and isolate the hot partition from unrelated cells.
3. Split a hot cell into finer subcells or use a temporary composite key such as cell plus time bucket.
4. Recombine subcell results through a controlled aggregation stage before pricing.
5. Apply backpressure or rate limits to non-critical consumers so pricing freshness retains priority.

Splitting improves throughput but makes ordering and recombination harder, so it should be triggered by measured skew rather than used everywhere.

## Event ordering

Require a monotonic sequence within each producer-and-cell stream. Partition by cell ID, track the last accepted sequence, and include event time separately from arrival time.

The state updater should define its policy before deployment:

- A newer sequence advances current state.
- A repeated sequence is treated as a duplicate.
- A lower sequence that arrives inside the allowed lateness window can trigger a correction.
- A lower sequence outside that window is quarantined or retained for offline reconciliation.

Global ordering is unnecessary and expensive. The pricing decision needs a coherent order for each cell.

## Late and duplicate events

Every event needs a stable `event_id`. Keep a bounded deduplication record per partition so retrying an accepted event does not apply its demand or supply delta twice.

Use a watermark to represent how far event-time processing has progressed. A late event inside the correction window can recompute the affected window and publish a new version. An event outside the window should not silently mutate current state. Route it to reconciliation, record the reason, and alert if the rate increases.

Replay must use the same idempotency and version rules as live processing. Otherwise recovery can produce a different price history from the original run.

## Cache freshness

A cached price should include:

- `cell_id`
- price result
- `computed_at`
- input watermark or state version
- model version
- expiration time

The Fare Estimate API should validate age and version before returning the value. Event-driven writes can update or invalidate the affected cell immediately. Time-based expiration remains a safety net, not the primary freshness mechanism.

Track both cache hit rate and cache age. A high hit rate is harmful if the hits are stale.

## Stale-data fallback

Define a maximum acceptable age for the last-known-good price. If the cache entry is inside that bound, return it with a degraded-state marker and record fallback use. If it is outside the bound, use a product-approved safe default or decline to issue a dynamic quote.

Do not invent a fresh-looking timestamp for old data. Preserve the original computation time so downstream services and operators can see the degradation.

## Failure handling

- Ingestion unavailable: reject or durably buffer producers according to the accepted loss policy.
- Consumer lag: autoscale, isolate hot cells, and alert on end-to-end freshness before queue depth alone.
- Aggregator restart: restore partition state from a checkpoint and replay the durable log idempotently.
- Pricing model timeout: use a bounded timeout and the last-known-good result only within the freshness limit.
- Cache unavailable: query the authoritative pricing result under a strict timeout or use the declared fallback.
- Bad deployment: support versioned events, canary rollout, rapid rollback, and side-by-side output comparison.
- Poison event: quarantine it with its schema version and producer identity instead of blocking the partition indefinitely.

## Observability

Measure the user-facing outcome and each stage that contributes to it:

- End-to-end pricing freshness from event time to quote availability
- Processing lag and queue depth by partition
- Event throughput and skew by cell
- Duplicate, late, rejected, and replayed event rates
- Watermark delay and correction count
- Pricing computation latency and error rate
- Cache age, hit rate, miss rate, and invalidation delay
- Stale fallback rate and age of the served fallback
- Difference between newly computed and previously served prices
- Availability and tail latency for the Fare Estimate API

Trace a quote back to the cell-state version, event watermark, and model version that produced it. This makes a surprising price explainable rather than merely observable.

## Periodic versus event-driven trade-off

| Decision | Periodic refresh | Event-driven update |
| --- | --- | --- |
| Complexity | Lower | Higher |
| Reaction to sudden demand | Delayed until refresh | Triggered by new state |
| Idle work | Recomputes even without change | Processes only incoming change |
| Burst behavior | Predictable schedule | Requires backpressure and hot-key handling |
| Event integrity | Simpler snapshot semantics | Requires ordering, deduplication, and lateness policy |
| Best fit | Slow-changing or tolerant workloads | Freshness-sensitive marketplace decisions |

A hybrid can aggregate events into short windows and publish only meaningful changes. This limits churn while keeping a measurable freshness objective.

## Evolution from local demo to large scale

1. Replace the in-browser scenario array with producer events and a durable replayable log.
2. Replace one JavaScript state object with partitioned, checkpointed cell state.
3. Separate aggregation from the pricing model so each can scale and deploy independently.
4. Add a distributed cache whose entries carry computation time, state version, and model version.
5. Isolate markets or regions so a hot event, failed deployment, or dependency outage has a bounded blast radius.
6. Add replay, correction, canary, and shadow-comparison workflows before increasing model complexity.

## Interview explanation

Start with the marketplace problem: local demand can jump faster than available supply, so a quote depends on fresh cell state. State the requirements and freshness objective. Draw the event path from producers through cell-partitioned aggregation, pricing, cache, and quote API. Use the simulator's four stale quotes to justify event-driven updates. Then deep-dive on the hardest parts: hot cells, per-cell ordering, duplicate and late events, cache age, safe fallback, and end-to-end observability. Close by naming the trade-off: lower latency and better freshness require more stateful-streaming complexity.

How to use this reference

Compare the heading order first. Check each section's wording after the structure matches.

You have turned a browser experiment into an interview-ready architecture defense. Every production decision now traces back to the freshness problem your simulator measured.

Secret mission

Design the Event-Integrity Contract

Retries and delayed events can corrupt a cell's price when their outcomes are undefined. Build a 90-second defense that gives every accepted event, duplicate delivery, and late arrival a deterministic result.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely from local files, so it creates no ongoing charges. You can keep the finished lab, close its open views for now, or remove its folder.

Resources you used:

  • The local surge-pricing-lab folder containing index.html, styles.css, app.js, and SYSTEM_DESIGN.md.

Keep everything running

No action is needed. Choose this option if you want to replay the simulator or rehearse your system-design defense.

  • Keep the surge-pricing-lab folder on your Mac.
  • Reopen index.html whenever you want to rerun the deterministic scenario.
  • Use SYSTEM_DESIGN.md whenever you want to practise explaining the architecture.

Pause - I'll come back to this later

Pause the project when you want to clear your open views. Your local files remain unchanged for your return.

  • Close the index.html tab in Safari.
  • Close the surge-pricing-lab workspace in Visual Studio Code.
  • Keep the surge-pricing-lab folder unchanged for your next session.

Delete - I don't want to use this again

Deleting the folder becomes final when you empty Trash. Your simulator files and system-design brief are removed from this Mac.

  • Press Cmd+Space to open macOS search.
  • Type Finder into the search field.
  • Press Enter to open Finder.
  • Search your Mac for surge-pricing-lab.
  • Confirm the search result has the exact folder name surge-pricing-lab.
  • Move the surge-pricing-lab search result to Trash.
  • Empty Trash to remove the folder permanently.
  • Search for surge-pricing-lab again to confirm Finder no longer returns the folder.

Nice Work!

Nice Work!

You did it! You built a visual surge-pricing lab that turns pricing freshness into measurable evidence across three geographic cells. Your matching system design brief now explains how the experiment evolves into a real-time marketplace architecture.

You've learned how to:

  • Build a deterministic browser simulator that isolates the Stadium concert spike. Downtown remains stable in the same run. Riverside remains stable in the same run.
  • Compare periodic refresh with event-driven processing on the same deterministic scenario. Measure four stale quotes on the periodic path. Measure 7.4 multiplier-points of cumulative error on that path. Confirm zero stale quotes on the event-driven path. Confirm 0.0 cumulative error on that path.
  • Use SYSTEM_DESIGN.md to defend geographic partitioning at larger scale. Explain how hot cells create skew. Define cache freshness. Specify graceful degradation. Use observability to reveal stale results.
  • Secret Mission: Define an event-integrity contract that ignores a repeated event_id. Give late events a deterministic correction policy. Name the metrics that expose each failure mode.

Ready to quiz yourself?