From 15d5f73f14b81d88bdc7ba1b94a10d2bbc2703ca Mon Sep 17 00:00:00 2001 From: ojfbot <151410806+ojfbot@users.noreply.github.com> Date: Fri, 10 Apr 2026 12:06:11 -0500 Subject: [PATCH 1/3] =?UTF-8?q?refactor:=20decompose=20StudyPanel=20into?= =?UTF-8?q?=20hook=20+=205=20presenters=20(328=E2=86=9272=20lines)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract useStudyPanel hook with all quiz/match/drag state and handlers. Split rendering into StudySessionHeader, FlashcardMode, QuizMode, MatchMode, and StudySetup presenters in study-panel/ subdirectory. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/panels/StudyPanel.tsx | 364 +++--------------- .../panels/study-panel/FlashcardMode.tsx | 37 ++ .../panels/study-panel/MatchMode.tsx | 82 ++++ .../panels/study-panel/QuizMode.tsx | 39 ++ .../panels/study-panel/StudySessionHeader.tsx | 34 ++ .../panels/study-panel/StudySetup.tsx | 73 ++++ .../panels/study-panel/useStudyPanel.ts | 152 ++++++++ 7 files changed, 473 insertions(+), 308 deletions(-) create mode 100644 packages/browser-app/src/components/panels/study-panel/FlashcardMode.tsx create mode 100644 packages/browser-app/src/components/panels/study-panel/MatchMode.tsx create mode 100644 packages/browser-app/src/components/panels/study-panel/QuizMode.tsx create mode 100644 packages/browser-app/src/components/panels/study-panel/StudySessionHeader.tsx create mode 100644 packages/browser-app/src/components/panels/study-panel/StudySetup.tsx create mode 100644 packages/browser-app/src/components/panels/study-panel/useStudyPanel.ts diff --git a/packages/browser-app/src/components/panels/StudyPanel.tsx b/packages/browser-app/src/components/panels/StudyPanel.tsx index 0f25b62..bc1e5d2 100644 --- a/packages/browser-app/src/components/panels/StudyPanel.tsx +++ b/packages/browser-app/src/components/panels/StudyPanel.tsx @@ -1,328 +1,76 @@ -import { useMemo, useState, useCallback, type DragEvent } from 'react' -import { Button, Toggle, Tag, InlineNotification } from '@carbon/react' -import { ArrowRight, Checkmark, Close, Draggable } from '@carbon/icons-react' -import { - GLOSSARY, TERM_CATEGORIES, CATEGORIES, CATEGORY_LABELS, - getStudyDeck, getDueCards, pickDistractors, - type Category, type StudyMode, -} from '@ojfbot/seh-study-shared' -import { useAppSelector, useAppDispatch } from '../../store/store.js' -import { - setMode, setSelectedCategories, startSession, - flipCard, answerCard, endSession, -} from '../../store/studySlice.js' +import { useStudyPanel } from './study-panel/useStudyPanel' +import { StudySessionHeader } from './study-panel/StudySessionHeader' +import { FlashcardMode } from './study-panel/FlashcardMode' +import { QuizMode } from './study-panel/QuizMode' +import { MatchMode } from './study-panel/MatchMode' +import { StudySetup } from './study-panel/StudySetup' import './StudyPanel.css' -const DECK_SIZE = 20 -const MODE_LABELS: Record = { - flashcard: 'Flashcards', - quiz: 'Quiz', - match: 'Drag & Match', -} -const MODE_ORDER: StudyMode[] = ['flashcard', 'quiz', 'match'] - export default function StudyPanel() { - const dispatch = useAppDispatch() - const { cards, mode, currentDeck, currentIndex, showAnswer, selectedCategories, activeSession } = useAppSelector(s => s.study) - - const dueCount = useMemo(() => getDueCards(cards).length, [cards]) - - const currentTermIndex = currentDeck[currentIndex] - const currentTerm = currentTermIndex != null ? GLOSSARY[currentTermIndex] : null - const currentCard = currentTermIndex != null ? cards.find(c => c.termIndex === currentTermIndex) : null - - // Quiz / match state - const [quizOptions, setQuizOptions] = useState([]) - const [quizAnswer, setQuizAnswer] = useState(null) - - // Drag-and-drop state - const [draggedOption, setDraggedOption] = useState(null) - const [dropHover, setDropHover] = useState(false) - const [matchResult, setMatchResult] = useState<'correct' | 'incorrect' | null>(null) - - function handleStart() { - const filteredCards = selectedCategories.length > 0 - ? cards.filter(c => selectedCategories.includes(TERM_CATEGORIES[c.termIndex])) - : cards - const deck = getStudyDeck(filteredCards, DECK_SIZE) - dispatch(startSession({ deck: deck.map(c => c.termIndex) })) - if ((mode === 'quiz' || mode === 'match') && deck.length > 0) { - setupQuizQuestion(deck[0].termIndex) - } - } - - function setupQuizQuestion(termIndex: number) { - const distractorIndices = pickDistractors(termIndex, GLOSSARY.length, 3) - const options = [...distractorIndices, termIndex].sort(() => Math.random() - 0.5) - setQuizOptions(options) - setQuizAnswer(null) - setMatchResult(null) - setDraggedOption(null) - setDropHover(false) - } - - function handleFlashcardAnswer(correct: boolean) { - dispatch(answerCard({ correct })) - if (currentIndex >= currentDeck.length - 1) { - dispatch(endSession()) - } - } - - function handleQuizSelect(selectedTermIndex: number) { - if (quizAnswer !== null) return - setQuizAnswer(selectedTermIndex) - const correct = selectedTermIndex === currentTermIndex - dispatch(answerCard({ correct })) - setTimeout(() => { - if (currentIndex >= currentDeck.length - 1) { - dispatch(endSession()) - } else { - setupQuizQuestion(currentDeck[currentIndex + 1]) - } - }, 1200) - } - - // ── Drag handlers ────────────────────────────────────────────────── - const onDragStart = useCallback((e: DragEvent, optIdx: number) => { - e.dataTransfer.setData('text/plain', String(optIdx)) - e.dataTransfer.effectAllowed = 'move' - setDraggedOption(optIdx) - }, []) - - const onDragEnd = useCallback(() => { - setDraggedOption(null) - setDropHover(false) - }, []) + const s = useStudyPanel() - const onDragOver = useCallback((e: DragEvent) => { - e.preventDefault() - e.dataTransfer.dropEffect = 'move' - setDropHover(true) - }, []) - - const onDragLeave = useCallback(() => { - setDropHover(false) - }, []) - - const onDrop = useCallback((e: DragEvent) => { - e.preventDefault() - setDropHover(false) - const droppedIdx = parseInt(e.dataTransfer.getData('text/plain'), 10) - if (isNaN(droppedIdx) || matchResult !== null) return - - const correct = droppedIdx === currentTermIndex - setQuizAnswer(droppedIdx) - setMatchResult(correct ? 'correct' : 'incorrect') - dispatch(answerCard({ correct })) - - setTimeout(() => { - if (currentIndex >= currentDeck.length - 1) { - dispatch(endSession()) - } else { - setupQuizQuestion(currentDeck[currentIndex + 1]) - } - }, 1400) - }, [currentTermIndex, currentIndex, currentDeck, matchResult, dispatch]) - - // ── Active session render ────────────────────────────────────────── - if (activeSession && currentTerm) { + if (s.activeSession && s.currentTerm) { return (
-
- - Card {currentIndex + 1} / {currentDeck.length} - - {currentCard && ( - - Box {currentCard.box} - - )} - - {CATEGORY_LABELS[TERM_CATEGORIES[currentTermIndex]]} - - -
- - {mode === 'flashcard' && ( -
dispatch(flipCard())}> -
-
-

{currentTerm.term}

-

Tap to reveal definition

-
-
-

{currentTerm.term}

-

{currentTerm.definition}

-
-
- {showAnswer && ( -
- - -
- )} -
+ + + {s.mode === 'flashcard' && ( + )} - {mode === 'quiz' && ( -
-

What is the definition of:

-

{currentTerm.term}

-
- {quizOptions.map(optIdx => { - const isCorrect = optIdx === currentTermIndex - const isSelected = quizAnswer === optIdx - let className = 'quiz-option' - if (quizAnswer !== null) { - if (isCorrect) className += ' correct' - else if (isSelected) className += ' incorrect' - } - return ( - - ) - })} -
-
+ {s.mode === 'quiz' && ( + )} - {mode === 'match' && ( -
- {/* Drop zone: the term card */} -
-

{currentTerm.term}

- {matchResult === null && ( -

Drag the correct definition here

- )} - {matchResult === 'correct' && ( -

- Correct! -

- )} - {matchResult === 'incorrect' && ( -
-

Incorrect

-

{currentTerm.definition}

-
- )} -
- - {/* Draggable option chips */} -
- {quizOptions.map(optIdx => { - const isDragging = draggedOption === optIdx - const isAnswered = matchResult !== null - const isCorrect = optIdx === currentTermIndex - const wasDropped = quizAnswer === optIdx - - let className = 'match-option' - if (isDragging) className += ' dragging' - if (isAnswered && isCorrect) className += ' correct' - if (isAnswered && wasDropped && !isCorrect) className += ' incorrect' - - return ( -
onDragStart(e, optIdx)} - onDragEnd={onDragEnd} - onClick={() => { - // Fallback: click-to-select for touch / accessibility - if (!isAnswered) { - const correct = optIdx === currentTermIndex - setQuizAnswer(optIdx) - setMatchResult(correct ? 'correct' : 'incorrect') - dispatch(answerCard({ correct })) - setTimeout(() => { - if (currentIndex >= currentDeck.length - 1) { - dispatch(endSession()) - } else { - setupQuizQuestion(currentDeck[currentIndex + 1]) - } - }, 1400) - } - }} - > - - - {GLOSSARY[optIdx].definition} - -
- ) - })} -
-
+ {s.mode === 'match' && ( + )}
) } - // ── Start screen ─────────────────────────────────────────────────── return (
-
-

Study Session

- -
- {MODE_ORDER.map(m => ( - - ))} -
- - {dueCount > 0 && ( - - )} - -
-

Filter by category (optional):

-
- {CATEGORIES.map(cat => ( - { - const next = selectedCategories.includes(cat) - ? selectedCategories.filter(c => c !== cat) - : [...selectedCategories, cat] - dispatch(setSelectedCategories(next)) - }} - style={{ cursor: 'pointer' }} - > - {CATEGORY_LABELS[cat]} - - ))} -
-
- - -
+
) } diff --git a/packages/browser-app/src/components/panels/study-panel/FlashcardMode.tsx b/packages/browser-app/src/components/panels/study-panel/FlashcardMode.tsx new file mode 100644 index 0000000..7db7b2b --- /dev/null +++ b/packages/browser-app/src/components/panels/study-panel/FlashcardMode.tsx @@ -0,0 +1,37 @@ +import { Button } from '@carbon/react' +import { Checkmark, Close } from '@carbon/icons-react' +import type { GlossaryTerm } from '@ojfbot/seh-study-shared' + +interface FlashcardModeProps { + currentTerm: GlossaryTerm + showAnswer: boolean + onFlip: () => void + onAnswer: (correct: boolean) => void +} + +export function FlashcardMode({ currentTerm, showAnswer, onFlip, onAnswer }: FlashcardModeProps) { + return ( +
+
+
+

{currentTerm.term}

+

Tap to reveal definition

+
+
+

{currentTerm.term}

+

{currentTerm.definition}

+
+
+ {showAnswer && ( +
+ + +
+ )} +
+ ) +} diff --git a/packages/browser-app/src/components/panels/study-panel/MatchMode.tsx b/packages/browser-app/src/components/panels/study-panel/MatchMode.tsx new file mode 100644 index 0000000..cd2e166 --- /dev/null +++ b/packages/browser-app/src/components/panels/study-panel/MatchMode.tsx @@ -0,0 +1,82 @@ +import type { DragEvent } from 'react' +import { Checkmark, Close, Draggable } from '@carbon/icons-react' +import { GLOSSARY, type GlossaryTerm } from '@ojfbot/seh-study-shared' + +interface MatchModeProps { + currentTerm: GlossaryTerm + currentTermIndex: number + quizOptions: number[] + quizAnswer: number | null + draggedOption: number | null + dropHover: boolean + matchResult: 'correct' | 'incorrect' | null + onDragStart: (e: DragEvent, optIdx: number) => void + onDragEnd: () => void + onDragOver: (e: DragEvent) => void + onDragLeave: () => void + onDrop: (e: DragEvent) => void + onSelect: (optIdx: number) => void +} + +export function MatchMode({ + currentTerm, currentTermIndex, quizOptions, quizAnswer, + draggedOption, dropHover, matchResult, + onDragStart, onDragEnd, onDragOver, onDragLeave, onDrop, onSelect, +}: MatchModeProps) { + return ( +
+
+

{currentTerm.term}

+ {matchResult === null && ( +

Drag the correct definition here

+ )} + {matchResult === 'correct' && ( +

+ Correct! +

+ )} + {matchResult === 'incorrect' && ( +
+

Incorrect

+

{currentTerm.definition}

+
+ )} +
+ +
+ {quizOptions.map(optIdx => { + const isDragging = draggedOption === optIdx + const isAnswered = matchResult !== null + const isCorrect = optIdx === currentTermIndex + const wasDropped = quizAnswer === optIdx + + let className = 'match-option' + if (isDragging) className += ' dragging' + if (isAnswered && isCorrect) className += ' correct' + if (isAnswered && wasDropped && !isCorrect) className += ' incorrect' + + return ( +
onDragStart(e, optIdx)} + onDragEnd={onDragEnd} + onClick={() => !isAnswered && onSelect(optIdx)} + > + + + {GLOSSARY[optIdx].definition} + +
+ ) + })} +
+
+ ) +} diff --git a/packages/browser-app/src/components/panels/study-panel/QuizMode.tsx b/packages/browser-app/src/components/panels/study-panel/QuizMode.tsx new file mode 100644 index 0000000..e2c992d --- /dev/null +++ b/packages/browser-app/src/components/panels/study-panel/QuizMode.tsx @@ -0,0 +1,39 @@ +import { GLOSSARY, type GlossaryTerm } from '@ojfbot/seh-study-shared' + +interface QuizModeProps { + currentTerm: GlossaryTerm + currentTermIndex: number + quizOptions: number[] + quizAnswer: number | null + onSelect: (selectedTermIndex: number) => void +} + +export function QuizMode({ currentTerm, currentTermIndex, quizOptions, quizAnswer, onSelect }: QuizModeProps) { + return ( +
+

What is the definition of:

+

{currentTerm.term}

+
+ {quizOptions.map(optIdx => { + const isCorrect = optIdx === currentTermIndex + const isSelected = quizAnswer === optIdx + let className = 'quiz-option' + if (quizAnswer !== null) { + if (isCorrect) className += ' correct' + else if (isSelected) className += ' incorrect' + } + return ( + + ) + })} +
+
+ ) +} diff --git a/packages/browser-app/src/components/panels/study-panel/StudySessionHeader.tsx b/packages/browser-app/src/components/panels/study-panel/StudySessionHeader.tsx new file mode 100644 index 0000000..3683d18 --- /dev/null +++ b/packages/browser-app/src/components/panels/study-panel/StudySessionHeader.tsx @@ -0,0 +1,34 @@ +import { Button, Tag } from '@carbon/react' +import { Close } from '@carbon/icons-react' +import { TERM_CATEGORIES, CATEGORY_LABELS, type CardState } from '@ojfbot/seh-study-shared' + +interface StudySessionHeaderProps { + currentIndex: number + deckLength: number + currentCard: CardState | null | undefined + currentTermIndex: number + onEnd: () => void +} + +export function StudySessionHeader({ + currentIndex, deckLength, currentCard, currentTermIndex, onEnd, +}: StudySessionHeaderProps) { + return ( +
+ + Card {currentIndex + 1} / {deckLength} + + {currentCard && ( + + Box {currentCard.box} + + )} + + {CATEGORY_LABELS[TERM_CATEGORIES[currentTermIndex]]} + + +
+ ) +} diff --git a/packages/browser-app/src/components/panels/study-panel/StudySetup.tsx b/packages/browser-app/src/components/panels/study-panel/StudySetup.tsx new file mode 100644 index 0000000..ccbb5ea --- /dev/null +++ b/packages/browser-app/src/components/panels/study-panel/StudySetup.tsx @@ -0,0 +1,73 @@ +import { Button, Tag, InlineNotification } from '@carbon/react' +import { ArrowRight } from '@carbon/icons-react' +import { CATEGORIES, CATEGORY_LABELS, type Category, type StudyMode } from '@ojfbot/seh-study-shared' + +const MODE_LABELS: Record = { + flashcard: 'Flashcards', + quiz: 'Quiz', + match: 'Drag & Match', +} +const MODE_ORDER: StudyMode[] = ['flashcard', 'quiz', 'match'] + +interface StudySetupProps { + mode: StudyMode + selectedCategories: Category[] + dueCount: number + deckSize: number + onSetMode: (mode: StudyMode) => void + onSetCategories: (cats: Category[]) => void + onStart: () => void +} + +export function StudySetup({ + mode, selectedCategories, dueCount, deckSize, + onSetMode, onSetCategories, onStart, +}: StudySetupProps) { + return ( +
+

Study Session

+ +
+ {MODE_ORDER.map(m => ( + + ))} +
+ + {dueCount > 0 && ( + + )} + +
+

Filter by category (optional):

+
+ {CATEGORIES.map(cat => ( + { + const next = selectedCategories.includes(cat) + ? selectedCategories.filter(c => c !== cat) + : [...selectedCategories, cat] + onSetCategories(next) + }} + style={{ cursor: 'pointer' }} + > + {CATEGORY_LABELS[cat]} + + ))} +
+
+ + +
+ ) +} diff --git a/packages/browser-app/src/components/panels/study-panel/useStudyPanel.ts b/packages/browser-app/src/components/panels/study-panel/useStudyPanel.ts new file mode 100644 index 0000000..e0e88ea --- /dev/null +++ b/packages/browser-app/src/components/panels/study-panel/useStudyPanel.ts @@ -0,0 +1,152 @@ +import { useMemo, useState, useCallback, type DragEvent } from 'react' +import { + GLOSSARY, TERM_CATEGORIES, getStudyDeck, getDueCards, pickDistractors, +} from '@ojfbot/seh-study-shared' +import { useAppSelector, useAppDispatch } from '../../../store/store.js' +import { + setMode, setSelectedCategories, startSession, + flipCard, answerCard, endSession, +} from '../../../store/studySlice.js' + +const DECK_SIZE = 20 + +export function useStudyPanel() { + const dispatch = useAppDispatch() + const { + cards, mode, currentDeck, currentIndex, showAnswer, + selectedCategories, activeSession, + } = useAppSelector(s => s.study) + + const dueCount = useMemo(() => getDueCards(cards).length, [cards]) + + const currentTermIndex = currentDeck[currentIndex] + const currentTerm = currentTermIndex != null ? GLOSSARY[currentTermIndex] : null + const currentCard = currentTermIndex != null ? cards.find(c => c.termIndex === currentTermIndex) : null + + // Quiz / match state + const [quizOptions, setQuizOptions] = useState([]) + const [quizAnswer, setQuizAnswer] = useState(null) + + // Drag-and-drop state + const [draggedOption, setDraggedOption] = useState(null) + const [dropHover, setDropHover] = useState(false) + const [matchResult, setMatchResult] = useState<'correct' | 'incorrect' | null>(null) + + function setupQuizQuestion(termIndex: number) { + const distractorIndices = pickDistractors(termIndex, GLOSSARY.length, 3) + const options = [...distractorIndices, termIndex].sort(() => Math.random() - 0.5) + setQuizOptions(options) + setQuizAnswer(null) + setMatchResult(null) + setDraggedOption(null) + setDropHover(false) + } + + function handleStart() { + const filteredCards = selectedCategories.length > 0 + ? cards.filter(c => selectedCategories.includes(TERM_CATEGORIES[c.termIndex])) + : cards + const deck = getStudyDeck(filteredCards, DECK_SIZE) + dispatch(startSession({ deck: deck.map(c => c.termIndex) })) + if ((mode === 'quiz' || mode === 'match') && deck.length > 0) { + setupQuizQuestion(deck[0].termIndex) + } + } + + function handleFlashcardAnswer(correct: boolean) { + dispatch(answerCard({ correct })) + if (currentIndex >= currentDeck.length - 1) { + dispatch(endSession()) + } + } + + function handleQuizSelect(selectedTermIndex: number) { + if (quizAnswer !== null) return + setQuizAnswer(selectedTermIndex) + const correct = selectedTermIndex === currentTermIndex + dispatch(answerCard({ correct })) + setTimeout(() => { + if (currentIndex >= currentDeck.length - 1) { + dispatch(endSession()) + } else { + setupQuizQuestion(currentDeck[currentIndex + 1]) + } + }, 1200) + } + + function handleMatchSelect(optIdx: number) { + if (matchResult !== null) return + const correct = optIdx === currentTermIndex + setQuizAnswer(optIdx) + setMatchResult(correct ? 'correct' : 'incorrect') + dispatch(answerCard({ correct })) + setTimeout(() => { + if (currentIndex >= currentDeck.length - 1) { + dispatch(endSession()) + } else { + setupQuizQuestion(currentDeck[currentIndex + 1]) + } + }, 1400) + } + + // Drag handlers + const onDragStart = useCallback((e: DragEvent, optIdx: number) => { + e.dataTransfer.setData('text/plain', String(optIdx)) + e.dataTransfer.effectAllowed = 'move' + setDraggedOption(optIdx) + }, []) + + const onDragEnd = useCallback(() => { + setDraggedOption(null) + setDropHover(false) + }, []) + + const onDragOver = useCallback((e: DragEvent) => { + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + setDropHover(true) + }, []) + + const onDragLeave = useCallback(() => { + setDropHover(false) + }, []) + + const onDrop = useCallback((e: DragEvent) => { + e.preventDefault() + setDropHover(false) + const droppedIdx = parseInt(e.dataTransfer.getData('text/plain'), 10) + if (isNaN(droppedIdx) || matchResult !== null) return + + const correct = droppedIdx === currentTermIndex + setQuizAnswer(droppedIdx) + setMatchResult(correct ? 'correct' : 'incorrect') + dispatch(answerCard({ correct })) + + setTimeout(() => { + if (currentIndex >= currentDeck.length - 1) { + dispatch(endSession()) + } else { + setupQuizQuestion(currentDeck[currentIndex + 1]) + } + }, 1400) + }, [currentTermIndex, currentIndex, currentDeck, matchResult, dispatch]) + + return { + // State + mode, currentDeck, currentIndex, showAnswer, selectedCategories, + activeSession, dueCount, currentTermIndex, currentTerm, currentCard, + quizOptions, quizAnswer, draggedOption, dropHover, matchResult, + // Actions + handleStart, + handleFlashcardAnswer, + handleQuizSelect, + handleMatchSelect, + handleFlip: () => dispatch(flipCard()), + handleEnd: () => dispatch(endSession()), + handleSetMode: (m: Parameters[0]) => dispatch(setMode(m)), + handleSetCategories: (cats: Parameters[0]) => dispatch(setSelectedCategories(cats)), + // Drag handlers + onDragStart, onDragEnd, onDragOver, onDragLeave, onDrop, + DECK_SIZE, + } +} From fe311423f6a05534518cab5702f302fa983eb082 Mon Sep 17 00:00:00 2001 From: ojfbot <151410806+ojfbot@users.noreply.github.com> Date: Fri, 10 Apr 2026 13:20:44 -0500 Subject: [PATCH 2/3] feat: add GET /api/beads endpoint (ADR-0016 bead projection) Add FrameBeadLike types and /api/beads route. Currently returns empty as study sessions are client-side. Ready for server-side persistence. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/api/src/beads/types.ts | 30 ++++++++++++++++++++++++++++++ packages/api/src/routes/beads.ts | 17 +++++++++++++++++ packages/api/src/routes/index.ts | 2 ++ 3 files changed, 49 insertions(+) create mode 100644 packages/api/src/beads/types.ts create mode 100644 packages/api/src/routes/beads.ts diff --git a/packages/api/src/beads/types.ts b/packages/api/src/beads/types.ts new file mode 100644 index 0000000..aab27ba --- /dev/null +++ b/packages/api/src/beads/types.ts @@ -0,0 +1,30 @@ +/** + * SehBeadLike — FrameBeadLike shape for SEH Study scenarios. + * + * Satisfies the FrameBeadLike contract defined in ADR-0016 (core repo). + * Deliberately not imported from @core/workflows to avoid cross-repo coupling. + * + * Prefix: "seh-" + * sourceApp: "seh-study" + * + * Note: study sessions are currently client-side (localStorage). + * This route returns an empty array until server-side session persistence is added. + */ + +export type SehBeadStatus = 'created' | 'live' | 'closed' | 'archived'; + +export interface SehBead { + id: string; + type: 'task'; + status: SehBeadStatus; + sourceApp: 'seh-study'; + created_at: string; + updated_at: string; + payload: { + scenarioId: string; + title: string; + difficulty: string; + missionStatus?: string; + score?: number; + }; +} diff --git a/packages/api/src/routes/beads.ts b/packages/api/src/routes/beads.ts new file mode 100644 index 0000000..e54c59c --- /dev/null +++ b/packages/api/src/routes/beads.ts @@ -0,0 +1,17 @@ +import { Router, type IRouter, type Request, type Response } from 'express'; + +export const beadsRouter: IRouter = Router(); + +/** + * GET /api/beads + * + * FrameBeadLike projection endpoint (ADR-0016). + * Read-only — Mayor/frame-agent aggregation endpoint. + * + * Currently returns empty: study sessions are client-side (localStorage). + * Once server-side session persistence is added, this will return + * scenario runs as beads with seh- prefix. + */ +beadsRouter.get('/', (_req: Request, res: Response) => { + res.json({ beads: [], count: 0 }); +}); diff --git a/packages/api/src/routes/index.ts b/packages/api/src/routes/index.ts index a0f5aca..94f2458 100644 --- a/packages/api/src/routes/index.ts +++ b/packages/api/src/routes/index.ts @@ -1,8 +1,10 @@ import type { Express } from 'express' import healthRouter from './health.js' import toolsRouter from './tools.js' +import { beadsRouter } from './beads.js' export function registerRoutes(app: Express): void { app.use('/health', healthRouter) app.use('/api/tools', toolsRouter) + app.use('/api/beads', beadsRouter) } From 2d6ed9319bce2dcbda1c4091350c159083b6d826 Mon Sep 17 00:00:00 2001 From: ojfbot <151410806+ojfbot@users.noreply.github.com> Date: Fri, 10 Apr 2026 15:25:20 -0500 Subject: [PATCH 3/3] chore: deploy bead hooks, skills, and session coordination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add bead coordination hook symlinks from core (_lib.sh, bead-emit.mjs, bead-session.sh, lint-after-edit.sh, lint-before-edit.sh, log-session.sh, log-tool-use.sh, scan-after-write.sh) - Add skill symlinks: diagram-intake, lint-audit, orchestrate, resume-audit - Add standup extension (.claude/standup.md) for /frame-standup - Update .claude/settings.json with bead-session.sh hook entries - Hooks enable session bead tracking: skill invocations → session beads, git commits → task beads, PRs → PR beads - All hooks gracefully degrade when Dolt is unreachable (silent exit) Deployed by core/scripts/install-agents.sh seh-study --force Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/settings.json | 13 +++++++++++++ .claude/skills/diagram-intake | 1 + .claude/skills/lint-audit | 1 + .claude/skills/orchestrate | 1 + .claude/skills/resume-audit | 1 + .claude/standup.md | 13 +++++++++++++ scripts/hooks/_lib.sh | 1 + scripts/hooks/bead-emit.mjs | 1 + scripts/hooks/bead-session.sh | 1 + scripts/hooks/lint-after-edit.sh | 1 + scripts/hooks/lint-before-edit.sh | 1 + scripts/hooks/log-session.sh | 1 + scripts/hooks/log-tool-use.sh | 1 + scripts/hooks/scan-after-write.sh | 1 + 14 files changed, 38 insertions(+) create mode 120000 .claude/skills/diagram-intake create mode 120000 .claude/skills/lint-audit create mode 120000 .claude/skills/orchestrate create mode 120000 .claude/skills/resume-audit create mode 100644 .claude/standup.md create mode 120000 scripts/hooks/_lib.sh create mode 120000 scripts/hooks/bead-emit.mjs create mode 120000 scripts/hooks/bead-session.sh create mode 120000 scripts/hooks/lint-after-edit.sh create mode 120000 scripts/hooks/lint-before-edit.sh create mode 120000 scripts/hooks/log-session.sh create mode 120000 scripts/hooks/log-tool-use.sh create mode 120000 scripts/hooks/scan-after-write.sh diff --git a/.claude/settings.json b/.claude/settings.json index 31e9308..650c7a9 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -9,6 +9,19 @@ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR/scripts/hooks/log-skill.sh\"", "async": true + }, + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR/scripts/hooks/bead-session.sh\"" + } + ] + }, + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR/scripts/hooks/bead-session.sh\"" } ] } diff --git a/.claude/skills/diagram-intake b/.claude/skills/diagram-intake new file mode 120000 index 0000000..bc4c4f5 --- /dev/null +++ b/.claude/skills/diagram-intake @@ -0,0 +1 @@ +../../../core/.claude/skills/diagram-intake \ No newline at end of file diff --git a/.claude/skills/lint-audit b/.claude/skills/lint-audit new file mode 120000 index 0000000..a86ca82 --- /dev/null +++ b/.claude/skills/lint-audit @@ -0,0 +1 @@ +../../../core/.claude/skills/lint-audit \ No newline at end of file diff --git a/.claude/skills/orchestrate b/.claude/skills/orchestrate new file mode 120000 index 0000000..3bdc1ef --- /dev/null +++ b/.claude/skills/orchestrate @@ -0,0 +1 @@ +../../../core/.claude/skills/orchestrate \ No newline at end of file diff --git a/.claude/skills/resume-audit b/.claude/skills/resume-audit new file mode 120000 index 0000000..226f674 --- /dev/null +++ b/.claude/skills/resume-audit @@ -0,0 +1 @@ +../../../core/.claude/skills/resume-audit \ No newline at end of file diff --git a/.claude/standup.md b/.claude/standup.md new file mode 100644 index 0000000..02660a1 --- /dev/null +++ b/.claude/standup.md @@ -0,0 +1,13 @@ +# Standup Extension — seh-study + +## Current blockers +- (none) + +## This week's priorities +- P1: (update with this week's priorities) + +## Open work +- [ ] (update with current tasks) + +## Context for today +(free-form notes for /frame-standup planning context) diff --git a/scripts/hooks/_lib.sh b/scripts/hooks/_lib.sh new file mode 120000 index 0000000..73698bb --- /dev/null +++ b/scripts/hooks/_lib.sh @@ -0,0 +1 @@ +../../../core/scripts/hooks/_lib.sh \ No newline at end of file diff --git a/scripts/hooks/bead-emit.mjs b/scripts/hooks/bead-emit.mjs new file mode 120000 index 0000000..03bb4b2 --- /dev/null +++ b/scripts/hooks/bead-emit.mjs @@ -0,0 +1 @@ +../../../core/scripts/hooks/bead-emit.mjs \ No newline at end of file diff --git a/scripts/hooks/bead-session.sh b/scripts/hooks/bead-session.sh new file mode 120000 index 0000000..9357423 --- /dev/null +++ b/scripts/hooks/bead-session.sh @@ -0,0 +1 @@ +../../../core/scripts/hooks/bead-session.sh \ No newline at end of file diff --git a/scripts/hooks/lint-after-edit.sh b/scripts/hooks/lint-after-edit.sh new file mode 120000 index 0000000..052a2e1 --- /dev/null +++ b/scripts/hooks/lint-after-edit.sh @@ -0,0 +1 @@ +../../../core/scripts/hooks/lint-after-edit.sh \ No newline at end of file diff --git a/scripts/hooks/lint-before-edit.sh b/scripts/hooks/lint-before-edit.sh new file mode 120000 index 0000000..71a608d --- /dev/null +++ b/scripts/hooks/lint-before-edit.sh @@ -0,0 +1 @@ +../../../core/scripts/hooks/lint-before-edit.sh \ No newline at end of file diff --git a/scripts/hooks/log-session.sh b/scripts/hooks/log-session.sh new file mode 120000 index 0000000..e7985ee --- /dev/null +++ b/scripts/hooks/log-session.sh @@ -0,0 +1 @@ +../../../core/scripts/hooks/log-session.sh \ No newline at end of file diff --git a/scripts/hooks/log-tool-use.sh b/scripts/hooks/log-tool-use.sh new file mode 120000 index 0000000..e2a1e13 --- /dev/null +++ b/scripts/hooks/log-tool-use.sh @@ -0,0 +1 @@ +../../../core/scripts/hooks/log-tool-use.sh \ No newline at end of file diff --git a/scripts/hooks/scan-after-write.sh b/scripts/hooks/scan-after-write.sh new file mode 120000 index 0000000..df9105b --- /dev/null +++ b/scripts/hooks/scan-after-write.sh @@ -0,0 +1 @@ +../../../core/scripts/hooks/scan-after-write.sh \ No newline at end of file