feat: timezone auto-detection, branding fix, frontend rebuild

Timezone:
- Add diligence/utils/dates.py with today_for_user(), now_for_user(), day_start_utc()
- Replace all 20 date.today() calls across 10 files with timezone-aware helpers
- Remove hardcoded Asia/Bangkok offset hack in nutrition router
- Change defaults from Asia/Bangkok to UTC in user model, nutrition model, config
- Add timezone field to RegisterRequest schema
- Set user.timezone from browser detection during registration
- Frontend sends Intl.DateTimeFormat().resolvedOptions().timeZone on register

Branding:
- Login page: Fitness Rewards -> Diligence, updated tagline and emoji
- index.html title: Fitness Rewards -> Diligence

Frontend rebuild:
- Fresh Vite build with all changes (index-CsKoBN0D.js)
- Nav bar, timezone detection, and branding all verified in built bundle
This commit is contained in:
Claude 2026-07-04 07:16:23 +00:00
parent 40c8485aa7
commit c37543b009
21 changed files with 1961 additions and 64 deletions

View file

@ -40,7 +40,7 @@ class Settings(BaseSettings):
# App
base_url: str = "http://localhost:8000"
timezone: str = "Asia/Bangkok"
timezone: str = "UTC"
data_dir: str = str(_default_data_dir())
# MCP

View file

@ -6,8 +6,8 @@
<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>
<script type="module" crossorigin src="/assets/index-De1TwOuS.js"></script>
<title>Diligence</title>
<script type="module" crossorigin src="/assets/index-CsKoBN0D.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-ciTnvXdZ.css">
</head>
<body>

View file

@ -32,7 +32,7 @@ class NutritionGoal(Base):
default_fast_hours: Mapped[int] = mapped_column(Integer, default=16) # 16:8
diet_style: Mapped[str] = mapped_column(String(30), default="strict_keto")
timezone_str: Mapped[str] = mapped_column(String(50), default="Asia/Bangkok")
timezone_str: Mapped[str] = mapped_column(String(50), default="UTC")
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

@ -17,7 +17,7 @@ class User(Base):
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")
timezone: Mapped[str] = mapped_column(String(50), default="UTC")
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
# Relationships

View file

