diff --git a/src/app/home/course/course.page.html b/src/app/home/course/course.page.html index 4dd3275..66b2d0a 100644 --- a/src/app/home/course/course.page.html +++ b/src/app/home/course/course.page.html @@ -113,6 +113,7 @@ } + this.videoPlayer?.currentTime() ?? 0, + seek: seconds => this.seekTo(seconds), + }; + constructor() { addIcons({ download, documentAttachOutline, documentTextOutline, checkmarkOutline, closeOutline, pauseCircleOutline }); } @@ -449,6 +458,17 @@ export class CoursePage implements OnInit, AfterViewInit, OnDestroy { this.playLog = []; } + private seekTo(seconds: number) { + if (!this.videoPlayer || !this.currentVideo) { + return; + } + // A jump asked for before the metadata arrives must not be undone by resuming the last position + this.expectVideoTimeJump = false; + this.videoPlayer.currentTime(seconds); + this.videoPlayer.play()?.catch(() => undefined); + this.videoPlayer.el().scrollIntoView({behavior: 'smooth', block: 'nearest'}); + } + async setPlaybackSpeed() { const alert = await this.alertController.create({ header: 'Please enter speed!', diff --git a/src/app/home/course/markdown-math.ts b/src/app/home/course/markdown-math.ts index 205f7e0..4cdc490 100644 --- a/src/app/home/course/markdown-math.ts +++ b/src/app/home/course/markdown-math.ts @@ -48,8 +48,9 @@ export const mathExtension: MarkedExtension = { export const markedWithMath = new Marked(mathExtension); // Replaces the math placeholders under `root` with KaTeX output. Invalid TeX is shown as the raw source. +// Placeholders already rendered are skipped, so it is safe to call again after more content appears. export function renderMath(root: HTMLElement) { - root.querySelectorAll(`.${INLINE_CLASS}, .${DISPLAY_CLASS}`).forEach(el => { + root.querySelectorAll(`.${INLINE_CLASS}:not(.math-rendered), .${DISPLAY_CLASS}:not(.math-rendered)`).forEach(el => { const tex = el.textContent ?? ''; katex.render(tex, el, { displayMode: el.classList.contains(DISPLAY_CLASS), diff --git a/src/app/shared/_checklist.scss b/src/app/shared/_checklist.scss new file mode 100644 index 0000000..9b01ec1 --- /dev/null +++ b/src/app/shared/_checklist.scss @@ -0,0 +1,172 @@ +// The checklist's look, shared by the summary and the summary's Changes view. + +@mixin layout { + display: block; + font-size: 0.95rem; + line-height: 1.55; + + .checklist-title { + margin: 0 0 0.75rem; + font-size: 1.1rem; + font-weight: 700; + text-decoration: underline; + text-underline-offset: 0.2em; + } + + .checklist-section + .checklist-section { + margin-top: 1rem; + } + + .checklist-heading { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem 0.5rem; + margin: 0 0 0.4rem; + font-size: 1.05rem; + font-weight: 700; + } + + .heading-text { + padding: 0.05em 0.35em; + border-radius: 0.25rem; + background: rgba(var(--ion-color-primary-rgb), 0.18); + } + + .time-chip { + display: inline-flex; + align-items: center; + gap: 0.2rem; + padding: 0.1rem 0.55rem; + border: 1px solid rgba(var(--ion-color-primary-rgb), 0.45); + border-radius: 1rem; + background: transparent; + color: var(--ion-color-primary); + font: inherit; + font-size: 0.75rem; + font-weight: 600; + font-variant-numeric: tabular-nums; + } + + button.time-chip { + cursor: pointer; + + &:hover { + background: rgba(var(--ion-color-primary-rgb), 0.1); + } + + &:focus-visible { + outline: 2px solid var(--ion-color-primary); + outline-offset: 2px; + } + } + + .checklist-point { + display: grid; + grid-template-columns: 2.4em minmax(0, 1fr); + column-gap: 0.4rem; + padding: 0.12rem 0; + } + + &.with-ticks .checklist-point { + grid-template-columns: 1.1rem 2.4em minmax(0, 1fr); + } + + /* Drawn from the theme: a native box follows the system's light or dark scheme, not the theme. */ + .point-tick { + appearance: none; + display: grid; + place-content: center; + width: 1rem; + height: 1rem; + margin: 0.22em 0 0; + border: 1.5px solid rgba(var(--ion-text-color-rgb, 0, 0, 0), 0.45); + border-radius: 0.25rem; + background: transparent; + cursor: pointer; + + &::after { + content: ''; + width: 0.28rem; + height: 0.55rem; + margin-top: -0.12rem; + border: solid var(--ion-color-primary-contrast); + border-width: 0 2px 2px 0; + transform: rotate(45deg) scale(0); + } + + &:checked { + border-color: var(--ion-color-primary); + background: var(--ion-color-primary); + } + + &:checked::after { + transform: rotate(45deg) scale(1); + } + + &:focus-visible { + outline: 2px solid var(--ion-color-primary); + outline-offset: 2px; + } + } + + .point-number { + text-align: right; + font-variant-numeric: tabular-nums; + } + + .is-ticked .point-number, + .is-ticked .point-body { + opacity: 0.55; + } + + .point-body { + strong { + color: var(--ion-color-primary); + } + + ol, + ul { + margin: 0.1rem 0; + padding-left: 1.6em; + list-style-type: lower-alpha; + } + + :is(ol, ul) :is(ol, ul) { + list-style-type: lower-roman; + } + + :is(ol, ul) :is(ol, ul) :is(ol, ul) { + list-style-type: decimal; + } + } + + /* Lines up with the text of the points around it. */ + .checklist-label { + margin: 0.5rem 0 0.2rem calc(2.4em + 0.4rem); + font-weight: 700; + text-decoration: underline; + text-underline-offset: 0.2em; + } + + &.with-ticks .checklist-label { + margin-left: calc(1.1rem + 2.4em + 0.8rem); + } + + .checklist-block { + margin: 0.4rem 0; + } + + /* A diagram drawn in text: every line and space kept, scrolled sideways rather than wrapped. */ + pre { + margin: 0.4rem 0; + padding: 0.6em 0.8em; + border-radius: 0.4rem; + background: rgba(var(--ion-text-color-rgb, 0, 0, 0), 0.05); + font-family: Menlo, Consolas, 'DejaVu Sans Mono', monospace; + font-size: 0.85em; + line-height: 1.2; + white-space: pre; + overflow-x: auto; + } +} diff --git a/src/app/shared/_study-card.scss b/src/app/shared/_study-card.scss new file mode 100644 index 0000000..9b8ac3d --- /dev/null +++ b/src/app/shared/_study-card.scss @@ -0,0 +1,107 @@ +// The look the study aids card shares with the Pomodoro card above it. +@use 'study-shared'; + +:host { + // A block, so the settings sheet can tell when it hides the card (see study-aids/displayed.ts). + display: block; + width: 100%; +} + +.study-card { + margin: 0.75rem 0 0 0; + border-radius: 0.75rem; + overflow: hidden; + box-shadow: var(--flick-welcome-card-shadow, 0 4px 16px rgba(0, 0, 0, 0.08)); +} + +.card-head { + display: flex; + align-items: center; +} + +.card-toggle { + display: flex; + flex: 1; + align-items: center; + gap: 0.5rem; + min-width: 0; + padding: 0.75rem 0.5rem 0.75rem 1rem; + border: 0; + background: transparent; + color: var(--ion-text-color); + font: inherit; + text-align: left; + cursor: pointer; + + &:focus-visible { + outline: 2px solid var(--ion-color-primary); + outline-offset: -2px; + } +} + +.card-heading { + display: flex; + flex: 1; + flex-wrap: wrap; + align-items: center; + gap: 0.25rem 0.5rem; + min-width: 0; +} + +.card-title { + font-size: 1rem; + font-weight: 600; +} + +.card-badge { + padding: 0.1rem 0.5rem; + border-radius: 1rem; + background: rgba(var(--ion-color-warning-rgb), 0.25); + font-size: 0.7rem; + font-weight: 600; + white-space: nowrap; +} + +.card-meta { + color: var(--ion-color-medium); + font-size: 0.8rem; + white-space: nowrap; +} + +.collapse-icon { + flex: none; + color: var(--ion-color-medium); + font-size: 1.2rem; +} + +// Opens the reading window; outside the toggle, since a button cannot hold another. +.card-expand { + display: flex; + align-items: center; + justify-content: center; + flex: none; + width: 2.25rem; + height: 2.25rem; + margin-right: 0.5rem; + padding: 0; + border: 0; + border-radius: 50%; + background: transparent; + color: var(--ion-color-medium); + font-size: 1.15rem; + cursor: pointer; + + &:hover { + background: rgba(var(--ion-color-primary-rgb), 0.1); + color: var(--ion-color-primary); + } + + &:focus-visible { + outline: 2px solid var(--ion-color-primary); + } +} + +.card-body { + padding: 0 1rem 1rem; + color: var(--ion-text-color); +} diff --git a/src/app/shared/_study-shared.scss b/src/app/shared/_study-shared.scss new file mode 100644 index 0000000..26340c7 --- /dev/null +++ b/src/app/shared/_study-shared.scss @@ -0,0 +1,47 @@ +// What the summary and quiz show the same way on the card and in the reading window. + +.byline { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.25rem 0.75rem; + margin-bottom: 0.75rem; + color: var(--ion-color-medium); + font-size: 0.8rem; + line-height: 1.4; +} + +.card-actions { + display: inline-flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.card-note { + margin: 0 0 0.5rem; + color: var(--ion-color-medium); + font-size: 0.85rem; + line-height: 1.45; +} + +.card-loading { + display: flex; + justify-content: center; + padding: 0.5rem 0; +} + +.small-button { + margin: 0; + --padding-start: 0.6rem; + --padding-end: 0.6rem; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} diff --git a/src/app/shared/checklist-view.component.html b/src/app/shared/checklist-view.component.html new file mode 100644 index 0000000..fff1c02 --- /dev/null +++ b/src/app/shared/checklist-view.component.html @@ -0,0 +1,41 @@ +@if (checklist.title) { +

{{ checklist.title }}

+} +@for (section of checklist.sections; track $index) { +
+ @if (section.headingHtml || section.time !== null) { +

+ @if (section.headingHtml) { + + } + @if (section.time !== null) { + @if (seekable) { + + } @else { + {{ formatClock(section.time) }} + } + } +

+ } + @for (block of section.blocks; track block.key) { + @if (block.kind === 'point') { +
+ @if (ticked) { + + } + {{ block.number }}. + +
+ } @else if (block.kind === 'label') { + + } @else { + + } + } +
+} diff --git a/src/app/shared/checklist-view.component.scss b/src/app/shared/checklist-view.component.scss new file mode 100644 index 0000000..9da9c33 --- /dev/null +++ b/src/app/shared/checklist-view.component.scss @@ -0,0 +1,5 @@ +@use 'checklist'; + +app-checklist-view { + @include checklist.layout; +} diff --git a/src/app/shared/checklist-view.component.ts b/src/app/shared/checklist-view.component.ts new file mode 100644 index 0000000..f9625b8 --- /dev/null +++ b/src/app/shared/checklist-view.component.ts @@ -0,0 +1,41 @@ +import {Component, EventEmitter, Input, Output, ViewEncapsulation} from '@angular/core'; +import {IonIcon} from '@ionic/angular'; +import {addIcons} from 'ionicons'; +import {play} from 'ionicons/icons'; +import {RichTextComponent} from './rich-text.component'; +import {Checklist, formatClock} from '../study-aids/checklist'; + +export interface TickChange { + key: string; + ticked: boolean; +} + +/** A summary laid out as a checklist: highlighted sections, points numbered straight through. */ +@Component({ + selector: 'app-checklist-view', + templateUrl: './checklist-view.component.html', + styleUrls: ['./checklist-view.component.scss'], + // The points arrive as HTML, which scoped styles would never reach + encapsulation: ViewEncapsulation.None, + imports: [IonIcon, RichTextComponent], + host: {'[class.with-ticks]': '!!ticked'}, +}) +export class ChecklistViewComponent { + @Input() checklist: Checklist; + /** The points this reader has ticked, or null to show the summary without boxes. */ + @Input() ticked: ReadonlySet | null = null; + /** Whether a section's time jumps the video there, rather than only being shown. */ + @Input() seekable = false; + @Output() tickChange = new EventEmitter(); + @Output() seek = new EventEmitter(); + + protected readonly formatClock = formatClock; + + constructor() { + addIcons({play}); + } + + onTick(key: string, event: Event): void { + this.tickChange.emit({key, ticked: (event.target as HTMLInputElement).checked}); + } +} diff --git a/src/app/shared/lecture-study-aids.component.html b/src/app/shared/lecture-study-aids.component.html new file mode 100644 index 0000000..99a6675 --- /dev/null +++ b/src/app/shared/lecture-study-aids.component.html @@ -0,0 +1,61 @@ +@if (view$ | async; as view) { + @if (shows(view)) { + @let tab = activeTab(view); + +
+ + @if (view.aids) { + + } +
+ + @if (!collapsed) { +
+ @if (view.status === 'loading') { +
+ +
+ } @else if (view.status === 'error') { +

The summary and quiz could not be loaded.

+ Try again + } @else if (view.aids) { + @if (view.tabs.length > 1) { + + @for (name of view.tabs; track name) { + + {{ tabLabels[name] }} + + } + + } + @switch (tab) { + @case ('summary') { + + } + @case ('transcript') { + + } + @case ('quiz') { + + } + } + } +
+ } +
+ } +} diff --git a/src/app/shared/lecture-study-aids.component.scss b/src/app/shared/lecture-study-aids.component.scss new file mode 100644 index 0000000..a9a7654 --- /dev/null +++ b/src/app/shared/lecture-study-aids.component.scss @@ -0,0 +1,5 @@ +@use 'study-card'; + +.study-tabs { + margin-bottom: 0.85rem; +} diff --git a/src/app/shared/lecture-study-aids.component.spec.ts b/src/app/shared/lecture-study-aids.component.spec.ts new file mode 100644 index 0000000..eb54839 --- /dev/null +++ b/src/app/shared/lecture-study-aids.component.spec.ts @@ -0,0 +1,330 @@ +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {By} from '@angular/platform-browser'; +import {ActivatedRoute, convertToParamMap} from '@angular/router'; +import {AlertController, ModalController} from '@ionic/angular'; +import {BehaviorSubject, of} from 'rxjs'; +import {LectureStudyAidsComponent} from './lecture-study-aids.component'; +import {QuizBodyComponent} from './quiz-body.component'; +import {StudyAidsService} from '../study-aids.service'; +import {Lecture} from '../man.service'; +import {QuizQuestion, StudyAids, StudyAidsState, TranscriptState} from '../study-aids/study-aids.model'; + +const VIDEO = {id: 7, title: 'Organelles', duration: 3000} as Lecture; + +const MARKDOWN = '# Organelles\n\n## Nucleus [00:01:10]\n\n1. **Barr body** : X − 1\n2. **Nucleolus** : rRNA\n'; + +function question(id: string, answer: number, mine: number | null = null): QuizQuestion { + return { + id, + prompt: 'Question ' + id, + choices: ['Plasma cell', 'Pancreatic acinar cell', 'Leydig cell', 'Neutrophil', 'Mature red blood cell'], + answer, + explanation: 'Because of **steroids**.', + time: 465, + stats: {answered: 12, correct: 10}, + mine, + }; +} + +function aids(changes: Partial = {}): StudyAids { + return { + video_id: 7, + generation: 'ready', + has_transcript: false, + my_ticks: null, + summary: {markdown: MARKDOWN}, + quiz: null, + ...changes, + }; +} + +function quiz(questions: QuizQuestion[]): StudyAids['quiz'] { + return {questions}; +} + +describe('LectureStudyAidsComponent', () => { + let fixture: ComponentFixture; + let service: jasmine.SpyObj>; + let seek: jasmine.Spy; + let query: Record; + /** How the reader answers the question before an unfinished quiz is cleared. */ + let redoChoice: string; + + const element = () => fixture.nativeElement as HTMLElement; + const text = () => element().textContent?.replace(/\s+/g, ' ') ?? ''; + const buttons = (selector: string) => Array.from(element().querySelectorAll(selector)); + const clickButton = (label: string) => { + const button = buttons('ion-button, button').find(candidate => candidate.textContent?.trim() === label); + expect(button).withContext(label).toBeTruthy(); + button?.click(); + fixture.detectChanges(); + }; + const tabs = () => buttons('ion-segment-button').map(button => button.textContent?.trim()); + const openTab = (tab: string) => { + const segment = element().querySelector('ion-segment'); + segment?.dispatchEvent(new CustomEvent('ionChange', {detail: {value: tab}})); + fixture.detectChanges(); + }; + + function create(state: StudyAidsState): void { + service.watch.and.returnValue(new BehaviorSubject(state)); + fixture = TestBed.createComponent(LectureStudyAidsComponent); + fixture.componentRef.setInput('video', VIDEO); + fixture.componentRef.setInput('playback', {currentTime: () => 0, seek}); + fixture.detectChanges(); + } + + beforeEach(() => { + for (const key of ['flickSummaryTicks:7', 'flickQuizAnswers:7', 'flickStudyAidsCollapsed', 'flickStudyAidsTab', 'flickSummaryTicksUnsynced']) { + localStorage.removeItem(key); + } + service = jasmine.createSpyObj('StudyAidsService', ['watch', 'refresh', 'answer', 'transcript', 'retryTranscript', 'saveTicks']); + service.saveTicks.and.callFake((videoId, ticks) => of(ticks)); + service.answer.and.returnValue(of({answered: 13, correct: 11})); + service.transcript.and.returnValue(of({status: 'ready', paragraphs: [ + {time: 5, text: 'สวัสดีครับ วันนี้เราจะคุยเรื่อง organelles'}, + {time: 70, text: 'The smooth ER stores Ca²⁺.'}, + ]})); + seek = jasmine.createSpy('seek'); + query = {}; + redoChoice = 'confirm'; + TestBed.configureTestingModule({ + imports: [LectureStudyAidsComponent], + providers: [ + {provide: StudyAidsService, useValue: service}, + {provide: ActivatedRoute, useValue: {snapshot: {get queryParamMap() { + return convertToParamMap(query); + }}}}, + {provide: ModalController, useValue: jasmine.createSpyObj('ModalController', ['create'])}, + {provide: AlertController, useValue: {create: () => Promise.resolve({ + present: () => Promise.resolve(), + onDidDismiss: () => Promise.resolve({role: redoChoice}), + })}}, + ], + }); + }); + + afterEach(() => { + for (const key of ['flickSummaryTicks:7', 'flickQuizAnswers:7', 'flickStudyAidsCollapsed', 'flickStudyAidsTab', 'flickSummaryTicksUnsynced']) { + localStorage.removeItem(key); + } + }); + + describe('summary', () => { + it('shows the AI summary as a numbered checklist', () => { + create({status: 'ready', aids: aids()}); + + expect(text()).toContain('Summary & Transcription'); + expect(element().querySelector('.card-badge')?.textContent).toBe('AI-generated'); + expect(text()).toContain('Written by AI from the lecture recording.'); + expect(Array.from(element().querySelectorAll('.point-number')).map(number => number.textContent)).toEqual(['1.', '2.']); + expect(element().querySelector('ion-segment')).toBeNull(); + }); + + it('jumps the lecture to a section', () => { + create({status: 'ready', aids: aids()}); + element().querySelector('button.time-chip')?.click(); + + expect(seek).toHaveBeenCalledWith(70); + }); + + it('remembers ticked points on this device', () => { + create({status: 'ready', aids: aids()}); + const box = element().querySelector('.point-tick'); + box!.checked = true; + box!.dispatchEvent(new Event('change')); + fixture.detectChanges(); + + expect(text()).toContain('1 of 2 points ticked.'); + expect(text()).toContain('1/2 ticked'); + expect(JSON.parse(localStorage.getItem('flickSummaryTicks:7') ?? '[]').length).toBe(1); + }); + + it('shows nothing when there is nothing for a reader', () => { + create({status: 'ready', aids: aids({summary: null})}); + + expect(element().querySelector('ion-card')).toBeNull(); + }); + + it('shows nothing when the server has no study aids', () => { + create({status: 'unavailable'}); + + expect(element().querySelector('ion-card')).toBeNull(); + }); + + it('tells readers a summary is on its way', () => { + create({status: 'ready', aids: aids({summary: null, generation: 'pending'})}); + + expect(text()).toContain('The AI is still writing a summary of this lecture.'); + }); + }); + + describe('tabs', () => { + it('offers the transcription when there is one, and remembers the tab chosen', () => { + create({status: 'ready', aids: aids({has_transcript: true, quiz: quiz([question('a', 2)])})}); + + expect(tabs()).toEqual(['Summary', 'Transcription', 'Quiz']); + openTab('transcript'); + + expect(service.transcript).toHaveBeenCalledWith(7); + expect(text()).toContain('The smooth ER stores Ca²⁺.'); + expect(localStorage.getItem('flickStudyAidsTab')).toBe('"transcript"'); + buttons('.transcript-paragraph button.time-chip')[1].click(); + expect(seek).toHaveBeenCalledWith(70); + }); + + it('opens on the first tab with something in it', () => { + localStorage.setItem('flickStudyAidsTab', '"quiz"'); + create({status: 'ready', aids: aids({has_transcript: true})}); + + expect(tabs()).toEqual(['Summary', 'Transcription']); + expect(element().querySelector('app-summary-body')).not.toBeNull(); + }); + + it('shows the transcription alone when there is nothing else yet', () => { + create({status: 'ready', aids: aids({summary: null, has_transcript: true})}); + + expect(element().querySelector('ion-segment')).toBeNull(); + expect(element().querySelector('app-transcript-body')).not.toBeNull(); + }); + }); + + describe('quiz', () => { + const createQuiz = (questions: QuizQuestion[]) => { + localStorage.setItem('flickStudyAidsTab', '"quiz"'); + create({status: 'ready', aids: aids({summary: null, quiz: quiz(questions)})}); + }; + + it('offers to start, then shows the first question', () => { + createQuiz([question('a', 2), question('b', 0)]); + + expect(text()).toContain('2 single-best-answer questions'); + expect(text()).toContain('Written by AI from the lecture recording.'); + clickButton('Start quiz'); + + expect(text()).toContain('Question 1 of 2'); + expect(buttons('.quiz-choice').length).toBe(5); + }); + + it('marks the answer, explains it and records it once', () => { + createQuiz([question('a', 2), question('b', 0)]); + clickButton('Start quiz'); + buttons('.quiz-choice')[1].click(); + fixture.detectChanges(); + + expect(buttons('.quiz-choice.is-wrong').length).toBe(1); + expect(buttons('.quiz-choice.is-correct').length).toBe(1); + expect(text()).toContain('Not quite. The answer is C.'); + expect(text()).toContain('83% of 12 students got this right.'); + expect(service.answer).toHaveBeenCalledOnceWith(7, 'a', 1); + + buttons('.quiz-choice')[2].click(); + fixture.detectChanges(); + expect(service.answer).toHaveBeenCalledTimes(1); + }); + + it('jumps the lecture to where the question is taught', () => { + createQuiz([question('a', 2)]); + clickButton('Start quiz'); + buttons('.quiz-choice')[0].click(); + fixture.detectChanges(); + clickButton('Watch this part (7:45)'); + + expect(seek).toHaveBeenCalledWith(465); + }); + + it('scores the quiz and lets the reader start again', async () => { + createQuiz([question('a', 2), question('b', 0)]); + clickButton('Start quiz'); + buttons('.quiz-choice')[2].click(); + fixture.detectChanges(); + clickButton('Next question'); + buttons('.quiz-choice')[3].click(); + fixture.detectChanges(); + clickButton('See results'); + + expect(text()).toContain('1 of 2 correct'); + expect(text()).toContain('1/2 correct'); + clickButton('Redo quiz'); + await fixture.whenStable(); + fixture.detectChanges(); + expect(text()).toContain('Question 1 of 2'); + expect(buttons('.quiz-choice.is-correct').length).toBe(0); + }); + + it('lets the reader redo an unfinished quiz once they confirm', async () => { + localStorage.setItem('flickQuizAnswers:7', JSON.stringify({a: 2})); + createQuiz([question('a', 2), question('b', 0)]); + redoChoice = 'cancel'; + clickButton('Redo quiz'); + await fixture.whenStable(); + fixture.detectChanges(); + expect(text()).toContain('You have answered 1 so far.'); + + redoChoice = 'confirm'; + clickButton('Redo quiz'); + await fixture.whenStable(); + fixture.detectChanges(); + expect(text()).toContain('Question 1 of 2'); + expect(buttons('.quiz-choice.is-correct').length).toBe(0); + expect(JSON.parse(localStorage.getItem('flickQuizAnswers:7') ?? 'null')).toEqual({}); + }); + + it('offers to redo the quiz from a question once one is answered', async () => { + createQuiz([question('a', 2), question('b', 0)]); + clickButton('Start quiz'); + expect(text()).not.toContain('Redo quiz'); + + buttons('.quiz-choice')[2].click(); + fixture.detectChanges(); + clickButton('Next question'); + clickButton('Redo quiz'); + await fixture.whenStable(); + fixture.detectChanges(); + expect(text()).toContain('Question 1 of 2'); + expect(buttons('.quiz-choice.is-correct').length).toBe(0); + }); + + it('starts from the answers given on another device', () => { + createQuiz([question('a', 2, 2), question('b', 0, 1)]); + + expect(text()).toContain('1 of 2 correct'); + expect(service.answer).not.toHaveBeenCalled(); + }); + + it('carries on where this device left off', () => { + localStorage.setItem('flickQuizAnswers:7', JSON.stringify({a: 2})); + createQuiz([question('a', 2), question('b', 0)]); + + expect(text()).toContain('You have answered 1 so far.'); + clickButton('Continue'); + expect(text()).toContain('Question 2 of 2'); + }); + + it('builds a link that opens this lecture\'s quiz', () => { + createQuiz([question('a', 2)]); + const body = fixture.debugElement.query(By.directive(QuizBodyComponent)).componentInstance as QuizBodyComponent; + const link = new URL(body.shareLink()); + + expect(link.searchParams.get('video')).toBe('7'); + expect(link.searchParams.get('quiz')).toBe('1'); + }); + + it('opens on the quiz when the page was reached through its link', () => { + localStorage.setItem('flickStudyAidsCollapsed', 'true'); + query = {video: '7', quiz: '1'}; + create({status: 'ready', aids: aids({quiz: quiz([question('a', 2)])})}); + + expect(text()).toContain('Start quiz'); + expect(localStorage.getItem('flickStudyAidsCollapsed')).toBe('true'); + }); + + it('stays folded away for a link to another lecture', () => { + localStorage.setItem('flickStudyAidsCollapsed', 'true'); + query = {video: '8', quiz: '1'}; + create({status: 'ready', aids: aids({quiz: quiz([question('a', 2)])})}); + + expect(text()).not.toContain('Start quiz'); + }); + }); +}); diff --git a/src/app/shared/lecture-study-aids.component.ts b/src/app/shared/lecture-study-aids.component.ts new file mode 100644 index 0000000..2f5a384 --- /dev/null +++ b/src/app/shared/lecture-study-aids.component.ts @@ -0,0 +1,173 @@ +import {afterNextRender, Component, ElementRef, inject, Injector, Input, NgZone} from '@angular/core'; +import {ActivatedRoute} from '@angular/router'; +import {AsyncPipe} from '@angular/common'; +import {IonButton, IonCard, IonIcon, IonLabel, IonSegment, IonSegmentButton, IonSpinner, ModalController} from '@ionic/angular'; +import {addIcons} from 'ionicons'; +import {chevronDown, chevronUp, expandOutline} from 'ionicons/icons'; +import {BehaviorSubject, combineLatest, Observable, of} from 'rxjs'; +import {map, switchMap} from 'rxjs/operators'; +import {Lecture} from '../man.service'; +import {StudyAidsService} from '../study-aids.service'; +import {StudyAidsProgressService} from '../study-aids-progress.service'; +import {SummaryBodyComponent} from './summary-body.component'; +import {TranscriptBodyComponent} from './transcript-body.component'; +import {QUIZ_LINK_PARAM, QuizBodyComponent} from './quiz-body.component'; +import {openReader} from './study-aids-reader.component'; +import {checklistFor} from '../study-aids/checklist'; +import {displayed} from '../study-aids/displayed'; +import {readCollapsed, readTab, writeCollapsed, writeTab} from '../study-aids/local-store'; +import {effectiveAnswers, score} from '../study-aids/quiz'; +import {isStudyTab, StudyTab, studyTabs, TAB_LABELS} from '../study-aids/tabs'; +import {LecturePlayback, StudyAids, StudyAidsState} from '../study-aids/study-aids.model'; + +interface CardView { + video: Lecture; + status: StudyAidsState['status']; + aids: StudyAids | null; + tabs: StudyTab[]; + points: number; + reviewed: number; + questions: number; + answered: number; + correct: number; +} + +/** + * The lecture's summary, transcription and quiz, in tabs on one card under the video. A shared quiz + * link opens the card on its quiz and brings it into view. + */ +@Component({ + selector: 'app-lecture-study-aids', + templateUrl: './lecture-study-aids.component.html', + styleUrls: ['./lecture-study-aids.component.scss'], + imports: [ + AsyncPipe, + IonCard, + IonButton, + IonIcon, + IonLabel, + IonSegment, + IonSegmentButton, + IonSpinner, + SummaryBodyComponent, + TranscriptBodyComponent, + QuizBodyComponent, + ], +}) +export class LectureStudyAidsComponent { + private service = inject(StudyAidsService); + private progress = inject(StudyAidsProgressService); + private modalCtrl = inject(ModalController); + private host = inject>(ElementRef); + private injector = inject(Injector); + + private readonly video$ = new BehaviorSubject(null); + + @Input() set video(video: Lecture | null) { + this.video$.next(video); + } + + @Input() playback: LecturePlayback | null = null; + + protected collapsed = readCollapsed(); + protected tab: StudyTab = readTab(); + protected readonly tabLabels = TAB_LABELS; + + /** The lecture a shared quiz link points at, until its quiz has been opened. */ + private linkedVideo: string | null = null; + + protected readonly view$: Observable = combineLatest([ + this.video$, + displayed(this.host.nativeElement, inject(NgZone)), + ]).pipe( + switchMap(([video, shown]) => video?.id && shown + ? combineLatest([this.service.watch(video.id), this.progress.ticks$(video.id), this.progress.answers$(video.id)]) + .pipe(map(([state, ticked, answers]) => this.toView(video, state, ticked, answers))) + : of(null)), + ); + + constructor() { + addIcons({chevronDown, chevronUp, expandOutline}); + const query = inject(ActivatedRoute, {optional: true})?.snapshot.queryParamMap; + if (query?.has(QUIZ_LINK_PARAM)) { + this.linkedVideo = query.get('video'); + } + } + + shows(view: CardView): boolean { + return view.status === 'ready' ? view.tabs.length > 0 : view.status !== 'unavailable'; + } + + /** The chosen tab, or the first with something in it when the chosen one is empty for this lecture. */ + activeTab(view: CardView): StudyTab { + return view.tabs.includes(this.tab) ? this.tab : view.tabs[0] ?? 'summary'; + } + + onTab(event: Event): void { + const value = (event as CustomEvent<{value: unknown}>).detail.value; + if (isStudyTab(value)) { + this.tab = value; + writeTab(value); + } + } + + /** How far the reader has got with the tab on show. */ + progressOf(view: CardView): string | null { + switch (this.activeTab(view)) { + case 'summary': + return view.points ? `${view.reviewed}/${view.points} ticked` : null; + case 'quiz': + if (!view.questions) { + return null; + } + + return view.answered === view.questions + ? `${view.correct}/${view.questions} correct` + : `${view.questions} ${view.questions === 1 ? 'question' : 'questions'}`; + default: + return null; + } + } + + toggle(): void { + this.collapsed = !this.collapsed; + writeCollapsed(this.collapsed); + } + + read(view: CardView): void { + void openReader(this.modalCtrl, view.video, this.playback, this.activeTab(view)); + } + + seek(seconds: number): void { + this.playback?.seek(seconds); + } + + retry(view: CardView): void { + this.service.refresh(view.video.id as number); + } + + private toView(video: Lecture, state: StudyAidsState, ticked: ReadonlySet, stored: Record | null): CardView { + const aids = state.status === 'ready' ? state.aids : null; + const pointKeys = aids?.summary ? checklistFor(aids.summary.markdown).pointKeys : []; + const questions = aids?.quiz?.questions ?? []; + const answers = effectiveAnswers(stored, questions); + if (this.linkedVideo === String(video.id) && questions.length) { + this.linkedVideo = null; + this.collapsed = false; + this.tab = 'quiz'; + afterNextRender(() => this.host.nativeElement.scrollIntoView({behavior: 'smooth', block: 'start'}), {injector: this.injector}); + } + + return { + video, + status: state.status, + aids, + tabs: aids ? studyTabs(aids) : [], + points: pointKeys.length, + reviewed: pointKeys.filter(key => ticked.has(key)).length, + questions: questions.length, + answered: questions.filter(question => question.id in answers).length, + correct: score(questions, answers), + }; + } +} diff --git a/src/app/shared/quiz-body.component.html b/src/app/shared/quiz-body.component.html new file mode 100644 index 0000000..6175dfb --- /dev/null +++ b/src/app/shared/quiz-body.component.html @@ -0,0 +1,134 @@ +@if (aids.quiz && items.length) { + + @if (shareNote) { + + } + + @switch (phase()) { + @case ('start') { +
+

+ {{ items.length }} single-best-answer {{ items.length === 1 ? 'question' : 'questions' }} + on this lecture. + @if (answeredCount(); as answered) { + You have answered {{ answered }} so far. + } +

+ + {{ answeredCount() ? 'Continue' : 'Start quiz' }} + + @if (answeredCount()) { + + + Redo quiz + + } +
+ } + @case ('question') { + @let item = items[current ?? 0]; +
+ Question {{ (current ?? 0) + 1 }} of {{ items.length }} + + @if (answeredCount()) { + + + Redo quiz + + } +
+ + + +
+ @for (choice of item.choicesHtml; track $index; let i = $index) { + @let state = choiceState(item, i); + + } +
+ + @if (isAnswered(item)) { +
+ + @if (statsLine(item.question); as line) { + + } + @if (item.explanationHtml) { + + } + @if (item.question.time !== null && playback) { + + + Watch this part ({{ formatClock(item.question.time) }}) + + } +
+
+ @if (answeredCount() === items.length) { + All results + } + + {{ nextIndex() === null ? 'See results' : 'Next question' }} + +
+ } + } + @case ('results') { +

+ {{ scoreOf() }} of {{ items.length }} correct +

+
    + @for (item of items; track item.question.id; let i = $index) { +
  1. + +
  2. + } +
+ + + Redo quiz + + } + } +} @else if (aids.generation === 'pending') { +

The AI is still writing questions on this lecture. Check back later.

+} @else { +

There are no questions on this lecture.

+} +

