Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
d6d3b21
Add session-grouped activity feed with inferred sessions
marcodejongh Feb 26, 2026
0f715a2
Improve session feed cards: UX fixes, chart redesign, ungrouped sessi…
marcodejongh Feb 26, 2026
5fc8679
Add outcome doughnut chart to desktop session feed cards
marcodejongh Feb 26, 2026
5e64f29
Persist inferred sessions, simplify feed, and add session editing
marcodejongh Feb 26, 2026
1b21503
Address PR review: race condition, orphaned ticks, dedup fetch, guard…
marcodejongh Feb 26, 2026
d614806
Fix migration: drop existing index before recreating with new columns
marcodejongh Feb 26, 2026
6a7717c
Fix session-detail-content tests: add mocks for new dependencies
marcodejongh Feb 26, 2026
06b4ca7
Address PR review: transaction safety, theme tokens, tests, and docs
marcodejongh Feb 26, 2026
dff1660
Add ownerUserId field, mutation edge case tests, and doc updates
marcodejongh Feb 26, 2026
192ed5e
Fix transaction inconsistency, add web builder tests, improve mutatio…
marcodejongh Feb 26, 2026
f6e5ff0
Fix N+1 query perf, transaction safety, and type safety in session re…
marcodejongh Feb 26, 2026
b2c5769
Fix nested transaction, add transaction to addUserToSession, remove d…
marcodejongh Feb 26, 2026
23b5c22
Fix session status race, remove unused params, improve error context
marcodejongh Feb 26, 2026
8a4c3c0
Fix e2e test selector: use activity-feed-item testid on SessionFeedCard
marcodejongh Feb 26, 2026
c29f960
Fix debounce leak, add batching, and eliminate stats race condition
marcodejongh Feb 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions docs/inferred-sessions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# Inferred Sessions

This document describes how climbing sessions are automatically inferred from tick data, how users can edit them, and how the session feed works.

---

## Overview

Every tick (ascent/attempt) in `boardsesh_ticks` belongs to exactly one session:

- **Party mode sessions** (`session_id`): Created explicitly when users start a collaborative session via the party mode UI. Stored in `board_sessions`.
- **Inferred sessions** (`inferred_session_id`): Automatically generated by grouping a user's ticks based on a **4-hour gap heuristic**. Stored in `inferred_sessions`.

There are no "ungrouped" ticks — the backfill migration (0060) ensures every historical tick has a session assignment.

---

## Inferred Session Builder

**Backend file:** `packages/backend/src/jobs/inferred-session-builder.ts`

### How sessions are created

1. **Real-time** (`assignInferredSession`): Called from `saveTick` when a user logs a new tick. Looks at the user's most recent tick — if it's within 4 hours, the new tick joins that session. Otherwise, a new inferred session is created.

2. **Batched** (`runInferredSessionBuilderBatched`): Runs on a 30-minute interval in the backend. Processes all users who have unassigned ticks, grouping them using the same 4-hour gap heuristic.

3. **Post-sync** (`buildInferredSessionsForUser`): Runs after Aurora data sync completes, assigning sessions to newly imported ticks.

### Deterministic session IDs

