fix(planner): charts, wizard footer layout, tooltip translations & summary label

- Charts: augment_d() now emits full Chart.js 4.x config objects {type, data,
  options} for all 7 charts. Previously raw data dicts were passed directly to
  new Chart() which requires a proper config, causing silent render failures.

- Wizard footer: HTMX outerHTML OOB swap for #wizPreview was stripping
  class="wizard-preview" on every recalc, collapsing the flex layout and
  stacking CAPEX / Monatl. CF / IRR vertically. Added class back to the OOB
  element in calculate_response.html.

- Wizard nav buttons: showWizStep() was generating wiz-btn--prev and
  wiz-btn--calc classes that had no CSS. Changed to wiz-btn--back and
  wiz-btn--next which are defined in planner.css.

- Tooltip translations: added 60 tip_* keys (EN + DE) to i18n.py and replaced
  all hardcoded English strings in planner.html slider calls with t.tip_* refs.
  German users now see German tooltip text on all "i" info spans.

- Summary label: added wiz_summary_label ("Live Summary" / "Aktuelle Werte")
  as a full-width caption in the wizard preview bar so users understand the
  three values reflect current slider state. Added flex-wrap + caption CSS.

- Tests: 384 new tests across test_planner_charts.py, test_i18n_tips.py,
  test_planner_routes.py covering all fixed bugs. Full suite: 1013 passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-20 19:11:24 +01:00
parent 7c710ada6b
commit 696581d57b
10 changed files with 833 additions and 97 deletions

View File

