From ddac1b0f157d52788a8f50613da35cf231f6a7cc Mon Sep 17 00:00:00 2001 From: Wesley Date: Sat, 19 Sep 2026 20:21:17 +0200 Subject: [PATCH 1/7] perf(cost-utils): jump ahead in next_payment_date instead of stepping Walking one billing period at a time is O(gap): a daily subscription running since 2015 is ~4000 steps from today, and callers ask for a whole month of payments, re-walking from the period start each time. Rendering the dashboard for 40 such subscriptions took 2.5s. Skip most of the cadence in one jump, then step the remainder. Day- and week-based cadences are plain timedeltas and always jump; month-family anchors on day 29-31 keep stepping, because month-end clamping makes that sequence non-arithmetic (Jan 31 -> Feb 28 -> Mar 28) and a single jump would land elsewhere. The month jump deliberately under-counts (31 days/month) so it can never overshoot the reference date. Same dashboard: 0.038s. Parametrised tests pin the jump to the step-by-step result across cadences, leap days and month-end anchors. Co-Authored-By: Claude Opus 5 (1M context) --- app/cost_utils.py | 22 ++++++++++++++++++++++ tests/test_cost_utils.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/app/cost_utils.py b/app/cost_utils.py index 7ccf4f1..346649e 100644 --- a/app/cost_utils.py +++ b/app/cost_utils.py @@ -118,6 +118,9 @@ def _advance(d: date, unit: str, n: int) -> date: raise ValueError(f"Unknown unit: {unit}") +_UNIT_MONTHS = {"monthly": 1, "quarterly": 3, "yearly": 12} + + def next_payment_date(start_date_str: str, frequency: str, interval: int, base_unit: str | None = None, reference: date | None = None) -> date: """Return the next payment date >= reference (defaults to today).""" @@ -126,6 +129,25 @@ def next_payment_date(start_date_str: str, frequency: str, interval: int, d = date.fromisoformat(start_date_str) if d >= ref: return d + + # Skip most of the cadence in one jump rather than stepping period by period: + # a daily subscription running since 2015 is thousands of steps from today, and + # callers walk a whole month of payments this way. + # Month-family anchors on day 29-31 are stepped the slow way: month-end clamping + # makes the sequence non-arithmetic (Jan 31 → Feb 28 → Mar 28 …), so one big jump + # would land on a different date than repeated stepping. Day-/week-based cadences + # are plain timedeltas and always jump. + gap = (ref - d).days + jumps = 0 + if unit in ("daily", "weekly"): + jumps = gap // ((1 if unit == "daily" else 7) * n) + elif d.day <= 28: + # 31 days per month deliberately under-counts the months in `gap`, so the + # jump can never overshoot `ref`; the loop below closes the last steps. + jumps = gap // (31 * _UNIT_MONTHS[unit] * n) + if jumps: + d = _advance(d, unit, jumps * n) + while d < ref: d = _advance(d, unit, n) return d diff --git a/tests/test_cost_utils.py b/tests/test_cost_utils.py index 1cec6a5..81e7d75 100644 --- a/tests/test_cost_utils.py +++ b/tests/test_cost_utils.py @@ -78,6 +78,34 @@ def test_next_payment_clamps_end_of_month(): assert nxt == date(2024, 2, 29) +def _step_by_step(start: str, frequency: str, interval: int, base_unit, reference: date): + """The naive walk next_payment_date short-circuits — the reference behaviour.""" + unit, n = cu.resolve(frequency, interval, base_unit) + d = date.fromisoformat(start) + while d < reference: + d = cu._advance(d, unit, n) + return d + + +@pytest.mark.parametrize("start,frequency,interval,base_unit,reference", [ + ("2015-02-01", "daily", 1, None, date(2026, 9, 19)), # thousands of steps + ("2015-01-31", "daily", 1, None, date(2026, 9, 19)), # month-end anchor + ("2015-03-15", "weekly", 1, None, date(2026, 9, 19)), + ("2015-01-31", "monthly", 1, None, date(2026, 9, 19)), # clamps to the 28th + ("2015-01-30", "monthly", 1, None, date(2026, 3, 1)), + ("2015-06-15", "quarterly", 1, None, date(2026, 9, 19)), + ("2016-02-29", "yearly", 1, None, date(2026, 9, 19)), # leap-day anchor + ("2015-01-05", "custom", 7, "monthly", date(2026, 9, 19)), + ("2015-01-05", "custom", 3, "weekly", date(2026, 9, 19)), +]) +def test_next_payment_jump_matches_the_step_by_step_walk(start, frequency, interval, + base_unit, reference): + # next_payment_date skips most of the cadence in one jump; the result must be + # identical to advancing one period at a time (month-end clamping included). + assert (cu.next_payment_date(start, frequency, interval, base_unit, reference) + == _step_by_step(start, frequency, interval, base_unit, reference)) + + # ── range_cost ─────────────────────────────────────────────────────────────── def test_range_cost_daily_is_exact_over_window(): From f0aa91aed52f856f1b994fbc36966e07e9c07067 Mon Sep 17 00:00:00 2001 From: Wesley Date: Sat, 19 Sep 2026 20:21:51 +0200 Subject: [PATCH 2/7] fix(cost-utils): keep the billing day when a price changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A price change is recorded by closing the running period and opening a new one the next day, but every period restarted the billing cycle on its own start date — so changing a price silently moved the billing day, and in the month of the change the subscription was billed twice (once by each period). Anchor each period at the start of the contiguous run it belongs to: back-to- back periods are one continuous subscription and keep their original day, while a gap (cancelled, then resubscribed) does start a new cycle on the new date. The period covering a billing date still supplies its price. Fixes both the doubled charge and the wrong billed/still-due split on the dashboard, and the same dates on the detail page's next-payment list. Co-Authored-By: Claude Opus 5 (1M context) --- app/cost_utils.py | 40 ++++++++++++++++++++++++----- tests/test_cost_utils.py | 55 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 6 deletions(-) diff --git a/app/cost_utils.py b/app/cost_utils.py index 346649e..0e88258 100644 --- a/app/cost_utils.py +++ b/app/cost_utils.py @@ -153,23 +153,51 @@ def next_payment_date(start_date_str: str, frequency: str, interval: int, return d +def cycle_anchors(periods_sorted: list) -> list: + """ + The date each period's billing cycle is counted from, one per period. + + A price change is recorded by closing the running period and opening a new one + the very next day, so *contiguous* periods are one continuous subscription and + keep billing on their original day — changing the price must not move the + billing date. A gap between periods means the subscription actually stopped and + was restarted later, which does begin a new cycle on the new start date. + """ + anchors: list = [] + for i, p in enumerate(periods_sorted): + if i == 0: + anchors.append(p["start_date"]) + continue + prev_end = periods_sorted[i - 1].get("end_date") + resumed_next_day = ( + prev_end is not None + and date.fromisoformat(p["start_date"]) == date.fromisoformat(prev_end) + + timedelta(days=1)) + anchors.append(anchors[i - 1] if resumed_next_day else p["start_date"]) + return anchors + + def upcoming_payments_for_periods(sub: dict, periods: list, count: int = 6, reference: date | None = None) -> list: """ Return up to `count` upcoming [{date, amount}] payments at/after `reference`, - walking each period's cadence (anchored at its start) and clamped to its end. + walking each period's cadence (anchored at its cycle start, see cycle_anchors) + and clamped to the period's own window. """ ref = reference or date.today() out: list = [] freq, interval, base_unit = sub["frequency"], sub.get("interval") or 1, sub.get("base_unit") - for p in sorted(periods, key=lambda x: x["start_date"]): + periods_sorted = sorted(periods, key=lambda x: x["start_date"]) + anchors = cycle_anchors(periods_sorted) + for anchor, p in zip(anchors, periods_sorted): pe = date.fromisoformat(p["end_date"]) if p.get("end_date") else None - anchor = max(ref, date.fromisoformat(p["start_date"])) - d = next_payment_date(p["start_date"], freq, interval, base_unit, anchor) + # Payments are dated from the cycle anchor but only count while they fall + # inside this period — that is what picks up the period's price. + first = max(ref, date.fromisoformat(p["start_date"])) + d = next_payment_date(anchor, freq, interval, base_unit, first) while (pe is None or d <= pe) and len(out) < count: out.append({"date": d, "amount": p["amount"]}) - d = next_payment_date(p["start_date"], freq, interval, base_unit, - d + timedelta(days=1)) + d = next_payment_date(anchor, freq, interval, base_unit, d + timedelta(days=1)) if len(out) >= count: break return out diff --git a/tests/test_cost_utils.py b/tests/test_cost_utils.py index 81e7d75..3d64d3c 100644 --- a/tests/test_cost_utils.py +++ b/tests/test_cost_utils.py @@ -106,6 +106,61 @@ def test_next_payment_jump_matches_the_step_by_step_walk(start, frequency, inter == _step_by_step(start, frequency, interval, base_unit, reference)) +# ── upcoming_payments_for_periods / billing-cycle anchoring ────────────────── + +_MONTHLY = {"frequency": "monthly", "interval": 1, "base_unit": None} + + +def test_price_change_keeps_the_original_billing_day(): + # Recorded as a price change: the old period is closed the day before the new + # one starts. Billing stays on the 20th — it must not move to the 16th. + periods = [{"amount": 9.99, "start_date": "2026-01-20", "end_date": "2026-06-15"}, + {"amount": 15.99, "start_date": "2026-06-16", "end_date": None}] + pays = cu.upcoming_payments_for_periods(_MONTHLY, periods, count=3, + reference=date(2026, 9, 1)) + assert [p["date"] for p in pays] == [date(2026, 9, 20), date(2026, 10, 20), + date(2026, 11, 20)] + assert {p["amount"] for p in pays} == {15.99} + + +def test_price_change_mid_month_is_not_billed_twice(): + # Bills on the 2nd; the price is raised from the 4th of May. May owes exactly one + # payment (on the 2nd, still at the old price) — not one per period. + periods = [{"amount": 59.80, "start_date": "2026-01-02", "end_date": "2026-03-31"}, + {"amount": 55.81, "start_date": "2026-04-01", "end_date": "2026-05-03"}, + {"amount": 54.44, "start_date": "2026-05-04", "end_date": None}] + pays = cu.upcoming_payments_for_periods(_MONTHLY, periods, count=3, + reference=date(2026, 5, 1)) + assert [(p["date"].isoformat(), p["amount"]) for p in pays] == [ + ("2026-05-02", 55.81), ("2026-06-02", 54.44), ("2026-07-02", 54.44)] + + +def test_the_period_covering_the_billing_date_sets_the_price(): + periods = [{"amount": 10.0, "start_date": "2026-01-10", "end_date": "2026-03-09"}, + {"amount": 20.0, "start_date": "2026-03-10", "end_date": None}] + pays = cu.upcoming_payments_for_periods(_MONTHLY, periods, count=4, + reference=date(2026, 2, 1)) + assert [(p["date"].isoformat(), p["amount"]) for p in pays] == [ + ("2026-02-10", 10.0), ("2026-03-10", 20.0), + ("2026-04-10", 20.0), ("2026-05-10", 20.0)] + + +def test_a_gap_between_periods_restarts_the_cycle(): + # Cancelled in March, resubscribed in September on a new day: the cycle restarts. + periods = [{"amount": 10.0, "start_date": "2026-01-12", "end_date": "2026-03-11"}, + {"amount": 10.0, "start_date": "2026-09-25", "end_date": None}] + pays = cu.upcoming_payments_for_periods(_MONTHLY, periods, count=2, + reference=date(2026, 9, 1)) + assert [p["date"] for p in pays] == [date(2026, 9, 25), date(2026, 10, 25)] + + +def test_cycle_anchors_follow_contiguity(): + periods = [{"amount": 1, "start_date": "2026-01-05", "end_date": "2026-02-04"}, + {"amount": 2, "start_date": "2026-02-05", "end_date": "2026-03-31"}, + {"amount": 3, "start_date": "2026-06-01", "end_date": None}] + assert cu.cycle_anchors(periods) == ["2026-01-05", "2026-01-05", "2026-06-01"] + + # ── range_cost ─────────────────────────────────────────────────────────────── def test_range_cost_daily_is_exact_over_window(): From 83c83cd951fb73077c6b7b00806ea5d0f4a5c16c Mon Sep 17 00:00:00 2001 From: Wesley Date: Sat, 19 Sep 2026 20:22:31 +0200 Subject: [PATCH 3/7] feat(ui): chart tooltips, compact charts, tab strip, disclosure Charts were tall and mute: a 640x220 viewBox scaled to ~420px on a wide screen, and a value was only readable from the SVG title attribute. - Every mark (bar, point, breakdown row) carries data-tip-* and sits in a with a transparent full-height hit area, so the whole column is hoverable. CHART_JS renders a floating card on hover and pins it on click or tap, which is the only way to read a value on a touch screen. Marks are focusable and labelled, so the keyboard reaches them too. - Default viewBox height 220 -> 150 (the height is really an aspect ratio), with tighter padding and one fewer gridline: ~30% shorter at the same width. - bar/line charts take tip_labels and notes, so a tooltip can say "January 2026 / EUR 141.56 / 4 subscriptions" while the axis stays at "Jan". - tab_nav() and disclosure(): shadcn-styled tabs (plain links) and a native
show/hide, both JS-free. - plural() moves into fmt so the routes stop redefining it. - badge_cls gained the success/warning kinds the dashboard was already asking for; they silently fell through to the solid default variant. Co-Authored-By: Claude Opus 5 (1M context) --- app/components/__init__.py | 9 ++- app/components/charts.py | 130 ++++++++++++++++++++++++++----------- app/components/fmt.py | 5 ++ app/components/layout.py | 32 +++++++++ app/components/widgets.py | 19 ++++++ app/main.py | 86 ++++++++++++++++++++++++ app/styles.py | 20 ++++++ 7 files changed, 261 insertions(+), 40 deletions(-) diff --git a/app/components/__init__.py b/app/components/__init__.py index b1626cb..8e3ad5b 100644 --- a/app/components/__init__.py +++ b/app/components/__init__.py @@ -8,6 +8,7 @@ category_label, fmt_eur, json_pretty, + plural, truncate, ) from app.components.forms import subscription_form @@ -17,6 +18,7 @@ nav_bar, page_title, section_card, + tab_nav, team_switcher, ) from app.components.widgets import ( @@ -24,6 +26,7 @@ action_menu, alert, badge, + disclosure, dropdown_menu, menu_item_cls, pagination_bar, @@ -32,10 +35,10 @@ ) __all__ = [ - "MONTH_LABELS", "fmt_eur", "category_label", "truncate", "json_pretty", + "MONTH_LABELS", "fmt_eur", "category_label", "truncate", "json_pretty", "plural", "alert", "badge", "status_badge", "action_btn", "action_menu", "pagination_bar", - "dropdown_menu", "menu_item_cls", "select_menu", - "nav_bar", "page_title", "section_card", "collapsible_card", + "dropdown_menu", "menu_item_cls", "select_menu", "disclosure", + "nav_bar", "page_title", "section_card", "collapsible_card", "tab_nav", "team_switcher", "forbidden_page", "bar_chart", "line_chart", "hbar_breakdown", "subscription_form", ] diff --git a/app/components/charts.py b/app/components/charts.py index 8e257a1..f83fe2f 100644 --- a/app/components/charts.py +++ b/app/components/charts.py @@ -1,19 +1,60 @@ """ charts.py — Inline SVG / CSS charts, styled with shadcn token utilities (fill-primary / stroke-border / fill-muted-foreground). + +Every data mark (bar, point, breakdown row) carries `data-tip-*` attributes and is +keyboard-focusable; CHART_JS (app/main.py) turns those into a hover tooltip that +can be pinned by clicking/tapping the mark. Marks are wrapped in a together +with a transparent full-height hit area, so the whole column is hoverable — not +just the painted bar. """ from fasthtml.common import * -from fasthtml.svg import Circle, Line, Polygon, Polyline, Rect, Svg, Text +from fasthtml.svg import Circle, G, Line, Polygon, Polyline, Rect, Svg, Text # Shared SVG viewBox geometry for line_chart / bar_chart. The charts scale to their # container via `w-full` + viewBox; these are the internal coordinate units only. +# Rendered height = container width × (height / _CHART_W), so `height` is really an +# aspect ratio: the flatter it is, the shorter the chart. _CHART_W = 640 # viewBox width -_PAD_L, _PAD_R, _PAD_T, _PAD_B = 48, 12, 12, 28 # plot insets (left axis labels, etc.) +_PAD_L, _PAD_R, _PAD_T, _PAD_B = 46, 10, 10, 24 # plot insets (left axis labels, etc.) +_HEIGHT = 150 # default viewBox height (compact) +_GRID_LINES = 4 # horizontal gridlines (incl. baseline) + +_AXIS_TEXT = "fill-muted-foreground text-[11px]" + + +def _tip(label: str, value: str, note: str | None = None) -> dict: + """Attributes that make an element a tooltip target for CHART_JS.""" + aria = f"{label}: {value}" + (f", {note}" if note else "") + attrs = {"data-tip-label": label, "data-tip-value": value, + "tabindex": "0", "role": "button", "aria-label": aria} + if note: + attrs["data-tip-note"] = note + return attrs + + +def _gridlines(vmax, pad_l, pad_t, plot_h, right_x, fmt) -> list: + """Horizontal gridlines with value labels, bottom (0) to top (vmax).""" + out = [] + for i in range(_GRID_LINES + 1): + frac = i / _GRID_LINES + y = pad_t + plot_h * (1 - frac) + out.append(Line(x1=pad_l, y1=y, x2=right_x, y2=y, + cls="stroke-border" if i else "stroke-border/80")) + out.append(Text(fmt(vmax * frac), x=pad_l - 6, y=y + 3, + text_anchor="end", cls=_AXIS_TEXT)) + return out + +def _at(seq, i, default=None): + """seq[i] when the optional per-mark list is supplied and long enough.""" + return seq[i] if seq and i < len(seq) else default -def line_chart(labels: list, values: list, *, height: int = 220, - fmt=lambda v: f"€{v:,.0f}") -> object: + +def line_chart(labels: list, values: list, *, height: int = _HEIGHT, + fmt=lambda v: f"€{v:,.0f}", tip_fmt=None, notes: list = None, + tip_labels: list = None) -> object: """Responsive line chart (with soft area fill) rendered as inline SVG. Suited to cumulative / running-total series where the trend matters more @@ -21,6 +62,7 @@ def line_chart(labels: list, values: list, *, height: int = 220, if not values or max(values) <= 0: return P("No data for this period.", cls="text-muted-foreground text-center py-8") + tip_fmt = tip_fmt or (lambda v: f"€{v:,.2f}") n = len(values) W, H = _CHART_W, height pad_l, pad_r, pad_t, pad_b = _PAD_L, _PAD_R, _PAD_T, _PAD_B @@ -32,33 +74,38 @@ def line_chart(labels: list, values: list, *, height: int = 220, xs = [pad_l + (plot_w * i / (n - 1) if n > 1 else plot_w / 2) for i in range(n)] ys = [pad_t + plot_h * (1 - (v / vmax if vmax else 0)) for v in values] - elems = [] - for i in range(5): - frac = i / 4 - y = pad_t + plot_h * (1 - frac) - elems.append(Line(x1=pad_l, y1=y, x2=W - pad_r, y2=y, cls="stroke-border")) - elems.append(Text(fmt(vmax * frac), x=pad_l - 6, y=y + 3, - text_anchor="end", cls="fill-muted-foreground text-[11px]")) + elems = _gridlines(vmax, pad_l, pad_t, plot_h, W - pad_r, fmt) pts = " ".join(f"{x:.1f},{y:.1f}" for x, y in zip(xs, ys)) elems.append(Polygon(points=f"{xs[0]:.1f},{base_y:.1f} {pts} {xs[-1]:.1f},{base_y:.1f}", cls="fill-primary/10")) elems.append(Polyline(points=pts, cls="fill-none stroke-primary", stroke_width="2")) - for (x, y), (lab, val) in zip(zip(xs, ys), zip(labels, values)): - elems.append(Circle(cx=x, cy=y, r=3, cls="fill-primary", title=f"{lab}: {fmt(val)}")) - elems.append(Text(lab, x=x, y=H - pad_b + 16, text_anchor="middle", - cls="fill-muted-foreground text-[11px]")) + for i, ((x, y), (lab, val)) in enumerate(zip(zip(xs, ys), zip(labels, values))): + elems.append(G( + Circle(cx=x, cy=y, r=3.5, cls="fill-primary chart-mark"), + Rect(x=x - plot_w / (2 * n), y=pad_t, width=plot_w / n, height=plot_h, + fill="transparent"), + Text(lab, x=x, y=H - pad_b + 15, text_anchor="middle", cls=_AXIS_TEXT), + cls="group cursor-pointer chart-hit", + **_tip(_at(tip_labels, i, lab), tip_fmt(val), _at(notes, i)), + )) return Svg(*elems, viewBox=f"0 0 {W} {H}", cls="w-full h-auto", preserveAspectRatio="xMidYMid meet", role="img") -def bar_chart(labels: list, values: list, *, height: int = 220, - fmt=lambda v: f"€{v:,.0f}") -> object: - """Responsive vertical bar chart rendered as inline SVG.""" +def bar_chart(labels: list, values: list, *, height: int = _HEIGHT, + fmt=lambda v: f"€{v:,.0f}", tip_fmt=None, notes: list = None, + tip_labels: list = None) -> object: + """Responsive vertical bar chart rendered as inline SVG. + + `tip_labels` optionally replaces the (abbreviated) axis label inside the tooltip, + and `notes` supplies a third tooltip line per bar (e.g. "3 subscriptions"). + """ if not values or max(values) <= 0: return P("No data for this period.", cls="text-muted-foreground text-center py-8") + tip_fmt = tip_fmt or (lambda v: f"€{v:,.2f}") n = len(values) W, H = _CHART_W, height pad_l, pad_r, pad_t, pad_b = _PAD_L, _PAD_R, _PAD_T, _PAD_B @@ -68,45 +115,54 @@ def bar_chart(labels: list, values: list, *, height: int = 220, slot = plot_w / n bar_w = slot * 0.62 - elems = [] - for i in range(5): - frac = i / 4 - y = pad_t + plot_h * (1 - frac) - elems.append(Line(x1=pad_l, y1=y, x2=W - pad_r, y2=y, cls="stroke-border")) - elems.append(Text(fmt(vmax * frac), x=pad_l - 6, y=y + 3, - text_anchor="end", cls="fill-muted-foreground text-[11px]")) + elems = _gridlines(vmax, pad_l, pad_t, plot_h, W - pad_r, fmt) for i, (lab, val) in enumerate(zip(labels, values)): bh = (val / vmax) * plot_h if vmax else 0 x = pad_l + slot * i + (slot - bar_w) / 2 y = pad_t + (plot_h - bh) - elems.append(Rect(x=x, y=y, width=bar_w, height=bh, rx=4, - cls="fill-primary transition-[fill] hover:fill-primary/80", - title=f"{lab}: {fmt(val)}")) - elems.append(Text(lab, x=x + bar_w / 2, y=H - pad_b + 16, - text_anchor="middle", cls="fill-muted-foreground text-[11px]")) + elems.append(G( + # The painted bar, then a transparent full-height hit area so the whole + # column (including empty space above a short bar) is hoverable. + Rect(x=x, y=y, width=bar_w, height=bh, rx=3, + cls="fill-primary transition-[fill] group-hover:fill-primary/75 chart-mark"), + Rect(x=pad_l + slot * i, y=pad_t, width=slot, height=plot_h, fill="transparent"), + Text(lab, x=x + bar_w / 2, y=H - pad_b + 15, + text_anchor="middle", cls=_AXIS_TEXT), + cls="group cursor-pointer chart-hit", + **_tip(_at(tip_labels, i, lab), tip_fmt(val), _at(notes, i)), + )) return Svg(*elems, viewBox=f"0 0 {W} {H}", cls="w-full h-auto", preserveAspectRatio="xMidYMid meet", role="img") -def hbar_breakdown(items: list, *, fmt=lambda v: f"€{v:,.2f}") -> object: - """Horizontal bar breakdown from [(label, value)], sorted desc by value.""" +def hbar_breakdown(items: list, *, fmt=lambda v: f"€{v:,.2f}", note_fmt=None) -> object: + """Horizontal bar breakdown from [(label, value)], sorted desc by value. + + Each row is a tooltip target showing its value and share of the total (or + `note_fmt(value, total)` when supplied).""" items = [(lab, v) for lab, v in items if v > 0] if not items: return P("No active subscriptions in this year.", cls="text-muted-foreground text-center py-8") items.sort(key=lambda t: t[1], reverse=True) vmax = items[0][1] + total = sum(v for _, v in items) + note_fmt = note_fmt or ( + lambda v, t: f"{v / t * 100:.1f}% of {fmt(t)} total" if t else "") rows = [] for lab, val in items: pct = (val / vmax) * 100 if vmax else 0 rows.append(Div( - Span(lab, cls="break-words leading-snug", title=lab), - Div(Div(cls="bg-primary h-full rounded-full", style=f"width:{pct:.1f}%"), + Span(lab, cls="break-words leading-snug"), + Div(Div(cls="bg-primary h-full rounded-full transition-[width]", + style=f"width:{pct:.1f}%"), cls="bg-muted rounded-full h-2 overflow-hidden"), - Span(fmt(val), cls="text-right text-muted-foreground"), - cls="grid grid-cols-[6.5rem_1fr_4.5rem] sm:grid-cols-[11rem_1fr_5.5rem] " - "items-center gap-2 mb-2 text-sm", + Span(fmt(val), cls="text-right text-muted-foreground tabular-nums"), + cls="chart-hit grid grid-cols-[6.5rem_1fr_4.5rem] sm:grid-cols-[11rem_1fr_5.5rem] " + "items-center gap-2 mb-1 py-1 px-1.5 -mx-1.5 rounded-md text-sm " + "cursor-pointer transition-colors hover:bg-muted/60", + **_tip(lab, fmt(val), note_fmt(val, total)), )) return Div(*rows) diff --git a/app/components/fmt.py b/app/components/fmt.py index 622f228..c069937 100644 --- a/app/components/fmt.py +++ b/app/components/fmt.py @@ -12,6 +12,11 @@ def fmt_eur(amount: float) -> str: return f"€{amount:,.2f}" +def plural(n: int, noun: str) -> str: + """'1 payment' / '3 payments' — naive -s plural, fine for the nouns we use.""" + return f"{n} {noun}{'' if n == 1 else 's'}" + + def category_label(cat) -> str: """Display name for a category, defaulting empty/None to 'Uncategorized'.""" return (cat or "").strip() or "Uncategorized" diff --git a/app/components/layout.py b/app/components/layout.py index 1c78746..393121d 100644 --- a/app/components/layout.py +++ b/app/components/layout.py @@ -168,6 +168,38 @@ def forbidden_page(ctx, missing): ) +# shadcn Tabs recipe — a muted "track" holding one pill per tab. Tabs here are +# links (?tab=…) rather than client-side panels, so a deep link / reload / redirect +# after a POST all land on the right tab without any JS. +_TABS = ("inline-flex flex-wrap items-center justify-start gap-1 rounded-lg bg-muted " + "p-1 text-muted-foreground mb-4") +_TAB = ("inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-md " + "px-3 py-1.5 text-sm font-medium transition-all hover:text-foreground " + "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring") +_TAB_ACTIVE = "bg-background text-foreground shadow" +_TAB_COUNT = ("rounded bg-foreground/10 px-1.5 text-[11px] font-normal tabular-nums " + "text-muted-foreground") + + +def tab_nav(items: list, active: str, href) -> Div: + """ + Tab strip from [(key, label)] (optionally (key, label, badge_text)); `href(key)` + builds each tab's URL and `active` is the currently-shown key. + """ + tabs = [] + for item in items: + key, label = item[0], item[1] + count = item[2] if len(item) > 2 else None + tabs.append(A( + label, + Span(str(count), cls=_TAB_COUNT) if count else "", + href=href(key), role="tab", + **{"aria-selected": "true" if key == active else "false"}, + cls=_TAB + (" " + _TAB_ACTIVE if key == active else ""), + )) + return Div(*tabs, cls=_TABS, role="tablist") + + def section_card(*children, heading: str = None) -> Div: inner = [H3(heading, cls="mb-3")] if heading else [] inner += list(children) diff --git a/app/components/widgets.py b/app/components/widgets.py index da47d1e..eb93ded 100644 --- a/app/components/widgets.py +++ b/app/components/widgets.py @@ -134,6 +134,25 @@ def action_menu(sub_id: int, name: str, *, can_edit: bool = True, return dropdown_menu("Actions", *items) +_DISCLOSURE_SUMMARY = ("inline-flex items-center gap-1.5 cursor-pointer select-none " + "text-sm text-muted-foreground hover:text-foreground " + "transition-colors list-none marker:hidden " + "[&::-webkit-details-marker]:hidden") + + +def disclosure(label: str, *children, open_: bool = False) -> Details: + """A plain show/hide toggle: a chevron summary over hidden content. + + Native
, so it needs no JS; the chevron flip lives in GLOBALS, scoped + to `details[data-disclosure]`.""" + return Details( + Summary(label, _CHEVRON, cls=_DISCLOSURE_SUMMARY), + Div(*children, cls="mt-2"), + data_disclosure=True, + **({"open": True} if open_ else {}), + ) + + def pagination_bar(page: int, total_pages: int, base_url: str) -> Div: sep = "&" if "?" in base_url else "?" prev_btn = (A("← Prev", href=f"{base_url}{sep}page={page-1}", role="button", diff --git a/app/main.py b/app/main.py index 961eda1..894597b 100644 --- a/app/main.py +++ b/app/main.py @@ -130,6 +130,91 @@ window.addEventListener('resize', repositionOpen); """ +# Chart tooltips: every mark rendered by app/components/charts.py carries +# data-tip-label / data-tip-value / data-tip-note. Hovering (or focusing) a mark +# shows a floating card near it; clicking or tapping pins the card until the next +# click elsewhere or Escape, so touch users and "let me read that number" both work. +CHART_JS = """ +(function () { + var tip = null, pinned = null; + + function node() { + if (!tip) { + tip = document.createElement('div'); + tip.className = 'pointer-events-none fixed z-[70] hidden max-w-[16rem] rounded-md ' + + 'border bg-popover px-3 py-2 text-popover-foreground shadow-md'; + tip.setAttribute('role', 'tooltip'); + document.body.appendChild(tip); + } + return tip; + } + function line(text, cls) { + var d = document.createElement('div'); + d.className = cls; d.textContent = text; return d; + } + function render(t) { + var e = node(); + e.replaceChildren( + line(t.getAttribute('data-tip-label') || '', 'text-xs text-muted-foreground'), + line(t.getAttribute('data-tip-value') || '', 'text-sm font-semibold tabular-nums')); + var note = t.getAttribute('data-tip-note'); + if (note) e.appendChild(line(note, 'text-xs text-muted-foreground mt-0.5')); + e.classList.remove('hidden'); + } + // Clamp inside the viewport; `center` anchors above an element, otherwise the + // card trails the cursor. + function place(x, y, center) { + var e = node(), pad = 8, w = e.offsetWidth, h = e.offsetHeight; + var vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight; + var left = center ? x - w / 2 : x + 14; + e.style.left = Math.max(pad, Math.min(left, vw - pad - w)) + 'px'; + var top = y - h - 12; + if (top < pad) top = Math.min(y + 20, vh - pad - h); + e.style.top = top + 'px'; + } + function placeOn(t) { + var r = t.getBoundingClientRect(); + place(r.left + r.width / 2, r.top, true); + } + function hide() { if (tip) tip.classList.add('hidden'); } + function unpin() { + if (pinned) pinned.classList.remove('chart-pinned'); + pinned = null; hide(); + } + function markOf(ev) { + var el = ev.target; + return (el && el.closest) ? el.closest('[data-tip-label]') : null; + } + + document.addEventListener('mousemove', function (ev) { + if (pinned) return; + var t = markOf(ev); + if (!t) { hide(); return; } + render(t); place(ev.clientX, ev.clientY, false); + }); + document.addEventListener('click', function (ev) { + var t = markOf(ev); + if (!t || t === pinned) { unpin(); return; } + unpin(); + pinned = t; + t.classList.add('chart-pinned'); + render(t); placeOn(t); + }); + document.addEventListener('focusin', function (ev) { + var t = markOf(ev); + if (!t || pinned) return; + render(t); placeOn(t); + }); + document.addEventListener('focusout', function () { if (!pinned) hide(); }); + document.addEventListener('keydown', function (ev) { if (ev.key === 'Escape') unpin(); }); + window.addEventListener('scroll', function () { + if (pinned) placeOn(pinned); else hide(); + }, true); + window.addEventListener('resize', function () { if (pinned) placeOn(pinned); else hide(); }); +})(); +""" + + app, rt = fast_app( secret_key=SECRET_KEY, pico=False, @@ -144,6 +229,7 @@ Script(src="https://cdn.tailwindcss.com"), Script(TAILWIND_CONFIG), Script(DROPDOWN_JS), + Script(CHART_JS), CSRF_JS, Style(GLOBALS, type="text/tailwindcss"), ), diff --git a/app/styles.py b/app/styles.py index 16855a7..7f49eb6 100644 --- a/app/styles.py +++ b/app/styles.py @@ -76,6 +76,24 @@ .dark .theme-icon-light { display: none; } } +/* Chart marks (app/components/charts.py + CHART_JS): keyboard focus ring, and the + highlighted state of the mark whose tooltip is pinned. */ +.chart-hit:focus { outline: none; } +.chart-hit:focus-visible { + outline: 2px solid hsl(var(--ring)); + outline-offset: 2px; + border-radius: 4px; +} +svg .chart-hit.chart-pinned .chart-mark { + fill: hsl(var(--primary) / 0.75); + stroke: hsl(var(--ring)); + stroke-width: 1.5; +} +div.chart-hit.chart-pinned { background-color: hsl(var(--muted)); } + +/* Disclosure toggle (app/components/widgets.py): flip the chevron when open. */ +details[data-disclosure][open] > summary svg { transform: rotate(180deg); } + /* shadcn DropdownMenu (built on
): open animation + trigger chevron flip. */ details[data-dropdown][open] > summary svg { transform: rotate(180deg); } details[data-dropdown] > [role="menu"] { animation: dropdown-in 0.12s ease-out; } @@ -134,8 +152,10 @@ def btn(variant: str = "default", size: str = "default", extra: str = "") -> str "role": "border-transparent bg-secondary text-secondary-foreground", "outline": "text-foreground", "active": "border-transparent bg-success/15 text-success", + "success": "border-transparent bg-success/15 text-success", "inactive": "border-transparent bg-destructive/10 text-destructive", "warn": "border-transparent bg-warning/15 text-warning", + "warning": "border-transparent bg-warning/15 text-warning", "info": "border-transparent bg-info/15 text-info", } From fb9b4092944f4ba5de4bd20df909f41e87e6ece7 Mon Sep 17 00:00:00 2001 From: Wesley Date: Sat, 19 Sep 2026 20:23:02 +0200 Subject: [PATCH 4/7] feat(dashboard): what you pay this month vs last month MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard answered "what did this year cost" but not "what do I owe now". Add a card that walks real billing dates rather than prorated cost, so it shows money as it lands on the card: this month's total, last month's, the delta, the payments still to come this month, and a toggle revealing those already billed. Reorder around it — "Right now" (month card + run-rate) now leads the page and the year selector sits below with everything it governs, including the two year-scoped tiles that used to sit in the run-rate row. The monthly chart gained tooltips: full month name, exact amount, and how many subscriptions were active that month. Co-Authored-By: Claude Opus 5 (1M context) --- app/routes/dashboard.py | 187 ++++++++++++++++++++++++++++++++++++---- tests/test_dashboard.py | 128 +++++++++++++++++++++++++++ 2 files changed, 300 insertions(+), 15 deletions(-) create mode 100644 tests/test_dashboard.py diff --git a/app/routes/dashboard.py b/app/routes/dashboard.py index bb5544e..871ac05 100644 --- a/app/routes/dashboard.py +++ b/app/routes/dashboard.py @@ -1,13 +1,19 @@ """ -dashboard.py — Spend dashboard: run-rate + summary stats, cost cards, charts. +dashboard.py — Spend dashboard: this-month billing, run-rate + summary stats, +cost cards, charts. -Two distinct lenses are surfaced: +Three distinct lenses are surfaced: + • Billing — what is actually charged this calendar month vs the previous one, + walking each subscription's real payment dates (what lands on the card). • Historical — what the selected calendar year actually cost, prorating each subscription over the days it was active (period + price aware). - • Run-rate — what is being paid right now: currently-active subscriptions at + • Run-rate — what is being paid right now: currently-active subscriptions at today's price, annualised. Answers "what's my ongoing commitment". """ +import calendar +from datetime import date + from fasthtml.common import * from app import timeutil @@ -17,16 +23,19 @@ badge, bar_chart, category_label, + disclosure, fmt_eur, hbar_breakdown, nav_bar, page_title, + plural, section_card, ) from app.cost_utils import ( frequency_label, get_annual_cost, monthly_costs_for_year, + upcoming_payments_for_periods, year_cost, ) from app.db import ( @@ -44,23 +53,27 @@ COST_CARDS, COST_LABEL, LINK, + MUTED_SM, PAGE_HEADER, btn, ) ar = APIRouter() +# Payments generated per subscription per month window. A daily subscription bills at +# most 31 times in a month; the walk stops at the window's end either way. +_MAX_MONTH_PAYMENTS = 70 + -def _year_analytics(db, ctx, year: int) -> dict: +def _year_analytics(subs: list, periods_map: dict, year: int) -> dict: """ Spend analytics for `year`, honouring price history and each subscription's active windows, plus current run-rate and a year-over-year comparison. """ - subs = get_all_subscriptions(db, ctx) - periods_map = get_periods_map(db, [s["id"] for s in subs]) today = timeutil.today_iso() - per_sub, per_cat, per_freq, months = [], {}, {}, [0.0] * 12 + per_sub, per_cat, per_freq = [], {}, {} + months, month_counts = [0.0] * 12, [0] * 12 yearly_total, prev_total = 0.0, 0.0 run_rate_annual, active_count = 0.0, 0 @@ -92,6 +105,8 @@ def _year_analytics(db, ctx, year: int) -> dict: yearly_total += sub_year for i, m in enumerate(sub_months): months[i] += m + if m > 0: + month_counts[i] += 1 prev_total = round(prev_total, 2) yearly_total = round(yearly_total, 2) @@ -113,6 +128,7 @@ def _year_analytics(db, ctx, year: int) -> dict: "per_cat": list(per_cat.items()), "per_freq": list(per_freq.items()), "months": months, + "month_counts": month_counts, "run_rate_annual": run_rate_annual, "run_rate_monthly": round(run_rate_annual / 12, 2), "active_count": active_count, @@ -122,6 +138,63 @@ def _year_analytics(db, ctx, year: int) -> dict: } +# ── This month vs last month (actual billing dates) ────────────────────────── + +def _month_bounds(d: date) -> tuple: + """(first, last) day of the calendar month containing `d`.""" + return d.replace(day=1), d.replace(day=calendar.monthrange(d.year, d.month)[1]) + + +def _previous_month(d: date) -> date: + return date(d.year - 1, 12, 1) if d.month == 1 else date(d.year, d.month - 1, 1) + + +def _month_label(d: date) -> str: + return f"{calendar.month_name[d.month]} {d.year}" + + +def _charges_in_month(subs: list, periods_map: dict, any_day: date) -> list: + """ + Every payment charged in the calendar month containing `any_day`, as + [{date, amount, name}] sorted by date — real billing dates, not prorated cost. + """ + start, end = _month_bounds(any_day) + charges = [] + for s in subs: + periods = periods_map.get(s["id"], []) + for pay in upcoming_payments_for_periods(s, periods, + count=_MAX_MONTH_PAYMENTS, reference=start): + if pay["date"] > end: + break + charges.append({"date": pay["date"], "amount": pay["amount"], "name": s["name"]}) + charges.sort(key=lambda c: (c["date"], c["name"])) + return charges + + +def _month_billing(subs: list, periods_map: dict, today: date) -> dict: + """What lands on the card this month and what landed last month.""" + this_charges = _charges_in_month(subs, periods_map, today) + prev_charges = _charges_in_month(subs, periods_map, _previous_month(today)) + remaining = [c for c in this_charges if c["date"] >= today] + paid = [c for c in this_charges if c["date"] < today] + + this_total = round(sum(c["amount"] for c in this_charges), 2) + prev_total = round(sum(c["amount"] for c in prev_charges), 2) + return { + "this_label": _month_label(today), + "prev_label": _month_label(_previous_month(today)), + "this_total": this_total, + "prev_total": prev_total, + "this_count": len(this_charges), + "prev_count": len(prev_charges), + "paid_total": round(this_total - sum(c["amount"] for c in remaining), 2), + "remaining": remaining, + "paid": paid, + "remaining_total": round(sum(c["amount"] for c in remaining), 2), + "delta": round(this_total - prev_total, 2), + } + + def _stat(label, value, caption=None): return Div( Div(label, cls=COST_LABEL), @@ -131,6 +204,76 @@ def _stat(label, value, caption=None): ) +def _charge_row(c: dict, today: date, billed: bool = False): + when = ("today" if c["date"] == today + else (f"in {(c['date'] - today).days}d" if c["date"] > today else "billed")) + return Div( + Span(f"{c['date'].day} {calendar.month_abbr[c['date'].month]}", + cls="w-14 shrink-0 text-muted-foreground tabular-nums"), + Span(c["name"], cls="flex-1 truncate"), + Span(when, cls="text-xs text-muted-foreground mr-3"), + Strong(fmt_eur(c["amount"]), cls="tabular-nums"), + cls="flex items-center gap-2 py-1.5 border-b last:border-0 text-sm" + + (" text-muted-foreground" if billed else ""), + ) + + +def _month_card(b: dict, today: date): + """This month vs last month, by actual billing date, plus what is still due.""" + delta, prev_total = b["delta"], b["prev_total"] + if prev_total > 0: + pct = delta / prev_total * 100 + change_badge = badge(f"{'▲' if delta >= 0 else '▼'} {abs(pct):.0f}%", + "warning" if delta >= 0 else "success") + else: + change_badge = badge("no charges last month", "info") + + change_tile = Div( + Div("vs last month", cls=COST_LABEL), + Div(f"{'+' if delta >= 0 else '−'}{fmt_eur(abs(delta))}", cls=COST_AMOUNT), + Div(change_badge, cls="mt-1"), + cls=COST_CARD, + ) + + if b["remaining"]: + shown = b["remaining"][:6] + rows = [_charge_row(c, today) for c in shown] + if len(b["remaining"]) > len(shown): + rows.append(P(f"+ {plural(len(b['remaining']) - len(shown), 'more payment')} " + f"this month", cls=MUTED_SM + " pt-2")) + still_due = Div( + Div(Span("Still to pay this month", cls="text-sm font-medium"), + Strong(fmt_eur(b["remaining_total"]), cls="tabular-nums"), + cls="flex items-center justify-between mb-1"), + *rows, + ) + else: + still_due = P("No payments are scheduled this month." if b["this_count"] == 0 else + "Nothing left to pay this month — every charge has already been billed.", + cls=MUTED_SM) + + already_billed = ( + disclosure(f"{plural(len(b['paid']), 'payment')} already billed · " + f"{fmt_eur(b['paid_total'])}", + *[_charge_row(c, today, billed=True) for c in b["paid"]]) + if b["paid"] else "" + ) + + return section_card( + Div( + _stat(f"This month · {b['this_label']}", fmt_eur(b["this_total"]), + plural(b["this_count"], "payment")), + _stat(f"Last month · {b['prev_label']}", fmt_eur(b["prev_total"]), + plural(b["prev_count"], "payment")), + change_tile, + cls="grid gap-4 sm:grid-cols-3 mb-4", + ), + still_due, + Div(already_billed, cls="mt-3 pt-3 border-t") if already_billed else "", + heading="What you pay this month", + ) + + def _yoy_badge(cur: float, prev: float, prev_year: int): """A coloured delta badge: spending less than last year is 'good' (success).""" if prev <= 0: @@ -147,10 +290,13 @@ def get(req, session, year: int = None): if (r := require(ctx, Perm.SUB_VIEW)): return r db = get_db() - current_year = timeutil.today().year - year = year or current_year + today = timeutil.today() + year = year or today.year - data = _year_analytics(db, ctx, year) + subs = get_all_subscriptions(db, ctx) + periods_map = get_periods_map(db, [s["id"] for s in subs]) + data = _year_analytics(subs, periods_map, year) + billing = _month_billing(subs, periods_map, today) year_nav = Div( A("← Previous", href=f"/dashboard?year={year - 1}", role="button", @@ -181,9 +327,12 @@ def get(req, session, year: int = None): "if nothing changes"), _stat("Active subscriptions", str(data["active_count"]), f"of {data['total_count']} total"), + cls="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6", + ) + year_cards = Div( _stat("Avg / subscription", fmt_eur(data["avg_per_sub"]), f"across {year}"), _stat("Most expensive", fmt_eur(top[1]) if top else "—", top[0] if top else None), - cls=COST_CARDS, + cls="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-5", ) cost_cards = Div( @@ -195,8 +344,13 @@ def get(req, session, year: int = None): cls=COST_CARDS, ) - monthly_chart = section_card(heading=f"Monthly spend in {year}", - *[bar_chart(MONTH_LABELS, data["months"])]) + monthly_chart = section_card( + heading=f"Monthly spend in {year}", + *[bar_chart(MONTH_LABELS, data["months"], + tip_labels=[f"{calendar.month_name[m + 1]} {year}" for m in range(12)], + notes=[plural(c, "subscription") for c in data["month_counts"]]), + P("Hover a bar for the exact amount — click to keep it open.", cls=MUTED_SM)], + ) breakdown_charts = Div( section_card(heading=f"Spend by subscription ({year})", @@ -214,10 +368,13 @@ def get(req, session, year: int = None): Div(H2("Dashboard ", Small(f"· {scope_label}", cls="text-muted-foreground font-normal")), A("Manage subscriptions →", href="/manage", cls=LINK), cls=PAGE_HEADER), - year_nav, - total_banner, P("Right now", cls="text-sm font-medium text-muted-foreground mb-2"), + _month_card(billing, today), run_rate_cards, + P("Calendar year", cls="text-sm font-medium text-muted-foreground mb-2"), + year_nav, + total_banner, + year_cards, P(f"Average per period ({year})", cls="text-sm font-medium text-muted-foreground mb-2"), cost_cards, monthly_chart, diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py new file mode 100644 index 0000000..806e20d --- /dev/null +++ b/tests/test_dashboard.py @@ -0,0 +1,128 @@ +""" +Dashboard month-billing figures. + +These walk real billing dates (what actually lands on the card), so they are +asserted against hand-computed payment schedules rather than the prorated +year-cost maths covered in test_cost_utils. +""" + +from datetime import date + +from app.routes.dashboard import _charges_in_month, _month_billing +from tests.conftest import post, setup_first_admin + +TODAY = date(2026, 9, 19) + + +def _sub(sub_id, name, frequency="monthly", interval=1, base_unit=None): + return {"id": sub_id, "name": name, "frequency": frequency, + "interval": interval, "base_unit": base_unit} + + +def _period(amount, start, end=None): + return {"amount": amount, "start_date": start, "end_date": end} + + +# ── month billing (pure) ───────────────────────────────────────────────────── + +def test_charges_in_month_lists_each_billing_date(): + subs = [_sub(1, "Netflix")] + pm = {1: [_period(12.99, "2025-03-04")]} + charges = _charges_in_month(subs, pm, TODAY) + assert [(c["date"], c["amount"]) for c in charges] == [(date(2026, 9, 4), 12.99)] + + +def test_charges_in_month_covers_a_daily_subscription(): + subs = [_sub(1, "Daily", "daily")] + pm = {1: [_period(1.0, "2026-01-01")]} + assert len(_charges_in_month(subs, pm, TODAY)) == 30 # September + + +def test_charges_in_month_excludes_ended_periods(): + subs = [_sub(1, "Gone")] + pm = {1: [_period(9.0, "2025-01-10", "2026-08-31")]} + assert _charges_in_month(subs, pm, TODAY) == [] + + +def test_charges_in_month_uses_the_price_of_the_period_it_falls_in(): + # Price rose mid-September: the 20th is billed at the new amount. + subs = [_sub(1, "Netflix")] + pm = {1: [_period(10.0, "2026-01-20", "2026-09-19"), _period(15.0, "2026-09-20")]} + assert [c["amount"] for c in _charges_in_month(subs, pm, TODAY)] == [15.0] + + +def test_month_billing_splits_paid_and_remaining(): + subs = [_sub(1, "Early"), _sub(2, "Late")] + pm = {1: [_period(10.0, "2026-01-05")], # bills the 5th → already billed + 2: [_period(25.0, "2026-01-25")]} # bills the 25th → still to come + b = _month_billing(subs, pm, TODAY) + assert (b["this_total"], b["paid_total"], b["remaining_total"]) == (35.0, 10.0, 25.0) + assert [c["name"] for c in b["remaining"]] == ["Late"] + assert b["this_count"] == 2 + + +def test_month_billing_separates_already_billed_charges(): + subs = [_sub(1, "Early"), _sub(2, "Late")] + pm = {1: [_period(10.0, "2026-01-05")], + 2: [_period(25.0, "2026-01-25")]} + b = _month_billing(subs, pm, TODAY) + assert [c["name"] for c in b["paid"]] == ["Early"] + assert [c["name"] for c in b["remaining"]] == ["Late"] + + +def test_month_billing_compares_against_the_previous_month(): + subs = [_sub(1, "Yearly renewal", "yearly")] + pm = {1: [_period(99.0, "2024-08-10")]} # bills every August + b = _month_billing(subs, pm, TODAY) + assert (b["this_total"], b["prev_total"]) == (0.0, 99.0) + assert b["delta"] == -99.0 + assert (b["this_label"], b["prev_label"]) == ("September 2026", "August 2026") + + +def test_month_billing_rolls_over_january_to_december(): + subs = [_sub(1, "Netflix")] + pm = {1: [_period(12.0, "2024-12-03")]} + b = _month_billing(subs, pm, date(2026, 1, 15)) + assert (b["this_label"], b["prev_label"]) == ("January 2026", "December 2025") + assert (b["this_total"], b["prev_total"]) == (12.0, 12.0) + + +# ── rendering ──────────────────────────────────────────────────────────────── + +def _create(client, **over): + data = {"name": "Netflix", "amount": "12.99", "start_date": "2025-03-04", + "frequency": "monthly"} + data.update(over) + return post(client, "/manage/new", data, token_path="/manage", follow_redirects=True) + + +def test_dashboard_shows_this_and_last_month(client, db): + setup_first_admin(client) + _create(client) + html = client.get("/dashboard").text + assert "What you pay this month" in html + assert "This month" in html and "Last month" in html + assert "data-tip-label" in html # charts carry tooltip data + + +def test_right_now_section_comes_before_the_year_selector(client, db): + setup_first_admin(client) + _create(client) + html = client.get("/dashboard").text + assert html.index("Right now") < html.index("Calendar year") < html.index("?year=") + + +def test_dashboard_splits_still_to_pay_from_already_billed(client, db, monkeypatch): + monkeypatch.setattr("app.timeutil.today", lambda: TODAY) # freeze at 19 Sep 2026 + setup_first_admin(client) + _create(client, name="Early", start_date="2026-01-05") # billed on the 5th + _create(client, name="Late", start_date="2026-01-25") # due on the 25th + html = client.get("/dashboard").text + + assert "Still to pay this month" in html + assert "1 payment already billed" in html + assert "data-disclosure" in html # the toggle itself + # The still-due block lists only the upcoming charge; the billed one is behind + # the toggle, further down the card. + assert html.index("Still to pay this month") < html.index("already billed") + assert html.index("Late") < html.index("already billed") < html.index("Early") From a68a69bc42f39381075b9139e6d82e509ad784e4 Mon Sep 17 00:00:00 2001 From: Wesley Date: Sat, 19 Sep 2026 20:24:10 +0200 Subject: [PATCH 5/7] feat(subscriptions): rebuild the detail page around tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven cards stacked on one page meant scrolling past the price history to reach the audit log. Give it a summary header — name, status, category, current price, next payment, actions — over four tabs: Overview (details, cost per period, next payments), Spend (monthly chart and lifetime figures), Periods (price history and the add form), History (audit trail). Tabs are plain ?tab= links, so they deep-link, survive a reload, and let the period POSTs redirect back to the tab they came from. The History tab and the add-period form only render for roles that may use them, and an unknown or no-longer-permitted tab falls back to Overview. Co-Authored-By: Claude Opus 5 (1M context) --- app/routes/subscriptions.py | 348 ++++++++++++++++++++++++------------ tests/test_detail_page.py | 53 ++++++ tests/test_http_rbac.py | 24 +++ 3 files changed, 311 insertions(+), 114 deletions(-) create mode 100644 tests/test_detail_page.py diff --git a/app/routes/subscriptions.py b/app/routes/subscriptions.py index 79f02f9..2cc7502 100644 --- a/app/routes/subscriptions.py +++ b/app/routes/subscriptions.py @@ -7,6 +7,7 @@ get_subscription(db, ctx, ...); all writes are gated by require(ctx, ...) and audited. """ +import calendar from datetime import date from urllib.parse import quote_plus @@ -20,13 +21,13 @@ badge, bar_chart, category_label, - collapsible_card, fmt_eur, nav_bar, page_title, section_card, status_badge, subscription_form, + tab_nav, ) from app.cost_utils import ( frequency_label, @@ -52,17 +53,30 @@ validate_periods, ) from app.permissions import Perm -from app.styles import INPUT, LINK, MUTED_SM, PAGE_HEADER, TABLE, TABLE_WRAP, btn +from app.styles import ( + CHARTS_GRID, + COST_AMOUNT, + COST_CARD, + COST_LABEL, + INPUT, + LINK, + MUTED_SM, + PAGE_HEADER, + TABLE, + TABLE_WRAP, + btn, +) ar = APIRouter() _PERIODS = ["daily", "weekly", "monthly", "quarterly", "yearly"] -def _detail_redirect(sub_id: int, msg: str = "", kind: str = "warning"): - url = f"/subscriptions/{sub_id}/detail" +def _detail_redirect(sub_id: int, msg: str = "", kind: str = "warning", + tab: str = "periods"): + url = f"/subscriptions/{sub_id}/detail?tab={tab}" if msg: - url += f"?msg={quote_plus(msg)}&msg_kind={kind}" + url += f"&msg={quote_plus(msg)}&msg_kind={kind}" return RedirectResponse(url, status_code=303) @@ -225,7 +239,7 @@ def get(req, session, sub_id: int, period_id: int): return page_title(f"Edit Period – {sub['name']}"), nav_bar(ctx, "manage"), Main( Div(H2(f"Edit Period: {sub['name']}"), - A("← Back", href=f"/subscriptions/{sub_id}/detail", cls=LINK), + A("← Back", href=f"/subscriptions/{sub_id}/detail?tab=periods", cls=LINK), cls=PAGE_HEADER), Form( Label("Amount (€) *", @@ -295,50 +309,75 @@ async def post(req, session, sub_id: int, period_id: int): # ── Subscription detail ────────────────────────────────────────────────────── -@ar("/subscriptions/{sub_id}/detail") -def get(req, session, sub_id: int, msg: str = "", msg_kind: str = "warning"): - ctx = req.scope["ctx"] - if (r := require(ctx, Perm.SUB_VIEW)): return r - db = get_db() - sub = get_subscription(db, ctx, sub_id) - if not sub: - return RedirectResponse("/manage", status_code=303) +# The detail page is split into linked tabs (?tab=…) instead of one long stack of +# cards: the summary header + figures always show, and each tab holds one concern. +_TAB_KEYS = ["overview", "spend", "periods", "history"] - today = timeutil.today() - today_iso = today.isoformat() - periods = get_periods(db, sub_id) - active = is_active_on(periods, today_iso) - price = current_price(periods) - audit_entries = get_audit_for_entity(db, sub_id, "subscription") - freq_lbl = frequency_label(sub["frequency"], sub["interval"] or 1, sub.get("base_unit")) - can_edit = ctx.can(Perm.SUB_EDIT) - can_delete = ctx.can(Perm.SUB_DELETE) - actions = [] - if can_edit: - actions.append( - A("✏️ Edit", href=f"/subscriptions/{sub_id}/edit", role="button", cls=btn("outline"))) - if can_delete: - actions.append(Button("🗑️ Delete", - hx_post=f"/subscriptions/{sub_id}/delete", - hx_confirm=f"Delete '{sub['name']}'? (soft-delete)", - hx_target="body", hx_push_url="true", cls=btn("destructive"))) +def _kv(label, value): + return Div(Div(label, cls="text-xs text-muted-foreground mb-0.5"), Div(value)) + + +def _figure(label, value, caption=None): + return Div( + Div(label, cls=COST_LABEL), + Div(value, cls=COST_AMOUNT), + Div(caption, cls="text-xs text-muted-foreground mt-1 truncate") if caption else "", + cls=COST_CARD, + ) - def kv(label, value): - return Div(Div(label, cls="text-xs text-muted-foreground mb-0.5"), Div(value)) - info = section_card( - H3(sub["name"]), +def _relative_day(d: date, today: date) -> str: + days = (d - today).days + if days == 0: return "today" + if days == 1: return "tomorrow" + return f"in {days} days" if days > 0 else f"{-days} days ago" + + +def _detail_header(sub, price, freq_lbl, active, next_pay, today, actions): + """Name + status + current price + next payment + actions, in one card.""" + subtitle = freq_lbl + if next_pay: + subtitle += f" · next {next_pay['date'].isoformat()} ({_relative_day(next_pay['date'], today)})" + return Div( + Div( + H2(sub["name"], cls="truncate"), + Div(status_badge(active), + badge(category_label(sub.get("category")), "info"), + cls="flex flex-wrap items-center gap-2 mt-2"), + cls="min-w-0 flex-1", + ), Div( - kv("Current Price", Strong(fmt_eur(price)) if price is not None else "—"), - kv("Frequency", freq_lbl), - kv("Status", status_badge(active)), - kv("Category", badge(category_label(sub.get("category")), "info")), - kv("Currency", sub["currency"] or "EUR"), - cls="grid grid-cols-2 sm:grid-cols-3 gap-4 my-4", + Div(fmt_eur(price) if price is not None else "—", + cls="text-3xl font-bold tracking-tight"), + Div(subtitle, cls=MUTED_SM), + cls="sm:text-right", ), - Div(Div("Notes", cls="text-xs text-muted-foreground mb-0.5"), Div(sub["notes"] or "—")), - Div(*actions, cls="flex gap-2 flex-wrap mt-4") if actions else "", + Div(*actions, cls="flex gap-2 flex-wrap") if actions else "", + cls="flex flex-wrap items-start justify-between gap-4 rounded-xl border " + "bg-card p-5 mb-4", + ) + + +def _overview_tab(sub, periods, price, freq_lbl, upcoming, today, can_edit): + started = min((p["start_date"] for p in periods), default=None) + ends = next((p["end_date"] for p in sorted(periods, key=lambda p: p["start_date"], + reverse=True)), None) + + details = section_card( + Div( + _kv("Current price", Strong(fmt_eur(price)) if price is not None else "—"), + _kv("Billing frequency", freq_lbl), + _kv("Category", badge(category_label(sub.get("category")), "info")), + _kv("Currency", sub["currency"] or "EUR"), + _kv("Tracking since", started or "—"), + _kv("Ends", ends or "open-ended"), + cls="grid grid-cols-2 sm:grid-cols-3 gap-4", + ), + Div(Div("Notes", cls="text-xs text-muted-foreground mb-0.5"), + Div(sub["notes"] or "—", cls="whitespace-pre-line"), + cls="mt-4 pt-4 border-t"), + heading="Details", ) costs = section_card( @@ -350,31 +389,71 @@ def kv(label, value): for p in _PERIODS])), cls=TABLE, ), cls=TABLE_WRAP), - heading="Cost Breakdown (current price)", + P("The same price expressed per period — useful for comparing subscriptions " + "that bill on different cadences.", cls=MUTED_SM + " mt-2"), + heading="What it costs per period", ) if price is not None else "" - # Spend-over-time charts for the current year + lifetime total. - spend_section = "" - if periods: - year = today.year - monthly = monthly_costs_for_year(sub, periods, year) - year_total = year_cost(sub, periods, year) - first_start = date.fromisoformat(min(p["start_date"] for p in periods)) - lifetime = range_cost(sub, periods, first_start, today) - - def figure(label, value): - return Div(Div(label, cls="text-xs text-muted-foreground"), - Div(fmt_eur(value), cls="text-2xl font-semibold tracking-tight")) - - spend_section = section_card( - Div(figure(f"This year ({year})", year_total), - figure("All-time", lifetime), - cls="flex gap-10 mb-4"), - Div(P("Monthly spend", cls="text-sm font-medium text-muted-foreground mb-2"), - bar_chart(MONTH_LABELS, monthly)), - heading=f"Spend over time ({year})", + if upcoming: + rows = [ + Div( + Span(pay["date"].isoformat(), cls="tabular-nums"), + Span(Span(_relative_day(pay["date"], today), + cls="text-muted-foreground text-sm mr-3"), + Strong(fmt_eur(pay["amount"]), cls="tabular-nums")), + cls="flex justify-between items-center py-2 border-b last:border-0", + ) + for pay in upcoming + ] + total = round(sum(p["amount"] for p in upcoming), 2) + payments = section_card( + *rows, + P(f"Next {len(upcoming)} payments add up to {fmt_eur(total)}.", + cls=MUTED_SM + " mt-3"), + heading="Next expected payments", + ) + else: + hint = (" Add a period covering today to start tracking payments again." + if can_edit else "") + payments = section_card( + P("No upcoming payments — this subscription is not currently active." + hint, + cls=MUTED_SM), + heading="Next expected payments", ) + return [details, Div(costs, payments, cls=CHARTS_GRID) if costs else payments] + + +def _spend_tab(sub, periods, today): + if not periods: + return [section_card(P("No periods recorded yet, so there is nothing to chart.", + cls=MUTED_SM), heading="Spend over time")] + year = today.year + monthly = monthly_costs_for_year(sub, periods, year) + year_total = year_cost(sub, periods, year) + first_start = date.fromisoformat(min(p["start_date"] for p in periods)) + lifetime = range_cost(sub, periods, first_start, today) + months_tracked = max(1, (today - first_start).days / 30.4375) + + return [ + Div( + _figure(f"This year ({year})", fmt_eur(year_total)), + _figure("All-time", fmt_eur(lifetime), f"since {first_start.isoformat()}"), + _figure("Average / month", fmt_eur(round(lifetime / months_tracked, 2)), + "over the tracked period"), + cls="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-5", + ), + section_card( + bar_chart(MONTH_LABELS, monthly, + tip_labels=[f"{calendar.month_name[m + 1]} {year}" for m in range(12)]), + P("Cost is spread across the days each period was active — hover a bar for " + "the exact amount, click to keep it open.", cls=MUTED_SM), + heading=f"Monthly spend in {year}", + ), + ] + + +def _periods_tab(sub_id, periods, today_iso, can_edit): def period_status(p): if p["start_date"] <= today_iso and (p["end_date"] is None or p["end_date"] >= today_iso): return badge("Active", "active") @@ -382,11 +461,11 @@ def period_status(p): return badge("Upcoming", "info") return badge("Ended", "inactive") - period_rows = [ + rows = [ Tr( - Td(fmt_eur(p["amount"]), cls="nowrap"), - Td(p["start_date"], cls="nowrap"), - Td(p["end_date"] or "open-ended", cls="nowrap"), + Td(fmt_eur(p["amount"]), cls="nowrap tabular-nums"), + Td(p["start_date"], cls="nowrap tabular-nums"), + Td(p["end_date"] or "open-ended", cls="nowrap tabular-nums"), Td(period_status(p), cls="nowrap"), Td( Div( @@ -404,8 +483,23 @@ def period_status(p): for p in periods ] - add_period_form = ( - section_card( + table = (Div(Table( + Thead(Tr(Th("Amount"), Th("Start"), Th("End"), Th("Status"), + *([Th("")] if can_edit else []))), + Tbody(*rows), cls=TABLE, + ), cls=TABLE_WRAP) if rows else P("No periods yet — add the first one below.", + cls=MUTED_SM)) + + out = [section_card( + P("A period is one stretch of time at one price. Add a new period when the " + "price changes or the subscription pauses and resumes; the old one is closed " + "automatically.", cls=MUTED_SM + " mb-3"), + table, + heading="Periods", + )] + + if can_edit: + out.append(section_card( Form( Div( Label("Amount (€) *", @@ -419,63 +513,89 @@ def period_status(p): Label("End Date", Input(name="end_date", type="date", cls=INPUT), cls="grid gap-1.5 text-sm font-medium"), - Div(Button("Add Period", type="submit", cls=btn("outline")), + Div(Button("Add Period", type="submit", cls=btn()), cls="flex items-end"), cls="grid gap-3 sm:grid-cols-4 items-start", ), method="post", action=f"/subscriptions/{sub_id}/periods/add", ), - heading="Add Period", - ) if can_edit else "" - ) - - periods_section = section_card( - heading="Periods", - *([Div(Table( - Thead(Tr(Th("Amount"), Th("Start"), Th("End"), Th("Status"), - *([Th("")] if can_edit else []))), - Tbody(*period_rows), cls=TABLE, - ), cls=TABLE_WRAP)] if period_rows else [P("No periods yet. Add one below.", cls=MUTED_SM)]), - ) - - upcoming = [] - for pay in upcoming_payments_for_periods(sub, periods, count=6, reference=today): - days_from_now = (pay["date"] - today).days - label = "today" if days_from_now == 0 else ( - f"in {days_from_now} day{'s' if days_from_now != 1 else ''}" - if days_from_now > 0 else f"{-days_from_now}d ago" - ) - upcoming.append(Div( - Span(pay["date"].isoformat()), - Span(Span(label, cls="text-muted-foreground text-sm mr-2"), - Strong(fmt_eur(pay["amount"]))), - cls="flex justify-between items-center py-2 border-b last:border-0", + heading="Add a period", )) + return out - next_payments = section_card( - heading="Next Expected Payments", - *(upcoming if upcoming else [P("No upcoming payments — subscription is not " - "currently active.")]), - ) - audit_rows = [ - Tr(Td(a["timestamp"][:16], cls="nowrap"), Td(a["action"], cls="nowrap"), +def _history_tab(audit_entries): + if not audit_entries: + return [section_card(P("No audit entries yet.", cls=MUTED_SM), heading="History")] + rows = [ + Tr(Td(a["timestamp"][:16], cls="nowrap tabular-nums"), Td(a["action"], cls="nowrap"), Td(a["description"])) for a in audit_entries ] - # Audit history is hidden from roles without audit access (e.g. viewers). - audit_section = collapsible_card( - f"Audit Log ({len(audit_entries)} entries)", - Div(Table( - Thead(Tr(Th("Time"), Th("Action"), Th("Description"))), - Tbody(*audit_rows), cls=TABLE, - ), cls=TABLE_WRAP) if audit_rows else P("No audit entries.", cls=MUTED_SM), - ) if ctx.can(Perm.AUDIT_VIEW) else "" + return [section_card( + Div(Table(Thead(Tr(Th("Time"), Th("Action"), Th("Description"))), + Tbody(*rows), cls=TABLE), cls=TABLE_WRAP), + heading=f"History ({len(audit_entries)} entries)", + )] + + +@ar("/subscriptions/{sub_id}/detail") +def get(req, session, sub_id: int, tab: str = "overview", msg: str = "", + msg_kind: str = "warning"): + ctx = req.scope["ctx"] + if (r := require(ctx, Perm.SUB_VIEW)): return r + db = get_db() + sub = get_subscription(db, ctx, sub_id) + if not sub: + return RedirectResponse("/manage", status_code=303) + + today = timeutil.today() + today_iso = today.isoformat() + periods = get_periods(db, sub_id) + active = is_active_on(periods, today_iso) + price = current_price(periods) + freq_lbl = frequency_label(sub["frequency"], sub["interval"] or 1, sub.get("base_unit")) + upcoming = upcoming_payments_for_periods(sub, periods, count=6, reference=today) + + can_edit = ctx.can(Perm.SUB_EDIT) + can_delete = ctx.can(Perm.SUB_DELETE) + can_audit = ctx.can(Perm.AUDIT_VIEW) + + actions = [] + if can_edit: + actions.append( + A("✏️ Edit", href=f"/subscriptions/{sub_id}/edit", role="button", cls=btn("outline"))) + if can_delete: + actions.append(Button("🗑️ Delete", + hx_post=f"/subscriptions/{sub_id}/delete", + hx_confirm=f"Delete '{sub['name']}'? (soft-delete)", + hx_target="body", hx_push_url="true", cls=btn("destructive"))) + + tabs = [("overview", "Overview"), ("spend", "Spend"), + ("periods", "Periods", len(periods))] + audit_entries = get_audit_for_entity(db, sub_id, "subscription") if can_audit else [] + if can_audit: + tabs.append(("history", "History", len(audit_entries))) + # An unknown (or no-longer-permitted) tab falls back to the overview. + if tab not in {t[0] for t in tabs}: + tab = "overview" + + if tab == "spend": + panels = _spend_tab(sub, periods, today) + elif tab == "periods": + panels = _periods_tab(sub_id, periods, today_iso, can_edit) + elif tab == "history": + panels = _history_tab(audit_entries) + else: + panels = _overview_tab(sub, periods, price, freq_lbl, upcoming, today, can_edit) return page_title(sub["name"]), nav_bar(ctx, "manage"), Main( - Div(H2(sub["name"]), A("← Manage", href="/manage", cls=LINK), cls=PAGE_HEADER), + A("← All subscriptions", href="/manage", cls=LINK + " inline-block mb-3"), alert(msg, msg_kind) if msg else "", - info, costs, spend_section, periods_section, add_period_form, next_payments, audit_section, + _detail_header(sub, price, freq_lbl, active, upcoming[0] if upcoming else None, + today, actions), + tab_nav(tabs, tab, lambda k: f"/subscriptions/{sub_id}/detail?tab={k}"), + *panels, ) diff --git a/tests/test_detail_page.py b/tests/test_detail_page.py new file mode 100644 index 0000000..d5f120a --- /dev/null +++ b/tests/test_detail_page.py @@ -0,0 +1,53 @@ +""" +The tabbed subscription detail page: each tab renders its own concern, tabs are +plain links, and period actions come back to the tab they were issued from. +""" + +from tests.conftest import post, setup_first_admin + + +def _create(client, **over): + data = {"name": "Netflix", "amount": "12.99", "start_date": "2025-03-04", + "frequency": "monthly"} + data.update(over) + return post(client, "/manage/new", data, token_path="/manage", follow_redirects=True) + + +def test_detail_tabs_render_their_own_content(client, db): + setup_first_admin(client) + _create(client) + sub_id = next(iter(db["subscriptions"].rows))["id"] + base = f"/subscriptions/{sub_id}/detail" + + overview = client.get(base).text + assert "Next expected payments" in overview + assert "Add a period" not in overview # lives on the Periods tab + + periods = client.get(base + "?tab=periods").text + assert "Add a period" in periods + assert "Next expected payments" not in periods + + spend = client.get(base + "?tab=spend").text + assert "Monthly spend in" in spend + + history = client.get(base + "?tab=history").text + assert "Created 'Netflix'" in history + + +def test_unknown_tab_falls_back_to_overview(client, db): + setup_first_admin(client) + _create(client) + sub_id = next(iter(db["subscriptions"].rows))["id"] + assert "Next expected payments" in client.get( + f"/subscriptions/{sub_id}/detail?tab=nope").text + + +def test_period_actions_return_to_the_periods_tab(client, db): + setup_first_admin(client) + _create(client) + sub_id = next(iter(db["subscriptions"].rows))["id"] + r = post(client, f"/subscriptions/{sub_id}/periods/add", + {"amount": "15.99", "start_date": "2026-06-01"}, + token_path="/manage", follow_redirects=False) + assert r.status_code == 303 + assert r.headers["location"].startswith(f"/subscriptions/{sub_id}/detail?tab=periods&msg=") diff --git a/tests/test_http_rbac.py b/tests/test_http_rbac.py index a0b0d07..9eda116 100644 --- a/tests/test_http_rbac.py +++ b/tests/test_http_rbac.py @@ -62,3 +62,27 @@ def test_viewer_cannot_reach_admin_or_users_pages(client, db, team): login(client, "vi", "password123") assert "not authorized" in client.get("/admin/deleted").text.lower() assert "not authorized" in client.get("/users").text.lower() + + +def test_viewer_detail_page_has_no_history_tab_or_period_form(client, db, team): + """A viewer sees the read-only tabs only; ?tab=history falls back to Overview.""" + from app.db import add_period, get_db + owner = db["users"].get(1)["id"] + sub_id = db["subscriptions"].insert({ + "team_id": team, "created_by": owner, "name": "Netflix", "currency": "EUR", + "frequency": "monthly", "interval": 1, "created_at": "2026-01-01T00:00:00", + "updated_at": "2026-01-01T00:00:00"}).last_pk + add_period(get_db(), sub_id, 12.99, "2026-01-01", None, owner) + + _member(db, team, "vi", "viewer") + login(client, "vi", "password123") + + overview = client.get(f"/subscriptions/{sub_id}/detail") + assert overview.status_code == 200 + assert "?tab=history" not in overview.text + assert "Next expected payments" in overview.text + + periods = client.get(f"/subscriptions/{sub_id}/detail?tab=periods").text + assert "Add a period" not in periods # no edit rights + assert "Next expected payments" in client.get( + f"/subscriptions/{sub_id}/detail?tab=history").text # falls back From 5a79adb015a3efc8556f4d7c0ae82c7221c91e3c Mon Sep 17 00:00:00 2001 From: Wesley Date: Sat, 19 Sep 2026 20:24:50 +0200 Subject: [PATCH 6/7] feat(subscriptions): combine a duplicate into the subscription it belongs to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Importing newer prices for a subscription whose current period is still open-ended creates a second subscription with the same name, and there was no way to fix that short of retyping the periods by hand. combine_subscriptions() folds one into the other: periods are re-pointed rather than copied, so who added each price and when survives, and an open-ended period is closed the day before the next one starts — add_period's price-change rule, applied across the merged timeline so it works whichever side is open. Genuine overlaps between bounded periods are refused and nothing is written; plan_combine() decides that before any change is made. Import no longer leaves the duplicate unexplained: it still creates the row (guessing would be worse) but the result page names the clash and offers Combine, spelling out what will move and which period gets closed. The detail page offers the same for any same-named leftovers. Combining needs subscriptions.edit + subscriptions.delete, is refused across teams (a super admin in cross-team view can see both rows), and audits both sides. Co-Authored-By: Claude Opus 5 (1M context) --- app/components/__init__.py | 4 +- app/components/widgets.py | 18 ++++ app/db/__init__.py | 6 ++ app/db/subscriptions.py | 81 +++++++++++++++ app/routes/import_export.py | 96 +++++++++++++++-- app/routes/subscriptions.py | 81 +++++++++++++++ tests/test_combine.py | 198 ++++++++++++++++++++++++++++++++++++ 7 files changed, 474 insertions(+), 10 deletions(-) create mode 100644 tests/test_combine.py diff --git a/app/components/__init__.py b/app/components/__init__.py index 8e3ad5b..83ae972 100644 --- a/app/components/__init__.py +++ b/app/components/__init__.py @@ -26,6 +26,8 @@ action_menu, alert, badge, + combine_button, + combine_confirm, disclosure, dropdown_menu, menu_item_cls, @@ -37,7 +39,7 @@ __all__ = [ "MONTH_LABELS", "fmt_eur", "category_label", "truncate", "json_pretty", "plural", "alert", "badge", "status_badge", "action_btn", "action_menu", "pagination_bar", - "dropdown_menu", "menu_item_cls", "select_menu", "disclosure", + "dropdown_menu", "menu_item_cls", "select_menu", "disclosure", "combine_button", "combine_confirm", "nav_bar", "page_title", "section_card", "collapsible_card", "tab_nav", "team_switcher", "forbidden_page", "bar_chart", "line_chart", "hbar_breakdown", "subscription_form", diff --git a/app/components/widgets.py b/app/components/widgets.py index eb93ded..e77e7ac 100644 --- a/app/components/widgets.py +++ b/app/components/widgets.py @@ -134,6 +134,24 @@ def action_menu(sub_id: int, name: str, *, can_edit: bool = True, return dropdown_menu("Actions", *items) +def combine_confirm(target_name: str, source_name: str, summary: str, + target_id: int = None, source_id: int = None) -> str: + """The prompt shown before a combine runs — it spells out what will change. + + Duplicates usually share a name, so each side is tagged with its id.""" + tag = lambda name, sid: f"“{name}”" + (f" (#{sid})" if sid else "") # noqa: E731 + return (f"Combine {tag(source_name, source_id)} into {tag(target_name, target_id)}? " + f"This {summary}, then soft-deletes {tag(source_name, source_id)}.") + + +def combine_button(target_id: int, source_id: int, confirm: str, + label: str = "Combine") -> Button: + """Fold subscription `source_id` into `target_id` after an explicit confirm.""" + return Button(label, cls=btn("outline", "sm"), + hx_post=f"/subscriptions/{target_id}/combine/{source_id}", + hx_confirm=confirm, hx_target="body", hx_push_url="true") + + _DISCLOSURE_SUMMARY = ("inline-flex items-center gap-1.5 cursor-pointer select-none " "text-sm text-muted-foreground hover:text-foreground " "transition-colors list-none marker:hidden " diff --git a/app/db/__init__.py b/app/db/__init__.py index 0ac30c1..2334c8c 100644 --- a/app/db/__init__.py +++ b/app/db/__init__.py @@ -21,6 +21,8 @@ from app.db.schema import has_any_users, init_db from app.db.subscriptions import ( add_period, + combine_preview, + combine_subscriptions, current_price, delete_period, get_active_subscriptions, @@ -30,8 +32,10 @@ get_periods_map, get_subscription, is_active_on, + plan_combine, purge_subscription, restore_subscription, + same_name_subscriptions, upcoming_price_change, update_period, validate_periods, @@ -69,6 +73,8 @@ "get_categories", "get_periods", "get_periods_map", "is_active_on", "current_price", "upcoming_price_change", "validate_periods", "add_period", "update_period", "delete_period", "restore_subscription", "purge_subscription", + "combine_subscriptions", "combine_preview", "plan_combine", + "same_name_subscriptions", "get_team", "list_all_teams", "list_user_teams", "get_membership", "list_team_members", "count_team_admins", "member_count", "create_team", "add_member", "set_member_role", "remove_member", "get_membership_by_id", diff --git a/app/db/subscriptions.py b/app/db/subscriptions.py index c3481f9..5954b76 100644 --- a/app/db/subscriptions.py +++ b/app/db/subscriptions.py @@ -231,6 +231,87 @@ def add_period(db, subscription_id: int, amount: float, start_date: str, return "", note +def plan_combine(target_periods: list, source_periods: list) -> tuple: + """ + Work out what merging `source_periods` into `target_periods` would do, without + touching the database. Returns (error, closes, moved) where `closes` is + {period_id: new_end_date} for open-ended periods that the merge closes off. + + The rule is add_period's, applied across the whole merged timeline: an + open-ended period is closed the day before the next period starts, because a + later period is the "further notice" that ends the open run. That is exactly the + case this exists for — importing newer prices for a subscription whose current + period is still open-ended. Genuine overlaps between bounded periods are + rejected and nothing is changed. + """ + ordered = sorted(target_periods + source_periods, + key=lambda p: (p["start_date"], p.get("id") or 0)) + closes: dict = {} + for prev, nxt in zip(ordered, ordered[1:]): + if prev["end_date"] is None and prev["start_date"] < nxt["start_date"]: + closes[prev["id"]] = (date.fromisoformat(nxt["start_date"]) + - timedelta(days=1)).isoformat() + proposed = [{**p, "end_date": closes.get(p["id"], p["end_date"])} for p in ordered] + err = validate_periods(proposed) + if err: + return err, {}, 0 + return "", closes, len(source_periods) + + +def combine_subscriptions(db, target_id: int, source_id: int) -> tuple: + """ + Move every period from `source_id` onto `target_id` and soft-delete the source. + + Periods keep their identity (they are re-pointed, not copied), so who added a + price and when survives the merge. Returns (error, moved, note); on error + nothing is written. Callers are responsible for permissions and auditing. + """ + if target_id == source_id: + return "A subscription cannot be combined with itself.", 0, "" + + target_periods = get_periods(db, target_id) + source_periods = get_periods(db, source_id) + err, closes, moved = plan_combine(target_periods, source_periods) + if err: + return err, 0, "" + + for period_id, end_date in closes.items(): + db["subscription_periods"].update(period_id, {"end_date": end_date}) + for p in source_periods: + db["subscription_periods"].update(p["id"], {"subscription_id": target_id}) + + now = timeutil.now_iso() + db["subscriptions"].update(target_id, {"updated_at": now}) + closed_target = [c for pid, c in closes.items() + if pid in {p["id"] for p in target_periods}] + note = (f"Closed the open-ended period on {min(closed_target)}." if closed_target else "") + return "", moved, note + + +def combine_preview(db, target_id: int, source_id: int) -> tuple: + """(error, summary) describing what combining `source_id` into `target_id` does.""" + target_periods = get_periods(db, target_id) + source_periods = get_periods(db, source_id) + err, closes, moved = plan_combine(target_periods, source_periods) + if err: + return err, "" + closed = [c for pid, c in closes.items() + if pid in {p["id"] for p in target_periods}] + summary = f"moves {moved} period{'' if moved == 1 else 's'} across" + if closed: + summary += f" and closes the open-ended period on {min(closed)}" + return "", summary + + +def same_name_subscriptions(db, ctx, name: str, exclude_id: int) -> list: + """Other live subscriptions in scope whose name matches `name` (case-insensitive).""" + tc, tp = _team_clause(ctx) + return rows_as_dicts(db, + f"SELECT * FROM subscriptions WHERE {tc} AND deleted_at IS NULL " + "AND id != ? AND LOWER(TRIM(name)) = LOWER(TRIM(?)) ORDER BY id ASC", + list(tp) + [exclude_id, name]) + + def update_period(db, subscription_id: int, period_id: int, amount: float, start_date: str, end_date: str) -> str: """Update a period, re-validating against the others. Returns "" or an error.""" diff --git a/app/routes/import_export.py b/app/routes/import_export.py index 428d933..6a03f51 100644 --- a/app/routes/import_export.py +++ b/app/routes/import_export.py @@ -21,11 +21,20 @@ from app import timeutil from app.authz import require, writable_team -from app.components import alert, nav_bar, page_title, section_card +from app.components import ( + alert, + combine_button, + combine_confirm, + nav_bar, + page_title, + plural, + section_card, +) from app.cost_utils import normalise_cadence from app.db import ( add_period, audit, + combine_preview, get_all_subscriptions, get_db, get_periods_map, @@ -209,8 +218,16 @@ def _parse_json(text: str) -> tuple: def _import_subs(db, ctx, parsed) -> tuple: - """Create each parsed subscription + its periods. Returns (created, periods, errors).""" - created, periods_added, errors = 0, 0, [] + """ + Create each parsed subscription + its periods. + Returns (created, periods, errors, clashes), where `clashes` names the created + rows whose name was already taken — the import page offers to combine those + into the subscription that already existed rather than leaving a duplicate. + """ + created, periods_added, errors, clashes = 0, 0, [], [] + # Names already in the team, before this import; created rows join the map so a + # file listing the same name twice also reports the second one as a duplicate. + existing = {s["name"].strip().lower(): s["id"] for s in get_all_subscriptions(db, ctx)} now = timeutil.now_iso() for sub in parsed: freq, interval, base_unit = normalise_cadence(sub["frequency"], sub["interval"], @@ -236,18 +253,73 @@ def _import_subs(db, ctx, parsed) -> tuple: n += 1 periods_added += n created += 1 + key = sub["name"].strip().lower() + if key in existing: + clashes.append({"target_id": existing[key], "source_id": sub_id, + "name": sub["name"], "periods": n}) + else: + existing[key] = sub_id audit(ctx, "CREATE", "subscription", sub_id, sub["name"], f"Imported '{sub['name']}' with {n} period(s)", new_values={"name": sub["name"], "frequency": freq, "category": category}, db=db) - return created, periods_added, errors + return created, periods_added, errors, clashes # ── Import / Export page ──────────────────────────────────────────────────────── -def _result_block(created: int, periods_added: int, errors: list): - kind = "success" if (created and not errors) else ("warning" if created else "error") +def _combine_card(db, ctx, clashes: list): + """ + Prompt to fold each freshly-imported duplicate into the subscription that + already existed — the usual case being newer prices imported for a + subscription whose current period is still open-ended. + """ + if not clashes: + return "" + can_combine = ctx.can(Perm.SUB_EDIT) and ctx.can(Perm.SUB_DELETE) + rows = [] + for c in clashes: + err, preview = combine_preview(db, c["target_id"], c["source_id"]) + if not can_combine: + action = Span("ask a team admin to combine them", cls=MUTED_SM) + elif err: + action = Span(f"cannot combine: {err}", cls="text-sm text-destructive") + else: + action = combine_button( + c["target_id"], c["source_id"], + combine_confirm(c["name"], c["name"], preview, + c["target_id"], c["source_id"]), label="Combine") + rows.append(Div( + Div(Strong(c["name"]), + Div(f"imported as #{c['source_id']} with {plural(c['periods'], 'period')} · " + f"already existed as #{c['target_id']}" + + (f" — combining {preview}" if preview else ""), + cls=MUTED_SM), + cls="min-w-0"), + Div(action, + A("Open", href=f"/subscriptions/{c['target_id']}/detail?tab=periods", + role="button", cls=btn("outline", "sm")), + cls="flex items-center gap-2 shrink-0"), + cls="flex items-start justify-between gap-3 flex-wrap py-2 border-b last:border-0", + )) + return section_card( + P("These were imported as new subscriptions because a subscription with the " + "same name already exists. Combining moves the imported periods into the " + "existing subscription and closes its open-ended period the day before the " + "next one starts.", cls=MUTED_SM + " mb-2"), + *rows, + heading=f"Combine {plural(len(clashes), 'duplicate')}?", + ) + + +def _result_block(created: int, periods_added: int, errors: list, + clashes: list = (), db=None, ctx=None): + kind = ("error" if not created + else ("warning" if (errors or clashes) else "success")) summary = (f"Imported {created} subscription(s) and {periods_added} period(s)." if created else "No subscriptions were imported.") + if clashes: + summary += (f" {plural(len(clashes), 'name')} already existed — combine them " + f"below to keep one subscription with its full price history.") issues = ( section_card( P(f"{len(errors)} issue(s) while importing:", cls="font-medium text-sm mb-2"), @@ -255,7 +327,8 @@ def _result_block(created: int, periods_added: int, errors: list): (P(f"…and {len(errors) - 30} more.", cls=MUTED_SM) if len(errors) > 30 else ""), ) if errors else "" ) - return Div(alert(summary, kind), issues) + combine = _combine_card(db, ctx, list(clashes)) if clashes else "" + return Div(alert(summary, kind), combine, issues) def _page(ctx, result=None): @@ -292,6 +365,9 @@ def _page(ctx, result=None): "(repeat the identity columns on each period row). Leave the " "amount/start_date columns blank for a subscription with no " "periods yet.", cls=MUTED_SM), + P("A name that already exists in this team is imported as a separate " + "subscription and then offered for combining, which moves the new " + "periods into the existing one.", cls=MUTED_SM), P("JSON — an object with a \"subscriptions\" array; each entry carries " "its identity fields plus a nested \"periods\" list.", cls="text-sm mt-2"), @@ -351,5 +427,7 @@ async def post(req, session): return _page(ctx, alert(f"Too many subscriptions in one import ({len(parsed)}); " f"the limit is {MAX_IMPORT_SUBS}.", "error")) - created, periods_added, import_errors = _import_subs(db=get_db(), ctx=ctx, parsed=parsed) - return _page(ctx, _result_block(created, periods_added, errors + import_errors)) + db = get_db() + created, periods_added, import_errors, clashes = _import_subs(db=db, ctx=ctx, parsed=parsed) + return _page(ctx, _result_block(created, periods_added, errors + import_errors, + clashes, db, ctx)) diff --git a/app/routes/subscriptions.py b/app/routes/subscriptions.py index 2cc7502..f1cdec3 100644 --- a/app/routes/subscriptions.py +++ b/app/routes/subscriptions.py @@ -21,9 +21,12 @@ badge, bar_chart, category_label, + combine_button, + combine_confirm, fmt_eur, nav_bar, page_title, + plural, section_card, status_badge, subscription_form, @@ -41,6 +44,8 @@ from app.db import ( add_period, audit, + combine_preview, + combine_subscriptions, current_price, delete_period, get_audit_for_entity, @@ -49,11 +54,13 @@ get_periods, get_subscription, is_active_on, + same_name_subscriptions, update_period, validate_periods, ) from app.permissions import Perm from app.styles import ( + ALERT, CHARTS_GRID, COST_AMOUNT, COST_CARD, @@ -539,6 +546,32 @@ def _history_tab(audit_entries): )] +def _duplicate_banner(sub_id, sub, duplicates, db): + """Offer to fold same-named subscriptions into this one, saying what that does.""" + rows = [] + for d in duplicates: + err, summary = combine_preview(db, sub_id, d["id"]) + periods = get_periods(db, d["id"]) + label = (f"#{d['id']} · {plural(len(periods), 'period')}" + + (f" · from {periods[0]['start_date']}" if periods else "")) + rows.append(Div( + Span(label, cls=MUTED_SM), + (Span(f"cannot combine: {err}", cls="text-sm text-destructive") if err + else combine_button(sub_id, d["id"], + combine_confirm(sub["name"], d["name"], summary, + sub_id, d["id"]))), + cls="flex items-center justify-between gap-3 py-1 flex-wrap", + )) + return Div( + P(f"{plural(len(duplicates), 'other subscription')} in this team " + f"named “{sub['name']}”. Combining moves their periods here and closes " + f"an open-ended period the day before the next one starts.", + cls="text-sm mb-2"), + *rows, + cls=ALERT["warning"], + ) + + @ar("/subscriptions/{sub_id}/detail") def get(req, session, sub_id: int, tab: str = "overview", msg: str = "", msg_kind: str = "warning"): @@ -589,9 +622,13 @@ def get(req, session, sub_id: int, tab: str = "overview", msg: str = "", else: panels = _overview_tab(sub, periods, price, freq_lbl, upcoming, today, can_edit) + duplicates = (same_name_subscriptions(db, ctx, sub["name"], sub_id) + if (can_edit and can_delete) else []) + return page_title(sub["name"]), nav_bar(ctx, "manage"), Main( A("← All subscriptions", href="/manage", cls=LINK + " inline-block mb-3"), alert(msg, msg_kind) if msg else "", + _duplicate_banner(sub_id, sub, duplicates, db) if duplicates else "", _detail_header(sub, price, freq_lbl, active, upcoming[0] if upcoming else None, today, actions), tab_nav(tabs, tab, lambda k: f"/subscriptions/{sub_id}/detail?tab={k}"), @@ -599,6 +636,50 @@ def get(req, session, sub_id: int, tab: str = "overview", msg: str = "", ) +# ── Combine two subscriptions ──────────────────────────────────────────────── + +@ar("/subscriptions/{target_id}/combine/{source_id}") +async def post(req, session, target_id: int, source_id: int): + """ + Fold `source_id` into `target_id`: its periods move across and it is + soft-deleted. This is the cure for a duplicate — most often an import that + added newer prices as a second subscription instead of as periods of the + existing one, whose current period is still open-ended. + """ + ctx = req.scope["ctx"] + if (r := require(ctx, Perm.SUB_EDIT, Perm.SUB_DELETE)): return r + db = get_db() + target = get_subscription(db, ctx, target_id) + source = get_subscription(db, ctx, source_id) + if not target or not source: + return RedirectResponse("/manage", status_code=303) + # A super admin in cross-team view can reach both rows; combining across teams + # would silently move one team's price history into another's. + if target["team_id"] != source["team_id"]: + return _detail_redirect(target_id, "Those subscriptions belong to different " + "teams and cannot be combined.", "error") + + err, moved, note = combine_subscriptions(db, target_id, source_id) + if err: + return _detail_redirect(target_id, f"Could not combine: {err}", "error", tab="periods") + + now = timeutil.now_iso() + db["subscriptions"].update(source_id, { + "deleted_at": now, "deleted_by": ctx.user["id"], "updated_at": now, + }) + desc = (f"Combined '{source['name']}' (#{source_id}) into '{target['name']}' " + f"(#{target_id}): moved {plural(moved, 'period')}") + if note: + desc += f" — {note}" + audit(ctx, "COMBINE", "subscription", target_id, target["name"], desc, + new_values={"merged_from": source["name"], "merged_from_id": source_id, + "periods_moved": moved}) + audit(ctx, "DELETE", "subscription", source_id, source["name"], + f"Combined into '{target['name']}' (#{target_id}) and soft-deleted", + new_values={"deleted_at": now, "merged_into_id": target_id}) + return _detail_redirect(target_id, desc + ".", "success", tab="periods") + + # ── Soft-delete a subscription ─────────────────────────────────────────────── @ar("/subscriptions/{sub_id}/delete") diff --git a/tests/test_combine.py b/tests/test_combine.py new file mode 100644 index 0000000..0f46ff6 --- /dev/null +++ b/tests/test_combine.py @@ -0,0 +1,198 @@ +""" +Combining a duplicate subscription into the one it should have been part of. + +The case this exists for: an import brings newer prices for a subscription whose +current period is still open-ended, so they land as a second subscription with the +same name. Combining moves those periods across and closes the open-ended one. +""" + +from app.db import combine_subscriptions, get_periods, plan_combine +from tests.conftest import csrf_token, post, setup_first_admin + + +def _periods(*specs): + """[(id, amount, start, end)] → period dicts as the data layer returns them.""" + return [{"id": i, "amount": a, "start_date": s, "end_date": e} for i, a, s, e in specs] + + +# ── planning (pure) ────────────────────────────────────────────────────────── + +def test_plan_closes_an_open_ended_period_before_the_incoming_one(): + target = _periods((1, 9.99, "2026-01-20", None)) + source = _periods((2, 15.99, "2026-06-16", None)) + err, closes, moved = plan_combine(target, source) + assert err == "" + assert closes == {1: "2026-06-15"} # closed the day before the new period + assert moved == 1 + + +def test_plan_rejects_a_genuine_overlap(): + target = _periods((1, 9.99, "2026-01-01", "2026-12-31")) + source = _periods((2, 15.99, "2026-06-01", "2026-08-31")) + err, closes, moved = plan_combine(target, source) + assert "overlap" in err.lower() + assert (closes, moved) == ({}, 0) + + +def test_plan_leaves_non_overlapping_periods_alone(): + target = _periods((1, 9.99, "2026-01-01", "2026-03-31")) + source = _periods((2, 15.99, "2026-04-01", None)) + err, closes, moved = plan_combine(target, source) + assert (err, closes, moved) == ("", {}, 1) + + +def test_plan_closes_the_incoming_open_period_when_the_target_continues_after_it(): + # Imported history that predates what is already recorded. + target = _periods((1, 15.99, "2026-06-16", None)) + source = _periods((2, 9.99, "2026-01-20", None)) + err, closes, moved = plan_combine(target, source) + assert (err, closes, moved) == ("", {2: "2026-06-15"}, 1) + + +# ── the merge itself ───────────────────────────────────────────────────────── + +def _create(client, name, amount="9.99", start="2026-01-20"): + return post(client, "/manage/new", + {"name": name, "amount": amount, "start_date": start, + "frequency": "monthly"}, token_path="/manage", follow_redirects=True) + + +def test_combine_moves_periods_and_keeps_their_identity(client, db): + setup_first_admin(client) + _create(client, "Netflix") + _create(client, "Netflix", amount="15.99", start="2026-06-16") + moved_id = get_periods(db, 2)[0]["id"] + + err, moved, note = combine_subscriptions(db, 1, 2) + assert (err, moved) == ("", 1) + assert "2026-06-15" in note + + periods = get_periods(db, 1) + assert [(p["amount"], p["start_date"], p["end_date"]) for p in periods] == [ + (9.99, "2026-01-20", "2026-06-15"), (15.99, "2026-06-16", None)] + assert periods[1]["id"] == moved_id # re-pointed, not copied + assert get_periods(db, 2) == [] + + +def test_combine_refuses_to_merge_a_subscription_into_itself(client, db): + setup_first_admin(client) + _create(client, "Netflix") + err, moved, _ = combine_subscriptions(db, 1, 1) + assert err and moved == 0 + + +def test_combine_changes_nothing_when_the_periods_overlap(client, db): + setup_first_admin(client) + _create(client, "Gym") + post(client, "/subscriptions/1/periods/1/edit", + {"amount": "9.99", "start_date": "2026-01-01", "end_date": "2026-12-31"}, + token_path="/manage", follow_redirects=True) + _create(client, "Gym", amount="12.00", start="2026-06-01") + post(client, "/subscriptions/2/periods/2/edit", + {"amount": "12.00", "start_date": "2026-06-01", "end_date": "2026-08-31"}, + token_path="/manage", follow_redirects=True) + + err, moved, _ = combine_subscriptions(db, 1, 2) + assert "overlap" in err.lower() and moved == 0 + assert len(get_periods(db, 1)) == 1 and len(get_periods(db, 2)) == 1 + + +# ── HTTP: the import prompt and the combine endpoint ───────────────────────── + +_CSV = (b"name,category,frequency,interval,base_unit,notes,amount,start_date,end_date\n" + b"Netflix,,monthly,1,,,15.99,2026-06-16,\n") + + +def _import(client, body=_CSV, filename="new.csv"): + return client.post("/import", files={"file": (filename, body, "text/csv")}, + data={"csrf_token": csrf_token(client, "/import")}, + follow_redirects=True) + + +def test_import_offers_to_combine_a_name_that_already_exists(client, db): + setup_first_admin(client) + _create(client, "Netflix") + html = _import(client).text + + assert "already existed" in html + assert "/subscriptions/1/combine/2" in html + # The prompt says what will happen before anything is merged. + assert "closes the open-ended period on 2026-06-15" in html + assert db["subscriptions"].count == 2 # nothing merged yet + + +def test_import_without_a_name_clash_offers_nothing_to_combine(client, db): + setup_first_admin(client) + _create(client, "Spotify") + html = _import(client).text + assert "combine" not in html.lower() + + +def test_combine_endpoint_merges_and_soft_deletes_the_duplicate(client, db): + setup_first_admin(client) + _create(client, "Netflix") + _import(client) + + r = post(client, "/subscriptions/1/combine/2", token_path="/manage", + follow_redirects=False) + assert r.status_code == 303 + assert r.headers["location"].startswith("/subscriptions/1/detail?tab=periods") + + assert db["subscriptions"].get(2)["deleted_at"] is not None + assert db["subscriptions"].get(1)["deleted_at"] is None + assert [(p["amount"], p["end_date"]) for p in get_periods(db, 1)] == [ + (9.99, "2026-06-15"), (15.99, None)] + actions = [a["action"] for a in db["audit_log"].rows] + assert "COMBINE" in actions + + +def test_detail_page_offers_to_combine_a_same_named_subscription(client, db): + setup_first_admin(client) + _create(client, "Netflix") + _import(client) + html = client.get("/subscriptions/1/detail").text + assert "named “Netflix”" in html + assert "/subscriptions/1/combine/2" in html + + +def test_viewer_is_not_offered_the_combine_action(client, db, monkeypatch): + from app.auth import create_user + from app.db import add_member + setup_first_admin(client) + _create(client, "Netflix") + _import(client) + uid = create_user("vi", "password123", global_role="user") + add_member(db, 1, uid, "viewer", created_by=uid) + + from tests.conftest import login + login(client, "vi", "password123") + html = client.get("/subscriptions/1/detail").text + assert "/combine/" not in html + r = post(client, "/subscriptions/1/combine/2", token_path="/manage", + follow_redirects=True) + assert "not authorized" in r.text.lower() + assert db["subscriptions"].get(2)["deleted_at"] is None + + +def test_combining_across_teams_is_refused(client, db): + """A super admin in cross-team view can see both rows; merging them would move + one team's price history into another's.""" + from app.db import add_member, create_team, get_db + setup_first_admin(client) + _create(client, "Netflix") # team 1 + other = create_team(db, "Other", "", created_by=1) + add_member(db, other, 1, "team_admin", created_by=1) + sub_id = db["subscriptions"].insert({ + "team_id": other, "created_by": 1, "name": "Netflix", "currency": "EUR", + "frequency": "monthly", "interval": 1, "created_at": "2026-01-01T00:00:00", + "updated_at": "2026-01-01T00:00:00"}).last_pk + from app.db import add_period + add_period(get_db(), sub_id, 15.99, "2026-06-16", None, 1) + + post(client, "/teams/switch", {"team_id": "__all__"}, token_path="/teams", + follow_redirects=True) + r = post(client, f"/subscriptions/1/combine/{sub_id}", token_path="/manage", + follow_redirects=False) + assert "different" in r.headers["location"] + assert db["subscriptions"].get(sub_id)["deleted_at"] is None + assert len(get_periods(db, 1)) == 1 From 04b617d948b0f9ee76681bdb95e582c512198f48 Mon Sep 17 00:00:00 2001 From: Wesley Date: Sat, 19 Sep 2026 20:24:57 +0200 Subject: [PATCH 7/7] docs: document the month view, tabbed detail page, and combining Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index e278f83..33d307d 100644 --- a/README.md +++ b/README.md @@ -9,15 +9,29 @@ log, and CSV/JSON import-export — all server-rendered, no JavaScript build ste ## Features -- **Spend dashboard** — historical spend for any calendar year (prorated over each - subscription's active windows and price changes), current run-rate, year-over-year +- **Spend dashboard** — what you actually pay this month vs last month (real billing + dates, plus what is still due), historical spend for any calendar year (prorated over + each subscription's active windows and price changes), current run-rate, year-over-year delta, per-period cost cards, and bar/breakdown charts by subscription, category, - and billing frequency. + and billing frequency. Every chart mark shows its exact value on hover; click or tap + one to pin the tooltip. +- **Subscription detail** — a summary header (status, current price, next payment) + over four tabs: Overview (details, cost per period, next payments), Spend (monthly + chart and lifetime figures), Periods (price history + add a period), and History + (audit trail). Tabs are plain links (`?tab=…`), so they deep-link and survive reloads. - **Flexible cadences** — daily / weekly / monthly / quarterly / yearly presets, plus a `custom` "every N units" option. - **Price periods** — each subscription owns one or more dated, non-overlapping active windows, each with its own price; the dashboard and next-payment forecast - honour them exactly. + honour them exactly. Periods that run back to back are one continuous subscription, + so a price change keeps the original billing day; a gap between periods (cancelled, + then resubscribed) starts a new billing cycle on the new start date. +- **Combine duplicates** — fold one subscription into another: its periods move + across (keeping who added them and when) and an open-ended period is closed the day + before the next one starts, then the duplicate is soft-deleted. Import offers this + whenever a file brings a name the team already uses — the usual case being newer + prices for a subscription whose current period is still open — and the detail page + offers it for any same-named leftovers. - **Multi-tenant** — subscriptions are owned by a team; users switch between the teams they belong to, and a super-admin can view across all teams at once. - **RBAC** — a global role axis (`super_admin`) and a per-team role axis