Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
958e9be
feat: add device-local settings with widget discovery and fonts
Punnawit9285 Sep 5, 2026
79269ce
feat: hide widgets by attribute and paint settings before boot
Punnawit9285 Sep 5, 2026
2d57119
feat: add the settings sheet and its toolbar button
Punnawit9285 Sep 5, 2026
46a4ebe
feat: reset the Pomodoro session count at a chosen hour
Punnawit9285 Sep 5, 2026
e939c7d
fix: make the whole row the switch and pick the font from a dropdown
Punnawit9285 Sep 5, 2026
8c4386d
feat: reach the settings from the course and year toolbars
Punnawit9285 Sep 5, 2026
51c267a
feat: preview each typeface in the font picker
Punnawit9285 Oct 5, 2026
e6d66e2
fix: describe the Pomodoro timer without naming a page
Punnawit9285 Oct 5, 2026
e0bd9a5
feat: let Restore default settings reach every feature
Punnawit9285 Oct 5, 2026
9a8b313
fix: let the settings sheet scroll to its last control
Punnawit9285 Oct 6, 2026
5c6e314
Merge remote-tracking branch 'upstream/master' into settings
Punnawit9285 Oct 6, 2026
07b41b6
fix: keep the settings sheet's text clear of its edges
Punnawit9285 Oct 6, 2026
20753b0
feat: choose in Settings whether leaving the tab pauses the Pomodoro
Punnawit9285 Oct 6, 2026
6446667
test: check each settings switch hides its feature
Punnawit9285 Oct 6, 2026
588649d
feat: show Pomodoro timer on homepage with fullscreen toasts and in-p…
Punnawit9285 Oct 6, 2026
49867d5
refactor(settings): rename lecture session mode to Flick only
Punnawit9285 Oct 6, 2026
1322221
feat: sync Pomodoro preferences across devices
Punnawit9285 Oct 7, 2026
106daf8
feat: start with the Pomodoro and heatmap switched off
Punnawit9285 Oct 8, 2026
dc0c4b7
Merge remote-tracking branch 'upstream/master' into settings
Punnawit9285 Oct 10, 2026
4f2d191
refactor: keep the Pomodoro preferences on the device only
Punnawit9285 Oct 11, 2026
cdfed9d
Merge remote-tracking branch 'upstream/master' into settings
Punnawit9285 Oct 11, 2026
5e9f101
feat: show or hide the activity heatmap from Settings
Punnawit9285 Oct 11, 2026
0bf7c37
feat: choose how much the activity heatmap on the home page covers
Punnawit9285 Oct 11, 2026
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
7 changes: 6 additions & 1 deletion angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@
},
{
"input": "src/global.scss"
},
{
"input": "src/settings.scss"
}
],
"scripts": [],
Expand Down Expand Up @@ -123,7 +126,9 @@
],
"tsConfig": "tsconfig.spec.json",
"karmaConfig": "karma.conf.js",
"styles": [],
"styles": [
"src/settings.scss"
],
"scripts": [],
"assets": [
{
Expand Down
3 changes: 3 additions & 0 deletions src/app/home/course/course.page.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
<small>{{ (courseProgress.duration - courseProgress.viewed) / 3600 | number:'1.0-1' }} hours left ({{ (courseProgress.viewed / courseProgress.duration) * 100 | number:'1.0-1' }}%)</small>
}
</ion-title>
<ion-buttons slot="end">
<app-settings-menu></app-settings-menu>
</ion-buttons>
</ion-toolbar>
</ion-header>

Expand Down
14 changes: 14 additions & 0 deletions src/app/home/course/course.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
AlertController,
IonBackButton,
IonButton,
IonButtons,
IonCard,
IonCardContent,
IonCardHeader,
Expand All @@ -31,6 +32,7 @@ import {
IonToolbar,
ModalController,
} from '@ionic/angular';
import {SettingsMenuComponent} from '../../shared/settings-menu.component';
import {DomSanitizer} from '@angular/platform-browser';
import {PlayHistory} from '../../play-tracker.service';
import {addIcons} from "ionicons";
Expand Down Expand Up @@ -59,6 +61,7 @@ interface SeekBarPointerHandlers {
IonHeader,
IonToolbar,
IonBackButton,
IonButtons,
IonTitle,
IonContent,
IonGrid,
Expand All @@ -82,6 +85,7 @@ interface SeekBarPointerHandlers {
DecimalPipe,
DatePipe,
PomodoroTimerComponent,
SettingsMenuComponent,
]
})
export class CoursePage implements OnInit, AfterViewInit, OnDestroy {
Expand Down Expand Up @@ -192,6 +196,16 @@ export class CoursePage implements OnInit, AfterViewInit, OnDestroy {
},
},
techOrder: ['html5', 'youtube'],
// Keep playback in the page instead of handing off to Apple's native fullscreen player.
// That player renders outside the web view, so nothing in the DOM — including the
// Pomodoro phase notification — can be drawn over it.
playsinline: true,
// Only consulted when the browser exposes no Fullscreen API (iPhone, and the in-app
// web views used by the Google and Chrome apps on iOS). Without it video.js hands those
// browsers to the native player; with it they get video.js's own full-window mode, which
// keeps the page — and any overlay on it — visible. Browsers that do have the Fullscreen
// API are unaffected: they fullscreen the player element and overlays are placed inside it.
preferFullWindow: true,
}, () => {
this.isPlayerReady = true;
if (this.pendingVideoToPlay) {
Expand Down
42 changes: 30 additions & 12 deletions src/app/home/home.page.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
MDCU Recorded Lecture
</ion-title>
<ion-buttons slot="end">
<app-settings-menu></app-settings-menu>
<ion-button fill="outline" color="secondary" (click)="logout()">
<span class="ion-hide-sm-down text-sign-out">Sign out</span>
<ion-icon name="log-out-outline"></ion-icon>
Expand Down Expand Up @@ -126,6 +127,7 @@ <h2 class="video-title">{{ result.title }}</h2>
}
</div>
} @else if (response$ | async; as response) {
<app-pomodoro-timer></app-pomodoro-timer>
@if (response.last_played) {
<ion-card color="tertiary" class="ion-margin-top">
<ion-card-header>
Expand Down Expand Up @@ -161,7 +163,7 @@ <h2>{{ response.last_played.video.course.name }}</h2>
aria-label="Open your activity for the past year">
<ion-card-content>
<div class="play-stats-header">
<strong>Your activity &middot; last 7 days</strong>
<strong>Your activity &middot; {{ activityRange.label.toLowerCase() }}</strong>
<span class="play-stats-summary">
@if (recentTotals.actual) {
{{ formatDuration(recentTotals.video) }} video &middot; {{ formatDuration(recentTotals.actual) }} actual
Expand All @@ -170,19 +172,35 @@ <h2>{{ response.last_played.video.course.name }}</h2>
}
</span>
</div>
<div class="recent-heatmap">
@for (row of recentRows; track row.date) {
<span class="day-label">{{ row.label }}</span>
@for (cell of row.cells; track $index) {
@if (cell) {
<span class="cell level-{{ cell.level }}"
[title]="formatDay(cell.date) + ' ' + cell.hour + ':00 · ' + (formatDuration(cell.actual_seconds) || 'no activity')"></span>
} @else {
<span class="cell"></span>
@if (activityWeeks.length) {
<div class="period-heatmap">
@for (week of activityWeeks; track $index) {
<span class="month">{{ week.monthLabel }}</span>
@for (day of week.days; track $index) {
@if (day) {
<span class="cell level-{{ day.level }}"
[title]="formatDay(day.date) + ' · ' + (formatDuration(day.actual_seconds) || 'no activity')"></span>
} @else {
<span class="cell"></span>
}
}
}
}
</div>
</div>
} @else {
<div class="recent-heatmap">
@for (row of recentRows; track row.date) {
<span class="day-label">{{ row.label }}</span>
@for (cell of row.cells; track $index) {
@if (cell) {
<span class="cell level-{{ cell.level }}"
[title]="formatDay(cell.date) + ' ' + cell.hour + ':00 · ' + (formatDuration(cell.actual_seconds) || 'no activity')"></span>
} @else {
<span class="cell"></span>
}
}
}
</div>
}
<div class="play-stats-hint">Tap to see the whole year</div>
</ion-card-content>
</ion-card>
Expand Down
34 changes: 34 additions & 0 deletions src/app/home/home.page.scss
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ main {
}
}

/* iOS centres the title between fixed gutters, which the toolbar buttons outgrow on a phone.
There the title moves to the start and gives way to them instead of running underneath. */
@media (max-width: 575.98px) {
ion-title.ios {
position: static;
padding-inline: 16px 0;
text-align: start;
}
}

ion-card {
min-height: 4rem;
}
Expand Down Expand Up @@ -297,3 +307,27 @@ p {
.recent-heatmap {
@include hour-grid;
}

// A longer period chosen in the settings: a day a square, a column a week with Sunday on top,
// under the month that starts in it. Columns shrink to fit a year on the card.
.period-heatmap {
display: grid;
grid-template-rows: auto repeat(7, auto);
grid-auto-flow: column;
grid-auto-columns: minmax(0, 1rem);
gap: 2px;
justify-content: center;

.month {
font-size: 0.6rem;
line-height: 1.2;
color: var(--ion-color-medium);
white-space: nowrap;
}

.cell {
aspect-ratio: 1;
border-radius: 2px;
@include heat-levels;
}
}
46 changes: 44 additions & 2 deletions src/app/home/home.page.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,19 @@ import {TestBed, fakeAsync, tick} from '@angular/core/testing';
import {Router} from '@angular/router';
import {AlertController, ModalController} from '@ionic/angular';
import {Analytics} from '@angular/fire/analytics';
import {of, throwError} from 'rxjs';
import {BehaviorSubject, of, throwError} from 'rxjs';
import {HomePage, SearchResults} from './home.page';
import {ManService} from '../man.service';
import {ManService, PlayStats} from '../man.service';
import {AuthService} from '../auth.service';
import {ConsentService} from '../consent.service';
import {SettingsService} from '../settings.service';
import {ActivityRange} from '../settings/settings.model';

describe('HomePage', () => {
let component: HomePage;
let manService: jasmine.SpyObj<Pick<ManService, 'getVideoList' | 'getPlayStats' | 'searchVideos' | 'searchDocuments'>>;
let router: jasmine.SpyObj<Pick<Router, 'navigate'>>;
let activityRange$: BehaviorSubject<ActivityRange>;

const videoList = {
years: {
Expand All @@ -26,6 +29,7 @@ describe('HomePage', () => {
manService.getVideoList.and.returnValue(of(videoList));
manService.getPlayStats.and.returnValue(of(null));
router = jasmine.createSpyObj('Router', ['navigate']);
activityRange$ = new BehaviorSubject<ActivityRange>('7d');

TestBed.configureTestingModule({
providers: [
Expand All @@ -37,6 +41,7 @@ describe('HomePage', () => {
{provide: ConsentService, useValue: {current: 'unset'}},
{provide: ModalController, useValue: {}},
{provide: AlertController, useValue: {}},
{provide: SettingsService, useValue: {activityRange$}},
],
});
component = TestBed.inject(HomePage);
Expand Down Expand Up @@ -134,4 +139,41 @@ describe('HomePage', () => {
expect(component.isSearching).toBe(false);
expect(manService.searchVideos).not.toHaveBeenCalled();
}));
describe('activity heatmap', () => {
const stats: PlayStats = {
from: '2025-10-11',
to: '2026-10-10',
updated_at: '2026-10-10T12:00:00+07:00',
total: {sessions: 2, video_seconds: 5400, actual_seconds: 3600},
days: [
{date: '2026-09-01', sessions: 1, video_seconds: 1800, actual_seconds: 1200},
{date: '2026-10-09', sessions: 1, video_seconds: 3600, actual_seconds: 2400},
],
recent_hours: [{date: '2026-10-09', hour: 9, sessions: 1, video_seconds: 3600, actual_seconds: 2400}],
};

beforeEach(() => manService.getPlayStats.and.returnValue(of(stats)));

it('shows the last 7 days by the hour until a longer period is chosen', () => {
component.ionViewWillEnter();

expect(component.recentRows.length).toBe(7);
expect(component.activityWeeks).toEqual([]);
expect(component.recentTotals).toEqual({video: 3600, actual: 2400});
});

it('shows the period chosen in the settings a day a square', () => {
component.ionViewWillEnter();
activityRange$.next('1m');

expect(component.activityRange.label).toBe('Last month');
expect(component.recentRows).toEqual([]);
// 11 September to 10 October; the first of September falls outside it
expect(component.activityWeeks.flatMap(week => week.days).filter(day => day).length).toBe(30);
expect(component.recentTotals).toEqual({video: 3600, actual: 2400});

activityRange$.next('1y');
expect(component.recentTotals).toEqual({video: 5400, actual: 3600});
});
});
});
47 changes: 39 additions & 8 deletions src/app/home/home.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ import {Component, inject, OnInit} from '@angular/core';
import {Observable, of, Subject, timer} from 'rxjs';
import {CourseListResponse, DocumentSearchHit, Lecture, ManService, PlayStats, SearchVideoResult} from '../man.service';
import {Router, RouterLink} from '@angular/router';
import {SettingsMenuComponent} from '../shared/settings-menu.component';
import {PomodoroTimerComponent} from '../shared/pomodoro-timer.component';
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
import {SettingsService} from '../settings.service';
import {ACTIVITY_RANGES, DEFAULT_ACTIVITY_RANGE} from '../settings/settings-presets';
import {AuthService} from '../auth.service';
import {colorByFolderName, formatDuration, snippetToHtml} from '../../helpers';
import {addIcons} from "ionicons";
Expand Down Expand Up @@ -38,7 +43,7 @@ import {Analytics, logEvent} from '@angular/fire/analytics';
import {ConsentService} from '../consent.service';
import {confirmAiDisclaimer, ModalDocumentComponent} from './course/modal-document.component';
import {ModalPlayStatsComponent} from './modal-play-stats.component';
import {buildRecentGrid, formatDay, RecentRow, serverNow} from './play-stats';
import {buildRecentGrid, buildYearGrid, formatDay, rangeStart, RecentRow, serverNow, sumFrom, WeekColumn} from './play-stats';

// `title` searches videos by title, lecturer or date; `content` searches the AI-generated transcript documents.
export type SearchMode = 'title' | 'content';
Expand All @@ -64,6 +69,7 @@ export type SearchResults =
styleUrls: ['home.page.scss'],
imports: [
IonHeader, IonToolbar, IonTitle, IonButtons, IonButton, IonIcon,
SettingsMenuComponent, PomodoroTimerComponent,
IonContent, IonGrid, IonRow, IonCol, IonCard, RouterLink, NgStyle,
IonCardHeader, IonCardTitle, AsyncPipe, DatePipe, IonCardContent, IonItem,
IonLabel, IonText, IonSpinner, IonSearchbar, IonList, IonSegment, IonSegmentButton,
Expand All @@ -77,12 +83,17 @@ export class HomePage implements OnInit {
private consentService = inject(ConsentService);
private modalCtrl = inject(ModalController);
private alertController = inject(AlertController);
private settings = inject(SettingsService);

response$: Observable<CourseListResponse>;
playStats: PlayStats | null = null;
recentRows: RecentRow[] = [];
/** Totals of the 7-day grid, in seconds */
/** The days of a longer period, a column a week; empty while the last 7 days are shown by the hour */
activityWeeks: WeekColumn[] = [];
/** Totals of the grid shown, in seconds */
recentTotals = {video: 0, actual: 0};
/** The period the grid covers, chosen in the settings */
activityRange = ACTIVITY_RANGES.find(range => range.value === DEFAULT_ACTIVITY_RANGE);
searchQuery = '';
searchMode: SearchMode = 'title';
// The mode switch is only shown while the search box is focused or holds a query
Expand All @@ -97,6 +108,10 @@ export class HomePage implements OnInit {

constructor() {
addIcons({documentTextOutline, filmOutline, logOutOutline, playOutline, searchOutline});
this.settings.activityRange$.pipe(takeUntilDestroyed()).subscribe(value => {
this.activityRange = ACTIVITY_RANGES.find(range => range.value === value);
this.showActivity();
});
}

logout() {
Expand All @@ -109,15 +124,31 @@ export class HomePage implements OnInit {
ionViewWillEnter() {
this.manService.getPlayStats().pipe(catchError(() => of(null))).subscribe(stats => {
this.playStats = stats;
this.recentRows = stats ? buildRecentGrid(stats, undefined, serverNow(stats)) : [];
const cells = this.recentRows.flatMap(row => row.cells).filter(c => c);
this.recentTotals = {
video: cells.reduce((sum, c) => sum + c.video_seconds, 0),
actual: cells.reduce((sum, c) => sum + c.actual_seconds, 0),
};
this.showActivity();
});
}

/** The last 7 days by the hour, or a longer period by the day */
private showActivity() {
const stats = this.playStats;
const months = this.activityRange.months;
if (stats && months !== null) {
const from = rangeStart(stats, months);
const total = sumFrom(stats, from);
this.recentRows = [];
this.activityWeeks = buildYearGrid(stats, undefined, from);
this.recentTotals = {video: total.video_seconds, actual: total.actual_seconds};
return;
}
this.activityWeeks = [];
this.recentRows = stats ? buildRecentGrid(stats, undefined, serverNow(stats)) : [];
const cells = this.recentRows.flatMap(row => row.cells).filter(c => c);
this.recentTotals = {
video: cells.reduce((sum, c) => sum + c.video_seconds, 0),
actual: cells.reduce((sum, c) => sum + c.actual_seconds, 0),
};
}

ngOnInit() {
this.response$ = this.manService.getVideoList();

Expand Down
3 changes: 3 additions & 0 deletions src/app/home/list/list.page.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
<ion-title>
<span [ngStyle]="{'background-color': colorByFolderName(year)}">{{ year || 'Loading'}}</span>
</ion-title>
<ion-buttons slot="end">
<app-settings-menu></app-settings-menu>
</ion-buttons>
</ion-toolbar>
<ion-toolbar>
<ion-searchbar
Expand Down
Loading
Loading