Page-Depth Audit: 16-Agent Fleet
One agent per left-menu page, each armed with today's live screenshots, the page source, and read-only prod queries. Every "data verified" quick win cites the actual query the agent ran. Synthesis first, then the per-page detail.
Model routing (capacity constraint)
Fable usage is at 80%+ with access ending Sunday 2026-07-19 5am (per Ace, 2026-07-17). Everything below routes to Sonnet 5 or Opus 4.8 by default; Fable is rationed to review gates and the highest-risk correctness work only.
- Sonnet 5
Single-surface UI and query work.Trend arrows, banners, freshness strips, defaults, mobile layouts, rollups, copy and label fixes, screenshot mining, QA driving.
- Opus 4.8
Multi-file features and correctness-sensitive logic.Data-destroying bug fixes, migrations, authz Phase 0, dashboard money band, cohort math, parsers, accounting logic, synthesis of fleet output.
- Fable
Rationed: review gates and identity-risk changes only, before Sunday 5am.Final pre-push review of authz Phase 0 and the geo-cohort + deal-staging identity changes (hotel identity is a standing high-risk area), plus top-level orchestration of this sprint.
Why the product feels generalized (6 themes)
- 1
Live daily feeds (night audit, rate_snapshots, booking_pace) already land in Supabase every day but almost no page joins them, so screens present March STR, stale CAD, and half-posted QBO as if they were current.
- 2
Wrong-cohort and wrong-basis math is reused everywhere: tax-tract compsets 16 miles wide, cap rates computed on ask beside LOI prices, all-levies millage sums, department-prefix 'labor', and double-counted revenue budgets produce numbers a domain pro distrusts on sight.
- 3
Pages default to the alphabetically-first property (aloft College Station / CLLAL) which has zero rows, so 13-14 of 15 selections open on an empty shell and the product reads as dead even where data is rich.
- 4
Dead scaffolding ships as decoration: whole tabs and KPI cards are wired to tables with zero rows ever (rate_alerts, variance_alerts, account_guests, parity sources, exemptions, close checklist), promising analysis that structurally cannot fire.
- 5
Numbers render without verdicts, trends, freshness, or aging: lifetime totals, single-month views, and 4-month-old indices are styled identically to live signals, so no persona can tell good from bad or fresh from rotten.
- 6
Write paths silently lose or destroy data: sales-plan edits wipe GM Budget scenarios, deal-staging promote drops hotel identity, reject flows discard reasons, and a duplicate hotel record strands 58% of all prospects.
Ranked build order
| # | Slot | Effort | Model | Page | Item |
|---|
| 1 | weekend | S | Opus 4.8 | /app/sales-plan | Stop scenario wipe + import GM Budget into V2 modelHighest urgency in the audit set: any edit today destroys the stored GM Budget, and fixing it simultaneously makes the real plan of record render in Forecast and Actuals vs Plan for all 3 planned hotels. |
| 2 | weekend | S | Sonnet 5 | /app/budget | Revenue-completeness banner (night audit vs posted QBO 41xx)One banner stops the page from presenting July as a catastrophic loss month; kills the single most trust-destroying artifact a comptroller would see. |
| 3 | weekend | S | Sonnet 5 | /app/data-health | Scheduled jobs health card from cron_execution_logsThe green 'all checks passed' banner is currently lying while QBO sync has failed 8/8 runs; one select makes the page honest about the whole pipeline. |
| 4 | weekend | S | Sonnet 5 | /app/compsets | RGI trend arrow (MoM) + rank column on list rowsThe hook already fetches the data and discards it; trend plus '2 of 5' rank is the first thing a STAR reader looks for and transforms bare index numbers into a verdict. |
| 5 | weekend | S | Sonnet 5 | /app/dashboard | Freshness strip (night audit / rates / pace / STR staleness)Four head queries; complements the already-committed money band by making the stale STR cards honest portfolio-wide and setting the freshness pattern other pages will copy. |
| 6 | weekend | S | Sonnet 5 | /app/compsets/:id | Rate-move deltas on calendar cells + per-night market rankZero new queries (captures already on the wire); turns the rate mirror into a 'what changed since yesterday' shop, the core daily RM question. |
| 7 | next-week | M | Opus 4.8 + Fable review | /app/hotel-intel/:strNumber | Geo compset cohort (radius + tier +/-1 + rooms +/-50%) replacing tax tractThe named fix: one data-source swap corrects the ranking panel, gap cards, gauges, and thesis simultaneously; verified 18-candidate query already works against str_hotel_summary. |
| 8 | next-week | S | Sonnet 5 | /app/hotel-intel/:strNumber | Exclude distressed hotels from averages + same-league gap leader + honest labels/n= samplesShips with the cohort fix: kills the fake +$10 outperformance story, the 303-room Marriott 'peer', and the three contradictory verdicts on one screen. |
| 9 | next-week | S | Sonnet 5 | /app/daily-ops | Booking Pace 'vs 7d ago' pickup column + revenue OTB68 banked snapshots make true pace a clean same-stay-date join; converts the misnamed OTB tab into the ahead/behind signal RMs actually ask for. |
| 10 | next-week | S | Sonnet 5 | /app/daily-ops | Night Audit LY same-date + trailing same-weekday contextThe useNightAuditLY hook already exists unused; delta chips answer 'was 70% on a Wednesday good?' with data going back to 2023. |
| 11 | next-week | S | Opus 4.8 + Fable review | /app/deal-staging | Promote carries identity into deal_references + $/key and revenue-multiple columnsEnds manual re-keying on every promotion and puts the first two numbers Ace computes mentally directly on the triage row. |
| 12 | next-week | S | Sonnet 5 | /app/deals | Pipeline card upgrades: broker line, open-questions badge, staleness chipThree small selects turn the board into a call sheet that shows who to call, what is blocking, and which deals (HGI N Dallas, 35d idle) are rotting. |
| 13 | next-week | S | Opus 4.8 | /app/budget | Labor tab fixed to actual wage accounts + real flow-through KPIReplaces total-opex-labeled-as-labor with real wage lines and gives BvA the 50%-rule flow-through metric M3 users expect; both computed from rows already loaded. |
| 14 | next-week | M | Sonnet 5 | /app/sales-intelligence | Last-seen + 30d momentum chips, honest win-back email routing, data-bearing default propertyStops drafting 'stayed with us recently' to accounts dead since March and re-ranks the queue by momentum instead of lifetime totals; the credibility fix for the whole sales lane. |
| 15 | next-week | S | Sonnet 5 | /app/tax | Assessment YoY column + 5yr sparkline, and FIPS derived from assessment rowsHistory is already fetched and discarded; surfaces the +53%/+25% protest triggers and restores millage coverage for the 3 missing counties (7 of 15 hotels). |
| 16 | next-week | M | Sonnet 5 | /app/compsets | Fill Avg RevPAR card + subject-vs-compset RevPAR, replace dead Rate Alerts card with computed Undercuts TonightKills the 'Coming soon' card with data one query away and makes the permanently-zero alerts card honest by computing undercuts from tonight's fresh snapshots. |
| 17 | next-week | M | Opus 4.8 | /app/compsets/:id | Subject OTB pace column in the rate calendar (HOUUS/HOURP/HOUZN)The join that makes it a pricing tool: OTB occupancy beside comp median flags exactly the soft-and-overpriced nights, using the existing useBookingPace hook. |
| 18 | next-week | M | Sonnet 5 | /app/prospects | No-next-action badge + reschedule, value-weighted columns, stage-aging chipsEnforces the one pipeline-discipline rule (35 of 36 open prospects have no next touch) and exposes that the entire 225-card board is 90+ days untouched. |
| 19 | next-week | S | Sonnet 5 | /app/daily-ops | Adjustments staff and reason rollups194 rows with staff and reason 100% populated; two client-side rollups answer the actual loss-prevention question owners have. |
| 20 | next-week | M | Sonnet 5 | /app/data-health | Other-feeds freshness strip + CLLSB/CLLAL coverage, unattributed-file drill-downExtends health beyond the 3-property flash lane to rate shop, pace, tax, and the 2 invisible email-feed properties; step toward the SLA-board deep feature. |
| 21 | next-week | M | Sonnet 5 | /app/leads | Review-queue aging pills, reject-reason capture, outcomes visible to capturerCloses the agent feedback loop before launch traffic arrives; all fields already fetched, and pre-launch (0 rows) is the cheapest moment to ship it. |
| 22 | next-week | M | Sonnet 5 | /app/pip | Mobile card layout + staleness chips + vendor rollupThe page is unusable on its persona's device today; cards, a 96d-stale flag, and vendor exposure make it minimally real, though the demo-seed data needs replacing before it earns daily opens. |
Per-page audits
/app/dashboard (Portfolio Dashboard) - src/pages/Dashboard.tsx shallow
An owner opening this at 7am on 2026-07-17 sees March 2026 STR numbers for 2 of 15 hotels, "No STR data" on the other 13 cards, and fifteen compset-drift chips, which are a data-plumbing signal, not an operating signal. Meanwhile prod already holds last night's FINAL night-audit for all 5 fed properties (business_date 2026-07-16, finality=final), rate shops captured today at 19:16 UTC for ~25 hotels, and pace reports dated today for 3 properties, and none of it appears. There is no money number for today, no MTD, no YoY, no budget pacing, and no freshness indicator beyond a tiny "Mar 2026" label, so a STAR/ProfitSword-grade user gets nothing they would act on this morning.
Quick wins
- S
Last Night band for the 5 fed properties data verifiedNew LastNightBand component above the card grid: one row per fed property from night_audit_current where business_date = yesterday, showing Occ / ADR / RevPAR / room revenue / left-to-sell / OOO with a 'final' badge. Single query, renders only when rows exist (feeds first, per board).Evidence: night_audit_current eq business_date 2026-07-16 returned 5 rows, all finality=final: HOUZN 91% / $121.11 / $109.78, HOUUS 52.76% / $100.62, HOURP 52% / $121.38 (90 LTS), CLLAL 94.29% / $169.30, CLLSB 100% / $201.64 / rev $17,744
- M
MTD money chips with true YoY for the 3 Houston fed props data verifiedPer fed property: sum night_audit_current room_revenue for month-to-date and show occupancy/RevPAR MTD; for HOUZN/HOUUS/HOURP also show same-calendar-dates last year delta. Pure actuals, no projection, so it clears the board's day-of-week rule.Evidence: night_audit_current July 2026 has 16/16 final dates for HOUZN, HOUUS, HOURP, CLLSB (CLLAL 12); night_audit_entries 2025-07-01..16 exist for exactly HOUZN, HOUUS, HOURP (16 rows each), and 2025-07-16 has 3 current rows for the same three
- S
Freshness strip under the header data verifiedReplace the mute 'Mar 2026' label with an explicit source-freshness strip: Night audit (max business_date per fed property), Rates (max captured_at), Pace (max report_date), STR (latest compset_metrics period, flagged stale when >60d). Four cheap head queries; green/amber/red dots. Makes the stale STR cards honest instead of silently 4 months old.Evidence: night_audit_current latest business_date 2026-07-16 for all 5 fed props; rate_snapshots max captured_at 2026-07-17T19:16:53Z across 25 hotels with next-3-day check-ins; booking_pace latest report_date 2026-07-17 for HOUUS/HOURP/HOUZN; dashboard hook already returns latestPeriod (2026-03) for the STR chip
- M
Tonight rate-position chip on the 3 Houston fed cards data verifiedOn PropertyCard for HOUZN/HOUUS/HOURP: 'Tonight $X vs comp median $Y, N left to sell' using rate_snapshots (check_in_date = today, latest captured_at per hotel) for subject + compset_members rates, and rooms_left_to_sell from last night's audit. Skip render when no same-day snapshot exists (CLLAL/CLLSB).Evidence: Subject next-3-day rates fresh today: HOUZN n=35, HOUUS n=44, HOURP n=40 (captured 2026-07-17 ~19:03-19:10 UTC); HOURP owned compset has 13 members, 10 with tonight (2026-07-17) rates ($113-$192, none sold out); CLLAL and CLLSB have 0 rate_snapshots rows (count exact = 0)
The deep feature
A per-property "today ledger" row for the fed properties that fuses all three live feeds into the revenue manager's morning one-liner: last night final (occ/ADR/RevPAR with same-day-last-year delta where LY exists), tonight on-the-books from booking_pace (rooms_sold, avg_rate, group_not_sold as of today's report), and tonight's shopped rate vs compset median from rate_snapshots, ending in left-to-sell. Concretely for HOURP this morning every input is already in prod: 89 of 190 sold for tonight at $115.51 OTB (booking_pace report_date 2026-07-17), shopped rate captured today vs a 10-member comp median around $146, 90 rooms left after last night's 52% close. That single fused row is what Lighthouse/Duetto-class tools open with, it is the difference between a dashboard that reports and one that prices, and it needs zero new pipelines for the 3 Houston properties (booking_pace, rate_snapshots, night_audit_current are all dated today). It also gives the money band a forward edge without violating the no-projection rule, because everything shown is an observed value.
Broken / misleading
- bug
useDashboardHotels (src/hooks/useDashboard.ts line 63) filters compsets on compset_type = 'primary', but only 3 of 15 THM hotels have a 'primary' compset while 10 are typed 'owned' (verified in prod: primary=3, owned=10). Result: 12 of 15 hotels get compsetId=null, so their CompsetDriftCards render unlinked (no navigation to Discovery) and any STR metrics attached to their owned compsets can never surface on the dashboard even after uploads resume.
- bug
The 'Needs Attention: RGI 82.4' pill (Dashboard.tsx lines 49-64) presents March 2026 RGI as a current alert with no staleness marker; four-month-old data is styled identically to a live red flag.
- bug
RevenueGapsCard is passed worstRgi.id, i.e. always the single worst-RGI hotel (currently Holiday Inn & Suites College Station on Mar 2026 data), while its own comment claims 'first hotel with data'; the chip links to /app/sales-intelligence without carrying the hotel context, so the label reads as a portfolio-wide gap when it is one stale hotel's segment gap.
- bug
Compset Changes (90d) = 45 renders as a bare count with no per-property breakdown or severity, indistinguishable from noise; it is the only portfolio rollup chip yet gives the owner nothing actionable without a click.
/app/compsets shallow
A cluster RM (Aadil) opens a compset tool daily to answer "where am I priced tonight vs my comps, and is my index trending up or down." This page only answers "what did the March STAR report say," and shows it as bare index numbers with no trend arrows, no rank, no RevPAR dollars, and a stale-data header the KPI cards ignore. The entire rate-shopping layer that would make it a Lighthouse/OTA-Insight peer is inert: the alerts card has literally never had data (rate_alerts table is empty, 0 rows ever), and 11 of 16 compsets show no rate scan at all because the daily flash scan only covers Houston. Meanwhile 498 fresh snapshots across 25 Houston hotels sit in rate_snapshots every 24h and none of that reaches this page.
Quick wins
- S
Fill the Avg RevPAR card and add subject-vs-compset RevPAR to rows data verifiedIn Compsets.tsx KPI strip, replace the 'Coming soon' card with portfolio avg subject RevPAR and delta vs compset avg from compset_metrics (metric_type='revpar', period_type='monthly', latest period per compset). Extend the useCompsetListMetrics query to also pull subject_value and compset_avg for revpar rows; render '$96.66 vs $89 comps' per row or as the card subtitle.Evidence: select compset_id, period, subject_value, compset_avg from compset_metrics where metric_type='revpar' and period_type='monthly' order by period desc: 13 compsets have 2026-03 rows with subject_value populated (e.g. 96.66, 140.05, 206.59), source str_comp
- S
RGI trend arrow (MoM delta) + RevPAR rank column data verifieduseCompsetListMetrics already fetches compset_metrics ordered period desc and discards everything after the first row per metric; keep the second period too and the rank/rank_out_of fields. Render RGI as '114.8 up 10.1' with an arrow and add a 'Rank' column ('2 of 5'). This is the first thing an STR STAR reader looks for.Evidence: rgi monthly rows per compset show latest plus prior periods with rank populated: e.g. compset 4e92cd79 2026-03:114.84 rank 2/5, 2026-02:104.78 rank 3/5; compset 258478a6 2026-03:109.05 rank 2/8, 2026-02:85.99 rank 5/6; all 13 metric-bearing compsets have at least 2 periods
- M
Live rate position column for scanned compsets data verifiedAdd a 'Rate vs Comps (7d)' column reusing the existing useCompsetForwardRateAverages hook (already built for the detail page) at list level: subject avg-lowest-BAR over next 7 nights vs median of active members, shown as '$128 vs $135 (-5%)'. Populates for HOUZN (9/9 members fresh), HOURP (8/8), HOUUS (6/6); renders 'Not monitored' elsewhere instead of a silent dash.Evidence: rate_snapshots where captured_at > now-24h: 498 rows, 25 distinct hotels, check_in_date 2026-07-17 through 2026-07-26; cross-referenced compset_members: HOUZN 9/9, HOURP 8/8, HOUUS 6/6 active members fresh plus their subjects; all other compsets 0 fresh members
- M
Replace the dead Rate Alerts card with computed 'Undercuts Tonight' data verifiedrate_alerts has never had a row, so the card, the Alerting filter, and the RM severity triage are permanently inert. Compute undercuts client-side for the fresh compsets: for tonight's check_in_date, count active members whose lowest-source rate undercuts the subject's, per compset. Card becomes 'Undercut by N comps tonight' with the compset list; keeps the red/amber styling honest.Evidence: select count(*) from rate_alerts: 0 total, 0 unacknowledged, latest-10 query returns []; rate_snapshots has tonight's (2026-07-17) rates for subject and all active members of the 3 Houston compsets per the 24h freshness query
The deep feature
A nightly rate-position engine that turns the already-flowing rate_snapshots into per-compset positioning, which is the core of what Lighthouse sells: for each compset-night over the next 30-90 days, compute the subject's rank among active members on lowest available BAR, days undercut, and spread to market median, persist it, and generate real undercut/parity rows into the currently empty rate_alerts table so the entire alert UI (card, filters, RM triage banner, severity rails) stops being decoration. The list page row then reads like a rate shop: 'Tonight #3 of 7, undercut on 12 of next 30 nights, avg gap -4%' next to the monthly RGI. The blocking gap is coverage, not code: the flash scan feeds only 25 Houston hotels, so DFW, BCS, and Midland compsets (11 of 16) have zero forward rates; extending the scan roster to those compset members is the single highest-leverage move because every downstream feature on this page starves without it. With that in place, Aadil gets one screen that answers both the monthly question (STAR index, rank, trend) and the daily question (position tonight and forward), which is exactly the two-tool workflow (STR + rate shop) this page is supposed to collapse.
Broken / misleading
- bug
Rate Alerts KPI shows 0 because rate_alerts has 0 rows ever (verified count); alert generation never ran, so the Alerting filters, RM severity triage banner, and red/amber row rails are all dead code in production
- bug
Avg RevPAR card says 'Coming soon' even though subject_revpar exists in compset_metrics for 13 compsets (2026-03, source str_comp); the data is already fetched-adjacent in the same table the page queries
- bug
Last Scan shows a dash for 11+ of 16 compsets: the daily flash scan covers only Houston hotels (25 fresh in 24h, all Houston), so DFW/BCS/Midland compsets are never scanned; Home2 Lewisville Dallas last scanned 2026-05-08 (the 69d-ago row) yet the row gives no 'not monitored' signal
- bug
compset_metrics has duplicate period rows: e.g. compset 5a1473e0 has two 2026-02 rgi rows (121.668992464606 with rank null, and 121.67 with rank 2/7); the hook's first-row-wins merge can nondeterministically pick the rank-null dupe
- bug
'RLS Test Compset Alpha' (1 member, test data) lives in the prod compsets table and would render for any tenant whose RLS scope includes it
- bug
Two rows (Four Points Midland, DoubleTree Galleria) show Market as a dash because hotels.region is null, so the market filter pills silently exclude them
/app/compsets/:id (CompsetDetail: overview / rates / parity / alerts / discovery) shallow
A revenue manager opening this daily gets one number per hotel per night (current lowest scraped rate) plus an STR verdict frozen at March 2026 (verified: latest compset_metrics monthly period = 2026-03 for every compset). The two questions a pro tool answers on screen one, "what changed since yesterday" and "am I priced right given what is on the books", are unanswerable here despite both datasets sitting in prod: twice-daily capture history per check-in date and daily 91-day booking_pace for 3 subject properties. Meanwhile Parity and Alerts, two of the five tabs, are structurally dead with current data (single source, zero alerts ever), so the page reads as a rate mirror wrapped in empty scaffolding rather than a pricing tool.
Quick wins
- S
Rate-move deltas on calendar cells data verifiedIn RateCalendarTab resolveCell, keep the prior capture per (hotel, check_in) alongside the latest; when they differ, render a small up/down arrow with the dollar delta next to the rate (e.g. '$178 ^ +10'). The range query already fetches all captures (limit sized hotels x nights x 12), so zero new queries.Evidence: rate_snapshots for hotel b05ccdde-6d95-479d-98e4-64641e330640, check_in 2026-07-22: 12 captures Jul 13-17, rates 139->147->168->178; for check_in 2026-07-19, 3 of 25 hotels moved between the last two capture cycles
- M
Peer rate moves, last 24h feed data verifiedNew card on the Rate Calendar tab: group already-fetched snapshots per (peer, check_in), diff latest vs prior capture, list movers sorted by absolute delta ('Home2 Westchase Jul 22: $168 -> $178, +6%'). This is the Lighthouse 'rate changes' feed from data already on the wire.Evidence: Same capture-history queries as above; scans run 2x daily (rate_scan_jobs cron-houston-flash at 11:00 and 19:00 UTC daily), so latest-vs-prior is a true ~12-24h diff
- M
Subject OTB pace column in the rate calendar data verifiedFor subjects with a property_code in booking_pace (HOUUS, HOURP, HOUZN), add an 'OTB' column pair next to the subject rate: occupancy_pct and avg_rate from the latest report_date per stay_date (useBookingPace hook already exists). Tint red when OTB occ is low AND subject rate is above comp median: that is the actual pricing signal.Evidence: booking_pace latest report_date per property: HOUUS/HOURP/HOUZN all 2026-07-17 (today), 91 forward stay_dates each; HOURP next 9 nights show occupancy_pct 26.8-51.1 and avg_rate 108-129, fully populated
- S
Per-night market position rank data verifiedCompute subject rank per night from the cellMap already in memory ('#3 cheapest of 9') and render it in the date column of each calendar row plus the Tonight's Parity card. Pure client-side derivation, no new query.Evidence: 25 hotels with snapshots captured in last 24h (498 rows); HOURP compset has 8 active members and 20 subject snapshot rows in the last 24h, so per-night ranks resolve for the full 9-day forward window
The deep feature
A per-night pricing decision view that fuses the three daily-fresh datasets this page already has but never joins: for each of the next 9+ nights, show subject on-the-books occupancy and pickup (booking_pace rooms_sold deltas across consecutive report_dates; verified HOURP stay 7/25 picked up 70 to 83 rooms over 4 report days), the comp median and subject price rank from rate_snapshots, and peer rate momentum from the 2x-daily capture history, then emit a defensible per-night call: push (high pickup, priced below median), hold, or drop (soft OTB, priced above a falling market). That is precisely the screen Lighthouse and OTA Insight charge hundreds per property per month for, and for HOUUS, HOURP, and HOUZN every input is already landing in Supabase daily with zero new pipelines. It converts the page persona from 'reading a rate mirror' to 'making tonight's pricing decision with evidence', which is the depth Ace says was lost.
Broken / misleading
- bug
Parity tab is structurally dead: it requires 2+ rate sources but every snapshot captured in the last 7 days has source='google' (verified via distinct-source query); the parity score can never compute, so the tab permanently shows 'Insufficient data'. Multi-OTA sources exist only in April-era rows.
- bug
ParityTab 'Scan Now' button (src/components/compset/ParityTab.tsx:149) calls triggerScan(compsetId) in the legacy string shape, which useTriggerScan (src/hooks/useCompsets.ts:615) converts to hotelIds: [], inserting a rate_scan_jobs row with zero targets that scans nothing.
- bug
Alerts tab and the Active Alerts card are perpetually empty: rate_alerts has 0 rows all-time; the alert writer in supabase/functions/dfs-webhook/index.ts skips generation when scan_job.compset_id is null, and every current scan is a cron-houston-flash job with compset_id null. The card's promise that 'default rules will fire when a peer undercuts >15% for 3+ nights' never happens.
- bug
Rate Calendar 30- and 60-night windows are mostly striped placeholders: max forward check_in_date in prod is 2026-07-26 (9 days out), so ~70-85% of those grids render as no-data stripes with no explanation of the real scan depth.
- bug
Sold-out badge is dead code: is_sold_out has 0 true rows all-time in rate_snapshots, so the compression signal it implies never renders.
- bug
Overview verdict ('Am I Winning My Market') runs on STR data frozen at 2026-03 for all compsets (2026-02 for one), so the page-leading headline is 3+ months stale; the small freshness chip does not stop it from reading as current.
- bug
Minor: computeVerdict's empty-state result (src/hooks/useCompsetOverview.ts:314) uses leaf 'WIN' for 'No data available', so downstream leaf checks misclassify the no-data case.
/app/sales-plan shallow
A GM or DOS opening this page today sees either an empty state (default property CLLAL has no plan and no STR rows) or, on HOURP/HOUUS/HOUZN, a 'forecast' that is just last year flat, because the V2 UI reads adjustment columns that are empty on every prod row while the actual GM Budget scenarios sit unrendered in the same table. Annual plan vs actuals is the one job of this page and the plan shown is not the plan the GM built. Worse, touching any cell silently destroys the stored GM Budget scenarios. Compared to a professional package (budget workbook plus STAR trend plus pace-to-plan), it has the bones of all three but connects none of them to the live data (night audit and booking pace) that already lands daily for exactly the three planned properties.
Quick wins
- S
Import the v1 GM Budget into the V2 model and stop wiping it data verifiedIn SalesPlan.tsx/ForecastTab: when a plan has version 1 scenarios and empty V2 adjustment columns, offer a one-click 'Import GM Budget' that copies each month's marketGrowth/occupancyAdjustment/adrAdjustment from the GM Budget scenario monthlyData into market_growth_pct/occ_adjustment_pct/adr_adjustment_pct; simultaneously change useMutateSalesPlanV2 to omit scenarios from the upsert when not passed (preserve existing) instead of defaulting to []. Forecast and Actuals vs Plan immediately reflect the real budget for all 3 properties.Evidence: select scenarios from sales_plans: HOURP/HOUUS/HOUZN 2026 each have 'GM Budget 2026' with 12 monthlyData rows containing marketGrowth, occupancyAdjustment, adrAdjustment, occ, adr, revenue (e.g. HOURP Jan: occ 53.4, adr 113.96, marketGrowth -3, adrAdjustment -5); all V2 adjustment columns have 0 keys on every prod row.
- S
GM Budget comparison row in ForecastGrid and as the baseline in Actuals vs Plan data verifiedRender the stored GM Budget scenario (occ/adr/revenue per month) as a reference row under each projection month in ForecastGrid, and in ActualsVsPlanTab compute variance against scenario monthlyData instead of the zero-adjustment TTM re-projection. Pure frontend read of plan.scenarios already fetched by useSalesPlan.Evidence: sales_plans.scenarios verified for all three 2026 plans: 12-month monthlyData with occupancy, adr, revenue (HOURP GM Budget total $6,534,417; HOUUS $3,205,770; HOUZN $3,850,083); compset_metrics has 2026-01..2026-03 actuals (6 metric types/month) for HOURP's compset to grade against.
- M
Current-month OTB vs plan strip data verifiedNew card above ForecastGrid for the in-flight month: MTD actual revenue/occ/ADR aggregated from night_audit_entries (business_date <= yesterday), plus on-the-books for remaining days from the latest booking_pace snapshot (report_date = max, stay_date in month), compared to the GM Budget month with pickup-needed rooms and dollars. Works for HOURP/HOUUS/HOUZN, hidden otherwise.Evidence: night_audit_entries: daily rows with occupancy_pct/adr/revpar/room_revenue through 2026-07-16 for property_ids resolving to HOURP, HOUZN, HOUUS, CLLSB, CLLAL; booking_pace: report_date max 2026-07-17 for HOURP/HOUUS/HOUZN (3451/5366/3379 rows), July snapshot for HOURP returned 15 remaining stay dates with rooms_sold/avg_rate/room_revenue.
- S
Plan-aware property default and coverage badges in the picker data verifiedDefault the property dropdown to the most recently updated property that has a sales_plans row (instead of alphabetical CLLAL, which has zero STR metrics), and suffix each option with its data state: 'plan', 'STR thru Mar 26', or 'no STR data', driven by one query over sales_plans plus the compset_metrics max(period) per compset. Kills the empty-state first impression.Evidence: sales_plans has exactly 4 rows (HOURP 2025/2026, HOUUS 2026, HOUZN 2026); compset_metrics coverage query showed monthly rows only for HOURP (thru 2026-03), DFWXY (2026-03), CLLSB (2026-03), DALDL (2025-10) plus 2 non-THM compsets; CLLAL's owned compset has zero rows.
The deep feature
A monthly pace-to-plan bridge that turns the annual plan into a living forecast: for each month show Plan (GM Budget), then MTD actual from night_audit_entries (daily occ/ADR/room_revenue, current through 2026-07-16 for HOURP/HOUUS/HOUZN), then on-the-books for remaining days from booking_pace (daily snapshots through 2026-07-17, ~90 days forward, per-stay-date rooms_sold/avg_rate/room_revenue for the same 3 planned hotels), and finally 'pickup needed': rooms and ADR required over remaining days to hit the plan month. This is what Duetto/Lighthouse-grade tools give a DOS and it is the difference between a plan you file and a plan you manage; the GM opens it Monday and knows July needs 412 more room-nights at $118+ to make budget. All three data feeds already land daily in Supabase for exactly the three properties that have plans, so no new pipeline is needed, just the join and the arithmetic. It also gives the page a reason to be opened weekly instead of twice a year (budget season and year-end).
Broken / misleading
- bug
Data-destroying edit path: every adjustment or rank-goal edit calls useMutateSalesPlanV2 without a scenarios arg, and the upsert defaults to scenarios: [] (src/hooks/useSalesPlan.ts line 563), silently wiping the v1 GM Budget / Fair Share / Actual scenarios and restamping version=2. All 4 prod plans (HOURP 2025/2026, HOUUS 2026, HOUZN 2026) carry real scenario data that one keystroke destroys.
- bug
The plan of record never renders: prod sales_plans are all version 1 with GM Budget 2026 monthlyData (12 months of occ/adr/revenue, e.g. HOURP $6,534,417), but ForecastTab and ActualsVsPlanTab read only the V2 columns market_growth_pct/occ_adjustment_pct/adr_adjustment_pct, which are empty ({}) on every prod row. So Forecast shows a flat copy of prior-year TTM and Actuals vs Plan grades actuals against a baseline no GM ever set.
- bug
First-run dead end: page defaults to the first property alphabetically (CLLAL), which has zero rows in compset_metrics for its compset, so Create Sales Plan produces an empty forecast with only a 'No 2025 STR data' warning; 9 of 13 properties with compsets have no metrics at all.
- bug
Annual roll-ups are methodologically wrong: computeAnnualSummary averages monthly RevPAR and monthly RGI arithmetically (not supply/revenue weighted), so the headline 'Projected RGI' will not reconcile with an STR annual index; a DOS will catch this immediately.
- bug
Lost-depth residue: OverviewTab.tsx, ScenariosTab.tsx, BudgetBuilderTab.tsx, MonthStatusBadge.tsx in src/components/sales-plan/ are dead code from the V1 page that showed scenario comparison (GM Budget vs SMART Forecast vs Fair Share); those tabs were removed without replacement, which is exactly the generalization Ace is complaining about.
/app/sales-intelligence shallow
A DOS reading a Hotelligence/Agency360-style report gets pace and trend per account; this page gives lifetime totals over a Mar 1 to Jun 17 window with no time axis. The number-one ranked target (fresenius medical care, 126 nights) last stayed 2026-03-10, and 45 of 87 accounts have not been seen since May 15, yet all count equally toward "598 room nights in play" and get the same "stayed with us recently" intro email, which would burn credibility on the first call. The account ADR is shown but never positioned against the hotel's live public rate (fresh in rate_snapshots today), so the DOS has no anchor for what LNR rate to offer. And the page renders real data for exactly 1 of 15 properties while defaulting to aloft College Station (zero rows), so the first impression is an empty shell with IHG-only jargon on a Marriott property.
Quick wins
- S
Default the property picker to a hotel that has data, with coverage badges data verifiedIn SalesIntelligence.tsx, when no saved LS_KEY, pick the property with the most sales_account_summary_v2 rows instead of sortedProps[0]; render a 'feed live' dot next to properties with rows and a one-line coverage note ('Account feed live for 1 of 15 properties') in the empty state. One extra query: select hotel_id from sales_account_summary_v2, count client-side.Evidence: select hotel_id from sales_account_summary_v2 returns rows for exactly one hotel_id 1310e61a (HIE Houston Westchase, HOUUS): 87 accounts, 48 targets, 598 target nights. Screenshot confirms default is aloft College Station showing four zeros.
- M
Last-seen date and 30-day momentum on every target card data verifiedNew hook querying account_production (company_name, business_date, room_nights) for the hotel, compute per-company last-seen plus trailing-30d vs prior-30d nights relative to the feed's max date; render 'Last stay Jun 12 · 44 nights last 30d (was 161)' with a growing/steady/fading/gone chip. Sort fading accounts down or flag them.Evidence: account_production has daily grain, 561 rows for HOUUS, 2026-03-01 to 2026-06-17. Computed 30d/prior-30d deltas client-side: bechtel 161 to 44 (fading), engine corp tvl 18 to 56 (growing), fresenius last seen 2026-03-10 (gone). 39 accounts active in last 30d of data, 42 in prior 30d.
- M
Rate positioning line: negotiated plan + paid ADR vs live public rate data verifiedRender the plans field (already fetched by useSalesAccounts, never displayed) as a chip on each card, and add one query to rate_snapshots for the next-7-day BAR band; show 'Pays $132/night on ENGINE CORP TVL vs your $83 to $114 public rate' so the DOS knows the floor before quoting an LNR.Evidence: sales_account_summary_v2.plans populated (e.g. 'IHGBE 14 5', 'GOLD 15 LRA'); rate_snapshots for HOUUS captured 2026-07-17T19:03Z with next-7-day rates $81 to $114; per-account adr present in account_production (fresenius $114.57 avg, engine corp $132.05).
- S
Stop the email drafts from claiming 'recently' on dead accounts data verifiedPass last-seen (from the momentum query above) into buildEmailDraft callers in SalesIntelligence.tsx; if last stay is over 45 days before the feed max, draft from the win_back sequence (already exists in salesCadence.ts) instead of the step-1 intro, and drop the 'stayed with us recently' recognition line.Evidence: fresenius medical care: 126 room nights, all in 2026-03 (last business_date 2026-03-10), yet introMailto builds 'Your team has stayed with us 126 room nights recently'. 45 of 87 HOUUS accounts unseen since 2026-05-15. WIN_BACK cadence already defined in src/lib/salesCadence.ts.
The deep feature
An account lifecycle model that turns the page from a roster into a ranked play sheet: classify every target as growing, steady, fading, or gone from the account_production daily grain (trailing 30d vs prior 30d nights plus last-seen), rank the queue by convertible revenue at risk (trailing-30d nights times paid ADR) instead of lifetime nights, and auto-route each class to the right cadence: growing and steady get the Business Edge or Corporate Gold intro, fading and gone get the existing win_back sequence. Pair it with a feed-freshness banner (the feed currently ends 2026-06-17, 30 days stale, and the page says nothing) so the DOS always knows what window she is selling from. This is exactly the pace-versus-history distinction that separates Agency360/Hotelligence-grade corporate reporting from a static production dump, and every input already exists in account_production plus salesCadence.ts.
Broken / misleading
- bug
account_guests is empty in prod (0 rows across all hotels), so the 'Who stayed' button never renders and the guest-name personalization clause in every email draft can never fire; the whole guests code path is dead
- bug
account_production feed is stale: last business_date 2026-06-17 vs today 2026-07-17, with no freshness indicator anywhere on the page; stats and emails present 30-day-old (and for many accounts 4-month-old) production as current
- bug
Default property selection is alphabetical-first (aloft College Station) which has zero data; 14 of 15 selector options show the empty state, making the page look dead on first open even though HOUUS has 48 live targets
- bug
IHG-only concept language (Business Edge, Corporate Gold KAD, empty-state copy, KPI card labels) renders unchanged on non-IHG properties like aloft (Marriott) and Hyatt Place; only the loyalty-program name in email copy adapts via loyaltyProgramFor
/app/prospects (ProspectPipeline.tsx) shallow
A DOS opening this page sees a bare six-column card board: no dollar value anywhere, no stage aging, no owner, no cadence status on the cards, and for 13 of 15 properties the board is literally empty. The underlying cadence engine (scripts, outcome logging, dilution-guard Won dialog) is genuinely thoughtful, but it is disconnected from the board: manually moving a card to Interested schedules nothing, so 35 of 36 open-stage prospects in prod have no next_touch_at and never surface in the Today queue. Prod usage confirms the tool is not being worked: 3 activity rows ever, 0 prospects assigned to anyone, 12 wins recorded with zero won_rate captured.
Quick wins
- M
No-next-action badge + one-click reschedule data verifiedIn ProspectPipeline.tsx, flag any card in contacted/interested/proposal with null next_touch_at with a red 'No next step' chip, and add a section under TodayQueue listing them with a 'Schedule follow-up' button that sets next_touch_at=today and cadence_step=1 (reuse useStartOutreach's update path). This is the core pipeline-discipline rule every real sales tool enforces: no open deal without a next action.Evidence: select stage,next_touch_at from prospects where stage in (contacted,interested,proposal): 36 open-stage rows, 35 with next_touch_at null (all 35 interested/contacted rows at HIE Westchase and SHS NW have step 0 and no scheduled touch)
- S
Value-weighted columns and card sort data verifiedShow estimated_room_nights on each ProspectCard and sum it in each KanbanColumn header ('NEW · 207 · 4,120 rn'), sorting cards within a column by estimated_room_nights desc so the biggest accounts float up. Pure client-side change on data already fetched by useProspectList.Evidence: select estimated_room_nights from prospects: 546 of 605 rows populated; SHS NW alone has 207 new-stage prospects currently ordered only by created_at
- S
Stage-aging chips data verifiedRender days-in-stage on each card from updated_at (updated on every stage move), amber >14d and red >30d. Instantly exposes rot: the newest prospect at SpringHill NW was created 2026-04-12, so the entire 225-card board is 90+ days untouched and would light up red.Evidence: select created_at,updated_at from prospects where hotel_id=066e5d73: all rows have both timestamps; max(created_at)=2026-04-12T02:47Z
- S
Empty-state that routes to unworked targets data verifiedWhen useProspectList returns 0 rows, replace the barren kanban with a card querying sales_account_summary_v2 for the hotel: 'N producing target accounts not yet in pipeline' with a link to Sales Intelligence, falling back to an honest 'no account data for this property yet' where the view is empty.Evidence: select from sales_account_summary_v2: 87 rows, 48 with is_target=true, $333,293 room_revenue, all for hotel 1310e61a (HIE Westchase); other hotels return 0 rows so the fallback copy is required
The deep feature
Closed-loop won-account verification: turn the Won column from a graveyard into a contract scoreboard. Join won prospects (won_rate, won_expected_nights from migration 258) against actual production in sales_account_summary_v2 and account guest stays for the 5 night-audit properties, and render each won account's pace: committed nights vs actualized nights year-to-date, realized ADR vs negotiated rate, with a red flag and a one-click win-back-style re-touch when an account is producing under ~50% of its commitment. This is what separates Delphi/Amadeus-grade sales systems from card boards: the DOS is held accountable for revenue that actually materialized, not cards that moved. The WonDialog already captures the two inputs needed, so the remaining work is the production join plus backfilling estimated_adr from account revenue/nights at prospect creation (the prospectInputFromAccount path already computes it) so the dilution guard and the scoreboard both have a floor to measure against.
Broken / misleading
- bug
58% of all prospects are unreachable: 353 of 605 rows sit on hotel_id 725f1eef (a duplicate 'SpringHill Suites Houston Medical Center/NRG Park' record with is_thm_property=false); the property dropdown uses useThmProperties which filters is_thm_property=true, and the real THM record is 38dd28fc, so these prospects can never be displayed. Needs a data remap.
- bug
Kanban renders empty for the default property and 12 others: aloft College Station has 0 prospects (verified count=0), and only SHS Northwest (225), HIE Westchase (27), and the orphaned duplicate hotel have any rows.
- bug
Won-dialog dilution guard is dead in practice: estimated_adr is populated on 1 of 605 prospects, so the below-floor warning almost never fires; all 12 existing won rows have won_rate null.
- bug
Today queue is empty for 14 of 15 properties: sales_today_queue RPC returns 0 due / 0 new_starts everywhere except HIE Westchase (2 due, 4 starts), because cadence fields are set on only 2 prospects portfolio-wide and account summary data exists for one hotel.
/app/leads (Capture, My Leads, Review Queue, Scoreboard, Points Setup) adequate
The capture side is genuinely strong (30-second form, direct-contact coaching, server-stamped points), but the loop back to the agent is half-built: a rejected lead dies silently because the reject button never collects a reason and reviewer_notes/dos_outcome_notes are fetched but rendered nowhere, and a Salesforce-grade user would immediately notice there is no duplicate detection, so five agents can capture the same crew company five times with no rollup. The Review Queue has no aging or SLA signal, so Matthew cannot see that a hot crew lead has been sitting in Awaiting review for four days. Converted revenue is a self-typed number with no tie to actual room-night production, which weakens the leaderboard the moment real money rides on tiers.
Quick wins
- S
Aging pills + oldest-first sort in Review Queue data verifiedIn ReviewQueueTab.tsx, sort Awaiting review ascending by created_at and add a 'waiting Xd' pill per LeadRow (amber >24h, red >72h from created_at; for In progress use updated_at so a lead stuck in qualified for 10 days glows). Pure client-side math on columns already selected.Evidence: select id,created_at,updated_at,status from frontline_leads via service role succeeded (table live, count=0 pre-launch; rows are written by this page itself). Columns confirmed in the select and in the FrontlineLead type.
- M
Close the loop: outcomes visible to the capturer + reject reason collected data verifiedIn ReviewQueueTab reject flow, reuse the existing converting-style inline input to collect action.note (the useLeadAction API already accepts it, UI currently passes nothing). In MyLeadsTab cards, render reviewer_notes, dos_outcome_notes, and converted_revenue when present so Asma sees WHY a lead was rejected or WHAT it booked. Fields are already in the select * payload.Evidence: select reviewer_notes,dos_outcome_notes,converted_revenue from frontline_leads succeeded; columns exist in prod schema and in the FrontlineLead interface (useFrontlineLeads.ts lines 31-35), reject action signature already has note?: string (line 258).
- M
Repeat-company recall at capture (dedupe + autocomplete) data verifiedIn CaptureTab.tsx, add a datalist on the Company field fed by a distinct-company query on frontline_leads, and on blur run an ilike match; if hits exist render an inline chip: 'Captured 2x before, 1 converted $8k at CSHIX' (company, status, converted_revenue, property_code). Warns before double-capture and shows the desk that a name on repeat is already being worked.Evidence: select company,status,converted_revenue,property_hotel_id from frontline_leads via service role succeeded (table+RLS live in prod, count=0 today; self-populating from launch day). Property code map already loaded on the page via useThmProperties.
- S
Tier progress nudge on Capture data verifiedUnder the Submit button (or atop RecentLeadsPanel), compute all-time points from useMyLeads (MyLeadsTab already sums points_awarded) and render 'X pts to Bronze/Silver/Gold' with a small progress bar using lead_points_config thresholds via the existing usePointsConfig hook. rewardTier() helper already exists.Evidence: select * from lead_points_config where id=1 returned seeded row (tier_bronze 50, tier_silver 150, tier_gold 300); points_awarded column confirmed in frontline_leads select; rewardTier() already implemented in useFrontlineLeads.ts lines 146-151.
The deep feature
Company account rollup with production verification. Entity-resolve captures into a company account (normalized company name) so repeated captures of the same crew or corporate name across shifts and properties merge into one record with combined estimated room nights, first-seen and last-seen dates, and every capturer credited. Then, for the 5 properties with live night-audit feeds, verify conversions against actual production instead of trusting the self-typed converted_revenue number: when a reviewer marks a lead won, show the company's actual room-night and revenue trend in the weeks after conversion so payout-bearing leaderboard revenue is defensible. This turns the page from a suggestion box into a real pipeline source that can feed Prospects and Sales Intelligence, and it is exactly what separates a gamified form from a Salesforce-grade lead product.
Broken / misleading
- bug
Reject flow loses the reason: ReviewQueueTab.tsx line 110 calls act({type:'reject'}) with no note prompt even though useLeadAction accepts action.note; rejection rationale is never captured anywhere.
- bug
reviewer_notes and dos_outcome_notes are fetched (select *) but rendered in neither MyLeadsTab nor ReviewQueueTab, so outcome context is stored yet invisible to everyone including the capturer.
- bug
Scoreboard month boundary uses UTC: currentPeriod() in useFrontlineLeads.ts line 159 uses toISOString().slice(0,7), so from ~6pm Central on the last day of a month the default board shows the empty next month.
/app/daily-ops shallow
The Flash tab is genuinely strong, but the other three tabs are data plumbing dressed as reports. Night Audit is a clerk's entry form: last night renders with zero context even though the same table holds up to 3 years of history and an unused useNightAuditLY hook; a GM cannot see "was 70% occupancy on a Wednesday good?" The Booking Pace tab is misnamed: it shows on-the-books, not pace; with 68 daily snapshots banked since February it never answers the only question a revenue manager asks, "am I ahead or behind of where I was last week / last year, and should I move rate?" Adjustments lists refunds one by one but never aggregates by staff or reason, which is the actual loss-prevention question.
Quick wins
- S
Night Audit: LY same-date + trailing same-weekday context on the saved-entry card data verifiedIn NightAuditTab.tsx ReadOnlyView, add a comparison strip: Occ/ADR/RevPAR vs same date last year (existing unused useNightAuditLY hook) and vs the trailing 4 same-weekday average (one query: night_audit_current for property, last 35 days, filter weekday client-side). Render as delta chips next to the current values, same pattern the Flash cards already use.Evidence: night_audit_current .eq('business_date','2025-07-16') returned occ/ADR rows for all 3 Houston props (e.g. HOURP 82.63% / $118.15); history query since 2026-05-01 for HOUZN returned 11 Wednesdays (7/15 70% $116.62, 7/8 73% $130.27...). Depth: HOUZN 1112 rows to 2023-07-01, HOUUS 898 to 2024-01-01, HOURP 634 to 2023-12-01.
- S
Booking Pace: 'vs last week' column (true pace, not just OTB) data verifiedIn useBookingPace.ts add a query for the snapshot at report_date <= latest-7d (same eq/order/limit pattern as useBookingPacePickup); in BookingPaceTab.tsx render a 'vs 7d ago' column per stay date (rooms_sold now minus rooms_sold in the T-7 snapshot for the same stay_date) plus a 7-day-pickup StatBox. This is the ahead/behind signal Lighthouse-class tools lead with.Evidence: booking_pace for HOURP has 68 distinct report_dates from 2026-02-22 to 2026-07-17, and a snapshot exists at exactly 2026-07-10 (queried .lte('report_date','2026-07-10') → 2026-07-10), so same-stay-date T-7 comparison joins cleanly.
- S
Booking Pace: revenue on the books + weekend/week grouping data verifiedAdd a 'Revenue OTB next 30' StatBox (sum room_revenue over forward rows) and a Revenue column to the forward table; insert week-separator subtotal rows (Mon-Sun sold/available/revenue) so weekends read as blocks instead of a flat 91-row scroll. All client-side over rows already fetched by useBookingPace.Evidence: Latest HOURP snapshot (report_date 2026-07-17) returned 91 forward stay dates (2026-07-17..2026-10-15) with room_revenue > 0 on 91/91 rows (sample: 2026-07-17 sold 89/190, avg_rate 115.51, room_revenue 10280.39).
- S
Adjustments: staff and reason rollup for the selected month data verifiedIn AdjustmentsTab.tsx, above the monthly table, add two small rollups computed from rows already fetched by useAdjustmentMonthSummaries/useAdjustmentsByDate (or one extra month-scoped select on adjustment_entries): total refund $ and count by staff, and by reason, per property. Turns the listing into the 'who is posting refunds and why' answer an owner actually wants.Evidence: adjustment_entries count = 194 (since 2026-07-07); sampled 1000-row select shows staff filled 194/194 and reason filled 194/194 (e.g. kind 'adjustment', -6.93, item 'Market Beverage', reason 'Guest Satisfaction', staff 'kjord422').
The deep feature
Turn Booking Pace into a real pace ladder: for each forward stay date show OTB occ, 1-day and 7-day pickup, the final actual from the same date last year (night_audit_current holds 2023-2026 actuals for all 3 pace hotels, so STLY-final is a straight join), and the currently shopped own rate from rate_snapshots (verified fresh for 10 forward days on all 3 hotels). From those, compute a defensible need-date flag: OTB materially below the trailing same-weekday sell pattern for that days-to-arrival, with rate context beside it. That converts the tab from "here is what is on the books" into "these 4 dates are behind and here is the rate you are showing," which is the decision Lighthouse/Duetto users pay for, and every input is already in Supabase with no new pipeline.
Broken / misleading
- bug
Drill-in from a Flash card (or single-property auto-lock) into Night Audit leaves Total Rooms empty: prefillTotalRooms only runs inside the property select's onChange (src/components/daily-ops/NightAuditTab.tsx line 392; the auto-lock useEffect at line 318 never calls it), so derived metrics compute from 0 and Save stays disabled until the user manually retypes the room count.
- bug
Night Audit 7-Day History always anchors on today (HistoryTable builds dates from new Date(), line 174-179) and useNightAuditHistory hardcodes a 7-day lookback from today, so when a user navigates the business date more than 7 days back the history table no longer contains or highlights the date being viewed.
- bug
Booking Pace silently swaps properties: when the shared property has no pace coverage (e.g. drill-in from CLLAL), the tab displays the first covered property (HOURP) with no notice that it is not the property you selected (BookingPaceTab.tsx line 113-115); intentional per code comment but reads as wrong data for the persona.
/app/budget shallow
The skeleton is genuinely good (USALI-grouped P&L, flexed BvA, transaction drill-down drawer), but a comptroller cannot trust or close from it: the revenue budget is double-counted in BvA (4100 total plus 4110-4190 segment splits both carry the full rooms budget, so 4100 shows a perpetual -100% variance and total revenue budget is 2x), the Labor tab calls all of departments 51-64 "labor" so Labor % of Revenue is really total opex, and the page happily renders July 2026 as a catastrophic loss month because expenses posted but revenue has not, with no completeness warning. What M3/ProfitSword users expect and this page never shows: per-occupied-room costs on every line, real flow-through (delta NOI over delta revenue), and any trend beyond a single month. Two of seven tabs (Alerts, and the STR/RGI columns) are wired to tables or compsets that do not exist in prod at all.
Quick wins
- S
Revenue-completeness banner from night audit vs QBO data verifiedIn PnlTab and BudgetVsActualTab, sum night_audit_current.room_revenue for the selected property/month and compare to posted 41xx in vw_pl_monthly_summary. If night audit shows revenue but QBO 41xx is ~0, render an amber banner: 'Rooms revenue not yet posted in QuickBooks for this month (night audit shows $X)' instead of silently displaying a giant loss. Also stops the auto-select of the latest month from landing on an incomplete period.Evidence: night_audit_current June 2026 HOURP: 30 days, room_revenue $845,839; pl_transactions 41xx for HOURP has $0 for 2026-06 and 2026-07 while expense rows exist (vw_pl_monthly_summary HOURP 2026-06 returned 24 expense rows, no 41xx). Night audit fresh through 2026-07-16 for HOURP/HOUUS/HOUZN.
- M
CPOR column on P&L and Labor tabs (actual occupied rooms) data verifiedAdd a 'Per Occ Room' column to the PnlTab income statement and Labor tab: sum night_audit_current.occupied_rooms for the selected month (join hotels on property_code to get property_id) and divide each expense line and subtotal. Also switch LaborTab's 'Labor per Occupied Room' KPI from budgeted occupied_rooms (budget_assumptions.monthly_stats) to this actual figure.Evidence: night_audit_current has occupied_rooms daily for the 3 budgeted properties: HOURP 634 days (2023-12-01 to 2026-07-16), HOUUS 898, HOUZN 1112; June 2026 sums: HOURP 3720, HOUUS 2558, HOUZN 2675 occupied rooms.
- S
Fix Labor tab to actual wage accounts data verifiedReplace the department-prefix filter (LABOR_DEPT_PREFIXES 51-64 in LaborTab.tsx) with an account_name regex on the BVA rows (Wages|Labor|Payroll|Salar), keeping the department grouping. This turns 'Total Labor' and 'Labor % of Revenue' from total-opex into real labor numbers, and makes PnlTab's dead Total Labor row (dept-name match never hits) computable from the same account list.Evidence: pl_transactions HOURP 2026 has distinct wage accounts: 5112 Executive Housekeeper Wages, 5121 Front Desk Wages, 5122 Night Audit Wages, 5131 Room Attendants Wages, 5132 Breakfast Attendants Wages, 5133 Housemen Wages, 5134 Laundry Wages, 5214 F&B Labor. vw_budget_vs_actual carries account_code per row (1033-1368 rows per property for 2026).
- S
Real flow-through KPI on Budget vs Actual data verifiedAdd a KPI card and GOP-row metric to BudgetVsActualTab: flow-through = (actual GOP - budget GOP) / (actual revenue - budget revenue), computed from the already-loaded BVA rows (exclude the double-counted 4100 header account from revenue budget until the budget seed is fixed). Color-code against the select-service 50 percent rule of thumb. Replaces the dead RGI-based flowThroughGap in Compare which is always null.Evidence: vw_budget_vs_actual has static_budget, flexed_budget, actual_amount for every account, all 12 months of 2026 for HOURP (1033 rows), HOUUS (1368), HOUZN (1308); usePriorYearActuals already fetches LY actuals for the same month. Compsets check confirmed rgi is null for all 3 properties, so the current flow-through column never renders.
The deep feature
A trailing-13-month trend P&L, which is the report a comptroller actually lives in during close (M3's Trend Report): every USALI line as columns Jun-25 through Jun-26 with budget overlay, CPOR per line, and GOP margin sparkline, so Junaid can see that Utilities crept from $6.2k to $8.9k over three months instead of eyeballing one month at a time. The data is fully present: vw_pl_monthly_summary serves any month range in one query (HOUUS has 55 continuous months, HOUZN 36, HOURP 21), budget_entries covers all 12 months of 2026, and night_audit_current provides actual occupied-room denominators for every month since 2023. This one view would also make the missing-revenue months self-evident (a $0 revenue column screams) and would convert the page from a single-period viewer into the close-and-forecast workbench the Budget page is named for.
Broken / misleading
- bug
Alerts tab is dead in prod: useVarianceAlerts queries variance_alerts and variance_thresholds, both return 'Could not find the table in the schema cache'
- bug
BvA revenue budget double-counted: HOURP May 2026 41xx budget sums to $1,104,552 vs true $552,276 because account 4100 carries the full rooms budget (fixed) and 4110-4190 carry the same dollars again (pct_room_rev); 4100 shows a perpetual -$552k / -100% variance since actuals post only to sub-accounts; this poisons revenue variance, NOI variance, and Compare's Total Var $
- bug
STR Index card (BvA tab) and RGI + Flow-Through columns (Compare tab) always render dashes: no primary compsets exist in prod for HOURP/HOUUS/HOUZN so useStrIndexMetrics and rgiMap return null
- bug
PnlTab 'Total Labor' row never renders: it matches department_name against 'labor'/'wages' but view names are 'ROOMS EXPENSE', 'A&G', etc. (PnlTab.tsx:257-264)
- bug
Labor tab mislabels total opex as labor: filters entire departments 51/52/61-64 including supplies, commissions, and maintenance contracts rather than the actual Wages accounts (5112-5134, 5214)
- bug
Page opens on 'aloft College Station' with an empty year dropdown and 'No P&L data' because the default is the first property by region/name sort while only HOURP/HOUUS/HOUZN (+partial HH, MHD) have P&L data
- bug
Latest-month auto-select lands on 2026-07, a month with expenses posted but zero rooms revenue in QBO (revenue posted only through May), rendering a misleading deeply-negative P&L with no incompleteness warning
- bug
P&L display silently drops account groups not in its prefix lists: 7500 Interest ($120,266 in HOURP June 2026) appears in no section and is excluded from every subtotal, while dept 8000 'OTHER INCOME/EXPENSE' rows with category=revenue are double-counted in both Revenue and Non-Operating sections
- bug
Close tab machinery (budget_close_checklist, budget_close_narratives, budget_month_locks) exists but has 0 rows in prod: no month has ever been closed or locked through the UI
/app/deals (Deal Pipeline list + DealDetail) adequate
DealDetail is genuinely deep (13 panels, model sync, PIP, proforma), but the LIST is a table of contents with money sprinkled on. A pipeline operator opens a board to answer three questions the page cannot answer: which deal is stalling (no staleness or days-in-stage; HGI N Dallas has sat untouched in Underwriting since Jun 12), who do I call next (broker_name/firm populated on 11 of 14 deals in prod but never selected or rendered at list level), and what is blocking each deal (90 tracked open questions across 10 deals, zero visible on cards). There is also no aggregate view: 5 LOIs out totaling $87.8M with roughly $36M combined cash-to-close, and no column totals anywhere. Worse, the trailing cap on LOI cards is computed against the ASK while the card leads with the LOI price, so Crowne Plaza shows "-2.3% cap" beside "LOI $15.0M" when the model's going-in cap at the offer is 2.2 percent, a misleading basis a lender or partner would catch instantly.
Quick wins
- S
Broker line on pipeline cards data verifiedAdd broker_name, broker_firm to the useDealReferenceList select (useHotelIntel.ts line 713) and render one muted line in DealCard/MobileDealRow: 'Joe Cuomo, Alchemy' with mailto when broker_email exists. Turns the board into a call sheet.Evidence: select deal_name,broker_name,broker_firm from deal_references: 11/14 populated (Joe Cuomo/Alchemy on 4 deals, Austin Brooks/Hodges Ward Elliott, Scott Dickey/JLL, Ryan Patterson/Mumford, Suraj Dalal/Kabani, Mark Rome/HREC, Eric Guerrero/HVS)
- S
Open-questions badge per card data verifiedSelect open_questions (or a computed unanswered count) in the list query and render a small amber badge on each card: '8 open Qs'. Click-through lands on the deal detail. This is the closest thing to a next-action indicator the schema already supports.Evidence: select open_questions from deal_references: arrays with {q, answered} on 10 deals; unanswered counts 4-14 (Hyatt Place 14/14, Crowne Plaza 10/10, DoubleTree IAH 8/8, Element 4/4), all currently unanswered
- S
Staleness indicator on cards data verifiedupdated_at is already in the list select; render 'touched 3d ago' per card, amber past 7 days, red past 14, and sort each kanban column stalest-last is-fine but flag visibly. True days-in-stage needs a history table that does not exist, so ship last-touch staleness and label it as such.Evidence: select updated_at from deal_references: HGI N Dallas 2026-06-12 (35 days idle), Hampton Inn 2026-06-18 (29 days), Element 2026-06-18, all still in active stages; six deals touched 2026-07-16
- M
Model economics chips + LOI column exposure footer data verifiedWiden the list select with model_summary->going_in_cap, dscr_initial, cash_to_close. On LOI-stage cards replace the ask-based cap with going-in cap at the offer price and add a DSCR chip (red under 1.0: DoubleTree IAH 0.37, Crowne 0.36 would finally look as risky on the board as they are in the model). Add a footer to the LOI column: total LOI out and total cash-to-close.Evidence: select model_summary from deal_references: 12/14 have going_in_cap (0.0202-0.1027), dscr_initial (0.36-1.84), cash_to_close (sum over 5 LOI deals ~$36.0M; LOI offers sum $87.8M); PostgREST arrow selects already used for offer_price so same pattern works
The deep feature
A deal_stage_events log written by useUpdateDealStage (deal_id, from_stage, to_stage, actor, timestamp) is the single unlock that makes this page expert-grade. It gives true days-in-stage per card instead of last-touch proxies, column-level aging norms (median days in Underwriting, LOI age before it goes stale with a seller), and funnel discipline over time: pass rate by stage, prospecting-to-LOI velocity, and which brokers' deals actually convert. Salesforce-grade pipelines live on exactly this table; without it every aging or conversion number on the board is unverifiable. It is one small table plus two lines in the existing mutation, then the list page reads it with one grouped query, and it starts accruing history the day it ships, which is why shipping it early matters more than any rendering change.
Broken / misleading
- bug
Deal names break mid-word on kanban cards ('Courtya rd Houston', 'Doub leTree', 'Elem ent', 'Hampto n Inn') - break-words on the h4 in Deals.tsx line 157 inside min-w-[200px] columns; every card in the screenshot wraps ugly
- bug
Cap-rate basis mismatch on LOI cards: DealEconLine (Deals.tsx line 83) computes cap = t12_ebitda / asking_price even when the card leads with the LOI price, so Crowne Plaza shows '-2.3% cap' next to 'LOI $15.0M' while the model's going_in_cap at the offer is 2.21% - the displayed number answers a question nobody at LOI stage is asking
- bug
Six fixed-width columns force horizontal scroll that hides Due Diligence, Closed, and Passed off-screen (screenshot shows CLOSED clipped mid-word) with no scroll affordance; empty Due Diligence still consumes a full column while 5 LOI deals compress into 200px
- bug
priority field is set in prod (high on DoubleTree Galleria, medium on SpringHill Sugar Land) and recommendation is set (pursue/hold) but neither is selected or rendered anywhere on the list, so curated triage signal is silently dropped
/app/hotel-intel/:strNumber (SiftDetail, Compset tab focus) shallow
The tab looks analytical (pecking order, gap math, index chart, percentile gauges, auto-thesis) but every conclusion is computed on the wrong cohort: the STR tax tract, which I verified spans 0 to 16.6 miles and pits Sugar Land/Stafford hotels against a Westchase subject while omitting the actual across-the-street compset (DoubleTree Westchase 0.4mi, Home2 1.2mi, Courtyard 1.7mi). A domain expert doing STAR-report work would spot in seconds that the "+$10 outperformance" is an artifact of a $6.56-RevPAR distressed Wyndham dragging the tract average, that the $61-vs-$145 comparison is a 112-room SpringHill against a 303-room full-service Marriott, and that three mutually contradictory verdicts render on one screen. Wrong-cohort analytics is worse than no analytics for an acquisition persona.
Quick wins
- M
Geo compset cohort: radius + tier +/-1 + rooms +/-50% data verifiedNew hook (bbox query on str_hotel_summary by lat/lng, haversine <= 5mi, chain-scale ladder distance <= 1, rooms within 50% of subject) passed into CompsetTab as the cohort, replacing tractRoster.hotels in tractHotelRanks, opportunity, scorecard, and gapDriver. One data-source swap fixes the ranking panel, the gap cards, the gauges, and the leader table simultaneously. Keep the tract view on the existing Tract tab.Evidence: Prod: str_census 4215/4227 rows have lat/lng; subject 64071 at (29.7161,-95.5593). Test query on str_hotel_summary (bbox + haversine 5mi, tier +/-1 of Upscale Chain, rooms 56-168) returned 18 candidates: DoubleTree Westchase 0.4mi/$72, Home2 Westchase 1.2mi/$79, Courtyard Westchase 1.7mi/$43, Residence Inn Westchase 1.7mi/$39, HIE Westchase 1.7mi/$64. Current tract cohort members measured 5.7-16.6mi away.
- S
Exclude distressed/dark hotels from cohort averages data verifiedFilter is_distressed (or estimated_revpar below a floor) out of tractAvgRevpar, chainScaleAvgRevpar, and scorecard percentiles in CompsetTab.tsx, with a muted 'excluded: N distressed' note. This kills the fake '+$10 outperformance / +$414K / +$104K NOI' story that currently anchors the whole tab.Evidence: Prod str_hotel_summary: Wyndham Houston Sugarland estimated_revpar 6.56, is_distressed true, severity critical; Sonesta Essential Houston Westchase 22.44, severity severe. Both sit in the current cohort averages; tract avg renders as $51 vs subject $61.
- S
Same-league gap leader + one reconciled verdict data verifiedgapDriver in CompsetTab.tsx (line ~394) picks max-RevPAR hotel in tract with no scale/size filter, producing the 303-room $145 Marriott vs the 112-room $61 SpringHill row. Constrain the leader to the compset cohort, and derive the Acquisition Thesis from a single number (cohort percentile) so the thesis, the gap card, and the gauges can no longer disagree on one screen.Evidence: Verified in code: leader filter is only taxpayer != subject and revpar != null. Prod tract roster contains Marriott Houston Sugar Land class hotels outside Upscale Chain that the ranking panel excludes but the leader table includes.
- S
Honest labels, sample sizes, and distance column data verifiedRename 'National (Texas)' to 'Texas'; unify the two same-concept numbers ($88 card = unweighted mean of estimated_revpar, $89 pecking order = room-weighted, both labeled national brand RevPAR); print 'n=X' under every percentile gauge and suppress gauges with n<5; add miles-from-subject to each comp row so cohort quality is self-evident.Evidence: Prod: 'vs SpringHill (Tract)' 100th-percentile gauge is n=3 (Westchase $61, Rosenberg $57, Sugar Land $57). Distances computable from lat/lng already fetched. Both national figures derive from the same chainScalePeers rows, only aggregation differs.
The deep feature
A STAR-style compset report on demand for any of the 4,227 hotels: once the geo cohort is right, aggregate monthly hotel_tax_receipts (statewide, monthly report_period granularity verified in prod) across the cohort to produce a RevPAR index (subject / cohort avg x 100), rank n of m, and a 24-month index trend line, plus receipts correlation to flag true revenue twins per the persona-weighted scoring already specced in docs/compset-discovery-redesign-prompt.md and running in scripts/precompute-discovery.mjs for portfolio hotels. That converts the tab from tract trivia into the sentence Ace actually needs when underwriting: this SpringHill indexes 84 against its real Westchase compset and has shed 9 index points in 18 months. It is the exact artifact STR sells, rebuilt from tax data THM already ingests for all of Texas.
Broken / misleading
- bug
Compset cohort is the STR tax tract: verified members 5.7-16.6mi from subject (Sugar Land/Stafford/Richmond vs Westchase), while true sub-2mi competitors (DoubleTree, Home2, Courtyard, HIE Westchase) are absent
- bug
Gap-driver 'leader' has no chain-scale or size filter (CompsetTab.tsx ~line 394): renders 303-room full-service Marriott at $145 as peer for a 112-room SpringHill at $61
- bug
Three contradictory verdicts on one screen: +$10 tract outperformance, 14th percentile below-average vs brand, and a rosy auto-thesis that picks the outperformance branch
- bug
Distressed hotels pollute averages: Wyndham Houston Sugarland ($6.56 RevPAR, critical) and Sonesta Essential ($22.44, severe) drag tract avg to $51, manufacturing the outperformance story
- bug
'National (Texas)' mislabel, and two different values for the same concept on one screen: $88 (unweighted mean) vs $89 (room-weighted) SpringHill national RevPAR
- bug
Percentile gauges hide sample size: '100th percentile, Strong locally' is a 3-hotel sample
- bug
'NOI Impact +$104K at 25% flow-through' renders on a positive gap, presenting outperformance as if it were capturable upside
/app/deal-staging shallow
This is a generic CRUD inbox (name, city, rooms, price, four status chips) when the database underneath already knows almost everything needed to triage. Every staged row's source_ref resolves to hotel_tax_summary with 6+ years of actual taxed room revenue current through 2026-05, yet the row shows none of it: no price per key, no revenue multiple, no implied RevPAR, no segment sanity check. An acquisitions person cannot decide promote-vs-dismiss from the row; proof in prod is SAIDA TOWERS III, a 1-room vacation_rental condo with $4.6K annual revenue, sitting as a New hotel lead for 3 months next to a 1,000-room Marriott Marquis that is wildly outside THM's select-service box, and the page gives zero visual signal that either is junk.
Quick wins
- S
Price per key + revenue multiple columns data verifiedTwo computed columns in DealStaging.tsx table: asking_price/rooms ($/key) and asking_price/trailing-year revenue (from the SIFT join in win 2, or $/key alone with zero new queries). $129K/key on the SpringHill vs $145K/key on the HGI is the first number Ace computes mentally every time; render it. Sortable via existing sortRows.Evidence: select * from deal_staging: all 3 priced rows have both asking_price (16M/16M/19M) and rooms (110/124/128); computation is pure client-side arithmetic.
- M
SIFT revenue enrichment on every staged row data verifiedBatch query hotel_tax_summary by str_number/taxpayer_number (source_ref holds one or the other; 5-digit vs 11-digit disambiguates) inside useDealStaging or a companion hook. Show trailing-year total receipts, implied RevPAR (receipts/rooms/365), and latest_period freshness in the row + expanded RowDetail. Pick the row with max latest_period when duplicates exist (str 68227 has a dead predecessor taxpayer stuck at 2024-09 and a successor current to 2026-05).Evidence: hotel_tax_summary.in('str_number',[60196,68227,65203,68812]) and .in('taxpayer_number',[32082285613,32049624110]) all returned rows with yearly_data populated, e.g. HGI 68812: 2026 receipts $1,620,937, rooms 110, latest_period 2026-05.
- S
Fit-box flag (out-of-box badge with reason) data verifiedClient-side rule badge per row: rooms outside ~60-160, or SIFT segment != 'hotel', or upper-upscale/luxury brand -> amber 'Out of box' chip with the reason (e.g. '1000 rms', 'vacation_rental'). Instantly marks SAIDA TOWERS (1 room, vacation_rental) and Marriott Marquis (1000 rooms) as dismiss-in-one-click, which is exactly the triage ergonomics the page is missing.Evidence: deal_staging rows show rooms 1 and 1000; hotel_tax_summary.taxpayer_number=32082285613 returns segment='vacation_rental', 32049624110 segment='hotel' with room_count 1000.
- S
Promote carries identity into deal_references data verifiedFix usePromoteDealStaging (src/hooks/useHotelIntel.ts:923) to also write str_number / hotel_taxpayer_number (derived from source_ref length), rooms_override, broker_name, broker_email, product_type, and notes into the deal_references insert. Today it writes only deal_name/stage/asking_price, so every promotion orphans the hotel identity and someone re-keys it by hand.Evidence: deal_references rows f130314e/8297bbdb/48d9dee9 show str_number, hotel_taxpayer_number, rooms_override, broker fields were all backfilled manually after promotion; columns verified to exist. deal_staging.source_ref holds the key (68227 etc).
The deep feature
A one-glance "does it pencil" screen per row: join the SIFT trailing revenue (verified present for every staged ref) and render three numbers next to the asking price: implied RevPAR, price-to-trailing-revenue multiple, and $/key vs the $150K/key replacement-cost anchor already stored in the underwriting model_summary. Color-band them against THM's own promoted-deal precedent (the three Central Expy deals cluster around 4.5x revenue and $129-150K/key, going-in cap ~7.2-7.4%), so a new lead lands on the page pre-scored against deals Ace actually signed off on rather than as a bare name and price. Add days-in-status aging and a duplicate check against deal_references/hotel_tax_summary so re-listed properties surface their prior pass/pursue history. That converts the page from a to-do list into the pre-underwriting screen a professional acquisitions pipeline (Dealpath-style) puts at intake, using zero new pipelines.
Broken / misleading
- bug
Promote loses hotel identity: usePromoteDealStaging (src/hooks/useHotelIntel.ts:923-937) inserts only deal_name/stage/asking_price/mc_deal_id; source_ref (STR/taxpayer number), rooms, brand, broker, and notes are silently dropped and were manually re-keyed on all three promoted prod deals.
- bug
Intake accepts non-hotels: SiftDetail's Create Deal button staged SAIDA TOWERS III (segment vacation_rental, 1 room, $4,635 annual receipts) as a hotel lead with no segment or rooms guard; it has sat as New for 3 months.
- bug
source_ref is polymorphic with no discriminator: rows hold either an STR number (68227) or a taxpayer number (32049624110) in the same column, and nothing on the page or in the promote flow resolves which, so the Source Ref field in RowDetail is an opaque number.
- bug
Broker column renders dashes for every prod row: hotel_intelligence intake only sets broker_name when a Crexi match exists (none do), so a full-width column carries zero information.
- bug
Clicking the All status tab silently wipes the search text and source filter (DealStaging.tsx:534-540), which no other tab does.
- bug
React key warning: the row map wraps tr + RowDetail in a keyless fragment (DealStaging.tsx:688); key belongs on the fragment, not the inner tr.
/app/tax (TaxIntel.tsx) shallow
The page is a CAD data dump, not tax intelligence: it shows only the latest assessment per parcel with zero trend, zero benchmarking, and no appeal math, even though prod holds 5 years of assessment history (52 rows, 2021-2025) and 12-month SIFT revenue through May 2026 for all 15 hotels. A comptroller opening this before the May 15 Texas protest deadline cannot answer the one question the page exists for: which properties are over-assessed and by how much. Meanwhile the numbers it does show are wrong: the Est. Tax Bill sums every levying body in the county (Harris 2025 sum is 67.7 per $100, an implied 67.7% tax rate), 3 of 5 THM counties are missing from the hardcoded FIPS list, and the $93.9M Total Assessed card silently drops a $9.6M property because a sliver parcel was linked. The Appeals tab shows real protest rows but its Total Savings card is permanently blank (all tax_savings null), and the Exemptions tab has exactly 1 row in the entire prod table.
Quick wins
- S
Assessment trend: YoY change column + 5yr sparkline per property data verifiedIn PortfolioOverview, stop discarding history in latestByCad: keep all years per cad account (already fetched by usePropertyAssessments, no query change), add a 'YoY' column showing latest vs prior assessed change with red highlight above +10%, and a tiny inline sparkline of 2021-2025 assessed values. CLLAL jumped +53% (6.40M to 9.82M), DFWXF +25%, HOUZK +23%, HOUUS +17% in 2025; these are the protest triggers the page currently hides.Evidence: select from tax_property_assessments in (11 THM cad accounts) returned 52 rows spanning tax_year 2021-2025 for 10 of 11 accounts; e.g. 307512 (CLLAL): 2024 assessed 6,403,762 vs 2025 assessed 9,820,564.
- M
Appeal-worthiness benchmark: assessed per key and revenue multiple vs county peers data verifiedJoin hotels to hotel_tax_summary by taxpayer_number (room_count, ttm_taxable_receipts) and add columns Assessed/Key and Assessed/TTM-Rev with a vs-county-median delta computed from hotel_tax_summary peers in the same county (one extra query filtered county + assessed_value not null + room_count 80-250). Flag any hotel above the county median on both. HOUZV sits at $79.1K/key vs Harris median $59.3K/key.Evidence: hotel_tax_summary Harris county peers with assessed_value + room_count 80-250 + ttm_taxable_receipts: n=234, median assessed/key $59,286, median assessed/TTM-revenue 3.39x; all 15 THM rows have room_count and 12-month ttm_taxable_receipts (latest_period 2026-05); Dallas county peer pool n=184.
- M
Assessment growth vs revenue growth exhibit (the actual protest argument) data verifiedhotel_tax_summary.yearly_data holds per-hotel taxable receipts 2017-2025. Render a per-property two-line mini chart (or two YoY columns): assessed value % change vs taxable receipts % change by year. CLLAL: 2025 assessment +53% while 2024-to-2025 receipts fell ~3% (3.24M to 3.14M). That single sentence is what a tax agent files as evidence; the data is already one select away.Evidence: hotel_tax_summary.yearly_data for taxpayer 32034945306 (CLLAL) returned [{year:2017,taxableReceipts:2494571}...{year:2024,taxableReceipts:3071709},{year:2025,...3135909 total}], full 2017-2025 series; assessment series verified in tax_property_assessments.
- S
Fix millage coverage: derive FIPS from assessment rows, kill the hardcoded list data verifiedTaxIntel.tsx line 612 hardcodes fipsCodes ['48201','48113','48121'] (Harris, Dallas, Denton). Actual THM counties on assessment rows are 48201 Harris, 48041 Brazos, 48085 Collin, 48439 Tarrant, 48113/48121 DFW. Derive fipsCodes from the distinct fips_code values on the fetched assessment rows; the Millage tab then shows Brazos, Collin and Tarrant (rates exist 2021-2025) instead of omitting the counties where 7 of 15 hotels sit.Evidence: tax_millage_rates grouped by fips:year shows 48041 (Brazos) 3 rows/yr 2021-2025, 48085 (Collin) 4 rows/yr 2024-2025, 48439 (Tarrant) 6 rows/yr 2021-2025, while hardcoded 48113 (Dallas) has zero rows any year; assessment rows carry fips_code 48041/48085/48439 for CLLAL, CLLSP, DFWXF, DFWXY.
The deep feature
A per-property protest recommendation engine, which is exactly what O'Connor and Popp Hutcheson (both already appearing as agents in prod tax_appeals rows) charge 25-40% contingency for. For each hotel it combines the verified inputs into one verdict line: 5-year assessment trajectory and latest YoY jump, assessed-per-key and assessed-to-revenue multiple ranked against the county peer distribution from SIFT (Harris n=234, Dallas n=184), the yearly_data revenue trajectory as counter-evidence, and the county millage stack to convert any over-assessment into dollars at stake per year. Output per property: "CLLAL: assessment +53% in 2025 while revenue fell 3%; assessed at $93.5K/key; estimated over-assessment $2.4M; roughly $46K/yr at stake; protest deadline May 15; status: not protested." That last field matters because hotel_tax_summary already tracks tax_protested and protest_agent, so the page can show which properties the tax agents are covering and which are exposed with nobody protesting.
Broken / misleading
- bug
Est. Tax Bill math is order-of-magnitude wrong: it multiplies taxable value by the sum of EVERY levying body in the county divided by 100; Harris 2025 bodies sum to 67.72, implying a 67.7% tax rate, because overlapping cities and ISDs that never apply to one parcel are all summed (TaxIntel.tsx lines 100-109, 192-195)
- bug
Hardcoded FIPS list ['48201','48113','48121'] is wrong: Brazos is 48041 (not 48121 which is Denton), and Collin 48085 + Tarrant 48439 are absent, so Est. Tax Bill is null and the Millage tab omits the counties for 7 of 15 hotels; 48113 Dallas has zero millage rows anyway (line 612)
- bug
Total Assessed Value card ($93.9M) is understated ~$9.4M: DFWCE (Home2 Dallas Northpark) is linked to sliver parcel 61031500070010200 assessed at $246,370 while hotel_tax_summary shows $9,607,130 for the same property
- bug
4 of 6 BCS hotels (CLLTX, CFDBR, CLLUD, CLLSB) have hcad_account null in both hotels and hotel_tax_summary, so their rows render fully blank; not fixable from existing data, needs CAD account backfill
- bug
DALDL (Home2 Lewisville) latest-row logic picks its all-null 2026 record; all 6 of its assessment rows (2021-2026) have null values and Denton 2025 millage rates are null
- bug
Appeals tab 'Total Savings Secured' is permanently '–': all 8 tax_appeals rows have tax_savings null; 2 of 8 rows (cad 1360760010001, RLS-CAD-001) belong to accounts not linked to any THM hotel and are silently filtered out
- bug
Exemptions tab is dead: exactly 1 row exists in prod tax_exemptions
- bug
Houston rows show '–' for Market Value though assessed_value exists (Harris rows carry only assessed/taxable); column should fall back or be labeled per-county
/app/pip (PipProjects.tsx + PipDetail.tsx) hollow
Every project on the page is April 11 seed data (all 4 visible pip_projects share created_at 2026-04-11T22:01:59, zero item updates since; vendors are demo names like FloorCraft LLC), so the marquee "3 overdue / 108d overdue" stat is demo-data rot, not portfolio status; THM's actual live PIP work (SpringHill Houston FF&E) is not in the system at all. The one analytical element, "Recommended Renovation Windows (Based on Occupancy)", is a hardcoded JSX strip identical for every project and never touches occupancy, while a purpose-built pip_schedule_windows table with avg_occupancy sits unused. For the persona, a chief engineer on a phone, the page is unusable: the 8-column table sits in an overflow-hidden wrapper, so Budget, Deadline, % Complete, and Days Left are clipped off-screen with no scroll (confirmed in the mobile screenshot), and the only write control (status dropdown) lives in that clipped table. No photos, no delivery dates, no install scheduling, no change history, despite install_status, planned_start_date, estimated_delivery_date, attachments, and pip_documents all existing in the schema, populated with nothing.
Quick wins
- M
Mobile card layout for projects and items (the persona's device) data verifiedIn PipProjects.tsx, below md breakpoint replace the 8-column table with stacked cards per project: name, status chip, days-left in red, % bar, budget; same treatment for PipDetail ItemsTab rows (description, status select, budget vs actual) and change the summary grid from grid-cols-4 to grid-cols-2 md:grid-cols-4. Pure CSS/JSX rework of data already fetched by usePipProjects/usePipItems.Evidence: select * from pip_projects returns 5 rows (4 non-RLS-test); pip_items returns 38 rows with status/budget_amount 100% populated. Mobile screenshot frontdesk-pip-attempt.png confirms columns Budget through Days Left are clipped and unreachable.
- S
Staleness chip: last activity per project data verifiedCompute max(updated_at) across a project's pip_items in usePipProjects (items are already fetched for counts) and render a 'No activity in Nd' amber/red chip next to Days Left, plus 'Updated Nd ago' in the PipDetail header. Makes the difference between 'behind schedule' and 'nobody is updating this' visible, which is exactly what the current 108d-overdue rows actually are.Evidence: select updated_at from pip_items: all 38 rows carry updated_at, distinct dates are 2026-04-01 and 2026-04-11, i.e. every project would show ~96d stale today.
- S
Vendor commitment rollup on PipDetail data verifiedAdd a 'Vendors' card (Overview tab or a section under Budget): group pip_items by vendor showing open item count, committed, actual, and committed-minus-actual exposure, sorted by exposure. This is the vendor-tracking axis every real PM tool has and the page completely lacks.Evidence: Query on pip_items (non-test tenant): 28 of 37 items have vendor, 21 distinct vendors, committed/actual populated, e.g. Amana Commercial 3 items $429,700 committed / $411,100 actual; BathPro Systems $63,500 committed / $0 actual.
- M
Real renovation-window strip from SIFT monthly receipts data verifiedReplace the hardcoded Jan-Aug demand bar in PhasesTab with a per-property strip: join project.hotel_id to hotels.taxpayer_number, query hotel_tax_receipts for that taxpayer, average total_receipts by calendar month, tercile-classify months into low/shoulder/high. Removes the fabricated 'Based on Occupancy' label and makes the strip differ per hotel.Evidence: hotel_tax_receipts by taxpayer_number: CLLSP 12003226284 has 113 monthly rows (report_period through 2026-05), DFWXY 32067517022 has 14, Home2 Lewisville 32074919989 has 18; monthly total_receipts populated (e.g. CLLSP 2026-05 $302,739).
The deep feature
Turn each PIP item into a real procurement record with a mobile status-advance flow and ledger-true actuals. The schema is already built for it: pip_items carries install_status, planned_start_date/planned_end_date, estimated_delivery_date, vendor_quotes and attachments jsonb; pip_documents exists for brand letters and invoices; and pip_match_rules (pattern, pattern_type=vendor_name, pip_item_id, hit_count) is clearly designed to auto-match vendor charges from the pl_imports/credit-card feed onto PIP items so 'Actual' comes from the ledger instead of hand-typed numbers. Ship the loop: Mohammed taps an item on his phone, advances it one status (quoted to awarded to ordered to received to installing to complete), attaches a photo on complete, and sets a delivery date; the page then flags delivery-vs-install-window conflicts against pip_phases and shows a per-item audit trail. That single loop converts the page from a static spreadsheet mirror into the reason field staff open the app daily, and it is what separates a Procore-grade tool from decoration.
Broken / misleading
- bug
Mobile layout is broken for the page's own persona: the projects table sits in an overflow-hidden wrapper so Budget, Deadline, % Complete, and Days Left are clipped off-screen with no horizontal scroll (frontdesk-pip-attempt.png); PipDetail's grid-cols-2 / grid-cols-5 / ml-[200px] gantt are equally non-responsive.
- bug
'Recommended Renovation Windows (Based on Occupancy)' in PipDetail.tsx PhasesTab is a hardcoded static strip, identical for every project, derived from no data; the pip_schedule_windows table (with avg_occupancy) exists and is never queried.
- bug
All 4 visible projects are seed/demo rows: identical created_at 2026-04-11T22:01:59, no item updated since 2026-04-11, demo vendors (FloorCraft LLC, BuildRight DFW); the headline 'Overdue 3' is stale demo data, and THM's real active PIP (SpringHill Houston FF&E) is absent.
- bug
Drive folder icon in the PipDetail header is a span with cursor-pointer but no onClick or href (dead control); drive_folder_id is null on every project anyway.
- bug
Documents tab is permanently disabled ('Coming in V2') even though the pip_documents table already exists in prod (1 test row).
- bug
Identity drift: pip_projects.property_code is 'DFWDL' while the same hotel_id row in hotels has property_code 'DALDL' (Home2 Lewisville); any join keyed on property_code silently misses.
- bug
React keyless-fragment bug: ItemsTab category map in PipDetail.tsx (~line 477) renders <>...</> without a key inside .map, producing key warnings and fragile reconciliation.
- bug
Visible em dash in project name 'PTAC Replacement - All Floors' (seed data violates the house no-em-dash rule in shipped UI).
/app/data-health shallow
The page equates "data health" with one pipeline: the 3-property flash verifier. Prod cron_execution_logs shows qbo-sync-pl-summary-daily has failed all 8 runs since Jul 10 (vault secret missing) yet the page banner says "all 27 checks passed" in green. Two more properties (CLLSB Staybridge, CLLAL aloft College Station) have live email_parse night-audit feeds and land 100+ files a week in report_inbox but are invisible here, and the rate-shop, booking-pace, and SIFT tax feeds have zero freshness representation. An ops person trusting this page would believe the number pipeline is fully healthy while a financial sync has been dark for a week.
Quick wins
- S
Scheduled jobs health card (cron_execution_logs) data verifiedNew card on DataHealth.tsx listing each distinct job_name from cron_execution_logs with last run time, last status, 7-day fail streak, and error_message on failure; wire the headline banner to also go red when any job's latest run failed. One select over last 7 days, aggregate client-side like useIntakeStatus does.Evidence: select job_name,status,started_at,error_message from cron_execution_logs gte started_at 2026-07-10: qbo-sync-pl-summary-daily 8/8 runs failed (latest 2026-07-17T03:30Z, error 'vault.secrets[qbo_sync_pl_summary_service_role_key] is missing'); refresh-hotel-revenue-views 8/8 success; qbo-sync-pl-daily 8/8 success. Table has job_name, status, duration_ms, error_message columns.
- M
Add CLLSB and CLLAL to coverage and intake panels data verifiedExtend FLASH_PIPELINE_PROPERTIES in src/hooks/useDataTrust.ts with CLLSB (ebc469f4) and CLLAL (89d0eafb) for the coverage and intake sections (verifier history stays 3-property), update the page copy from 'three automated-feed properties' to five. CLLSB coverage will correctly show ~44 missing nights YTD, which is exactly what this panel exists to expose.Evidence: night_audit_current: ebc469f4 (hotels.property_code CLLSB, Staybridge Suites College Station) has 153 nights YTD, first 2026-01-31, last 2026-07-16, source email_parse; 89d0eafb (CLLAL, aloft College Station) 16 nights since 2026-06-23. report_inbox last 7d property_code counts: CLLSB 111 files, CLLAL 20 files.
- S
Other-feeds freshness strip (rate shop, pace, tax) data verifiedSmall card with one row per non-flash feed: rate_snapshots (max captured_at + distinct hotel count last 24h), booking_pace (max report_date per property_code), hotel_tax_receipts (max created_at). Three cheap selects, stale threshold coloring (rate shop >36h amber, pace >2d amber, tax >100d amber).Evidence: rate_snapshots max(captured_at) = 2026-07-17T19:17:20Z, 747 rows / 25 distinct hotel_ids in last ~24h; booking_pace max(report_date) = 2026-07-17 for HOURP/HOUUS/HOUZN (364/364/272 rows since 7/14); hotel_tax_receipts latest created_at 2026-07-14T11:01Z.
- S
Unattributed and failed file drill-down data verifiedMake the '0 failed / 0 unattributed' footer in the intake card expandable: widen useIntakeStatus (or a sibling hook) to last 7 days and list the actual filenames + received_at for unattributed/failed report_inbox rows so someone can fix the attribution rule instead of staring at a count.Evidence: report_inbox gte received_at 2026-07-10: 75 rows with property_code null, filenames like ADVDEPBAL.pdf, AR.ARAGING DETAIL.pdf, CCTRANS.pdf, DLYREV.pdf; 0 status='failed'. Current hook only queries since local midnight and exposes counts, not rows.
The deep feature
Turn the page into a unified pipeline SLA board: every feed is a monitored lane (5 night-audit email feeds, morning verifier, each cron job, rate-shop scans, booking pace, QBO P&L sync, SIFT tax loads, STR uploads) with its expected cadence declared once, then last-success time, staleness vs cadence, and consecutive-failure streak rendered per lane. The headline banner becomes an aggregate across all lanes rather than just the flash verifier, so a week of silent QBO sync failures or a stale rate-scan lane flips the page red the way Innrly or a Datadog monitor board would. Fold in data_quality_issues (1,000 unresolved rows today, including 131 high-severity chain_integrity and 28 str_collision) as a triage count per lane so hygiene debt is visible next to feed health. Everything needed already lands in Supabase (cron_execution_logs, report_inbox, night_audit_current, rate_snapshots, booking_pace, hotel_tax_receipts, str_uploads, data_quality_issues); the work is the lane abstraction plus a cadence manifest, roughly 2 to 3 days.