Session IDs are generated using **UUIDv5** with a fixed namespace (`6ba7b812-9dad-11d1-80b4-00c04fd430c8`) and input `userId:firstTickTimestamp`. This means:
- The same user + first tick timestamp always produces the same session ID
- Re-running the builder is idempotent
- The SQL backfill migration produces identical IDs to the TypeScript builder (via PostgreSQL's `uuid_generate_v5`)

---

## Session Editing

Users can edit inferred sessions they participate in. Authorization requires being either the session owner (from `inferred_sessions.user_id`) or a manually-added member (via `session_member_overrides`).

### GraphQL Mutations

**Schema:** `packages/shared-schema/src/schema.ts`
**Resolvers:** `packages/backend/src/graphql/resolvers/social/session-mutations.ts`

#### `updateInferredSession`
Update session name and/or description. The `description` field maps to `goal` in the feed response.

#### `addUserToSession`
Adds another user's ticks to the session:
1. Finds the target user's ticks within ±30 minutes of the session's time window
2. Saves each tick's current `inferredSessionId` to `previousInferredSessionId` (for undo)
3. Reassigns `inferredSessionId` to the target session
4. Creates a `session_member_overrides` record
5. Recalculates aggregate stats for both affected sessions

#### `removeUserFromSession`
Restores ticks to their original sessions (non-destructive undo):
1. Restores each tick's `inferredSessionId` from `previousInferredSessionId`
2. Deletes the `session_member_overrides` record
3. Recalculates stats for all affected sessions
4. The session owner cannot be removed

---

## Database Schema

### `inferred_sessions` table
| Column | Type | Description |
|--------|------|-------------|
| `id` | text PK | Deterministic UUIDv5 |
| `user_id` | text FK | Session owner |
| `name` | text nullable | User-editable session name |
| `description` | text nullable | User-editable notes (maps to `goal`) |
| `first_tick_at` | timestamp | Earliest tick time |
| `last_tick_at` | timestamp | Latest tick time |
| `tick_count` | integer | Total ticks in session |
| `total_sends` | integer | Flashes + sends |
| `total_flashes` | integer | Flash count |
| `total_attempts` | integer | Attempt count |

### `session_member_overrides` table
Tracks when a user is manually added to another user's session.

| Column | Type | Description |
|--------|------|-------------|
| `session_id` | text FK | Target inferred session |
| `user_id` | text FK | Added user |
| `added_by_user_id` | text FK | Who added them |
| `added_at` | timestamp | When they were added |

### `boardsesh_ticks` columns
| Column | Description |
|--------|-------------|
| `session_id` | Party mode session (from `board_sessions`) |
| `inferred_session_id` | Inferred session assignment |
| `previous_inferred_session_id` | Saved before manual reassignment (for undo) |

---

## Session Feed

**Resolver:** `packages/backend/src/graphql/resolvers/social/session-feed.ts`

The `sessionGroupedFeed` query returns sessions ordered by `last_tick_at`. Each session includes:
- `ownerUserId` — the actual session owner (`inferred_sessions.user_id` or `board_sessions.created_by_user_id`), used by the frontend to determine who can be removed
- Participant info with per-user stats (sorted by sends DESC)
- Grade distribution
- Board types used
- Hardest grade sent
- Duration
- Vote/comment counts

The feed uses `COALESCE(session_id, inferred_session_id)` to unify party and inferred sessions. Since all ticks have one of these set, no complex ungrouped-session logic is needed.

---

## Frontend

**Session detail page:** `packages/web/app/session/[sessionId]/`
- Server component (`page.tsx`) fetches session data with `React.cache()` deduplication
- Client component (`session-detail-content.tsx`) handles editing, add/remove users
- `user-search-dialog.tsx` provides user search for adding climbers

**Feed card:** `packages/web/app/components/activity-feed/session-feed-card.tsx`
- Compact card showing session summary, grade chart, participants
4 changes: 3 additions & 1 deletion docs/social-features-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ The polymorphic system uses a string enum `entity_type` to identify what's being
| `proposal` | A community proposal on a climb | proposal `uuid` | Discussion thread on the proposal |
| `board` | A user-created board entity | board `uuid` | Board discussion / community thread |

This is extensible -- new entity types (e.g. `session`) can be added later by extending the enum.
The `session` entity type has been implemented — see [`docs/inferred-sessions.md`](./inferred-sessions.md) for the inferred session system, session-grouped activity feed, and session editing mutations (rename, add/remove users).

**Key distinction**: A climb can have comments in two independent scopes:
- `playlist_climb` with `entity_id = "{playlist_uuid}:{climb_uuid}"` -- discussion in the context of a curated playlist
Expand Down Expand Up @@ -3057,6 +3057,8 @@ Add `gym` to `social_entity_type` enum: `ALTER TYPE social_entity_type ADD VALUE

### Milestone 11: Enhanced Sessions

> **Note**: Inferred sessions and session editing have been implemented. See [`docs/inferred-sessions.md`](./inferred-sessions.md) for the current system: automatic session grouping via 4-hour gap heuristic, session-grouped activity feed, and mutations for renaming sessions and adding/removing users. The plan below covers additional party mode enhancements not yet built.

**User value**: "I can set goals for my session, see a summary when I'm done, and sessions automatically end when everyone leaves."

**DB schema changes to `board_sessions`** (`packages/db/src/schema/app/sessions.ts`):
Expand Down
1 change: 1 addition & 0 deletions packages/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"test:db:migrate": "DATABASE_URL=postgresql://postgres:postgres@localhost:5433/boardsesh_backend_test drizzle-kit migrate",
"test:integration": "vitest run src/__tests__/integration.test.ts",
"test:redis": "vitest run src/__tests__/redis-pubsub.test.ts",
"backfill-sessions": "tsx src/scripts/backfill-inferred-sessions.ts",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio"
Expand Down
185 changes: 185 additions & 0 deletions packages/backend/src/__tests__/inferred-session-assignment.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';

// Mock the db module before imports
const mockSelect = vi.fn();
const mockFrom = vi.fn();
const mockWhere = vi.fn();
const mockOrderBy = vi.fn();
const mockLimit = vi.fn();
const mockUpdate = vi.fn();
const mockSet = vi.fn();
const mockInsert = vi.fn();
const mockValues = vi.fn();
const mockOnConflictDoUpdate = vi.fn();

vi.mock('../db/client', () => ({
db: {
select: (...args: unknown[]) => {
mockSelect(...args);
return {
from: (...a: unknown[]) => {
mockFrom(...a);
return {
where: (...a2: unknown[]) => {
mockWhere(...a2);
return {
orderBy: (...a3: unknown[]) => {
mockOrderBy(...a3);
return {
limit: (...a4: unknown[]) => {
mockLimit(...a4);
return Promise.resolve([]);
},
};
},
};
},
};
},
};
},
update: (...args: unknown[]) => {
mockUpdate(...args);
return {
set: (...a: unknown[]) => {
mockSet(...a);
return {
where: (...a2: unknown[]) => {
mockWhere(...a2);
return Promise.resolve();
},
};
},
};
},
insert: (...args: unknown[]) => {
mockInsert(...args);
return {
values: (...a: unknown[]) => {
mockValues(...a);
return {
onConflictDoUpdate: (...a2: unknown[]) => {
mockOnConflictDoUpdate(...a2);
return Promise.resolve();
},
};
},
};
},
},
}));

vi.mock('@boardsesh/db/schema', () => ({
boardseshTicks: {
uuid: 'uuid',
userId: 'user_id',
climbedAt: 'climbed_at',
status: 'status',
sessionId: 'session_id',
inferredSessionId: 'inferred_session_id',
},
inferredSessions: {
id: 'id',
userId: 'user_id',
firstTickAt: 'first_tick_at',
lastTickAt: 'last_tick_at',
endedAt: 'ended_at',
tickCount: 'tick_count',
totalSends: 'total_sends',
totalFlashes: 'total_flashes',
totalAttempts: 'total_attempts',
},
}));

import { generateInferredSessionId } from '../jobs/inferred-session-builder';

describe('Inferred Session Assignment (assignInferredSession)', () => {
beforeEach(() => {
vi.clearAllMocks();
});

describe('generateInferredSessionId used in assignment', () => {
it('generates a valid UUID for new sessions', () => {
const id = generateInferredSessionId('user-1', '2024-01-15T10:00:00.000Z');
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
});

it('is deterministic for same inputs', () => {
const id1 = generateInferredSessionId('user-1', '2024-01-15T10:00:00.000Z');
const id2 = generateInferredSessionId('user-1', '2024-01-15T10:00:00.000Z');
expect(id1).toBe(id2);
});

it('varies with different users', () => {
const id1 = generateInferredSessionId('user-1', '2024-01-15T10:00:00.000Z');
const id2 = generateInferredSessionId('user-2', '2024-01-15T10:00:00.000Z');
expect(id1).not.toBe(id2);
});

it('varies with different timestamps', () => {
const id1 = generateInferredSessionId('user-1', '2024-01-15T10:00:00.000Z');
const id2 = generateInferredSessionId('user-1', '2024-01-15T18:00:00.000Z');
expect(id1).not.toBe(id2);
});
});

describe('Status-based counting logic', () => {
it('flash counts as both send and flash', () => {
const status = 'flash';
const isSend = status === 'flash' || status === 'send';
const isFlash = status === 'flash';
const isAttempt = status === 'attempt';

expect(isSend).toBe(true);
expect(isFlash).toBe(true);
expect(isAttempt).toBe(false);
});

it('send counts as send only', () => {
const status = 'send';
const isSend = status === 'flash' || status === 'send';
const isFlash = status === 'flash';
const isAttempt = status === 'attempt';

expect(isSend).toBe(true);
expect(isFlash).toBe(false);
expect(isAttempt).toBe(false);
});

it('attempt counts as attempt only', () => {
const status = 'attempt';
const isSend = status === 'flash' || status === 'send';
const isFlash = status === 'flash';
const isAttempt = status === 'attempt';

expect(isSend).toBe(false);
expect(isFlash).toBe(false);
expect(isAttempt).toBe(true);
});
});

describe('Gap detection logic', () => {
const SESSION_GAP_MS = 4 * 60 * 60 * 1000;

it('detects ticks within 4h as same session', () => {
const prevTime = new Date('2024-01-15T10:00:00.000Z').getTime();
const currTime = new Date('2024-01-15T13:00:00.000Z').getTime();
const gap = Math.abs(currTime - prevTime);
expect(gap <= SESSION_GAP_MS).toBe(true);
});

it('detects ticks beyond 4h as new session', () => {
const prevTime = new Date('2024-01-15T10:00:00.000Z').getTime();
const currTime = new Date('2024-01-15T14:00:00.001Z').getTime();
const gap = Math.abs(currTime - prevTime);
expect(gap > SESSION_GAP_MS).toBe(true);
});

it('detects exactly 4h gap as same session', () => {
const prevTime = new Date('2024-01-15T10:00:00.000Z').getTime();
const currTime = new Date('2024-01-15T14:00:00.000Z').getTime();
const gap = Math.abs(currTime - prevTime);
expect(gap <= SESSION_GAP_MS).toBe(true);
});
});
});
Loading
Loading