This article explains how the Café module's numbers are made: where the sales data comes from, how the 7-day weather forecast on the Today tab is fitted and predicted, what the staffing coverage grid is counting, and why the Trends tab's busy-hours heatmap is labelled modeled. Everything here is what the code does as of 2026-09-01; the model is one implementation shared by both tabs (pepper-ui/frontend/src/lib/cafe-model.js).
Where the data comes from
Every sales figure on both Café tabs is a Dripos POS report. The backend (pepper-ui/backend/analytics-data.js) posts to https://api.dripos.com/report/salessummary for location 646, using the Dripos session token that dripos-keepalive.py maintains in .dripos-session.json on the Mini. If that token is missing, the report fails with No Dripos token — run dripos-auth.py.
| Report | Window | Used for |
|---|---|---|
DAY, TIMESPAN, HOUR | trailing 365 days | daily rows, day-of-week means, the sales-hour weights for rain |
HOUR_TODAY | today, from midnight | today's hourly bars |
HOUR_30D | trailing 30 days | the dashed "typical day" line, the staffing grid |
/cache/hourly?from&to | the selected Trends range | the range's hourly shape (peak hour, heatmap) |
The report is cached for 3 minutes on the server, so Dripos is hit at most once per window no matter how many screens are open. The Today tab re-reads the cache every 60 seconds while visible; the refresh button is the only thing that forces a live Dripos pull (POST /refresh). All day boundaries are Central Time (America/Chicago), matching Dripos's own day boundaries — that is what the subtitle "Dripos POS · Central Time" means (the content audit slates it for trimming to the date alone).
One limit shapes several panels: Dripos reports hourly sales only as an hour-of-day aggregate. Any window returns 24 rows, all stamped on the range's first day (verified live 2026-09-01). There is no per-day or per-weekday hourly split in any Dripos call, and the hourly rows carry gross dollars but no ticket count under any report variant. Every hourly shape in the module is therefore a share of dollars, and any panel that spreads tickets across hours assumes tickets follow the revenue curve through the day.
Weather data
Weather comes from Open-Meteo (public, no key): the archive endpoint from the earliest sales day to today, and the forecast endpoint with past_days=14 and forecast_days=14. Coordinates are 41.8978, −87.8139; units are °F and inches. Per day it stores the daily high and low, precipitation and snowfall totals, the WMO weather code, and for forecast days the maximum precipitation probability. Hourly precipitation and snowfall are folded into 24-slot arrays per day so rain can be weighted by selling hour. The payload is cached for 3 hours per start date.
The header line and the forecast strip show "{pop}% rain" only when the probability is 20% or more. A weather failure never becomes the view's error state: the forecast section says the fetch failed and every other number stands.
The forecast model
Predicted tickets for a day = that day-of-week's average tickets × a temperature-band multiplier × a precipitation multiplier. The fit is built as follows.
- Rows. Every day in the 365-day report with at least one ticket that also has a recorded daily high in the weather archive. Each row's value is a ticket index: tickets ÷ that day-of-week's mean tickets, which isolates the weather effect from the weekly rhythm.
- Temperature band, by daily high: <20°, 20–32°, 32–45°, 45–60°, 60–75°, 75–85°, 85°+.
- Precipitation bucket. The day's hourly rain and snow are weighted by the café's selling hours — each hour's share of a day's gross from the 365-day hourly report, scaled so the peak hour weighs 1.0 — so a downpour at 3am barely counts and rain through the morning peak counts most. With no hourly split, the daily total is discounted to a flat 70% open-hours share. The bucket is the first match in this order: snow if effective snowfall ≥ 0.3 in; wet if effective precipitation ≥ 0.15 in; light if ≥ 0.02 in; otherwise dry.
- Multipliers. Each band and bucket gets the mean ticket index of its rows, shrunk toward 1.0 with K = 6: (sum + 6) ÷ (n + 6). Three snow days cannot claim −40% on their own.
- Prediction. Day-of-week mean × temp multiplier × precip multiplier, with each multiplier clamped to 0.55–1.45.
- Correlation. Pearson r between daily high and ticket index, shown only once there are at least 10 matched rows.
- Revenue. Predicted tickets × the 365-day average ticket (gross ÷ tickets).
The panel prints how many matched days the fit used. Under 90 days it adds Model is young — treat the numbers as directional until a full season has accumulated. If no sales day matches a recorded daily high, the section says so rather than inventing a model.
Known wart, kept deliberately so both surfaces agree: today's partial, still-accumulating row is one of the fitted rows, so it lands in today's own temperature band and slightly deflates the multiplier used to predict today.
Today's row: projection, not a reading
Today blends what has already sold with the predicted remainder: projection = tickets sold so far + prediction × (1 − share of the day already behind us). The share is the cumulative gross share of a typical day through the end of the previous full hour, from the 365-day hourly report — it never over-credits the hour in progress. The cell is labelled proj and prints "{n} sold so far" beneath it. Without an hourly split it shows the plain prediction, labelled est. The other six days are plain predictions. The page anchors "today" on the browser clock, never on the weather payload's own date, so a tab left open past midnight does not blend yesterday's completed count with a fresh-day prediction.
Staffing coverage grid
The grid shows one digit per weekday × hour: how many baristas the bar needs.
| Term | Definition |
|---|---|
| Hourly share | Each hour's share of gross over the trailing 30 days (HOUR_30D), falling back to the 365-day aggregate. Rounded to two decimal places, exactly as the legacy page did — an unrounded value lands the two surfaces on opposite sides of the threshold. |
| Average open day | 365-day tickets ÷ days with at least one ticket. The eyebrow reads "~{N} tickets on an average open day". |
| Cell estimate | average open day × hourly share, in tickets per hour (the cell's hover shows it). |
| Two baristas | estimate ≥ 12 tickets/hour on any day; on Friday, Saturday and Sunday the threshold drops to 7. |
One hourly curve drives every row. A Tuesday and a Saturday at the same hour carry the same estimate; only the weekend threshold separates them. There is no per-day volume curve behind the grid, because Dripos has no weekday hourly split. The 30-day window is deliberate: a 365-day aggregate shifts under 0.3% a day and looks frozen, a 30-day window moves week to week. When there is no hourly split or no open day with tickets, the grid renders an empty state rather than the legacy page's hardcoded 69-ticket day.
Other Today-tab panels
- Today by hour. Today's real per-hour gross as bars against the trailing-30-day per-open-day average as a dashed line. "Pace" compares today so far with the 30-day average through the same hour — the only like-for-like intraday comparison; a full-day "vs last Monday" mid-shift would be arithmetic on a partial day.
- Days that carry the week. Trailing 90 days of closed days, average per open day, ranked.
- Last 7 days. Daily rows from the report with tips and vs-prior-week deltas.
Trends tab
Range presets: All time, {year}, {year−1}, This quarter, Last quarter, This month, Last month — the two year chips are labelled with the year number — plus a day-of-week chip. A range under two months is drawn daily; longer ranges are monthly.
- Weather × sales. Diagnostics of the same fit: each bar is a temperature band's multiplier against its day-of-week average, plus the rain, wet, light and snow multipliers and the temp↔traffic r. The bar track is scaled to ±35%. Fitted on all history, unfiltered, so the range presets and the day-of-week chip cannot destabilize it.
- Peak hour. The hour with the biggest revenue share of the selected range's hourly shape. With a weekday chip active it still shows the all-days value, because no weekday split exists.
- Busy hours by weekday. Modeled, not measured per hour. Each row's total is real — that weekday's average tickets per open day in the filtered range. Each cell is that total spread across the range's real hourly revenue share (refund-heavy hours that go negative are clamped to zero). Every row shares one hourly shape; only row totals differ. Row totals and the hourly shape are each measured; their combination is the estimate.
Where to look when it is wrong
- Forecast dark, other numbers fine → the Open-Meteo fetch failed. A total upstream outage still returns a 200 with an empty day map; the view treats that as a failure and retries every 5 minutes, or every 30 minutes when the server's cached payload is a day behind (its 3-hour TTL cannot be busted from the client).
- Everything dark → Dripos token.
python3 scripts/dripos-auth.pyon the Mini restores it;dripos-keepalive.pykeeps it alive afterwards. - Model and grid disagree with each other → they should not; both read
cafe-model.js. Do not change the arithmetic on one surface without the other.