Compare commits
23 Commits
7d7af335dd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70ac27ec8e | ||
|
|
1807f2b6d6 | ||
|
|
e48df48376 | ||
|
|
0b0a6bd073 | ||
|
|
a7c8925b3c | ||
|
|
1765f4036c | ||
|
|
ce396ecf2d | ||
|
|
56e0162230 | ||
|
|
974ecb120d | ||
|
|
0d82b6efbc | ||
|
|
eae9a53a26 | ||
|
|
cbfcb4a414 | ||
|
|
660cbe50c8 | ||
|
|
76ad235c9f | ||
|
|
c0d9d21991 | ||
|
|
67dd0d398f | ||
|
|
286e2d29db | ||
|
|
77659edf8e | ||
|
|
682a9dce44 | ||
|
|
02b7522b87 | ||
|
|
bb0a0b2a57 | ||
|
|
7cdc88e824 | ||
|
|
6fce7a7197 |
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
|
||||||
53
.gitea/workflows/docker.yml
Normal file
53
.gitea/workflows/docker.yml
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
name: Build and Push Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Login to Gitea Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: gitea.vrossem.net
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push API
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: apps/api/Dockerfile
|
||||||
|
push: true
|
||||||
|
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 }}
|
||||||
@@ -6,10 +6,10 @@
|
|||||||
"semi": true,
|
"semi": true,
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"jsxSingleQuote": false,
|
"jsxSingleQuote": false,
|
||||||
"trailingComma": "es5",
|
"trailingComma": "all",
|
||||||
"bracketSpacing": true,
|
"bracketSpacing": true,
|
||||||
"bracketSameLine": false,
|
"bracketSameLine": false,
|
||||||
"arrowParens": "always",
|
"arrowParens": "always",
|
||||||
"endOfLine": "lf",
|
"endOfLine": "lf",
|
||||||
"ignorePatterns": ["examples/**/*"]
|
"ignorePatterns": ["examples/**/*", "docs/**", "**/drizzle/**"]
|
||||||
}
|
}
|
||||||
|
|||||||
2
apps/admin/.gitignore
vendored
Normal file
2
apps/admin/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
dist
|
||||||
|
*.tsbuildinfo
|
||||||
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
|
||||||
98
apps/admin/README.md
Normal file
98
apps/admin/README.md
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# @solelog/admin
|
||||||
|
|
||||||
|
The SoleLog **Admin panel**: a **Vite + React + TypeScript** single-page app for the
|
||||||
|
shop-floor administrator. An admin logs in (email + password → bearer token in
|
||||||
|
`localStorage`), watches who is working **live**, and manages the **handelingen**
|
||||||
|
(activities). It is a **client only** — it talks to the `@solelog/api` backend over HTTP
|
||||||
|
with `Authorization: Bearer <token>`; it never touches the database.
|
||||||
|
|
||||||
|
> **Admin-only.** Sign-in confirms `role === 'admin'` via `GET /api/me`. A worker who signs
|
||||||
|
> in with valid credentials is rejected with **"Geen toegang — alleen beheerders."** and the
|
||||||
|
> token is cleared. The UI is in **Dutch**.
|
||||||
|
|
||||||
|
It mirrors `apps/worker`'s toolchain and conventions (same Vite/React/react-query/vitest
|
||||||
|
versions, the same `lib/api.ts` + `lib/auth-storage.ts`), so it shares proven patterns.
|
||||||
|
|
||||||
|
## What Phase 3a covers
|
||||||
|
|
||||||
|
- **Login** — Dutch email + password form (no self-signup), with the admin-only gate.
|
||||||
|
- **Sidebar shell** — left sidebar with the signed-in email + an Uitloggen button, nav to
|
||||||
|
**Live** and **Handelingen**, and a muted "Binnenkort" group (Rapporten / Gebruikers /
|
||||||
|
Handmatig) hinting at Phase 3b.
|
||||||
|
- **Live** (`/`) — who is working right now, from `GET /api/admin/sessions/active`,
|
||||||
|
auto-refreshing every **5 s** (read-only in 3a). One card per session: worker name,
|
||||||
|
activity, insole-type pill, pair count, and a client-side ticking elapsed timer. Empty
|
||||||
|
state: "Niemand is nu aan het werk.".
|
||||||
|
- **Handelingen** (`/handelingen`) — add / inline-edit / delete activities and their insole
|
||||||
|
types (`Kurk` / `Berk` / `3D`), against `/api/activities` (writes are admin-gated
|
||||||
|
server-side; the admin bearer satisfies them).
|
||||||
|
|
||||||
|
**Deferred to Phase 3b:** reports + all-users CSV export, user management
|
||||||
|
(`/api/auth/admin/*`), and manual session entry/edit + admin stop/fix of a running session
|
||||||
|
(needs new backend endpoints).
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node + Corepack (Yarn 4 is pinned in the repo).
|
||||||
|
- From the **repo root**, install everything once:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run it
|
||||||
|
|
||||||
|
Two processes: the API on `:3000` and the admin SPA on `:5174`.
|
||||||
|
|
||||||
|
### 1. Backend API (`:3000`)
|
||||||
|
|
||||||
|
From the repo root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn workspace @solelog/api db:migrate # apply migrations (creates ./data/app.db on first run)
|
||||||
|
yarn workspace @solelog/api db:seed # idempotent: seeds reference activities + dev logins
|
||||||
|
yarn workspace @solelog/api start # Hono server on http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
`db:seed` creates two **dev logins** (dev-only — skipped when `NODE_ENV=production`):
|
||||||
|
**admin** `admin@solelog.local` / `werkplaats-admin` and **worker**
|
||||||
|
`worker@solelog.local` / `werkplaats123`. Only the admin can use this app.
|
||||||
|
|
||||||
|
The API's default `WEB_ORIGINS` already allows `http://localhost:5174` (this app's dev
|
||||||
|
origin), so cross-origin sign-in and requests work out of the box. Override with
|
||||||
|
`CORS_ORIGINS` for other origins (e.g. a LAN IP).
|
||||||
|
|
||||||
|
### 2. Admin SPA (`:5174`)
|
||||||
|
|
||||||
|
From the repo root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn workspace @solelog/admin dev # Vite dev server on http://localhost:5174
|
||||||
|
```
|
||||||
|
|
||||||
|
Open **http://localhost:5174** and sign in as the admin. The API base URL comes from
|
||||||
|
`VITE_API_URL` (default `http://localhost:3000`).
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn workspace @solelog/admin dev # dev server (:5174, LAN-exposed)
|
||||||
|
yarn workspace @solelog/admin build # tsc -b && vite build → dist/
|
||||||
|
yarn workspace @solelog/admin preview # preview the production build
|
||||||
|
yarn workspace @solelog/admin typecheck # tsc -b
|
||||||
|
yarn workspace @solelog/admin test # vitest run
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **Auth.** `lib/api.ts` + `lib/auth-storage.ts` are copied from the worker: sign-in reads
|
||||||
|
the bearer token from the `set-auth-token` response header into `localStorage`, and
|
||||||
|
`apiFetch<T>` attaches `Authorization: Bearer <token>` to every request.
|
||||||
|
`auth/AuthContext.tsx` adds the admin gate (re-fetch `/api/me`, reject non-admins).
|
||||||
|
- **Live data.** `api/admin-sessions.ts` `useActiveSessions()` uses react-query with
|
||||||
|
`refetchInterval: 5000`; each card computes elapsed time from the server `start_time`
|
||||||
|
with a 1 s client tick (`lib/elapsed.ts` `formatTime`).
|
||||||
|
- **Activities.** `api/activities.ts` hooks (`useActivities` + create/update/delete)
|
||||||
|
invalidate the `['activities']` query on every mutation.
|
||||||
|
- **Shared contracts.** Request/response shapes are zod schemas in `@solelog/shared`,
|
||||||
|
imported here for a typed client.
|
||||||
15
apps/admin/index.html
Normal file
15
apps/admin/index.html
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="nl">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
<meta name="theme-color" content="#2563EB" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<link rel="apple-touch-icon" href="/icon-192.png" />
|
||||||
|
<title>SoleLog Admin</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
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";
|
||||||
|
}
|
||||||
|
}
|
||||||
34
apps/admin/package.json
Normal file
34
apps/admin/package.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "@solelog/admin",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"typecheck": "tsc -b",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@solelog/shared": "workspace:*",
|
||||||
|
"@tanstack/react-query": "^5.0.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.26.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@testing-library/dom": "^10.4.1",
|
||||||
|
"@testing-library/jest-dom": "^6.4.0",
|
||||||
|
"@testing-library/react": "^16.0.0",
|
||||||
|
"@testing-library/user-event": "^14.5.0",
|
||||||
|
"@types/react": "^18.3.0",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
|
"jsdom": "^25.0.0",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"vite": "^7.0.0",
|
||||||
|
"vitest": "^3.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
56
apps/admin/src/App.test.tsx
Normal file
56
apps/admin/src/App.test.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { render, screen, within } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import App from './App';
|
||||||
|
import { clearToken, getToken, setToken } from './lib/auth-storage';
|
||||||
|
|
||||||
|
// Stub the network layer so the authed shell renders without real requests.
|
||||||
|
// /api/me resolves an admin so the gate stays authed and the header shows the email.
|
||||||
|
vi.mock('./lib/api', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('./lib/api')>('./lib/api');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
apiFetch: vi.fn().mockResolvedValue({
|
||||||
|
user: { id: 'u1', email: 'admin@solelog.local', name: 'Admin', role: 'admin' },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderApp() {
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<App />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('App', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
clearToken();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the login screen when there is no token', () => {
|
||||||
|
clearToken();
|
||||||
|
renderApp();
|
||||||
|
expect(screen.getByText('E-mailadres')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Inloggen' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the sidebar nav when a token is present', () => {
|
||||||
|
setToken('tok');
|
||||||
|
renderApp();
|
||||||
|
const nav = within(screen.getByRole('navigation'));
|
||||||
|
expect(nav.getByText('Live')).toBeInTheDocument();
|
||||||
|
expect(nav.getByText('Handelingen')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears the token when logout is clicked', async () => {
|
||||||
|
setToken('tok');
|
||||||
|
renderApp();
|
||||||
|
expect(getToken()).toBe('tok');
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Uitloggen' }));
|
||||||
|
expect(getToken()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
35
apps/admin/src/App.tsx
Normal file
35
apps/admin/src/App.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
||||||
|
import { AuthProvider, useAuth } from './auth/AuthContext';
|
||||||
|
import Login from './screens/Login';
|
||||||
|
import Sidebar from './components/Sidebar';
|
||||||
|
import Live from './screens/Live';
|
||||||
|
import Activities from './screens/Activities';
|
||||||
|
|
||||||
|
function AuthedShell() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<div className="admin-shell">
|
||||||
|
<Sidebar />
|
||||||
|
<main className="admin-main">
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Live />} />
|
||||||
|
<Route path="/handelingen" element={<Activities />} />
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Gate() {
|
||||||
|
const { isAuthed } = useAuth();
|
||||||
|
return isAuthed ? <AuthedShell /> : <Login />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<Gate />
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
apps/admin/src/api/activities.ts
Normal file
63
apps/admin/src/api/activities.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import type { Activity, CreateActivityInput, UpdateActivityInput } from '@solelog/shared';
|
||||||
|
import { apiFetch } from '../lib/api';
|
||||||
|
|
||||||
|
export function useActivities() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['activities'],
|
||||||
|
queryFn: () => apiFetch<Activity[]>('/api/activities'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateActivity() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (input: CreateActivityInput) =>
|
||||||
|
apiFetch<Activity>('/api/activities', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['activities'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateActivity() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, input }: { id: number; input: UpdateActivityInput }) =>
|
||||||
|
apiFetch<Activity>(`/api/activities/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['activities'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteActivity() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) =>
|
||||||
|
apiFetch<{ success: true }>(`/api/activities/${id}`, { method: 'DELETE' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['activities'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useReorderActivities() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (ids: number[]) =>
|
||||||
|
apiFetch<Activity[]>('/api/activities/reorder', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ ids }),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['activities'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
13
apps/admin/src/api/admin-sessions.ts
Normal file
13
apps/admin/src/api/admin-sessions.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import type { WorkSession } from '@solelog/shared';
|
||||||
|
import { apiFetch } from '../lib/api';
|
||||||
|
|
||||||
|
// Active work sessions across all workers (admin cross-user view).
|
||||||
|
// Polls every 5s so the Live view reflects who is working without a manual refresh.
|
||||||
|
export function useActiveSessions() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['admin', 'sessions', 'active'],
|
||||||
|
queryFn: () => apiFetch<WorkSession[]>('/api/admin/sessions/active'),
|
||||||
|
refetchInterval: 5000,
|
||||||
|
});
|
||||||
|
}
|
||||||
16
apps/admin/src/api/me.ts
Normal file
16
apps/admin/src/api/me.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import type { MeResponse } from '@solelog/shared';
|
||||||
|
import { apiFetch } from '../lib/api';
|
||||||
|
|
||||||
|
// Fetch the signed-in user (id, email, name, role).
|
||||||
|
export function fetchMe(): Promise<MeResponse> {
|
||||||
|
return apiFetch<MeResponse>('/api/me');
|
||||||
|
}
|
||||||
|
|
||||||
|
// react-query wrapper around fetchMe; used by authed screens (e.g. the shell header).
|
||||||
|
export function useMe() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['me'],
|
||||||
|
queryFn: fetchMe,
|
||||||
|
});
|
||||||
|
}
|
||||||
114
apps/admin/src/auth/AuthContext.test.tsx
Normal file
114
apps/admin/src/auth/AuthContext.test.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { render, screen, waitFor } 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 { fetchMe } from '../api/me';
|
||||||
|
import { signIn as apiSignIn } from '../lib/api';
|
||||||
|
import { clearToken, getToken, setToken } from '../lib/auth-storage';
|
||||||
|
|
||||||
|
// Mock the network layer so no real requests are made.
|
||||||
|
vi.mock('../lib/api', () => ({
|
||||||
|
signIn: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock('../api/me', () => ({
|
||||||
|
fetchMe: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockedSignIn = vi.mocked(apiSignIn);
|
||||||
|
const mockedFetchMe = vi.mocked(fetchMe);
|
||||||
|
|
||||||
|
// A tiny harness that exposes the auth context's state + signIn + signOut.
|
||||||
|
function Harness() {
|
||||||
|
const { isAuthed, signIn, signOut } = useAuth();
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span data-testid="authed">{String(isAuthed)}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
signIn('user@solelog.local', 'pw').catch((err) => {
|
||||||
|
const el = document.getElementById('err');
|
||||||
|
if (el) el.textContent = err instanceof Error ? err.message : String(err);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
go
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => signOut()}>
|
||||||
|
uit
|
||||||
|
</button>
|
||||||
|
<span id="err" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHarness() {
|
||||||
|
return render(
|
||||||
|
<AuthProvider>
|
||||||
|
<Harness />
|
||||||
|
</AuthProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AuthContext admin gate', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockedSignIn.mockReset();
|
||||||
|
mockedFetchMe.mockReset();
|
||||||
|
// signIn stores a token (mirror the real lib/api behaviour).
|
||||||
|
mockedSignIn.mockImplementation(async () => {
|
||||||
|
setToken('tok');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
clearToken();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('signs in an admin: isAuthed becomes true, token kept', async () => {
|
||||||
|
mockedFetchMe.mockResolvedValue({
|
||||||
|
user: { id: 'a1', email: 'admin@solelog.local', name: 'Admin', role: 'admin' },
|
||||||
|
});
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderHarness();
|
||||||
|
expect(screen.getByTestId('authed')).toHaveTextContent('false');
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'go' }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId('authed')).toHaveTextContent('true'));
|
||||||
|
expect(getToken()).toBe('tok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a worker: throws not-admin, clears the token, stays unauthed', async () => {
|
||||||
|
mockedFetchMe.mockResolvedValue({
|
||||||
|
user: { id: 'w1', email: 'worker@solelog.local', name: 'Werker', role: 'worker' },
|
||||||
|
});
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderHarness();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'go' }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(document.getElementById('err')).toHaveTextContent('not-admin'));
|
||||||
|
expect(getToken()).toBeNull();
|
||||||
|
expect(screen.getByTestId('authed')).toHaveTextContent('false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('signOut resets the path to / so the next login lands on Live', async () => {
|
||||||
|
mockedFetchMe.mockResolvedValue({
|
||||||
|
user: { id: 'a1', email: 'admin@solelog.local', name: 'Admin', role: 'admin' },
|
||||||
|
});
|
||||||
|
window.history.replaceState(null, '', '/activities');
|
||||||
|
expect(window.location.pathname).toBe('/activities');
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderHarness();
|
||||||
|
await user.click(screen.getByRole('button', { name: 'go' }));
|
||||||
|
await waitFor(() => expect(screen.getByTestId('authed')).toHaveTextContent('true'));
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'uit' }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId('authed')).toHaveTextContent('false'));
|
||||||
|
expect(window.location.pathname).toBe('/');
|
||||||
|
expect(getToken()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
53
apps/admin/src/auth/AuthContext.tsx
Normal file
53
apps/admin/src/auth/AuthContext.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { createContext, useCallback, useContext, useState, type ReactNode } from 'react';
|
||||||
|
import { clearToken, getToken } from '../lib/auth-storage';
|
||||||
|
import { signIn as apiSignIn } from '../lib/api';
|
||||||
|
import { fetchMe } from '../api/me';
|
||||||
|
|
||||||
|
// Thrown by signIn when the authenticated user is not an admin. Login distinguishes it
|
||||||
|
// from a plain auth failure to show a different message.
|
||||||
|
export class NotAdminError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('not-admin');
|
||||||
|
this.name = 'NotAdminError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthContextValue {
|
||||||
|
isAuthed: boolean;
|
||||||
|
signIn: (email: string, password: string) => Promise<void>;
|
||||||
|
signOut: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [isAuthed, setIsAuthed] = useState<boolean>(() => getToken() !== null);
|
||||||
|
|
||||||
|
const signIn = useCallback(async (email: string, password: string) => {
|
||||||
|
await apiSignIn(email, password);
|
||||||
|
const me = await fetchMe();
|
||||||
|
if (me.user.role !== 'admin') {
|
||||||
|
clearToken();
|
||||||
|
throw new NotAdminError();
|
||||||
|
}
|
||||||
|
setIsAuthed(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const signOut = useCallback(() => {
|
||||||
|
// Reset the path to / so the next authed mount lands on Live, not whatever tab
|
||||||
|
// (e.g. Account/Activities) the admin happened to log out from.
|
||||||
|
window.history.replaceState(null, '', '/');
|
||||||
|
clearToken();
|
||||||
|
setIsAuthed(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ isAuthed, signIn, signOut }}>{children}</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth(): AuthContextValue {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) throw new Error('useAuth must be used within an AuthProvider');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
52
apps/admin/src/components/Sidebar.tsx
Normal file
52
apps/admin/src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { NavLink } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../auth/AuthContext';
|
||||||
|
import { useMe } from '../api/me';
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ to: '/', label: 'Live' },
|
||||||
|
{ to: '/handelingen', label: 'Handelingen' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// Sections planned for Phase 3b — shown muted/disabled as a hint of what's coming.
|
||||||
|
const soonItems = ['Rapporten', 'Gebruikers', 'Handmatig'] as const;
|
||||||
|
|
||||||
|
export default function Sidebar() {
|
||||||
|
const { signOut } = useAuth();
|
||||||
|
const meQuery = useMe();
|
||||||
|
const email = meQuery.data?.user.email;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="sidebar">
|
||||||
|
<div className="sidebar-brand">SoleLog Admin</div>
|
||||||
|
|
||||||
|
<nav className="sidebar-nav">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
end={item.to === '/'}
|
||||||
|
className={({ isActive }) => (isActive ? 'nav-link nav-link-active' : 'nav-link')}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="nav-soon">
|
||||||
|
<span className="nav-soon-label">Binnenkort</span>
|
||||||
|
{soonItems.map((label) => (
|
||||||
|
<span key={label} className="nav-disabled" aria-disabled="true">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="topbar">
|
||||||
|
{email && <span className="topbar-email">{email}</span>}
|
||||||
|
<button type="button" className="btn-logout" aria-label="Uitloggen" onClick={signOut}>
|
||||||
|
Uitloggen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
apps/admin/src/lib/api.ts
Normal file
37
apps/admin/src/lib/api.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { getToken, setToken } from './auth-storage';
|
||||||
|
|
||||||
|
export const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3000';
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
public status: number,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ApiError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||||
|
const token = getToken();
|
||||||
|
const headers = new Headers(init.headers);
|
||||||
|
if (token) headers.set('Authorization', `Bearer ${token}`);
|
||||||
|
if (init.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json');
|
||||||
|
const res = await fetch(`${API_URL}${path}`, { ...init, headers });
|
||||||
|
if (!res.ok) throw new ApiError(res.status, `Request failed: ${res.status}`);
|
||||||
|
const text = await res.text();
|
||||||
|
return (text ? JSON.parse(text) : undefined) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign in: POST /api/auth/sign-in/email, capture the bearer token from the response header.
|
||||||
|
export async function signIn(email: string, password: string): Promise<void> {
|
||||||
|
const res = await fetch(`${API_URL}/api/auth/sign-in/email`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new ApiError(res.status, 'Inloggen mislukt');
|
||||||
|
const token = res.headers.get('set-auth-token');
|
||||||
|
if (!token) throw new ApiError(500, 'Geen token ontvangen');
|
||||||
|
setToken(token);
|
||||||
|
}
|
||||||
13
apps/admin/src/lib/auth-storage.ts
Normal file
13
apps/admin/src/lib/auth-storage.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
const TOKEN_KEY = 'solelog.token';
|
||||||
|
|
||||||
|
export function getToken(): string | null {
|
||||||
|
return localStorage.getItem(TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setToken(token: string): void {
|
||||||
|
localStorage.setItem(TOKEN_KEY, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearToken(): void {
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
}
|
||||||
14
apps/admin/src/lib/elapsed.test.ts
Normal file
14
apps/admin/src/lib/elapsed.test.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { formatTime } from './elapsed';
|
||||||
|
|
||||||
|
describe('formatTime', () => {
|
||||||
|
it('formats seconds as HH:MM:SS', () => {
|
||||||
|
expect(formatTime(0)).toBe('00:00:00');
|
||||||
|
expect(formatTime(65)).toBe('00:01:05');
|
||||||
|
expect(formatTime(3661)).toBe('01:01:01');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never returns a negative time', () => {
|
||||||
|
expect(formatTime(-10)).toBe('00:00:00');
|
||||||
|
});
|
||||||
|
});
|
||||||
11
apps/admin/src/lib/elapsed.ts
Normal file
11
apps/admin/src/lib/elapsed.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
// Pure timing helper — server-authoritative elapsed.
|
||||||
|
// Elapsed is computed from the server start_time (wall-clock), not a tick counter,
|
||||||
|
// so it survives backgrounding. Ported from the worker's lib/stopwatch.ts.
|
||||||
|
|
||||||
|
export function formatTime(totalSeconds: number): string {
|
||||||
|
const s = Math.max(0, Math.floor(totalSeconds));
|
||||||
|
const h = Math.floor(s / 3600);
|
||||||
|
const m = Math.floor((s % 3600) / 60);
|
||||||
|
const sec = s % 60;
|
||||||
|
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
15
apps/admin/src/main.tsx
Normal file
15
apps/admin/src/main.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import App from './App';
|
||||||
|
import './styles.css';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<App />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
166
apps/admin/src/screens/Activities.test.tsx
Normal file
166
apps/admin/src/screens/Activities.test.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import type { Activity } from '@solelog/shared';
|
||||||
|
import Activities from './Activities';
|
||||||
|
import { apiFetch } from '../lib/api';
|
||||||
|
|
||||||
|
vi.mock('../lib/api', () => ({
|
||||||
|
apiFetch: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockApiFetch = vi.mocked(apiFetch);
|
||||||
|
|
||||||
|
const existing: Activity[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Frezen',
|
||||||
|
insole_types: ['Kurk', 'Berk'],
|
||||||
|
created_at: '2026-06-17T00:00:00.000Z',
|
||||||
|
sort_order: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Lijmen',
|
||||||
|
insole_types: ['3D'],
|
||||||
|
created_at: '2026-06-17T00:00:00.000Z',
|
||||||
|
sort_order: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: 'Polijsten',
|
||||||
|
insole_types: ['Kurk'],
|
||||||
|
created_at: '2026-06-17T00:00:00.000Z',
|
||||||
|
sort_order: 2,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function renderActivities() {
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Activities />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Activities', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockApiFetch.mockReset();
|
||||||
|
// Default: the activities GET returns the existing list.
|
||||||
|
mockApiFetch.mockResolvedValue(existing);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the title, subtitle, and existing activities', async () => {
|
||||||
|
renderActivities();
|
||||||
|
expect(await screen.findByText('Handelingen')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Beheer handelingen per zooltype')).toBeInTheDocument();
|
||||||
|
expect(await screen.findByText('Frezen')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Lijmen')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adding a handeling POSTs /api/activities with name + insole_types', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderActivities();
|
||||||
|
await screen.findByText('Frezen');
|
||||||
|
|
||||||
|
await user.type(screen.getByPlaceholderText('Naam van de stap, bijv. Leerrand'), 'Polijsten');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Stap toevoegen' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockApiFetch).toHaveBeenCalledWith('/api/activities', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ name: 'Polijsten', insole_types: ['Kurk', 'Berk', '3D'] }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('editing a handeling PUTs /api/activities/:id', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderActivities();
|
||||||
|
await screen.findByText('Frezen');
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Bewerk Frezen' }));
|
||||||
|
const input = screen.getByDisplayValue('Frezen');
|
||||||
|
await user.clear(input);
|
||||||
|
await user.type(input, 'Frezen 2');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Opslaan' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockApiFetch).toHaveBeenCalledWith('/api/activities/1', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ name: 'Frezen 2', insole_types: ['Kurk', 'Berk'] }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deleting a handeling (confirmed) DELETEs /api/activities/:id', async () => {
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderActivities();
|
||||||
|
await screen.findByText('Frezen');
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Verwijder Frezen' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockApiFetch).toHaveBeenCalledWith('/api/activities/1', { method: 'DELETE' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not DELETE when the confirm is cancelled', async () => {
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderActivities();
|
||||||
|
await screen.findByText('Frezen');
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Verwijder Frezen' }));
|
||||||
|
|
||||||
|
expect(mockApiFetch).not.toHaveBeenCalledWith('/api/activities/1', { method: 'DELETE' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moving the second row up PUTs /api/activities/reorder with the swapped ids', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderActivities();
|
||||||
|
await screen.findByText('Lijmen');
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Verplaats Lijmen omhoog' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockApiFetch).toHaveBeenCalledWith('/api/activities/reorder', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ ids: [2, 1, 3] }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moving the second row down PUTs /api/activities/reorder with the swapped ids', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderActivities();
|
||||||
|
await screen.findByText('Lijmen');
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Verplaats Lijmen omlaag' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockApiFetch).toHaveBeenCalledWith('/api/activities/reorder', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ ids: [1, 3, 2] }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables up on the first row and down on the last row', async () => {
|
||||||
|
renderActivities();
|
||||||
|
await screen.findByText('Frezen');
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: 'Verplaats Frezen omhoog' })).toBeDisabled();
|
||||||
|
expect(screen.getByRole('button', { name: 'Verplaats Polijsten omlaag' })).toBeDisabled();
|
||||||
|
// The opposite ends are enabled.
|
||||||
|
expect(screen.getByRole('button', { name: 'Verplaats Frezen omlaag' })).toBeEnabled();
|
||||||
|
expect(screen.getByRole('button', { name: 'Verplaats Polijsten omhoog' })).toBeEnabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
266
apps/admin/src/screens/Activities.tsx
Normal file
266
apps/admin/src/screens/Activities.tsx
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import type { InsoleType } from '@solelog/shared';
|
||||||
|
import {
|
||||||
|
useActivities,
|
||||||
|
useCreateActivity,
|
||||||
|
useDeleteActivity,
|
||||||
|
useReorderActivities,
|
||||||
|
useUpdateActivity,
|
||||||
|
} from '../api/activities';
|
||||||
|
|
||||||
|
const ALL_TYPES: InsoleType[] = ['Kurk', 'Berk', '3D'];
|
||||||
|
|
||||||
|
const TYPE_COLORS: Record<InsoleType, { bg: string; border: string; text: string }> = {
|
||||||
|
Kurk: { bg: '#FEF9C3', border: '#FDE047', text: '#854D0E' },
|
||||||
|
Berk: { bg: '#DCFCE7', border: '#86EFAC', text: '#166534' },
|
||||||
|
'3D': { bg: '#EDE9FE', border: '#C4B5FD', text: '#5B21B6' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function typePillStyle(type: InsoleType, selected: boolean): React.CSSProperties {
|
||||||
|
const c = TYPE_COLORS[type];
|
||||||
|
return {
|
||||||
|
borderRadius: 999,
|
||||||
|
padding: '6px 14px',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: 'pointer',
|
||||||
|
border: `1px solid ${selected ? c.border : '#E5E7EB'}`,
|
||||||
|
background: selected ? c.bg : '#ffffff',
|
||||||
|
color: selected ? c.text : '#9CA3AF',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeBadgeStyle(type: InsoleType): React.CSSProperties {
|
||||||
|
const c = TYPE_COLORS[type];
|
||||||
|
return {
|
||||||
|
borderRadius: 999,
|
||||||
|
padding: '2px 10px',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
border: `1px solid ${c.border}`,
|
||||||
|
background: c.bg,
|
||||||
|
color: c.text,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function TypeToggles({
|
||||||
|
selected,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
selected: InsoleType[];
|
||||||
|
onToggle: (type: InsoleType) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="type-toggles" style={{ display: 'flex', gap: 8 }}>
|
||||||
|
{ALL_TYPES.map((type) => (
|
||||||
|
<button
|
||||||
|
key={type}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={selected.includes(type)}
|
||||||
|
style={typePillStyle(type, selected.includes(type))}
|
||||||
|
onClick={() => onToggle(type)}
|
||||||
|
>
|
||||||
|
{type}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Activities() {
|
||||||
|
const activitiesQuery = useActivities();
|
||||||
|
const createActivity = useCreateActivity();
|
||||||
|
const updateActivity = useUpdateActivity();
|
||||||
|
const deleteActivity = useDeleteActivity();
|
||||||
|
const reorderActivities = useReorderActivities();
|
||||||
|
|
||||||
|
// Add form state.
|
||||||
|
const [newName, setNewName] = useState('');
|
||||||
|
const [newTypes, setNewTypes] = useState<InsoleType[]>([...ALL_TYPES]);
|
||||||
|
|
||||||
|
// Edit state.
|
||||||
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
|
const [editName, setEditName] = useState('');
|
||||||
|
const [editTypes, setEditTypes] = useState<InsoleType[]>([]);
|
||||||
|
|
||||||
|
const activities = activitiesQuery.data ?? [];
|
||||||
|
|
||||||
|
function toggle(list: InsoleType[], type: InsoleType): InsoleType[] {
|
||||||
|
return list.includes(type) ? list.filter((t) => t !== type) : [...list, type];
|
||||||
|
}
|
||||||
|
|
||||||
|
const addEnabled = newName.trim().length > 0 && newTypes.length > 0;
|
||||||
|
|
||||||
|
function handleAdd() {
|
||||||
|
if (!addEnabled) return;
|
||||||
|
createActivity.mutate(
|
||||||
|
{ name: newName.trim(), insole_types: newTypes },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setNewName('');
|
||||||
|
setNewTypes([...ALL_TYPES]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(id: number, name: string, types: InsoleType[]) {
|
||||||
|
setEditingId(id);
|
||||||
|
setEditName(name);
|
||||||
|
setEditTypes(types.length > 0 ? [...types] : [...ALL_TYPES]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
setEditingId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSave(id: number) {
|
||||||
|
if (editName.trim().length === 0 || editTypes.length === 0) return;
|
||||||
|
updateActivity.mutate(
|
||||||
|
{ id, input: { name: editName.trim(), insole_types: editTypes } },
|
||||||
|
{ onSuccess: () => setEditingId(null) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete(id: number, name: string) {
|
||||||
|
const ok = window.confirm(
|
||||||
|
`"${name}" verwijderen? Alle tijdsregistraties voor deze taak worden ook verwijderd.`,
|
||||||
|
);
|
||||||
|
if (ok) deleteActivity.mutate(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMove(index: number, direction: -1 | 1) {
|
||||||
|
const target = index + direction;
|
||||||
|
if (target < 0 || target >= activities.length) return;
|
||||||
|
const ids = activities.map((a) => a.id);
|
||||||
|
[ids[index], ids[target]] = [ids[target], ids[index]];
|
||||||
|
reorderActivities.mutate(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="screen">
|
||||||
|
<h1 className="screen-title">Handelingen</h1>
|
||||||
|
<p className="screen-subtitle">Beheer handelingen per zooltype</p>
|
||||||
|
|
||||||
|
{/* Add new handling card */}
|
||||||
|
<section className="card">
|
||||||
|
<h2 className="section-label">Nieuwe handeling toevoegen</h2>
|
||||||
|
<input
|
||||||
|
className="field-input"
|
||||||
|
placeholder="Naam van de stap, bijv. Leerrand"
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="sub-label">Van toepassing op</p>
|
||||||
|
<TypeToggles
|
||||||
|
selected={newTypes}
|
||||||
|
onToggle={(type) => setNewTypes((prev) => toggle(prev, type))}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-primary"
|
||||||
|
disabled={!addEnabled || createActivity.isPending}
|
||||||
|
onClick={handleAdd}
|
||||||
|
>
|
||||||
|
Stap toevoegen
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Handling list */}
|
||||||
|
<h2 className="section-label">Huidige stappen ({activities.length})</h2>
|
||||||
|
{activitiesQuery.isLoading ? (
|
||||||
|
<p className="muted">Laden...</p>
|
||||||
|
) : activities.length === 0 ? (
|
||||||
|
<p className="muted">Nog geen stappen. Voeg er een toe hierboven.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="activity-list">
|
||||||
|
{activities.map((activity, index) => (
|
||||||
|
<li key={activity.id} className="activity-card">
|
||||||
|
{editingId === activity.id ? (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
className="field-input"
|
||||||
|
autoFocus
|
||||||
|
value={editName}
|
||||||
|
onChange={(e) => setEditName(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="sub-label">Van toepassing op</p>
|
||||||
|
<TypeToggles
|
||||||
|
selected={editTypes}
|
||||||
|
onToggle={(type) => setEditTypes((prev) => toggle(prev, type))}
|
||||||
|
/>
|
||||||
|
<div className="row-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-save"
|
||||||
|
disabled={
|
||||||
|
updateActivity.isPending ||
|
||||||
|
editTypes.length === 0 ||
|
||||||
|
editName.trim().length === 0
|
||||||
|
}
|
||||||
|
onClick={() => handleSave(activity.id)}
|
||||||
|
>
|
||||||
|
Opslaan
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn-cancel" onClick={cancelEdit}>
|
||||||
|
Annuleren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="activity-row">
|
||||||
|
<span className="activity-name">{activity.name}</span>
|
||||||
|
<div className="row-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-btn icon-move"
|
||||||
|
aria-label={`Verplaats ${activity.name} omhoog`}
|
||||||
|
disabled={index === 0 || reorderActivities.isPending}
|
||||||
|
onClick={() => handleMove(index, -1)}
|
||||||
|
>
|
||||||
|
↑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-btn icon-move"
|
||||||
|
aria-label={`Verplaats ${activity.name} omlaag`}
|
||||||
|
disabled={index === activities.length - 1 || reorderActivities.isPending}
|
||||||
|
onClick={() => handleMove(index, 1)}
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-btn icon-edit"
|
||||||
|
aria-label={`Bewerk ${activity.name}`}
|
||||||
|
onClick={() => startEdit(activity.id, activity.name, activity.insole_types)}
|
||||||
|
>
|
||||||
|
✎
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-btn icon-delete"
|
||||||
|
aria-label={`Verwijder ${activity.name}`}
|
||||||
|
onClick={() => handleDelete(activity.id, activity.name)}
|
||||||
|
>
|
||||||
|
🗑
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="badge-row" style={{ display: 'flex', gap: 6, marginTop: 8 }}>
|
||||||
|
{activity.insole_types.map((type) => (
|
||||||
|
<span key={type} style={typeBadgeStyle(type)}>
|
||||||
|
{type}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
144
apps/admin/src/screens/Live.test.tsx
Normal file
144
apps/admin/src/screens/Live.test.tsx
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import type { WorkSession } from '@solelog/shared';
|
||||||
|
import Live from './Live';
|
||||||
|
import { apiFetch } from '../lib/api';
|
||||||
|
|
||||||
|
vi.mock('../lib/api', () => ({
|
||||||
|
apiFetch: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockApiFetch = vi.mocked(apiFetch);
|
||||||
|
|
||||||
|
function makeSession(over: Partial<WorkSession>): WorkSession {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
user_id: 'u1',
|
||||||
|
activity_id: 10,
|
||||||
|
activity_name: 'Frezen',
|
||||||
|
user_name: 'Jan',
|
||||||
|
insole_type: 'Kurk',
|
||||||
|
pair_count: 4,
|
||||||
|
start_time: new Date(Date.now() - 65_000).toISOString(),
|
||||||
|
end_time: null,
|
||||||
|
duration_seconds: null,
|
||||||
|
paused_seconds: 0,
|
||||||
|
paused_at: null,
|
||||||
|
status: 'active',
|
||||||
|
source: 'app',
|
||||||
|
notes: null,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLive() {
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Live />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Live', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockApiFetch.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a card per active session with header count', async () => {
|
||||||
|
mockApiFetch.mockResolvedValue([
|
||||||
|
makeSession({ id: 1, user_name: 'Jan', activity_name: 'Frezen', insole_type: 'Kurk' }),
|
||||||
|
makeSession({ id: 2, user_name: 'Piet', activity_name: 'Lijmen', insole_type: 'Berk' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
renderLive();
|
||||||
|
|
||||||
|
expect(await screen.findByText('Actief nu (2)')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Jan')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Frezen')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Piet')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Lijmen')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the empty state when nobody is working', async () => {
|
||||||
|
mockApiFetch.mockResolvedValue([]);
|
||||||
|
|
||||||
|
renderLive();
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
74
apps/admin/src/screens/Live.tsx
Normal file
74
apps/admin/src/screens/Live.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import type { WorkSession } from '@solelog/shared';
|
||||||
|
import { useActiveSessions } from '../api/admin-sessions';
|
||||||
|
import { formatTime } from '../lib/elapsed';
|
||||||
|
|
||||||
|
export default function Live() {
|
||||||
|
const { data, isLoading, isError } = useActiveSessions();
|
||||||
|
|
||||||
|
// Client-side 1s tick so the elapsed timer on each card keeps counting up
|
||||||
|
// between the 5s server refreshes.
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => setNow(Date.now()), 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="screen">
|
||||||
|
<p className="muted">Laden…</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div className="screen">
|
||||||
|
<p className="muted">Kon gegevens niet laden.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions = Array.isArray(data) ? data : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="screen">
|
||||||
|
<h1 className="screen-title">Actief nu ({sessions.length})</h1>
|
||||||
|
{sessions.length === 0 ? (
|
||||||
|
<p className="muted">Niemand is nu aan het werk.</p>
|
||||||
|
) : (
|
||||||
|
<div className="live-grid">
|
||||||
|
{sessions.map((session) => (
|
||||||
|
<LiveCard key={session.id} session={session} now={now} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LiveCard({ session, now }: { session: WorkSession; now: number }) {
|
||||||
|
// While paused the worked timer freezes at the pause moment; otherwise it counts to now.
|
||||||
|
// Worked = (base - start) - paused_seconds, where base is the pause moment when paused.
|
||||||
|
const base = session.paused_at ? Date.parse(session.paused_at) : now;
|
||||||
|
const worked = Math.max(
|
||||||
|
0,
|
||||||
|
Math.floor((base - Date.parse(session.start_time)) / 1000) - session.paused_seconds,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<article className="live-card">
|
||||||
|
<div className="live-card-head">
|
||||||
|
<span className="live-name">{session.user_name ?? 'Onbekend'}</span>
|
||||||
|
{session.insole_type && <span className="live-pill">{session.insole_type}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="live-activity">{session.activity_name ?? 'Onbekende handeling'}</div>
|
||||||
|
<div className="live-meta">{session.pair_count} zolen</div>
|
||||||
|
<div className="live-timer">{formatTime(worked)}</div>
|
||||||
|
{session.paused_at && <span className="live-badge-paused">Gepauzeerd</span>}
|
||||||
|
{session.paused_seconds > 0 && (
|
||||||
|
<span className="live-paused-total">Pauze {formatTime(session.paused_seconds)}</span>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
apps/admin/src/screens/Login.tsx
Normal file
57
apps/admin/src/screens/Login.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { NotAdminError, useAuth } from '../auth/AuthContext';
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const { signIn } = useAuth();
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await signIn(email, password);
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
err instanceof NotAdminError ? 'Geen toegang — alleen beheerders.' : 'Inloggen mislukt',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login">
|
||||||
|
<h1 className="login-title">SoleLog Admin</h1>
|
||||||
|
<form className="login-form" onSubmit={handleSubmit}>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">E-mailadres</span>
|
||||||
|
<input
|
||||||
|
className="field-input"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Wachtwoord</span>
|
||||||
|
<input
|
||||||
|
className="field-input"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{error && <p className="login-error">{error}</p>}
|
||||||
|
<button className="btn-primary" type="submit" disabled={busy}>
|
||||||
|
Inloggen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
422
apps/admin/src/styles.css
Normal file
422
apps/admin/src/styles.css
Normal file
@@ -0,0 +1,422 @@
|
|||||||
|
:root {
|
||||||
|
--primary: #2563eb;
|
||||||
|
--primary-light: #eff6ff;
|
||||||
|
--text: #111827;
|
||||||
|
--text-muted: #6b7280;
|
||||||
|
--border: #e5e7eb;
|
||||||
|
--danger: #dc2626;
|
||||||
|
--amber: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family:
|
||||||
|
'Inter',
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Admin shell layout (desktop) ---- */
|
||||||
|
.admin-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 220px 1fr;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-main {
|
||||||
|
padding: 32px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screen {
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screen-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screen-subtitle {
|
||||||
|
margin: 0 0 24px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Sidebar ---- */
|
||||||
|
.sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 24px 16px;
|
||||||
|
background: #ffffff;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-brand {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary);
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
display: block;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link-active {
|
||||||
|
color: var(--primary);
|
||||||
|
background: var(--primary-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-soon {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-soon-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 0 12px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-disabled {
|
||||||
|
display: block;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--border);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Topbar (signed-in identity + logout) ---- */
|
||||||
|
.topbar {
|
||||||
|
margin-top: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-email {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 0 8px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-logout {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--danger);
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid var(--danger);
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Login screen ---- */
|
||||||
|
.login {
|
||||||
|
max-width: 420px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 48px 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
min-height: 100vh;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-title {
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px 16px;
|
||||||
|
font-size: 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
width: 100%;
|
||||||
|
padding: 16px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ffffff;
|
||||||
|
background: var(--primary);
|
||||||
|
border: none;
|
||||||
|
border-radius: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
background: var(--border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error {
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Shared helpers ---- */
|
||||||
|
.muted {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Activity management (Handelingen) ---- */
|
||||||
|
.card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card .section-label {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-card {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-name {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: #ffffff;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-edit {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-delete {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-save {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #166534;
|
||||||
|
background: #dcfce7;
|
||||||
|
border: 1px solid #86efac;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-save:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: #f3f4f6;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Live active-work view ---- */
|
||||||
|
.live-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-name {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-pill {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary);
|
||||||
|
background: var(--primary-light);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-activity {
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-meta {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-timer {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-badge-paused {
|
||||||
|
align-self: flex-start;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--amber);
|
||||||
|
background: #fef3c7;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-paused-total {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
1
apps/admin/src/test/setup.ts
Normal file
1
apps/admin/src/test/setup.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import '@testing-library/jest-dom';
|
||||||
9
apps/admin/src/vite-env.d.ts
vendored
Normal file
9
apps/admin/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_API_URL?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
17
apps/admin/tsconfig.app.json
Normal file
17
apps/admin/tsconfig.app.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"target": "ES2020",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": ["vitest/globals", "@testing-library/jest-dom"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
4
apps/admin/tsconfig.json
Normal file
4
apps/admin/tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
12
apps/admin/tsconfig.node.json
Normal file
12
apps/admin/tsconfig.node.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"target": "ES2022",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts", "vitest.config.ts"]
|
||||||
|
}
|
||||||
7
apps/admin/vite.config.ts
Normal file
7
apps/admin/vite.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: { host: true, port: 5174 },
|
||||||
|
});
|
||||||
7
apps/admin/vitest.config.ts
Normal file
7
apps/admin/vitest.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: { environment: 'jsdom', globals: true, setupFiles: ['./src/test/setup.ts'] },
|
||||||
|
});
|
||||||
@@ -3,7 +3,8 @@ BETTER_AUTH_SECRET=change-me-to-a-long-random-string
|
|||||||
BETTER_AUTH_URL=http://localhost:3000
|
BETTER_AUTH_URL=http://localhost:3000
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
|
||||||
# Comma-separated browser origins allowed for CORS + better-auth (the worker SPA).
|
# Comma-separated browser origins allowed for CORS + better-auth (the worker SPA on 5173
|
||||||
|
# and the admin SPA on 5174).
|
||||||
# Add your phone's LAN origin to test on a device — no code edit needed, e.g.:
|
# Add your phone's LAN origin to test on a device — no code edit needed, e.g.:
|
||||||
# CORS_ORIGINS=http://localhost:5173,http://192.168.1.50:5173
|
# CORS_ORIGINS=http://localhost:5173,http://192.168.1.50:5173
|
||||||
CORS_ORIGINS=http://localhost:5173
|
CORS_ORIGINS=http://localhost:5173,http://localhost:5174
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ WORKDIR /repo
|
|||||||
|
|
||||||
# Copy workspace manifests for cached install
|
# Copy workspace manifests for cached install
|
||||||
COPY package.json yarn.lock .yarnrc.yml ./
|
COPY package.json yarn.lock .yarnrc.yml ./
|
||||||
COPY .yarn/ ./.yarn/
|
|
||||||
COPY packages/shared/package.json ./packages/shared/package.json
|
COPY packages/shared/package.json ./packages/shared/package.json
|
||||||
COPY apps/api/package.json ./apps/api/package.json
|
COPY apps/api/package.json ./apps/api/package.json
|
||||||
|
|
||||||
|
|||||||
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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -18,7 +18,7 @@ export function createApp(): Hono {
|
|||||||
allowHeaders: ['Content-Type', 'Authorization'],
|
allowHeaders: ['Content-Type', 'Authorization'],
|
||||||
exposeHeaders: ['set-auth-token'], // so the SPA can read the bearer token on sign-in
|
exposeHeaders: ['set-auth-token'], // so the SPA can read the bearer token on sign-in
|
||||||
credentials: true,
|
credentials: true,
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
app.route('/', health);
|
app.route('/', health);
|
||||||
app.on(['POST', 'GET'], '/api/auth/*', (c) => auth.handler(c.req.raw));
|
app.on(['POST', 'GET'], '/api/auth/*', (c) => auth.handler(c.req.raw));
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export const session = sqliteTable(
|
|||||||
},
|
},
|
||||||
(table) => ({
|
(table) => ({
|
||||||
sessionUserIdIdx: index('session_userId_idx').on(table.userId),
|
sessionUserIdIdx: index('session_userId_idx').on(table.userId),
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const account = sqliteTable(
|
export const account = sqliteTable(
|
||||||
@@ -73,7 +73,7 @@ export const account = sqliteTable(
|
|||||||
},
|
},
|
||||||
(table) => ({
|
(table) => ({
|
||||||
accountUserIdIdx: index('account_userId_idx').on(table.userId),
|
accountUserIdIdx: index('account_userId_idx').on(table.userId),
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const verification = sqliteTable(
|
export const verification = sqliteTable(
|
||||||
@@ -93,7 +93,7 @@ export const verification = sqliteTable(
|
|||||||
},
|
},
|
||||||
(table) => ({
|
(table) => ({
|
||||||
verificationIdentifierIdx: index('verification_identifier_idx').on(table.identifier),
|
verificationIdentifierIdx: index('verification_identifier_idx').on(table.identifier),
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const userRelations = relations(user, ({ many }) => ({
|
export const userRelations = relations(user, ({ many }) => ({
|
||||||
@@ -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'),
|
||||||
@@ -154,5 +157,5 @@ export const workSessions = sqliteTable(
|
|||||||
(table) => ({
|
(table) => ({
|
||||||
workSessionsUserIdIdx: index('work_sessions_userId_idx').on(table.userId),
|
workSessionsUserIdIdx: index('work_sessions_userId_idx').on(table.userId),
|
||||||
workSessionsStartTimeIdx: index('work_sessions_startTime_idx').on(table.startTime),
|
workSessionsStartTimeIdx: index('work_sessions_startTime_idx').on(table.startTime),
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ export const env = {
|
|||||||
PORT: Number(process.env.PORT ?? 3000),
|
PORT: Number(process.env.PORT ?? 3000),
|
||||||
// Browser origins allowed for CORS + better-auth trustedOrigins. Set CORS_ORIGINS to a
|
// Browser origins allowed for CORS + better-auth trustedOrigins. Set CORS_ORIGINS to a
|
||||||
// comma-separated list (e.g. "http://localhost:5173,http://192.168.1.50:5173") to let a
|
// comma-separated list (e.g. "http://localhost:5173,http://192.168.1.50:5173") to let a
|
||||||
// phone on the LAN reach the API — no code edit needed. Defaults to the local Vite origin.
|
// phone on the LAN reach the API — no code edit needed. Defaults to the local Vite origins
|
||||||
WEB_ORIGINS: webOrigins && webOrigins.length ? webOrigins : ['http://localhost:5173'],
|
// for the worker (5173) and admin (5174) SPAs.
|
||||||
|
WEB_ORIGINS:
|
||||||
|
webOrigins && webOrigins.length
|
||||||
|
? webOrigins
|
||||||
|
: ['http://localhost:5173', 'http://localhost:5174'],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ type WorkSessionRow = typeof workSessions.$inferSelect;
|
|||||||
|
|
||||||
export function toWorkSession(
|
export function toWorkSession(
|
||||||
row: WorkSessionRow,
|
row: WorkSessionRow,
|
||||||
opts: { activityName?: string | null; userName?: string | null; userEmail?: string | null } = {}
|
opts: { activityName?: string | null; userName?: string | null; userEmail?: string | null } = {},
|
||||||
): WorkSession {
|
): WorkSession {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ adminRoutes.get('/api/admin/sessions', async (c) => {
|
|||||||
activityName: r.activityName,
|
activityName: r.activityName,
|
||||||
userName: r.userName,
|
userName: r.userName,
|
||||||
userEmail: r.userEmail,
|
userEmail: r.userEmail,
|
||||||
})
|
}),
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ adminRoutes.get('/api/admin/sessions/active', async (c) => {
|
|||||||
activityName: r.activityName,
|
activityName: r.activityName,
|
||||||
userName: r.userName,
|
userName: r.userName,
|
||||||
userEmail: r.userEmail,
|
userEmail: r.userEmail,
|
||||||
})
|
}),
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import type { MeResponse } from '@solelog/shared';
|
import type { MeResponse, Role } from '@solelog/shared';
|
||||||
import { auth } from '../auth';
|
import { auth } from '../auth';
|
||||||
|
|
||||||
export const me = new Hono();
|
export const me = new Hono();
|
||||||
@@ -14,6 +14,7 @@ me.get('/api/me', async (c) => {
|
|||||||
id: session.user.id,
|
id: session.user.id,
|
||||||
email: session.user.email,
|
email: session.user.email,
|
||||||
name: session.user.name,
|
name: session.user.name,
|
||||||
|
role: ((session.user as { role?: string | null }).role ?? 'worker') as Role,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return c.json(body);
|
return c.json(body);
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest';
|
|||||||
import { createApp } from '../src/app';
|
import { createApp } from '../src/app';
|
||||||
|
|
||||||
const ORIGIN = 'http://localhost:5173';
|
const ORIGIN = 'http://localhost:5173';
|
||||||
|
const ADMIN_ORIGIN = 'http://localhost:5174';
|
||||||
|
|
||||||
describe('cors', () => {
|
describe('cors', () => {
|
||||||
it('answers a CORS preflight for the SPA origin', async () => {
|
it('answers a CORS preflight for the SPA origin', async () => {
|
||||||
@@ -18,6 +19,20 @@ describe('cors', () => {
|
|||||||
expect(allowMethods).toContain('GET');
|
expect(allowMethods).toContain('GET');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('answers a CORS preflight for the admin SPA origin', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const res = await app.request('/api/activities', {
|
||||||
|
method: 'OPTIONS',
|
||||||
|
headers: {
|
||||||
|
Origin: ADMIN_ORIGIN,
|
||||||
|
'Access-Control-Request-Method': 'GET',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.headers.get('access-control-allow-origin')).toBe(ADMIN_ORIGIN);
|
||||||
|
const allowMethods = res.headers.get('access-control-allow-methods') ?? '';
|
||||||
|
expect(allowMethods).toContain('GET');
|
||||||
|
});
|
||||||
|
|
||||||
it('exposes set-auth-token to the SPA origin', async () => {
|
it('exposes set-auth-token to the SPA origin', async () => {
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
const res = await app.request('/api/activities', {
|
const res = await app.request('/api/activities', {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ async function completedSession(
|
|||||||
token: string,
|
token: string,
|
||||||
activityId: number,
|
activityId: number,
|
||||||
insoleType: string,
|
insoleType: string,
|
||||||
durationSeconds: number
|
durationSeconds: number,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const startRes = await app.request('/api/sessions/start', {
|
const startRes = await app.request('/api/sessions/start', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -47,13 +47,13 @@ describe('csv export', () => {
|
|||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.headers.get('content-type')).toContain('text/csv');
|
expect(res.headers.get('content-type')).toContain('text/csv');
|
||||||
expect(res.headers.get('content-disposition')).toBe(
|
expect(res.headers.get('content-disposition')).toBe(
|
||||||
'attachment; filename="insole-production-report.csv"'
|
'attachment; filename="insole-production-report.csv"',
|
||||||
);
|
);
|
||||||
|
|
||||||
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');
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export async function createTestUser(email: string, role: 'worker' | 'admin' = '
|
|||||||
export async function authToken(
|
export async function authToken(
|
||||||
app: Hono,
|
app: Hono,
|
||||||
email: string,
|
email: string,
|
||||||
role: 'worker' | 'admin' = 'worker'
|
role: 'worker' | 'admin' = 'worker',
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
await createTestUser(email, role);
|
await createTestUser(email, role);
|
||||||
const signin = await app.request('/api/auth/sign-in/email', {
|
const signin = await app.request('/api/auth/sign-in/email', {
|
||||||
@@ -47,7 +47,7 @@ export function bearer(token: string): Record<string, string> {
|
|||||||
// Insert an activity straight into the DB (test setup that should not depend on authz).
|
// Insert an activity straight into the DB (test setup that should not depend on authz).
|
||||||
export async function seedActivity(
|
export async function seedActivity(
|
||||||
name: string,
|
name: string,
|
||||||
insoleTypes: string[] = ['Kurk', 'Berk', '3D']
|
insoleTypes: string[] = ['Kurk', 'Berk', '3D'],
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const [row] = await db.insert(activities).values({ name, insoleTypes }).returning();
|
const [row] = await db.insert(activities).values({ name, insoleTypes }).returning();
|
||||||
return row.id;
|
return row.id;
|
||||||
|
|||||||
@@ -19,4 +19,24 @@ describe('GET /api/me', () => {
|
|||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.user.email).toBe(email);
|
expect(body.user.email).toBe(email);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns role "worker" for a worker token', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const token = await authToken(app, 'worker-role@example.com', 'worker');
|
||||||
|
|
||||||
|
const res = await app.request('/api/me', { headers: bearer(token) });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.user.role).toBe('worker');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns role "admin" for an admin token', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const token = await authToken(app, 'admin-role@example.com', 'admin');
|
||||||
|
|
||||||
|
const res = await app.request('/api/me', { headers: bearer(token) });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.user.role).toBe('admin');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ describe('seed', () => {
|
|||||||
|
|
||||||
await seed();
|
await seed();
|
||||||
expect(await db.select().from(user).where(eq(user.email, 'admin@solelog.local'))).toHaveLength(
|
expect(await db.select().from(user).where(eq(user.email, 'admin@solelog.local'))).toHaveLength(
|
||||||
1
|
1,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
@@ -220,7 +378,7 @@ describe('session reads', () => {
|
|||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body).toHaveLength(2);
|
expect(body).toHaveLength(2);
|
||||||
expect(new Date(body[0].start_time).getTime()).toBeGreaterThan(
|
expect(new Date(body[0].start_time).getTime()).toBeGreaterThan(
|
||||||
new Date(body[1].start_time).getTime()
|
new Date(body[1].start_time).getTime(),
|
||||||
);
|
);
|
||||||
expect(body[0].activity_name).toBe('Slijpen');
|
expect(body[0].activity_name).toBe('Slijpen');
|
||||||
expect(body[1].activity_name).toBe('Frezen');
|
expect(body[1].activity_name).toBe('Frezen');
|
||||||
|
|||||||
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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ function renderApp() {
|
|||||||
return render(
|
return render(
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<App />
|
<App />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -28,10 +28,7 @@ describe('api client', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('throws ApiError on a non-2xx response', async () => {
|
it('throws ApiError on a non-2xx response', async () => {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 401 })));
|
||||||
'fetch',
|
|
||||||
vi.fn().mockResolvedValue(new Response(null, { status: 401 })),
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(apiFetch('/api/me')).rejects.toMatchObject({ status: 401 });
|
await expect(apiFetch('/api/me')).rejects.toMatchObject({ status: 401 });
|
||||||
await expect(apiFetch('/api/me')).rejects.toBeInstanceOf(ApiError);
|
await expect(apiFetch('/api/me')).rejects.toBeInstanceOf(ApiError);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3000';
|
|||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
public status: number,
|
public status: number,
|
||||||
message: string
|
message: string,
|
||||||
) {
|
) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = 'ApiError';
|
this.name = 'ApiError';
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ function renderAccount() {
|
|||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<Account />
|
<Account />
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -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,13 +100,23 @@ 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),
|
||||||
|
);
|
||||||
mockedUseStopSession.mockReturnValue(mutation<ReturnType<typeof useStopSession>>(stopMutate));
|
mockedUseStopSession.mockReturnValue(mutation<ReturnType<typeof useStopSession>>(stopMutate));
|
||||||
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 +204,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:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Insole Production Time Tracker — Rebuild Roadmap & Project Overview
|
# Insole Production Time Tracker — Rebuild Roadmap & Project Overview
|
||||||
|
|
||||||
- **Created:** 2026-06-17
|
- **Created:** 2026-06-17
|
||||||
- **Status:** Approved — living project doc; Phases 0–2 implemented (`docs/plans/phase-2-accounts-roles.md`)
|
- **Status:** Approved — living project doc; Phases 0–2 implemented + Phase **3a** implemented (`docs/superpowers/plans/2026-06-17-phase-3a-admin-panel.md`) + server-authoritative **pause accounting** (worked-vs-paused duration), **reorderable handelingen** (admin ↑/↓), and the **login-tab fix** landed (`docs/superpowers/plans/2026-06-17-pause-reorder-loginfix.md`)
|
||||||
- **Type:** Greenfield rebuild of an inherited app
|
- **Type:** Greenfield rebuild of an inherited app
|
||||||
- **Tracked in git** under `docs/` (the project's documentation source of truth).
|
- **Tracked in git** under `docs/` (the project's documentation source of truth).
|
||||||
|
|
||||||
@@ -174,9 +174,23 @@ Each phase keeps the system working and is its own spec → plan → build cycle
|
|||||||
an admin account exists; admin manages users via `/api/auth/admin/*` and sees all sessions via
|
an admin account exists; admin manages users via `/api/auth/admin/*` and sees all sessions via
|
||||||
`/api/admin/sessions`. Public sign-up is closed; activity writes are admin-only.
|
`/api/admin/sessions`. Public sign-up is closed; activity writes are admin-only.
|
||||||
_Done when:_ workers see only their own sessions; an admin account exists.
|
_Done when:_ workers see only their own sessions; an admin account exists.
|
||||||
- **Phase 3 — Admin panel.** The React admin app: live active-work view, reports/export,
|
- **Phase 3 — Admin panel.** The React admin app (`apps/admin`, Vite + React, dev port
|
||||||
user management, **manual entry/edit (the fallback)**. _Done when:_ an admin can see
|
5174): live active-work view, reports/export, user management, **activity management**,
|
||||||
who's working now, manage users, and hand-correct a session.
|
**manual entry/edit (the fallback)**.
|
||||||
|
**3a implemented** (`apps/admin`; plan `docs/superpowers/plans/2026-06-17-phase-3a-admin-panel.md`):
|
||||||
|
admin-only login (rejects non-admins via `role` on `/api/me`), the sidebar shell, the live
|
||||||
|
active-work view (`/api/admin/sessions/active`, 5 s auto-refresh, read-only), and activity
|
||||||
|
management (handelingen CRUD on `/api/activities`). **3b remaining:** reports/export
|
||||||
|
(all-users filtered CSV — current `/api/export` is self-scoped), user management
|
||||||
|
(better-auth `/api/auth/admin/*`), and manual entry/edit + admin stop/fix of a running
|
||||||
|
session (needs new backend endpoints).
|
||||||
|
Activity management (add/edit/delete handelingen + their `insole_types`) was removed from the
|
||||||
|
worker client in the Phase 2 follow-up because it is admin-only; it must be **ported here**. The
|
||||||
|
backend already exists (`/api/activities` writes are admin-gated; `useActivities`/the legacy
|
||||||
|
worker `Settings.tsx` at git `decb158`/`1631c16` are the UI reference), so this is a UI-only port.
|
||||||
|
User management likewise consumes the existing better-auth `/api/auth/admin/*` endpoints, and the
|
||||||
|
live view + reports consume `/api/admin/sessions[/active]`. _Done when:_ an admin can see who's
|
||||||
|
working now, manage users **and activities**, and hand-correct a session.
|
||||||
- **Phase 4 — Workbench scanning.** QR at the bench → select workbench/activity, with
|
- **Phase 4 — Workbench scanning.** QR at the bench → select workbench/activity, with
|
||||||
manual selection fallback. _Done when:_ scanning a bench QR pre-fills the session.
|
manual selection fallback. _Done when:_ scanning a bench QR pre-fills the session.
|
||||||
- **Phase 5 — Polish & deploy.** Reporting niceties, dependency slimming, push the
|
- **Phase 5 — Polish & deploy.** Reporting niceties, dependency slimming, push the
|
||||||
|
|||||||
99
docs/sessions/2026-06-17-pause-reorder-loginfix.md
Normal file
99
docs/sessions/2026-06-17-pause-reorder-loginfix.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# Session: 2026-06-17 — Pause accounting + reorderable handelingen + login-tab fix
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Ship four maintainer-reported items grouped around one root change (server-authoritative
|
||||||
|
pause):
|
||||||
|
|
||||||
|
1. **Admin shows paused sessions as running** — pause was client-only, so the admin live
|
||||||
|
view (filters `status='active'`) could not tell paused from running.
|
||||||
|
2. **Reorderable handelingen** — admin sets the order with ↑/↓ arrows; the worker picker
|
||||||
|
follows.
|
||||||
|
3. **Saved duration ignored pause** — stop stored wall-clock `(end − start)`, but the
|
||||||
|
stopwatch displayed *worked* time. Now save worked time and store paused time too.
|
||||||
|
4. **Wrong default tab after re-login** — logout on the Account tab left the URL at
|
||||||
|
`/account`, so the next login re-mounted there instead of the Stopwatch.
|
||||||
|
|
||||||
|
Spec: `docs/superpowers/specs/2026-06-17-pause-reorder-loginfix-design.md`;
|
||||||
|
plan: `docs/superpowers/plans/2026-06-17-pause-reorder-loginfix.md`.
|
||||||
|
|
||||||
|
## Work done
|
||||||
|
|
||||||
|
Implemented task-by-task per the plan (TDD throughout), one commit per task:
|
||||||
|
|
||||||
|
- **Task 1 — Contracts + schema + migration + mappers** (`0d82b6e`). `@solelog/shared`:
|
||||||
|
`WorkSession` gains `paused_seconds: number` + `paused_at: string | null`; `Activity`
|
||||||
|
gains `sort_order: number`; new `ReorderActivitiesInput`. Schema: `work_sessions`
|
||||||
|
`paused_seconds` (int NOT NULL default 0) + `paused_at` (timestamp_ms nullable);
|
||||||
|
`activities.sort_order` (int NOT NULL default 0). Migration `0003` generated via
|
||||||
|
`db:generate` and applied. `toWorkSession`/`toActivity` map the new fields.
|
||||||
|
- **Task 2 — Pause/resume endpoints + stop math + CSV column** (`974ecb1`).
|
||||||
|
`POST /api/sessions/:id/pause` (active + not paused → set `paused_at`, else 409),
|
||||||
|
`POST /api/sessions/:id/resume` (paused → accumulate `paused_seconds`, clear `paused_at`,
|
||||||
|
else 409). `stop` folds any open pause span into `paused_seconds`, then
|
||||||
|
`duration_seconds = max(0, wall − paused_seconds)`. `/api/export` gains a
|
||||||
|
`Paused Duration` column (after `Total Duration`).
|
||||||
|
- **Task 3 — Orderable activities + reorder endpoint** (`56e0162`). `GET /api/activities`
|
||||||
|
orders by `(sort_order, name)`; `POST` appends with `max(sort_order)+1`;
|
||||||
|
`PUT /api/activities/reorder` (admin-gated, registered *before* `:id` routes) assigns
|
||||||
|
`sort_order = index`, validates the id set (unknown/missing → 400), and returns the
|
||||||
|
reordered list.
|
||||||
|
- **Task 4 — Worker Stopwatch server pause + recovery** (`ce396ec`). `usePauseSession` /
|
||||||
|
`useResumeSession` hooks (invalidate `['sessions']`); tapping the display calls
|
||||||
|
pause/resume; the local clock stays for snappy feel but the server is source of truth.
|
||||||
|
Recovery-on-load seeds `isPaused`/`pausedMs`/`pauseStartedMs` from `paused_at` /
|
||||||
|
`paused_seconds`.
|
||||||
|
- **Task 5 — Worker History paused line + login-tab fix** (`1765f40`). History card shows
|
||||||
|
a grey "Pauze H:MM:SS" pill when `paused_seconds > 0`. `AuthContext.signOut` does
|
||||||
|
`window.history.replaceState(null, '', '/')` before clearing auth so the next login lands
|
||||||
|
on the Stopwatch.
|
||||||
|
- **Task 6 — Admin Live paused state + login-tab fix** (`0b0a6bd`). A "Gepauzeerd" badge
|
||||||
|
when `paused_at` is set; the elapsed timer freezes (worked =
|
||||||
|
`(paused_at − start) − paused_seconds`) and a paused total is shown. Admin
|
||||||
|
`AuthContext.signOut` gets the same path reset (lands on Live).
|
||||||
|
- **Task 7 — Admin Activities ↑/↓ reorder** (`e48df48`). `useReorderActivities()` →
|
||||||
|
`PUT /api/activities/reorder`. Each non-editing row gets ↑/↓ buttons (aria-labels
|
||||||
|
"Verplaats <naam> omhoog/omlaag"), disabled at the ends, swapping with the neighbour and
|
||||||
|
firing the mutation.
|
||||||
|
- **Task 8 — Docs, lint, verification** (this task). Lint/format on the feature files, full
|
||||||
|
green matrix, an in-process live smoke, and this session log + roadmap note.
|
||||||
|
|
||||||
|
## Verification (Task 8)
|
||||||
|
|
||||||
|
- `npx oxlint` — clean (exit 0).
|
||||||
|
- `npx oxfmt` on the feature-changed files only — reformatted two Task 4 files
|
||||||
|
(`apps/worker/src/screens/Stopwatch.tsx` + `.test.tsx`) that carried a stray trailing
|
||||||
|
comma after the last call argument (es5 trailing-comma style strips it); pure formatting,
|
||||||
|
worker tests + typecheck stay green afterward. All other feature files were already clean.
|
||||||
|
- `yarn workspace @solelog/api typecheck` — pass; `test` — **60 passed** (12 files).
|
||||||
|
- `yarn workspace @solelog/worker typecheck` — pass; `test` — **28 passed** (8 files);
|
||||||
|
`build` — pass (vite, 91 modules).
|
||||||
|
- `yarn workspace @solelog/admin typecheck` — pass; `test` — **21 passed** (5 files);
|
||||||
|
`build` — pass (vite, 89 modules).
|
||||||
|
- **Live smoke** — driven **in-process** (`createApp()` + `app.request`) against a real
|
||||||
|
on-disk SQLite file freshly migrated to `0003`; no server started, so port 3000 was never
|
||||||
|
bound (avoids the Windows libsql lock trap). Worker: start → pause (`paused_at` set, still
|
||||||
|
active) → resume (`paused_seconds = 2`, `paused_at` cleared) → stop
|
||||||
|
(`duration_seconds = 2`, `paused_seconds = 2`, wall ≈ 4; `duration + paused ≈ wall` and
|
||||||
|
`duration < wall`). Admin: `PUT /api/activities/reorder` → `GET /api/activities` reflects
|
||||||
|
the new order; a worker reorder → 403. The smoke script + temp DB were deleted afterward;
|
||||||
|
port 3000 confirmed free.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
The feature is implemented and green across all three workspaces. Pause is now
|
||||||
|
server-authoritative: the admin live view shows a "Gepauzeerd" badge with a frozen timer,
|
||||||
|
the stored `duration_seconds` is worked time (paused time stored separately and surfaced in
|
||||||
|
the worker History and the CSV `Paused Duration` column), admins reorder handelingen with
|
||||||
|
↑/↓ arrows (the worker picker inherits the order), and logging out resets the route to `/`
|
||||||
|
in both clients so the next login lands on Stopwatch (worker) / Live (admin).
|
||||||
|
|
||||||
|
The two unrelated working-tree edits to `.env.prod.example` / `docker-compose.prod.yml`
|
||||||
|
(deploy SQLite bind-mount config) were left untouched — out of this feature's scope.
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
- Phase 3b inherits the paused fields through `/api/admin/sessions` (via `toWorkSession`):
|
||||||
|
the all-sessions list / reports view should show worked + paused per the design.
|
||||||
|
- Admin pause/resume/stop of *another worker's* session remains Phase 3b (manual-entry /
|
||||||
|
admin-control work); pause stayed a worker action here.
|
||||||
72
docs/sessions/2026-06-17-phase-3a-admin-panel.md
Normal file
72
docs/sessions/2026-06-17-phase-3a-admin-panel.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# Session: 2026-06-17 — Phase 3a (Admin panel MVP)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Build `apps/admin`: a Vite + React desktop SPA where an admin logs in, watches who is
|
||||||
|
working **live** (auto-refreshing, read-only), and manages **handelingen** (activities).
|
||||||
|
Everything rides on existing backend endpoints plus one tiny backend touch (`role` on
|
||||||
|
`/api/me`). Reports/export, user management, and manual session entry/edit are deferred to
|
||||||
|
Phase 3b. Spec: `docs/superpowers/specs/2026-06-17-phase-3a-admin-panel-design.md`;
|
||||||
|
plan: `docs/superpowers/plans/2026-06-17-phase-3a-admin-panel.md`.
|
||||||
|
|
||||||
|
## Work done
|
||||||
|
|
||||||
|
Implemented Phase 3a task-by-task per the plan (TDD throughout):
|
||||||
|
|
||||||
|
- **Task 1 — `role` on `/api/me` + admin-origin CORS** (`02b7522`). Added `role: Role` to
|
||||||
|
`PublicUser` in `@solelog/shared`; `apps/api/src/routes/me.ts` now returns the session
|
||||||
|
user's role (default `'worker'`). Added `http://localhost:5174` (admin dev origin) to the
|
||||||
|
default `WEB_ORIGINS` in `env.ts` + `.env.example` (it drives both `hono/cors` and
|
||||||
|
better-auth `trustedOrigins`). Extended `me.test.ts` and `cors.test.ts`. Worker client
|
||||||
|
ignores the extra field — no worker change needed.
|
||||||
|
- **Task 2 — Scaffold `apps/admin`** (`682a9dc`). New `@solelog/admin` workspace mirroring
|
||||||
|
`apps/worker`'s toolchain (Vite 7, React 18.3, react-router 6, react-query 5, vitest 3,
|
||||||
|
TS 5.7). Copied `lib/api.ts` + `lib/auth-storage.ts` verbatim (shared `solelog.token`
|
||||||
|
key; separate localStorage because the admin runs on a different port). Dev port **5174**.
|
||||||
|
- **Task 3 — Auth context + admin gate + Login** (`77659ed`). `auth/AuthContext.tsx`:
|
||||||
|
`signIn` calls the worker `signIn`, fetches `/api/me`, and rejects non-admins
|
||||||
|
(`clearToken()` + throw) so `Login` shows "Geen toegang — alleen beheerders."; other
|
||||||
|
failures show "Inloggen mislukt".
|
||||||
|
- **Task 4 — Sidebar shell + routing** (`286e2d2`). Left-sidebar shell: brand "SoleLog
|
||||||
|
Admin", `NavLink`s **Live** / **Handelingen**, a muted "Binnenkort" group (Rapporten /
|
||||||
|
Gebruikers / Handmatig) hinting 3b, and a header strip with the signed-in email + an
|
||||||
|
Uitloggen button (`aria-label="Uitloggen"`).
|
||||||
|
- **Task 5 — Live active-work view** (`67dd0d3`). `useActiveSessions()` polls
|
||||||
|
`/api/admin/sessions/active` with `refetchInterval: 5000`; `lib/elapsed.ts` `formatTime`
|
||||||
|
ported verbatim from the worker stopwatch. One card per session (worker name, activity,
|
||||||
|
insole-type pill, pair count, ticking elapsed timer). Header "Actief nu (N)"; empty state
|
||||||
|
"Niemand is nu aan het werk.".
|
||||||
|
- **Task 6 — Activity management** (`c0d9d21`). Ported the legacy worker `Settings.tsx`
|
||||||
|
(git `decb158`) near-verbatim: `api/activities.ts` hooks (`useActivities` +
|
||||||
|
create/update/delete, all invalidating `['activities']`) and `screens/Activities.tsx`
|
||||||
|
(add form with insole-type toggles, list with inline edit, delete-with-confirm). Title
|
||||||
|
"Handelingen".
|
||||||
|
- **Task 7 — Docs, lint, verification** (this task). Lint/format clean, full green matrix,
|
||||||
|
live smoke, docs.
|
||||||
|
|
||||||
|
## Verification (Task 7)
|
||||||
|
|
||||||
|
- `npx oxlint` — clean (exit 0).
|
||||||
|
- `npx oxfmt --list-different` over the phase-3a files — nothing to change (all formatted).
|
||||||
|
- `yarn workspace @solelog/api typecheck` — pass; `test` — **46 passed** (11 files).
|
||||||
|
- `yarn workspace @solelog/admin typecheck` — pass; `test` — **14 passed** (5 files);
|
||||||
|
`build` — pass (`vite build`, 89 modules).
|
||||||
|
- `yarn workspace @solelog/worker test` (regression) — **22 passed** (7 files).
|
||||||
|
- **Live smoke** (API started, seeded, then server tree killed + port 3000 freed):
|
||||||
|
- admin sign-in → `GET /api/me` → `{ ..., "role": "admin" }`.
|
||||||
|
- `GET /api/admin/sessions/active` with the admin bearer → `[]`, HTTP 200.
|
||||||
|
- worker sign-in → `GET /api/me` → `{ ..., "role": "worker" }`.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
Phase 3a is implemented and green. An admin can sign in to the admin app, see who is
|
||||||
|
working right now (5s auto-refresh, read-only), and add/edit/delete handelingen; a worker
|
||||||
|
who signs in with valid credentials is rejected with "Geen toegang". Roadmap Phase 3 status
|
||||||
|
updated to "3a implemented; 3b remaining"; `apps/admin/README.md` filled.
|
||||||
|
|
||||||
|
## Next (Phase 3b)
|
||||||
|
|
||||||
|
- Reports + all-users filtered CSV export (current `/api/export` is self-scoped).
|
||||||
|
- User management via better-auth `/api/auth/admin/*`.
|
||||||
|
- Manual session entry/edit + admin stop/fix of another worker's session (needs new
|
||||||
|
backend endpoints).
|
||||||
320
docs/superpowers/plans/2026-06-17-pause-reorder-loginfix.md
Normal file
320
docs/superpowers/plans/2026-06-17-pause-reorder-loginfix.md
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
# Pause Accounting + Reorderable Handelingen + Login-Tab Fix — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** Implement task-by-task with TDD. Steps use checkbox (`- [ ]`).
|
||||||
|
> Spec: `docs/superpowers/specs/2026-06-17-pause-reorder-loginfix-design.md`.
|
||||||
|
|
||||||
|
**Goal:** Make pause server-authoritative (admin sees "Gepauzeerd"; stop saves worked time +
|
||||||
|
stores paused), let admins reorder handelingen with ↑/↓ arrows (worker picker follows), and
|
||||||
|
fix the worker landing on Account instead of Stopwatch after re-login.
|
||||||
|
|
||||||
|
**Architecture:** SQLite columns + new user-scoped pause/resume endpoints + changed stop math;
|
||||||
|
`activities.sort_order` + an admin-gated reorder endpoint; worker Stopwatch switches to
|
||||||
|
server pause; small UI additions in worker History + admin Live; one-line router reset on
|
||||||
|
signOut in both clients.
|
||||||
|
|
||||||
|
**Tech Stack:** Hono + Drizzle + libsql (api), Vite+React+react-query (worker, admin),
|
||||||
|
`@solelog/shared` zod contracts, vitest. Yarn 4 monorepo.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- **TDD**: failing test → see it fail → minimal implementation → green → commit.
|
||||||
|
- **Commit per task**, conventional-commit message; commit **locally only** (no push, no
|
||||||
|
remote, no amend of earlier commits); stage only your task's files.
|
||||||
|
- **oxlint + oxfmt on changed files only** (never repo-wide). Style: 2-space, single quotes,
|
||||||
|
semicolons, width 100, **ES5 trailing commas** — no trailing comma after the last function
|
||||||
|
param / last call arg; DO use them in multiline arrays/objects.
|
||||||
|
- **Dutch UI strings.**
|
||||||
|
- **Migrations are generated, not hand-written:** run `yarn workspace @solelog/api db:generate`
|
||||||
|
to emit `0003` from the edited schema, then `yarn workspace @solelog/api db:migrate`. Do not
|
||||||
|
hand-author the SQL.
|
||||||
|
- **Windows libsql lock trap:** if you start the API server, kill the process tree afterward
|
||||||
|
and free port 3000. Tests use in-process `app.request` and a temp DB — fine.
|
||||||
|
- Status stays `active|completed|discarded`; a paused session is **active** + `paused_at` set.
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
packages/shared/src/index.ts MODIFY WorkSession +paused_seconds/+paused_at; Activity +sort_order; ReorderActivitiesInput
|
||||||
|
apps/api/src/db/schema.ts MODIFY work_sessions +paused_seconds/+paused_at; activities +sort_order
|
||||||
|
apps/api/drizzle/0003_*.sql CREATE (generated)
|
||||||
|
apps/api/src/lib/work-session.ts MODIFY map paused fields
|
||||||
|
apps/api/src/routes/sessions.ts MODIFY pause/resume endpoints; stop math; CSV paused column
|
||||||
|
apps/api/src/routes/activities.ts MODIFY order by (sort_order,name); reorder endpoint; append on create
|
||||||
|
apps/api/test/{sessions,activities,export}.test.ts MODIFY
|
||||||
|
apps/worker/src/api/sessions.ts MODIFY usePauseSession/useResumeSession
|
||||||
|
apps/worker/src/screens/Stopwatch.tsx MODIFY server pause + recovery
|
||||||
|
apps/worker/src/screens/History.tsx MODIFY paused line
|
||||||
|
apps/worker/src/auth/AuthContext.tsx MODIFY reset path to / on signOut
|
||||||
|
apps/admin/src/api/admin-sessions.ts MODIFY (consumes paused fields — type only)
|
||||||
|
apps/admin/src/screens/Live.tsx MODIFY Gepauzeerd badge + frozen timer + paused total
|
||||||
|
apps/admin/src/auth/AuthContext.tsx MODIFY reset path to / on signOut
|
||||||
|
apps/admin/src/api/activities.ts MODIFY useReorderActivities
|
||||||
|
apps/admin/src/screens/Activities.tsx MODIFY ↑/↓ arrows
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Contracts + schema + migration + mappers
|
||||||
|
|
||||||
|
**Files:** `packages/shared/src/index.ts`, `apps/api/src/db/schema.ts`,
|
||||||
|
`apps/api/src/lib/work-session.ts`, `apps/api/src/routes/activities.ts` (toActivity),
|
||||||
|
`apps/api/drizzle/*` (generated), test `apps/api/test/work-session.test.ts` (create) or extend
|
||||||
|
`schema.test.ts`.
|
||||||
|
|
||||||
|
**Interfaces — Produces:**
|
||||||
|
- `WorkSession` gains `paused_seconds: number`, `paused_at: string | null`.
|
||||||
|
- `Activity` gains `sort_order: number`.
|
||||||
|
- `ReorderActivitiesInput = z.object({ ids: z.array(z.number().int()).min(1) })`.
|
||||||
|
- `toWorkSession` returns the paused fields; `toActivity` returns `sort_order`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing unit test** for `toWorkSession`: given a row with
|
||||||
|
`pausedSeconds: 120, pausedAt: null`, the result has `paused_seconds === 120` and
|
||||||
|
`paused_at === null`; with a `pausedAt` Date, `paused_at` is its ISO string. (Build a row
|
||||||
|
literal of `typeof workSessions.$inferSelect` shape.)
|
||||||
|
- [ ] **Step 2: Run — fail** (`paused_seconds` undefined / type error).
|
||||||
|
- [ ] **Step 3: Shared contracts** — add the two `WorkSession` fields (place after
|
||||||
|
`duration_seconds`), `Activity.sort_order` (after `created_at` is fine), and
|
||||||
|
`ReorderActivitiesInput`.
|
||||||
|
- [ ] **Step 4: Schema** — in `work_sessions` add
|
||||||
|
`pausedSeconds: integer('paused_seconds').notNull().default(0)` and
|
||||||
|
`pausedAt: integer('paused_at', { mode: 'timestamp_ms' })`; in `activities` add
|
||||||
|
`sortOrder: integer('sort_order').notNull().default(0)`.
|
||||||
|
- [ ] **Step 5: Mappers** — `toWorkSession`: add
|
||||||
|
`paused_seconds: row.pausedSeconds ?? 0`, `paused_at: row.pausedAt ? new Date(row.pausedAt).toISOString() : null`.
|
||||||
|
`toActivity` (in `activities.ts`): add `sort_order: row.sortOrder ?? 0`.
|
||||||
|
- [ ] **Step 6: Generate + apply migration** — `yarn workspace @solelog/api db:generate`
|
||||||
|
(creates `0003_*.sql`), then `yarn workspace @solelog/api db:migrate`. Confirm the SQL has
|
||||||
|
three `ALTER TABLE … ADD … `columns.
|
||||||
|
- [ ] **Step 7: Run tests + typecheck** — `yarn workspace @solelog/api test` + `typecheck`
|
||||||
|
green. (Existing tests must still pass; the new fields are additive.)
|
||||||
|
- [ ] **Step 8: Commit** — `feat(shared,api): add pause + sort_order columns and contracts`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Backend — pause/resume endpoints, stop math, CSV paused column
|
||||||
|
|
||||||
|
**Files:** `apps/api/src/routes/sessions.ts`; tests `apps/api/test/sessions.test.ts`,
|
||||||
|
`apps/api/test/export.test.ts`.
|
||||||
|
|
||||||
|
**Interfaces — Consumes** Task 1's columns/mappers. **Produces** `POST /api/sessions/:id/pause`,
|
||||||
|
`POST /api/sessions/:id/resume`; changed `stop`; `/api/export` Paused column.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing tests** in `sessions.test.ts` (use the helpers `createTestUser`/
|
||||||
|
`bearer`/`seedActivity`, start a session, then drive pause/resume/stop via `app.request`):
|
||||||
|
- pause sets `paused_at` non-null, status still `active`; pausing an already-paused → 409.
|
||||||
|
- resume clears `paused_at` and increases `paused_seconds`; resuming a running → 409.
|
||||||
|
- **stop math:** start, (simulate elapsed), pause then stop → `duration_seconds` excludes
|
||||||
|
the paused span and equals worked; `paused_seconds > 0`. (To make timing deterministic,
|
||||||
|
assert `duration_seconds + paused_seconds ≈ wall-clock` and `paused_seconds > 0` rather
|
||||||
|
than exact seconds, or stub times — keep it robust.)
|
||||||
|
- In `export.test.ts`: the CSV header includes `Paused Duration` and a paused session's row
|
||||||
|
carries the formatted paused value.
|
||||||
|
- [ ] **Step 2: Run — fail** (routes 404 / header missing).
|
||||||
|
- [ ] **Step 3: Implement pause** — mirror the `stop` handler's ownership/lookup:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Implement resume** — same guards; require `row.pausedAt` (else 409); set
|
||||||
|
`pausedSeconds: row.pausedSeconds + Math.round((Date.now() - new Date(row.pausedAt).getTime())/1000)`
|
||||||
|
and `pausedAt: null`.
|
||||||
|
- [ ] **Step 5: Change stop** — after loading the active row, fold any open pause span:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const now = Date.now();
|
||||||
|
const extraPaused = row.pausedAt ? 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);
|
||||||
|
// .set({ endTime, durationSeconds, pausedSeconds, pausedAt: null, status: 'completed' })
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: CSV paused column** — in `/api/export`, add `'Paused Duration'` to the header
|
||||||
|
(after `'Total Duration'`) and `formatDuration(session.pausedSeconds ?? 0)` to each data row
|
||||||
|
in the matching position.
|
||||||
|
- [ ] **Step 7: Run tests + typecheck — green.**
|
||||||
|
- [ ] **Step 8: Commit** — `feat(api): server-authoritative pause/resume + worked-time stop + CSV paused`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Backend — activities ordering + reorder + append-on-create
|
||||||
|
|
||||||
|
**Files:** `apps/api/src/routes/activities.ts`; test `apps/api/test/activities.test.ts`.
|
||||||
|
|
||||||
|
**Interfaces — Produces** `PUT /api/activities/reorder` (admin-gated); ordered `GET`;
|
||||||
|
`POST` appends.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing tests:** GET returns activities ordered by `sort_order` then name;
|
||||||
|
`PUT /api/activities/reorder` with `{ ids: [b, a] }` (admin token) sets their `sort_order`
|
||||||
|
so a later GET returns them in that order; reorder as a worker → 403; unknown id → 400; new
|
||||||
|
activity created via POST gets a `sort_order` greater than existing ones.
|
||||||
|
- [ ] **Step 2: Run — fail.**
|
||||||
|
- [ ] **Step 3: Order GET** — change `.orderBy(asc(activities.name))` to
|
||||||
|
`.orderBy(asc(activities.sortOrder), asc(activities.name))`.
|
||||||
|
- [ ] **Step 4: Append on create** — in POST, compute next order:
|
||||||
|
`const [{ max }] = await db.select({ max: sql<number>`COALESCE(MAX(${activities.sortOrder}), -1)` }).from(activities);`
|
||||||
|
then insert with `sortOrder: max + 1` (import `sql` from drizzle-orm).
|
||||||
|
- [ ] **Step 5: Reorder endpoint** (admin-gated like POST/PUT/DELETE):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
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);
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Register it BEFORE `/api/activities/:id` routes** so `reorder` isn't captured as an `:id`.
|
||||||
|
- [ ] **Step 6: Run tests + typecheck — green.**
|
||||||
|
- [ ] **Step 7: Commit** — `feat(api): orderable activities + admin reorder endpoint`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Worker — Stopwatch uses server pause + recovery
|
||||||
|
|
||||||
|
**Files:** `apps/worker/src/api/sessions.ts`, `apps/worker/src/screens/Stopwatch.tsx`;
|
||||||
|
tests `apps/worker/src/screens/Stopwatch.test.tsx`.
|
||||||
|
|
||||||
|
**Interfaces — Produces** `usePauseSession()`, `useResumeSession()` (mutations invalidating
|
||||||
|
`['sessions']`).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing tests** (mock `apiFetch`): tapping the display while running calls
|
||||||
|
`POST /api/sessions/:id/pause`; tapping while paused calls `…/resume`. Recovery: when the
|
||||||
|
active session query returns a session with `paused_at` set, the UI mounts in the paused
|
||||||
|
state (status pill shows the resume hint).
|
||||||
|
- [ ] **Step 2: Run — fail.**
|
||||||
|
- [ ] **Step 3: Add hooks** in `api/sessions.ts` mirroring `useStopSession`:
|
||||||
|
`usePauseSession`/`useResumeSession` → `apiFetch<WorkSession>('/api/sessions/${id}/pause'|'/resume', { method: 'POST' })`, `onSuccess` invalidate `['sessions']`.
|
||||||
|
- [ ] **Step 4: Wire `handleTapDisplay`** — on pause call `pauseSession.mutate(sessionId)`; on
|
||||||
|
resume call `resumeSession.mutate(sessionId)`. Keep the optimistic local `isPaused`/`pausedMs`
|
||||||
|
bookkeeping for the live clock, but the mutation is the source of truth.
|
||||||
|
- [ ] **Step 5: Recovery** — in the active-session recovery effect, set
|
||||||
|
`isPaused = !!session.paused_at`, seed `pausedMs` from `session.paused_seconds * 1000`, and if
|
||||||
|
`paused_at` set, seed `pauseStartedMs` from it so the frozen clock matches.
|
||||||
|
- [ ] **Step 6: Run worker tests + typecheck + build — green.**
|
||||||
|
- [ ] **Step 7: Commit** — `feat(worker): server-authoritative pause/resume on the stopwatch`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Worker — History paused line + login-tab fix
|
||||||
|
|
||||||
|
**Files:** `apps/worker/src/screens/History.tsx`, `apps/worker/src/auth/AuthContext.tsx`;
|
||||||
|
tests `apps/worker/src/screens/History.test.tsx`, `apps/worker/src/auth/*` or
|
||||||
|
`apps/worker/src/App.test.tsx`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing tests:** a session with `paused_seconds > 0` renders a "Pauze …"
|
||||||
|
label on its History card (none when 0). Login-tab: after `signOut`, `window.location.pathname`
|
||||||
|
is `/` (set pathname to `/account` first via `history.replaceState`).
|
||||||
|
- [ ] **Step 2: Run — fail.**
|
||||||
|
- [ ] **Step 3: History** — in `SessionCard`, when `session.paused_seconds > 0`, render an
|
||||||
|
extra grey pill/line `Pauze {formatDuration(session.paused_seconds)}` (reuse the local
|
||||||
|
`formatDuration`). The existing duration pill stays = worked time.
|
||||||
|
- [ ] **Step 4: Login-tab fix** — in `AuthContext.signOut`, before `clearToken()/setIsAuthed(false)`,
|
||||||
|
add `window.history.replaceState(null, '', '/')`.
|
||||||
|
- [ ] **Step 5: Run worker tests + typecheck + build — green.**
|
||||||
|
- [ ] **Step 6: Commit** — `fix(worker): show paused time in history; reset to stopwatch on logout`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Admin — Live paused state + login-tab fix
|
||||||
|
|
||||||
|
**Files:** `apps/admin/src/screens/Live.tsx`, `apps/admin/src/auth/AuthContext.tsx`;
|
||||||
|
tests `apps/admin/src/screens/Live.test.tsx`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing tests:** an active session with `paused_at` set renders a "Gepauzeerd"
|
||||||
|
badge and its timer is frozen (does not depend on the 1s tick); `paused_seconds > 0` shows a
|
||||||
|
paused total. After admin `signOut`, `window.location.pathname` is `/`.
|
||||||
|
- [ ] **Step 2: Run — fail.**
|
||||||
|
- [ ] **Step 3: Live freeze + badge** — compute elapsed as
|
||||||
|
`const base = session.paused_at ? Date.parse(session.paused_at) : now;`
|
||||||
|
`const worked = Math.max(0, Math.floor((base - Date.parse(session.start_time)) / 1000) - session.paused_seconds);`
|
||||||
|
When `paused_at` is set, render a "Gepauzeerd" badge (amber) and show
|
||||||
|
`Pauze {formatTime(session.paused_seconds)}`.
|
||||||
|
- [ ] **Step 4: Admin login-tab fix** — `AuthContext.signOut` gets
|
||||||
|
`window.history.replaceState(null, '', '/')` before clearing auth.
|
||||||
|
- [ ] **Step 5: Run admin tests + typecheck + build — green.**
|
||||||
|
- [ ] **Step 6: Commit** — `feat(admin): show paused sessions in live view; reset to live on logout`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Admin — Activities ↑/↓ reorder
|
||||||
|
|
||||||
|
**Files:** `apps/admin/src/api/activities.ts`, `apps/admin/src/screens/Activities.tsx`;
|
||||||
|
test `apps/admin/src/screens/Activities.test.tsx`.
|
||||||
|
|
||||||
|
**Interfaces — Produces** `useReorderActivities()` → `PUT /api/activities/reorder`
|
||||||
|
(`{ ids }`), invalidates `['activities']`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing test** (mock `apiFetch`): given activities `[A, B, C]`, clicking B's
|
||||||
|
"omhoog" (↑) calls `PUT /api/activities/reorder` with `{ ids: [B, A, C] }`; clicking the
|
||||||
|
first row's ↑ is disabled (no call); last row's ↓ disabled.
|
||||||
|
- [ ] **Step 2: Run — fail.**
|
||||||
|
- [ ] **Step 3: Hook** — add `useReorderActivities` in `api/activities.ts`:
|
||||||
|
`mutationFn: (ids: number[]) => apiFetch('/api/activities/reorder', { method: 'PUT', body: JSON.stringify({ ids }) })`,
|
||||||
|
invalidate `['activities']`.
|
||||||
|
- [ ] **Step 4: Arrows UI** — in the (non-editing) activity row, add ↑/↓ buttons
|
||||||
|
(aria-labels `Verplaats <naam> omhoog` / `omlaag`), disabled at the ends. On click, build the
|
||||||
|
reordered id array by swapping with the neighbour and call the mutation. Keep the existing
|
||||||
|
edit/delete buttons.
|
||||||
|
- [ ] **Step 5: Run admin tests + typecheck + build — green.**
|
||||||
|
- [ ] **Step 6: Commit** — `feat(admin): reorder handelingen with up/down arrows`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Docs, lint, verification
|
||||||
|
|
||||||
|
**Files:** `docs/roadmap.md` (note pause accounting + reorder landed), `apps/admin/README.md`
|
||||||
|
/ `apps/worker/README.md` (if behaviour notes belong), `docs/sessions/2026-06-17-pause-reorder-loginfix.md` (create).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Lint/format** — `npx oxlint` clean; `npx oxfmt` on changed files only.
|
||||||
|
- [ ] **Step 2: Full green** — `yarn workspace @solelog/api typecheck && test`;
|
||||||
|
`yarn workspace @solelog/worker typecheck && test`;
|
||||||
|
`yarn workspace @solelog/admin typecheck && test && build`.
|
||||||
|
- [ ] **Step 3: Live smoke (preferred)** — start API, seed; as a worker: start a session,
|
||||||
|
`POST …/pause`, `…/resume`, `…/stop`, confirm `duration_seconds` excludes paused and
|
||||||
|
`paused_seconds > 0`; as admin: `PUT /api/activities/reorder` and confirm `GET` order; then
|
||||||
|
**kill the server tree + free port 3000**.
|
||||||
|
- [ ] **Step 4: Docs** — session log (goal/work/verification/outcome), and a one-line roadmap
|
||||||
|
note. Keep the main note untouched (no Obsidian here — SoleLog uses `docs/` + Plane).
|
||||||
|
- [ ] **Step 5: Commit** — `docs: pause-accounting + reorder session log`.
|
||||||
|
|
||||||
|
## Self-Review notes
|
||||||
|
|
||||||
|
- `reorder` route registered before `:id` routes (Task 3) — else `reorder` parses as an id.
|
||||||
|
- Stop math folds an open pause span before subtracting (Task 2) — covers stop-while-paused.
|
||||||
|
- Worker keeps its local clock but the server now owns pause truth (Task 4); recovery seeds
|
||||||
|
from `paused_at`/`paused_seconds`.
|
||||||
|
- Admin "session views" today = the Live screen only (the all-sessions list is Phase 3b); the
|
||||||
|
paused fields already flow through `/api/admin/sessions` via `toWorkSession` (Task 1) so 3b
|
||||||
|
inherits them.
|
||||||
333
docs/superpowers/plans/2026-06-17-phase-3a-admin-panel.md
Normal file
333
docs/superpowers/plans/2026-06-17-phase-3a-admin-panel.md
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
# Phase 3a — Admin Panel (MVP) Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** Implement task-by-task with TDD. Steps use checkbox (`- [ ]`) syntax.
|
||||||
|
> Spec: `docs/superpowers/specs/2026-06-17-phase-3a-admin-panel-design.md`.
|
||||||
|
|
||||||
|
**Goal:** A new `apps/admin` desktop SPA where an admin logs in, watches who is working
|
||||||
|
live (auto-refreshing, read-only), and manages handelingen — on existing endpoints plus
|
||||||
|
`role` added to `/api/me`.
|
||||||
|
|
||||||
|
**Architecture:** Vite + React 18 + TS client (dev port 5174) mirroring `apps/worker`'s
|
||||||
|
toolchain. Bearer-token auth reused from worker; admin gate reads `role` from `/api/me`.
|
||||||
|
Left-sidebar shell. Live view polls `/api/admin/sessions/active`; activities CRUD via
|
||||||
|
`/api/activities`.
|
||||||
|
|
||||||
|
**Tech Stack:** Vite 7, React 18.3, react-router-dom 6, @tanstack/react-query 5, vitest 3,
|
||||||
|
@testing-library/react 16, TypeScript 5.7, `@solelog/shared` (zod contracts).
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- **Reuse, don't reinvent:** `apps/worker` is the canonical template — copy `lib/api.ts`,
|
||||||
|
`lib/auth-storage.ts`, tsconfig/vite/vitest configs, `test/setup.ts`, `main.tsx`
|
||||||
|
structure verbatim, adjusting only names/title/port.
|
||||||
|
- **Dutch UI strings** throughout (worker app is the reference for tone/terms).
|
||||||
|
- **Lint/format:** oxlint + oxfmt — 2-space, single quotes, semicolons, width 100, **es5
|
||||||
|
trailing commas** (no trailing comma in function params or last call args). Run scoped to
|
||||||
|
changed files only; do **not** reformat unrelated files.
|
||||||
|
- **TDD:** write the failing test, see it fail, implement minimally, see it pass, commit.
|
||||||
|
- **Commit per task** with a conventional-commit message.
|
||||||
|
- **No backend change beyond `role` on `/api/me`** (Task 1). Live view is **read-only**.
|
||||||
|
- **Windows libsql lock trap:** any agent that starts the API server must kill the server
|
||||||
|
tree afterward (a lingering `tsx`/node holding `data/app.db` or port 3000 breaks the next
|
||||||
|
run). Prefer not starting the server unless verifying live.
|
||||||
|
- Admin dev port **5174** (worker uses 5173).
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
packages/shared/src/index.ts MODIFY add role to PublicUser
|
||||||
|
apps/api/src/routes/me.ts MODIFY return role
|
||||||
|
apps/api/test/me.test.ts MODIFY assert role present (create if absent)
|
||||||
|
|
||||||
|
apps/admin/ NEW workspace (mirror apps/worker)
|
||||||
|
package.json, index.html, .gitignore, README.md
|
||||||
|
tsconfig.json, tsconfig.app.json, tsconfig.node.json
|
||||||
|
vite.config.ts (port 5174), vitest.config.ts
|
||||||
|
src/
|
||||||
|
main.tsx, vite-env.d.ts, styles.css
|
||||||
|
test/setup.ts
|
||||||
|
lib/api.ts (copied from worker)
|
||||||
|
lib/auth-storage.ts (copied from worker)
|
||||||
|
lib/elapsed.ts (formatTime ported from worker stopwatch)
|
||||||
|
auth/AuthContext.tsx (signIn + admin-role gate)
|
||||||
|
api/me.ts (useMe / fetchMe)
|
||||||
|
api/admin-sessions.ts (useActiveSessions, refetchInterval 5000)
|
||||||
|
api/activities.ts (useActivities + create/update/delete)
|
||||||
|
components/Sidebar.tsx
|
||||||
|
screens/Login.tsx, Live.tsx, Activities.tsx
|
||||||
|
App.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Backend — `role` on `/api/me` + allow the admin origin (CORS)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `packages/shared/src/index.ts` (PublicUser)
|
||||||
|
- Modify: `apps/api/src/routes/me.ts`
|
||||||
|
- Modify: `apps/api/src/env.ts` (default `WEB_ORIGINS`), `apps/api/.env.example` (comment +
|
||||||
|
default `CORS_ORIGINS`)
|
||||||
|
- Test: `apps/api/test/me.test.ts` (extend), `apps/api/test/cors.test.ts` (extend)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `PublicUser` now has `role: Role`; `MeResponse.user.role` is `'worker' | 'admin'`.
|
||||||
|
The default CORS/trustedOrigins list includes the admin dev origin `http://localhost:5174`.
|
||||||
|
|
||||||
|
**Why CORS:** the admin app runs on dev port 5174 and calls the API on :3000 cross-origin.
|
||||||
|
`env.WEB_ORIGINS` (used for both `hono/cors` and better-auth `trustedOrigins`) currently
|
||||||
|
defaults to only `http://localhost:5173`, so without this the admin app's sign-in and every
|
||||||
|
request are blocked in the browser.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write/extend the failing test.** Using the test helpers
|
||||||
|
(`apps/api/test/helpers.ts`: `createTestUser`, `authToken`/`bearer`), assert that
|
||||||
|
`GET /api/me` with a worker token returns `user.role === 'worker'`, and with an admin
|
||||||
|
token returns `user.role === 'admin'`. (Create an admin via `auth.api.createUser` with
|
||||||
|
`role: 'admin'` — see `seed.ts` for the cast pattern.)
|
||||||
|
- [ ] **Step 2: Run it, watch it fail** (`role` undefined).
|
||||||
|
`yarn workspace @solelog/api test` (filter to the me test).
|
||||||
|
- [ ] **Step 3: Add `role` to `PublicUser`** in shared:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const PublicUser = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
email: z.string().email(),
|
||||||
|
name: z.string(),
|
||||||
|
role: Role,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Return `role` from the route.** In `apps/api/src/routes/me.ts`, add to the
|
||||||
|
`user` body: `role: ((session.user as { role?: string | null }).role ?? 'worker') as Role`
|
||||||
|
(import `Role`/`MeResponse` type from `@solelog/shared`). Keep the `MeResponse` typing.
|
||||||
|
- [ ] **Step 5: CORS — allow the admin origin.** Add `http://localhost:5174` to the
|
||||||
|
default `WEB_ORIGINS` array in `apps/api/src/env.ts`
|
||||||
|
(`['http://localhost:5173', 'http://localhost:5174']`) and to the `CORS_ORIGINS=` line +
|
||||||
|
comment in `apps/api/.env.example`. Extend `apps/api/test/cors.test.ts` with a case
|
||||||
|
asserting the preflight allows `http://localhost:5174` (parametrize or add a second
|
||||||
|
origin assertion). Run the cors test — green.
|
||||||
|
- [ ] **Step 6: Run tests — pass.** Also run `yarn workspace @solelog/api typecheck`.
|
||||||
|
- [ ] **Step 7: Confirm worker app unaffected** — `yarn workspace @solelog/worker test`
|
||||||
|
still green (it ignores the extra field).
|
||||||
|
- [ ] **Step 8: Commit** — `feat(api): include role in /api/me + allow admin origin in CORS`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Scaffold `apps/admin` workspace
|
||||||
|
|
||||||
|
**Files:** all new under `apps/admin/` (see File Structure). Copy from `apps/worker`.
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: an installable `@solelog/admin` workspace that builds and runs an empty app
|
||||||
|
shell; `apiFetch`, `signIn`, `getToken/setToken/clearToken` available.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Copy config + boilerplate from worker**, adjusting only identifiers:
|
||||||
|
- `package.json` → `"name": "@solelog/admin"`, same scripts/deps/devDeps as worker.
|
||||||
|
- `index.html` → `<title>SoleLog Admin</title>`.
|
||||||
|
- `tsconfig.json`, `tsconfig.app.json`, `tsconfig.node.json` → copy verbatim.
|
||||||
|
- `vite.config.ts` → `server: { host: true, port: 5174 }`.
|
||||||
|
- `vitest.config.ts`, `src/test/setup.ts`, `src/vite-env.d.ts` → copy verbatim.
|
||||||
|
- `.gitignore` → copy from worker.
|
||||||
|
- `src/lib/api.ts`, `src/lib/auth-storage.ts` → copy **verbatim** (token key
|
||||||
|
`solelog.token` is shared intentionally — same backend, same browser origin is fine;
|
||||||
|
admin runs on a different port so localStorage is separate anyway).
|
||||||
|
- [ ] **Step 2: Minimal `src/main.tsx`** (copy worker's; renders `<App/>` inside
|
||||||
|
`QueryClientProvider`) and a placeholder `src/App.tsx` returning `<div>SoleLog Admin</div>`
|
||||||
|
and an empty `src/styles.css`.
|
||||||
|
- [ ] **Step 3: Smoke test** `src/App.test.tsx`: renders App, expects "SoleLog Admin" text
|
||||||
|
(wrap in `QueryClientProvider`).
|
||||||
|
- [ ] **Step 4: Install + verify** — from repo root `yarn install`, then
|
||||||
|
`yarn workspace @solelog/admin test` (smoke passes), `yarn workspace @solelog/admin typecheck`,
|
||||||
|
`yarn workspace @solelog/admin build`.
|
||||||
|
- [ ] **Step 5: Commit** — `feat(admin): scaffold Vite+React admin workspace`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Auth context + admin gate + Login screen
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `apps/admin/src/api/me.ts`, `apps/admin/src/auth/AuthContext.tsx`,
|
||||||
|
`apps/admin/src/screens/Login.tsx`
|
||||||
|
- Modify: `apps/admin/src/App.tsx`
|
||||||
|
- Test: `apps/admin/src/auth/AuthContext.test.tsx` (or `Login.test.tsx`)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `signIn` from `lib/api`, `getToken/clearToken` from `lib/auth-storage`,
|
||||||
|
`MeResponse` from `@solelog/shared`.
|
||||||
|
- Produces: `useAuth(): { isAuthed, signIn, signOut }` where `signIn` rejects non-admins;
|
||||||
|
`fetchMe(): Promise<MeResponse>`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests** (mock `lib/api`):
|
||||||
|
- signing in as an admin (`/api/me` → `role: 'admin'`) sets `isAuthed` true;
|
||||||
|
- signing in as a worker (`role: 'worker'`) throws, clears the token, `isAuthed` false.
|
||||||
|
- [ ] **Step 2: Run — fail** (`AuthContext` not implemented).
|
||||||
|
- [ ] **Step 3: Implement `api/me.ts`:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { MeResponse } from '@solelog/shared';
|
||||||
|
import { apiFetch } from '../lib/api';
|
||||||
|
|
||||||
|
export function fetchMe(): Promise<MeResponse> {
|
||||||
|
return apiFetch<MeResponse>('/api/me');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Implement `auth/AuthContext.tsx`** — mirror worker's, but `signIn` does:
|
||||||
|
call `apiSignIn(email,password)`; then `const me = await fetchMe()`; if
|
||||||
|
`me.user.role !== 'admin'` → `clearToken()` and `throw new Error('not-admin')`; else
|
||||||
|
`setIsAuthed(true)`. `signOut` clears token + sets false. Initial `isAuthed` =
|
||||||
|
`getToken() !== null` (a stale worker token is harmless — the admin endpoints 403 and the
|
||||||
|
next `/api/me`-backed screen can sign out; keep 3a simple).
|
||||||
|
- [ ] **Step 5: Implement `screens/Login.tsx`** — copy worker's Login; change the catch to
|
||||||
|
set `'Geen toegang — alleen beheerders.'` when the error is the not-admin error, else
|
||||||
|
`'Inloggen mislukt'`. (Distinguish by error message/`instanceof`.)
|
||||||
|
- [ ] **Step 6: Wire `App.tsx`** — `AuthProvider` + `Gate` (authed → shell placeholder,
|
||||||
|
else `<Login/>`), following worker's `App.tsx`.
|
||||||
|
- [ ] **Step 7: Run tests — pass.** typecheck.
|
||||||
|
- [ ] **Step 8: Commit** — `feat(admin): bearer auth with admin-only gate + login screen`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Sidebar shell + routing
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `apps/admin/src/components/Sidebar.tsx`
|
||||||
|
- Modify: `apps/admin/src/App.tsx`, `apps/admin/src/styles.css`
|
||||||
|
- Test: `apps/admin/src/App.test.tsx` (replace the Task-2 smoke test)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `useAuth` (signOut), `useMe`/`fetchMe` for the signed-in email.
|
||||||
|
- Produces: an authed shell with `<nav>` containing **Live** and **Handelingen** links, a
|
||||||
|
header with the signed-in email + logout button, and a content `<Routes>` outlet.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing test** — with a token set and `apiFetch` mocked (`/api/me`
|
||||||
|
→ admin), the authed app shows nav items "Live" and "Handelingen"; clicking logout clears
|
||||||
|
the token. (Mock `react-router` via `MemoryRouter` or render through `App`.)
|
||||||
|
- [ ] **Step 2: Run — fail.**
|
||||||
|
- [ ] **Step 3: Implement `Sidebar.tsx`** — `<aside>` with brand "SoleLog Admin",
|
||||||
|
`NavLink`s to `/` ("Live") and `/handelingen` ("Handelingen") using an `tab-active`-style
|
||||||
|
active class, a muted disabled group (Rapporten / Gebruikers / Handmatig — "binnenkort"),
|
||||||
|
and a header strip with the signed-in email + a logout `button` (aria-label "Uitloggen").
|
||||||
|
- [ ] **Step 4: Update `App.tsx`** — authed shell = `<BrowserRouter>` with `Sidebar` +
|
||||||
|
`<main>` `<Routes>`: `/` → `Live` (placeholder for now is fine, real in Task 5),
|
||||||
|
`/handelingen` → `Activities` (placeholder, real in Task 6). Use placeholders that the
|
||||||
|
next tasks replace, OR sequence so Task 5/6 add the routes — either way keep tests green.
|
||||||
|
- [ ] **Step 5: Add sidebar/header CSS** to `styles.css` (port worker tokens: `:root`
|
||||||
|
vars, base body; new `.admin-shell` grid `220px 1fr`, `.sidebar`, `.nav-link`,
|
||||||
|
`.nav-link-active`, `.nav-disabled`, `.topbar`, `.btn-logout`).
|
||||||
|
- [ ] **Step 6: Run tests — pass.** typecheck + build.
|
||||||
|
- [ ] **Step 7: Commit** — `feat(admin): sidebar shell + routing`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Live active-work view
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `apps/admin/src/api/admin-sessions.ts`, `apps/admin/src/lib/elapsed.ts`,
|
||||||
|
`apps/admin/src/screens/Live.tsx`
|
||||||
|
- Modify: `apps/admin/src/App.tsx` (route), `apps/admin/src/styles.css`
|
||||||
|
- Test: `apps/admin/src/screens/Live.test.tsx`, `apps/admin/src/lib/elapsed.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `apiFetch`, `WorkSession` from `@solelog/shared`.
|
||||||
|
- Produces: `useActiveSessions()` (react-query, `refetchInterval: 5000`, queryKey
|
||||||
|
`['admin','sessions','active']`); `formatTime(seconds)` HH:MM:SS.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests:**
|
||||||
|
- `elapsed.test.ts`: `formatTime(0)==='00:00:00'`, `formatTime(3661)==='01:01:01'`.
|
||||||
|
- `Live.test.tsx` (mock `apiFetch`): given two active sessions (with `user_name`,
|
||||||
|
`activity_name`, `insole_type`, `pair_count`, `start_time`), renders a card per session
|
||||||
|
showing the worker name + activity + type; header "Actief nu (2)". With `[]`, shows
|
||||||
|
"Niemand is nu aan het werk.".
|
||||||
|
- [ ] **Step 2: Run — fail.**
|
||||||
|
- [ ] **Step 3: Implement `lib/elapsed.ts`** — port `formatTime` from worker
|
||||||
|
`lib/stopwatch.ts` (verbatim).
|
||||||
|
- [ ] **Step 4: Implement `api/admin-sessions.ts`:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import type { WorkSession } from '@solelog/shared';
|
||||||
|
import { apiFetch } from '../lib/api';
|
||||||
|
|
||||||
|
export function useActiveSessions() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['admin', 'sessions', 'active'],
|
||||||
|
queryFn: () => apiFetch<WorkSession[]>('/api/admin/sessions/active'),
|
||||||
|
refetchInterval: 5000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Implement `screens/Live.tsx`** — `useActiveSessions()`; loading "Laden…",
|
||||||
|
error "Kon gegevens niet laden.", empty "Niemand is nu aan het werk.". Header
|
||||||
|
"Actief nu (N)". Per session, a `.live-card` with worker name (`user_name`), activity
|
||||||
|
(`activity_name`), an insole-type pill, pair count, and a ticking elapsed timer: a `now`
|
||||||
|
state updated by `setInterval(…, 1000)` in a `useEffect`; elapsed =
|
||||||
|
`formatTime((now - Date.parse(start_time)) / 1000)`.
|
||||||
|
- [ ] **Step 6: Add route** `/` → `Live` in `App.tsx`; add `.live-card`/`.live-timer`/pill
|
||||||
|
CSS.
|
||||||
|
- [ ] **Step 7: Run tests — pass.** typecheck + build.
|
||||||
|
- [ ] **Step 8: Commit** — `feat(admin): live active-work view (5s refresh)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Activity management (port of legacy Settings)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `apps/admin/src/api/activities.ts`, `apps/admin/src/screens/Activities.tsx`
|
||||||
|
- Modify: `apps/admin/src/App.tsx` (route), `apps/admin/src/styles.css`
|
||||||
|
- Test: `apps/admin/src/screens/Activities.test.tsx`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `apiFetch`, `Activity`/`CreateActivityInput`/`InsoleType` from `@solelog/shared`.
|
||||||
|
- Produces: `useActivities()`, `useCreateActivity()`, `useUpdateActivity()`,
|
||||||
|
`useDeleteActivity()` (queryKey `['activities']`, mutations invalidate it).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests** (mock `apiFetch`): adding a handeling POSTs
|
||||||
|
`/api/activities` with `{name, insole_types}`; editing PUTs `/api/activities/:id`;
|
||||||
|
deleting (confirm stubbed true) DELETEs `/api/activities/:id`. Render shows existing
|
||||||
|
activities from the mocked GET.
|
||||||
|
- [ ] **Step 2: Run — fail.**
|
||||||
|
- [ ] **Step 3: Implement `api/activities.ts`** — recreate the hooks removed from the
|
||||||
|
worker (reference git `decb158:apps/worker/src/api/activities.ts`): `useActivities`
|
||||||
|
(GET `/api/activities`), `useCreateActivity` (POST), `useUpdateActivity` (PUT
|
||||||
|
`/api/activities/${id}` with `{ id, input }`), `useDeleteActivity` (DELETE), all
|
||||||
|
invalidating `['activities']`.
|
||||||
|
- [ ] **Step 4: Implement `screens/Activities.tsx`** — port `decb158:apps/worker/src/screens/Settings.tsx`
|
||||||
|
near-verbatim: `TypeToggles`/type pill helpers, add-form, list with inline edit, delete
|
||||||
|
with `window.confirm`. Title "Handelingen", subtitle "Beheer handelingen per zooltype".
|
||||||
|
Reuse the worker's `.activity-*`/`.btn-*`/`.field-*` class names (port the CSS).
|
||||||
|
- [ ] **Step 5: Add route** `/handelingen` → `Activities`; port the activity-management CSS
|
||||||
|
block from worker `styles.css` into admin `styles.css`.
|
||||||
|
- [ ] **Step 6: Run tests — pass.** typecheck + build.
|
||||||
|
- [ ] **Step 7: Commit** — `feat(admin): activity management (handelingen CRUD)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Docs, lint, and verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docs/roadmap.md`, `apps/admin/README.md`
|
||||||
|
- Create: `docs/sessions/2026-06-17-phase-3a-admin-panel.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Lint/format** — `npx oxlint` clean; `npx oxfmt` **scoped to changed
|
||||||
|
files** (do not touch unrelated files); fix any es5-trailing-comma issues.
|
||||||
|
- [ ] **Step 2: Full green** — `yarn workspace @solelog/api typecheck` + `test`;
|
||||||
|
`yarn workspace @solelog/admin typecheck` + `test` + `build`;
|
||||||
|
`yarn workspace @solelog/worker test` (regression).
|
||||||
|
- [ ] **Step 3: Live smoke (optional but preferred)** — start the API, seed
|
||||||
|
(`worker@solelog.local` / `admin@solelog.local`), `curl` `/api/me` with an admin bearer
|
||||||
|
to confirm `role`, and `/api/admin/sessions/active`. **Kill the server tree afterward**
|
||||||
|
(Windows lock trap).
|
||||||
|
- [ ] **Step 4: Docs** — update `docs/roadmap.md` (Phase 3 → "3a implemented; 3b
|
||||||
|
remaining"); write the session log; fill `apps/admin/README.md` (dev on :5174, admin-only
|
||||||
|
login, what 3a covers).
|
||||||
|
- [ ] **Step 5: Commit** — `docs(admin): phase 3a session log + roadmap status`.
|
||||||
|
|
||||||
|
## Self-Review notes
|
||||||
|
|
||||||
|
- Type consistency: `formatTime` (Task 5) matches worker's name; `useActiveSessions` here
|
||||||
|
hits `/api/admin/sessions/active` (admin), distinct from worker's same-named hook on
|
||||||
|
`/api/sessions/active` — intentional, different app.
|
||||||
|
- `PublicUser.role` (Task 1) is consumed by the admin gate (Task 3) — defined before use.
|
||||||
|
- Activity hooks (Task 6) mirror the removed worker hooks exactly so the ported Settings
|
||||||
|
component compiles unchanged.
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# Pause Accounting + Reorderable Handelingen + Login-Tab Fix — Design
|
||||||
|
|
||||||
|
- **Created:** 2026-06-17
|
||||||
|
- **Status:** Approved (brainstorming) — ready for implementation plan
|
||||||
|
- **Tracker:** Plane (workspace `solelog`, project SoleLog)
|
||||||
|
- **Touches:** `packages/shared`, `apps/api`, `apps/worker`, `apps/admin`
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Four maintainer-reported items, grouped because three of them share one root change
|
||||||
|
(server-authoritative pause):
|
||||||
|
|
||||||
|
1. **Admin shows paused sessions as running.** Pause is client-only today, so the admin
|
||||||
|
live view (filters `status='active'`) can't tell paused from running.
|
||||||
|
2. **Reorderable handelingen.** Admin sets the order; the worker picker follows it.
|
||||||
|
3. **Saved duration ignores pause.** Stop stores wall-clock `(end − start)`, but the
|
||||||
|
stopwatch displays *worked* time. Save worked time, and store paused time too
|
||||||
|
("gewerkt 1:00 · pauze 0:20").
|
||||||
|
4. **Wrong default tab after re-login.** Logout happens on the Account tab, leaving the URL
|
||||||
|
at `/account`; the next login re-mounts there instead of Stopwatch.
|
||||||
|
|
||||||
|
## Root-cause findings (from current code)
|
||||||
|
|
||||||
|
- `apps/worker/src/screens/Stopwatch.tsx`: pause is **purely client-side** (`pausedMs`
|
||||||
|
accumulator); the server session stays `status='active'`, `paused` unknown server-side.
|
||||||
|
- `apps/api/src/routes/sessions.ts` stop: `durationSeconds = round((end − start)/1000)` —
|
||||||
|
wall-clock, includes paused time. The worker's displayed elapsed already excludes pause,
|
||||||
|
hence the mismatch (#3).
|
||||||
|
- `apps/api/src/db/schema.ts`: `work_sessions` has no pause columns; `activities` has no
|
||||||
|
`sort_order`.
|
||||||
|
- Worker logout sits on the Account tab → `BrowserRouter` re-mounts at `/account` (#4).
|
||||||
|
|
||||||
|
## A. Server-authoritative pause (#1 + #3)
|
||||||
|
|
||||||
|
**Data model** (migration `0003`, via `db:generate`):
|
||||||
|
- `work_sessions.paused_seconds` — `integer NOT NULL DEFAULT 0` (accumulated paused secs).
|
||||||
|
- `work_sessions.paused_at` — `integer timestamp_ms NULL` (set while paused; null = running).
|
||||||
|
- Status stays `active | completed | discarded`; a paused session is still **active** with
|
||||||
|
`paused_at` set, so no existing status filter changes.
|
||||||
|
|
||||||
|
**Shared contract** `WorkSession`: add `paused_seconds: number` and
|
||||||
|
`paused_at: string | null` (ISO). `toWorkSession` maps both.
|
||||||
|
|
||||||
|
**Endpoints** (`sessions.ts`, user-scoped exactly like stop/discard):
|
||||||
|
- `POST /api/sessions/:id/pause` — active + not already paused → set `paused_at = now`;
|
||||||
|
else 409. Returns the updated `WorkSession`.
|
||||||
|
- `POST /api/sessions/:id/resume` — paused → `paused_seconds += round((now − paused_at)/1000)`,
|
||||||
|
clear `paused_at`; else 409.
|
||||||
|
- `POST /api/sessions/:id/stop` — **changed**: if `paused_at` set, fold the open span into
|
||||||
|
`paused_seconds` first; then `duration_seconds = round((end − start)/1000) − paused_seconds`
|
||||||
|
(clamp ≥ 0); set `paused_at = null`, status `completed`. Stores worked + paused.
|
||||||
|
|
||||||
|
**Worker `Stopwatch.tsx`:** pause/resume call the new endpoints (`usePauseSession` /
|
||||||
|
`useResumeSession`); keep the local clock for snappy feel, server is source of truth.
|
||||||
|
Recovery-on-load restores `paused_at`/`paused_seconds` (today it forces running). Displayed
|
||||||
|
elapsed remains worked time; `isPaused` derives from `paused_at`.
|
||||||
|
|
||||||
|
## B. Paused-time display (#3 display)
|
||||||
|
|
||||||
|
- **Worker History card:** "Gewerkt H:MM:SS" + (if `paused_seconds > 0`) a grey
|
||||||
|
"Pauze H:MM:SS".
|
||||||
|
- **Admin Live + admin sessions views:** a **"Gepauzeerd"** badge when `paused_at` set; the
|
||||||
|
elapsed timer **freezes** while paused (worked = `(paused_at − start) − paused_seconds`),
|
||||||
|
and paused total shown.
|
||||||
|
- **CSV export** (`/api/export`): new "Paused Duration" column (`formatDuration(paused_seconds)`);
|
||||||
|
the existing "Total Duration" stays = worked.
|
||||||
|
|
||||||
|
## C. Reorderable handelingen (#2) — arrow buttons
|
||||||
|
|
||||||
|
**Data model** (same `0003`): `activities.sort_order` — `integer NOT NULL DEFAULT 0`.
|
||||||
|
Existing rows get 0; `GET` orders by `(sort_order ASC, name ASC)` so current alphabetical
|
||||||
|
order is preserved until an admin reorders. `Activity` contract gains `sort_order: number`.
|
||||||
|
|
||||||
|
**Endpoints** (`activities.ts`):
|
||||||
|
- `GET /api/activities` orders by `(sort_order, name)`.
|
||||||
|
- `PUT /api/activities/reorder` (admin-gated): body `{ ids: number[] }` (the full ordered
|
||||||
|
id list) → assigns `sort_order = index`. Validates the ids; returns the reordered list.
|
||||||
|
- `POST /api/activities` sets new rows to `sort_order = max(sort_order)+1` (append).
|
||||||
|
|
||||||
|
**Admin Activities screen:** each row gets ↑/↓ buttons (disabled at the ends) that swap with
|
||||||
|
the neighbour and fire the reorder mutation (invalidate `['activities']`). No new dependency.
|
||||||
|
**Worker picker:** inherits the order from `GET` — no worker UI change.
|
||||||
|
|
||||||
|
## D. Login-tab fix (#4)
|
||||||
|
|
||||||
|
Worker `AuthContext.signOut` resets the path to `/` (`window.history.replaceState(null, '', '/')`)
|
||||||
|
before clearing auth, so the next authed mount starts on Stopwatch. The **admin**
|
||||||
|
`AuthContext.signOut` gets the same one-liner (lands on Live) for consistency.
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
- pause/resume/stop on a non-owned or wrong-state session → 404/409 as today; client surfaces
|
||||||
|
nothing intrusive (the active-session query reconciles).
|
||||||
|
- reorder with unknown/missing ids → 400.
|
||||||
|
- Clamp negative worked durations to 0 (guards clock skew / odd pause data).
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- **API:** pause sets `paused_at`; resume accumulates; stop excludes paused and folds an open
|
||||||
|
pause span; reorder assigns `sort_order` by index and `GET` returns ordered; create appends.
|
||||||
|
- **Worker:** Stopwatch calls pause/resume; recovery restores paused state; History renders the
|
||||||
|
pauze line when `paused_seconds > 0`; after `signOut` the path is `/` so login shows Stopwatch.
|
||||||
|
- **Admin:** Live shows "Gepauzeerd" + frozen timer; Activities ↑/↓ fire the reorder mutation
|
||||||
|
with the swapped order; paused total shown in session views.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Admin pausing/resuming/stopping *another worker's* session (that's the Phase 3b
|
||||||
|
manual-entry/admin-control work). Pause remains a worker action here.
|
||||||
|
- Drag-and-drop reordering (arrows chosen; DnD would add dnd-kit against the dependency-light
|
||||||
|
goal).
|
||||||
|
|
||||||
|
## Build approach
|
||||||
|
|
||||||
|
One spec → `writing-plans` → **one Workflow** (per the maintainer's standing preference),
|
||||||
|
~8 TDD tasks, commit per task, final verify pass. Tracked as a Plane epic.
|
||||||
120
docs/superpowers/specs/2026-06-17-phase-3a-admin-panel-design.md
Normal file
120
docs/superpowers/specs/2026-06-17-phase-3a-admin-panel-design.md
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
# Phase 3a — Admin Panel (MVP) Design
|
||||||
|
|
||||||
|
- **Created:** 2026-06-17
|
||||||
|
- **Status:** Approved (brainstorming) — ready for implementation plan
|
||||||
|
- **Phase:** 3a (first of two slices of roadmap Phase 3; see `docs/roadmap.md` §9)
|
||||||
|
- **Tracker:** Plane (workspace `solelog`, project SoleLog)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
A working `apps/admin` desktop web app where an admin logs in, watches who is working
|
||||||
|
**live**, and manages **handelingen** (activities). Everything consumes existing backend
|
||||||
|
endpoints plus one tiny backend touch (`role` on `/api/me`). Reports/export, user
|
||||||
|
management, and manual session entry/edit are explicitly deferred to **Phase 3b**.
|
||||||
|
|
||||||
|
_Done when:_ an admin can sign in to the admin app, see who is working right now (auto-
|
||||||
|
refreshing), and add/edit/delete activities. A worker who signs in with valid credentials
|
||||||
|
is rejected with "Geen toegang".
|
||||||
|
|
||||||
|
## Scope decisions (confirmed during brainstorming, 2026-06-17)
|
||||||
|
|
||||||
|
1. **Slice** → MVP-first, two cycles. 3a = shell + admin login + live view + activity
|
||||||
|
management (existing endpoints). 3b = reports/export + user management + manual
|
||||||
|
entry/edit.
|
||||||
|
2. **Auth** → reuse the worker's bearer-token flow (`POST /api/auth/sign-in/email` →
|
||||||
|
capture `set-auth-token` → localStorage → `Authorization: Bearer`). Gate admin access
|
||||||
|
by reading `role` from `/api/me`; non-admins are signed out with "Geen toegang".
|
||||||
|
3. **Layout** → fixed left sidebar + content area (scales as 3b adds sections).
|
||||||
|
4. **Live refresh** → react-query `refetchInterval: 5000` (5s); elapsed time ticks
|
||||||
|
client-side every second.
|
||||||
|
5. **Live view is read-only in 3a** — no stop/fix button (admin-stopping another worker's
|
||||||
|
session needs a new backend endpoint → 3b).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/admin/ (new Vite + React 18 + TS SPA, dev port 5174)
|
||||||
|
src/
|
||||||
|
lib/ api.ts (apiFetch + signIn), auth-storage.ts ← copied from worker
|
||||||
|
auth/ AuthContext.tsx (signIn + admin-role gate via /api/me)
|
||||||
|
api/ admin-sessions.ts (useActiveSessions), activities.ts (CRUD hooks)
|
||||||
|
screens/ Login.tsx, Live.tsx, Activities.tsx
|
||||||
|
components/ Sidebar.tsx
|
||||||
|
lib/ elapsed.ts (HH:MM:SS formatter, ported from worker stopwatch)
|
||||||
|
App.tsx, main.tsx, styles.css
|
||||||
|
```
|
||||||
|
|
||||||
|
The admin app is a **client only** — it talks to the existing backend over HTTP with a
|
||||||
|
bearer token. No DB access. It mirrors `apps/worker`'s toolchain and conventions exactly
|
||||||
|
so the build can copy proven patterns.
|
||||||
|
|
||||||
|
### Backend changes (minimal, in 3a)
|
||||||
|
|
||||||
|
- `packages/shared/src/index.ts`: add `role: Role` to `PublicUser` (so `MeResponse.user`
|
||||||
|
carries it).
|
||||||
|
- `apps/api/src/routes/me.ts`: include `role` in the response (read from the session user,
|
||||||
|
default `'worker'`). The worker app ignores the extra field — no worker change needed.
|
||||||
|
- `apps/api/src/env.ts` + `.env.example`: add `http://localhost:5174` (the admin dev
|
||||||
|
origin) to the default `WEB_ORIGINS` / `CORS_ORIGINS`. Required because `WEB_ORIGINS`
|
||||||
|
drives both `hono/cors` and better-auth `trustedOrigins`; the admin app at :5174 calls
|
||||||
|
the API at :3000 cross-origin and would otherwise be blocked.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
- **`lib/api.ts` / `lib/auth-storage.ts`** — copied verbatim from worker; bearer token in
|
||||||
|
localStorage, `apiFetch<T>` adds the `Authorization` header.
|
||||||
|
- **`auth/AuthContext.tsx`** — `signIn(email,password)` calls the worker `signIn`, then
|
||||||
|
fetches `/api/me`; if `role !== 'admin'` it clears the token and throws so Login shows
|
||||||
|
"Geen toegang — alleen beheerders." `isAuthed` reflects a present token; an admin check
|
||||||
|
on mount (re-fetch `/api/me`) guards against a stale worker token.
|
||||||
|
- **`screens/Login.tsx`** — Dutch email+password form (no self-signup), error line.
|
||||||
|
- **`components/Sidebar.tsx`** — app name + signed-in email + logout (⏻); nav items **Live**
|
||||||
|
and **Handelingen**; a muted/disabled "binnenkort" group hints 3b (Rapporten /
|
||||||
|
Gebruikers / Handmatig).
|
||||||
|
- **`screens/Live.tsx`** — consumes `GET /api/admin/sessions/active` (`useActiveSessions`,
|
||||||
|
`refetchInterval: 5000`). One card per session: worker name, activity name, insole-type
|
||||||
|
pill, pair count, ticking elapsed timer (from `start_time`). Empty state "Niemand is nu
|
||||||
|
aan het werk."; header "Actief nu (N)".
|
||||||
|
- **`screens/Activities.tsx`** — port of the legacy worker `Settings.tsx` (git `decb158`):
|
||||||
|
add form (name + insole-type toggles), list with inline edit, delete-with-confirm —
|
||||||
|
desktop-styled. Uses `api/activities.ts` hooks against `/api/activities`.
|
||||||
|
|
||||||
|
## Data flow
|
||||||
|
|
||||||
|
1. Admin enters credentials → `signIn` → token stored → `/api/me` confirms `role==='admin'`.
|
||||||
|
2. Live screen polls `/api/admin/sessions/active` every 5s; each card computes elapsed from
|
||||||
|
`start_time` with a 1s client tick.
|
||||||
|
3. Activity CRUD hits `/api/activities` (GET open to any authed user; POST/PUT/DELETE are
|
||||||
|
admin-gated server-side — the admin bearer satisfies them).
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
- Login: 401/no-token → "Inloggen mislukt"; authenticated non-admin → "Geen toegang —
|
||||||
|
alleen beheerders" (token cleared).
|
||||||
|
- Live/Activities: react-query error → inline "Kon gegevens niet laden." with the data
|
||||||
|
hidden; loading → "Laden…".
|
||||||
|
- Delete activity: `window.confirm` warns that the activity's sessions are also deleted
|
||||||
|
(matches backend cascade in `activities.ts`).
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Mirror the worker's vitest + testing-library setup, mocking `apiFetch`:
|
||||||
|
|
||||||
|
- **Backend:** `/api/me` includes `role` for a worker and an admin (extend `me.test.ts`).
|
||||||
|
- **Auth/login:** admin signs in; worker credentials are rejected with "Geen toegang".
|
||||||
|
- **Shell:** sidebar renders nav; logout clears the token.
|
||||||
|
- **Live:** renders a card per active session (name/activity/type); empty state when none.
|
||||||
|
- **Activities:** add/edit/delete invoke the correct endpoints (mocked).
|
||||||
|
|
||||||
|
## Out of scope (→ Phase 3b)
|
||||||
|
|
||||||
|
- All-users filtered CSV export (current `/api/export` is self-scoped).
|
||||||
|
- User management via better-auth `/api/auth/admin/*`.
|
||||||
|
- Manual session entry/edit + admin stop/fix of another worker's session (needs new
|
||||||
|
backend endpoints).
|
||||||
|
|
||||||
|
## Build approach
|
||||||
|
|
||||||
|
spec → `writing-plans` → **one Workflow** that executes the plan task-by-task (TDD, commit
|
||||||
|
per task, final lint/typecheck/test/build + live verify), matching the Phase 2 pattern and
|
||||||
|
keeping this session's context clean.
|
||||||
@@ -5,10 +5,14 @@ export const HealthResponse = z.object({
|
|||||||
});
|
});
|
||||||
export type HealthResponse = z.infer<typeof HealthResponse>;
|
export type HealthResponse = z.infer<typeof HealthResponse>;
|
||||||
|
|
||||||
|
export const Role = z.enum(['worker', 'admin']);
|
||||||
|
export type Role = z.infer<typeof Role>;
|
||||||
|
|
||||||
export const PublicUser = z.object({
|
export const PublicUser = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
|
role: Role,
|
||||||
});
|
});
|
||||||
export type PublicUser = z.infer<typeof PublicUser>;
|
export type PublicUser = z.infer<typeof PublicUser>;
|
||||||
|
|
||||||
@@ -20,14 +24,12 @@ export type MeResponse = z.infer<typeof MeResponse>;
|
|||||||
export const InsoleType = z.enum(['Kurk', 'Berk', '3D']);
|
export const InsoleType = z.enum(['Kurk', 'Berk', '3D']);
|
||||||
export type InsoleType = z.infer<typeof InsoleType>;
|
export type InsoleType = z.infer<typeof InsoleType>;
|
||||||
|
|
||||||
export const Role = z.enum(['worker', 'admin']);
|
|
||||||
export type Role = z.infer<typeof Role>;
|
|
||||||
|
|
||||||
export const Activity = z.object({
|
export const Activity = z.object({
|
||||||
id: z.number().int(),
|
id: z.number().int(),
|
||||||
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>;
|
||||||
|
|
||||||
@@ -40,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>;
|
||||||
|
|
||||||
@@ -55,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(),
|
||||||
|
|||||||
23
yarn.lock
23
yarn.lock
@@ -1761,6 +1761,29 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"@solelog/admin@workspace:apps/admin":
|
||||||
|
version: 0.0.0-use.local
|
||||||
|
resolution: "@solelog/admin@workspace:apps/admin"
|
||||||
|
dependencies:
|
||||||
|
"@solelog/shared": "workspace:*"
|
||||||
|
"@tanstack/react-query": "npm:^5.0.0"
|
||||||
|
"@testing-library/dom": "npm:^10.4.1"
|
||||||
|
"@testing-library/jest-dom": "npm:^6.4.0"
|
||||||
|
"@testing-library/react": "npm:^16.0.0"
|
||||||
|
"@testing-library/user-event": "npm:^14.5.0"
|
||||||
|
"@types/react": "npm:^18.3.0"
|
||||||
|
"@types/react-dom": "npm:^18.3.0"
|
||||||
|
"@vitejs/plugin-react": "npm:^4.3.0"
|
||||||
|
jsdom: "npm:^25.0.0"
|
||||||
|
react: "npm:^18.3.1"
|
||||||
|
react-dom: "npm:^18.3.1"
|
||||||
|
react-router-dom: "npm:^6.26.0"
|
||||||
|
typescript: "npm:^5.7.2"
|
||||||
|
vite: "npm:^7.0.0"
|
||||||
|
vitest: "npm:^3.0.0"
|
||||||
|
languageName: unknown
|
||||||
|
linkType: soft
|
||||||
|
|
||||||
"@solelog/api@workspace:apps/api":
|
"@solelog/api@workspace:apps/api":
|
||||||
version: 0.0.0-use.local
|
version: 0.0.0-use.local
|
||||||
resolution: "@solelog/api@workspace:apps/api"
|
resolution: "@solelog/api@workspace:apps/api"
|
||||||
|
|||||||
Reference in New Issue
Block a user