Spaces:
Running
Redesign Break Even Timeline as cost-vs-revenue lines
Browse filesReplaces 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>
|
@@ -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 =
|
| 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 =
|
| 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 =
|
| 594 |
chart_months = min(reuse_cycles * lease_term_months, len(breakeven_cumulative) - 1)
|
| 595 |
be_months = list(range(chart_months + 1))
|
| 596 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 597 |
|
| 598 |
fig_be = go.Figure()
|
| 599 |
|
| 600 |
-
#
|
| 601 |
-
|
| 602 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 603 |
|
| 604 |
fig_be.add_trace(go.Scatter(
|
| 605 |
-
x=be_months, y=
|
| 606 |
-
|
| 607 |
-
|
| 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=
|
| 616 |
-
line=dict(color=COLORS["
|
|
|
|
|
|
|
| 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=[
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 627 |
|
| 628 |
fig_be.update_layout(
|
| 629 |
-
xaxis_title="Months", yaxis_title="Cumulative
|
| 630 |
-
showlegend=
|
|
|
|
| 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 |
|