Key Formulas
How the numbers in HTeaO are calculated — labor, sales pace, inventory, money. The Help Assistant reads this file, so add anything users keep asking about.
How this doc works
This is the canonical reference for how every calculated number in the app is computed. The AI Help Assistant retrieves this file, so questions like "how is cut/add calculated?" or "why is the 7Shifts actual grossed up?" get answered from here. When users ask about a calculation that isn't covered, that gap shows up in Admin → Data Integrity → Help Assistant Question Log — add the missing formula here and the assistant will pick it up on the next ask.
Weather-model math (temperature elasticity, cloud-cover and rain weights, AM/PM split, seasonal index) is out of scope — see Admin → Weather Model Docs.
Labor
7Shifts → Revel actual-sales gross-up
7Shifts under-reports actual sales by about 5% versus Revel. When the actual-sales reading is sourced from 7Shifts, the app grosses it up before any downstream calculation; Revel readings are used as-is.
actualSales = sourcedFrom7Shifts
? rawActual × 1.05
: rawActual (Revel — already true dollars)
- The bump applies only to actual sales, only when sourced from 7Shifts.
- Projected sales never get a multiplier.
- Revel actuals never get a multiplier.
Constant: SHIFTS_TO_REVEL_ADJ = 1.05 in src/components/labor/intraday-labor-dashboard.tsx.
Sales pace (paced projection)
An intraday "if today keeps going at this rate" projection of daily sales, derived from how much of the day has elapsed.
pace = actualSales / fractionElapsed
fractionElapsed comes from the store's hourly distribution (next section). Source: computeAdjustedProjection in intraday-labor-dashboard.tsx.
Cumulative sales fraction
What share of a typical day's sales would be in the till by the current hour — store-specific and season-aware.
fractionElapsed =
sum( hourlyPct[h] for h = open .. floor(currentHour) - 1 )
+ hourlyPct[floor(currentHour)] × (currentHour - floor(currentHour))
The first term is finished hours; the second is partial credit for the in-progress hour. Source: getCumulativeSalesFraction in intraday-labor-dashboard.tsx; per-store hourly shares in src/lib/weather-demand/store-hourly-pct.ts as STORE_HOURLY_PCT[storeNum][season][dayKey][hour]. Seasons are offseason (Sep–Apr) and summer (May–Aug).
Pace by hour — sample curve
A concrete sample for the formula above. These are real percentages from Store 001 (Yukon), summer. Every store has its own row in STORE_HOURLY_PCT so exact values vary, but the shape (slow morning ramp → afternoon peak ~2–3pm → evening taper) is consistent across stores.
The "Cumulative (pace)" column is what you'd ask the AI for: what fraction of the day's sales should be in the till by the end of that hour.
Mon–Thu (summer, Store 001):
Saturday (summer, Store 001):
How to read it: at 2pm on a Saturday this store should be ~56% of the way through the day's expected sales; if actuals are well above or below that cumulative fraction at that hour, the pace projection (and the intraday cut/add) adjusts accordingly.
Sunday opens later (11am) and Friday is between the Mon–Thu and Saturday shapes. Offseason curves (Sep–Apr) shift slightly — the peak is a bit earlier and the tails are thinner — but the cumulative pace at any given hour stays within ~2 percentage points of the summer numbers for most hours.
For store-specific exact values: Labor Management → Schedule Distribution tool, or the raw data in src/lib/weather-demand/store-hourly-pct.ts.
Intraday adjusted projection (pace + weather blend)
The "best estimate" of today's total sales the Intraday Labor view shows. It blends the pace (real-time evidence) with the weather-model forecast, weighted toward the forecast early in the day and toward the pace late in the day.
result = forecastWeight × weatherForecast + (1 - forecastWeight) × pace
When no weather forecast is available, result = pace. Source: computeAdjustedProjection in intraday-labor-dashboard.tsx.
Hourly forecast weight
How much to trust the weather forecast vs the pace at the current hour. Piecewise-linear, dropping from 0.9 at open to 0 at close.
Mon–Sat:
open → noon: forecastWeight = 0.9 − (currentHour − open) / (12 − open) × 0.4 // 0.9 → 0.5
noon → close: forecastWeight = 0.5 − (currentHour − 12) / (close − 12) × 0.5 // 0.5 → 0.0
Sunday (opens 11am):
open → 3pm: forecastWeight = 0.9 − (currentHour − open) / (15 − open) × 0.5 // 0.9 → 0.4
3pm → close: forecastWeight = 0.4 − (currentHour − 15) / (close − 15) × 0.4 // 0.4 → 0.0
Source: getForecastWeight in intraday-labor-dashboard.tsx. Store hours are in src/lib/labor/constants.ts (STORE_HOURS): Mon–Thu / Fri open 6am, Sat open 7am, Sun open 11am; close is 8pm offseason (Sep–Apr) and 9pm summer (May–Aug).
Concrete values per hour — the projection blend is result = forecastWeight × weatherForecast + (1 − forecastWeight) × pace, so at any given hour the table below is how much each side gets.
Mon–Thu (summer — open 6am, close 9pm):
Sunday (summer — open 11am, pivot 3pm, close 9pm):
- Friday matches the Mon–Thu shape (also opens 6am).
- Saturday matches Mon–Thu starting at 7am (the open hour shifts; the curve from noon is identical).
- Offseason (Sep–Apr) closes at 8pm instead of 9pm. The slope from noon→close is steeper across 8 hours instead of 9, so the pace weight at each afternoon hour is a few percentage points higher than the summer numbers above (e.g. 2pm becomes ~63% pace / 37% forecast rather than 61/39).
The shape is intentional: at open the model has no real-time evidence — trust the forecast (90%). By close all evidence is in — trust the pace (100%). The crossover is noon Mon–Sat, 3pm Sunday.
Labor cut/add
How many staff hours to cut from or add to the current schedule, given today's adjusted daily sales.
-
Implied weekly sales — gross today's adjusted daily up to the implied week, using the store's day-of-week share:
impliedWeekly = adjDailySales / (dowPct / 100) -
Schedule hour target — look up the target hours for that implied weekly on the store's smoothed bracket curve:
scheduleHourTarget = calcSmoothedDayTarget(impliedWeekly, store, dayKey, dowWeights) -
Cut/Add — the delta from what's currently scheduled:
cutAdd = scheduleHourTarget − modelImpactedHoursPositive = add hours; negative = cut hours.
Source: calcLaborTarget in intraday-labor-dashboard.tsx; bracket tables in src/lib/labor/bracket-data.ts; smoothing in src/lib/labor/distribution-calc.ts.
Weather recovery alert
The amber "Hold PM Labor" / "Cut PM Earlier" banner on Intraday Labor. Fires when today's AM→PM weather swings enough to change the cut decision at this store.
-
AM and PM demand-adjustment percentages — the weather-demand model run separately on the AM block (open–noon) and the PM block (noon–close) using cached hourly Open-Meteo data:
blockAdj = (0.4 × tempAdj + 0.5 × cloudAdj + 0.1 × rainAdj) × 100Where
tempAdj = tempElasticity × (avgTempF − seasonalBaseline)(withtempElasticity = 0.0125per °F outside OU Med, 0 there),cloudAdjis the cloud-tier penalty (Clear=0, Partly=−0.20, Mostly=−0.40, Overcast=−0.75), andrainAdjis the rain-tier penalty (None=0, Light=−0.15, Moderate=−0.20, Heavy=−0.25). Result is in percentage points vs. that store's seasonal baseline. -
Swing delta — the difference between the two block adjustments:
delta = pmAdj − amAdj -
Fire conditions — two thresholds must both clear before the alert appears:
|delta| ≥ 12 (percentage-point swing) AND the suppressed-side adj ≤ −8 (at least one block is meaningfully below normal)The sign of
deltapicks the variant: positive (pmAdj > amAdj) → HOLD PM LABOR (AM suppressed, PM recovers). Negative → CONSIDER CUTTING PM EARLIER.
Source: computeRecoveryAlert in src/lib/labor/recovery-alert.ts. Same model parameters as the weather-demand FORECASTED card (src/lib/weather-demand/calc.ts).
Shift-move suggestion algorithm
How the sparkle-chip modal picks concrete schedule edits to hit today's cut target.
-
Inputs — three pieces of state from the same dashboard view:
shifts = today's model-impacted shifts from /api/labor/7shifts-proxy hourlyDelta = per-hour (scheduled − model target) for the selected store cutTargetHrs = −cutAdd at the selected bracket row (positive = cut needed) alertKind = "hold-pm" | "cut-pm-early" | null (from the recovery alert) -
Per-shift candidate moves — for each shift that's eligible (see eligibility rules below), build the single best start-later and end-earlier candidate, walking 15-min increments. A candidate's score is the over-staffed hours it removes, weighted by delta size:
score = Σ over [cutFrom, cutTo) of max(0, hourlyDelta[h]) × overlap(h)A candidate's cut window is rejected if any hour inside it is under-staffed by more than 0.25 (cutting needed hours).
-
Bias weighting by alert + role — each candidate's ranking score is:
weightedScore = score × biasWeight × roleWeight × savingsHrsbiasWeight:- hold-pm + start-later → 1.5, hold-pm + end-earlier → 0.5
- cut-pm-early + end-earlier → 1.5, cut-pm-early + start-later → 0.5
- no alert → 1.0 in both directions
roleWeight:- Team Lead with no GM/Supervisor on shift at every 15-min tick in the cut window → 0.4 (de facto senior protection)
- Otherwise → 1.0
-
Greedy fill with cumulative validation — candidates sorted by
weightedScoredesc, picked one at a time (up to one start-later + one end-earlier per shift, with the second move composed against the running state, not the baseline). Each pick is re-validated against the running cumulative state before being committed. Plan stops when cumulative savings reachescutTargetHrs × 1.25(over-suggest by ~25% so the GM has options). -
Hard eligibility rules — these block candidate generation upfront:
- GM / Sr GM / Supervisor — never moved. Hard skip.
- Opener (earliest start of the day) — start-later not allowed.
- Pre-9am-start shifts — locked entirely; staff already on the clock.
- Min shift length 4 hr — no candidate can drop the resulting shift below this (rechecked on the composed bounds when both directions stack on one shift).
-
Hard validation rules — applied per candidate against cumulative state:
- Peak lock — cut range cannot overlap hours 14 or 15 (2pm–4pm).
- Floor — min headcount during the affected hours stays ≥ 2 outside peak / ≥ 3 in peak. Sub-hour resolution (sampled at 15-min ticks) catches gaps between shift transitions that hour-bucket counting would miss.
- Closer floor — last operating hour must keep ≥ 2 staff. Post-cut headcount in the closer hour must be ≥
min(pre, 2)— i.e. with 3+ closers a cut is allowed (leaves ≥ 2), with exactly 2 closers a cut is rejected (would leave 1). Cardinal "always 2 at close" rule. - AM monotonic ramp — hours h < 16 (excluding peak): post-edit headcount must be non-decreasing wherever it was non-decreasing pre-edit. Don't-worsen semantics: a pre-existing inversion is left alone, a new one is rejected.
- PM monotonic ramp — hours h ≥ 16: non-increasing, same don't-worsen semantics.
Source: buildSuggestionPlan and validateMove in src/lib/labor/shift-suggestions.ts. Distribution-rule constants (PEAK_HOURS, FLOOR_STD, FLOOR_PEAK) imported from src/lib/labor/distribution-calc.ts — single source of truth shared with the Suggested Distribution panel.
SPLH — Sales Per Labor Hour
SPLH = totalSales / totalLaborHours
Higher is better — more sales per scheduled hour.
Labor %
Labor% = (laborCost / netSales) × 100
The standard ratio for whether labor is in line with sales.
DayKey rollup
The labor system buckets days into four "day keys" because brackets and hourly curves are shaped per group rather than per single day.
Sunday → "sunday"
Monday–Thursday → "monthu" (averaged)
Friday → "friday"
Saturday → "saturday"
Source: dowToKey in intraday-labor-dashboard.tsx.
Senior GM hours adjustment
Senior GM hours are removed from a store's labor actual before comparing against the labor target — they're tracked separately because Sr GM payroll is handled at the corporate level.
adjustedLaborHours = rawLaborHours − seniorGmHours
Month-boundary correction (the live-fetch fix): Sr GM hours used to be summed from weekly labor snapshots, which leaked across month boundaries — a single week spanning two months counted Sr GM into the wrong month. The current logic fetches Sr GM hours live from 7Shifts /shifts in 7-day chunks across the exact month range, so the boundary is clean and snapshot drift is bypassed. Sr GM cost is wages only — no SHIFTS_TO_REVEL_ADJ (that adjustment is for sales, not wages):
srGmAdj = srGmHrs × stateWage
Source: src/app/api/labor/monthly-labor/route.ts (~lines 302–365).
Wage rate resolution
The hourly wage rate used in labor calculations is resolved with this precedence:
storeWage = storeWageOverrides[storeId] // explicit per-store override, if set
?? stateWages[storeState] // state default (OK, MO, KS, NM, TX)
?? fallbackWage // global default
Defaults are in src/lib/labor/schedule-guidance-data.ts (DEFAULT_STATE_WAGES); per-store overrides are in the store_wage_overrides table.
No-GM wage uplift
When a store has no GM scheduled in the period, its effective wage is inflated by a configurable percentage (default 10%) before labor budget computation — to compensate for the higher mix of senior staff hours that fill the absent GM's shifts.
upliftPct = storeGmSchedule.wageUpliftPct ?? 10
stateWage = hasGm ? baseWage : baseWage × (1 + upliftPct / 100)
Source: src/app/api/labor/weekly-labor/route.ts (~line 239) and monthly-labor/route.ts (~line 345).
Midweek labor budget — DOW-weighted pacing
For an incomplete week (say it's Wednesday), the labor budget can't just be "the full-week target." The system projects full-week sales from partial actuals using DOW weights, then scales the budget hours back down to the days already elapsed:
elapsedWeightFraction = sum(dowWeights[0..todayDow-1]) / 100
fcstSales = partialActualSales / elapsedWeightFraction
fullBracketHrs = lookupWeeklyHours(fcstSales)
budgetHrs = fullBracketHrs × elapsedWeightFraction
This is why the mid-week budget number can move as actuals roll in. Source: src/app/api/labor/weekly-labor/route.ts (~lines 501–516).
Monthly labor budget — partial-month annualization
For the current (incomplete) month, the system scales the day-by-day run rate up to a full-month projection, converts to a weekly run rate, looks up bracket hours, then scales back down to the elapsed days:
annualizedMonthly = partialActualSales / throughDay × daysInMonth
weeklyRunRate = annualizedMonthly × 12 / 52
weeklyBudgetHrs = lookupWeeklyHours(weeklyRunRate) − gmAdj
budgetHrs = weeklyBudgetHrs / 7 × throughDay
monthlyLaborBudget = budgetHrs × stateWage
This drives the live monthly labor-vs-budget figure — and explains why it shifts as the month progresses. Source: src/app/api/labor/monthly-labor/route.ts (~lines 392–398).
Labor delta (vs budget)
budgetPct = (weeklyBudget / sales) × 100
actualPct = (netLaborCost / sales) × 100
delta = actualPct − budgetPct
delta positive = over budget; negative = under. The Weekly and Monthly Labor views show all three numbers. Source: weekly-labor/route.ts (~lines 293–295), monthly-labor/route.ts (~lines 451–453).
Manual labor adjustment
A manually-entered hours/dollars adjustment that moves labor to/from corporate. Stored per-period on LaborAdjustment rows and added to the labor totals when computing weekly/monthly labor vs budget.
Repairs & maintenance
R&M smart planner
How the supervisor's weekly route is built — packs open tickets + due PMs around the scheduled lawn cuts.
-
Per-day candidate pool — three streams, all filtered to the selected state:
lawns = LawnScheduleEntry rows for the day (one anchor with startTime + N flex-time entries) tickets = open RepairTicket rows where (status="new") OR (assignedOpcoSupervisor=true) pms = PM_SCHEDULE tasks for this month, performedBy ∈ {Maintenance, Manager/Maint}, minus rows already in MaintenanceCompletion for this year+month -
Day start — anchored lawn places first at its
startTimeand runsstartTime + durationMinutes. If no anchored lawn, the day starts at 8:30am at the highest-priority first pick. -
Greedy fill — repeat until 4pm or the pool empties:
for each candidate c in pool: travel = haversine(cursor.lat/lon, c.store.lat/lon) × 1.25 × (55 mph) startHour = nowHour + travel.minutes / 60 endHour = startHour + c.durationMinutes / 60 if endHour > 16: skip priority = basePriority(c) + (atSameStore && pmsAtThisStoreSoFar < 3 ? 1000 : 0) sort candidates: priority desc, travel miles asc pick top, advance cursor and clock -
Priority tiers:
lawn = 100 // must happen today, can't slip critical = 4 high = 3 normal = 2 PM = 1.5 low = 1 -
Same-store boost — +1000 when the candidate's store equals the cursor. The boost dominates the tier system, so tickets + PMs at the current store get picked before driving anywhere else. PM cap: only the first 3 PMs at a store get the boost; the 4th PM has to compete normally so the supervisor doesn't get stuck.
-
Travel time — Haversine miles between coordinates, multiplied by 1.25 for road circuity (matches
src/lib/repairs-maintenance/drive-estimate.ts), divided by 55 mph average speed.
Source: src/lib/repairs-maintenance/smart-plan.ts, src/app/api/repairs-maintenance/smart-plan/route.ts.
R&M learned estimate
Running average per (category, urgency) bucket — feeds the smart planner so per-ticket time estimates improve as the supervisor logs more actualMinutes on resolve.
sampleCount = COUNT(resolved tickets with actualMinutes set in this bucket)
meanMinutes = ROUND(SUM(actualMinutes) / sampleCount)
Used by the planner only when sampleCount ≥ 3. Below the threshold, the planner falls back to the ticket's stored estimatedMinutes (default 90).
Source: src/lib/repairs-maintenance/learned-estimates.ts.
Sales & forecasting
DOW weights (day-of-week sales distribution)
What share of a typical week's sales lands on each weekday at each store. Seven values summing to 100. Used by labor (cut/add), the weather model, and pacing/projection calculations.
- Storage:
store_dow_weightstable, per (store, dayOfWeek), values as percentages. - Where used:
calcLaborTarget(implied weekly), the weather-demand forecast, weekly base calculations.
Hourly sales distribution
What share of a daily sales total each hour typically holds — per store, per season, per day-key. Fractions sum to 1.0 across open hours.
- Storage:
src/lib/weather-demand/store-hourly-pct.ts(STORE_HOURLY_PCT). - The
monthuday-key curve is the mean of Mon/Tue/Wed/Thu. - Seasons: offseason (Sep–Apr), summer (May–Aug).
Schedule-guidance weekly sales target
The per-store, per-week sales target that drives the labor schedule. DB-backed and editable, one row per (store, weekStart). It's a managed value — the "smoothed bracket" math (above) consumes it.
- Storage:
schedule_guidance_entriestable. - Source:
src/app/api/labor/schedule-guidance/route.ts.
Weekly base sales — from monthly budget
The store's "weekly base" (the typical-week sales figure used in pacing) is derived from the month's budget normalized to a 7-day equivalent:
weeklyBase = monthlyBudget / (daysInMonth / 7)
Source: src/lib/weather-demand/calc.ts (~lines 241–251).
Weather-normalized weekly run rate
Used internally to back out a "what the week looked like without the weather effect" weekly run rate from a single day's actuals — useful for diagnostics and for keeping the weather model honest against itself:
dowOnlyWeekly = actual / (dowPct / 100)
impliedWeekly = dowOnlyWeekly / (1 + weatherAdjPct / 100)
Source: src/lib/weather-demand/calc.ts (~lines 284–295).
YTD budget — month-weighted pacing
Year-to-date budget through a cutoff date sums the budgets of completed months plus a DOW-weighted MTD portion of the current partial month — flat proration would be wrong because weekdays don't weigh equally:
YTD = sum(monthlyBudgets[completed months])
+ dowWeightedMtdBudget(currentMonth, cutoffDate)
Remaining = (currentMonthBudget − MTD) + sum(monthlyBudgets[remaining months])
Source: src/lib/weather-demand/calc.ts (~lines 299–323).
Full-year forecast projection
Projects where the year will land by applying the YTD pace (actual / budget) to the remaining budget:
ytdActual = baseActual + (stormAdjOn ? stormImpactAmount : 0)
rate = ytdActual / ytdBudget // pace
fullYearForecast = ytdActual + remainingBudget × rate
When ytdBudget == 0 (very early in the year), falls back to ytdActual + remainingBudget (no rate applied). Source: src/lib/weather-demand/calc.ts (~lines 331–339).
Rolling 7-day average
A smoothed line over recent daily sales, used on the YOY and forecast charts.
rolling7[d] = sum( dailySales[d-6 .. d] ) / 7
Forecast accuracy — MAE per horizon
Mean absolute error of the weather-demand forecast vs actuals, computed per forecast horizon (1-day, 3-day, 5-day, 7-day, 10-day, 14-day) — i.e., "how good is the model when predicting N days out?" The 1-day horizon is usually the most accurate (it's seeing tomorrow's weather almost perfectly); error grows with horizon length.
errorPct[i] = (forecast[i] − actual[i]) / actual[i] × 100
MAE[horizon] = mean( | errorPct | ) over the comparison window for that horizon
Source: src/app/api/scheduling/forecast-accuracy/route.ts (~lines 68–86).
Forecast accuracy — AVG Δ
Average percent difference between forecast and actual.
AVG Δ = mean( (forecast − actual) / actual ) × 100
Positive = forecast overshoots actual on average; negative = undershoots.
YOY baseline from inception
A per-store flag. When true, the YOY comparison uses the store's full owned history as the baseline rather than the standard 52-week trailing window — used for newly-acquired stores so their YOY charts have meaningful prior-owner data behind them.
- Storage:
Store.yoyBaselineFromInception(boolean). - Behavior: YOY route bypasses the day-of-year gate for per-store charts when the flag is on; aggregates still gate by
ownedSinceDoyMap.
Inventory & product
Case divisor (units → cases)
The standard conversion from individual units to inventoried cases used by Inventory, COGS, EOM lock, and EOM financial.
qtyInCases = defaultCountMode === "EA"
? qty / divisor
: qty // already counted in cases
Snack waste pack overrides
A small list of products where the waste / giveaway count flow uses a different divisor than inventory. Store teams log waste in individual pieces, but inventory treats one retail bag as one case — without the override, 30 pieces would be costed as 30 cases.
- The override is consulted only on the giveaway and waste write paths (and the giveaway-form EA→CS preview).
- Inventory, COGS, EOM lock, and EOM financial are not affected — they use the product's default
caseDivisor. - Source:
src/lib/snack-waste/waste-pack-overrides.ts(WASTE_PACK_OVERRIDES). After adding or changing an override, runnpx tsx scripts/backfill-snack-waste-pack-overrides.ts --applyto fix historical rows.
Gallon prep — daily target
Translates each store's weekly gallon PAR into a daily build target, factoring DOW weight, seasonal index, and an optional manual override %, then splits across flavors with pre/post hand-held shifts:
dailyGallons = weeklyGallons
× (dowWeightBp / 700) // DOW share (basis points → fraction of week)
× (seasonalityIndexBp / 10000) // seasonal index (basis points → multiplier)
× (1 + overridePct / 100) // optional manual override
preHH = ceil(dailyGallons × 0.70) // 70% built before the hand-held shift
postHH = max(1, dailyGallons − preHH) // remainder; never below 1
perFlavor = round(dailyGallons × flavorPct / 100) // filtered to ≥ 1
This is why daily gallon prep can change between seasons even with the same weekly PAR — the seasonality index drives it. Source: src/lib/product-tracking/gallon-prep-calc.ts (~lines 48–69).
Jug prep factor
Diagnostic ratio of jugs built to sales, over rolling windows (7 / 14 / 30 / 60 days). Today's incomplete day is excluded:
prepFactor = (totalJugsBuilt / totalSales) × 100 // percentage
Useful for spotting stores building too many or too few jugs relative to their sales. Source: src/app/api/product-tracking/prep-factor/route.ts (~lines 64–66).
PAR level
The minimum on-hand stock the store should keep going into a week, set per (store, product). It's a target, not a formula — Inventory uses it for the order tracker and the PAR Analyzer.
Product shrinkage / drift
The variance between product used (from logged drinks/builds) and product sold (from Revel sales).
shrinkage = used − sold
drift = shrinkage / expected
A row is flagged in the shrinkage report when drift ≥ 1.0 (used at least 100% more than sold). Source: src/app/(dashboard)/.../product-slippage/page.tsx.
Product Mix percentages
Each product's share of total sold quantity (or revenue) by category. Standard share-of-total — no special math beyond count(product) / total.
Gallon / jug defaults
Per-store defaults that feed gallon prep and jug rotation guidance:
weeklyGallonDefault— baseline gallons-per-week for the store.jugRotationDefault— baseline rotation count.jugMinWeekday/jugMinWeekend— minimum jug counts by day type.
Editable in Admin → Store Management under Jug & gallon defaults.
Cash & End of Month
Drawer / safe variance
variance = countedAmount − expectedAmount
Positive = over; negative = short. Same formula for both the cash drawer reconciliation and the safe count.
COGS (Cost of Goods Sold)
The standard inventory identity holds throughout — consumed = beginning + purchases − ending — but which side is the input depends on how purchases were captured for the month:
QB-upload path (the QuickBooks total is the truth). Consumption is allocated from the QB total via per-category percentages; purchases are back-solved from the identity:
consumed[cat] = qbTotal × (overridePct[cat] ?? defaultPct[cat]) / 100
purchases[cat] = consumed[cat] − beginningInventory[cat] + endingInventory[cat]
App-orders path (orders logged in-app are the truth). Purchases come straight from the logged orders; consumption is computed forward:
purchases[cat] = sum(orderItems[cat]) × unitCost
consumed[cat] = beginningInventory[cat] + purchases[cat] − endingInventory[cat]
Source: src/app/api/inventory/eom-financial/route.ts (~lines 118–145). JE (Journal Entry) exports format the resulting per-category COGS lines for QuickBooks Online.
Theoretical COGS % by sales band
The EOM view compares each store's actual COGS % against a theoretical target driven by the store's annual sales band:
Source: src/lib/eom/cogs-targets.ts.
Storm impact
A per-store dollar adjustment for sales affected by a storm event. Stored as Store.stormImpactAmount; subtracted from raw sales where downstream calculations need a "normalized" number rather than the storm-distorted total.
Money audit framework
Any write to a money field in the DB goes through writeMoneyValue() in src/lib/audit/money-audit.ts, which:
- Updates the field inside a transaction.
- Writes a
MoneyAuditLogrow recording table, field, old value, new value, who, when, and source ("api"/"onboarding"/"script"/"migration"). - Busts every downstream cache that reads runtime-editable money fields (
invalidateBudgetReadPaths()).
This is what makes the audit log in Admin → Data Integrity → Store Change Audit Log complete for money fields. Non-money changes are recorded separately by the store-admin save route, and the audit-log view merges both timelines.
A note on weather
The DOW weights and hourly distributions above are inputs to the weather-demand forecast — but the forecast's own math (temperature elasticity per °F, cloud-cover and rain weights, AM/PM split, seasonal index) is documented separately. See Admin → Weather Model Docs.
Adding to this doc
When the Help Assistant flags a gap in Data Integrity → Help Assistant Question Log, that's a question this doc didn't cover. Add the missing formula here — the assistant reads this file directly and will start answering from it next time.