Skip to content

Commit 9701bc5

Browse files
feat(admin): クラス・課題に俯瞰ダッシュボードと復元ファセットを追加
たくさんの課題があるとフラット一覧では把握できないため、用途(俯瞰して みんなの課題に有益な課題を見つける / 依頼ベースの復元)に沿って再設計。 俯瞰ダッシュボード(新・既定タブ): - サマリー(総数/利用中/アーカイブ/直近30日) - 作成の推移(月別)・内容の充実度(ページ/画像/スターターでスコア化)・ テーマ傾向(課題名/クラス名の頻出語)。人気/提出数ではなく中身と傾向で判断 - みんなの課題の有益候補(充実かつ未共有らしい課題)を見える化。促す仕組み (推奨フラグ+先生UIバナー)は別 EPIC #1106 に切り出し - チャートは外部ライブラリ不使用(CSS 横棒) 期限切れ復元: q を任意にし、削除時期・先生でファセット絞り込み(みんなの課題 カタログ風)。大量の削除済みを分類してからクラス丸ごと復元 - 新 API: GET /admin/classrooms/overview(サーバー側集計、監査ログつき) - restore-candidates は q 任意 + facets(byMonth/byTeacher)を返すよう拡張 - 集計は純関数 classroom-overview.ts に分離してユニットテスト テスト: infra 41(overview 9 + handler の overview/facets)/ SPA 37。 tsc・eslint clean。stg デプロイ + ブラウザで 3 タブ描画・ファセット絞り込みを確認 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 488941c commit 9701bc5

11 files changed

Lines changed: 994 additions & 143 deletions

File tree

docs/admin/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
### セクション(3 ドメイン)
1818

