Initial commit: full fitness-rewards app

Backend: FastAPI + PostgreSQL + SQLAlchemy async
- Auth (JWT), onboarding (PAR-Q+, TTM, BREQ-2), activities, food log
- Points engine with daily gate + weekly targets
- Strava + Polar OAuth integration
- Open Food Facts barcode lookup
- Resource recommendation engine
- 10 seeded Darebee/StrongLifts/YouTube programs

Frontend: React + Vite, mobile-first dark theme
- Login/Register, 8-step onboarding flow
- Dashboard with daily gate status
- Activity logger, food logger (manual + barcode scan + search)
- Reward shop with redemption
- Weekly summary view
- Settings (point rules, targets, integrations)

Deployment: Docker Compose for Coolify (Traefik)
This commit is contained in:
claude 2026-03-14 23:53:00 +00:00
commit 4db2b0846b
61 changed files with 4280 additions and 0 deletions

View file

@ -0,0 +1,15 @@
from app.models.user import User
from app.models.profile import UserProfile
from app.models.program import Program
from app.models.activity import ActivityLog
from app.models.food import FoodLog
from app.models.points import PointRule, DailyTarget
from app.models.reward import Reward, RewardRedemption
from app.models.oauth import OAuthToken
from app.models.resource import Resource
__all__ = [
"User", "UserProfile", "Program", "ActivityLog", "FoodLog",
"PointRule", "DailyTarget", "Reward", "RewardRedemption",
"OAuthToken", "Resource",
]

View file

@ -0,0 +1,31 @@
import uuid
from datetime import datetime, date, timezone
from sqlalchemy import String, Integer, Text, Date, DateTime, ForeignKey, Index
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.database import Base
class ActivityLog(Base):
__tablename__ = "activity_log"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
category: Mapped[str] = mapped_column(String(50), nullable=False)
title: Mapped[str | None] = mapped_column(String(200))
description: Mapped[str | None] = mapped_column(Text)
duration_minutes: Mapped[int | None] = mapped_column(Integer)
source: Mapped[str] = mapped_column(String(30), default="manual")
external_id: Mapped[str | None] = mapped_column(String(100))
points_earned: Mapped[int] = mapped_column(Integer, default=0)
rule_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("point_rules.id"), nullable=True)
activity_date: Mapped[date] = mapped_column(Date, nullable=False)
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
program_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("programs.id"), nullable=True)
program_day: Mapped[int | None] = mapped_column(Integer)
metadata_json: Mapped[dict] = mapped_column("metadata", JSONB, default=dict)
__table_args__ = (
Index("idx_activity_log_user_date", "user_id", "activity_date"),
Index("idx_activity_log_external", "source", "external_id"),
)

View file

@ -0,0 +1,35 @@
import uuid
from datetime import datetime, date, timezone
from decimal import Decimal
from sqlalchemy import String, Numeric, Date, DateTime, Text, ForeignKey, Index
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.database import Base
class FoodLog(Base):
__tablename__ = "food_log"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
meal_type: Mapped[str] = mapped_column(String(20), nullable=False)
food_name: Mapped[str] = mapped_column(String(300), nullable=False)
brand: Mapped[str | None] = mapped_column(String(200))
barcode: Mapped[str | None] = mapped_column(String(50))
serving_size: Mapped[str | None] = mapped_column(String(100))
servings: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=1)
calories: Mapped[Decimal | None] = mapped_column(Numeric(7, 1))
protein_g: Mapped[Decimal | None] = mapped_column(Numeric(6, 1))
carbs_g: Mapped[Decimal | None] = mapped_column(Numeric(6, 1))
fat_g: Mapped[Decimal | None] = mapped_column(Numeric(6, 1))
fiber_g: Mapped[Decimal | None] = mapped_column(Numeric(6, 1))
sodium_mg: Mapped[Decimal | None] = mapped_column(Numeric(7, 1))
sugar_g: Mapped[Decimal | None] = mapped_column(Numeric(6, 1))
food_date: Mapped[date] = mapped_column(Date, nullable=False)
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
off_product_id: Mapped[str | None] = mapped_column(String(50))
off_data: Mapped[dict] = mapped_column(JSONB, default=dict)
__table_args__ = (
Index("idx_food_log_user_date", "user_id", "food_date"),
)

