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
21 changes: 21 additions & 0 deletions khata/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)."""
Expand Down
85 changes: 85 additions & 0 deletions khata/web/helpers.py
Original file line number Diff line number Diff line change
@@ -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)
230 changes: 230 additions & 0 deletions khata/web/main.py
Original file line number Diff line number Diff line change
@@ -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()
Loading