Compare commits

..

4 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
31 changed files with 299 additions and 47 deletions

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

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

@@ -46,7 +46,7 @@ function renderHarness() {
return render( return render(
<AuthProvider> <AuthProvider>
<Harness /> <Harness />
</AuthProvider> </AuthProvider>,
); );
} }
@@ -92,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>,
); );
} }

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

@@ -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 }) => ({
@@ -157,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,

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

@@ -236,7 +236,7 @@ describe('activities routes', () => {
expect(after.map((r) => r.id)).toEqual(ids); expect(after.map((r) => r.id)).toEqual(ids);
// In particular, b now precedes a. // In particular, b now precedes a.
expect(after.findIndex((r) => r.id === b.id)).toBeLessThan( expect(after.findIndex((r) => r.id === b.id)).toBeLessThan(
after.findIndex((r) => r.id === a.id) after.findIndex((r) => r.id === a.id),
); );
}); });

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","Paused 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"');

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

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

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

View File

@@ -39,7 +39,7 @@ describe('AuthContext signOut', () => {
render( render(
<AuthProvider> <AuthProvider>
<SignOutButton /> <SignOutButton />
</AuthProvider> </AuthProvider>,
); );
await user.click(screen.getByRole('button', { name: 'Uitloggen' })); await user.click(screen.getByRole('button', { name: 'Uitloggen' }));

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

@@ -46,7 +46,7 @@ function renderHistory() {
return render( return render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<History /> <History />
</QueryClientProvider> </QueryClientProvider>,
); );
} }

View File

@@ -104,12 +104,16 @@ describe('Stopwatch', () => {
resumeMutate = 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)); mockedUsePauseSession.mockReturnValue(
mutation<ReturnType<typeof usePauseSession>>(pauseMutate),
);
mockedUseResumeSession.mockReturnValue( mockedUseResumeSession.mockReturnValue(
mutation<ReturnType<typeof useResumeSession>>(resumeMutate), mutation<ReturnType<typeof useResumeSession>>(resumeMutate),
); );

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.