6 Commits
v0.1.0 ... main

Author SHA1 Message Date
f463f4af29 Update version number 2026-01-09 19:24:40 +01:00
e7dba96b0f Update LICENSE for 2026 2026-01-09 19:22:22 +01:00
d9db4d6bde Fix init crashing if db parent directory does not exist
The init command now correctly creates the parent directory of the
database file. Fixes #1.
2026-01-09 15:46:34 +01:00
4c372881c1 Fix broken installation
Changes the setuptools config to install all subpackages, not just the
top level mft package. Fixes #2.
2026-01-09 15:32:43 +01:00
ec9b3b56fb Split frontend into tabs, add a tab for showing totals 2026-01-09 12:04:43 +01:00
870928e20d Add routes to get weekly totals 2026-01-09 03:59:20 +01:00
9 changed files with 575 additions and 26 deletions

View File

@@ -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

View File

@@ -1 +1 @@
__version__ = "0.1.0"
__version__ = "0.2.0"

View File

@@ -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)

View File

@@ -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
View 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

View File

@@ -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');
});
});

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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/*"]