@@ -533,6 +533,68 @@ _TRANSLATIONS: dict[str, dict[str, str]] = {
"sl_hold_years": "Holding Period", "sl_hold_years": "Holding Period",
"sl_exit_multiple": "Exit EBITDA Multiple", "sl_exit_multiple": "Exit EBITDA Multiple",
"sl_annual_rev_growth": "Annual Revenue Growth", "sl_annual_rev_growth": "Annual Revenue Growth",
# ── Tooltip tip texts ────────────────────────────────────────────────
"wiz_summary_label": "Live Summary",
"tip_permits_compliance": "Building permits, noise studies, change-of-use, fire safety, and regulatory compliance. Adjusts automatically when you pick a country \u2014 feel free to override.",
"tip_dbl_courts": "Standard padel court for 4 players. Most common format with highest recreational demand.",
"tip_sgl_courts": "Narrow court for 2 players. Popular for coaching, training, and competitive play.",
"tip_sqm_dbl_hall": "Total hall space needed per double court. Includes court (200\u202fm\u00b2), safety zones, circulation, and minimum clearances. Standard: 300\u2013350\u202fm\u00b2.",
"tip_sqm_sgl_hall": "Total hall space needed per single court. Includes court (120\u202fm\u00b2), safety zones, and access. Standard: 200\u2013250\u202fm\u00b2.",
"tip_sqm_dbl_outdoor": "Outdoor land area per double court. Includes court area, drainage slopes, access paths, and buffer zones. Standard: 280\u2013320\u202fm\u00b2.",
"tip_sqm_sgl_outdoor": "Outdoor land area per single court. Includes court, surrounding space, and access paths. Standard: 180\u2013220\u202fm\u00b2.",
"tip_rate_peak": "Price per court per hour during peak times (evenings 17:00\u201322:00 and weekends). Highest demand period.",
"tip_rate_offpeak": "Price per court per hour during off-peak (weekday mornings/afternoons). Typically 30\u201340\u00a0% lower than peak.",
"tip_rate_single": "Hourly rate for single-width courts. Usually lower than doubles since fewer players share the cost.",
"tip_peak_pct": "Percentage of total booked hours at peak rate. Higher means more revenue but harder to fill off-peak slots.",
"tip_booking_fee": "Commission taken by booking platforms like Playtomic or Matchi. Typically 5\u201315\u00a0% of court revenue.",
"tip_util_target": "Percentage of available court-hours that are actually booked. 35\u201345\u00a0% is realistic for new venues, 50\u00a0%+ is strong.",
"tip_hours_per_day": "Total operating hours per day. Typical padel venues run 7:00\u201323:00 (16\u202fh). Some extend to 6:00\u201324:00.",
"tip_days_indoor": "Average operating days per month for indoor venue. ~29 accounts for holidays and maintenance closures.",
"tip_days_outdoor": "Average playable days per month outdoors. Reduced by rain, extreme heat, or cold weather.",
"tip_membership_rev": "Monthly membership/subscription income per court. From loyalty programs, monthly plans, or club memberships.",
"tip_fb_rev": "Food & Beverage revenue per court per month. Income from bar, caf\u00e9, restaurant, or vending machines at the venue.",
"tip_coaching_rev": "Revenue from coaching sessions, clinics, tournaments, and events allocated per court per month.",
"tip_retail_rev": "Revenue from pro shop sales: grip tape, overgrips, accessories, and branded merchandise per court per month.",
"tip_glass_type": "Standard glass: \u20ac25\u201330K per court. Panoramic glass: \u20ac30\u201345K. Panoramic offers full visibility and premium feel.",
"tip_court_cost_dbl": "Base price of one double padel court. The glass type multiplier is applied automatically.",
"tip_court_cost_sgl": "Base price of one single padel court. Generally 60\u201370\u00a0% of a double court cost.",
"tip_hall_cost_sqm": "Construction cost per m\u00b2 for a new hall (Warmhalle). Includes structure, insulation, and cladding. Requires 10\u201312\u202fm clear height.",
"tip_foundation_sqm": "Foundation cost per m\u00b2. Depends on soil conditions, load-bearing requirements, and local ground water levels.",
"tip_land_price_sqm": "Land purchase price per m\u00b2. Rural: \u20ac20\u201360. Suburban: \u20ac60\u2013150. Urban: \u20ac150\u2013300+. Varies hugely by location.",
"tip_hvac": "Heating, ventilation, and air conditioning. Essential for indoor comfort and humidity control. Cost scales with hall volume.",
"tip_electrical": "Complete electrical installation: court lighting (LED, 500+ lux), power distribution, panels, and outlets.",
"tip_sanitary": "Changing rooms, showers, toilets, and plumbing. Includes fixtures, tiling, waterproofing, and ventilation.",
"tip_fire_protection": "Fire detection, sprinkler suppression, emergency exits, and smoke ventilation. Often the biggest surprise cost for large halls.",
"tip_planning": "Architectural planning, structural engineering, building permits, zoning applications, and regulatory compliance costs.",
"tip_floor_prep": "Floor leveling, sealing, and preparation for court installation in an existing rented building.",
"tip_hvac_upgrade": "Upgrading existing HVAC in a rented building to handle sports venue airflow and humidity requirements.",
"tip_lighting_upgrade": "Upgrading existing lighting to meet padel requirements: minimum 500 lux, no glare, even distribution across courts.",
"tip_fitout": "Interior fit-out for reception, lounge, viewing areas, and common spaces when renting an existing building.",
"tip_outdoor_foundation": "Concrete pad per m\u00b2 for outdoor courts. Needs proper drainage, level surface, and frost-resistant construction.",
"tip_outdoor_site_work": "Grading, drainage installation, utilities connection, and site preparation for outdoor courts.",
"tip_outdoor_lighting": "Floodlight installation per court. LED recommended for energy efficiency. Must meet competition standards if applicable.",
"tip_outdoor_fencing": "Perimeter fencing around outdoor court area. Includes wind screens, security gates, and ball containment nets.",
"tip_working_capital": "Cash reserve for operating losses during ramp-up phase and seasonal dips. Critical buffer \u2014 underfunding is a common startup failure.",
"tip_contingency": "Percentage buffer on total CAPEX for unexpected costs. 10\u201315\u00a0% is standard for construction, 15\u201320\u00a0% for complex projects.",
"tip_budget_target": "Set your total budget to see how your planned CAPEX compares. Leave at 0 to hide the budget indicator.",
"tip_rent_sqm": "Monthly rent per square meter for indoor hall space. Varies by location, building quality, and lease terms.",
"tip_outdoor_rent": "Monthly land rent for outdoor court area. Much cheaper than indoor space but weather-dependent.",
"tip_property_tax": "Monthly property tax when owning the building/land. Grundsteuer in Germany, varies by municipality and property value.",
"tip_insurance": "Monthly insurance premium covering liability, property damage, business interruption, and equipment.",
"tip_electricity": "Monthly electricity cost. Major driver for indoor venues due to court lighting, HVAC, and equipment.",
"tip_heating": "Monthly heating cost for indoor venue. Significant in northern European climates during winter months.",
"tip_water": "Monthly water cost for showers, toilets, cleaning, and potentially outdoor court irrigation.",
"tip_maintenance": "Monthly court and facility maintenance: glass cleaning, surface repair, net replacement, and equipment upkeep.",
"tip_cleaning": "Monthly professional cleaning of courts, changing rooms, common areas, and reception.",
"tip_marketing": "Monthly spend on marketing, booking platform subscriptions, website, social media, and customer acquisition.",
"tip_staff": "Monthly staff costs including wages, social contributions, and benefits. Many venues run lean using automated booking and access systems.",
"tip_loan_pct": "Percentage of total CAPEX financed by debt. Banks typically offer 70\u201385\u00a0%. Higher with personal guarantees or subsidies.",
"tip_interest_rate": "Annual interest rate on the loan. Depends on creditworthiness, collateral, market conditions, and bank relationship.",
"tip_loan_term": "Loan repayment period in years. Longer terms mean lower monthly payments but more total interest paid.",
"tip_construction_months": "Months of construction/setup before opening. Costs accrue (loan interest, rent) but no revenue is generated.",
"tip_hold_years": "Investment holding period before exit/sale. Typical for PE/investors: 5\u20137 years. Owner-operators may hold indefinitely.",
"tip_exit_multiple": "EBITDA multiple used to value the business at exit. Reflects market demand, brand strength, and growth potential. Small business: 4\u20136\u00d7, strong brand: 6\u20138\u00d7.",
"tip_annual_rev_growth": "Expected annual revenue growth rate after the initial 12-month ramp-up period. Driven by price increases and utilization gains.",
"btn_save": "Save", "btn_save": "Save",
"btn_my_scenarios": "My Scenarios", "btn_my_scenarios": "My Scenarios",
"btn_reset": "Reset to Defaults", "btn_reset": "Reset to Defaults",
@@ -1157,6 +1219,68 @@ _TRANSLATIONS: dict[str, dict[str, str]] = {
"sl_hold_years": "Haltedauer", "sl_hold_years": "Haltedauer",
"sl_exit_multiple": "Exit-EBITDA-Multiplikator", "sl_exit_multiple": "Exit-EBITDA-Multiplikator",
"sl_annual_rev_growth": "J\u00e4hrliches Umsatzwachstum", "sl_annual_rev_growth": "J\u00e4hrliches Umsatzwachstum",
# ── Tooltip tip texts ────────────────────────────────────────────────
"wiz_summary_label": "Aktuelle Werte",
"tip_permits_compliance": "Baugenehmigungen, L\u00e4rmgutachten, Nutzungs\u00e4nderungen, Brandschutz und beh\u00f6rdliche Auflagen. Wird automatisch angepasst, wenn du ein Land w\u00e4hlst \u2014 kann manuell \u00fcberschrieben werden.",
"tip_dbl_courts": "Standard-Padelplatz f\u00fcr 4 Spieler. H\u00e4ufigstes Format mit der h\u00f6chsten Freizeitnachfrage.",
"tip_sgl_courts": "Schmaler Platz f\u00fcr 2 Spieler. Beliebt f\u00fcr Coaching, Training und Wettkampf.",
"tip_sqm_dbl_hall": "Gesamte Hallenfl\u00e4che pro Doppelplatz. Enth\u00e4lt Spielfeld (200\u202fm\u00b2), Sicherheitszonen, Laufwege und Mindestabst\u00e4nde. Standard: 300\u2013350\u202fm\u00b2.",
"tip_sqm_sgl_hall": "Gesamte Hallenfl\u00e4che pro Einzelplatz. Enth\u00e4lt Spielfeld (120\u202fm\u00b2), Sicherheitszonen und Zugang. Standard: 200\u2013250\u202fm\u00b2.",
"tip_sqm_dbl_outdoor": "Au\u00dfenfl\u00e4che pro Doppelplatz. Enth\u00e4lt Spielfeld, Entw\u00e4sserungsgef\u00e4lle, Zugangswege und Pufferzonen. Standard: 280\u2013320\u202fm\u00b2.",
"tip_sqm_sgl_outdoor": "Au\u00dfenfl\u00e4che pro Einzelplatz. Enth\u00e4lt Spielfeld, Umgebungsfl\u00e4che und Zugangswege. Standard: 180\u2013220\u202fm\u00b2.",
"tip_rate_peak": "Preis pro Platz und Stunde w\u00e4hrend Sto\u00dfzeiten (Abends 17\u201322\u202fUhr und Wochenende). H\u00f6chste Nachfragezeit.",
"tip_rate_offpeak": "Preis pro Platz und Stunde au\u00dferhalb der Sto\u00dfzeiten (Werktage morgens/nachmittags). Typischerweise 30\u201340\u00a0% niedriger als Peak.",
"tip_rate_single": "Stundensatz f\u00fcr Einzelpl\u00e4tze. Meist niedriger als Doppelpl\u00e4tze, da sich weniger Spieler die Kosten teilen.",
"tip_peak_pct": "Anteil der gebuchten Stunden zum Spitzentarif. H\u00f6herer Wert bedeutet mehr Umsatz, aber schwieriger zu f\u00fcllende Nebenstunden.",
"tip_booking_fee": "Provision von Buchungsplattformen wie Playtomic oder Matchi. Typisch: 5\u201315\u00a0% des Platzumsatzes.",
"tip_util_target": "Anteil der verf\u00fcgbaren Platzstunden, der tats\u00e4chlich gebucht wird. 35\u201345\u00a0% sind realistisch f\u00fcr neue Anlagen, 50\u00a0%+ ist stark.",
"tip_hours_per_day": "Gesamte Betriebsstunden pro Tag. Typische Padel-Anlagen \u00f6ffnen 7\u201323\u202fUhr (16\u202fh). Manche auch 6\u201324\u202fUhr.",
"tip_days_indoor": "Durchschnittliche Betriebstage pro Monat f\u00fcr Indoor-Anlagen. ~29 ber\u00fccksichtigt Feiertage und Wartungsschlie\u00dfungen.",
"tip_days_outdoor": "Durchschnittliche bespielbaren Tage pro Monat im Freien. Reduziert durch Regen, Extremhitze oder K\u00e4lte.",
"tip_membership_rev": "Monatliche Mitgliedschafts-/Abonnementeinnahmen pro Platz. Aus Treueprogrammen, Monatsp\u00e4ssen oder Clubmitgliedschaften.",
"tip_fb_rev": "F&B-Einnahmen pro Platz und Monat. Einnahmen aus Bar, Caf\u00e9, Restaurant oder Automaten in der Anlage.",
"tip_coaching_rev": "Einnahmen aus Coaching-Stunden, Kursen, Turnieren und Veranstaltungen, pro Platz und Monat.",
"tip_retail_rev": "Einnahmen aus dem Fachhandel: Griffband, Overgrips, Zubeh\u00f6r und Merchandise pro Platz und Monat.",
"tip_glass_type": "Standardglas: 25\u201330\u202fT\u20ac pro Platz. Panoramaglas: 30\u201345\u202fT\u20ac. Panoramaglas bietet volle Sicht und Premium-Atmosph\u00e4re.",
"tip_court_cost_dbl": "Grundpreis eines Doppel-Padelplatzes. Der Glastyp-Multiplikator wird automatisch angewendet.",
"tip_court_cost_sgl": "Grundpreis eines Einzelplatzes. In der Regel 60\u201370\u00a0% der Kosten eines Doppelplatzes.",
"tip_hall_cost_sqm": "Baukosten pro m\u00b2 f\u00fcr eine neue Halle (Warmhalle). Enth\u00e4lt Tragwerk, D\u00e4mmung und Verkleidung. Erfordert 10\u201312\u202fm lichte H\u00f6he.",
"tip_foundation_sqm": "Fundamentkosten pro m\u00b2. Abh\u00e4ngig von Bodenbedingungen, Tragf\u00e4higkeit und lokalem Grundwasserstand.",
"tip_land_price_sqm": "Grundst\u00fcckskaufpreis pro m\u00b2. Land: 20\u201360\u202f\u20ac. Stadtrand: 60\u2013150\u202f\u20ac. Stadtlage: 150\u2013300\u202f\u20ac+. Stark standortabh\u00e4ngig.",
"tip_hvac": "Heizung, L\u00fcftung und Klimatisierung. Unverzichtbar f\u00fcr Raumklima und Feuchtigkeitskontrolle. Kosten skalieren mit dem Hallenvolumen.",
"tip_electrical": "Komplette Elektroinstallation: Platzbeleuchtung (LED, 500+ Lux), Stromverteilung, Schaltschr\u00e4nke und Steckdosen.",
"tip_sanitary": "Umkleidekabinen, Duschen, Toiletten und Sanit\u00e4rinstallation. Enth\u00e4lt Armaturen, Fliesen, Abdichtung und L\u00fcftung.",
"tip_fire_protection": "Brandmeldeanlage, Sprinkler, Notausg\u00e4nge und Rauchabzug. Oft der gr\u00f6\u00dfte \u00dcberraschungsposten bei gro\u00dfen Hallen.",
"tip_planning": "Architektenplanung, Tragwerksplanung, Baugenehmigungen, Bebauungsplanantrag und beh\u00f6rdliche Auflagen.",
"tip_floor_prep": "Bodenausgleich, Abdichtung und Vorbereitung f\u00fcr die Platzinstallation in einem bestehenden Mietobjekt.",
"tip_hvac_upgrade": "Ausr\u00fcstung der vorhandenen HLK-Anlage im Mietobjekt f\u00fcr sportgerechten Luftstrom und Feuchtigkeitskontrolle.",
"tip_lighting_upgrade": "Ausr\u00fcstung der vorhandenen Beleuchtung auf Padel-Standard: mind. 500 Lux, blendfrei, gleichm\u00e4\u00dfige Ausleuchtung.",
"tip_fitout": "Innenausbau f\u00fcr Empfang, Lounge, Zuschauerbereich und Gemeinschaftsfl\u00e4chen im Mietobjekt.",
"tip_outdoor_foundation": "Betonplatte pro m\u00b2 f\u00fcr Outdoor-Pl\u00e4tze. Erfordert ordentliche Entw\u00e4sserung, ebenen Untergrund und frostsichere Bauweise.",
"tip_outdoor_site_work": "Gel\u00e4ndeausgleich, Entw\u00e4sserungsinstallation, Versorgungsanschl\u00fcsse und Erschlie\u00dfung f\u00fcr Au\u00dfenpl\u00e4tze.",
"tip_outdoor_lighting": "Flutlichtinstallation pro Platz. LED empfohlen f\u00fcr Energieeffizienz. Wettkampfnormen einhalten, falls relevant.",
"tip_outdoor_fencing": "Einz\u00e4unung der Au\u00dfenplatzanlage. Enth\u00e4lt Windschutz, Sicherheitstore und Ballr\u00fcckhaltevorrichtungen.",
"tip_working_capital": "Kassenreserve f\u00fcr Betriebsverluste in der Anlaufphase und bei saisonalen Schwankungen. Kritischer Puffer \u2014 zu geringes Betriebskapital ist ein h\u00e4ufiger Startup-Fehler.",
"tip_contingency": "Prozentualer Puffer auf den Gesamt-CAPEX f\u00fcr unvorhergesehene Kosten. 10\u201315\u00a0% sind beim Bau Standard, 15\u201320\u00a0% bei komplexen Projekten.",
"tip_budget_target": "Gesamtbudget festlegen, um den geplanten CAPEX zu vergleichen. 0 lassen, um den Budgetindikator auszublenden.",
"tip_rent_sqm": "Monatliche Miete pro m\u00b2 f\u00fcr Hallenfl\u00e4che. Abh\u00e4ngig von Lage, Geb\u00e4udequalit\u00e4t und Mietkonditionen.",
"tip_outdoor_rent": "Monatliche Grundst\u00fccksmiete f\u00fcr Au\u00dfenplatzfl\u00e4che. Deutlich g\u00fcnstiger als Hallenfl\u00e4che, aber wetterabh\u00e4ngig.",
"tip_property_tax": "Monatliche Grundsteuer bei Eigentum an Geb\u00e4ude/Grundst\u00fcck. Variiert je nach Gemeinde und Grundst\u00fcckswert.",
"tip_insurance": "Monatlicher Versicherungsbeitrag: Haftpflicht, Sachschaden, Betriebsunterbrechung und Ausr\u00fcstung.",
"tip_electricity": "Monatliche Stromkosten. Gr\u00f6\u00dfter Kostentreiber f\u00fcr Indoor-Anlagen durch Platzbeleuchtung, HLK und Ger\u00e4te.",
"tip_heating": "Monatliche Heizkosten f\u00fcr Indoor-Anlagen. Relevant in nordeurop\u00e4ischen Klimazonen in den Wintermonaten.",
"tip_water": "Monatliche Wasserkosten f\u00fcr Duschen, Toiletten, Reinigung und ggf. Outdoor-Platzbew\u00e4sserung.",
"tip_maintenance": "Monatliche Platz- und Anlagenwartung: Glasreinigung, Belagreparatur, Netzaustausch und Ger\u00e4tepflege.",
"tip_cleaning": "Monatliche professionelle Reinigung von Pl\u00e4tzen, Umkleiden, Gemeinschaftsfl\u00e4chen und Empfang.",
"tip_marketing": "Monatliche Ausgaben f\u00fcr Marketing, Buchungsplattform-Abonnements, Website, Social Media und Kundengewinnung.",
"tip_staff": "Monatliche Personalkosten: Geh\u00e4lter, Sozialabgaben und Leistungen. Viele Anlagen fahren schlank mit automatisierten Buchungs- und Zugangssystemen.",
"tip_loan_pct": "Anteil des Gesamt-CAPEX, der fremdfinanziert wird. Banken bieten typisch 70\u201385\u00a0%. H\u00f6her mit Bürg\u00fcschaft oder F\u00f6rdermitteln.",
"tip_interest_rate": "J\u00e4hrlicher Zinssatz des Darlehens. Abh\u00e4ngig von Bonit\u00e4t, Sicherheiten, Marktlage und Bankbeziehung.",
"tip_loan_term": "Kreditlaufzeit in Jahren. L\u00e4ngere Laufzeit bedeutet niedrigere Monatsraten, aber mehr Gesamtzinsen.",
"tip_construction_months": "Monate Bau/Einrichtung vor der Er\u00f6ffnung. Kosten laufen bereits auf (Zinsen, Miete), aber noch kein Umsatz.",
"tip_hold_years": "Investitionshaltedauer bis zum Exit/Verkauf. Typisch f\u00fcr PE/Investoren: 5\u20137 Jahre. Betreiber-Eigent\u00fcmer k\u00f6nnen unbegrenzt halten.",
"tip_exit_multiple": "EBITDA-Multiplikator zur Unternehmensbewertung beim Exit. Spiegelt Marktnachfrage, Markenst\u00e4rke und Wachstumspotenzial wider. Kleines Business: 4\u20136\u00d7, starke Marke: 6\u20138\u00d7.",
"tip_annual_rev_growth": "Erwartetes j\u00e4hrliches Umsatzwachstum nach der ersten 12-monatigen Anlaufphase. Getrieben durch Preiserh\u00f6hungen und steigende Auslastung.",
"btn_save": "Speichern", "btn_save": "Speichern",
"btn_my_scenarios": "Meine Szenarien", "btn_my_scenarios": "Meine Szenarien",
"btn_reset": "Zur\u00fccksetzen", "btn_reset": "Zur\u00fccksetzen",

View File

@@ -93,57 +93,170 @@ def augment_d(d: dict, s: dict, lang: str) -> None:
d["irr_ok"] = math.isfinite(d.get("irr", 0)) d["irr_ok"] = math.isfinite(d.get("irr", 0))
# Chart data — embedded as JSON in partials, consumed by Chart.js via JS # Chart data — full Chart.js 4.x config objects, embedded as JSON in partials
_PALETTE = [
"#1D4ED8", "#16A34A", "#D97706", "#EF4444", "#8B5CF6",
"#EC4899", "#06B6D4", "#84CC16", "#F97316", "#475569",
"#0EA5E9", "#A78BFA",
]
_cap_items = [i for i in d["capexItems"] if i["amount"] > 0]
d["capex_chart"] = { d["capex_chart"] = {
"labels": [i["name"] for i in d["capexItems"] if i["amount"] > 0], "type": "doughnut",
"data": [i["amount"] for i in d["capexItems"] if i["amount"] > 0], "data": {
"labels": [i["name"] for i in _cap_items],
"datasets": [{
"data": [i["amount"] for i in _cap_items],
"backgroundColor": [_PALETTE[i % len(_PALETTE)] for i in range(len(_cap_items))],
"borderWidth": 0,
}],
},
"options": {
"responsive": True,
"maintainAspectRatio": False,
"cutout": "60%",
"plugins": {"legend": {"position": "right", "labels": {"boxWidth": 10, "font": {"size": 10}}}},
},
} }
ramp_data = d["months"][:24] ramp_data = d["months"][:24]
d["ramp_chart"] = { d["ramp_chart"] = {
"months": [f"M{m['m']}" for m in ramp_data], "type": "line",
"revenue": [round(m["totalRev"]) for m in ramp_data], "data": {
"opex_debt": [round(abs(m["opex"]) + abs(m["loan"])) for m in ramp_data], "labels": [f"M{m['m']}" for m in ramp_data],
"label_revenue": t["chart_revenue"], "datasets": [
"label_opex_debt": t["chart_opex_debt"], {
"label": t["chart_revenue"],
"data": [round(m["totalRev"]) for m in ramp_data],
"borderColor": "#16A34A",
"backgroundColor": "rgba(22,163,74,0.08)",
"fill": True,
"tension": 0.35,
"pointRadius": 0,
"borderWidth": 2,
},
{
"label": t["chart_opex_debt"],
"data": [round(abs(m["opex"]) + abs(m["loan"])) for m in ramp_data],
"borderColor": "#EF4444",
"backgroundColor": "rgba(239,68,68,0.06)",
"fill": True,
"tension": 0.35,
"pointRadius": 0,
"borderWidth": 2,
},
],
},
"options": {
"responsive": True,
"maintainAspectRatio": False,
"plugins": {"legend": {"display": True, "labels": {"boxWidth": 12, "font": {"size": 10}}}},
"scales": {"y": {"ticks": {"font": {"size": 10}}}, "x": {"ticks": {"font": {"size": 9}}}},
},
} }
d["pl_chart"] = { _pl_values = [
"labels": [
t["chart_court_rev"], t["chart_fees"], t["chart_ancillary"],
t["chart_opex"], t["chart_debt"],
],
"values": [
round(d["courtRevMonth"]), round(d["courtRevMonth"]),
-round(d["feeDeduction"]), -round(d["feeDeduction"]),
round(d["racketRev"] + d["ballMargin"] + d["membershipRev"] round(d["racketRev"] + d["ballMargin"] + d["membershipRev"]
+ d["fbRev"] + d["coachingRev"] + d["retailRev"]), + d["fbRev"] + d["coachingRev"] + d["retailRev"]),
-round(d["opex"]), -round(d["opex"]),
-round(d["monthlyPayment"]), -round(d["monthlyPayment"]),
], ]
d["pl_chart"] = {
"type": "bar",
"data": {
"labels": [t["chart_court_rev"], t["chart_fees"], t["chart_ancillary"], t["chart_opex"], t["chart_debt"]],
"datasets": [{
"data": _pl_values,
"backgroundColor": ["rgba(22,163,74,0.7)" if v >= 0 else "rgba(239,68,68,0.7)" for v in _pl_values],
"borderRadius": 4,
}],
},
"options": {
"indexAxis": "y",
"responsive": True,
"maintainAspectRatio": False,
"plugins": {"legend": {"display": False}},
"scales": {"x": {"ticks": {"font": {"size": 9}}}, "y": {"ticks": {"font": {"size": 10}}}},
},
} }
_cf_values = [round(m["ncf"]) for m in d["months"]]
d["cf_chart"] = { d["cf_chart"] = {
"type": "bar",
"data": {
"labels": [f"Y{m['yr']}" if m["m"] % 12 == 1 else "" for m in d["months"]], "labels": [f"Y{m['yr']}" if m["m"] % 12 == 1 else "" for m in d["months"]],
"values": [round(m["ncf"]) for m in d["months"]], "datasets": [{
"pos": [m["ncf"] >= 0 for m in d["months"]], "data": _cf_values,
"backgroundColor": ["rgba(22,163,74,0.7)" if v >= 0 else "rgba(239,68,68,0.7)" for v in _cf_values],
"borderRadius": 2,
}],
},
"options": {
"responsive": True,
"maintainAspectRatio": False,
"plugins": {"legend": {"display": False}},
"scales": {"y": {"ticks": {"font": {"size": 10}}}, "x": {"ticks": {"font": {"size": 9}}}},
},
} }
d["cum_chart"] = { d["cum_chart"] = {
"type": "line",
"data": {
"labels": [f"M{m['m']}" if m["m"] % 6 == 1 else "" for m in d["months"]], "labels": [f"M{m['m']}" if m["m"] % 6 == 1 else "" for m in d["months"]],
"values": [round(m["cum"]) for m in d["months"]], "datasets": [{
"data": [round(m["cum"]) for m in d["months"]],
"borderColor": "#1D4ED8",
"backgroundColor": "rgba(29,78,216,0.08)",
"fill": True,
"tension": 0.3,
"pointRadius": 0,
"borderWidth": 2,
}],
},
"options": {
"responsive": True,
"maintainAspectRatio": False,
"plugins": {"legend": {"display": False}},
"scales": {"y": {"ticks": {"font": {"size": 10}}}, "x": {"ticks": {"font": {"size": 9}}}},
},
} }
_dscr_values = [min(x["dscr"], 10) for x in d["dscr"]]
d["dscr_chart"] = { d["dscr_chart"] = {
"type": "bar",
"data": {
"labels": [f"Y{x['year']}" for x in d["dscr"]], "labels": [f"Y{x['year']}" for x in d["dscr"]],
"values": [min(x["dscr"], 10) for x in d["dscr"]], "datasets": [{
"pos": [x["dscr"] >= 1.2 for x in d["dscr"]], "data": _dscr_values,
"backgroundColor": ["rgba(22,163,74,0.7)" if v >= 1.2 else "rgba(239,68,68,0.7)" for v in _dscr_values],
"borderRadius": 4,
}],
},
"options": {
"responsive": True,
"maintainAspectRatio": False,
"plugins": {"legend": {"display": False}},
"scales": {"y": {"ticks": {"font": {"size": 10}}, "min": 0}, "x": {"ticks": {"font": {"size": 10}}}},
},
} }
d["season_chart"] = { d["season_chart"] = {
"type": "bar",
"data": {
"labels": [t[f"month_{k}"] for k in month_keys], "labels": [t[f"month_{k}"] for k in month_keys],
"values": [v * 100 for v in s["season"]], "datasets": [{
"pos": [v > 0 for v in s["season"]], "data": [v * 100 for v in s["season"]],
"backgroundColor": "rgba(29,78,216,0.6)",
"borderRadius": 3,
}],
},
"options": {
"responsive": True,
"maintainAspectRatio": False,
"plugins": {"legend": {"display": False}},
"scales": {"y": {"ticks": {"font": {"size": 10}}, "min": 0}, "x": {"ticks": {"font": {"size": 10}}}},
},
} }
# Sensitivity tables (pre-computed for returns tab) # Sensitivity tables (pre-computed for returns tab)

View File

@@ -5,4 +5,4 @@
<div id="courtSummary" hx-swap-oob="true">{% include "partials/court_summary.html" %}</div> <div id="courtSummary" hx-swap-oob="true">{% include "partials/court_summary.html" %}</div>
<div id="wizPreview" hx-swap-oob="true">{% include "partials/wizard_preview.html" %}</div> <div class="wizard-preview" id="wizPreview" hx-swap-oob="true">{% include "partials/wizard_preview.html" %}</div>

View File

@@ -1,4 +1,5 @@
{% set cf = d.ebitdaMonth - d.monthlyPayment %} {% set cf = d.ebitdaMonth - d.monthlyPayment %}
<div class="wiz-preview__caption">{{ t.wiz_summary_label }}</div>
<div class="wiz-preview__item"> <div class="wiz-preview__item">
<div class="wiz-preview__label">{{ t.wiz_capex }}</div> <div class="wiz-preview__label">{{ t.wiz_capex }}</div>
<div class="wiz-preview__value">{{ d.capex | fmt_k }}</div> <div class="wiz-preview__value">{{ d.capex | fmt_k }}</div>

View File

@@ -159,7 +159,7 @@
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
{{ slider('permitsCompliance', t.sl_permits, 0, 50000, 1000, s.permitsCompliance, 'Building permits, noise studies, change-of-use, fire safety, and regulatory compliance. Adjusts automatically when you pick a country — feel free to override.') }} {{ slider('permitsCompliance', t.sl_permits, 0, 50000, 1000, s.permitsCompliance, t.tip_permits_compliance) }}
</div> </div>
<div class="mb-section"> <div class="mb-section">
@@ -168,8 +168,8 @@
{% else %} {% else %}
<div class="section-header"><h3>Court Configuration</h3></div> <div class="section-header"><h3>Court Configuration</h3></div>
{% endif %} {% endif %}
{{ slider('dblCourts', t.sl_dbl_courts, 0, 30, 1, s.dblCourts, 'Standard padel court for 4 players. Most common format with highest recreational demand.') }} {{ slider('dblCourts', t.sl_dbl_courts, 0, 30, 1, s.dblCourts, t.tip_dbl_courts) }}
{{ slider('sglCourts', t.sl_sgl_courts, 0, 30, 1, s.sglCourts, 'Narrow court for 2 players. Popular for coaching, training, and competitive play.') }} {{ slider('sglCourts', t.sl_sgl_courts, 0, 30, 1, s.sglCourts, t.tip_sgl_courts) }}
{% if lang == 'de' %} {% if lang == 'de' %}
<div class="section-header" style="margin-top:1rem"><h3>Platzbedarf</h3></div> <div class="section-header" style="margin-top:1rem"><h3>Platzbedarf</h3></div>
@@ -177,12 +177,12 @@
<div class="section-header" style="margin-top:1rem"><h3>Space Requirements</h3></div> <div class="section-header" style="margin-top:1rem"><h3>Space Requirements</h3></div>
{% endif %} {% endif %}
<div data-show-venue="indoor"> <div data-show-venue="indoor">
{{ slider('sqmPerDblHall', t.sl_sqm_dbl_hall, 200, 600, 10, s.sqmPerDblHall, 'Total hall space needed per double court. Includes court (200m²), safety zones, circulation, and minimum clearances. Standard: 300350m².') }} {{ slider('sqmPerDblHall', t.sl_sqm_dbl_hall, 200, 600, 10, s.sqmPerDblHall, t.tip_sqm_dbl_hall) }}
{{ slider('sqmPerSglHall', t.sl_sqm_sgl_hall, 120, 400, 10, s.sqmPerSglHall, 'Total hall space needed per single court. Includes court (120m²), safety zones, and access. Standard: 200250m².') }} {{ slider('sqmPerSglHall', t.sl_sqm_sgl_hall, 120, 400, 10, s.sqmPerSglHall, t.tip_sqm_sgl_hall) }}
</div> </div>
<div data-show-venue="outdoor"> <div data-show-venue="outdoor">
{{ slider('sqmPerDblOutdoor', t.sl_sqm_dbl_outdoor, 200, 500, 10, s.sqmPerDblOutdoor, 'Outdoor land area per double court. Includes court area, drainage slopes, access paths, and buffer zones. Standard: 280320m².') }} {{ slider('sqmPerDblOutdoor', t.sl_sqm_dbl_outdoor, 200, 500, 10, s.sqmPerDblOutdoor, t.tip_sqm_dbl_outdoor) }}
{{ slider('sqmPerSglOutdoor', t.sl_sqm_sgl_outdoor, 120, 350, 10, s.sqmPerSglOutdoor, 'Outdoor land area per single court. Includes court, surrounding space, and access paths. Standard: 180220m².') }} {{ slider('sqmPerSglOutdoor', t.sl_sqm_sgl_outdoor, 120, 350, 10, s.sqmPerSglOutdoor, t.tip_sqm_sgl_outdoor) }}
</div> </div>
<div class="court-summary" id="courtSummary">{% include "partials/court_summary.html" %}</div> <div class="court-summary" id="courtSummary">{% include "partials/court_summary.html" %}</div>
</div> </div>
@@ -204,11 +204,11 @@
{% else %} {% else %}
<div class="section-header"><h3>Pricing</h3><span class="hint">Per court per hour</span></div> <div class="section-header"><h3>Pricing</h3><span class="hint">Per court per hour</span></div>
{% endif %} {% endif %}
{{ slider('ratePeak', t.sl_rate_peak, 0, 150, 1, s.ratePeak, 'Price per court per hour during peak times (evenings 17:0022:00 and weekends). Highest demand period.') }} {{ slider('ratePeak', t.sl_rate_peak, 0, 150, 1, s.ratePeak, t.tip_rate_peak) }}
{{ slider('rateOffPeak', t.sl_rate_offpeak, 0, 150, 1, s.rateOffPeak, 'Price per court per hour during off-peak (weekday mornings/afternoons). Typically 3040% lower than peak.') }} {{ slider('rateOffPeak', t.sl_rate_offpeak, 0, 150, 1, s.rateOffPeak, t.tip_rate_offpeak) }}
{{ slider('rateSingle', t.sl_rate_single, 0, 150, 1, s.rateSingle, 'Hourly rate for single-width courts. Usually lower than doubles since fewer players share the cost.') }} {{ slider('rateSingle', t.sl_rate_single, 0, 150, 1, s.rateSingle, t.tip_rate_single) }}
{{ slider('peakPct', t.sl_peak_pct, 0, 100, 1, s.peakPct, 'Percentage of total booked hours at peak rate. Higher means more revenue but harder to fill off-peak slots.') }} {{ slider('peakPct', t.sl_peak_pct, 0, 100, 1, s.peakPct, t.tip_peak_pct) }}
{{ slider('bookingFee', t.sl_booking_fee, 0, 30, 1, s.bookingFee, 'Commission taken by booking platforms like Playtomic or Matchi. Typically 515% of court revenue.') }} {{ slider('bookingFee', t.sl_booking_fee, 0, 30, 1, s.bookingFee, t.tip_booking_fee) }}
</div> </div>
<div class="mb-section"> <div class="mb-section">
@@ -217,15 +217,15 @@
{% else %} {% else %}
<div class="section-header"><h3>Utilization &amp; Operations</h3></div> <div class="section-header"><h3>Utilization &amp; Operations</h3></div>
{% endif %} {% endif %}
{{ slider('utilTarget', t.sl_util_target, 0, 100, 1, s.utilTarget, 'Percentage of available court-hours that are actually booked. 3545% is realistic for new venues, 50%+ is strong.') }} {{ slider('utilTarget', t.sl_util_target, 0, 100, 1, s.utilTarget, t.tip_util_target) }}
{{ slider('hoursPerDay', t.sl_hours_per_day, 0, 24, 1, s.hoursPerDay, 'Total operating hours per day. Typical padel venues run 7:0023:00 (16h). Some extend to 6:0024:00.') }} {{ slider('hoursPerDay', t.sl_hours_per_day, 0, 24, 1, s.hoursPerDay, t.tip_hours_per_day) }}
{{ slider('daysPerMonthIndoor', t.sl_days_indoor, 0, 31, 1, s.daysPerMonthIndoor, 'Average operating days per month for indoor venue. ~29 accounts for holidays and maintenance closures.') }} {{ slider('daysPerMonthIndoor', t.sl_days_indoor, 0, 31, 1, s.daysPerMonthIndoor, t.tip_days_indoor) }}
{{ slider('daysPerMonthOutdoor', t.sl_days_outdoor, 0, 31, 1, s.daysPerMonthOutdoor, 'Average playable days per month outdoors. Reduced by rain, extreme heat, or cold weather.') }} {{ slider('daysPerMonthOutdoor', t.sl_days_outdoor, 0, 31, 1, s.daysPerMonthOutdoor, t.tip_days_outdoor) }}
<div style="font-size:11px;color:var(--txt-3);margin:4px 0 8px"><b>{{ t.sl_ancillary_header }}</b></div> <div style="font-size:11px;color:var(--txt-3);margin:4px 0 8px"><b>{{ t.sl_ancillary_header }}</b></div>
{{ slider('membershipRevPerCourt', t.sl_membership_rev, 0, 2000, 50, s.membershipRevPerCourt, 'Monthly membership/subscription income per court. From loyalty programs, monthly plans, or club memberships.') }} {{ slider('membershipRevPerCourt', t.sl_membership_rev, 0, 2000, 50, s.membershipRevPerCourt, t.tip_membership_rev) }}
{{ slider('fbRevPerCourt', t.sl_fb_rev, 0, 2000, 25, s.fbRevPerCourt, 'Food & Beverage revenue per court per month. Income from bar, café, restaurant, or vending machines at the venue.') }} {{ slider('fbRevPerCourt', t.sl_fb_rev, 0, 2000, 25, s.fbRevPerCourt, t.tip_fb_rev) }}
{{ slider('coachingRevPerCourt', t.sl_coaching_rev, 0, 2000, 25, s.coachingRevPerCourt, 'Revenue from coaching sessions, clinics, tournaments, and events allocated per court per month.') }} {{ slider('coachingRevPerCourt', t.sl_coaching_rev, 0, 2000, 25, s.coachingRevPerCourt, t.tip_coaching_rev) }}
{{ slider('retailRevPerCourt', t.sl_retail_rev, 0, 1000, 10, s.retailRevPerCourt, 'Revenue from pro shop sales: grip tape, overgrips, accessories, and branded merchandise per court per month.') }} {{ slider('retailRevPerCourt', t.sl_retail_rev, 0, 1000, 10, s.retailRevPerCourt, t.tip_retail_rev) }}
</div> </div>
</div> </div>
@@ -247,7 +247,7 @@
{% endif %} {% endif %}
<div class="pill-group"> <div class="pill-group">
<label><span class="slider-group__label">{{ t.pill_glass_type }}</span><span class="ti">i<span class="tp">Standard glass: €2530K per court. Panoramic glass: €3045K. Panoramic offers full visibility and premium feel.</span></span></label> <label><span class="slider-group__label">{{ t.pill_glass_type }}</span><span class="ti">i<span class="tp">{{ t.tip_glass_type }}</span></span></label>
<div class="pill-options"> <div class="pill-options">
{{ pill_btn('glassType','standard', t.pill_glass_standard, s.glassType == 'standard') }} {{ pill_btn('glassType','standard', t.pill_glass_standard, s.glassType == 'standard') }}
{{ pill_btn('glassType','panoramic', t.pill_glass_panoramic, s.glassType == 'panoramic') }} {{ pill_btn('glassType','panoramic', t.pill_glass_panoramic, s.glassType == 'panoramic') }}
@@ -263,41 +263,41 @@
</div> </div>
</div> </div>
{{ slider('courtCostDbl', t.sl_court_cost_dbl, 0, 80000, 1000, s.courtCostDbl, 'Base price of one double padel court. The glass type multiplier is applied automatically.') }} {{ slider('courtCostDbl', t.sl_court_cost_dbl, 0, 80000, 1000, s.courtCostDbl, t.tip_court_cost_dbl) }}
{{ slider('courtCostSgl', t.sl_court_cost_sgl, 0, 60000, 1000, s.courtCostSgl, 'Base price of one single padel court. Generally 6070% of a double court cost.') }} {{ slider('courtCostSgl', t.sl_court_cost_sgl, 0, 60000, 1000, s.courtCostSgl, t.tip_court_cost_sgl) }}
<!-- Indoor + Buy --> <!-- Indoor + Buy -->
<div data-show-capex="indoor-buy"> <div data-show-capex="indoor-buy">
{{ slider('hallCostSqm', t.sl_hall_cost_sqm, 0, 2000, 10, s.hallCostSqm, 'Construction cost per m² for a new hall (Warmhalle). Includes structure, insulation, and cladding. Requires 1012m clear height.') }} {{ slider('hallCostSqm', t.sl_hall_cost_sqm, 0, 2000, 10, s.hallCostSqm, t.tip_hall_cost_sqm) }}
{{ slider('foundationSqm', t.sl_foundation_sqm, 0, 400, 5, s.foundationSqm, 'Foundation cost per m². Depends on soil conditions, load-bearing requirements, and local ground water levels.') }} {{ slider('foundationSqm', t.sl_foundation_sqm, 0, 400, 5, s.foundationSqm, t.tip_foundation_sqm) }}
{{ slider('landPriceSqm', t.sl_land_price_sqm, 0, 500, 5, s.landPriceSqm, 'Land purchase price per m². Rural: €2060. Suburban: €60150. Urban: €150300+. Varies hugely by location.') }} {{ slider('landPriceSqm', t.sl_land_price_sqm, 0, 500, 5, s.landPriceSqm, t.tip_land_price_sqm) }}
{{ slider('hvac', t.sl_hvac, 0, 500000, 5000, s.hvac, 'Heating, ventilation, and air conditioning. Essential for indoor comfort and humidity control. Cost scales with hall volume.') }} {{ slider('hvac', t.sl_hvac, 0, 500000, 5000, s.hvac, t.tip_hvac) }}
{{ slider('electrical', t.sl_electrical, 0, 400000, 5000, s.electrical, 'Complete electrical installation: court lighting (LED, 500+ lux), power distribution, panels, and outlets.') }} {{ slider('electrical', t.sl_electrical, 0, 400000, 5000, s.electrical, t.tip_electrical) }}
{{ slider('sanitary', t.sl_sanitary, 0, 400000, 5000, s.sanitary, 'Changing rooms, showers, toilets, and plumbing. Includes fixtures, tiling, waterproofing, and ventilation.') }} {{ slider('sanitary', t.sl_sanitary, 0, 400000, 5000, s.sanitary, t.tip_sanitary) }}
{{ slider('fireProtection', t.sl_fire, 0, 500000, 5000, s.fireProtection, 'Fire detection, sprinkler suppression, emergency exits, and smoke ventilation. Often the biggest surprise cost for large halls.') }} {{ slider('fireProtection', t.sl_fire, 0, 500000, 5000, s.fireProtection, t.tip_fire_protection) }}
{{ slider('planning', t.sl_planning, 0, 500000, 5000, s.planning, 'Architectural planning, structural engineering, building permits, zoning applications, and regulatory compliance costs.') }} {{ slider('planning', t.sl_planning, 0, 500000, 5000, s.planning, t.tip_planning) }}
</div> </div>
<!-- Indoor + Rent --> <!-- Indoor + Rent -->
<div data-show-capex="indoor-rent"> <div data-show-capex="indoor-rent">
{{ slider('floorPrep', t.sl_floor_prep, 0, 100000, 1000, s.floorPrep, 'Floor leveling, sealing, and preparation for court installation in an existing rented building.') }} {{ slider('floorPrep', t.sl_floor_prep, 0, 100000, 1000, s.floorPrep, t.tip_floor_prep) }}
{{ slider('hvacUpgrade', t.sl_hvac_upgrade, 0, 200000, 1000, s.hvacUpgrade, 'Upgrading existing HVAC in a rented building to handle sports venue airflow and humidity requirements.') }} {{ slider('hvacUpgrade', t.sl_hvac_upgrade, 0, 200000, 1000, s.hvacUpgrade, t.tip_hvac_upgrade) }}
{{ slider('lightingUpgrade', t.sl_lighting_upgrade, 0, 100000, 1000, s.lightingUpgrade, 'Upgrading existing lighting to meet padel requirements: minimum 500 lux, no glare, even distribution across courts.') }} {{ slider('lightingUpgrade', t.sl_lighting_upgrade, 0, 100000, 1000, s.lightingUpgrade, t.tip_lighting_upgrade) }}
{{ slider('fitout', t.sl_fitout, 0, 300000, 1000, s.fitout, 'Interior fit-out for reception, lounge, viewing areas, and common spaces when renting an existing building.') }} {{ slider('fitout', t.sl_fitout, 0, 300000, 1000, s.fitout, t.tip_fitout) }}
</div> </div>
<!-- Outdoor --> <!-- Outdoor -->
<div data-show-capex="outdoor"> <div data-show-capex="outdoor">
{{ slider('outdoorFoundation', t.sl_outdoor_foundation, 0, 150, 1, s.outdoorFoundation, 'Concrete pad per m² for outdoor courts. Needs proper drainage, level surface, and frost-resistant construction.') }} {{ slider('outdoorFoundation', t.sl_outdoor_foundation, 0, 150, 1, s.outdoorFoundation, t.tip_outdoor_foundation) }}
{{ slider('outdoorSiteWork', t.sl_outdoor_site_work, 0, 60000, 500, s.outdoorSiteWork, 'Grading, drainage installation, utilities connection, and site preparation for outdoor courts.') }} {{ slider('outdoorSiteWork', t.sl_outdoor_site_work, 0, 60000, 500, s.outdoorSiteWork, t.tip_outdoor_site_work) }}
{{ slider('outdoorLighting', t.sl_outdoor_lighting, 0, 20000, 500, s.outdoorLighting, 'Floodlight installation per court. LED recommended for energy efficiency. Must meet competition standards if applicable.') }} {{ slider('outdoorLighting', t.sl_outdoor_lighting, 0, 20000, 500, s.outdoorLighting, t.tip_outdoor_lighting) }}
{{ slider('outdoorFencing', t.sl_outdoor_fencing, 0, 40000, 500, s.outdoorFencing, 'Perimeter fencing around outdoor court area. Includes wind screens, security gates, and ball containment nets.') }} {{ slider('outdoorFencing', t.sl_outdoor_fencing, 0, 40000, 500, s.outdoorFencing, t.tip_outdoor_fencing) }}
<div data-show-capex="outdoor-buy">{{ slider('landPriceSqm', t.sl_land_price_sqm, 0, 500, 5, s.landPriceSqm, 'Land purchase price per m². Varies by location, zoning, and accessibility.') }}</div> <div data-show-capex="outdoor-buy">{{ slider('landPriceSqm', t.sl_land_price_sqm, 0, 500, 5, s.landPriceSqm, t.tip_land_price_sqm) }}</div>
</div> </div>
{{ slider('workingCapital', t.sl_working_capital, 0, 200000, 1000, s.workingCapital, 'Cash reserve for operating losses during ramp-up phase and seasonal dips. Critical buffer — underfunding is a common startup failure.') }} {{ slider('workingCapital', t.sl_working_capital, 0, 200000, 1000, s.workingCapital, t.tip_working_capital) }}
{{ slider('contingencyPct', t.sl_contingency, 0, 30, 1, s.contingencyPct, 'Percentage buffer on total CAPEX for unexpected costs. 1015% is standard for construction, 1520% for complex projects.') }} {{ slider('contingencyPct', t.sl_contingency, 0, 30, 1, s.contingencyPct, t.tip_contingency) }}
{{ slider('budgetTarget', t.sl_budget_target, 0, 5000000, 10000, s.budgetTarget, 'Set your total budget to see how your planned CAPEX compares. Leave at 0 to hide the budget indicator.') }} {{ slider('budgetTarget', t.sl_budget_target, 0, 5000000, 10000, s.budgetTarget, t.tip_budget_target) }}
</div> </div>
</div> </div>
@@ -315,41 +315,41 @@
{% if lang == 'de' %}<div class="section-header"><h3>Monatliche Betriebskosten</h3></div> {% if lang == 'de' %}<div class="section-header"><h3>Monatliche Betriebskosten</h3></div>
{% else %}<div class="section-header"><h3>Monthly Operating Costs</h3></div>{% endif %} {% else %}<div class="section-header"><h3>Monthly Operating Costs</h3></div>{% endif %}
<div data-show-opex="indoor-rent">{{ slider('rentSqm', t.sl_rent_sqm, 0, 25, 0.5, s.rentSqm, 'Monthly rent per square meter for indoor hall space. Varies by location, building quality, and lease terms.') }}</div> <div data-show-opex="indoor-rent">{{ slider('rentSqm', t.sl_rent_sqm, 0, 25, 0.5, s.rentSqm, t.tip_rent_sqm) }}</div>
<div data-show-opex="outdoor-rent">{{ slider('outdoorRent', t.sl_outdoor_rent, 0, 5000, 50, s.outdoorRent, 'Monthly land rent for outdoor court area. Much cheaper than indoor space but weather-dependent.') }}</div> <div data-show-opex="outdoor-rent">{{ slider('outdoorRent', t.sl_outdoor_rent, 0, 5000, 50, s.outdoorRent, t.tip_outdoor_rent) }}</div>
<div data-show-opex="buy">{{ slider('propertyTax', t.sl_property_tax, 0, 2000, 25, s.propertyTax, 'Monthly property tax when owning the building/land. Grundsteuer in Germany, varies by municipality and property value.') }}</div> <div data-show-opex="buy">{{ slider('propertyTax', t.sl_property_tax, 0, 2000, 25, s.propertyTax, t.tip_property_tax) }}</div>
{{ slider('insurance', t.sl_insurance, 0, 2000, 25, s.insurance, 'Monthly insurance premium covering liability, property damage, business interruption, and equipment.') }} {{ slider('insurance', t.sl_insurance, 0, 2000, 25, s.insurance, t.tip_insurance) }}
{{ slider('electricity', t.sl_electricity, 0, 5000, 25, s.electricity, 'Monthly electricity cost. Major driver for indoor venues due to court lighting, HVAC, and equipment.') }} {{ slider('electricity', t.sl_electricity, 0, 5000, 25, s.electricity, t.tip_electricity) }}
<div data-show-opex="indoor"> <div data-show-opex="indoor">
{{ slider('heating', t.sl_heating, 0, 3000, 25, s.heating, 'Monthly heating cost for indoor venue. Significant in northern European climates during winter months.') }} {{ slider('heating', t.sl_heating, 0, 3000, 25, s.heating, t.tip_heating) }}
{{ slider('water', t.sl_water, 0, 1000, 25, s.water, 'Monthly water cost for showers, toilets, cleaning, and potentially outdoor court irrigation.') }} {{ slider('water', t.sl_water, 0, 1000, 25, s.water, t.tip_water) }}
</div> </div>
{{ slider('maintenance', t.sl_maintenance, 0, 2000, 25, s.maintenance, 'Monthly court and facility maintenance: glass cleaning, surface repair, net replacement, and equipment upkeep.') }} {{ slider('maintenance', t.sl_maintenance, 0, 2000, 25, s.maintenance, t.tip_maintenance) }}
<div data-show-opex="indoor">{{ slider('cleaning', t.sl_cleaning, 0, 2000, 25, s.cleaning, 'Monthly professional cleaning of courts, changing rooms, common areas, and reception.') }}</div> <div data-show-opex="indoor">{{ slider('cleaning', t.sl_cleaning, 0, 2000, 25, s.cleaning, t.tip_cleaning) }}</div>
{{ slider('marketing', t.sl_marketing, 0, 5000, 25, s.marketing, 'Monthly spend on marketing, booking platform subscriptions, website, social media, and customer acquisition.') }} {{ slider('marketing', t.sl_marketing, 0, 5000, 25, s.marketing, t.tip_marketing) }}
{{ slider('staff', t.sl_staff, 0, 20000, 100, s.staff, 'Monthly staff costs including wages, social contributions, and benefits. Many venues run lean using automated booking and access systems.') }} {{ slider('staff', t.sl_staff, 0, 20000, 100, s.staff, t.tip_staff) }}
</div> </div>
<div class="mb-section"> <div class="mb-section">
{% if lang == 'de' %}<div class="section-header"><h3>Finanzierung</h3></div> {% if lang == 'de' %}<div class="section-header"><h3>Finanzierung</h3></div>
{% else %}<div class="section-header"><h3>Financing</h3></div>{% endif %} {% else %}<div class="section-header"><h3>Financing</h3></div>{% endif %}
{{ slider('loanPct', t.sl_loan_pct, 0, 100, 1, s.loanPct, 'Percentage of total CAPEX financed by debt. Banks typically offer 7085%. Higher with personal guarantees or subsidies.') }} {{ slider('loanPct', t.sl_loan_pct, 0, 100, 1, s.loanPct, t.tip_loan_pct) }}
{{ slider('interestRate', t.sl_interest_rate, 0, 15, 0.1, s.interestRate, 'Annual interest rate on the loan. Depends on creditworthiness, collateral, market conditions, and bank relationship.') }} {{ slider('interestRate', t.sl_interest_rate, 0, 15, 0.1, s.interestRate, t.tip_interest_rate) }}
{{ slider('loanTerm', t.sl_loan_term, 0, 30, 1, s.loanTerm, 'Loan repayment period in years. Longer terms mean lower monthly payments but more total interest paid.') }} {{ slider('loanTerm', t.sl_loan_term, 0, 30, 1, s.loanTerm, t.tip_loan_term) }}
{{ slider('constructionMonths', t.sl_construction_months, 0, 24, 1, s.constructionMonths, 'Months of construction/setup before opening. Costs accrue (loan interest, rent) but no revenue is generated.') }} {{ slider('constructionMonths', t.sl_construction_months, 0, 24, 1, s.constructionMonths, t.tip_construction_months) }}
</div> </div>
<div class="mb-section"> <div class="mb-section">
{% if lang == 'de' %}<div class="section-header"><h3>Exit-Annahmen</h3></div> {% if lang == 'de' %}<div class="section-header"><h3>Exit-Annahmen</h3></div>
{% else %}<div class="section-header"><h3>Exit Assumptions</h3></div>{% endif %} {% else %}<div class="section-header"><h3>Exit Assumptions</h3></div>{% endif %}
{{ slider('holdYears', t.sl_hold_years, 1, 20, 1, s.holdYears, 'Investment holding period before exit/sale. Typical for PE/investors: 57 years. Owner-operators may hold indefinitely.') }} {{ slider('holdYears', t.sl_hold_years, 1, 20, 1, s.holdYears, t.tip_hold_years) }}
{{ slider('exitMultiple', t.sl_exit_multiple, 0, 20, 0.5, s.exitMultiple, 'EBITDA multiple used to value the business at exit. Reflects market demand, brand strength, and growth potential. Small business: 46x, strong brand: 68x.') }} {{ slider('exitMultiple', t.sl_exit_multiple, 0, 20, 0.5, s.exitMultiple, t.tip_exit_multiple) }}
{{ slider('annualRevGrowth', t.sl_annual_rev_growth, 0, 15, 0.5, s.annualRevGrowth, 'Expected annual revenue growth rate after the initial 12-month ramp-up period. Driven by price increases and utilization gains.') }} {{ slider('annualRevGrowth', t.sl_annual_rev_growth, 0, 15, 0.5, s.annualRevGrowth, t.tip_annual_rev_growth) }}
</div> </div>
</div> </div>

View File

@@ -970,8 +970,9 @@
} }
.wizard-preview { .wizard-preview {
display: flex; display: flex;
gap: 1rem; flex-wrap: wrap;
padding: 12px 16px; gap: 0 1rem;
padding: 8px 16px 12px;
background: var(--bg-2); background: var(--bg-2);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 16px 16px 0 0; border-radius: 16px 16px 0 0;
@@ -980,6 +981,15 @@
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
} }
.wiz-preview__caption {
flex-basis: 100%;
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--txt-3);
font-weight: 600;
margin-bottom: 4px;
}
.wiz-preview__item { .wiz-preview__item {
flex: 1; flex: 1;
text-align: center; text-align: center;

View File

@@ -100,9 +100,9 @@ function showWizStep(n) {
const isLast = n >= steps.length; const isLast = n >= steps.length;
const nav = document.getElementById('wizNav'); const nav = document.getElementById('wizNav');
nav.innerHTML = nav.innerHTML =
(n > 1 ? `<button type="button" class="wiz-btn--prev" onclick="showWizStep(${n - 1})">${prev}</button>` : '<div></div>') + (n > 1 ? `<button type="button" class="wiz-btn--back" onclick="showWizStep(${n - 1})">${prev}</button>` : '<div></div>') +
(isLast (isLast
? `<button type="button" class="wiz-btn--calc" onclick="document.querySelector('.tab-btn[data-tab=\\'capex\\']').click()">${calc}</button>` ? `<button type="button" class="wiz-btn--next" onclick="document.querySelector('.tab-btn[data-tab=\\'capex\\']').click()">${calc}</button>`
: `<button type="button" class="wiz-btn--next" onclick="showWizStep(${n + 1})">${next}</button>`); : `<button type="button" class="wiz-btn--next" onclick="showWizStep(${n + 1})">${next}</button>`);
} }

View File

@@ -0,0 +1,112 @@
"""
Tests for i18n tip key completeness.
Regression for: "i" tooltip spans showing untranslated English text in the
German planner because tip_* keys were missing from the DE translation dict.
Also covers wiz_summary_label used in the wizard preview summary bar.
"""
import pytest
from padelnomics.i18n import get_translations
EN = get_translations("en")
DE = get_translations("de")
# Every key that a slider tip or inline tooltip now references via t.tip_*
TIP_KEYS = [
"wiz_summary_label",
"tip_permits_compliance",
"tip_dbl_courts",
"tip_sgl_courts",
"tip_sqm_dbl_hall",
"tip_sqm_sgl_hall",
"tip_sqm_dbl_outdoor",
"tip_sqm_sgl_outdoor",
"tip_rate_peak",
"tip_rate_offpeak",
"tip_rate_single",
"tip_peak_pct",
"tip_booking_fee",
"tip_util_target",
"tip_hours_per_day",
"tip_days_indoor",
"tip_days_outdoor",
"tip_membership_rev",
"tip_fb_rev",
"tip_coaching_rev",
"tip_retail_rev",
"tip_glass_type",
"tip_court_cost_dbl",
"tip_court_cost_sgl",
"tip_hall_cost_sqm",
"tip_foundation_sqm",
"tip_land_price_sqm",
"tip_hvac",
"tip_electrical",
"tip_sanitary",
"tip_fire_protection",
"tip_planning",
"tip_floor_prep",
"tip_hvac_upgrade",
"tip_lighting_upgrade",
"tip_fitout",
"tip_outdoor_foundation",
"tip_outdoor_site_work",
"tip_outdoor_lighting",
"tip_outdoor_fencing",
"tip_working_capital",
"tip_contingency",
"tip_budget_target",
"tip_rent_sqm",
"tip_outdoor_rent",
"tip_property_tax",
"tip_insurance",
"tip_electricity",
"tip_heating",
"tip_water",
"tip_maintenance",
"tip_cleaning",
"tip_marketing",
"tip_staff",
"tip_loan_pct",
"tip_interest_rate",
"tip_loan_term",
"tip_construction_months",
"tip_hold_years",
"tip_exit_multiple",
"tip_annual_rev_growth",
]
@pytest.mark.parametrize("key", TIP_KEYS)
def test_key_present_in_english(key):
assert key in EN, f"Missing EN translation key: {key}"
@pytest.mark.parametrize("key", TIP_KEYS)
def test_key_present_in_german(key):
assert key in DE, f"Missing DE translation key: {key}"
@pytest.mark.parametrize("key", TIP_KEYS)
def test_english_value_non_empty(key):
assert EN[key], f"Empty EN value for: {key}"
@pytest.mark.parametrize("key", TIP_KEYS)
def test_german_value_non_empty(key):
assert DE[key], f"Empty DE value for: {key}"
@pytest.mark.parametrize("key", TIP_KEYS)
def test_german_differs_from_english(key):
"""Every tooltip must be translated, not just copied from English."""
assert EN[key] != DE[key], f"DE translation identical to EN for: {key}"
def test_wiz_summary_label_english_value():
assert EN["wiz_summary_label"] == "Live Summary"
def test_wiz_summary_label_german_value():
assert DE["wiz_summary_label"] == "Aktuelle Werte"

View File

@@ -0,0 +1,251 @@
"""
Tests for augment_d() chart output.
Regression for: charts not rendering because augment_d() was producing raw
data dicts instead of full Chart.js 4.x config objects. Each chart must have
the shape {type, data: {labels, datasets: [{data, ...}]}, options} so that
initCharts() can pass it directly to new Chart(canvas, config).
"""
import json
import pytest
from padelnomics.planner.calculator import calc, validate_state
from padelnomics.planner.routes import augment_d
def make_result(lang="en", **state_overrides):
"""Return (d, s) with augment_d applied."""
s = validate_state(state_overrides)
d = calc(s)
augment_d(d, s, lang)
return d, s
INDOOR_CHART_KEYS = ["capex_chart", "ramp_chart", "pl_chart", "cf_chart", "cum_chart", "dscr_chart"]
ALL_CHART_KEYS = INDOOR_CHART_KEYS + ["season_chart"]
# ════════════════════════════════════════════════════════════
# Structure: every chart must be a valid Chart.js config
# ════════════════════════════════════════════════════════════
class TestChartStructure:
"""Each chart must have the shape Chart.js 4.x expects: {type, data, options}."""
def test_all_indoor_charts_present(self):
d, _ = make_result()
for key in INDOOR_CHART_KEYS:
assert key in d, f"Missing chart key: {key}"
def test_season_chart_present_for_outdoor(self):
d, _ = make_result(venue="outdoor")
assert "season_chart" in d
@pytest.mark.parametrize("key", INDOOR_CHART_KEYS)
def test_chart_has_type_string(self, key):
d, _ = make_result()
assert "type" in d[key], f"{key} missing 'type'"
assert isinstance(d[key]["type"], str)
@pytest.mark.parametrize("key", INDOOR_CHART_KEYS)
def test_chart_has_data_with_datasets_list(self, key):
d, _ = make_result()
chart = d[key]
assert "data" in chart, f"{key} missing 'data'"
assert "datasets" in chart["data"], f"{key} missing 'data.datasets'"
assert isinstance(chart["data"]["datasets"], list)
assert len(chart["data"]["datasets"]) >= 1
@pytest.mark.parametrize("key", INDOOR_CHART_KEYS)
def test_chart_has_responsive_options(self, key):
d, _ = make_result()
opts = d[key].get("options", {})
assert opts.get("responsive") is True, f"{key} options.responsive must be True"
assert opts.get("maintainAspectRatio") is False, f"{key} options.maintainAspectRatio must be False"
@pytest.mark.parametrize("key", INDOOR_CHART_KEYS)
def test_chart_is_json_serializable(self, key):
"""Charts are embedded as JSON in templates — they must serialise cleanly."""
d, _ = make_result()
json.dumps(d[key]) # must not raise
@pytest.mark.parametrize("key", INDOOR_CHART_KEYS)
def test_datasets_each_have_data_array(self, key):
d, _ = make_result()
for ds in d[key]["data"]["datasets"]:
assert "data" in ds, f"{key} dataset missing 'data'"
assert isinstance(ds["data"], list)
# ════════════════════════════════════════════════════════════
# Per-chart specifics
# ════════════════════════════════════════════════════════════
class TestCapexChart:
def test_type_is_doughnut(self):
d, _ = make_result()
assert d["capex_chart"]["type"] == "doughnut"
def test_labels_data_colors_same_length(self):
d, _ = make_result()
chart = d["capex_chart"]
ds = chart["data"]["datasets"][0]
assert len(chart["data"]["labels"]) == len(ds["data"]) == len(ds["backgroundColor"])
def test_only_nonzero_items_included(self):
"""Zero-amount CAPEX items must be excluded from the chart."""
d, _ = make_result()
for val in d["capex_chart"]["data"]["datasets"][0]["data"]:
assert val > 0, "Chart must only contain items with amount > 0"
def test_cutout_set(self):
d, _ = make_result()
assert d["capex_chart"]["options"].get("cutout") == "60%"
def test_legend_position_right(self):
d, _ = make_result()
legend = d["capex_chart"]["options"]["plugins"]["legend"]
assert legend["position"] == "right"
class TestRampChart:
def test_type_is_line(self):
d, _ = make_result()
assert d["ramp_chart"]["type"] == "line"
def test_two_datasets(self):
d, _ = make_result()
assert len(d["ramp_chart"]["data"]["datasets"]) == 2
def test_24_data_points(self):
d, _ = make_result()
for ds in d["ramp_chart"]["data"]["datasets"]:
assert len(ds["data"]) == 24
def test_labels_translated(self):
d_en, _ = make_result(lang="en")
d_de, _ = make_result(lang="de")
en_label = d_en["ramp_chart"]["data"]["datasets"][0]["label"]
de_label = d_de["ramp_chart"]["data"]["datasets"][0]["label"]
assert en_label != de_label, "Ramp chart labels must be translated"
class TestPLChart:
def test_type_is_bar(self):
d, _ = make_result()
assert d["pl_chart"]["type"] == "bar"
def test_horizontal_axis(self):
"""P&L chart must be horizontal (indexAxis='y')."""
d, _ = make_result()
assert d["pl_chart"]["options"].get("indexAxis") == "y"
def test_five_labels_and_values(self):
d, _ = make_result()
assert len(d["pl_chart"]["data"]["labels"]) == 5
assert len(d["pl_chart"]["data"]["datasets"][0]["data"]) == 5
def test_colors_reflect_sign(self):
"""Positive values → green, negative → red."""
d, _ = make_result()
ds = d["pl_chart"]["data"]["datasets"][0]
for val, color in zip(ds["data"], ds["backgroundColor"]):
if val >= 0:
assert "22,163,74" in color, f"Expected green for positive {val}"
else:
assert "239,68,68" in color, f"Expected red for negative {val}"
class TestCFChart:
def test_type_is_bar(self):
d, _ = make_result()
assert d["cf_chart"]["type"] == "bar"
def test_60_data_points(self):
d, _ = make_result()
assert len(d["cf_chart"]["data"]["datasets"][0]["data"]) == 60
def test_colors_reflect_sign(self):
d, _ = make_result()
ds = d["cf_chart"]["data"]["datasets"][0]
for val, color in zip(ds["data"], ds["backgroundColor"]):
if val >= 0:
assert "22,163,74" in color
else:
assert "239,68,68" in color
class TestCumChart:
def test_type_is_line(self):
d, _ = make_result()
assert d["cum_chart"]["type"] == "line"
def test_60_data_points(self):
d, _ = make_result()
assert len(d["cum_chart"]["data"]["datasets"][0]["data"]) == 60
def test_fill_enabled(self):
d, _ = make_result()
assert d["cum_chart"]["data"]["datasets"][0].get("fill") is True
class TestDSCRChart:
def test_type_is_bar(self):
d, _ = make_result()
assert d["dscr_chart"]["type"] == "bar"
def test_five_entries(self):
d, _ = make_result()
assert len(d["dscr_chart"]["data"]["datasets"][0]["data"]) == 5
def test_colors_reflect_1_2_threshold(self):
d, _ = make_result()
ds = d["dscr_chart"]["data"]["datasets"][0]
for val, color in zip(ds["data"], ds["backgroundColor"]):
if val >= 1.2:
assert "22,163,74" in color, f"Expected green for DSCR {val} >= 1.2"
else:
assert "239,68,68" in color, f"Expected red for DSCR {val} < 1.2"
def test_values_capped_at_10(self):
"""DSCR values above 10 (no-debt scenarios) must be capped."""
d, _ = make_result(loanPct=0)
for val in d["dscr_chart"]["data"]["datasets"][0]["data"]:
assert val <= 10
class TestSeasonChart:
def test_type_is_bar(self):
d, _ = make_result(venue="outdoor")
assert d["season_chart"]["type"] == "bar"
def test_12_points(self):
d, _ = make_result(venue="outdoor")
assert len(d["season_chart"]["data"]["datasets"][0]["data"]) == 12
assert len(d["season_chart"]["data"]["labels"]) == 12
def test_values_are_percentages(self):
"""Season values are fractions (01.5) multiplied by 100."""
d, s = make_result(venue="outdoor")
chart_vals = d["season_chart"]["data"]["datasets"][0]["data"]
expected = [v * 100 for v in s["season"]]
assert chart_vals == expected
def test_labels_translated(self):
d_en, _ = make_result(lang="en", venue="outdoor")
d_de, _ = make_result(lang="de", venue="outdoor")
assert d_en["season_chart"]["data"]["labels"] != d_de["season_chart"]["data"]["labels"]
# ════════════════════════════════════════════════════════════
# All combos: charts always valid
# ════════════════════════════════════════════════════════════
@pytest.mark.parametrize("venue,own", [
("indoor", "rent"), ("indoor", "buy"), ("outdoor", "rent"), ("outdoor", "buy")
])
def test_all_charts_json_serializable_for_all_combos(venue, own):
d, _ = make_result(venue=venue, own=own)
for key in INDOOR_CHART_KEYS:
json.dumps(d[key])

View File

@@ -0,0 +1,125 @@
"""
Tests for planner route responses.
Regression for:
1. OOB swap for #wizPreview stripping class="wizard-preview", causing the
flex layout to break and CAPEX/CF/IRR values to stack vertically.
Fix: calculate_response.html OOB element must include class="wizard-preview".
2. Charts: /calculate must embed valid Chart.js JSON (not raw data dicts).
"""
import json
import pytest
class TestCalculateEndpoint:
async def test_returns_200(self, client):
resp = await client.post("/en/planner/calculate", form={"activeTab": "capex"})
assert resp.status_code == 200
async def test_returns_html(self, client):
resp = await client.post("/en/planner/calculate", form={"activeTab": "capex"})
assert resp.content_type.startswith("text/html")
async def test_all_tabs_render(self, client):
for tab in ("capex", "operating", "cashflow", "returns", "metrics"):
resp = await client.post(
"/en/planner/calculate", form={"activeTab": tab}
)
assert resp.status_code == 200, f"Tab {tab} returned {resp.status_code}"
async def test_german_endpoint_works(self, client):
resp = await client.post("/de/planner/calculate", form={"activeTab": "capex"})
assert resp.status_code == 200
class TestWizPreviewOOBSwap:
"""
Regression: HTMX outerHTML OOB swap replaces the entire #wizPreview element.
If the response element lacks class="wizard-preview", the flex layout is lost
and the three preview values stack vertically on every recalculation.
"""
async def test_oob_element_has_wizard_preview_class(self, client):
resp = await client.post("/en/planner/calculate", form={"activeTab": "capex"})
body = (await resp.get_data()).decode()
# The OOB swap element must carry class="wizard-preview" so the flex
# box layout survives the outerHTML replacement.
assert 'class="wizard-preview"' in body, (
"OOB #wizPreview element must include class='wizard-preview' "
"to preserve flex layout after HTMX outerHTML swap"
)
async def test_oob_element_has_correct_id(self, client):
resp = await client.post("/en/planner/calculate", form={"activeTab": "capex"})
body = (await resp.get_data()).decode()
assert 'id="wizPreview"' in body
async def test_oob_element_has_hx_swap_oob(self, client):
resp = await client.post("/en/planner/calculate", form={"activeTab": "capex"})
body = (await resp.get_data()).decode()
assert 'hx-swap-oob="true"' in body
class TestChartJSONInResponse:
"""
Regression: augment_d() was embedding raw data dicts instead of full
Chart.js configs. initCharts() passes the embedded JSON directly to
new Chart(canvas, config) — which requires {type, data, options}.
"""
async def _get_chart_json(self, client, chart_id: str, tab: str) -> dict:
resp = await client.post(
"/en/planner/calculate", form={"activeTab": tab}
)
body = (await resp.get_data()).decode()
# Charts are embedded as: <script type="application/json" id="chartX-data">...</script>
marker = f'id="{chart_id}-data">'
start = body.find(marker)
assert start != -1, f"Chart script tag '{chart_id}-data' not found in response"
start += len(marker)
end = body.find("</script>", start)
return json.loads(body[start:end])
async def test_capex_chart_is_valid_chartjs_config(self, client):
config = await self._get_chart_json(client, "chartCapex", "capex")
assert config["type"] == "doughnut"
assert "datasets" in config["data"]
assert "options" in config
async def test_cf_chart_is_valid_chartjs_config(self, client):
config = await self._get_chart_json(client, "chartCF", "cashflow")
assert config["type"] == "bar"
assert "datasets" in config["data"]
assert config["options"]["responsive"] is True
assert config["options"]["maintainAspectRatio"] is False
async def test_dscr_chart_is_valid_chartjs_config(self, client):
config = await self._get_chart_json(client, "chartDSCR", "returns")
assert config["type"] == "bar"
assert len(config["data"]["datasets"][0]["data"]) == 5
async def test_ramp_chart_is_valid_chartjs_config(self, client):
config = await self._get_chart_json(client, "chartRevRamp", "operating")
assert config["type"] == "line"
assert len(config["data"]["datasets"]) == 2
async def test_pl_chart_is_horizontal_bar(self, client):
config = await self._get_chart_json(client, "chartPL", "operating")
assert config["type"] == "bar"
assert config["options"]["indexAxis"] == "y"
class TestWizSummaryLabel:
"""The wizard preview must include the summary caption."""
async def test_summary_caption_in_response(self, client):
resp = await client.post("/en/planner/calculate", form={"activeTab": "capex"})
body = (await resp.get_data()).decode()
assert "Live Summary" in body
async def test_german_summary_caption_in_response(self, client):
resp = await client.post("/de/planner/calculate", form={"activeTab": "capex"})
body = (await resp.get_data()).decode()
assert "Aktuelle Werte" in body