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

11
frontend/Dockerfile Normal file
View file

@ -0,0 +1,11 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

15
frontend/index.html Normal file
View file

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="theme-color" content="#1a1a2e" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<title>Fitness Rewards</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

18
frontend/nginx.conf Normal file
View file

@ -0,0 +1,18 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /api {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
}

21
frontend/package.json Normal file
View file

@ -0,0 +1,21 @@
{
"name": "fitness-rewards-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@vitejs/plugin-react": "^4.3.4",
"vite": "^6.0.3"
}
}

56
frontend/src/App.jsx Normal file
View file

@ -0,0 +1,56 @@
import { Routes, Route, Navigate, NavLink, useNavigate } from 'react-router-dom'
import { useState, useEffect } from 'react'
import { hasToken, clearToken, api } from './api'
import Login from './pages/Login'
import Dashboard from './pages/Dashboard'
import LogActivity from './pages/LogActivity'
import LogFood from './pages/LogFood'
import Rewards from './pages/Rewards'
import Settings from './pages/Settings'
import Onboarding from './pages/Onboarding'
import WeekView from './pages/WeekView'
function ProtectedRoute({ children }) {
if (!hasToken()) return <Navigate to="/login" />
return children
}
function NavBar() {
return (
<nav className="nav-bar">
<NavLink to="/" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
<span className="nav-icon">🏠</span> Home
</NavLink>
<NavLink to="/log" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
<span className="nav-icon">💪</span> Log
</NavLink>
<NavLink to="/food" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
<span className="nav-icon">🍽</span> Food
</NavLink>
<NavLink to="/rewards" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
<span className="nav-icon">🎮</span> Rewards
</NavLink>
<NavLink to="/settings" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
<span className="nav-icon"></span> Settings
</NavLink>
</nav>
)
}
export default function App() {
return (
<>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/onboarding" element={<ProtectedRoute><Onboarding /></ProtectedRoute>} />
<Route path="/" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
<Route path="/log" element={<ProtectedRoute><LogActivity /></ProtectedRoute>} />
<Route path="/food" element={<ProtectedRoute><LogFood /></ProtectedRoute>} />
<Route path="/rewards" element={<ProtectedRoute><Rewards /></ProtectedRoute>} />
<Route path="/week" element={<ProtectedRoute><WeekView /></ProtectedRoute>} />
<Route path="/settings" element={<ProtectedRoute><Settings /></ProtectedRoute>} />
</Routes>
{hasToken() && <NavBar />}
</>
)
}

94
frontend/src/api.js Normal file
View file

