Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions docs/components/topicComponent.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,140 @@
# Topic Component - Video Playback Issues

## Topic Attention Metrics

### Purpose

Topic tasks now send a non-blocking attention snapshot when an incomplete topic is marked complete. The metric is intended to help understand whether learners likely engaged with the topic content; it is not proof of reading or comprehension.

The learner experience is unchanged in v1:

- The "Mark as complete and continue" action is not blocked or delayed.
- Already completed topics continue without posting another progress update or attention payload.
- No raw topic text, pointer paths, keystrokes, or personal content is sent.

### Payload

`TopicComponent` emits a `TopicContinueEvent` with `{ topic, attention }`. Desktop and mobile pages pass `attention` into `TopicService.updateTopicProgress(id, 'completed', attention)`, which posts:

```json
{
"model": "topic",
"model_id": 123,
"state": "completed",
"meta": {
"attention": {
"version": 1,
"score": 80,
"confidence": "high",
"activeMs": 10000,
"visibleMs": 10000,
"estimatedReadMs": 9000,
"textWordCount": 30,
"contentExposureRatio": 1,
"mediaProgressRatio": 0,
"mediaPlayedMs": 0,
"filePreviewCount": 0,
"fileDownloadCount": 0,
"quickComplete": false
}
}
}
```

Backend requirement: `api/v2/motivations/progress/create.json` must accept and persist optional `meta.attention` without changing existing topic progress semantics.

### Signals And Scoring

The tracker uses native browser APIs and existing component interactions:

- Foreground visible time, paused while the document is hidden.
- Quick-complete flag when foreground time is under 5 seconds.
- Word count from the raw topic HTML retained during topic normalisation.
- Maximum rendered content exposure for `app-description`.
- Native audio/video and Plyr media progress/play time where available.
- Attachment preview/download counts from existing file actions.

Applicable signal weights are redistributed when a topic lacks that signal:

- Text exposure: `0.35`
- Reading-time ratio: `0.25`
- Media progress: `0.25`
- File interaction: `0.15`

`score` is `0-100`. `confidence` is `high` at `>= 75`, `medium` at `>= 40`, otherwise `low`. A quick-complete session forces `confidence` to `low` unless the score is already `>= 75`.

### Limitations

- Attention confidence is an engagement heuristic, not a comprehension measure.
- Embedded media providers may expose progress through Plyr inconsistently.
- File interaction counts indicate opening/downloading, not whether the file was read.
- v1 does not revive the old `stopped` progress state or add completion gating.

## Local Topic Time Spent Indicator

### Purpose

Incomplete topic tasks show a small "Time spent" indicator below the topic title. This is a local learner-facing timer and is separate from the backend attention metric.

### Storage And Lifecycle

- Each topic uses localStorage key `topicTimeSpent:<topicId>`.
- In activity navigation, the selected `Task.id` is the authoritative topic id for this key. This avoids continuing the previous topic timer while the next topic content is still loading asynchronously.
- The timer starts when `TopicComponent` receives a topic.
- The timer is persisted and stopped when the component changes topic, is destroyed, or leaves the page.
- Returning to the same topic resumes from the persisted value.
- Marking an incomplete topic complete removes the stored timer for that topic.
- Already completed topics do not show the indicator and do not clear any timer through the continue-only path.
- A direct mobile topic load refreshes the parent activity and matches its task by topic id, so a completed topic keeps its completed state after a browser refresh.

### Format

The visible timer uses `m:ss` until it reaches one hour, then `h:mm:ss`.

## Topic Attention And Completion Flow

The topic interaction is measurement-only in v1. The learner can complete the topic immediately; tracking does not block, delay, or require additional interaction.

```text
Topic loads
-> start attention tracking for the current topic session
-> start or resume the local topic time-spent timer

Learner interacts with the topic
-> accumulate foreground-visible time
-> record maximum content exposure
-> record media play time and furthest media progress
-> record file preview and download actions

Learner selects the completion action
-> build the aggregate attention snapshot
-> emit { topic, attention } from TopicComponent
-> remove the local timer for an incomplete topic

Desktop or mobile page receives the event
-> if the task is already done, navigate to the next task without posting progress
-> otherwise post completed topic progress with optional meta.attention
-> refresh activity state and continue to the next task
```

The UI uses the task status to choose the completion state:

- Incomplete topic: show `Time spent: m:ss` and `Mark as complete and continue`.
- Completed topic: hide the time indicator and show `Continue`.
- Completed topic continuation: skip `updateTopicProgress()` so attention is not submitted repeatedly.

### Measurement Boundaries

The local time-spent timer and backend attention metrics have different purposes and lifecycles:

