mathiassandnes Claude Sonnet 5 commited on
Commit
e3bb96a
·
1 Parent(s): 55228ec

Redesign Break Even Timeline as cost-vs-revenue lines

Browse files

Replaces the single net-profit curve with two cumulative lines: a
straight revenue line from zero and a jagged cost line starting at
the upfront manufacturing spend, stepping up at each cycle's
logistics cost and creeping up from repairs. This reads more
directly than the old cumulative-profit area chart, whose unified
hover tooltip was also showing confusing duplicate zero-valued
entries from the shading traces — now fixed with clean, pre-formatted
hover text and hoverinfo="skip" on the shading layers.

Also fixes the Strategy view's "Cycle Cost" slider, which was wired
up but never actually read by the break-even/revenue math (a
hardcoded 150 NOK was used instead), and changes the default Cycle
Length from 12 to 8 months.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Files changed (1) hide show
  1. app.py +71 -24
app.py CHANGED
@@ -252,7 +252,7 @@ if "strategy_portfolio" not in st.session_state:
252
  if "strategy_reuse_cycles" not in st.session_state:
253
  st.session_state.strategy_reuse_cycles = 4
254
  if "strategy_cycle_length" not in st.session_state:
255
- st.session_state.strategy_cycle_length = 12
256
  if "strategy_customer_base" not in st.session_state:
257
  st.session_state.strategy_customer_base = 1000
258
 
@@ -523,7 +523,7 @@ if selected_view == "Strategy":
523
  # CALCULATIONS
524
  # ============================================================
525
  lease_term_months = st.session_state.strategy_cycle_length
526
- logistics_per_cycle = 150 # NOK - pickup + delivery per cycle
527
  repair_per_cycle = 200 # NOK - average repair/refurb per cycle
528
 
529
  # Per-product economics
@@ -590,46 +590,93 @@ if selected_view == "Strategy":
590
  if monthly_lease == 0:
591
  st.warning("Set a lease price above 0 NOK to see the break-even timeline.")
592
  else:
593
- # Scale chart length with reuse cycles (each cycle = 12 months)
594
  chart_months = min(reuse_cycles * lease_term_months, len(breakeven_cumulative) - 1)
595
  be_months = list(range(chart_months + 1))
596
- be_values = breakeven_cumulative[:chart_months + 1]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
597
 
598
  fig_be = go.Figure()
599
 
600
- # Split into loss (red) and profit (green) fill zones
601
- loss_y = [v if v <= 0 else 0 for v in be_values]
602
- profit_y = [v if v >= 0 else 0 for v in be_values]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
603
 
604
  fig_be.add_trace(go.Scatter(
605
- x=be_months, y=loss_y, mode="none", name="Loss period",
606
- fill="tozeroy", fillcolor="rgba(160, 68, 74, 0.15)",
607
- showlegend=False,
608
- ))
609
- fig_be.add_trace(go.Scatter(
610
- x=be_months, y=profit_y, mode="none", name="Profit zone",
611
- fill="tozeroy", fillcolor="rgba(18, 75, 95, 0.15)",
612
- showlegend=False,
613
  ))
614
  fig_be.add_trace(go.Scatter(
615
- x=be_months, y=be_values, mode="lines", name="Cumulative Profit",
616
- line=dict(color=COLORS["primary"], width=3),
 
 
617
  ))
618
- fig_be.add_hline(y=0, line_color=COLORS["border"], line_width=1)
619
 
620
  if breakeven_month and breakeven_month <= chart_months:
 
621
  fig_be.add_trace(go.Scatter(
622
- x=[breakeven_month], y=[breakeven_cumulative[breakeven_month]],
623
- mode="markers+text", name="Break-even",
624
- marker=dict(size=14, color=COLORS["warning"], symbol="diamond"),
625
- text=[f"Month {breakeven_month}"], textposition="top center",
626
  ))
 
 
 
 
 
 
 
627
 
628
  fig_be.update_layout(
629
- xaxis_title="Months", yaxis_title="Cumulative Profit (NOK)",
630
- showlegend=False, yaxis=dict(separatethousands=True),
 
631
  )
632
  st.plotly_chart(fig_be, use_container_width=True)
 
633
 
634
  st.markdown("---")
635
 
 
252
  if "strategy_reuse_cycles" not in st.session_state:
253
  st.session_state.strategy_reuse_cycles = 4
254
  if "strategy_cycle_length" not in st.session_state:
255
+ st.session_state.strategy_cycle_length = 8
256
  if "strategy_customer_base" not in st.session_state:
257
  st.session_state.strategy_customer_base = 1000
258
 
 
523
  # CALCULATIONS
524
  # ============================================================
525
  lease_term_months = st.session_state.strategy_cycle_length
526
+ logistics_per_cycle = st.session_state.logistics_cost_per_cycle # NOK - pickup + delivery per cycle
527
  repair_per_cycle = 200 # NOK - average repair/refurb per cycle
528
 
529
  # Per-product economics
 
590
  if monthly_lease == 0:
591
  st.warning("Set a lease price above 0 NOK to see the break-even timeline.")
592
  else:
593
+ # Scale chart length with reuse cycles (each cycle = lease_term_months long)
594
  chart_months = min(reuse_cycles * lease_term_months, len(breakeven_cumulative) - 1)
595
  be_months = list(range(chart_months + 1))
