Semester-Wide Partner Rotation with Recency-Based Window
Implement the following plan:
Semester-Wide Partner Rotation with Recency-Based Window
Goal
Replace per-groupKey partner tracking with semester-wide, recency-based tracking. Students get graduated friction when pairing with recent partners: strong friction for their most recent partner (two-step confirm), soft warnings for other recent partners within a window, and no warning beyond the window.
Design
Recency Ranking
Instead of counting raw pairing occurrences, rank each prior partner by how recently the student last worked with them across ALL group activities in the semester:
- Rank 1: Most recent partner (last person they worked with)
- Rank 2: Second most recent, etc.
Window Size
Window = max(3, floor(sqrt(enrolled_students))) where enrolled students is queried from the enrollments table (active students in the semester).
- 100 students → window 10
- 25 students → window 5
- 9 students → window 3
Friction Levels
| Recency Rank | Behavior |
|---|---|
| Rank 1 (immediate repeat) | Two-step confirm: First attempt returns warning with NO join button + code is cleared. Student must re-enter the code, then sees "Join anyway" button. Join does NOT happen until confirmed. |
| Rank 2 through window | Soft warning: Join happens immediately, warning displayed with "OK" dismiss. |
| Beyond window | No warning. |
Current Flow (for reference)
getPriorPartnerCounts()(line 506) filters bygroupKey, returnsRecord<string, number>(userId → count)- GET endpoint (line 163) returns
priorPartnerCountsmap to client - POST join endpoint (line 274) checks count, always performs the join, returns
priorPairingCountin response - Client shows warning cosmetically after join has already happened (line 375)
Changes
1. Replace getPriorPartnerCounts() with getPartnerRecency()
File: apps/api/src/routes/group-activities.ts (~line 506)
Replace the function with:
async function getPartnerRecency(
userId: string,
currentActivityId: string,
semester: string
): Promise<Record<string, number>>
Returns Record<string, number> mapping partnerId → recency rank (1 = most recent).
Query logic:
- Find all groups where user is a member with
joinedAtset, in this semester, excluding current activity - For each group, find other members (also with
joinedAtset) - For each partner, take their most recent
joinedAtacross all shared groups - Order by most recent first, assign ranks 1, 2, 3...
No longer filters by groupKey — uses only semester and activityId.
2. Add getEnrollmentWindowSize() helper
File: apps/api/src/routes/group-activities.ts
async function getEnrollmentWindowSize(semester: string): Promise<number> {
const [result] = await db
.select({ count: count() })
.from(enrollments)
.where(
and(
eq(enrollments.semester, semester),
eq(enrollments.enrollmentType, "student"),
eq(enrollments.isActive, true)
)
);
return Math.max(3, Math.floor(Math.sqrt(result?.count ?? 0)));
}
3. Update GET endpoint
File: apps/api/src/routes/group-activities.ts (~line 163)
- Call
getPartnerRecency()instead ofgetPriorPartnerCounts()(dropgroupKeyparam) - Also call
getEnrollmentWindowSize() - Return
priorPartnerRecency(rank map) andwindowSizeinstead ofpriorPartnerCounts
4. Update POST join endpoint — two-step confirm for rank 1
File: apps/api/src/routes/group-activities.ts (~line 209-443)
- Add
confirmPriorPartnerboolean to join request schema (default false) - After finding the partner membership, call
getPartnerRecency()andgetEnrollmentWindowSize() - Look up the partner's recency rank
- Rank 1 +
!confirmPriorPartner: Return early WITHOUT performing the join:{ "success": false, "requiresConfirmation": true, "recencyRank": 1, "partnerName": "...", "windowSize": N } - Rank 1 +
confirmPriorPartner: Perform the join, return{ "success": true, "recencyRank": 1, ... } - Rank 2-N within window: Perform the join, return
{ "success": true, "recencyRank": N, "partnerName": "...", "windowSize": N } - Beyond window or no prior: Perform the join, return
{ "success": true }
5. Update client join response types and warning UI
File: apps/web/components/mdx/group-activity-client.tsx
Types (~line 149):
- Replace
priorPairingCount?: numberwithrecencyRank?: number,windowSize?: number,requiresConfirmation?: booleaninJoinResponse - Replace
priorPartnerCounts: Record<string, number>withpriorPartnerRecency: Record<string, number>andwindowSize: numberinGroupStatusResponse
Warning state (~line 187):
- Change from
{ count, name, code }to{ recencyRank, name, code, confirmed: boolean } confirmedtracks whether user has re-entered the code (for rank 1 two-step)
Join handler (~line 322):
- On
requiresConfirmation: true(rank 1, first attempt): set warning state withconfirmed: false, clear code input. No "Join anyway" button shown. - When user re-enters the same code and it matches the warning state code: set
confirmed: true, show "Join anyway" button. - Clicking "Join anyway": call
handleJoinwithconfirmPriorPartner: true - On rank 2-N within window (join succeeded): show soft dismissible warning, no action needed.
Warning UI (~line 579):
- Rank 1 (not confirmed): Strong message: "You just worked with [name]! Please find a different partner." No join button. Code input cleared.
- Rank 1 (confirmed, code re-entered): Same strong message + "Join anyway" button.
- Rank 2-N within window: Soft message: "You've worked with [name] recently. Try meeting someone new next time!" with "OK" dismiss button (join already succeeded).
6. Add third test page with different groupKey
File: apps/web/app/testing/components/group-activity-3/page.tsx (new)
import { GroupActivity } from "@/components/mdx/group-activity";
export default function TestGroupActivity3() {
return (
<div data-testid="test-page" className="p-6">
<h1 className="mb-6 text-2xl font-bold">Group Activity Test (Different Key)</h1>
<GroupActivity id="third-activity" group="test-meeting-OTHER" size={2}>
<div className="rounded border border-purple-500/50 bg-purple-500/10 p-4">
<h2 className="mb-2 font-semibold">Third Activity Content!</h2>
<p>This activity has a different group key for cross-key partner tracking tests.</p>
</div>
</GroupActivity>
</div>
);
}
7. Update E2E tests
File: testing/e2e/tests/group-activity.spec.ts
Update existing "Partner Rotation" test (~line 131):
- Update assertions to match new warning text ("just worked with" instead of "already worked with ... today")
- For rank 1 (immediate repeat): verify NO "Join anyway" button on first attempt, re-enter code, verify button appears, click it
Add new test: "cross-groupKey partner tracking":
- Pair student1 and student2 in
group-activity(groupKeytest-meeting-2026-01-25) - Navigate both to
group-activity-3(groupKeytest-meeting-OTHER) - Student2 enters student1's code → verify rank 1 warning appears despite different groupKey
- Verify two-step confirm flow works
Verification
# Run the specific test
cd testing/e2e && CI=true bunx playwright test --project=group-activity
# Run full suite
CI=true bun run test:e2e
If you need specific details from before exiting plan mode (like exact code snippets, error messages, or content you generated), read the full transcript at: ~/.claude/projects/-Users-challen-www-usingandunderstanding-ai/d679d4da-a306-4569-b2f3-ffb567cfd742.jsonl
I'll start by reading the key files that need to be modified to understand the current implementation.
Now let me check the existing test page files and the enrollments schema.
Now let me also check how the schema imports work to use the enrollments table properly.
I have a complete picture of the codebase. Let me now create tasks and implement all the changes.
Starting with the backend changes. First, I'll replace getPriorPartnerCounts with getPartnerRecency and add the getEnrollmentWindowSize helper.
Now replace the getPriorPartnerCounts function with getPartnerRecency and add getEnrollmentWindowSize:
The ne(activityGroupMembers.joinedAt, null as never) is incorrect for null filtering. Let me fix the query to properly filter for non-null joinedAt using drizzle's isNull/isNotNull:
Drizzle ORM uses isNotNull from drizzle-orm. Let me check the available exports:
Drizzle has isNotNull as a separate export. Let me fix the import and the query:
Now fix the getPartnerRecency function to use isNotNull properly:
Now update the GET endpoint to use getPartnerRecency and getEnrollmentWindowSize:
Now update the POST join endpoint. First, update the join schema to accept confirmPriorPartner:
Now update the destructured variables in the POST handler:
Now replace the partner count check logic in the POST handler with recency-based logic. The key change is: for rank 1 without confirmation, return early WITHOUT performing the join:
Now update the success response at the end of the POST handler to return recency info:
Let me verify the backend file looks correct now:
Backend looks good. Now let me update the client.
Update the GroupStatusResponse type:
Update the JoinResponse type:
Update the warning state type:
Now update the handleJoin function. This is the critical part — the logic for handling the two-step confirmation and soft warnings:
Now I need to update the code input onChange handler. When the user re-enters the same code during rank 1 warning, we need to detect that and set confirmed: true. Let me update the CodeInput onChange to detect re-entry of the blocked code:
That onChange logic is overly complicated. Let me simplify it:
Now update the warning UI. Replace the existing prior partner warning block:
Let me verify the client file looks correct:
Client looks correct. Let me mark task 3 as done and move on to tests.
Now create the third test page:
Now update the E2E tests. I need to update the existing "Partner Rotation" test and add the new cross-groupKey test:
Now let me check if the dev server is running, then run the TypeScript checks and E2E tests:
Dev server is running. Let me first do a TypeScript check to catch any compilation errors:
There's a TypeScript error in the server component group-activity.tsx — it constructs the GroupStatusResponse too, so I need to update it as well. Let me read that file:
The server component has its own copy of GroupStatusResponse. I need to update it to match:
Now let me rerun TypeScript check:
All TypeScript checks pass. Now let me run the E2E tests:
All 14 tests pass, including both new partner rotation tests.
Let me also run the lint check to make sure no lint issues were introduced:
All lint checks pass. Here's a summary of all changes made:
Summary
Backend: apps/api/src/routes/group-activities.ts
- Replaced
getPriorPartnerCounts()withgetPartnerRecency()— now queries across all activities in the semester (not per-groupKey), returnsRecord<string, number>mapping partnerId to recency rank (1 = most recent) - Added
getEnrollmentWindowSize()— calculatesmax(3, floor(sqrt(enrolled_students)))from the enrollments table - Updated GET endpoint — returns
priorPartnerRecencyandwindowSizeinstead ofpriorPartnerCounts - Updated POST join endpoint — accepts
confirmPriorPartnerboolean; rank 1 without confirmation returns early withrequiresConfirmation: true(no join performed); rank 1 with confirmation proceeds; rank 2-N within window returns recency info after joining
Client: apps/web/components/mdx/group-activity-client.tsx
- Updated types —
GroupStatusResponseusespriorPartnerRecency/windowSize;JoinResponseusesrecencyRank/windowSize/requiresConfirmation - Updated warning state — tracks
recencyRank,confirmedboolean (for two-step), replacescount - Updated join handler — handles
requiresConfirmationresponse (rank 1 first attempt: clears code, sets unconfirmed warning); rank 2+ soft warning shows after join succeeds - Updated code input onChange — detects re-entry of blocked code to set
confirmed: true - Updated warning UI — rank 1 shows strong warning ("just worked with") with no button until confirmed; rank 2+ shows soft warning ("worked with recently") with OK dismiss button
Server component: apps/web/components/mdx/group-activity.tsx
- Updated
GroupStatusResponsetype to match client
New test page: apps/web/app/testing/components/group-activity-3/page.tsx
- Uses groupKey
test-meeting-OTHER(different from first two activities'test-meeting-2026-01-25)
E2E tests: testing/e2e/tests/group-activity.spec.ts
- Updated "shows warning for repeat partner" → "shows rank 1 warning with two-step confirm for immediate repeat partner" — verifies no join button on first attempt, code clearing, re-entry, then button appears
- Added "cross-groupKey partner tracking shows warning for most recent partner" — pairs in first activity (one groupKey), then tries same pair in third activity (different groupKey), verifies rank 1 warning fires
All 14 E2E tests pass, TypeScript compiles cleanly, and lint is green.
Your task
First, run git status, git diff, and git log --oneline -10 to understand the current state.
Then add and commit all modified files with a descriptive message. Break the changes into multiple commits if appropriate. If there are untracked files, ask if they should be added or added to the appropriate .gitignore file. The user may also want to take over at this point, so provide an abort option as well. Always check changes for unnecessary debugging statements or logging that might have been accidentally included. Prompt the user if you find any, and offer to remove each one by one. Do not use interactive git staging (git add -p) as it does not work.
I reviewed the diff. No debug statements, console.logs, or accidental logging were introduced. The changes are clean.
There is one untracked directory:
apps/web/app/testing/components/group-activity-3/— new test page needed for cross-groupKey E2E tests
Committed as fab8cf1 on branch after_action. Working tree is clean.
For the GPT models on the chat page, can we configure their context window length? That might be helpful for exploration, particularly creating small windows.
(no content)
Let me read a few key files to understand how conversation history is currently managed.
Let me also check the conversation schema and client code to understand the full flow.
Before diving in, I want to clarify what you mean since "context window" could go a few directions.
What would you like to clarify about those questions? Happy to adjust based on what you have in mind.
Can we control this on the message level? That would be cool!
(no content)
Per-message control is a nice idea — students could change it mid-conversation and immediately see how the model's behavior shifts.
Good, I have a clear picture of the architecture. Let me design the plan.
Now I have all the context I need. Let me write the plan.