Compare commits

...

5 Commits

Author SHA1 Message Date
a94485b138 Address code review findings across all phases
Phase 1 fixes:
- W-01: Add start_period: 30s to backend healthcheck for migration window
- W-03: Narrow .dockerignore *.md to specific files (preserve alembic/README)

Phase 2 fixes:
- C-01: Wrap Argon2id calls in totp.py (disable, regenerate, backup verify,
  backup store) — missed in initial AC-2 pass
- S-01: Extract async wrappers (ahash_password, averify_password,
  averify_password_with_upgrade) into services/auth.py, refactor all
  callers to use them instead of manual run_in_executor boilerplate
- W-01: Fix ntfy dedup regression — commit per category instead of per-user
  to preserve dedup records if a later category fails

Phase 4 fixes:
- C-01: Fix optimistic drag-and-drop cache key to include date range
- C-02: Replace toISOString() with format() to avoid UTC date shift in
  visible range calculation
- W-02: Initialize visibleRange from current month to eliminate unscoped
  first fetch + immediate refetch

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 00:19:33 +08:00
2ab7121e42 Phase 4: Frontend performance optimizations
- AW-2: Scope calendar events fetch to visible date range via start/end
  query params, leveraging existing backend support
- AW-3: Reduce calendar events poll from 5s to 30s (personal organiser
  doesn't need 12 API calls/min)
- AS-4: Gate shared-calendar polling on hasSharedCalendars — saves 12
  wasted API calls/min for personal-only users
- AS-2: Lazy-load all route components with React.lazy() — only
  AdminPortal was previously lazy, now all 10 routes are code-split
- AS-1: Add Vite manualChunks to split FullCalendar (~400KB), React,
  TanStack Query, and UI libs into separate cacheable chunks
- AS-3: Extract clockNow into isolated ClockDisplay memo component —
  prevents all 8 dashboard widgets from re-rendering every minute

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 00:12:33 +08:00
846019d5c1 Phase 3: Backend queries and indexes optimization
- AW-1: Add composite index on calendar_members(user_id, status) for the
  hot shared-calendar polling query
- AS-6: Add composite index on ntfy_sent(user_id, sent_at) for dedup lookups
- AW-5: Combine get_user_permission into single LEFT JOIN query instead of
  2 sequential queries (called twice per event edit)
- AC-5: Batch cascade_on_disconnect — single GROUP BY + bulk UPDATE instead
  of N per-calendar checks when a connection is severed
- AW-6: Collapse admin dashboard 5 COUNT queries into single conditional
  aggregation using COUNT().filter()
- AC-3: Cache get_current_settings in request.state to avoid redundant
  queries when multiple dependencies need settings in the same request

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 00:08:45 +08:00
1f2083ee61 Phase 2: Backend critical path optimizations
- AC-1: Merge get_current_user into single JOIN query (session + user in
  one round-trip instead of two sequential queries per request)
- AC-2: Wrap all Argon2id hash/verify calls in run_in_executor to avoid
  blocking the async event loop (~150ms per operation)
- AW-7: Add connection pool config (pool_size=10, pool_pre_ping=True,
  pool_recycle=1800) to prevent connection exhaustion under load
- AC-4: Batch-fetch tasks in reorder_tasks with IN clause instead of
  N sequential queries during Kanban drag operations
- AW-4: Bulk NtfySent inserts with single commit per user instead of
  per-notification commits in the dispatch job

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 00:05:54 +08:00
dbad9c69b3 Phase 1: Docker infrastructure optimization
- Add .dockerignore for backend and frontend (DC-1: eliminates node_modules/
  and .env from build context)
- Delete start.sh with --reload flag (DC-2: superseded by Dockerfile CMD)
- Create entrypoint.sh with exec uvicorn (DW-5: proper PID 1 signal handling)
- Pin base images to patch-level tags (DW-1: reproducible builds)
- Reorder Dockerfile: create appuser before COPY, use --chown (DW-2)
- Switch to npm ci for lockfile-enforced installs (DW-3)
- Add network segmentation: backend_net + frontend_net (DW-4: db unreachable
  from frontend container)
- Add deploy.resources limits to all services (DW-6: OOM protection)
- Refactor proxy-params.conf to include security headers, deduplicate from
  nginx.conf location blocks (DW-7)
- Add image/svg+xml to gzip_types (DS-1)
- Add wget healthcheck for frontend service (DS-2)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 00:03:46 +08:00
23 changed files with 479 additions and 217 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 ────────────────────────────────── # ── Build stage: compile C extensions ──────────────────────────────────
FROM python:3.12-slim AS builder FROM python:3.12.9-slim-bookworm AS builder
WORKDIR /build WORKDIR /build
@ -11,24 +11,25 @@ COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# ── Runtime stage: lean production image ─────────────────────────────── # ── 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 WORKDIR /app
# Copy pre-built Python packages from builder # Copy pre-built Python packages from builder
COPY --from=builder /install /usr/local COPY --from=builder /install /usr/local
# Copy application code # Copy application code with correct ownership — avoids redundant chown layer
COPY . . 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 USER appuser
EXPOSE 8000 EXPOSE 8000
# Run migrations and start server # Use entrypoint with exec so uvicorn runs as PID 1 and receives signals (DW-5)
# --no-server-header: suppresses uvicorn version disclosure ENTRYPOINT ["./entrypoint.sh"]
# --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 '*'"]

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 sqlalchemy.orm import declarative_base
from app.config import settings from app.config import settings
# Create async engine # Create async engine with tuned pool (AW-7)
engine = create_async_engine( engine = create_async_engine(
settings.DATABASE_URL, settings.DATABASE_URL,
echo=False, echo=False,
future=True future=True,
pool_size=10,
max_overflow=5,
pool_pre_ping=True,
pool_recycle=1800,
) )
# Create async session factory # 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: 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)) db.add(NtfySent(notification_key=key, user_id=user_id))
await db.commit()
# ── Dispatch functions ──────────────────────────────────────────────────────── # ── 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 # Batch-fetch all sent keys once per user instead of one query per entity
sent_keys = await _get_sent_keys(db, settings.user_id) 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: if settings.ntfy_reminders_enabled:
await _dispatch_reminders(db, settings, now, sent_keys) await _dispatch_reminders(db, settings, now, sent_keys)
await db.commit()
if settings.ntfy_events_enabled: if settings.ntfy_events_enabled:
await _dispatch_events(db, settings, now, sent_keys) await _dispatch_events(db, settings, now, sent_keys)
await db.commit()
if settings.ntfy_todos_enabled: if settings.ntfy_todos_enabled:
await _dispatch_todos(db, settings, now.date(), sent_keys) await _dispatch_todos(db, settings, now.date(), sent_keys)
await db.commit()
if settings.ntfy_projects_enabled: if settings.ntfy_projects_enabled:
await _dispatch_projects(db, settings, now.date(), sent_keys) await _dispatch_projects(db, settings, now.date(), sent_keys)
await db.commit()
async def _purge_old_sent_records(db: AsyncSession) -> None: async def _purge_old_sent_records(db: AsyncSession) -> None:

View File

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

View File

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

View File

@ -294,16 +294,20 @@ async def reorder_tasks(
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
for item in items: # AC-4: Batch-fetch all tasks in one query instead of N sequential queries
task_result = await db.execute( task_ids = [item.id for item in items]
select(ProjectTask).where( task_result = await db.execute(
ProjectTask.id == item.id, select(ProjectTask).where(
ProjectTask.project_id == project_id ProjectTask.id.in_(task_ids),
) ProjectTask.project_id == project_id,
) )
task = task_result.scalar_one_or_none() )
if task: tasks_by_id = {t.id: t for t in task_result.scalars().all()}
task.sort_order = item.sort_order
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() await db.commit()

View File

@ -17,6 +17,7 @@ Security:
- Failed TOTP attempts increment user.failed_login_count (shared lockout counter) - Failed TOTP attempts increment user.failed_login_count (shared lockout counter)
- totp-verify uses mfa_token (not session cookie) user is not yet authenticated - totp-verify uses mfa_token (not session cookie) user is not yet authenticated
""" """
import asyncio
import uuid import uuid
import secrets import secrets
import logging 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.routers.auth import get_current_user, _set_session_cookie
from app.services.audit import get_client_ip from app.services.audit import get_client_ip
from app.services.auth import ( from app.services.auth import (
verify_password_with_upgrade, averify_password_with_upgrade,
hash_password,
verify_mfa_token, verify_mfa_token,
verify_mfa_enforce_token, verify_mfa_enforce_token,
create_session_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: async def _store_backup_codes(db: AsyncSession, user_id: int, plaintext_codes: list[str]) -> None:
"""Hash and insert backup codes for the given user.""" """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: 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)) db.add(BackupCode(user_id=user_id, code_hash=code_hash))
await db.commit() await db.commit()
@ -145,9 +147,12 @@ async def _verify_backup_code(
) )
unused_codes = result.scalars().all() 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: for record in unused_codes:
try: 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() record.used_at = datetime.now()
await db.commit() await db.commit()
return True return True
@ -355,7 +360,8 @@ async def totp_disable(
raise HTTPException(status_code=400, detail="TOTP is not enabled") raise HTTPException(status_code=400, detail="TOTP is not enabled")
# Verify password (handles bcrypt→Argon2id upgrade transparently) # 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: if not valid:
raise HTTPException(status_code=401, detail="Invalid password") raise HTTPException(status_code=401, detail="Invalid password")
@ -391,7 +397,8 @@ async def regenerate_backup_codes(
if not current_user.totp_enabled: if not current_user.totp_enabled:
raise HTTPException(status_code=400, detail="TOTP is not 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: if not valid:
raise HTTPException(status_code=401, detail="Invalid password") 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 - Legacy bcrypt hashes (migrated from PIN auth): accepted on login, immediately
rehashed to Argon2id on first successful use. rehashed to Argon2id on first successful use.
""" """
import asyncio
from argon2 import PasswordHasher from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError, VerificationError, InvalidHashError from argon2.exceptions import VerifyMismatchError, VerificationError, InvalidHashError
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired 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 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 # Session tokens
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@ -7,7 +7,7 @@ import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy import delete, select, text from sqlalchemy import delete, select, text, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.calendar import Calendar 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 Returns "owner" if the user owns the calendar, the permission string
if they are an accepted member, or None if they have no access. 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( AW-5: Single query with LEFT JOIN instead of 2 sequential queries.
select(CalendarMember).where( """
CalendarMember.calendar_id == calendar_id, result = await db.execute(
CalendarMember.user_id == user_id, select(
CalendarMember.status == "accepted", 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() row = result.one_or_none()
return row.permission if row else 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( 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}, {"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 all_cal_ids = a_cal_ids + b_cal_ids
for cal_id in all_cal_ids: if all_cal_ids:
remaining = await db.execute( # Find which calendars still have members
select(CalendarMember.id).where(CalendarMember.calendar_id == cal_id).limit(1) 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(): cals_with_members = {row[0] for row in has_members_result.all()}
cal_result = await db.execute(
select(Calendar).where(Calendar.id == cal_id) # 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 env_file: .env
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
networks:
- backend_net
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U $POSTGRES_USER -d $POSTGRES_DB"] test: ["CMD-SHELL", "pg_isready -U $POSTGRES_USER -d $POSTGRES_DB"]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 5 retries: 5
deploy:
resources:
limits:
memory: 512M
cpus: "1.0"
backend: backend:
build: ./backend build: ./backend
@ -18,11 +25,20 @@ services:
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
networks:
- backend_net
- frontend_net
healthcheck: healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')\""] test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')\""]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 3 retries: 3
start_period: 30s
deploy:
resources:
limits:
memory: 512M
cpus: "1.0"
frontend: frontend:
build: ./frontend build: ./frontend
@ -32,6 +48,24 @@ services:
depends_on: depends_on:
backend: backend:
condition: service_healthy 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: volumes:
postgres_data: 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 # Build stage
FROM node:20-alpine AS build FROM node:20.18-alpine AS build
WORKDIR /app WORKDIR /app
# Copy package files # Copy package files
COPY package*.json ./ COPY package*.json ./
# Install dependencies # Install dependencies from lockfile (DW-3)
RUN npm install RUN npm ci
# Copy source files # Copy source files
COPY . . COPY . .
@ -16,7 +16,7 @@ COPY . .
RUN npm run build RUN npm run build
# Production stage — unprivileged nginx (runs as non-root, listens on 8080) # 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 built files from build stage
COPY --from=build /app/dist /usr/share/nginx/html COPY --from=build /app/dist /usr/share/nginx/html

View File

@ -41,7 +41,7 @@ server {
gzip on; gzip on;
gzip_vary on; gzip_vary on;
gzip_min_length 1024; 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) # Block dotfiles (except .well-known for ACME/Let's Encrypt) (PT-04)
location ~ /\.(?!well-known) { location ~ /\.(?!well-known) {
@ -122,28 +122,12 @@ server {
include /etc/nginx/proxy-params.conf; include /etc/nginx/proxy-params.conf;
} }
# API proxy # API proxy (catch-all for non-rate-limited endpoints)
location /api { location /api {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection '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; proxy_cache_bypass $http_upgrade;
include /etc/nginx/proxy-params.conf;
# 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;
} }
# SPA fallback - serve index.html for all routes # 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-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $forwarded_proto; 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 { useAuth } from '@/hooks/useAuth';
import LockScreen from '@/components/auth/LockScreen'; import LockScreen from '@/components/auth/LockScreen';
import AppLayout from '@/components/layout/AppLayout'; 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 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 }) { function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { authStatus, isLoading } = useAuth(); const { authStatus, isLoading } = useAuth();
@ -57,21 +62,21 @@ function App() {
} }
> >
<Route index element={<Navigate to="/dashboard" replace />} /> <Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<DashboardPage />} /> <Route path="dashboard" element={<Suspense fallback={<RouteFallback />}><DashboardPage /></Suspense>} />
<Route path="todos" element={<TodosPage />} /> <Route path="todos" element={<Suspense fallback={<RouteFallback />}><TodosPage /></Suspense>} />
<Route path="calendar" element={<CalendarPage />} /> <Route path="calendar" element={<Suspense fallback={<RouteFallback />}><CalendarPage /></Suspense>} />
<Route path="reminders" element={<RemindersPage />} /> <Route path="reminders" element={<Suspense fallback={<RouteFallback />}><RemindersPage /></Suspense>} />
<Route path="projects" element={<ProjectsPage />} /> <Route path="projects" element={<Suspense fallback={<RouteFallback />}><ProjectsPage /></Suspense>} />
<Route path="projects/:id" element={<ProjectDetail />} /> <Route path="projects/:id" element={<Suspense fallback={<RouteFallback />}><ProjectDetail /></Suspense>} />
<Route path="people" element={<PeoplePage />} /> <Route path="people" element={<Suspense fallback={<RouteFallback />}><PeoplePage /></Suspense>} />
<Route path="locations" element={<LocationsPage />} /> <Route path="locations" element={<Suspense fallback={<RouteFallback />}><LocationsPage /></Suspense>} />
<Route path="notifications" element={<NotificationsPage />} /> <Route path="notifications" element={<Suspense fallback={<RouteFallback />}><NotificationsPage /></Suspense>} />
<Route path="settings" element={<SettingsPage />} /> <Route path="settings" element={<Suspense fallback={<RouteFallback />}><SettingsPage /></Suspense>} />
<Route <Route
path="admin/*" path="admin/*"
element={ element={
<AdminRoute> <AdminRoute>
<Suspense fallback={<div className="flex h-full items-center justify-center text-muted-foreground">Loading...</div>}> <Suspense fallback={<RouteFallback />}>
<AdminPortal /> <AdminPortal />
</Suspense> </Suspense>
</AdminRoute> </AdminRoute>

View File

@ -1,6 +1,7 @@
import { useState, useRef, useEffect, useMemo, useCallback } from 'react'; import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
import { useMediaQuery, DESKTOP } from '@/hooks/useMediaQuery'; import { useMediaQuery, DESKTOP } from '@/hooks/useMediaQuery';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { format } from 'date-fns';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import FullCalendar from '@fullcalendar/react'; import FullCalendar from '@fullcalendar/react';
@ -205,13 +206,28 @@ export default function CalendarPage() {
return () => el.removeEventListener('wheel', handleWheel); 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({ const { data: events = [] } = useQuery({
queryKey: ['calendar-events'], queryKey: ['calendar-events', visibleRange.start, visibleRange.end],
queryFn: async () => { 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; 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( const selectedEvent = useMemo(
@ -261,12 +277,15 @@ export default function CalendarPage() {
allDay: boolean, allDay: boolean,
revert: () => void, revert: () => void,
) => { ) => {
queryClient.setQueryData<CalendarEvent[]>(['calendar-events'], (old) => // C-01 fix: match active query key which includes date range
old?.map((e) => queryClient.setQueryData<CalendarEvent[]>(
e.id === id ['calendar-events', visibleRange.start, visibleRange.end],
? { ...e, start_datetime: start, end_datetime: end, all_day: allDay } (old) =>
: e, old?.map((e) =>
), e.id === id
? { ...e, start_datetime: start, end_datetime: end, all_day: allDay }
: e,
),
); );
eventMutation.mutate({ id, start, end, allDay, revert }); eventMutation.mutate({ id, start, end, allDay, revert });
}; };
@ -467,6 +486,13 @@ export default function CalendarPage() {
const handleDatesSet = (arg: DatesSetArg) => { const handleDatesSet = (arg: DatesSetArg) => {
setCalendarTitle(arg.view.title); setCalendarTitle(arg.view.title);
setCurrentView(arg.view.type as CalendarView); 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(); 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 { useNavigate } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { format } from 'date-fns'; import { format } from 'date-fns';
@ -34,6 +34,75 @@ function getGreeting(name?: string): string {
return `Good night${suffix}`; 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() { export default function DashboardPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@ -42,38 +111,7 @@ export default function DashboardPage() {
const [quickAddType, setQuickAddType] = useState<'event' | 'todo' | 'reminder' | null>(null); const [quickAddType, setQuickAddType] = useState<'event' | 'todo' | 'reminder' | null>(null);
const [dropdownOpen, setDropdownOpen] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
const [clockNow, setClockNow] = useState(() => new Date()); // Clock state moved to <ClockDisplay /> (AS-3)
// 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);
};
}, []);
// Click outside to close dropdown // Click outside to close dropdown
useEffect(() => { 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 ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Header — greeting + date + quick add */} {/* 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"> <h1 className="font-heading text-3xl font-bold tracking-tight animate-fade-in">
{getGreeting(settings?.preferred_name || undefined)} {getGreeting(settings?.preferred_name || undefined)}
</h1> </h1>
<div className="flex items-center gap-2 mt-1"> <ClockDisplay dataUpdatedAt={dataUpdatedAt} onRefresh={handleRefresh} />
<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>
</div> </div>
<div className="relative" ref={dropdownRef}> <div className="relative" ref={dropdownRef}>
<Button <Button

View File

@ -1,4 +1,4 @@
import { useMemo } from 'react'; import { useMemo, useRef } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import api from '@/lib/api'; import api from '@/lib/api';
import type { Calendar, SharedCalendarMembership } from '@/types'; import type { Calendar, SharedCalendarMembership } from '@/types';
@ -16,16 +16,26 @@ 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.
// Use a ref to latch "has sharing" once discovered, so polling doesn't flicker.
const hasSharingRef = useRef(false);
const ownsShared = (ownedQuery.data ?? []).some((c) => c.is_shared);
if (ownsShared) hasSharingRef.current = true;
const sharedQuery = useQuery({ const sharedQuery = useQuery({
queryKey: ['calendars', 'shared'], queryKey: ['calendars', 'shared'],
queryFn: async () => { queryFn: async () => {
const { data } = await api.get<SharedCalendarMembership[]>('/shared-calendars'); const { data } = await api.get<SharedCalendarMembership[]>('/shared-calendars');
return data; return data;
}, },
refetchInterval: pollingEnabled ? 5_000 : false, refetchInterval: pollingEnabled && hasSharingRef.current ? 5_000 : false,
staleTime: 3_000, staleTime: 3_000,
}); });
// Also latch if user is a member of others' shared calendars
if ((sharedQuery.data ?? []).length > 0) hasSharingRef.current = true;
const allCalendarIds = useMemo(() => { const allCalendarIds = useMemo(() => {
const owned = (ownedQuery.data ?? []).map((c) => c.id); const owned = (ownedQuery.data ?? []).map((c) => c.id);
const shared = (sharedQuery.data ?? []).map((m) => m.calendar_id); const shared = (sharedQuery.data ?? []).map((m) => m.calendar_id);

View File

@ -9,6 +9,25 @@ export default defineConfig({
'@': path.resolve(__dirname, './src'), '@': 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: { server: {
port: 5173, port: 5173,
proxy: { proxy: {