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 @@ + + +
+ + + + +| time | +symbol | +dir | +qty | +entry | +exit | +net P&L | +fees | +status | +
|---|---|---|---|---|---|---|---|---|
| {{ 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 }} | +
No trades on this day.
+ {% endif %} + +| time | +role | +side | +qty | +price | +
|---|---|---|---|---|
| {{ fmt_time_ist(e.ts) }} | +{{ e.leg_role }} | +{{ e.side }} | +{{ e.qty_contributed }} | +{{ fmt_rupees(e.price_paise) }} | +