Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 8 additions & 3 deletions app/components/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
category_label,
fmt_eur,
json_pretty,
plural,
truncate,
)
from app.components.forms import subscription_form
Expand All @@ -17,13 +18,17 @@
nav_bar,
page_title,
section_card,
tab_nav,
team_switcher,
)
from app.components.widgets import (
action_btn,
action_menu,
alert,
badge,
combine_button,
combine_confirm,
disclosure,
dropdown_menu,
menu_item_cls,
pagination_bar,
Expand All @@ -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",
]
130 changes: 93 additions & 37 deletions app/components/charts.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,68 @@
"""
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 <g> 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
than per-bucket magnitude."""
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
Expand All @@ -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
Expand All @@ -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)
5 changes: 5 additions & 0 deletions app/components/fmt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
32 changes: 32 additions & 0 deletions app/components/layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
37 changes: 37 additions & 0 deletions app/components/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <details>, 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",
Expand Down
Loading
Loading