From 7c710ada6b7ecc73f36255edf0d875e4756be6c2 Mon Sep 17 00:00:00 2001 From: Deeman Date: Fri, 20 Feb 2026 17:12:28 +0100 Subject: [PATCH] =?UTF-8?q?refactor(planner):=20HTMX=20server-render=20ref?= =?UTF-8?q?actor=20=E2=80=94=20eliminate=20JS=20SPA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the 847-line client-side planner with an HTMX architecture: - All tab content (CAPEX, Operating, Cash Flow, Returns, Metrics) rendered server-side as Jinja2 partials; slider changes POST to /planner/calculate which returns HTML; HTMX swaps into #tab-content - Merge _PLANNER_TRANSLATIONS into _TRANSLATIONS; delete get_planner_translations() and window.__PADELNOMICS_LOCALE__; all strings now {{ t.key }} in templates - New form_to_state() and augment_d() helpers in routes.py; calculate endpoint returns HTML instead of JSON; OOB swaps update header tag + wizard preview - Add 5 Jinja2 filters: fmt_currency, fmt_k, fmt_pct, fmt_x, fmt_n - Rewrite planner.js to ~200 lines: chart init on htmx:afterSettle, slider sync, toggle management, wizard nav, scenario save/load, reset to defaults - Add 7 new template partials: tab_capex, tab_operating, tab_cashflow, tab_returns, tab_metrics, calculate_response, court_summary, wizard_preview - Update test_phase0 to match new HTML-returning /calculate endpoint Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 4 + padelnomics/src/padelnomics/app.py | 39 + padelnomics/src/padelnomics/i18n.py | 472 ++++---- padelnomics/src/padelnomics/planner/routes.py | 181 ++- .../partials/calculate_response.html | 8 + .../templates/partials/court_summary.html | 15 + .../planner/templates/partials/tab_capex.html | 49 + .../templates/partials/tab_cashflow.html | 69 ++ .../templates/partials/tab_metrics.html | 159 +++ .../templates/partials/tab_operating.html | 95 ++ .../templates/partials/tab_returns.html | 102 ++ .../templates/partials/wizard_preview.html | 13 + .../planner/templates/planner.html | 553 +++++---- .../src/padelnomics/static/js/planner.js | 1005 +++-------------- padelnomics/tests/test_phase0.py | 9 +- 15 files changed, 1446 insertions(+), 1327 deletions(-) create mode 100644 padelnomics/src/padelnomics/planner/templates/partials/calculate_response.html create mode 100644 padelnomics/src/padelnomics/planner/templates/partials/court_summary.html create mode 100644 padelnomics/src/padelnomics/planner/templates/partials/tab_capex.html create mode 100644 padelnomics/src/padelnomics/planner/templates/partials/tab_cashflow.html create mode 100644 padelnomics/src/padelnomics/planner/templates/partials/tab_metrics.html create mode 100644 padelnomics/src/padelnomics/planner/templates/partials/tab_operating.html create mode 100644 padelnomics/src/padelnomics/planner/templates/partials/tab_returns.html create mode 100644 padelnomics/src/padelnomics/planner/templates/partials/wizard_preview.html diff --git a/CHANGELOG.md b/CHANGELOG.md index a32c4d8..74e5154 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Changed +- planner: full HTMX refactor — replaced 847-line SPA `planner.js` with server-rendered Jinja2 tab partials; planner now uses `hx-post /planner/calculate` + form state; all tab content (CAPEX, Operating, Cash Flow, Returns, Metrics) rendered server-side; Chart.js data embedded as ` diff --git a/padelnomics/src/padelnomics/planner/templates/partials/tab_cashflow.html b/padelnomics/src/padelnomics/planner/templates/partials/tab_cashflow.html new file mode 100644 index 0000000..f5feeac --- /dev/null +++ b/padelnomics/src/padelnomics/planner/templates/partials/tab_cashflow.html @@ -0,0 +1,69 @@ +{% set y1ncf = d.annuals[0].ncf if d.annuals else 0 %} +{% set y3ncf = d.annuals[2].ncf if d.annuals | length >= 3 else 0 %} +{% set payback_label = t.payback_not_reached if d.paybackIdx < 0 else ('Month ' ~ (d.paybackIdx + 1)) %} +{% set payback_sub = '' if d.paybackIdx < 0 else ('~' ~ ((d.paybackIdx + 1) / 12) | round(1) ~ ' years') %} + +
+
+
{{ t.card_y1_ncf }}
+
{{ y1ncf | int | fmt_currency }}
+
+
+
{{ t.card_y3_ncf }}
+
{{ y3ncf | int | fmt_currency }}
+
{{ t.sub_stabilized }}
+
+
+
{{ t.card_payback }}
+
{{ payback_label }}
+
{{ payback_sub }}
+
+
+
{{ t.card_initial_inv }}
+
{{ d.capex | fmt_currency }}
+
+
+ +
+
{% if lang == 'de' %}Monatlicher Netto-Cashflow (60 Monate){% else %}Monthly Net Cash Flow (60 Months){% endif %}
+
+
+ + +
+
{% if lang == 'de' %}Kumulierter Cashflow{% else %}Cumulative Cash Flow{% endif %}
+
+
+ + +
+

