Files
firefly_reports/COMPLETE_SOURCE_CODE.md
2026-05-27 13:58:05 +10:00

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:

  1. Copy each section to the corresponding file path
  2. Run backend/pyproject.toml setup
  3. Run npm install in frontend
  4. Use docker-compose up to deploy

Refer to INSTALLATION.md for detailed setup instructions.