@ -34,6 +34,7 @@ async def register(req: RegisterRequest, db: Annotated[AsyncSession, Depends(get
display_name=req.display_name,
password_hash=hash_password(req.password),
email=req.email,
timezone=req.timezone or "UTC",
is_admin=is_first_user,
)
db.add(user)

View file

@ -18,6 +18,7 @@ from diligence.models.catalog import ProgramCatalog, CatalogWorkout, CrawlQueue,
from diligence.services.program_research import slugify, find_urls_for_program
from diligence.services.points_engine import log_activity_with_points
from diligence.utils.auth import get_current_user
from diligence.utils.dates import today_for_user
router = APIRouter(prefix="/api/programs", tags=["programs-v2"])
@ -337,7 +338,7 @@ async def get_program_schedule(
)
# Calculate current real week from start date
today = date.today()
today = today_for_user(user.timezone)
days_elapsed = max(0, (today - program.start_date).days)
current_week = min(total_weeks, (days_elapsed // 7) + 1)
@ -491,7 +492,7 @@ async def complete_workout(
db=db,
user_id=user.id,
category="workout",
activity_date=date.today(),
activity_date=today_for_user(user.timezone),
title=f"{program.name}: Wk{real_week} {workout.workout_name or f'Day {workout.day_number}'}",
description=f"Completed program workout ({len(workout.exercises)} exercises)",
source="program",
@ -556,7 +557,7 @@ async def check_weekly_bonus(
db=db,
user_id=user.id,
category="bonus",
activity_date=date.today(),
activity_date=today_for_user(user.timezone),
title=f"{program.name}: Week {real_week} Complete!",
source="program",
program_id=program.id,
@ -599,7 +600,7 @@ async def check_completion_bonus(
db=db,
user_id=user.id,
category="bonus",
activity_date=date.today(),
activity_date=today_for_user(user.timezone),
title=f"{program.name}: Program Complete!",
source="program",
program_id=program.id,
@ -654,7 +655,7 @@ async def get_program_progress(
)
total_points = result.scalar() or 0
today = date.today()
today = today_for_user(user.timezone)
days_elapsed = max(0, (today - program.start_date).days)
current_week = min(total_weeks, (days_elapsed // 7) + 1)

View file

@ -12,6 +12,7 @@ from diligence.models.user import User
from diligence.models.food import FoodLog
from diligence.schemas.food import FoodCreate, FoodSearchResult
from diligence.utils.auth import get_current_user
from diligence.utils.dates import today_for_user
from diligence.services.food_lookup import lookup_barcode, search_food
router = APIRouter(prefix="/api/food", tags=["food"])
@ -49,7 +50,7 @@ async def list_food(
if item.calories:
total_cals += float(item.calories) * float(item.servings or 1)
return {"date": (food_date or date.today()).isoformat(), "meals": meals, "total_calories": round(total_cals, 1)}
return {"date": (food_date or today_for_user(user.timezone)).isoformat(), "meals": meals, "total_calories": round(total_cals, 1)}
@router.post("")

View file

@ -14,6 +14,7 @@ from diligence.database import get_db
from diligence.models.user import User
from diligence.models.meal_plan import MealPlan, MealPlanItem, MealCompliance
from diligence.utils.auth import get_current_user
from diligence.utils.dates import today_for_user
router = APIRouter(prefix="/api/meal-plans", tags=["meal-plans"])
@ -94,7 +95,7 @@ async def create_meal_plan(
daily_fat_g=body.daily_fat_g,
restrictions=body.restrictions,
duration_days=body.duration_days,
start_date=body.start_date or date.today(),
start_date=body.start_date or today_for_user(user.timezone),
)
db.add(plan)
await db.flush()
@ -131,7 +132,7 @@ async def get_today_meals(
if not plan:
return {"active_plan": None}
day_num = (date.today() - plan.start_date).days + 1
day_num = (today_for_user(user.timezone) - plan.start_date).days + 1
if day_num < 1 or day_num > plan.duration_days:
return {"active_plan": plan.name, "day": day_num, "meals": [], "message": "No meals planned for today"}
@ -217,7 +218,7 @@ async def log_compliance(
user_id=user.id,
plan_id=plan.id,
plan_item_id=uuid_mod.UUID(body.plan_item_id) if body.plan_item_id else None,
compliance_date=body.compliance_date or date.today(),
compliance_date=body.compliance_date or today_for_user(user.timezone),
status=body.status,
substitution=body.substitution,
)
@ -250,7 +251,7 @@ async def get_plan_progress(
substituted = sum(1 for e in entries if e.status == "substituted")
skipped = sum(1 for e in entries if e.status == "skipped")
days_elapsed = (date.today() - plan.start_date).days + 1
days_elapsed = (today_for_user(user.timezone) - plan.start_date).days + 1
compliance_pct = (followed + substituted) / total * 100 if total > 0 else 0
return {

View file

@ -19,6 +19,7 @@ from diligence.schemas.nutrition import (
)
from diligence.utils.auth import get_current_user
from diligence.services.points_engine import log_activity_with_points
from diligence.utils.dates import today_for_user, now_for_user, day_start_utc
router = APIRouter(prefix="/api/nutrition", tags=["nutrition"])
@ -87,7 +88,7 @@ async def get_today(
):
"""Today's macros vs target, eating-window state, active fast, compliance."""
goal = await _get_or_create_goal(db, user.id)
today = date.today()
today = today_for_user(user.timezone)
# Sum today's food
result = await db.execute(
@ -103,8 +104,8 @@ async def get_today(
cals, prot, carbs, fat, fiber = float(cals), float(prot), float(carbs), float(fat), float(fiber)
net_carbs = max(0.0, carbs - fiber)
# Eating window (local time, using goal timezone_str — simplified: assume server in UTC, user sends local)
now_local = datetime.now(timezone.utc) + timedelta(hours=7) # Asia/Bangkok offset hack
# Eating window (user's local time)
now_local = now_for_user(user.timezone)
hour = now_local.hour
in_window = goal.eating_window_start_hour <= hour < goal.eating_window_end_hour
window_str = f"{goal.eating_window_start_hour:02d}:00{goal.eating_window_end_hour:02d}:00"
@ -352,7 +353,7 @@ async def get_electrolytes_today(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
today_start = datetime.combine(date.today(), datetime.min.time()).replace(tzinfo=timezone.utc)
today_start = day_start_utc(user.timezone)
result = await db.execute(
select(
func.coalesce(func.sum(ElectrolyteLog.sodium_mg), 0),

View file

@ -14,6 +14,7 @@ from diligence.models.reward import Reward
from diligence.schemas.points import PointRuleUpdate, DailyTargetUpdate
from diligence.schemas.reward import RewardCreate, RewardRedeemRequest
from diligence.utils.auth import get_current_user
from diligence.utils.dates import today_for_user
from diligence.services.points_engine import get_today_status, get_weekly_summary, redeem_reward
router = APIRouter(prefix="/api/points", tags=["points"])
@ -24,7 +25,7 @@ async def today_status(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
return await get_today_status(db, user.id)
return await get_today_status(db, user.id, tz_str=user.timezone)
@router.get("/week")
@ -33,7 +34,7 @@ async def week_summary(
db: Annotated[AsyncSession, Depends(get_db)],
start: date | None = None,
):
d = start or date.today()
d = start or today_for_user(user.timezone)
return await get_weekly_summary(db, user.id, d)
@ -143,7 +144,7 @@ async def redeem(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
result = await redeem_reward(db, user.id, uuid_mod.UUID(reward_id), req.redemption_date)
result = await redeem_reward(db, user.id, uuid_mod.UUID(reward_id), req.redemption_date, tz_str=user.timezone)
if not result["success"]:
raise HTTPException(status_code=400, detail=result["reason"])
return result

View file

@ -12,6 +12,7 @@ from diligence.models.user import User
from diligence.models.program import Program
from diligence.models.activity import ActivityLog
from diligence.utils.auth import get_current_user
from diligence.utils.dates import today_for_user
from pydantic import BaseModel
router = APIRouter(prefix="/api/programs", tags=["programs"])
@ -37,7 +38,7 @@ async def list_programs(
programs = result.scalars().all()
out = []
for p in programs:
today = date.today()
today = today_for_user(user.timezone)
day_num = (today - p.start_date).days + 1
total = (p.end_date - p.start_date).days + 1
out.append({
@ -90,7 +91,7 @@ async def get_program(
)
logged = days_logged.scalar() or 0
today = date.today()
today = today_for_user(user.timezone)
day_num = (today - p.start_date).days + 1
total = (p.end_date - p.start_date).days + 1

View file

@ -19,6 +19,7 @@ from diligence.models.support import SupportThread, SupportMessage
from diligence.models.activity import ActivityLog
from diligence.services.points_engine import get_active_program, get_today_status
from diligence.utils.auth import get_current_user
from diligence.utils.dates import day_start_utc
from diligence.config import settings
logger = logging.getLogger(__name__)
@ -45,7 +46,7 @@ async def gather_user_context(db: AsyncSession, user: User) -> dict:
}
try:
program = await get_active_program(db, user.id)
program = await get_active_program(db, user.id, tz_str=user.timezone)
if program:
context["program_name"] = program.get("name")
context["program_day"] = program.get("day")
@ -57,7 +58,7 @@ async def gather_user_context(db: AsyncSession, user: User) -> dict:
pass
try:
status = await get_today_status(db, user.id)
status = await get_today_status(db, user.id, tz_str=user.timezone)
context["points_today"] = status.get("points_earned", 0)
context["daily_target"] = status.get("daily_minimum", 80)
context["gate_passed"] = status.get("gate_passed", False)
@ -219,7 +220,7 @@ async def send_message(
raise HTTPException(status_code=400, detail="Message too long (max 2000 characters)")
# Rate limit check
today_start = datetime.combine(date.today(), datetime.min.time(), tzinfo=timezone.utc)
today_start = day_start_utc(user.timezone)
thread = await get_or_create_thread(db, user.id)
result = await db.execute(

View file

@ -13,6 +13,7 @@ class RegisterRequest(BaseModel):
password: str = Field(min_length=8, max_length=128)
display_name: str = Field(min_length=1, max_length=100)
email: str | None = None
timezone: str | None = None
class TokenResponse(BaseModel):

View file

@ -81,7 +81,7 @@ async def get_available_rewards(db: AsyncSession, user_id: uuid.UUID) -> list[di
]
async def get_active_program(db: AsyncSession, user_id: uuid.UUID) -> dict | None:
async def get_active_program(db: AsyncSession, user_id: uuid.UUID, tz_str: str = "UTC") -> dict | None:
result = await db.execute(
select(Program)
.where(Program.user_id == user_id, Program.status == "active")
@ -90,7 +90,7 @@ async def get_active_program(db: AsyncSession, user_id: uuid.UUID) -> dict | Non
program = result.scalar_one_or_none()
if not program:
return None
today = date.today()
today = today_for_user(tz_str)
day_num = (today - program.start_date).days + 1
total = (program.end_date - program.start_date).days + 1
return {
@ -104,8 +104,8 @@ async def get_active_program(db: AsyncSession, user_id: uuid.UUID) -> dict | Non
}
async def get_today_status(db: AsyncSession, user_id: uuid.UUID) -> dict:
today = date.today()
async def get_today_status(db: AsyncSession, user_id: uuid.UUID, tz_str: str = "UTC") -> dict:
today = today_for_user(tz_str)
earned = await get_daily_points_earned(db, user_id, today)
target = await get_daily_target(db, user_id)
daily_min = target.daily_minimum_pts if target else 80
@ -189,9 +189,9 @@ async def log_activity_with_points(
async def redeem_reward(
db: AsyncSession, user_id: uuid.UUID, reward_id: uuid.UUID, redemption_date: date | None = None
) -> dict:
, tz_str: str = "UTC") -> dict:
"""Attempt to redeem a reward. Returns success/failure with details."""
d = redemption_date or date.today()
d = redemption_date or today_for_user(tz_str)
earned = await get_daily_points_earned(db, user_id, d)
target = await get_daily_target(db, user_id)
daily_min = target.daily_minimum_pts if target else 80

View file

@ -3,6 +3,7 @@ from __future__ import annotations
"""Polar AccessLink API client for activity sync."""
import uuid
from datetime import datetime, timezone
from diligence.utils.dates import today_for_user
import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@ -108,7 +109,7 @@ async def sync_polar_activities(db: AsyncSession, user_id: uuid.UUID) -> list[di
activity_date = datetime.fromisoformat(start).date()
except (ValueError, TypeError):
from datetime import date
activity_date = date.today()
activity_date = today_for_user()
# Parse duration ISO 8601 (e.g., PT1H30M)
duration_str = ex.get("duration", "")

View file

@ -1,20 +1,42 @@
"""Timezone-aware date helpers.
All date calculations should use these helpers instead of date.today()
so the result reflects the user's local date, not the server's.
"""
from __future__ import annotations
from datetime import date, timedelta
from datetime import date, datetime, timezone
from zoneinfo import ZoneInfo
def get_week_boundaries(d: date) -> tuple[date, date]:
"""Return Monday and Sunday of the week containing date d."""
monday = d - timedelta(days=d.weekday())
sunday = monday + timedelta(days=6)
return monday, sunday
def today_for_user(tz_str: str = "UTC") -> date:
"""Return today's date in the user's timezone."""
try:
tz = ZoneInfo(tz_str)
except (KeyError, Exception):
tz = ZoneInfo("UTC")
return datetime.now(tz).date()
def get_month_boundaries(year: int, month: int) -> tuple[date, date]:
"""Return first and last day of a month."""
first = date(year, month, 1)
if month == 12:
last = date(year + 1, 1, 1) - timedelta(days=1)
else:
last = date(year, month + 1, 1) - timedelta(days=1)
return first, last
def now_for_user(tz_str: str = "UTC") -> datetime:
"""Return the current datetime in the user's timezone (tz-aware)."""
try:
tz = ZoneInfo(tz_str)
except (KeyError, Exception):
tz = ZoneInfo("UTC")
return datetime.now(tz)
def day_start_utc(tz_str: str = "UTC") -> datetime:
"""Return the start of today (midnight) in the user's timezone, converted to UTC.
Useful for database queries that store timestamps in UTC but need
to filter by the user's local day.
"""
user_today = today_for_user(tz_str)
try:
tz = ZoneInfo(tz_str)
except (KeyError, Exception):
tz = ZoneInfo("UTC")
local_midnight = datetime(user_today.year, user_today.month, user_today.day, tzinfo=tz)
return local_midnight.astimezone(ZoneInfo("UTC"))

View file

@ -6,7 +6,7 @@
<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>
<title>Diligence</title>
</head>
<body>
<div id="root"></div>

1865
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -30,7 +30,7 @@ export const api = {
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 }) }),
request('/auth/register', { method: 'POST', body: JSON.stringify({ username, password, display_name, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone }) }),
me: () => request('/auth/me'),
// Onboarding

View file

@ -36,12 +36,12 @@ export default function Login() {
<div style={{ width: '100%', maxWidth: '380px' }}>
{/* Brand */}
<div style={{ textAlign: 'center', marginBottom: '32px', color: '#fff' }}>
<div style={{ fontSize: '3.2rem', marginBottom: '8px', filter: 'drop-shadow(0 4px 12px rgba(0,0,0,0.2))' }}>🔥</div>
<div style={{ fontSize: '3.2rem', marginBottom: '8px', filter: 'drop-shadow(0 4px 12px rgba(0,0,0,0.2))' }}>💪</div>
<h1 style={{ fontFamily: 'var(--font-display)', fontSize: '2.2rem', fontWeight: 900, letterSpacing: '-0.03em' }}>
Fitness Rewards
Diligence
</h1>
<p style={{ opacity: 0.8, marginTop: '4px', fontSize: '0.95rem', fontWeight: 500 }}>
Earn your rewards. Every single day.
Your fitness. Your data. Your rewards.
</p>
</div>