- `topicTimeSpent:<topicId>` is a learner-facing localStorage value that accumulates across visits to the same topic. It is persisted when the topic is left, the component is destroyed, or the selected topic changes, and is removed when an incomplete topic is completed.
- The `attention` snapshot is created for the current TopicComponent session when the completion action is selected. It is sent only with the first completion request for an incomplete task.
- The accumulated local timer is currently not included in `meta.attention` and does not contribute to the attention score.
- `activeMs` and `visibleMs` currently represent the same foreground-visible session time. Hidden browser tabs pause this attention measurement; the local timer is stopped by page/component lifecycle events.
- The tracker records aggregate interaction signals only. It does not send raw topic HTML, pointer paths, keystrokes, or personal content.

On a direct mobile topic refresh, the page reloads the parent activity and matches the task by the route topic ID before rendering the completion state. This ensures a previously completed topic remains completed after refresh.

## Issue: YouTube Video Overlay Not Loading on Subsequent Visits

### Problem Description
Expand Down
4 changes: 4 additions & 0 deletions projects/v3/src/app/components/topic/topic.component.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
<div *ngIf="topic" class="ion-padding main-content" style="min-height: 100%;">
<div class="headline-2 topic-title" aria-live="polite" role="heading" [innerHTML]="sanitizedTitle"></div>
<div *ngIf="task?.status !== 'done'" class="topic-time-spent body-3" aria-live="polite">
<ion-icon name="time-outline" aria-hidden="true"></ion-icon>
<span i18n="topic time spent">Time spent: {{ formattedTopicTimeSpent }}</span>
</div>
<div *ngIf="topic.videolink && topic.videolink !=='magiclink'" class="text-center topic-video">
<div *ngIf="iframeHtml" class="video-embed" [innerHTML]="iframeHtml"></div>
<video
Expand Down
12 changes: 12 additions & 0 deletions projects/v3/src/app/components/topic/topic.component.scss
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
.topic-title {
margin-bottom: 19px;
}
.topic-time-spent {
align-items: center;
color: var(--practera-grey-75);
display: flex;
gap: 6px;
margin: -8px 0 18px;

ion-icon {
color: var(--ion-color-primary);
font-size: 18px;
}
}
.topic-video {
margin-bottom: 20px;
}
Expand Down
185 changes: 183 additions & 2 deletions projects/v3/src/app/components/topic/topic.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ describe('TopicComponent', () => {
sharedSpy = jasmine.createSpyObj('SharedService', ['stopPlayingVideos']);
routerSpy = jasmine.createSpyObj('Router', ['navigate']);
notificationSpy = jasmine.createSpyObj('NotificationsService', ['alert', 'presentToast']);
storageSpy = jasmine.createSpyObj('BrowserStorageService', ['getUser', 'get', 'remove']);
storageSpy = jasmine.createSpyObj('BrowserStorageService', ['getUser', 'get', 'set', 'remove']);
activitySpy = jasmine.createSpyObj('ActivityService', ['gotoNextTask']);

await TestBed.configureTestingModule({
Expand Down Expand Up @@ -67,7 +67,7 @@ describe('TopicComponent', () => {
utilsSpy = TestBed.inject(UtilsService) as jasmine.SpyObj<UtilsService>;

storageSpy.getUser.and.returnValue({ teamId: 1, projectId: 2 });
storageSpy.get.and.returnValue({});
storageSpy.get.and.returnValue(null);
});

it('should create', () => {
Expand All @@ -80,6 +80,187 @@ describe('TopicComponent', () => {
expect(sharedSpy.stopPlayingVideos).toHaveBeenCalledTimes(1);
});

it('should emit topic and attention metrics when continuing', fakeAsync(() => {
const topic = {
id: 1,
title: 'Topic',
rawContent: '<p>Topic body</p>',
files: [],
} as any;
const attention = {
version: 1,
score: 20,
confidence: 'low',
activeMs: 1000,
visibleMs: 1000,
estimatedReadMs: 300,
textWordCount: 1,
contentExposureRatio: 1,
mediaProgressRatio: 0,
mediaPlayedMs: 0,
filePreviewCount: 0,
fileDownloadCount: 0,
quickComplete: true,
} as any;
spyOn<any>(component, 'getAttentionMetrics').and.returnValue(attention);
const emitSpy = spyOn(component.continue, 'emit');

component.ngOnInit();
component.actionBarContinue(topic);
tick();

expect(emitSpy).toHaveBeenCalledWith({ topic, attention });
}));

it('should reset attention tracking when topic changes', () => {
const stopSpy = spyOn<any>(component, 'stopAttentionTracking').and.callThrough();
const startSpy = spyOn<any>(component, 'startAttentionTracking').and.callThrough();
component.topic = {
id: 2,
title: 'New topic',
rawContent: '<p>New topic body</p>',
files: [],
} as any;

component.ngOnChanges({
topic: {
currentValue: component.topic,
previousValue: { id: 1 },
firstChange: false,
isFirstChange: () => false
}
});

expect(stopSpy).toHaveBeenCalled();
expect(startSpy).toHaveBeenCalledWith(component.topic);
});

it('should include file interactions in attention metrics', () => {
component.topic = {
id: 3,
title: 'File topic',
rawContent: '',
files: [{ url: 'https://example.com/file.pdf', name: 'file.pdf' }],
} as any;
component['startAttentionTracking'](component.topic);

component.actionBtnClick(component.topic.files[0], 0);

expect(component['getAttentionMetrics']().fileDownloadCount).toBe(1);
});

it('should clean up attention listeners', () => {
component.topic = {
id: 4,
title: 'Cleanup topic',
rawContent: '<p>Cleanup body</p>',
files: [],
} as any;

component['startAttentionTracking'](component.topic);
expect(component['attentionListeners'].length).toBeGreaterThan(0);

component['stopAttentionTracking']();
expect(component['attentionListeners'].length).toBe(0);
});

it('should resume persisted topic time from local storage', () => {
storageSpy.get.withArgs('topicTimeSpent:5').and.returnValue(61000);

component['startTopicTimeTracking'](5);

expect(component.formattedTopicTimeSpent).toBe('1:01');
});

it('should persist topic time when tracking stops', () => {
component.topic = { id: 6, title: 'Timed topic', files: [] } as any;
storageSpy.get.withArgs('topicTimeSpent:6').and.returnValue(0);
spyOn(Date, 'now').and.returnValues(1000, 1000, 4000, 4000);

component['startTopicTimeTracking'](6);
component['stopTopicTimeTracking']();

expect(storageSpy.set).toHaveBeenCalledWith('topicTimeSpent:6', 3000);
});

it('should switch timer storage when selected task changes before topic data updates', () => {
component.topic = { id: 8, title: 'Old topic', files: [] } as any;
component.task = { id: 8, type: 'Topic', status: 'in progress' } as any;
storageSpy.get.withArgs('topicTimeSpent:8').and.returnValue(0);
storageSpy.get.withArgs('topicTimeSpent:9').and.returnValue(120000);
spyOn(Date, 'now').and.returnValues(1000, 1000, 5000, 5000, 5000, 5000);

component['startTopicTimeTracking'](8);
component.task = { id: 9, type: 'Topic', status: 'in progress' } as any;
component.ngOnChanges({
task: {
currentValue: component.task,
previousValue: { id: 8, type: 'Topic', status: 'in progress' },
firstChange: false,
isFirstChange: () => false
}
});

expect(storageSpy.set).toHaveBeenCalledWith('topicTimeSpent:8', 4000);
expect(storageSpy.get).toHaveBeenCalledWith('topicTimeSpent:9');
expect(component.formattedTopicTimeSpent).toBe('2:00');
});

it('should remove topic time when marking an incomplete topic complete', fakeAsync(() => {
const topic = { id: 7, title: 'Complete topic', files: [] } as any;
component.topic = topic;
component.task = { id: 7, type: 'Topic', status: 'in progress' } as any;
spyOn<any>(component, 'getAttentionMetrics').and.returnValue({
version: 1,
score: 0,
confidence: 'low',
activeMs: 0,
visibleMs: 0,
estimatedReadMs: 0,
textWordCount: 0,
contentExposureRatio: 0,
mediaProgressRatio: 0,
mediaPlayedMs: 0,
filePreviewCount: 0,
fileDownloadCount: 0,
quickComplete: true,
});

component.ngOnInit();
component.actionBarContinue(topic);
tick();

expect(storageSpy.remove).toHaveBeenCalledWith('topicTimeSpent:7');
expect(component.formattedTopicTimeSpent).toBe('0:00');
}));

it('should remove timer using selected task id instead of stale topic id', fakeAsync(() => {
const topic = { id: 10, title: 'Stale topic', files: [] } as any;
component.topic = topic;
component.task = { id: 11, type: 'Topic', status: 'in progress' } as any;
spyOn<any>(component, 'getAttentionMetrics').and.returnValue({
version: 1,
score: 0,
confidence: 'low',
activeMs: 0,
visibleMs: 0,
estimatedReadMs: 0,
textWordCount: 0,
contentExposureRatio: 0,
mediaProgressRatio: 0,
mediaPlayedMs: 0,
filePreviewCount: 0,
fileDownloadCount: 0,
quickComplete: true,
});

component.ngOnInit();
component.actionBarContinue(topic);
tick();

expect(storageSpy.remove).toHaveBeenCalledWith('topicTimeSpent:11');
}));

describe('ngOnChanges', () => {
it('should embed video when video element found', fakeAsync(() => {
const originalQSA = component['document'].querySelectorAll.bind(component['document']);
Expand Down
Loading