Compare commits

..

11 Commits

Author SHA1 Message Date
cottongin
0833cf6167 feat: add Prev/Next pagination bar to session history
Made-with: Cursor
2026-03-23 11:35:42 -04:00
cottongin
bfabf390b4 feat: render sessions grouped by day with styled header bars
Made-with: Cursor
2026-03-23 11:34:41 -04:00
cottongin
ad8efc0fbf feat: add pagination state and offset to session API calls
Made-with: Cursor
2026-03-23 11:32:59 -04:00
cottongin
db369a807e feat: add getLocalDateKey, formatDayHeader, formatTimeOnly date helpers
Made-with: Cursor
2026-03-23 11:31:35 -04:00
cottongin
d49601c54e feat: add offset pagination and X-Prev-Last-Date header to GET /sessions
Made-with: Cursor
2026-03-23 11:30:48 -04:00
cottongin
de1a02b9bb docs: pagination and day grouping implementation plan
Made-with: Cursor
2026-03-23 11:26:37 -04:00
cottongin
07858f973b docs: address spec review feedback (offset validation, header format, Tailwind class, polling)
Made-with: Cursor
2026-03-23 11:18:28 -04:00
cottongin
57ab3cf7ba docs: pagination and day grouping design spec
Made-with: Cursor
2026-03-23 11:16:33 -04:00
cottongin
c25db19008 chore: add superpowers to gitignore 2026-03-23 10:42:48 -04:00
cottongin
85c06ff258 fix: session count label distinguishes visible vs total
Show "X visible (Y total)" when the history list is filtered or limited,
and "X sessions total" only when every session is actually displayed.

Made-with: Cursor
2026-03-23 10:41:38 -04:00
cottongin
3b18034d11 tweak: language for admin presence bar 2026-03-23 10:21:03 -04:00
8 changed files with 1068 additions and 103 deletions

1
.gitignore vendored
View File

@@ -44,6 +44,7 @@ backend/config/admins.json
# Cursor # Cursor
.cursor/ .cursor/
.superpowers/
chat-summaries/ chat-summaries/
plan.md plan.md

View File