@ -0,0 +1,94 @@
const API_BASE = '/api';
function getToken() {
return localStorage.getItem('fitness_token');
}
async function request(path, options = {}) {
const token = getToken();
const headers = { 'Content-Type': 'application/json', ...options.headers };
if (token) headers['Authorization'] = `Bearer ${token}`;
const resp = await fetch(`${API_BASE}${path}`, { ...options, headers });
if (resp.status === 401) {
localStorage.removeItem('fitness_token');
window.location.href = '/login';
throw new Error('Unauthorized');
}
if (!resp.ok) {
const err = await resp.json().catch(() => ({ detail: 'Request failed' }));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
return resp.json();
}
export const api = {
// Auth
login: (username, password) =>
request('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) }),
register: (username, password, display_name) =>
request('/auth/register', { method: 'POST', body: JSON.stringify({ username, password, display_name }) }),
me: () => request('/auth/me'),
// Onboarding
onboardingStatus: () => request('/onboarding/status'),
savePhase1: (data) => request('/onboarding/phase1', { method: 'POST', body: JSON.stringify(data) }),
savePhase2: (data) => request('/onboarding/phase2', { method: 'POST', body: JSON.stringify(data) }),
getRecommendations: () => request('/onboarding/recommendations'),
// Points
today: () => request('/points/today'),
week: (start) => request(`/points/week${start ? `?start=${start}` : ''}`),
getRules: () => request('/points/rules'),
updateRule: (id, data) => request(`/points/rules/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
getTargets: () => request('/points/targets'),
updateTargets: (data) => request('/points/targets', { method: 'PATCH', body: JSON.stringify(data) }),
// Activities
listActivities: (date) => request(`/activities${date ? `?date=${date}` : ''}`),
logActivity: (data) => request('/activities', { method: 'POST', body: JSON.stringify(data) }),
deleteActivity: (id) => request(`/activities/${id}`, { method: 'DELETE' }),
// Food
listFood: (date) => request(`/food${date ? `?date=${date}` : ''}`),
logFood: (data) => request('/food', { method: 'POST', body: JSON.stringify(data) }),
deleteFood: (id) => request(`/food/${id}`, { method: 'DELETE' }),
scanBarcode: (barcode) => request(`/food/scan/${barcode}`),
searchFood: (q) => request(`/food/search?q=${encodeURIComponent(q)}`),
// Rewards
listRewards: () => request('/rewards'),
createReward: (data) => request('/rewards', { method: 'POST', body: JSON.stringify(data) }),
redeemReward: (id, date) => request(`/rewards/${id}/redeem`, { method: 'POST', body: JSON.stringify({ date }) }),
// Programs
listPrograms: () => request('/programs'),
createProgram: (data) => request('/programs', { method: 'POST', body: JSON.stringify(data) }),
getProgram: (id) => request(`/programs/${id}`),
// Integrations
integrationStatus: () => request('/integrations'),
stravaAuth: () => request('/integrations/strava/auth'),
stravaSync: () => request('/integrations/strava/sync', { method: 'POST' }),
polarAuth: () => request('/integrations/polar/auth'),
polarSync: () => request('/integrations/polar/sync', { method: 'POST' }),
disconnect: (provider) => request(`/integrations/${provider}`, { method: 'DELETE' }),
// Resources
getRecommendations: () => request('/onboarding/recommendations'),
};
export function setToken(token) {
localStorage.setItem('fitness_token', token);
}
export function clearToken() {
localStorage.removeItem('fitness_token');
}
export function hasToken() {
return !!localStorage.getItem('fitness_token');
}

296
frontend/src/index.css Normal file
View file

@ -0,0 +1,296 @@
:root {
--bg: #0f0f1a;
--bg-card: #1a1a2e;
--bg-card-hover: #222240;
--text: #e8e8f0;
--text-muted: #8888a0;
--accent: #6c63ff;
--accent-hover: #7f78ff;
--success: #4ade80;
--danger: #f87171;
--warning: #fbbf24;
--border: #2a2a45;
--radius: 12px;
--radius-sm: 8px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100dvh;
-webkit-font-smoothing: antialiased;
}
a { color: var(--accent); text-decoration: none; }
a:hover { color: var(--accent-hover); }
button {
font-family: inherit;
cursor: pointer;
border: none;
border-radius: var(--radius-sm);
padding: 10px 20px;
font-size: 0.95rem;
font-weight: 600;
transition: all 0.15s;
}
.btn-primary {
background: var(--accent);
color: white;
}
.btn-primary:hover { background: var(--accent-hover); }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-success {
background: var(--success);
color: #111;
}
.btn-outline {
background: transparent;
border: 1px solid var(--border);
color: var(--text);
}
.btn-outline:hover { border-color: var(--accent); color: var(--accent); }
.btn-danger {
background: var(--danger);
color: white;
}
.btn-sm { padding: 6px 14px; font-size: 0.85rem; }
.btn-full { width: 100%; }
input, select, textarea {
font-family: inherit;
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 14px;
font-size: 0.95rem;
width: 100%;
outline: none;
transition: border-color 0.15s;
}
input:focus, select:focus, textarea:focus {
border-color: var(--accent);
}
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
margin-bottom: 16px;
}
.page {
max-width: 480px;
margin: 0 auto;
padding: 16px;
padding-bottom: 100px;
}
.page-title {
font-size: 1.4rem;
font-weight: 700;
margin-bottom: 20px;
}
.progress-bar {
width: 100%;
height: 10px;
background: var(--border);
border-radius: 5px;
overflow: hidden;
}
.progress-bar-fill {
height: 100%;
border-radius: 5px;
transition: width 0.4s ease;
}
.status-earned { color: var(--success); }
.status-locked { color: var(--danger); }
.checklist-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 0;
border-bottom: 1px solid var(--border);
font-size: 0.95rem;
}
.checklist-item:last-child { border-bottom: none; }
.checklist-check { font-size: 1.2rem; min-width: 28px; }
.checklist-points {
margin-left: auto;
color: var(--text-muted);
font-size: 0.85rem;
}
.nav-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: var(--bg-card);
border-top: 1px solid var(--border);
display: flex;
justify-content: space-around;
padding: 8px 0;
z-index: 100;
}
.nav-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
color: var(--text-muted);
font-size: 0.7rem;
padding: 4px 12px;
text-decoration: none;
transition: color 0.15s;
}
.nav-item.active, .nav-item:hover { color: var(--accent); }
.nav-icon { font-size: 1.3rem; }
.form-group { margin-bottom: 16px; }
.form-label {
display: block;
font-size: 0.85rem;
color: var(--text-muted);
margin-bottom: 6px;
}
.option-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.option-btn {
background: var(--bg);
border: 2px solid var(--border);
border-radius: var(--radius);
padding: 16px 12px;
text-align: center;
color: var(--text);
font-size: 0.9rem;
cursor: pointer;
transition: all 0.15s;
}
.option-btn:hover { border-color: var(--accent); }
.option-btn.selected {
border-color: var(--accent);
background: rgba(108, 99, 255, 0.1);
}
.chip-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.chip {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 20px;
padding: 8px 16px;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.15s;
}
.chip.selected {
border-color: var(--accent);
background: rgba(108, 99, 255, 0.15);
color: var(--accent);
}
.reward-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 0;
border-bottom: 1px solid var(--border);
}
.reward-card:last-child { border-bottom: none; }
.likert-row {
display: flex;
align-items: center;
gap: 8px;
margin: 8px 0 20px;
}
.likert-btn {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--bg);
border: 2px solid var(--border);
color: var(--text);
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.likert-btn.selected {
border-color: var(--accent);
background: var(--accent);
color: white;
}
.likert-labels {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
color: var(--text-muted);
}
.gate-banner {
text-align: center;
padding: 20px;
border-radius: var(--radius);
margin-bottom: 16px;
}
.gate-earned {
background: rgba(74, 222, 128, 0.1);
border: 1px solid rgba(74, 222, 128, 0.3);
}
.gate-locked {
background: rgba(248, 113, 113, 0.1);
border: 1px solid rgba(248, 113, 113, 0.3);
}
.gate-pts {
font-size: 2rem;
font-weight: 800;
margin: 4px 0;
}
.meal-section { margin-bottom: 20px; }
.meal-title {
font-size: 0.85rem;
font-weight: 600;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 8px;
}
.loading {
display: flex;
justify-content: center;
padding: 40px;
color: var(--text-muted);
}
.error-msg {
background: rgba(248, 113, 113, 0.1);
border: 1px solid rgba(248, 113, 113, 0.3);
padding: 12px;
border-radius: var(--radius-sm);
color: var(--danger);
margin-bottom: 16px;
}

13
frontend/src/main.jsx Normal file
View 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>,
)

View file

@ -0,0 +1,145 @@
import { useState, useEffect } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { api } from '../api'
export default function Dashboard() {
const [status, setStatus] = useState(null)
const [loading, setLoading] = useState(true)
const navigate = useNavigate()
useEffect(() => { loadStatus() }, [])
async function loadStatus() {
try {
const data = await api.today()
setStatus(data)
} catch (err) {
console.error(err)
} finally {
setLoading(false)
}
}
async function handleSync(provider) {
try {
const fn = provider === 'strava' ? api.stravaSync : api.polarSync
await fn()
await loadStatus()
} catch (err) {
alert(`Sync failed: ${err.message}`)
}
}
if (loading) return <div className="page"><div className="loading">Loading...</div></div>
if (!status) return <div className="page"><div className="error-msg">Failed to load</div></div>
const pct = status.daily_minimum > 0 ? Math.min(100, Math.round((status.points_earned / status.daily_minimum) * 100)) : 0
const weekPct = status.weekly_target > 0 ? Math.min(100, Math.round((status.week_points / status.weekly_target) * 100)) : 0
// Build checklist from activities
const categories = {
workout: { label: 'Workout', icon: '💪', pts: 50 },
food_log: { label: 'Food logged', icon: '🍽️', pts: 30 },
steps_target: { label: 'Steps target', icon: '👟', pts: 20 },
screen_free: { label: 'Screen-free time', icon: '📚', pts: '20/hr' },
daily_checkin: { label: 'Daily check-in', icon: '✅', pts: 10 },
}
const doneCats = new Set(status.activities_today.map(a => a.category))
return (
<div className="page">
{/* Program progress */}
{status.program_name && (
<div className="card" style={{ textAlign: 'center', padding: '14px 20px' }}>
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>
{status.program_name}
</div>
<div style={{ fontWeight: 700, margin: '4px 0' }}>
Day {status.program_day} of {status.program_total_days}
</div>
<div className="progress-bar">
<div className="progress-bar-fill" style={{
width: `${Math.round((status.program_day / status.program_total_days) * 100)}%`,
background: 'var(--accent)'
}} />
</div>
</div>
)}
{/* Gate banner */}
<div className={`gate-banner ${status.gate_passed ? 'gate-earned' : 'gate-locked'}`}>
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>TODAY</div>
<div className="gate-pts">
<span className={status.gate_passed ? 'status-earned' : 'status-locked'}>
{status.points_earned}
</span>
<span style={{ color: 'var(--text-muted)', fontSize: '1.2rem' }}> / {status.daily_minimum} pts</span>
</div>
<div className="progress-bar" style={{ marginTop: '8px' }}>
<div className="progress-bar-fill" style={{
width: `${pct}%`,
background: status.gate_passed ? 'var(--success)' : 'var(--danger)'
}} />
</div>
<div style={{ marginTop: '8px', fontSize: '0.9rem', fontWeight: 600 }}>
{status.gate_passed ? '✅ REWARDS UNLOCKED' : `🔒 Earn ${status.points_remaining} more pts to unlock`}
</div>
</div>
{/* Week progress */}
<div className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
<span style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>This Week</span>
<Link to="/week" style={{ fontSize: '0.85rem' }}>Details </Link>
</div>
<div style={{ fontWeight: 700, marginBottom: '6px' }}>
{status.week_points} / {status.weekly_target} pts
</div>
<div className="progress-bar">
<div className="progress-bar-fill" style={{ width: `${weekPct}%`, background: 'var(--accent)' }} />
</div>
</div>
{/* Activity checklist */}
<div className="card">
<div style={{ fontWeight: 700, marginBottom: '10px', fontSize: '0.95rem' }}>Today's Activities</div>
{Object.entries(categories).map(([cat, info]) => (
<div className="checklist-item" key={cat}>
<span className="checklist-check">{doneCats.has(cat) ? '✅' : '⬜'}</span>
<span>{info.icon} {info.label}</span>
<span className="checklist-points">+{info.pts}</span>
</div>
))}
</div>
{/* Rewards (if gate passed) */}
{status.gate_passed && status.rewards_available.length > 0 && (
<div className="card">
<div style={{ fontWeight: 700, marginBottom: '10px', fontSize: '0.95rem' }}>🎮 Rewards Available</div>
{status.rewards_available.map(r => (
<div className="reward-card" key={r.id}>
<div>
<div style={{ fontWeight: 600 }}>{r.name}</div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>{r.point_cost} pts</div>
</div>
<button className="btn-success btn-sm" onClick={async () => {
try {
await api.redeemReward(r.id)
await loadStatus()
} catch (err) { alert(err.message) }
}}>Redeem</button>
</div>
))}
</div>
)}
{/* Quick actions */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px', marginTop: '8px' }}>
<button className="btn-primary btn-full" onClick={() => navigate('/log')}>+ Log Activity</button>
<button className="btn-outline btn-full" onClick={() => navigate('/food')}>📷 Log Food</button>
<button className="btn-outline btn-full btn-sm" onClick={() => handleSync('strava')}>🔄 Sync Strava</button>
<button className="btn-outline btn-full btn-sm" onClick={() => handleSync('polar')}>🔄 Sync Polar</button>
</div>
</div>
)
}

View file

@ -0,0 +1,94 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '../api'
const CATEGORIES = [
{ value: 'workout', label: 'Workout', icon: '💪', desc: 'Any exercise session' },
{ value: 'steps_target', label: 'Steps Target', icon: '👟', desc: 'Hit your daily step goal' },
{ value: 'screen_free', label: 'Screen-Free', icon: '📚', desc: 'Reading, outdoor time, etc.' },
{ value: 'daily_checkin', label: 'Daily Check-in', icon: '✅', desc: 'Just showing up counts' },
]
export default function LogActivity() {
const [category, setCategory] = useState('')
const [title, setTitle] = useState('')
const [duration, setDuration] = useState('')
const [description, setDescription] = useState('')
const [loading, setLoading] = useState(false)
const [success, setSuccess] = useState('')
const navigate = useNavigate()
async function handleSubmit(e) {
e.preventDefault()
if (!category) return
setLoading(true)
try {
const today = new Date().toISOString().split('T')[0]
const result = await api.logActivity({
category,
title: title || CATEGORIES.find(c => c.value === category)?.label,
description: description || null,
duration_minutes: duration ? parseInt(duration) : null,
activity_date: today,
})
setSuccess(`+${result.points_earned} points earned!`)
setCategory('')
setTitle('')
setDuration('')
setDescription('')
setTimeout(() => navigate('/'), 1500)
} catch (err) {
alert(err.message)
} finally {
setLoading(false)
}
}
return (
<div className="page">
<h1 className="page-title">Log Activity</h1>
{success && (
<div style={{ textAlign: 'center', padding: '20px', background: 'rgba(74,222,128,0.1)', borderRadius: 'var(--radius)', marginBottom: '16px' }}>
<div style={{ fontSize: '1.5rem', marginBottom: '4px' }}>🎉</div>
<div style={{ color: 'var(--success)', fontWeight: 700 }}>{success}</div>
</div>
)}
<div style={{ marginBottom: '20px' }}>
<div className="form-label">What did you do?</div>
<div className="option-grid">
{CATEGORIES.map(c => (
<div key={c.value} className={`option-btn ${category === c.value ? 'selected' : ''}`} onClick={() => setCategory(c.value)}>
<div style={{ fontSize: '1.3rem', marginBottom: '4px' }}>{c.icon}</div>
<div style={{ fontWeight: 600, fontSize: '0.85rem' }}>{c.label}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: '2px' }}>{c.desc}</div>
</div>
))}
</div>
</div>
{category && (
<form onSubmit={handleSubmit}>
<div className="form-group">
<label className="form-label">Title (optional)</label>
<input value={title} onChange={e => setTitle(e.target.value)} placeholder="e.g. Morning run, StrongLifts Day 12" />
</div>
{(category === 'workout' || category === 'screen_free') && (
<div className="form-group">
<label className="form-label">Duration (minutes)</label>
<input type="number" value={duration} onChange={e => setDuration(e.target.value)} placeholder="30" />
</div>
)}
<div className="form-group">
<label className="form-label">Notes (optional)</label>
<textarea value={description} onChange={e => setDescription(e.target.value)} rows={2} placeholder="How did it go?" />
</div>
<button type="submit" className="btn-primary btn-full" disabled={loading}>
{loading ? 'Saving...' : 'Log Activity'}
</button>
</form>
)}
</div>
)
}

View file

@ -0,0 +1,211 @@
import { useState, useEffect, useRef } from 'react'
import { api } from '../api'
const MEALS = ['breakfast', 'lunch', 'dinner', 'snack']
export default function LogFood() {
const [tab, setTab] = useState('log') // log, scan, search
const [mealType, setMealType] = useState('lunch')
const [foodName, setFoodName] = useState('')
const [brand, setBrand] = useState('')
const [calories, setCalories] = useState('')
const [protein, setProtein] = useState('')
const [carbs, setCarbs] = useState('')
const [fat, setFat] = useState('')
const [servings, setServings] = useState('1')
const [barcode, setBarcode] = useState('')
const [searchQuery, setSearchQuery] = useState('')
const [searchResults, setSearchResults] = useState([])
const [todayFood, setTodayFood] = useState(null)
const [loading, setLoading] = useState(false)
const [success, setSuccess] = useState('')
useEffect(() => { loadToday() }, [])
async function loadToday() {
try {
const today = new Date().toISOString().split('T')[0]
const data = await api.listFood(today)
setTodayFood(data)
} catch (err) { console.error(err) }
}
function fillFromProduct(product) {
setFoodName(product.product_name || '')
setBrand(product.brand || '')
setCalories(product.calories_100g ? String(Math.round(product.calories_100g)) : '')
setProtein(product.protein_100g ? String(Math.round(product.protein_100g)) : '')
setCarbs(product.carbs_100g ? String(Math.round(product.carbs_100g)) : '')
setFat(product.fat_100g ? String(Math.round(product.fat_100g)) : '')
setTab('log')
}
async function handleScan() {
if (!barcode.trim()) return
setLoading(true)
try {
const product = await api.scanBarcode(barcode.trim())
fillFromProduct(product)
} catch (err) { alert('Product not found. Try manual entry.') }
finally { setLoading(false) }
}
async function handleSearch() {
if (!searchQuery.trim()) return
setLoading(true)
try {
const data = await api.searchFood(searchQuery)
setSearchResults(data.results || [])
} catch (err) { alert(err.message) }
finally { setLoading(false) }
}
async function handleLog(e) {
e.preventDefault()
if (!foodName.trim()) return
setLoading(true)
try {
const today = new Date().toISOString().split('T')[0]
await api.logFood({
meal_type: mealType,
food_name: foodName,
brand: brand || null,
calories: calories ? parseFloat(calories) * parseFloat(servings || 1) : null,
protein_g: protein ? parseFloat(protein) * parseFloat(servings || 1) : null,
carbs_g: carbs ? parseFloat(carbs) * parseFloat(servings || 1) : null,
fat_g: fat ? parseFloat(fat) * parseFloat(servings || 1) : null,
servings: parseFloat(servings || 1),
food_date: today,
})
setSuccess('Food logged!')
setFoodName(''); setBrand(''); setCalories(''); setProtein(''); setCarbs(''); setFat(''); setServings('1')
await loadToday()
setTimeout(() => setSuccess(''), 2000)
} catch (err) { alert(err.message) }
finally { setLoading(false) }
}
return (
<div className="page">
<h1 className="page-title">Food Log</h1>
{success && <div style={{ padding: '10px', background: 'rgba(74,222,128,0.1)', borderRadius: 'var(--radius-sm)', color: 'var(--success)', textAlign: 'center', marginBottom: '12px', fontWeight: 600 }}>{success}</div>}
{/* Tab bar */}
<div style={{ display: 'flex', gap: '8px', marginBottom: '16px' }}>
<button className={tab === 'log' ? 'btn-primary btn-sm' : 'btn-outline btn-sm'} onClick={() => setTab('log')}>Manual</button>
<button className={tab === 'scan' ? 'btn-primary btn-sm' : 'btn-outline btn-sm'} onClick={() => setTab('scan')}>📷 Scan</button>
<button className={tab === 'search' ? 'btn-primary btn-sm' : 'btn-outline btn-sm'} onClick={() => setTab('search')}>🔍 Search</button>
</div>
{/* Barcode scan */}
{tab === 'scan' && (
<div className="card">
<div className="form-group">
<label className="form-label">Barcode number</label>
<input value={barcode} onChange={e => setBarcode(e.target.value)} placeholder="Enter or scan barcode" inputMode="numeric" />
</div>
<button className="btn-primary btn-full" onClick={handleScan} disabled={loading}>
{loading ? 'Looking up...' : 'Lookup'}
</button>
<p style={{ fontSize: '0.8rem', color: 'var(--text-muted)', marginTop: '8px', textAlign: 'center' }}>
Powered by Open Food Facts (4M+ products)
</p>
</div>
)}
{/* Food search */}
{tab === 'search' && (
<div className="card">
<div style={{ display: 'flex', gap: '8px', marginBottom: '12px' }}>
<input value={searchQuery} onChange={e => setSearchQuery(e.target.value)} placeholder="Search foods..." onKeyDown={e => e.key === 'Enter' && handleSearch()} />
<button className="btn-primary btn-sm" onClick={handleSearch} disabled={loading}>Go</button>
</div>
{searchResults.map((r, i) => (
<div key={i} style={{ padding: '10px 0', borderBottom: '1px solid var(--border)', cursor: 'pointer' }} onClick={() => fillFromProduct(r)}>
<div style={{ fontWeight: 600, fontSize: '0.9rem' }}>{r.product_name || 'Unknown'}</div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>
{r.brand && `${r.brand} · `}{r.calories_100g ? `${Math.round(r.calories_100g)} cal/100g` : ''}
</div>
</div>
))}
</div>
)}
{/* Manual log form */}
{tab === 'log' && (
<form onSubmit={handleLog}>
<div className="card">
<div className="form-group">
<label className="form-label">Meal</label>
<div style={{ display: 'flex', gap: '6px' }}>
{MEALS.map(m => (
<button key={m} type="button" className={mealType === m ? 'btn-primary btn-sm' : 'btn-outline btn-sm'} onClick={() => setMealType(m)}>
{m.charAt(0).toUpperCase() + m.slice(1)}
</button>
))}
</div>
</div>
<div className="form-group">
<label className="form-label">Food name</label>
<input value={foodName} onChange={e => setFoodName(e.target.value)} placeholder="e.g. Grilled chicken breast" required />
</div>
<div className="form-group">
<label className="form-label">Brand (optional)</label>
<input value={brand} onChange={e => setBrand(e.target.value)} placeholder="" />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
<div className="form-group">
<label className="form-label">Calories</label>
<input type="number" value={calories} onChange={e => setCalories(e.target.value)} placeholder="per serving" />
</div>
<div className="form-group">
<label className="form-label">Servings</label>
<input type="number" step="0.5" value={servings} onChange={e => setServings(e.target.value)} />
</div>
<div className="form-group">
<label className="form-label">Protein (g)</label>
<input type="number" value={protein} onChange={e => setProtein(e.target.value)} />
</div>
<div className="form-group">
<label className="form-label">Carbs (g)</label>
<input type="number" value={carbs} onChange={e => setCarbs(e.target.value)} />
</div>
<div className="form-group">
<label className="form-label">Fat (g)</label>
<input type="number" value={fat} onChange={e => setFat(e.target.value)} />
</div>
</div>
<button type="submit" className="btn-primary btn-full" disabled={loading}>
{loading ? 'Saving...' : 'Log Food'}
</button>
</div>
</form>
)}
{/* Today's summary */}
{todayFood && (
<div className="card" style={{ marginTop: '8px' }}>
<div style={{ fontWeight: 700, marginBottom: '10px' }}>
Today: {Math.round(todayFood.total_calories)} cal
</div>
{MEALS.map(m => {
const items = todayFood.meals[m] || []
if (items.length === 0) return null
return (
<div className="meal-section" key={m}>
<div className="meal-title">{m}</div>
{items.map(item => (
<div key={item.id} style={{ display: 'flex', justifyContent: 'space-between', padding: '4px 0', fontSize: '0.9rem' }}>
<span>{item.food_name}</span>
<span style={{ color: 'var(--text-muted)' }}>{item.calories ? `${Math.round(item.calories)} cal` : ''}</span>
</div>
))}
</div>
)
})}
</div>
)}
</div>
)
}

View file

@ -0,0 +1,87 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { api, setToken } from '../api'
export default function Login() {
const [mode, setMode] = useState('login')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [displayName, setDisplayName] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const navigate = useNavigate()
async function handleSubmit(e) {
e.preventDefault()
setError('')
setLoading(true)
try {
let data
if (mode === 'login') {
data = await api.login(username, password)
} else {
data = await api.register(username, password, displayName || username)
}
setToken(data.access_token)
// Check onboarding status
const status = await api.onboardingStatus()
if (!status.phase1_completed) {
navigate('/onboarding')
} else {
navigate('/')
}
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
return (
<div className="page" style={{ paddingTop: '80px' }}>
<div style={{ textAlign: 'center', marginBottom: '40px' }}>
<div style={{ fontSize: '3rem', marginBottom: '8px' }}>🏆</div>
<h1 style={{ fontSize: '1.8rem', fontWeight: 800 }}>Fitness Rewards</h1>
<p style={{ color: 'var(--text-muted)', marginTop: '8px' }}>Earn your rewards. Every day.</p>
</div>
<div className="card">
<div style={{ display: 'flex', gap: '8px', marginBottom: '20px' }}>
<button
className={mode === 'login' ? 'btn-primary btn-full' : 'btn-outline btn-full'}
onClick={() => setMode('login')}
type="button"
>Sign In</button>
<button
className={mode === 'register' ? 'btn-primary btn-full' : 'btn-outline btn-full'}
onClick={() => setMode('register')}
type="button"
>Sign Up</button>
</div>
{error && <div className="error-msg">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label className="form-label">Username</label>
<input value={username} onChange={e => setUsername(e.target.value)} required autoComplete="username" />
</div>
<div className="form-group">
<label className="form-label">Password</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required autoComplete="current-password" />
</div>
{mode === 'register' && (
<div className="form-group">
<label className="form-label">Display Name</label>
<input value={displayName} onChange={e => setDisplayName(e.target.value)} placeholder="What should we call you?" />
</div>
)}
<button type="submit" className="btn-primary btn-full" disabled={loading}>
{loading ? '...' : mode === 'login' ? 'Sign In' : 'Create Account'}
</button>
</form>
</div>
</div>
)
}

View file

@ -0,0 +1,335 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '../api'
const GOALS = [
{ value: 'lose_weight', label: 'Lose Weight', icon: '⚖️' },
{ value: 'build_strength', label: 'Build Strength', icon: '💪' },
{ value: 'get_active', label: 'Get More Active', icon: '🏃' },
{ value: 'feel_better', label: 'Feel Better Overall', icon: '😊' },
]
const TTM_STAGES = [
{ value: 'precontemplation', label: "I'm not exercising and haven't thought about starting" },
{ value: 'contemplation', label: "I've been thinking about getting more active but haven't started" },
{ value: 'preparation', label: 'I do some exercise but not consistently' },
{ value: 'action', label: "I've been exercising regularly for a few months" },
{ value: 'maintenance', label: "I've been exercising regularly for 6+ months" },
]
const ACTIVITIES = [
'Walking', 'Hiking', 'Running', 'Cycling', 'Swimming',
'Bodyweight', 'Weights', 'Yoga', 'Martial Arts', 'Dance', 'Team Sports',
]
const EQUIPMENT = [
{ value: 'none', label: 'Nothing — bodyweight only' },
{ value: 'basic_home', label: 'Basic home equipment' },
{ value: 'full_gym', label: 'Full gym access' },
]
export default function Onboarding() {
const [step, setStep] = useState(0)
const [goal, setGoal] = useState('')
const [ttm, setTtm] = useState('')
const [age, setAge] = useState('')
const [heightCm, setHeightCm] = useState('')
const [weightKg, setWeightKg] = useState('')
const [gender, setGender] = useState('')
const [parq, setParq] = useState({ heart: false, joints: false, meds: false })
const [motivation, setMotivation] = useState({ ext: 0, intro: 0, ident: 0, intr: 0, amot: 0 })
const [activities, setActivities] = useState([])
const [equipment, setEquipment] = useState('none')
const [daysPerWeek, setDaysPerWeek] = useState(3)
const [minsPerSession, setMinsPerSession] = useState(30)
const [rewards, setRewards] = useState([{ name: '', cost: 100 }])
const [recommendations, setRecommendations] = useState([])
const [loading, setLoading] = useState(false)
const navigate = useNavigate()
const totalSteps = 8
function Likert({ label, value, onChange }) {
return (
<div style={{ marginBottom: '16px' }}>
<div style={{ fontSize: '0.9rem', marginBottom: '6px' }}>{label}</div>
<div className="likert-row">
{[1, 2, 3, 4, 5].map(n => (
<button key={n} type="button" className={`likert-btn ${value === n ? 'selected' : ''}`} onClick={() => onChange(n)}>
{n}
</button>
))}
</div>
<div className="likert-labels"><span>Not at all</span><span>Very true</span></div>
</div>
)
}
async function handlePhase1() {
setLoading(true)
try {
await api.savePhase1({ primary_goal: goal, ttm_stage: ttm })
setStep(2)
} catch (err) { alert(err.message) }
finally { setLoading(false) }
}
async function handlePhase2() {
setLoading(true)
try {
await api.savePhase2({
age: age ? parseInt(age) : null,
height_cm: heightCm ? parseFloat(heightCm) : null,
weight_kg: weightKg ? parseFloat(weightKg) : null,
gender: gender || null,
parq_heart_condition: parq.heart,
parq_joint_issues: parq.joints,
parq_medications: parq.meds,
motivation_external: motivation.ext || null,
motivation_introjected: motivation.intro || null,
motivation_identified: motivation.ident || null,
motivation_intrinsic: motivation.intr || null,
motivation_amotivation: motivation.amot || null,
activity_preferences: activities.map(a => a.toLowerCase().replace(/ /g, '_')),
equipment_access: equipment,
days_per_week: daysPerWeek,
minutes_per_session: minsPerSession,
})
// Create rewards
for (const r of rewards) {
if (r.name.trim()) {
await api.createReward({ name: r.name, point_cost: r.cost || 100 })
}
}
// Get recommendations
const recs = await api.getRecommendations()
setRecommendations(recs.recommendations || [])
setStep(7)
} catch (err) { alert(err.message) }
finally { setLoading(false) }
}
async function handleCommit(rec) {
try {
const today = new Date()
const monday = new Date(today)
monday.setDate(today.getDate() + ((8 - today.getDay()) % 7 || 7))
const startDate = monday.toISOString().split('T')[0]
await api.createProgram({
name: rec.name,
source: rec.source,
source_url: rec.url,
start_date: startDate,
duration_days: rec.duration_days || 90,
})
navigate('/')
} catch (err) { alert(err.message) }
}
function toggleActivity(a) {
setActivities(prev => prev.includes(a) ? prev.filter(x => x !== a) : [...prev, a])
}
// Progress bar
const progress = Math.round(((step + 1) / totalSteps) * 100)
return (
<div className="page" style={{ paddingTop: '40px' }}>
<div className="progress-bar" style={{ marginBottom: '24px' }}>
<div className="progress-bar-fill" style={{ width: `${progress}%`, background: 'var(--accent)' }} />
</div>
{/* Step 0: Goal */}
{step === 0 && (
<div>
<h2 style={{ marginBottom: '8px' }}>What matters most to you?</h2>
<p style={{ color: 'var(--text-muted)', marginBottom: '20px', fontSize: '0.9rem' }}>Pick one you can change this anytime.</p>
<div className="option-grid">
{GOALS.map(g => (
<div key={g.value} className={`option-btn ${goal === g.value ? 'selected' : ''}`} onClick={() => setGoal(g.value)}>
<div style={{ fontSize: '1.5rem', marginBottom: '6px' }}>{g.icon}</div>
{g.label}
</div>
))}
</div>
<button className="btn-primary btn-full" style={{ marginTop: '20px' }} disabled={!goal} onClick={() => setStep(1)}>Continue</button>
</div>
)}
{/* Step 1: TTM Stage */}
{step === 1 && (
<div>
<h2 style={{ marginBottom: '8px' }}>Where are you now?</h2>
<p style={{ color: 'var(--text-muted)', marginBottom: '20px', fontSize: '0.9rem' }}>Be honest there's no wrong answer.</p>
{TTM_STAGES.map(s => (
<div key={s.value}
className={`option-btn ${ttm === s.value ? 'selected' : ''}`}
style={{ textAlign: 'left', marginBottom: '10px' }}
onClick={() => setTtm(s.value)}
>{s.label}</div>
))}
<button className="btn-primary btn-full" style={{ marginTop: '16px' }} disabled={!ttm || loading} onClick={handlePhase1}>
{loading ? '...' : 'Continue'}
</button>
</div>
)}
{/* Step 2: Safety (PAR-Q+) */}
{step === 2 && (
<div>
<h2 style={{ marginBottom: '8px' }}>Quick health check</h2>
<p style={{ color: 'var(--text-muted)', marginBottom: '20px', fontSize: '0.9rem' }}>For your safety this takes 10 seconds.</p>
{[
{ key: 'heart', label: 'I have a heart condition or high blood pressure' },
{ key: 'joints', label: 'I have bone or joint problems that could worsen with exercise' },
{ key: 'meds', label: 'I take prescription medications for a chronic condition' },
].map(q => (
<label key={q.key} style={{ display: 'flex', gap: '12px', padding: '12px 0', cursor: 'pointer', borderBottom: '1px solid var(--border)' }}>
<input type="checkbox" checked={parq[q.key]} onChange={e => setParq({ ...parq, [q.key]: e.target.checked })} style={{ width: 'auto' }} />
<span style={{ fontSize: '0.9rem' }}>{q.label}</span>
</label>
))}
{(parq.heart || parq.joints || parq.meds) && (
<div style={{ marginTop: '12px', padding: '12px', background: 'rgba(251, 191, 36, 0.1)', borderRadius: 'var(--radius-sm)', fontSize: '0.85rem', color: 'var(--warning)' }}>
We recommend checking with your doctor before starting. This won't stop you just be mindful.
</div>
)}
<button className="btn-primary btn-full" style={{ marginTop: '20px' }} onClick={() => setStep(3)}>Continue</button>
</div>
)}
{/* Step 3: Body Metrics */}
{step === 3 && (
<div>
<h2 style={{ marginBottom: '8px' }}>About you</h2>
<p style={{ color: 'var(--text-muted)', marginBottom: '20px', fontSize: '0.9rem' }}>Optional helps estimate nutrition needs.</p>
<div className="form-group">
<label className="form-label">Age</label>
<input type="number" value={age} onChange={e => setAge(e.target.value)} placeholder="e.g. 35" />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
<div className="form-group">
<label className="form-label">Height (cm)</label>
<input type="number" value={heightCm} onChange={e => setHeightCm(e.target.value)} placeholder="175" />
</div>
<div className="form-group">
<label className="form-label">Weight (kg)</label>
<input type="number" value={weightKg} onChange={e => setWeightKg(e.target.value)} placeholder="80" />
</div>
</div>
<div className="form-group">
<label className="form-label">Gender</label>
<select value={gender} onChange={e => setGender(e.target.value)}>
<option value="">Prefer not to say</option>
<option value="male">Male</option>
<option value="female">Female</option>
</select>
</div>
<div style={{ display: 'flex', gap: '10px' }}>
<button className="btn-outline btn-full" onClick={() => setStep(4)}>Skip</button>
<button className="btn-primary btn-full" onClick={() => setStep(4)}>Continue</button>
</div>
</div>
)}
{/* Step 4: Motivation (BREQ-2) */}
{step === 4 && (
<div>
<h2 style={{ marginBottom: '8px' }}>What drives you?</h2>
<p style={{ color: 'var(--text-muted)', marginBottom: '20px', fontSize: '0.9rem' }}>Rate each honestly helps us calibrate your experience.</p>
<Likert label='"People important to me say I should exercise"' value={motivation.ext} onChange={v => setMotivation({ ...motivation, ext: v })} />
<Likert label='"I feel bad about myself when I skip exercise"' value={motivation.intro} onChange={v => setMotivation({ ...motivation, intro: v })} />
<Likert label='"I value what exercise does for my health"' value={motivation.ident} onChange={v => setMotivation({ ...motivation, ident: v })} />
<Likert label='"I find exercise enjoyable and satisfying"' value={motivation.intr} onChange={v => setMotivation({ ...motivation, intr: v })} />
<Likert label={'"I don\'t really see why I should bother"'} value={motivation.amot} onChange={v => setMotivation({ ...motivation, amot: v })} />
<button className="btn-primary btn-full" onClick={() => setStep(5)}>Continue</button>
</div>
)}
{/* Step 5: Activities + Equipment */}
{step === 5 && (
<div>
<h2 style={{ marginBottom: '8px' }}>What sounds fun?</h2>
<p style={{ color: 'var(--text-muted)', marginBottom: '16px', fontSize: '0.9rem' }}>Pick all that interest you.</p>
<div className="chip-grid" style={{ marginBottom: '24px' }}>
{ACTIVITIES.map(a => (
<div key={a} className={`chip ${activities.includes(a) ? 'selected' : ''}`} onClick={() => toggleActivity(a)}>{a}</div>
))}
</div>
<div className="form-group">
<label className="form-label">Equipment access</label>
{EQUIPMENT.map(e => (
<div key={e.value} className={`option-btn ${equipment === e.value ? 'selected' : ''}`} style={{ textAlign: 'left', marginBottom: '8px' }} onClick={() => setEquipment(e.value)}>
{e.label}
</div>
))}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', marginBottom: '16px' }}>
<div className="form-group">
<label className="form-label">Days/week</label>
<select value={daysPerWeek} onChange={e => setDaysPerWeek(parseInt(e.target.value))}>
{[1, 2, 3, 4, 5, 6, 7].map(n => <option key={n} value={n}>{n}</option>)}
</select>
</div>
<div className="form-group">
<label className="form-label">Minutes/session</label>
<select value={minsPerSession} onChange={e => setMinsPerSession(parseInt(e.target.value))}>
{[15, 20, 30, 45, 60, 90].map(n => <option key={n} value={n}>{n}</option>)}
</select>
</div>
</div>
<button className="btn-primary btn-full" onClick={() => setStep(6)}>Continue</button>
</div>
)}
{/* Step 6: Define Rewards */}
{step === 6 && (
<div>
<h2 style={{ marginBottom: '8px' }}>Define your rewards</h2>
<p style={{ color: 'var(--text-muted)', marginBottom: '20px', fontSize: '0.9rem' }}>What guilty pleasures do you want to earn?</p>
{rewards.map((r, i) => (
<div key={i} style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: '10px', marginBottom: '12px' }}>
<input placeholder="e.g. 1hr gaming" value={r.name} onChange={e => {
const next = [...rewards]; next[i] = { ...r, name: e.target.value }; setRewards(next)
}} />
<input type="number" placeholder="100" value={r.cost} onChange={e => {
const next = [...rewards]; next[i] = { ...r, cost: parseInt(e.target.value) || 0 }; setRewards(next)
}} />
</div>
))}
<button className="btn-outline btn-sm" style={{ marginBottom: '20px' }} onClick={() => setRewards([...rewards, { name: '', cost: 100 }])}>
+ Add another reward
</button>
<button className="btn-primary btn-full" disabled={loading} onClick={handlePhase2}>
{loading ? 'Saving...' : 'See Recommendations'}
</button>
</div>
)}
{/* Step 7: Recommendations + Commit */}
{step === 7 && (
<div>
<h2 style={{ marginBottom: '8px' }}>Recommended for you</h2>
<p style={{ color: 'var(--text-muted)', marginBottom: '20px', fontSize: '0.9rem' }}>Pick a program and commit to 90 days.</p>
{recommendations.map(rec => (
<div className="card" key={rec.id}>
<div style={{ fontWeight: 700, marginBottom: '4px' }}>{rec.name}</div>
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)', marginBottom: '8px' }}>
{rec.difficulty} {rec.duration_days ? `${rec.duration_days} days` : 'Ongoing'} {rec.source}
</div>
<p style={{ fontSize: '0.9rem', marginBottom: '12px' }}>{rec.description}</p>
<div style={{ display: 'flex', gap: '8px' }}>
<a href={rec.url} target="_blank" rel="noopener noreferrer" className="btn-outline btn-sm" style={{ display: 'inline-block' }}>View </a>
<button className="btn-primary btn-sm" onClick={() => handleCommit(rec)}>Select & Commit</button>
</div>
</div>
))}
<button className="btn-outline btn-full" style={{ marginTop: '8px' }} onClick={() => navigate('/')}>Skip for now</button>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,90 @@
import { useState, useEffect } from 'react'
import { api } from '../api'
export default function Rewards() {
const [rewards, setRewards] = useState([])
const [newName, setNewName] = useState('')
const [newCost, setNewCost] = useState('100')
const [status, setStatus] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => { load() }, [])
async function load() {
try {
const [r, s] = await Promise.all([api.listRewards(), api.today()])
setRewards(r)
setStatus(s)
} catch (err) { console.error(err) }
finally { setLoading(false) }
}
async function handleCreate(e) {
e.preventDefault()
if (!newName.trim()) return
try {
await api.createReward({ name: newName, point_cost: parseInt(newCost) || 100 })
setNewName(''); setNewCost('100')
await load()
} catch (err) { alert(err.message) }
}
async function handleRedeem(id) {
try {
await api.redeemReward(id)
await load()
} catch (err) { alert(err.message) }
}
if (loading) return <div className="page"><div className="loading">Loading...</div></div>
return (
<div className="page">
<h1 className="page-title">🎮 Rewards</h1>
{/* Status */}
{status && (
<div className={`gate-banner ${status.gate_passed ? 'gate-earned' : 'gate-locked'}`} style={{ marginBottom: '16px' }}>
<div style={{ fontWeight: 700 }}>
{status.gate_passed ? '✅ Rewards Unlocked' : `🔒 Earn ${status.points_remaining} more pts`}
</div>
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>
{status.points_earned} / {status.daily_minimum} pts today
</div>
</div>
)}
{/* Reward list */}
<div className="card">
{rewards.length === 0 && <div style={{ textAlign: 'center', color: 'var(--text-muted)', padding: '16px' }}>No rewards yet. Add one below!</div>}
{rewards.map(r => (
<div className="reward-card" key={r.id}>
<div>
<div style={{ fontWeight: 600 }}>{r.name}</div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>{r.point_cost} pts</div>
</div>
<button
className={status?.gate_passed ? 'btn-success btn-sm' : 'btn-outline btn-sm'}
disabled={!status?.gate_passed}
onClick={() => handleRedeem(r.id)}
>
{status?.gate_passed ? 'Redeem' : 'Locked'}
</button>
</div>
))}
</div>
{/* Add new reward */}
<div className="card">
<div style={{ fontWeight: 700, marginBottom: '12px', fontSize: '0.9rem' }}>Add New Reward</div>
<form onSubmit={handleCreate}>
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: '10px', marginBottom: '10px' }}>
<input value={newName} onChange={e => setNewName(e.target.value)} placeholder="e.g. 1hr gaming" required />
<input type="number" value={newCost} onChange={e => setNewCost(e.target.value)} placeholder="pts" />
</div>
<button type="submit" className="btn-primary btn-full btn-sm">Add Reward</button>
</form>
</div>
</div>
)
}

View file

@ -0,0 +1,137 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { api, clearToken } from '../api'
export default function Settings() {
const [user, setUser] = useState(null)
const [rules, setRules] = useState([])
const [targets, setTargets] = useState(null)
const [integrations, setIntegrations] = useState(null)
const [loading, setLoading] = useState(true)
const navigate = useNavigate()
useEffect(() => { load() }, [])
async function load() {
try {
const [u, r, t, intg] = await Promise.all([
api.me(), api.getRules(), api.getTargets(), api.integrationStatus(),
])
setUser(u); setRules(r); setTargets(t); setIntegrations(intg)
} catch (err) { console.error(err) }
finally { setLoading(false) }
}
async function updateRule(id, points) {
try {
await api.updateRule(id, { points: parseInt(points) })
} catch (err) { alert(err.message) }
}
async function updateTargets(field, value) {
const update = { [field]: parseInt(value) }
try {
await api.updateTargets(update)
setTargets({ ...targets, ...update })
} catch (err) { alert(err.message) }
}
async function connectProvider(provider) {
try {
const fn = provider === 'strava' ? api.stravaAuth : api.polarAuth
const data = await fn()
window.location.href = data.auth_url
} catch (err) { alert(err.message) }
}
async function disconnectProvider(provider) {
if (!confirm(`Disconnect ${provider}?`)) return
try {
await api.disconnect(provider)
await load()
} catch (err) { alert(err.message) }
}
function handleLogout() {
clearToken()
navigate('/login')
}
if (loading) return <div className="page"><div className="loading">Loading...</div></div>
return (
<div className="page">
<h1 className="page-title"> Settings</h1>
{/* User */}
{user && (
<div className="card">
<div style={{ fontWeight: 700, marginBottom: '4px' }}>{user.display_name}</div>
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>@{user.username}</div>
</div>
)}
{/* Point Rules */}
<div className="card">
<div style={{ fontWeight: 700, marginBottom: '12px' }}>Point Rules</div>
{rules.map(r => (
<div key={r.id} style={{ display: 'flex', alignItems: 'center', gap: '10px', padding: '8px 0', borderBottom: '1px solid var(--border)' }}>
<div style={{ flex: 1, fontSize: '0.9rem' }}>{r.description}</div>
<input
type="number"
style={{ width: '70px', textAlign: 'center' }}
defaultValue={r.points}
onBlur={e => updateRule(r.id, e.target.value)}
/>
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>pts</span>
</div>
))}
</div>
{/* Daily Targets */}
{targets && (
<div className="card">
<div style={{ fontWeight: 700, marginBottom: '12px' }}>Daily Targets</div>
<div className="form-group">
<label className="form-label">Daily minimum (pts to unlock rewards)</label>
<input type="number" defaultValue={targets.daily_minimum_pts} onBlur={e => updateTargets('daily_minimum_pts', e.target.value)} />
</div>
<div className="form-group">
<label className="form-label">Weekly target</label>
<input type="number" defaultValue={targets.weekly_target_pts} onBlur={e => updateTargets('weekly_target_pts', e.target.value)} />
</div>
<div className="form-group">
<label className="form-label">Weekly bonus (for hitting target)</label>
<input type="number" defaultValue={targets.weekly_bonus_pts} onBlur={e => updateTargets('weekly_bonus_pts', e.target.value)} />
</div>
</div>
)}
{/* Integrations */}
<div className="card">
<div style={{ fontWeight: 700, marginBottom: '12px' }}>Integrations</div>
{['strava', 'polar'].map(provider => {
const info = integrations?.[provider] || { connected: false }
return (
<div key={provider} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 0', borderBottom: '1px solid var(--border)' }}>
<div>
<div style={{ fontWeight: 600, textTransform: 'capitalize' }}>{provider}</div>
<div style={{ fontSize: '0.8rem', color: info.connected ? 'var(--success)' : 'var(--text-muted)' }}>
{info.connected ? 'Connected' : 'Not connected'}
</div>
</div>
{info.connected ? (
<button className="btn-outline btn-sm" onClick={() => disconnectProvider(provider)}>Disconnect</button>
) : (
<button className="btn-primary btn-sm" onClick={() => connectProvider(provider)}>Connect</button>
)}
</div>
)
})}
</div>
{/* Logout */}
<button className="btn-danger btn-full" style={{ marginTop: '16px' }} onClick={handleLogout}>Sign Out</button>
</div>
)
}

View file

@ -0,0 +1,77 @@
import { useState, useEffect } from 'react'
import { api } from '../api'
export default function WeekView() {
const [week, setWeek] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => { load() }, [])
async function load() {
try {
const data = await api.week()
setWeek(data)
} catch (err) { console.error(err) }
finally { setLoading(false) }
}
if (loading) return <div className="page"><div className="loading">Loading...</div></div>
if (!week) return <div className="page"><div className="error-msg">Failed to load</div></div>
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
const pct = week.weekly_target > 0 ? Math.min(100, Math.round((week.total_points_earned / week.weekly_target) * 100)) : 0
return (
<div className="page">
<h1 className="page-title">📊 This Week</h1>
{/* Week summary */}
<div className="card" style={{ textAlign: 'center' }}>
<div style={{ fontSize: '2rem', fontWeight: 800 }}>{week.total_points_earned}</div>
<div style={{ color: 'var(--text-muted)', marginBottom: '8px' }}>/ {week.weekly_target} pts</div>
<div className="progress-bar">
<div className="progress-bar-fill" style={{ width: `${pct}%`, background: week.hit_weekly_target ? 'var(--success)' : 'var(--accent)' }} />
</div>
{week.hit_weekly_target && (
<div style={{ marginTop: '8px', color: 'var(--success)', fontWeight: 600 }}>
🏆 Weekly target hit! +{week.weekly_bonus_earned} bonus pts
</div>
)}
<div style={{ display: 'flex', justifyContent: 'center', gap: '24px', marginTop: '12px', fontSize: '0.85rem' }}>
<div><span style={{ fontWeight: 700 }}>{week.active_days}</span> <span style={{ color: 'var(--text-muted)' }}>active days</span></div>
<div><span style={{ fontWeight: 700 }}>{week.gate_passed_days}</span> <span style={{ color: 'var(--text-muted)' }}>rewards earned</span></div>
</div>
</div>
{/* Daily breakdown */}
<div className="card">
<div style={{ fontWeight: 700, marginBottom: '12px' }}>Daily Breakdown</div>
{week.daily_breakdown.map((day, i) => {
const dayPct = day.daily_minimum > 0 ? Math.min(100, Math.round((day.points_earned / day.daily_minimum) * 100)) : 0
const isToday = day.date === new Date().toISOString().split('T')[0]
return (
<div key={day.date} style={{ display: 'flex', alignItems: 'center', gap: '10px', padding: '8px 0', borderBottom: i < 6 ? '1px solid var(--border)' : 'none' }}>
<div style={{ width: '40px', fontWeight: isToday ? 700 : 400, color: isToday ? 'var(--accent)' : 'var(--text)' }}>
{dayNames[i]}
</div>
<div style={{ flex: 1 }}>
<div className="progress-bar" style={{ height: '6px' }}>
<div className="progress-bar-fill" style={{
width: `${dayPct}%`,
background: day.gate_passed ? 'var(--success)' : day.points_earned > 0 ? 'var(--warning)' : 'var(--border)',
}} />
</div>
</div>
<div style={{ width: '50px', textAlign: 'right', fontSize: '0.85rem', fontWeight: 600 }}>
{day.points_earned}
</div>
<div style={{ width: '24px', textAlign: 'center' }}>
{day.gate_passed ? '✅' : day.points_earned > 0 ? '🟡' : '⬜'}
</div>
</div>
)
})}
</div>
</div>
)
}

14
frontend/vite.config.js Normal file
View file

@ -0,0 +1,14 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
})