- Python 3.11+
- Node.js 22+ (with pnpm)
- Git
- VS Code (recommended)
# Clone and setup
git clone https://github.com/Sagargupta16/ledger-sync.git
cd ledger-sync
pnpm run setup # Installs all dependencies
# Run development servers
pnpm run dev # Backend: http://localhost:8000, Frontend: http://localhost:5173# 1. Clone repository
git clone https://github.com/Sagargupta16/ledger-sync.git
cd ledger-sync
# 2. Install Python dependencies
cd backend
uv sync --group dev
cd ..
# 3. Install Node dependencies
cd frontend
pnpm install
cd ..
# 4. Initialize database
cd backend
uv run alembic upgrade head
# 5. Install pre-commit hooks
uv run pre-commit install
cd ..# From project root - runs both services
pnpm run devTerminal 1 - Backend:
cd backend
uv run uvicorn ledger_sync.api.main:app --reload --port 8000Terminal 2 - Frontend:
cd frontend
pnpm run devbackend/
├── src/ledger_sync/
│ ├── api/ # FastAPI endpoints
│ ├── core/ # Business logic
│ │ ├── query_helpers.py # Shared SQL aggregation helpers
│ │ ├── analytics_engine.py
│ │ ├── calculator.py
│ │ ├── reconciler.py
│ │ └── sync_engine.py
│ ├── db/ # Database layer
│ ├── ingest/ # Data ingestion
│ └── utils/ # Utilities
├── tests/ # Test suite
├── alembic/ # Migrations
└── pyproject.toml # Dependencies (uv)
The backend server automatically reloads when you make changes (using --reload flag).
- Add endpoint to
src/ledger_sync/api/:
# In analytics.py
from fastapi import APIRouter, Depends, Query
from ledger_sync.db.session import get_session
router = APIRouter(prefix="/api/analytics", tags=["analytics"])
@router.get("/new-endpoint")
def get_new_data(db: Session = Depends(get_session)):
"""Get new data"""
# Implementation
return {"data": []}- Add business logic to
src/ledger_sync/core/:
# In calculator.py
def calculate_new_metric(transactions):
"""Calculate new metric"""
return sum(t.amount for t in transactions)- Test the endpoint:
- Access http://localhost:8000/docs
- Try the endpoint in Swagger UI
- Define model in
src/ledger_sync/db/models.py:
from sqlalchemy import Column, String, Integer
from ledger_sync.db.base import Base
class NewModel(Base):
__tablename__ = "new_table"
id = Column(Integer, primary_key=True)
name = Column(String(100))- Create migration:
uv run alembic revision --autogenerate -m "Add new_table"- Apply migration:
uv run alembic upgrade head# Run all tests
uv run pytest tests/ -v
# Run specific test file
uv run pytest tests/unit/test_hash_id.py
# Run with coverage
uv run pytest --cov=ledger_sync tests/
# Run with verbose output
uv run pytest -v# In tests/unit/test_example.py
import pytest
from ledger_sync.core.calculator import calculate_total_income
def test_calculate_total_income():
transactions = [
{"type": "Income", "amount": 100},
{"type": "Expense", "amount": 50},
]
result = calculate_total_income(transactions)
assert result == 100Using print statements:
print(f"Debug: {variable}") # Will show in terminalUsing Python debugger:
import pdb
pdb.set_trace() # Execution will pause hereUsing logging:
from ledger_sync.utils.logging import logger
logger.debug("Debug message")
logger.info("Info message")
logger.error("Error message")# Open SQLite shell
sqlite3 ledger_sync.db
# List tables
.tables
# Show schema
.schema transactions
# Run query
SELECT COUNT(*) FROM transactions;
# Exit
.quitimport time
start = time.time()
# Code to profile
end = time.time()
print(f"Elapsed: {end - start:.3f}s")frontend/
├── src/
│ ├── pages/ # 24 page components
│ ├── components/ # UI components
│ │ ├── analytics/ # Analytics components (25+, including CategoryBreakdown)
│ │ ├── layout/ # Layout components
│ │ ├── shared/ # Shared components
│ │ ├── transactions/ # Transaction components
│ │ ├── ui/ # Base UI components
│ │ └── upload/ # Upload components
│ ├── hooks/ # Custom hooks
│ │ ├── useAnalyticsTimeFilter.ts # Shared time-filter state for analytics pages
│ │ ├── useChartDimensions.ts # Responsive chart sizing
│ │ └── api/ # API-specific hooks (TanStack Query)
│ ├── lib/ # Utilities (formatters, tax/projection calculators, queryClient)
│ ├── services/ # API client
│ │ └── api/ # API service modules
│ ├── store/ # Zustand state stores
│ ├── types/ # TypeScript types
│ └── constants/ # App constants
├── public/ # Static assets
└── package.json # Dependencies
The frontend uses Vite's HMR (Hot Module Replacement). Changes automatically refresh in the browser.
- Create page component in
src/pages/:
// src/pages/NewPage.tsx
export default function NewPage() {
return (
<div className="p-6">
<h1 className="text-3xl font-bold text-white">New Page</h1>
{/* Content */}
</div>
);
}-
Add lazy import and route in
App.tsx(insidepageImportsand<Routes>). Never eager-import pages. -
Add sidebar entry in
Sidebar.tsxunder the appropriate navigation group.
- Create component in
src/components/analytics/:
// src/components/analytics/MyAnalyticsComponent.tsx
import { useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
interface Props {
timeRange?: string;
}
export default function MyAnalyticsComponent({ timeRange }: Props) {
const { data, isLoading, error } = useQuery({
queryKey: ["myData", timeRange],
queryFn: () => api.getMyData(timeRange),
});
if (isLoading) return <div className="animate-pulse">Loading...</div>;
if (error) return <div className="text-red-400">Error loading data</div>;
return (
<div className="bg-zinc-900 rounded-xl p-6 border border-white/10">
<h3 className="text-lg font-semibold text-white mb-4">My Analytics</h3>
{/* Chart or visualization */}
</div>
);
}- Import directly from the file where needed (no barrel files /
index.tsre-exports).
All analytics pages use useAnalyticsTimeFilter to manage time-range state consistently:
import { useAnalyticsTimeFilter } from '@/hooks/useAnalyticsTimeFilter'
import AnalyticsTimeFilter from '@/components/shared/AnalyticsTimeFilter'
export default function MyAnalyticsPage() {
const { data: allTransactions = [] } = useTransactions()
const { dateRange, dataDateRange, timeFilterProps } = useAnalyticsTimeFilter(
allTransactions,
{ availableModes: ['all_time', 'fy', 'yearly', 'monthly'] }, // optional
)
// Use dateRange.start_date / dateRange.end_date to filter data
// Spread timeFilterProps onto AnalyticsTimeFilter
return <AnalyticsTimeFilter {...timeFilterProps} />
}// src/hooks/api/useMyData.ts
import { useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
export function useMyData(timeRange?: string) {
return useQuery({
queryKey: ["myData", timeRange],
queryFn: () => api.getMyData(timeRange),
staleTime: Infinity, // Data is stable; only refetched on explicit invalidation
});
}- Add API call in
src/services/api/:
// src/services/api/myApi.ts
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "http://localhost:8000";
export interface MyDataResponse {
data: MyData[];
total: number;
}
export async function getMyData(timeRange?: string): Promise<MyDataResponse> {
const params = new URLSearchParams();
if (timeRange) params.set("time_range", timeRange);
const response = await fetch(
`${API_BASE_URL}/api/my-endpoint?${params.toString()}`,
);
if (!response.ok) {
throw new Error(`Failed to fetch: ${response.statusText}`);
}
return await response.json();
}- Use with TanStack Query:
import { useQuery } from "@tanstack/react-query";
import { getMyData } from "@/services/api/myApi";
export const MyComponent = () => {
const { data, isLoading, error } = useQuery({
queryKey: ["myData"],
queryFn: () => getMyData(),
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return <div>{/* Render data */}</div>;
};// src/store/myStore.ts
import { create } from "zustand";
import { persist } from "zustand/middleware";
interface MyStore {
items: string[];
addItem: (item: string) => void;
removeItem: (item: string) => void;
}
export const useMyStore = create<MyStore>()(
persist(
(set) => ({
items: [],
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
removeItem: (item) =>
set((state) => ({ items: state.items.filter((i) => i !== item) })),
}),
{ name: "my-store" },
),
);Use Tailwind CSS utility classes:
<div className="p-4 bg-white rounded-lg shadow-lg border border-gray-200">
<h2 className="text-xl font-bold text-gray-900">Title</h2>
<p className="text-gray-600 mt-2">Description</p>
</div>Keep types organized in src/types/:
// src/types/index.ts
export interface Transaction {
id: string;
date: Date;
amount: number;
type: "Income" | "Expense" | "Transfer";
category: string;
}
export interface KPIData {
income: number;
expenses: number;
netSavings: number;
}Browser DevTools:
- F12 to open
- Console tab for logs
- Network tab to inspect API calls
- React DevTools extension for component inspection
VS Code Debugger:
Create .vscode/launch.json:
{
"version": "0.2.0",
"configurations": [
{
"type": "chrome",
"request": "launch",
"name": "Launch Chrome",
"url": "http://localhost:5173",
"webRoot": "${workspaceFolder}/frontend/src"
}
]
}cd frontend
# Check for errors
pnpm run lint
# Format code
pnpm run format
# Type check
pnpm run type-check- Create feature branch:
git checkout -b feature/new-feature- Implement backend endpoint (if needed)
- Write backend tests
- Implement frontend page/component
- Add API integration
- Test end-to-end
- Commit and push:
git add .
git commit -m "feat: add new feature"
git push origin feature/new-featureBackend:
cd backend
uv pip list --outdated
uv lock --upgrade-package package_name && uv syncFrontend:
cd frontend
pnpm outdated
pnpm update
pnpm install new-packagecd backend
# Create migration
uv run alembic revision --autogenerate -m "Description"
# Apply
uv run alembic upgrade head
# Rollback
uv run alembic downgrade -1Create .env files:
backend/.env
LEDGER_SYNC_DATABASE_URL=sqlite:///./ledger_sync.db
LEDGER_SYNC_LOG_LEVEL=INFO
# OAuth — at least one provider required for login
LEDGER_SYNC_GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com
LEDGER_SYNC_GOOGLE_CLIENT_SECRET=your-google-client-secret
LEDGER_SYNC_GITHUB_CLIENT_ID=your-github-client-id
LEDGER_SYNC_GITHUB_CLIENT_SECRET=your-github-client-secret
LEDGER_SYNC_FRONTEND_URL=http://localhost:5173
frontend/.env
VITE_API_BASE_URL=http://localhost:8000
Authentication uses OAuth only (no email/password). You need at least one provider configured:
Google:
- Go to Google Cloud Console > Credentials
- Create an OAuth 2.0 Client ID (Web application)
- Add authorized redirect URI:
http://localhost:5173/auth/callback/google - Add authorized JavaScript origin:
http://localhost:5173 - Copy Client ID and Secret to
backend/.env
GitHub:
- Go to GitHub Developer Settings > OAuth Apps
- Create a new OAuth App
- Set callback URL:
http://localhost:5173/auth/callback/github - Copy Client ID and generate a Client Secret
- Add both to
backend/.env
# Update from main
git pull origin main
# Create feature branch
git checkout -b feature/my-feature
# Make changes and commit
git add .
git commit -m "commit message"
# Push to remote
git push origin feature/my-feature
# Create pull request on GitHub- Check Python version:
python --version - Install dependencies:
cd backend && uv sync --group dev - Check port 8000 is available
- Check database permissions
- Check Node version:
node --version - Install dependencies:
pnpm install - Clear node_modules:
rm -rf node_modules && pnpm install - Check port 5173 is available
- Clear Vite cache:
pnpm run clean
- Check SQLite file exists
- Run migrations:
uv run alembic upgrade head - Check permissions on database file
- Reset database: delete
.dbfile and re-run migrations
- Check backend is running on port 8000
- Check API endpoint exists
- Check request/response format
- Check CORS configuration
- Check browser console for errors
- Python: ms-python.python
- Pylance: ms-python.vscode-pylance
- Prettier: esbenp.prettier-vscode
- ESLint: dbaeumer.vscode-eslint
- REST Client: humao.rest-client
- SQLite: alexcvzz.vscode-sqlite
Create .vscode/launch.json:
{
"version": "0.2.0",
"configurations": [
{
"name": "Backend",
"type": "python",
"request": "launch",
"module": "uvicorn",
"args": ["ledger_sync.api.main:app", "--reload"],
"jinja": true,
"cwd": "${workspaceFolder}/backend"
}
]
}Add to .vscode/settings.json:
{
"python.defaultInterpreterPath": "${workspaceFolder}/backend/.venv/Scripts/python",
"python.linting.enabled": true,
"python.linting.pylintEnabled": true,
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[python]": {
"editor.defaultFormatter": "ms-python.python",
"editor.formatOnSave": true
}
}