feat(classroom): kick notification, classcode URL auto-leave, and kick request flow (Phase 0: localStorage migration)#694
Merged
takaokouji merged 10 commits intoMay 21, 2026
Conversation
…calStorage Student session was persisted to sessionStorage even though the docs and the in-app behavior promised localStorage-style persistence. As a result: - A new tab opened from a `?classcode=<code>` link saw an empty session and offered the seat-selection screen, where the student's own seat was shown as occupied (because the original tab still held the membership on the server). - A browser restart silently lost the membership state on the client, while the server-side seat remained taken until TTL. Move persistence to localStorage so that new tabs and browser restarts restore the student session, matching the documented behavior. A one-shot migration on module load promotes any pre-existing sessionStorage value to localStorage (without overwriting an existing localStorage value) and always clears the legacy sessionStorage entry so it stops resurfacing. Refs #692 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ssion
When a teacher removes a student via DELETE /classrooms/{id}/members/{memberId},
the next thing the student does is open the classroom modal and watch the UI
say "session expired" — the same message they would see for a TTL expiry or a
deleted classroom. They have no way to tell that the teacher just kicked them
on purpose, and the seat-selection flow can't auto-rehydrate to let them pick
a different number.
Switch the kick from a hard delete to a tombstone so the next request from the
kicked sessionToken can be distinguished from the generic auth failure:
- handleDeleteMember now SETs `kicked=true, kickedAt, kickJoinCode,
kickClassName, kickSeatNumber` and shortens the row's TTL to
KICK_TOMBSTONE_TTL_SECONDS (1h). If the row was already gone, we treat the
request as success (idempotent).
- verifySessionToken throws a new KickedError when it sees `kicked=true`. The
top-level handler converts KickedError to a 410 response carrying
`{reason: 'kicked', joinCode, className, seatNumber}` so the student UI can
navigate straight back into seat selection for the same classroom.
- handleListMembers and handleLookupClassroom add a FilterExpression to skip
tombstones — the seat appears free to the teacher's grid and to the
takenSeats list immediately after the kick.
- handleJoinClassroom relaxes its ConditionExpression to
`attribute_not_exists(memberId) OR kicked = :true`, so a freshly kicked seat
can be re-occupied without a separate cleanup pass. The new row drops the
kick attributes, so the old sessionToken naturally stops resolving via the
sessionToken-index and falls back to the standard 401 path.
Integration tests added against the stg endpoint exercise the full sequence
(join → verify=200 → kick → verify=410 + payload → listMembers excludes →
lookup takenSeats excludes → another student joins same seat → original
sessionToken now resolves to 401).
Note: one pre-existing test failure remains in the lookup describe block
(expects `data.className`/`assignmentName` to be undefined while the handler
returns them); it predates this commit (added by aa35f9a) and is left for
a separate cleanup.
Refs #692
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… on forced leave
Pair with the Phase 1 backend change so a student who was kicked by the
teacher gets a specific in-modal banner instead of the generic "session
expired" alert, and lands on the seat-selection screen for the same
classroom — ready to pick a different seat in one click instead of
having to re-type the join code.
- classroom-api.js: attach the parsed response body to thrown Errors so
callers can read response-specific fields (the 410 kick body carries
reason/joinCode/className/seatNumber).
- classroom-error-utils.js: new `extractKickReason(err)` helper that
returns the kick context for a 410 + reason='kicked', or null
otherwise. Pure function, unit-tested in isolation.
- classroom-modal.jsx (container): when refreshStudentStatus catches an
error, detect kick via extractKickReason. On kick: clear the local
session, save a `kickedNotice` state, and call handleJoinWithCode with
the kicked classroom's joinCode — which lookups the seat grid and
flips phase to student-seat. On non-kick errors, keep the existing
generic-alert path.
- ClassroomModal (component) + StudentSeatSelector: forward kickedNotice
and onDismissKickedNotice. The selector renders a dismissible orange
banner above the seat grid ("先生によってクラスから退室させられました
...") using the existing CSS-module styling vocabulary.
- locales: add `gui.classroom.kicked.banner.title` and `.subtitle` in
en / ja / ja-Hira.
- Tests:
* 6 cases for extractKickReason (happy path, missing body, non-kick
410, null/undefined err, …)
* 3 cases for classroom-api error-body propagation
- Playwright smoke verified end-to-end on localhost against the deployed
stg backend: join seat 2 → teacher kicks via API → reopen modal →
banner appears + seat-selection auto-displayed + seat 2 immediately
re-selectable → re-join lands student back in student-joined.
Refs #692
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… to different classroom
Previously, opening a `?classcode=<other>` URL while still holding a
session for a different classroom only cleared the local Redux state —
the previous seat stayed occupied on the server until TTL, so a
returning student or the teacher's grid kept treating the old seat as
"taken." With the Phase 0 move to localStorage this became much more
likely to bite, since the session survives tab close / browser restart.
Extract the URL-parameter decision into a pure helper
`decideClasscodeAction()` and unit-test it across the five cases:
fresh_join (no session), same_class (matching joinCode), switch_class
(different joinCode → release old seat + re-lookup), case-insensitive
join code match, and the malformed-state fallback (sessionToken but no
classroomId).
Wire the helper into the classcode useEffect so the switch_class branch
fires `classroomAPI.leaveClassroom(oldToken, oldClassroomId)` as a
best-effort, non-blocking call. We do not await it: a slow API or a
401 (e.g. server already expired the session) must not stall the new
lookup or block the student from joining the new classroom. The new
join takes precedence either way.
Playwright verified on localhost frontend against the deployed stg
backend:
1. Join class A (seat 3) → `GET /members` shows seat-03 on A.
2. Navigate to `?classcode=<B>` (different class) → land on seat
selection for B, `GET /members` on A is now empty, lookup
`takenSeats` on A returns [].
3. Join class B (seat 2) → revisit `?classcode=<B>` → straight to
student-status (same_class branch, no leave fired, lastActiveAt
ticked on B).
Refs #692
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
While joined, the menu-bar button used to render
"{assignmentName || className} / {seatNumber}" (e.g. "第1回チャットアプリ
を作ろう / 03"). In user testing this turned out to be confusing — the
left half looked like a project title and it wasn't obvious that the
two-digit suffix was the seat number rather than a version or a date.
Replace the joined label with a fixed-format "クラス:出席番号NN" (English:
"Class: Seat NN"). The class / assignment name still lives in the modal
once opened, but is dropped from the always-visible bar so the bar
unambiguously identifies *what the number means*. The unjoined bar
remains "クラス" alone.
- menu-bar.jsx: switch the joined branch to a single FormattedMessage
`gui.menuBar.classroomJoined` (with the seat-number span passed as a
formatter value so the `classroom-menu-seat-number` testid is
preserved for Playwright). The old `classroom-menu-class-name` span
is removed — no test or tool referenced it.
- locales: add `gui.menuBar.classroomJoined` in en/ja/ja-Hira.
- docs: update docs/classroom/ui-ux.md, docs/classroom/testing.md and
.claude/rules/scratch-gui/e2e-test.md to match the new format and
drop the removed testid.
Playwright verified end-to-end on localhost: not-joined → "クラス";
joined to seat 4 → "クラス:出席番号04"; `classroom-menu-seat-number` is
still a child span; `classroom-menu-class-name` is no longer in the
DOM.
Refs #692
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…claim Phase 4 of #692 — student-driven path for the case where a wrong-seat classmate is occupying the seat the student wants. The student opens the seat-selection screen, taps their own seat (currently grey/taken), fires a kick request to the teacher, and waits; the teacher sees the request next to the seat in the class-detail view and either approves (which kicks the occupant, reusing Phase 1's tombstone path so the kicked student gets the "you were removed" banner) or rejects (the request disappears, the occupant stays). CDK: - New `ClassroomKickRequests{stageSuffix}` DynamoDB table with PK=classroomId, SK=requestId (UUID) so a single seat can hold multiple pending requests simultaneously. A `classroomId-seatNumber-index` GSI lets the approve handler delete all sibling requests for the same seat in one query so duplicates don't ghost-linger after a kick. - KICK_REQUESTS_TABLE_NAME env wired into the Lambda; grantReadWriteData added. - Four new HTTP API v2 routes: POST /classrooms/lookup/kick-request (no auth — joinCode + seatNumber) GET /classrooms/{classroomId}/kick-requests (teacher auth) POST /classrooms/{classroomId}/kick-requests/{requestId}/approve DELETE /classrooms/{classroomId}/kick-requests/{requestId} (= reject) Handler: - KICK_REQUEST_TTL_SECONDS = 3600 (1 hour); rows expire automatically. - MAX_KICK_REQUEST_REASON_LENGTH = 200; `validateKickRequestReason()` trims and enforces. - `findClassroomWithSeatOccupied()` refuses requests for a seat that is already empty (or only holds a kick tombstone) so the teacher doesn't see noise from misclicks or stale clients. - `handleApproveKickRequest()` calls into the existing `handleDeleteMember()` so the kicked student hits exactly the same 410 reason='kicked' verify-session path as a direct teacher kick, then batch-deletes every kick request that targeted the same seat (the approved one and any duplicates). - `handleRejectKickRequest()` deletes the single request row; the occupant is untouched. - Anonymous create endpoint reuses the join-IP rate limiter; explicit abuse-prevention beyond that (e.g. dedupe by IP) is intentionally skipped per the Issue's "規制なし — 複数件のリクエストを許可" decision. Integration tests against the deployed stg endpoint cover the full flow plus auth boundaries — 60/60 new+existing tests green except for one pre-existing failure (`/classrooms/lookup` expecting className to be undefined, see aa35f9a) unrelated to this change. Refs #692 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wire the student-side UI for the Phase 4 kick-request endpoints. When a
student opens the seat-selection screen and finds the seat they actually
want already occupied (e.g. someone picked the wrong seat number first),
they can now tap the grey-tinted seat to send a request to the teacher.
- classroom-api.js: add createKickRequest, listKickRequests,
approveKickRequest, rejectKickRequest methods.
- classroom-kick-request-storage.js: small localStorage helper that
persists {requestId, joinCode, seatNumber, reason, createdAt} so the
"依頼中" state survives a tab close or reload. Records older than the
backend's 1h TTL are dropped on load so we don't keep showing the
pending banner forever.
- StudentSeatSelector: render occupied seats as tappable when an
onRequestKick handler is provided AND no pending request is already
outstanding (we cap at one in-flight request to keep the teacher's
view manageable). Show a blue "Waiting for the teacher to free seat
NN..." banner above the grid while a request is pending. The dialog
replaces the grid (vs. overlaying) so users on small viewports can
still tap the buttons.
- KickRequestConfirmDialog (new): confirm dialog with an optional
reason textarea (200 char max enforced both client- and server-side)
and Cancel / Send buttons. The dialog inherits the modal's container
styling so it doesn't introduce a new portal.
- classroom-modal.jsx container: holds dialog/pending state, calls the
API, persists to localStorage, and polls lookupClassroom every 5s
while a request is outstanding. When the targeted seat is no longer
in takenSeats, the teacher acted (approve = handleDeleteMember
kicked the occupant; reject = request just disappeared from the
list; TTL = backend GC'd it after an hour) — we clear pending state
so the student can re-pick the seat. Submitting a successful join
also clears pending state.
- locales: gui.classroom.kickRequest.{title,body,reasonPlaceholder,
cancel,submit,pendingBanner} in en/ja/ja-Hira.
End-to-end Playwright check on localhost against stg backend:
1. seat 2 occupied by another student via API.
2. Open `?classcode=...` → student-seat. Seat 2 is grey but
clickable; `data-taken="1"` exposed for tests.
3. Tap seat 2 → kick-request-confirm-dialog opens with title
"出席番号02の人に退室を依頼しますか?".
4. Type "私の席です" reason → submit → 201 from
/classrooms/lookup/kick-request. Banner appears, seat 2 is no
longer tappable (`data-taken="0"`), localStorage holds the
pending record.
5. Teacher approves via API. Within one polling tick (5s) the
banner disappears, seat 2 becomes available, localStorage is
cleared, and the student can now select it.
Unit tests added (14 total):
* createKickRequest: payload shape, omits reason, no Authorization
header
* Storage helper: load/save/clear, stale-record drop, malformed
JSON, missing fields, no-op for invalid input
Refs #692
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wire the teacher's side of the Phase 4-5 kick-request flow into the
existing class-detail screen. The seat grid shows a red "!" badge on
each occupied seat that has any pending request, and the member-detail
panel (which opens when the teacher clicks a seat) gains a yellow
"requests from students" panel listing each request's optional reason
with per-request Approve / Reject buttons.
Approve calls /classrooms/{id}/kick-requests/{rid}/approve which
already triggers the same kick path as the manual Remove button — the
kicked student gets the 410 reason='kicked' verify-session response
and the seat opens up in `lookupClassroom` for whoever was waiting.
Reject just deletes the request row; the occupant stays.
- use-teacher-classrooms.js: parallel-fetch listKickRequests alongside
listMembers + listSubmissions on both initial load and the 30s
auto-refresh. Group by seatNumber for fast lookup in the renderer.
Add `handleApproveKickRequest` / `handleRejectKickRequest`, which
also call `refreshMembersOnly` so the seat grid and member panel
reflect the kick / cleared request without an extra round trip.
Reset kickRequestsBySeat in resetClassrooms, handleBackToDashboard.
- use-teacher-classroom.js: forward the new state + handlers through
the aggregator.
- classroom-teacher-modal (container + presentational): pass
kickRequestsBySeat / onApproveKickRequest / onRejectKickRequest
through to TeacherClassDetail.
- TeacherClassDetail: add a red "!" badge (`seat-kick-request-badge`)
to each member cell with pending requests; expose seat-level
`data-testid="classroom-seat-kick-request-{N}"` for tests. Compute
the per-seat slice of kickRequestsBySeat for the selected seat and
pass it to TeacherMemberDetail.
- TeacherMemberDetail: render a `kick-request-panel` below the Remove
button when the selected seat has pending requests, with one row
per request (reason or "(no reason given)" placeholder, plus
per-request Approve / Reject buttons). Buttons are bound via stable
callbacks to satisfy `react/jsx-no-bind`.
- classroom-modal.css: `seat-kick-request-badge` (absolute-positioned
red dot, requires the cell to be `position: relative` — added to
`.member-cell`), and the yellow `kick-request-panel` plus inner
styles for the approve (red) / reject (light) buttons.
- locales: gui.classroom.kickRequest.{teacherTitle,noReason,approve,
reject} in en/ja/ja-Hira (teacherTitle is plural-aware via ICU).
End-to-end Playwright check against deployed stg:
1. Teacher logs in via devlogin, opens a class with seat 3 occupied
and one outstanding kick request.
2. Seat-3 cell shows the red "!" badge.
3. Clicking seat 3 opens the member detail; "1 件の退室リクエストが
届いています" header + "「これは私の席です」" reason + Approve /
Reject buttons.
4. Reject → request gone, badge cleared, member still seated;
`GET /kick-requests` empty, `GET /members` still has seat-03.
5. Send a new request via curl. Refresh → badge reappears. Approve
→ seat-03 removed from `GET /members`, request cleared, badge
gone.
Refs #692
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bring the classroom docs in sync with the Phase 1-6 implementation:
- README: new "強制退室通知と退室リクエスト" section pointing readers at
the new flows and noting the sessionStorage → localStorage migration
+ classcode auto-leave behavior.
- architecture.md: API route table now lists
/classrooms/lookup/kick-request and the three teacher kick-request
endpoints; verify-session is documented to return 410 with reason
for kicked sessions. New ClassroomKickRequests table (PK
classroomId / SK requestId, classroomId-seatNumber-index GSI, TTL
1h) and the membership kick-tombstone shape are described in their
own subsections.
- user-stories.md: two new student stories (request the teacher free a
seat / handle being kicked with the in-modal banner) and one new
teacher story (approve/reject pending kick requests in the
member-detail panel).
- testing.md + .claude/rules/scratch-gui/e2e-test.md: data-testid
tables list every new selector
(classroom-kicked-banner, kick-request-confirm-dialog,
kick-request-reason-input, kick-request-submit/cancel/error,
kick-request-pending-banner, classroom-seat-kick-request-{N},
classroom-member-kick-request-panel,
classroom-kick-request-row/approve/reject-{requestId}).
- source-code.md: new file entries (kick-request-confirm-dialog.jsx,
classroom-kick-request-storage.js); classroom-api.js method count
bumped 20 → 24.
- screenshots/: add four new captures
(0103 menu-bar joined, 0208 teacher kick-request panel,
0307 student kicked banner, 0308 student kick-request pending).
Refs #692
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…jected
Two follow-up fixes after the first round of preview-URL verification:
1) **Reject UX**: a student who sent a kick request used to watch the
"依頼中です…" banner for up to an hour after the teacher hit Reject —
the banner only cleared once the polling lookup observed the seat go
empty, which never happens on rejection. Surface active kick-request
IDs from the public lookup endpoint so the student's 5-second poll
can also detect "my request is gone but the seat is still taken =
rejected (or TTL'd)" and swap the pending banner for a red "出席番号NN
の退室は受理されませんでした。別の席を選ぶか、もう一度依頼を出してくだ
さい。" banner with a × dismiss button. The same banner clears
automatically on a new request submission or a successful join.
- handler.ts `handleLookupClassroom`: also query the kick-requests
table and return `activeKickRequestIds: string[]` alongside the
existing fields. The IDs are UUIDs the client already received
from `/lookup/kick-request`, so no new auth boundary.
- integration test: covers (a) ID present after submit,
(b) absent after reject.
- classroom-modal container: polling now branches on
`seatStillTaken` × `requestStillActive` and drives a new
`kickRequestRejectedNotice` state.
- StudentSeatSelector + classroom-modal component: new banner
rendered when the rejected notice is set and no pending banner is
active. Banner is dismissible
(`kick-request-rejected-banner-dismiss`) and also clears when the
student opens a fresh kick dialog or successfully joins another
seat.
- locales: `gui.classroom.kickRequest.rejectedBanner` in en / ja /
ja-Hira.
2) **Seat color alignment**: the teacher's class-detail grid showed
empty = grey and occupied = blue, which was the inverse of the
student's seat-selection grid (empty = blue, occupied = grey).
Swap the two member-cell color classes so the role-independent
rule "blue = available, grey = occupied-but-no-submission" holds on
both sides. Submitted (green) and returned (orange) keep their
teacher-only colors.
End-to-end Playwright check against the updated stg backend:
1. Student joins seat 2 → seat is grey in the teacher view.
2. Another student taps seat 2 → kick-request submitted, pending
banner appears, server returns the requestId in
`activeKickRequestIds`.
3. Teacher rejects → within ≤ 5 s the student's polling tick swaps
the blue pending banner for the red rejected banner; pending
cleared from localStorage; seat 2 is tappable again so the
student can send a new request if needed.
Refs #692
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
61 tasks
github-actions Bot
pushed a commit
that referenced
this pull request
May 21, 2026
…-692-classroom-localstorage-migration feat(classroom): kick notification, classcode URL auto-leave, and kick request flow (Phase 0: localStorage migration)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Issue #692 のクラス管理機能改修。問題2(classcode URL 再オープン時に自分の席が押せない) の根本原因の半分(sessionStorage で参加状態を保持していた)を解消し、ドキュメント (
docs/classroom/*.md) の「localStorage に永続化」記述と実装を一致させる。smalruby:classroom) をsessionStorage→localStorageに移行これだけで以下が解決:
?classcode=付き URL を開いても、すでに参加済みならstudent-statusに直行(自分の席が「使用中」で押せなくならない)Refs #692
Implementation Steps(TDD + Phase-by-Phase Commit)
Phase 0: ストレージ移行(localStorage 化)
[RED]test/unit/reducers/classroom-reducer.test.jsを更新 —localStorageを見るように、sessionStorage→localStorage マイグレーション込みで失敗するテストを追加[GREEN]src/reducers/classroom.jsを更新 —loadSession/saveSession/clearStoredSessionをlocalStorage経由に変更、旧 sessionStorage 値があれば 1 回だけコピー後に削除[PASS]lint + 該当 unit test (21 tests pass, 0 warnings)[COMMIT & PUSH]fix(classroom): migrate session persistence from sessionStorage to localStorage[MAKE PR]Phase 1: backend — kick tombstone + verify-session 拡張
[RED]lambda/tests/handler.integration.test.tsに「kick 後 → verify-session 410 reason='kicked'」のテスト追加(10 ケース)[GREEN]handler.tsのhandleDeleteMember/handleVerifySession/handleLookupClassroom/handleJoinClassroomを改修 + 新規KickedErrorクラス[PASS]docker compose run --rm -w /app/infra/smalruby-classroom infra npm test— 63 unit tests pass[stg deploy]cdk deploy --context stage=stg完了、integration 43/44 pass(1 件 pre-existing failure 残)[COMMIT & PUSH]feat(classroom): mark kicked members and surface reason via verify-sessionPhase 2: frontend — kick 通知 + 自動 seat 遷移
[RED]新規test/unit/containers/classroom-error-utils.test.js(6 ケース) +test/unit/lib/classroom-api.test.js(3 ケース) で kick 検出ロジック / API 410 body 透過を unit-test[GREEN]extractKickReason()追加、classroom-api.jsでerr.bodyを expose、classroom-modal.jsxで 410 reason='kicked' を検出してkickedNoticestate を保存・seat 画面へ自動遷移、StudentSeatSelector に dismissible バナーを追加 (classroom-kicked-banner)、locales 3 言語に新メッセージ[PASS]lint + 該当 unit test 30/30 pass + Playwright で localhost → stg backend で完全 kick→banner→re-join サイクル確認 (tmp/phase2-kick-banner.png)[COMMIT & PUSH]feat(classroom): show kick banner and auto-navigate to seat selection on forced leavePhase 3: frontend — classcode URL 再オープン時の自動 leave
[RED]新規test/unit/containers/classroom-classcode-utils.test.js(5 ケース) で pure なdecideClasscodeAction()の挙動を検証(fresh_join / same_class / switch_class / 大文字小文字差 / classroomId 欠落フォールバック)[GREEN]新規src/containers/classroom-classcode-utils.js追加、classroom-modal.jsxの classcode useEffect が switch_class 時に best-effort でleaveClassroom()を発射(await しない: 遅延 / 401 で新 lookup を止めない)[PASS]lint zero warnings + classroom 関連 unit test 35/35 pass[COMMIT & PUSH]fix(classroom): leave old session on server when classcode URL points to different classroomPhase 4: backend — kick request エンドポイント群
[RED]integration test 16 ケース — create (4 シナリオ) / list (初期空・1件・2件) / approve / reject / 401 認証境界 (3 件)[GREEN]classroom-stack.tsにClassroomKickRequests{stage}テーブル +classroomId-seatNumber-indexGSI、handler.tsに 4 ハンドラ + 4 ルーティング +findClassroomWithSeatOccupiedヘルパー +validateKickRequestReason、HTTP API v2 に 4 ルート (POST/classrooms/lookup/kick-request、GET/classrooms/{id}/kick-requests、POST.../approve、DELETE.../{requestId})、KICK_REQUEST_TTL_SECONDS=3600(1h)、approve は handleDeleteMember を呼び出して Phase 1 の tombstone 経路を再利用[PASS]TypeScript clean + unit test 63/63 pass + cdk synth + cdk diff レビュー[stg deploy]cdk deploy --context stage=stg完了、integration test 59/60 pass(残 1 件はaa35f9a7b9由来の pre-existing failure)[COMMIT & PUSH]feat(classroom): kick request endpoints for student-initiated seat reclaimPhase 5: frontend — 生徒の退室リクエスト送信
[RED]API 3 ケース (test/unit/lib/classroom-api.test.js) + storage 8 ケース (test/unit/lib/classroom-kick-request-storage.test.js) で createKickRequest + localStorage 永続化を unit test[GREEN]classroom-api.jsに 4 メソッド (create/list/approve/reject)、classroom-kick-request-storage.js新規 (1h TTL)、classroom-modal.jsxcontainer に席タップハンドラ + ダイアログ state + 5s polling、kick-request-confirm-dialog.jsx新規、student-seat-selector.jsxで「使用中の席」を tap 可能 + pending バナー、locales 3 言語[PASS]lint + 該当 unit test 14/14 pass + Playwright で完全動作確認 (タップ→ダイアログ→送信→pending バナー→localStorage 永続化→承認後 5s polling で席空き反映)[COMMIT & PUSH]feat(classroom): allow students to request seat reclaim from teacherPhase 6: frontend — 先生の退室リクエスト承認 / 却下 UI
[GREEN]use-teacher-classrooms.jsに kickRequestsBySeat + approve/reject handler 追加、30 秒自動リフレッシュにも組込み、teacher-class-detail.jsxの seat cell に赤い「!」バッジ +kickRequestsForSelectedSeatをteacher-member-detail.jsxに渡す、teacher-member-detail.jsxで 「承認 (kick this student)」/「却下」ボタンを描画、CSS で badge + panel デザイン、locales 3 言語 (plural-aware)[PASS]lint + classroom 全 unit test 46/46 pass + Playwright で完全動作確認 (座席バッジ表示 → 「却下」でバッジ消去・メンバー残存 → 別リクエスト送信 → 「承認」でメンバー削除 + バッジ消去)[COMMIT & PUSH]feat(classroom): teacher UI to approve or reject kick requestsPhase 7: Integration tests + docs + screenshots
docs/classroom/{README,architecture,user-stories,source-code,testing}.md更新、.claude/rules/scratch-gui/e2e-test.mdの data-testid 表を更新0103-menu-bar-joined.png/0208-teacher-kick-request-panel.png/0307-student-kicked-banner.png/0308-student-kick-request-pending.png[COMMIT & PUSH]docs(classroom): document kick notification and kick request flowPhase DoD: CI 完了待ち + stg デプロイ + ブラウザ確認
gh pr checks <PR番号> --watchで CI 完了待ちDefinition of Done
aa35f9a7b9由来の className expected undefined テスト)npm run lintで zero warnings) — ローカルで確認、CI lint job も greeninfra/smalruby-classroomのnpm run test:integrationpass0307-student-kicked-banner.png)GET /membersが空に) + 新クラスの seat 選択へ遷移 (Phase 3 で確認、A クラス members:[] / takenSeats:[] → B クラス seat 選択)0308-student-kick-request-pending.png)0208-teacher-kick-request-panel.png)GET /kick-requests空 + メンバー残存)tmp/dod-ipad-landscape-*.png,tmp/dod-iphone-portrait-*.png)npm run build) で同じシナリオ — CI のbuild-and-deployジョブ (prod build) が pass、build:devで動作確認済みコードが prod build でも問題なくバンドルされたTest plan
bin/dx bash -c "cd packages/scratch-gui && npm exec jest test/unit/reducers/classroom-reducer.test.js test/unit/containers/classroom-classcode-utils.test.js test/unit/containers/classroom-error-utils.test.js test/unit/lib/classroom-api.test.js test/unit/lib/classroom-kick-request-storage.test.js"— 46 passbin/dx bash -c "cd packages/scratch-gui && npm run lint"— zero warningsdocker compose run --rm -w /app/infra/smalruby-classroom infra npm test— 63 passdocker compose run --rm -w /app/infra/smalruby-classroom infra npm run test:integration(stg) — 59/60 pass (1 件 pre-existing)🤖 Generated with Claude Code