View file

@ -0,0 +1,25 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import String, Text, DateTime, ForeignKey, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID
from app.database import Base
class OAuthToken(Base):
__tablename__ = "oauth_tokens"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
provider: Mapped[str] = mapped_column(String(20), nullable=False)
access_token: Mapped[str] = mapped_column(Text, nullable=False)
refresh_token: Mapped[str | None] = mapped_column(Text)
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
athlete_id: Mapped[str | None] = mapped_column(String(100))
scope: Mapped[str | None] = mapped_column(String(200))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
__table_args__ = (
UniqueConstraint("user_id", "provider", name="uq_user_provider"),
)

View file

@ -0,0 +1,52 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import String, Integer, Boolean, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.database import Base
class PointRule(Base):
__tablename__ = "point_rules"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
category: Mapped[str] = mapped_column(String(50), nullable=False)
description: Mapped[str] = mapped_column(String(200), nullable=False)
points: Mapped[int] = mapped_column(Integer, nullable=False)
unit: Mapped[str | None] = mapped_column(String(20))
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
user: Mapped["User"] = relationship(back_populates="point_rules")
class DailyTarget(Base):
__tablename__ = "daily_targets"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, unique=True)
daily_minimum_pts: Mapped[int] = mapped_column(Integer, default=80)
weekly_target_pts: Mapped[int] = mapped_column(Integer, default=500)
weekly_bonus_pts: Mapped[int] = mapped_column(Integer, default=50)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
# Default rules to create per user
DEFAULT_POINT_RULES = [
{"category": "workout", "description": "Complete a workout", "points": 50, "unit": "per_completion"},
{"category": "food_log", "description": "Log all meals for the day", "points": 30, "unit": "per_day"},
{"category": "steps_target", "description": "Hit daily step/activity goal", "points": 20, "unit": "per_day"},
{"category": "screen_free", "description": "Screen-free activity", "points": 20, "unit": "per_hour"},
{"category": "daily_checkin", "description": "Complete daily check-in", "points": 10, "unit": "per_day"},
]
# Suggested targets by TTM stage
TTM_DAILY_TARGETS = {
"precontemplation": {"daily_minimum_pts": 30, "weekly_target_pts": 150},
"contemplation": {"daily_minimum_pts": 50, "weekly_target_pts": 250},
"preparation": {"daily_minimum_pts": 80, "weekly_target_pts": 400},
"action": {"daily_minimum_pts": 100, "weekly_target_pts": 600},
"maintenance": {"daily_minimum_pts": 120, "weekly_target_pts": 700},
}

View file

@ -0,0 +1,53 @@
import uuid
from datetime import datetime, timezone
from decimal import Decimal
from sqlalchemy import String, Boolean, Integer, Numeric, DateTime, Text, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.database import Base
class UserProfile(Base):
__tablename__ = "user_profiles"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, unique=True)
# Phase 1
primary_goal: Mapped[str | None] = mapped_column(String(50))
ttm_stage: Mapped[str | None] = mapped_column(String(20))
# Phase 2 - Body
age: Mapped[int | None] = mapped_column(Integer)
height_cm: Mapped[Decimal | None] = mapped_column(Numeric(5, 1))
weight_kg: Mapped[Decimal | None] = mapped_column(Numeric(5, 1))
gender: Mapped[str | None] = mapped_column(String(20))
# PAR-Q+
parq_heart_condition: Mapped[bool] = mapped_column(Boolean, default=False)
parq_joint_issues: Mapped[bool] = mapped_column(Boolean, default=False)
parq_medications: Mapped[bool] = mapped_column(Boolean, default=False)
parq_other_conditions: Mapped[str | None] = mapped_column(Text)
parq_cleared: Mapped[bool] = mapped_column(Boolean, default=True)
# BREQ-2 motivation
motivation_external: Mapped[Decimal | None] = mapped_column(Numeric(3, 1))
motivation_introjected: Mapped[Decimal | None] = mapped_column(Numeric(3, 1))
motivation_identified: Mapped[Decimal | None] = mapped_column(Numeric(3, 1))
motivation_intrinsic: Mapped[Decimal | None] = mapped_column(Numeric(3, 1))
motivation_amotivation: Mapped[Decimal | None] = mapped_column(Numeric(3, 1))
motivation_rai: Mapped[Decimal | None] = mapped_column(Numeric(5, 1))
# Preferences
activity_preferences: Mapped[dict] = mapped_column(JSONB, default=list)
equipment_access: Mapped[str | None] = mapped_column(String(50))
days_per_week: Mapped[int] = mapped_column(Integer, default=3)
minutes_per_session: Mapped[int] = mapped_column(Integer, default=30)
# Completion
phase1_completed: Mapped[bool] = mapped_column(Boolean, default=False)
phase2_completed: Mapped[bool] = mapped_column(Boolean, default=False)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
user: Mapped["User"] = relationship(back_populates="profile")