{% if lang == 'de' %}Jahresübersicht{% else %}Annual Summary{% endif %}

+ + + + + + + + + + + + + + {% for y in d.annuals %} + {% set dscr = (y.ebitda / y.ds) if y.ds > 0 else 999 %} + {% set util = ((y.booked / y.avail * 100) | int) if y.avail > 0 else 0 %} + + + + + + + + + + {% endfor %} + +
{{ t.th_year }}{{ t.th_revenue }}{{ t.th_ebitda }}{{ t.th_debt_service }}{{ t.th_net_cf }}{{ t.th_dscr }}{{ t.th_util }}
Year {{ y.year }}{{ y.revenue | int | fmt_currency }}{{ y.ebitda | int | fmt_currency }}{{ y.ds | int | fmt_currency }}{{ y.ncf | int | fmt_currency }}{{ '∞' if dscr > 99 else dscr | fmt_x }}{{ util }}%
+
diff --git a/padelnomics/src/padelnomics/planner/templates/partials/tab_metrics.html b/padelnomics/src/padelnomics/planner/templates/partials/tab_metrics.html new file mode 100644 index 0000000..197f702 --- /dev/null +++ b/padelnomics/src/padelnomics/planner/templates/partials/tab_metrics.html @@ -0,0 +1,159 @@ +{% set y3_rev = d.annuals[2].revenue if d.annuals | length >= 3 else 0 %} +{% set y3_dscr = d.dscr[2].dscr if d.dscr | length >= 3 else 0 %} +{% set is_in = s.venue == 'indoor' %} + +
+

{{ t.metrics_return }}

+
+
+
IRR
+
{{ d.irr | fmt_pct if d.irr_ok else 'N/A' }}
+
{{ s.holdYears }}-year
+
+
+
MOIC
+
{{ d.moic | fmt_x }}
+
Total return multiple
+
+
+
Cash-on-Cash
+
{{ d.cashOnCash | fmt_pct }}
+
Y3 NCF ÷ Equity
+
+
+
Payback
+
{{ ((d.paybackIdx + 1) / 12) | round(1) ~ ' yr' if d.paybackIdx >= 0 else 'N/A' }}
+
Months: {{ d.paybackIdx + 1 if d.paybackIdx >= 0 else '∞' }}
+
+
+
+ +
+

{{ t.metrics_revenue }}

+
+
+
RevPAH
+
{{ d.revPAH | fmt_currency }}
+
Revenue per Available Hour
+
+
+
Revenue / m²
+
{{ d.revPerSqm | int | fmt_currency }}
+
Annual net revenue ÷ area
+
+
+
Revenue / Court
+
{{ (y3_rev / [1, d.totalCourts] | max) | int | fmt_currency }}
+
Year 3 annual
+
+
+
Avg Booked Rate
+
{{ d.weightedRate | int | fmt_currency }}
+
Blended peak/off-peak
+
+
+
+ +
+

