+
@@ -311,15 +422,11 @@
{% block scripts %}
{% endblock %}
diff --git a/padelnomics/src/padelnomics/static/js/planner.js b/padelnomics/src/padelnomics/static/js/planner.js
index b288a31..cafc718 100644
--- a/padelnomics/src/padelnomics/static/js/planner.js
+++ b/padelnomics/src/padelnomics/static/js/planner.js
@@ -1,846 +1,207 @@
-// ── State ──────────────────────────────────────────────────
-const S = {
- venue:'indoor', own:'rent',
- dblCourts:4, sglCourts:2,
- sqmPerDblHall:336, sqmPerSglHall:240, sqmPerDblOutdoor:312, sqmPerSglOutdoor:216,
- ratePeak:50, rateOffPeak:35, rateSingle:30,
- peakPct:40, hoursPerDay:16, daysPerMonthIndoor:29, daysPerMonthOutdoor:25,
- bookingFee:10, utilTarget:40,
- membershipRevPerCourt:500, fbRevPerCourt:300, coachingRevPerCourt:200, retailRevPerCourt:80,
- racketRentalRate:15, racketPrice:5, racketQty:2, ballRate:10, ballPrice:3, ballCost:1.5,
- courtCostDbl:25000, courtCostSgl:15000, shipping:3000,
- hallCostSqm:500, foundationSqm:150, landPriceSqm:60,
- hvac:100000, electrical:60000, sanitary:80000, parking:50000,
- fitout:40000, planning:100000, fireProtection:80000,
- floorPrep:12000, hvacUpgrade:20000, lightingUpgrade:10000,
- outdoorFoundation:35, outdoorSiteWork:8000, outdoorLighting:4000, outdoorFencing:6000,
- equipment:2000, workingCapital:15000, contingencyPct:10,
- country:'DE', permitsCompliance:12000,
- budgetTarget:0, glassType:'standard', lightingType:'led_standard',
- rentSqm:4, outdoorRent:400, insurance:300, electricity:600, heating:400,
- maintenance:300, cleaning:300, marketing:350, staff:0, propertyTax:250, water:125,
- loanPct:85, interestRate:5, loanTerm:10, constructionMonths:0,
- holdYears:5, exitMultiple:6, annualRevGrowth:2,
- ramp:[.25,.35,.45,.55,.65,.75,.82,.88,.93,.96,.98,1],
- season:[0,0,0,.7,.9,1,1,1,.8,0,0,0],
-};
+'use strict';
-// Freeze a copy of defaults before any overrides
-const DEFAULTS = Object.freeze(JSON.parse(JSON.stringify(S)));
+// ─── Chart management ─────────────────────────────────────────────────────────
+const _charts = {};
-// Locale helpers — injected by server as window.__PADELNOMICS_LOCALE__
-const L = window.__PADELNOMICS_LOCALE__ || {};
-const tr = (k, fb) => L[k] !== undefined ? L[k] : fb;
-
-// Restore saved scenario if available
-if (window.__PADELNOMICS_INITIAL_STATE__) {
- Object.assign(S, window.__PADELNOMICS_INITIAL_STATE__);
-}
-
-const TABS = [
- {id:'assumptions',label:tr('tab_assumptions','Assumptions')},
- {id:'capex',label:tr('tab_capex','Investment')},
- {id:'operating',label:tr('tab_operating','Operating Model')},
- {id:'cashflow',label:tr('tab_cashflow','Cash Flow')},
- {id:'returns',label:tr('tab_returns','Returns & Exit')},
- {id:'metrics',label:tr('tab_metrics','Key Metrics')},
-];
-let activeTab = 'assumptions';
-const charts = {};
-
-// ── Wizard state ──────────────────────────────────────────
-let wizStep = 1;
-const WIZ_STEPS = [
- {n:1, label:tr('wiz_venue','Venue')},
- {n:2, label:tr('wiz_pricing','Pricing')},
- {n:3, label:tr('wiz_costs','Costs')},
- {n:4, label:tr('wiz_finance','Finance')},
-];
-
-// ── Helpers ────────────────────────────────────────────────
-const $=s=>document.querySelector(s);
-const $$=s=>document.querySelectorAll(s);
-const fmt=n=>new Intl.NumberFormat('de-DE',{style:'currency',currency:'EUR',maximumFractionDigits:0}).format(n);
-const fmtK=n=>Math.abs(n)>=1e6?`\u20AC${(n/1e6).toFixed(1)}M`:Math.abs(n)>=1e3?`\u20AC${(n/1e3).toFixed(0)}K`:fmt(n);
-const fmtP=n=>`${(n*100).toFixed(1)}%`;
-const fmtX=n=>`${n.toFixed(2)}x`;
-const fmtN=n=>new Intl.NumberFormat('de-DE').format(Math.round(n));
-const fE=v=>fmt(v), fP=v=>v+'%', fN=v=>v, fR=v=>v+'x', fY=v=>v+' yr', fH=v=>v+'h', fD=v=>'\u20AC'+v, fM=v=>v+' mo';
-
-function ti(text){
- if(!text) return '';
- return `
i${text}`;
-}
-
-function pillSelect(key,label,options,tip){
- let h=`
`;
- for(const opt of options){
- h+=``;
- }
- h+='
';
- return h;
-}
-
-const COUNTRY_PRESETS = {
- DE: { permitsCompliance: 12000 },
- ES: { permitsCompliance: 25000 },
- IT: { permitsCompliance: 18000 },
- FR: { permitsCompliance: 15000 },
- NL: { permitsCompliance: 10000 },
- SE: { permitsCompliance: 8000 },
- UK: { permitsCompliance: 10000 },
- US: { permitsCompliance: 15000 },
-};
-// Track which keys the user has manually adjusted
-const _userAdjusted = new Set();
-
-function bindPills(){
- document.querySelectorAll('.pill-btn').forEach(b=>{
- b.onclick=()=>{
- const k=b.dataset.key, v=b.dataset.val;
- S[k]=v;
- // Update active state within same group
- b.closest('.pill-options').querySelectorAll('.pill-btn').forEach(btn=>{
- btn.classList.toggle('pill-btn--active',btn.dataset.val===v);
- });
- // Apply country presets when country changes
- if(k==='country'){
- const preset = COUNTRY_PRESETS[v] || COUNTRY_PRESETS.DE;
- for(const [pk,pv] of Object.entries(preset)){
- if(!_userAdjusted.has(pk)){ S[pk]=pv; }
- }
- buildCountryPill(); rebuildCapexInputs(); bindSliders(); bindPills();
- }
- // Rebuild inputs if lighting options depend on venue
- if(k==='glassType'||k==='lightingType'){
- rebuildCapexInputs(); bindSliders(); bindPills();
- }
- render();
- };
+function initCharts() {
+ document.querySelectorAll('script[type="application/json"][id$="-data"]').forEach(el => {
+ const id = el.id.slice(0, -5); // strip '-data'
+ const canvas = document.getElementById(id);
+ if (!canvas) return;
+ try {
+ if (_charts[id]) { _charts[id].destroy(); delete _charts[id]; }
+ _charts[id] = new Chart(canvas.getContext('2d'), JSON.parse(el.textContent));
+ } catch (e) { console.warn('Chart init failed:', id, e); }
});
}
-function cardHTML(label,value,sub,cls='',tip=''){
- const cc = cls==='green'?'c-green':cls==='red'?'c-red':cls==='blue'?'c-blue':cls==='amber'?'c-amber':'c-head';
- return `
-
${label}${ti(tip)}
-
${value}
- ${sub?`
${sub}
`:''}
-
`;
-}
-function cardSmHTML(label,value,sub,cls='',tip=''){
- const cc = cls==='green'?'c-green':cls==='red'?'c-red':cls==='blue'?'c-blue':cls==='amber'?'c-amber':'c-head';
- return `
-
${label}${ti(tip)}
-
${value}
- ${sub?`
${sub}
`:''}
-
`;
-}
-
-// ── Server-side calculation ──────────────────────────────
-let _lastD = window.__PADELNOMICS_INITIAL_D__ || null;
-let _calcTimer = null;
-let _calcController = null;
-
-function fetchCalc(){
- if(_calcController) _calcController.abort();
- _calcController = new AbortController();
- const app = $('.planner-app');
- if(app) app.classList.add('planner-app--computing');
- fetch(window.__PADELNOMICS_CALC_URL__, {
- method:'POST',
- headers:{'Content-Type':'application/json'},
- body:JSON.stringify({state:S}),
- signal:_calcController.signal,
- })
- .then(r=>r.json())
- .then(d=>{
- _lastD = d;
- _calcController = null;
- if(app) app.classList.remove('planner-app--computing');
- renderWith(d);
- })
- .catch(e=>{
- if(e.name!=='AbortError'){
- _calcController = null;
- if(app) app.classList.remove('planner-app--computing');
- }
- });
-}
-
-function scheduleCalc(){
- if(_calcTimer) clearTimeout(_calcTimer);
- _calcTimer = setTimeout(fetchCalc, 200);
-}
-
-// ── UI Builders ───────────────────────────────────────────
-function buildNav(){
- const n = $('#nav');
- n.innerHTML = TABS.map(t=>`
`).join('');
- n.querySelectorAll('button').forEach(b=>b.onclick=()=>{activeTab=b.dataset.tab;render()});
-}
-
-function slider(key,label,min,max,step,fmtFn,tip){
- return `
`;
-}
-
-function buildInputs(){
- buildToggle('tog-venue',[{v:'indoor',l:tr('toggle_indoor','Indoor')},{v:'outdoor',l:tr('toggle_outdoor','Outdoor')}],'venue');
- buildToggle('tog-own',[{v:'rent',l:tr('toggle_rent','Rent / Lease')},{v:'buy',l:tr('toggle_buy','Buy / Build')}],'own');
- buildCountryPill();
-
- $('#inp-courts').innerHTML =
- slider('dblCourts',tr('sl_dbl_courts','Double Courts (20\u00D710m)'),0,30,1,fN,'Standard padel court for 4 players. Most common format with highest recreational demand.')+
- slider('sglCourts',tr('sl_sgl_courts','Single Courts (20\u00D76m)'),0,30,1,fN,'Narrow court for 2 players. Popular for coaching, training, and competitive play.');
-
- rebuildSpaceInputs();
-
- $('#inp-pricing').innerHTML =
- slider('ratePeak',tr('sl_rate_peak','Peak Hour Rate (\u20AC)'),0,150,1,fD,'Price per court per hour during peak times (evenings 17:00\u201322:00 and weekends). Highest demand period.')+
- slider('rateOffPeak',tr('sl_rate_offpeak','Off-Peak Hour Rate (\u20AC)'),0,150,1,fD,'Price per court per hour during off-peak (weekday mornings/afternoons). Typically 30\u201340% lower than peak.')+
- slider('rateSingle',tr('sl_rate_single','Single Court Rate (\u20AC)'),0,150,1,fD,'Hourly rate for single-width courts. Usually lower than doubles since fewer players share the cost.')+
- slider('peakPct',tr('sl_peak_pct','Peak Hours Share'),0,100,1,fP,'Percentage of total booked hours at peak rate. Higher means more revenue but harder to fill off-peak slots.')+
- slider('bookingFee',tr('sl_booking_fee','Platform Fee'),0,30,1,fP,'Commission taken by booking platforms like Playtomic or Matchi. Typically 5\u201315% of court revenue.');
-
- $('#inp-util').innerHTML =
- slider('utilTarget',tr('sl_util_target','Target Utilization'),0,100,1,fP,'Percentage of available court-hours that are actually booked. 35\u201345% is realistic for new venues, 50%+ is strong.')+
- slider('hoursPerDay',tr('sl_hours_per_day','Operating Hours / Day'),0,24,1,fH,'Total operating hours per day. Typical padel venues run 7:00\u201323:00 (16h). Some extend to 6:00\u201324:00.')+
- slider('daysPerMonthIndoor',tr('sl_days_indoor','Indoor Days / Month'),0,31,1,fN,'Average operating days per month for indoor venue. ~29 accounts for holidays and maintenance closures.')+
- slider('daysPerMonthOutdoor',tr('sl_days_outdoor','Outdoor Days / Month'),0,31,1,fN,'Average playable days per month outdoors. Reduced by rain, extreme heat, or cold weather.')+
- '
'+tr('sl_ancillary_header','Ancillary Revenue (per court/month):')+'
'+
- slider('membershipRevPerCourt',tr('sl_membership_rev','Membership Revenue / Court'),0,2000,50,fE,'Monthly membership/subscription income per court. From loyalty programs, monthly plans, or club memberships.')+
- slider('fbRevPerCourt',tr('sl_fb_rev','F&B Revenue / Court'),0,2000,25,fE,'Food & Beverage revenue per court per month. Income from bar, caf\u00E9, restaurant, or vending machines at the venue.')+
- slider('coachingRevPerCourt',tr('sl_coaching_rev','Coaching & Events / Court'),0,2000,25,fE,'Revenue from coaching sessions, clinics, tournaments, and events allocated per court per month.')+
- slider('retailRevPerCourt',tr('sl_retail_rev','Retail / Court'),0,1000,10,fE,'Revenue from pro shop sales: grip tape, overgrips, accessories, and branded merchandise per court per month.');
-
- rebuildCapexInputs();
- rebuildOpexInputs();
-
- $('#inp-finance').innerHTML =
- slider('loanPct',tr('sl_loan_pct','Loan-to-Cost (LTC)'),0,100,1,fP,'Percentage of total CAPEX financed by debt. Banks typically offer 70\u201385%. Higher with personal guarantees or subsidies.')+
- slider('interestRate',tr('sl_interest_rate','Interest Rate'),0,15,0.1,fP,'Annual interest rate on the loan. Depends on creditworthiness, collateral, market conditions, and bank relationship.')+
- slider('loanTerm',tr('sl_loan_term','Loan Term'),0,30,1,fY,'Loan repayment period in years. Longer terms mean lower monthly payments but more total interest paid.')+
- slider('constructionMonths',tr('sl_construction_months','Construction Period'),0,24,1,fM,'Months of construction/setup before opening. Costs accrue (loan interest, rent) but no revenue is generated.');
-
- $('#inp-exit').innerHTML =
- slider('holdYears',tr('sl_hold_years','Holding Period'),1,20,1,fY,'Investment holding period before exit/sale. Typical for PE/investors: 5\u20137 years. Owner-operators may hold indefinitely.')+
- slider('exitMultiple',tr('sl_exit_multiple','Exit EBITDA Multiple'),0,20,0.5,fR,'EBITDA multiple used to value the business at exit. Reflects market demand, brand strength, and growth potential. Small business: 4\u20136x, strong brand: 6\u20138x.')+
- slider('annualRevGrowth',tr('sl_annual_rev_growth','Annual Revenue Growth'),0,15,0.5,fP,'Expected annual revenue growth rate after the initial 12-month ramp-up period. Driven by price increases and utilization gains.');
-}
-
-function rebuildSpaceInputs(){
- const isIn = S.venue==='indoor';
- let h = '';
- if(isIn){
- h += slider('sqmPerDblHall',tr('sl_sqm_dbl_hall','Hall m\u00B2 per Double Court'),200,600,10,fN,'Total hall space needed per double court. Includes court (200m\u00B2), safety zones, circulation, and minimum clearances. Standard: 300\u2013350m\u00B2.')+
- slider('sqmPerSglHall',tr('sl_sqm_sgl_hall','Hall m\u00B2 per Single Court'),120,400,10,fN,'Total hall space needed per single court. Includes court (120m\u00B2), safety zones, and access. Standard: 200\u2013250m\u00B2.');
- } else {
- h += slider('sqmPerDblOutdoor',tr('sl_sqm_dbl_outdoor','Land m\u00B2 per Double Court'),200,500,10,fN,'Outdoor land area per double court. Includes court area, drainage slopes, access paths, and buffer zones. Standard: 280\u2013320m\u00B2.')+
- slider('sqmPerSglOutdoor',tr('sl_sqm_sgl_outdoor','Land m\u00B2 per Single Court'),120,350,10,fN,'Outdoor land area per single court. Includes court, surrounding space, and access paths. Standard: 180\u2013220m\u00B2.');
- }
- $('#inp-space').innerHTML = h;
-}
-
-function buildCountryPill(){
- $('#inp-country').innerHTML = pillSelect('country',tr('pill_country','Country'),[
- {v:'DE',l:tr('country_de','Germany')},{v:'ES',l:tr('country_es','Spain')},{v:'IT',l:tr('country_it','Italy')},
- {v:'FR',l:tr('country_fr','France')},{v:'NL',l:tr('country_nl','Netherlands')},{v:'SE',l:tr('country_se','Sweden')},
- {v:'UK',l:tr('country_uk','UK')},{v:'US',l:tr('country_us','USA')},
- ]) + slider('permitsCompliance',tr('sl_permits','Permits & Compliance'),0,50000,1000,fE,
- 'Building permits, noise studies, change-of-use, fire safety, and regulatory compliance. Adjusts automatically when you pick a country — feel free to override.');
-}
-
-function rebuildCapexInputs(){
- const isIn=S.venue==='indoor', isBuy=S.own==='buy';
- let glassOpts=[{v:'standard',l:tr('pill_glass_standard','Standard Glass')},{v:'panoramic',l:tr('pill_glass_panoramic','Panoramic Glass')}];
- let lightOpts=[{v:'led_standard',l:tr('pill_light_led_standard','LED Standard')},{v:'led_competition',l:tr('pill_light_led_competition','LED Competition')}];
- if(!isIn) lightOpts.push({v:'natural',l:tr('pill_light_natural','Natural Light')});
- // Reset lightingType to led_standard if natural was selected but switched to indoor
- if(isIn && S.lightingType==='natural') S.lightingType='led_standard';
-
- const lightTip = isIn
- ? 'LED Standard: meets club play requirements. LED Competition: 50% more cost, meets tournament/broadcast standards.'
- : 'LED Standard: meets club play requirements. LED Competition: 50% more cost, meets tournament standards. Natural: no lighting cost, daylight only.';
- let h = pillSelect('glassType',tr('pill_glass_type','Glass Type'),glassOpts,'Standard glass: \u20AC25\u201330K per court. Panoramic glass: \u20AC30\u201345K. Panoramic offers full visibility and premium feel.')+
- pillSelect('lightingType',tr('pill_lighting_type','Lighting Type'),lightOpts,lightTip)+
- slider('courtCostDbl',tr('sl_court_cost_dbl','Court Cost \u2014 Double'),0,80000,1000,fE,'Base price of one double padel court. The glass type multiplier is applied automatically.')+
- slider('courtCostSgl',tr('sl_court_cost_sgl','Court Cost \u2014 Single'),0,60000,1000,fE,'Base price of one single padel court. Generally 60\u201370% of a double court cost.');
- if(isIn&&isBuy){
- h+=slider('hallCostSqm',tr('sl_hall_cost_sqm','Hall Construction (\u20AC/m\u00B2)'),0,2000,10,fE,'Construction cost per m\u00B2 for a new hall (Warmhalle). Includes structure, insulation, and cladding. Requires 10\u201312m clear height.')+
- slider('foundationSqm',tr('sl_foundation_sqm','Foundation (\u20AC/m\u00B2)'),0,400,5,fE,'Foundation cost per m\u00B2. Depends on soil conditions, load-bearing requirements, and local ground water levels.')+
- slider('landPriceSqm',tr('sl_land_price_sqm','Land Price (\u20AC/m\u00B2)'),0,500,5,fE,'Land purchase price per m\u00B2. Rural: \u20AC20\u201360. Suburban: \u20AC60\u2013150. Urban: \u20AC150\u2013300+. Varies hugely by location.')+
- slider('hvac',tr('sl_hvac','HVAC System'),0,500000,5000,fE,'Heating, ventilation, and air conditioning. Essential for indoor comfort and humidity control. Cost scales with hall volume.')+
- slider('electrical',tr('sl_electrical','Electrical + Lighting'),0,400000,5000,fE,'Complete electrical installation: court lighting (LED, 500+ lux), power distribution, panels, and outlets.')+
- slider('sanitary',tr('sl_sanitary','Sanitary / Changing'),0,400000,5000,fE,'Changing rooms, showers, toilets, and plumbing. Includes fixtures, tiling, waterproofing, and ventilation.')+
- slider('fireProtection',tr('sl_fire','Fire Protection'),0,500000,5000,fE,'Fire detection, sprinkler suppression, emergency exits, and smoke ventilation. Often the biggest surprise cost for large halls.')+
- slider('planning',tr('sl_planning','Planning + Permits'),0,500000,5000,fE,'Architectural planning, structural engineering, building permits, zoning applications, and regulatory compliance costs.');
- } else if(isIn&&!isBuy){
- h+=slider('floorPrep',tr('sl_floor_prep','Floor Preparation'),0,100000,1000,fE,'Floor leveling, sealing, and preparation for court installation in an existing rented building.')+
- slider('hvacUpgrade',tr('sl_hvac_upgrade','HVAC Upgrade'),0,200000,1000,fE,'Upgrading existing HVAC in a rented building to handle sports venue airflow and humidity requirements.')+
- slider('lightingUpgrade',tr('sl_lighting_upgrade','Lighting Upgrade'),0,100000,1000,fE,'Upgrading existing lighting to meet padel requirements: minimum 500 lux, no glare, even distribution across courts.')+
- slider('fitout',tr('sl_fitout','Fit-Out & Reception'),0,300000,1000,fE,'Interior fit-out for reception, lounge, viewing areas, and common spaces when renting an existing building.');
- } else if(!isIn){
- h+=slider('outdoorFoundation',tr('sl_outdoor_foundation','Concrete (\u20AC/m\u00B2)'),0,150,1,fE,'Concrete pad per m\u00B2 for outdoor courts. Needs proper drainage, level surface, and frost-resistant construction.')+
- slider('outdoorSiteWork',tr('sl_outdoor_site_work','Site Work'),0,60000,500,fE,'Grading, drainage installation, utilities connection, and site preparation for outdoor courts.')+
- slider('outdoorLighting',tr('sl_outdoor_lighting','Lighting per Court'),0,20000,500,fE,'Floodlight installation per court. LED recommended for energy efficiency. Must meet competition standards if applicable.')+
- slider('outdoorFencing',tr('sl_outdoor_fencing','Fencing'),0,40000,500,fE,'Perimeter fencing around outdoor court area. Includes wind screens, security gates, and ball containment nets.');
- if(isBuy) h+=slider('landPriceSqm',tr('sl_land_price_sqm','Land Price (\u20AC/m\u00B2)'),0,500,5,fE,'Land purchase price per m\u00B2. Varies by location, zoning, and accessibility.');
- }
- h+=slider('workingCapital',tr('sl_working_capital','Working Capital'),0,200000,1000,fE,'Cash reserve for operating losses during ramp-up phase and seasonal dips. Critical buffer \u2014 underfunding is a common startup failure.')+
- slider('contingencyPct',tr('sl_contingency','Contingency'),0,30,1,fP,'Percentage buffer on total CAPEX for unexpected costs. 10\u201315% is standard for construction, 15\u201320% for complex projects.')+
- slider('budgetTarget',tr('sl_budget_target','Your Budget Target'),0,5000000,10000,fE,'Set your total budget to see how your planned CAPEX compares. Leave at 0 to hide the budget indicator.');
- $('#inp-capex').innerHTML = h;
-}
-
-function rebuildOpexInputs(){
- const isIn=S.venue==='indoor', isBuy=S.own==='buy';
- let h='';
- if(!isBuy){
- if(isIn) h+=slider('rentSqm',tr('sl_rent_sqm','Rent (\u20AC/m\u00B2/month)'),0,25,0.5,fD,'Monthly rent per square meter for indoor hall space. Varies by location, building quality, and lease terms.');
- else h+=slider('outdoorRent',tr('sl_outdoor_rent','Monthly Land Rent'),0,5000,50,fE,'Monthly land rent for outdoor court area. Much cheaper than indoor space but weather-dependent.');
- } else {
- h+=slider('propertyTax',tr('sl_property_tax','Property Tax / month'),0,2000,25,fE,'Monthly property tax when owning the building/land. Grundsteuer in Germany, varies by municipality and property value.');
- }
- h+=slider('insurance',tr('sl_insurance','Insurance (\u20AC/mo)'),0,2000,25,fE,'Monthly insurance premium covering liability, property damage, business interruption, and equipment.')+
- slider('electricity',tr('sl_electricity','Electricity (\u20AC/mo)'),0,5000,25,fE,'Monthly electricity cost. Major driver for indoor venues due to court lighting, HVAC, and equipment.');
- if(isIn) h+=slider('heating',tr('sl_heating','Heating (\u20AC/mo)'),0,3000,25,fE,'Monthly heating cost for indoor venue. Significant in northern European climates during winter months.')+
- slider('water',tr('sl_water','Water (\u20AC/mo)'),0,1000,25,fE,'Monthly water cost for showers, toilets, cleaning, and potentially outdoor court irrigation.');
- h+=slider('maintenance',tr('sl_maintenance','Maintenance (\u20AC/mo)'),0,2000,25,fE,'Monthly court and facility maintenance: glass cleaning, surface repair, net replacement, and equipment upkeep.')+
- (isIn?slider('cleaning',tr('sl_cleaning','Cleaning (\u20AC/mo)'),0,2000,25,fE,'Monthly professional cleaning of courts, changing rooms, common areas, and reception.'):'')+
- slider('marketing',tr('sl_marketing','Marketing / Misc (\u20AC/mo)'),0,5000,25,fE,'Monthly spend on marketing, booking platform subscriptions, website, social media, and customer acquisition.')+
- slider('staff',tr('sl_staff','Staff (\u20AC/mo)'),0,20000,100,fE,'Monthly staff costs including wages, social contributions, and benefits. Many venues run lean using automated booking and access systems.');
- $('#inp-opex').innerHTML = h;
-}
-
-function buildToggle(id,opts,key){
- const el = $(`#${id}`);
- el.innerHTML = opts.map(o=>`
`).join('');
- el.querySelectorAll('button').forEach(b=>b.onclick=()=>{
- S[key]=b.dataset.val;
- // Update active state on all buttons in this toggle group
- el.querySelectorAll('button').forEach(btn=>{
- btn.classList.toggle('toggle-btn--active', btn.dataset.val===S[key]);
- });
- rebuildSpaceInputs(); rebuildCapexInputs(); rebuildOpexInputs(); bindSliders(); bindPills(); render();
- });
-}
-
-function bindSliders(){
- document.querySelectorAll('input[type=range][data-key]').forEach(inp=>{
- inp.oninput = () => {
- const k=inp.dataset.key;
- S[k]=parseFloat(inp.value);
- if(k==='permitsCompliance') _userAdjusted.add(k);
- const numInp = document.querySelector(`input[type=number][data-numfor="${k}"]`);
- if(numInp) numInp.value = S[k];
- render();
- };
- });
- document.querySelectorAll('input[type=number][data-numfor]').forEach(inp=>{
- inp.oninput = () => {
- const k=inp.dataset.numfor;
- const v = parseFloat(inp.value);
- if(isNaN(v)) return;
- S[k]=v;
- if(k==='permitsCompliance') _userAdjusted.add(k);
- const rangeInp = document.querySelector(`input[type=range][data-key="${k}"]`);
- if(rangeInp) rangeInp.value = v;
- render();
- };
- });
-}
-
-// ── Render ─────────────────────────────────────────────────
-function render(){
- // Update tab visibility immediately (no server call needed)
- $$('.tab-btn').forEach(b=>{
- b.classList.toggle('tab-btn--active', b.dataset.tab===activeTab);
- });
- $$('.tab').forEach(t=>{
- t.classList.toggle('active',t.id===`tab-${activeTab}`);
- });
-
- // Show signup bar on results tabs for guests
- const sb=$('#signupBar');
- if(sb) sb.style.display=activeTab!=='assumptions'?'flex':'none';
-
- // Show quote sidebar + inline CTA on all tabs
- const qs=$('#quoteSidebar');
- if(qs) qs.style.display='block';
- const qi=$('#quoteInlineCta');
- if(qi) qi.style.display='block';
-
- // If we have cached data, render immediately with it
- if(_lastD) renderWith(_lastD);
-
- // Schedule server-side recalculation
- scheduleCalc();
-}
-
-function renderWith(d){
- const isIn=S.venue==='indoor';
- const label = `${isIn?tr('label_indoor','Indoor'):tr('label_outdoor','Outdoor')} \u00B7 ${S.own==='buy'?tr('label_build_buy','Build/Buy'):tr('label_rent','Rent')}`;
- $('#headerTag').textContent = `${label} \u00B7 ${d.totalCourts} ${tr('label_courts','courts')} \u00B7 ${fmtK(d.capex)}`;
-
- const courtPlaySqm = S.dblCourts*200+S.sglCourts*120;
- $('#courtSummary').innerHTML =
- cardSmHTML(tr('card_total_courts','Total Courts'),d.totalCourts)+
- cardSmHTML(tr('card_floor_area','Floor Area'),`${fmtN(d.sqm)} m\u00B2`,isIn?tr('label_indoor_hall','Indoor hall'):tr('label_outdoor_land','Outdoor land'))+
- cardSmHTML(tr('card_court_area','Court Area'),`${fmtN(courtPlaySqm)} m\u00B2`,tr('label_playing_surface','Playing surface'));
-
- if(activeTab==='capex') renderCapex(d);
- if(activeTab==='operating') renderOperating(d);
- if(activeTab==='cashflow') renderCashflow(d);
- if(activeTab==='returns') renderReturns(d);
- if(activeTab==='metrics') renderMetrics(d);
-
- if(activeTab==='operating'){
- const sec = $('#seasonSection');
- if(isIn){ sec.classList.remove('visible'); }
- else { sec.classList.add('visible'); renderSeasonChart(); }
- }
-
- if(activeTab==='assumptions') renderWizPreview();
-}
-
-// ── Table helper ──
-const TH = t => `
${t} | `;
-const THR = t => `
${t} | `;
-
-function renderCapex(d){
- $('#capexCards').innerHTML =
- cardHTML(tr('card_total_capex','Total CAPEX'),fmt(d.capex),'','red','Capital Expenditure: total upfront investment required to build and equip the venue before opening.')+
- cardHTML(tr('card_per_court','Per Court'),fmt(Math.round(d.capexPerCourt)),d.totalCourts+' '+tr('label_courts','courts'),'','Total investment divided by number of courts. Useful for comparing scenarios and benchmarking.')+
- cardHTML(tr('card_per_sqm','Per m\u00B2'),fmt(Math.round(d.capexPerSqm)),fmtN(d.sqm)+' m\u00B2','','Total investment per square meter of venue space. Benchmarks construction efficiency.');
-
- // Budget indicator
- if(d.budgetTarget>0){
- const over=d.budgetVariance>0;
- const cls=over?'red':'green';
- const sign=over?'+':'';
- $('#capexCards').innerHTML+=`
-
${over?tr('budget_over','BUDGET OVER'):tr('budget_under','BUDGET UNDER')}
-
${sign}${fmt(Math.round(d.budgetVariance))}
-
${d.budgetPct.toFixed(0)}% of ${fmtK(d.budgetTarget)} budget
-
`;
- }
-
- let rows = d.capexItems.map(i=>`
| ${i.name}${i.info?` (${i.info})`:''} | ${fmt(i.amount)} |
`).join('');
- rows += `
| ${tr('table_total_capex','TOTAL CAPEX')} | ${fmt(d.capex)} |
`;
- $('#capexTable').innerHTML = `
${TH(tr('th_item','Item'))}${THR(tr('th_amount','Amount'))}
${rows}
`;
-
- renderChart('chartCapex','doughnut',{
- labels:d.capexItems.filter(i=>i.amount>0).map(i=>i.name),
- datasets:[{data:d.capexItems.filter(i=>i.amount>0).map(i=>i.amount),
- backgroundColor:['#3B82F6','#10B981','#F59E0B','#8B5CF6','#EC4899','#06B6D4','#84CC16','#F97316','#6366F1','#14B8A6','#A855F7','#EF4444','#22C55E','#EAB308','#2563EB'],
- borderWidth:0}]
- },{plugins:{legend:{position:'right',labels:{color:'#64748B',font:{size:10,family:'Inter'},boxWidth:10,padding:6}}}});
-}
-
-function renderOperating(d){
- const margin = d.netRevMonth>0?(d.ebitdaMonth/d.netRevMonth*100).toFixed(1):0;
- $('#opCards').innerHTML =
- cardHTML(tr('card_net_rev_mo','Net Revenue/mo'),fmt(Math.round(d.netRevMonth)),tr('sub_stabilized','Stabilized'),'green','Monthly revenue after deducting platform booking fees but before operating expenses.')+
- cardHTML(tr('card_ebitda_mo','EBITDA/mo'),fmt(Math.round(d.ebitdaMonth)),margin+'% margin',d.ebitdaMonth>=0?'green':'red','Earnings Before Interest, Taxes, Depreciation & Amortization. Core monthly operating profit of the business.')+
- cardHTML(tr('card_annual_rev','Annual Revenue'),fmt(Math.round(d.annuals.length>=3?d.annuals[2].revenue:0)),tr('sub_year3','Year 3'),'','Projected total annual revenue in Year 3 when the business has reached stabilized utilization.')+
- cardHTML(tr('card_rev_pah','RevPAH'),fmt(d.revPAH),'Revenue per available hour','blue','Revenue Per Available Hour. Net revenue divided by total available court-hours. Measures how well you monetize capacity.');
-
- const streams=[
- [tr('stream_court_rental','Court Rental (net of fees)'),d.courtRevMonth-d.feeDeduction],
- [tr('stream_equipment','Equipment Rental (rackets/balls)'),d.racketRev+d.ballMargin],
- [tr('stream_memberships','Memberships'),d.membershipRev],
- [tr('stream_fb','F&B'),d.fbRev],
- [tr('stream_coaching','Coaching & Events'),d.coachingRev],
- [tr('stream_retail','Retail'),d.retailRev],
- ];
- const totalStream = streams.reduce((s,r)=>s+r[1],0);
- let sRows = streams.map(([n,v])=>{
- const pct=totalStream>0?(v/totalStream*100).toFixed(0):0;
- return `
| ${n} | ${fmt(Math.round(v))} | ${pct}% |
`;
- }).join('');
- sRows+=`
| ${tr('table_total_net_rev','Total Net Revenue')} | ${fmt(Math.round(totalStream))} | 100% |
`;
- $('#revenueTable').innerHTML=`
${TH(tr('th_stream','Stream'))}${THR(tr('th_monthly','Monthly'))}${THR(tr('th_share','Share'))}
${sRows}
`;
-
- let oRows=d.opexItems.map(i=>`
| ${i.name}${i.info?` (${i.info})`:''} | ${fmt(i.amount)} |
`).join('');
- oRows+=`
| ${tr('table_total_opex','Total Monthly OpEx')} | ${fmt(d.opex)} |
`;
- $('#opexDetailTable').innerHTML=`
${TH(tr('th_item','Item'))}${THR(tr('th_monthly','Monthly'))}
${oRows}
`;
-
- const rampData = d.months.slice(0,24);
- renderChart('chartRevRamp','bar',{
- labels:rampData.map(m=>'M'+m.m),
- datasets:[
- {label:tr('chart_revenue','Revenue'),data:rampData.map(m=>Math.round(m.totalRev)),backgroundColor:'rgba(16,185,129,0.5)',borderRadius:3},
- {label:tr('chart_opex_debt','OpEx+Debt'),data:rampData.map(m=>Math.round(Math.abs(m.opex)+Math.abs(m.loan))),backgroundColor:'rgba(239,68,68,0.4)',borderRadius:3},
- ]
- },{scales:{x:{ticks:{maxTicksLimit:12,color:'#94A3B8',font:{size:9}}},y:{ticks:{color:'#94A3B8',font:{size:9}},grid:{color:'rgba(0,0,0,0.04)'}}},plugins:{legend:{labels:{color:'#64748B',font:{size:10}}}}});
-
- const plData = [
- {label:tr('chart_court_rev','Court Rev'),val:Math.round(d.courtRevMonth)},
- {label:tr('chart_fees','Fees'),val:-Math.round(d.feeDeduction)},
- {label:tr('chart_ancillary','Ancillary'),val:Math.round(d.racketRev+d.ballMargin+d.membershipRev+d.fbRev+d.coachingRev+d.retailRev)},
- {label:tr('chart_opex','OpEx'),val:-Math.round(d.opex)},
- {label:tr('chart_debt','Debt'),val:-Math.round(d.monthlyPayment)},
- ];
- renderChart('chartPL','bar',{
- labels:plData.map(p=>p.label),
- datasets:[{data:plData.map(p=>p.val),backgroundColor:plData.map(p=>p.val>=0?'rgba(16,185,129,0.6)':'rgba(239,68,68,0.5)'),borderRadius:4}]
- },{indexAxis:'y',scales:{x:{ticks:{color:'#94A3B8',font:{size:9}},grid:{color:'rgba(0,0,0,0.04)'}},y:{ticks:{color:'#64748B',font:{size:10}}}},plugins:{legend:{display:false}}});
-}
-
-function renderCashflow(d){
- const payback = d.paybackIdx>=0?`Month ${d.paybackIdx+1}`:tr('payback_not_reached','Not reached');
- const y1ncf = d.annuals[0]?.ncf||0;
- const y3ncf = d.annuals.length>=3?d.annuals[2].ncf:0;
- $('#cfCards').innerHTML =
- cardHTML(tr('card_y1_ncf','Year 1 Net CF'),fmt(Math.round(y1ncf)),'',y1ncf>=0?'green':'red','Net Cash Flow in Year 1. Typically negative due to ramp-up. Includes all revenue minus OpEx and debt service.')+
- cardHTML(tr('card_y3_ncf','Year 3 Net CF'),fmt(Math.round(y3ncf)),tr('sub_stabilized','Stabilized'),y3ncf>=0?'green':'red','Net Cash Flow in Year 3 when utilization has reached target levels. This is the stabilized annual performance.')+
- cardHTML(tr('card_payback','Payback'),payback,d.paybackIdx>=0?`~${((d.paybackIdx+1)/12).toFixed(1)} years`:'','','Number of months until cumulative cash flows recover the full initial CAPEX investment.')+
- cardHTML(tr('card_initial_inv','Initial Investment'),fmt(d.capex),'','red','Total upfront capital required including construction, equipment, permits, and working capital buffer.');
-
- renderChart('chartCF','bar',{
- labels:d.months.map(m=>m.m%12===1?'Y'+m.yr:''),
- datasets:[{data:d.months.map(m=>Math.round(m.ncf)),
- backgroundColor:d.months.map(m=>m.ncf>=0?'rgba(16,185,129,0.5)':'rgba(239,68,68,0.4)'),borderRadius:2}]
- },{scales:{x:{ticks:{color:'#94A3B8',font:{size:9}}},y:{ticks:{color:'#94A3B8',font:{size:9}},grid:{color:'rgba(0,0,0,0.04)'}}},plugins:{legend:{display:false}}});
-
- renderChart('chartCum','line',{
- labels:d.months.map(m=>m.m%6===1?'M'+m.m:''),
- datasets:[{data:d.months.map(m=>Math.round(m.cum)),borderColor:'#3B82F6',backgroundColor:'rgba(59,130,246,0.08)',fill:true,pointRadius:0,tension:0.3}]
- },{scales:{x:{ticks:{color:'#94A3B8',font:{size:9}}},y:{ticks:{color:'#94A3B8',font:{size:9}},grid:{color:'rgba(0,0,0,0.04)'}}},plugins:{legend:{display:false}}});
-
- let rows = d.annuals.map(y=>{
- const dscr = y.ds>0?y.ebitda/y.ds:999;
- const util = y.avail>0?(y.booked/y.avail*100).toFixed(0):0;
- return `
- | Year ${y.year} |
- ${fmt(Math.round(y.revenue))} |
- ${fmt(Math.round(y.ebitda))} |
- ${fmt(Math.round(y.ds))} |
- ${fmt(Math.round(y.ncf))} |
- ${dscr>99?'\u221E':fmtX(dscr)} |
- ${util}% |
-
`;
- }).join('');
- $('#annualTable').innerHTML=`
${TH(tr('th_year','Year'))}${THR(tr('th_revenue','Revenue'))}${THR(tr('th_ebitda','EBITDA'))}${THR(tr('th_debt_service','Debt Service'))}${THR(tr('th_net_cf','Net CF'))}${THR(tr('th_dscr','DSCR'))}${THR(tr('th_util','Util.'))}
${rows}
`;
-}
-
-function renderReturns(d){
- const irrOk=isFinite(d.irr)&&!isNaN(d.irr);
- $('#retCards').innerHTML =
- cardHTML(tr('card_irr','IRR'),irrOk?fmtP(d.irr):'N/A',irrOk&&d.irr>0.2?'\u2713 Above 20%':'\u2717 Below target',irrOk&&d.irr>0.2?'green':'red','Internal Rate of Return. The annualized rate of return that makes the NPV of all cash flows equal zero. Accounts for timing of cash flows. Target: >20% for small business risk.')+
- cardHTML(tr('card_moic','MOIC'),fmtX(d.moic),d.moic>2?'\u2713 Above 2.0x':'\u2717 Below 2.0x',d.moic>2?'green':'red','Multiple on Invested Capital. Total money returned (cash flows + exit proceeds) divided by total money invested. 2.0x = you doubled your money.')+
- cardHTML(tr('card_break_even','Break-Even Util.'),fmtP(d.breakEvenUtil),`${d.breakEvenHrsPerCourt.toFixed(1)} hrs/court/day`,d.breakEvenUtil<0.35?'green':'amber','Minimum court utilization needed to cover all monthly costs (OpEx + debt service). Below this level, the venue loses money each month.')+
- cardHTML(tr('card_cash_on_cash','Cash-on-Cash'),fmtP(d.cashOnCash),'Year 3 NCF \u00F7 Equity',d.cashOnCash>0.15?'green':'amber','Annual cash flow (Year 3, stabilized) divided by your equity investment. Measures the cash yield on your own money, ignoring appreciation.');
-
- const wf = [
- [tr('wf_stab_ebitda','Stabilized EBITDA (Y3)'),fmt(Math.round(d.stabEbitda)),'c-head'],
- [tr('wf_exit_multiple','\u00D7 Exit Multiple'),S.exitMultiple+'x','c-head'],
- [tr('wf_enterprise_value','= Enterprise Value'),fmt(Math.round(d.exitValue)),'c-blue'],
- [tr('wf_remaining_loan','\u2013 Remaining Loan'),fmt(Math.round(d.remainingLoan)),'c-red'],
- [tr('wf_net_exit','= Net Exit Proceeds'),fmt(Math.round(d.netExit)),d.netExit>0?'c-green':'c-red'],
- [tr('wf_cum_cf','+ Cumulative Cash Flow'),fmt(Math.round(d.totalReturned-d.netExit)),'c-head'],
- [tr('wf_total_returns','= Total Returns'),fmt(Math.round(d.totalReturned)),d.totalReturned>0?'c-green':'c-red'],
- [tr('wf_investment','\u00F7 Investment'),fmt(d.capex),'c-head'],
- [tr('wf_moic','= MOIC'),fmtX(d.moic),d.moic>2?'c-green':'c-red'],
- ];
- $('#exitWaterfall').innerHTML = wf.map(([l,v,c])=>`
${l}${v}
`).join('');
-
- renderChart('chartDSCR','bar',{
- labels:d.dscr.map(x=>'Y'+x.year),
- datasets:[{data:d.dscr.map(x=>Math.min(x.dscr,10)),backgroundColor:d.dscr.map(x=>x.dscr>=1.2?'rgba(16,185,129,0.5)':'rgba(239,68,68,0.5)'),borderRadius:4}]
- },{scales:{x:{ticks:{color:'#94A3B8'}},y:{ticks:{color:'#94A3B8',font:{size:9}},grid:{color:'rgba(0,0,0,0.04)'}}},plugins:{legend:{display:false}}});
-
- const utils = [15,20,25,30,35,40,45,50,55,60,65,70];
- const isIn = S.venue==='indoor';
- const wRate = d.weightedRate;
- const revPerHr = wRate*(1-S.bookingFee/100)+(S.racketRentalRate/100)*S.racketQty*S.racketPrice+(S.ballRate/100)*(S.ballPrice-S.ballCost);
- let sRows = utils.map(u=>{
- const booked = d.availHoursMonth*(u/100);
- const rev = booked*revPerHr + d.totalCourts*(S.membershipRevPerCourt+S.fbRevPerCourt+S.coachingRevPerCourt+S.retailRevPerCourt)*(u/Math.max(S.utilTarget,1));
- const ncf = rev-d.opex-d.monthlyPayment;
- const annual = ncf*(isIn?12:6);
- const ebitda = rev-d.opex;
- const dscr = d.annualDebtService>0?(ebitda*(isIn?12:6))/d.annualDebtService:999;
- const isTarget = u===S.utilTarget;
- return `
| ${isTarget?'\u2192 ':''} ${u}%${isTarget?' \u2190':''} | ${fmt(Math.round(rev))} | ${fmt(Math.round(ncf))} | ${fmt(Math.round(annual))} | ${dscr>99?'\u221E':fmtX(dscr)} |
`;
- }).join('');
- $('#sensTable').innerHTML=`
${TH(tr('th_utilization','Utilization'))}${THR(tr('th_monthly_rev','Monthly Rev'))}${THR(tr('th_monthly_ncf','Monthly NCF'))}${THR(tr('th_annual_ncf','Annual NCF'))}${THR(tr('th_dscr','DSCR'))}
${sRows}
`;
-
- const prices = [-20,-10,-5,0,5,10,15,20];
- let pRows = prices.map(delta=>{
- const adjRate = wRate*(1+delta/100);
- const booked = d.bookedHoursMonth;
- const rev = booked*adjRate*(1-S.bookingFee/100)+booked*((S.racketRentalRate/100)*S.racketQty*S.racketPrice+(S.ballRate/100)*(S.ballPrice-S.ballCost))+d.totalCourts*(S.membershipRevPerCourt+S.fbRevPerCourt+S.coachingRevPerCourt+S.retailRevPerCourt);
- const ncf = rev-d.opex-d.monthlyPayment;
- const isBase = delta===0;
- return `
| ${isBase?'\u2192 ':''}${delta>=0?'+':''}${delta}%${isBase?' (base)':''} | ${fmt(Math.round(adjRate))} | ${fmt(Math.round(rev))} | ${fmt(Math.round(ncf))} |
`;
- }).join('');
- $('#priceSensTable').innerHTML=`
${TH(tr('th_price_change','Price Change'))}${THR(tr('th_avg_rate','Avg Rate'))}${THR(tr('th_monthly_rev','Monthly Rev'))}${THR(tr('th_monthly_ncf','Monthly NCF'))}
${pRows}
`;
-}
-
-function renderMetrics(d){
- const isIn=S.venue==='indoor';
- const irrOk=isFinite(d.irr)&&!isNaN(d.irr);
- const annRev = d.annuals.length>=3?d.annuals[2].revenue:0;
-
- $('#mReturn').innerHTML =
- cardSmHTML('IRR',irrOk?fmtP(d.irr):'N/A',`${S.holdYears}-year`,irrOk&&d.irr>.2?'green':'red','Internal Rate of Return. Annualized return accounting for the timing of all cash flows over the holding period.')+
- cardSmHTML('MOIC',fmtX(d.moic),'Total return multiple',d.moic>2?'green':'red','Multiple on Invested Capital. Total cash returned divided by total cash invested. 2.0x means you doubled your money.')+
- cardSmHTML('Cash-on-Cash',fmtP(d.cashOnCash),'Y3 NCF \u00F7 Equity',d.cashOnCash>.15?'green':'amber','Year 3 net cash flow divided by equity invested. Measures annual cash yield on your own capital, ignoring asset appreciation.')+
- cardSmHTML('Payback',d.paybackIdx>=0?`${((d.paybackIdx+1)/12).toFixed(1)} yr`:'N/A','Months: '+(d.paybackIdx>=0?d.paybackIdx+1:'\u221E'),'','Months until cumulative net cash flows fully recover the initial CAPEX investment. Shorter payback = lower risk.');
-
- $('#mRevenue').innerHTML =
- cardSmHTML('RevPAH',fmt(d.revPAH),'Revenue per Available Hour','blue','Revenue Per Available Hour. Net revenue divided by total available court-hours (booked + unbooked). Measures capacity monetization.')+
- cardSmHTML('Revenue / m\u00B2',fmt(Math.round(d.revPerSqm)),'Annual net revenue \u00F7 area','blue','Annual net revenue divided by total venue floor area. Benchmarks how efficiently you use your space compared to other venues.')+
- cardSmHTML('Revenue / Court',fmt(Math.round(annRev/Math.max(1,d.totalCourts))),'Year 3 annual','','Year 3 annual revenue divided by number of courts. Useful for comparing venue performance across different sizes.')+
- cardSmHTML('Avg Booked Rate',fmt(Math.round(d.weightedRate)),'Blended peak/off-peak','','Weighted average hourly rate across peak, off-peak, and single court bookings. The effective price per court-hour.');
-
- $('#mCost').innerHTML =
- cardSmHTML('EBITDA Margin',fmtP(d.ebitdaMargin),'Operating profit margin',d.ebitdaMargin>.3?'green':'amber','EBITDA as percentage of net revenue. Measures what share of revenue becomes operating profit. Higher = more efficient operations.')+
- cardSmHTML('OpEx Ratio',fmtP(d.opexRatio),'OpEx \u00F7 Revenue','','Monthly operating expenses divided by net revenue. Lower ratio means more of each euro earned is profit. Target: <60%.')+
- cardSmHTML('Occupancy Cost',fmtP(d.rentRatio),'Rent \u00F7 Revenue',d.rentRatio<.3?'green':'red','Rent as percentage of net revenue. Key metric for rented venues. Above 30% is risky \u2014 it squeezes margins on everything else.')+
- cardSmHTML('Cost / Booked Hour',fmt(d.costPerBookedHr),'All-in cost per hour sold','','Total monthly costs (OpEx + debt service) divided by booked hours. Your true all-in cost to deliver one hour of court time.');
-
- const y3dscr = d.dscr.length>=3?d.dscr[2].dscr:0;
- $('#mDebt').innerHTML =
- cardSmHTML('DSCR (Y3)',y3dscr>99?'\u221E':fmtX(y3dscr),'Min 1.2x for banks',y3dscr>=1.2?'green':'red','Debt Service Coverage Ratio. Annual EBITDA divided by annual loan payments (principal + interest). Banks require minimum 1.2x, prefer 1.5x+.')+
- cardSmHTML('LTV',fmtP(d.ltv),'Loan \u00F7 Total Investment','','Loan-to-Value ratio. Total debt as percentage of total investment cost. Banks typically cap at 80\u201385%. Lower = less financial risk.')+
- cardSmHTML('Debt Yield',fmtP(d.debtYield),'Stab. EBITDA \u00F7 Loan',d.debtYield>.1?'green':'amber','Stabilized EBITDA divided by total loan amount. Alternative lender risk metric. Above 10% is healthy, indicating the loan is well-supported by earnings.')+
- cardSmHTML('Monthly Debt Service',fmt(Math.round(d.monthlyPayment)),'P&I payment','red','Monthly loan payment including both principal repayment and interest. This is a fixed cost that must be paid regardless of revenue.');
-
- $('#mInvest').innerHTML =
- cardSmHTML('CAPEX / Court',fmt(Math.round(d.capexPerCourt)),'Total investment per court','','Total CAPEX divided by number of courts. Key benchmark for comparing build costs across scenarios and competitor venues.')+
- cardSmHTML('CAPEX / m\u00B2',fmt(Math.round(d.capexPerSqm)),'Investment per floor area','','Total CAPEX divided by total venue area. Measures construction cost efficiency per unit of space.')+
- cardSmHTML('Yield on Cost',fmtP(d.yieldOnCost),'Stab. EBITDA \u00F7 CAPEX',d.yieldOnCost>.08?'green':'amber','Stabilized annual EBITDA divided by total CAPEX. Measures the annual return generated by the physical asset. Target: >8%.')+
- cardSmHTML('Exit Value',fmtK(d.exitValue),`${S.exitMultiple}x Y3 EBITDA`,'','Estimated sale value of the business at exit. Calculated as stabilized EBITDA multiplied by the exit EBITDA multiple.');
-
- $('#mOps').innerHTML =
- cardSmHTML('Break-Even Util.',fmtP(d.breakEvenUtil),`${d.breakEvenHrsPerCourt.toFixed(1)} hrs/court/day`,d.breakEvenUtil<.35?'green':'amber','Minimum utilization needed to cover all costs. The lower this is, the safer the business \u2014 more room for underperformance.')+
- cardSmHTML('Y3 Utilization',fmtP(d.avgUtil),'Effective avg utilization','','Average effective utilization in Year 3. Should be at or near your target utilization, accounting for ramp-up completion.')+
- cardSmHTML('Available Hours/mo',fmtN(d.availHoursMonth),'All courts combined','','Total available court-hours per month across all courts. Operating hours \u00D7 days per month \u00D7 number of courts.')+
- cardSmHTML('Operating Months',isIn?'12':'~'+S.season.filter(s=>s>0).length,isIn?'Year-round':'Seasonal','','Number of months per year the venue generates revenue. Indoor: 12. Outdoor: depends on climate, typically 6\u20138 months.');
-}
-
-function renderSeasonChart(){
- const months=[tr('month_jan','Jan'),tr('month_feb','Feb'),tr('month_mar','Mar'),tr('month_apr','Apr'),tr('month_may','May'),tr('month_jun','Jun'),tr('month_jul','Jul'),tr('month_aug','Aug'),tr('month_sep','Sep'),tr('month_oct','Oct'),tr('month_nov','Nov'),tr('month_dec','Dec')];
- renderChart('chartSeason','bar',{
- labels:months,
- datasets:[{data:S.season.map(s=>s*100),backgroundColor:S.season.map(s=>s>0?'rgba(16,185,129,0.5)':'rgba(239,68,68,0.2)'),borderRadius:4}]
- },{scales:{x:{ticks:{color:'#94A3B8'}},y:{max:110,ticks:{color:'#94A3B8'},grid:{color:'rgba(0,0,0,0.04)'}}},plugins:{legend:{display:false}}});
-}
-
-// ── Chart Helper ──────────────────────────────────────────
-function renderChart(canvasId,type,data,opts={}){
- if(charts[canvasId]) charts[canvasId].destroy();
- const ctx = document.getElementById(canvasId);
- if(!ctx) return;
- const defaults = {
- responsive:true, maintainAspectRatio:false, animation:{duration:0},
- scales:{},
- plugins:{legend:{labels:{color:'#64748B',font:{family:'Inter',size:10}}}},
- };
- if(type==='doughnut'||type==='pie'){
- delete defaults.scales;
- defaults.cutout = '55%';
- } else {
- defaults.scales = {
- x:{ticks:{color:'#94A3B8',font:{size:9,family:'Inter'}},grid:{display:false},border:{color:'#E2E8F0'}},
- y:{ticks:{color:'#94A3B8',font:{size:9,family:'Commit Mono'}},grid:{color:'rgba(0,0,0,0.04)'},border:{color:'#E2E8F0'}},
- };
- }
- charts[canvasId] = new Chart(ctx,{type,data,options:deepMerge(defaults,opts)});
-}
-
-function deepMerge(t,s){
- const o={...t};
- for(const k in s){
- if(s[k]&&typeof s[k]==='object'&&!Array.isArray(s[k])&&t[k]&&typeof t[k]==='object') o[k]=deepMerge(t[k],s[k]);
- else o[k]=s[k];
- }
- return o;
-}
-
-// ── Scenario Save/Load ────────────────────────────────────
-function saveScenario(){
- const name = prompt(tr('prompt_scenario_name','Scenario name:'), tr('prompt_scenario_default','My Padel Plan'));
- if(!name) return;
- const csrf = document.querySelector('input[name="csrf_token"]')?.value;
- fetch(window.__PADELNOMICS_SAVE_URL__, {
- method: 'POST',
- headers: {'Content-Type':'application/json', 'X-CSRF-Token': csrf},
- body: JSON.stringify({name, state_json: JSON.stringify(S)}),
- })
- .then(r=>r.json())
- .then(data=>{
- if(data.ok){
- const fb = document.getElementById('save-feedback');
- fb.innerHTML = `
${tr('toast_saved','Scenario saved!')}
`;
- const countBtn = document.getElementById('scenarioListBtn');
- if(countBtn) countBtn.textContent = `${tr('btn_my_scenarios','My Scenarios')} (${data.count})`;
- }
- });
-}
-
-function loadScenario(id){
- fetch(window.__PADELNOMICS_SCENARIO_URL__ + id)
- .then(r=>r.json())
- .then(data=>{
- if(data.state_json){
- const state = JSON.parse(data.state_json);
- Object.assign(S, state);
- buildInputs();
- bindSliders();
- bindPills();
- render();
- document.getElementById('scenario-drawer').classList.remove('open');
- }
- });
-}
-
-let _resetPending = false;
-let _resetTimer = null;
-function resetToDefaults(){
- const btn = document.getElementById('resetDefaultsBtn');
- if(!_resetPending){
- _resetPending = true;
- btn.textContent = tr('btn_reset_confirm','Sure? Reset');
- btn.classList.add('btn-reset--confirm');
- _resetTimer = setTimeout(()=>{
- _resetPending = false;
- btn.textContent = tr('btn_reset','Reset to Defaults');
- btn.classList.remove('btn-reset--confirm');
- }, 3000);
- return;
- }
- clearTimeout(_resetTimer);
- _resetPending = false;
- btn.textContent = tr('btn_reset','Reset to Defaults');
- btn.classList.remove('btn-reset--confirm');
- Object.assign(S, JSON.parse(JSON.stringify(DEFAULTS)));
- _userAdjusted.clear();
- buildInputs();
- bindSliders();
- bindPills();
- render();
-}
-
-// Wire up save button
-document.addEventListener('DOMContentLoaded', () => {
- const resetBtn = document.getElementById('resetDefaultsBtn');
- if(resetBtn) resetBtn.onclick = resetToDefaults;
-
- const saveBtn = document.getElementById('saveScenarioBtn');
- if(saveBtn) saveBtn.onclick = saveScenario;
-
- const listBtn = document.getElementById('scenarioListBtn');
- if(listBtn) {
- listBtn.addEventListener('click', () => {
- document.getElementById('scenario-drawer').classList.add('open');
- });
+document.addEventListener('htmx:afterSettle', initCharts);
+
+// ─── Slider ↔ number sync ─────────────────────────────────────────────────────
+document.addEventListener('input', e => {
+ const el = e.target;
+ if (el.type === 'range') {
+ const num = el.closest('.slider-combo')?.querySelector('[data-sync]');
+ if (num) num.value = el.value;
+ } else if (el.dataset.sync) {
+ const rng = el.closest('.slider-combo')?.querySelector('[type="range"]');
+ if (rng) { rng.value = el.value; rng.dispatchEvent(new InputEvent('input', { bubbles: true })); }
}
});
-// ── Wizard navigation ─────────────────────────────────────
-function buildWizardNav(){
- const dots = $('#wizardDots');
- if(!dots) return;
- dots.innerHTML = WIZ_STEPS.map(s=>{
- const cls = s.n===wizStep?'wiz-dot wiz-dot--active':s.n
${s.n${s.label}`;
- }).join('');
- dots.querySelectorAll('button').forEach(b=>b.onclick=()=>{
- wizStep=parseInt(b.dataset.wiz);
- showWizStep();
+// ─── Toggle buttons (venue / own / glassType / lightingType / country) ────────
+function setToggle(btn, key, val) {
+ const h = document.getElementById('h-' + key);
+ if (h) h.value = val;
+ document.querySelectorAll(`[data-toggle="${key}"]`).forEach(b => {
+ const on = b.dataset.val === val;
+ b.classList.toggle('toggle-btn--active', on);
+ b.classList.toggle('pill-btn--active', on);
+ });
+ updateWizardSections();
+}
+
+function setCountryPreset(code) {
+ const p = (window.__COUNTRY_PRESETS__ || {})[code];
+ if (!p) return;
+ Object.entries(p).forEach(([k, v]) => {
+ const inp = document.querySelector(`#planner-form [name="${k}"]`);
+ const sync = document.querySelector(`[data-sync="${k}"]`);
+ if (inp) inp.value = v;
+ if (sync) sync.value = v;
});
}
-function showWizStep(){
- document.querySelectorAll('.wizard-step').forEach(el=>{
- el.classList.toggle('active',parseInt(el.dataset.wiz)===wizStep);
+// ─── Conditional wizard sections ──────────────────────────────────────────────
+function _setSection(el, visible) {
+ el.style.display = visible ? '' : 'none';
+ // Disable inputs in hidden sections so they're excluded from form submission
+ el.querySelectorAll('input[name]').forEach(i => { i.disabled = !visible; });
+}
+
+function updateWizardSections() {
+ const venue = document.getElementById('h-venue')?.value || 'indoor';
+ const own = document.getElementById('h-own')?.value || 'rent';
+ const combo = venue + '-' + own;
+ document.querySelectorAll('[data-show-venue]').forEach(el =>
+ _setSection(el, el.dataset.showVenue === venue));
+ document.querySelectorAll('[data-show-capex]').forEach(el => {
+ const v = el.dataset.showCapex;
+ _setSection(el, v === combo || v === venue);
+ });
+ document.querySelectorAll('[data-show-opex]').forEach(el => {
+ const v = el.dataset.showOpex;
+ _setSection(el, v === combo || v === venue || v === own);
});
- buildWizardNav();
- renderWizNav();
- if(_lastD) renderWizPreview();
}
-function renderWizPreview(){
- const el=$('#wizPreview');
- if(!el||!_lastD) return;
- const d=_lastD;
- const cf = d.ebitdaMonth - d.monthlyPayment;
- const cfCls = cf>=0?'c-green':'c-red';
- const irrOk = isFinite(d.irr)&&!isNaN(d.irr);
- el.innerHTML=`
-
-
${tr('wiz_capex','CAPEX')}
-
${fmtK(d.capex)}
-
-
-
${tr('wiz_monthly_cf','Monthly CF')}
-
${fmtK(cf)}${tr('wiz_mo','/mo')}
-
-
-
${tr('wiz_irr','IRR')} (${S.holdYears}yr)
-
${irrOk?fmtP(d.irr):'N/A'}
-
`;
+// ─── Tab switching ────────────────────────────────────────────────────────────
+function setActiveTab(tab) {
+ const isWiz = tab === 'assumptions';
+ document.getElementById('h-activeTab').value = tab;
+ document.getElementById('planner-wizard').style.display = isWiz ? '' : 'none';
+ document.getElementById('tab-content').style.display = isWiz ? 'none' : '';
+ const cta = document.getElementById('quoteInlineCta');
+ if (cta) cta.style.display = isWiz ? 'none' : '';
+ document.querySelectorAll('.tab-btn').forEach(b =>
+ b.classList.toggle('tab-btn--active', b.dataset.tab === tab));
}
-function renderWizNav(){
- const el=$('#wizNav');
- if(!el) return;
- let left='', right='';
+// ─── Wizard navigation ────────────────────────────────────────────────────────
+function showWizStep(n) {
+ const steps = document.querySelectorAll('.wizard-step');
+ steps.forEach(s => s.classList.toggle('active', +s.dataset.wiz === n));
+ document.querySelectorAll('.wiz-dot').forEach(d =>
+ d.classList.toggle('wiz-dot--active', +d.dataset.wiz === n));
+ const de = document.documentElement.lang === 'de';
+ const prev = de ? 'Zurück' : 'Back';
+ const next = de ? 'Weiter' : 'Next';
+ const calc = de ? 'Berechnen →' : 'Calculate →';
+ const isLast = n >= steps.length;
+ const nav = document.getElementById('wizNav');
+ nav.innerHTML =
+ (n > 1 ? `` : '') +
+ (isLast
+ ? ``
+ : ``);
+}
- if(wizStep>1){
- left=``;
- } else {
- left='';
+// ─── Scenarios ────────────────────────────────────────────────────────────────
+function _formState() {
+ const fd = new FormData(document.getElementById('planner-form'));
+ const state = {};
+ for (const [k, v] of fd.entries()) {
+ if (k === 'ramp' || k === 'season') { (state[k] = state[k] || []).push(Number(v)); }
+ else { state[k] = v; }
}
-
- if(wizStep<4){
- right=``;
- } else if(wizStep===4){
- right=``;
- }
-
- el.innerHTML=left+right;
+ return state;
}
-// ── Navigate to standalone quote form ─────────────────────
-function goToQuoteForm(){
- const p = new URLSearchParams({
- venue: S.venue,
- courts: S.dblCourts + S.sglCourts,
- glass: S.glassType,
- lighting: S.lightingType,
- country: S.country,
+async function saveScenario() {
+ const de = document.documentElement.lang === 'de';
+ const name = prompt(de ? 'Szenario-Name:' : 'Scenario name:', de ? 'Mein Szenario' : 'My Scenario');
+ if (!name) return;
+ const csrf = document.querySelector('[name="csrf_token"]')?.value || '';
+ const res = await fetch(window.__SAVE_URL__, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrf },
+ body: JSON.stringify({ name, state_json: JSON.stringify(_formState()) }),
});
- if(S.budgetTarget) p.set('budget', S.budgetTarget);
- window.location.href = (window.__PADELNOMICS_QUOTE_URL__ || '/leads/quote') + '?' + p.toString();
+ const fb = document.getElementById('save-feedback');
+ fb.textContent = res.ok ? '✓ Saved' : '✗ Error saving';
+ setTimeout(() => { fb.textContent = ''; }, 2500);
}
-// ── Init ──────────────────────────────────────────────────
-buildNav();
-buildInputs();
-bindSliders();
-bindPills();
-showWizStep();
-// Use server-provided initial data for first render (no API call needed)
-if(_lastD){
- renderWith(_lastD);
- // Update tab visibility
- $$('.tab-btn').forEach(b=>b.classList.toggle('tab-btn--active', b.dataset.tab===activeTab));
- $$('.tab').forEach(t=>t.classList.toggle('active',t.id===`tab-${activeTab}`));
- // Show CTAs
- const _qs=$('#quoteSidebar'); if(_qs) _qs.style.display='block';
- const _qi=$('#quoteInlineCta'); if(_qi) _qi.style.display='block';
-} else {
- render();
+async function loadScenario(id) {
+ const res = await fetch(window.__SCENARIO_URL__ + id);
+ if (!res.ok) return;
+ const row = await res.json();
+ const state = JSON.parse(row.state_json || '{}');
+ Object.entries(state).forEach(([k, v]) => {
+ if (Array.isArray(v)) {
+ document.querySelectorAll(`#planner-form [name="${k}"]`).forEach((inp, i) => {
+ if (v[i] !== undefined) inp.value = v[i];
+ });
+ } else {
+ const inp = document.querySelector(`#planner-form [name="${k}"]`);
+ const sync = document.querySelector(`[data-sync="${k}"]`);
+ if (inp) inp.value = v;
+ if (sync) sync.value = v;
+ }
+ });
+ ['venue', 'own', 'glassType', 'lightingType', 'country'].forEach(key => {
+ const val = document.getElementById('h-' + key)?.value;
+ if (!val) return;
+ document.querySelectorAll(`[data-toggle="${key}"]`).forEach(b => {
+ b.classList.toggle('toggle-btn--active', b.dataset.val === val);
+ b.classList.toggle('pill-btn--active', b.dataset.val === val);
+ });
+ });
+ updateWizardSections();
+ document.querySelector('.tab-btn[data-tab="capex"]').click();
}
+
+// ─── Reset & quote ────────────────────────────────────────────────────────────
+function resetToDefaults() {
+ const D = window.__DEFAULTS__ || {};
+ Object.entries(D).forEach(([k, v]) => {
+ if (Array.isArray(v)) {
+ document.querySelectorAll(`#planner-form [name="${k}"]`).forEach((inp, i) => {
+ if (v[i] !== undefined) inp.value = v[i];
+ });
+ } else {
+ const inp = document.querySelector(`#planner-form [name="${k}"]`);
+ const sync = document.querySelector(`[data-sync="${k}"]`);
+ if (inp) inp.value = v;
+ if (sync) sync.value = v;
+ }
+ });
+ ['venue', 'own', 'glassType', 'lightingType'].forEach(key => {
+ const val = document.getElementById('h-' + key)?.value;
+ if (!val) return;
+ document.querySelectorAll(`[data-toggle="${key}"]`).forEach(b => {
+ b.classList.toggle('toggle-btn--active', b.dataset.val === val);
+ b.classList.toggle('pill-btn--active', b.dataset.val === val);
+ });
+ });
+ updateWizardSections();
+}
+
+function goToQuoteForm() {
+ const fd = new FormData(document.getElementById('planner-form'));
+ const params = new URLSearchParams({
+ courts: fd.get('dblCourts') || '0',
+ venue: fd.get('venue') || 'indoor',
+ });
+ window.location.href = window.__QUOTE_URL__ + '?' + params;
+}
+
+// ─── Init ─────────────────────────────────────────────────────────────────────
+document.addEventListener('DOMContentLoaded', () => {
+ updateWizardSections();
+ document.getElementById('saveScenarioBtn')?.addEventListener('click', saveScenario);
+ document.getElementById('resetDefaultsBtn')?.addEventListener('click', resetToDefaults);
+ // Show signup nudge after 30 s for unauthenticated visitors
+ const bar = document.getElementById('signupBar');
+ if (bar) setTimeout(() => { bar.style.display = ''; }, 30_000);
+});
diff --git a/padelnomics/tests/test_phase0.py b/padelnomics/tests/test_phase0.py
index 8871944..5f0d0e4 100644
--- a/padelnomics/tests/test_phase0.py
+++ b/padelnomics/tests/test_phase0.py
@@ -43,14 +43,15 @@ class TestGuestMode:
assert resp.status_code == 200
async def test_calculate_endpoint_works_without_login(self, client):
- """POST /planner/calculate returns valid JSON for guest."""
+ """POST /planner/calculate returns HTML partial for guest."""
resp = await client.post(
"/en/planner/calculate",
- json={"state": {"dblCourts": 4}},
+ data={"dblCourts": "4", "activeTab": "capex"},
)
assert resp.status_code == 200
- data = await resp.get_json()
- assert "capex" in data
+ html = (await resp.data).decode()
+ # HTMX endpoint returns an HTML partial containing CAPEX data
+ assert "capex" in html.lower() or "metric-card" in html
async def test_scenario_routes_require_login(self, client):
"""Save/load/delete/list scenarios still require auth."""