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 diff --git a/app/components/__init__.py b/app/components/__init__.py index b1626cb..83ae972 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,9 @@ action_menu, alert, badge, + combine_button, + combine_confirm, + disclosure, dropdown_menu, menu_item_cls, pagination_bar, @@ -32,10 +37,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", "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/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..e77e7ac 100644 --- a/app/components/widgets.py +++ b/app/components/widgets.py @@ -134,6 +134,43 @@ 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 " + "[&::-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/cost_utils.py b/app/cost_utils.py index 7ccf4f1..0e88258 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,28 +129,75 @@ 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 +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/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/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/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/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 79f02f9..f1cdec3 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,16 @@ badge, bar_chart, category_label, - collapsible_card, + combine_button, + combine_confirm, fmt_eur, nav_bar, page_title, + plural, section_card, status_badge, subscription_form, + tab_nav, ) from app.cost_utils import ( frequency_label, @@ -40,6 +44,8 @@ from app.db import ( add_period, audit, + combine_preview, + combine_subscriptions, current_price, delete_period, get_audit_for_entity, @@ -48,21 +54,36 @@ get_periods, get_subscription, is_active_on, + same_name_subscriptions, update_period, 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 ( + ALERT, + 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 +246,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 +316,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 _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( + 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(*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 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 _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("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", + _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 "—")), - Div(*actions, cls="flex gap-2 flex-wrap mt-4") if actions else "", + 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 +396,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 +468,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 +490,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,66 +520,166 @@ 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)", + )] + + +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"): + 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) + + 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( - 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, + _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}"), + *panels, ) +# ── 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/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", } 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 diff --git a/tests/test_cost_utils.py b/tests/test_cost_utils.py index 1cec6a5..3d64d3c 100644 --- a/tests/test_cost_utils.py +++ b/tests/test_cost_utils.py @@ -78,6 +78,89 @@ 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)) + + +# ── 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(): 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") 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