596
+
597
+ # Revenue builds steadily from zero. Cost starts at the upfront
598
+ # manufacturing spend, then climbs: a small continuous rise from repairs,
599
+ # plus a step at the start of every cycle when logistics are paid.
600
+ monthly_revenue_val = portfolio_size * monthly_lease
601
+ monthly_repair_val = portfolio_size * (repair_per_cycle / lease_term_months)
602
+ initial_investment = portfolio_size * s_mfg_cost
603
+
604
+ revenue_cum = [monthly_revenue_val * m for m in be_months]
605
+ cost_cum = [
606
+ initial_investment + monthly_repair_val * m
607
+ + portfolio_size * logistics_per_cycle * (m // lease_term_months)
608
+ for m in be_months
609
+ ]
610
+
611
+ be_lead = f'Cost starts at <b>{sfmt(initial_investment)} NOK</b> — the upfront manufacturing spend — and climbs in steps as logistics and repairs are paid. Revenue builds steadily from zero.'
612
+ if breakeven_month and breakeven_month <= chart_months:
613
+ lead_gap = revenue_cum[-1] - cost_cum[-1]
614
+ be_lead += f' Revenue overtakes cost in <b>month {breakeven_month}</b>, and the portfolio is <b>{sfmt(lead_gap)} NOK</b> ahead by month {chart_months}.'
615
+ else:
616
+ be_lead += f' Cost still leads revenue after {chart_months} months at these settings.'
617
+ st.markdown(f'<p style="color: {COLORS["text_muted"]}; font-size: 0.95rem; margin: 0 0 1rem 0;">{be_lead}</p>', unsafe_allow_html=True)
618
+
619
+ # Pre-formatted hover text (matches the rest of the app's number style,
620
+ # instead of Plotly's default SI-suffix auto-formatting)
621
+ rev_hover = [f"+{sfmt(v)} NOK" for v in revenue_cum]
622
+ cost_hover = [f"-{sfmt(v)} NOK" for v in cost_cum]
623
 
624
  fig_be = go.Figure()
625
 
626
+ # Shaded gap between the lines — red while cost leads, teal once revenue overtakes it.
627
+ # hoverinfo is skipped so these don't clutter the tooltip.
628
+ crossing = breakeven_month if (breakeven_month and breakeven_month <= chart_months) else chart_months
629
+ if crossing > 0:
630
+ fig_be.add_trace(go.Scatter(
631
+ x=be_months[:crossing + 1] + be_months[:crossing + 1][::-1],
632
+ y=cost_cum[:crossing + 1] + revenue_cum[:crossing + 1][::-1],
633
+ mode="none", fill="toself", fillcolor="rgba(160, 68, 74, 0.16)",
634
+ hoverinfo="skip", showlegend=False,
635
+ ))
636
+ if crossing < chart_months:
637
+ fig_be.add_trace(go.Scatter(
638
+ x=be_months[crossing:] + be_months[crossing:][::-1],
639
+ y=revenue_cum[crossing:] + cost_cum[crossing:][::-1],
640
+ mode="none", fill="toself", fillcolor="rgba(18, 75, 95, 0.12)",
641
+ hoverinfo="skip", showlegend=False,
642
+ ))
643
 
644
  fig_be.add_trace(go.Scatter(
645
+ x=be_months, y=revenue_cum, mode="lines", name="Cumulative revenue",
646
+ line=dict(color=COLORS["primary"], width=3),
647
+ customdata=rev_hover,
648
+ hovertemplate="Month %{x}<br>Revenue: <b>%{customdata}</b><extra></extra>",
 
 
 
 
649
  ))
650
  fig_be.add_trace(go.Scatter(
651
+ x=be_months, y=cost_cum, mode="lines", name="Cumulative cost",
652
+ line=dict(color=COLORS["negative"], width=3),
653
+ customdata=cost_hover,
654
+ hovertemplate="Month %{x}<br>Cost: <b>%{customdata}</b><extra></extra>",
655
  ))
 
656
 
657
  if breakeven_month and breakeven_month <= chart_months:
658
+ cross_y = revenue_cum[breakeven_month]
659
  fig_be.add_trace(go.Scatter(
660
+ x=[breakeven_month], y=[cross_y], mode="markers",
661
+ marker=dict(size=14, color=COLORS["warning"], symbol="diamond",
662
+ line=dict(color="white", width=1.5)),
663
+ hoverinfo="skip", showlegend=False,
664
  ))
665
+ fig_be.add_annotation(
666
+ x=breakeven_month, y=cross_y,
667
+ text=f"<b>Break-even — month {breakeven_month}</b>",
668
+ showarrow=True, arrowhead=2, arrowcolor=COLORS["warning"], ax=0, ay=-45,
669
+ bgcolor="white", bordercolor=COLORS["warning"], borderwidth=1, borderpad=4,
670
+ font=dict(size=12, color=COLORS["text"]),
671
+ )
672
 
673
  fig_be.update_layout(
674
+ xaxis_title="Months", yaxis_title="Cumulative NOK",
675
+ showlegend=True, yaxis=dict(separatethousands=True),
676
+ margin=dict(t=30),
677
  )
678
  st.plotly_chart(fig_be, use_container_width=True)
679
+ st.caption(f"Cost steps up at the start of each {lease_term_months}-month cycle (logistics) and creeps up between cycles (repairs); revenue accrues steadily from the lease price.")
680
 
681
  st.markdown("---")
682