1919
1. **みんなの課題**(S3 #1083): 通報キュー(多い順)/ 全投稿一覧 / 詳細(ページ・画像・クレジット)/ 非公開⇄再公開(2 段階確認・audit)
20-
2. **クラス・課題**(S4 #1084): 全クラス検索(参加コード完全一致・名前部分一致/ 詳細(参加・提出カウント)/ アーカイブ切替 / **期限切れクラスの復元**(ddb-archive スナップショット検索 → dry-run プラン → 実行。EPIC #1049 の CLI `bin/restore-classroom.ts` の UI 後継)
20+
2. **クラス・課題**(S4 #1084 + 俯瞰ダッシュボード): 3 タブ構成 — ①**俯瞰ダッシュボード**(作成の推移・内容の充実度・テーマ傾向 + みんなの課題の**有益候補**を見える化。有益候補を先生に促す仕組みは別 EPIC #1106)②**クラス検索**参加コード完全一致・名前部分一致 / 詳細 / アーカイブ切替)③**期限切れ復元**(ddb-archive スナップショットを削除時期・先生でファセット絞り込み → dry-run → 実行。EPIC #1049 の CLI の UI 後継)
2121
3. **バグ報告**(S5 #1085 + 対応機能追加): 既存バグ報告の一覧・状態フィルタ・詳細・添付 presigned DL に加え、**状態の変更と進捗コメント(開発者からの返信)**を既存 bug-report admin API の PATCH で行える(2 段階確認・終端ステータスは自動削除 TTL の警告つき。返信は報告者の「私の不具合報告」に表示され、非表示にしていた報告も再表示される — サーバー側の既存挙動)。詳細には**状態に応じた Claude 連携プロンプト**`/bug-report` スキル向け・受付→Issue 化 / 改修 / 解決返信 / 再開)が表示され、ワンクリックでコピーして Claude Code に貼り付けられる
2222

2323
## 認証・認可モデル(要点)
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
/**
2+
* Pure aggregation for the クラス・課題 俯瞰ダッシュボード (Admin, EPIC #1073).
3+
*
4+
* The operator wants to grasp "どんな課題がどれくらい作られているか" along three
5+
* axes (chosen in the design interview): creation trend, content richness and
6+
* theme tendency — NOT popularity/engagement. Kept pure so the whole shape is
7+
* unit-tested without DynamoDB.
8+
*/
9+
10+
/** A classroom row as stored (only the fields we aggregate on). */
11+
export interface ClassroomRow {
12+
classroomId?: unknown;
13+
className?: unknown;
14+
assignmentName?: unknown;
15+
teacherSub?: unknown;
16+
status?: unknown; // 'active' | 'archived'
17+
createdAt?: unknown;
18+
content?: { pages?: { text?: string; imageKey?: string }[]; starterKey?: string };
19+
}
20+
21+
/** Quota rows reuse the classrooms key space; never count them. */
22+
export function isRealClassroom(row: ClassroomRow): boolean {
23+
return typeof row.classroomId === 'string' && !row.classroomId.includes('-quota#');
24+
}
25+
26+
/**
27+
* Content richness score (0-4): 1 for having any page, +1 for multiple pages,
28+
* +1 for any page image, +1 for a starter project. Higher = more ready to
29+
* share as-is to みんなの課題.
30+
* @param row - classroom row
31+
* @returns {score, pageCount, hasImages, hasStarter}
32+
*/
33+
export function richness(row: ClassroomRow): {
34+
score: number; pageCount: number; hasImages: boolean; hasStarter: boolean;
35+
} {
36+
const pages = row.content?.pages || [];
37+
const pageCount = pages.length;
38+
const hasImages = pages.some(p => typeof p?.imageKey === 'string' && p.imageKey.length > 0);
39+
const hasStarter = typeof row.content?.starterKey === 'string' && row.content.starterKey.length > 0;
40+
let score = 0;
41+
if (pageCount >= 1) score += 1;
42+
if (pageCount >= 2) score += 1;
43+
if (hasImages) score += 1;
44+
if (hasStarter) score += 1;
45+
return { score, pageCount, hasImages, hasStarter };
46+
}
47+
48+
/** Split a Japanese/English title into rough keyword tokens for tallying. */
49+
export function tokenizeTitle(title: string): string[] {
50+
return title
51+
.normalize('NFKC')
52+
.toLowerCase()
53+
// split on whitespace, punctuation and common separators (keep JP/EN/digits)
54+
.split(/[\s,.\/|:;\-_()\[\]{}!?]+/u)
55+
.map(t => t.trim())
56+
.filter(t => t.length >= 2 && !/^\d+$/.test(t));
57+
}
58+
59+
const MONTH_KEY = (iso: string): string => iso.slice(0, 7); // YYYY-MM
60+
61+
/**
62+
* Build the whole overview payload from every classroom row.
63+
* @param rows - raw classroom items (quota rows are filtered out here)
64+
* @param sharedTitles - normalized assignment titles already on みんなの課題
65+
* (to flag "likely already shared" candidates); pass [] if unknown
66+
* @param nowMs - current time (injectable for deterministic tests)
67+
* @returns the dashboard aggregation
68+
*/
69+
export function buildOverview(
70+
rows: ClassroomRow[],
71+
sharedTitles: string[],
72+
nowMs: number,
73+
): {
74+
summary: { total: number; active: number; archived: number; recent30d: number };
75+
creationTrend: { month: string; count: number }[];
76+
richnessDistribution: { score: number; count: number }[];
77+
candidates: {
78+
classroomId: string; className: string; assignmentName: string;
79+
teacherSub: string; score: number; pageCount: number;
80+
hasImages: boolean; hasStarter: boolean; createdAt: string; likelyShared: boolean;
81+
}[];
82+
themeKeywords: { keyword: string; count: number }[];
83+
} {
84+
const classrooms = rows.filter(isRealClassroom);
85+
const sharedSet = new Set(sharedTitles.map(t => t.normalize('NFKC').trim().toLowerCase()));
86+
const cutoff30 = nowMs - 30 * 24 * 60 * 60 * 1000;
87+
88+
let active = 0;
89+
let archived = 0;
90+
let recent30d = 0;
91+
const byMonth = new Map<string, number>();
92+
const byScore = new Map<number, number>();
93+
const keywordCount = new Map<string, number>();
94+
const candidates: ReturnType<typeof buildOverview>['candidates'] = [];
95+
96+
for (const row of classrooms) {
97+
const status = String(row.status || '');
98+
if (status === 'archived') archived += 1; else active += 1;
99+
100+
const createdAt = typeof row.createdAt === 'string' ? row.createdAt : '';
101+
if (createdAt) {
102+
byMonth.set(MONTH_KEY(createdAt), (byMonth.get(MONTH_KEY(createdAt)) || 0) + 1);
103+
if (Date.parse(createdAt) >= cutoff30) recent30d += 1;
104+
}
105+
106+
const r = richness(row);
107+
byScore.set(r.score, (byScore.get(r.score) || 0) + 1);
108+
109+
const assignmentName = typeof row.assignmentName === 'string' ? row.assignmentName : '';
110+
const className = typeof row.className === 'string' ? row.className : '';
111+
for (const token of [...tokenizeTitle(assignmentName), ...tokenizeTitle(className)]) {
112+
keywordCount.set(token, (keywordCount.get(token) || 0) + 1);
113+
}
114+
115+
// 有益候補: reasonably rich (>=3) content. Flag likely-already-shared so
116+
// the operator focuses on the unshared ones.
117+
if (r.score >= 3) {
118+
const likelyShared = sharedSet.has(assignmentName.normalize('NFKC').trim().toLowerCase());
119+
candidates.push({
120+
classroomId: String(row.classroomId),
121+
className,
122+
assignmentName,
123+
teacherSub: typeof row.teacherSub === 'string' ? row.teacherSub : '',
124+
score: r.score,
125+
pageCount: r.pageCount,
126+
hasImages: r.hasImages,
127+
hasStarter: r.hasStarter,
128+
createdAt,
129+
likelyShared,
130+
});
131+
}
132+
}
133+
134+
const creationTrend = [...byMonth.entries()]
135+
.map(([month, count]) => ({ month, count }))
136+
.sort((a, b) => a.month.localeCompare(b.month));
137+
138+
const richnessDistribution = [0, 1, 2, 3, 4]
139+
.map(score => ({ score, count: byScore.get(score) || 0 }));
140+
141+
// Unshared candidates first, then richer, then newer.
142+
candidates.sort((a, b) =>
143+
Number(a.likelyShared) - Number(b.likelyShared) ||
144+
b.score - a.score ||
145+
b.createdAt.localeCompare(a.createdAt));
146+
147+
const themeKeywords = [...keywordCount.entries()]
148+
.map(([keyword, count]) => ({ keyword, count }))
149+
.filter(k => k.count >= 2)
150+
.sort((a, b) => b.count - a.count || a.keyword.localeCompare(b.keyword))
151+
.slice(0, 30);
152+
153+
return {
154+
summary: { total: classrooms.length, active, archived, recent30d },
155+
creationTrend,
156+
richnessDistribution,
157+
candidates: candidates.slice(0, 50),
158+
themeKeywords,
159+
};
160+
}
161+
162+
/**
163+
* Facet buckets for the restore tab (削除済みスナップショット). Groups a large
164+
* candidate list by deletion month and by teacher so the operator can narrow
165+
* down like the みんなの課題 catalog.
166+
* @param snapshots - matched snapshot summaries
167+
* @returns facet counts
168+
*/
169+
export function buildRestoreFacets(
170+
snapshots: { deletedAt?: string | null; teacherSub?: string }[],
171+
): { byMonth: { month: string; count: number }[]; byTeacher: { teacherSub: string; count: number }[] } {
172+
const byMonth = new Map<string, number>();
173+
const byTeacher = new Map<string, number>();
174+
for (const s of snapshots) {
175+
const month = s.deletedAt ? s.deletedAt.slice(0, 7) : '(不明)';
176+
byMonth.set(month, (byMonth.get(month) || 0) + 1);
177+
const t = s.teacherSub || '(不明)';
178+
byTeacher.set(t, (byTeacher.get(t) || 0) + 1);
179+
}
180+
return {
181+
byMonth: [...byMonth.entries()].map(([month, count]) => ({ month, count }))
182+
.sort((a, b) => b.month.localeCompare(a.month)),
183+
byTeacher: [...byTeacher.entries()].map(([teacherSub, count]) => ({ teacherSub, count }))
184+
.sort((a, b) => b.count - a.count),
185+
};
186+
}

infra/smalruby-admin/lambda/handler.ts

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { OAuth2Client } from 'google-auth-library';
3030
import {
3131
buildRestorePlan, matchSnapshot, PlannedItem, RestorePlanInput, Snapshot,
3232
} from './restore-plan';
33+
import { buildOverview, buildRestoreFacets, ClassroomRow } from './classroom-overview';
3334

3435
// --- Configuration ---
3536

@@ -416,6 +417,22 @@ async function handleListClassrooms(
416417
return { statusCode: 200, body: JSON.stringify({ items: items.slice(0, 100) }) };
417418
}
418419

420+
async function handleClassroomOverview(
421+
identity: AdminIdentity,
422+
): Promise<APIGatewayProxyStructuredResultV2> {
423+
audit('classroom.overview', identity, {});
424+
const [classroomRows, sharedRows] = await Promise.all([
425+
scanAll(CLASSROOMS_TABLE),
426+
// Titles already on みんなの課題 — flag likely-shared candidates.
427+
scanAll(SHARED_ASSIGNMENTS_TABLE).catch(() => [] as Record<string, unknown>[]),
428+
]);
429+
const sharedTitles = sharedRows
430+
.map(r => (typeof r.title === 'string' ? r.title : ''))
431+
.filter(Boolean);
432+
const overview = buildOverview(classroomRows as ClassroomRow[], sharedTitles, Date.now());
433+
return { statusCode: 200, body: JSON.stringify(overview) };
434+
}
435+
419436
async function handleGetClassroom(
420437
identity: AdminIdentity, classroomId: string,
421438
): Promise<APIGatewayProxyStructuredResultV2> {
@@ -522,24 +539,45 @@ async function collectSnapshotChildren(kind: string, classroomId: string): Promi
522539
async function handleSearchRestoreCandidates(
523540
identity: AdminIdentity, query: Record<string, string | undefined>,
524541
): Promise<APIGatewayProxyStructuredResultV2> {
542+
// q is now optional: the operator can browse ALL deleted snapshots and
543+
// narrow with facets (削除時期 / 先生) instead of only text search, because
544+
// the raw list is too large to eyeball (みんなの課題 catalog style).
525545
const q = (query.q || '').trim();
526-
if (!q) {
527-
throw new ValidationError('q is required');
528-
}
529-
audit('classroom.searchSnapshots', identity, { q });
546+
const monthFilter = (query.month || '').trim();
547+
const teacherFilter = (query.teacher || '').trim();
548+
audit('classroom.searchSnapshots', identity, { q: q || null, month: monthFilter || null });
530549

531550
const keys = await listArchiveKeys(`${ARCHIVE_PREFIX}/classrooms/`);
532-
const matches: Record<string, unknown>[] = [];
551+
const all: { item: Record<string, unknown>; deletedAt: string | null; teacherSub: string }[] = [];
533552
for (const key of keys) {
534553
const snapshot = await readSnapshot(key);
535-
if (snapshot && matchSnapshot(snapshot, q)) {
536-
matches.push({
537-
...mapClassroomForAdmin(snapshot.item),
554+
if (snapshot?.item) {
555+
all.push({
556+
item: snapshot.item,
538557
deletedAt: snapshot.deletedAt,
558+
teacherSub: typeof snapshot.item.teacherSub === 'string' ? snapshot.item.teacherSub : '',
539559
});
540560
}
541561
}
542-
return { statusCode: 200, body: JSON.stringify({ items: matches }) };
562+
563+
// Facets are computed over everything (so the operator sees the full shape),
564+
// the item list is what survives the active filters.
565+
const facets = buildRestoreFacets(all);
566+
const matches = all
567+
.filter(s => !q || matchSnapshot({ table: 'classrooms', deletedAt: s.deletedAt, eventId: null, item: s.item }, q))
568+
.filter(s => !monthFilter || (s.deletedAt || '').slice(0, 7) === monthFilter)
569+
.filter(s => !teacherFilter || s.teacherSub === teacherFilter)
570+
.map(s => ({
571+
...mapClassroomForAdmin(s.item),
572+
teacherSub: s.teacherSub,
573+
deletedAt: s.deletedAt,
574+
}))
575+
.sort((a, b) => String(b.deletedAt || '').localeCompare(String(a.deletedAt || '')));
576+
577+
return {
578+
statusCode: 200,
579+
body: JSON.stringify({ items: matches.slice(0, 200), total: matches.length, facets }),
580+
};
543581
}
544582

545583
async function gatherRestoreInput(classroomId: string): Promise<{
@@ -711,6 +749,9 @@ export const handler = async (event: APIGatewayProxyEventV2): Promise<APIGateway
711749
const sharedId = event.pathParameters?.sharedId || '';
712750
result = await handleSetSharedStatus(identity, sharedId, body);
713751

752+
} else if (method === 'GET' && path === '/admin/classrooms/overview') {
753+
result = await handleClassroomOverview(identity);
754+
714755
} else if (method === 'GET' && path === '/admin/classrooms') {
715756
result = await handleListClassrooms(identity, event.queryStringParameters || {});
716757

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import {
2+
buildOverview, buildRestoreFacets, isRealClassroom, richness, tokenizeTitle,
3+
} from '../classroom-overview';
4+
5+
const NOW = Date.parse('2026-07-19T00:00:00.000Z');
6+
7+
const rows = [
8+
// rich + recent + unshared → top candidate
9+
{
10+
classroomId: 'c1', className: '5年1組', assignmentName: 'ねこ迷路ゲーム', teacherSub: 't1',
11+
status: 'active', createdAt: '2026-07-10T00:00:00.000Z',
12+
content: { pages: [{ text: 'a', imageKey: 'k1' }, { text: 'b' }], starterKey: 's1' },
13+
},
14+
// rich but already shared
15+
{
16+
classroomId: 'c2', className: '6年2組', assignmentName: 'ねこあつめ入門', teacherSub: 't2',
17+
status: 'archived', createdAt: '2026-06-05T00:00:00.000Z',
18+
content: { pages: [{ text: 'a', imageKey: 'k' }, { text: 'b' }], starterKey: 's' },
19+
},
20+
// thin content → not a candidate
21+
{
22+
classroomId: 'c3', className: '5年1組', assignmentName: 'ねこ体操', teacherSub: 't1',
23+
status: 'active', createdAt: '2026-07-01T00:00:00.000Z',
24+
content: { pages: [{ text: 'a' }] },
25+
},
26+
// quota row → excluded from everything
27+
{ classroomId: 'eval-quota#t1#2026-07-19', status: 'active', createdAt: '2026-07-19T00:00:00.000Z' },
28+
];
29+
30+
describe('richness / tokenizeTitle / isRealClassroom', () => {
31+
test('richness scores pages, images and starter', () => {
32+
expect(richness(rows[0]).score).toBe(4); // >=2 pages(2) + image(1) + starter(1)
33+
expect(richness(rows[2]).score).toBe(1); // 1 page only
34+
expect(richness({}).score).toBe(0);
35+
});
36+
37+
test('tokenizeTitle drops short tokens and pure numbers', () => {
38+
const t = tokenizeTitle('ねこ迷路ゲーム 2026');
39+
expect(t).toContain('ねこ迷路ゲーム');
40+
expect(t).not.toContain('2026');
41+
});
42+
43+
test('quota rows are not real classrooms', () => {
44+
expect(isRealClassroom(rows[3])).toBe(false);
45+
expect(isRealClassroom(rows[0])).toBe(true);
46+
});
47+
});
48+
49+
describe('buildOverview (issue #1106 dashboard)', () => {
50+
const o = buildOverview(rows, ['ねこあつめ入門'], NOW);
51+
52+
test('summary counts exclude quota rows and split active/archived', () => {
53+
expect(o.summary.total).toBe(3);
54+
expect(o.summary.active).toBe(2);
55+
expect(o.summary.archived).toBe(1);
56+
expect(o.summary.recent30d).toBe(2); // c1 and c3 within 30d of NOW
57+
});
58+
59+
test('creation trend is per-month, ascending', () => {
60+
expect(o.creationTrend).toEqual([
61+
{ month: '2026-06', count: 1 },
62+
{ month: '2026-07', count: 2 },
63+
]);
64+
});
65+
66+
test('richness distribution covers scores 0-4', () => {
67+
expect(o.richnessDistribution).toHaveLength(5);
68+
expect(o.richnessDistribution.find(d => d.score === 4)?.count).toBe(2);
69+
});
70+
71+
test('candidates: unshared rich ones first, shared flagged, thin excluded', () => {
72+
expect(o.candidates.map(c => c.classroomId)).toEqual(['c1', 'c2']);
73+
expect(o.candidates[0].likelyShared).toBe(false);
74+
expect(o.candidates[1].likelyShared).toBe(true); // ねこあつめ入門 already shared
75+
expect(o.candidates.some(c => c.classroomId === 'c3')).toBe(false);
76+
});
77+
78+
test('theme keywords tally repeated tokens only', () => {
79+
// '5年1組' appears on c1 and c3
80+
expect(o.themeKeywords.some(k => k.keyword === '5年1組' && k.count === 2)).toBe(true);
81+
});
82+
});
83+
84+
describe('buildRestoreFacets (issue #1084 restore facets)', () => {
85+
test('groups snapshots by deletion month and teacher', () => {
86+
const f = buildRestoreFacets([
87+
{ deletedAt: '2026-07-10T00:00:00Z', teacherSub: 't1' },
88+
{ deletedAt: '2026-07-11T00:00:00Z', teacherSub: 't1' },
89+
{ deletedAt: '2026-06-01T00:00:00Z', teacherSub: 't2' },
90+
{ deletedAt: null, teacherSub: '' },
91+
]);
92+
expect(f.byMonth).toEqual([
93+
{ month: '2026-07', count: 2 },
94+
{ month: '2026-06', count: 1 },
95+
{ month: '(不明)', count: 1 },
96+
]);
97+
expect(f.byTeacher[0]).toEqual({ teacherSub: 't1', count: 2 });
98+
});
99+
});

0 commit comments

Comments
 (0)