diff --git a/padelnomics/src/padelnomics/i18n.py b/padelnomics/src/padelnomics/i18n.py index cf10908..062d854 100644 --- a/padelnomics/src/padelnomics/i18n.py +++ b/padelnomics/src/padelnomics/i18n.py @@ -533,6 +533,68 @@ _TRANSLATIONS: dict[str, dict[str, str]] = { "sl_hold_years": "Holding Period", "sl_exit_multiple": "Exit EBITDA Multiple", "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_my_scenarios": "My Scenarios", "btn_reset": "Reset to Defaults", @@ -1157,6 +1219,68 @@ _TRANSLATIONS: dict[str, dict[str, str]] = { "sl_hold_years": "Haltedauer", "sl_exit_multiple": "Exit-EBITDA-Multiplikator", "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_my_scenarios": "Meine Szenarien", "btn_reset": "Zur\u00fccksetzen", diff --git a/padelnomics/src/padelnomics/planner/routes.py b/padelnomics/src/padelnomics/planner/routes.py index 0e67fdd..f662430 100644 --- a/padelnomics/src/padelnomics/planner/routes.py +++ b/padelnomics/src/padelnomics/planner/routes.py @@ -93,57 +93,170 @@ def augment_d(d: dict, s: dict, lang: str) -> None: 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"] = { - "labels": [i["name"] for i in d["capexItems"] if i["amount"] > 0], - "data": [i["amount"] for i in d["capexItems"] if i["amount"] > 0], + "type": "doughnut", + "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] d["ramp_chart"] = { - "months": [f"M{m['m']}" for m in ramp_data], - "revenue": [round(m["totalRev"]) for m in ramp_data], - "opex_debt": [round(abs(m["opex"]) + abs(m["loan"])) for m in ramp_data], - "label_revenue": t["chart_revenue"], - "label_opex_debt": t["chart_opex_debt"], + "type": "line", + "data": { + "labels": [f"M{m['m']}" for m in ramp_data], + "datasets": [ + { + "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}}}}, + }, } + _pl_values = [ + round(d["courtRevMonth"]), + -round(d["feeDeduction"]), + round(d["racketRev"] + d["ballMargin"] + d["membershipRev"] + + d["fbRev"] + d["coachingRev"] + d["retailRev"]), + -round(d["opex"]), + -round(d["monthlyPayment"]), + ] d["pl_chart"] = { - "labels": [ - t["chart_court_rev"], t["chart_fees"], t["chart_ancillary"], - t["chart_opex"], t["chart_debt"], - ], - "values": [ - round(d["courtRevMonth"]), - -round(d["feeDeduction"]), - round(d["racketRev"] + d["ballMargin"] + d["membershipRev"] - + d["fbRev"] + d["coachingRev"] + d["retailRev"]), - -round(d["opex"]), - -round(d["monthlyPayment"]), - ], + "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"] = { - "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"]], - "pos": [m["ncf"] >= 0 for m in d["months"]], + "type": "bar", + "data": { + "labels": [f"Y{m['yr']}" if m["m"] % 12 == 1 else "" for m in d["months"]], + "datasets": [{ + "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"] = { - "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"]], + "type": "line", + "data": { + "labels": [f"M{m['m']}" if m["m"] % 6 == 1 else "" 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"] = { - "labels": [f"Y{x['year']}" for x in d["dscr"]], - "values": [min(x["dscr"], 10) for x in d["dscr"]], - "pos": [x["dscr"] >= 1.2 for x in d["dscr"]], + "type": "bar", + "data": { + "labels": [f"Y{x['year']}" for x in d["dscr"]], + "datasets": [{ + "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"] = { - "labels": [t[f"month_{k}"] for k in month_keys], - "values": [v * 100 for v in s["season"]], - "pos": [v > 0 for v in s["season"]], + "type": "bar", + "data": { + "labels": [t[f"month_{k}"] for k in month_keys], + "datasets": [{ + "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) diff --git a/padelnomics/src/padelnomics/planner/templates/partials/calculate_response.html b/padelnomics/src/padelnomics/planner/templates/partials/calculate_response.html index 0028cdc..0d4742e 100644 --- a/padelnomics/src/padelnomics/planner/templates/partials/calculate_response.html +++ b/padelnomics/src/padelnomics/planner/templates/partials/calculate_response.html @@ -5,4 +5,4 @@
{% include "partials/court_summary.html" %}
-
{% include "partials/wizard_preview.html" %}
+
{% include "partials/wizard_preview.html" %}
diff --git a/padelnomics/src/padelnomics/planner/templates/partials/wizard_preview.html b/padelnomics/src/padelnomics/planner/templates/partials/wizard_preview.html index 9b4fdec..39bd33d 100644 --- a/padelnomics/src/padelnomics/planner/templates/partials/wizard_preview.html +++ b/padelnomics/src/padelnomics/planner/templates/partials/wizard_preview.html @@ -1,4 +1,5 @@ {% set cf = d.ebitdaMonth - d.monthlyPayment %} +
{{ t.wiz_summary_label }}
{{ t.wiz_capex }}
{{ d.capex | fmt_k }}
diff --git a/padelnomics/src/padelnomics/planner/templates/planner.html b/padelnomics/src/padelnomics/planner/templates/planner.html index f7689a6..ed05f72 100644 --- a/padelnomics/src/padelnomics/planner/templates/planner.html +++ b/padelnomics/src/padelnomics/planner/templates/planner.html @@ -159,7 +159,7 @@ {% endfor %}
- {{ 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) }}
@@ -168,8 +168,8 @@ {% else %}

Court Configuration

{% 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('sglCourts', t.sl_sgl_courts, 0, 30, 1, s.sglCourts, 'Narrow court for 2 players. Popular for coaching, training, and competitive play.') }} + {{ 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, t.tip_sgl_courts) }} {% if lang == 'de' %}

Platzbedarf

@@ -177,12 +177,12 @@

Space Requirements

{% endif %}
- {{ 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: 300–350m².') }} - {{ 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: 200–250m².') }} + {{ 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, t.tip_sqm_sgl_hall) }}
- {{ 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: 280–320m².') }} - {{ 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: 180–220m².') }} + {{ 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, t.tip_sqm_sgl_outdoor) }}
{% include "partials/court_summary.html" %}
@@ -204,11 +204,11 @@ {% else %}

Pricing

Per court per hour
{% endif %} - {{ slider('ratePeak', t.sl_rate_peak, 0, 150, 1, s.ratePeak, 'Price per court per hour during peak times (evenings 17:00–22:00 and weekends). Highest demand period.') }} - {{ slider('rateOffPeak', t.sl_rate_offpeak, 0, 150, 1, s.rateOffPeak, 'Price per court per hour during off-peak (weekday mornings/afternoons). Typically 30–40% lower than peak.') }} - {{ 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('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('bookingFee', t.sl_booking_fee, 0, 30, 1, s.bookingFee, 'Commission taken by booking platforms like Playtomic or Matchi. Typically 5–15% of court revenue.') }} + {{ 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, t.tip_rate_offpeak) }} + {{ 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, t.tip_peak_pct) }} + {{ slider('bookingFee', t.sl_booking_fee, 0, 30, 1, s.bookingFee, t.tip_booking_fee) }}
@@ -217,15 +217,15 @@ {% else %}

Utilization & Operations

{% endif %} - {{ slider('utilTarget', t.sl_util_target, 0, 100, 1, s.utilTarget, 'Percentage of available court-hours that are actually booked. 35–45% is realistic for new venues, 50%+ is strong.') }} - {{ slider('hoursPerDay', t.sl_hours_per_day, 0, 24, 1, s.hoursPerDay, 'Total operating hours per day. Typical padel venues run 7:00–23:00 (16h). Some extend to 6:00–24:00.') }} - {{ 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('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('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, t.tip_hours_per_day) }} + {{ 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, t.tip_days_outdoor) }}
{{ t.sl_ancillary_header }}
- {{ 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('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('coachingRevPerCourt', t.sl_coaching_rev, 0, 2000, 25, s.coachingRevPerCourt, 'Revenue from coaching sessions, clinics, tournaments, and events allocated per court per month.') }} - {{ 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('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, t.tip_fb_rev) }} + {{ 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, t.tip_retail_rev) }}
@@ -247,7 +247,7 @@ {% endif %}
- +
{{ pill_btn('glassType','standard', t.pill_glass_standard, s.glassType == 'standard') }} {{ pill_btn('glassType','panoramic', t.pill_glass_panoramic, s.glassType == 'panoramic') }} @@ -263,41 +263,41 @@
- {{ 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('courtCostSgl', t.sl_court_cost_sgl, 0, 60000, 1000, s.courtCostSgl, 'Base price of one single padel court. Generally 60–70% of a double court cost.') }} + {{ 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, t.tip_court_cost_sgl) }}
- {{ 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 10–12m clear height.') }} - {{ 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('landPriceSqm', t.sl_land_price_sqm, 0, 500, 5, s.landPriceSqm, 'Land purchase price per m². Rural: €20–60. Suburban: €60–150. Urban: €150–300+. Varies hugely by location.') }} - {{ 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('electrical', t.sl_electrical, 0, 400000, 5000, s.electrical, 'Complete electrical installation: court lighting (LED, 500+ lux), power distribution, panels, and outlets.') }} - {{ slider('sanitary', t.sl_sanitary, 0, 400000, 5000, s.sanitary, 'Changing rooms, showers, toilets, and plumbing. Includes fixtures, tiling, waterproofing, and ventilation.') }} - {{ 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('planning', t.sl_planning, 0, 500000, 5000, s.planning, 'Architectural planning, structural engineering, building permits, zoning applications, and regulatory compliance costs.') }} + {{ 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, t.tip_foundation_sqm) }} + {{ 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, t.tip_hvac) }} + {{ slider('electrical', t.sl_electrical, 0, 400000, 5000, s.electrical, t.tip_electrical) }} + {{ slider('sanitary', t.sl_sanitary, 0, 400000, 5000, s.sanitary, t.tip_sanitary) }} + {{ slider('fireProtection', t.sl_fire, 0, 500000, 5000, s.fireProtection, t.tip_fire_protection) }} + {{ slider('planning', t.sl_planning, 0, 500000, 5000, s.planning, t.tip_planning) }}
- {{ 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('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('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('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('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, t.tip_hvac_upgrade) }} + {{ 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, t.tip_fitout) }}
- {{ 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('outdoorSiteWork', t.sl_outdoor_site_work, 0, 60000, 500, s.outdoorSiteWork, 'Grading, drainage installation, utilities connection, and site preparation for outdoor courts.') }} - {{ 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('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('landPriceSqm', t.sl_land_price_sqm, 0, 500, 5, s.landPriceSqm, 'Land purchase price per m². Varies by location, zoning, and accessibility.') }}
+ {{ 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, t.tip_outdoor_site_work) }} + {{ 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, t.tip_outdoor_fencing) }} +
{{ slider('landPriceSqm', t.sl_land_price_sqm, 0, 500, 5, s.landPriceSqm, t.tip_land_price_sqm) }}
- {{ 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('contingencyPct', t.sl_contingency, 0, 30, 1, s.contingencyPct, 'Percentage buffer on total CAPEX for unexpected costs. 10–15% is standard for construction, 15–20% for complex projects.') }} - {{ 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('workingCapital', t.sl_working_capital, 0, 200000, 1000, s.workingCapital, t.tip_working_capital) }} + {{ slider('contingencyPct', t.sl_contingency, 0, 30, 1, s.contingencyPct, t.tip_contingency) }} + {{ slider('budgetTarget', t.sl_budget_target, 0, 5000000, 10000, s.budgetTarget, t.tip_budget_target) }} @@ -315,41 +315,41 @@ {% if lang == 'de' %}

Monatliche Betriebskosten

{% else %}

Monthly Operating Costs

{% endif %} -
{{ 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.') }}
-
{{ 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.') }}
-
{{ 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.') }}
+
{{ slider('rentSqm', t.sl_rent_sqm, 0, 25, 0.5, s.rentSqm, t.tip_rent_sqm) }}
+
{{ slider('outdoorRent', t.sl_outdoor_rent, 0, 5000, 50, s.outdoorRent, t.tip_outdoor_rent) }}
+
{{ slider('propertyTax', t.sl_property_tax, 0, 2000, 25, s.propertyTax, t.tip_property_tax) }}
- {{ slider('insurance', t.sl_insurance, 0, 2000, 25, s.insurance, 'Monthly insurance premium covering liability, property damage, business interruption, and equipment.') }} - {{ 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('insurance', t.sl_insurance, 0, 2000, 25, s.insurance, t.tip_insurance) }} + {{ slider('electricity', t.sl_electricity, 0, 5000, 25, s.electricity, t.tip_electricity) }}
- {{ 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('water', t.sl_water, 0, 1000, 25, s.water, 'Monthly water cost for showers, toilets, cleaning, and potentially outdoor court irrigation.') }} + {{ slider('heating', t.sl_heating, 0, 3000, 25, s.heating, t.tip_heating) }} + {{ slider('water', t.sl_water, 0, 1000, 25, s.water, t.tip_water) }}
- {{ 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) }} -
{{ slider('cleaning', t.sl_cleaning, 0, 2000, 25, s.cleaning, 'Monthly professional cleaning of courts, changing rooms, common areas, and reception.') }}
+
{{ slider('cleaning', t.sl_cleaning, 0, 2000, 25, s.cleaning, t.tip_cleaning) }}
- {{ slider('marketing', t.sl_marketing, 0, 5000, 25, s.marketing, 'Monthly spend on marketing, booking platform subscriptions, website, social media, and customer acquisition.') }} - {{ 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('marketing', t.sl_marketing, 0, 5000, 25, s.marketing, t.tip_marketing) }} + {{ slider('staff', t.sl_staff, 0, 20000, 100, s.staff, t.tip_staff) }}
{% if lang == 'de' %}

Finanzierung

{% else %}

Financing

{% endif %} - {{ slider('loanPct', t.sl_loan_pct, 0, 100, 1, s.loanPct, 'Percentage of total CAPEX financed by debt. Banks typically offer 70–85%. Higher with personal guarantees or subsidies.') }} - {{ 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('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('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('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, t.tip_interest_rate) }} + {{ 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, t.tip_construction_months) }}
{% if lang == 'de' %}

Exit-Annahmen

{% else %}

Exit Assumptions

{% endif %} - {{ slider('holdYears', t.sl_hold_years, 1, 20, 1, s.holdYears, 'Investment holding period before exit/sale. Typical for PE/investors: 5–7 years. Owner-operators may hold indefinitely.') }} - {{ 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: 4–6x, strong brand: 6–8x.') }} - {{ 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('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, t.tip_exit_multiple) }} + {{ slider('annualRevGrowth', t.sl_annual_rev_growth, 0, 15, 0.5, s.annualRevGrowth, t.tip_annual_rev_growth) }}
diff --git a/padelnomics/src/padelnomics/static/css/planner.css b/padelnomics/src/padelnomics/static/css/planner.css index 852a784..490358b 100644 --- a/padelnomics/src/padelnomics/static/css/planner.css +++ b/padelnomics/src/padelnomics/static/css/planner.css @@ -970,8 +970,9 @@ } .wizard-preview { display: flex; - gap: 1rem; - padding: 12px 16px; + flex-wrap: wrap; + gap: 0 1rem; + padding: 8px 16px 12px; background: var(--bg-2); border: 1px solid var(--border); border-radius: 16px 16px 0 0; @@ -980,6 +981,15 @@ margin-left: 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 { flex: 1; text-align: center; diff --git a/padelnomics/src/padelnomics/static/js/planner.js b/padelnomics/src/padelnomics/static/js/planner.js index cafc718..1d8942a 100644 --- a/padelnomics/src/padelnomics/static/js/planner.js +++ b/padelnomics/src/padelnomics/static/js/planner.js @@ -100,9 +100,9 @@ function showWizStep(n) { const isLast = n >= steps.length; const nav = document.getElementById('wizNav'); nav.innerHTML = - (n > 1 ? `` : '
') + + (n > 1 ? `` : '
') + (isLast - ? `` + ? `` : ``); } diff --git a/padelnomics/tests/test_i18n_tips.py b/padelnomics/tests/test_i18n_tips.py new file mode 100644 index 0000000..feb9017 --- /dev/null +++ b/padelnomics/tests/test_i18n_tips.py @@ -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" diff --git a/padelnomics/tests/test_planner_charts.py b/padelnomics/tests/test_planner_charts.py new file mode 100644 index 0000000..d59e2ea --- /dev/null +++ b/padelnomics/tests/test_planner_charts.py @@ -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 (0–1.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]) diff --git a/padelnomics/tests/test_planner_routes.py b/padelnomics/tests/test_planner_routes.py new file mode 100644 index 0000000..0a0b257 --- /dev/null +++ b/padelnomics/tests/test_planner_routes.py @@ -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: + 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("", 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