Compare commits

...

12 Commits

Author SHA1 Message Date
Bas van Rossem
70ac27ec8e style: align oxfmt to trailing-comma 'all' and normalize code
All checks were successful
Build and Push Docker Image / build (push) Successful in 28s
The repo was authored prettier-style (trailing-comma 'all') but .oxfmtrc.json
was set to 'es5', so every formatted file diverged. Switch the config to 'all'
to match the existing code, ignore docs/** and **/drizzle/** (prose + generated
snapshots the formatter should not own), and reformat the source tree once for
consistency. No behavioural change; all suites green (api 60, worker 28, admin 21).
2026-06-17 21:36:18 +02:00
Bas van Rossem
1807f2b6d6 docs: pause-accounting + reorder session log
Finalize the pause-accounting + reorderable-handelingen + login-tab-fix
feature: session log (goal/work/verification/outcome), a one-line roadmap
status note, and an oxfmt pass over the changed files that strips a stray
trailing comma after the last call argument in the worker Stopwatch (es5
trailing-comma style) — pure formatting, tests stay green.
2026-06-17 21:24:16 +02:00
Bas van Rossem
e48df48376 feat(admin): reorder handelingen with up/down arrows 2026-06-17 21:18:07 +02:00
Bas van Rossem
0b0a6bd073 feat(admin): show paused sessions in live view; reset to live on logout
- Live cards freeze the worked timer at paused_at and show an amber
  "Gepauzeerd" badge plus a "Pauze H:MM:SS" total when paused.
- AuthContext.signOut resets the path to / so the next admin login lands
  on Live rather than the tab it logged out from.
2026-06-17 21:14:15 +02:00
Bas van Rossem
a7c8925b3c feat(deploy): build + serve worker and admin as static nginx images
All checks were successful
Build and Push Docker Image / build (push) Successful in 53s
- per-app Dockerfiles (vite build → nginx) + SPA nginx.conf
- Gitea workflow pushes 3 images; frontends bake VITE_API_URL
- docker-compose.prod.yml (registry images, solelog_network) + .env.prod.example
- docker-compose.yml runs the full stack locally; add .dockerignore
2026-06-17 21:11:32 +02:00
Bas van Rossem
1765f4036c fix(worker): show paused time in history; reset to stopwatch on logout 2026-06-17 21:10:01 +02:00
Bas van Rossem
ce396ecf2d feat(worker): server-authoritative pause/resume on the stopwatch 2026-06-17 21:06:10 +02:00
Bas van Rossem
56e0162230 feat(api): orderable activities + admin reorder endpoint 2026-06-17 20:58:06 +02:00
Bas van Rossem
974ecb120d feat(api): server-authoritative pause/resume + worked-time stop + CSV paused
Add user-scoped POST /api/sessions/:id/pause and /resume endpoints,
mirroring the stop handler's ownership/lookup and 401/404/409 guards.
Pause sets paused_at (status stays active); resume folds the open span
into paused_seconds and clears paused_at.

Change stop to fold any open pause span into paused_seconds, then set
duration_seconds = max(0, round((end-start)/1000) - paused_seconds) so
saved duration is worked time, and clear paused_at.

Add a "Paused Duration" column to /api/export (after "Total Duration")
using formatDuration(paused_seconds).

Products affected: SoleLog backend (apps/api)
2026-06-17 20:54:42 +02:00
Bas van Rossem
0d82b6efbc feat(shared,api): add pause + sort_order columns and contracts
Adds server-side pause accounting and activity ordering primitives.
- WorkSession contract gains paused_seconds (number) and paused_at (ISO string | null).
- Activity contract gains sort_order (number); new ReorderActivitiesInput zod.
- work_sessions += paused_seconds (int NOT NULL DEFAULT 0) + paused_at (timestamp_ms nullable).
- activities += sort_order (int NOT NULL DEFAULT 0).
- toWorkSession / toActivity map the new fields; generated migration 0003.

The new fields are additive; existing api/worker/admin tests stay green.

Products affected: SoleLog backend (apps/api), shared contracts (packages/shared)
2026-06-17 20:49:56 +02:00
Bas van Rossem
eae9a53a26 fix(docker): drop COPY .yarn/ — dir is gitignored, absent in CI builds
All checks were successful
Build and Push Docker Image / build (push) Successful in 37s
2026-06-17 20:45:53 +02:00
Bas van Rossem
cbfcb4a414 docs: spec + plan for pause accounting, reorder, login-tab fix 2026-06-17 20:43:19 +02:00
54 changed files with 2294 additions and 62 deletions

36
.dockerignore Normal file
View 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
View 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

View File

@@ -18,7 +18,7 @@ jobs:
username: ${{ gitea.actor }} username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }} password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push - name: Build and push API
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: . context: .
@@ -27,3 +27,27 @@ jobs:
tags: | tags: |
gitea.vrossem.net/bas/solelog:latest gitea.vrossem.net/bas/solelog:latest
gitea.vrossem.net/bas/solelog:${{ gitea.sha }} gitea.vrossem.net/bas/solelog:${{ gitea.sha }}
- name: Build and push Worker
uses: docker/build-push-action@v5
with:
context: .
file: apps/worker/Dockerfile
push: true
build-args: |
VITE_API_URL=https://api.solelog.vrossem.net
tags: |
gitea.vrossem.net/bas/solelog-worker:latest
gitea.vrossem.net/bas/solelog-worker:${{ gitea.sha }}
- name: Build and push Admin
uses: docker/build-push-action@v5
with:
context: .
file: apps/admin/Dockerfile
push: true
build-args: |
VITE_API_URL=https://api.solelog.vrossem.net
tags: |
gitea.vrossem.net/bas/solelog-admin:latest
gitea.vrossem.net/bas/solelog-admin:${{ gitea.sha }}

View File

@@ -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/**"]
} }

28
apps/admin/Dockerfile Normal file
View File

@@ -0,0 +1,28 @@
# ---- build stage: produce the static Vite bundle ----
FROM node:22-alpine AS build
RUN corepack enable
WORKDIR /repo
# Workspace manifests first, so `yarn install` layers cache across source changes
COPY package.json yarn.lock .yarnrc.yml ./
COPY packages/shared/package.json ./packages/shared/package.json
COPY apps/admin/package.json ./apps/admin/package.json
RUN yarn workspaces focus @solelog/admin
# Sources (@solelog/shared is consumed as raw TS, so it just needs to be present)
COPY packages/shared/ ./packages/shared/
COPY apps/admin/ ./apps/admin/
# The API base URL is baked into the bundle at build time (Vite inlines import.meta.env.*).
# Defaults to localhost for local builds; the Gitea workflow overrides it for production.
ARG VITE_API_URL=http://localhost:3000
ENV VITE_API_URL=$VITE_API_URL
# `vite build` only (no `tsc -b`): the image ships the bundle; type/test checks
# are a separate CI concern and shouldn't gate the production image.
RUN yarn workspace @solelog/admin exec vite build
# ---- runtime stage: serve the static files with nginx ----
FROM nginx:alpine AS runtime
COPY apps/admin/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /repo/apps/admin/dist /usr/share/nginx/html
EXPOSE 80

23
apps/admin/nginx.conf Normal file
View 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";
}
}

View File

@@ -22,7 +22,7 @@ function renderApp() {
return render( return render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<App /> <App />
</QueryClientProvider> </QueryClientProvider>,
); );
} }

View File

@@ -47,3 +47,17 @@ export function useDeleteActivity() {
}, },
}); });
} }
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'] });
},
});
}

View File

@@ -17,9 +17,9 @@ vi.mock('../api/me', () => ({
const mockedSignIn = vi.mocked(apiSignIn); const mockedSignIn = vi.mocked(apiSignIn);
const mockedFetchMe = vi.mocked(fetchMe); const mockedFetchMe = vi.mocked(fetchMe);
// A tiny harness that exposes the auth context's state + signIn. // A tiny harness that exposes the auth context's state + signIn + signOut.
function Harness() { function Harness() {
const { isAuthed, signIn } = useAuth(); const { isAuthed, signIn, signOut } = useAuth();
return ( return (
<div> <div>
<span data-testid="authed">{String(isAuthed)}</span> <span data-testid="authed">{String(isAuthed)}</span>
@@ -34,6 +34,9 @@ function Harness() {
> >
go go
</button> </button>
<button type="button" onClick={() => signOut()}>
uit
</button>
<span id="err" /> <span id="err" />
</div> </div>
); );
@@ -43,7 +46,7 @@ function renderHarness() {
return render( return render(
<AuthProvider> <AuthProvider>
<Harness /> <Harness />
</AuthProvider> </AuthProvider>,
); );
} }
@@ -89,4 +92,23 @@ describe('AuthContext admin gate', () => {
expect(getToken()).toBeNull(); expect(getToken()).toBeNull();
expect(screen.getByTestId('authed')).toHaveTextContent('false'); 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();
});
}); });

View File

@@ -34,6 +34,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, []); }, []);
const signOut = useCallback(() => { 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(); clearToken();
setIsAuthed(false); setIsAuthed(false);
}, []); }, []);

View File

@@ -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';

View File

@@ -11,5 +11,5 @@ createRoot(document.getElementById('root')!).render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<App /> <App />
</QueryClientProvider> </QueryClientProvider>
</React.StrictMode> </React.StrictMode>,
); );

View File

@@ -13,8 +13,27 @@ vi.mock('../lib/api', () => ({
const mockApiFetch = vi.mocked(apiFetch); const mockApiFetch = vi.mocked(apiFetch);
const existing: Activity[] = [ const existing: Activity[] = [
{ id: 1, name: 'Frezen', insole_types: ['Kurk', 'Berk'], created_at: '2026-06-17T00:00:00.000Z' }, {
{ id: 2, name: 'Lijmen', insole_types: ['3D'], created_at: '2026-06-17T00:00:00.000Z' }, 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() { function renderActivities() {
@@ -22,7 +41,7 @@ function renderActivities() {
return render( return render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<Activities /> <Activities />
</QueryClientProvider> </QueryClientProvider>,
); );
} }
@@ -103,4 +122,45 @@ describe('Activities', () => {
expect(mockApiFetch).not.toHaveBeenCalledWith('/api/activities/1', { method: 'DELETE' }); 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();
});
}); });

View File

@@ -4,6 +4,7 @@ import {
useActivities, useActivities,
useCreateActivity, useCreateActivity,
useDeleteActivity, useDeleteActivity,
useReorderActivities,
useUpdateActivity, useUpdateActivity,
} from '../api/activities'; } from '../api/activities';
@@ -71,6 +72,7 @@ export default function Activities() {
const createActivity = useCreateActivity(); const createActivity = useCreateActivity();
const updateActivity = useUpdateActivity(); const updateActivity = useUpdateActivity();
const deleteActivity = useDeleteActivity(); const deleteActivity = useDeleteActivity();
const reorderActivities = useReorderActivities();
// Add form state. // Add form state.
const [newName, setNewName] = useState(''); const [newName, setNewName] = useState('');
@@ -98,7 +100,7 @@ export default function Activities() {
setNewName(''); setNewName('');
setNewTypes([...ALL_TYPES]); setNewTypes([...ALL_TYPES]);
}, },
} },
); );
} }
@@ -116,17 +118,25 @@ export default function Activities() {
if (editName.trim().length === 0 || editTypes.length === 0) return; if (editName.trim().length === 0 || editTypes.length === 0) return;
updateActivity.mutate( updateActivity.mutate(
{ id, input: { name: editName.trim(), insole_types: editTypes } }, { id, input: { name: editName.trim(), insole_types: editTypes } },
{ onSuccess: () => setEditingId(null) } { onSuccess: () => setEditingId(null) },
); );
} }
function handleDelete(id: number, name: string) { function handleDelete(id: number, name: string) {
const ok = window.confirm( const ok = window.confirm(
`"${name}" verwijderen? Alle tijdsregistraties voor deze taak worden ook verwijderd.` `"${name}" verwijderen? Alle tijdsregistraties voor deze taak worden ook verwijderd.`,
); );
if (ok) deleteActivity.mutate(id); 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 ( return (
<div className="screen"> <div className="screen">
<h1 className="screen-title">Handelingen</h1> <h1 className="screen-title">Handelingen</h1>
@@ -164,7 +174,7 @@ export default function Activities() {
<p className="muted">Nog geen stappen. Voeg er een toe hierboven.</p> <p className="muted">Nog geen stappen. Voeg er een toe hierboven.</p>
) : ( ) : (
<ul className="activity-list"> <ul className="activity-list">
{activities.map((activity) => ( {activities.map((activity, index) => (
<li key={activity.id} className="activity-card"> <li key={activity.id} className="activity-card">
{editingId === activity.id ? ( {editingId === activity.id ? (
<> <>
@@ -202,6 +212,24 @@ export default function Activities() {
<div className="activity-row"> <div className="activity-row">
<span className="activity-name">{activity.name}</span> <span className="activity-name">{activity.name}</span>
<div className="row-actions"> <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 <button
type="button" type="button"
className="icon-btn icon-edit" className="icon-btn icon-edit"

View File

@@ -23,6 +23,8 @@ function makeSession(over: Partial<WorkSession>): WorkSession {
start_time: new Date(Date.now() - 65_000).toISOString(), start_time: new Date(Date.now() - 65_000).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,
@@ -36,7 +38,7 @@ function renderLive() {
return render( return render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<Live /> <Live />
</QueryClientProvider> </QueryClientProvider>,
); );
} }
@@ -72,4 +74,71 @@ describe('Live', () => {
expect(await screen.findByText('Niemand is nu aan het werk.')).toBeInTheDocument(); expect(await screen.findByText('Niemand is nu aan het werk.')).toBeInTheDocument();
expect(screen.getByText('Actief nu (0)')).toBeInTheDocument(); expect(screen.getByText('Actief nu (0)')).toBeInTheDocument();
}); });
it('shows a Gepauzeerd badge with a frozen worked timer when paused_at is set', async () => {
vi.useFakeTimers();
const start = Date.now() - 100_000;
const pausedAt = Date.now() - 40_000; // paused 40s ago
mockApiFetch.mockResolvedValue([
makeSession({
id: 1,
user_name: 'Jan',
start_time: new Date(start).toISOString(),
paused_at: new Date(pausedAt).toISOString(),
paused_seconds: 10,
}),
]);
renderLive();
// The fake clock needs to advance for react-query's async resolution.
await vi.advanceTimersByTimeAsync(0);
expect(screen.getByText('Gepauzeerd')).toBeInTheDocument();
// worked = (paused_at - start)/1000 - paused_seconds = 60 - 10 = 50s = 00:00:50
const frozen = screen.getByText('00:00:50');
expect(frozen).toBeInTheDocument();
// Advance the 1s tick: the worked timer must stay frozen (does not count up).
await vi.advanceTimersByTimeAsync(3000);
expect(screen.getByText('00:00:50')).toBeInTheDocument();
});
it('shows the paused total when paused_seconds > 0', async () => {
mockApiFetch.mockResolvedValue([
makeSession({
id: 1,
user_name: 'Jan',
paused_seconds: 125,
paused_at: new Date().toISOString(),
}),
]);
renderLive();
// 125s = 00:02:05
expect(await screen.findByText('Pauze 00:02:05')).toBeInTheDocument();
});
it('keeps the timer counting (no Gepauzeerd badge) when not paused', async () => {
vi.useFakeTimers();
mockApiFetch.mockResolvedValue([
makeSession({
id: 1,
user_name: 'Jan',
start_time: new Date(Date.now() - 10_000).toISOString(),
paused_at: null,
paused_seconds: 0,
}),
]);
renderLive();
await vi.advanceTimersByTimeAsync(0);
expect(screen.queryByText('Gepauzeerd')).not.toBeInTheDocument();
expect(screen.getByText('00:00:10')).toBeInTheDocument();
await vi.advanceTimersByTimeAsync(2000);
expect(screen.getByText('00:00:12')).toBeInTheDocument();
});
}); });

View File

@@ -49,7 +49,13 @@ export default function Live() {
} }
function LiveCard({ session, now }: { session: WorkSession; now: number }) { function LiveCard({ session, now }: { session: WorkSession; now: number }) {
const elapsed = formatTime((now - Date.parse(session.start_time)) / 1000); // 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 ( return (
<article className="live-card"> <article className="live-card">
<div className="live-card-head"> <div className="live-card-head">
@@ -58,7 +64,11 @@ function LiveCard({ session, now }: { session: WorkSession; now: number }) {
</div> </div>
<div className="live-activity">{session.activity_name ?? 'Onbekende handeling'}</div> <div className="live-activity">{session.activity_name ?? 'Onbekende handeling'}</div>
<div className="live-meta">{session.pair_count} zolen</div> <div className="live-meta">{session.pair_count} zolen</div>
<div className="live-timer">{elapsed}</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> </article>
); );
} }

View File

@@ -16,7 +16,7 @@ export default function Login() {
await signIn(email, password); await signIn(email, password);
} catch (err) { } catch (err) {
setError( setError(
err instanceof NotAdminError ? 'Geen toegang — alleen beheerders.' : 'Inloggen mislukt' err instanceof NotAdminError ? 'Geen toegang — alleen beheerders.' : 'Inloggen mislukt',
); );
} finally { } finally {
setBusy(false); setBusy(false);

View File

@@ -404,3 +404,19 @@ body {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
color: var(--text); 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;
}

View File

@@ -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

View 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;

View 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": {}
}
}

View File

@@ -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
} }
] ]
} }

View File

@@ -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));

View File

@@ -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),
}) }),
); );

View File

@@ -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,

View File

@@ -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);

View File

@@ -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,
}) }),
) ),
); );
}); });

View File

@@ -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));

View File

@@ -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');

View File

@@ -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');

View File

@@ -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;

View File

@@ -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,
); );
}); });
}); });

View File

@@ -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');

View 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
View 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
View 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";
}
}

View File

@@ -18,7 +18,7 @@ function renderApp() {
return render( return render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<App /> <App />
</QueryClientProvider> </QueryClientProvider>,
); );
} }

View File

@@ -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'] });
},
});
}

View 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('/');
});
});

View File

@@ -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);
}, []); }, []);

View File

@@ -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);

View File

@@ -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';

View File

@@ -21,7 +21,7 @@ function renderAccount() {
<AuthProvider> <AuthProvider>
<Account /> <Account />
</AuthProvider> </AuthProvider>
</QueryClientProvider> </QueryClientProvider>,
); );
} }

View File

@@ -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();

View File

@@ -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>
); );

View File

@@ -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();
});
}); });

View File

@@ -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
View 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:

View File

@@ -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:

View File

@@ -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 02 implemented + Phase **3a** implemented (`docs/superpowers/plans/2026-06-17-phase-3a-admin-panel.md`) - **Status:** Approved — living project doc; Phases 02 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).

View 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.

View 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.

View File

@@ -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.

View File

@@ -29,6 +29,7 @@ export const Activity = z.object({
name: z.string(), name: z.string(),
insole_types: z.array(InsoleType), insole_types: z.array(InsoleType),
created_at: z.string(), // ISO-8601 created_at: z.string(), // ISO-8601
sort_order: z.number().int(),
}); });
export type Activity = z.infer<typeof Activity>; export type Activity = z.infer<typeof Activity>;
@@ -41,6 +42,11 @@ export type CreateActivityInput = z.infer<typeof CreateActivityInput>;
export const UpdateActivityInput = CreateActivityInput; export const UpdateActivityInput = CreateActivityInput;
export type UpdateActivityInput = z.infer<typeof UpdateActivityInput>; export type UpdateActivityInput = z.infer<typeof UpdateActivityInput>;
export const ReorderActivitiesInput = z.object({
ids: z.array(z.number().int()).min(1),
});
export type ReorderActivitiesInput = z.infer<typeof ReorderActivitiesInput>;
export const SessionStatus = z.enum(['active', 'completed', 'discarded']); export const SessionStatus = z.enum(['active', 'completed', 'discarded']);
export type SessionStatus = z.infer<typeof SessionStatus>; export type SessionStatus = z.infer<typeof SessionStatus>;
@@ -56,6 +62,8 @@ export const WorkSession = z.object({
start_time: z.string(), // ISO-8601 start_time: z.string(), // ISO-8601
end_time: z.string().nullable(), end_time: z.string().nullable(),
duration_seconds: z.number().int().nullable(), duration_seconds: z.number().int().nullable(),
paused_seconds: z.number().int(),
paused_at: z.string().nullable(), // ISO-8601; set while paused, null = running
status: SessionStatus, status: SessionStatus,
source: z.enum(['app', 'manual']), source: z.enum(['app', 'manual']),
notes: z.string().nullable(), notes: z.string().nullable(),