Compare commits
6 Commits
eae9a53a26
...
a7c8925b3c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7c8925b3c | ||
|
|
1765f4036c | ||
|
|
ce396ecf2d | ||
|
|
56e0162230 | ||
|
|
974ecb120d | ||
|
|
0d82b6efbc |
36
.dockerignore
Normal file
36
.dockerignore
Normal file
@@ -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
|
||||||
12
.env.prod.example
Normal file
12
.env.prod.example
Normal file
@@ -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
|
||||||
@@ -18,7 +18,7 @@ jobs:
|
|||||||
username: ${{ gitea.actor }}
|
username: ${{ gitea.actor }}
|
||||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push API
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
@@ -27,3 +27,27 @@ jobs:
|
|||||||
tags: |
|
tags: |
|
||||||
gitea.vrossem.net/bas/solelog:latest
|
gitea.vrossem.net/bas/solelog:latest
|
||||||
gitea.vrossem.net/bas/solelog:${{ gitea.sha }}
|
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 }}
|
||||||
|
|||||||
28
apps/admin/Dockerfile
Normal file
28
apps/admin/Dockerfile
Normal file
@@ -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
|
||||||
23
apps/admin/nginx.conf
Normal file
23
apps/admin/nginx.conf
Normal file
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,9 +17,9 @@ vi.mock('../api/me', () => ({
|
|||||||
const mockedSignIn = vi.mocked(apiSignIn);
|
const mockedSignIn = vi.mocked(apiSignIn);
|
||||||
const mockedFetchMe = vi.mocked(fetchMe);
|
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() {
|
function Harness() {
|
||||||
const { isAuthed, signIn } = useAuth();
|
const { isAuthed, signIn, signOut } = useAuth();
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<span data-testid="authed">{String(isAuthed)}</span>
|
<span data-testid="authed">{String(isAuthed)}</span>
|
||||||
@@ -34,6 +34,9 @@ function Harness() {
|
|||||||
>
|
>
|
||||||
go
|
go
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" onClick={() => signOut()}>
|
||||||
|
uit
|
||||||
|
</button>
|
||||||
<span id="err" />
|
<span id="err" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -72,4 +72,71 @@ describe('Live', () => {
|
|||||||
expect(await screen.findByText('Niemand is nu aan het werk.')).toBeInTheDocument();
|
expect(await screen.findByText('Niemand is nu aan het werk.')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Actief nu (0)')).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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
3
apps/api/drizzle/0003_sharp_giant_girl.sql
Normal file
3
apps/api/drizzle/0003_sharp_giant_girl.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE `activities` ADD `sort_order` integer DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE `work_sessions` ADD `paused_seconds` integer DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE `work_sessions` ADD `paused_at` integer;
|
||||||
608
apps/api/drizzle/meta/0003_snapshot.json
Normal file
608
apps/api/drizzle/meta/0003_snapshot.json
Normal file
@@ -0,0 +1,608 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "f74073b0-c7a9-4329-9b48-69e927e79fc5",
|
||||||
|
"prevId": "3d48e08d-2ae7-4987-bb60-e5199726c129",
|
||||||
|
"tables": {
|
||||||
|
"account": {
|
||||||
|
"name": "account",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"account_id": {
|
||||||
|
"name": "account_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"provider_id": {
|
||||||
|
"name": "provider_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"access_token": {
|
||||||
|
"name": "access_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"refresh_token": {
|
||||||
|
"name": "refresh_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"id_token": {
|
||||||
|
"name": "id_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"access_token_expires_at": {
|
||||||
|
"name": "access_token_expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"refresh_token_expires_at": {
|
||||||
|
"name": "refresh_token_expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"name": "scope",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"name": "password",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"account_userId_idx": {
|
||||||
|
"name": "account_userId_idx",
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"account_user_id_user_id_fk": {
|
||||||
|
"name": "account_user_id_user_id_fk",
|
||||||
|
"tableFrom": "account",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"activities": {
|
||||||
|
"name": "activities",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"insole_types": {
|
||||||
|
"name": "insole_types",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'[\"Kurk\",\"Berk\",\"3D\"]'"
|
||||||
|
},
|
||||||
|
"sort_order": {
|
||||||
|
"name": "sort_order",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"session": {
|
||||||
|
"name": "session",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"name": "token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"ip_address": {
|
||||||
|
"name": "ip_address",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_agent": {
|
||||||
|
"name": "user_agent",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"impersonated_by": {
|
||||||
|
"name": "impersonated_by",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"session_token_unique": {
|
||||||
|
"name": "session_token_unique",
|
||||||
|
"columns": [
|
||||||
|
"token"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
},
|
||||||
|
"session_userId_idx": {
|
||||||
|
"name": "session_userId_idx",
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"session_user_id_user_id_fk": {
|
||||||
|
"name": "session_user_id_user_id_fk",
|
||||||
|
"tableFrom": "session",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"name": "user",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"email_verified": {
|
||||||
|
"name": "email_verified",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"name": "image",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"name": "role",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"banned": {
|
||||||
|
"name": "banned",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"ban_reason": {
|
||||||
|
"name": "ban_reason",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"ban_expires": {
|
||||||
|
"name": "ban_expires",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"user_email_unique": {
|
||||||
|
"name": "user_email_unique",
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"verification": {
|
||||||
|
"name": "verification",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"identifier": {
|
||||||
|
"name": "identifier",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"name": "value",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"verification_identifier_idx": {
|
||||||
|
"name": "verification_identifier_idx",
|
||||||
|
"columns": [
|
||||||
|
"identifier"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"work_sessions": {
|
||||||
|
"name": "work_sessions",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"activity_id": {
|
||||||
|
"name": "activity_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"insole_type": {
|
||||||
|
"name": "insole_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"pair_count": {
|
||||||
|
"name": "pair_count",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 2
|
||||||
|
},
|
||||||
|
"start_time": {
|
||||||
|
"name": "start_time",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"end_time": {
|
||||||
|
"name": "end_time",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"duration_seconds": {
|
||||||
|
"name": "duration_seconds",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"paused_seconds": {
|
||||||
|
"name": "paused_seconds",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"paused_at": {
|
||||||
|
"name": "paused_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'active'"
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"name": "source",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'app'"
|
||||||
|
},
|
||||||
|
"notes": {
|
||||||
|
"name": "notes",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"work_sessions_userId_idx": {
|
||||||
|
"name": "work_sessions_userId_idx",
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
},
|
||||||
|
"work_sessions_startTime_idx": {
|
||||||
|
"name": "work_sessions_startTime_idx",
|
||||||
|
"columns": [
|
||||||
|
"start_time"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"work_sessions_user_id_user_id_fk": {
|
||||||
|
"name": "work_sessions_user_id_user_id_fk",
|
||||||
|
"tableFrom": "work_sessions",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"work_sessions_activity_id_activities_id_fk": {
|
||||||
|
"name": "work_sessions_activity_id_activities_id_fk",
|
||||||
|
"tableFrom": "work_sessions",
|
||||||
|
"tableTo": "activities",
|
||||||
|
"columnsFrom": [
|
||||||
|
"activity_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,13 @@
|
|||||||
"when": 1781710299706,
|
"when": 1781710299706,
|
||||||
"tag": "0002_solid_prodigy",
|
"tag": "0002_solid_prodigy",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1781722003307,
|
||||||
|
"tag": "0003_sharp_giant_girl",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -124,6 +124,7 @@ export const activities = sqliteTable('activities', {
|
|||||||
.$type<string[]>()
|
.$type<string[]>()
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(['Kurk', 'Berk', '3D']),
|
.default(['Kurk', 'Berk', '3D']),
|
||||||
|
sortOrder: integer('sort_order').notNull().default(0),
|
||||||
createdAt: integer('created_at', { mode: 'timestamp_ms' })
|
createdAt: integer('created_at', { mode: 'timestamp_ms' })
|
||||||
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
||||||
.notNull(),
|
.notNull(),
|
||||||
@@ -144,6 +145,8 @@ export const workSessions = sqliteTable(
|
|||||||
startTime: integer('start_time', { mode: 'timestamp_ms' }).notNull(),
|
startTime: integer('start_time', { mode: 'timestamp_ms' }).notNull(),
|
||||||
endTime: integer('end_time', { mode: 'timestamp_ms' }), // null = active
|
endTime: integer('end_time', { mode: 'timestamp_ms' }), // null = active
|
||||||
durationSeconds: integer('duration_seconds'),
|
durationSeconds: integer('duration_seconds'),
|
||||||
|
pausedSeconds: integer('paused_seconds').notNull().default(0),
|
||||||
|
pausedAt: integer('paused_at', { mode: 'timestamp_ms' }), // null = running
|
||||||
status: text('status').notNull().default('active'), // 'active' | 'completed' | 'discarded'
|
status: text('status').notNull().default('active'), // 'active' | 'completed' | 'discarded'
|
||||||
source: text('source').notNull().default('app'), // 'app' | 'manual'
|
source: text('source').notNull().default('app'), // 'app' | 'manual'
|
||||||
notes: text('notes'),
|
notes: text('notes'),
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export function toWorkSession(
|
|||||||
start_time: new Date(row.startTime).toISOString(),
|
start_time: new Date(row.startTime).toISOString(),
|
||||||
end_time: row.endTime ? new Date(row.endTime).toISOString() : null,
|
end_time: row.endTime ? new Date(row.endTime).toISOString() : null,
|
||||||
duration_seconds: row.durationSeconds ?? null,
|
duration_seconds: row.durationSeconds ?? null,
|
||||||
|
paused_seconds: row.pausedSeconds ?? 0,
|
||||||
|
paused_at: row.pausedAt ? new Date(row.pausedAt).toISOString() : null,
|
||||||
status: row.status as WorkSession['status'],
|
status: row.status as WorkSession['status'],
|
||||||
source: row.source as WorkSession['source'],
|
source: row.source as WorkSession['source'],
|
||||||
notes: row.notes ?? null,
|
notes: row.notes ?? null,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { eq, asc } from 'drizzle-orm';
|
import { eq, asc, sql } from 'drizzle-orm';
|
||||||
import { CreateActivityInput, UpdateActivityInput } from '@solelog/shared';
|
import { CreateActivityInput, UpdateActivityInput, ReorderActivitiesInput } from '@solelog/shared';
|
||||||
import type { Activity } from '@solelog/shared';
|
import type { Activity } from '@solelog/shared';
|
||||||
import { db } from '../db/client';
|
import { db } from '../db/client';
|
||||||
import { activities, workSessions } from '../db/schema';
|
import { activities, workSessions } from '../db/schema';
|
||||||
@@ -16,6 +16,7 @@ function toActivity(row: ActivityRow): Activity {
|
|||||||
name: row.name,
|
name: row.name,
|
||||||
insole_types: row.insoleTypes as Activity['insole_types'],
|
insole_types: row.insoleTypes as Activity['insole_types'],
|
||||||
created_at: new Date(row.createdAt).toISOString(),
|
created_at: new Date(row.createdAt).toISOString(),
|
||||||
|
sort_order: row.sortOrder ?? 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,7 +24,10 @@ activitiesRoutes.get('/api/activities', async (c) => {
|
|||||||
const sessionUser = await getSessionUser(c);
|
const sessionUser = await getSessionUser(c);
|
||||||
if (!sessionUser) return c.json({ error: 'Unauthorized' }, 401);
|
if (!sessionUser) return c.json({ error: 'Unauthorized' }, 401);
|
||||||
|
|
||||||
const rows = await db.select().from(activities).orderBy(asc(activities.name));
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(activities)
|
||||||
|
.orderBy(asc(activities.sortOrder), asc(activities.name));
|
||||||
const insoleType = c.req.query('insole_type');
|
const insoleType = c.req.query('insole_type');
|
||||||
const filtered = insoleType
|
const filtered = insoleType
|
||||||
? rows.filter((r) => (r.insoleTypes as string[]).includes(insoleType))
|
? rows.filter((r) => (r.insoleTypes as string[]).includes(insoleType))
|
||||||
@@ -39,13 +43,47 @@ activitiesRoutes.post('/api/activities', async (c) => {
|
|||||||
const parsed = CreateActivityInput.safeParse(await c.req.json().catch(() => null));
|
const parsed = CreateActivityInput.safeParse(await c.req.json().catch(() => null));
|
||||||
if (!parsed.success) return c.json({ error: 'Invalid input' }, 400);
|
if (!parsed.success) return c.json({ error: 'Invalid input' }, 400);
|
||||||
|
|
||||||
|
// Append: place the new activity after the current highest sort_order.
|
||||||
|
const [{ max }] = await db
|
||||||
|
.select({ max: sql<number>`COALESCE(MAX(${activities.sortOrder}), -1)` })
|
||||||
|
.from(activities);
|
||||||
|
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.insert(activities)
|
.insert(activities)
|
||||||
.values({ name: parsed.data.name, insoleTypes: parsed.data.insole_types })
|
.values({
|
||||||
|
name: parsed.data.name,
|
||||||
|
insoleTypes: parsed.data.insole_types,
|
||||||
|
sortOrder: max + 1,
|
||||||
|
})
|
||||||
.returning();
|
.returning();
|
||||||
return c.json(toActivity(row));
|
return c.json(toActivity(row));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
activitiesRoutes.put('/api/activities/reorder', async (c) => {
|
||||||
|
const sessionUser = await getSessionUser(c);
|
||||||
|
if (!sessionUser) return c.json({ error: 'Unauthorized' }, 401);
|
||||||
|
if (!isAdmin(sessionUser)) return c.json({ error: 'Forbidden' }, 403);
|
||||||
|
|
||||||
|
const parsed = ReorderActivitiesInput.safeParse(await c.req.json().catch(() => null));
|
||||||
|
if (!parsed.success) return c.json({ error: 'Invalid input' }, 400);
|
||||||
|
|
||||||
|
// The payload must be the full ordered set: same size, same ids.
|
||||||
|
const existing = await db.select({ id: activities.id }).from(activities);
|
||||||
|
const known = new Set(existing.map((r) => r.id));
|
||||||
|
if (parsed.data.ids.length !== known.size || parsed.data.ids.some((id) => !known.has(id)))
|
||||||
|
return c.json({ error: 'Invalid input' }, 400);
|
||||||
|
|
||||||
|
for (let i = 0; i < parsed.data.ids.length; i++) {
|
||||||
|
await db.update(activities).set({ sortOrder: i }).where(eq(activities.id, parsed.data.ids[i]));
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(activities)
|
||||||
|
.orderBy(asc(activities.sortOrder), asc(activities.name));
|
||||||
|
return c.json(rows.map(toActivity));
|
||||||
|
});
|
||||||
|
|
||||||
activitiesRoutes.put('/api/activities/:id', async (c) => {
|
activitiesRoutes.put('/api/activities/:id', async (c) => {
|
||||||
const sessionUser = await getSessionUser(c);
|
const sessionUser = await getSessionUser(c);
|
||||||
if (!sessionUser) return c.json({ error: 'Unauthorized' }, 401);
|
if (!sessionUser) return c.json({ error: 'Unauthorized' }, 401);
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ sessionsRoutes.get('/api/export', async (c) => {
|
|||||||
'No. of Insoles',
|
'No. of Insoles',
|
||||||
'Date',
|
'Date',
|
||||||
'Total Duration',
|
'Total Duration',
|
||||||
|
'Paused Duration',
|
||||||
'Start Time',
|
'Start Time',
|
||||||
'End Time',
|
'End Time',
|
||||||
]
|
]
|
||||||
@@ -43,6 +44,7 @@ sessionsRoutes.get('/api/export', async (c) => {
|
|||||||
session.pairCount ?? 2,
|
session.pairCount ?? 2,
|
||||||
start.toLocaleDateString('nl-BE', { day: '2-digit', month: '2-digit', year: 'numeric' }),
|
start.toLocaleDateString('nl-BE', { day: '2-digit', month: '2-digit', year: 'numeric' }),
|
||||||
formatDuration(session.durationSeconds ?? 0),
|
formatDuration(session.durationSeconds ?? 0),
|
||||||
|
formatDuration(session.pausedSeconds ?? 0),
|
||||||
start.toLocaleTimeString('nl-BE', {
|
start.toLocaleTimeString('nl-BE', {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
@@ -122,6 +124,55 @@ sessionsRoutes.post('/api/sessions/start', async (c) => {
|
|||||||
return c.json(toWorkSession(row, { activityName: activity.name }));
|
return c.json(toWorkSession(row, { activityName: activity.name }));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
sessionsRoutes.post('/api/sessions/:id/pause', async (c) => {
|
||||||
|
const sessionUser = await getSessionUser(c);
|
||||||
|
if (!sessionUser) return c.json({ error: 'Unauthorized' }, 401);
|
||||||
|
|
||||||
|
const id = Number.parseInt(c.req.param('id'), 10);
|
||||||
|
if (Number.isNaN(id)) return c.json({ error: 'Session not found' }, 404);
|
||||||
|
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(workSessions)
|
||||||
|
.where(and(eq(workSessions.id, id), eq(workSessions.userId, sessionUser.id)));
|
||||||
|
if (!row) return c.json({ error: 'Session not found' }, 404);
|
||||||
|
if (row.status !== 'active') return c.json({ error: 'Session not active' }, 409);
|
||||||
|
if (row.pausedAt) return c.json({ error: 'Already paused' }, 409);
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(workSessions)
|
||||||
|
.set({ pausedAt: new Date() })
|
||||||
|
.where(eq(workSessions.id, id))
|
||||||
|
.returning();
|
||||||
|
return c.json(toWorkSession(updated));
|
||||||
|
});
|
||||||
|
|
||||||
|
sessionsRoutes.post('/api/sessions/:id/resume', async (c) => {
|
||||||
|
const sessionUser = await getSessionUser(c);
|
||||||
|
if (!sessionUser) return c.json({ error: 'Unauthorized' }, 401);
|
||||||
|
|
||||||
|
const id = Number.parseInt(c.req.param('id'), 10);
|
||||||
|
if (Number.isNaN(id)) return c.json({ error: 'Session not found' }, 404);
|
||||||
|
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(workSessions)
|
||||||
|
.where(and(eq(workSessions.id, id), eq(workSessions.userId, sessionUser.id)));
|
||||||
|
if (!row) return c.json({ error: 'Session not found' }, 404);
|
||||||
|
if (row.status !== 'active') return c.json({ error: 'Session not active' }, 409);
|
||||||
|
if (!row.pausedAt) return c.json({ error: 'Not paused' }, 409);
|
||||||
|
|
||||||
|
const pausedSeconds =
|
||||||
|
(row.pausedSeconds ?? 0) + Math.round((Date.now() - new Date(row.pausedAt).getTime()) / 1000);
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(workSessions)
|
||||||
|
.set({ pausedSeconds, pausedAt: null })
|
||||||
|
.where(eq(workSessions.id, id))
|
||||||
|
.returning();
|
||||||
|
return c.json(toWorkSession(updated));
|
||||||
|
});
|
||||||
|
|
||||||
sessionsRoutes.post('/api/sessions/:id/stop', async (c) => {
|
sessionsRoutes.post('/api/sessions/:id/stop', async (c) => {
|
||||||
const sessionUser = await getSessionUser(c);
|
const sessionUser = await getSessionUser(c);
|
||||||
if (!sessionUser) return c.json({ error: 'Unauthorized' }, 401);
|
if (!sessionUser) return c.json({ error: 'Unauthorized' }, 401);
|
||||||
@@ -136,14 +187,18 @@ sessionsRoutes.post('/api/sessions/:id/stop', async (c) => {
|
|||||||
if (!row) return c.json({ error: 'Session not found' }, 404);
|
if (!row) return c.json({ error: 'Session not found' }, 404);
|
||||||
if (row.status !== 'active') return c.json({ error: 'Session already closed' }, 409);
|
if (row.status !== 'active') return c.json({ error: 'Session already closed' }, 409);
|
||||||
|
|
||||||
const endTime = new Date();
|
const now = Date.now();
|
||||||
const durationSeconds = Math.round(
|
const extraPaused = row.pausedAt
|
||||||
(endTime.getTime() - new Date(row.startTime).getTime()) / 1000
|
? Math.round((now - new Date(row.pausedAt).getTime()) / 1000)
|
||||||
);
|
: 0;
|
||||||
|
const pausedSeconds = (row.pausedSeconds ?? 0) + extraPaused;
|
||||||
|
const endTime = new Date(now);
|
||||||
|
const wall = Math.round((now - new Date(row.startTime).getTime()) / 1000);
|
||||||
|
const durationSeconds = Math.max(0, wall - pausedSeconds);
|
||||||
|
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
.update(workSessions)
|
.update(workSessions)
|
||||||
.set({ endTime, durationSeconds, status: 'completed' })
|
.set({ endTime, durationSeconds, pausedSeconds, pausedAt: null, status: 'completed' })
|
||||||
.where(eq(workSessions.id, id))
|
.where(eq(workSessions.id, id))
|
||||||
.returning();
|
.returning();
|
||||||
return c.json(toWorkSession(updated));
|
return c.json(toWorkSession(updated));
|
||||||
|
|||||||
@@ -150,6 +150,120 @@ describe('activities routes', () => {
|
|||||||
expect(res.status).toBe(404);
|
expect(res.status).toBe(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('orders GET by sort_order then name', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const token = await authToken(app, 'act-order@example.com', 'admin');
|
||||||
|
|
||||||
|
// Create three activities; they append with increasing sort_order.
|
||||||
|
const names = ['Order-C', 'Order-A', 'Order-B'];
|
||||||
|
const created: Array<{ id: number; name: string }> = [];
|
||||||
|
for (const name of names) {
|
||||||
|
const res = await app.request('/api/activities', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(token),
|
||||||
|
body: JSON.stringify({ name, insole_types: ['Kurk'] }),
|
||||||
|
});
|
||||||
|
created.push(await res.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
const listRes = await app.request('/api/activities', { headers: bearer(token) });
|
||||||
|
const list: Array<{ id: number; sort_order: number }> = await listRes.json();
|
||||||
|
// Filtered to just our three, in returned order.
|
||||||
|
const ours = list.filter((a) => created.some((c) => c.id === a.id));
|
||||||
|
expect(ours.map((a) => a.id)).toEqual(created.map((c) => c.id));
|
||||||
|
// sort_order is non-decreasing across the whole list.
|
||||||
|
for (let i = 1; i < list.length; i++) {
|
||||||
|
expect(list[i].sort_order).toBeGreaterThanOrEqual(list[i - 1].sort_order);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends a new activity with a higher sort_order than existing ones', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const token = await authToken(app, 'act-append@example.com', 'admin');
|
||||||
|
|
||||||
|
const firstRes = await app.request('/api/activities', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(token),
|
||||||
|
body: JSON.stringify({ name: 'Append-1', insole_types: ['Kurk'] }),
|
||||||
|
});
|
||||||
|
const first = await firstRes.json();
|
||||||
|
|
||||||
|
const secondRes = await app.request('/api/activities', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(token),
|
||||||
|
body: JSON.stringify({ name: 'Append-2', insole_types: ['Kurk'] }),
|
||||||
|
});
|
||||||
|
const second = await secondRes.json();
|
||||||
|
|
||||||
|
expect(second.sort_order).toBeGreaterThan(first.sort_order);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets an admin reorder activities (PUT /api/activities/reorder)', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const token = await authToken(app, 'act-reorder@example.com', 'admin');
|
||||||
|
|
||||||
|
// Existing activities (possibly seeded by other tests) plus our two.
|
||||||
|
const aRes = await app.request('/api/activities', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(token),
|
||||||
|
body: JSON.stringify({ name: 'Reorder-A', insole_types: ['Kurk'] }),
|
||||||
|
});
|
||||||
|
const a = await aRes.json();
|
||||||
|
const bRes = await app.request('/api/activities', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(token),
|
||||||
|
body: JSON.stringify({ name: 'Reorder-B', insole_types: ['Kurk'] }),
|
||||||
|
});
|
||||||
|
const b = await bRes.json();
|
||||||
|
|
||||||
|
// Build the full ordered id list, then swap a and b so b comes first.
|
||||||
|
const beforeRes = await app.request('/api/activities', { headers: bearer(token) });
|
||||||
|
const before: Array<{ id: number }> = await beforeRes.json();
|
||||||
|
const ids = before.map((r) => r.id);
|
||||||
|
const ia = ids.indexOf(a.id);
|
||||||
|
const ib = ids.indexOf(b.id);
|
||||||
|
[ids[ia], ids[ib]] = [ids[ib], ids[ia]];
|
||||||
|
|
||||||
|
const reorderRes = await app.request('/api/activities/reorder', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: bearer(token),
|
||||||
|
body: JSON.stringify({ ids }),
|
||||||
|
});
|
||||||
|
expect(reorderRes.status).toBe(200);
|
||||||
|
|
||||||
|
const afterRes = await app.request('/api/activities', { headers: bearer(token) });
|
||||||
|
const after: Array<{ id: number }> = await afterRes.json();
|
||||||
|
expect(after.map((r) => r.id)).toEqual(ids);
|
||||||
|
// In particular, b now precedes a.
|
||||||
|
expect(after.findIndex((r) => r.id === b.id)).toBeLessThan(
|
||||||
|
after.findIndex((r) => r.id === a.id)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forbids a worker from reordering activities (403)', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const token = await authToken(app, 'act-reorder-worker@example.com');
|
||||||
|
|
||||||
|
const res = await app.request('/api/activities/reorder', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: bearer(token),
|
||||||
|
body: JSON.stringify({ ids: [1] }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('400s reorder when ids do not match the full set', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const token = await authToken(app, 'act-reorder-badids@example.com', 'admin');
|
||||||
|
|
||||||
|
const res = await app.request('/api/activities/reorder', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: bearer(token),
|
||||||
|
body: JSON.stringify({ ids: [999999] }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
it('deletes an activity and its sessions', async () => {
|
it('deletes an activity and its sessions', async () => {
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
const token = await authToken(app, 'act-delete@example.com', 'admin');
|
const token = await authToken(app, 'act-delete@example.com', 'admin');
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ describe('csv export', () => {
|
|||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
const lines = text.split('\n');
|
const lines = text.split('\n');
|
||||||
expect(lines[0]).toBe(
|
expect(lines[0]).toBe(
|
||||||
'"ID","Task","Insole Type","No. of Insoles","Date","Total Duration","Start Time","End Time"'
|
'"ID","Task","Insole Type","No. of Insoles","Date","Total Duration","Paused Duration","Start Time","End Time"'
|
||||||
);
|
);
|
||||||
expect(lines).toHaveLength(2);
|
expect(lines).toHaveLength(2);
|
||||||
expect(lines[1]).toContain('"Frezen"');
|
expect(lines[1]).toContain('"Frezen"');
|
||||||
@@ -61,6 +61,27 @@ describe('csv export', () => {
|
|||||||
expect(lines[1]).toContain('"00:01:30"');
|
expect(lines[1]).toContain('"00:01:30"');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('includes a Paused Duration column carrying the formatted paused value', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const token = await authToken(app, 'export-paused@example.com');
|
||||||
|
const activityId = await seedActivity('Bekleden');
|
||||||
|
const id = await completedSession(app, token, activityId, 'Kurk', 90);
|
||||||
|
|
||||||
|
// Stamp a known paused total on the completed session.
|
||||||
|
await db.update(workSessions).set({ pausedSeconds: 75 }).where(eq(workSessions.id, id));
|
||||||
|
|
||||||
|
const res = await app.request('/api/export', { headers: bearer(token) });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const lines = (await res.text()).split('\n');
|
||||||
|
const header = lines[0].split(',');
|
||||||
|
const totalIdx = header.indexOf('"Total Duration"');
|
||||||
|
const pausedIdx = header.indexOf('"Paused Duration"');
|
||||||
|
expect(pausedIdx).toBe(totalIdx + 1);
|
||||||
|
|
||||||
|
const cells = lines[1].split(',');
|
||||||
|
expect(cells[pausedIdx]).toBe('"00:01:15"');
|
||||||
|
});
|
||||||
|
|
||||||
it('excludes active and discarded sessions and scopes to the user', async () => {
|
it('excludes active and discarded sessions and scopes to the user', async () => {
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
const tokenA = await authToken(app, 'export-scopeA@example.com');
|
const tokenA = await authToken(app, 'export-scopeA@example.com');
|
||||||
|
|||||||
@@ -149,6 +149,164 @@ describe('session lifecycle', () => {
|
|||||||
expect(body.duration_seconds).toBeNull();
|
expect(body.duration_seconds).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('401s pause/resume without a token', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
|
||||||
|
const pause = await app.request('/api/sessions/1/pause', { method: 'POST' });
|
||||||
|
expect(pause.status).toBe(401);
|
||||||
|
|
||||||
|
const resume = await app.request('/api/sessions/1/resume', { method: 'POST' });
|
||||||
|
expect(resume.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('404s pause/resume for a missing session', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const token = await authToken(app, 'sess-pause-missing@example.com');
|
||||||
|
|
||||||
|
const pause = await app.request('/api/sessions/999999/pause', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(token),
|
||||||
|
});
|
||||||
|
expect(pause.status).toBe(404);
|
||||||
|
|
||||||
|
const resume = await app.request('/api/sessions/999999/resume', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(token),
|
||||||
|
});
|
||||||
|
expect(resume.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pauses an active session and 409s a double pause', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const token = await authToken(app, 'sess-pause@example.com');
|
||||||
|
const activityId = await seedActivity('Frezen');
|
||||||
|
|
||||||
|
const startRes = await app.request('/api/sessions/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(token),
|
||||||
|
body: JSON.stringify({ activity_id: activityId, insole_type: 'Kurk', pair_count: 2 }),
|
||||||
|
});
|
||||||
|
const started = await startRes.json();
|
||||||
|
|
||||||
|
const pause = await app.request(`/api/sessions/${started.id}/pause`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(token),
|
||||||
|
});
|
||||||
|
expect(pause.status).toBe(200);
|
||||||
|
const paused = await pause.json();
|
||||||
|
expect(paused.status).toBe('active');
|
||||||
|
expect(paused.paused_at).not.toBeNull();
|
||||||
|
|
||||||
|
const again = await app.request(`/api/sessions/${started.id}/pause`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(token),
|
||||||
|
});
|
||||||
|
expect(again.status).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resumes a paused session, accumulating paused_seconds, and 409s a running resume', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const token = await authToken(app, 'sess-resume@example.com');
|
||||||
|
const activityId = await seedActivity('Slijpen');
|
||||||
|
|
||||||
|
const startRes = await app.request('/api/sessions/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(token),
|
||||||
|
body: JSON.stringify({ activity_id: activityId, insole_type: 'Kurk', pair_count: 2 }),
|
||||||
|
});
|
||||||
|
const started = await startRes.json();
|
||||||
|
|
||||||
|
// Resuming a running session is a 409.
|
||||||
|
const runningResume = await app.request(`/api/sessions/${started.id}/resume`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(token),
|
||||||
|
});
|
||||||
|
expect(runningResume.status).toBe(409);
|
||||||
|
|
||||||
|
await app.request(`/api/sessions/${started.id}/pause`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(token),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Backdate the pause start by 3s so resume accumulates a positive span.
|
||||||
|
await db
|
||||||
|
.update(workSessions)
|
||||||
|
.set({ pausedAt: new Date(Date.now() - 3000) })
|
||||||
|
.where(eq(workSessions.id, started.id));
|
||||||
|
|
||||||
|
const resume = await app.request(`/api/sessions/${started.id}/resume`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(token),
|
||||||
|
});
|
||||||
|
expect(resume.status).toBe(200);
|
||||||
|
const resumed = await resume.json();
|
||||||
|
expect(resumed.paused_at).toBeNull();
|
||||||
|
expect(resumed.status).toBe('active');
|
||||||
|
expect(resumed.paused_seconds).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stop folds an open pause span and excludes paused time from worked duration', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const token = await authToken(app, 'sess-stop-paused@example.com');
|
||||||
|
const activityId = await seedActivity('Bekleden');
|
||||||
|
|
||||||
|
const startRes = await app.request('/api/sessions/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(token),
|
||||||
|
body: JSON.stringify({ activity_id: activityId, insole_type: 'Kurk', pair_count: 2 }),
|
||||||
|
});
|
||||||
|
const started = await startRes.json();
|
||||||
|
|
||||||
|
// The session has run for 10s wall-clock and has been paused for the last 4s
|
||||||
|
// (open span — paused_at still set at stop time).
|
||||||
|
await db
|
||||||
|
.update(workSessions)
|
||||||
|
.set({ startTime: new Date(Date.now() - 10000), pausedAt: new Date(Date.now() - 4000) })
|
||||||
|
.where(eq(workSessions.id, started.id));
|
||||||
|
|
||||||
|
const res = await app.request(`/api/sessions/${started.id}/stop`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(token),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.status).toBe('completed');
|
||||||
|
expect(body.paused_at).toBeNull();
|
||||||
|
// Robust relationships rather than exact seconds.
|
||||||
|
expect(body.paused_seconds).toBeGreaterThan(0);
|
||||||
|
expect(body.duration_seconds).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(body.duration_seconds).toBeLessThan(10);
|
||||||
|
const wall = Math.round((Date.parse(body.end_time) - Date.parse(body.start_time)) / 1000);
|
||||||
|
expect(body.duration_seconds + body.paused_seconds).toBe(wall);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stop on a never-paused session keeps paused_seconds at 0 and duration = wall-clock', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const token = await authToken(app, 'sess-stop-nopause@example.com');
|
||||||
|
const activityId = await seedActivity('Afwerken');
|
||||||
|
|
||||||
|
const startRes = await app.request('/api/sessions/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(token),
|
||||||
|
body: JSON.stringify({ activity_id: activityId, insole_type: 'Kurk', pair_count: 2 }),
|
||||||
|
});
|
||||||
|
const started = await startRes.json();
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(workSessions)
|
||||||
|
.set({ startTime: new Date(Date.now() - 5000) })
|
||||||
|
.where(eq(workSessions.id, started.id));
|
||||||
|
|
||||||
|
const res = await app.request(`/api/sessions/${started.id}/stop`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(token),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.paused_seconds).toBe(0);
|
||||||
|
expect(body.duration_seconds).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
it("does not let user B stop user A's session", async () => {
|
it("does not let user B stop user A's session", async () => {
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
const tokenA = await authToken(app, 'sess-ownerA@example.com');
|
const tokenA = await authToken(app, 'sess-ownerA@example.com');
|
||||||
|
|||||||
39
apps/api/test/work-session.test.ts
Normal file
39
apps/api/test/work-session.test.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { toWorkSession } from '../src/lib/work-session';
|
||||||
|
import type { workSessions } from '../src/db/schema';
|
||||||
|
|
||||||
|
type WorkSessionRow = typeof workSessions.$inferSelect;
|
||||||
|
|
||||||
|
function baseRow(overrides: Partial<WorkSessionRow> = {}): WorkSessionRow {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
userId: 'user-1',
|
||||||
|
activityId: 1,
|
||||||
|
insoleType: 'Kurk',
|
||||||
|
pairCount: 2,
|
||||||
|
startTime: new Date('2026-06-17T08:00:00.000Z'),
|
||||||
|
endTime: null,
|
||||||
|
durationSeconds: null,
|
||||||
|
pausedSeconds: 0,
|
||||||
|
pausedAt: null,
|
||||||
|
status: 'active',
|
||||||
|
source: 'app',
|
||||||
|
notes: null,
|
||||||
|
createdAt: new Date('2026-06-17T08:00:00.000Z'),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('toWorkSession paused fields', () => {
|
||||||
|
it('maps pausedSeconds and a null pausedAt', () => {
|
||||||
|
const result = toWorkSession(baseRow({ pausedSeconds: 120, pausedAt: null }));
|
||||||
|
expect(result.paused_seconds).toBe(120);
|
||||||
|
expect(result.paused_at).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps a pausedAt Date to its ISO string', () => {
|
||||||
|
const pausedAt = new Date('2026-06-17T08:05:00.000Z');
|
||||||
|
const result = toWorkSession(baseRow({ pausedSeconds: 0, pausedAt }));
|
||||||
|
expect(result.paused_at).toBe(pausedAt.toISOString());
|
||||||
|
});
|
||||||
|
});
|
||||||
28
apps/worker/Dockerfile
Normal file
28
apps/worker/Dockerfile
Normal file
@@ -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
|
||||||
23
apps/worker/nginx.conf
Normal file
23
apps/worker/nginx.conf
Normal file
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,3 +51,25 @@ export function useDiscardSession() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function usePauseSession() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) =>
|
||||||
|
apiFetch<WorkSession>(`/api/sessions/${id}/pause`, { method: 'POST' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['sessions'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useResumeSession() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) =>
|
||||||
|
apiFetch<WorkSession>(`/api/sessions/${id}/resume`, { method: 'POST' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['sessions'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
49
apps/worker/src/auth/AuthContext.test.tsx
Normal file
49
apps/worker/src/auth/AuthContext.test.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { AuthProvider, useAuth } from './AuthContext';
|
||||||
|
import { clearToken, setToken } from '../lib/auth-storage';
|
||||||
|
|
||||||
|
// The sign-in network call is irrelevant here; stub it so nothing hits the network.
|
||||||
|
vi.mock('../lib/api', () => ({
|
||||||
|
signIn: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function SignOutButton() {
|
||||||
|
const { signOut } = useAuth();
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={signOut}>
|
||||||
|
Uitloggen
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AuthContext signOut', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
clearToken();
|
||||||
|
window.history.replaceState(null, '', '/');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
window.history.replaceState(null, '', '/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets the path to / so re-login lands on the Stopwatch', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
setToken('tok');
|
||||||
|
// Simulate logging out while sitting on the Account tab.
|
||||||
|
window.history.replaceState(null, '', '/account');
|
||||||
|
expect(window.location.pathname).toBe('/account');
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AuthProvider>
|
||||||
|
<SignOutButton />
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Uitloggen' }));
|
||||||
|
|
||||||
|
expect(window.location.pathname).toBe('/');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,6 +19,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const signOut = useCallback(() => {
|
const signOut = useCallback(() => {
|
||||||
|
// Reset the router path so the next authed mount lands on Stopwatch, not Account.
|
||||||
|
window.history.replaceState(null, '', '/');
|
||||||
clearToken();
|
clearToken();
|
||||||
setIsAuthed(false);
|
setIsAuthed(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ function session(overrides: Partial<WorkSession> = {}): WorkSession {
|
|||||||
start_time: '2026-01-02T08:30:00.000Z',
|
start_time: '2026-01-02T08:30:00.000Z',
|
||||||
end_time: '2026-01-02T09:31:01.000Z',
|
end_time: '2026-01-02T09:31:01.000Z',
|
||||||
duration_seconds: 3661,
|
duration_seconds: 3661,
|
||||||
|
paused_seconds: 0,
|
||||||
|
paused_at: null,
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
source: 'app',
|
source: 'app',
|
||||||
notes: null,
|
notes: null,
|
||||||
@@ -44,7 +46,7 @@ function renderHistory() {
|
|||||||
return render(
|
return render(
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<History />
|
<History />
|
||||||
</QueryClientProvider>,
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,6 +96,23 @@ describe('History', () => {
|
|||||||
expect(card.textContent).not.toContain('1 inlegzolen');
|
expect(card.textContent).not.toContain('1 inlegzolen');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders a Pauze line when paused_seconds > 0', async () => {
|
||||||
|
mockedApiFetch.mockResolvedValue([session({ paused_seconds: 125 })]);
|
||||||
|
renderHistory();
|
||||||
|
|
||||||
|
const card = (await screen.findByText('Frezen')).closest('.session-card') as HTMLElement;
|
||||||
|
// 125s -> "2m 5s".
|
||||||
|
expect(card.textContent).toContain('Pauze 2m 5s');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render a Pauze line when paused_seconds is 0', async () => {
|
||||||
|
mockedApiFetch.mockResolvedValue([session({ paused_seconds: 0 })]);
|
||||||
|
renderHistory();
|
||||||
|
|
||||||
|
const card = (await screen.findByText('Frezen')).closest('.session-card') as HTMLElement;
|
||||||
|
expect(card.textContent).not.toContain('Pauze');
|
||||||
|
});
|
||||||
|
|
||||||
it('triggers the CSV download on Exporteer CSV', async () => {
|
it('triggers the CSV download on Exporteer CSV', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderHistory();
|
renderHistory();
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ function SessionCard({ session }: { session: WorkSession }) {
|
|||||||
{session.pair_count} {noun}
|
{session.pair_count} {noun}
|
||||||
</span>
|
</span>
|
||||||
<span className="pill pill-grey">{formatDuration(session.duration_seconds)}</span>
|
<span className="pill pill-grey">{formatDuration(session.duration_seconds)}</span>
|
||||||
|
{session.paused_seconds > 0 && (
|
||||||
|
<span className="pill pill-grey">Pauze {formatDuration(session.paused_seconds)}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
useStartSession,
|
useStartSession,
|
||||||
useStopSession,
|
useStopSession,
|
||||||
useDiscardSession,
|
useDiscardSession,
|
||||||
|
usePauseSession,
|
||||||
|
useResumeSession,
|
||||||
} from '../api/sessions';
|
} from '../api/sessions';
|
||||||
|
|
||||||
vi.mock('../api/activities', () => ({
|
vi.mock('../api/activities', () => ({
|
||||||
@@ -21,6 +23,8 @@ vi.mock('../api/sessions', () => ({
|
|||||||
useStartSession: vi.fn(),
|
useStartSession: vi.fn(),
|
||||||
useStopSession: vi.fn(),
|
useStopSession: vi.fn(),
|
||||||
useDiscardSession: vi.fn(),
|
useDiscardSession: vi.fn(),
|
||||||
|
usePauseSession: vi.fn(),
|
||||||
|
useResumeSession: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mockedUseActivities = vi.mocked(useActivities);
|
const mockedUseActivities = vi.mocked(useActivities);
|
||||||
@@ -28,17 +32,21 @@ const mockedUseActiveSessions = vi.mocked(useActiveSessions);
|
|||||||
const mockedUseStartSession = vi.mocked(useStartSession);
|
const mockedUseStartSession = vi.mocked(useStartSession);
|
||||||
const mockedUseStopSession = vi.mocked(useStopSession);
|
const mockedUseStopSession = vi.mocked(useStopSession);
|
||||||
const mockedUseDiscardSession = vi.mocked(useDiscardSession);
|
const mockedUseDiscardSession = vi.mocked(useDiscardSession);
|
||||||
|
const mockedUsePauseSession = vi.mocked(usePauseSession);
|
||||||
|
const mockedUseResumeSession = vi.mocked(useResumeSession);
|
||||||
|
|
||||||
const FREZEN: Activity = {
|
const FREZEN: Activity = {
|
||||||
id: 1,
|
id: 1,
|
||||||
name: 'Frezen',
|
name: 'Frezen',
|
||||||
insole_types: ['Kurk', 'Berk'],
|
insole_types: ['Kurk', 'Berk'],
|
||||||
|
sort_order: 0,
|
||||||
created_at: '2026-01-01T00:00:00.000Z',
|
created_at: '2026-01-01T00:00:00.000Z',
|
||||||
};
|
};
|
||||||
const PRINTEN: Activity = {
|
const PRINTEN: Activity = {
|
||||||
id: 2,
|
id: 2,
|
||||||
name: 'Printen',
|
name: 'Printen',
|
||||||
insole_types: ['3D'],
|
insole_types: ['3D'],
|
||||||
|
sort_order: 1,
|
||||||
created_at: '2026-01-01T00:00:00.000Z',
|
created_at: '2026-01-01T00:00:00.000Z',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -53,6 +61,8 @@ function activeSession(overrides: Partial<WorkSession> = {}): WorkSession {
|
|||||||
start_time: new Date().toISOString(),
|
start_time: new Date().toISOString(),
|
||||||
end_time: null,
|
end_time: null,
|
||||||
duration_seconds: null,
|
duration_seconds: null,
|
||||||
|
paused_seconds: 0,
|
||||||
|
paused_at: null,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
source: 'app',
|
source: 'app',
|
||||||
notes: null,
|
notes: null,
|
||||||
@@ -69,6 +79,8 @@ function query<R>(data: unknown): R {
|
|||||||
let startMutate: ReturnType<typeof vi.fn>;
|
let startMutate: ReturnType<typeof vi.fn>;
|
||||||
let stopMutate: ReturnType<typeof vi.fn>;
|
let stopMutate: ReturnType<typeof vi.fn>;
|
||||||
let discardMutate: ReturnType<typeof vi.fn>;
|
let discardMutate: ReturnType<typeof vi.fn>;
|
||||||
|
let pauseMutate: ReturnType<typeof vi.fn>;
|
||||||
|
let resumeMutate: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
function mutation<R>(mutate: ReturnType<typeof vi.fn>): R {
|
function mutation<R>(mutate: ReturnType<typeof vi.fn>): R {
|
||||||
return { mutate, isPending: false } as unknown as R;
|
return { mutate, isPending: false } as unknown as R;
|
||||||
@@ -88,6 +100,8 @@ describe('Stopwatch', () => {
|
|||||||
startMutate = vi.fn();
|
startMutate = vi.fn();
|
||||||
stopMutate = vi.fn();
|
stopMutate = vi.fn();
|
||||||
discardMutate = vi.fn();
|
discardMutate = vi.fn();
|
||||||
|
pauseMutate = vi.fn();
|
||||||
|
resumeMutate = vi.fn();
|
||||||
mockedUseActivities.mockReturnValue(query<ReturnType<typeof useActivities>>([FREZEN, PRINTEN]));
|
mockedUseActivities.mockReturnValue(query<ReturnType<typeof useActivities>>([FREZEN, PRINTEN]));
|
||||||
mockedUseActiveSessions.mockReturnValue(query<ReturnType<typeof useActiveSessions>>([]));
|
mockedUseActiveSessions.mockReturnValue(query<ReturnType<typeof useActiveSessions>>([]));
|
||||||
mockedUseStartSession.mockReturnValue(mutation<ReturnType<typeof useStartSession>>(startMutate));
|
mockedUseStartSession.mockReturnValue(mutation<ReturnType<typeof useStartSession>>(startMutate));
|
||||||
@@ -95,6 +109,10 @@ describe('Stopwatch', () => {
|
|||||||
mockedUseDiscardSession.mockReturnValue(
|
mockedUseDiscardSession.mockReturnValue(
|
||||||
mutation<ReturnType<typeof useDiscardSession>>(discardMutate),
|
mutation<ReturnType<typeof useDiscardSession>>(discardMutate),
|
||||||
);
|
);
|
||||||
|
mockedUsePauseSession.mockReturnValue(mutation<ReturnType<typeof usePauseSession>>(pauseMutate));
|
||||||
|
mockedUseResumeSession.mockReturnValue(
|
||||||
|
mutation<ReturnType<typeof useResumeSession>>(resumeMutate),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -182,4 +200,47 @@ describe('Stopwatch', () => {
|
|||||||
expect(discardMutate).toHaveBeenCalledTimes(1);
|
expect(discardMutate).toHaveBeenCalledTimes(1);
|
||||||
expect(discardMutate.mock.calls[0][0]).toBe(99);
|
expect(discardMutate.mock.calls[0][0]).toBe(99);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('pauses via the server when the display is tapped while running', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
mockedUseActiveSessions.mockReturnValue(
|
||||||
|
query<ReturnType<typeof useActiveSessions>>([activeSession()]),
|
||||||
|
);
|
||||||
|
renderStopwatch();
|
||||||
|
|
||||||
|
const display = await screen.findByRole('button', { name: 'Stopwatch' });
|
||||||
|
await user.click(display);
|
||||||
|
|
||||||
|
expect(pauseMutate).toHaveBeenCalledTimes(1);
|
||||||
|
expect(pauseMutate.mock.calls[0][0]).toBe(99);
|
||||||
|
expect(resumeMutate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resumes via the server when the display is tapped while paused', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
mockedUseActiveSessions.mockReturnValue(
|
||||||
|
query<ReturnType<typeof useActiveSessions>>([
|
||||||
|
activeSession({ paused_at: new Date().toISOString(), paused_seconds: 30 }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
renderStopwatch();
|
||||||
|
|
||||||
|
const display = await screen.findByRole('button', { name: 'Stopwatch' });
|
||||||
|
await user.click(display);
|
||||||
|
|
||||||
|
expect(resumeMutate).toHaveBeenCalledTimes(1);
|
||||||
|
expect(resumeMutate.mock.calls[0][0]).toBe(99);
|
||||||
|
expect(pauseMutate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recovers a paused session into the paused state on load', async () => {
|
||||||
|
mockedUseActiveSessions.mockReturnValue(
|
||||||
|
query<ReturnType<typeof useActiveSessions>>([
|
||||||
|
activeSession({ paused_at: new Date().toISOString(), paused_seconds: 30 }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
renderStopwatch();
|
||||||
|
|
||||||
|
expect(await screen.findByText('Gepauzeerd — tik om te hervatten')).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
useStartSession,
|
useStartSession,
|
||||||
useStopSession,
|
useStopSession,
|
||||||
useDiscardSession,
|
useDiscardSession,
|
||||||
|
usePauseSession,
|
||||||
|
useResumeSession,
|
||||||
} from '../api/sessions';
|
} from '../api/sessions';
|
||||||
import { elapsedSeconds, formatTime } from '../lib/stopwatch';
|
import { elapsedSeconds, formatTime } from '../lib/stopwatch';
|
||||||
|
|
||||||
@@ -18,6 +20,8 @@ export default function Stopwatch() {
|
|||||||
const startSession = useStartSession();
|
const startSession = useStartSession();
|
||||||
const stopSession = useStopSession();
|
const stopSession = useStopSession();
|
||||||
const discardSession = useDiscardSession();
|
const discardSession = useDiscardSession();
|
||||||
|
const pauseSession = usePauseSession();
|
||||||
|
const resumeSession = useResumeSession();
|
||||||
|
|
||||||
const activities = activitiesQuery.data ?? [];
|
const activities = activitiesQuery.data ?? [];
|
||||||
|
|
||||||
@@ -54,9 +58,11 @@ export default function Stopwatch() {
|
|||||||
if (session.insole_type) setInsoleType(session.insole_type);
|
if (session.insole_type) setInsoleType(session.insole_type);
|
||||||
setPairCount(session.pair_count);
|
setPairCount(session.pair_count);
|
||||||
setPairCountText(String(session.pair_count));
|
setPairCountText(String(session.pair_count));
|
||||||
setIsPaused(false);
|
// Restore pause state from the server (source of truth).
|
||||||
setPausedMs(0);
|
const pausedAtMs = session.paused_at ? new Date(session.paused_at).getTime() : null;
|
||||||
setPauseStartedMs(null);
|
setIsPaused(pausedAtMs !== null);
|
||||||
|
setPausedMs((session.paused_seconds ?? 0) * 1000);
|
||||||
|
setPauseStartedMs(pausedAtMs);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [activeSessionsQuery.data]);
|
}, [activeSessionsQuery.data]);
|
||||||
|
|
||||||
@@ -117,19 +123,22 @@ export default function Stopwatch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleTapDisplay() {
|
function handleTapDisplay() {
|
||||||
if (!isRunning) {
|
if (!isRunning || sessionId === null) {
|
||||||
if (canStart) handleStart();
|
if (canStart) handleStart();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isPaused) {
|
if (isPaused) {
|
||||||
// Resume: fold the just-finished pause span into the accumulator.
|
// Resume: fold the just-finished pause span into the accumulator (snappy local clock);
|
||||||
|
// the server is the source of truth.
|
||||||
if (pauseStartedMs !== null) setPausedMs((prev) => prev + (Date.now() - pauseStartedMs));
|
if (pauseStartedMs !== null) setPausedMs((prev) => prev + (Date.now() - pauseStartedMs));
|
||||||
setPauseStartedMs(null);
|
setPauseStartedMs(null);
|
||||||
setIsPaused(false);
|
setIsPaused(false);
|
||||||
|
resumeSession.mutate(sessionId);
|
||||||
} else {
|
} else {
|
||||||
// Pause.
|
// Pause locally for snappy feedback; the server records the authoritative pause.
|
||||||
setPauseStartedMs(Date.now());
|
setPauseStartedMs(Date.now());
|
||||||
setIsPaused(true);
|
setIsPaused(true);
|
||||||
|
pauseSession.mutate(sessionId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
52
docker-compose.prod.yml
Normal file
52
docker-compose.prod.yml
Normal file
@@ -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 <caddy-container>
|
||||||
|
# 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:
|
||||||
@@ -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:
|
services:
|
||||||
api:
|
api:
|
||||||
build:
|
build:
|
||||||
@@ -9,9 +14,32 @@ services:
|
|||||||
DATABASE_URL: file:/data/app.db
|
DATABASE_URL: file:/data/app.db
|
||||||
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:-change-me-to-a-long-random-string}
|
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:-change-me-to-a-long-random-string}
|
||||||
BETTER_AUTH_URL: http://localhost:3000
|
BETTER_AUTH_URL: http://localhost:3000
|
||||||
|
CORS_ORIGINS: http://localhost:5173,http://localhost:5174
|
||||||
PORT: '3000'
|
PORT: '3000'
|
||||||
volumes:
|
volumes:
|
||||||
- solelog_db:/data
|
- 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:
|
volumes:
|
||||||
solelog_db:
|
solelog_db:
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export const Activity = z.object({
|
|||||||
name: z.string(),
|
name: z.string(),
|
||||||
insole_types: z.array(InsoleType),
|
insole_types: z.array(InsoleType),
|
||||||
created_at: z.string(), // ISO-8601
|
created_at: z.string(), // ISO-8601
|
||||||
|
sort_order: z.number().int(),
|
||||||
});
|
});
|
||||||
export type Activity = z.infer<typeof Activity>;
|
export type Activity = z.infer<typeof Activity>;
|
||||||
|
|
||||||
@@ -41,6 +42,11 @@ export type CreateActivityInput = z.infer<typeof CreateActivityInput>;
|
|||||||
export const UpdateActivityInput = CreateActivityInput;
|
export const UpdateActivityInput = CreateActivityInput;
|
||||||
export type UpdateActivityInput = z.infer<typeof UpdateActivityInput>;
|
export type UpdateActivityInput = z.infer<typeof UpdateActivityInput>;
|
||||||
|
|
||||||
|
export const ReorderActivitiesInput = z.object({
|
||||||
|
ids: z.array(z.number().int()).min(1),
|
||||||
|
});
|
||||||
|
export type ReorderActivitiesInput = z.infer<typeof ReorderActivitiesInput>;
|
||||||
|
|
||||||
export const SessionStatus = z.enum(['active', 'completed', 'discarded']);
|
export const SessionStatus = z.enum(['active', 'completed', 'discarded']);
|
||||||
export type SessionStatus = z.infer<typeof SessionStatus>;
|
export type SessionStatus = z.infer<typeof SessionStatus>;
|
||||||
|
|
||||||
@@ -56,6 +62,8 @@ export const WorkSession = z.object({
|
|||||||
start_time: z.string(), // ISO-8601
|
start_time: z.string(), // ISO-8601
|
||||||
end_time: z.string().nullable(),
|
end_time: z.string().nullable(),
|
||||||
duration_seconds: z.number().int().nullable(),
|
duration_seconds: z.number().int().nullable(),
|
||||||
|
paused_seconds: z.number().int(),
|
||||||
|
paused_at: z.string().nullable(), // ISO-8601; set while paused, null = running
|
||||||
status: SessionStatus,
|
status: SessionStatus,
|
||||||
source: z.enum(['app', 'manual']),
|
source: z.enum(['app', 'manual']),
|
||||||
notes: z.string().nullable(),
|
notes: z.string().nullable(),
|
||||||
|
|||||||
Reference in New Issue
Block a user