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: