diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..195d9c1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,36 @@ +# Keep Docker build contexts small and reproducible. +# (CI checks out only git-tracked files, but local `docker compose build` would +# otherwise ship node_modules/dist — hundreds of MB — into the context.) + +# dependencies +node_modules +**/node_modules +.pnp.* + +# build output / incremental caches +**/dist +out +**/*.tsbuildinfo +**/coverage + +# vcs / ci +.git +.gitignore +.gitea + +# env & secrets (API URL is passed as a build-arg, never copied in) +**/.env +**/.env.* +!**/.env.example + +# local API data (SQLite) +apps/api/data +apps/api/.tmp + +# editor / os cruft +.idea +.DS_Store + +# test artefacts +playwright-report +test-results diff --git a/.env.prod.example b/.env.prod.example new file mode 100644 index 0000000..4d99cdd --- /dev/null +++ b/.env.prod.example @@ -0,0 +1,12 @@ +# Production environment for docker-compose.prod.yml. +# Copy to `.env` on the server and fill in real values (`.env` is gitignored). + +# Strong random secret — generate with: openssl rand -base64 32 +BETTER_AUTH_SECRET=replace-with-a-long-random-string + +# Public URL of the API (what the browser and better-auth see) +BETTER_AUTH_URL=https://api.solelog.vrossem.net + +# Browser origins allowed for CORS + better-auth trusted origins (comma-separated). +# These are the two frontend hostnames. +CORS_ORIGINS=https://solelog.vrossem.net,https://admin.solelog.vrossem.net diff --git a/.gitea/workflows/docker.yml b/.gitea/workflows/docker.yml index 80e3593..bb93886 100644 --- a/.gitea/workflows/docker.yml +++ b/.gitea/workflows/docker.yml @@ -18,7 +18,7 @@ jobs: username: ${{ gitea.actor }} password: ${{ secrets.REGISTRY_TOKEN }} - - name: Build and push + - name: Build and push API uses: docker/build-push-action@v5 with: context: . @@ -27,3 +27,27 @@ jobs: tags: | gitea.vrossem.net/bas/solelog:latest gitea.vrossem.net/bas/solelog:${{ gitea.sha }} + + - name: Build and push Worker + uses: docker/build-push-action@v5 + with: + context: . + file: apps/worker/Dockerfile + push: true + build-args: | + VITE_API_URL=https://api.solelog.vrossem.net + tags: | + gitea.vrossem.net/bas/solelog-worker:latest + gitea.vrossem.net/bas/solelog-worker:${{ gitea.sha }} + + - name: Build and push Admin + uses: docker/build-push-action@v5 + with: + context: . + file: apps/admin/Dockerfile + push: true + build-args: | + VITE_API_URL=https://api.solelog.vrossem.net + tags: | + gitea.vrossem.net/bas/solelog-admin:latest + gitea.vrossem.net/bas/solelog-admin:${{ gitea.sha }} diff --git a/apps/admin/Dockerfile b/apps/admin/Dockerfile new file mode 100644 index 0000000..43b361e --- /dev/null +++ b/apps/admin/Dockerfile @@ -0,0 +1,28 @@ +# ---- build stage: produce the static Vite bundle ---- +FROM node:22-alpine AS build +RUN corepack enable +WORKDIR /repo + +# Workspace manifests first, so `yarn install` layers cache across source changes +COPY package.json yarn.lock .yarnrc.yml ./ +COPY packages/shared/package.json ./packages/shared/package.json +COPY apps/admin/package.json ./apps/admin/package.json +RUN yarn workspaces focus @solelog/admin + +# Sources (@solelog/shared is consumed as raw TS, so it just needs to be present) +COPY packages/shared/ ./packages/shared/ +COPY apps/admin/ ./apps/admin/ + +# The API base URL is baked into the bundle at build time (Vite inlines import.meta.env.*). +# Defaults to localhost for local builds; the Gitea workflow overrides it for production. +ARG VITE_API_URL=http://localhost:3000 +ENV VITE_API_URL=$VITE_API_URL +# `vite build` only (no `tsc -b`): the image ships the bundle; type/test checks +# are a separate CI concern and shouldn't gate the production image. +RUN yarn workspace @solelog/admin exec vite build + +# ---- runtime stage: serve the static files with nginx ---- +FROM nginx:alpine AS runtime +COPY apps/admin/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /repo/apps/admin/dist /usr/share/nginx/html +EXPOSE 80 diff --git a/apps/admin/nginx.conf b/apps/admin/nginx.conf new file mode 100644 index 0000000..8d08691 --- /dev/null +++ b/apps/admin/nginx.conf @@ -0,0 +1,23 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + gzip on; + gzip_types text/css application/javascript application/json image/svg+xml; + + # Hashed build assets are immutable — cache them hard. + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + # SPA fallback: any unknown path serves index.html so React Router can + # handle it client-side (deep links and refreshes work). Never cache the shell. + location / { + try_files $uri $uri/ /index.html; + add_header Cache-Control "no-cache"; + } +} diff --git a/apps/admin/src/auth/AuthContext.test.tsx b/apps/admin/src/auth/AuthContext.test.tsx index d8b847f..e28b808 100644 --- a/apps/admin/src/auth/AuthContext.test.tsx +++ b/apps/admin/src/auth/AuthContext.test.tsx @@ -17,9 +17,9 @@ vi.mock('../api/me', () => ({ const mockedSignIn = vi.mocked(apiSignIn); const mockedFetchMe = vi.mocked(fetchMe); -// A tiny harness that exposes the auth context's state + signIn. +// A tiny harness that exposes the auth context's state + signIn + signOut. function Harness() { - const { isAuthed, signIn } = useAuth(); + const { isAuthed, signIn, signOut } = useAuth(); return (
{String(isAuthed)} @@ -34,6 +34,9 @@ function Harness() { > go +
); diff --git a/apps/admin/src/screens/Live.test.tsx b/apps/admin/src/screens/Live.test.tsx index 8dac052..8dcdc39 100644 --- a/apps/admin/src/screens/Live.test.tsx +++ b/apps/admin/src/screens/Live.test.tsx @@ -72,4 +72,71 @@ describe('Live', () => { expect(await screen.findByText('Niemand is nu aan het werk.')).toBeInTheDocument(); expect(screen.getByText('Actief nu (0)')).toBeInTheDocument(); }); + + it('shows a Gepauzeerd badge with a frozen worked timer when paused_at is set', async () => { + vi.useFakeTimers(); + const start = Date.now() - 100_000; + const pausedAt = Date.now() - 40_000; // paused 40s ago + mockApiFetch.mockResolvedValue([ + makeSession({ + id: 1, + user_name: 'Jan', + start_time: new Date(start).toISOString(), + paused_at: new Date(pausedAt).toISOString(), + paused_seconds: 10, + }), + ]); + + renderLive(); + + // The fake clock needs to advance for react-query's async resolution. + await vi.advanceTimersByTimeAsync(0); + expect(screen.getByText('Gepauzeerd')).toBeInTheDocument(); + + // worked = (paused_at - start)/1000 - paused_seconds = 60 - 10 = 50s = 00:00:50 + const frozen = screen.getByText('00:00:50'); + expect(frozen).toBeInTheDocument(); + + // Advance the 1s tick: the worked timer must stay frozen (does not count up). + await vi.advanceTimersByTimeAsync(3000); + expect(screen.getByText('00:00:50')).toBeInTheDocument(); + }); + + it('shows the paused total when paused_seconds > 0', async () => { + mockApiFetch.mockResolvedValue([ + makeSession({ + id: 1, + user_name: 'Jan', + paused_seconds: 125, + paused_at: new Date().toISOString(), + }), + ]); + + renderLive(); + + // 125s = 00:02:05 + expect(await screen.findByText('Pauze 00:02:05')).toBeInTheDocument(); + }); + + it('keeps the timer counting (no Gepauzeerd badge) when not paused', async () => { + vi.useFakeTimers(); + mockApiFetch.mockResolvedValue([ + makeSession({ + id: 1, + user_name: 'Jan', + start_time: new Date(Date.now() - 10_000).toISOString(), + paused_at: null, + paused_seconds: 0, + }), + ]); + + renderLive(); + + await vi.advanceTimersByTimeAsync(0); + expect(screen.queryByText('Gepauzeerd')).not.toBeInTheDocument(); + expect(screen.getByText('00:00:10')).toBeInTheDocument(); + + await vi.advanceTimersByTimeAsync(2000); + expect(screen.getByText('00:00:12')).toBeInTheDocument(); + }); }); diff --git a/apps/worker/Dockerfile b/apps/worker/Dockerfile new file mode 100644 index 0000000..6a94ccf --- /dev/null +++ b/apps/worker/Dockerfile @@ -0,0 +1,28 @@ +# ---- build stage: produce the static Vite bundle ---- +FROM node:22-alpine AS build +RUN corepack enable +WORKDIR /repo + +# Workspace manifests first, so `yarn install` layers cache across source changes +COPY package.json yarn.lock .yarnrc.yml ./ +COPY packages/shared/package.json ./packages/shared/package.json +COPY apps/worker/package.json ./apps/worker/package.json +RUN yarn workspaces focus @solelog/worker + +# Sources (@solelog/shared is consumed as raw TS, so it just needs to be present) +COPY packages/shared/ ./packages/shared/ +COPY apps/worker/ ./apps/worker/ + +# The API base URL is baked into the bundle at build time (Vite inlines import.meta.env.*). +# Defaults to localhost for local builds; the Gitea workflow overrides it for production. +ARG VITE_API_URL=http://localhost:3000 +ENV VITE_API_URL=$VITE_API_URL +# `vite build` only (no `tsc -b`): the image ships the bundle; type/test checks +# are a separate CI concern and shouldn't gate the production image. +RUN yarn workspace @solelog/worker exec vite build + +# ---- runtime stage: serve the static files with nginx ---- +FROM nginx:alpine AS runtime +COPY apps/worker/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /repo/apps/worker/dist /usr/share/nginx/html +EXPOSE 80 diff --git a/apps/worker/nginx.conf b/apps/worker/nginx.conf new file mode 100644 index 0000000..8d08691 --- /dev/null +++ b/apps/worker/nginx.conf @@ -0,0 +1,23 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + gzip on; + gzip_types text/css application/javascript application/json image/svg+xml; + + # Hashed build assets are immutable — cache them hard. + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + # SPA fallback: any unknown path serves index.html so React Router can + # handle it client-side (deep links and refreshes work). Never cache the shell. + location / { + try_files $uri $uri/ /index.html; + add_header Cache-Control "no-cache"; + } +} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..1514e17 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,52 @@ +# Production deployment — runs on the server behind the Caddy reverse proxy. +# Images are built and pushed by the Gitea workflow (.gitea/workflows/docker.yml). +# +# One-time server setup: +# docker network create solelog_network +# # attach your (containerised) Caddy to that network, e.g. add it to Caddy's +# # compose as an external network, or: docker network connect solelog_network +# cp .env.prod.example .env # then fill in real values +# +# Deploy / update: +# docker compose -f docker-compose.prod.yml pull +# docker compose -f docker-compose.prod.yml up -d +# +# Caddyfile (on your edge Caddy — it resolves these names over solelog_network): +# api.solelog.vrossem.net { reverse_proxy solelog-api:3000 } +# solelog.vrossem.net { reverse_proxy solelog-worker:80 } +# admin.solelog.vrossem.net { reverse_proxy solelog-admin:80 } + +services: + api: + image: gitea.vrossem.net/bas/solelog:latest + container_name: solelog-api + env_file: .env # BETTER_AUTH_SECRET, BETTER_AUTH_URL, CORS_ORIGINS + environment: + DATABASE_URL: file:/data/app.db + PORT: '3000' + volumes: + - solelog_db:/data + networks: + - solelog_network + restart: unless-stopped + + worker: + image: gitea.vrossem.net/bas/solelog-worker:latest + container_name: solelog-worker + networks: + - solelog_network + restart: unless-stopped + + admin: + image: gitea.vrossem.net/bas/solelog-admin:latest + container_name: solelog-admin + networks: + - solelog_network + restart: unless-stopped + +networks: + solelog_network: + external: true + +volumes: + solelog_db: diff --git a/docker-compose.yml b/docker-compose.yml index 61375b7..a5eba43 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,3 +1,8 @@ +# Local full-stack build. Runs the API + both frontends on your machine: +# docker compose up --build +# API on :3000, worker on :5173, admin on :5174 (matching the Vite dev ports). +# For the server deployment see docker-compose.prod.yml. + services: api: build: @@ -9,9 +14,32 @@ services: DATABASE_URL: file:/data/app.db BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:-change-me-to-a-long-random-string} BETTER_AUTH_URL: http://localhost:3000 + CORS_ORIGINS: http://localhost:5173,http://localhost:5174 PORT: '3000' volumes: - solelog_db:/data + worker: + build: + context: . + dockerfile: apps/worker/Dockerfile + args: + VITE_API_URL: http://localhost:3000 + ports: + - '5173:80' + depends_on: + - api + + admin: + build: + context: . + dockerfile: apps/admin/Dockerfile + args: + VITE_API_URL: http://localhost:3000 + ports: + - '5174:80' + depends_on: + - api + volumes: solelog_db: