diff --git a/docs/admin/README.md b/docs/admin/README.md index 57a873c125..42ac0624d6 100644 --- a/docs/admin/README.md +++ b/docs/admin/README.md @@ -17,7 +17,7 @@ ### セクション(3 ドメイン) 1. **みんなの課題**(S3 #1083): 通報キュー(多い順)/ 全投稿一覧 / 詳細(ページ・画像・クレジット)/ 非公開⇄再公開(2 段階確認・audit) -2. **クラス・課題**(S4 #1084): 全クラス検索(参加コード完全一致・名前部分一致)/ 詳細(参加・提出カウント)/ アーカイブ切替 / **期限切れクラスの復元**(ddb-archive スナップショット検索 → dry-run プラン → 実行。EPIC #1049 の CLI `bin/restore-classroom.ts` の UI 後継) +2. **クラス・課題**(S4 #1084 + 俯瞰ダッシュボード): 3 タブ構成 — ①**俯瞰ダッシュボード**(作成の推移・内容の充実度・テーマ傾向 + みんなの課題の**有益候補**を見える化。有益候補を先生に促す仕組みは別 EPIC #1106)②**クラス検索**(参加コード完全一致・名前部分一致 / 詳細 / アーカイブ切替)③**期限切れ復元**(ddb-archive スナップショットを削除時期・先生でファセット絞り込み → dry-run → 実行。EPIC #1049 の CLI の UI 後継) 3. **バグ報告**(S5 #1085 + 対応機能追加): 既存バグ報告の一覧・状態フィルタ・詳細・添付 presigned DL に加え、**状態の変更と進捗コメント(開発者からの返信)**を既存 bug-report admin API の PATCH で行える(2 段階確認・終端ステータスは自動削除 TTL の警告つき。返信は報告者の「私の不具合報告」に表示され、非表示にしていた報告も再表示される — サーバー側の既存挙動)。詳細には**状態に応じた Claude 連携プロンプト**(`/bug-report` スキル向け・受付→Issue 化 / 改修 / 解決返信 / 再開)が表示され、ワンクリックでコピーして Claude Code に貼り付けられる ## 認証・認可モデル(要点) diff --git a/infra/smalruby-admin/lambda/classroom-overview.ts b/infra/smalruby-admin/lambda/classroom-overview.ts new file mode 100644 index 0000000000..a3b7894aac --- /dev/null +++ b/infra/smalruby-admin/lambda/classroom-overview.ts @@ -0,0 +1,186 @@ +/** + * Pure aggregation for the クラス・課題 俯瞰ダッシュボード (Admin, EPIC #1073). + * + * The operator wants to grasp "どんな課題がどれくらい作られているか" along three + * axes (chosen in the design interview): creation trend, content richness and + * theme tendency — NOT popularity/engagement. Kept pure so the whole shape is + * unit-tested without DynamoDB. + */ + +/** A classroom row as stored (only the fields we aggregate on). */ +export interface ClassroomRow { + classroomId?: unknown; + className?: unknown; + assignmentName?: unknown; + teacherSub?: unknown; + status?: unknown; // 'active' | 'archived' + createdAt?: unknown; + content?: { pages?: { text?: string; imageKey?: string }[]; starterKey?: string }; +} + +/** Quota rows reuse the classrooms key space; never count them. */ +export function isRealClassroom(row: ClassroomRow): boolean { + return typeof row.classroomId === 'string' && !row.classroomId.includes('-quota#'); +} + +/** + * Content richness score (0-4): 1 for having any page, +1 for multiple pages, + * +1 for any page image, +1 for a starter project. Higher = more ready to + * share as-is to みんなの課題. + * @param row - classroom row + * @returns {score, pageCount, hasImages, hasStarter} + */ +export function richness(row: ClassroomRow): { + score: number; pageCount: number; hasImages: boolean; hasStarter: boolean; +} { + const pages = row.content?.pages || []; + const pageCount = pages.length; + const hasImages = pages.some(p => typeof p?.imageKey === 'string' && p.imageKey.length > 0); + const hasStarter = typeof row.content?.starterKey === 'string' && row.content.starterKey.length > 0; + let score = 0; + if (pageCount >= 1) score += 1; + if (pageCount >= 2) score += 1; + if (hasImages) score += 1; + if (hasStarter) score += 1; + return { score, pageCount, hasImages, hasStarter }; +} + +/** Split a Japanese/English title into rough keyword tokens for tallying. */ +export function tokenizeTitle(title: string): string[] { + return title + .normalize('NFKC') + .toLowerCase() + // split on whitespace, punctuation and common separators (keep JP/EN/digits) + .split(/[\s、。・,.\/|::;;\-—_()()「」『』[]\[\]{}!!??]+/u) + .map(t => t.trim()) + .filter(t => t.length >= 2 && !/^\d+$/.test(t)); +} + +const MONTH_KEY = (iso: string): string => iso.slice(0, 7); // YYYY-MM + +/** + * Build the whole overview payload from every classroom row. + * @param rows - raw classroom items (quota rows are filtered out here) + * @param sharedTitles - normalized assignment titles already on みんなの課題 + * (to flag "likely already shared" candidates); pass [] if unknown + * @param nowMs - current time (injectable for deterministic tests) + * @returns the dashboard aggregation + */ +export function buildOverview( + rows: ClassroomRow[], + sharedTitles: string[], + nowMs: number, +): { + summary: { total: number; active: number; archived: number; recent30d: number }; + creationTrend: { month: string; count: number }[]; + richnessDistribution: { score: number; count: number }[]; + candidates: { + classroomId: string; className: string; assignmentName: string; + teacherSub: string; score: number; pageCount: number; + hasImages: boolean; hasStarter: boolean; createdAt: string; likelyShared: boolean; + }[]; + themeKeywords: { keyword: string; count: number }[]; +} { + const classrooms = rows.filter(isRealClassroom); + const sharedSet = new Set(sharedTitles.map(t => t.normalize('NFKC').trim().toLowerCase())); + const cutoff30 = nowMs - 30 * 24 * 60 * 60 * 1000; + + let active = 0; + let archived = 0; + let recent30d = 0; + const byMonth = new Map(); + const byScore = new Map(); + const keywordCount = new Map(); + const candidates: ReturnType['candidates'] = []; + + for (const row of classrooms) { + const status = String(row.status || ''); + if (status === 'archived') archived += 1; else active += 1; + + const createdAt = typeof row.createdAt === 'string' ? row.createdAt : ''; + if (createdAt) { + byMonth.set(MONTH_KEY(createdAt), (byMonth.get(MONTH_KEY(createdAt)) || 0) + 1); + if (Date.parse(createdAt) >= cutoff30) recent30d += 1; + } + + const r = richness(row); + byScore.set(r.score, (byScore.get(r.score) || 0) + 1); + + const assignmentName = typeof row.assignmentName === 'string' ? row.assignmentName : ''; + const className = typeof row.className === 'string' ? row.className : ''; + for (const token of [...tokenizeTitle(assignmentName), ...tokenizeTitle(className)]) { + keywordCount.set(token, (keywordCount.get(token) || 0) + 1); + } + + // 有益候補: reasonably rich (>=3) content. Flag likely-already-shared so + // the operator focuses on the unshared ones. + if (r.score >= 3) { + const likelyShared = sharedSet.has(assignmentName.normalize('NFKC').trim().toLowerCase()); + candidates.push({ + classroomId: String(row.classroomId), + className, + assignmentName, + teacherSub: typeof row.teacherSub === 'string' ? row.teacherSub : '', + score: r.score, + pageCount: r.pageCount, + hasImages: r.hasImages, + hasStarter: r.hasStarter, + createdAt, + likelyShared, + }); + } + } + + const creationTrend = [...byMonth.entries()] + .map(([month, count]) => ({ month, count })) + .sort((a, b) => a.month.localeCompare(b.month)); + + const richnessDistribution = [0, 1, 2, 3, 4] + .map(score => ({ score, count: byScore.get(score) || 0 })); + + // Unshared candidates first, then richer, then newer. + candidates.sort((a, b) => + Number(a.likelyShared) - Number(b.likelyShared) || + b.score - a.score || + b.createdAt.localeCompare(a.createdAt)); + + const themeKeywords = [...keywordCount.entries()] + .map(([keyword, count]) => ({ keyword, count })) + .filter(k => k.count >= 2) + .sort((a, b) => b.count - a.count || a.keyword.localeCompare(b.keyword)) + .slice(0, 30); + + return { + summary: { total: classrooms.length, active, archived, recent30d }, + creationTrend, + richnessDistribution, + candidates: candidates.slice(0, 50), + themeKeywords, + }; +} + +/** + * Facet buckets for the restore tab (削除済みスナップショット). Groups a large + * candidate list by deletion month and by teacher so the operator can narrow + * down like the みんなの課題 catalog. + * @param snapshots - matched snapshot summaries + * @returns facet counts + */ +export function buildRestoreFacets( + snapshots: { deletedAt?: string | null; teacherSub?: string }[], +): { byMonth: { month: string; count: number }[]; byTeacher: { teacherSub: string; count: number }[] } { + const byMonth = new Map(); + const byTeacher = new Map(); + for (const s of snapshots) { + const month = s.deletedAt ? s.deletedAt.slice(0, 7) : '(不明)'; + byMonth.set(month, (byMonth.get(month) || 0) + 1); + const t = s.teacherSub || '(不明)'; + byTeacher.set(t, (byTeacher.get(t) || 0) + 1); + } + return { + byMonth: [...byMonth.entries()].map(([month, count]) => ({ month, count })) + .sort((a, b) => b.month.localeCompare(a.month)), + byTeacher: [...byTeacher.entries()].map(([teacherSub, count]) => ({ teacherSub, count })) + .sort((a, b) => b.count - a.count), + }; +} diff --git a/infra/smalruby-admin/lambda/handler.ts b/infra/smalruby-admin/lambda/handler.ts index 9f895537f8..ff70063aa4 100644 --- a/infra/smalruby-admin/lambda/handler.ts +++ b/infra/smalruby-admin/lambda/handler.ts @@ -30,6 +30,7 @@ import { OAuth2Client } from 'google-auth-library'; import { buildRestorePlan, matchSnapshot, PlannedItem, RestorePlanInput, Snapshot, } from './restore-plan'; +import { buildOverview, buildRestoreFacets, ClassroomRow } from './classroom-overview'; // --- Configuration --- @@ -416,6 +417,22 @@ async function handleListClassrooms( return { statusCode: 200, body: JSON.stringify({ items: items.slice(0, 100) }) }; } +async function handleClassroomOverview( + identity: AdminIdentity, +): Promise { + audit('classroom.overview', identity, {}); + const [classroomRows, sharedRows] = await Promise.all([ + scanAll(CLASSROOMS_TABLE), + // Titles already on みんなの課題 — flag likely-shared candidates. + scanAll(SHARED_ASSIGNMENTS_TABLE).catch(() => [] as Record[]), + ]); + const sharedTitles = sharedRows + .map(r => (typeof r.title === 'string' ? r.title : '')) + .filter(Boolean); + const overview = buildOverview(classroomRows as ClassroomRow[], sharedTitles, Date.now()); + return { statusCode: 200, body: JSON.stringify(overview) }; +} + async function handleGetClassroom( identity: AdminIdentity, classroomId: string, ): Promise { @@ -522,24 +539,45 @@ async function collectSnapshotChildren(kind: string, classroomId: string): Promi async function handleSearchRestoreCandidates( identity: AdminIdentity, query: Record, ): Promise { + // q is now optional: the operator can browse ALL deleted snapshots and + // narrow with facets (削除時期 / 先生) instead of only text search, because + // the raw list is too large to eyeball (みんなの課題 catalog style). const q = (query.q || '').trim(); - if (!q) { - throw new ValidationError('q is required'); - } - audit('classroom.searchSnapshots', identity, { q }); + const monthFilter = (query.month || '').trim(); + const teacherFilter = (query.teacher || '').trim(); + audit('classroom.searchSnapshots', identity, { q: q || null, month: monthFilter || null }); const keys = await listArchiveKeys(`${ARCHIVE_PREFIX}/classrooms/`); - const matches: Record[] = []; + const all: { item: Record; deletedAt: string | null; teacherSub: string }[] = []; for (const key of keys) { const snapshot = await readSnapshot(key); - if (snapshot && matchSnapshot(snapshot, q)) { - matches.push({ - ...mapClassroomForAdmin(snapshot.item), + if (snapshot?.item) { + all.push({ + item: snapshot.item, deletedAt: snapshot.deletedAt, + teacherSub: typeof snapshot.item.teacherSub === 'string' ? snapshot.item.teacherSub : '', }); } } - return { statusCode: 200, body: JSON.stringify({ items: matches }) }; + + // Facets are computed over everything (so the operator sees the full shape), + // the item list is what survives the active filters. + const facets = buildRestoreFacets(all); + const matches = all + .filter(s => !q || matchSnapshot({ table: 'classrooms', deletedAt: s.deletedAt, eventId: null, item: s.item }, q)) + .filter(s => !monthFilter || (s.deletedAt || '').slice(0, 7) === monthFilter) + .filter(s => !teacherFilter || s.teacherSub === teacherFilter) + .map(s => ({ + ...mapClassroomForAdmin(s.item), + teacherSub: s.teacherSub, + deletedAt: s.deletedAt, + })) + .sort((a, b) => String(b.deletedAt || '').localeCompare(String(a.deletedAt || ''))); + + return { + statusCode: 200, + body: JSON.stringify({ items: matches.slice(0, 200), total: matches.length, facets }), + }; } async function gatherRestoreInput(classroomId: string): Promise<{ @@ -711,6 +749,9 @@ export const handler = async (event: APIGatewayProxyEventV2): Promise { + test('richness scores pages, images and starter', () => { + expect(richness(rows[0]).score).toBe(4); // >=2 pages(2) + image(1) + starter(1) + expect(richness(rows[2]).score).toBe(1); // 1 page only + expect(richness({}).score).toBe(0); + }); + + test('tokenizeTitle drops short tokens and pure numbers', () => { + const t = tokenizeTitle('ねこ迷路ゲーム 2026'); + expect(t).toContain('ねこ迷路ゲーム'); + expect(t).not.toContain('2026'); + }); + + test('quota rows are not real classrooms', () => { + expect(isRealClassroom(rows[3])).toBe(false); + expect(isRealClassroom(rows[0])).toBe(true); + }); +}); + +describe('buildOverview (issue #1106 dashboard)', () => { + const o = buildOverview(rows, ['ねこあつめ入門'], NOW); + + test('summary counts exclude quota rows and split active/archived', () => { + expect(o.summary.total).toBe(3); + expect(o.summary.active).toBe(2); + expect(o.summary.archived).toBe(1); + expect(o.summary.recent30d).toBe(2); // c1 and c3 within 30d of NOW + }); + + test('creation trend is per-month, ascending', () => { + expect(o.creationTrend).toEqual([ + { month: '2026-06', count: 1 }, + { month: '2026-07', count: 2 }, + ]); + }); + + test('richness distribution covers scores 0-4', () => { + expect(o.richnessDistribution).toHaveLength(5); + expect(o.richnessDistribution.find(d => d.score === 4)?.count).toBe(2); + }); + + test('candidates: unshared rich ones first, shared flagged, thin excluded', () => { + expect(o.candidates.map(c => c.classroomId)).toEqual(['c1', 'c2']); + expect(o.candidates[0].likelyShared).toBe(false); + expect(o.candidates[1].likelyShared).toBe(true); // ねこあつめ入門 already shared + expect(o.candidates.some(c => c.classroomId === 'c3')).toBe(false); + }); + + test('theme keywords tally repeated tokens only', () => { + // '5年1組' appears on c1 and c3 + expect(o.themeKeywords.some(k => k.keyword === '5年1組' && k.count === 2)).toBe(true); + }); +}); + +describe('buildRestoreFacets (issue #1084 restore facets)', () => { + test('groups snapshots by deletion month and teacher', () => { + const f = buildRestoreFacets([ + { deletedAt: '2026-07-10T00:00:00Z', teacherSub: 't1' }, + { deletedAt: '2026-07-11T00:00:00Z', teacherSub: 't1' }, + { deletedAt: '2026-06-01T00:00:00Z', teacherSub: 't2' }, + { deletedAt: null, teacherSub: '' }, + ]); + expect(f.byMonth).toEqual([ + { month: '2026-07', count: 2 }, + { month: '2026-06', count: 1 }, + { month: '(不明)', count: 1 }, + ]); + expect(f.byTeacher[0]).toEqual({ teacherSub: 't1', count: 2 }); + }); +}); diff --git a/infra/smalruby-admin/lambda/tests/handler.test.ts b/infra/smalruby-admin/lambda/tests/handler.test.ts index 94c6407581..e2d5c539f2 100644 --- a/infra/smalruby-admin/lambda/tests/handler.test.ts +++ b/infra/smalruby-admin/lambda/tests/handler.test.ts @@ -498,7 +498,36 @@ describe('クラス管理 + 期限切れ復元 (issue #1084)', () => { expect(bad.statusCode).toBe(400); }); - test('GET restore-candidates searches snapshots and requires q', async () => { + test('GET /admin/classrooms/overview aggregates the dashboard', async () => { + mockSend.mockImplementation(async (command: { + constructor: { name: string }; input?: { TableName?: string }; + }) => { + const name = command.constructor.name; + if (name === 'GetCommand') return adminRow; + if (name === 'ScanCommand' && command.input?.TableName?.includes('SharedAssignments')) { + return { Items: [{ title: '共有済みの課題' }] }; + } + if (name === 'ScanCommand') { + return { Items: [ + { classroomId: 'c1', className: '5年1組', assignmentName: 'ねこ迷路ゲーム', teacherSub: 't1', + status: 'active', createdAt: '2026-07-10T00:00:00.000Z', + content: { pages: [{ text: 'a', imageKey: 'k' }, { text: 'b' }], starterKey: 's' } }, + { classroomId: 'eval-quota#t1#2026-07-19', status: 'active' }, + ] }; + } + return {}; + }); + + const res = await handler(makeAuthedEvent('GET', '/admin/classrooms/overview')); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body as string); + expect(body.summary.total).toBe(1); // quota row excluded + expect(body.candidates[0].classroomId).toBe('c1'); + expect(body.candidates[0].hasStarter).toBe(true); + expect(body.creationTrend[0].month).toBe('2026-07'); + }); + + test('GET restore-candidates browses all with facets, filters by q', async () => { mockSend.mockResolvedValue(adminRow); mockS3Send.mockImplementation(async (command: { constructor: { name: string }; input?: { Key?: string } }) => { if (command.constructor.name === 'ListObjectsV2Command') { @@ -507,13 +536,21 @@ describe('クラス管理 + 期限切れ復元 (issue #1084)', () => { { Key: 'ddb-archive/classrooms/c2.json' }, ] }; } - if (command.input?.Key === 'ddb-archive/classrooms/c1.json') return s3Json(classroomItem); - return s3Json({ ...classroomItem, classroomId: 'c2', className: '6年2組', joinCode: 'XYZ999' }); + if (command.input?.Key === 'ddb-archive/classrooms/c1.json') { + return s3Json({ ...classroomItem, teacherSub: 't1' }); + } + return s3Json({ ...classroomItem, classroomId: 'c2', className: '6年2組', joinCode: 'XYZ999', teacherSub: 't2' }); }); - const missing = await handler(makeAuthedEvent('GET', '/admin/classrooms/restore-candidates')); - expect(missing.statusCode).toBe(400); + // No q → browse everything, with facets (削除時期/先生) for narrowing. + const all = await handler(makeAuthedEvent('GET', '/admin/classrooms/restore-candidates')); + expect(all.statusCode).toBe(200); + const allBody = JSON.parse(all.body as string); + expect(allBody.items).toHaveLength(2); + expect(allBody.facets.byTeacher.map((t: { teacherSub: string }) => t.teacherSub).sort()).toEqual(['t1', 't2']); + expect(allBody.facets.byMonth[0].month).toBe('2026-07'); + // q filters the item list (facets still reflect the whole set). const res = await handler(makeAuthedEvent('GET', '/admin/classrooms/restore-candidates', { queryStringParameters: { q: '5年' }, })); diff --git a/infra/smalruby-admin/lib/admin-stack.ts b/infra/smalruby-admin/lib/admin-stack.ts index d460e67fd7..1253ba5d4a 100644 --- a/infra/smalruby-admin/lib/admin-stack.ts +++ b/infra/smalruby-admin/lib/admin-stack.ts @@ -266,6 +266,7 @@ export class SmalrubyAdminStack extends cdk.Stack { // Classroom management + expired restore (S4 #1084). HTTP API prefers // the literal restore-candidates route over {classroomId} by specificity. + addRoute('/admin/classrooms/overview', [apigatewayv2.HttpMethod.GET]); addRoute('/admin/classrooms', [apigatewayv2.HttpMethod.GET]); addRoute('/admin/classrooms/restore-candidates', [apigatewayv2.HttpMethod.GET]); addRoute('/admin/classrooms/{classroomId}', diff --git a/packages/admin/src/components/app.css b/packages/admin/src/components/app.css index bb1ceb3806..988e290279 100644 --- a/packages/admin/src/components/app.css +++ b/packages/admin/src/components/app.css @@ -300,3 +300,109 @@ body { cursor: pointer; font-size: 0.85rem; } + +/* --- クラス・課題 俯瞰ダッシュボード --- */ +.admin-cards { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin: 0.75rem 0 1rem; +} + +.admin-card { + display: flex; + flex-direction: column; + min-width: 8rem; + padding: 0.75rem 1rem; + border: 1px solid hsl(220, 15%, 85%); + border-radius: 0.5rem; + background: white; +} + +.admin-card-num { + font-size: 1.6rem; + font-weight: 700; + color: hsl(260, 55%, 45%); +} + +.admin-card-label { + font-size: 0.8rem; + color: hsl(220, 12%, 45%); +} + +.admin-section { + margin: 1.25rem 0; +} + +.admin-section h3 { + margin: 0 0 0.5rem; + font-size: 1rem; +} + +.admin-bar-row { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0.2rem 0; + font-size: 0.85rem; +} + +.admin-bar-label { + flex: 0 0 12rem; + text-align: right; + color: hsl(220, 12%, 40%); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-bar-track { + flex: 1; + height: 0.8rem; + background: hsl(220, 20%, 93%); + border-radius: 0.4rem; + overflow: hidden; +} + +.admin-bar-fill { + display: block; + height: 100%; + min-width: 2px; + background: hsl(260, 60%, 62%); +} + +.admin-bar-count { + flex: 0 0 2.5rem; + color: hsl(220, 12%, 40%); +} + +/* --- 復元タブのファセット --- */ +.admin-facets { + display: flex; + flex-direction: column; + gap: 0.4rem; + margin: 0.5rem 0; +} + +.admin-facet-group { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem; +} + +.admin-chip, +.admin-chip-active { + padding: 0.15rem 0.6rem; + border: 1px solid hsl(220, 15%, 75%); + border-radius: 1rem; + background: white; + font-size: 0.8rem; + cursor: pointer; +} + +.admin-chip-active { + background: hsl(260, 60%, 55%); + color: white; + border-color: hsl(260, 60%, 55%); +} diff --git a/packages/admin/src/components/classroom-overview-view.jsx b/packages/admin/src/components/classroom-overview-view.jsx new file mode 100644 index 0000000000..9747e555c8 --- /dev/null +++ b/packages/admin/src/components/classroom-overview-view.jsx @@ -0,0 +1,181 @@ +/** + * クラス・課題 俯瞰ダッシュボード (EPIC #1073, design 2026-07-19). + * + * 「どんな課題がどれくらい作られているか」を、作成の推移・内容の充実度・ + * テーマ傾向の 3 軸で掴み、みんなの課題に載せると有益そうな候補を見える化 + * する(人気/提出数ではなく中身と傾向で判断)。チャートは外部ライブラリを + * 使わず CSS 横棒で描く。 + */ +import PropTypes from 'prop-types'; +import {useEffect, useState} from 'react'; +import {fetchClassroomOverview} from '../lib/admin-api.js'; + +// A labelled horizontal bar; width is relative to the row's max value. +const Bar = ({label, count, max, testid}) => ( +
+ {label} + + 0 ? Math.round((count / max) * 100) : 0}%`}} + /> + + {count} +
+); + +Bar.propTypes = { + count: PropTypes.number.isRequired, + label: PropTypes.string.isRequired, + max: PropTypes.number.isRequired, + testid: PropTypes.string +}; + +const RICHNESS_LABELS = { + 0: '中身なし', + 1: '説明のみ', + 2: '説明が複数ページ', + 3: '画像 or スターター付き', + 4: '画像+スターター(充実)' +}; + +const ClassroomOverviewView = ({onOpenCandidate}) => { + const [data, setData] = useState(null); + const [error, setError] = useState(''); + + useEffect(() => { + fetchClassroomOverview() + .then(setData) + .catch(err => setError(err.message)); + }, []); + + if (error) { + return (

{error}

); + } + if (!data) return

{'集計中…'}

; + + const trendMax = Math.max(1, ...data.creationTrend.map(t => t.count)); + const richMax = Math.max(1, ...data.richnessDistribution.map(r => r.count)); + const kwMax = Math.max(1, ...data.themeKeywords.map(k => k.count)); + + return ( +
+
+
+ {data.summary.total} + {'クラス総数'} +
+
+ {data.summary.active} + {'利用中'} +
+
+ {data.summary.archived} + {'アーカイブ'} +
+
+ {data.summary.recent30d} + {'直近30日の新規'} +
+
+ +
+

{'作成の推移(月別)'}

+ {data.creationTrend.length === 0 ? ( +

{'データがありません。'}

+ ) : data.creationTrend.map(t => ( + + ))} +
+ +
+

{'内容の充実度'}

+ {data.richnessDistribution.map(r => ( + + ))} +
+ +
+

{'みんなの課題に有益そうな候補'}

+

+ {'内容が充実していて、まだ みんなの課題 に無さそうな課題です。'} + {'共有は先生本人が CC BY で行うため、作成した先生に共有をおすすめしてください。'} +

+ {data.candidates.length === 0 ? ( +

{'候補はまだありません。'}

+ ) : ( +
    + {data.candidates.map(c => ( +
  • + +
  • + ))} +
+ )} +
+ +
+

{'テーマの傾向(課題名・クラス名の頻出語)'}

+ {data.themeKeywords.length === 0 ? ( +

{'繰り返し使われている語はまだありません。'}

+ ) : data.themeKeywords.map(k => ( + + ))} +
+
+ ); +}; + +ClassroomOverviewView.propTypes = { + onOpenCandidate: PropTypes.func.isRequired +}; + +export default ClassroomOverviewView; diff --git a/packages/admin/src/components/classrooms-view.jsx b/packages/admin/src/components/classrooms-view.jsx index 81bb1ddcdb..b3c027da1a 100644 --- a/packages/admin/src/components/classrooms-view.jsx +++ b/packages/admin/src/components/classrooms-view.jsx @@ -1,10 +1,12 @@ /** - * クラス・課題管理 + 期限切れ復元 view (EPIC #1073 S4 #1084). + * クラス・課題管理 view (EPIC #1073 S4 #1084 + 俯瞰ダッシュボード 2026-07-19). * - * Two tabs: live classroom search (archive⇄active flip) and the - * ddb-archive snapshot restore — the UI successor to classroom's - * bin/restore-classroom.ts (EPIC #1049 D6). Every mutation goes through an - * explicit two-step confirmation, matching the moderation view. + * Three tabs: + * - 俯瞰: dashboard (creation trend / content richness / theme / candidates). + * - クラス検索: live classroom search (archive⇄active flip). + * - 期限切れ復元: ddb-archive snapshot restore, narrowed by facets + * (削除時期 / 先生) so a large deleted set is browsable みんなの課題-style. + * Every mutation goes through an explicit two-step confirmation. */ import PropTypes from 'prop-types'; import {useCallback, useEffect, useState} from 'react'; @@ -16,6 +18,7 @@ import { fetchRestorePlan, setClassroomStatus } from '../lib/admin-api.js'; +import ClassroomOverviewView from './classroom-overview-view.jsx'; const ClassroomStatusBadge = ({status}) => ( @@ -27,6 +30,10 @@ ClassroomStatusBadge.propTypes = {status: PropTypes.string}; const formatDate = iso => (iso ? iso.replace('T', ' ').slice(0, 16) : '-'); +// One-line summary shared by the live and restore item rows. +const itemLine = (item, tail) => + `課題: ${item.assignmentName || '-'} ・ コード: ${item.joinCode} ・ ${tail}`; + const ClassroomDetail = ({classroomId, onBack, onChanged}) => { const [detail, setDetail] = useState(null); const [error, setError] = useState(''); @@ -261,90 +268,171 @@ RestorePanel.propTypes = { onBack: PropTypes.func.isRequired }; -const ClassroomsView = () => { - const [tab, setTab] = useState('live'); +// 期限切れ復元タブ: q は任意。ファセット(削除時期 / 先生)で大量の削除済みを +// 絞り込んでからクラス丸ごと復元する。 +const RestoreBrowser = ({onOpen}) => { + const [query, setQuery] = useState(''); + const [month, setMonth] = useState(''); + const [teacher, setTeacher] = useState(''); + const [data, setData] = useState(null); + const [error, setError] = useState(''); + + const load = useCallback(async filters => { + setError(''); + setData(null); + try { + setData(await fetchRestoreCandidates(filters)); + } catch (err) { + setError(err.message); + } + }, []); + + // First open browses everything (so the facets are populated). + useEffect(() => { + load({}); + }, [load]); + + const handleSubmit = useCallback(e => { + e.preventDefault(); + load({q: query.trim(), month, teacher}); + }, [load, query, month, teacher]); + const handleQueryChange = useCallback(e => setQuery(e.target.value), []); + const handleMonth = useCallback(e => { + const next = e.currentTarget.dataset.month === month ? '' : e.currentTarget.dataset.month; + setMonth(next); + load({q: query.trim(), month: next, teacher}); + }, [load, query, month, teacher]); + const handleTeacher = useCallback(e => { + const next = e.currentTarget.dataset.teacher === teacher ? '' : e.currentTarget.dataset.teacher; + setTeacher(next); + load({q: query.trim(), month, teacher: next}); + }, [load, query, month, teacher]); + + return ( +
+
+ + +
+ {error ?

{error}

: null} + {data === null ? ( +

{'読み込み中…'}

+ ) : ( +
+
+ + {'削除時期:'} + {data.facets.byMonth.map(f => ( + + ))} + + + {'先生:'} + {data.facets.byTeacher.slice(0, 8).map(f => ( + + ))} + +
+

{`${data.total} 件中 ${Math.min(data.total, data.items.length)} 件を表示`}

+ {data.items.length === 0 ? ( +

{'該当する削除済みクラスはありません。'}

+ ) : ( +
    + {data.items.map(item => ( +
  • + +
  • + ))} +
+ )} +
+ )} +
+ ); +}; + +RestoreBrowser.propTypes = { + onOpen: PropTypes.func.isRequired +}; + +// クラス検索タブ: 生きているクラスの一覧・検索 + アーカイブ切替。 +const LiveBrowser = ({onOpen, reloadKey}) => { const [query, setQuery] = useState(''); const [items, setItems] = useState(null); - const [selectedId, setSelectedId] = useState(null); const [error, setError] = useState(''); - const search = useCallback(async () => { + // search takes the query explicitly so it stays referentially stable — the + // effect can then key on reloadKey without refetching on every keystroke. + const search = useCallback(async q => { setError(''); setItems(null); try { - const data = tab === 'live' ? - await fetchClassrooms(query.trim()) : - await fetchRestoreCandidates(query.trim()); + const data = await fetchClassrooms(q.trim()); setItems(data.items || []); } catch (err) { setError(err.message); } - }, [tab, query]); + }, []); - // The live tab lists everything up-front; the snapshot tab waits for an - // explicit query (a full S3 sweep per keystroke would be wasteful) — - // hence the effect keys on the tab alone, not on the query/search pair. + // reloadKey bumps after a detail change so the list refreshes. useEffect(() => { - if (tab === 'live') { - search(); - } else { - setItems(null); - } - }, [tab]); + search(''); + }, [reloadKey, search]); - const handleTabLive = useCallback(() => { - setTab('live'); - setSelectedId(null); - }, []); - const handleTabRestore = useCallback(() => { - setTab('restore'); - setSelectedId(null); - }, []); - const handleQueryChange = useCallback(e => setQuery(e.target.value), []); const handleSubmit = useCallback(e => { e.preventDefault(); - search(); - }, [search]); - const handleOpen = useCallback(e => setSelectedId(e.currentTarget.dataset.classroomId), []); - const handleBack = useCallback(() => { - setSelectedId(null); - search(); - }, [search]); - - if (selectedId && tab === 'live') { - return ( - - ); - } - if (selectedId && tab === 'restore') { - return ( - - ); - } + search(query); + }, [search, query]); + const handleQueryChange = useCallback(e => setQuery(e.target.value), []); return ( -
-
- - -
+
{ data-testid="classroom-admin-error" >{error}

: null} {items === null ? ( -

- {tab === 'restore' ? - '参加コードかクラス名・課題名で、期限切れで消えたクラスを検索します。' : - '読み込み中…'} -

+

{'読み込み中…'}

) : items.length === 0 ? (

{'見つかりませんでした。'}

) : ( @@ -384,17 +468,12 @@ const ClassroomsView = () => { data-classroom-id={item.classroomId} data-testid={`classroom-admin-item-${item.classroomId}`} type="button" - onClick={handleOpen} + onClick={onOpen} > {item.className} - {tab === 'live' ? - : - {'期限切れ'}} + - {`課題: ${item.assignmentName || '-'} ・ コード: ${item.joinCode}`} - {tab === 'live' ? - ` ・ 期限 ${formatDate(item.expiresAt)}` : - ` ・ 削除 ${formatDate(item.deletedAt)}`} + {itemLine(item, `期限 ${formatDate(item.expiresAt)}`)} @@ -405,4 +484,88 @@ const ClassroomsView = () => { ); }; +LiveBrowser.propTypes = { + onOpen: PropTypes.func.isRequired, + reloadKey: PropTypes.number.isRequired +}; + +const ClassroomsView = () => { + const [tab, setTab] = useState('overview'); + // 'live' detail (archive flip) vs 'restore' panel — track which kind is open. + const [selected, setSelected] = useState(null); // {id, kind} + const [reloadKey, setReloadKey] = useState(0); + + const handleTabOverview = useCallback(() => { + setTab('overview'); + setSelected(null); + }, []); + const handleTabLive = useCallback(() => { + setTab('live'); + setSelected(null); + }, []); + const handleTabRestore = useCallback(() => { + setTab('restore'); + setSelected(null); + }, []); + + // Candidates and live items open the live classroom detail; restore items + // open the restore panel. + const openLive = useCallback(e => setSelected({id: e.currentTarget.dataset.classroomId, kind: 'live'}), []); + const openRestore = useCallback(e => setSelected({id: e.currentTarget.dataset.classroomId, kind: 'restore'}), []); + const handleBack = useCallback(() => { + setSelected(null); + setReloadKey(k => k + 1); + }, []); + const bumpReload = useCallback(() => setReloadKey(k => k + 1), []); + + if (selected && selected.kind === 'live') { + return ( + + ); + } + if (selected && selected.kind === 'restore') { + return ( + + ); + } + + return ( +
+
+ + + +
+ {tab === 'overview' && } + {tab === 'live' && } + {tab === 'restore' && } +
+ ); +}; + export default ClassroomsView; diff --git a/packages/admin/src/lib/admin-api.js b/packages/admin/src/lib/admin-api.js index 4ce0629c60..df53f11cc0 100644 --- a/packages/admin/src/lib/admin-api.js +++ b/packages/admin/src/lib/admin-api.js @@ -135,12 +135,27 @@ const setClassroomStatus = (classroomId, status) => request('PATCH', `/admin/classrooms/${classroomId}`, {status}); /** - * Search ddb-archive snapshots of expired classrooms. - * @param {string} q - join code (exact) or class/assignment name (substring) - * @returns {Promise} {items} (each with deletedAt) + * Fleet-wide overview aggregation for the dashboard (creation trend, content + * richness, theme keywords, みんなの課題 candidates). + * @returns {Promise} {summary, creationTrend, richnessDistribution, candidates, themeKeywords} */ -const fetchRestoreCandidates = q => - request('GET', `/admin/classrooms/restore-candidates?q=${encodeURIComponent(q)}`); +const fetchClassroomOverview = () => request('GET', '/admin/classrooms/overview'); + +/** + * Browse ddb-archive snapshots of deleted classrooms, narrowed by facets. + * q is optional now — omit it to browse everything and classify with the + * returned facets (削除時期 / 先生). + * @param {object} [filters] - {q?, month?, teacher?} + * @returns {Promise} {items, total, facets:{byMonth, byTeacher}} + */ +const fetchRestoreCandidates = (filters = {}) => { + const params = new URLSearchParams(); + if (filters.q) params.set('q', filters.q); + if (filters.month) params.set('month', filters.month); + if (filters.teacher) params.set('teacher', filters.teacher); + const qs = params.toString(); + return request('GET', `/admin/classrooms/restore-candidates${qs ? `?${qs}` : ''}`); +}; /** * Dry-run summary for one restore ({alive:true} when the classroom still @@ -164,6 +179,7 @@ export { fetchClassrooms, fetchClassroom, setClassroomStatus, + fetchClassroomOverview, fetchRestoreCandidates, fetchRestorePlan, executeRestore diff --git a/packages/admin/test/unit/classrooms-view.test.jsx b/packages/admin/test/unit/classrooms-view.test.jsx index d4f6722bb1..305d55b354 100644 --- a/packages/admin/test/unit/classrooms-view.test.jsx +++ b/packages/admin/test/unit/classrooms-view.test.jsx @@ -4,6 +4,7 @@ import {fireEvent, render, screen, waitFor} from '@testing-library/react'; const mockFetchClassrooms = jest.fn(); const mockFetchClassroom = jest.fn(); const mockSetStatus = jest.fn(); +const mockFetchOverview = jest.fn(); const mockFetchCandidates = jest.fn(); const mockFetchPlan = jest.fn(); const mockExecuteRestore = jest.fn(); @@ -11,6 +12,7 @@ jest.mock('../../src/lib/admin-api.js', () => ({ fetchClassrooms: (...args) => mockFetchClassrooms(...args), fetchClassroom: (...args) => mockFetchClassroom(...args), setClassroomStatus: (...args) => mockSetStatus(...args), + fetchClassroomOverview: (...args) => mockFetchOverview(...args), fetchRestoreCandidates: (...args) => mockFetchCandidates(...args), fetchRestorePlan: (...args) => mockFetchPlan(...args), executeRestore: (...args) => mockExecuteRestore(...args) @@ -40,63 +42,94 @@ const plan = { missingFiles: 1 }; -describe('ClassroomsView (issue #1084)', () => { +const overview = { + summary: {total: 3, active: 2, archived: 1, recent30d: 2}, + creationTrend: [{month: '2026-07', count: 3}], + richnessDistribution: [ + {score: 0, count: 0}, {score: 1, count: 1}, {score: 2, count: 0}, + {score: 3, count: 0}, {score: 4, count: 2} + ], + candidates: [{ + classroomId: 'c1', + className: '5年1組', + assignmentName: 'ねこ迷路ゲーム', + teacherSub: 't1', + score: 4, + pageCount: 2, + hasImages: true, + hasStarter: true, + createdAt: '2026-07-10T00:00:00.000Z', + likelyShared: false + }], + themeKeywords: [{keyword: 'ねこ', count: 3}] +}; + +const restoreResponse = { + items: [{...liveItem, teacherSub: 't1', deletedAt: plan.deletedAt}], + total: 1, + facets: {byMonth: [{month: '2026-07', count: 1}], byTeacher: [{teacherSub: 't1', count: 1}]} +}; + +describe('ClassroomsView (issue #1084 + 俯瞰 #1106)', () => { beforeEach(() => { mockFetchClassrooms.mockReset().mockResolvedValue({items: [liveItem]}); mockFetchClassroom.mockReset().mockResolvedValue(detail); mockSetStatus.mockReset(); - mockFetchCandidates.mockReset().mockResolvedValue({items: [{...liveItem, deletedAt: plan.deletedAt}]}); + mockFetchOverview.mockReset().mockResolvedValue(overview); + mockFetchCandidates.mockReset().mockResolvedValue(restoreResponse); mockFetchPlan.mockReset().mockResolvedValue(plan); mockExecuteRestore.mockReset(); }); - test('the live tab lists classrooms up-front', async () => { + test('the default tab is the overview dashboard', async () => { render(); - await waitFor(() => expect(screen.getByTestId('classroom-admin-list')).toBeInTheDocument()); - const item = screen.getByTestId('classroom-admin-item-c1'); - expect(item.textContent).toContain('5年1組'); - expect(item.textContent).toContain('ABC123'); - expect(mockFetchClassrooms).toHaveBeenCalled(); + await waitFor(() => expect(screen.getByTestId('overview-view')).toBeInTheDocument()); + expect(screen.getByTestId('overview-summary').textContent).toContain('クラス総数'); + expect(screen.getByTestId('overview-candidates').textContent).toContain('ねこ迷路ゲーム'); + expect(screen.getByTestId('overview-candidates').textContent).toContain('未共有らしい'); }); - test('archiving flows through an explicit confirmation', async () => { + test('opening a dashboard candidate shows its live classroom detail', async () => { + render(); + await waitFor(() => screen.getByTestId('overview-candidate-c1')); + fireEvent.click(screen.getByTestId('overview-candidate-c1')); + await waitFor(() => screen.getByTestId('classroom-admin-detail')); + expect(mockFetchClassroom).toHaveBeenCalledWith('c1'); + }); + + test('the live tab lists classrooms and archives with confirmation', async () => { mockSetStatus.mockResolvedValue({...detail, status: 'archived'}); render(); + fireEvent.click(screen.getByTestId('classroom-admin-tab-live')); await waitFor(() => screen.getByTestId('classroom-admin-item-c1')); + expect(mockFetchClassrooms).toHaveBeenCalled(); + fireEvent.click(screen.getByTestId('classroom-admin-item-c1')); await waitFor(() => screen.getByTestId('classroom-admin-detail')); - - expect(screen.getByTestId('classroom-admin-counts').textContent).toContain('参加 12 人'); fireEvent.click(screen.getByTestId('classroom-admin-flip')); expect(mockSetStatus).not.toHaveBeenCalled(); fireEvent.click(screen.getByTestId('classroom-admin-confirm-yes')); await waitFor(() => expect(mockSetStatus).toHaveBeenCalledWith('c1', 'archived')); }); - test('the restore tab requires an explicit search, then shows the plan', async () => { + test('the restore tab browses all up-front with facets, then filters', async () => { render(); fireEvent.click(screen.getByTestId('classroom-admin-tab-restore')); - expect(screen.getByTestId('classroom-admin-hint')).toBeInTheDocument(); - expect(mockFetchCandidates).not.toHaveBeenCalled(); - - fireEvent.change(screen.getByTestId('classroom-admin-query'), {target: {value: '5年'}}); - fireEvent.click(screen.getByTestId('classroom-admin-search')); - await waitFor(() => screen.getByTestId('classroom-admin-item-c1')); - expect(mockFetchCandidates).toHaveBeenCalledWith('5年'); + // Browses everything immediately (no q required) so facets populate. + await waitFor(() => screen.getByTestId('restore-facets')); + expect(mockFetchCandidates).toHaveBeenCalledWith({}); + expect(screen.getByTestId('restore-facet-month-2026-07')).toBeInTheDocument(); - fireEvent.click(screen.getByTestId('classroom-admin-item-c1')); - await waitFor(() => screen.getByTestId('restore-admin-plan')); - expect(screen.getByTestId('restore-admin-summary').textContent).toContain('参加 12 人'); - expect(screen.getByTestId('restore-admin-summary').textContent).toContain('組も復元します'); - expect(screen.getByTestId('restore-admin-missing').textContent).toContain('1 件'); + // Clicking a month facet re-queries with that filter. + fireEvent.click(screen.getByTestId('restore-facet-month-2026-07')); + await waitFor(() => expect(mockFetchCandidates).toHaveBeenCalledWith( + {q: '', month: '2026-07', teacher: ''})); }); - test('restore executes only after confirmation and reports the result', async () => { + test('restore executes only after confirmation', async () => { mockExecuteRestore.mockResolvedValue({restored: 22, missingFiles: 1, classroom: {...liveItem}}); render(); fireEvent.click(screen.getByTestId('classroom-admin-tab-restore')); - fireEvent.change(screen.getByTestId('classroom-admin-query'), {target: {value: 'abc123'}}); - fireEvent.click(screen.getByTestId('classroom-admin-search')); await waitFor(() => screen.getByTestId('classroom-admin-item-c1')); fireEvent.click(screen.getByTestId('classroom-admin-item-c1')); await waitFor(() => screen.getByTestId('restore-admin-plan')); @@ -108,16 +141,4 @@ describe('ClassroomsView (issue #1084)', () => { await waitFor(() => screen.getByTestId('restore-admin-done')); expect(screen.getByTestId('restore-admin-done').textContent).toContain('22 件'); }); - - test('a still-alive classroom points the operator to the teacher UI', async () => { - mockFetchPlan.mockResolvedValue({alive: true, status: 'archived'}); - render(); - fireEvent.click(screen.getByTestId('classroom-admin-tab-restore')); - fireEvent.change(screen.getByTestId('classroom-admin-query'), {target: {value: 'abc123'}}); - fireEvent.click(screen.getByTestId('classroom-admin-search')); - await waitFor(() => screen.getByTestId('classroom-admin-item-c1')); - fireEvent.click(screen.getByTestId('classroom-admin-item-c1')); - await waitFor(() => screen.getByTestId('restore-admin-alive')); - expect(screen.queryByTestId('restore-admin-execute')).not.toBeInTheDocument(); - }); });