Compare commits
11 Commits
3da97a39ad
...
0833cf6167
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0833cf6167
|
||
|
|
bfabf390b4
|
||
|
|
ad8efc0fbf
|
||
|
|
db369a807e
|
||
|
|
d49601c54e
|
||
|
|
de1a02b9bb
|
||
|
|
07858f973b
|
||
|
|
57ab3cf7ba
|
||
|
|
c25db19008
|
||
|
|
85c06ff258
|
||
|
|
3b18034d11
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -44,6 +44,7 @@ backend/config/admins.json
|
|||||||
|
|
||||||
# Cursor
|
# Cursor
|
||||||
.cursor/
|
.cursor/
|
||||||
|
.superpowers/
|
||||||
chat-summaries/
|
chat-summaries/
|
||||||
plan.md
|
plan.md
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
611
docs/superpowers/plans/2026-03-23-pagination-day-grouping.md
Normal file
611
docs/superpowers/plans/2026-03-23-pagination-day-grouping.md
Normal 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**
|
||||||
@@ -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
|
||||||
@@ -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) => (
|
||||||
|
|||||||
@@ -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,12 +237,41 @@ 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 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 isActive = session.is_active === 1;
|
||||||
const isSelected = selectedIds.has(session.id);
|
const isSelected = selectedIds.has(session.id);
|
||||||
const isSundaySession = isSunday(session.created_at);
|
|
||||||
const isArchived = session.archived === 1;
|
const isArchived = session.archived === 1;
|
||||||
const canSelect = selectMode && !isActive;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -261,11 +326,6 @@ function History() {
|
|||||||
Active
|
Active
|
||||||
</span>
|
</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') && (
|
{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">
|
<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
|
Archived
|
||||||
@@ -277,10 +337,7 @@ function History() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
{formatLocalDate(session.created_at)}
|
{formatTimeOnly(session.created_at)}
|
||||||
{isSundaySession && (
|
|
||||||
<span className="text-gray-400 dark:text-gray-500"> · Sunday</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{session.has_notes && session.notes_preview && (
|
{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">
|
<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">
|
||||||
@@ -308,8 +365,48 @@ function History() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</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">
|
||||||
|
|||||||
@@ -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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user