34 KiB
34 KiB
COMPLETE SOURCE CODE FOR FIREFLY III ANALYTICS
This file contains all source code for the application. Each section is clearly labeled with the file path.
BACKEND FILES
backend/pyproject.toml
[project]
name = "firefly-analytics-backend"
version = "0.1.0"
description = "Firefly III Analytics & Reporting Backend"
requires-python = ">=3.11"
dependencies = [
"fastapi==0.104.1",
"uvicorn[standard]==0.24.0",
"httpx==0.25.2",
"python-dotenv==1.0.0",
"pydantic==2.5.0",
"pydantic-settings==2.1.0",
"sqlalchemy==2.0.23",
"python-dateutil==2.8.2",
"apscheduler==3.10.4",
]
[project.optional-dependencies]
dev = [
"pytest==7.4.3",
"pytest-asyncio==0.21.1",
"black==23.12.0",
"ruff==0.1.8",
]
backend/Dockerfile
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
&& rm -rf /var/lib/apt/lists/*
COPY pyproject.toml pyproject.toml
COPY app/ app/
RUN pip install --no-cache-dir -e .
RUN mkdir -p /app/data
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
backend/app/init.py
# Firefly Analytics App
backend/app/config.py
import os
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
FIREFLY_URL: str = os.getenv("FIREFLY_URL", "https://firefly.scsimedia.duckdns.org")
FIREFLY_API_TOKEN: str = os.getenv("FIREFLY_API_TOKEN", "")
DATABASE_URL: str = os.getenv("DATABASE_URL", "sqlite:///./data/app.db")
SYNC_INTERVAL_MINUTES: int = int(os.getenv("SYNC_INTERVAL_MINUTES", "30"))
API_TIMEOUT: int = 30
class Config:
env_file = ".env"
settings = Settings()
backend/app/models.py
from sqlalchemy import Column, Integer, String, Float, DateTime, Boolean, Text, ForeignKey
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship
from datetime import datetime
Base = declarative_base()
class Transaction(Base):
__tablename__ = "transactions"
id = Column(Integer, primary_key=True, index=True)
firefly_id = Column(Integer, unique=True, index=True)
date = Column(DateTime, index=True)
amount = Column(Float)
description = Column(String)
type = Column(String)
category_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
from_account_id = Column(Integer, ForeignKey("accounts.id"), nullable=True)
to_account_id = Column(Integer, ForeignKey("accounts.id"), nullable=True)
notes = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
category = relationship("Category", foreign_keys=[category_id], back_populates="transactions")
from_account = relationship("Account", foreign_keys=[from_account_id])
to_account = relationship("Account", foreign_keys=[to_account_id])
class Category(Base):
__tablename__ = "categories"
id = Column(Integer, primary_key=True, index=True)
firefly_id = Column(Integer, unique=True, index=True)
name = Column(String, unique=True, index=True)
type = Column(String)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
transactions = relationship("Transaction", foreign_keys=[Transaction.category_id], back_populates="category")
class Account(Base):
__tablename__ = "accounts"
id = Column(Integer, primary_key=True, index=True)
firefly_id = Column(Integer, unique=True, index=True)
name = Column(String, unique=True, index=True)
type = Column(String)
balance = Column(Float)
currency_code = Column(String)
active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class SyncLog(Base):
__tablename__ = "sync_logs"
id = Column(Integer, primary_key=True, index=True)
entity_type = Column(String)
last_sync = Column(DateTime)
status = Column(String)
message = Column(String, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
backend/app/database.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, Session
from app.config import settings
from app.models import Base
engine = create_engine(
settings.DATABASE_URL,
connect_args={"check_same_thread": False} if "sqlite" in settings.DATABASE_URL else {}
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def init_db():
Base.metadata.create_all(bind=engine)
def get_db() -> Session:
db = SessionLocal()
try:
yield db
finally:
db.close()
backend/app/main.py
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from dotenv import load_dotenv
from app.database import init_db
from app.routers import transactions, categories, accounts, reports, summary
from app.services.sync_service import start_sync_scheduler, stop_sync_scheduler
load_dotenv()
@asynccontextmanager
async def lifespan(app: FastAPI):
init_db()
start_sync_scheduler()
yield
stop_sync_scheduler()
app = FastAPI(
title="Firefly III Analytics API",
description="Analytics and reporting for Firefly III",
version="0.1.0",
lifespan=lifespan
)
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000", "http://localhost"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(transactions.router, prefix="/api/transactions", tags=["transactions"])
app.include_router(categories.router, prefix="/api/categories", tags=["categories"])
app.include_router(accounts.router, prefix="/api/accounts", tags=["accounts"])
app.include_router(reports.router, prefix="/api/reports", tags=["reports"])
app.include_router(summary.router, prefix="/api/summary", tags=["summary"])
@app.get("/")
async def root():
return {"status": "ok", "message": "Firefly III Analytics API"}
@app.get("/health")
async def health():
return {"status": "healthy"}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
backend/app/routers/init.py
# API routers
backend/app/routers/transactions.py
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.database import get_db
from datetime import datetime
router = APIRouter()
@router.get("/")
async def get_transactions(
start_date: str = Query(None),
end_date: str = Query(None),
category_id: int = Query(None),
db: Session = Depends(get_db)
):
from app.models import Transaction
query = db.query(Transaction)
if start_date:
query = query.filter(Transaction.date >= datetime.fromisoformat(start_date))
if end_date:
query = query.filter(Transaction.date <= datetime.fromisoformat(end_date))
if category_id:
query = query.filter(Transaction.category_id == category_id)
transactions = query.order_by(Transaction.date.desc()).all()
return {"data": transactions, "count": len(transactions)}
backend/app/routers/categories.py
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import Category
router = APIRouter()
@router.get("/")
async def get_categories(db: Session = Depends(get_db)):
categories = db.query(Category).all()
return {"data": categories, "count": len(categories)}
backend/app/routers/accounts.py
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import Account
router = APIRouter()
@router.get("/")
async def get_accounts(db: Session = Depends(get_db)):
accounts = db.query(Account).all()
return {"data": accounts, "count": len(accounts)}
backend/app/routers/reports.py
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from sqlalchemy import func
from app.database import get_db
from app.models import Transaction, Category
from datetime import datetime
router = APIRouter()
@router.get("/spending-by-category")
async def spending_by_category(
start_date: str = Query(...),
end_date: str = Query(...),
db: Session = Depends(get_db)
):
start = datetime.fromisoformat(start_date)
end = datetime.fromisoformat(end_date)
results = db.query(
Category.name,
func.sum(Transaction.amount).label("total")
).join(
Transaction
).filter(
Transaction.date >= start,
Transaction.date <= end,
Transaction.type == "withdrawal"
).group_by(Category.name).all()
return {
"period": {"start": start.isoformat(), "end": end.isoformat()},
"data": [{"category": r[0], "amount": float(r[1]) if r[1] else 0} for r in results]
}
@router.get("/income-vs-expenses")
async def income_vs_expenses(
start_date: str = Query(...),
end_date: str = Query(...),
db: Session = Depends(get_db)
):
start = datetime.fromisoformat(start_date)
end = datetime.fromisoformat(end_date)
income = db.query(func.sum(Transaction.amount)).filter(
Transaction.date >= start,
Transaction.date <= end,
Transaction.type == "deposit"
).scalar() or 0
expenses = db.query(func.sum(Transaction.amount)).filter(
Transaction.date >= start,
Transaction.date <= end,
Transaction.type == "withdrawal"
).scalar() or 0
return {
"period": {"start": start.isoformat(), "end": end.isoformat()},
"income": float(income),
"expenses": float(expenses),
"net": float(income - expenses)
}
@router.get("/trends")
async def trends(
start_date: str = Query(...),
end_date: str = Query(...),
db: Session = Depends(get_db)
):
start = datetime.fromisoformat(start_date)
end = datetime.fromisoformat(end_date)
results = db.query(
func.date(Transaction.date).label("date"),
func.sum(Transaction.amount).label("amount")
).filter(
Transaction.date >= start,
Transaction.date <= end
).group_by(func.date(Transaction.date)).order_by("date").all()
return {
"period": {"start": start.isoformat(), "end": end.isoformat()},
"data": [{"date": str(r[0]), "amount": float(r[1]) if r[1] else 0} for r in results]
}
backend/app/routers/summary.py
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from sqlalchemy import func
from app.database import get_db
from app.models import Transaction, Account
from datetime import datetime, timedelta
router = APIRouter()
@router.get("/")
async def get_summary(db: Session = Depends(get_db)):
thirty_days_ago = datetime.utcnow() - timedelta(days=30)
total_assets = db.query(func.sum(Account.balance)).scalar() or 0
recent_transactions = db.query(func.count(Transaction.id)).filter(
Transaction.date >= thirty_days_ago
).scalar() or 0
income = db.query(func.sum(Transaction.amount)).filter(
Transaction.date >= thirty_days_ago,
Transaction.type == "deposit"
).scalar() or 0
expenses = db.query(func.sum(Transaction.amount)).filter(
Transaction.date >= thirty_days_ago,
Transaction.type == "withdrawal"
).scalar() or 0
return {
"total_assets": float(total_assets),
"recent_transactions": recent_transactions,
"last_30_days": {
"income": float(income),
"expenses": float(expenses),
"net": float(income - expenses)
}
}
backend/app/clients/init.py
# API clients
backend/app/clients/firefly_client.py
import httpx
from typing import Optional, List, Dict, Any
from app.config import settings
import logging
logger = logging.getLogger(__name__)
class FireflyClient:
def __init__(self):
self.base_url = settings.FIREFLY_URL.rstrip('/')
self.headers = {
"Authorization": f"Bearer {settings.FIREFLY_API_TOKEN}",
"Accept": "application/vnd.api+json",
"Content-Type": "application/json"
}
async def get_transactions(
self,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
limit: int = 50
) -> List[Dict[str, Any]]:
async with httpx.AsyncClient() as client:
params = {"limit": limit}
if start_date:
params["start"] = start_date
if end_date:
params["end"] = end_date
try:
response = await client.get(
f"{self.base_url}/api/v1/transactions",
headers=self.headers,
params=params,
timeout=settings.API_TIMEOUT
)
response.raise_for_status()
return response.json().get("data", [])
except Exception as e:
logger.error(f"Error fetching transactions: {e}")
return []
async def get_categories(self) -> List[Dict[str, Any]]:
async with httpx.AsyncClient() as client:
try:
response = await client.get(
f"{self.base_url}/api/v1/categories",
headers=self.headers,
timeout=settings.API_TIMEOUT
)
response.raise_for_status()
return response.json().get("data", [])
except Exception as e:
logger.error(f"Error fetching categories: {e}")
return []
async def get_accounts(self) -> List[Dict[str, Any]]:
async with httpx.AsyncClient() as client:
try:
response = await client.get(
f"{self.base_url}/api/v1/accounts",
headers=self.headers,
timeout=settings.API_TIMEOUT
)
response.raise_for_status()
return response.json().get("data", [])
except Exception as e:
logger.error(f"Error fetching accounts: {e}")
return []
firefly_client = FireflyClient()
backend/app/services/init.py
# Services
backend/app/services/sync_service.py
from apscheduler.schedulers.background import BackgroundScheduler
from app.config import settings
from app.clients.firefly_client import firefly_client
from app.database import SessionLocal
from app.models import Transaction, Category, Account, SyncLog
from datetime import datetime
import logging
import asyncio
logger = logging.getLogger(__name__)
scheduler = BackgroundScheduler()
async def sync_data():
db = SessionLocal()
try:
# Sync categories
categories = await firefly_client.get_categories()
for cat in categories:
attrs = cat.get("attributes", {})
existing = db.query(Category).filter_by(firefly_id=cat["id"]).first()
if existing:
existing.name = attrs.get("name")
existing.type = attrs.get("type")
existing.updated_at = datetime.utcnow()
else:
db.add(Category(
firefly_id=cat["id"],
name=attrs.get("name"),
type=attrs.get("type")
))
# Sync accounts
accounts = await firefly_client.get_accounts()
for acc in accounts:
attrs = acc.get("attributes", {})
existing = db.query(Account).filter_by(firefly_id=acc["id"]).first()
if existing:
existing.name = attrs.get("name")
existing.balance = float(attrs.get("current_balance", 0))
existing.updated_at = datetime.utcnow()
else:
db.add(Account(
firefly_id=acc["id"],
name=attrs.get("name"),
type=attrs.get("type"),
balance=float(attrs.get("current_balance", 0)),
currency_code=attrs.get("currency_code")
))
# Sync transactions
transactions = await firefly_client.get_transactions()
for txn in transactions:
attrs = txn.get("attributes", {})
existing = db.query(Transaction).filter_by(firefly_id=txn["id"]).first()
if not existing:
db.add(Transaction(
firefly_id=txn["id"],
date=datetime.fromisoformat(attrs.get("date")),
amount=float(attrs.get("amount", 0)),
description=attrs.get("description"),
type=attrs.get("type"),
notes=attrs.get("notes")
))
db.commit()
logger.info("Data sync completed successfully")
except Exception as e:
logger.error(f"Error syncing data: {e}")
db.rollback()
finally:
db.close()
def sync_job():
asyncio.run(sync_data())
def start_sync_scheduler():
if not scheduler.running:
scheduler.add_job(sync_job, 'interval', minutes=settings.SYNC_INTERVAL_MINUTES)
scheduler.start()
logger.info(f"Sync scheduler started (interval: {settings.SYNC_INTERVAL_MINUTES} minutes)")
def stop_sync_scheduler():
if scheduler.running:
scheduler.shutdown()
logger.info("Sync scheduler stopped")
FRONTEND FILES
frontend/package.json
{
"name": "firefly-analytics-frontend",
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"@types/node": "^20.10.6",
"@types/react": "^18.2.45",
"@types/react-dom": "^18.2.18",
"axios": "^1.6.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-scripts": "5.0.1",
"recharts": "^2.10.3",
"typescript": "^4.9.5",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}
frontend/Dockerfile
FROM node:18-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/build /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 3000
CMD ["nginx", "-g", "daemon off;"]
frontend/nginx.conf
server {
listen 3000;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
frontend/src/types.ts
export interface Transaction {
id: number;
firefly_id: number;
date: string;
amount: number;
description: string;
type: 'withdrawal' | 'deposit' | 'transfer';
category_id?: number;
}
export interface Category {
id: number;
firefly_id: number;
name: string;
type: string;
}
export interface Account {
id: number;
firefly_id: number;
name: string;
type: string;
balance: number;
currency_code: string;
}
export interface ReportData {
period: {
start: string;
end: string;
};
data: any[];
}
export interface SummaryData {
total_assets: number;
recent_transactions: number;
last_30_days: {
income: number;
expenses: number;
net: number;
};
}
frontend/src/services/api.ts
import axios from 'axios';
import { Transaction, Category, Account, ReportData, SummaryData } from '../types';
const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:8000';
const api = axios.create({
baseURL: API_URL,
});
export const transactionService = {
getTransactions: (startDate?: string, endDate?: string) =>
api.get<{ data: Transaction[] }>('/api/transactions', {
params: { start_date: startDate, end_date: endDate },
}),
};
export const categoryService = {
getCategories: () =>
api.get<{ data: Category[] }>('/api/categories'),
};
export const accountService = {
getAccounts: () =>
api.get<{ data: Account[] }>('/api/accounts'),
};
export const reportService = {
getSpendingByCategory: (startDate: string, endDate: string) =>
api.get<ReportData>('/api/reports/spending-by-category', {
params: { start_date: startDate, end_date: endDate },
}),
getIncomeVsExpenses: (startDate: string, endDate: string) =>
api.get<any>('/api/reports/income-vs-expenses', {
params: { start_date: startDate, end_date: endDate },
}),
getTrends: (startDate: string, endDate: string) =>
api.get<ReportData>('/api/reports/trends', {
params: { start_date: startDate, end_date: endDate },
}),
};
export const summaryService = {
getSummary: () =>
api.get<SummaryData>('/api/summary'),
};
frontend/src/components/DateRangePicker.tsx
import React from 'react';
interface DateRangePickerProps {
startDate: string;
endDate: string;
onStartDateChange: (date: string) => void;
onEndDateChange: (date: string) => void;
onPreset: (days: number) => void;
}
const DateRangePicker: React.FC<DateRangePickerProps> = ({
startDate,
endDate,
onStartDateChange,
onEndDateChange,
onPreset,
}) => {
return (
<div style={{ marginBottom: '20px', padding: '15px', backgroundColor: '#f5f5f5', borderRadius: '8px' }}>
<div style={{ display: 'flex', gap: '10px', marginBottom: '10px' }}>
<button onClick={() => onPreset(30)}>Last 30 Days</button>
<button onClick={() => onPreset(90)}>Last 90 Days</button>
<button onClick={() => onPreset(365)}>Last Year</button>
</div>
<div style={{ display: 'flex', gap: '10px' }}>
<input
type="date"
value={startDate}
onChange={(e) => onStartDateChange(e.target.value)}
placeholder="Start Date"
/>
<input
type="date"
value={endDate}
onChange={(e) => onEndDateChange(e.target.value)}
placeholder="End Date"
/>
</div>
</div>
);
};
export default DateRangePicker;
frontend/src/components/SpendingChart.tsx
import React from 'react';
import { PieChart, Pie, Cell, ResponsiveContainer, Legend, Tooltip } from 'recharts';
interface SpendingChartProps {
data: Array<{ category: string; amount: number }>;
}
const COLORS = [
'#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884D8',
'#82ca9d', '#ffc658', '#ff7c7c', '#8dd1e1', '#d084d0',
];
const SpendingChart: React.FC<SpendingChartProps> = ({ data }) => {
if (!data || data.length === 0) {
return <p>No data available</p>;
}
return (
<ResponsiveContainer width="100%" height={400}>
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
labelLine={false}
label={({ category, amount }) => `${category}: $${amount.toFixed(2)}`}
outerRadius={120}
fill="#8884d8"
dataKey="amount"
>
{data.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip formatter={(value: number) => `$${value.toFixed(2)}`} />
<Legend />
</PieChart>
</ResponsiveContainer>
);
};
export default SpendingChart;
frontend/src/components/TrendChart.tsx
import React from 'react';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
interface TrendChartProps {
data: Array<{ date: string; amount: number }>;
}
const TrendChart: React.FC<TrendChartProps> = ({ data }) => {
if (!data || data.length === 0) {
return <p>No data available</p>;
}
return (
<ResponsiveContainer width="100%" height={400}>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis />
<Tooltip formatter={(value: number) => `$${value.toFixed(2)}`} />
<Legend />
<Line type="monotone" dataKey="amount" stroke="#8884d8" />
</LineChart>
</ResponsiveContainer>
);
};
export default TrendChart;
frontend/src/pages/Dashboard.tsx
import React, { useState, useEffect } from 'react';
import { summaryService } from '../services/api';
import { SummaryData } from '../types';
const Dashboard: React.FC = () => {
const [summary, setSummary] = useState<SummaryData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchSummary = async () => {
try {
const response = await summaryService.getSummary();
setSummary(response.data);
} catch (error) {
console.error('Error fetching summary:', error);
} finally {
setLoading(false);
}
};
fetchSummary();
}, []);
if (loading) return <p>Loading...</p>;
if (!summary) return <p>Error loading data</p>;
return (
<div style={{ padding: '20px' }}>
<h1>Financial Dashboard</h1>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '20px', marginTop: '20px' }}>
<div style={{ backgroundColor: '#f5f5f5', padding: '20px', borderRadius: '8px' }}>
<h3>Total Assets</h3>
<p style={{ fontSize: '24px', fontWeight: 'bold' }}>${summary.total_assets.toFixed(2)}</p>
</div>
<div style={{ backgroundColor: '#f5f5f5', padding: '20px', borderRadius: '8px' }}>
<h3>Last 30 Days Income</h3>
<p style={{ fontSize: '24px', fontWeight: 'bold', color: '#00C49F' }}>
${summary.last_30_days.income.toFixed(2)}
</p>
</div>
<div style={{ backgroundColor: '#f5f5f5', padding: '20px', borderRadius: '8px' }}>
<h3>Last 30 Days Expenses</h3>
<p style={{ fontSize: '24px', fontWeight: 'bold', color: '#FF8042' }}>
${summary.last_30_days.expenses.toFixed(2)}
</p>
</div>
</div>
</div>
);
};
export default Dashboard;
frontend/src/pages/ReportsPage.tsx
import React, { useState, useEffect } from 'react';
import DateRangePicker from '../components/DateRangePicker';
import SpendingChart from '../components/SpendingChart';
import TrendChart from '../components/TrendChart';
import { reportService } from '../services/api';
import { ReportData } from '../types';
const ReportsPage: React.FC = () => {
const [reportType, setReportType] = useState('spending-by-category');
const [startDate, setStartDate] = useState(
new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
);
const [endDate, setEndDate] = useState(new Date().toISOString().split('T')[0]);
const [reportData, setReportData] = useState<any>(null);
const [loading, setLoading] = useState(false);
const handlePreset = (days: number) => {
const end = new Date();
const start = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
setStartDate(start.toISOString().split('T')[0]);
setEndDate(end.toISOString().split('T')[0]);
};
useEffect(() => {
const fetchReport = async () => {
setLoading(true);
try {
let response;
if (reportType === 'spending-by-category') {
response = await reportService.getSpendingByCategory(startDate, endDate);
} else if (reportType === 'income-vs-expenses') {
response = await reportService.getIncomeVsExpenses(startDate, endDate);
} else if (reportType === 'trends') {
response = await reportService.getTrends(startDate, endDate);
}
setReportData(response.data);
} catch (error) {
console.error('Error fetching report:', error);
} finally {
setLoading(false);
}
};
fetchReport();
}, [reportType, startDate, endDate]);
return (
<div style={{ padding: '20px' }}>
<h1>Reports</h1>
<DateRangePicker
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
onPreset={handlePreset}
/>
<select value={reportType} onChange={(e) => setReportType(e.target.value)} style={{ marginBottom: '20px', padding: '10px' }}>
<option value="spending-by-category">Spending by Category</option>
<option value="income-vs-expenses">Income vs Expenses</option>
<option value="trends">Trends</option>
</select>
{loading ? (
<p>Loading report...</p>
) : reportData ? (
<>
{reportType === 'spending-by-category' && <SpendingChart data={reportData.data} />}
{reportType === 'income-vs-expenses' && (
<div>
<p>Income: ${reportData.income.toFixed(2)}</p>
<p>Expenses: ${reportData.expenses.toFixed(2)}</p>
<p>Net: ${reportData.net.toFixed(2)}</p>
</div>
)}
{reportType === 'trends' && <TrendChart data={reportData.data} />}
</>
) : (
<p>No data available</p>
)}
</div>
);
};
export default ReportsPage;
frontend/src/App.tsx
import React, { useState } from 'react';
import Dashboard from './pages/Dashboard';
import ReportsPage from './pages/ReportsPage';
const App: React.FC = () => {
const [currentPage, setCurrentPage] = useState('dashboard');
return (
<div style={{ fontFamily: 'Arial, sans-serif' }}>
<nav style={{ backgroundColor: '#333', color: 'white', padding: '15px', display: 'flex', gap: '20px' }}>
<button
onClick={() => setCurrentPage('dashboard')}
style={{
backgroundColor: currentPage === 'dashboard' ? '#0088FE' : 'transparent',
color: 'white',
border: 'none',
padding: '10px 20px',
cursor: 'pointer',
borderRadius: '4px',
}}
>
Dashboard
</button>
<button
onClick={() => setCurrentPage('reports')}
style={{
backgroundColor: currentPage === 'reports' ? '#0088FE' : 'transparent',
color: 'white',
border: 'none',
padding: '10px 20px',
cursor: 'pointer',
borderRadius: '4px',
}}
>
Reports
</button>
</nav>
<main>
{currentPage === 'dashboard' && <Dashboard />}
{currentPage === 'reports' && <ReportsPage />}
</main>
</div>
);
};
export default App;
frontend/src/index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
frontend/public/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Firefly III Analytics & Reporting Dashboard"
/>
<title>Firefly III Analytics</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto',
'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans',
'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
</style>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>
Summary
All source code is provided above. To implement:
- Copy each section to the corresponding file path
- Run
backend/pyproject.tomlsetup - Run
npm installin frontend - Use
docker-compose upto deploy
Refer to INSTALLATION.md for detailed setup instructions.