Compare commits
16 Commits
3c5e8571c3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f463f4af29 | |||
| e7dba96b0f | |||
| d9db4d6bde | |||
| 4c372881c1 | |||
| ec9b3b56fb | |||
| 870928e20d | |||
| 48c67e69ad | |||
| a8714ebf7e | |||
| be1544d5d5 | |||
| 17a1c29d76 | |||
| 61a13d65a4 | |||
| 13dcad6c0f | |||
| 6bf56d7537 | |||
| 7525d2b2ed | |||
| 4becbcdea3 | |||
| 4f6e5cd33a |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,5 +1,6 @@
|
|||||||
/venv
|
/venv/
|
||||||
|
/build/
|
||||||
|
/dev/
|
||||||
*.pyc
|
*.pyc
|
||||||
__pycache__
|
__pycache__
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
/dev/*
|
|
||||||
|
|||||||
2
LICENSE
2
LICENSE
@@ -1,4 +1,4 @@
|
|||||||
Copyright (c) 2025 omicron <omicron.me@protonmail.com>
|
Copyright (c) 2025-2026 omicron <omicron.me@protonmail.com>
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
this software and associated documentation files (the "Software"), to deal in
|
this software and associated documentation files (the "Software"), to deal in
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "0.1.0"
|
__version__ = "0.2.0"
|
||||||
|
|||||||
10
mft/app.py
10
mft/app.py
@@ -1,7 +1,6 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from mft.settings import settings
|
from mft.settings import settings
|
||||||
@@ -14,15 +13,6 @@ def create_app() -> FastAPI:
|
|||||||
description="A simple expense tracking application",
|
description="A simple expense tracking application",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Configure CORS
|
|
||||||
app.add_middleware(
|
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=settings.cors_origins,
|
|
||||||
allow_credentials=True,
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Register routes
|
# Register routes
|
||||||
from mft.routes import api_router
|
from mft.routes import api_router
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from fastapi import Depends, HTTPException, status
|
|||||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
|
||||||
from mft.database import get_db
|
from mft.database import get_db
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
|
||||||
security = HTTPBearer()
|
security = HTTPBearer()
|
||||||
@@ -23,6 +24,7 @@ def verify_token(
|
|||||||
HTTPException: If token is invalid or disabled
|
HTTPException: If token is invalid or disabled
|
||||||
"""
|
"""
|
||||||
token = credentials.credentials
|
token = credentials.credentials
|
||||||
|
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
@@ -32,7 +34,7 @@ def verify_token(
|
|||||||
FROM auth
|
FROM auth
|
||||||
WHERE token = ?
|
WHERE token = ?
|
||||||
""",
|
""",
|
||||||
(token,),
|
(token_hash,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
if result is None or not result["enabled"]:
|
if result is None or not result["enabled"]:
|
||||||
|
|||||||
142
mft/cli.py
142
mft/cli.py
@@ -4,7 +4,9 @@ import argparse
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
import uvicorn
|
import uvicorn
|
||||||
import mft.config
|
import mft.config
|
||||||
from datetime import datetime
|
import secrets
|
||||||
|
import hashlib
|
||||||
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
@@ -49,6 +51,15 @@ def parse_args() -> argparse.Namespace:
|
|||||||
user_add_parser = user_subparsers.add_parser("add", help="Add a new user")
|
user_add_parser = user_subparsers.add_parser("add", help="Add a new user")
|
||||||
user_add_parser.add_argument("name", type=str, help="User name")
|
user_add_parser.add_argument("name", type=str, help="User name")
|
||||||
|
|
||||||
|
# token (list, add, disable) subcommands
|
||||||
|
token_parser = subparsers.add_parser("token", help="Token management")
|
||||||
|
token_subparsers = token_parser.add_subparsers(dest="token_command", required=True)
|
||||||
|
token_list_parser = token_subparsers.add_parser("list", help="List all tokens")
|
||||||
|
token_add_parser = token_subparsers.add_parser("add", help="Add a new token")
|
||||||
|
token_add_parser.add_argument("username", type=str, help="User name for the token")
|
||||||
|
token_disable_parser = token_subparsers.add_parser("disable", help="Disable a token")
|
||||||
|
token_disable_parser.add_argument("token_id", type=int, help="Token ID to disable")
|
||||||
|
|
||||||
return transform_args(parser.parse_args())
|
return transform_args(parser.parse_args())
|
||||||
|
|
||||||
|
|
||||||
@@ -78,6 +89,8 @@ def main():
|
|||||||
db_command(args, settings)
|
db_command(args, settings)
|
||||||
elif args.command == "user":
|
elif args.command == "user":
|
||||||
user_command(args, settings)
|
user_command(args, settings)
|
||||||
|
elif args.command == "token":
|
||||||
|
token_command(args, settings)
|
||||||
|
|
||||||
|
|
||||||
def run_command(args, settings):
|
def run_command(args, settings):
|
||||||
@@ -100,6 +113,8 @@ def db_command(args, settings):
|
|||||||
def db_init_command(args, settings):
|
def db_init_command(args, settings):
|
||||||
from mft.database import get_db, db_init, SchemaError
|
from mft.database import get_db, db_init, SchemaError
|
||||||
|
|
||||||
|
settings.database_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
try:
|
try:
|
||||||
db_init(conn)
|
db_init(conn)
|
||||||
@@ -207,5 +222,130 @@ def user_add_command(args, settings):
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def token_command(args, settings):
|
||||||
|
if args.token_command == "list":
|
||||||
|
token_list_command(args, settings)
|
||||||
|
elif args.token_command == "add":
|
||||||
|
token_add_command(args, settings)
|
||||||
|
elif args.token_command == "disable":
|
||||||
|
token_disable_command(args, settings)
|
||||||
|
|
||||||
|
|
||||||
|
def token_list_command(args, settings):
|
||||||
|
from mft.database import get_db
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
a.id,
|
||||||
|
u.name,
|
||||||
|
a.created,
|
||||||
|
a.enabled,
|
||||||
|
SUBSTR(a.token, 1, 8) as token_prefix
|
||||||
|
FROM auth a
|
||||||
|
JOIN user u ON a.uid = u.id
|
||||||
|
ORDER BY a.id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
tokens = cursor.fetchall()
|
||||||
|
|
||||||
|
if not tokens:
|
||||||
|
print("No tokens found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"{'ID':<5} {'User':<20} {'Created':<30} {'Enabled':<10} {'Token Prefix':<15}")
|
||||||
|
print("-" * 90)
|
||||||
|
|
||||||
|
for token in tokens:
|
||||||
|
token_id = token[0]
|
||||||
|
username = token[1]
|
||||||
|
created = token[2]
|
||||||
|
enabled = "Yes" if token[3] else "No"
|
||||||
|
token_prefix = token[4]
|
||||||
|
print(
|
||||||
|
f"{token_id:<5} {username:<20} {created:<30} {enabled:<10} {token_prefix}..."
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"\nTotal tokens: {len(tokens)}")
|
||||||
|
|
||||||
|
|
||||||
|
def token_add_command(args, settings):
|
||||||
|
from mft.database import get_db
|
||||||
|
|
||||||
|
# Generate the token
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
# Hash the token for storage
|
||||||
|
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||||
|
|
||||||
|
# Get current timestamp
|
||||||
|
created = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Check if user exists
|
||||||
|
cursor.execute("SELECT id FROM user WHERE name = ?", (args.username,))
|
||||||
|
user = cursor.fetchone()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
print(
|
||||||
|
f"Error: User '{args.username}' does not exist.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
user_id = user[0]
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO auth (uid, created, token) VALUES (?, ?, ?)",
|
||||||
|
(user_id, created, token_hash),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
token_id = cursor.lastrowid
|
||||||
|
|
||||||
|
print(f"Token created successfully for user '{args.username}'")
|
||||||
|
print(f"Token ID: {token_id}")
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"Token (save this, it won't be shown again):")
|
||||||
|
print(f"{token}")
|
||||||
|
print(f"{'='*60}\n")
|
||||||
|
|
||||||
|
except sqlite3.IntegrityError as e:
|
||||||
|
print(f"Error: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def token_disable_command(args, settings):
|
||||||
|
from mft.database import get_db
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Check if token exists
|
||||||
|
cursor.execute("SELECT enabled FROM auth WHERE id = ?", (args.token_id,))
|
||||||
|
token = cursor.fetchone()
|
||||||
|
|
||||||
|
if not token:
|
||||||
|
print(
|
||||||
|
f"Error: Token with ID {args.token_id} does not exist.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not token[0]:
|
||||||
|
print(f"Token {args.token_id} is already disabled.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Disable the token
|
||||||
|
cursor.execute("UPDATE auth SET enabled = 0 WHERE id = ?", (args.token_id,))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
print(f"Token {args.token_id} has been disabled.")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ port = 8080
|
|||||||
|
|
||||||
[mft]
|
[mft]
|
||||||
database = "~/.local/var/db/mft.db"
|
database = "~/.local/var/db/mft.db"
|
||||||
cors_origins = ["http://127.0.0.1:8080"]
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@@ -30,7 +29,6 @@ class MftConfig(BaseModel):
|
|||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
database: str = "~/.local/var/db/mft.db"
|
database: str = "~/.local/var/db/mft.db"
|
||||||
cors_origins: list[str] = Field(default_factory=lambda: ["http://127.0.0.1:8080"])
|
|
||||||
|
|
||||||
|
|
||||||
class AppConfig(BaseModel):
|
class AppConfig(BaseModel):
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ def get_db() -> Generator[sqlite3.Connection, None, None]:
|
|||||||
"""Get a database connection context manager."""
|
"""Get a database connection context manager."""
|
||||||
conn = sqlite3.connect(settings.database_path)
|
conn = sqlite3.connect(settings.database_path)
|
||||||
conn.row_factory = sqlite3.Row # Enable dict-like access to rows
|
conn.row_factory = sqlite3.Row # Enable dict-like access to rows
|
||||||
|
conn.execute("PRAGMA foreign_keys = ON")
|
||||||
try:
|
try:
|
||||||
yield conn
|
yield conn
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
"""API routes aggregation."""
|
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
from mft.routes import auth, categories, expense, health, statistics
|
||||||
from mft.routes import auth, categories, health
|
|
||||||
|
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
|
|
||||||
# Include all route modules
|
|
||||||
api_router.include_router(health.router, tags=["health"])
|
api_router.include_router(health.router, tags=["health"])
|
||||||
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
||||||
api_router.include_router(categories.router, tags=["categories"])
|
api_router.include_router(categories.router, tags=["categories"])
|
||||||
|
api_router.include_router(expense.router, tags=["expenses"])
|
||||||
|
api_router.include_router(statistics.router, tags=["statistics"])
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
"""Authentication routes."""
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
from mft.auth import verify_token
|
from mft.auth import verify_token
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@@ -19,8 +15,4 @@ async def validate(user_id: int = Depends(verify_token)):
|
|||||||
Returns:
|
Returns:
|
||||||
A simple status response with the authenticated user ID
|
A simple status response with the authenticated user ID
|
||||||
"""
|
"""
|
||||||
return {
|
return {"status": "ok", "user_id": user_id, "message": "Successfully authenticated"}
|
||||||
"status": "ok",
|
|
||||||
"user_id": user_id,
|
|
||||||
"message": "Successfully authenticated"
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
"""Category endpoints."""
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from mft.auth import verify_token
|
from mft.auth import verify_token
|
||||||
from mft.database import get_db
|
from mft.database import get_db
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
192
mft/routes/expense.py
Normal file
192
mft/routes/expense.py
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from fastapi import APIRouter, HTTPException, status, Depends, Query
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from mft.auth import verify_token
|
||||||
|
from mft.database import get_db
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class ExpenseCreate(BaseModel):
|
||||||
|
cid: int = Field(..., description="Category ID")
|
||||||
|
value: int = Field(..., gt=0, description="Amount in cents")
|
||||||
|
note: str | None = Field(None, description="Optional note")
|
||||||
|
|
||||||
|
|
||||||
|
class Expense(BaseModel):
|
||||||
|
id: int
|
||||||
|
uid: int
|
||||||
|
cid: int
|
||||||
|
ts: str
|
||||||
|
value: int
|
||||||
|
note: str | None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_month(month_str: str) -> datetime:
|
||||||
|
"""
|
||||||
|
Parse YYYY-MM format string to datetime at start of month.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
month_str: String in YYYY-MM format
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
datetime object at start of month (00:00:00 UTC)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If format is invalid
|
||||||
|
"""
|
||||||
|
if not re.match(r"^\d{4}-\d{2}$", month_str):
|
||||||
|
raise ValueError(f"Invalid month format: {month_str}")
|
||||||
|
|
||||||
|
year, month = map(int, month_str.split("-"))
|
||||||
|
if month < 1 or month > 12:
|
||||||
|
raise ValueError(f"Invalid month value: {month}")
|
||||||
|
if year < 1 or year > 5000:
|
||||||
|
raise ValueError(f"Invalid year value: {year}")
|
||||||
|
return datetime(year, month, 1, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def get_default_range() -> tuple[datetime, datetime]:
|
||||||
|
"""
|
||||||
|
Get default time range: current month + previous month.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (from_date, to_date) where range is [from, to)
|
||||||
|
"""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
this_month = datetime(now.year, now.month, 1, tzinfo=timezone.utc)
|
||||||
|
to_date = this_month + relativedelta(months=1)
|
||||||
|
from_date = this_month - relativedelta(months=1)
|
||||||
|
|
||||||
|
return from_date, to_date
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/expenses", response_model=list[Expense])
|
||||||
|
def list_expenses(
|
||||||
|
from_month: str | None = Query(
|
||||||
|
None, description="Start month (YYYY-MM, inclusive)"
|
||||||
|
),
|
||||||
|
to_month: str | None = Query(None, description="End month (YYYY-MM, exclusive)"),
|
||||||
|
uid: int = Depends(verify_token),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
List expenses for a time range.
|
||||||
|
|
||||||
|
Query parameters must be both provided or both omitted.
|
||||||
|
If omitted, defaults to current month + previous month.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
from_month: Start of range in YYYY-MM format (inclusive)
|
||||||
|
to_month: End of range in YYYY-MM format (exclusive)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of expenses ordered by timestamp descending (newest first)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
400: Invalid parameters (format, missing one param, from > to)
|
||||||
|
"""
|
||||||
|
# FIXME: Timezones are more or less ignored unless you happen to live in
|
||||||
|
# UTC. Possible fixes are adding a TZ parameter to the api endpoint or
|
||||||
|
# storing the TZ preference in the user account table.
|
||||||
|
|
||||||
|
# Validate both-or-neither
|
||||||
|
if (from_month is None) != (to_month is None):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Both 'from' and 'to' parameters must be provided, or both omitted",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if from_month is None:
|
||||||
|
from_date, to_date = get_default_range()
|
||||||
|
else:
|
||||||
|
from_date = parse_month(from_month)
|
||||||
|
to_date = parse_month(to_month)
|
||||||
|
|
||||||
|
if from_date >= to_date:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="'from' must be before 'to'",
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=str(e),
|
||||||
|
) from e
|
||||||
|
|
||||||
|
# Convert to ISO format strings for SQLite comparison
|
||||||
|
from_ts = from_date.isoformat()
|
||||||
|
to_ts = to_date.isoformat()
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, uid, cid, ts, value, note
|
||||||
|
FROM expense
|
||||||
|
WHERE ts >= ? AND ts < ?
|
||||||
|
ORDER BY ts DESC
|
||||||
|
""",
|
||||||
|
(from_ts, to_ts),
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
return [
|
||||||
|
Expense(
|
||||||
|
id=row[0],
|
||||||
|
uid=row[1],
|
||||||
|
cid=row[2],
|
||||||
|
ts=row[3],
|
||||||
|
value=row[4],
|
||||||
|
note=row[5],
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/expenses", response_model=Expense, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_expense(expense: ExpenseCreate, uid: int = Depends(verify_token)):
|
||||||
|
"""
|
||||||
|
Create a new expense entry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
expense: Expense data (category ID, value in cents, optional note)
|
||||||
|
uid: User ID from authentication token
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The created expense with generated ID and timestamp
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
400: Invalid category ID or constraint violation
|
||||||
|
"""
|
||||||
|
timestamp = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
try:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO expense (uid, cid, ts, value, note)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(uid, expense.cid, timestamp, expense.value, expense.note),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
expense_id = cursor.lastrowid
|
||||||
|
|
||||||
|
return Expense(
|
||||||
|
id=expense_id,
|
||||||
|
uid=uid,
|
||||||
|
cid=expense.cid,
|
||||||
|
ts=timestamp,
|
||||||
|
value=expense.value,
|
||||||
|
note=expense.note,
|
||||||
|
)
|
||||||
|
except sqlite3.IntegrityError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Invalid request: constraint violation",
|
||||||
|
) from e
|
||||||
@@ -1,12 +1,8 @@
|
|||||||
"""Health check and status endpoints."""
|
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status")
|
@router.get("/status")
|
||||||
async def status():
|
async def status():
|
||||||
"""Minimal status check to verify the API is up."""
|
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|||||||
219
mft/routes/statistics.py
Normal file
219
mft/routes/statistics.py
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
import re
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from fastapi import APIRouter, HTTPException, status, Depends, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from mft.auth import verify_token
|
||||||
|
from mft.database import get_db
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryTotal(BaseModel):
|
||||||
|
"""Per-category total within a time period."""
|
||||||
|
|
||||||
|
cid: int
|
||||||
|
name: str
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class WeekTotal(BaseModel):
|
||||||
|
"""Weekly totals and per-category breakdown."""
|
||||||
|
|
||||||
|
week: str # ISO week format (YYYY-Www)
|
||||||
|
from_date: str # ISO date format (YYYY-MM-DD)
|
||||||
|
to_date: str # ISO date format (YYYY-MM-DD)
|
||||||
|
total: int
|
||||||
|
by_category: list[CategoryTotal]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_iso_week(week_str: str) -> datetime:
|
||||||
|
"""
|
||||||
|
Parse ISO week format (YYYY-Www) to datetime at start of week (Monday).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
week_str: String in YYYY-Www format (e.g., "2026-W01")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
datetime object for Monday of that week (00:00:00 UTC)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If format is invalid
|
||||||
|
"""
|
||||||
|
if not re.match(r"^\d{4}-W\d{2}$", week_str):
|
||||||
|
raise ValueError(f"Invalid ISO week format: {week_str}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Parse ISO week: %G=ISO year, %V=ISO week, %u=day of week (1=Mon)
|
||||||
|
return datetime.strptime(f"{week_str}-1", "%G-W%V-%u").replace(
|
||||||
|
tzinfo=timezone.utc
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise ValueError(f"Invalid ISO week: {week_str}") from e
|
||||||
|
|
||||||
|
|
||||||
|
def get_iso_week_string(dt: datetime) -> str:
|
||||||
|
"""
|
||||||
|
Convert datetime to ISO week format string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dt: datetime object
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ISO week string (YYYY-Www)
|
||||||
|
"""
|
||||||
|
iso_cal = dt.isocalendar()
|
||||||
|
return f"{iso_cal.year}-W{iso_cal.week:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_default_week_range() -> tuple[datetime, datetime]:
|
||||||
|
"""
|
||||||
|
Get default week range: The most recent 4 weeks, which includes the ongoing week.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (from_date, to_date) where range is [from, to)
|
||||||
|
from_date is Monday of 3 weeks ago
|
||||||
|
to_date is Monday of next week (start of week after current)
|
||||||
|
"""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
current_week_monday = now - timedelta(days=now.weekday())
|
||||||
|
current_week_monday = current_week_monday.replace(
|
||||||
|
hour=0, minute=0, second=0, microsecond=0
|
||||||
|
)
|
||||||
|
|
||||||
|
from_date = current_week_monday - timedelta(days=21)
|
||||||
|
to_date = current_week_monday + timedelta(days=7)
|
||||||
|
|
||||||
|
return from_date, to_date
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/statistics/totals", response_model=list[WeekTotal])
|
||||||
|
def get_weekly_totals(
|
||||||
|
granularity: str = Query(
|
||||||
|
"weekly", description="Time granularity (only 'weekly' supported)"
|
||||||
|
),
|
||||||
|
from_week: str | None = Query(None, description="Start week (YYYY-Www, inclusive)"),
|
||||||
|
to_week: str | None = Query(None, description="End week (YYYY-Www, exclusive)"),
|
||||||
|
uid: int = Depends(verify_token),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get totals aggregated by time period.
|
||||||
|
|
||||||
|
Currently only supports weekly granularity with ISO week format (YYYY-Www).
|
||||||
|
If from_week and to_week are omitted, defaults to the most recent 4 weeks
|
||||||
|
(including the current ongoing week).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
granularity: Time granularity (must be "weekly")
|
||||||
|
from_week: Start week in ISO format (inclusive)
|
||||||
|
to_week: End week in ISO format (exclusive)
|
||||||
|
uid: User ID from authentication token
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of weekly totals with global and per-category breakdowns
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
400: Invalid parameters (unsupported granularity, format errors, from >= to)
|
||||||
|
"""
|
||||||
|
# Validate granularity
|
||||||
|
if granularity != "weekly":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Only 'weekly' granularity is currently supported",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate both-or-neither for week parameters
|
||||||
|
if (from_week is None) != (to_week is None):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Both 'from_week' and 'to_week' must be provided, or both omitted",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if from_week is None:
|
||||||
|
from_date, to_date = get_default_week_range()
|
||||||
|
else:
|
||||||
|
from_date = parse_iso_week(from_week)
|
||||||
|
to_date = parse_iso_week(to_week)
|
||||||
|
|
||||||
|
if from_date >= to_date:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="'from_week' must be before 'to_week'",
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=str(e),
|
||||||
|
) from e
|
||||||
|
|
||||||
|
from_ts = from_date.isoformat()
|
||||||
|
to_ts = to_date.isoformat()
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# FIXME: this could be better with a GROUP BY/SUM query but it works
|
||||||
|
# well enough for small datasets
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT e.ts, e.value, e.cid, c.name
|
||||||
|
FROM expense e
|
||||||
|
JOIN category c ON e.cid = c.id
|
||||||
|
WHERE e.ts >= ? AND e.ts < ?
|
||||||
|
ORDER BY e.ts
|
||||||
|
""",
|
||||||
|
(from_ts, to_ts),
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
|
weeks_data = {}
|
||||||
|
for row in rows:
|
||||||
|
ts_str, value, cid, category_name = row
|
||||||
|
ts = datetime.fromisoformat(ts_str)
|
||||||
|
week_str = get_iso_week_string(ts)
|
||||||
|
|
||||||
|
if week_str not in weeks_data:
|
||||||
|
weeks_data[week_str] = {
|
||||||
|
"total": 0,
|
||||||
|
"categories": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
weeks_data[week_str]["total"] += value
|
||||||
|
|
||||||
|
if cid not in weeks_data[week_str]["categories"]:
|
||||||
|
weeks_data[week_str]["categories"][cid] = {
|
||||||
|
"name": category_name,
|
||||||
|
"total": 0,
|
||||||
|
}
|
||||||
|
weeks_data[week_str]["categories"][cid]["total"] += value
|
||||||
|
|
||||||
|
# Build response with all weeks in range (including empty ones)
|
||||||
|
result = []
|
||||||
|
current = from_date
|
||||||
|
while current < to_date:
|
||||||
|
week_str = get_iso_week_string(current)
|
||||||
|
week_data = weeks_data.get(week_str, {"total": 0, "categories": {}})
|
||||||
|
|
||||||
|
week_end = current + timedelta(days=7)
|
||||||
|
|
||||||
|
by_category = [
|
||||||
|
CategoryTotal(cid=cid, name=cat["name"], total=cat["total"])
|
||||||
|
for cid, cat in week_data["categories"].items()
|
||||||
|
]
|
||||||
|
# Sort by category name for consistent output
|
||||||
|
by_category.sort(key=lambda x: x.name)
|
||||||
|
|
||||||
|
result.append(
|
||||||
|
WeekTotal(
|
||||||
|
week=week_str,
|
||||||
|
from_date=current.date().isoformat(),
|
||||||
|
to_date=week_end.date().isoformat(),
|
||||||
|
total=week_data["total"],
|
||||||
|
by_category=by_category,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
current = week_end
|
||||||
|
|
||||||
|
return result
|
||||||
@@ -27,11 +27,18 @@ CREATE TABLE category (
|
|||||||
);
|
);
|
||||||
|
|
||||||
INSERT INTO category (name) VALUES
|
INSERT INTO category (name) VALUES
|
||||||
('Food/Drink'),
|
('Living'),
|
||||||
|
('Groceries'),
|
||||||
|
('Take-out'),
|
||||||
('Household consumables'),
|
('Household consumables'),
|
||||||
('Household Other'),
|
('Household goods'),
|
||||||
('Hobby'),
|
('Clothing'),
|
||||||
('Utilities');
|
('Entertainment'),
|
||||||
|
('Utilities'),
|
||||||
|
('Subscriptions'),
|
||||||
|
('Transportation'),
|
||||||
|
('Health'),
|
||||||
|
('Other');
|
||||||
|
|
||||||
CREATE TABLE expense (
|
CREATE TABLE expense (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ class Settings:
|
|||||||
|
|
||||||
def set_config_file_values(self, config):
|
def set_config_file_values(self, config):
|
||||||
self.database_path: Path = Path(config.mft.database).expanduser().absolute()
|
self.database_path: Path = Path(config.mft.database).expanduser().absolute()
|
||||||
self.cors_origins = config.mft.cors_origins
|
|
||||||
self.host = config.server.host
|
self.host = config.server.host
|
||||||
self.port = config.server.port
|
self.port = config.server.port
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
const TOKEN_KEY = 'mft_token';
|
const TOKEN_KEY = 'mft_token';
|
||||||
|
|
||||||
|
// Store categories for mapping cid to name
|
||||||
|
let categories = [];
|
||||||
|
|
||||||
// Validate token with the API
|
// Validate token with the API
|
||||||
async function validateToken(token) {
|
async function validateToken(token) {
|
||||||
try {
|
try {
|
||||||
@@ -58,11 +61,49 @@ document.getElementById('logout-btn').addEventListener('click', () => {
|
|||||||
document.getElementById('token-form').reset();
|
document.getElementById('token-form').reset();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle expense form submission (no-op for now)
|
// Handle expense form submission
|
||||||
document.getElementById('expense-form').addEventListener('submit', (e) => {
|
document.getElementById('expense-form').addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// TODO: Implement expense submission
|
|
||||||
console.log('Expense form submitted (no-op)');
|
const token = localStorage.getItem(TOKEN_KEY);
|
||||||
|
const categoryId = parseInt(document.getElementById('category').value);
|
||||||
|
const valueInDollars = parseFloat(document.getElementById('value').value);
|
||||||
|
const note = document.getElementById('note').value.trim() || null;
|
||||||
|
|
||||||
|
// Convert dollars to cents
|
||||||
|
const valueInCents = Math.round(valueInDollars * 100);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/expenses', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
cid: categoryId,
|
||||||
|
value: valueInCents,
|
||||||
|
note: note
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.detail || 'Failed to add expense');
|
||||||
|
}
|
||||||
|
|
||||||
|
const expense = await response.json();
|
||||||
|
console.log('Expense created:', expense);
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
document.getElementById('expense-form').reset();
|
||||||
|
|
||||||
|
// Add to expense list
|
||||||
|
addExpenseToList(expense);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding expense:', error);
|
||||||
|
alert(`Failed to add expense: ${error.message}`);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function showLogin() {
|
function showLogin() {
|
||||||
@@ -74,6 +115,7 @@ async function showApp() {
|
|||||||
document.getElementById('login-section').style.display = 'none';
|
document.getElementById('login-section').style.display = 'none';
|
||||||
document.getElementById('app-section').style.display = 'block';
|
document.getElementById('app-section').style.display = 'block';
|
||||||
await loadCategories();
|
await loadCategories();
|
||||||
|
await loadExpenses();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadCategories() {
|
async function loadCategories() {
|
||||||
@@ -92,7 +134,7 @@ async function loadCategories() {
|
|||||||
throw new Error('Failed to load categories');
|
throw new Error('Failed to load categories');
|
||||||
}
|
}
|
||||||
|
|
||||||
const categories = await response.json();
|
categories = await response.json();
|
||||||
|
|
||||||
// Clear loading option
|
// Clear loading option
|
||||||
categorySelect.innerHTML = '';
|
categorySelect.innerHTML = '';
|
||||||
@@ -115,3 +157,256 @@ async function loadCategories() {
|
|||||||
categorySelect.innerHTML = '<option value="">Error loading categories</option>';
|
categorySelect.innerHTML = '<option value="">Error loading categories</option>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadExpenses() {
|
||||||
|
const token = localStorage.getItem(TOKEN_KEY);
|
||||||
|
const listContainer = document.getElementById('expense-list');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/expenses', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load expenses');
|
||||||
|
}
|
||||||
|
|
||||||
|
const expenses = await response.json();
|
||||||
|
renderExpenses(expenses);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading expenses:', error);
|
||||||
|
listContainer.innerHTML = '<p>Error loading expenses</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createExpenseCard(expense) {
|
||||||
|
const category = categories.find(c => c.id === expense.cid);
|
||||||
|
const categoryName = category ? category.name : 'Unknown';
|
||||||
|
const amount = (expense.value / 100).toFixed(2);
|
||||||
|
const timestamp = new Date(expense.ts).toLocaleString();
|
||||||
|
|
||||||
|
// Create card structure
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'expense-item';
|
||||||
|
item.innerHTML = `
|
||||||
|
<div class="expense-header">
|
||||||
|
<span class="expense-category"></span>
|
||||||
|
<span class="expense-amount"></span>
|
||||||
|
</div>
|
||||||
|
<div class="expense-details">
|
||||||
|
<span class="expense-time"></span>
|
||||||
|
<span class="expense-note"></span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Populate with textContent
|
||||||
|
item.querySelector('.expense-category').textContent = categoryName;
|
||||||
|
item.querySelector('.expense-amount').textContent = `€${amount}`;
|
||||||
|
item.querySelector('.expense-time').textContent = timestamp;
|
||||||
|
|
||||||
|
if (expense.note) {
|
||||||
|
item.querySelector('.expense-note').textContent = expense.note;
|
||||||
|
} else {
|
||||||
|
item.querySelector('.expense-note').remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderExpenses(expenses) {
|
||||||
|
const listContainer = document.getElementById('expense-list');
|
||||||
|
|
||||||
|
if (expenses.length === 0) {
|
||||||
|
listContainer.innerHTML = '<p class="empty-state">No expenses recorded</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
listContainer.innerHTML = '';
|
||||||
|
expenses.forEach(expense => {
|
||||||
|
const card = createExpenseCard(expense);
|
||||||
|
listContainer.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addExpenseToList(expense) {
|
||||||
|
const listContainer = document.getElementById('expense-list');
|
||||||
|
|
||||||
|
// Remove empty state if it exists
|
||||||
|
const emptyState = listContainer.querySelector('.empty-state');
|
||||||
|
if (emptyState) {
|
||||||
|
listContainer.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const card = createExpenseCard(expense);
|
||||||
|
listContainer.insertAdjacentElement('afterbegin', card);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab switching
|
||||||
|
document.querySelectorAll('.tab-button').forEach(button => {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
const targetTab = button.dataset.tab;
|
||||||
|
|
||||||
|
// Update button states
|
||||||
|
document.querySelectorAll('.tab-button').forEach(btn => {
|
||||||
|
btn.classList.remove('active');
|
||||||
|
});
|
||||||
|
button.classList.add('active');
|
||||||
|
|
||||||
|
// Update content visibility
|
||||||
|
document.querySelectorAll('.tab-content').forEach(content => {
|
||||||
|
content.classList.remove('active');
|
||||||
|
});
|
||||||
|
document.getElementById(`${targetTab}-tab`).classList.add('active');
|
||||||
|
|
||||||
|
// Load statistics when switching to statistics tab
|
||||||
|
if (targetTab === 'statistics') {
|
||||||
|
loadWeeklyTotals();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Weekly totals functionality
|
||||||
|
async function loadWeeklyTotals() {
|
||||||
|
const token = localStorage.getItem(TOKEN_KEY);
|
||||||
|
const container = document.getElementById('weekly-totals');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/statistics/totals?granularity=weekly', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load weekly totals');
|
||||||
|
}
|
||||||
|
|
||||||
|
const weeklyData = await response.json();
|
||||||
|
renderWeeklyTotals(weeklyData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading weekly totals:', error);
|
||||||
|
container.innerHTML = '<p class="empty-state">Error loading weekly totals</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateRange(fromDate, toDate) {
|
||||||
|
const from = new Date(fromDate);
|
||||||
|
const to = new Date(toDate);
|
||||||
|
|
||||||
|
const options = { month: 'short', day: 'numeric' };
|
||||||
|
const fromStr = from.toLocaleDateString('en-US', options);
|
||||||
|
const toStr = to.toLocaleDateString('en-US', options);
|
||||||
|
|
||||||
|
return `${fromStr} - ${toStr}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWeeklyTotals(weeklyData) {
|
||||||
|
const container = document.getElementById('weekly-totals');
|
||||||
|
|
||||||
|
if (weeklyData.length === 0) {
|
||||||
|
container.innerHTML = '<p class="empty-state">No expense data available</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const table = document.createElement('table');
|
||||||
|
table.className = 'totals-table';
|
||||||
|
|
||||||
|
const tbody = document.createElement('tbody');
|
||||||
|
|
||||||
|
// Reverse to show newest weeks first
|
||||||
|
weeklyData.slice().reverse().forEach(week => {
|
||||||
|
// Main week row
|
||||||
|
const weekRow = document.createElement('tr');
|
||||||
|
weekRow.className = 'week-row';
|
||||||
|
weekRow.dataset.week = week.week;
|
||||||
|
|
||||||
|
const dateCell = document.createElement('td');
|
||||||
|
dateCell.className = 'week-date';
|
||||||
|
dateCell.innerHTML = `<span class="expand-icon">▶</span>${formatDateRange(week.from_date, week.to_date)}`;
|
||||||
|
|
||||||
|
const totalCell = document.createElement('td');
|
||||||
|
totalCell.className = 'week-total';
|
||||||
|
totalCell.textContent = `€${(week.total / 100).toFixed(2)}`;
|
||||||
|
|
||||||
|
weekRow.appendChild(dateCell);
|
||||||
|
weekRow.appendChild(totalCell);
|
||||||
|
|
||||||
|
// Categories row (hidden by default)
|
||||||
|
const categoriesRow = document.createElement('tr');
|
||||||
|
categoriesRow.className = 'categories-row';
|
||||||
|
categoriesRow.dataset.week = week.week;
|
||||||
|
|
||||||
|
const categoriesCell = document.createElement('td');
|
||||||
|
categoriesCell.colSpan = 2;
|
||||||
|
|
||||||
|
const categoriesContent = document.createElement('div');
|
||||||
|
categoriesContent.className = 'categories-content';
|
||||||
|
|
||||||
|
if (week.by_category.length === 0) {
|
||||||
|
categoriesContent.innerHTML = '<p class="empty-state">No expenses this week</p>';
|
||||||
|
} else {
|
||||||
|
week.by_category.forEach(category => {
|
||||||
|
const categoryItem = document.createElement('div');
|
||||||
|
categoryItem.className = 'category-item';
|
||||||
|
|
||||||
|
const nameSpan = document.createElement('span');
|
||||||
|
nameSpan.className = 'category-name';
|
||||||
|
nameSpan.textContent = category.name;
|
||||||
|
|
||||||
|
const amountSpan = document.createElement('span');
|
||||||
|
amountSpan.className = 'category-amount';
|
||||||
|
amountSpan.textContent = `€${(category.total / 100).toFixed(2)}`;
|
||||||
|
|
||||||
|
categoryItem.appendChild(nameSpan);
|
||||||
|
categoryItem.appendChild(amountSpan);
|
||||||
|
categoriesContent.appendChild(categoryItem);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
categoriesCell.appendChild(categoriesContent);
|
||||||
|
categoriesRow.appendChild(categoriesCell);
|
||||||
|
|
||||||
|
// Add click handler for expansion
|
||||||
|
weekRow.addEventListener('click', () => {
|
||||||
|
toggleWeekExpansion(week.week);
|
||||||
|
});
|
||||||
|
|
||||||
|
tbody.appendChild(weekRow);
|
||||||
|
tbody.appendChild(categoriesRow);
|
||||||
|
});
|
||||||
|
|
||||||
|
table.appendChild(tbody);
|
||||||
|
container.innerHTML = '';
|
||||||
|
container.appendChild(table);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleWeekExpansion(weekId) {
|
||||||
|
const weekRow = document.querySelector(`.week-row[data-week="${weekId}"]`);
|
||||||
|
const categoriesRow = document.querySelector(`.categories-row[data-week="${weekId}"]`);
|
||||||
|
|
||||||
|
weekRow.classList.toggle('expanded');
|
||||||
|
categoriesRow.classList.toggle('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expand/collapse all buttons
|
||||||
|
document.getElementById('expand-all-btn').addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.week-row').forEach(row => {
|
||||||
|
row.classList.add('expanded');
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.categories-row').forEach(row => {
|
||||||
|
row.classList.add('visible');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('collapse-all-btn').addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.week-row').forEach(row => {
|
||||||
|
row.classList.remove('expanded');
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.categories-row').forEach(row => {
|
||||||
|
row.classList.remove('visible');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -23,27 +23,51 @@
|
|||||||
|
|
||||||
<div id="app-section" style="display: none;">
|
<div id="app-section" style="display: none;">
|
||||||
<div class="header-bar">
|
<div class="header-bar">
|
||||||
<h2>Add Expense</h2>
|
<h2>Minimal Finance Tracker</h2>
|
||||||
<button id="logout-btn">Logout</button>
|
<button id="logout-btn">Logout</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form id="expense-form">
|
<div class="tabs">
|
||||||
<div class="form-group">
|
<button class="tab-button active" data-tab="expenses">Expenses</button>
|
||||||
<label for="category">Category</label>
|
<button class="tab-button" data-tab="statistics">Statistics</button>
|
||||||
<select id="category" name="category" required>
|
</div>
|
||||||
<option value="">Loading...</option>
|
|
||||||
</select>
|
<div id="expenses-tab" class="tab-content active">
|
||||||
|
<h2>Add Expense</h2>
|
||||||
|
<form id="expense-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="category">Category</label>
|
||||||
|
<select id="category" name="category" required>
|
||||||
|
<option value="">Loading...</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="value">Amount</label>
|
||||||
|
<input type="number" id="value" name="value" step="0.01" min="0" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="note">Note</label>
|
||||||
|
<input type="text" id="note" name="note">
|
||||||
|
</div>
|
||||||
|
<button type="submit">Add Expense</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="expense-list-section">
|
||||||
|
<h2>Recent Expenses</h2>
|
||||||
|
<div id="expense-list"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
</div>
|
||||||
<label for="value">Amount</label>
|
|
||||||
<input type="number" id="value" name="value" step="0.01" min="0" required>
|
<div id="statistics-tab" class="tab-content">
|
||||||
|
<div class="stats-header">
|
||||||
|
<h2>Weekly Totals</h2>
|
||||||
|
<div class="stats-controls">
|
||||||
|
<button id="expand-all-btn" class="secondary-btn">Expand All</button>
|
||||||
|
<button id="collapse-all-btn" class="secondary-btn">Collapse All</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div id="weekly-totals"></div>
|
||||||
<label for="note">Note</label>
|
</div>
|
||||||
<input type="text" id="note" name="note">
|
|
||||||
</div>
|
|
||||||
<button type="submit">Add Expense</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -87,3 +87,202 @@ select:focus {
|
|||||||
outline: none;
|
outline: none;
|
||||||
border-color: #3498db;
|
border-color: #3498db;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#expense-list-section {
|
||||||
|
margin-top: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expense-item {
|
||||||
|
padding: 15px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expense-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expense-category {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expense-amount {
|
||||||
|
font-size: 1.1em;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #e74c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expense-details {
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expense-time {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expense-note {
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
color: #999;
|
||||||
|
font-style: italic;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
border-bottom: 2px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button {
|
||||||
|
background: transparent;
|
||||||
|
color: #666;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 20px;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 3px solid transparent;
|
||||||
|
margin-bottom: -2px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button:hover {
|
||||||
|
background: transparent;
|
||||||
|
color: #3498db;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button.active {
|
||||||
|
color: #3498db;
|
||||||
|
border-bottom-color: #3498db;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-btn {
|
||||||
|
background: #95a5a6;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-btn:hover {
|
||||||
|
background: #7f8c8d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-row {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-row td {
|
||||||
|
padding: 15px;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-row:hover {
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-date {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-total {
|
||||||
|
text-align: right;
|
||||||
|
font-size: 1.1em;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #e74c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-icon {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 8px;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-row.expanded .expand-icon {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.categories-row {
|
||||||
|
display: none;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.categories-row.visible {
|
||||||
|
display: table-row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.categories-row td {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.categories-content {
|
||||||
|
padding: 10px 15px 15px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid #e8e8e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-name {
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-amount {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ requires-python = ">=3.12"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"fastapi",
|
"fastapi",
|
||||||
"uvicorn[standard]",
|
"uvicorn[standard]",
|
||||||
|
"python-dateutil",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
@@ -19,3 +20,10 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
mft = "mft.cli:main"
|
mft = "mft.cli:main"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["."]
|
||||||
|
include = ["mft*"]
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
mft = ["static/*", "schema/*"]
|
||||||
|
|||||||
Reference in New Issue
Block a user