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
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
18 changes: 18 additions & 0 deletions src/auth/login_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
25 changes: 25 additions & 0 deletions src/auth/session_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
42 changes: 42 additions & 0 deletions src/data/demo_client.py
Original file line number Diff line number Diff line change
@@ -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
131 changes: 131 additions & 0 deletions src/data/demo_data.py
Original file line number Diff line number Diff line change
@@ -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},
}
30 changes: 29 additions & 1 deletion src/main.py
Original file line number Diff line number Diff line change
@@ -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()}"
Expand Down Expand Up @@ -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(
Expand Down
16 changes: 12 additions & 4 deletions src/views/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion tach.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
6 changes: 5 additions & 1 deletion tests/test_accessibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")


Expand Down
Loading
Loading