diff --git a/README.md b/README.md index 134d234..46f88c6 100644 --- a/README.md +++ b/README.md @@ -124,12 +124,37 @@ Set up pre-commit hooks (ruff lint/format + ty on every commit): uv run pre-commit install ``` +To run the hooks without committing — useful for verifying staged work +before you create the commit — use: + +```bash +uv run pre-commit run # only the currently-staged files +uv run pre-commit run --all-files # every file in the repo +``` + Tests are expected to pass before opening a PR. CI runs lint, type check, and pytest on all pull requests. See [CONTRIBUTING.md](CONTRIBUTING.md) for the contribution and testing policy, and [SECURITY.md](SECURITY.md) if you've found a vulnerability. +### Demo mode + +The login screen has a **Try Demo Mode** button that opens the dashboard +against synthetic data, so you can explore the app before entering your +Monarch credentials. Useful for evaluating the app before connecting real +financial data, taking screenshots, and reproducing bug reports. + +The demo fixture is a designed forecast: one checking account, one credit +card, a biweekly paycheck, monthly rent and bills, weekly groceries, and +current-cycle credit-card charges. Dates are generated relative to today, +so the forecast is always fresh. Demo state is stored separately at +`~/.monarch-forecast/demo-cache.db` and +`~/.monarch-forecast/demo-preferences.json`, so experimenting with demo +mode will not touch your real preferences or cached data. + +Signing out of demo mode returns to the login screen. + ### Building desktop packages locally Building native installers requires Flutter and platform toolchains: diff --git a/src/auth/login_view.py b/src/auth/login_view.py index 43b447e..8774671 100644 --- a/src/auth/login_view.py +++ b/src/auth/login_view.py @@ -22,10 +22,12 @@ def __init__( # calls ``page.run_task(...)``, which returns a Future. The return # value is unused either way. on_login_success: Callable[[], Any], + on_demo: Callable[[], Any], ) -> None: super().__init__() self.session_manager = session_manager self.on_login_success = on_login_success + self.on_demo = on_demo self._needs_mfa = False self.email_field = ft.TextField( @@ -62,6 +64,15 @@ def __init__( shape=ft.RoundedRectangleBorder(radius=8), ), ) + self.demo_button = ft.OutlinedButton( + content=ft.Text("Try Demo Mode"), + width=350, + on_click=lambda _: self.on_demo(), + tooltip="Explore the app with sample data before signing in", + style=ft.ButtonStyle( + shape=ft.RoundedRectangleBorder(radius=8), + ), + ) self.status_text = ft.Text( value="", color=ft.Colors.RED_400, @@ -140,6 +151,13 @@ def __init__( ), self._status_live_region, self.login_button, + ft.Container(height=4), + ft.Text( + "Want to try the app before signing in?", + size=11, + color=ft.Colors.ON_SURFACE_VARIANT, + ), + self.demo_button, ], horizontal_alignment=ft.CrossAxisAlignment.CENTER, spacing=12, diff --git a/src/auth/session_manager.py b/src/auth/session_manager.py index 95f785c..e2798de 100644 --- a/src/auth/session_manager.py +++ b/src/auth/session_manager.py @@ -12,6 +12,7 @@ SERVICE_NAME = "monarch-forecast" SESSION_DIR = Path.home() / ".monarch-forecast" SESSION_FILE = SESSION_DIR / "session.pickle" +DEMO_EMAIL = "demo@example.com" def _session_file_is_safe_to_load(path: Path) -> bool: @@ -176,3 +177,27 @@ def logout(self) -> None: SESSION_FILE.unlink() except OSError: pass + + +class DemoSessionManager(SessionManager): + """Session manager for the login screen's "Try Demo Mode" button. + + Bypasses the real MonarchMoney client and keychain — all data comes from + `src.data.demo_client.DemoClient`. The dashboard only reads `.client` + when constructing its own MonarchClient, so demo mode must always pass + its own `raw_client` override to DashboardView. + """ + + def __init__(self) -> None: + # Skip super().__init__ — no MonarchMoney, no keychain, no filesystem. + self._mm = None # type: ignore[assignment] + self._authenticated = True + + def load_credentials(self) -> tuple[str | None, str | None]: + return (DEMO_EMAIL, None) + + def logout(self) -> None: + pass + + async def try_restore_session(self) -> bool: + return True diff --git a/src/data/demo_client.py b/src/data/demo_client.py new file mode 100644 index 0000000..90b99c7 --- /dev/null +++ b/src/data/demo_client.py @@ -0,0 +1,42 @@ +"""Stand-in for MonarchClient that serves demo_data — no network.""" + +from datetime import date, timedelta +from typing import Any + +from src.data import demo_data +from src.data.models import RecurringItem +from src.data.monarch_client import MonarchClient + + +class DemoClient(MonarchClient): + """Returns synthetic data. Dashboard detects recurring items from the + transactions list, so `get_recurring_items` is intentionally empty.""" + + def __init__(self) -> None: + # Skip super().__init__ — there is no real MonarchMoney client to wrap. + pass + + async def get_checking_accounts(self) -> list[dict[str, Any]]: + return demo_data.build_checking_accounts() + + async def get_credit_card_accounts(self) -> list[dict[str, Any]]: + return demo_data.build_credit_card_accounts() + + async def get_recurring_items(self) -> list[RecurringItem]: + return [] + + async def get_transactions( + self, + account_ids: list[str] | None = None, + lookback_days: int = 90, + ) -> list[dict[str, Any]]: + txns = demo_data.build_transactions() + cutoff = date.today() - timedelta(days=lookback_days) + txns = [t for t in txns if date.fromisoformat(t["date"][:10]) >= cutoff] + if account_ids: + allowed = set(account_ids) + txns = [t for t in txns if t["account"]["id"] in allowed] + return txns + + async def refresh_accounts(self) -> bool: + return True diff --git a/src/data/demo_data.py b/src/data/demo_data.py new file mode 100644 index 0000000..bd3a27a --- /dev/null +++ b/src/data/demo_data.py @@ -0,0 +1,131 @@ +"""Synthetic data served by `DemoClient` when the user picks "Try Demo Mode". + +Designed so the 45-day forecast shows a realistic dip-and-recover arc: +rent and a credit-card payment hit before the next paycheck, pushing +the balance into overdraft territory around days 7-13 and triggering the +low-balance alerts. Dates are generated relative to `date.today()` so the +demo is always fresh. +""" + +from datetime import date, timedelta +from typing import Any + +CHECKING_ID = "demo-checking" +CHECKING_NAME = "Everyday Checking" +CHECKING_STARTING_BALANCE = 1_950.00 + +CC_ID = "demo-cc" +CC_NAME = "Credit Card" +CC_BALANCE = -847.00 + +_PAYCHECK = 3_200.00 +_RENT = -1_850.00 + +# Monthly expenses keyed on day-of-month for the recurring detector to pick up +# as "monthly" over 3 months of history. +_MONTHLY_ITEMS: list[tuple[int, float, str, str]] = [ + (1, _RENT, "Rent", "Housing"), + (5, -22.00, "News Subscription", "Subscriptions"), + (8, -12.00, "Streaming Music", "Subscriptions"), + (12, -94.00, "Electric Bill", "Utilities"), + (15, -89.00, "Internet", "Utilities"), + (20, -18.00, "Streaming Video", "Subscriptions"), +] + +# Weekly groceries — amounts must stay within ±20% of their median so the +# recurring detector accepts them as consistent. +_GROCERY_AMOUNTS = [-142.30, -156.75, -138.90, -149.50, -152.15, -145.80] + +# Credit-card charges in the current cycle. Summed total = $847.00, matching +# the card's outstanding balance so the payment estimate lines up. +_CC_CHARGES: list[tuple[int, float, str, str]] = [ + (2, -67.40, "Gas Station", "Transportation"), + (5, -42.10, "Coffee Shop", "Food"), + (9, -156.30, "Hardware Store", "Shopping"), + (14, -89.00, "Restaurant", "Food"), + (19, -215.75, "Online Shopping", "Shopping"), + (24, -276.45, "Furniture Store", "Shopping"), +] + + +def build_checking_accounts() -> list[dict[str, Any]]: + return [ + { + "id": CHECKING_ID, + "name": CHECKING_NAME, + "balance": CHECKING_STARTING_BALANCE, + "institution": "Demo Bank", + "type": "Depository", + "subtype": "checking", + } + ] + + +def build_credit_card_accounts() -> list[dict[str, Any]]: + return [ + { + "id": CC_ID, + "name": CC_NAME, + "balance": CC_BALANCE, + "institution": "Demo Bank", + } + ] + + +def build_transactions() -> list[dict[str, Any]]: + """90 days of synthetic history, shaped for the recurring detector.""" + today = date.today() + txns: list[dict[str, Any]] = [] + + # Paychecks — biweekly. Offset so the most recent landed 4 days ago and + # the next one arrives ~10 days out. This opens a gap around the next + # rent payment, giving the chart a visible dip-and-recovery arc. + for days_ago in range(4, 91, 14): + txns.append( + _make(today - timedelta(days=days_ago), _PAYCHECK, "Paycheck", "Income", CHECKING_ID) + ) + + # Monthly expenses across up to 4 months back (some will fall outside 90d). + for day_of_month, amount, name, category in _MONTHLY_ITEMS: + for offset in range(4): + y, m = today.year, today.month - offset + while m <= 0: + m += 12 + y -= 1 + try: + d = date(y, m, day_of_month) + except ValueError: + continue + if d > today or (today - d).days > 90: + continue + txns.append(_make(d, amount, name, category, CHECKING_ID)) + + # Weekly groceries — cycle through the amount list. + for i, days_ago in enumerate(range(3, 91, 7)): + txns.append( + _make( + today - timedelta(days=days_ago), + _GROCERY_AMOUNTS[i % len(_GROCERY_AMOUNTS)], + "Grocery Store", + "Food", + CHECKING_ID, + ) + ) + + # Credit-card charges in the current cycle. + for days_ago, amount, merchant, category in _CC_CHARGES: + txns.append(_make(today - timedelta(days=days_ago), amount, merchant, category, CC_ID)) + + return txns + + +def _make(d: date, amount: float, merchant: str, category: str, acct_id: str) -> dict[str, Any]: + acct_name = CHECKING_NAME if acct_id == CHECKING_ID else CC_NAME + return { + "id": f"demo-{merchant.replace(' ', '-').lower()}-{d.isoformat()}", + "date": d.isoformat(), + "amount": amount, + "merchant": {"name": merchant}, + "category": {"name": category}, + "account": {"id": acct_id, "displayName": acct_name}, + } diff --git a/src/main.py b/src/main.py index 03dad29..6769bd4 100644 --- a/src/main.py +++ b/src/main.py @@ -1,12 +1,21 @@ """Monarch Forecast - Financial forecasting desktop app.""" +from pathlib import Path + import flet as ft from src.auth.login_view import LoginView -from src.auth.session_manager import SessionManager +from src.auth.session_manager import DemoSessionManager, SessionManager +from src.data.cache import DataCache +from src.data.demo_client import DemoClient +from src.data.preferences import Preferences from src.utils.updater import get_current_version from src.views.dashboard import DashboardView +_DATA_DIR = Path.home() / ".monarch-forecast" +DEMO_CACHE_DB = _DATA_DIR / "demo-cache.db" +DEMO_PREFS_FILE = _DATA_DIR / "demo-preferences.json" + async def main(page: ft.Page) -> None: page.title = f"Monarch Forecast v{get_current_version()}" @@ -83,11 +92,30 @@ async def show_dashboard() -> None: page.update() await dashboard.load_data() + async def show_demo_dashboard() -> None: + """Open the dashboard with synthetic data — no Monarch account needed. + + Logging out of demo mode returns to the login screen rather than + clearing real credentials, since there are none to clear. + """ + page.controls.clear() + dashboard = DashboardView( + session_manager=DemoSessionManager(), + on_logout=lambda: page.run_task(show_login), + raw_client=DemoClient(), + cache=DataCache(db_path=DEMO_CACHE_DB), + preferences=Preferences(path=DEMO_PREFS_FILE), + ) + page.controls.append(dashboard) + page.update() + await dashboard.load_data() + async def show_login() -> None: page.controls.clear() login_view = LoginView( session_manager=session_manager, on_login_success=lambda: page.run_task(show_dashboard), + on_demo=lambda: page.run_task(show_demo_dashboard), ) page.controls.append( ft.Container( diff --git a/src/views/dashboard.py b/src/views/dashboard.py index 6e1bae2..ecf7f99 100644 --- a/src/views/dashboard.py +++ b/src/views/dashboard.py @@ -64,13 +64,21 @@ def _safe_update(control: ft.Control) -> None: class DashboardView(ft.Column): """Main dashboard showing forecast summary, chart, transactions, alerts, and adjustments.""" - def __init__(self, session_manager: SessionManager, on_logout: Callable[[], Any]) -> None: + def __init__( + self, + session_manager: SessionManager, + on_logout: Callable[[], Any], + *, + raw_client: MonarchClient | None = None, + cache: DataCache | None = None, + preferences: Preferences | None = None, + ) -> None: super().__init__() self.session_manager = session_manager - self._raw_client = MonarchClient(session_manager.client) - self._cache = DataCache() + self._raw_client = raw_client or MonarchClient(session_manager.client) + self._cache = cache or DataCache() self.monarch = CachedMonarchClient(self._raw_client, self._cache) - self._prefs = Preferences() + self._prefs = preferences or Preferences() self.on_logout = on_logout self.expand = True diff --git a/tach.toml b/tach.toml index 1c8c62d..c8388f3 100644 --- a/tach.toml +++ b/tach.toml @@ -23,4 +23,4 @@ depends_on = ["src.auth", "src.data", "src.forecast", "src.utils"] [[modules]] path = "src.main" -depends_on = ["src.auth", "src.utils", "src.views"] +depends_on = ["src.auth", "src.data", "src.utils", "src.views"] diff --git a/tests/test_accessibility.py b/tests/test_accessibility.py index b80932f..6a48434 100644 --- a/tests/test_accessibility.py +++ b/tests/test_accessibility.py @@ -188,7 +188,11 @@ def test_dashboard_icon_buttons(self, patched_session_manager): def test_login_view_icon_buttons(self, patched_session_manager): from src.auth.login_view import LoginView - view = LoginView(session_manager=patched_session_manager, on_login_success=lambda: None) + view = LoginView( + session_manager=patched_session_manager, + on_login_success=lambda: None, + on_demo=lambda: None, + ) _assert_every_icon_button_is_labeled(view, "LoginView") diff --git a/tests/test_demo.py b/tests/test_demo.py new file mode 100644 index 0000000..6ddffd0 --- /dev/null +++ b/tests/test_demo.py @@ -0,0 +1,99 @@ +"""Smoke tests for demo mode.""" + +import flet as ft + +from src.auth.session_manager import DEMO_EMAIL, DemoSessionManager +from src.data import demo_data +from src.data.demo_client import DemoClient +from src.data.recurring_detector import detect_recurring + + +async def test_demo_client_returns_accounts() -> None: + client = DemoClient() + checking = await client.get_checking_accounts() + cc = await client.get_credit_card_accounts() + + assert len(checking) == 1 + assert checking[0]["id"] == demo_data.CHECKING_ID + assert checking[0]["balance"] == demo_data.CHECKING_STARTING_BALANCE + + assert len(cc) == 1 + assert cc[0]["id"] == demo_data.CC_ID + + +async def test_demo_client_transactions_have_required_fields() -> None: + client = DemoClient() + txns = await client.get_transactions() + + assert len(txns) > 20 # 7 paychecks + rent + bills + 13 groceries + 6 cc charges + + for txn in txns: + assert "date" in txn + assert "amount" in txn + assert txn["merchant"]["name"] + assert txn["category"]["name"] + assert txn["account"]["id"] in (demo_data.CHECKING_ID, demo_data.CC_ID) + + +async def test_demo_transactions_detect_expected_recurring_items() -> None: + """The recurring detector should pick up every designed monthly/weekly item.""" + client = DemoClient() + txns = await client.get_transactions() + + detected_names = {item.name for item in detect_recurring(txns)} + + expected = {"Paycheck", "Rent", "Electric Bill", "Internet", "Grocery Store"} + assert expected.issubset(detected_names), ( + f"missing recurring items: {expected - detected_names}" + ) + + +async def test_demo_client_filters_by_account_id() -> None: + client = DemoClient() + cc_only = await client.get_transactions(account_ids=[demo_data.CC_ID]) + + assert cc_only + assert all(t["account"]["id"] == demo_data.CC_ID for t in cc_only) + + +def test_demo_session_manager_reports_authenticated() -> None: + sm = DemoSessionManager() + assert sm.is_authenticated + email, password = sm.load_credentials() + assert email == DEMO_EMAIL + assert password is None + + +async def test_demo_session_manager_try_restore_succeeds() -> None: + sm = DemoSessionManager() + assert await sm.try_restore_session() is True + + +def test_demo_session_manager_logout_is_noop() -> None: + sm = DemoSessionManager() + sm.logout() + # Still reports authenticated — logout is a no-op in demo mode. + assert sm.is_authenticated + + +async def test_demo_client_refresh_accounts_succeeds() -> None: + client = DemoClient() + assert await client.refresh_accounts() is True + + +def test_dashboard_accepts_demo_overrides(tmp_path) -> None: + """DashboardView must wire up cleanly with the demo raw_client/cache/prefs.""" + from src.data.cache import DataCache + from src.data.preferences import Preferences + from src.views.dashboard import DashboardView + + sm = DemoSessionManager() + dashboard = DashboardView( + session_manager=sm, + on_logout=lambda: None, + raw_client=DemoClient(), + cache=DataCache(db_path=tmp_path / "cache.db"), + preferences=Preferences(path=tmp_path / "prefs.json"), + ) + assert isinstance(dashboard, ft.Column) + assert dashboard._raw_client.__class__.__name__ == "DemoClient" diff --git a/tests/test_ui_integrity.py b/tests/test_ui_integrity.py index 247ebd6..dde6752 100644 --- a/tests/test_ui_integrity.py +++ b/tests/test_ui_integrity.py @@ -52,7 +52,11 @@ def test_view_construction_no_deprecation(self, mock_keyring, tmp_path: Path, mo with warnings.catch_warnings(): warnings.filterwarnings("error", category=DeprecationWarning) sm = SessionManager() - LoginView(session_manager=sm, on_login_success=lambda: None) + LoginView( + session_manager=sm, + on_login_success=lambda: None, + on_demo=lambda: None, + ) DashboardView(session_manager=sm, on_logout=lambda: None) AdjustmentsPanel(recurring_items=[], on_change=lambda: None) @@ -63,7 +67,11 @@ class TestLoginViewInit: def test_creates_without_error(self, patched_session_manager): from src.auth.login_view import LoginView - view = LoginView(session_manager=patched_session_manager, on_login_success=lambda: None) + view = LoginView( + session_manager=patched_session_manager, + on_login_success=lambda: None, + on_demo=lambda: None, + ) assert isinstance(view, ft.Column) assert len(view.controls) > 0