{{ t.metrics_cost }}

+
+
+
EBITDA Margin
+
{{ d.ebitdaMargin | fmt_pct }}
+
Operating profit margin
+
+
+
OpEx Ratio
+
{{ d.opexRatio | fmt_pct }}
+
OpEx ÷ Revenue
+
+
+
Occupancy Cost
+
{{ d.rentRatio | fmt_pct }}
+
Rent ÷ Revenue
+
+
+
Cost / Booked Hour
+
{{ d.costPerBookedHr | fmt_currency }}
+
All-in cost per hour sold
+
+
+
+ +
+

{{ t.metrics_debt }}

+
+
+
DSCR (Y3)
+
{{ '∞' if y3_dscr > 99 else y3_dscr | fmt_x }}
+
Min 1.2x for banks
+
+
+
LTV
+
{{ d.ltv | fmt_pct }}
+
Loan ÷ Total Investment
+
+
+
Debt Yield
+
{{ d.debtYield | fmt_pct }}
+
Stab. EBITDA ÷ Loan
+
+
+
Monthly Debt Service
+
{{ d.monthlyPayment | int | fmt_currency }}
+
P&I payment
+
+
+
+ +
+

{{ t.metrics_invest }}

+
+
+
CAPEX / Court
+
{{ d.capexPerCourt | int | fmt_currency }}
+
Total investment per court
+
+
+
CAPEX / m²
+
{{ d.capexPerSqm | int | fmt_currency }}
+
Investment per floor area
+
+
+
Yield on Cost
+
{{ d.yieldOnCost | fmt_pct }}
+
Stab. EBITDA ÷ CAPEX
+
+
+
Exit Value
+
{{ d.exitValue | fmt_k }}
+
{{ s.exitMultiple }}x Y3 EBITDA
+
+
+
+ +
+

{{ t.metrics_ops }}

+
+
+
Break-Even Util.
+
{{ d.breakEvenUtil | fmt_pct }}
+
{{ d.breakEvenHrsPerCourt | round(1) }} hrs/court/day
+
+
+
Y3 Utilization
+
{{ d.avgUtil | fmt_pct }}
+
Effective avg utilization
+
+
+
Available Hours/mo
+
{{ d.availHoursMonth | fmt_n }}
+
All courts combined
+
+
+
Operating Months
+
{{ '12' if is_in else '~' ~ s.season | selectattr('>', 0) | list | length }}
+
{{ 'Year-round' if is_in else 'Seasonal' }}
+
+
+
diff --git a/padelnomics/src/padelnomics/planner/templates/partials/tab_operating.html b/padelnomics/src/padelnomics/planner/templates/partials/tab_operating.html new file mode 100644 index 0000000..f46e975 --- /dev/null +++ b/padelnomics/src/padelnomics/planner/templates/partials/tab_operating.html @@ -0,0 +1,95 @@ +{% set margin = (d.ebitdaMonth / d.netRevMonth * 100) | round(1) if d.netRevMonth > 0 else 0 %} +{% set y3_rev = d.annuals[2].revenue if d.annuals | length >= 3 else 0 %} +
+
+
{{ t.card_net_rev_mo }}
+
{{ d.netRevMonth | int | fmt_currency }}
+
{{ t.sub_stabilized }}
+
+
+
{{ t.card_ebitda_mo }}
+
{{ d.ebitdaMonth | int | fmt_currency }}
+
{{ margin }}% margin
+
+
+
{{ t.card_annual_rev }}
+
{{ y3_rev | int | fmt_currency }}
+
{{ t.sub_year3 }}
+
+
+
{{ t.card_rev_pah }}
+
{{ d.revPAH | fmt_currency }}
+
Revenue per available hour
+
+
+ +
+
+
{% if lang == 'de' %}Monatlicher Umsatzaufbau (Anlaufphase){% else %}Monthly Revenue Build-Up (Ramp Period){% endif %}
+
+
+
+
{% if lang == 'de' %}Stabilisierte monatliche GuV{% else %}Stabilized Monthly P&L{% endif %}
+
+
+
+ + + +
+