{{ announcement }}

diff --git a/src/app/shared/quiz-body.component.scss b/src/app/shared/quiz-body.component.scss new file mode 100644 index 0000000..e607df7 --- /dev/null +++ b/src/app/shared/quiz-body.component.scss @@ -0,0 +1,276 @@ +@use 'study-shared'; + +:host { + display: block; +} + +.share-note { + margin: -0.4rem 0 0.75rem; + color: var(--ion-color-medium); + font-size: 0.8rem; + overflow-wrap: anywhere; +} + +.quiz-start ion-button { + margin: 0.25rem 0 0; + + & + ion-button { + margin-left: 0.5rem; + } +} + +.quiz-progress { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.35rem 0.75rem; + margin-bottom: 0.6rem; + color: var(--ion-color-medium); + font-size: 0.8rem; +} + +.progress-dots { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; + // Keeps the dots and the Redo button together at the end of the row. + margin-left: auto; +} + +.dot { + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background: rgba(var(--ion-text-color-rgb, 0, 0, 0), 0.18); +} + +.dot-right { + background: var(--ion-color-success); +} + +.dot-wrong { + background: var(--ion-color-danger); +} + +.dot-current { + outline: 2px solid var(--ion-color-primary); + outline-offset: 1px; +} + +.quiz-prompt { + font-size: 1rem; + font-weight: 600; + line-height: 1.5; +} + +.quiz-choices { + display: grid; + gap: 0.5rem; + margin: 0.75rem 0; +} + +.quiz-choice { + display: grid; + grid-template-columns: 1.9rem minmax(0, 1fr) auto; + align-items: center; + gap: 0.6rem; + width: 100%; + padding: 0.55rem 0.75rem; + border: 1px solid rgba(var(--ion-text-color-rgb, 0, 0, 0), 0.16); + border-radius: 0.6rem; + background: transparent; + color: var(--ion-text-color); + font: inherit; + line-height: 1.4; + text-align: left; + cursor: pointer; + transition: background-color 0.15s ease, border-color 0.15s ease, opacity 0.15s ease; + + &:hover:not(:disabled) { + border-color: var(--ion-color-primary); + background: rgba(var(--ion-color-primary-rgb), 0.06); + } + + &:focus-visible { + outline: 2px solid var(--ion-color-primary); + outline-offset: 2px; + } + + &:disabled { + cursor: default; + } + + &.is-correct { + border-color: var(--ion-color-success); + background: rgba(var(--ion-color-success-rgb), 0.14); + } + + &.is-wrong { + border-color: var(--ion-color-danger); + background: rgba(var(--ion-color-danger-rgb), 0.12); + } + + &.is-muted { + opacity: 0.6; + } +} + +.choice-letter { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.9rem; + height: 1.9rem; + border-radius: 50%; + background: rgba(var(--ion-color-primary-rgb), 0.12); + color: var(--ion-color-primary); + font-size: 0.85rem; + font-weight: 700; +} + +.is-correct .choice-letter { + background: var(--ion-color-success); + color: var(--ion-color-success-contrast); +} + +.is-wrong .choice-letter { + background: var(--ion-color-danger); + color: var(--ion-color-danger-contrast); +} + +.choice-mark { + font-size: 1.3rem; +} + +.is-correct .choice-mark { + color: var(--ion-color-success); +} + +.is-wrong .choice-mark { + color: var(--ion-color-danger); +} + +.quiz-feedback { + padding: 0.75rem 0.85rem; + border-left: 3px solid var(--ion-color-danger); + border-radius: 0.5rem; + background: rgba(var(--ion-text-color-rgb, 0, 0, 0), 0.04); + font-size: 0.9rem; + line-height: 1.5; + + &.is-right { + border-left-color: var(--ion-color-success); + } +} + +.feedback-verdict { + display: flex; + align-items: center; + gap: 0.35rem; + margin: 0 0 0.35rem; + font-weight: 700; + + ion-icon { + flex: none; + color: var(--ion-color-danger); + font-size: 1.15rem; + } +} + +.is-right .feedback-verdict ion-icon { + color: var(--ion-color-success); +} + +.feedback-stats { + margin: 0 0 0.5rem; + color: var(--ion-color-medium); + font-size: 0.8rem; +} + +.watch-button { + margin-top: 0.4rem; + --padding-start: 0; +} + +.quiz-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + margin-top: 0.75rem; + + ion-button { + margin: 0; + } +} + +.results-score { + margin: 0 0 0.5rem; + font-size: 1.05rem; +} + +.results-list { + margin: 0 0 0.75rem; + padding: 0; + list-style: none; +} + +.results-row { + display: grid; + grid-template-columns: 1.3rem 1.8em minmax(0, 1fr); + align-items: start; + gap: 0.4rem; + width: 100%; + padding: 0.4rem 0.3rem; + border: 0; + border-radius: 0.4rem; + background: transparent; + color: var(--ion-text-color); + font: inherit; + font-size: 0.9rem; + text-align: left; + cursor: pointer; + + &:hover { + background: rgba(var(--ion-color-primary-rgb), 0.06); + } + + &:focus-visible { + outline: 2px solid var(--ion-color-primary); + outline-offset: -2px; + } + + ion-icon { + margin-top: 0.15em; + font-size: 1.1rem; + } +} + +.mark-right { + color: var(--ion-color-success); +} + +.mark-wrong { + color: var(--ion-color-danger); +} + +.results-number { + color: var(--ion-color-medium); + font-variant-numeric: tabular-nums; +} + +.results-prompt { + overflow-wrap: anywhere; +} + +// The reading window has room for a larger question and choices. +:host(.reader) { + .quiz-prompt { + font-size: 1.15rem; + } + + .quiz-choice { + padding: 0.75rem 1rem; + font-size: 1.05rem; + } +} diff --git a/src/app/shared/quiz-body.component.ts b/src/app/shared/quiz-body.component.ts new file mode 100644 index 0000000..d2b4f74 --- /dev/null +++ b/src/app/shared/quiz-body.component.ts @@ -0,0 +1,259 @@ +import {Component, DestroyRef, EventEmitter, inject, Input, OnChanges, Output} from '@angular/core'; +import {AlertController, IonButton, IonIcon} from '@ionic/angular'; +import {addIcons} from 'ionicons'; +import {checkmarkCircle, closeCircle, playCircleOutline, refreshOutline, shareSocialOutline} from 'ionicons/icons'; +import {Subscription} from 'rxjs'; +import {Lecture} from '../man.service'; +import {StudyAidsService} from '../study-aids.service'; +import {StudyAidsProgressService} from '../study-aids-progress.service'; +import {RichTextComponent} from './rich-text.component'; +import {formatClock, plainText, renderBlock, renderInline} from '../study-aids/checklist'; +import {CHOICE_LETTERS, effectiveAnswers, score} from '../study-aids/quiz'; +import {LecturePlayback, QuizQuestion, StudyAids} from '../study-aids/study-aids.model'; + +/** Fewer answers than this say nothing about how hard a question is. */ +const MIN_ANSWERS_FOR_STATS = 5; + +/** `?video=…&quiz=1` opens the lecture with its quiz ready, which is what a shared link sends. */ +export const QUIZ_LINK_PARAM = 'quiz'; + +interface QuizItem { + question: QuizQuestion; + promptHtml: string; + promptText: string; + choicesHtml: string[]; + explanationHtml: string | null; +} + +type ChoiceState = 'correct' | 'wrong' | 'muted' | null; + +/** + * A lecture's practice questions, one at a time, with the answer, the explanation and how the + * class did shown as soon as the reader picks a choice. Used on the card and in the reading window. + */ +@Component({ + selector: 'app-quiz-body', + templateUrl: './quiz-body.component.html', + styleUrls: ['./quiz-body.component.scss'], + imports: [IonButton, IonIcon, RichTextComponent], + host: {'[class.reader]': 'reader'}, +}) +export class QuizBodyComponent implements OnChanges { + private service = inject(StudyAidsService); + private progress = inject(StudyAidsProgressService); + private alertCtrl = inject(AlertController); + + @Input({required: true}) aids: StudyAids; + @Input({required: true}) video: Lecture; + @Input() playback: LecturePlayback | null = null; + /** Laid out for the reading window rather than the card. */ + @Input() reader = false; + @Output() seek = new EventEmitter(); + + protected items: QuizItem[] = []; + /** The answers shown, by question id. */ + protected answers: Record = {}; + /** The open question; null shows the start or, once everything is answered, the results. */ + protected current: number | null = null; + /** Read out by screen readers when an answer is checked. */ + protected announcement = ''; + /** What happened to the last shared link. */ + protected shareNote = ''; + protected readonly formatClock = formatClock; + + private videoId: number | null = null; + private stored: Record | null = null; + private storedSubscription: Subscription | null = null; + private renderedFrom: QuizQuestion[] | null = null; + + constructor() { + addIcons({checkmarkCircle, closeCircle, playCircleOutline, refreshOutline, shareSocialOutline}); + inject(DestroyRef).onDestroy(() => this.storedSubscription?.unsubscribe()); + } + + ngOnChanges(): void { + if (this.aids.video_id !== this.videoId) { + this.videoId = this.aids.video_id; + this.current = null; + this.announcement = ''; + this.shareNote = ''; + this.storedSubscription?.unsubscribe(); + this.storedSubscription = this.progress.answers$(this.videoId).subscribe(stored => { + this.stored = stored; + this.answers = effectiveAnswers(stored, this.questions()); + }); + } + this.renderItems(this.questions()); + this.answers = effectiveAnswers(this.stored, this.questions()); + if (this.current !== null && this.current >= this.items.length) { + this.current = null; + } + } + + phase(): 'start' | 'question' | 'results' { + if (this.current !== null && this.items[this.current]) { + return 'question'; + } + + return this.answeredCount() === this.items.length ? 'results' : 'start'; + } + + answeredCount(): number { + return this.items.filter(item => this.isAnswered(item)).length; + } + + scoreOf(): number { + return score(this.questions(), this.answers); + } + + isAnswered(item: QuizItem): boolean { + return item.question.id in this.answers; + } + + isRight(item: QuizItem): boolean { + return this.answers[item.question.id] === item.question.answer; + } + + choiceState(item: QuizItem, index: number): ChoiceState { + const chosen = this.answers[item.question.id]; + if (chosen === undefined) { + return null; + } + if (index === item.question.answer) { + return 'correct'; + } + + return index === chosen ? 'wrong' : 'muted'; + } + + letter(index: number): string { + return CHOICE_LETTERS[index] ?? String(index + 1); + } + + statsLine(question: QuizQuestion): string | null { + const stats = question.stats; + if (!stats || stats.answered < MIN_ANSWERS_FOR_STATS) { + return null; + } + + return `${Math.round(stats.correct / stats.answered * 100)}% of ${stats.answered} students got this right.`; + } + + /** Where Next goes: the following question, else one still unanswered, else the results. */ + nextIndex(): number | null { + const following = (this.current ?? -1) + 1; + if (following < this.items.length) { + return following; + } + const unanswered = this.items.findIndex(item => !this.isAnswered(item)); + + return unanswered >= 0 ? unanswered : null; + } + + start(): void { + const unanswered = this.items.findIndex(item => !this.isAnswered(item)); + this.current = unanswered >= 0 ? unanswered : 0; + } + + open(index: number): void { + this.current = index; + } + + showResults(): void { + this.current = null; + } + + choose(item: QuizItem, choice: number): void { + const id = item.question.id; + if (this.isAnswered(item)) { + return; + } + this.progress.saveAnswers(this.aids.video_id, {...this.answers, [id]: choice}); + this.announcement = choice === item.question.answer + ? 'Correct.' + : `Not quite. The answer is ${this.letter(item.question.answer)}.`; + + // Only a first answer counts towards the class stats, so later ones need not be sent. + if (item.question.mine === null && this.progress.claimSend(this.aids.video_id, id)) { + this.service.answer(this.aids.video_id, id, choice).subscribe(); + } + } + + next(): void { + this.current = this.nextIndex(); + this.announcement = ''; + } + + /** Clears the reader's answers and starts again from the first question, asking first while the quiz is unfinished. */ + async redo(): Promise { + if (this.answeredCount() < this.items.length && !(await this.confirmRedo())) { + return; + } + this.progress.saveAnswers(this.aids.video_id, {}); + this.current = 0; + this.announcement = ''; + } + + /** A link that opens this lecture with its quiz ready, for sending to the class. */ + shareLink(): string { + const url = new URL(location.href); + url.search = ''; + url.hash = ''; + url.searchParams.set('video', String(this.aids.video_id)); + url.searchParams.set(QUIZ_LINK_PARAM, '1'); + + return url.toString(); + } + + async share(): Promise { + const url = this.shareLink(); + if (typeof navigator.share === 'function') { + try { + await navigator.share({title: 'Quiz: ' + this.video.title, url}); + + return; + } catch (error) { + if ((error as DOMException).name === 'AbortError') { + return; + } + } + } + try { + await navigator.clipboard.writeText(url); + this.shareNote = 'Link copied. Anyone who signs in to Flick can open the quiz with it.'; + } catch { + this.shareNote = 'Copy this link to share the quiz: ' + url; + } + } + + private async confirmRedo(): Promise { + const alert = await this.alertCtrl.create({ + header: 'Redo the quiz?', + message: 'Your answers so far will be cleared, and you start again from the first question.', + buttons: [ + {text: 'Cancel', role: 'cancel'}, + {text: 'Redo', role: 'confirm'}, + ], + }); + await alert.present(); + + return (await alert.onDidDismiss()).role === 'confirm'; + } + + private questions(): QuizQuestion[] { + return this.aids?.quiz?.questions ?? []; + } + + private renderItems(questions: QuizQuestion[]): void { + if (questions !== this.renderedFrom) { + this.renderedFrom = questions; + this.items = questions.map(question => ({ + question, + promptHtml: renderBlock(question.prompt), + promptText: plainText(question.prompt), + choicesHtml: question.choices.map(choice => renderInline(choice)), + explanationHtml: question.explanation ? renderBlock(question.explanation) : null, + })); + } + } +} diff --git a/src/app/shared/rich-text.component.html b/src/app/shared/rich-text.component.html new file mode 100644 index 0000000..11df65a --- /dev/null +++ b/src/app/shared/rich-text.component.html @@ -0,0 +1 @@ +
diff --git a/src/app/shared/rich-text.component.scss b/src/app/shared/rich-text.component.scss new file mode 100644 index 0000000..33f2630 --- /dev/null +++ b/src/app/shared/rich-text.component.scss @@ -0,0 +1,74 @@ +@import 'katex/dist/katex.min.css'; + +app-rich-text { + display: block; + overflow-wrap: anywhere; + + &.inline, + &.inline > .rich-text-body { + display: inline; + } + + p { + margin: 0 0 0.5em; + } + + p:last-child { + margin-bottom: 0; + } + + mark { + padding: 0 0.15em; + border-radius: 0.2em; + background: rgba(var(--ion-color-warning-rgb), 0.35); + color: inherit; + } + + a { + color: var(--ion-color-primary); + } + + code { + padding: 0.1em 0.3em; + border-radius: 0.25em; + background: rgba(var(--ion-text-color-rgb, 0, 0, 0), 0.07); + font-size: 0.9em; + } + + // A code block is boxed as a whole: padding on its code would push its first line out of line. + pre code { + padding: 0; + border-radius: 0; + background: none; + font: inherit; + } + + .math-display { + overflow-x: auto; + overflow-y: hidden; + margin: 0.5em 0; + } + + .table-scroll { + overflow-x: auto; + margin: 0.5em 0; + } + + table { + border-collapse: collapse; + font-size: 0.9em; + } + + th, + td { + padding: 0.35em 0.6em; + border: 1px solid rgba(var(--ion-text-color-rgb, 0, 0, 0), 0.15); + text-align: left; + vertical-align: top; + } + + th { + background: rgba(var(--ion-color-primary-rgb), 0.12); + font-weight: 700; + } +} diff --git a/src/app/shared/rich-text.component.ts b/src/app/shared/rich-text.component.ts new file mode 100644 index 0000000..d9706f4 --- /dev/null +++ b/src/app/shared/rich-text.component.ts @@ -0,0 +1,24 @@ +import {afterNextRender, Component, ElementRef, inject, Injector, Input, OnChanges, ViewEncapsulation} from '@angular/core'; +import {renderMath} from '../home/course/markdown-math'; + +/** + * HTML rendered from a summary's or a question's Markdown. Angular sanitizes it on the way in, and + * the maths is drawn afterwards, as KaTeX's output would not survive the sanitizer. + */ +@Component({ + selector: 'app-rich-text', + templateUrl: './rich-text.component.html', + styleUrls: ['./rich-text.component.scss'], + // [innerHTML] content and KaTeX's output carry no scoping attributes, so scoped styles would never match them + encapsulation: ViewEncapsulation.None, +}) +export class RichTextComponent implements OnChanges { + private host = inject>(ElementRef); + private injector = inject(Injector); + + @Input() html = ''; + + ngOnChanges() { + afterNextRender(() => renderMath(this.host.nativeElement), {injector: this.injector}); + } +} diff --git a/src/app/shared/study-aids-reader.component.html b/src/app/shared/study-aids-reader.component.html new file mode 100644 index 0000000..a592851 --- /dev/null +++ b/src/app/shared/study-aids-reader.component.html @@ -0,0 +1,89 @@ + + + {{ video.title }} + + + + + + + @if (state$ | async; as state) { + @if (aidsOf(state); as aids) { + @let tabs = tabsOf(aids); + @if (tabs.length > 1) { + + + @for (name of tabs; track name) { + + {{ tabLabels[name] }} + + } + + + } + } + } + + + +
+ @if (state$ | async; as state) { + @if (state.status === 'loading') { +
+ +
+ } @else if (state.status === 'error') { +

The summary and quiz could not be loaded.

+ } @else if (aidsOf(state); as aids) { + @switch (activeTab(aids)) { + @case ('summary') { + @let outlineEntries = outline(aids); +
+ @if (outlineEntries.length) { + + } + +
+ } + @case ('transcript') { + @let timedEntries = timedOutline(aids); +
+ @if (timedEntries.length > 1) { + + } + +
+ } + @case ('quiz') { + + } + } + } @else { +

There is no summary, transcription or quiz for this lecture.

+ } + } +
+
diff --git a/src/app/shared/study-aids-reader.component.scss b/src/app/shared/study-aids-reader.component.scss new file mode 100644 index 0000000..299bb20 --- /dev/null +++ b/src/app/shared/study-aids-reader.component.scss @@ -0,0 +1,122 @@ +// Ionic attaches modals to the app root, so their size can only be set from unscoped styles. +// Phones keep Ionic's full-screen modal. +@media (min-width: 768px) and (min-height: 600px) { + ion-modal.study-reader-modal { + --width: min(1200px, 95vw); + --height: 92vh; + --max-width: 100%; + --max-height: 100%; + } +} + +app-study-aids-reader { + .reader-tabs ion-segment { + max-width: 24rem; + margin: 0 auto; + } + + .study-reader { + padding: 1.25rem 1rem 3rem; + color: var(--ion-text-color); + } + + .reader-column { + display: block; + max-width: 52rem; + margin: 0 auto; + } + + .quiz-column { + max-width: 44rem; + } + + .reader-loading { + display: flex; + justify-content: center; + padding: 2rem 0; + } + + .reader-note { + max-width: 44rem; + margin: 2rem auto; + color: var(--ion-color-medium); + text-align: center; + } + + .reader-outline { + display: none; + } + + .outline-title { + margin: 0 0 0.5rem; + color: var(--ion-color-medium); + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + } + + .outline-entry { + display: flex; + justify-content: space-between; + gap: 0.5rem; + width: 100%; + padding: 0.35rem 0.6rem; + border: 0; + border-left: 2px solid transparent; + background: transparent; + color: var(--ion-text-color); + font: inherit; + font-size: 0.85rem; + line-height: 1.35; + text-align: left; + cursor: pointer; + + &:hover { + background: rgba(var(--ion-color-primary-rgb), 0.06); + } + + &.active { + border-left-color: var(--ion-color-primary); + color: var(--ion-color-primary); + font-weight: 600; + } + + &:focus-visible { + outline: 2px solid var(--ion-color-primary); + outline-offset: -2px; + } + } + + .outline-time { + flex: none; + color: var(--ion-color-medium); + font-variant-numeric: tabular-nums; + } + + // Wide enough to keep the list of sections beside the summary. + @media (min-width: 992px) { + .study-reader { + padding: 1.5rem 2rem 3rem; + } + + .reader-summary.with-outline { + display: grid; + grid-template-columns: 15rem minmax(0, 1fr); + align-items: start; + gap: 2rem; + + .reader-column { + margin: 0; + } + } + + .reader-outline { + display: block; + position: sticky; + top: 0; + max-height: calc(92vh - 10rem); + overflow-y: auto; + } + } +} diff --git a/src/app/shared/study-aids-reader.component.spec.ts b/src/app/shared/study-aids-reader.component.spec.ts new file mode 100644 index 0000000..57ff277 --- /dev/null +++ b/src/app/shared/study-aids-reader.component.spec.ts @@ -0,0 +1,128 @@ +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {ModalController} from '@ionic/angular'; +import {BehaviorSubject, of} from 'rxjs'; +import {StudyAidsReaderComponent} from './study-aids-reader.component'; +import {StudyAidsService} from '../study-aids.service'; +import {StudyAidsProgressService} from '../study-aids-progress.service'; +import {Lecture} from '../man.service'; +import {StudyAids, StudyAidsState, TranscriptState} from '../study-aids/study-aids.model'; +import {StudyTab} from '../study-aids/tabs'; + +const VIDEO = {id: 7, title: 'Organelles', duration: 3000} as Lecture; +const MARKDOWN = '# Organelles\n\n## Nucleus [00:01:10]\n\n1. **Barr body** : X − 1\n\n## Golgi [00:14:05]\n\n1. **Golgi** : sorts protein\n'; + +const AIDS: StudyAids = { + video_id: 7, + generation: 'ready', + has_transcript: true, + my_ticks: null, + summary: {markdown: MARKDOWN}, + quiz: { + questions: [{id: 'a', prompt: 'Which cell?', choices: ['Plasma cell', 'Leydig cell'], answer: 1, explanation: null, time: 465, stats: null, mine: null}], + }, +}; + +const TRANSCRIPT: TranscriptState = {status: 'ready', paragraphs: [ + {time: 5, text: 'สวัสดีครับ'}, + {time: 70, text: 'Nucleus เก็บ genetic material'}, + {time: 845, text: 'Golgi รับ protein จาก RER'}, +]}; + +describe('StudyAidsReaderComponent', () => { + let fixture: ComponentFixture; + let modalCtrl: jasmine.SpyObj>; + let seek: jasmine.Spy; + + const element = () => fixture.nativeElement as HTMLElement; + const text = () => element().textContent?.replace(/\s+/g, ' ') ?? ''; + + function create(tab: StudyTab, state: StudyAidsState = {status: 'ready', aids: AIDS}): void { + TestBed.inject(StudyAidsService).watch = () => new BehaviorSubject(state); + fixture = TestBed.createComponent(StudyAidsReaderComponent); + fixture.componentRef.setInput('video', VIDEO); + fixture.componentRef.setInput('playback', {currentTime: () => 0, seek}); + fixture.componentRef.setInput('tab', tab); + fixture.detectChanges(); + } + + beforeEach(() => { + localStorage.removeItem('flickSummaryTicks:7'); + localStorage.removeItem('flickQuizAnswers:7'); + modalCtrl = jasmine.createSpyObj('ModalController', ['create', 'dismiss']); + modalCtrl.dismiss.and.resolveTo(true); + seek = jasmine.createSpy('seek'); + TestBed.configureTestingModule({ + imports: [StudyAidsReaderComponent], + providers: [ + {provide: StudyAidsService, useValue: { + watch: () => new BehaviorSubject({status: 'loading'}), + transcript: () => of(TRANSCRIPT), + saveTicks: (videoId: number, ticks: string[]) => of(ticks), + }}, + {provide: ModalController, useValue: modalCtrl}, + ], + }); + }); + + afterEach(() => { + localStorage.removeItem('flickSummaryTicks:7'); + localStorage.removeItem('flickQuizAnswers:7'); + }); + + it('opens on the tab it was asked for', () => { + create('quiz'); + + expect(element().querySelector('app-quiz-body')).not.toBeNull(); + expect(element().querySelector('app-summary-body')).toBeNull(); + expect(text()).toContain('Organelles'); + expect(Array.from(element().querySelectorAll('ion-segment-button')).map(button => button.textContent?.trim())) + .toEqual(['Summary', 'Transcription', 'Quiz']); + }); + + it('moves through the transcription by the summary\'s sections', () => { + create('transcript'); + const transcript = element().querySelector('app-transcript-body'); + const scrolled = spyOn(HTMLElement.prototype, 'scrollIntoView'); + const entries = Array.from(element().querySelectorAll('.outline-entry')); + + expect(transcript?.textContent).toContain('Golgi รับ protein จาก RER'); + expect(entries.map(entry => entry.querySelector('.outline-time')?.textContent?.trim())).toEqual(['1:10', '14:05']); + entries[1].click(); + expect((scrolled.calls.mostRecent().object as HTMLElement).textContent).toContain('Golgi รับ protein'); + }); + + it('lists the summary\'s sections beside it', () => { + create('summary'); + const entries = Array.from(element().querySelectorAll('.outline-entry')) + .map(entry => [entry.querySelector('.outline-text')?.textContent?.trim(), entry.querySelector('.outline-time')?.textContent?.trim()]); + + expect(entries).toEqual([['Nucleus', '1:10'], ['Golgi', '14:05']]); + }); + + it('closes before jumping the lecture, so the video can be seen', async () => { + create('summary'); + element().querySelector('button.time-chip')?.click(); + await fixture.whenStable(); + + expect(modalCtrl.dismiss).toHaveBeenCalled(); + expect(seek).toHaveBeenCalledWith(70); + }); + + it('shares ticks with the card', () => { + create('summary'); + TestBed.inject(StudyAidsProgressService).setTick(7, 'unknown-key', true); + const box = element().querySelector('.point-tick'); + box!.checked = true; + box!.dispatchEvent(new Event('change')); + fixture.detectChanges(); + + expect(JSON.parse(localStorage.getItem('flickSummaryTicks:7') ?? '[]').length).toBe(2); + expect(text()).toContain('1 of 2 points ticked.'); + }); + + it('says so when a lecture has nothing to read', () => { + create('summary', {status: 'unavailable'}); + + expect(text()).toContain('There is no summary, transcription or quiz for this lecture.'); + }); +}); diff --git a/src/app/shared/study-aids-reader.component.ts b/src/app/shared/study-aids-reader.component.ts new file mode 100644 index 0000000..f7a6962 --- /dev/null +++ b/src/app/shared/study-aids-reader.component.ts @@ -0,0 +1,177 @@ +import {Component, ElementRef, inject, Input, OnInit, ViewChild, ViewEncapsulation} from '@angular/core'; +import {AsyncPipe} from '@angular/common'; +import { + IonButton, + IonButtons, + IonContent, + IonHeader, + IonIcon, + IonLabel, + IonSegment, + IonSegmentButton, + IonSpinner, + IonTitle, + IonToolbar, + ModalController, +} from '@ionic/angular'; +import {addIcons} from 'ionicons'; +import {close} from 'ionicons/icons'; +import {Observable} from 'rxjs'; +import {Lecture} from '../man.service'; +import {StudyAidsService} from '../study-aids.service'; +import {SummaryBodyComponent} from './summary-body.component'; +import {TranscriptBodyComponent} from './transcript-body.component'; +import {QuizBodyComponent} from './quiz-body.component'; +import {Checklist, checklistFor, formatClock} from '../study-aids/checklist'; +import {isStudyTab, StudyTab, studyTabs, TAB_LABELS} from '../study-aids/tabs'; +import {LecturePlayback, StudyAids, StudyAidsState} from '../study-aids/study-aids.model'; + +interface OutlineEntry { + /** The section's position in the checklist, which is also its place in the page. */ + index: number; + text: string; + time: number | null; +} + +export async function openReader(modalCtrl: ModalController, video: Lecture, playback: LecturePlayback | null, tab: StudyTab): Promise { + const modal = await modalCtrl.create({ + component: StudyAidsReaderComponent, + cssClass: 'study-reader-modal', + componentProps: {video, playback, tab}, + }); + await modal.present(); +} + +/** The summary, the transcription and the quiz in a large window, with room to read and practise. */ +@Component({ + selector: 'app-study-aids-reader', + templateUrl: './study-aids-reader.component.html', + styleUrls: ['./study-aids-reader.component.scss'], + // Sizes the ion-modal, which sits outside this component + encapsulation: ViewEncapsulation.None, + imports: [ + AsyncPipe, + IonHeader, + IonToolbar, + IonTitle, + IonButtons, + IonButton, + IonIcon, + IonContent, + IonSegment, + IonSegmentButton, + IonLabel, + IonSpinner, + SummaryBodyComponent, + TranscriptBodyComponent, + QuizBodyComponent, + ], +}) +export class StudyAidsReaderComponent implements OnInit { + private service = inject(StudyAidsService); + private modalCtrl = inject(ModalController); + private host = inject>(ElementRef); + + @Input() video: Lecture; + @Input() playback: LecturePlayback | null = null; + @Input() tab: StudyTab = 'summary'; + + @ViewChild(TranscriptBodyComponent) transcript?: TranscriptBodyComponent; + + protected state$: Observable; + protected activeSection = 0; + protected readonly formatClock = formatClock; + protected readonly tabLabels = TAB_LABELS; + protected readonly tabsOf = studyTabs; + + private outlineFrom: Checklist | null = null; + private outlineEntries: OutlineEntry[] = []; + + constructor() { + addIcons({close}); + } + + ngOnInit(): void { + this.state$ = this.service.watch(this.video.id as number); + } + + aidsOf(state: StudyAidsState): StudyAids | null { + return state.status === 'ready' ? state.aids : null; + } + + /** The chosen tab, or the first with something in it. */ + activeTab(aids: StudyAids): StudyTab { + const tabs = studyTabs(aids); + + return tabs.includes(this.tab) ? this.tab : tabs[0] ?? 'summary'; + } + + /** The summary's sections that say when they start, to move through the transcript by. */ + timedOutline(aids: StudyAids): OutlineEntry[] { + const checklist = aids.summary ? checklistFor(aids.summary.markdown) : null; + + return (checklist?.sections ?? []) + .map((section, index) => ({index, text: section.headingText ?? '', time: section.time})) + .filter(entry => entry.text && entry.time !== null); + } + + scrollTranscriptTo(entry: OutlineEntry): void { + if (entry.time !== null) { + this.transcript?.scrollToTime(entry.time); + this.activeSection = entry.index; + } + } + + /** The sections worth listing beside a summary, or none when there is only one. */ + outline(aids: StudyAids): OutlineEntry[] { + const checklist = aids.summary ? checklistFor(aids.summary.markdown) : null; + if (checklist !== this.outlineFrom) { + this.outlineFrom = checklist; + const entries = (checklist?.sections ?? []) + .map((section, index) => ({index, text: section.headingText ?? '', time: section.time})) + .filter(entry => entry.text); + this.outlineEntries = entries.length > 1 ? entries : []; + } + + return this.outlineEntries; + } + + onTab(event: Event): void { + const value = (event as CustomEvent<{value: unknown}>).detail.value; + if (isStudyTab(value)) { + this.tab = value; + this.activeSection = 0; + } + } + + /** Jumps the lecture from inside the window, which closes first so the video can be seen. */ + async watch(seconds: number): Promise { + await this.modalCtrl.dismiss(); + this.playback?.seek(seconds); + } + + scrollToSection(index: number): void { + this.sections()[index]?.scrollIntoView({behavior: 'smooth', block: 'start'}); + this.activeSection = index; + } + + /** Marks the section whose heading was last scrolled past, as the transcript's outline does. */ + onScroll(): void { + const top = (this.host.nativeElement.querySelector('ion-content')?.getBoundingClientRect().top ?? 0) + 80; + let active = 0; + this.sections().forEach((section, index) => { + if (section.getBoundingClientRect().top <= top) { + active = index; + } + }); + this.activeSection = active; + } + + close(): void { + void this.modalCtrl.dismiss(); + } + + private sections(): HTMLElement[] { + return Array.from(this.host.nativeElement.querySelectorAll('app-checklist-view .checklist-section')); + } +} diff --git a/src/app/shared/summary-body.component.html b/src/app/shared/summary-body.component.html new file mode 100644 index 0000000..493f169 --- /dev/null +++ b/src/app/shared/summary-body.component.html @@ -0,0 +1,20 @@ +@if (aids.summary && checklist) { + @let ticked = (ticked$ | async) ?? noTicks; + + @if (checklist.pointKeys.length) { +

+ @if (reviewed(checklist, ticked); as count) { + {{ count }} of {{ checklist.pointKeys.length }} points ticked. + + } @else { + Tick each point once you know it. + } +

+ } + +} @else if (aids.generation === 'pending') { +

The AI is still writing a summary of this lecture. Check back later.

+} @else { +

There is no summary of this lecture.

+} diff --git a/src/app/shared/summary-body.component.scss b/src/app/shared/summary-body.component.scss new file mode 100644 index 0000000..a82c364 --- /dev/null +++ b/src/app/shared/summary-body.component.scss @@ -0,0 +1,26 @@ +@use 'study-shared'; + +:host { + display: block; +} + +.review-progress { + margin: -0.25rem 0 0.75rem; + color: var(--ion-color-medium); + font-size: 0.8rem; +} + +.link-button { + margin-left: 0.25rem; + padding: 0; + border: 0; + background: none; + color: var(--ion-color-primary); + font: inherit; + text-decoration: underline; + cursor: pointer; +} + +:host(.reader) app-checklist-view { + font-size: 1.05rem; +} diff --git a/src/app/shared/summary-body.component.spec.ts b/src/app/shared/summary-body.component.spec.ts new file mode 100644 index 0000000..f74e572 --- /dev/null +++ b/src/app/shared/summary-body.component.spec.ts @@ -0,0 +1,87 @@ +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {of} from 'rxjs'; +import {SummaryBodyComponent} from './summary-body.component'; +import {StudyAidsService} from '../study-aids.service'; +import {StudyAids} from '../study-aids/study-aids.model'; + +const SUMMARY = '# Organelles\n\n## Lysosome [00:21:40]\n\n1. **Lysosome** : pH 5\n2. **Gaucher** : glucocerebrosidase\n'; + +function aids(changes: Partial = {}): StudyAids { + return { + video_id: 7, + generation: 'ready', + has_transcript: false, + my_ticks: null, + summary: {markdown: SUMMARY}, + quiz: null, + ...changes, + }; +} + +describe('SummaryBodyComponent', () => { + let fixture: ComponentFixture; + let service: jasmine.SpyObj>; + + const element = () => fixture.nativeElement as HTMLElement; + const text = () => element().textContent?.replace(/\s+/g, ' ') ?? ''; + + function show(value: StudyAids): void { + fixture.componentRef.setInput('aids', value); + fixture.detectChanges(); + } + + beforeEach(() => { + localStorage.removeItem('flickSummaryTicks:7'); + service = jasmine.createSpyObj('StudyAidsService', ['watch', 'saveTicks']); + service.watch.and.returnValue(of({status: 'loading'})); + service.saveTicks.and.callFake((videoId, ticks) => of(ticks)); + TestBed.configureTestingModule({ + imports: [SummaryBodyComponent], + providers: [{provide: StudyAidsService, useValue: service}], + }); + fixture = TestBed.createComponent(SummaryBodyComponent); + }); + + afterEach(() => localStorage.removeItem('flickSummaryTicks:7')); + + it('says the AI wrote the summary, and shows it as a checklist to tick', () => { + show(aids()); + + expect(text()).toContain('Written by AI from the lecture recording. Check it against the lecture.'); + expect(text()).toContain('Tick each point once you know it.'); + expect(element().querySelectorAll('.point-tick').length).toBe(2); + }); + + it('counts the ticked points and clears them', () => { + show(aids()); + const box = element().querySelector('.point-tick')!; + box.checked = true; + box.dispatchEvent(new Event('change')); + fixture.detectChanges(); + + expect(text()).toContain('1 of 2 points ticked.'); + + element().querySelector('.link-button')!.click(); + fixture.detectChanges(); + expect(text()).toContain('Tick each point once you know it.'); + }); + + it('draws a diagram line for line in a fixed-width font', () => { + show(aids({summary: {markdown: '# Veins\n\n1. **Face** : veins\n\n```\n Facial v.\n │\n ▼\n[ Internal jugular v. ]\n```\n'}})); + const pre = element().querySelector('pre')!; + + expect(pre.textContent).toBe(' Facial v.\n │\n ▼\n[ Internal jugular v. ]\n'); + expect(getComputedStyle(pre).whiteSpace).toBe('pre'); + expect(getComputedStyle(pre).overflowX).toBe('auto'); + expect(getComputedStyle(pre).fontFamily).toContain('Menlo'); + expect(getComputedStyle(pre.querySelector('code')!).paddingLeft).toBe('0px'); + }); + + it('says when the AI is still writing the summary, or there is none', () => { + show(aids({summary: null, generation: 'pending'})); + expect(text()).toContain('The AI is still writing a summary of this lecture.'); + + show(aids({summary: null, generation: 'failed'})); + expect(text()).toContain('There is no summary of this lecture.'); + }); +}); diff --git a/src/app/shared/summary-body.component.ts b/src/app/shared/summary-body.component.ts new file mode 100644 index 0000000..86f245d --- /dev/null +++ b/src/app/shared/summary-body.component.ts @@ -0,0 +1,53 @@ +import {Component, EventEmitter, inject, Input, OnChanges, Output} from '@angular/core'; +import {AsyncPipe} from '@angular/common'; +import {Observable} from 'rxjs'; +import {StudyAidsProgressService} from '../study-aids-progress.service'; +import {ChecklistViewComponent, TickChange} from './checklist-view.component'; +import {Checklist, checklistFor} from '../study-aids/checklist'; +import {LecturePlayback, StudyAids} from '../study-aids/study-aids.model'; + +const NO_TICKS: ReadonlySet = new Set(); + +/** A lecture's AI summary with the reader's ticks, on the card and in the reading window. */ +@Component({ + selector: 'app-summary-body', + templateUrl: './summary-body.component.html', + styleUrls: ['./summary-body.component.scss'], + imports: [AsyncPipe, ChecklistViewComponent], + host: {'[class.reader]': 'reader'}, +}) +export class SummaryBodyComponent implements OnChanges { + private progress = inject(StudyAidsProgressService); + + @Input({required: true}) aids: StudyAids; + @Input() playback: LecturePlayback | null = null; + /** Laid out for the reading window rather than the card. */ + @Input() reader = false; + @Output() seek = new EventEmitter(); + + protected checklist: Checklist | null = null; + protected ticked$: Observable>; + protected readonly noTicks = NO_TICKS; + + private videoId: number | null = null; + + ngOnChanges(): void { + this.checklist = this.aids.summary ? checklistFor(this.aids.summary.markdown) : null; + if (this.aids.video_id !== this.videoId) { + this.videoId = this.aids.video_id; + this.ticked$ = this.progress.ticks$(this.videoId); + } + } + + reviewed(checklist: Checklist, ticked: ReadonlySet): number { + return checklist.pointKeys.filter(key => ticked.has(key)).length; + } + + onTick(change: TickChange): void { + this.progress.setTick(this.aids.video_id, change.key, change.ticked); + } + + clearTicks(): void { + this.progress.clearTicks(this.aids.video_id); + } +} diff --git a/src/app/shared/transcript-body.component.html b/src/app/shared/transcript-body.component.html new file mode 100644 index 0000000..56a8931 --- /dev/null +++ b/src/app/shared/transcript-body.component.html @@ -0,0 +1,69 @@ +@if (state$ | async; as state) { + @if (state.status === 'loading') { +
+ +
+ } @else if (state.status === 'none') { +

There is no transcript of this lecture.

+ } @else if (state.status === 'error') { +

+ @if (state.limited) { + You have opened many transcripts in the last hour, so Flick has to wait before opening more. Try again later. + } @else { + The transcript could not be loaded. + } +

+ Try again + } @else { +
+

+ Word for word as the AI heard the lecture, so it can mishear names and terms. + @if (playback) { + Select a time to play the lecture from there. + } +

+ + + + Download .txt + + @if (playback) { + + + Now playing + + + Follow the lecture + + } + +
+
    + @for (paragraph of reading; track $index) { +
  1. + @if (playback) { + + } @else { + {{ formatClock(paragraph.time) }} + } +

    + @for (line of paragraph.lines; track line.index) { + @if (playback && paragraph.lines.length > 1) { + + } @else { + {{ line.text + ' ' }} + } + } +

    +
  2. + } +
+ } +} diff --git a/src/app/shared/transcript-body.component.scss b/src/app/shared/transcript-body.component.scss new file mode 100644 index 0000000..017c4bb --- /dev/null +++ b/src/app/shared/transcript-body.component.scss @@ -0,0 +1,115 @@ +@use 'study-shared'; + +:host { + display: block; +} + +.transcript-head { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 0.25rem 0.75rem; + margin-bottom: 0.5rem; + + .card-note { + flex: 1 1 16rem; + } +} + +// On the card the transcript scrolls by itself, so a long lecture does not push the list of lectures away. +.transcript { + position: relative; + max-height: 26rem; + margin: 0; + padding: 0 0.25rem 0 0; + overflow-y: auto; + list-style: none; +} + +:host(.reader) .transcript { + max-height: none; + overflow: visible; + font-size: 1.05rem; +} + +.transcript-paragraph { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: start; + gap: 0.6rem; + padding: 0.45rem 0.5rem; + border-radius: 0.5rem; + + &.is-current { + background: rgba(var(--ion-color-primary-rgb), 0.1); + } +} + +.paragraph-text { + margin: 0; + line-height: 1.6; + white-space: pre-line; + overflow-wrap: anywhere; +} + +.time-chip { + display: inline-flex; + align-items: center; + gap: 0.2rem; + margin-top: 0.15rem; + padding: 0.1rem 0.55rem; + border: 1px solid rgba(var(--ion-color-primary-rgb), 0.45); + border-radius: 1rem; + background: transparent; + color: var(--ion-color-primary); + font: inherit; + font-size: 0.75rem; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +button.time-chip { + cursor: pointer; + + &:hover { + background: rgba(var(--ion-color-primary-rgb), 0.1); + } + + &:focus-visible { + outline: 2px solid var(--ion-color-primary); + outline-offset: 2px; + } +} + +// One stamped line inside a paragraph, which plays the lecture from where it is said. +.transcript-line { + display: inline; + padding: 0; + border: 0; + border-radius: 0.2em; + background: transparent; + color: inherit; + font: inherit; + line-height: inherit; + text-align: inherit; + white-space: inherit; + + &.is-current { + background: rgba(var(--ion-color-primary-rgb), 0.16); + } +} + +button.transcript-line { + cursor: pointer; + + &:hover { + text-decoration: underline; + text-decoration-color: rgba(var(--ion-color-primary-rgb), 0.6); + } + + &:focus-visible { + outline: 2px solid var(--ion-color-primary); + outline-offset: 1px; + } +} diff --git a/src/app/shared/transcript-body.component.spec.ts b/src/app/shared/transcript-body.component.spec.ts new file mode 100644 index 0000000..706e945 --- /dev/null +++ b/src/app/shared/transcript-body.component.spec.ts @@ -0,0 +1,96 @@ +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {of} from 'rxjs'; +import {readingParagraphs, TranscriptBodyComponent, transcriptFileName, transcriptText} from './transcript-body.component'; +import {Lecture} from '../man.service'; +import {StudyAidsService} from '../study-aids.service'; +import {TranscriptParagraph} from '../study-aids/study-aids.model'; + +const SENTENCES: TranscriptParagraph[] = [ + {time: 5, text: 'สวัสดีครับ'}, + {time: 9, text: 'วันนี้เราจะคุยเรื่อง spinal cord'}, + {time: 16, text: 'Spinal cord ยาวเต็ม vertebral canal ในเด็กแรกเกิด'}, + {time: 62, text: 'Cervical vertebrae มี C1 ถึง C7'}, +]; + +describe('TranscriptBodyComponent', () => { + it('groups lines stamped one by one into paragraphs for reading', () => { + const paragraphs = readingParagraphs(SENTENCES); + + expect(paragraphs.map(paragraph => [paragraph.time, paragraph.lines.map(line => line.index)])).toEqual([[5, [0, 1, 2]], [62, [3]]]); + }); + + it('keeps minute-long paragraphs as they are', () => { + const long = 'คำ '.repeat(250); + + expect(readingParagraphs([{time: 0, text: long}, {time: 30, text: long}]).length).toBe(2); + }); + + it('writes the transcript as text, each paragraph after the time it starts', () => { + expect(transcriptText(readingParagraphs(SENTENCES), {title: 'Spinal cord', lecturer: 'อ.นพ.สมชาย'})).toBe([ + 'Spinal cord', + 'อ.นพ.สมชาย', + 'Transcribed word for word by AI, which can mishear names and terms.', + '', + '[00:00:05] สวัสดีครับ วันนี้เราจะคุยเรื่อง spinal cord Spinal cord ยาวเต็ม vertebral canal ในเด็กแรกเกิด', + '', + '[00:01:02] Cervical vertebrae มี C1 ถึง C7', + '', + ].join('\n')); + expect(transcriptText([], {title: 'Spinal cord', lecturer: ''}).split('\n').slice(0, 2)).toEqual([ + 'Spinal cord', + 'Transcribed word for word by AI, which can mishear names and terms.', + ]); + }); + + it('names the file after the lecture, leaving out what file systems refuse', () => { + expect(transcriptFileName('Spine: C1/C2 "atlas"?')).toBe('Spine C1 C2 atlas - Transcript.txt'); + expect(transcriptFileName('. ')).toBe('Lecture - Transcript.txt'); + expect(transcriptFileName('ไขสันหลัง'.repeat(20)).length).toBe(75 + ' - Transcript.txt'.length); + }); + + it('plays the lecture from a paragraph or from any line in it', async () => { + TestBed.configureTestingModule({ + imports: [TranscriptBodyComponent], + providers: [{provide: StudyAidsService, useValue: {transcript: () => of({status: 'ready', paragraphs: SENTENCES})}}], + }); + const fixture: ComponentFixture = TestBed.createComponent(TranscriptBodyComponent); + const seek = jasmine.createSpy('seek'); + fixture.componentRef.setInput('videoId', 7); + fixture.componentRef.setInput('playback', {currentTime: () => 10, seek: () => undefined}); + fixture.componentInstance.seek.subscribe(seek); + fixture.detectChanges(); + const element = fixture.nativeElement as HTMLElement; + const lines = Array.from(element.querySelectorAll('button.transcript-line')); + + expect(element.querySelectorAll('.transcript-paragraph').length).toBe(2); + expect(lines.map(line => line.textContent?.trim())).toEqual(SENTENCES.slice(0, 3).map(line => line.text)); + lines[2].click(); + element.querySelectorAll('button.time-chip')[1].click(); + expect(seek.calls.allArgs()).toEqual([[16], [62]]); + fixture.destroy(); + }); + + it('downloads the transcript as a UTF-8 text file named after the lecture', async () => { + TestBed.configureTestingModule({ + imports: [TranscriptBodyComponent], + providers: [{provide: StudyAidsService, useValue: {transcript: () => of({status: 'ready', paragraphs: SENTENCES})}}], + }); + const fixture = TestBed.createComponent(TranscriptBodyComponent); + const video = {title: 'Spinal cord', lecturer: 'อ.นพ.สมชาย'} as Lecture; + const createObjectURL = spyOn(URL, 'createObjectURL').and.returnValue('blob:transcript'); + const click = spyOn(HTMLAnchorElement.prototype, 'click'); + fixture.componentRef.setInput('videoId', 7); + fixture.componentRef.setInput('video', video); + fixture.detectChanges(); + (fixture.nativeElement as HTMLElement).querySelector('ion-button.download-button')?.click(); + + const file = createObjectURL.calls.mostRecent().args[0] as Blob; + const link = click.calls.mostRecent().object as HTMLAnchorElement; + expect(file.type).toBe('text/plain;charset=utf-8'); + expect(Array.from(new Uint8Array(await file.arrayBuffer()).slice(0, 3))).toEqual([0xef, 0xbb, 0xbf]); + expect(await file.text()).toBe(transcriptText(readingParagraphs(SENTENCES), video)); + expect(link.download).toBe('Spinal cord - Transcript.txt'); + expect(link.href).toBe('blob:transcript'); + fixture.destroy(); + }); +}); diff --git a/src/app/shared/transcript-body.component.ts b/src/app/shared/transcript-body.component.ts new file mode 100644 index 0000000..b0b9863 --- /dev/null +++ b/src/app/shared/transcript-body.component.ts @@ -0,0 +1,196 @@ +import {Component, ElementRef, EventEmitter, inject, Input, NgZone, OnChanges, OnDestroy, Output} from '@angular/core'; +import {AsyncPipe} from '@angular/common'; +import {IonButton, IonIcon, IonSpinner} from '@ionic/angular'; +import {addIcons} from 'ionicons'; +import {downloadOutline, locateOutline, play} from 'ionicons/icons'; +import {Observable} from 'rxjs'; +import {tap} from 'rxjs/operators'; +import {Lecture} from '../man.service'; +import {StudyAidsService} from '../study-aids.service'; +import {formatClock, formatStamp} from '../study-aids/checklist'; +import {LecturePlayback, TranscriptParagraph, TranscriptState} from '../study-aids/study-aids.model'; + +/** A paragraph as the transcript shows it: FlickMan's stamped lines, grouped back together for reading. */ +export interface ReadingParagraph { + time: number; + /** Each line keeps its place in the transcript, to mark the one being spoken. */ + lines: {index: number; time: number; text: string}[]; +} + +/** Lines stamped one by one are grouped into paragraphs of about this long; longer lines stand alone. */ +const PARAGRAPH_SECONDS = 45; +const PARAGRAPH_CHARACTERS = 600; + +export function readingParagraphs(lines: readonly TranscriptParagraph[]): ReadingParagraph[] { + const paragraphs: ReadingParagraph[] = []; + let length = 0; + lines.forEach((line, index) => { + const last = paragraphs[paragraphs.length - 1]; + const entry = {index, time: line.time, text: line.text}; + if (last && line.time - last.time < PARAGRAPH_SECONDS && length + line.text.length <= PARAGRAPH_CHARACTERS) { + last.lines.push(entry); + length += line.text.length; + } else { + paragraphs.push({time: line.time, lines: [entry]}); + length = line.text.length; + } + }); + + return paragraphs; +} + +/** The line being spoken at `seconds`, or -1 before the first. */ +export function paragraphAt(paragraphs: readonly TranscriptParagraph[], seconds: number): number { + let found = -1; + paragraphs.forEach((paragraph, i) => { + if (paragraph.time <= seconds) { + found = i; + } + }); + + return found; +} + +/** The transcript as a text file: the lecture, then each paragraph after the time it starts. */ +export function transcriptText(paragraphs: readonly ReadingParagraph[], video: Pick): string { + const head = [video.title, video.lecturer, 'Transcribed word for word by AI, which can mishear names and terms.'] + .map(line => line?.trim()) + .filter(Boolean); + const body = paragraphs.map(paragraph => `[${formatStamp(paragraph.time)}] ${paragraph.lines.map(line => line.text).join(' ')}`); + + return [head.join('\n'), ...body].join('\n\n') + '\n'; +} + +/** Named after the lecture, without the characters file systems refuse, and short enough for any of them. */ +export function transcriptFileName(title: string): string { + const name = title.replace(/[\\/:*?"<>|\s]+/g, ' ').slice(0, 75).replace(/^[\s.]+|[\s.]+$/g, ''); + + return `${name || 'Lecture'} - Transcript.txt`; +} + +/** + * The AI's word-for-word transcript of the lecture, each paragraph with the time it starts, which + * plays the lecture from there, as does each stamped line in it. The line being spoken is marked + * as the lecture plays. + */ +@Component({ + selector: 'app-transcript-body', + templateUrl: './transcript-body.component.html', + styleUrls: ['./transcript-body.component.scss'], + imports: [AsyncPipe, IonButton, IonIcon, IonSpinner], + host: {'[class.reader]': 'reader'}, +}) +export class TranscriptBodyComponent implements OnChanges, OnDestroy { + private service = inject(StudyAidsService); + private host = inject>(ElementRef); + private zone = inject(NgZone); + + @Input({required: true}) videoId: number; + @Input({required: true}) video: Lecture; + @Input() playback: LecturePlayback | null = null; + /** Laid out for the reading window rather than the card. */ + @Input() reader = false; + @Output() seek = new EventEmitter(); + + protected state$: Observable; + protected reading: ReadingParagraph[] = []; + /** The line being spoken, or -1. */ + protected current = -1; + /** Whether the list scrolls along as the lecture plays. */ + protected following = false; + protected readonly formatClock = formatClock; + + private paragraphs: TranscriptParagraph[] = []; + private shownFor: number | null = null; + private readonly timer: ReturnType; + + constructor() { + addIcons({downloadOutline, locateOutline, play}); + // Checked outside Angular, which only hears about it when another paragraph starts. + this.timer = this.zone.runOutsideAngular(() => setInterval(() => this.follow(), 1000)); + } + + ngOnChanges(): void { + if (this.videoId !== this.shownFor) { + this.shownFor = this.videoId; + this.current = -1; + this.state$ = this.service.transcript(this.videoId).pipe( + tap(state => { + this.paragraphs = state.status === 'ready' ? state.paragraphs : []; + this.reading = readingParagraphs(this.paragraphs); + }), + ); + } + } + + ngOnDestroy(): void { + clearInterval(this.timer); + } + + retry(): void { + this.service.retryTranscript(this.videoId); + } + + download(): void { + // The byte order mark keeps editors that guess the encoding from misreading the Thai. + const file = new Blob(['\uFEFF' + transcriptText(this.reading, this.video)], {type: 'text/plain;charset=utf-8'}); + const link = document.createElement('a'); + link.href = URL.createObjectURL(file); + link.download = transcriptFileName(this.video.title); + link.click(); + // Some browsers only start reading the file after the click has returned. + this.zone.runOutsideAngular(() => setTimeout(() => URL.revokeObjectURL(link.href), 60_000)); + } + + toggleFollowing(): void { + this.following = !this.following; + if (this.following) { + this.showNow(); + } + } + + /** Brings the paragraph being spoken into view. */ + showNow(): void { + this.follow(); + this.scrollTo(this.current); + } + + /** Brings the part of the lecture at `seconds` into view, for the reading window's list of sections. */ + scrollToTime(seconds: number): void { + this.scrollTo(Math.max(0, paragraphAt(this.paragraphs, seconds))); + } + + private follow(): void { + if (!this.playback || !this.paragraphs.length) { + return; + } + const index = paragraphAt(this.paragraphs, this.playback.currentTime()); + if (index !== this.current) { + this.zone.run(() => this.current = index); + if (this.following) { + this.scrollTo(index); + } + } + } + + /** Whether the line being spoken is in the paragraph. */ + holdsCurrent(paragraph: ReadingParagraph): boolean { + return paragraph.lines.some(line => line.index === this.current); + } + + private scrollTo(line: number): void { + const index = this.reading.findIndex(paragraph => paragraph.lines.some(entry => entry.index === line)); + const paragraph = this.host.nativeElement.querySelectorAll('.transcript-paragraph')[index]; + if (!paragraph) { + return; + } + if (this.reader) { + paragraph.scrollIntoView({behavior: 'smooth', block: 'center'}); + + return; + } + // On the card only the list scrolls, so the video above stays in view. + const list = this.host.nativeElement.querySelector('.transcript'); + list?.scrollTo({top: paragraph.offsetTop - list.clientHeight / 3, behavior: 'smooth'}); + } +} diff --git a/src/app/study-aids-progress.service.spec.ts b/src/app/study-aids-progress.service.spec.ts new file mode 100644 index 0000000..1b6ed26 --- /dev/null +++ b/src/app/study-aids-progress.service.spec.ts @@ -0,0 +1,107 @@ +import {fakeAsync, TestBed, tick} from '@angular/core/testing'; +import {BehaviorSubject, of, throwError} from 'rxjs'; +import {StudyAidsProgressService} from './study-aids-progress.service'; +import {StudyAidsService} from './study-aids.service'; +import {StudyAids, StudyAidsState} from './study-aids/study-aids.model'; + +function lecture(myTicks: string[] | null): StudyAidsState { + return {status: 'ready', aids: { + video_id: 7, generation: 'ready', has_transcript: false, my_ticks: myTicks, summary: null, quiz: null, + } as StudyAids}; +} + +describe('StudyAidsProgressService', () => { + let progress: StudyAidsProgressService; + let state$: BehaviorSubject; + let service: jasmine.SpyObj>; + + const clear = () => ['flickSummaryTicks:7', 'flickQuizAnswers:7', 'flickSummaryTicksUnsynced'].forEach(key => localStorage.removeItem(key)); + const ticksOf = () => { + let seen: string[] = []; + progress.ticks$(7).subscribe(ticks => seen = [...ticks]).unsubscribe(); + + return seen; + }; + + beforeEach(() => { + clear(); + state$ = new BehaviorSubject({status: 'loading'}); + service = jasmine.createSpyObj('StudyAidsService', ['watch', 'saveTicks']); + service.watch.and.returnValue(state$); + service.saveTicks.and.callFake((videoId, ticks) => of(ticks)); + TestBed.configureTestingModule({providers: [{provide: StudyAidsService, useValue: service}]}); + progress = TestBed.inject(StudyAidsProgressService); + }); + + afterEach(clear); + + it('tells everyone watching about a tick, and keeps it', () => { + const seen: number[] = []; + progress.ticks$(7).subscribe(ticks => seen.push(ticks.size)); + progress.setTick(7, 'a', true); + progress.setTick(7, 'b', true); + progress.setTick(7, 'a', false); + + expect(seen).toEqual([0, 1, 2, 1]); + expect(JSON.parse(localStorage.getItem('flickSummaryTicks:7') ?? '[]')).toEqual(['b']); + }); + + it('sends a burst of ticks to FlickMan once', fakeAsync(() => { + progress.setTick(7, 'a', true); + tick(500); + progress.setTick(7, 'b', true); + tick(1500); + + expect(service.saveTicks).toHaveBeenCalledOnceWith(7, ['a', 'b']); + expect(localStorage.getItem('flickSummaryTicksUnsynced')).toBe('[]'); + })); + + it('shows the ticks made on another device', () => { + localStorage.setItem('flickSummaryTicks:7', JSON.stringify(['old'])); + expect(ticksOf()).toEqual(['old']); + state$.next(lecture(['k3x9', 'a1-b2'])); + + expect(ticksOf()).toEqual(['k3x9', 'a1-b2']); + expect(service.saveTicks).not.toHaveBeenCalled(); + }); + + it('sends this device\'s changes when FlickMan has not had them yet', fakeAsync(() => { + progress.setTick(7, 'mine', true); + state$.next(lecture(['theirs'])); + + expect(ticksOf()).toEqual(['mine']); + expect(service.saveTicks).toHaveBeenCalledWith(7, ['mine']); + tick(1500); + })); + + it('hands FlickMan the ticks kept on this device before it kept any', () => { + localStorage.setItem('flickSummaryTicks:7', JSON.stringify(['a'])); + ticksOf(); + state$.next(lecture(null)); + + expect(service.saveTicks).toHaveBeenCalledOnceWith(7, ['a']); + }); + + it('keeps a change that could not be sent for the next try', fakeAsync(() => { + service.saveTicks.and.returnValue(throwError(() => ({kind: 'network'}))); + progress.setTick(7, 'a', true); + tick(1500); + + expect(JSON.parse(localStorage.getItem('flickSummaryTicksUnsynced') ?? '[]')).toEqual([7]); + })); + + it('knows a quiz never answered on this device from one cleared for a retake', () => { + let answers: Record | null | undefined; + progress.answers$(7).subscribe(value => answers = value); + expect(answers).toBeNull(); + + progress.saveAnswers(7, {}); + expect(answers).toEqual({}); + }); + + it('lets each answer be sent once', () => { + expect(progress.claimSend(7, 'a')).toBeTrue(); + expect(progress.claimSend(7, 'a')).toBeFalse(); + expect(progress.claimSend(8, 'a')).toBeTrue(); + }); +}); diff --git a/src/app/study-aids-progress.service.ts b/src/app/study-aids-progress.service.ts new file mode 100644 index 0000000..0203c63 --- /dev/null +++ b/src/app/study-aids-progress.service.ts @@ -0,0 +1,139 @@ +import {inject, Injectable} from '@angular/core'; +import {BehaviorSubject, Observable} from 'rxjs'; +import {filter, take} from 'rxjs/operators'; +import {StudyAidsService} from './study-aids.service'; +import {markTicksUnsynced, readAnswers, readTicks, ticksUnsynced, writeAnswers, writeTicks} from './study-aids/local-store'; + +/** Ticking several points in a row sends them to FlickMan once. */ +const SYNC_DELAY_MS = 1500; + +/** + * What a reader has done with a lecture's summary and quiz. The card under the video and the + * reading window share it, so ticking or answering in one shows in the other. Ticks are kept on + * the device at once and on FlickMan shortly after, so the reader's other devices show them too. + */ +@Injectable({ + providedIn: 'root', +}) +export class StudyAidsProgressService { + private service = inject(StudyAidsService); + + private readonly ticks = new Map>>(); + private readonly answers = new Map | null>>(); + private readonly sent = new Set(); + /** Lectures whose ticks were matched with FlickMan's on this visit. */ + private readonly matched = new Set(); + private readonly waiting = new Map>(); + + ticks$(videoId: number): Observable> { + this.match(videoId); + + return this.tickSubject(videoId).asObservable(); + } + + setTick(videoId: number, key: string, ticked: boolean): void { + const next = new Set(this.tickSubject(videoId).value); + if (ticked) { + next.add(key); + } else { + next.delete(key); + } + this.saveTicks(videoId, next); + } + + clearTicks(videoId: number): void { + this.saveTicks(videoId, new Set()); + } + + /** This device's quiz answers, by question id; null until the quiz is first answered here. */ + answers$(videoId: number): Observable | null> { + return this.answerSubject(videoId).asObservable(); + } + + saveAnswers(videoId: number, answers: Record): void { + writeAnswers(videoId, answers); + this.answerSubject(videoId).next(answers); + } + + /** True only the first time it is asked about an answer: the one worth sending for the class stats. */ + claimSend(videoId: number, questionId: string): boolean { + const key = videoId + ':' + questionId; + if (this.sent.has(key)) { + return false; + } + this.sent.add(key); + + return true; + } + + private saveTicks(videoId: number, ticks: ReadonlySet): void { + this.keepTicks(videoId, ticks); + markTicksUnsynced(videoId, true); + clearTimeout(this.waiting.get(videoId)); + this.waiting.set(videoId, setTimeout(() => this.send(videoId), SYNC_DELAY_MS)); + } + + private keepTicks(videoId: number, ticks: ReadonlySet): void { + writeTicks(videoId, ticks); + this.tickSubject(videoId).next(ticks); + } + + /** Sends this device's ticks; if that fails they stay marked, and go with the next change or visit. */ + private send(videoId: number): void { + this.waiting.delete(videoId); + const ticks = this.tickSubject(videoId).value; + this.service.saveTicks(videoId, [...ticks]).subscribe({ + next: () => { + // A tick made while the request was out is sent by its own timer. + if (this.tickSubject(videoId).value === ticks) { + markTicksUnsynced(videoId, false); + } + }, + error: () => undefined, + }); + } + + /** + * Once a visit, takes the ticks FlickMan keeps for the reader, unless this device has changes + * FlickMan has not had yet, or ticks from before FlickMan kept any, which are sent instead. + */ + private match(videoId: number): void { + if (this.matched.has(videoId)) { + return; + } + this.matched.add(videoId); + this.service.watch(videoId).pipe(filter(state => state.status !== 'loading'), take(1)).subscribe(state => { + if (state.status !== 'ready') { + this.matched.delete(videoId); + + return; + } + const saved = state.aids.my_ticks ?? null; + if (ticksUnsynced(videoId) || (saved === null && this.tickSubject(videoId).value.size)) { + this.send(videoId); + } else if (saved !== null) { + this.keepTicks(videoId, new Set(saved)); + } + }); + } + + private tickSubject(videoId: number): BehaviorSubject> { + let subject = this.ticks.get(videoId); + if (!subject) { + subject = new BehaviorSubject>(readTicks(videoId)); + this.ticks.set(videoId, subject); + } + + return subject; + } + + private answerSubject(videoId: number): BehaviorSubject | null> { + let subject = this.answers.get(videoId); + if (!subject) { + subject = new BehaviorSubject(readAnswers(videoId)); + this.answers.set(videoId, subject); + } + + return subject; + } +} diff --git a/src/app/study-aids.service.spec.ts b/src/app/study-aids.service.spec.ts new file mode 100644 index 0000000..be11d31 --- /dev/null +++ b/src/app/study-aids.service.spec.ts @@ -0,0 +1,227 @@ +import {TestBed} from '@angular/core/testing'; +import {HttpErrorResponse} from '@angular/common/http'; +import {Observable, of, throwError} from 'rxjs'; +import {ManService} from './man.service'; +import {StudyAidsService} from './study-aids.service'; +import {StudyAidsState, TranscriptState} from './study-aids/study-aids.model'; + +const SUMMARY = {markdown: '## Nucleus [00:01:10]\n\n1. **Barr body** : X − 1'}; + +const QUESTION = { + id: '01M4D7B2STKMV08W6Z0ZF3G56N', + prompt: 'เซลล์ชนิดใดมี smooth ER มากที่สุด', + choices: ['Plasma cell', 'Pancreatic acinar cell', 'Leydig cell', 'Neutrophil', 'Mature red blood cell'], + answer: 2, + explanation: null, + time: 465, + stats: {answered: 12, correct: 10}, + mine: null, +}; + +const QUIZ = {questions: [QUESTION]}; + +interface Body { + operationName: string; + query: string; + variables: Record; +} + +type Handler = (variables: Record) => Observable; + +function data(value: object): Observable { + return of({data: value}); +} + +function graphqlError(message: string, extensions: object = {}): Observable { + return of({errors: [{message, extensions}], data: null}); +} + +function lecture(changes: object = {}, video: object = {}): Observable { + return data({video: { + has_transcript: false, + studyAids: {video_id: '7', generation: 'ready', summary: SUMMARY, quiz: QUIZ, ...changes}, + ...video, + }}); +} + +function httpError(status: number): Observable { + return throwError(() => new HttpErrorResponse({status})); +} + +describe('StudyAidsService', () => { + let service: StudyAidsService; + let man: jasmine.SpyObj>; + let handlers: Record; + + const latest = (videoId: number): StudyAidsState => { + let state: StudyAidsState = {status: 'loading'}; + service.watch(videoId).subscribe(value => state = value).unsubscribe(); + + return state; + }; + const calls = (operation: string) => man.post.calls.all().filter(call => (call.args[1] as Body).operationName === operation); + const variablesOf = (operation: string) => (calls(operation).pop()?.args[1] as Body).variables; + + beforeEach(() => { + handlers = {StudyAids: () => lecture()}; + man = jasmine.createSpyObj('ManService', ['post']); + man.post.and.callFake(((path: string, body: Body | FormData) => { + expect(path).toBe('graphql'); + const operation = body instanceof FormData ? JSON.parse(body.get('operations') as string) as Body : body; + const handler = handlers[operation.operationName]; + + return handler ? handler(operation.variables) : graphqlError('No handler for ' + operation.operationName); + }) as never); + TestBed.configureTestingModule({ + providers: [{provide: ManService, useValue: man}], + }); + service = TestBed.inject(StudyAidsService); + }); + + describe('watch', () => { + it('reads a lecture\'s summary and quiz', () => { + const state = latest(7); + + expect(variablesOf('StudyAids')).toEqual({id: '7'}); + expect(state.status).toBe('ready'); + if (state.status === 'ready') { + expect(state.aids.video_id).toBe(7); + expect(state.aids.generation).toBe('ready'); + expect(state.aids.has_transcript).toBeFalse(); + expect(state.aids.summary).toEqual(SUMMARY); + expect(state.aids.quiz?.questions[0].time).toBe(465); + } + }); + + it('reads whether there is a transcript', () => { + handlers['StudyAids'] = () => lecture({}, {has_transcript: true}); + const state = latest(7); + + expect(state.status === 'ready' && state.aids.has_transcript).toBeTrue(); + }); + + it('asks once for both tabs', () => { + latest(7); + latest(7); + + expect(calls('StudyAids').length).toBe(1); + }); + + it('drops a malformed question and keeps the rest', () => { + handlers['StudyAids'] = () => lecture({quiz: {...QUIZ, questions: [QUESTION, {...QUESTION, id: 'bad', answer: 9}]}}); + const state = latest(7); + + expect(state.status === 'ready' && state.aids.quiz?.questions.map(question => question.id)).toEqual([QUESTION.id]); + }); + + it('shows nothing for a lecture FlickMan does not have', () => { + handlers['StudyAids'] = () => data({video: null}); + + expect(latest(7).status).toBe('unavailable'); + handlers['StudyAids'] = () => lecture(); + expect(latest(8).status).toBe('ready'); + }); + + it('stops asking once the server turns out to have no study aids', () => { + handlers['StudyAids'] = () => graphqlError('Cannot query field "studyAids" on type "Video".'); + + expect(latest(7).status).toBe('unavailable'); + expect(latest(8).status).toBe('unavailable'); + expect(calls('StudyAids').length).toBe(1); + }); + + it('asks again after a failure, such as the rate limit', () => { + handlers['StudyAids'] = () => graphqlError('Rate limit for Video.studyAids exceeded. Try again later.'); + + expect(latest(7).status).toBe('error'); + handlers['StudyAids'] = () => httpError(0); + expect(latest(7).status).toBe('error'); + handlers['StudyAids'] = () => lecture(); + expect(latest(7).status).toBe('ready'); + }); + }); + + describe('answer', () => { + it('records the answer and shows the new class stats', () => { + latest(7); + handlers['AnswerStudyQuestion'] = () => data({answerStudyQuestion: {answered: 13, correct: 11}}); + service.answer(7, QUESTION.id, 2).subscribe(); + + expect(variablesOf('AnswerStudyQuestion')).toEqual({video_id: '7', question_id: QUESTION.id, choice: 2}); + const state = latest(7); + const question = state.status === 'ready' ? state.aids.quiz?.questions[0] : undefined; + expect(question?.stats).toEqual({answered: 13, correct: 11}); + expect(question?.mine).toBe(2); + }); + + it('reloads the quiz when the question has gone', () => { + latest(7); + handlers['AnswerStudyQuestion'] = () => data({answerStudyQuestion: null}); + service.answer(7, QUESTION.id, 2).subscribe(); + + expect(calls('StudyAids').length).toBe(2); + }); + }); + + describe('transcript', () => { + const latestTranscript = (videoId: number): TranscriptState => { + let state: TranscriptState = {status: 'loading'}; + service.transcript(videoId).subscribe(value => state = value).unsubscribe(); + + return state; + }; + + it('reads the paragraphs in the order spoken, once a visit', () => { + handlers['LectureTranscript'] = () => data({video: {transcript: {paragraphs: [ + {time: 70, text: 'The smooth ER stores Ca²⁺.'}, + {time: 65, text: 'overlap'}, + {time: 5, text: ' '}, + ]}}}); + + expect(latestTranscript(7)).toEqual({status: 'ready', paragraphs: [{time: 70, text: 'The smooth ER stores Ca²⁺.'}, {time: 65, text: 'overlap'}]}); + latestTranscript(7); + expect(calls('LectureTranscript').length).toBe(1); + expect(variablesOf('LectureTranscript')).toEqual({id: '7'}); + }); + + it('knows a lecture without a transcript from a failure', () => { + handlers['LectureTranscript'] = () => data({video: {transcript: null}}); + expect(latestTranscript(7)).toEqual({status: 'none'}); + + handlers['LectureTranscript'] = () => graphqlError('Rate limit for Video.transcript exceeded. Try again later.'); + expect(latestTranscript(8)).toEqual({status: 'error', limited: true}); + }); + + it('only tries again when asked', () => { + handlers['LectureTranscript'] = () => httpError(500); + expect(latestTranscript(7)).toEqual({status: 'error', limited: false}); + latestTranscript(7); + expect(calls('LectureTranscript').length).toBe(1); + + handlers['LectureTranscript'] = () => data({video: {transcript: {paragraphs: [{time: 0, text: 'Hello'}]}}}); + service.retryTranscript(7); + expect(latestTranscript(7).status).toBe('ready'); + }); + }); + + describe('ticks', () => { + it('reads the ticks kept for this reader', () => { + handlers['StudyAids'] = () => lecture({my_ticks: ['k3x9', 'a1-b2']}); + const state = latest(7); + + expect(state.status === 'ready' && state.aids.my_ticks).toEqual(['k3x9', 'a1-b2']); + handlers['StudyAids'] = () => lecture({my_ticks: null}); + const fresh = latest(8); + expect(fresh.status === 'ready' && fresh.aids.my_ticks).toBeNull(); + }); + + it('keeps a lecture\'s ticks on FlickMan', () => { + handlers['SaveStudyTicks'] = () => data({saveStudyTicks: ['k3x9', 'a1-b2']}); + let saved: string[] | undefined; + service.saveTicks(7, ['k3x9', 'a1-b2', 'k3x9']).subscribe(ticks => saved = ticks); + + expect(variablesOf('SaveStudyTicks')).toEqual({video_id: '7', ticks: ['k3x9', 'a1-b2', 'k3x9']}); + expect(saved).toEqual(['k3x9', 'a1-b2']); + }); + }); +}); diff --git a/src/app/study-aids.service.ts b/src/app/study-aids.service.ts new file mode 100644 index 0000000..702e991 --- /dev/null +++ b/src/app/study-aids.service.ts @@ -0,0 +1,262 @@ +import {inject, Injectable} from '@angular/core'; +import {HttpErrorResponse} from '@angular/common/http'; +import {BehaviorSubject, Observable, of, Subscription, throwError} from 'rxjs'; +import {catchError, map, tap} from 'rxjs/operators'; +import {ManService} from './man.service'; +import {ANSWER, Operation, SAVE_TICKS, STUDY_AIDS, TRANSCRIPT} from './study-aids/operations'; +import {isObject, parseStats, parseStudyAids, parseTranscript, text} from './study-aids/parse'; +import {AnswerStats, QuizQuestion, StudyAids, StudyAidsState, TranscriptState} from './study-aids/study-aids.model'; + +/** A lecture's study aids are fetched again when a card shows them after this long. */ +const FRESH_FOR_MS = 5 * 60 * 1000; +/** A transcript can be long, and FlickMan only lets a reader fetch a few an hour. */ +const KEPT_TRANSCRIPTS = 6; + +interface Entry { + state: BehaviorSubject; + fetchedAt: number; + request: Subscription | null; +} + +type Fields = Record; + +interface Reply { + data?: Fields | null; + errors?: unknown[]; +} + +/** What can go wrong, including a FlickMan that has no study aids at all. */ +type Failure = {kind: 'forbidden' | 'rate-limited' | 'network' | 'unsupported' | 'failed'}; + +const UNEXPECTED: Failure = {kind: 'failed'}; + +function isFailure(value: unknown): value is Failure { + return isObject(value) && typeof value['kind'] === 'string'; +} + +/** The value at a path through an answer, such as `video`, `transcript`. */ +function at(data: unknown, ...keys: string[]): unknown { + return keys.reduce((value, key) => isObject(value) ? value[key] : null, data); +} + +function graphqlFailure(errors: unknown[]): Failure { + const first = errors.find(isObject); + const message = text(first?.['message']) ?? ''; + if (at(first, 'extensions', 'category') === 'authorization' || message === 'This action is unauthorized.') { + return {kind: 'forbidden'}; + } + if (/^Rate limit for .+ exceeded/.test(message)) { + return {kind: 'rate-limited'}; + } + // A FlickMan without study aids does not know their fields. + if (/^Cannot query field/.test(message)) { + return {kind: 'unsupported'}; + } + + return UNEXPECTED; +} + +function failureOf(error: unknown): Failure { + if (isFailure(error)) { + return error; + } + if (!(error instanceof HttpErrorResponse) || error.status === 0) { + return {kind: 'network'}; + } + const errors = at(error.error, 'errors'); + if (Array.isArray(errors) && errors.length) { + return graphqlFailure(errors); + } + switch (error.status) { + case 401: + case 403: + return {kind: 'forbidden'}; + case 429: + return {kind: 'rate-limited'}; + default: + return UNEXPECTED; + } +} + +/** + * The AI summary, quiz and transcript FlickMan keeps for each lecture, through its GraphQL API. + * The tabs share one request per lecture, and an answer updates what they show without fetching again. + */ +@Injectable({ + providedIn: 'root', +}) +export class StudyAidsService { + private man = inject(ManService); + + private readonly entries = new Map(); + private readonly transcripts = new Map>(); + /** Turned off for the visit once FlickMan turns out to have no study aids at all. */ + private supported = true; + + watch(videoId: number): Observable { + const entry = this.entry(videoId); + const status = entry.state.value.status; + if (!this.busy(entry) && (status === 'error' || Date.now() - entry.fetchedAt > FRESH_FOR_MS)) { + this.fetch(videoId, entry); + } + + return entry.state.asObservable(); + } + + refresh(videoId: number): void { + this.fetch(videoId, this.entry(videoId)); + } + + /** The AI's word-for-word transcript, fetched once a visit; a failure is only tried again on request. */ + transcript(videoId: number): Observable { + let subject = this.transcripts.get(videoId); + if (!subject) { + subject = new BehaviorSubject({status: 'loading'}); + this.transcripts.set(videoId, subject); + this.fetchTranscript(videoId, subject); + for (const old of [...this.transcripts.keys()].slice(0, -KEPT_TRANSCRIPTS)) { + this.transcripts.delete(old); + } + } + + return subject.asObservable(); + } + + retryTranscript(videoId: number): void { + const subject = this.transcripts.get(videoId); + if (subject?.value.status === 'error') { + subject.next({status: 'loading'}); + this.fetchTranscript(videoId, subject); + } + } + + /** Adds an answer to the class stats. Only a reader's first answer to a question counts. */ + answer(videoId: number, questionId: string, choice: number): Observable { + return this.run(ANSWER, {video_id: String(videoId), question_id: questionId, choice}).pipe( + map(data => { + const stats = parseStats(data['answerStudyQuestion']); + if (!stats) { + // The AI wrote a new quiz since this one was loaded. + this.refresh(videoId); + } + + return stats; + }), + tap(stats => this.updateQuestion(videoId, questionId, question => ({ + ...question, + stats: stats ?? question.stats, + mine: question.mine ?? choice, + }))), + catchError(() => of(null)), + ); + } + + /** Keeps the summary points this reader has ticked on FlickMan, so their other devices show them too. */ + saveTicks(videoId: number, ticks: string[]): Observable { + return this.run(SAVE_TICKS, {video_id: String(videoId), ticks}).pipe( + map(data => { + const saved = data['saveStudyTicks']; + if (!Array.isArray(saved)) { + throw UNEXPECTED; + } + + return saved.filter((key): key is string => typeof key === 'string'); + }), + ); + } + + /** The answer's data, or a Failure when FlickMan reports errors or cannot be reached. */ + private run(operation: Operation, variables: object): Observable { + return this.man.post('graphql', {operationName: operation.name, query: operation.query, variables}).pipe( + map(reply => { + if (!reply) { + throw {kind: 'network'} satisfies Failure; + } + if (Array.isArray(reply.errors) && reply.errors.length) { + throw graphqlFailure(reply.errors); + } + if (!isObject(reply.data)) { + throw UNEXPECTED; + } + + return reply.data; + }), + catchError((error: unknown) => throwError(() => failureOf(error))), + ); + } + + private fetchTranscript(videoId: number, subject: BehaviorSubject): void { + this.run(TRANSCRIPT, {id: String(videoId)}).subscribe({ + next: data => { + const paragraphs = parseTranscript(at(data, 'video', 'transcript')); + subject.next(paragraphs?.length ? {status: 'ready', paragraphs} : {status: 'none'}); + }, + error: (failure: Failure) => subject.next(failure.kind === 'forbidden' || failure.kind === 'unsupported' + ? {status: 'none'} + : {status: 'error', limited: failure.kind === 'rate-limited'}), + }); + } + + private fetch(videoId: number, entry: Entry): void { + entry.request?.unsubscribe(); + if (!this.supported) { + entry.state.next({status: 'unavailable'}); + + return; + } + if (entry.state.value.status !== 'ready') { + entry.state.next({status: 'loading'}); + } + entry.request = this.run(STUDY_AIDS, {id: String(videoId)}).subscribe({ + next: data => { + const video = at(data, 'video'); + // A lecture FlickMan does not have, or does not show this reader, comes back as null. + const aids = isObject(video) && isObject(video['studyAids']) + ? parseStudyAids({...video['studyAids'], has_transcript: video['has_transcript']}) + : null; + entry.fetchedAt = Date.now(); + entry.state.next(aids ? {status: 'ready', aids} : {status: 'unavailable'}); + }, + error: (failure: Failure) => { + if (failure.kind === 'unsupported') { + this.supported = false; + } + const status = failure.kind === 'unsupported' || failure.kind === 'forbidden' ? 'unavailable' : 'error'; + entry.fetchedAt = Date.now(); + // A failed refresh keeps what the tabs already show. + if (status === 'unavailable' || entry.state.value.status !== 'ready') { + entry.state.next({status}); + } + }, + }); + } + + private update(videoId: number, change: (aids: StudyAids) => StudyAids): void { + const entry = this.entries.get(videoId); + const state = entry?.state.value; + if (entry && state?.status === 'ready') { + entry.state.next({status: 'ready', aids: change(state.aids)}); + } + } + + private updateQuestion(videoId: number, questionId: string, change: (question: QuizQuestion) => QuizQuestion): void { + this.update(videoId, aids => aids.quiz ? { + ...aids, + quiz: {...aids.quiz, questions: aids.quiz.questions.map(question => question.id === questionId ? change(question) : question)}, + } : aids); + } + + private busy(entry: Entry): boolean { + return !!entry.request && !entry.request.closed; + } + + private entry(videoId: number): Entry { + let entry = this.entries.get(videoId); + if (!entry) { + entry = {state: new BehaviorSubject({status: 'loading'}), fetchedAt: 0, request: null}; + this.entries.set(videoId, entry); + } + + return entry; + } +} diff --git a/src/app/study-aids/checklist.spec.ts b/src/app/study-aids/checklist.spec.ts new file mode 100644 index 0000000..e90467e --- /dev/null +++ b/src/app/study-aids/checklist.spec.ts @@ -0,0 +1,150 @@ +import {buildChecklist, formatClock, formatStamp, parseTimestamp, plainText, renderInline} from './checklist'; + +// Shaped like FlickMan's demo summary: numbering restarts in every section, as the AI writes it. +const SUMMARY = `# Organelles + +## Nucleus [00:01:10] + +1. **Barr body** : inactivated X = facultative heterochromatin; จำนวน Barr body = จำนวน X − 1 +2. **Nucleolus** : สังเคราะห์ rRNA และประกอบ ribosomal subunit + +## Endoplasmic reticulum [00:06:30] + +1. **RER** : เริ่ม ==N-linked glycosylation (O-linked เกิดที่ Golgi)== +2. **SER** : เก็บ intracellular Ca²⁺ + +## Lysosome [00:21:40] + +1. **Gaucher disease** : ขาด glucocerebrosidase (*GBA1*) + 1. Gaucher cell : cytoplasm แบบ "crumpled tissue paper" + 2. Hepatosplenomegaly, pancytopenia, bone crisis +2. **Catalase** : $2\\,H_2O_2 \\rightarrow 2\\,H_2O + O_2$ + +### Compared + +| | Apoptosis | Necrosis | +|---|---|---| +| Cell | หดเล็กลง | บวม | +`; + +describe('buildChecklist', () => { + const checklist = buildChecklist(SUMMARY); + const points = checklist.sections.flatMap(section => section.blocks.filter(block => block.kind === 'point')); + + it('takes the first heading as the title', () => { + expect(checklist.title).toBe('Organelles'); + }); + + it('makes a section of each second-level heading and reads its time', () => { + expect(checklist.sections.length).toBe(3); + expect(checklist.sections.map(section => section.time)).toEqual([70, 390, 1300]); + expect(checklist.sections[0].headingHtml).toBe('Nucleus'); + }); + + it('numbers the points straight through the summary', () => { + expect(points.map(point => point.number)).toEqual([1, 2, 3, 4, 5, 6]); + expect(checklist.pointKeys.length).toBe(6); + }); + + it('keeps sub-points inside their point', () => { + const gaucher = points[4]; + + expect(gaucher.html).toContain('
    '); + expect(gaucher.html).toContain('crumpled tissue paper'); + expect(points.length).toBe(6); + }); + + it('renders highlights, key terms and maths placeholders', () => { + expect(points[2].html).toContain('N-linked glycosylation (O-linked เกิดที่ Golgi)'); + expect(points[0].html).toContain('Barr body'); + expect(points[5].html).toContain('class="math-inline"'); + }); + + it('keeps labels and tables as blocks of their own', () => { + const blocks = checklist.sections[2].blocks; + + expect(blocks.some(block => block.kind === 'label' && block.html === 'Compared')).toBeTrue(); + expect(blocks.some(block => block.kind === 'html' && block.html.includes(''))).toBeTrue(); + }); + + it('keeps a diagram drawn in a code block line for line, between the points', () => { + const diagram = ' [ Retromandibular v. ]\n ├── Anterior division + Facial v. ───────► [ Internal jugular v. ]\n │'; + const drawn = buildChecklist(`## Veins\n\n1. **Face** : first\n\n\`\`\`\n${diagram}\n\`\`\`\n\n1. **Neck** : second\n`); + const blocks = drawn.sections[0].blocks; + + expect(blocks.map(block => block.kind)).toEqual(['point', 'code', 'point']); + expect(blocks.map(block => block.number)).toEqual([1, null, 2]); + expect(new DOMParser().parseFromString(blocks[1].html, 'text/html').querySelector('pre')?.textContent).toBe(diagram + '\n'); + }); + + it('keys a point by its text, so a change to another point keeps its tick', () => { + const edited = buildChecklist(SUMMARY.replace('**SER**', '**SER (smooth ER)**')); + + expect(edited.pointKeys[0]).toBe(checklist.pointKeys[0]); + expect(edited.pointKeys[3]).not.toBe(checklist.pointKeys[3]); + }); + + it('gives repeated points different keys', () => { + const repeated = buildChecklist('1. Same point\n2. Same point\n'); + + expect(new Set(repeated.pointKeys).size).toBe(2); + }); + + it('puts content before any heading into a section without a heading', () => { + const loose = buildChecklist('1. First\n\n## Later\n\n1. Second\n'); + + expect(loose.title).toBeNull(); + expect(loose.sections[0].headingHtml).toBeNull(); + expect(loose.sections[1].headingHtml).toBe('Later'); + expect(loose.pointKeys.length).toBe(2); + }); + + it('leaves a heading alone when its time is not a real time', () => { + const odd = buildChecklist('## Odd [00:75:00]\n'); + + expect(odd.sections[0].time).toBeNull(); + expect(odd.sections[0].headingHtml).toContain('[00:75:00]'); + }); + + it('opens links in a new tab', () => { + expect(renderInline('[notes](https://example.com)')).toContain('target="_blank"'); + }); + + it('turns an image into a link, so nothing loads from elsewhere', () => { + const markdown = '![pixel](https://tracker.example/p.gif) raw'; + const html = renderInline(markdown); + + expect(html).not.toContain('pixel'); + expect(html).toContain('>raw'); + }); +}); + +describe('timestamps', () => { + it('reads m:ss, mm:ss and h:mm:ss', () => { + expect(parseTimestamp('7:45')).toBe(465); + expect(parseTimestamp('07:45')).toBe(465); + expect(parseTimestamp('1:02:03')).toBe(3723); + expect(parseTimestamp(' 00:01:10 ')).toBe(70); + }); + + it('refuses what is not a time', () => { + expect(parseTimestamp('')).toBeNull(); + expect(parseTimestamp('7:60')).toBeNull(); + expect(parseTimestamp('1:60:00')).toBeNull(); + expect(parseTimestamp('abc')).toBeNull(); + }); + + it('formats for reading and for writing', () => { + expect(formatClock(465)).toBe('7:45'); + expect(formatClock(3723)).toBe('1:02:03'); + expect(formatStamp(465)).toBe('00:07:45'); + expect(formatStamp(465.9)).toBe('00:07:45'); + }); +}); + +describe('plainText', () => { + it('drops the formatting', () => { + expect(plainText('**Bold** and ==marked==\nline')).toBe('Bold and marked line'); + }); +}); diff --git a/src/app/study-aids/checklist.ts b/src/app/study-aids/checklist.ts new file mode 100644 index 0000000..51239e8 --- /dev/null +++ b/src/app/study-aids/checklist.ts @@ -0,0 +1,250 @@ +import {Marked, MarkedExtension} from 'marked'; +import {mathExtension} from '../home/course/markdown-math'; + +/* + * A summary is Markdown written to these conventions (FlickMan's AI follows them too): + * + * # Title + * ## Section heading [HH:MM:SS] the time is optional, where the lecture starts the topic + * ### Label a small heading inside a section + * 1. Term : fact every top-level list item is one numbered point + * 1. detail nested items are sub-points + * ``` a diagram drawn in text, shown line for line in a fixed-width font + * **key term**, ==exam point==, tables and $maths$ + * + * Points are numbered here, continuously through the whole summary, so the numbers written in + * the Markdown never matter. + */ + +export interface ChecklistBlock { + kind: 'point' | 'label' | 'code' | 'html'; + /** Ticks are stored under a point's key, which only changes when that point's text does. */ + key: string; + /** The point's number; null for other blocks. */ + number: number | null; + html: string; +} + +export interface ChecklistSection { + headingHtml: string | null; + /** The heading as plain text, for the outline in the reading window. */ + headingText: string | null; + /** Seconds into the lecture where this section starts. */ + time: number | null; + blocks: ChecklistBlock[]; +} + +export interface Checklist { + title: string | null; + sections: ChecklistSection[]; + pointKeys: string[]; +} + +const HIGHLIGHT_RULE = /^==(?=\S)([\s\S]*?\S)==/; + +/** `==text==` becomes ``, for the points worth remembering for the exam. */ +export const highlightExtension: MarkedExtension = { + extensions: [ + { + name: 'highlight', + level: 'inline', + start: (src: string) => { + const index = src.indexOf('=='); + + return index >= 0 ? index : undefined; + }, + tokenizer(src: string) { + const match = HIGHLIGHT_RULE.exec(src); + + return match + ? {type: 'highlight', raw: match[0], text: match[1], tokens: this.lexer.inlineTokens(match[1])} + : undefined; + }, + renderer(token) { + return `${this.parser.parseInline(token.tokens ?? [])}`; + }, + }, + ], +}; + +export const studyMarked = new Marked(mathExtension, highlightExtension); + +/** Markdown for a block of text, such as a question or an explanation. */ +export function renderBlock(markdown: string): string { + return finishHtml(studyMarked.parse(markdown, {async: false})); +} + +/** Markdown for text that sits inside a line, such as a choice. */ +export function renderInline(markdown: string): string { + return finishHtml(studyMarked.parseInline(markdown, {async: false})); +} + +/** The words of some Markdown without its formatting, for a one-line preview. */ +export function plainText(markdown: string): string { + const text = new DOMParser().parseFromString(renderInline(markdown), 'text/html').body.textContent ?? ''; + + return text.replace(/\s+/g, ' ').trim(); +} + +const TIMESTAMP = /^(?:(\d{1,2}):)?(\d{1,2}):(\d{2})$/; +const HEADING_TIME = /\s*\[((?:\d{1,2}:)?\d{1,2}:\d{2})\]\s*$/; + +/** Reads `m:ss`, `mm:ss` or `h:mm:ss` into seconds. */ +export function parseTimestamp(value: string): number | null { + const match = TIMESTAMP.exec(value.trim()); + if (!match) { + return null; + } + const hours = Number(match[1] ?? 0); + const minutes = Number(match[2]); + const seconds = Number(match[3]); + if (seconds >= 60 || (match[1] !== undefined && minutes >= 60)) { + return null; + } + + return hours * 3600 + minutes * 60 + seconds; +} + +/** `7:45` or `1:02:03`, for reading. */ +export function formatClock(totalSeconds: number): string { + const total = Math.max(0, Math.floor(totalSeconds)); + const hours = Math.floor(total / 3600); + const minutes = Math.floor(total % 3600 / 60); + const seconds = String(total % 60).padStart(2, '0'); + + return hours > 0 ? `${hours}:${String(minutes).padStart(2, '0')}:${seconds}` : `${minutes}:${seconds}`; +} + +/** `00:07:45`, the form written into summaries. */ +export function formatStamp(totalSeconds: number): string { + const total = Math.max(0, Math.floor(totalSeconds)); + + return [Math.floor(total / 3600), Math.floor(total % 3600 / 60), total % 60] + .map(part => String(part).padStart(2, '0')) + .join(':'); +} + +/** FNV-1a, enough to tell a point's text from another's. */ +function hash(value: string): string { + let result = 0x811c9dc5; + for (let i = 0; i < value.length; i++) { + result ^= value.charCodeAt(i); + result = Math.imul(result, 0x01000193); + } + + return (result >>> 0).toString(36); +} + +function openInNewTab(link: Element): void { + link.setAttribute('target', '_blank'); + link.setAttribute('rel', 'noopener noreferrer'); +} + +/** + * Links open in a new tab, so following one never takes the reader away from the lecture. An image + * is only a link: FlickMan never serves one, and loading it would tell a third party who is reading. + */ +function finishHtml(html: string): string { + const body = new DOMParser().parseFromString(html, 'text/html').body; + for (const image of Array.from(body.querySelectorAll('img'))) { + const src = image.getAttribute('src') ?? ''; + if (!src) { + image.remove(); + continue; + } + const link = body.ownerDocument.createElement('a'); + link.setAttribute('href', src); + link.textContent = image.getAttribute('alt') || 'Image'; + image.replaceWith(link); + } + body.querySelectorAll('a[href]').forEach(openInNewTab); + + return body.innerHTML; +} + +/** Removes the trailing `[HH:MM:SS]` from a heading and returns it in seconds. */ +function takeHeadingTime(heading: Element): number | null { + const match = HEADING_TIME.exec(heading.textContent ?? ''); + const time = match ? parseTimestamp(match[1]) : null; + if (time === null) { + return null; + } + const walker = heading.ownerDocument.createTreeWalker(heading, NodeFilter.SHOW_TEXT); + let last: Text | null = null; + while (walker.nextNode()) { + last = walker.currentNode as Text; + } + if (!last || !HEADING_TIME.test(last.data)) { + return null; + } + last.data = last.data.replace(HEADING_TIME, ''); + + return time; +} + +export function buildChecklist(markdown: string): Checklist { + const html = renderBlock(markdown); + const body = new DOMParser().parseFromString(html, 'text/html').body; + const checklist: Checklist = {title: null, sections: [], pointKeys: []}; + const occurrences = new Map(); + let section: ChecklistSection | null = null; + let blockCount = 0; + + const currentSection = (): ChecklistSection => { + if (!section) { + section = {headingHtml: null, headingText: null, time: null, blocks: []}; + checklist.sections.push(section); + } + + return section; + }; + const addBlock = (kind: ChecklistBlock['kind'], content: string) => { + currentSection().blocks.push({kind, key: 'block-' + blockCount++, number: null, html: content}); + }; + + for (const element of Array.from(body.children)) { + const tag = element.tagName; + if (tag === 'H1' && checklist.title === null && checklist.sections.length === 0) { + checklist.title = element.textContent?.trim() || null; + } else if (tag === 'H1' || tag === 'H2') { + const time = takeHeadingTime(element); + section = {headingHtml: element.innerHTML.trim(), headingText: element.textContent?.trim() || null, time, blocks: []}; + checklist.sections.push(section); + } else if (/^H[3-6]$/.test(tag)) { + addBlock('label', element.innerHTML.trim()); + } else if (tag === 'OL' || tag === 'UL') { + for (const item of Array.from(element.children).filter(child => child.tagName === 'LI')) { + const content = (item.textContent ?? '').replace(/\s+/g, ' ').trim(); + const seen = (occurrences.get(content) ?? 0) + 1; + occurrences.set(content, seen); + const key = hash(content) + (seen > 1 ? '-' + seen : ''); + checklist.pointKeys.push(key); + currentSection().blocks.push({kind: 'point', key, number: checklist.pointKeys.length, html: item.innerHTML.trim()}); + } + } else if (tag === 'PRE') { + addBlock('code', element.outerHTML); + } else if (tag === 'TABLE') { + addBlock('html', `
    ${element.outerHTML}
    `); + } else { + addBlock('html', element.outerHTML); + } + } + + return checklist; +} + +const recent = new Map(); + +/** buildChecklist, remembered for the last few summaries, since the card and the reading window both show one. */ +export function checklistFor(markdown: string): Checklist { + let checklist = recent.get(markdown); + if (!checklist) { + checklist = buildChecklist(markdown); + if (recent.size >= 4) { + recent.delete(recent.keys().next().value as string); + } + recent.set(markdown, checklist); + } + + return checklist; +} diff --git a/src/app/study-aids/displayed.ts b/src/app/study-aids/displayed.ts new file mode 100644 index 0000000..793886b --- /dev/null +++ b/src/app/study-aids/displayed.ts @@ -0,0 +1,24 @@ +import {NgZone} from '@angular/core'; +import {Observable} from 'rxjs'; + +/** + * Whether the element is laid out at all. The settings sheet switches a card off with CSS, and a + * card that is switched off should cost the server nothing. Needs the element to be a block. + */ +export function displayed(element: HTMLElement, zone: NgZone): Observable { + return new Observable(subscriber => { + let shown = element.getClientRects().length > 0; + subscriber.next(shown); + // Resizes are frequent while a card's content changes, so only a change re-enters Angular. + const observer = new ResizeObserver(() => { + const now = element.getClientRects().length > 0; + if (now !== shown) { + shown = now; + zone.run(() => subscriber.next(now)); + } + }); + observer.observe(element); + + return () => observer.disconnect(); + }); +} diff --git a/src/app/study-aids/local-store.ts b/src/app/study-aids/local-store.ts new file mode 100644 index 0000000..102adc9 --- /dev/null +++ b/src/app/study-aids/local-store.ts @@ -0,0 +1,92 @@ +import {isStudyTab, StudyTab} from './tabs'; + +/* + * What a reader does with the summary and quiz stays on the device: ticks, quiz answers in + * progress, whether the card is folded away and which tab is open. + * Storage may be unavailable (private browsing), in which case everything still works for the visit. + */ + +const TICKS_PREFIX = 'flickSummaryTicks:'; +/** Lectures whose ticks changed here and have not reached FlickMan yet. */ +const UNSYNCED_TICKS_KEY = 'flickSummaryTicksUnsynced'; +const ANSWERS_PREFIX = 'flickQuizAnswers:'; +const COLLAPSED_KEY = 'flickStudyAidsCollapsed'; +const TAB_KEY = 'flickStudyAidsTab'; + +function read(key: string): unknown { + try { + const raw = localStorage.getItem(key); + + return raw === null ? null : JSON.parse(raw); + } catch { + return null; + } +} + +function write(key: string, value: unknown): void { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch { + // Kept for this visit only. + } +} + +export function readTicks(videoId: number): Set { + const stored = read(TICKS_PREFIX + videoId); + + return new Set(Array.isArray(stored) ? stored.filter((key): key is string => typeof key === 'string') : []); +} + +export function writeTicks(videoId: number, ticks: ReadonlySet): void { + write(TICKS_PREFIX + videoId, [...ticks]); +} + +export function ticksUnsynced(videoId: number): boolean { + const stored = read(UNSYNCED_TICKS_KEY); + + return Array.isArray(stored) && stored.includes(videoId); +} + +export function markTicksUnsynced(videoId: number, unsynced: boolean): void { + const stored = read(UNSYNCED_TICKS_KEY); + const others = (Array.isArray(stored) ? stored : []).filter(id => typeof id === 'number' && id !== videoId); + write(UNSYNCED_TICKS_KEY, unsynced ? [...others, videoId] : others); +} + +/** Null when this device has never started the quiz, as opposed to having cleared it. */ +export function readAnswers(videoId: number): Record | null { + const stored = read(ANSWERS_PREFIX + videoId); + if (typeof stored !== 'object' || stored === null || Array.isArray(stored)) { + return null; + } + const answers: Record = {}; + for (const [id, choice] of Object.entries(stored)) { + if (Number.isInteger(choice) && (choice as number) >= 0) { + answers[id] = choice as number; + } + } + + return answers; +} + +export function writeAnswers(videoId: number, answers: Record): void { + write(ANSWERS_PREFIX + videoId, answers); +} + +export function readCollapsed(): boolean { + return read(COLLAPSED_KEY) === true; +} + +export function writeCollapsed(collapsed: boolean): void { + write(COLLAPSED_KEY, collapsed); +} + +export function readTab(): StudyTab { + const stored = read(TAB_KEY); + + return isStudyTab(stored) ? stored : 'summary'; +} + +export function writeTab(tab: StudyTab): void { + write(TAB_KEY, tab); +} diff --git a/src/app/study-aids/operations.ts b/src/app/study-aids/operations.ts new file mode 100644 index 0000000..ec3d777 --- /dev/null +++ b/src/app/study-aids/operations.ts @@ -0,0 +1,34 @@ +/* + * The GraphQL operations the study aids use. Field names follow FlickMan's schema, which keeps the + * names of the old REST answers. + */ + +const QUIZ = 'questions { id prompt choices answer explanation time stats { answered correct } mine }'; + +export interface Operation { + name: string; + query: string; +} + +function operation(name: string, query: string): Operation { + return {name, query: query.replace(/\s+/g, ' ').trim()}; +} + +export const STUDY_AIDS = operation('StudyAids', `query StudyAids($id: ID!) { + video(id: $id) { + has_transcript + studyAids { video_id generation my_ticks summary { markdown } quiz { ${QUIZ} } } + } +}`); + +export const TRANSCRIPT = operation('LectureTranscript', `query LectureTranscript($id: ID!) { + video(id: $id) { transcript { paragraphs { time text } } } +}`); + +export const ANSWER = operation('AnswerStudyQuestion', `mutation AnswerStudyQuestion($video_id: ID!, $question_id: ID!, $choice: Int!) { + answerStudyQuestion(video_id: $video_id, question_id: $question_id, choice: $choice) { answered correct } +}`); + +export const SAVE_TICKS = operation('SaveStudyTicks', `mutation SaveStudyTicks($video_id: ID!, $ticks: [String!]!) { + saveStudyTicks(video_id: $video_id, ticks: $ticks) +}`); diff --git a/src/app/study-aids/parse.ts b/src/app/study-aids/parse.ts new file mode 100644 index 0000000..4999b19 --- /dev/null +++ b/src/app/study-aids/parse.ts @@ -0,0 +1,112 @@ +import {AnswerStats, GenerationState, QuizPart, QuizQuestion, StudyAids, SummaryPart, TranscriptParagraph} from './study-aids.model'; + +/* + * FlickMan's answers are checked field by field rather than trusted, so a malformed question is + * dropped instead of breaking the whole card. + */ + +const GENERATION_STATES: GenerationState[] = ['none', 'pending', 'ready', 'failed']; + +type Fields = Record; + +export function isObject(value: unknown): value is Fields { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function text(value: unknown): string | null { + return typeof value === 'string' ? value : null; +} + +function count(value: unknown): number | null { + return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : null; +} + +/** GraphQL sends IDs as strings: "7". */ +function numericId(value: unknown): number | null { + return count(typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : value); +} + +function seconds(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? Math.floor(value) : null; +} + +export function parseStats(value: unknown): AnswerStats | null { + if (!isObject(value)) { + return null; + } + const answered = count(value['answered']); + const correct = count(value['correct']); + + return answered !== null && correct !== null && correct <= answered ? {answered, correct} : null; +} + +function parseQuestion(value: unknown): QuizQuestion | null { + if (!isObject(value)) { + return null; + } + const id = text(value['id']); + const prompt = text(value['prompt']); + const choices = Array.isArray(value['choices']) && value['choices'].every(choice => typeof choice === 'string') + ? value['choices'] as string[] + : null; + const answer = count(value['answer']); + if (!id || prompt === null || !choices || choices.length < 2 || answer === null || answer >= choices.length) { + return null; + } + const mine = count(value['mine']); + + return { + id, + prompt, + choices, + answer, + explanation: text(value['explanation']) || null, + time: seconds(value['time']), + stats: parseStats(value['stats']), + mine: mine !== null && mine < choices.length ? mine : null, + }; +} + +export function parseSummary(value: unknown): SummaryPart | null { + const markdown = isObject(value) ? text(value['markdown']) : null; + + return markdown === null ? null : {markdown}; +} + +export function parseQuiz(value: unknown): QuizPart | null { + if (!isObject(value) || !Array.isArray(value['questions'])) { + return null; + } + + return {questions: value['questions'].map(parseQuestion).filter((question): question is QuizQuestion => question !== null)}; +} + +export function parseStudyAids(value: unknown): StudyAids | null { + if (!isObject(value)) { + return null; + } + const videoId = numericId(value['video_id']); + if (videoId === null) { + return null; + } + + return { + video_id: videoId, + generation: GENERATION_STATES.find(state => state === value['generation']) ?? 'none', + has_transcript: value['has_transcript'] === true, + my_ticks: Array.isArray(value['my_ticks']) ? value['my_ticks'].filter((key): key is string => typeof key === 'string') : null, + summary: parseSummary(value['summary']), + quiz: parseQuiz(value['quiz']), + }; +} + +export function parseTranscript(value: unknown): TranscriptParagraph[] | null { + if (!isObject(value) || !Array.isArray(value['paragraphs'])) { + return null; + } + + // Kept in the order spoken: a stamp can be earlier than the one before where two recorded pieces overlap. + return value['paragraphs'] + .map(paragraph => isObject(paragraph) ? {time: seconds(paragraph['time']), text: text(paragraph['text'])?.trim() ?? ''} : null) + .filter((paragraph): paragraph is TranscriptParagraph => !!paragraph && paragraph.time !== null && !!paragraph.text); +} diff --git a/src/app/study-aids/quiz.spec.ts b/src/app/study-aids/quiz.spec.ts new file mode 100644 index 0000000..16a7432 --- /dev/null +++ b/src/app/study-aids/quiz.spec.ts @@ -0,0 +1,36 @@ +import {effectiveAnswers, score} from './quiz'; +import {QuizQuestion} from './study-aids.model'; + +const QUESTION: QuizQuestion = { + id: '01M4D7B2STKMV08W6Z0ZF3G56N', + prompt: 'เซลล์ชนิดใดมี smooth ER มากที่สุด', + choices: ['Plasma cell', 'Pancreatic acinar cell', 'Leydig cell', 'Neutrophil', 'Mature red blood cell'], + answer: 2, + explanation: 'SER สังเคราะห์ steroid hormone', + time: 465, + stats: {answered: 12, correct: 10}, + mine: null, +}; + +describe('score', () => { + it('counts right answers only', () => { + const other = {...QUESTION, id: 'other', answer: 0}; + + expect(score([QUESTION, other], {[QUESTION.id]: 2, other: 1})).toBe(1); + }); +}); + +describe('effectiveAnswers', () => { + it('takes this device\'s answers, even when it has cleared them', () => { + const answered = {...QUESTION, mine: 1}; + + expect(effectiveAnswers({[QUESTION.id]: 2}, [answered])).toEqual({[QUESTION.id]: 2}); + expect(effectiveAnswers({}, [answered])).toEqual({}); + }); + + it('falls back to the first answers the reader gave on any device', () => { + const other = {...QUESTION, id: 'other', mine: 3}; + + expect(effectiveAnswers(null, [QUESTION, other])).toEqual({other: 3}); + }); +}); diff --git a/src/app/study-aids/quiz.ts b/src/app/study-aids/quiz.ts new file mode 100644 index 0000000..5f26a00 --- /dev/null +++ b/src/app/study-aids/quiz.ts @@ -0,0 +1,15 @@ +import {QuizQuestion} from './study-aids.model'; + +export const CHOICE_LETTERS = 'ABCDEFGH'; + +/** How many of the given answers are right. */ +export function score(questions: QuizQuestion[], answers: Record): number { + return questions.filter(question => answers[question.id] === question.answer).length; +} + +/** The answers to show: this device's, or else the first answers the reader gave on any device. */ +export function effectiveAnswers(stored: Record | null, questions: QuizQuestion[]): Record { + return stored ?? Object.fromEntries(questions + .filter(question => question.mine !== null) + .map(question => [question.id, question.mine as number])); +} diff --git a/src/app/study-aids/study-aids.model.ts b/src/app/study-aids/study-aids.model.ts new file mode 100644 index 0000000..04d3424 --- /dev/null +++ b/src/app/study-aids/study-aids.model.ts @@ -0,0 +1,74 @@ +/** + * The summary and quiz FlickMan's AI writes for each lecture, as served by `Video.studyAids` in its GraphQL API. + * Field names follow the API, like the rest of the ManService models. + */ +export interface StudyAids { + video_id: number; + generation: GenerationState; + /** Whether FlickMan has the AI's word-for-word transcript of the lecture. */ + has_transcript: boolean; + /** The summary points this reader has ticked, from any device; null until they first save some. */ + my_ticks: string[] | null; + summary: SummaryPart | null; + quiz: QuizPart | null; +} + +/** + * `none`: no lecture document to work from. `pending`: the AI is writing the parts, which can take + * up to a day. + */ +export type GenerationState = 'none' | 'pending' | 'ready' | 'failed'; + +export interface SummaryPart { + markdown: string; +} + +export interface QuizPart { + questions: QuizQuestion[]; +} + +export interface QuizQuestion { + id: string; + prompt: string; + choices: string[]; + /** Index into `choices`. */ + answer: number; + explanation: string | null; + /** Seconds into the lecture where the question's topic is taught. */ + time: number | null; + stats: AnswerStats | null; + /** This user's first answer, from any device. Only the first answer counts towards the stats. */ + mine: number | null; +} + +export interface AnswerStats { + answered: number; + correct: number; +} + +export interface TranscriptParagraph { + /** Seconds into the lecture where the paragraph starts. */ + time: number; + text: string; +} + +export type TranscriptState = + | {status: 'loading'} + | {status: 'ready'; paragraphs: TranscriptParagraph[]} + /** The lecture has no transcript. */ + | {status: 'none'} + /** `limited` when this reader has opened too many transcripts for now. */ + | {status: 'error'; limited: boolean}; + +/** Lets the cards read and move the lecture video without depending on the player. */ +export interface LecturePlayback { + currentTime(): number; + seek(seconds: number): void; +} + +export type StudyAidsState = + | {status: 'loading'} + | {status: 'ready'; aids: StudyAids} + /** Nothing to show for this lecture, or no study aids on this server. */ + | {status: 'unavailable'} + | {status: 'error'}; diff --git a/src/app/study-aids/tabs.ts b/src/app/study-aids/tabs.ts new file mode 100644 index 0000000..b21c55d --- /dev/null +++ b/src/app/study-aids/tabs.ts @@ -0,0 +1,29 @@ +import {StudyAids} from './study-aids.model'; + +export type StudyTab = 'summary' | 'transcript' | 'quiz'; + +export const TAB_LABELS: Record = { + summary: 'Summary', + transcript: 'Transcription', + quiz: 'Quiz', +}; + +/** The tabs with something in them, or that the AI is still writing. */ +export function studyTabs(aids: StudyAids): StudyTab[] { + const tabs: StudyTab[] = []; + if (aids.summary || aids.generation === 'pending') { + tabs.push('summary'); + } + if (aids.has_transcript) { + tabs.push('transcript'); + } + if (aids.quiz?.questions.length || (aids.generation === 'pending' && !aids.quiz)) { + tabs.push('quiz'); + } + + return tabs; +} + +export function isStudyTab(value: unknown): value is StudyTab { + return value === 'summary' || value === 'transcript' || value === 'quiz'; +}