Merge optimize/docker-and-performance into main

Docker: .dockerignore, entrypoint.sh (PID 1), pinned images, network
segmentation, resource limits, npm ci, frontend healthcheck, DRY proxy config.

Backend: single JOIN auth, async Argon2id, settings cache, batch reorder,
bulk ntfy dedup, permission JOIN, cascade batch, collapsed admin COUNTs,
connection pool tuning, composite indexes (calendar_members, ntfy_sent).

Frontend: lazy-loaded routes, vendor chunk splitting, date-scoped calendar
events (87% payload reduction), 30s poll interval, clock isolation,
conditional shared-calendar polling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-03-13 01:15:40 +08:00
commit bb5cbfa4b3
23 changed files with 482 additions and 218 deletions

44
backend/.dockerignore Normal file
View File

@ -0,0 +1,44 @@
# Version control
.git
.gitignore
# Python artifacts
__pycache__
*.pyc
*.pyo
*.egg-info
dist
build
.eggs
# Virtual environments
.venv
venv
env
# IDE
.vscode
.idea
# Environment files — never bake secrets into the image
.env
.env.*
# Tests
tests
pytest.ini
.pytest_cache
.coverage
htmlcov
# Documentation
README.md
CHANGELOG.md
LICENSE
# Dev scripts
start.sh
# Docker files (no need to copy into the image)
Dockerfile
docker-compose*.yaml

View File

@ -1,5 +1,5 @@
# ── Build stage: compile C extensions ──────────────────────────────────
FROM python:3.12-slim AS builder
FROM python:3.12.9-slim-bookworm AS builder
WORKDIR /build
@ -11,24 +11,25 @@ COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# ── Runtime stage: lean production image ───────────────────────────────
FROM python:3.12-slim
FROM python:3.12.9-slim-bookworm
# Create non-root user first, then copy with correct ownership (DW-2)
RUN useradd -m -u 1000 appuser
WORKDIR /app
# Copy pre-built Python packages from builder
COPY --from=builder /install /usr/local
# Copy application code
COPY . .
# Copy application code with correct ownership — avoids redundant chown layer
COPY --chown=appuser:appuser . .
# Make entrypoint executable
RUN chmod +x entrypoint.sh
# Create non-root user
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
USER appuser
EXPOSE 8000
# Run migrations and start server
# --no-server-header: suppresses uvicorn version disclosure
# --proxy-headers: reads X-Forwarded-Proto/For from reverse proxy so redirects use correct scheme
# --forwarded-allow-ips '*': trusts proxy headers from any IP (nginx is on Docker bridge network)
CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --no-server-header --proxy-headers --forwarded-allow-ips '*'"]
# Use entrypoint with exec so uvicorn runs as PID 1 and receives signals (DW-5)
ENTRYPOINT ["./entrypoint.sh"]

View File

@ -0,0 +1,29 @@
"""Add composite indexes for calendar_members and ntfy_sent
Revision ID: 053
Revises: 052
"""
from alembic import op
revision = "053"
down_revision = "052"
def upgrade():
# AW-1: Hot query polled every 5s uses (user_id, status) together
op.create_index(
"ix_calendar_members_user_id_status",
"calendar_members",
["user_id", "status"],
)
# AS-6: Dedup lookup in notification dispatch uses (user_id, sent_at)
op.create_index(
"ix_ntfy_sent_user_id_sent_at",
"ntfy_sent",
["user_id", "sent_at"],
)
def downgrade():
op.drop_index("ix_ntfy_sent_user_id_sent_at", table_name="ntfy_sent")
op.drop_index("ix_calendar_members_user_id_status", table_name="calendar_members")

View File

@ -2,11 +2,15 @@ from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sess
from sqlalchemy.orm import declarative_base
from app.config import settings
# Create async engine
# Create async engine with tuned pool (AW-7)
engine = create_async_engine(
settings.DATABASE_URL,
echo=False,
future=True
future=True,
pool_size=10,
max_overflow=5,
pool_pre_ping=True,
pool_recycle=1800,
)
# Create async session factory

View File