{% if lang == 'de' %}Einnahmequellen (stabilisierter Monat){% else %}Revenue Streams (Stabilized Month){% endif %}

+ {% set streams = [ + (t.stream_court_rental, d.courtRevMonth - d.feeDeduction), + (t.stream_equipment, d.racketRev + d.ballMargin), + (t.stream_memberships, d.membershipRev), + (t.stream_fb, d.fbRev), + (t.stream_coaching, d.coachingRev), + (t.stream_retail, d.retailRev), + ] %} + {% set total_stream = namespace(v=0) %} + {% for name, val in streams %}{% set total_stream.v = total_stream.v + val %}{% endfor %} + + + + {% for name, val in streams %} + + + + + + {% endfor %} + + + + + + +
{{ t.th_stream }}{{ t.th_monthly }}{{ t.th_share }}
{{ name }}{{ val | int | fmt_currency }}{{ ((val / total_stream.v * 100) | int if total_stream.v > 0 else 0) }}%
{{ t.table_total_net_rev }}{{ total_stream.v | int | fmt_currency }}100%
+
+ +
+

{% if lang == 'de' %}Monatliche OPEX-Aufschlüsselung{% else %}Monthly OpEx Breakdown{% endif %}

+ + + + {% for item in d.opexItems %} + + + + + {% endfor %} + + + + + +
{{ t.th_item }}{{ t.th_monthly }}
{{ item.name }}{% if item.info %} ({{ item.info }}){% endif %}{{ item.amount | fmt_currency }}
{{ t.table_total_opex }}{{ d.opex | fmt_currency }}
+
+ +{% if s.venue == 'outdoor' %} +
+

{% if lang == 'de' %}Outdoor-Saisonalität{% else %}Outdoor Seasonality{% endif %}

+
+
+ +{% endif %} diff --git a/padelnomics/src/padelnomics/planner/templates/partials/tab_returns.html b/padelnomics/src/padelnomics/planner/templates/partials/tab_returns.html new file mode 100644 index 0000000..5983a2e --- /dev/null +++ b/padelnomics/src/padelnomics/planner/templates/partials/tab_returns.html @@ -0,0 +1,102 @@ +
+
+
{{ t.card_irr }}
+
{{ d.irr | fmt_pct if d.irr_ok else 'N/A' }}
+
{{ '✓ Above 20%' if d.irr_ok and d.irr > 0.2 else '✗ Below target' }}
+
+
+
{{ t.card_moic }}
+
{{ d.moic | fmt_x }}
+
{{ '✓ Above 2.0x' if d.moic > 2 else '✗ Below 2.0x' }}
+
+
+
{{ t.card_break_even }}
+
{{ d.breakEvenUtil | fmt_pct }}
+
{{ d.breakEvenHrsPerCourt | round(1) }} hrs/court/day
+
+
+
{{ t.card_cash_on_cash }}
+
{{ d.cashOnCash | fmt_pct }}
+
Year 3 NCF ÷ Equity
+
+
+ +
+
+
{% if lang == 'de' %}Exit-Bewertungs-Wasserfall{% else %}Exit Valuation Waterfall{% endif %}
+
+ {% set wf_rows = [ + (t.wf_stab_ebitda, d.stabEbitda | int | fmt_currency, 'c-head'), + (t.wf_exit_multiple, s.exitMultiple ~ 'x', 'c-head'), + (t.wf_enterprise_value, d.exitValue | int | fmt_currency, 'c-blue'), + (t.wf_remaining_loan, d.remainingLoan | int | fmt_currency, 'c-red'), + (t.wf_net_exit, d.netExit | int | fmt_currency, 'c-green' if d.netExit > 0 else 'c-red'), + (t.wf_cum_cf, (d.totalReturned - d.netExit) | int | fmt_currency, 'c-head'), + (t.wf_total_returns, d.totalReturned | int | fmt_currency, 'c-green' if d.totalReturned > 0 else 'c-red'), + (t.wf_investment, d.capex | fmt_currency, 'c-head'), + (t.wf_moic, d.moic | fmt_x, 'c-green' if d.moic > 2 else 'c-red'), + ] %} + {% for label, value, cls in wf_rows %} +
+ {{ label }} + {{ value }} +
+ {% endfor %} +
+
+
+
{% if lang == 'de' %}DSCR nach Jahr{% else %}DSCR by Year{% endif %}
+
+
+
+ + +
+