View file

@ -0,0 +1,23 @@
import uuid
from datetime import datetime, date, timezone
from sqlalchemy import String, Text, Date, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.database import Base
class Program(Base):
__tablename__ = "programs"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
name: Mapped[str] = mapped_column(String(200), nullable=False)
source: Mapped[str | None] = mapped_column(String(50))
source_url: Mapped[str | None] = mapped_column(Text)
start_date: Mapped[date] = mapped_column(Date, nullable=False)
end_date: Mapped[date] = mapped_column(Date, nullable=False)
status: Mapped[str] = mapped_column(String(20), default="active")
notes: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
user: Mapped["User"] = relationship(back_populates="programs")

View file

@ -0,0 +1,25 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import String, Integer, Boolean, Text, DateTime
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.database import Base
class Resource(Base):
__tablename__ = "resource_library"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(200), nullable=False)
source: Mapped[str] = mapped_column(String(50), nullable=False)
url: Mapped[str] = mapped_column(Text, nullable=False)
description: Mapped[str | None] = mapped_column(Text)
thumbnail_url: Mapped[str | None] = mapped_column(Text)
goal_tags: Mapped[dict] = mapped_column(JSONB, default=list)
activity_tags: Mapped[dict] = mapped_column(JSONB, default=list)
equipment_needed: Mapped[str | None] = mapped_column(String(50))
ttm_stages: Mapped[dict] = mapped_column(JSONB, default=list)
difficulty: Mapped[str | None] = mapped_column(String(20))
duration_days: Mapped[int | None] = mapped_column(Integer)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))

View file

@ -0,0 +1,36 @@
import uuid
from datetime import datetime, date, timezone
from sqlalchemy import String, Integer, Boolean, Text, Date, DateTime, ForeignKey, Index
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.database import Base
class Reward(Base):
__tablename__ = "rewards"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
name: Mapped[str] = mapped_column(String(200), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
point_cost: Mapped[int] = mapped_column(Integer, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
user: Mapped["User"] = relationship(back_populates="rewards")
class RewardRedemption(Base):
__tablename__ = "reward_redemptions"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
reward_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("rewards.id"), nullable=False)
points_spent: Mapped[int] = mapped_column(Integer, nullable=False)
redeemed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
redemption_date: Mapped[date] = mapped_column(Date, nullable=False)
notes: Mapped[str | None] = mapped_column(Text)
__table_args__ = (
Index("idx_redemptions_user_date", "user_id", "redemption_date"),
)

View file

@ -0,0 +1,25 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import String, Boolean, DateTime
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.database import Base
class User(Base):
__tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
display_name: Mapped[str] = mapped_column(String(100), nullable=False)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
timezone: Mapped[str] = mapped_column(String(50), default="Asia/Bangkok")
# Relationships
profile: Mapped["UserProfile"] = relationship(back_populates="user", uselist=False, lazy="selectin")
programs: Mapped[list["Program"]] = relationship(back_populates="user", lazy="selectin")
point_rules: Mapped[list["PointRule"]] = relationship(back_populates="user", lazy="selectin")
rewards: Mapped[list["Reward"]] = relationship(back_populates="user", lazy="selectin")