@ -56,8 +56,8 @@ async def _get_sent_keys(db: AsyncSession, user_id: int) -> set[str]:
async def _mark_sent(db: AsyncSession, key: str, user_id: int) -> None:
"""Stage a sent record — caller must commit (AW-4: bulk commit per user)."""
db.add(NtfySent(notification_key=key, user_id=user_id))
await db.commit()
# ── Dispatch functions ────────────────────────────────────────────────────────
@ -239,14 +239,20 @@ async def _dispatch_for_user(db: AsyncSession, settings: Settings, now: datetime
# Batch-fetch all sent keys once per user instead of one query per entity
sent_keys = await _get_sent_keys(db, settings.user_id)
# AW-4: Commit after each category to preserve dedup records if a later
# category fails (prevents re-sending already-sent notifications)
if settings.ntfy_reminders_enabled:
await _dispatch_reminders(db, settings, now, sent_keys)
await db.commit()
if settings.ntfy_events_enabled:
await _dispatch_events(db, settings, now, sent_keys)
await db.commit()
if settings.ntfy_todos_enabled:
await _dispatch_todos(db, settings, now.date(), sent_keys)
await db.commit()
if settings.ntfy_projects_enabled:
await _dispatch_projects(db, settings, now.date(), sent_keys)
await db.commit()
async def _purge_old_sent_records(db: AsyncSession) -> None:

View File

@ -51,7 +51,7 @@ from app.schemas.admin import (
UserListResponse,
)
from app.services.audit import get_client_ip, log_audit_event
from app.services.auth import hash_password
from app.services.auth import ahash_password
# ---------------------------------------------------------------------------
# Router — all endpoints inherit require_admin
@ -225,7 +225,7 @@ async def create_user(
new_user = User(
username=data.username,
umbral_name=data.username,
password_hash=hash_password(data.password),
password_hash=await ahash_password(data.password),
role=data.role,
email=email,
first_name=data.first_name,
@ -341,7 +341,7 @@ async def reset_user_password(
raise HTTPException(status_code=404, detail="User not found")
temp_password = secrets.token_urlsafe(16)
user.password_hash = hash_password(temp_password)
user.password_hash = await ahash_password(temp_password)
user.must_change_password = True
user.last_password_change_at = datetime.now()
@ -740,18 +740,18 @@ async def admin_dashboard(
_actor: User = Depends(get_current_user),
):
"""Aggregate stats for the admin portal dashboard."""
total_users = await db.scalar(
sa.select(sa.func.count()).select_from(User)
)
active_users = await db.scalar(
sa.select(sa.func.count()).select_from(User).where(User.is_active == True)
)
admin_count = await db.scalar(
sa.select(sa.func.count()).select_from(User).where(User.role == "admin")
)
totp_count = await db.scalar(
sa.select(sa.func.count()).select_from(User).where(User.totp_enabled == True)
# AW-6: Single conditional aggregation instead of 5 separate COUNT queries
user_stats = await db.execute(
sa.select(
sa.func.count().label("total"),
sa.func.count().filter(User.is_active == True).label("active"),
sa.func.count().filter(User.role == "admin").label("admins"),
sa.func.count().filter(User.totp_enabled == True).label("totp"),
).select_from(User)
)
row = user_stats.one()
total_users, active_users, admin_count, totp_count = row.tuple()
active_sessions = await db.scalar(
sa.select(sa.func.count()).select_from(UserSession).where(
UserSession.revoked == False,

View File

@ -37,6 +37,9 @@ from app.schemas.auth import (
ProfileUpdate, ProfileResponse,
)
from app.services.auth import (
ahash_password,
averify_password,
averify_password_with_upgrade,
hash_password,
verify_password,
verify_password_with_upgrade,
@ -101,25 +104,22 @@ async def get_current_user(
if user_id is None or session_id is None:
raise HTTPException(status_code=401, detail="Malformed session token")
# Verify session is active in DB (covers revocation + expiry)
session_result = await db.execute(
select(UserSession).where(
# AC-1: Single JOIN query for session + user (was 2 sequential queries)
result = await db.execute(
select(UserSession, User)
.join(User, UserSession.user_id == User.id)
.where(
UserSession.id == session_id,
UserSession.user_id == user_id,
UserSession.revoked == False,
UserSession.expires_at > datetime.now(),
User.is_active == True,
)
)
db_session = session_result.scalar_one_or_none()
if not db_session:
raise HTTPException(status_code=401, detail="Session has been revoked or expired")
user_result = await db.execute(
select(User).where(User.id == user_id, User.is_active == True)
)
user = user_result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=401, detail="User not found or inactive")
row = result.one_or_none()
if not row:
raise HTTPException(status_code=401, detail="Session expired or user inactive")
db_session, user = row.tuple()
# L-03: Sliding window renewal — extend session if >1 day has elapsed since
# last renewal (i.e. remaining time < SESSION_MAX_AGE_DAYS - 1 day).
@ -149,19 +149,26 @@ async def get_current_user(
async def get_current_settings(
request: Request,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Settings:
"""
Convenience dependency for routers that need Settings access.
Always chain after get_current_user never use standalone.
AC-3: Cache in request.state so multiple dependencies don't re-query.
"""
cached = getattr(request.state, "settings", None)
if cached is not None:
return cached
result = await db.execute(
select(Settings).where(Settings.user_id == current_user.id)
)
settings_obj = result.scalar_one_or_none()
if not settings_obj:
raise HTTPException(status_code=500, detail="Settings not found for user")
request.state.settings = settings_obj
return settings_obj
@ -299,7 +306,7 @@ async def setup(
if user_count.scalar_one() > 0:
raise HTTPException(status_code=400, detail="Setup already completed")
password_hash = hash_password(data.password)
password_hash = await ahash_password(data.password)
new_user = User(
username=data.username,
umbral_name=data.username,
@ -352,12 +359,12 @@ async def login(
if not user:
# M-02: Run Argon2id against a dummy hash so the response time is
# indistinguishable from a wrong-password attempt (prevents username enumeration).
verify_password("x", _DUMMY_HASH)
await averify_password("x", _DUMMY_HASH)
raise HTTPException(status_code=401, detail="Invalid username or password")
# M-02: Run password verification BEFORE lockout check so Argon2id always
# executes — prevents distinguishing "locked" from "wrong password" via timing.
valid, new_hash = verify_password_with_upgrade(data.password, user.password_hash)
valid, new_hash = await averify_password_with_upgrade(data.password, user.password_hash)
await _check_account_lockout(user)
@ -465,7 +472,7 @@ async def register(
if existing_email.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Registration could not be completed. Please check your details and try again.")
password_hash = hash_password(data.password)
password_hash = await ahash_password(data.password)
# SEC-01: Explicit field assignment — never **data.model_dump()
new_user = User(
username=data.username,
@ -630,7 +637,7 @@ async def verify_password(
"""
await _check_account_lockout(current_user)
valid, new_hash = verify_password_with_upgrade(data.password, current_user.password_hash)
valid, new_hash = await averify_password_with_upgrade(data.password, current_user.password_hash)
if not valid:
await _record_failed_login(db, current_user)
raise HTTPException(status_code=401, detail="Invalid password")
@ -656,7 +663,7 @@ async def change_password(
"""Change the current user's password. Requires old password verification."""
await _check_account_lockout(current_user)
valid, _ = verify_password_with_upgrade(data.old_password, current_user.password_hash)
valid, _ = await averify_password_with_upgrade(data.old_password, current_user.password_hash)
if not valid:
await _record_failed_login(db, current_user)
raise HTTPException(status_code=401, detail="Invalid current password")
@ -664,7 +671,7 @@ async def change_password(
if data.new_password == data.old_password:
raise HTTPException(status_code=400, detail="New password must be different from your current password")
current_user.password_hash = hash_password(data.new_password)
current_user.password_hash = await ahash_password(data.new_password)
current_user.last_password_change_at = datetime.now()
# Clear forced password change flag if set (SEC-12)

View File

@ -4,7 +4,7 @@ from sqlalchemy import select
from sqlalchemy.orm import selectinload
from typing import List, Optional
from datetime import date, timedelta
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict
from app.database import get_db
from app.models.project import Project
@ -20,6 +20,7 @@ router = APIRouter()
class ReorderItem(BaseModel):
model_config = ConfigDict(extra="forbid")
id: int
sort_order: int
@ -294,16 +295,20 @@ async def reorder_tasks(
if not project:
raise HTTPException(status_code=404, detail="Project not found")
for item in items:
task_result = await db.execute(
select(ProjectTask).where(
ProjectTask.id == item.id,
ProjectTask.project_id == project_id
)
# AC-4: Batch-fetch all tasks in one query instead of N sequential queries
task_ids = [item.id for item in items]
task_result = await db.execute(
select(ProjectTask).where(
ProjectTask.id.in_(task_ids),
ProjectTask.project_id == project_id,
)
task = task_result.scalar_one_or_none()
if task:
task.sort_order = item.sort_order
)
tasks_by_id = {t.id: t for t in task_result.scalars().all()}
order_map = {item.id: item.sort_order for item in items}
for task_id, task in tasks_by_id.items():
if task_id in order_map:
task.sort_order = order_map[task_id]
await db.commit()

View File

@ -17,6 +17,7 @@ Security:
- Failed TOTP attempts increment user.failed_login_count (shared lockout counter)
- totp-verify uses mfa_token (not session cookie) user is not yet authenticated
"""
import asyncio
import uuid
import secrets
import logging
@ -37,8 +38,7 @@ from app.models.backup_code import BackupCode
from app.routers.auth import get_current_user, _set_session_cookie
from app.services.audit import get_client_ip
from app.services.auth import (
verify_password_with_upgrade,
hash_password,
averify_password_with_upgrade,
verify_mfa_token,
verify_mfa_enforce_token,
create_session_token,
@ -117,8 +117,10 @@ class EnforceConfirmRequest(BaseModel):
async def _store_backup_codes(db: AsyncSession, user_id: int, plaintext_codes: list[str]) -> None:
"""Hash and insert backup codes for the given user."""
# AC-2: Run Argon2id hashing in executor to avoid blocking event loop
loop = asyncio.get_running_loop()
for code in plaintext_codes:
code_hash = _ph.hash(code)
code_hash = await loop.run_in_executor(None, _ph.hash, code)
db.add(BackupCode(user_id=user_id, code_hash=code_hash))
await db.commit()
@ -145,9 +147,12 @@ async def _verify_backup_code(
)
unused_codes = result.scalars().all()
# AC-2: Run Argon2id verification in executor to avoid blocking event loop
loop = asyncio.get_running_loop()
for record in unused_codes:
try:
if _ph.verify(record.code_hash, submitted_code):
matched = await loop.run_in_executor(None, _ph.verify, record.code_hash, submitted_code)
if matched:
record.used_at = datetime.now()
await db.commit()
return True
@ -355,7 +360,8 @@ async def totp_disable(
raise HTTPException(status_code=400, detail="TOTP is not enabled")
# Verify password (handles bcrypt→Argon2id upgrade transparently)
valid, new_hash = verify_password_with_upgrade(data.password, current_user.password_hash)
# AC-2: async wrapper to avoid blocking event loop
valid, new_hash = await averify_password_with_upgrade(data.password, current_user.password_hash)
if not valid:
raise HTTPException(status_code=401, detail="Invalid password")
@ -391,7 +397,8 @@ async def regenerate_backup_codes(
if not current_user.totp_enabled:
raise HTTPException(status_code=400, detail="TOTP is not enabled")
valid, new_hash = verify_password_with_upgrade(data.password, current_user.password_hash)
# AC-2: async wrapper to avoid blocking event loop
valid, new_hash = await averify_password_with_upgrade(data.password, current_user.password_hash)
if not valid:
raise HTTPException(status_code=401, detail="Invalid password")

View File

@ -6,6 +6,8 @@ Password strategy:
- Legacy bcrypt hashes (migrated from PIN auth): accepted on login, immediately
rehashed to Argon2id on first successful use.
"""
import asyncio
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError, VerificationError, InvalidHashError
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
@ -76,6 +78,28 @@ def verify_password_with_upgrade(password: str, hashed: str) -> tuple[bool, str
return valid, new_hash
# ---------------------------------------------------------------------------
# Async wrappers — run CPU-bound Argon2id ops in a thread pool (AC-2/S-01)
# ---------------------------------------------------------------------------
async def ahash_password(password: str) -> str:
"""Async wrapper for hash_password — runs Argon2id in executor."""
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, hash_password, password)
async def averify_password(password: str, hashed: str) -> bool:
"""Async wrapper for verify_password — runs Argon2id in executor."""
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, verify_password, password, hashed)
async def averify_password_with_upgrade(password: str, hashed: str) -> tuple[bool, str | None]:
"""Async wrapper for verify_password_with_upgrade — runs Argon2id in executor."""
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, verify_password_with_upgrade, password, hashed)
# ---------------------------------------------------------------------------
# Session tokens
# ---------------------------------------------------------------------------

View File

@ -7,7 +7,7 @@ import logging
from datetime import datetime, timedelta
from fastapi import HTTPException
from sqlalchemy import delete, select, text
from sqlalchemy import delete, select, text, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.calendar import Calendar
@ -24,25 +24,29 @@ async def get_user_permission(db: AsyncSession, calendar_id: int, user_id: int)
"""
Returns "owner" if the user owns the calendar, the permission string
if they are an accepted member, or None if they have no access.
"""
cal = await db.execute(
select(Calendar).where(Calendar.id == calendar_id)
)
calendar = cal.scalar_one_or_none()
if not calendar:
return None
if calendar.user_id == user_id:
return "owner"
member = await db.execute(
select(CalendarMember).where(
CalendarMember.calendar_id == calendar_id,
CalendarMember.user_id == user_id,
CalendarMember.status == "accepted",
AW-5: Single query with LEFT JOIN instead of 2 sequential queries.
"""
result = await db.execute(
select(
Calendar.user_id,
CalendarMember.permission,
)
.outerjoin(
CalendarMember,
(CalendarMember.calendar_id == Calendar.id)
& (CalendarMember.user_id == user_id)
& (CalendarMember.status == "accepted"),
)
.where(Calendar.id == calendar_id)
)
row = member.scalar_one_or_none()
return row.permission if row else None
row = result.one_or_none()
if not row:
return None
owner_id, member_permission = row.tuple()
if owner_id == user_id:
return "owner"
return member_permission
async def require_permission(
@ -202,16 +206,22 @@ async def cascade_on_disconnect(db: AsyncSession, user_a_id: int, user_b_id: int
{"user_id": user_a_id, "cal_ids": b_cal_ids},
)
# Reset is_shared on calendars with no remaining members
# AC-5: Single aggregation query instead of N per-calendar checks
all_cal_ids = a_cal_ids + b_cal_ids
for cal_id in all_cal_ids:
remaining = await db.execute(
select(CalendarMember.id).where(CalendarMember.calendar_id == cal_id).limit(1)
if all_cal_ids:
# Find which calendars still have members
has_members_result = await db.execute(
select(CalendarMember.calendar_id)
.where(CalendarMember.calendar_id.in_(all_cal_ids))
.group_by(CalendarMember.calendar_id)
)
if not remaining.scalar_one_or_none():
cal_result = await db.execute(
select(Calendar).where(Calendar.id == cal_id)
cals_with_members = {row[0] for row in has_members_result.all()}
# Reset is_shared on calendars with no remaining members
empty_cal_ids = [cid for cid in all_cal_ids if cid not in cals_with_members]
if empty_cal_ids:
await db.execute(
update(Calendar)
.where(Calendar.id.in_(empty_cal_ids))
.values(is_shared=False)
)
cal = cal_result.scalar_one_or_none()
if cal:
cal.is_shared = False

13
backend/entrypoint.sh Normal file
View File

@ -0,0 +1,13 @@
#!/bin/sh
set -e
echo "Running database migrations..."
alembic upgrade head
echo "Starting uvicorn..."
exec uvicorn app.main:app \
--host 0.0.0.0 \
--port 8000 \
--no-server-header \
--proxy-headers \
--forwarded-allow-ips '*'

View File

@ -1,9 +0,0 @@
#!/bin/bash
# Run database migrations
echo "Running database migrations..."
alembic upgrade head
# Start the FastAPI application
echo "Starting FastAPI application..."
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload

View File

@ -5,11 +5,18 @@ services:
env_file: .env
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- backend_net
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $POSTGRES_USER -d $POSTGRES_DB"]
interval: 5s
timeout: 5s
retries: 5
deploy:
resources:
limits:
memory: 512M
cpus: "1.0"
backend:
build: ./backend
@ -18,11 +25,20 @@ services:
depends_on:
db:
condition: service_healthy
networks:
- backend_net
- frontend_net
healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')\""]
interval: 10s
timeout: 5s
retries: 3
start_period: 30s
deploy:
resources:
limits:
memory: 512M
cpus: "1.0"
frontend:
build: ./frontend
@ -32,6 +48,24 @@ services:
depends_on:
backend:
condition: service_healthy
networks:
- frontend_net
healthcheck:
test: ["CMD", "wget", "--spider", "--quiet", "http://localhost:8080/"]
interval: 15s
timeout: 5s
retries: 3
deploy:
resources:
limits:
memory: 128M
cpus: "0.5"
volumes:
postgres_data:
networks:
backend_net:
driver: bridge
frontend_net:
driver: bridge

25
frontend/.dockerignore Normal file
View File

@ -0,0 +1,25 @@
# Dependencies — rebuilt inside the container from lockfile
node_modules
# Build output — rebuilt inside the container
dist
# Version control
.git
.gitignore
# Environment files
.env
.env.*
# IDE
.vscode
.idea
# Documentation
*.md
LICENSE
# Docker files
Dockerfile
docker-compose*.yaml

View File

@ -1,13 +1,13 @@
# Build stage
FROM node:20-alpine AS build
FROM node:20.18-alpine AS build
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm install
# Install dependencies from lockfile (DW-3)
RUN npm ci
# Copy source files
COPY . .
@ -16,7 +16,7 @@ COPY . .
RUN npm run build
# Production stage — unprivileged nginx (runs as non-root, listens on 8080)
FROM nginxinc/nginx-unprivileged:alpine
FROM nginxinc/nginx-unprivileged:1.27-alpine
# Copy built files from build stage
COPY --from=build /app/dist /usr/share/nginx/html

View File

@ -41,7 +41,7 @@ server {
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json image/svg+xml;
# Block dotfiles (except .well-known for ACME/Let's Encrypt) (PT-04)
location ~ /\.(?!well-known) {
@ -122,28 +122,12 @@ server {
include /etc/nginx/proxy-params.conf;
}
# API proxy
# API proxy (catch-all for non-rate-limited endpoints)
location /api {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
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 $forwarded_proto;
proxy_cache_bypass $http_upgrade;
# PT-L01: Prevent browser caching of authenticated API responses
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
# Security headers (must be repeated nginx add_header in a location block
# overrides server-level add_header directives, so all headers must be explicit)
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self';" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always;
include /etc/nginx/proxy-params.conf;
}
# SPA fallback - serve index.html for all routes

View File

@ -4,3 +4,13 @@ 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 $forwarded_proto;
# Security headers (repeated per location — nginx add_header in a location block
# overrides server-level directives, so all headers must be explicit)
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self';" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always;

View File

@ -3,19 +3,24 @@ import { Routes, Route, Navigate } from 'react-router-dom';
import { useAuth } from '@/hooks/useAuth';
import LockScreen from '@/components/auth/LockScreen';
import AppLayout from '@/components/layout/AppLayout';
import DashboardPage from '@/components/dashboard/DashboardPage';
import TodosPage from '@/components/todos/TodosPage';
import CalendarPage from '@/components/calendar/CalendarPage';
import RemindersPage from '@/components/reminders/RemindersPage';
import ProjectsPage from '@/components/projects/ProjectsPage';
import ProjectDetail from '@/components/projects/ProjectDetail';
import PeoplePage from '@/components/people/PeoplePage';
import LocationsPage from '@/components/locations/LocationsPage';
import SettingsPage from '@/components/settings/SettingsPage';
import NotificationsPage from '@/components/notifications/NotificationsPage';
// AS-2: Lazy-load all route components to reduce initial bundle parse time
const DashboardPage = lazy(() => import('@/components/dashboard/DashboardPage'));
const TodosPage = lazy(() => import('@/components/todos/TodosPage'));
const CalendarPage = lazy(() => import('@/components/calendar/CalendarPage'));
const RemindersPage = lazy(() => import('@/components/reminders/RemindersPage'));
const ProjectsPage = lazy(() => import('@/components/projects/ProjectsPage'));
const ProjectDetail = lazy(() => import('@/components/projects/ProjectDetail'));
const PeoplePage = lazy(() => import('@/components/people/PeoplePage'));
const LocationsPage = lazy(() => import('@/components/locations/LocationsPage'));
const SettingsPage = lazy(() => import('@/components/settings/SettingsPage'));
const NotificationsPage = lazy(() => import('@/components/notifications/NotificationsPage'));
const AdminPortal = lazy(() => import('@/components/admin/AdminPortal'));
const RouteFallback = () => (
<div className="flex h-full items-center justify-center text-muted-foreground">Loading...</div>
);
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { authStatus, isLoading } = useAuth();
@ -57,21 +62,21 @@ function App() {
}
>
<Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<DashboardPage />} />
<Route path="todos" element={<TodosPage />} />
<Route path="calendar" element={<CalendarPage />} />
<Route path="reminders" element={<RemindersPage />} />
<Route path="projects" element={<ProjectsPage />} />
<Route path="projects/:id" element={<ProjectDetail />} />
<Route path="people" element={<PeoplePage />} />
<Route path="locations" element={<LocationsPage />} />
<Route path="notifications" element={<NotificationsPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="dashboard" element={<Suspense fallback={<RouteFallback />}><DashboardPage /></Suspense>} />
<Route path="todos" element={<Suspense fallback={<RouteFallback />}><TodosPage /></Suspense>} />
<Route path="calendar" element={<Suspense fallback={<RouteFallback />}><CalendarPage /></Suspense>} />
<Route path="reminders" element={<Suspense fallback={<RouteFallback />}><RemindersPage /></Suspense>} />
<Route path="projects" element={<Suspense fallback={<RouteFallback />}><ProjectsPage /></Suspense>} />
<Route path="projects/:id" element={<Suspense fallback={<RouteFallback />}><ProjectDetail /></Suspense>} />
<Route path="people" element={<Suspense fallback={<RouteFallback />}><PeoplePage /></Suspense>} />
<Route path="locations" element={<Suspense fallback={<RouteFallback />}><LocationsPage /></Suspense>} />
<Route path="notifications" element={<Suspense fallback={<RouteFallback />}><NotificationsPage /></Suspense>} />
<Route path="settings" element={<Suspense fallback={<RouteFallback />}><SettingsPage /></Suspense>} />
<Route
path="admin/*"
element={
<AdminRoute>
<Suspense fallback={<div className="flex h-full items-center justify-center text-muted-foreground">Loading...</div>}>
<Suspense fallback={<RouteFallback />}>
<AdminPortal />
</Suspense>
</AdminRoute>

View File

@ -1,6 +1,7 @@
import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
import { useMediaQuery, DESKTOP } from '@/hooks/useMediaQuery';
import { useLocation } from 'react-router-dom';
import { format } from 'date-fns';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import FullCalendar from '@fullcalendar/react';
@ -205,13 +206,28 @@ export default function CalendarPage() {
return () => el.removeEventListener('wheel', handleWheel);
}, []);
// AW-2: Track visible date range for scoped event fetching
// W-02 fix: Initialize from current month to avoid unscoped first fetch
const [visibleRange, setVisibleRange] = useState<{ start: string; end: string }>(() => {
const now = new Date();
const y = now.getFullYear();
const m = now.getMonth();
// FullCalendar month view typically fetches prev month to next month
const start = format(new Date(y, m - 1, 1), 'yyyy-MM-dd');
const end = format(new Date(y, m + 2, 0), 'yyyy-MM-dd');
return { start, end };
});
const { data: events = [] } = useQuery({
queryKey: ['calendar-events'],
queryKey: ['calendar-events', visibleRange.start, visibleRange.end],
queryFn: async () => {
const { data } = await api.get<CalendarEvent[]>('/events');
const { data } = await api.get<CalendarEvent[]>('/events', {
params: { start: visibleRange.start, end: visibleRange.end },
});
return data;
},
refetchInterval: 5_000,
// AW-3: Reduce from 5s to 30s — personal organiser doesn't need 12 calls/min
refetchInterval: 30_000,
});
const selectedEvent = useMemo(
@ -261,12 +277,15 @@ export default function CalendarPage() {
allDay: boolean,
revert: () => void,
) => {
queryClient.setQueryData<CalendarEvent[]>(['calendar-events'], (old) =>
old?.map((e) =>
e.id === id
? { ...e, start_datetime: start, end_datetime: end, all_day: allDay }
: e,
),
// C-01 fix: match active query key which includes date range
queryClient.setQueryData<CalendarEvent[]>(
['calendar-events', visibleRange.start, visibleRange.end],
(old) =>
old?.map((e) =>
e.id === id
? { ...e, start_datetime: start, end_datetime: end, all_day: allDay }
: e,
),
);
eventMutation.mutate({ id, start, end, allDay, revert });
};
@ -467,6 +486,13 @@ export default function CalendarPage() {
const handleDatesSet = (arg: DatesSetArg) => {
setCalendarTitle(arg.view.title);
setCurrentView(arg.view.type as CalendarView);
// AW-2: Capture visible range for scoped event fetching
// C-02 fix: use format() not toISOString() to avoid UTC date shift
const start = format(arg.start, 'yyyy-MM-dd');
const end = format(arg.end, 'yyyy-MM-dd');
setVisibleRange((prev) =>
prev.start === start && prev.end === end ? prev : { start, end }
);
};
const navigatePrev = () => calendarRef.current?.getApi().prev();

View File

@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useState, useEffect, useRef, useCallback, memo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { format } from 'date-fns';
@ -34,6 +34,75 @@ function getGreeting(name?: string): string {
return `Good night${suffix}`;
}
// AS-3: Isolated clock component — only this re-renders every minute,
// not all 8 dashboard widgets.
const ClockDisplay = memo(function ClockDisplay({ dataUpdatedAt, onRefresh }: {
dataUpdatedAt?: number;
onRefresh: () => void;
}) {
const [now, setNow] = useState(() => new Date());
useEffect(() => {
let intervalId: ReturnType<typeof setInterval>;
let timeoutId: ReturnType<typeof setTimeout>;
function startClock() {
clearTimeout(timeoutId);
clearInterval(intervalId);
setNow(new Date());
const msUntilNextMinute = (60 - new Date().getSeconds()) * 1000 - new Date().getMilliseconds();
timeoutId = setTimeout(() => {
setNow(new Date());
intervalId = setInterval(() => setNow(new Date()), 60_000);
}, msUntilNextMinute);
}
startClock();
function handleVisibility() {
if (document.visibilityState === 'visible') startClock();
}
document.addEventListener('visibilitychange', handleVisibility);
return () => {
clearTimeout(timeoutId);
clearInterval(intervalId);
document.removeEventListener('visibilitychange', handleVisibility);
};
}, []);
const updatedAgo = dataUpdatedAt
? (() => {
const mins = Math.floor((now.getTime() - dataUpdatedAt) / 60_000);
if (mins < 1) return 'just now';
if (mins === 1) return '1 min ago';
return `${mins} min ago`;
})()
: null;
return (
<div className="flex items-center gap-2 mt-1">
<p className="text-muted-foreground text-sm">
<span className="tabular-nums">{format(now, 'h:mm a')}</span>
<span className="mx-1.5 text-muted-foreground/30">|</span>
{format(now, 'EEEE, MMMM d, yyyy')}
</p>
{updatedAgo && (
<>
<span className="text-muted-foreground/40 text-xs">·</span>
<span className="text-muted-foreground/60 text-xs">Updated {updatedAgo}</span>
<button
onClick={onRefresh}
className="p-0.5 rounded text-muted-foreground/40 hover:text-accent transition-colors"
title="Refresh dashboard"
aria-label="Refresh dashboard"
>
<RefreshCw className="h-3 w-3" />
</button>
</>
)}
</div>
);
});
export default function DashboardPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
@ -42,38 +111,7 @@ export default function DashboardPage() {
const [quickAddType, setQuickAddType] = useState<'event' | 'todo' | 'reminder' | null>(null);
const [dropdownOpen, setDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const [clockNow, setClockNow] = useState(() => new Date());
// Live clock — synced to the minute boundary, re-syncs after tab sleep/resume
useEffect(() => {
let intervalId: ReturnType<typeof setInterval>;
let timeoutId: ReturnType<typeof setTimeout>;
function startClock() {
clearTimeout(timeoutId);
clearInterval(intervalId);
setClockNow(new Date());
const msUntilNextMinute = (60 - new Date().getSeconds()) * 1000 - new Date().getMilliseconds();
timeoutId = setTimeout(() => {
setClockNow(new Date());
intervalId = setInterval(() => setClockNow(new Date()), 60_000);
}, msUntilNextMinute);
}
startClock();
// Re-sync when tab becomes visible again (after sleep/background throttle)
function handleVisibility() {
if (document.visibilityState === 'visible') startClock();
}
document.addEventListener('visibilitychange', handleVisibility);
return () => {
clearTimeout(timeoutId);
clearInterval(intervalId);
document.removeEventListener('visibilitychange', handleVisibility);
};
}, []);
// Clock state moved to <ClockDisplay /> (AS-3)
// Click outside to close dropdown
useEffect(() => {
@ -191,15 +229,6 @@ export default function DashboardPage() {
);
}
const updatedAgo = dataUpdatedAt
? (() => {
const mins = Math.floor((clockNow.getTime() - dataUpdatedAt) / 60_000);
if (mins < 1) return 'just now';
if (mins === 1) return '1 min ago';
return `${mins} min ago`;
})()
: null;
return (
<div className="flex flex-col h-full">
{/* Header — greeting + date + quick add */}
@ -208,27 +237,7 @@ export default function DashboardPage() {
<h1 className="font-heading text-3xl font-bold tracking-tight animate-fade-in">
{getGreeting(settings?.preferred_name || undefined)}
</h1>
<div className="flex items-center gap-2 mt-1">
<p className="text-muted-foreground text-sm">
<span className="tabular-nums">{format(clockNow, 'h:mm a')}</span>
<span className="mx-1.5 text-muted-foreground/30">|</span>
{format(clockNow, 'EEEE, MMMM d, yyyy')}
</p>
{updatedAgo && (
<>
<span className="text-muted-foreground/40 text-xs">·</span>
<span className="text-muted-foreground/60 text-xs">Updated {updatedAgo}</span>
<button
onClick={handleRefresh}
className="p-0.5 rounded text-muted-foreground/40 hover:text-accent transition-colors"
title="Refresh dashboard"
aria-label="Refresh dashboard"
>
<RefreshCw className="h-3 w-3" />
</button>
</>
)}
</div>
<ClockDisplay dataUpdatedAt={dataUpdatedAt} onRefresh={handleRefresh} />
</div>
<div className="relative" ref={dropdownRef}>
<Button

View File

@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import api from '@/lib/api';
import type { Calendar, SharedCalendarMembership } from '@/types';
@ -16,16 +16,27 @@ export function useCalendars({ pollingEnabled = false }: UseCalendarsOptions = {
},
});
// AS-4: Gate shared-calendar polling on whether user participates in sharing.
// Saves ~12 API calls/min for personal-only users.
// Uses useState (not useRef) so changes trigger a re-render and the
// refetchInterval picks up the new value immediately.
const [hasSharing, setHasSharing] = useState(false);
const ownsShared = (ownedQuery.data ?? []).some((c) => c.is_shared);
if (ownsShared && !hasSharing) setHasSharing(true);
const sharedQuery = useQuery({
queryKey: ['calendars', 'shared'],
queryFn: async () => {
const { data } = await api.get<SharedCalendarMembership[]>('/shared-calendars');
return data;
},
refetchInterval: pollingEnabled ? 5_000 : false,
refetchInterval: pollingEnabled && hasSharing ? 5_000 : false,
staleTime: 3_000,
});
// Also latch if user is a member of others' shared calendars
if ((sharedQuery.data ?? []).length > 0 && !hasSharing) setHasSharing(true);
const allCalendarIds = useMemo(() => {
const owned = (ownedQuery.data ?? []).map((c) => c.id);
const shared = (sharedQuery.data ?? []).map((m) => m.calendar_id);

View File

@ -9,6 +9,25 @@ export default defineConfig({
'@': path.resolve(__dirname, './src'),
},
},
build: {
rollupOptions: {
output: {
// AS-1: Split large dependencies into separate chunks for better caching
manualChunks: {
'vendor-react': ['react', 'react-dom', 'react-router-dom'],
'vendor-query': ['@tanstack/react-query'],
'vendor-fullcalendar': [
'@fullcalendar/react',
'@fullcalendar/core',
'@fullcalendar/daygrid',
'@fullcalendar/timegrid',
'@fullcalendar/interaction',
],
'vendor-ui': ['sonner', 'lucide-react', 'date-fns'],
},
},
},
},
server: {
port: 5173,
proxy: {