{% if lang == 'de' %}Auslastungs-Sensitivität{% else %}Utilization Sensitivity{% endif %}

+ + + + + + + + + + + + {% for row in d.sens_rows %} + + + + + + + + {% endfor %} + +
{{ t.th_utilization }}{{ t.th_monthly_rev }}{{ t.th_monthly_ncf }}{{ t.th_annual_ncf }}{{ t.th_dscr }}
{% if row.is_target %}→ {% endif %}{{ row.util }}%{% if row.is_target %} ←{% endif %}{{ row.rev | fmt_currency }}{{ row.ncf | fmt_currency }}{{ row.annual | fmt_currency }}{{ '∞' if row.dscr > 99 else row.dscr | fmt_x }}
+
+ +
+

{% if lang == 'de' %}Preis-Sensitivität (bei Ziel-Auslastung){% else %}Pricing Sensitivity (at target utilization){% endif %}

+ + + + + + + + + + + {% for row in d.price_rows %} + + + + + + + {% endfor %} + +
{{ t.th_price_change }}{{ t.th_avg_rate }}{{ t.th_monthly_rev }}{{ t.th_monthly_ncf }}
{% if row.is_base %}→ {% endif %}{{ '+' if row.delta >= 0 else '' }}{{ row.delta }}%{% if row.is_base %} (base){% endif %}{{ row.adj_rate | fmt_currency }}{{ row.rev | fmt_currency }}{{ row.ncf | fmt_currency }}
+
diff --git a/padelnomics/src/padelnomics/planner/templates/partials/wizard_preview.html b/padelnomics/src/padelnomics/planner/templates/partials/wizard_preview.html new file mode 100644 index 0000000..9b4fdec --- /dev/null +++ b/padelnomics/src/padelnomics/planner/templates/partials/wizard_preview.html @@ -0,0 +1,13 @@ +{% set cf = d.ebitdaMonth - d.monthlyPayment %} +
+
{{ t.wiz_capex }}
+
{{ d.capex | fmt_k }}
+
+
+
{{ t.wiz_monthly_cf }}
+
{{ cf | fmt_k }}{{ t.wiz_mo }}
+
+
+
{{ t.wiz_irr }} ({{ s.holdYears }}yr)
+
{{ d.irr | fmt_pct if d.irr_ok else 'N/A' }}
+
diff --git a/padelnomics/src/padelnomics/planner/templates/planner.html b/padelnomics/src/padelnomics/planner/templates/planner.html index 5d4b039..f7689a6 100644 --- a/padelnomics/src/padelnomics/planner/templates/planner.html +++ b/padelnomics/src/padelnomics/planner/templates/planner.html @@ -17,11 +17,36 @@ {% endblock %} +{% macro slider(name, label, min, max, step, value, tip='') %} +
+ +
+ + +
+
+{% endmacro %} + +{% macro pill_btn(key, val, label, active, extra_js='') %} + +{% endmacro %} + {% block content %}

{% if lang == 'de' %}Padel-Platz Finanzrechner{% else %}Padel Court Financial Planner{% endif %}

- + {{ s.venue == 'indoor' and t.label_indoor or t.label_outdoor }} · {{ s.own == 'buy' and t.label_build_buy or t.label_rent }} · {{ d.totalCourts }} {{ t.label_courts }} · {{ d.capex | fmt_k }} {% if user %}
@@ -29,240 +54,326 @@ hx-get="{{ url_for('planner.scenario_list') }}" hx-target="#scenario-drawer" hx-swap="innerHTML"> - {{ planner_t.btn_my_scenarios }} ({{ scenario_count }}) + {{ t.btn_my_scenarios }} ({{ scenario_count }}) - +
{% endif %}
- +
- -
-
-
- -
+ +
+ + - -
- {% if lang == 'de' %} -

