Compare commits
6 Commits
48c67e69ad
...
f463f4af29
| Author | SHA1 | Date | |
|---|---|---|---|
| f463f4af29 | |||
| e7dba96b0f | |||
| d9db4d6bde | |||
| 4c372881c1 | |||
| ec9b3b56fb | |||
| 870928e20d |
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
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "0.2.0"
|
||||
|
||||
@@ -113,6 +113,8 @@ def db_command(args, settings):
|
||||
def db_init_command(args, settings):
|
||||
from mft.database import get_db, db_init, SchemaError
|
||||
|
||||
settings.database_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with get_db() as conn:
|
||||
try:
|
||||
db_init(conn)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from fastapi import APIRouter
|
||||
from mft.routes import auth, categories, expense, health
|
||||
from mft.routes import auth, categories, expense, health, statistics
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
@@ -7,3 +7,4 @@ api_router.include_router(health.router, tags=["health"])
|
||||
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
||||
api_router.include_router(categories.router, tags=["categories"])
|
||||
api_router.include_router(expense.router, tags=["expenses"])
|
||||
api_router.include_router(statistics.router, tags=["statistics"])
|
||||
|
||||
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
|
||||
@@ -243,3 +243,170 @@ function addExpenseToList(expense) {
|
||||
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,31 +23,50 @@
|
||||
|
||||
<div id="app-section" style="display: none;">
|
||||
<div class="header-bar">
|
||||
<h2>Add Expense</h2>
|
||||
<h2>Minimal Finance Tracker</h2>
|
||||
<button id="logout-btn">Logout</button>
|
||||
</div>
|
||||
|
||||
<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 class="tabs">
|
||||
<button class="tab-button active" data-tab="expenses">Expenses</button>
|
||||
<button class="tab-button" data-tab="statistics">Statistics</button>
|
||||
</div>
|
||||
|
||||
<div id="expense-list-section">
|
||||
<h2>Recent Expenses</h2>
|
||||
<div id="expense-list"></div>
|
||||
<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 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 id="weekly-totals"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -146,3 +146,143 @@ select:focus {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -21,8 +21,9 @@ build-backend = "setuptools.build_meta"
|
||||
[project.scripts]
|
||||
mft = "mft.cli:main"
|
||||
|
||||
[tool.setuptools]
|
||||
packages = ["mft"]
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["mft*"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
mft = ["static/*", "schema/*"]
|
||||
|
||||
Reference in New Issue
Block a user