From 6356fa16ed7dee6bdea644cc07088eeef85f25ef Mon Sep 17 00:00:00 2001 From: kenrinzero Date: Sat, 30 May 2026 15:44:28 +0200 Subject: [PATCH] Fix crash editing a cancelled subscription; add resume (r). Bump 0.4.0 -> 0.4.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - subscription_modal: the status Select excluded 'cancelled' (you cancel via delete), so editing an archived sub set Select.value to an option that wasn't there and raised InvalidSelectValueError on mount. Extracted _status_options(sub, lang); it now includes 'cancelled' only when the edited sub is already cancelled — fixing the crash and letting the user switch it back to active/paused from the form. - main_screen: new `r` Resume action un-cancels the highlighted sub (status -> active), a no-op otherwise. + EN/JA strings, help text, and the `n` tutorial row. - tests/test_subscription_modal.py: regression guard on _status_options. - docs (CLAUDE.md / README / docs/index.html) updated. Verified: 60 tests pass; a headless Textual pilot mounts the edit modal on the exact cancelled row from the bug report without crashing. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 4 ++-- README.md | 2 +- docs/index.html | 4 ++-- gakkari/__init__.py | 2 +- gakkari/strings.py | 8 ++++++-- gakkari/ui/main_screen.py | 21 ++++++++++++++++++- gakkari/ui/notice_panel.py | 1 + gakkari/ui/subscription_modal.py | 18 +++++++++++++--- pyproject.toml | 2 +- tests/test_subscription_modal.py | 35 ++++++++++++++++++++++++++++++++ 10 files changed, 84 insertions(+), 13 deletions(-) create mode 100644 tests/test_subscription_modal.py diff --git a/CLAUDE.md b/CLAUDE.md index 8f52b15..708f2a7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -124,7 +124,7 @@ ExchangeRateCache: `billing_period` values: `"monthly"` `"yearly"` `"quarterly"` `"weekly"` `"half_yearly"`. -Subscriptions are never hard-deleted for historical accuracy — use `status = "cancelled"` instead. There is deliberately **no hard-delete helper** in `db.py` (the unused `delete_subscription` was removed); deletion always routes through `ConfirmModal` → `status = "cancelled"` via `update_subscription`. `is_active` from the original spec became a three-way `status` field. Cancelled rows are hidden by default; press `v` to surface them dimmed for reference. +Subscriptions are never hard-deleted for historical accuracy — use `status = "cancelled"` instead. There is deliberately **no hard-delete helper** in `db.py` (the unused `delete_subscription` was removed); deletion always routes through `ConfirmModal` → `status = "cancelled"` via `update_subscription`. `is_active` from the original spec became a three-way `status` field. Cancelled rows are hidden by default; press `v` to surface them dimmed for reference and `r` to resume (un-cancel → `active`) the highlighted one. Editing an archived sub works too — the status `Select` includes `cancelled` only when the sub being edited is already cancelled (otherwise it would reject the value and crash on mount). `trial_ends` is `None` for most subs. When set and within the notice window, the notice panel emits a louder trial-expiry post (distinct kaomoji pool, "trial ends today!" body) on the expiry date in place of (or alongside) the regular renewal post for that day. @@ -192,7 +192,7 @@ DB-write failures at the consumer level (settings load/save, history load) surfa - **Convert column (`c`):** when on, each row shows `9.99 USD ≈ 9.20 EUR ●`. The conversion target is **`convert_currency`** if set, else `base_currency` — it is decoupled from the base so totals can stay in one currency (e.g. EUR at the top) while the column converts to another (e.g. JPY). Set the target in the Settings modal (blank = follow base). Rows where the sub currency equals the *target* show `—` in place of the conversion. Toggle indicator `· conv→` in the title bar. Rates to the target are fetched into a second cache (`_conv_rates`) alongside the base cache in `_build_rate_cache`, still one lookup per unique currency per refresh. - **Auto-advance (`k`):** advances the highlighted sub's `next_renewal_date` by one billing cycle (`Subscription.next_renewal_after`, with month-end clamping via `_add_months` and leap-year safety) AND writes a `renewal_log` row at the *old* date. Toast confirms `name: old → new`. - **History screen (`h`):** `HistoryScreen` — renewal log grouped by month: a dim `YYYY-MM · BASE subtotal (count)` header precedes each month's rows, and the current month's header also shows the amortized `est BASE x/mo` so actual-so-far reads against the estimate. The title keeps the all-time running total in `base_currency`. Esc returns. -- **Archive view (`v`):** cycles cancelled subs in/out of the visible list, dimmed throughout so they read as for-reference. Title-bar indicator `· +archive`. +- **Archive view (`v`) + resume (`r`):** `v` cycles cancelled subs in/out of the visible list, dimmed throughout so they read as for-reference (title-bar indicator `· +archive`); `r` (`action_resume`) un-cancels the highlighted cancelled sub (status → `active`), a no-op otherwise. Editing an archived sub is supported — `SubscriptionModal._status_options` adds `cancelled` to the status `Select` only for an already-cancelled sub, so the value is valid instead of raising `InvalidSelectValueError`. - **Trial expiry:** optional `trial_ends` field on `Subscription`. Notice panel detects trial endings in its window and emits a distinct trial-flavor post (alarmed kaomoji pool: `(((;゚Д゚)))`, `(´;ω;`)`, etc.; `"trial ends today!"` body) on the expiry day, taking priority over the regular renewal post if both fall on the same day. - **Notice panel (`n` cycles):** - **Notices state (default):** banner + 7-to-14 stacked posts (`{n} :OL :YYYY-MM-DD(月) ID:hash8`, body, kaomoji, `─` rule). Three pools (renewal / trial / empty) picked deterministically by `post_id` hash so the same day always renders identically. Always-JA single-char weekday labels; EN/JA bodies. Adaptive window via `_pick_window(height)`. 60-second `set_interval` tick watches for date rollover. diff --git a/README.md b/README.md index 58e3da9..44deec1 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ The SQLite database is created on first launch at `data/gakkari.db` inside the p - Subscription CRUD with soft-delete (`status` = active / paused / cancelled), keyboard-only navigation, notes drill-in. - **Auto-advance (`k`) + undo (`u`)** — one keystroke advances a sub's renewal date by one billing cycle (with month-end + leap-year clamping) and logs it to a local ledger; `u` reverses the last one if you mis-pressed. - **History view (`h`)** — renewal ledger grouped by month with per-month subtotals; the current month also shows your amortized estimate so actual spend reads against it. Running total in your base currency. -- **Archive view (`v`)** — surface cancelled subs dimmed for reference without un-deleting them. +- **Archive view (`v`) + resume (`r`)** — surface cancelled subs dimmed for reference; press `r` to resume (un-cancel) the highlighted one. - **Adaptive notice board** — Japanese textboard styling with a rolling 1-to-2-week window that expands as your terminal gets taller. Deterministic kaomoji per day, EN/JA bilingual. Press `n` to flip between notices and a categorized keybindings tutorial. - **Trial expiry warnings** — optional `trial_ends` per sub; the notice board surfaces an alarmed-kaomoji post on the day a trial converts to paid. - **Multi-currency totals** — exchange rates fetched daily from [frankfurter.dev](https://frankfurter.dev/) (ECB-derived, no API key) and cached locally. Bad currency codes fall back gracefully with an inline warning. diff --git a/docs/index.html b/docs/index.html index 2074025..18530d6 100644 --- a/docs/index.html +++ b/docs/index.html @@ -229,7 +229,7 @@
C:\GAKKARI> - ver 0.4.0 · 2026 · ready + ver 0.4.1 · 2026 · ready
@@ -321,7 +321,7 @@

features

  • Adaptive notice board — 1 to 2 week rolling textboard window. Trial-expiry warnings with louder kaomoji.
  • Auto-advance (k) + undo (u) — one keystroke advances a renewal and logs it to a local ledger; u reverses the last one.
  • History view (h) — renewal log grouped by month with subtotals; the current month shows actual vs. your estimate.
  • -
  • Archive view (v) — surface cancelled subs dimmed for reference.
  • +
  • Archive view (v) + resume (r) — surface cancelled subs dimmed for reference; r resumes the highlighted one.
  • Conversion column (c) — show each row converted to your base (or any chosen) currency next to its native price.
  • Duplicate (D) & payment-method tags — clone a sub into a prefilled form; tag which card it's on, and filter by it.
  • Budget watch — set a monthly income; the notice board flags when committed spend goes over.
  • diff --git a/gakkari/__init__.py b/gakkari/__init__.py index 6a9beea..3d26edf 100644 --- a/gakkari/__init__.py +++ b/gakkari/__init__.py @@ -1 +1 @@ -__version__ = "0.4.0" +__version__ = "0.4.1" diff --git a/gakkari/strings.py b/gakkari/strings.py index 50ba4db..1d536cd 100644 --- a/gakkari/strings.py +++ b/gakkari/strings.py @@ -37,6 +37,7 @@ "duplicate_suffix": "(copy)", "bind_advance": "Kept it", "bind_undo": "Undo", + "bind_resume": "Resume", "bind_quit": "Quit", "bind_help": "Help", "bind_back": "Back", @@ -90,6 +91,7 @@ "undo_hint": "u to undo", "undo_done": "Renewal undone", "undo_nothing": "Nothing to undo", + "resume_notify": "Resumed {name}", # History screen (Phase 6) "bind_history": "History", "bind_notes": "Notes", @@ -139,7 +141,7 @@ "export_success": "Exported {count} subscription(s) to {path}", # Misc UI "title_shortcuts": "Keyboard shortcuts", - "help_text": "a Add · e Edit · d Delete · D Duplicate · k Kept it · u Undo · h History · / Filter · g Gross/Net · p Paused · v Archive · o Sort · t Totals · c Convert · s Settings · x Export · i Import · l Lang · n Notices · q Quit", + "help_text": "a Add · e Edit · d Delete · D Duplicate · k Kept it · u Undo · r Resume · h History · / Filter · g Gross/Net · p Paused · v Archive · o Sort · t Totals · c Convert · s Settings · x Export · i Import · l Lang · n Notices · q Quit", "no_subs": "No subscriptions. Press [a] to add one.", "due_soon": "▲ DUE SOON", "no_notes": "no notes", @@ -224,6 +226,7 @@ "duplicate_suffix": "(コピー)", "bind_advance": "更新済", "bind_undo": "取消", + "bind_resume": "再開", "bind_quit": "終了", "bind_help": "ヘルプ", "bind_back": "戻る", @@ -277,6 +280,7 @@ "undo_hint": "u で取消", "undo_done": "更新を取り消しました", "undo_nothing": "取り消す操作がありません", + "resume_notify": "{name} を再開しました", # History screen (Phase 6) "bind_history": "履歴", "bind_notes": "メモ", @@ -326,7 +330,7 @@ "export_success": "{count}件を {path} に書き出しました", # Misc UI "title_shortcuts": "キーボードショートカット", - "help_text": "a 追加 · e 編集 · d 削除 · D 複製 · k 更新済 · u 取消 · h 履歴 · / 絞込 · g 税込/抜 · p 停止中 · v 解約済 · o 並替 · t 合計 · c 換算 · s 設定 · x 書出 · i 読込 · l 言語 · n 通知 · q 終了", + "help_text": "a 追加 · e 編集 · d 削除 · D 複製 · k 更新済 · u 取消 · r 再開 · h 履歴 · / 絞込 · g 税込/抜 · p 停止中 · v 解約済 · o 並替 · t 合計 · c 換算 · s 設定 · x 書出 · i 読込 · l 言語 · n 通知 · q 終了", "no_subs": "サブスクなし。[a] で追加。", "due_soon": "▲ 期限近", "no_notes": "メモなし", diff --git a/gakkari/ui/main_screen.py b/gakkari/ui/main_screen.py index 2634113..6bf0d81 100644 --- a/gakkari/ui/main_screen.py +++ b/gakkari/ui/main_screen.py @@ -79,6 +79,7 @@ class MainScreen(Screen): Binding("D", "duplicate", "Duplicate"), Binding("k", "advance_renewal", "Kept it"), Binding("u", "undo_advance", "Undo"), + Binding("r", "resume", "Resume"), Binding("right", "open_notes", "Notes", key_display="→", priority=True), Binding("slash", "focus_filter", "Filter", key_display="/"), Binding("g", "toggle_gross_net", "Gross/Net"), @@ -270,7 +271,7 @@ def on_resize(self) -> None: def check_action(self, action: str, parameters: tuple) -> bool | None: if self._notes_active and action in ( - "add", "edit", "delete", "duplicate", "open_notes", "help", + "add", "edit", "delete", "duplicate", "resume", "open_notes", "help", "toggle_notices", "focus_filter", "toggle_gross_net", "toggle_paused", "settings", "export", "import_subs", "cycle_sort", "cycle_totals", "toggle_convert", "advance_renewal", @@ -757,6 +758,24 @@ async def action_delete(self) -> None: prev_idx or 0, len(self._subs) - 1 ) + def action_resume(self) -> None: + """Un-cancel the highlighted sub (surfaced via Archive view, `v`). + No-op on a sub that isn't cancelled.""" + sub = self._current_sub() + if sub is None or sub.status != "cancelled": + return + sub.status = "active" + prev_idx = self.query_one("#list-view", OptionList).highlighted + with get_conn() as conn: + update_subscription(conn, sub) + self._load_subs() + option_list = self.query_one("#list-view", OptionList) + if self._subs: + option_list.highlighted = min(prev_idx or 0, len(self._subs) - 1) + self.notify( + t("resume_notify", self._lang).format(name=sub.name), timeout=3 + ) + # ── Notes flow ────────────────────────────────────────────────────── def action_open_notes(self) -> None: diff --git a/gakkari/ui/notice_panel.py b/gakkari/ui/notice_panel.py index 79fd24b..976cc69 100644 --- a/gakkari/ui/notice_panel.py +++ b/gakkari/ui/notice_panel.py @@ -112,6 +112,7 @@ def _render_tutorial(self, lang: str, width: int) -> Group: ("D", "bind_duplicate"), ("k", "bind_advance"), ("u", "bind_undo"), + ("r", "bind_resume"), ("→", "bind_notes"), )), ("tutorial_section_views", ( diff --git a/gakkari/ui/subscription_modal.py b/gakkari/ui/subscription_modal.py index bea5271..f3fe1b1 100644 --- a/gakkari/ui/subscription_modal.py +++ b/gakkari/ui/subscription_modal.py @@ -13,6 +13,20 @@ from gakkari.strings import fmt_period, fmt_status, fmt_tax_mode, t +def _status_options(sub: Subscription | None, lang: str) -> list[tuple[str, str]]: + """Status choices for the form. 'cancelled' is normally hidden — you cancel + via delete, not the dropdown — but an already-cancelled sub being edited + must include it, or the Select rejects the value and the modal crashes on + mount. Surfacing it also lets the user switch the row back to active/paused + (resume) straight from the edit form.""" + allow_cancelled = sub is not None and sub.status == "cancelled" + return [ + (fmt_status(s, lang), s) + for s in STATUSES + if s != "cancelled" or allow_cancelled + ] + + class SubscriptionModal(ModalScreen[Subscription | None]): BINDINGS = [ @@ -87,9 +101,7 @@ def compose(self) -> ComposeResult: period_options = [(fmt_period(p, lang), p) for p in BILLING_PERIODS] tax_options = [(fmt_tax_mode(m, lang), m) for m in TAX_MODES] - status_options = [ - (fmt_status(s, lang), s) for s in STATUSES if s != "cancelled" - ] + status_options = _status_options(self._sub, lang) with Vertical(id="dialog"): yield Label(title, id="dialog-title") diff --git a/pyproject.toml b/pyproject.toml index b6b5268..12cc919 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "gakkari-ol" -version = "0.4.0" +version = "0.4.1" description = "Calm, local-only terminal subscription tracker — Python + Textual + SQLite." readme = "README.md" license = "MIT" diff --git a/tests/test_subscription_modal.py b/tests/test_subscription_modal.py new file mode 100644 index 0000000..1d16384 --- /dev/null +++ b/tests/test_subscription_modal.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from datetime import date +from decimal import Decimal + +from gakkari.models import Subscription +from gakkari.ui.subscription_modal import _status_options + + +def _sub(status: str) -> Subscription: + return Subscription( + name="X", amount=Decimal("1"), currency="USD", + billing_period="monthly", next_renewal_date=date(2026, 6, 1), + status=status, + ) + + +def _values(sub): + return [v for _, v in _status_options(sub, "en")] + + +def test_new_sub_hides_cancelled(): + vals = _values(None) + assert "active" in vals and "paused" in vals and "cancelled" not in vals + + +def test_active_sub_hides_cancelled(): + assert "cancelled" not in _values(_sub("active")) + + +def test_editing_cancelled_includes_cancelled(): + # Regression: an archived (cancelled) sub must offer 'cancelled' as an + # option, otherwise the Select rejects the value and the edit modal + # crashes on mount (InvalidSelectValueError). + assert "cancelled" in _values(_sub("cancelled"))