This is the initial push of the outline of the life dashboard
This commit is contained in:
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
dist/
|
||||
venv/
|
||||
.venv/
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
frontend/dist/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
*.env.local
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Alembic
|
||||
*.pyc
|
||||
26
Dockerfile
Normal file
26
Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
||||
# Stage 1: Build frontend
|
||||
FROM node:20-alpine AS frontend-build
|
||||
WORKDIR /app/frontend
|
||||
COPY frontend/package.json frontend/package-lock.json* ./
|
||||
RUN npm install
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Backend runtime
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
|
||||
# Install Python dependencies
|
||||
COPY backend/requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy backend code
|
||||
COPY backend/ ./
|
||||
|
||||
# Copy built frontend into static directory
|
||||
COPY --from=frontend-build /app/frontend/dist ./static
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# Run migrations then start server
|
||||
CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000"]
|
||||
36
backend/alembic.ini
Normal file
36
backend/alembic.ini
Normal file
@@ -0,0 +1,36 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
sqlalchemy.url = postgresql://admin:AAAbraK!!!@localhost:5432/life
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
45
backend/alembic/env.py
Normal file
45
backend/alembic/env.py
Normal file
@@ -0,0 +1,45 @@
|
||||
import os
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from app.database import Base
|
||||
from app.models import * # noqa: F401,F403 — ensure all models are registered
|
||||
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
# Override URL from environment if available
|
||||
url = os.environ.get("DATABASE_URL")
|
||||
if url:
|
||||
config.set_main_option("sqlalchemy.url", url)
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
25
backend/alembic/script.py.mako
Normal file
25
backend/alembic/script.py.mako
Normal file
@@ -0,0 +1,25 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
106
backend/alembic/versions/001_initial_schema.py
Normal file
106
backend/alembic/versions/001_initial_schema.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""Initial schema
|
||||
|
||||
Revision ID: 001
|
||||
Revises:
|
||||
Create Date: 2026-03-09
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "001"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"weight_entries",
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column("date", sa.Date, unique=True, nullable=False),
|
||||
sa.Column("weight_lbs", sa.Numeric(5, 1), nullable=False),
|
||||
sa.Column("body_fat_pct", sa.Numeric(4, 1), nullable=True),
|
||||
sa.Column("notes", sa.Text, nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime,
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"exercises",
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column("name", sa.Text, unique=True, nullable=False),
|
||||
sa.Column("category", sa.Text, nullable=False),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"workouts",
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column("date", sa.Date, nullable=False),
|
||||
sa.Column("name", sa.Text, nullable=True),
|
||||
sa.Column("notes", sa.Text, nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime,
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"workout_sets",
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column(
|
||||
"workout_id",
|
||||
sa.Integer,
|
||||
sa.ForeignKey("workouts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"exercise_id",
|
||||
sa.Integer,
|
||||
sa.ForeignKey("exercises.id"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("set_number", sa.Integer, nullable=False),
|
||||
sa.Column("reps", sa.Integer, nullable=True),
|
||||
sa.Column("weight_lbs", sa.Numeric(6, 1), nullable=True),
|
||||
sa.Column("duration_min", sa.Numeric(5, 1), nullable=True),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"finance_transactions",
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column("date", sa.Date, nullable=False),
|
||||
sa.Column("amount", sa.Numeric(12, 2), nullable=False),
|
||||
sa.Column("category", sa.Text, nullable=False),
|
||||
sa.Column("description", sa.Text, nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime,
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"finance_snapshots",
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column("date", sa.Date, unique=True, nullable=False),
|
||||
sa.Column("net_worth", sa.Numeric(14, 2), nullable=False),
|
||||
sa.Column("notes", sa.Text, nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("finance_snapshots")
|
||||
op.drop_table("finance_transactions")
|
||||
op.drop_table("workout_sets")
|
||||
op.drop_table("workouts")
|
||||
op.drop_table("exercises")
|
||||
op.drop_table("weight_entries")
|
||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
32
backend/app/auth.py
Normal file
32
backend/app/auth.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from jose import JWTError, jwt
|
||||
|
||||
from app.config import settings
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
def create_access_token(subject: str) -> str:
|
||||
expire = datetime.now(timezone.utc) + timedelta(hours=settings.JWT_EXPIRE_HOURS)
|
||||
payload = {"sub": subject, "exp": expire}
|
||||
return jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
|
||||
|
||||
|
||||
def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
) -> str:
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
credentials.credentials,
|
||||
settings.JWT_SECRET,
|
||||
algorithms=[settings.JWT_ALGORITHM],
|
||||
)
|
||||
username: str | None = payload.get("sub")
|
||||
if username is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
return username
|
||||
except JWTError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
13
backend/app/config.py
Normal file
13
backend/app/config.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
DATABASE_URL: str = "postgresql://admin:AAAbraK!!!@localhost:5432/life"
|
||||
AUTH_USERNAME: str = "will"
|
||||
AUTH_PASSWORD: str = "changeme"
|
||||
JWT_SECRET: str = "changeme-secret"
|
||||
JWT_ALGORITHM: str = "HS256"
|
||||
JWT_EXPIRE_HOURS: int = 24
|
||||
|
||||
|
||||
settings = Settings()
|
||||
19
backend/app/database.py
Normal file
19
backend/app/database.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
||||
|
||||
from app.config import settings
|
||||
|
||||
engine = create_engine(settings.DATABASE_URL)
|
||||
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
28
backend/app/main.py
Normal file
28
backend/app/main.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.responses import FileResponse
|
||||
|
||||
from app.routers import auth, finance, weight, workout
|
||||
|
||||
app = FastAPI(title="Life Dashboard API")
|
||||
|
||||
# API routers
|
||||
app.include_router(auth.router)
|
||||
app.include_router(weight.router)
|
||||
app.include_router(workout.router)
|
||||
app.include_router(finance.router)
|
||||
|
||||
# Serve React SPA static files
|
||||
STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
|
||||
|
||||
if STATIC_DIR.exists():
|
||||
app.mount("/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets")
|
||||
|
||||
@app.get("/{full_path:path}")
|
||||
async def serve_spa(full_path: str):
|
||||
file_path = STATIC_DIR / full_path
|
||||
if file_path.is_file():
|
||||
return FileResponse(file_path)
|
||||
return FileResponse(STATIC_DIR / "index.html")
|
||||
12
backend/app/models/__init__.py
Normal file
12
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from app.models.weight import WeightEntry
|
||||
from app.models.workout import Exercise, Workout, WorkoutSet
|
||||
from app.models.finance import FinanceTransaction, FinanceSnapshot
|
||||
|
||||
__all__ = [
|
||||
"WeightEntry",
|
||||
"Exercise",
|
||||
"Workout",
|
||||
"WorkoutSet",
|
||||
"FinanceTransaction",
|
||||
"FinanceSnapshot",
|
||||
]
|
||||
26
backend/app/models/finance.py
Normal file
26
backend/app/models/finance.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Date, Numeric, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class FinanceTransaction(Base):
|
||||
__tablename__ = "finance_transactions"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
date: Mapped[date] = mapped_column(Date)
|
||||
amount: Mapped[float] = mapped_column(Numeric(12, 2))
|
||||
category: Mapped[str] = mapped_column(Text)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
|
||||
|
||||
|
||||
class FinanceSnapshot(Base):
|
||||
__tablename__ = "finance_snapshots"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
date: Mapped[date] = mapped_column(Date, unique=True)
|
||||
net_worth: Mapped[float] = mapped_column(Numeric(14, 2))
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
17
backend/app/models/weight.py
Normal file
17
backend/app/models/weight.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Date, Numeric, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class WeightEntry(Base):
|
||||
__tablename__ = "weight_entries"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
date: Mapped[date] = mapped_column(Date, unique=True)
|
||||
weight_lbs: Mapped[float] = mapped_column(Numeric(5, 1))
|
||||
body_fat_pct: Mapped[float | None] = mapped_column(Numeric(4, 1), nullable=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
|
||||
43
backend/app/models/workout.py
Normal file
43
backend/app/models/workout.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Date, ForeignKey, Integer, Numeric, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Exercise(Base):
|
||||
__tablename__ = "exercises"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(Text, unique=True)
|
||||
category: Mapped[str] = mapped_column(Text)
|
||||
|
||||
|
||||
class Workout(Base):
|
||||
__tablename__ = "workouts"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
date: Mapped[date] = mapped_column(Date)
|
||||
name: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
|
||||
|
||||
sets: Mapped[list["WorkoutSet"]] = relationship(
|
||||
back_populates="workout", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class WorkoutSet(Base):
|
||||
__tablename__ = "workout_sets"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
workout_id: Mapped[int] = mapped_column(ForeignKey("workouts.id", ondelete="CASCADE"))
|
||||
exercise_id: Mapped[int] = mapped_column(ForeignKey("exercises.id"))
|
||||
set_number: Mapped[int] = mapped_column(Integer)
|
||||
reps: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
weight_lbs: Mapped[float | None] = mapped_column(Numeric(6, 1), nullable=True)
|
||||
duration_min: Mapped[float | None] = mapped_column(Numeric(5, 1), nullable=True)
|
||||
|
||||
workout: Mapped["Workout"] = relationship(back_populates="sets")
|
||||
exercise: Mapped["Exercise"] = relationship()
|
||||
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
37
backend/app/routers/auth.py
Normal file
37
backend/app/routers/auth.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.auth import create_access_token, get_current_user
|
||||
from app.config import settings
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
username: str
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
def login(body: LoginRequest):
|
||||
if body.username != settings.AUTH_USERNAME or body.password != settings.AUTH_PASSWORD:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid credentials",
|
||||
)
|
||||
token = create_access_token(body.username)
|
||||
return TokenResponse(access_token=token)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
def me(username: str = Depends(get_current_user)):
|
||||
return UserResponse(username=username)
|
||||
143
backend/app/routers/finance.py
Normal file
143
backend/app/routers/finance.py
Normal file
@@ -0,0 +1,143 @@
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import get_current_user
|
||||
from app.database import get_db
|
||||
from app.models.finance import FinanceSnapshot, FinanceTransaction
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/finances", tags=["finances"], dependencies=[Depends(get_current_user)]
|
||||
)
|
||||
|
||||
|
||||
# --- Schemas ---
|
||||
|
||||
|
||||
class TransactionCreate(BaseModel):
|
||||
date: date
|
||||
amount: float
|
||||
category: str
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class TransactionRead(TransactionCreate):
|
||||
id: int
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class SnapshotCreate(BaseModel):
|
||||
date: date
|
||||
net_worth: float
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class SnapshotRead(SnapshotCreate):
|
||||
id: int
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class CategorySummary(BaseModel):
|
||||
category: str
|
||||
total: float
|
||||
|
||||
|
||||
# --- Transaction endpoints ---
|
||||
|
||||
|
||||
@router.get("/transactions", response_model=list[TransactionRead])
|
||||
def list_transactions(
|
||||
from_date: date | None = Query(None, alias="from"),
|
||||
to_date: date | None = Query(None, alias="to"),
|
||||
category: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
q = select(FinanceTransaction).order_by(FinanceTransaction.date.desc())
|
||||
if from_date:
|
||||
q = q.where(FinanceTransaction.date >= from_date)
|
||||
if to_date:
|
||||
q = q.where(FinanceTransaction.date <= to_date)
|
||||
if category:
|
||||
q = q.where(FinanceTransaction.category == category)
|
||||
return db.scalars(q).all()
|
||||
|
||||
|
||||
@router.post("/transactions", response_model=TransactionRead, status_code=201)
|
||||
def create_transaction(body: TransactionCreate, db: Session = Depends(get_db)):
|
||||
txn = FinanceTransaction(**body.model_dump())
|
||||
db.add(txn)
|
||||
db.commit()
|
||||
db.refresh(txn)
|
||||
return txn
|
||||
|
||||
|
||||
@router.put("/transactions/{txn_id}", response_model=TransactionRead)
|
||||
def update_transaction(
|
||||
txn_id: int, body: TransactionCreate, db: Session = Depends(get_db)
|
||||
):
|
||||
txn = db.get(FinanceTransaction, txn_id)
|
||||
if not txn:
|
||||
raise HTTPException(status_code=404, detail="Transaction not found")
|
||||
for key, val in body.model_dump().items():
|
||||
setattr(txn, key, val)
|
||||
db.commit()
|
||||
db.refresh(txn)
|
||||
return txn
|
||||
|
||||
|
||||
@router.delete("/transactions/{txn_id}", status_code=204)
|
||||
def delete_transaction(txn_id: int, db: Session = Depends(get_db)):
|
||||
txn = db.get(FinanceTransaction, txn_id)
|
||||
if not txn:
|
||||
raise HTTPException(status_code=404, detail="Transaction not found")
|
||||
db.delete(txn)
|
||||
db.commit()
|
||||
|
||||
|
||||
# --- Snapshot endpoints ---
|
||||
|
||||
|
||||
@router.get("/snapshots", response_model=list[SnapshotRead])
|
||||
def list_snapshots(db: Session = Depends(get_db)):
|
||||
return db.scalars(
|
||||
select(FinanceSnapshot).order_by(FinanceSnapshot.date.desc())
|
||||
).all()
|
||||
|
||||
|
||||
@router.post("/snapshots", response_model=SnapshotRead, status_code=201)
|
||||
def create_snapshot(body: SnapshotCreate, db: Session = Depends(get_db)):
|
||||
snap = FinanceSnapshot(**body.model_dump())
|
||||
db.add(snap)
|
||||
db.commit()
|
||||
db.refresh(snap)
|
||||
return snap
|
||||
|
||||
|
||||
# --- Summary endpoint ---
|
||||
|
||||
|
||||
@router.get("/summary", response_model=list[CategorySummary])
|
||||
def spending_summary(
|
||||
period: str = Query("month", pattern="^(month|year)$"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
today = date.today()
|
||||
if period == "month":
|
||||
start = today.replace(day=1)
|
||||
else:
|
||||
start = today.replace(month=1, day=1)
|
||||
|
||||
rows = db.execute(
|
||||
select(
|
||||
FinanceTransaction.category,
|
||||
func.sum(FinanceTransaction.amount).label("total"),
|
||||
)
|
||||
.where(FinanceTransaction.date >= start)
|
||||
.where(FinanceTransaction.amount < 0)
|
||||
.group_by(FinanceTransaction.category)
|
||||
.order_by(func.sum(FinanceTransaction.amount))
|
||||
).all()
|
||||
return [CategorySummary(category=r.category, total=float(r.total)) for r in rows]
|
||||
71
backend/app/routers/weight.py
Normal file
71
backend/app/routers/weight.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import get_current_user
|
||||
from app.database import get_db
|
||||
from app.models.weight import WeightEntry
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/weight", tags=["weight"], dependencies=[Depends(get_current_user)]
|
||||
)
|
||||
|
||||
|
||||
class WeightCreate(BaseModel):
|
||||
date: date
|
||||
weight_lbs: float
|
||||
body_fat_pct: float | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class WeightRead(WeightCreate):
|
||||
id: int
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
@router.get("", response_model=list[WeightRead])
|
||||
def list_weight(
|
||||
from_date: date | None = Query(None, alias="from"),
|
||||
to_date: date | None = Query(None, alias="to"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
q = select(WeightEntry).order_by(WeightEntry.date.desc())
|
||||
if from_date:
|
||||
q = q.where(WeightEntry.date >= from_date)
|
||||
if to_date:
|
||||
q = q.where(WeightEntry.date <= to_date)
|
||||
return db.scalars(q).all()
|
||||
|
||||
|
||||
@router.post("", response_model=WeightRead, status_code=201)
|
||||
def create_weight(body: WeightCreate, db: Session = Depends(get_db)):
|
||||
entry = WeightEntry(**body.model_dump())
|
||||
db.add(entry)
|
||||
db.commit()
|
||||
db.refresh(entry)
|
||||
return entry
|
||||
|
||||
|
||||
@router.put("/{entry_id}", response_model=WeightRead)
|
||||
def update_weight(entry_id: int, body: WeightCreate, db: Session = Depends(get_db)):
|
||||
entry = db.get(WeightEntry, entry_id)
|
||||
if not entry:
|
||||
raise HTTPException(status_code=404, detail="Entry not found")
|
||||
for key, val in body.model_dump().items():
|
||||
setattr(entry, key, val)
|
||||
db.commit()
|
||||
db.refresh(entry)
|
||||
return entry
|
||||
|
||||
|
||||
@router.delete("/{entry_id}", status_code=204)
|
||||
def delete_weight(entry_id: int, db: Session = Depends(get_db)):
|
||||
entry = db.get(WeightEntry, entry_id)
|
||||
if not entry:
|
||||
raise HTTPException(status_code=404, detail="Entry not found")
|
||||
db.delete(entry)
|
||||
db.commit()
|
||||
137
backend/app/routers/workout.py
Normal file
137
backend/app/routers/workout.py
Normal file
@@ -0,0 +1,137 @@
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.auth import get_current_user
|
||||
from app.database import get_db
|
||||
from app.models.workout import Exercise, Workout, WorkoutSet
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api", tags=["workouts"], dependencies=[Depends(get_current_user)]
|
||||
)
|
||||
|
||||
|
||||
# --- Schemas ---
|
||||
|
||||
|
||||
class ExerciseCreate(BaseModel):
|
||||
name: str
|
||||
category: str
|
||||
|
||||
|
||||
class ExerciseRead(ExerciseCreate):
|
||||
id: int
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class WorkoutSetCreate(BaseModel):
|
||||
exercise_id: int
|
||||
set_number: int
|
||||
reps: int | None = None
|
||||
weight_lbs: float | None = None
|
||||
duration_min: float | None = None
|
||||
|
||||
|
||||
class WorkoutSetRead(WorkoutSetCreate):
|
||||
id: int
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class WorkoutCreate(BaseModel):
|
||||
date: date
|
||||
name: str | None = None
|
||||
notes: str | None = None
|
||||
sets: list[WorkoutSetCreate] = []
|
||||
|
||||
|
||||
class WorkoutRead(BaseModel):
|
||||
id: int
|
||||
date: date
|
||||
name: str | None
|
||||
notes: str | None
|
||||
sets: list[WorkoutSetRead]
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# --- Exercise endpoints ---
|
||||
|
||||
|
||||
@router.get("/exercises", response_model=list[ExerciseRead])
|
||||
def list_exercises(db: Session = Depends(get_db)):
|
||||
return db.scalars(select(Exercise).order_by(Exercise.name)).all()
|
||||
|
||||
|
||||
@router.post("/exercises", response_model=ExerciseRead, status_code=201)
|
||||
def create_exercise(body: ExerciseCreate, db: Session = Depends(get_db)):
|
||||
ex = Exercise(**body.model_dump())
|
||||
db.add(ex)
|
||||
db.commit()
|
||||
db.refresh(ex)
|
||||
return ex
|
||||
|
||||
|
||||
# --- Workout endpoints ---
|
||||
|
||||
|
||||
@router.get("/workouts", response_model=list[WorkoutRead])
|
||||
def list_workouts(
|
||||
from_date: date | None = Query(None, alias="from"),
|
||||
to_date: date | None = Query(None, alias="to"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
q = (
|
||||
select(Workout)
|
||||
.options(selectinload(Workout.sets))
|
||||
.order_by(Workout.date.desc())
|
||||
)
|
||||
if from_date:
|
||||
q = q.where(Workout.date >= from_date)
|
||||
if to_date:
|
||||
q = q.where(Workout.date <= to_date)
|
||||
return db.scalars(q).all()
|
||||
|
||||
|
||||
@router.post("/workouts", response_model=WorkoutRead, status_code=201)
|
||||
def create_workout(body: WorkoutCreate, db: Session = Depends(get_db)):
|
||||
sets_data = body.sets
|
||||
workout = Workout(
|
||||
date=body.date,
|
||||
name=body.name,
|
||||
notes=body.notes,
|
||||
sets=[WorkoutSet(**s.model_dump()) for s in sets_data],
|
||||
)
|
||||
db.add(workout)
|
||||
db.commit()
|
||||
db.refresh(workout)
|
||||
return workout
|
||||
|
||||
|
||||
@router.put("/workouts/{workout_id}", response_model=WorkoutRead)
|
||||
def update_workout(
|
||||
workout_id: int, body: WorkoutCreate, db: Session = Depends(get_db)
|
||||
):
|
||||
workout = db.get(Workout, workout_id, options=[selectinload(Workout.sets)])
|
||||
if not workout:
|
||||
raise HTTPException(status_code=404, detail="Workout not found")
|
||||
workout.date = body.date
|
||||
workout.name = body.name
|
||||
workout.notes = body.notes
|
||||
# Replace sets
|
||||
workout.sets.clear()
|
||||
for s in body.sets:
|
||||
workout.sets.append(WorkoutSet(**s.model_dump()))
|
||||
db.commit()
|
||||
db.refresh(workout)
|
||||
return workout
|
||||
|
||||
|
||||
@router.delete("/workouts/{workout_id}", status_code=204)
|
||||
def delete_workout(workout_id: int, db: Session = Depends(get_db)):
|
||||
workout = db.get(Workout, workout_id)
|
||||
if not workout:
|
||||
raise HTTPException(status_code=404, detail="Workout not found")
|
||||
db.delete(workout)
|
||||
db.commit()
|
||||
9
backend/requirements.txt
Normal file
9
backend/requirements.txt
Normal file
@@ -0,0 +1,9 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
sqlalchemy==2.0.36
|
||||
alembic==1.14.1
|
||||
psycopg2-binary==2.9.10
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
pydantic-settings==2.7.1
|
||||
python-multipart==0.0.20
|
||||
15
docker-compose.yml
Normal file
15
docker-compose.yml
Normal file
@@ -0,0 +1,15 @@
|
||||
services:
|
||||
life:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://admin:AAAbraK!!!@postgres:5432/life
|
||||
- AUTH_USERNAME=will
|
||||
- AUTH_PASSWORD=${AUTH_PASSWORD:-changeme}
|
||||
- JWT_SECRET=${JWT_SECRET:-changeme-secret}
|
||||
networks:
|
||||
- web
|
||||
|
||||
networks:
|
||||
web:
|
||||
external: true
|
||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Life Dashboard</title>
|
||||
</head>
|
||||
<body class="min-h-screen bg-background text-foreground antialiased">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4154
frontend/package-lock.json
generated
Normal file
4154
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
frontend/package.json
Normal file
36
frontend/package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "life-dashboard",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"recharts": "^2.15.0",
|
||||
"clsx": "^2.1.1",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"lucide-react": "^0.468.0",
|
||||
"@radix-ui/react-dialog": "^1.1.4",
|
||||
"@radix-ui/react-label": "^2.1.1",
|
||||
"@radix-ui/react-select": "^2.1.4",
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@radix-ui/react-tabs": "^1.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.0.7"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
55
frontend/src/App.tsx
Normal file
55
frontend/src/App.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Routes, Route, Navigate } from "react-router-dom";
|
||||
import { isAuthenticated } from "@/api/client";
|
||||
import { Shell } from "@/components/layout/Shell";
|
||||
import Login from "@/pages/Login";
|
||||
import Dashboard from "@/pages/Dashboard";
|
||||
import Weight from "@/pages/Weight";
|
||||
import Workouts from "@/pages/Workouts";
|
||||
import Finances from "@/pages/Finances";
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
if (!isAuthenticated()) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
return <Shell>{children}</Shell>;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Dashboard />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/weight"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Weight />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/workouts"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Workouts />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/finances"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Finances />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
56
frontend/src/api/client.ts
Normal file
56
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
const API_BASE = "/api";
|
||||
|
||||
function getToken(): string | null {
|
||||
return localStorage.getItem("token");
|
||||
}
|
||||
|
||||
export function setToken(token: string) {
|
||||
localStorage.setItem("token", token);
|
||||
}
|
||||
|
||||
export function clearToken() {
|
||||
localStorage.removeItem("token");
|
||||
}
|
||||
|
||||
export function isAuthenticated(): boolean {
|
||||
return !!getToken();
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const token = getToken();
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}${path}`, { ...options, headers });
|
||||
|
||||
if (res.status === 401) {
|
||||
clearToken();
|
||||
window.location.href = "/login";
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.detail || `Request failed: ${res.status}`);
|
||||
}
|
||||
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>(path),
|
||||
post: <T>(path: string, data: unknown) =>
|
||||
request<T>(path, { method: "POST", body: JSON.stringify(data) }),
|
||||
put: <T>(path: string, data: unknown) =>
|
||||
request<T>(path, { method: "PUT", body: JSON.stringify(data) }),
|
||||
delete: <T>(path: string) => request<T>(path, { method: "DELETE" }),
|
||||
};
|
||||
50
frontend/src/api/types.ts
Normal file
50
frontend/src/api/types.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
export interface WeightEntry {
|
||||
id: number;
|
||||
date: string;
|
||||
weight_lbs: number;
|
||||
body_fat_pct: number | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export interface Exercise {
|
||||
id: number;
|
||||
name: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
export interface WorkoutSet {
|
||||
id: number;
|
||||
exercise_id: number;
|
||||
set_number: number;
|
||||
reps: number | null;
|
||||
weight_lbs: number | null;
|
||||
duration_min: number | null;
|
||||
}
|
||||
|
||||
export interface Workout {
|
||||
id: number;
|
||||
date: string;
|
||||
name: string | null;
|
||||
notes: string | null;
|
||||
sets: WorkoutSet[];
|
||||
}
|
||||
|
||||
export interface Transaction {
|
||||
id: number;
|
||||
date: string;
|
||||
amount: number;
|
||||
category: string;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
export interface Snapshot {
|
||||
id: number;
|
||||
date: string;
|
||||
net_worth: number;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export interface CategorySummary {
|
||||
category: string;
|
||||
total: number;
|
||||
}
|
||||
67
frontend/src/components/layout/Shell.tsx
Normal file
67
frontend/src/components/layout/Shell.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Scale,
|
||||
Dumbbell,
|
||||
DollarSign,
|
||||
LogOut,
|
||||
} from "lucide-react";
|
||||
import { clearToken } from "@/api/client";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const navItems = [
|
||||
{ to: "/", label: "Dashboard", icon: LayoutDashboard },
|
||||
{ to: "/weight", label: "Weight", icon: Scale },
|
||||
{ to: "/workouts", label: "Workouts", icon: Dumbbell },
|
||||
{ to: "/finances", label: "Finances", icon: DollarSign },
|
||||
];
|
||||
|
||||
export function Shell({ children }: { children: React.ReactNode }) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const logout = () => {
|
||||
clearToken();
|
||||
navigate("/login");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-56 border-r bg-card flex flex-col">
|
||||
<div className="p-4 border-b">
|
||||
<h1 className="text-lg font-bold">Life Dashboard</h1>
|
||||
</div>
|
||||
<nav className="flex-1 p-2 space-y-1">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors",
|
||||
location.pathname === item.to
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "hover:bg-accent"
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<div className="p-2 border-t">
|
||||
<button
|
||||
onClick={logout}
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-md text-sm w-full hover:bg-accent transition-colors"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 overflow-auto p-6">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
frontend/src/components/ui/button.tsx
Normal file
55
frontend/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
36
frontend/src/components/ui/card.tsx
Normal file
36
frontend/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h3 ref={ref} className={cn("font-semibold leading-none tracking-tight", className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
export { Card, CardHeader, CardTitle, CardContent };
|
||||
21
frontend/src/components/ui/input.tsx
Normal file
21
frontend/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
20
frontend/src/components/ui/label.tsx
Normal file
20
frontend/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
55
frontend/src/index.css
Normal file
55
frontend/src/index.css
Normal file
@@ -0,0 +1,55 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 224 71% 4%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 224 71% 4%;
|
||||
--primary: 220 90% 56%;
|
||||
--primary-foreground: 210 20% 98%;
|
||||
--secondary: 220 14% 96%;
|
||||
--secondary-foreground: 220 9% 46%;
|
||||
--muted: 220 14% 96%;
|
||||
--muted-foreground: 220 9% 46%;
|
||||
--accent: 220 14% 96%;
|
||||
--accent-foreground: 224 71% 4%;
|
||||
--destructive: 0 84% 60%;
|
||||
--destructive-foreground: 210 20% 98%;
|
||||
--border: 220 13% 91%;
|
||||
--input: 220 13% 91%;
|
||||
--ring: 220 90% 56%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 224 71% 4%;
|
||||
--foreground: 210 20% 98%;
|
||||
--card: 224 71% 4%;
|
||||
--card-foreground: 210 20% 98%;
|
||||
--primary: 217 91% 60%;
|
||||
--primary-foreground: 224 71% 4%;
|
||||
--secondary: 215 28% 17%;
|
||||
--secondary-foreground: 210 20% 98%;
|
||||
--muted: 215 28% 17%;
|
||||
--muted-foreground: 218 11% 65%;
|
||||
--accent: 215 28% 17%;
|
||||
--accent-foreground: 210 20% 98%;
|
||||
--destructive: 0 63% 31%;
|
||||
--destructive-foreground: 210 20% 98%;
|
||||
--border: 215 28% 17%;
|
||||
--input: 215 28% 17%;
|
||||
--ring: 216 34% 17%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
13
frontend/src/main.tsx
Normal file
13
frontend/src/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
121
frontend/src/pages/Dashboard.tsx
Normal file
121
frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
ResponsiveContainer,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { Scale, Dumbbell, DollarSign } from "lucide-react";
|
||||
import { api } from "@/api/client";
|
||||
import type { WeightEntry, Workout, CategorySummary } from "@/api/types";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
|
||||
function Sparkline({ data, dataKey }: { data: unknown[]; dataKey: string }) {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={40}>
|
||||
<LineChart data={data}>
|
||||
<YAxis domain={["dataMin - 1", "dataMax + 1"]} hide />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey={dataKey}
|
||||
stroke="hsl(220 90% 56%)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const [weights, setWeights] = useState<WeightEntry[]>([]);
|
||||
const [workouts, setWorkouts] = useState<Workout[]>([]);
|
||||
const [spending, setSpending] = useState<CategorySummary[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
api.get<WeightEntry[]>("/weight?from=" + thirtyDaysAgo()).then(setWeights);
|
||||
api.get<Workout[]>("/workouts?from=" + sevenDaysAgo()).then(setWorkouts);
|
||||
api.get<CategorySummary[]>("/finances/summary?period=month").then(setSpending);
|
||||
}, []);
|
||||
|
||||
const latestWeight = weights.length > 0 ? weights[0] : null;
|
||||
const weightChartData = [...weights].reverse();
|
||||
const workoutCount = workouts.length;
|
||||
const totalSpending = spending.reduce((sum, c) => sum + Math.abs(c.total), 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-2xl font-bold">Dashboard</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* Weight card */}
|
||||
<Link to="/weight">
|
||||
<Card className="hover:border-primary/50 transition-colors">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Weight
|
||||
</CardTitle>
|
||||
<Scale className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{latestWeight ? `${latestWeight.weight_lbs} lbs` : "—"}
|
||||
</div>
|
||||
{weightChartData.length > 1 && (
|
||||
<Sparkline data={weightChartData} dataKey="weight_lbs" />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
{/* Workouts card */}
|
||||
<Link to="/workouts">
|
||||
<Card className="hover:border-primary/50 transition-colors">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Workouts (7d)
|
||||
</CardTitle>
|
||||
<Dumbbell className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{workoutCount}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">this week</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
{/* Spending card */}
|
||||
<Link to="/finances">
|
||||
<Card className="hover:border-primary/50 transition-colors">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Monthly Spending
|
||||
</CardTitle>
|
||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
${totalSpending.toFixed(2)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{spending.length} categories
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function thirtyDaysAgo() {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - 30);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function sevenDaysAgo() {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - 7);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
278
frontend/src/pages/Finances.tsx
Normal file
278
frontend/src/pages/Finances.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
} from "recharts";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { api } from "@/api/client";
|
||||
import type { Transaction, Snapshot, CategorySummary } from "@/api/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
|
||||
const COLORS = [
|
||||
"hsl(220 90% 56%)",
|
||||
"hsl(150 60% 50%)",
|
||||
"hsl(40 90% 56%)",
|
||||
"hsl(0 84% 60%)",
|
||||
"hsl(270 60% 56%)",
|
||||
"hsl(180 60% 45%)",
|
||||
"hsl(30 80% 55%)",
|
||||
"hsl(320 60% 50%)",
|
||||
];
|
||||
|
||||
export default function Finances() {
|
||||
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
||||
const [snapshots, setSnapshots] = useState<Snapshot[]>([]);
|
||||
const [summary, setSummary] = useState<CategorySummary[]>([]);
|
||||
const [tab, setTab] = useState<"transactions" | "networth">("transactions");
|
||||
|
||||
// Transaction form
|
||||
const [tDate, setTDate] = useState(today());
|
||||
const [tAmount, setTAmount] = useState("");
|
||||
const [tCategory, setTCategory] = useState("");
|
||||
const [tDesc, setTDesc] = useState("");
|
||||
|
||||
// Snapshot form
|
||||
const [sDate, setSDate] = useState(today());
|
||||
const [sNetWorth, setSNetWorth] = useState("");
|
||||
const [sNotes, setSNotes] = useState("");
|
||||
|
||||
const load = () => {
|
||||
api.get<Transaction[]>("/finances/transactions").then(setTransactions);
|
||||
api.get<Snapshot[]>("/finances/snapshots").then(setSnapshots);
|
||||
api.get<CategorySummary[]>("/finances/summary?period=month").then(setSummary);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const handleAddTransaction = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
await api.post("/finances/transactions", {
|
||||
date: tDate,
|
||||
amount: parseFloat(tAmount),
|
||||
category: tCategory,
|
||||
description: tDesc || null,
|
||||
});
|
||||
setTAmount("");
|
||||
setTCategory("");
|
||||
setTDesc("");
|
||||
load();
|
||||
};
|
||||
|
||||
const handleDeleteTransaction = async (id: number) => {
|
||||
await api.delete(`/finances/transactions/${id}`);
|
||||
load();
|
||||
};
|
||||
|
||||
const handleAddSnapshot = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
await api.post("/finances/snapshots", {
|
||||
date: sDate,
|
||||
net_worth: parseFloat(sNetWorth),
|
||||
notes: sNotes || null,
|
||||
});
|
||||
setSNetWorth("");
|
||||
setSNotes("");
|
||||
load();
|
||||
};
|
||||
|
||||
const pieData = summary.map((s) => ({
|
||||
name: s.category,
|
||||
value: Math.abs(s.total),
|
||||
}));
|
||||
|
||||
const nwData = [...snapshots].reverse();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-2xl font-bold">Finances</h2>
|
||||
|
||||
{/* Tab switcher */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={tab === "transactions" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setTab("transactions")}
|
||||
>
|
||||
Transactions
|
||||
</Button>
|
||||
<Button
|
||||
variant={tab === "networth" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setTab("networth")}
|
||||
>
|
||||
Net Worth
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{tab === "transactions" && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Transaction form */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Add Transaction</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleAddTransaction} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Date</Label>
|
||||
<Input type="date" value={tDate} onChange={(e) => setTDate(e.target.value)} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Amount (negative = expense)</Label>
|
||||
<Input type="number" step="0.01" value={tAmount} onChange={(e) => setTAmount(e.target.value)} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Category</Label>
|
||||
<Input value={tCategory} onChange={(e) => setTCategory(e.target.value)} placeholder="e.g. food, rent" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Description</Label>
|
||||
<Input value={tDesc} onChange={(e) => setTDesc(e.target.value)} />
|
||||
</div>
|
||||
<Button type="submit" className="w-full">Save</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Category pie chart */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Monthly Spending by Category</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{pieData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={pieData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={100}
|
||||
dataKey="value"
|
||||
label={({ name, value }) => `${name}: $${value.toFixed(0)}`}
|
||||
>
|
||||
{pieData.map((_, i) => (
|
||||
<Cell key={i} fill={COLORS[i % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip formatter={(v: number) => `$${v.toFixed(2)}`} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">No spending data this month.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Transactions table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Transactions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left py-2 px-2">Date</th>
|
||||
<th className="text-left py-2 px-2">Amount</th>
|
||||
<th className="text-left py-2 px-2">Category</th>
|
||||
<th className="text-left py-2 px-2">Description</th>
|
||||
<th className="py-2 px-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{transactions.map((t) => (
|
||||
<tr key={t.id} className="border-b">
|
||||
<td className="py-2 px-2">{t.date}</td>
|
||||
<td className={`py-2 px-2 font-medium ${t.amount >= 0 ? "text-green-600" : "text-red-500"}`}>
|
||||
{t.amount >= 0 ? "+" : ""}${Math.abs(t.amount).toFixed(2)}
|
||||
</td>
|
||||
<td className="py-2 px-2">{t.category}</td>
|
||||
<td className="py-2 px-2 text-muted-foreground">{t.description || "—"}</td>
|
||||
<td className="py-2 px-2">
|
||||
<Button variant="ghost" size="icon" onClick={() => handleDeleteTransaction(t.id)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "networth" && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Snapshot form */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Log Net Worth</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleAddSnapshot} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Date</Label>
|
||||
<Input type="date" value={sDate} onChange={(e) => setSDate(e.target.value)} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Net Worth</Label>
|
||||
<Input type="number" step="0.01" value={sNetWorth} onChange={(e) => setSNetWorth(e.target.value)} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Notes</Label>
|
||||
<Input value={sNotes} onChange={(e) => setSNotes(e.target.value)} />
|
||||
</div>
|
||||
<Button type="submit" className="w-full">Save</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Net worth chart */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Net Worth Over Time</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{nwData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={nwData}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||
<XAxis dataKey="date" className="text-xs" />
|
||||
<YAxis className="text-xs" />
|
||||
<Tooltip formatter={(v: number) => `$${v.toLocaleString()}`} />
|
||||
<Line type="monotone" dataKey="net_worth" stroke="hsl(150 60% 50%)" strokeWidth={2} name="Net Worth" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">No snapshots yet.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function today() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
66
frontend/src/pages/Login.tsx
Normal file
66
frontend/src/pages/Login.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { api, setToken } from "@/api/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
try {
|
||||
const res = await api.post<{ access_token: string }>("/auth/login", {
|
||||
username,
|
||||
password,
|
||||
});
|
||||
setToken(res.access_token);
|
||||
navigate("/");
|
||||
} catch {
|
||||
setError("Invalid credentials");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-background">
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl text-center">Life Dashboard</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Input
|
||||
id="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
<Button type="submit" className="w-full">
|
||||
Sign in
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
197
frontend/src/pages/Weight.tsx
Normal file
197
frontend/src/pages/Weight.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { api } from "@/api/client";
|
||||
import type { WeightEntry } from "@/api/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
|
||||
export default function Weight() {
|
||||
const [entries, setEntries] = useState<WeightEntry[]>([]);
|
||||
const [date, setDate] = useState(today());
|
||||
const [weightLbs, setWeightLbs] = useState("");
|
||||
const [bodyFatPct, setBodyFatPct] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
const load = () => api.get<WeightEntry[]>("/weight").then(setEntries);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
await api.post("/weight", {
|
||||
date,
|
||||
weight_lbs: parseFloat(weightLbs),
|
||||
body_fat_pct: bodyFatPct ? parseFloat(bodyFatPct) : null,
|
||||
notes: notes || null,
|
||||
});
|
||||
setWeightLbs("");
|
||||
setBodyFatPct("");
|
||||
setNotes("");
|
||||
load();
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
await api.delete(`/weight/${id}`);
|
||||
load();
|
||||
};
|
||||
|
||||
const chartData = [...entries].reverse();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-2xl font-bold">Weight Tracker</h2>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Entry form */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Log Weight</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Date</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Weight (lbs)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={weightLbs}
|
||||
onChange={(e) => setWeightLbs(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Body Fat %</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={bodyFatPct}
|
||||
onChange={(e) => setBodyFatPct(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Notes</Label>
|
||||
<Input
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full">
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Chart */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Weight Over Time</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{chartData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||
<XAxis dataKey="date" className="text-xs" />
|
||||
<YAxis domain={["dataMin - 2", "dataMax + 2"]} className="text-xs" />
|
||||
<Tooltip />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="weight_lbs"
|
||||
stroke="hsl(220 90% 56%)"
|
||||
strokeWidth={2}
|
||||
name="Weight (lbs)"
|
||||
/>
|
||||
{chartData.some((d) => d.body_fat_pct) && (
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="body_fat_pct"
|
||||
stroke="hsl(150 60% 50%)"
|
||||
strokeWidth={2}
|
||||
name="Body Fat %"
|
||||
/>
|
||||
)}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No data yet. Log your first entry!
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* History table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>History</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left py-2 px-2">Date</th>
|
||||
<th className="text-left py-2 px-2">Weight</th>
|
||||
<th className="text-left py-2 px-2">Body Fat</th>
|
||||
<th className="text-left py-2 px-2">Notes</th>
|
||||
<th className="py-2 px-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((e) => (
|
||||
<tr key={e.id} className="border-b">
|
||||
<td className="py-2 px-2">{e.date}</td>
|
||||
<td className="py-2 px-2">{e.weight_lbs} lbs</td>
|
||||
<td className="py-2 px-2">
|
||||
{e.body_fat_pct ? `${e.body_fat_pct}%` : "—"}
|
||||
</td>
|
||||
<td className="py-2 px-2 text-muted-foreground">
|
||||
{e.notes || "—"}
|
||||
</td>
|
||||
<td className="py-2 px-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDelete(e.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function today() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
265
frontend/src/pages/Workouts.tsx
Normal file
265
frontend/src/pages/Workouts.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { api } from "@/api/client";
|
||||
import type { Exercise, Workout } from "@/api/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
|
||||
interface SetForm {
|
||||
exercise_id: number;
|
||||
set_number: number;
|
||||
reps: string;
|
||||
weight_lbs: string;
|
||||
duration_min: string;
|
||||
}
|
||||
|
||||
export default function Workouts() {
|
||||
const [workouts, setWorkouts] = useState<Workout[]>([]);
|
||||
const [exercises, setExercises] = useState<Exercise[]>([]);
|
||||
const [date, setDate] = useState(today());
|
||||
const [name, setName] = useState("");
|
||||
const [wNotes, setWNotes] = useState("");
|
||||
const [sets, setSets] = useState<SetForm[]>([]);
|
||||
const [newExName, setNewExName] = useState("");
|
||||
const [newExCategory, setNewExCategory] = useState("");
|
||||
const [showNewExercise, setShowNewExercise] = useState(false);
|
||||
|
||||
const load = () => {
|
||||
api.get<Workout[]>("/workouts").then(setWorkouts);
|
||||
api.get<Exercise[]>("/exercises").then(setExercises);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const addSet = () => {
|
||||
setSets([
|
||||
...sets,
|
||||
{
|
||||
exercise_id: exercises[0]?.id || 0,
|
||||
set_number: sets.length + 1,
|
||||
reps: "",
|
||||
weight_lbs: "",
|
||||
duration_min: "",
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const updateSet = (i: number, field: keyof SetForm, value: string | number) => {
|
||||
const next = [...sets];
|
||||
(next[i] as any)[field] = value;
|
||||
next[i].set_number = i + 1;
|
||||
setSets(next);
|
||||
};
|
||||
|
||||
const removeSet = (i: number) => {
|
||||
setSets(sets.filter((_, idx) => idx !== i).map((s, idx) => ({ ...s, set_number: idx + 1 })));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
await api.post("/workouts", {
|
||||
date,
|
||||
name: name || null,
|
||||
notes: wNotes || null,
|
||||
sets: sets.map((s) => ({
|
||||
exercise_id: s.exercise_id,
|
||||
set_number: s.set_number,
|
||||
reps: s.reps ? parseInt(s.reps) : null,
|
||||
weight_lbs: s.weight_lbs ? parseFloat(s.weight_lbs) : null,
|
||||
duration_min: s.duration_min ? parseFloat(s.duration_min) : null,
|
||||
})),
|
||||
});
|
||||
setName("");
|
||||
setWNotes("");
|
||||
setSets([]);
|
||||
load();
|
||||
};
|
||||
|
||||
const handleAddExercise = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
await api.post("/exercises", { name: newExName, category: newExCategory });
|
||||
setNewExName("");
|
||||
setNewExCategory("");
|
||||
setShowNewExercise(false);
|
||||
load();
|
||||
};
|
||||
|
||||
const handleDeleteWorkout = async (id: number) => {
|
||||
await api.delete(`/workouts/${id}`);
|
||||
load();
|
||||
};
|
||||
|
||||
const exerciseMap = Object.fromEntries(exercises.map((e) => [e.id, e.name]));
|
||||
|
||||
// Volume chart: total sets per day
|
||||
const volumeData = [...workouts]
|
||||
.reverse()
|
||||
.map((w) => ({ date: w.date, sets: w.sets.length }));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-2xl font-bold">Workouts</h2>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Workout form */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Log Workout</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Date</Label>
|
||||
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Name (optional)</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Push Day" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Sets</Label>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={addSet} disabled={exercises.length === 0}>
|
||||
<Plus className="h-4 w-4 mr-1" /> Add Set
|
||||
</Button>
|
||||
</div>
|
||||
{exercises.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">Create an exercise first</p>
|
||||
)}
|
||||
{sets.map((s, i) => (
|
||||
<div key={i} className="flex gap-2 items-end">
|
||||
<select
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-2 text-sm"
|
||||
value={s.exercise_id}
|
||||
onChange={(e) => updateSet(i, "exercise_id", parseInt(e.target.value))}
|
||||
>
|
||||
{exercises.map((ex) => (
|
||||
<option key={ex.id} value={ex.id}>{ex.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<Input className="w-16" placeholder="Reps" value={s.reps} onChange={(e) => updateSet(i, "reps", e.target.value)} />
|
||||
<Input className="w-20" placeholder="Wt (lbs)" value={s.weight_lbs} onChange={(e) => updateSet(i, "weight_lbs", e.target.value)} />
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => removeSet(i)}>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Notes</Label>
|
||||
<Input value={wNotes} onChange={(e) => setWNotes(e.target.value)} />
|
||||
</div>
|
||||
<Button type="submit" className="w-full">Save Workout</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
{!showNewExercise ? (
|
||||
<Button variant="outline" size="sm" onClick={() => setShowNewExercise(true)} className="w-full">
|
||||
<Plus className="h-4 w-4 mr-1" /> New Exercise
|
||||
</Button>
|
||||
) : (
|
||||
<form onSubmit={handleAddExercise} className="space-y-2">
|
||||
<Input placeholder="Exercise name" value={newExName} onChange={(e) => setNewExName(e.target.value)} required />
|
||||
<Input placeholder="Category (e.g. chest)" value={newExCategory} onChange={(e) => setNewExCategory(e.target.value)} required />
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" size="sm">Add</Button>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setShowNewExercise(false)}>Cancel</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Volume chart */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Sets per Workout</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{volumeData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={volumeData}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||
<XAxis dataKey="date" className="text-xs" />
|
||||
<YAxis className="text-xs" />
|
||||
<Tooltip />
|
||||
<Bar dataKey="sets" fill="hsl(220 90% 56%)" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">No workouts logged yet.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* History */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>History</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{workouts.map((w) => (
|
||||
<div key={w.id} className="border rounded-md p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<span className="font-medium">{w.date}</span>
|
||||
{w.name && <span className="ml-2 text-muted-foreground">— {w.name}</span>}
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={() => handleDeleteWorkout(w.id)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{w.sets.length > 0 && (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground">
|
||||
<th className="text-left py-1">#</th>
|
||||
<th className="text-left py-1">Exercise</th>
|
||||
<th className="text-left py-1">Reps</th>
|
||||
<th className="text-left py-1">Weight</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{w.sets.map((s) => (
|
||||
<tr key={s.id}>
|
||||
<td className="py-1">{s.set_number}</td>
|
||||
<td className="py-1">{exerciseMap[s.exercise_id] || `#${s.exercise_id}`}</td>
|
||||
<td className="py-1">{s.reps ?? (s.duration_min ? `${s.duration_min} min` : "—")}</td>
|
||||
<td className="py-1">{s.weight_lbs ? `${s.weight_lbs} lbs` : "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{w.notes && <p className="text-xs text-muted-foreground mt-2">{w.notes}</p>}
|
||||
</div>
|
||||
))}
|
||||
{workouts.length === 0 && (
|
||||
<p className="text-muted-foreground text-sm">No workouts yet.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function today() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
45
frontend/tailwind.config.js
Normal file
45
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,45 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
24
frontend/tsconfig.json
Normal file
24
frontend/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"baseUrl": "."
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
1
frontend/tsconfig.tsbuildinfo
Normal file
1
frontend/tsconfig.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/api/client.ts","./src/api/types.ts","./src/components/layout/shell.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/lib/utils.ts","./src/pages/dashboard.tsx","./src/pages/finances.tsx","./src/pages/login.tsx","./src/pages/weight.tsx","./src/pages/workouts.tsx"],"version":"5.9.3"}
|
||||
17
frontend/vite.config.ts
Normal file
17
frontend/vite.config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": "http://localhost:8000",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
# Hello World
|
||||
|
||||
Hello, world!
|
||||
Reference in New Issue
Block a user