@@ -23,6 +23,9 @@ router.get('/', (req, res) => {
try { try {
const filter = req.query.filter || 'default'; const filter = req.query.filter || 'default';
const limitParam = req.query.limit || 'all'; const limitParam = req.query.limit || 'all';
const offsetParam = req.query.offset || '0';
let offset = parseInt(offsetParam, 10);
if (isNaN(offset) || offset < 0) offset = 0;
let whereClause = ''; let whereClause = '';
if (filter === 'default') { if (filter === 'default') {
@@ -45,6 +48,11 @@ router.get('/', (req, res) => {
} }
} }
let offsetClause = '';
if (offset > 0) {
offsetClause = `OFFSET ${offset}`;
}
const sessions = db.prepare(` const sessions = db.prepare(`
SELECT SELECT
s.id, s.id,
@@ -60,6 +68,7 @@ router.get('/', (req, res) => {
GROUP BY s.id GROUP BY s.id
ORDER BY s.created_at DESC ORDER BY s.created_at DESC
${limitClause} ${limitClause}
${offsetClause}
`).all(); `).all();
const result = sessions.map(({ notes, ...session }) => { const result = sessions.map(({ notes, ...session }) => {
@@ -67,7 +76,23 @@ router.get('/', (req, res) => {
return { ...session, has_notes, notes_preview }; return { ...session, has_notes, notes_preview };
}); });
const absoluteTotal = db.prepare('SELECT COUNT(*) as total FROM sessions').get();
if (offset > 0) {
const prevRow = db.prepare(`
SELECT s.created_at
FROM sessions s
${whereClause}
ORDER BY s.created_at DESC
LIMIT 1 OFFSET ${offset - 1}
`).get();
if (prevRow) {
res.set('X-Prev-Last-Date', prevRow.created_at);
}
}
res.set('X-Total-Count', String(countRow.total)); res.set('X-Total-Count', String(countRow.total));
res.set('X-Absolute-Total', String(absoluteTotal.total));
res.json(result); res.json(result);
} catch (error) { } catch (error) {
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });

View File

@@ -0,0 +1,611 @@
# Pagination & Day Grouping Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add offset-based pagination and day-grouped session rendering to the History page.
**Architecture:** Backend adds `offset` param and `X-Prev-Last-Date` header to `GET /sessions`. Frontend adds page state, groups sessions by local date at render time with styled day headers, and renders a Prev/Next pagination bar.
**Tech Stack:** Node.js/Express/better-sqlite3 (backend), React/Tailwind CSS (frontend), Jest/supertest (tests)
---
### Task 1: Backend — Add `offset` param and `X-Prev-Last-Date` header
**Files:**
- Modify: `backend/routes/sessions.js:22-76` (the `GET /` handler)
- Test: `tests/api/session-archive.test.js` (add new tests to the existing `GET /api/sessions` describe block)
- [ ] **Step 1: Write failing tests for `offset` and `X-Prev-Last-Date`**
Add these tests at the end of the `GET /api/sessions — filter and limit` describe block in `tests/api/session-archive.test.js`:
```javascript
test('offset skips the first N sessions', async () => {
for (let i = 0; i < 5; i++) {
seedSession({ is_active: 0, notes: null });
}
const allRes = await request(app).get('/api/sessions?filter=all&limit=all');
const offsetRes = await request(app).get('/api/sessions?filter=all&limit=2&offset=2');
expect(offsetRes.status).toBe(200);
expect(offsetRes.body).toHaveLength(2);
expect(offsetRes.body[0].id).toBe(allRes.body[2].id);
expect(offsetRes.body[1].id).toBe(allRes.body[3].id);
});
test('offset defaults to 0 when not provided', async () => {
for (let i = 0; i < 3; i++) {
seedSession({ is_active: 0, notes: null });
}
const res = await request(app).get('/api/sessions?filter=all&limit=2');
expect(res.status).toBe(200);
expect(res.body).toHaveLength(2);
});
test('negative offset is clamped to 0', async () => {
seedSession({ is_active: 0, notes: null });
const res = await request(app).get('/api/sessions?filter=all&offset=-5');
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
});
test('non-numeric offset is clamped to 0', async () => {
seedSession({ is_active: 0, notes: null });
const res = await request(app).get('/api/sessions?filter=all&offset=abc');
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
});
test('offset past end returns empty array', async () => {
seedSession({ is_active: 0, notes: null });
const res = await request(app).get('/api/sessions?filter=all&limit=5&offset=100');
expect(res.status).toBe(200);
expect(res.body).toHaveLength(0);
expect(res.headers['x-total-count']).toBe('1');
});
test('X-Prev-Last-Date header is set with correct value when offset > 0', async () => {
for (let i = 0; i < 5; i++) {
seedSession({ is_active: 0, notes: null });
}
const allRes = await request(app).get('/api/sessions?filter=all&limit=all');
const res = await request(app).get('/api/sessions?filter=all&limit=2&offset=2');
expect(res.headers['x-prev-last-date']).toBe(allRes.body[1].created_at);
});
test('X-Prev-Last-Date header is absent when offset is 0', async () => {
seedSession({ is_active: 0, notes: null });
const res = await request(app).get('/api/sessions?filter=all&limit=2');
expect(res.headers['x-prev-last-date']).toBeUndefined();
});
test('X-Total-Count is unaffected by offset', async () => {
for (let i = 0; i < 10; i++) {
seedSession({ is_active: 0, notes: null });
}
const res = await request(app).get('/api/sessions?filter=all&limit=3&offset=6');
expect(res.headers['x-total-count']).toBe('10');
expect(res.body).toHaveLength(3);
});
test('offset works with filter=default', async () => {
for (let i = 0; i < 5; i++) {
seedSession({ is_active: 0, notes: null });
}
const archived = seedSession({ is_active: 0, notes: null });
require('../helpers/test-utils').db.prepare(
'UPDATE sessions SET archived = 1 WHERE id = ?'
).run(archived.id);
const res = await request(app).get('/api/sessions?filter=default&limit=2&offset=2');
expect(res.status).toBe(200);
expect(res.body).toHaveLength(2);
expect(res.headers['x-total-count']).toBe('5');
res.body.forEach(s => expect(s.archived).toBe(0));
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npx jest tests/api/session-archive.test.js --no-coverage --forceExit`
Expected: 9 new tests FAIL (offset is not yet implemented)
- [ ] **Step 3: Implement offset and X-Prev-Last-Date in the GET handler**
In `backend/routes/sessions.js`, modify the `router.get('/')` handler (lines 22-76). After parsing `limitParam` (line 25), add offset parsing:
```javascript
const offsetParam = req.query.offset || '0';
let offset = parseInt(offsetParam, 10);
if (isNaN(offset) || offset < 0) offset = 0;
```
After the `limitClause` block (line 46), build the offset clause:
```javascript
let offsetClause = '';
if (offset > 0) {
offsetClause = `OFFSET ${offset}`;
}
```
Update the sessions query (line 62) to include `${offsetClause}` after `${limitClause}`:
```sql
ORDER BY s.created_at DESC
${limitClause}
${offsetClause}
```
Before `res.set('X-Total-Count', ...)`, add the `X-Prev-Last-Date` logic:
```javascript
if (offset > 0) {
const prevRow = db.prepare(`
SELECT s.created_at
FROM sessions s
${whereClause}
ORDER BY s.created_at DESC
LIMIT 1 OFFSET ${offset - 1}
`).get();
if (prevRow) {
res.set('X-Prev-Last-Date', prevRow.created_at);
}
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `npx jest tests/api/session-archive.test.js --no-coverage --forceExit`
Expected: ALL tests pass (24 existing + 9 new = 33)
- [ ] **Step 5: Commit**
```bash
git add backend/routes/sessions.js tests/api/session-archive.test.js
git commit -m "feat: add offset pagination and X-Prev-Last-Date header to GET /sessions"
```
---
### Task 2: Frontend — Date utility helpers
**Files:**
- Modify: `frontend/src/utils/dateUtils.js`
- [ ] **Step 1: Add `getLocalDateKey` and `formatDayHeader` helpers**
Append to `frontend/src/utils/dateUtils.js`:
```javascript
/**
* Get a locale-independent date key for grouping sessions by local calendar day
* @param {string} sqliteTimestamp
* @returns {string} - e.g., "2026-03-23"
*/
export function getLocalDateKey(sqliteTimestamp) {
const d = parseUTCTimestamp(sqliteTimestamp);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* Format a SQLite timestamp as a day header string (e.g., "Sunday, Mar 23, 2026")
* @param {string} sqliteTimestamp
* @returns {string}
*/
export function formatDayHeader(sqliteTimestamp) {
const d = parseUTCTimestamp(sqliteTimestamp);
return d.toLocaleDateString(undefined, {
weekday: 'long',
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
/**
* Format a SQLite timestamp as a time-only string (e.g., "7:30 PM")
* @param {string} sqliteTimestamp
* @returns {string}
*/
export function formatTimeOnly(sqliteTimestamp) {
const d = parseUTCTimestamp(sqliteTimestamp);
return d.toLocaleTimeString(undefined, {
hour: 'numeric',
minute: '2-digit',
});
}
```
- [ ] **Step 2: Verify frontend builds**
Run: `cd frontend && npm run build`
Expected: Build succeeds with no errors
- [ ] **Step 3: Commit**
```bash
git add frontend/src/utils/dateUtils.js
git commit -m "feat: add getLocalDateKey, formatDayHeader, formatTimeOnly date helpers"
```
---
### Task 3: Frontend — Pagination state and API integration
**Files:**
- Modify: `frontend/src/pages/History.jsx:14-75` (state declarations and `loadSessions`)
- [ ] **Step 1: Add page state and update loadSessions**
In `History.jsx`, add state after line 17 (`absoluteTotal`):
```javascript
const [page, setPage] = useState(1);
const [prevLastDate, setPrevLastDate] = useState(null);
```
Update `loadSessions` (the `api.get` call around line 32) to pass `offset`:
```javascript
const limitNum = limit === 'all' ? null : parseInt(limit, 10);
const offset = limitNum ? (page - 1) * limitNum : 0;
const response = await api.get('/sessions', {
params: { filter, limit, offset: offset || undefined }
});
```
After setting `absoluteTotal`, add:
```javascript
setPrevLastDate(response.headers['x-prev-last-date'] || null);
```
Add `page` to the `useCallback` dependency array for `loadSessions`.
- [ ] **Step 2: Add page reset logic**
Update `handleFilterChange` and `handleLimitChange` to reset page:
```javascript
const handleFilterChange = (newFilter) => {
setFilter(newFilter);
localStorage.setItem(prefixKey(adminName, 'history-filter'), newFilter);
setSelectedIds(new Set());
setPage(1);
};
const handleLimitChange = (newLimit) => {
setLimit(newLimit);
localStorage.setItem(prefixKey(adminName, 'history-show-limit'), newLimit);
setSelectedIds(new Set());
setPage(1);
};
```
Add auto-reset when page becomes empty. Place this check **after** all state updates (`setSessions`, `setTotalCount`, `setAbsoluteTotal`, `setPrevLastDate`) to avoid stale state:
```javascript
setSessions(response.data);
setTotalCount(parseInt(response.headers['x-total-count'] || '0', 10));
setAbsoluteTotal(parseInt(response.headers['x-absolute-total'] || '0', 10));
setPrevLastDate(response.headers['x-prev-last-date'] || null);
if (response.data.length === 0 && offset > 0) {
setPage(1);
}
```
Also add `setPage(1)` to `exitSelectMode`:
```javascript
const exitSelectMode = () => {
setSelectMode(false);
setSelectedIds(new Set());
setShowBulkDeleteConfirm(false);
setPage(1);
};
```
And in the select mode toggle button's `onClick` (where `setSelectMode(true)` is called), add `setPage(1)` after it. Similarly in `handlePointerDown` where `setSelectMode(true)` is called, add `setPage(1)` after it.
- [ ] **Step 3: Verify frontend builds**
Run: `cd frontend && npm run build`
Expected: Build succeeds
- [ ] **Step 4: Commit**
```bash
git add frontend/src/pages/History.jsx
git commit -m "feat: add pagination state and offset to session API calls"
```
---
### Task 4: Frontend — Day grouping rendering
**Files:**
- Modify: `frontend/src/pages/History.jsx:1-8` (imports) and `208-316` (session list rendering)
- [ ] **Step 1: Update imports**
Replace the `dateUtils` import at line 6:
```javascript
import { formatDayHeader, formatTimeOnly, getLocalDateKey, isSunday } from '../utils/dateUtils';
```
(Remove `formatLocalDate` since session cards will now show time-only under day headers.)
- [ ] **Step 2: Add grouping logic and render day headers**
Replace the session list rendering section (`{sessions.map(session => { ... })}`) with day-grouped rendering. The grouping is computed at render time using `useMemo`:
Add before the `return` statement (above `if (loading)`):
```javascript
const groupedSessions = useMemo(() => {
const groups = [];
let currentKey = null;
sessions.forEach(session => {
const dateKey = getLocalDateKey(session.created_at);
if (dateKey !== currentKey) {
currentKey = dateKey;
groups.push({ dateKey, sessions: [session] });
} else {
groups[groups.length - 1].sessions.push(session);
}
});
return groups;
}, [sessions]);
```
Add `useMemo` to the React import at line 1.
Replace the `{sessions.map(session => { ... })}` block inside `<div className="space-y-2">` with:
```jsx
{groupedSessions.map((group, groupIdx) => {
const isSundayGroup = isSunday(group.sessions[0].created_at);
const isContinued = groupIdx === 0 && page > 1 && prevLastDate &&
getLocalDateKey(prevLastDate) === group.dateKey;
return (
<div key={group.dateKey}>
{/* Day header bar */}
<div className="bg-gray-100 dark:bg-[#1e2a3a] rounded-md px-3.5 py-2 mb-2 flex justify-between items-center border-l-[3px] border-indigo-500">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-indigo-700 dark:text-indigo-300">
{formatDayHeader(group.sessions[0].created_at)}
</span>
{isContinued && (
<span className="text-xs text-gray-400 dark:text-gray-500 italic">(continued)</span>
)}
</div>
{!isContinued && (
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500 dark:text-gray-400">
{group.sessions.length} session{group.sessions.length !== 1 ? 's' : ''}
</span>
{isSundayGroup && (
<span className="text-xs font-semibold text-amber-700 dark:text-amber-300">🎲 Game Night</span>
)}
</div>
)}
</div>
{/* Session cards under this day */}
<div className="ml-3 space-y-1.5 mb-4">
{group.sessions.map(session => {
const isActive = session.is_active === 1;
const isSelected = selectedIds.has(session.id);
const isArchived = session.archived === 1;
const canSelect = selectMode && !isActive;
return (
<div
key={session.id}
className={`border rounded-lg transition ${
selectMode && isActive
? 'opacity-50 cursor-not-allowed border-gray-300 dark:border-gray-600'
: isSelected
? 'border-indigo-500 bg-indigo-50 dark:bg-indigo-900/20 cursor-pointer'
: 'border-gray-300 dark:border-gray-600 hover:border-indigo-400 dark:hover:border-indigo-500 cursor-pointer'
}`}
onClick={() => {
if (longPressFired.current) {
longPressFired.current = false;
return;
}
if (selectMode) {
if (!isActive) toggleSelection(session.id);
} else {
navigate(`/history/${session.id}`);
}
}}
onPointerDown={() => {
if (!isActive) handlePointerDown(session.id);
}}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
>
<div className="p-4">
<div className="flex items-start gap-3">
{selectMode && (
<div className={`mt-0.5 w-5 h-5 flex-shrink-0 rounded border-2 flex items-center justify-center ${
isActive
? 'border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-700'
: isSelected
? 'border-indigo-600 bg-indigo-600'
: 'border-gray-300 dark:border-gray-600'
}`}>
{isSelected && (
<span className="text-white text-xs font-bold"></span>
)}
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex justify-between items-center mb-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-gray-800 dark:text-gray-100">
Session #{session.id}
</span>
{isActive && (
<span className="bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 text-xs px-2 py-0.5 rounded">
Active
</span>
)}
{isArchived && (filter === 'all' || filter === 'archived') && (
<span className="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 text-xs px-2 py-0.5 rounded">
Archived
</span>
)}
</div>
<span className="text-sm text-gray-500 dark:text-gray-400">
{session.games_played} game{session.games_played !== 1 ? 's' : ''}
</span>
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{formatTimeOnly(session.created_at)}
</div>
{session.has_notes && session.notes_preview && (
<div className="mt-2 text-sm text-indigo-400 dark:text-indigo-300 bg-indigo-50 dark:bg-indigo-900/20 px-3 py-2 rounded border-l-2 border-indigo-500">
{session.notes_preview}
</div>
)}
</div>
</div>
</div>
{!selectMode && isAuthenticated && isActive && (
<div className="px-4 pb-4 pt-0">
<button
onClick={(e) => {
e.stopPropagation();
setClosingSession(session.id);
}}
className="w-full bg-orange-600 dark:bg-orange-700 text-white px-4 py-2 rounded text-sm hover:bg-orange-700 dark:hover:bg-orange-800 transition"
>
End Session
</button>
</div>
)}
</div>
);
})}
</div>
</div>
);
})}
```
Each session card inside the group keeps its existing structure (selection, badges, notes preview, etc.) but:
- The date line changes from `formatLocalDate(session.created_at)` to `formatTimeOnly(session.created_at)`
- Remove the per-card `isSundaySession` badge (`🎲 Game Night` span) and the `· Sunday` text — these are now on the day header
- Remove the `isSundaySession` const from inside the card map — it's computed per-group instead
- [ ] **Step 3: Verify frontend builds**
Run: `cd frontend && npm run build`
Expected: Build succeeds
- [ ] **Step 4: Commit**
```bash
git add frontend/src/pages/History.jsx
git commit -m "feat: render sessions grouped by day with styled header bars"
```
---
### Task 5: Frontend — Pagination bar
**Files:**
- Modify: `frontend/src/pages/History.jsx` (add pagination bar below session list, above multi-select action bar)
- [ ] **Step 1: Add pagination bar JSX**
After the closing `</div>` of `<div className="space-y-2">` (the session list) and before the multi-select action bar `{selectMode && selectedIds.size > 0 && (`, add:
```jsx
{/* Pagination bar */}
{limit !== 'all' && (() => {
const limitNum = parseInt(limit, 10);
const totalPages = Math.ceil(totalCount / limitNum);
if (totalPages <= 1) return null;
return (
<div className="flex justify-center items-center gap-4 py-3 mt-3 border-t border-gray-200 dark:border-gray-700">
<button
onClick={() => { setPage(p => p - 1); setSelectedIds(new Set()); }}
disabled={page <= 1}
className={`px-4 py-1.5 rounded-md text-sm font-medium transition ${
page <= 1
? 'bg-gray-600 dark:bg-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed'
: 'bg-indigo-600 text-white hover:bg-indigo-700'
}`}
>
Prev
</button>
<span className="text-sm text-gray-500 dark:text-gray-400">
Page {page} of {totalPages}
</span>
<button
onClick={() => { setPage(p => p + 1); setSelectedIds(new Set()); }}
disabled={page >= totalPages}
className={`px-4 py-1.5 rounded-md text-sm font-medium transition ${
page >= totalPages
? 'bg-gray-600 dark:bg-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed'
: 'bg-indigo-600 text-white hover:bg-indigo-700'
}`}
>
Next
</button>
</div>
);
})()}
```
- [ ] **Step 2: Verify frontend builds**
Run: `cd frontend && npm run build`
Expected: Build succeeds
- [ ] **Step 3: Commit**
```bash
git add frontend/src/pages/History.jsx
git commit -m "feat: add Prev/Next pagination bar to session history"
```
---
### Task 6: Final verification
**Files:** None (verification only)
- [ ] **Step 1: Run full backend test suite**
Run: `npx jest --no-coverage --forceExit`
Expected: All tests pass (147 existing + 9 new = 156)
- [ ] **Step 2: Verify frontend build**
Run: `cd frontend && npm run build`
Expected: Clean build, no warnings
- [ ] **Step 3: Final commit if any cleanup needed**

View File

@@ -0,0 +1,99 @@
# Pagination & Day Grouping — Design Spec
**Date:** 2026-03-23
**Status:** Approved
## Overview
Two enhancements to the session History page:
1. **Pagination** — When "Show X" is set to a value other than "All", add Prev/Next navigation to access older sessions.
2. **Day Grouping** — Group sessions that occurred on the same calendar day under a shared header bar.
## Backend Changes
### `GET /api/sessions` — New `offset` parameter
Add an `offset` query parameter (default `0`) to the existing endpoint. Works with the existing `limit`, `filter`, `X-Total-Count`, and `X-Absolute-Total` headers.
```
GET /api/sessions?filter=default&limit=5&offset=10
```
**Offset validation:** Non-numeric or negative values are clamped to 0. An offset past the end returns an empty array (the pagination bar will show "Page X of Y" and the user can navigate back).
**New response header:**
- `X-Prev-Last-Date` — When `offset > 0`, the raw SQLite `created_at` timestamp (same format as `created_at` in response body, e.g. `"2026-03-23 19:30:00"`) of the session immediately before the current page (the session at position `offset - 1`). Used by the frontend to detect whether the first day group on the current page is a continuation from the previous page. Omitted when `offset` is 0. The frontend parses this with the existing `parseUTCTimestamp` utility.
**SQL changes:** Add `OFFSET` clause to the existing query. For `X-Prev-Last-Date`, run a small secondary query to fetch the `created_at` of the session at position `offset - 1` (same filter/ordering).
No other backend changes required.
## Frontend Changes
### State
- `page` (number, default `1`) — current page number. Derived from offset: `offset = (page - 1) * limit`. **Not persisted** in localStorage; resets to 1 on navigation.
- `prevLastDate` (string|null) — from `X-Prev-Last-Date` header. Used for "(continued)" detection.
### Page math
```
totalPages = Math.ceil(totalCount / limitNum)
offset = (page - 1) * limitNum
```
When `limit` is `"all"`, pagination is disabled (no offset, no pagination bar).
### `loadSessions` changes
Pass `offset` as a query parameter alongside `filter` and `limit`. Read `X-Prev-Last-Date` from response headers.
### Page reset triggers
Changing `filter`, `limit`, or entering/exiting `selectMode` resets `page` to 1.
### Day Grouping (render-time only)
Group the flat session array by local calendar date at render time. For each group:
1. **Day header bar** — Styled with `bg-[#1e2a3a]` (dark) / `bg-gray-100` (light), left border accent (`border-l-[3px] border-indigo-500`), contains:
- Full date: "Sunday, Mar 23, 2026"
- Right side: session count ("2 sessions") and, if Sunday, "🎲 Game Night"
2. **Session cards** — Indented slightly (`ml-3`) beneath their day header. Display **time only** (e.g., "7:30 PM") since the full date is in the header. Remove the per-card "· Sunday" text and per-card "🎲 Game Night" badge since that information is now on the day header.
### "(continued)" detection
When `page > 1` and `prevLastDate` is set:
- Parse the previous page's last session date to a local calendar date string
- If it matches the first day group's date, append an italic "(continued)" tag to that day header (no session count shown for continued groups since the count would be incomplete)
### Pagination bar
Rendered below the session list, above the multi-select action bar (if active). Only shown when `limit !== "all"` and `totalPages > 1`.
Layout: `← Prev` button | "Page X of Y" text | `Next →` button
- Prev button disabled (grayed out) on page 1
- Next button disabled on last page
- Active buttons use indigo (`bg-indigo-600`)
- Disabled buttons use gray (`bg-gray-600/700` with `cursor-not-allowed`)
### Multi-select interaction
Day header bars are not selectable. Only session cards participate in multi-select. Checkboxes render inside the indented card area as they do today. Changing pages clears selected IDs but keeps select mode active.
### Polling behavior
The existing 3-second polling interval refetches the current page (same offset/limit/filter). If sessions are deleted while the user is on a later page and the page becomes empty, the next poll cycle detects `sessions.length === 0 && page > 1` and resets to page 1.
### "Visible" count update
The existing "X visible (Y total)" label continues to work as-is. `sessions.length` reflects the current page's sessions.
## Scope
- No changes to `SessionDetail.jsx`
- No changes to bulk endpoints
- No new dependencies
- The `dateUtils.js` gains a `formatDayHeader` helper (e.g., "Sunday, Mar 23, 2026") and a `getLocalDateKey` helper for grouping
- Existing tests for `GET /sessions` updated to cover `offset` parameter; new tests for `X-Prev-Last-Date` header

View File

@@ -16,7 +16,7 @@ function PresenceBar() {
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 px-4 py-2"> <div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 px-4 py-2">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider font-medium flex-shrink-0"> <span className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider font-medium flex-shrink-0">
who is watching who's here?
</span> </span>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{viewers.map((name, i) => ( {viewers.map((name, i) => (

View File

@@ -1,9 +1,9 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'; import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useToast } from '../components/Toast'; import { useToast } from '../components/Toast';
import api from '../api/axios'; import api from '../api/axios';
import { formatLocalDate, isSunday } from '../utils/dateUtils'; import { formatDayHeader, formatTimeOnly, getLocalDateKey, isSunday } from '../utils/dateUtils';
import { prefixKey } from '../utils/adminPrefs'; import { prefixKey } from '../utils/adminPrefs';
function History() { function History() {
@@ -14,6 +14,9 @@ function History() {
const [sessions, setSessions] = useState([]); const [sessions, setSessions] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [totalCount, setTotalCount] = useState(0); const [totalCount, setTotalCount] = useState(0);
const [absoluteTotal, setAbsoluteTotal] = useState(0);
const [page, setPage] = useState(1);
const [prevLastDate, setPrevLastDate] = useState(null);
const [closingSession, setClosingSession] = useState(null); const [closingSession, setClosingSession] = useState(null);
const [filter, setFilter] = useState(() => localStorage.getItem(prefixKey(adminName, 'history-filter')) || 'default'); const [filter, setFilter] = useState(() => localStorage.getItem(prefixKey(adminName, 'history-filter')) || 'default');
@@ -28,17 +31,26 @@ function History() {
const loadSessions = useCallback(async () => { const loadSessions = useCallback(async () => {
try { try {
const limitNum = limit === 'all' ? null : parseInt(limit, 10);
const offset = limitNum ? (page - 1) * limitNum : 0;
const response = await api.get('/sessions', { const response = await api.get('/sessions', {
params: { filter, limit } params: { filter, limit, offset: offset || undefined }
}); });
setSessions(response.data); setSessions(response.data);
setTotalCount(parseInt(response.headers['x-total-count'] || '0', 10)); setTotalCount(parseInt(response.headers['x-total-count'] || '0', 10));
setAbsoluteTotal(parseInt(response.headers['x-absolute-total'] || '0', 10));
setPrevLastDate(response.headers['x-prev-last-date'] || null);
if (response.data.length === 0 && offset > 0) {
setPage(1);
}
} catch (err) { } catch (err) {
console.error('Failed to load sessions', err); console.error('Failed to load sessions', err);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [filter, limit]); }, [filter, limit, page]);
useEffect(() => { useEffect(() => {
loadSessions(); loadSessions();
@@ -64,12 +76,14 @@ function History() {
setFilter(newFilter); setFilter(newFilter);
localStorage.setItem(prefixKey(adminName, 'history-filter'), newFilter); localStorage.setItem(prefixKey(adminName, 'history-filter'), newFilter);
setSelectedIds(new Set()); setSelectedIds(new Set());
setPage(1);
}; };
const handleLimitChange = (newLimit) => { const handleLimitChange = (newLimit) => {
setLimit(newLimit); setLimit(newLimit);
localStorage.setItem(prefixKey(adminName, 'history-show-limit'), newLimit); localStorage.setItem(prefixKey(adminName, 'history-show-limit'), newLimit);
setSelectedIds(new Set()); setSelectedIds(new Set());
setPage(1);
}; };
const handleCloseSession = async (sessionId, notes) => { const handleCloseSession = async (sessionId, notes) => {
@@ -100,6 +114,7 @@ function History() {
setSelectMode(false); setSelectMode(false);
setSelectedIds(new Set()); setSelectedIds(new Set());
setShowBulkDeleteConfirm(false); setShowBulkDeleteConfirm(false);
setPage(1);
}; };
const handlePointerDown = (sessionId) => { const handlePointerDown = (sessionId) => {
@@ -109,6 +124,7 @@ function History() {
longPressFired.current = true; longPressFired.current = true;
setSelectMode(true); setSelectMode(true);
setSelectedIds(new Set([sessionId])); setSelectedIds(new Set([sessionId]));
setPage(1);
}, 500); }, 500);
}; };
@@ -134,6 +150,23 @@ function History() {
} }
}; };
const groupedSessions = useMemo(() => {
const groups = [];
let currentKey = null;
sessions.forEach(session => {
const dateKey = getLocalDateKey(session.created_at);
if (dateKey !== currentKey) {
currentKey = dateKey;
groups.push({ dateKey, sessions: [session] });
} else {
groups[groups.length - 1].sessions.push(session);
}
});
return groups;
}, [sessions]);
if (loading) { if (loading) {
return ( return (
<div className="flex justify-center items-center h-64"> <div className="flex justify-center items-center h-64">
@@ -179,11 +212,14 @@ function History() {
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-xs text-gray-400 dark:text-gray-500"> <span className="text-xs text-gray-400 dark:text-gray-500">
{totalCount} session{totalCount !== 1 ? 's' : ''} total {sessions.length === absoluteTotal
? `${absoluteTotal} session${absoluteTotal !== 1 ? 's' : ''} total`
: `${sessions.length} visible (${absoluteTotal} total)`
}
</span> </span>
{isAuthenticated && ( {isAuthenticated && (
<button <button
onClick={selectMode ? exitSelectMode : () => setSelectMode(true)} onClick={selectMode ? exitSelectMode : () => { setSelectMode(true); setPage(1); }}
className={`px-3 py-1.5 rounded text-sm font-medium transition ${ className={`px-3 py-1.5 rounded text-sm font-medium transition ${
selectMode selectMode
? 'bg-indigo-600 dark:bg-indigo-700 text-white hover:bg-indigo-700 dark:hover:bg-indigo-800' ? 'bg-indigo-600 dark:bg-indigo-700 text-white hover:bg-indigo-700 dark:hover:bg-indigo-800'
@@ -201,115 +237,176 @@ function History() {
<p className="text-gray-500 dark:text-gray-400">No sessions found</p> <p className="text-gray-500 dark:text-gray-400">No sessions found</p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{sessions.map(session => { {groupedSessions.map((group, groupIdx) => {
const isActive = session.is_active === 1; const isSundayGroup = isSunday(group.sessions[0].created_at);
const isSelected = selectedIds.has(session.id); const isContinued = groupIdx === 0 && page > 1 && prevLastDate &&
const isSundaySession = isSunday(session.created_at); getLocalDateKey(prevLastDate) === group.dateKey;
const isArchived = session.archived === 1;
const canSelect = selectMode && !isActive;
return ( return (
<div <div key={group.dateKey}>
key={session.id} {/* Day header bar */}
className={`border rounded-lg transition ${ <div className="bg-gray-100 dark:bg-[#1e2a3a] rounded-md px-3.5 py-2 mb-2 flex justify-between items-center border-l-[3px] border-indigo-500">
selectMode && isActive <div className="flex items-center gap-2">
? 'opacity-50 cursor-not-allowed border-gray-300 dark:border-gray-600' <span className="text-sm font-semibold text-indigo-700 dark:text-indigo-300">
: isSelected {formatDayHeader(group.sessions[0].created_at)}
? 'border-indigo-500 bg-indigo-50 dark:bg-indigo-900/20 cursor-pointer' </span>
: 'border-gray-300 dark:border-gray-600 hover:border-indigo-400 dark:hover:border-indigo-500 cursor-pointer' {isContinued && (
}`} <span className="text-xs text-gray-400 dark:text-gray-500 italic">(continued)</span>
onClick={() => {
if (longPressFired.current) {
longPressFired.current = false;
return;
}
if (selectMode) {
if (!isActive) toggleSelection(session.id);
} else {
navigate(`/history/${session.id}`);
}
}}
onPointerDown={() => {
if (!isActive) handlePointerDown(session.id);
}}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
>
<div className="p-4">
<div className="flex items-start gap-3">
{selectMode && (
<div className={`mt-0.5 w-5 h-5 flex-shrink-0 rounded border-2 flex items-center justify-center ${
isActive
? 'border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-700'
: isSelected
? 'border-indigo-600 bg-indigo-600'
: 'border-gray-300 dark:border-gray-600'
}`}>
{isSelected && (
<span className="text-white text-xs font-bold"></span>
)}
</div>
)} )}
<div className="flex-1 min-w-0"> </div>
<div className="flex justify-between items-center mb-1"> {!isContinued && (
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2">
<span className="font-semibold text-gray-800 dark:text-gray-100"> <span className="text-xs text-gray-500 dark:text-gray-400">
Session #{session.id} {group.sessions.length} session{group.sessions.length !== 1 ? 's' : ''}
</span> </span>
{isActive && ( {isSundayGroup && (
<span className="bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 text-xs px-2 py-0.5 rounded"> <span className="text-xs font-semibold text-amber-700 dark:text-amber-300">🎲 Game Night</span>
Active
</span>
)}
{isSundaySession && (
<span className="bg-amber-100 dark:bg-amber-900 text-amber-800 dark:text-amber-200 text-xs px-2 py-0.5 rounded font-semibold">
🎲 Game Night
</span>
)}
{isArchived && (filter === 'all' || filter === 'archived') && (
<span className="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 text-xs px-2 py-0.5 rounded">
Archived
</span>
)}
</div>
<span className="text-sm text-gray-500 dark:text-gray-400">
{session.games_played} game{session.games_played !== 1 ? 's' : ''}
</span>
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{formatLocalDate(session.created_at)}
{isSundaySession && (
<span className="text-gray-400 dark:text-gray-500"> · Sunday</span>
)}
</div>
{session.has_notes && session.notes_preview && (
<div className="mt-2 text-sm text-indigo-400 dark:text-indigo-300 bg-indigo-50 dark:bg-indigo-900/20 px-3 py-2 rounded border-l-2 border-indigo-500">
{session.notes_preview}
</div>
)} )}
</div> </div>
</div> )}
</div> </div>
{!selectMode && isAuthenticated && isActive && ( {/* Session cards under this day */}
<div className="px-4 pb-4 pt-0"> <div className="ml-3 space-y-1.5 mb-4">
<button {group.sessions.map(session => {
onClick={(e) => { const isActive = session.is_active === 1;
e.stopPropagation(); const isSelected = selectedIds.has(session.id);
setClosingSession(session.id); const isArchived = session.archived === 1;
}}
className="w-full bg-orange-600 dark:bg-orange-700 text-white px-4 py-2 rounded text-sm hover:bg-orange-700 dark:hover:bg-orange-800 transition" return (
> <div
End Session key={session.id}
</button> className={`border rounded-lg transition ${
</div> selectMode && isActive
)} ? 'opacity-50 cursor-not-allowed border-gray-300 dark:border-gray-600'
: isSelected
? 'border-indigo-500 bg-indigo-50 dark:bg-indigo-900/20 cursor-pointer'
: 'border-gray-300 dark:border-gray-600 hover:border-indigo-400 dark:hover:border-indigo-500 cursor-pointer'
}`}
onClick={() => {
if (longPressFired.current) {
longPressFired.current = false;
return;
}
if (selectMode) {
if (!isActive) toggleSelection(session.id);
} else {
navigate(`/history/${session.id}`);
}
}}
onPointerDown={() => {
if (!isActive) handlePointerDown(session.id);
}}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
>
<div className="p-4">
<div className="flex items-start gap-3">
{selectMode && (
<div className={`mt-0.5 w-5 h-5 flex-shrink-0 rounded border-2 flex items-center justify-center ${
isActive
? 'border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-700'
: isSelected
? 'border-indigo-600 bg-indigo-600'
: 'border-gray-300 dark:border-gray-600'
}`}>
{isSelected && (
<span className="text-white text-xs font-bold"></span>
)}
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex justify-between items-center mb-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-gray-800 dark:text-gray-100">
Session #{session.id}
</span>
{isActive && (
<span className="bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 text-xs px-2 py-0.5 rounded">
Active
</span>
)}
{isArchived && (filter === 'all' || filter === 'archived') && (
<span className="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 text-xs px-2 py-0.5 rounded">
Archived
</span>
)}
</div>
<span className="text-sm text-gray-500 dark:text-gray-400">
{session.games_played} game{session.games_played !== 1 ? 's' : ''}
</span>
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{formatTimeOnly(session.created_at)}
</div>
{session.has_notes && session.notes_preview && (
<div className="mt-2 text-sm text-indigo-400 dark:text-indigo-300 bg-indigo-50 dark:bg-indigo-900/20 px-3 py-2 rounded border-l-2 border-indigo-500">
{session.notes_preview}
</div>
)}
</div>
</div>
</div>
{!selectMode && isAuthenticated && isActive && (
<div className="px-4 pb-4 pt-0">
<button
onClick={(e) => {
e.stopPropagation();
setClosingSession(session.id);
}}
className="w-full bg-orange-600 dark:bg-orange-700 text-white px-4 py-2 rounded text-sm hover:bg-orange-700 dark:hover:bg-orange-800 transition"
>
End Session
</button>
</div>
)}
</div>
);
})}
</div>
</div> </div>
); );
})} })}
</div> </div>
)} )}
{/* Pagination bar */}
{limit !== 'all' && (() => {
const limitNum = parseInt(limit, 10);
const totalPages = Math.ceil(totalCount / limitNum);
if (totalPages <= 1) return null;
return (
<div className="flex justify-center items-center gap-4 py-3 mt-3 border-t border-gray-200 dark:border-gray-700">
<button
onClick={() => { setPage(p => p - 1); setSelectedIds(new Set()); }}
disabled={page <= 1}
className={`px-4 py-1.5 rounded-md text-sm font-medium transition ${
page <= 1
? 'bg-gray-600 dark:bg-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed'
: 'bg-indigo-600 text-white hover:bg-indigo-700'
}`}
>
Prev
</button>
<span className="text-sm text-gray-500 dark:text-gray-400">
Page {page} of {totalPages}
</span>
<button
onClick={() => { setPage(p => p + 1); setSelectedIds(new Set()); }}
disabled={page >= totalPages}
className={`px-4 py-1.5 rounded-md text-sm font-medium transition ${
page >= totalPages
? 'bg-gray-600 dark:bg-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed'
: 'bg-indigo-600 text-white hover:bg-indigo-700'
}`}
>
Next
</button>
</div>
);
})()}
{/* Multi-select Action Bar */} {/* Multi-select Action Bar */}
{selectMode && selectedIds.size > 0 && ( {selectMode && selectedIds.size > 0 && (
<div className="sticky bottom-4 mt-4 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 p-4 flex justify-between items-center"> <div className="sticky bottom-4 mt-4 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 p-4 flex justify-between items-center">

View File

@@ -56,3 +56,44 @@ export function isSunday(sqliteTimestamp) {
return parseUTCTimestamp(sqliteTimestamp).getDay() === 0; return parseUTCTimestamp(sqliteTimestamp).getDay() === 0;
} }
/**
* Get a locale-independent date key for grouping sessions by local calendar day
* @param {string} sqliteTimestamp
* @returns {string} - e.g., "2026-03-23"
*/
export function getLocalDateKey(sqliteTimestamp) {
const d = parseUTCTimestamp(sqliteTimestamp);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* Format a SQLite timestamp as a day header string (e.g., "Sunday, Mar 23, 2026")
* @param {string} sqliteTimestamp
* @returns {string}
*/
export function formatDayHeader(sqliteTimestamp) {
const d = parseUTCTimestamp(sqliteTimestamp);
return d.toLocaleDateString(undefined, {
weekday: 'long',
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
/**
* Format a SQLite timestamp as a time-only string (e.g., "7:30 PM")
* @param {string} sqliteTimestamp
* @returns {string}
*/
export function formatTimeOnly(sqliteTimestamp) {
const d = parseUTCTimestamp(sqliteTimestamp);
return d.toLocaleTimeString(undefined, {
hour: 'numeric',
minute: '2-digit',
});
}

View File

@@ -92,6 +92,97 @@ describe('GET /api/sessions — filter and limit', () => {
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.body).toHaveLength(8); expect(res.body).toHaveLength(8);
}); });
test('offset skips the first N sessions', async () => {
for (let i = 0; i < 5; i++) {
seedSession({ is_active: 0, notes: null });
}
const allRes = await request(app).get('/api/sessions?filter=all&limit=all');
const offsetRes = await request(app).get('/api/sessions?filter=all&limit=2&offset=2');
expect(offsetRes.status).toBe(200);
expect(offsetRes.body).toHaveLength(2);
expect(offsetRes.body[0].id).toBe(allRes.body[2].id);
expect(offsetRes.body[1].id).toBe(allRes.body[3].id);
});
test('offset defaults to 0 when not provided', async () => {
for (let i = 0; i < 3; i++) {
seedSession({ is_active: 0, notes: null });
}
const res = await request(app).get('/api/sessions?filter=all&limit=2');
expect(res.status).toBe(200);
expect(res.body).toHaveLength(2);
});
test('negative offset is clamped to 0', async () => {
seedSession({ is_active: 0, notes: null });
const res = await request(app).get('/api/sessions?filter=all&offset=-5');
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
});
test('non-numeric offset is clamped to 0', async () => {
seedSession({ is_active: 0, notes: null });
const res = await request(app).get('/api/sessions?filter=all&offset=abc');
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
});
test('offset past end returns empty array', async () => {
seedSession({ is_active: 0, notes: null });
const res = await request(app).get('/api/sessions?filter=all&limit=5&offset=100');
expect(res.status).toBe(200);
expect(res.body).toHaveLength(0);
expect(res.headers['x-total-count']).toBe('1');
});
test('X-Prev-Last-Date header is set with correct value when offset > 0', async () => {
for (let i = 0; i < 5; i++) {
seedSession({ is_active: 0, notes: null });
}
const allRes = await request(app).get('/api/sessions?filter=all&limit=all');
const res = await request(app).get('/api/sessions?filter=all&limit=2&offset=2');
expect(res.headers['x-prev-last-date']).toBe(allRes.body[1].created_at);
});
test('X-Prev-Last-Date header is absent when offset is 0', async () => {
seedSession({ is_active: 0, notes: null });
const res = await request(app).get('/api/sessions?filter=all&limit=2');
expect(res.headers['x-prev-last-date']).toBeUndefined();
});
test('X-Total-Count is unaffected by offset', async () => {
for (let i = 0; i < 10; i++) {
seedSession({ is_active: 0, notes: null });
}
const res = await request(app).get('/api/sessions?filter=all&limit=3&offset=6');
expect(res.headers['x-total-count']).toBe('10');
expect(res.body).toHaveLength(3);
});
test('offset works with filter=default', async () => {
for (let i = 0; i < 5; i++) {
seedSession({ is_active: 0, notes: null });
}
const archived = seedSession({ is_active: 0, notes: null });
require('../helpers/test-utils').db.prepare(
'UPDATE sessions SET archived = 1 WHERE id = ?'
).run(archived.id);
const res = await request(app).get('/api/sessions?filter=default&limit=2&offset=2');
expect(res.status).toBe(200);
expect(res.body).toHaveLength(2);
expect(res.headers['x-total-count']).toBe('5');
res.body.forEach(s => expect(s.archived).toBe(0));
});
}); });
describe('POST /api/sessions/:id/archive', () => { describe('POST /api/sessions/:id/archive', () => {