Dein Padel-Platz

-

Definiere den Typ des Padel-Platzes, den du planst.

-
- -
- -
+ + + + + + + + + {% for val in s.ramp %}{% endfor %} + {% for val in s.season %}{% endfor %} + + +
+
+
+ {% set steps = [('wiz_venue',1),('wiz_pricing',2),('wiz_costs',3),('wiz_finance',4)] %} + {% for key, n in steps %} + + {% endfor %} +
+
- {% else %} -

Your Venue

-

Define the type of facility you're planning to build.

-
- -
- -
-
- {% endif %} -
-
-
-
+ + +
{% if lang == 'de' %} -

Platzkonfiguration

+

Dein Padel-Platz

+

Definiere den Typ des Padel-Platzes, den du planst.

{% else %} -

Court Configuration

+

Your Venue

+

Define the type of facility you're planning to build.

{% endif %} -
+ +
+ +
+ + +
+ +
+ + +
+
+ +
+
+ +
+ {% for code, lkey in [('DE','country_de'),('ES','country_es'),('IT','country_it'),('FR','country_fr'),('NL','country_nl'),('SE','country_se'),('UK','country_uk'),('US','country_us')] %} + + {% 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.') }} +
+ +
+ {% if lang == 'de' %} +

Platzkonfiguration

+ {% 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.') }} + + {% if lang == 'de' %} +

Platzbedarf

+ {% else %} +

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('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².') }} +
+
{% include "partials/court_summary.html" %}
+
+
+ + +
{% if lang == 'de' %} -

Platzbedarf

+

Preise & Auslastung

+

Lege Deine Platztarife, Betriebszeiten und Nebeneinnahmen fest.

{% else %} -

Space Requirements

+

Pricing & Utilization

+

Set your court rates, operating schedule, and ancillary revenue streams.

{% endif %} -
-
-
-
- -
- {% if lang == 'de' %} -

Preise & Auslastung

-

Lege Deine Platztarife, Betriebszeiten und Nebeneinnahmen fest.

-
-

Preise

Pro Platz und Stunde
-
-
-
-

Auslastung & Betrieb

-
-
- {% else %} -

Pricing & Utilization

-

Set your court rates, operating schedule, and ancillary revenue streams.

-
-

Pricing

Per court per hour
-
-
-
-

Utilization & Operations

-
-
- {% endif %} -
+
+ {% if lang == 'de' %} +

Preise

