From 915bcf114d772f6a4f0eabb034c53fb2e83dc2db Mon Sep 17 00:00:00 2001 From: Swathi Date: Tue, 21 Apr 2026 01:31:58 +0530 Subject: [PATCH 1/3] =?UTF-8?q?feat(web):=20v0.1=20UI=20=E2=80=94=20calend?= =?UTF-8?q?ar=20heatmap,=20day=20detail,=20trade=20detail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the first web UI for khata: FastAPI + Jinja2 + HTMX, zero JS framework, single stylesheet, dark-mode via prefers-color-scheme. Pages - GET / → redirect to current month calendar - GET /calendar/{year}/{month} → month grid, P&L-coloured days, Tue/Thu expiry markers, prev/next nav - GET /day/{YYYY-MM-DD} → day header, summary strip, trade table, inline daily reflection - GET /trade/{id} → trade meta, executions/fills, tag chips with add/remove, note editor HTMX partials (no client JS) - POST /notes/trade/{id} → save trade note on blur - POST /notes/day/{YYYY-MM-DD} → save daily note on blur - POST /tags/trade/{id} → add tag (kind: psych/setup/mistake/custom) - DELETE /tags/trade/{id}/{tag} → remove tag CLI - khata web [--host 127.0.0.1] [--port 8000] [--reload/--no-reload] Defaults to localhost-only and reload-on for dev. Tests - 12 new smoke tests via FastAPI TestClient covering every route, HTMX round-trip, and edge cases (invalid date, missing trade, empty day). 34/34 green overall. Notes - Schema untouched; only new reads on existing tables. - HTMX loaded via unpkg CDN; comment in base.html notes vendoring option for offline installs. - FastAPI's Depends() in parameter defaults is idiomatic — added per-file ruff ignore for B008 in khata/web/main.py only. - `datetime.utcnow()` replaced with timezone-aware equivalent for 3.12+. Verified against live Dhan data: 103 executions, 18 trades, calendar heatmap coloured correctly, day view links to trade detail, daily note save+reload roundtrip, tag add+remove persists. --- khata/cli.py | 21 + khata/web/helpers.py | 85 ++++ khata/web/main.py | 230 ++++++++++ khata/web/queries.py | 188 ++++++++ khata/web/static/favicon.svg | 7 + khata/web/static/style.css | 453 +++++++++++++++++++ khata/web/templates/base.html | 34 ++ khata/web/templates/calendar.html | 58 +++ khata/web/templates/day.html | 76 ++++ khata/web/templates/partials/note_block.html | 17 + khata/web/templates/partials/tag_list.html | 27 ++ khata/web/templates/trade.html | 69 +++ pyproject.toml | 4 + tests/test_web.py | 179 ++++++++ 14 files changed, 1448 insertions(+) create mode 100644 khata/web/helpers.py create mode 100644 khata/web/main.py create mode 100644 khata/web/queries.py create mode 100644 khata/web/static/favicon.svg create mode 100644 khata/web/static/style.css create mode 100644 khata/web/templates/base.html create mode 100644 khata/web/templates/calendar.html create mode 100644 khata/web/templates/day.html create mode 100644 khata/web/templates/partials/note_block.html create mode 100644 khata/web/templates/partials/tag_list.html create mode 100644 khata/web/templates/trade.html create mode 100644 tests/test_web.py diff --git a/khata/cli.py b/khata/cli.py index 66ce7a4..7792449 100644 --- a/khata/cli.py +++ b/khata/cli.py @@ -152,6 +152,27 @@ def reset(yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation console.print("[green]✓[/] reset complete") +@app.command() +def web( + host: str = typer.Option("127.0.0.1", help="Bind address (localhost-only by default)"), + port: int = typer.Option(8000, help="Port"), + reload: bool = typer.Option( + True, "--reload/--no-reload", help="Auto-reload on code change (disable for deploy)" + ), +) -> None: + """Start the khata web UI (FastAPI + HTMX).""" + import uvicorn + + console.print(f"→ starting khata web at [cyan]http://{host}:{port}[/] (reload={reload})") + uvicorn.run( + "khata.web.main:app", + host=host, + port=port, + reload=reload, + log_level="info", + ) + + @app.command("dump-executions") def dump_executions(limit: int = 20) -> None: """Print the last N executions (debug).""" diff --git a/khata/web/helpers.py b/khata/web/helpers.py new file mode 100644 index 0000000..cb42dd1 --- /dev/null +++ b/khata/web/helpers.py @@ -0,0 +1,85 @@ +"""Small formatting + date helpers used across templates.""" + +from __future__ import annotations + +import calendar +from datetime import date, datetime, timedelta +from zoneinfo import ZoneInfo + +IST = ZoneInfo("Asia/Kolkata") + +_MONTHS = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", +] + + +def month_bounds(year: int, month: int) -> tuple[date, date]: + """First and last date of the given month.""" + first = date(year, month, 1) + _, last_day = calendar.monthrange(year, month) + return first, date(year, month, last_day) + + +def month_grid(year: int, month: int) -> list[list[date | None]]: + """6×7 grid for the month. Each cell is a date or None (outside month). + Week starts Monday (Indian convention is mixed; Mon-start reads better for weekly expiries). + """ + cal = calendar.Calendar(firstweekday=0) # Monday + weeks = [] + for week in cal.monthdayscalendar(year, month): + weeks.append([date(year, month, d) if d else None for d in week]) + return weeks + + +def prev_month(year: int, month: int) -> tuple[int, int]: + return (year - 1, 12) if month == 1 else (year, month - 1) + + +def next_month(year: int, month: int) -> tuple[int, int]: + return (year + 1, 1) if month == 12 else (year, month + 1) + + +def month_name(month: int) -> str: + return _MONTHS[month - 1] + + +def today_ist() -> date: + return datetime.now(IST).date() + + +def ist_from_utc_iso(ts: str | None) -> datetime | None: + """Parse a UTC ISO string from the DB and return an IST-aware datetime.""" + if not ts: + return None + dt = datetime.fromisoformat(ts.replace("Z", "+00:00")) + return dt.astimezone(IST) + + +def fmt_time_ist(ts: str | None) -> str: + dt = ist_from_utc_iso(ts) + return dt.strftime("%H:%M") if dt else "—" + + +def fmt_date_iso(d: date) -> str: + return d.isoformat() + + +def shift_day(d: date, delta: int) -> date: + return d + timedelta(days=delta) + + +# Indian weekly expiry days: NIFTY (NFO) weekly = Thursday (3 in Python weekday). +# BSE/Sensex weekly = Tuesday (1). We mark both. +def is_expiry_day(d: date) -> bool: + return d.weekday() in (1, 3) diff --git a/khata/web/main.py b/khata/web/main.py new file mode 100644 index 0000000..598ca6d --- /dev/null +++ b/khata/web/main.py @@ -0,0 +1,230 @@ +"""FastAPI app for khata's local web UI. + +Single-user, localhost-first. No auth (run behind Tailscale/VPN for remote +access). HTMX-powered inline editing — no client-side JS framework. +""" + +from __future__ import annotations + +from datetime import date +from pathlib import Path +from typing import Annotated + +from fastapi import Depends, FastAPI, Form, HTTPException, Request +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates + +from khata.config import Config +from khata.core.db import connect, init_schema, user_id_for +from khata.core.money import fmt_rupees, paise_to_rupees +from khata.web import helpers as H +from khata.web import queries as Q + +HERE = Path(__file__).parent +TEMPLATES = Jinja2Templates(directory=HERE / "templates") +TEMPLATES.env.globals.update( + fmt_rupees=fmt_rupees, + paise_to_rupees=paise_to_rupees, + is_expiry_day=H.is_expiry_day, + month_name=H.month_name, + fmt_time_ist=H.fmt_time_ist, + today_iso=lambda: H.today_ist().isoformat(), +) + + +def create_app() -> FastAPI: + app = FastAPI(title="khata", docs_url=None, redoc_url=None) + app.mount("/static", StaticFiles(directory=HERE / "static"), name="static") + + cfg = Config.load() + + def _conn(): + c = connect(cfg) + init_schema(c) + try: + yield c + finally: + c.close() + + def _user_id(conn=Depends(_conn)) -> int: + return user_id_for(conn, cfg.user) + + # ── routes ───────────────────────────────────────────────────────── + @app.get("/", response_class=HTMLResponse) + def home(): + today = H.today_ist() + return RedirectResponse(f"/calendar/{today.year}/{today.month}") + + @app.get("/calendar/{year}/{month}", response_class=HTMLResponse) + def calendar_view( + request: Request, + year: int, + month: int, + conn=Depends(_conn), + user_id: int = Depends(_user_id), + ): + if not (1 <= month <= 12): + raise HTTPException(400, "invalid month") + summary = Q.month_summary_by_day(conn, user_id, year, month) + grid = H.month_grid(year, month) + prev_y, prev_m = H.prev_month(year, month) + next_y, next_m = H.next_month(year, month) + + # Month totals + total_net = sum((d.get("net_paise") or 0) for d in summary.values()) + total_trades = sum((d.get("n") or 0) for d in summary.values()) + active_days = len(summary) + + return TEMPLATES.TemplateResponse( + request, + "calendar.html", + { + "year": year, + "month": month, + "grid": grid, + "summary": summary, + "prev_y": prev_y, + "prev_m": prev_m, + "next_y": next_y, + "next_m": next_m, + "today": H.today_ist(), + "total_net": total_net, + "total_trades": total_trades, + "active_days": active_days, + }, + ) + + @app.get("/day/{day}", response_class=HTMLResponse) + def day_view( + request: Request, + day: str, + conn=Depends(_conn), + user_id: int = Depends(_user_id), + ): + try: + d = date.fromisoformat(day) + except ValueError as e: + raise HTTPException(400, "invalid date") from e + trades = Q.trades_on_day(conn, user_id, d) + totals = Q.day_totals(trades) + note = Q.get_daily_note(conn, user_id, d) + return TEMPLATES.TemplateResponse( + request, + "day.html", + { + "d": d, + "prev_day": H.shift_day(d, -1), + "next_day": H.shift_day(d, 1), + "trades": trades, + "totals": totals, + "note": note, + "endpoint": f"/notes/day/{d.isoformat()}", + "is_expiry": H.is_expiry_day(d), + }, + ) + + @app.get("/trade/{trade_id}", response_class=HTMLResponse) + def trade_view( + request: Request, + trade_id: int, + conn=Depends(_conn), + user_id: int = Depends(_user_id), + ): + trade = Q.trade_by_id(conn, user_id, trade_id) + if trade is None: + raise HTTPException(404, "trade not found") + execs = Q.executions_for_trade(conn, trade_id) + note = Q.get_trade_note(conn, user_id, trade_id) + tags = Q.tags_for_trade(conn, user_id, trade_id) + return TEMPLATES.TemplateResponse( + request, + "trade.html", + { + "trade": trade, + "executions": execs, + "note": note, + "tags": tags, + "trade_id": trade_id, + "endpoint": f"/notes/trade/{trade_id}", + }, + ) + + # ── HTMX partial endpoints ───────────────────────────────────────── + @app.post("/notes/trade/{trade_id}", response_class=HTMLResponse) + def save_trade_note( + request: Request, + trade_id: int, + body: Annotated[str, Form()] = "", + conn=Depends(_conn), + user_id: int = Depends(_user_id), + ): + if Q.trade_by_id(conn, user_id, trade_id) is None: + raise HTTPException(404) + note = Q.set_trade_note(conn, user_id, trade_id, body) + return TEMPLATES.TemplateResponse( + request, + "partials/note_block.html", + {"note": note, "endpoint": f"/notes/trade/{trade_id}"}, + ) + + @app.post("/notes/day/{day}", response_class=HTMLResponse) + def save_daily_note( + request: Request, + day: str, + body: Annotated[str, Form()] = "", + conn=Depends(_conn), + user_id: int = Depends(_user_id), + ): + try: + d = date.fromisoformat(day) + except ValueError as e: + raise HTTPException(400) from e + note = Q.set_daily_note(conn, user_id, d, body) + return TEMPLATES.TemplateResponse( + request, + "partials/note_block.html", + {"note": note, "endpoint": f"/notes/day/{day}"}, + ) + + @app.post("/tags/trade/{trade_id}", response_class=HTMLResponse) + def add_trade_tag( + request: Request, + trade_id: int, + name: Annotated[str, Form()] = "", + kind: Annotated[str, Form()] = "custom", + conn=Depends(_conn), + user_id: int = Depends(_user_id), + ): + if Q.trade_by_id(conn, user_id, trade_id) is None: + raise HTTPException(404) + kind = kind if kind in ("psych", "setup", "mistake", "custom") else "custom" + Q.add_tag_to_trade(conn, user_id, trade_id, name, kind) + tags = Q.tags_for_trade(conn, user_id, trade_id) + return TEMPLATES.TemplateResponse( + request, + "partials/tag_list.html", + {"tags": tags, "trade_id": trade_id}, + ) + + @app.delete("/tags/trade/{trade_id}/{tag_id}", response_class=HTMLResponse) + def delete_trade_tag( + request: Request, + trade_id: int, + tag_id: int, + conn=Depends(_conn), + user_id: int = Depends(_user_id), + ): + Q.remove_tag_from_trade(conn, trade_id, tag_id) + tags = Q.tags_for_trade(conn, user_id, trade_id) + return TEMPLATES.TemplateResponse( + request, + "partials/tag_list.html", + {"tags": tags, "trade_id": trade_id}, + ) + + return app + + +# Convenience: `uvicorn khata.web.main:app` +app = create_app() diff --git a/khata/web/queries.py b/khata/web/queries.py new file mode 100644 index 0000000..2a09a94 --- /dev/null +++ b/khata/web/queries.py @@ -0,0 +1,188 @@ +"""SQL query layer for the web UI. Returns plain dicts/rows ready for templates.""" + +from __future__ import annotations + +import sqlite3 +from datetime import UTC, date, datetime +from typing import Any + + +# ── calendar ─────────────────────────────────────────────────────────── +def month_summary_by_day( + conn: sqlite3.Connection, user_id: int, year: int, month: int +) -> dict[str, dict[str, Any]]: + """Per-day summary for one month, keyed by ISO date string. + + Uses entry_ts date in the database's UTC representation. For a first cut + that's acceptable — intraday IST trades all fall on their IST date anyway + since IST is UTC+5:30 and Indian markets close ~10:00 UTC. + """ + # strftime on the ISO string picks the YYYY-MM-DD prefix. + first = f"{year:04d}-{month:02d}-01" + # Next month's first day as exclusive upper bound. + ny, nm = (year + 1, 1) if month == 12 else (year, month + 1) + last_excl = f"{ny:04d}-{nm:02d}-01" + + rows = conn.execute( + """ + SELECT + substr(entry_ts, 1, 10) AS day, + COUNT(*) AS n, + SUM(CASE WHEN net_pnl_paise > 0 THEN 1 ELSE 0 END) AS wins, + SUM(CASE WHEN net_pnl_paise <= 0 AND status='CLOSED' THEN 1 ELSE 0 END) AS losses, + SUM(CASE WHEN status='OPEN' THEN 1 ELSE 0 END) AS opens, + COALESCE(SUM(net_pnl_paise), 0) AS net_paise + FROM trades + WHERE user_id = ? + AND entry_ts >= ? + AND entry_ts < ? + GROUP BY day + """, + (user_id, first, last_excl), + ).fetchall() + return {r["day"]: dict(r) for r in rows} + + +# ── day ──────────────────────────────────────────────────────────────── +def trades_on_day(conn: sqlite3.Connection, user_id: int, d: date) -> list[sqlite3.Row]: + return conn.execute( + """ + SELECT id, symbol, underlying, instrument_type, option_type, strike_paise, + expiry, direction, qty, avg_entry_paise, avg_exit_paise, + gross_pnl_paise, net_pnl_paise, fees_paise, entry_ts, exit_ts, + duration_s, status, strategy_id + FROM trades + WHERE user_id = ? AND substr(entry_ts, 1, 10) = ? + ORDER BY entry_ts + """, + (user_id, d.isoformat()), + ).fetchall() + + +def day_totals(trades: list[sqlite3.Row]) -> dict[str, Any]: + wins = sum(1 for t in trades if (t["net_pnl_paise"] or 0) > 0) + losses = sum(1 for t in trades if t["status"] == "CLOSED" and (t["net_pnl_paise"] or 0) <= 0) + net = sum((t["net_pnl_paise"] or 0) for t in trades) + fees = sum((t["fees_paise"] or 0) for t in trades) + return { + "count": len(trades), + "wins": wins, + "losses": losses, + "net_paise": net, + "fees_paise": fees, + } + + +# ── trade ────────────────────────────────────────────────────────────── +def trade_by_id(conn: sqlite3.Connection, user_id: int, trade_id: int) -> sqlite3.Row | None: + return conn.execute( + "SELECT * FROM trades WHERE user_id = ? AND id = ?", + (user_id, trade_id), + ).fetchone() + + +def executions_for_trade(conn: sqlite3.Connection, trade_id: int) -> list[sqlite3.Row]: + return conn.execute( + """ + SELECT e.side, e.qty, e.price_paise, e.ts, l.leg_role, l.qty_contributed + FROM trade_legs l + JOIN executions e ON e.id = l.execution_id + WHERE l.trade_id = ? + ORDER BY e.ts, e.id + """, + (trade_id,), + ).fetchall() + + +# ── notes ────────────────────────────────────────────────────────────── +def get_trade_note(conn: sqlite3.Connection, user_id: int, trade_id: int) -> sqlite3.Row | None: + return conn.execute( + "SELECT * FROM notes WHERE user_id = ? AND trade_id = ?", + (user_id, trade_id), + ).fetchone() + + +def set_trade_note( + conn: sqlite3.Connection, user_id: int, trade_id: int, body_md: str +) -> sqlite3.Row: + existing = get_trade_note(conn, user_id, trade_id) + now = datetime.now(UTC).replace(tzinfo=None).isoformat(timespec="seconds") + "Z" + if existing: + conn.execute( + "UPDATE notes SET body_md = ?, updated_at = ? WHERE id = ?", + (body_md, now, existing["id"]), + ) + else: + conn.execute( + """INSERT INTO notes (user_id, trade_id, body_md, updated_at, created_at) + VALUES (?, ?, ?, ?, ?)""", + (user_id, trade_id, body_md, now, now), + ) + return get_trade_note(conn, user_id, trade_id) # type: ignore[return-value] + + +def get_daily_note(conn: sqlite3.Connection, user_id: int, d: date) -> sqlite3.Row | None: + return conn.execute( + "SELECT * FROM notes WHERE user_id = ? AND for_date = ?", + (user_id, d.isoformat()), + ).fetchone() + + +def set_daily_note(conn: sqlite3.Connection, user_id: int, d: date, body_md: str) -> sqlite3.Row: + existing = get_daily_note(conn, user_id, d) + now = datetime.now(UTC).replace(tzinfo=None).isoformat(timespec="seconds") + "Z" + if existing: + conn.execute( + "UPDATE notes SET body_md = ?, updated_at = ? WHERE id = ?", + (body_md, now, existing["id"]), + ) + else: + conn.execute( + """INSERT INTO notes (user_id, for_date, body_md, updated_at, created_at) + VALUES (?, ?, ?, ?, ?)""", + (user_id, d.isoformat(), body_md, now, now), + ) + return get_daily_note(conn, user_id, d) # type: ignore[return-value] + + +# ── tags ─────────────────────────────────────────────────────────────── +def tags_for_trade(conn: sqlite3.Connection, user_id: int, trade_id: int) -> list[sqlite3.Row]: + return conn.execute( + """ + SELECT t.id, t.name, t.kind + FROM tags t + JOIN trade_tags tt ON tt.tag_id = t.id + WHERE t.user_id = ? AND tt.trade_id = ? + ORDER BY t.kind, t.name + """, + (user_id, trade_id), + ).fetchall() + + +def add_tag_to_trade( + conn: sqlite3.Connection, user_id: int, trade_id: int, name: str, kind: str = "custom" +) -> None: + name = name.strip() + if not name: + return + # Upsert tag. + conn.execute( + "INSERT OR IGNORE INTO tags (user_id, name, kind) VALUES (?, ?, ?)", + (user_id, name, kind), + ) + row = conn.execute( + "SELECT id FROM tags WHERE user_id = ? AND name = ?", + (user_id, name), + ).fetchone() + if row: + conn.execute( + "INSERT OR IGNORE INTO trade_tags (trade_id, tag_id) VALUES (?, ?)", + (trade_id, row["id"]), + ) + + +def remove_tag_from_trade(conn: sqlite3.Connection, trade_id: int, tag_id: int) -> None: + conn.execute( + "DELETE FROM trade_tags WHERE trade_id = ? AND tag_id = ?", + (trade_id, tag_id), + ) diff --git a/khata/web/static/favicon.svg b/khata/web/static/favicon.svg new file mode 100644 index 0000000..22372ab --- /dev/null +++ b/khata/web/static/favicon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/khata/web/static/style.css b/khata/web/static/style.css new file mode 100644 index 0000000..79ce551 --- /dev/null +++ b/khata/web/static/style.css @@ -0,0 +1,453 @@ +/* khata — a single stylesheet. Mobile-first. Dark mode via prefers-color-scheme. */ + +:root { + --bg: #fafafa; + --surface: #ffffff; + --surface-2: #f1f5f9; + --border: #e2e8f0; + --text: #0f172a; + --muted: #64748b; + --subtle: #94a3b8; + + --accent: #fbbf24; + --accent-ink: #0f172a; + + --win: #16a34a; + --win-bg: #dcfce7; + --loss: #dc2626; + --loss-bg: #fee2e2; + --neutral-bg: #f1f5f9; + --expiry: #8b5cf6; + + --radius: 8px; + --shadow: 0 1px 2px rgba(15, 23, 42, 0.04), 0 1px 3px rgba(15, 23, 42, 0.06); + --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; + --sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #0b1220; + --surface: #111827; + --surface-2: #1e293b; + --border: #1e293b; + --text: #f1f5f9; + --muted: #94a3b8; + --subtle: #64748b; + + --win: #4ade80; + --win-bg: #052e1a; + --loss: #f87171; + --loss-bg: #3a0f0f; + --neutral-bg: #1e293b; + --expiry: #a78bfa; + --shadow: 0 1px 2px rgba(0, 0, 0, 0.3); + } +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + font-family: var(--sans); + background: var(--bg); + color: var(--text); + font-size: 15px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +a { color: var(--text); } +a:hover { color: var(--accent); } +.mono, code { font-family: var(--mono); font-size: 0.94em; } +.subtle { color: var(--subtle); } + +/* ── header ──────────────────────────────────────────────────────── */ +.site-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 20px; + background: var(--surface); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 10; +} +.brand { + display: flex; + align-items: center; + gap: 10px; + text-decoration: none; + font-weight: 700; + font-size: 1.15rem; + letter-spacing: -0.01em; +} +.brand-mark { flex: 0 0 32px; } +.site-nav { display: flex; gap: 18px; } +.site-nav a { + text-decoration: none; + color: var(--muted); + font-weight: 500; + font-size: 0.95rem; +} +.site-nav a:hover { color: var(--text); } + +/* ── layout ──────────────────────────────────────────────────────── */ +.container { + max-width: 1100px; + margin: 0 auto; + padding: 28px 20px 60px; +} +.page-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16px; + margin-bottom: 20px; +} +.page-title h1 { + margin: 0; + font-size: 1.9rem; + font-weight: 700; + letter-spacing: -0.02em; +} +.page-sub { + color: var(--muted); + margin-top: 4px; + font-size: 0.95rem; +} +.page-sub a { color: var(--muted); text-decoration: none; } +.page-sub a:hover { color: var(--accent); } + +.page-stat { + text-align: right; + padding: 10px 16px; + border-radius: var(--radius); + background: var(--neutral-bg); +} +.page-stat.win { background: var(--win-bg); color: var(--win); } +.page-stat.loss { background: var(--loss-bg); color: var(--loss); } +.stat-label { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); +} +.page-stat.win .stat-label, .page-stat.loss .stat-label { color: inherit; opacity: 0.8; } +.stat-value { + font-family: var(--mono); + font-size: 1.4rem; + font-weight: 700; +} + +/* ── month nav ───────────────────────────────────────────────────── */ +.month-nav { + display: flex; + gap: 8px; + margin-bottom: 16px; +} +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 6px 12px; + border-radius: var(--radius); + background: var(--surface); + border: 1px solid var(--border); + color: var(--text); + text-decoration: none; + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: background 0.1s, border-color 0.1s; +} +.btn:hover { background: var(--surface-2); border-color: var(--accent); } +.btn-muted { color: var(--muted); } + +/* ── calendar grid ───────────────────────────────────────────────── */ +.calendar { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 4px; + background: var(--border); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} +.dow { + background: var(--surface-2); + padding: 8px 10px; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); + font-weight: 600; +} +.day { + background: var(--surface); + padding: 10px; + min-height: 84px; + display: flex; + flex-direction: column; + gap: 2px; + text-decoration: none; + color: var(--text); + transition: background 0.1s; + position: relative; +} +.day:hover { background: var(--surface-2); color: var(--text); } +.day-empty { background: transparent; pointer-events: none; } +.day-today { outline: 2px solid var(--accent); outline-offset: -2px; } +.day-num { font-weight: 600; font-size: 0.95rem; } +.day-pnl { font-family: var(--mono); font-size: 0.9rem; font-weight: 600; margin-top: auto; } +.day-count { font-size: 0.72rem; color: var(--muted); } +.day-win { background: var(--win-bg); } +.day-win .day-pnl { color: var(--win); } +.day-loss { background: var(--loss-bg); } +.day-loss .day-pnl { color: var(--loss); } +.day-neutral .day-pnl { color: var(--muted); } +.expiry-dot { + position: absolute; + top: 4px; + right: 6px; + color: var(--expiry); + font-size: 1.3rem; + line-height: 1; +} + +.legend { + display: flex; + gap: 18px; + margin-top: 14px; + font-size: 0.82rem; + color: var(--muted); + align-items: center; +} +.swatch { + display: inline-block; + width: 12px; height: 12px; + border-radius: 3px; + margin-right: 6px; + vertical-align: middle; +} +.swatch-win { background: var(--win-bg); border: 1px solid var(--win); } +.swatch-loss { background: var(--loss-bg); border: 1px solid var(--loss); } +.swatch-expiry { background: var(--expiry); opacity: 0.9; } + +/* ── summary strip ───────────────────────────────────────────────── */ +.summary-strip { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); + gap: 1px; + background: var(--border); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + margin: 18px 0; +} +.summary-strip > div { + background: var(--surface); + padding: 12px 16px; +} +.summary-strip .lbl { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); +} +.summary-strip .val { + font-family: var(--mono); + font-size: 1.15rem; + font-weight: 700; + margin-top: 2px; +} + +/* ── tables ──────────────────────────────────────────────────────── */ +.trade-table { + width: 100%; + border-collapse: collapse; + background: var(--surface); + border-radius: var(--radius); + overflow: hidden; + box-shadow: var(--shadow); + margin: 12px 0 24px; +} +.trade-table th, .trade-table td { + padding: 10px 14px; + text-align: left; + border-bottom: 1px solid var(--border); +} +.trade-table th { + background: var(--surface-2); + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted); +} +.trade-table td.num, .trade-table th.num { text-align: right; } +.trade-table tr:last-child td { border-bottom: none; } +.trade-table tr:hover td { background: var(--surface-2); } +.trade-table .sym { font-weight: 600; text-decoration: none; } +.trade-table td.win { color: var(--win); font-weight: 600; } +.trade-table td.loss { color: var(--loss); font-weight: 600; } + +/* ── badges ──────────────────────────────────────────────────────── */ +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: 12px; + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.badge-long, .badge-buy { background: var(--win-bg); color: var(--win); } +.badge-short, .badge-sell { background: var(--loss-bg); color: var(--loss); } +.badge-closed { background: var(--surface-2); color: var(--muted); } +.badge-open { background: var(--accent); color: var(--accent-ink); } +.badge-muted { background: var(--surface-2); color: var(--muted); } +.badge-expiry { background: var(--expiry); color: white; } + +/* ── trade detail ────────────────────────────────────────────────── */ +.trade-meta { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 1px; + background: var(--border); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + margin-bottom: 20px; +} +.trade-meta > div { + background: var(--surface); + padding: 12px 16px; +} +.trade-meta .lbl { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); +} +.trade-meta .val { + font-size: 1.05rem; + font-weight: 600; + margin-top: 2px; +} + +h2 { + font-size: 1.1rem; + font-weight: 700; + margin: 28px 0 8px; + letter-spacing: -0.01em; +} + +/* ── notes + tags ────────────────────────────────────────────────── */ +.note-block { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 14px; +} +.note-block textarea { + width: 100%; + border: none; + background: transparent; + resize: vertical; + font-family: var(--sans); + font-size: 0.95rem; + color: var(--text); + line-height: 1.55; + outline: none; + min-height: 120px; +} +.note-meta { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 10px; + font-size: 0.8rem; +} + +.tag-list { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 14px; +} +.tag-chips { + display: flex; + gap: 6px; + flex-wrap: wrap; + margin-bottom: 10px; + min-height: 28px; + align-items: center; +} +.tag { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 4px 3px 10px; + border-radius: 12px; + font-size: 0.8rem; + font-weight: 500; + background: var(--surface-2); +} +.tag button { + background: transparent; + border: none; + color: var(--muted); + font-size: 1rem; + line-height: 1; + cursor: pointer; + padding: 0 6px; + border-radius: 10px; +} +.tag button:hover { background: rgba(0,0,0,0.05); color: var(--loss); } +.tag-psych { background: rgba(167, 139, 250, 0.18); color: var(--expiry); } +.tag-setup { background: var(--win-bg); color: var(--win); } +.tag-mistake { background: var(--loss-bg); color: var(--loss); } + +.tag-form { + display: flex; + gap: 6px; + padding-top: 8px; + border-top: 1px dashed var(--border); +} +.tag-form input, .tag-form select { + padding: 5px 10px; + border: 1px solid var(--border); + border-radius: var(--radius); + font-family: var(--sans); + background: var(--surface); + color: var(--text); + font-size: 0.88rem; +} +.tag-form input { flex: 1; min-width: 0; } + +/* ── misc ────────────────────────────────────────────────────────── */ +.empty { + color: var(--muted); + padding: 28px; + text-align: center; + background: var(--surface); + border-radius: var(--radius); + border: 1px dashed var(--border); +} + +.win { color: var(--win); } +.loss { color: var(--loss); } + +/* ── small screens ───────────────────────────────────────────────── */ +@media (max-width: 720px) { + .page-header { flex-direction: column; align-items: flex-start; } + .page-stat { align-self: stretch; text-align: left; } + .day { min-height: 64px; padding: 6px; } + .day-pnl { font-size: 0.78rem; } + .day-count { display: none; } + .trade-table { font-size: 0.85rem; } + .trade-table th, .trade-table td { padding: 8px; } +} diff --git a/khata/web/templates/base.html b/khata/web/templates/base.html new file mode 100644 index 0000000..ed28daa --- /dev/null +++ b/khata/web/templates/base.html @@ -0,0 +1,34 @@ + + + + + + + + {% block title %}khata{% endblock %} + + + + + + +
+ {% block content %}{% endblock %} +
+ + diff --git a/khata/web/templates/calendar.html b/khata/web/templates/calendar.html new file mode 100644 index 0000000..a00f62e --- /dev/null +++ b/khata/web/templates/calendar.html @@ -0,0 +1,58 @@ +{% extends "base.html" %} +{% block title %}{{ month_name(month) }} {{ year }} · khata{% endblock %} + +{% block content %} +
+ + + + +
+
Mon
Tue
Wed
+
Thu
Fri
Sat
+
Sun
+ + {% for week in grid %} + {% for d in week %} + {% if d is none %} +
+ {% else %} + {% set iso = d.isoformat() %} + {% set s = summary.get(iso) %} + {% set net = s.net_paise if s else 0 %} + + {{ d.day }} + {% if is_expiry_day(d) %}{% endif %} + {% if s %} + {{ fmt_rupees(net) }} + {{ s.n }} trade{{ '' if s.n == 1 else 's' }} + {% endif %} + + {% endif %} + {% endfor %} + {% endfor %} +
+ +
+ win day + loss day + weekly expiry (Tue/Thu) +
+
+{% endblock %} diff --git a/khata/web/templates/day.html b/khata/web/templates/day.html new file mode 100644 index 0000000..530867c --- /dev/null +++ b/khata/web/templates/day.html @@ -0,0 +1,76 @@ +{% extends "base.html" %} +{% block title %}{{ d.strftime('%a, %d %b %Y') }} · khata{% endblock %} + +{% block content %} +
+ + + + + {% if trades %} +
+
trades
{{ totals.count }}
+
wins
{{ totals.wins }}
+
losses
{{ totals.losses }}
+
fees
{{ fmt_rupees(totals.fees_paise) }}
+
+ + + + + + + + + + + + + + + + + {% for t in trades %} + + + + + + + + + + + + {% endfor %} + +
timesymboldirqtyentryexitnet P&Lfeesstatus
{{ fmt_time_ist(t.entry_ts) }}{{ t.symbol }}{{ t.direction }}{{ t.qty }}{{ fmt_rupees(t.avg_entry_paise) }}{{ fmt_rupees(t.avg_exit_paise) if t.avg_exit_paise else '—' }} + {{ fmt_rupees(t.net_pnl_paise) if t.net_pnl_paise is not none else '—' }} + {{ fmt_rupees(t.fees_paise) }}{{ t.status }}
+ {% else %} +

No trades on this day.

+ {% endif %} + +
+

Daily reflection

+ {% include "partials/note_block.html" with context %} +
+
+{% endblock %} diff --git a/khata/web/templates/partials/note_block.html b/khata/web/templates/partials/note_block.html new file mode 100644 index 0000000..8d4cbae --- /dev/null +++ b/khata/web/templates/partials/note_block.html @@ -0,0 +1,17 @@ +
+
+ +
+ {% if note and note.updated_at %}saved {{ note.updated_at[:19].replace('T', ' ') }} UTC{% else %}not saved yet{% endif %} + +
+
+
diff --git a/khata/web/templates/partials/tag_list.html b/khata/web/templates/partials/tag_list.html new file mode 100644 index 0000000..7b71a0c --- /dev/null +++ b/khata/web/templates/partials/tag_list.html @@ -0,0 +1,27 @@ +
+
+ {% for tag in tags %} + + {{ tag.name }} + + + {% endfor %} + {% if not tags %}no tags yet{% endif %} +
+
+ + + +
+
diff --git a/khata/web/templates/trade.html b/khata/web/templates/trade.html new file mode 100644 index 0000000..1068dc2 --- /dev/null +++ b/khata/web/templates/trade.html @@ -0,0 +1,69 @@ +{% extends "base.html" %} +{% block title %}{{ trade.symbol }} · khata{% endblock %} + +{% block content %} +
+ + +
+
avg entry
{{ fmt_rupees(trade.avg_entry_paise) }}
+
avg exit
{{ fmt_rupees(trade.avg_exit_paise) if trade.avg_exit_paise else '—' }}
+
gross P&L
{{ fmt_rupees(trade.gross_pnl_paise) if trade.gross_pnl_paise is not none else '—' }}
+
fees
{{ fmt_rupees(trade.fees_paise) }}
+
duration
{{ (trade.duration_s // 60) if trade.duration_s else '—' }}m
+
status
{{ trade.status }}
+
+ + {% if executions %} +
+

Fills

+ + + + + + + + + + + + {% for e in executions %} + + + + + + + + {% endfor %} + +
timerolesideqtyprice
{{ fmt_time_ist(e.ts) }}{{ e.leg_role }}{{ e.side }}{{ e.qty_contributed }}{{ fmt_rupees(e.price_paise) }}
+
+ {% endif %} + +
+

Tags

+ {% include "partials/tag_list.html" with context %} +
+ +
+

Notes

+ {% include "partials/note_block.html" with context %} +
+
+{% endblock %} diff --git a/pyproject.toml b/pyproject.toml index 46e5724..b3d214d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,6 +55,10 @@ target-version = "py311" select = ["E", "F", "I", "UP", "B", "SIM"] ignore = ["E501"] +[tool.ruff.lint.per-file-ignores] +# FastAPI idiom: Depends() in parameter defaults is the recommended pattern. +"khata/web/main.py" = ["B008"] + [tool.pytest.ini_options] testpaths = ["tests"] asyncio_mode = "auto" diff --git a/tests/test_web.py b/tests/test_web.py new file mode 100644 index 0000000..deed09c --- /dev/null +++ b/tests/test_web.py @@ -0,0 +1,179 @@ +"""Smoke tests for the web UI. + +Uses a FastAPI TestClient pointed at a throwaway SQLite DB seeded with a few +trades. Tests don't assert CSS — just that routes return 2xx, render expected +HTML snippets, and HTMX partial endpoints round-trip. +""" + +from __future__ import annotations + +import os +import sqlite3 +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from khata.core.db import init_schema + + +@pytest.fixture +def seeded_db(tmp_path, monkeypatch): + db_path = tmp_path / "test.db" + monkeypatch.setenv("KHATA_DB_PATH", str(db_path)) + monkeypatch.setenv("KHATA_MEDIA_DIR", str(tmp_path / "media")) + monkeypatch.setenv("KHATA_USER", "default") + monkeypatch.setenv("KHATA_SECRET", "test-secret") + + conn = sqlite3.connect(db_path, isolation_level=None) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + init_schema(conn) + + # Seed: 2 trades on 2026-04-15, 1 on 2026-04-16 + base = datetime(2026, 4, 15, 4, 0, tzinfo=UTC) + for i, (day_off, sym, entry, exit_, net) in enumerate( + [ + (0, "NIFTY 24300 PE", 10000, 10500, 37500), + (0, "BANKNIFTY 51000 CE", 15000, 13000, -60000), + (1, "NIFTY 24400 PE", 8000, 9200, 90000), + ] + ): + ts = (base + timedelta(days=day_off, minutes=i * 30)).isoformat() + exit_ts = (base + timedelta(days=day_off, minutes=i * 30 + 10)).isoformat() + # First insert two executions + for leg_i, side, qty, px in [(0, "BUY", 75, entry), (1, "SELL", 75, exit_)]: + conn.execute( + """INSERT INTO executions + (user_id, broker, broker_trade_id, symbol, underlying, exchange, segment, + instrument_type, side, qty, price_paise, ts, brokerage_paise) + VALUES (1, 'test', ?, ?, ?, 'NFO', 'NSE_FNO', 'OPT', ?, ?, ?, ?, 2000)""", + ( + f"t{i}-{leg_i}", + sym, + sym.split()[0], + side, + qty, + px, + ts if leg_i == 0 else exit_ts, + ), + ) + # Then insert the reconstructed trade directly (bypass round-trip engine for speed) + conn.execute( + """INSERT INTO trades + (user_id, symbol, underlying, instrument_type, direction, qty, + avg_entry_paise, avg_exit_paise, entry_ts, exit_ts, + gross_pnl_paise, fees_paise, net_pnl_paise, status) + VALUES (1, ?, ?, 'OPT', 'LONG', 75, ?, ?, ?, ?, ?, 500, ?, 'CLOSED')""", + (sym, sym.split()[0], entry, exit_, ts, exit_ts, net + 500, net), + ) + conn.close() + return db_path + + +@pytest.fixture +def client(seeded_db): + # Import after env is set so Config.load() picks up the tmp paths. + if "khata.web.main" in os.sys.modules: + del os.sys.modules["khata.web.main"] + from khata.web.main import create_app + + return TestClient(create_app()) + + +def test_root_redirects_to_current_month(client): + r = client.get("/", follow_redirects=False) + assert r.status_code in (301, 302, 307) + assert r.headers["location"].startswith("/calendar/") + + +def test_calendar_page_renders(client): + r = client.get("/calendar/2026/4") + assert r.status_code == 200 + assert "April 2026" in r.text + assert "month P&L" in r.text + # Days with trades are linked; 2026-04-15 should be there + assert "/day/2026-04-15" in r.text + + +def test_calendar_invalid_month(client): + r = client.get("/calendar/2026/13") + assert r.status_code == 400 + + +def test_day_page_renders_with_trades(client): + r = client.get("/day/2026-04-15") + assert r.status_code == 200 + assert "NIFTY 24300 PE" in r.text + assert "BANKNIFTY 51000 CE" in r.text + assert "Daily reflection" in r.text + + +def test_day_page_empty(client): + r = client.get("/day/2026-01-01") + assert r.status_code == 200 + assert "No trades on this day" in r.text + + +def test_day_invalid_date(client): + r = client.get("/day/not-a-date") + assert r.status_code == 400 + + +def test_trade_page_renders(client): + # trade id 1 should exist from fixture (fixture skips trade_legs for speed, + # so 'Fills' section won't render — just check trade metadata + sections). + r = client.get("/trade/1") + assert r.status_code == 200 + assert "NIFTY 24300 PE" in r.text + assert "Tags" in r.text + assert "Notes" in r.text + assert "avg entry" in r.text + + +def test_trade_404(client): + r = client.get("/trade/9999") + assert r.status_code == 404 + + +def test_note_save_and_reload(client): + r = client.post("/notes/trade/1", data={"body": "First thoughts on this trade"}) + assert r.status_code == 200 + assert "First thoughts" in r.text + # Reload the page and confirm note persisted + r2 = client.get("/trade/1") + assert "First thoughts" in r2.text + + +def test_tag_add_and_remove(client): + r = client.post("/tags/trade/1", data={"name": "fomo", "kind": "psych"}) + assert r.status_code == 200 + assert "fomo" in r.text + + # Find the tag id in the DB to build the delete URL + import os as _os + conn = sqlite3.connect(_os.environ["KHATA_DB_PATH"]) + conn.row_factory = sqlite3.Row + tag_id = conn.execute("SELECT id FROM tags WHERE name='fomo'").fetchone()["id"] + conn.close() + + r = client.delete(f"/tags/trade/1/{tag_id}") + assert r.status_code == 200 + assert "fomo" not in r.text + assert "no tags yet" in r.text + + +def test_daily_note_save(client): + r = client.post("/notes/day/2026-04-15", data={"body": "Revenge traded after the morning loss"}) + assert r.status_code == 200 + assert "Revenge traded" in r.text + + r2 = client.get("/day/2026-04-15") + assert "Revenge traded" in r2.text + + +def test_static_css_served(client): + r = client.get("/static/style.css") + assert r.status_code == 200 + assert "khata" in r.text.lower() # our stylesheet header comment From 13be5646426c53ac8bf9bc02a1fcfdd16f737770 Mon Sep 17 00:00:00 2001 From: Swathi Date: Tue, 21 Apr 2026 09:02:06 +0530 Subject: [PATCH 2/3] fix(web): remove unused pathlib import flagged by CI ruff --- tests/test_web.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_web.py b/tests/test_web.py index deed09c..ec2a39b 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -10,7 +10,6 @@ import os import sqlite3 from datetime import UTC, datetime, timedelta -from pathlib import Path import pytest from fastapi.testclient import TestClient From ef075031ed06d8f5b0e0fd7aa1fff339b4937624 Mon Sep 17 00:00:00 2001 From: Swathi Date: Tue, 21 Apr 2026 09:03:00 +0530 Subject: [PATCH 3/3] fix(web): apply ruff format to test_web.py --- tests/test_web.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_web.py b/tests/test_web.py index ec2a39b..ea156fe 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -152,6 +152,7 @@ def test_tag_add_and_remove(client): # Find the tag id in the DB to build the delete URL import os as _os + conn = sqlite3.connect(_os.environ["KHATA_DB_PATH"]) conn.row_factory = sqlite3.Row tag_id = conn.execute("SELECT id FROM tags WHERE name='fomo'").fetchone()["id"]