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
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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→<target>` 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.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@

<header class="boot">
<span class="prompt">C:\GAKKARI&gt;</span>
ver 0.4.0 · 2026 · ready<span class="cursor">▮</span>
ver 0.4.1 · 2026 · ready<span class="cursor">▮</span>
</header>

<main>
Expand Down Expand Up @@ -321,7 +321,7 @@ <h2>features</h2>
<li><b>Adaptive notice board</b> — 1 to 2 week rolling textboard window. Trial-expiry warnings with louder kaomoji.</li>
<li><b>Auto-advance (<kbd>k</kbd>) + undo (<kbd>u</kbd>)</b> — one keystroke advances a renewal and logs it to a local ledger; <kbd>u</kbd> reverses the last one.</li>
<li><b>History view (<kbd>h</kbd>)</b> — renewal log grouped by month with subtotals; the current month shows actual vs. your estimate.</li>
<li><b>Archive view (<kbd>v</kbd>)</b> — surface cancelled subs dimmed for reference.</li>
<li><b>Archive view (<kbd>v</kbd>) + resume (<kbd>r</kbd>)</b> — surface cancelled subs dimmed for reference; <kbd>r</kbd> resumes the highlighted one.</li>
<li><b>Conversion column (<kbd>c</kbd>)</b> — show each row converted to your base (or any chosen) currency next to its native price.</li>
<li><b>Duplicate (<kbd>D</kbd>) &amp; payment-method tags</b> — clone a sub into a prefilled form; tag which card it's on, and filter by it.</li>
<li><b>Budget watch</b> — set a monthly income; the notice board flags when committed spend goes over.</li>
Expand Down
2 changes: 1 addition & 1 deletion gakkari/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.4.0"
__version__ = "0.4.1"
8 changes: 6 additions & 2 deletions gakkari/strings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -224,6 +226,7 @@
"duplicate_suffix": "(コピー)",
"bind_advance": "更新済",
"bind_undo": "取消",
"bind_resume": "再開",
"bind_quit": "終了",
"bind_help": "ヘルプ",
"bind_back": "戻る",
Expand Down Expand Up @@ -277,6 +280,7 @@
"undo_hint": "u で取消",
"undo_done": "更新を取り消しました",
"undo_nothing": "取り消す操作がありません",
"resume_notify": "{name} を再開しました",
# History screen (Phase 6)
"bind_history": "履歴",
"bind_notes": "メモ",
Expand Down Expand Up @@ -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": "メモなし",
Expand Down
21 changes: 20 additions & 1 deletion gakkari/ui/main_screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions gakkari/ui/notice_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", (
Expand Down
18 changes: 15 additions & 3 deletions gakkari/ui/subscription_modal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
35 changes: 35 additions & 0 deletions tests/test_subscription_modal.py
Original file line number Diff line number Diff line change
@@ -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"))
Loading