Pro Platz und Stunde
+ {% 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.') }} +
- -
- {% if lang == 'de' %} -

Investition & Baukosten

-

Konfiguriere Baukosten, Glas- und Beleuchtungsoptionen sowie Dein Budgetziel.

-
-

Bau & CAPEX

Nach Szenario anpassen
-
+
+ {% if lang == 'de' %} +

Auslastung & Betrieb

+ {% 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.') }} +
{{ 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.') }} +
- {% else %} -

Investment & Build Costs

-

Configure construction costs, glass and lighting options, and your budget target.

-
-

Construction & CAPEX

Adjust per scenario
-
-
- {% endif %} -
- -
- {% if lang == 'de' %} -

Betrieb & Finanzierung

-

Monatliche Betriebskosten, Kreditkonditionen und Exit-Annahmen.

-
-

Monatliche Betriebskosten

-
-
-
-

Finanzierung

-
-
-
-

Exit-Annahmen

-
-
- {% else %} -

Operations & Financing

-

Monthly operating costs, loan terms, and exit assumptions.

-
-

Monthly Operating Costs

-
-
-
-

Financing

-
-
-
-

Exit Assumptions

-
-
- {% endif %} -
+ +
+ {% if lang == 'de' %} +

Investition & Baukosten

+

Konfiguriere Baukosten, Glas- und Beleuchtungsoptionen sowie Dein Budgetziel.

+ {% else %} +

Investment & Build Costs

+

Configure construction costs, glass and lighting options, and your budget target.

+ {% endif %} - - +
+ {% if lang == 'de' %} +

Bau & CAPEX

Nach Szenario anpassen
+ {% else %} +

Construction & CAPEX

Adjust per scenario
+ {% endif %} + +
+ +
+ {{ pill_btn('glassType','standard', t.pill_glass_standard, s.glassType == 'standard') }} + {{ pill_btn('glassType','panoramic', t.pill_glass_panoramic, s.glassType == 'panoramic') }} +
+
+ +
+ +
+ {{ pill_btn('lightingType','led_standard', t.pill_light_led_standard, s.lightingType == 'led_standard') }} + {{ pill_btn('lightingType','led_competition', t.pill_light_led_competition, s.lightingType == 'led_competition') }} + {{ pill_btn('lightingType','natural', t.pill_light_natural, s.lightingType == 'natural') }} +
+
+ + {{ 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('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('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('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('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.') }} +
+
+ + +
+ {% if lang == 'de' %} +

Betrieb & Finanzierung

+

Monatliche Betriebskosten, Kreditkonditionen und Exit-Annahmen.

+ {% else %} +

Operations & Financing

+

Monthly operating costs, loan terms, and exit assumptions.

+ {% endif %} + +
+ {% 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('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('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('maintenance', t.sl_maintenance, 0, 2000, 25, s.maintenance, 'Monthly court and facility maintenance: glass cleaning, surface repair, net replacement, and equipment upkeep.') }} + +
{{ slider('cleaning', t.sl_cleaning, 0, 2000, 25, s.cleaning, 'Monthly professional cleaning of courts, changing rooms, common areas, and reception.') }}
+ + {{ 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.') }} +
+ +
+ {% 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.') }} +
+ +
+ {% 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.') }} +
+
+ + + +
+ + + + - -
-
-
-
-
{% if lang == 'de' %}CAPEX-Aufschlüsselung{% else %}CAPEX Breakdown{% endif %}
-
-
-
- - -
-
-
-
-
{% if lang == 'de' %}Monatlicher Umsatzaufbau (Anlaufphase){% else %}Monthly Revenue Build-Up (Ramp Period){% endif %}
-
-
-
-
{% if lang == 'de' %}Stabilisierte monatliche GuV{% else %}Stabilized Monthly P&L{% endif %}
-
-
-
-
-

{% if lang == 'de' %}Einnahmequellen (stabilisierter Monat){% else %}Revenue Streams (Stabilized Month){% endif %}

-
-
-
-

{% if lang == 'de' %}Monatliche OPEX-Aufschlüsselung{% else %}Monthly OpEx Breakdown{% endif %}

-
-
-
-

{% if lang == 'de' %}Outdoor-Saisonalität{% else %}Outdoor Seasonality{% endif %}

-
-
-
- - -
-
-
-
{% if lang == 'de' %}Monatlicher Netto-Cashflow (60 Monate){% else %}Monthly Net Cash Flow (60 Months){% endif %}
-
-
-
-
{% if lang == 'de' %}Kumulierter Cashflow{% else %}Cumulative Cash Flow{% endif %}
-
-
-
-

{% if lang == 'de' %}Jahresübersicht{% else %}Annual Summary{% endif %}

-
-
-
- - -
-
-
-
-
{% if lang == 'de' %}Exit-Bewertungs-Wasserfall{% else %}Exit Valuation Waterfall{% endif %}
-
-
-
-
{% if lang == 'de' %}DSCR nach Jahr{% else %}DSCR by Year{% endif %}
-
-
-
-
-

{% if lang == 'de' %}Auslastungs-Sensitivität{% else %}Utilization Sensitivity{% endif %}

-
-
-
-

{% if lang == 'de' %}Preis-Sensitivität (bei Ziel-Auslastung){% else %}Pricing Sensitivity (at target utilization){% endif %}

-
-
-
- - -
-

{{ planner_t.metrics_return }}

-

{{ planner_t.metrics_revenue }}

-

{{ planner_t.metrics_cost }}

-

{{ planner_t.metrics_debt }}

-

{{ planner_t.metrics_invest }}

-

{{ planner_t.metrics_ops }}

-
-
+
- + {% if not user %} -