From df3a98456a57adaa522f18d3082b438de6df533d Mon Sep 17 00:00:00 2001 From: David Kamau Nganga Date: Wed, 22 Apr 2026 22:14:00 +1000 Subject: [PATCH 1/2] feat(peer-progress): add peer progress page and engagement heatmap widget Made-with: Cursor --- src/app/api/models/doubtfire-model.ts | 4 + src/app/api/models/engagement-heatmap.ts | 37 ++ .../engagement-heatmap.service.spec.ts | 112 ++++++ .../services/engagement-heatmap.service.ts | 73 ++++ .../task-dropdown.component.html | 23 +- src/app/doubtfire-angular.module.ts | 4 + src/app/doubtfire.states.ts | 20 + .../peer-progress.component.html | 30 ++ .../peer-progress.component.scss | 3 + .../peer-progress.component.spec.ts | 35 ++ .../peer-progress/peer-progress.component.ts | 32 ++ .../engagement-heatmap-card.component.html | 164 ++++++++ .../engagement-heatmap-card.component.scss | 368 ++++++++++++++++++ .../engagement-heatmap-card.component.spec.ts | 240 ++++++++++++ .../engagement-heatmap-card.component.ts | 156 ++++++++ .../engagement-heatmap.util.spec.ts | 170 ++++++++ .../engagement-heatmap.util.ts | 176 +++++++++ 17 files changed, 1641 insertions(+), 6 deletions(-) create mode 100644 src/app/api/models/engagement-heatmap.ts create mode 100644 src/app/api/services/engagement-heatmap.service.spec.ts create mode 100644 src/app/api/services/engagement-heatmap.service.ts create mode 100644 src/app/projects/states/peer-progress/peer-progress.component.html create mode 100644 src/app/projects/states/peer-progress/peer-progress.component.scss create mode 100644 src/app/projects/states/peer-progress/peer-progress.component.spec.ts create mode 100644 src/app/projects/states/peer-progress/peer-progress.component.ts create mode 100644 src/app/projects/states/peer-progress/widgets/engagement-heatmap-card/engagement-heatmap-card.component.html create mode 100644 src/app/projects/states/peer-progress/widgets/engagement-heatmap-card/engagement-heatmap-card.component.scss create mode 100644 src/app/projects/states/peer-progress/widgets/engagement-heatmap-card/engagement-heatmap-card.component.spec.ts create mode 100644 src/app/projects/states/peer-progress/widgets/engagement-heatmap-card/engagement-heatmap-card.component.ts create mode 100644 src/app/projects/states/peer-progress/widgets/engagement-heatmap-card/engagement-heatmap.util.spec.ts create mode 100644 src/app/projects/states/peer-progress/widgets/engagement-heatmap-card/engagement-heatmap.util.ts diff --git a/src/app/api/models/doubtfire-model.ts b/src/app/api/models/doubtfire-model.ts index ed485c939a..eca5f9ba7c 100644 --- a/src/app/api/models/doubtfire-model.ts +++ b/src/app/api/models/doubtfire-model.ts @@ -64,3 +64,7 @@ export * from '../services/task-similarity.service'; export * from '../services/test-attempt.service'; export * from '../models/d2l/d2l_assessment_mapping.service'; export * from '../services/feedback-template.service'; + +// Engagement heatmap -- unit-specific task activity for a project +export * from './engagement-heatmap'; +export * from '../services/engagement-heatmap.service'; diff --git a/src/app/api/models/engagement-heatmap.ts b/src/app/api/models/engagement-heatmap.ts new file mode 100644 index 0000000000..ef07c7ae1f --- /dev/null +++ b/src/app/api/models/engagement-heatmap.ts @@ -0,0 +1,37 @@ +/** + * Shape returned by `GET /api/projects/:id/engagement_heatmap`. + * + * This mirrors the backend `EngagementHeatmapService.build(project: ...)` + * contract (doubtfire-api). Keep this interface and the backend presenter in + * sync when the contract changes. + */ + +export interface EngagementHeatmapDay { + /** ISO date string, e.g. "2026-01-21". */ + date: string; + /** Count of activity events on that day for this project only. */ + activity_count: number; +} + +export interface EngagementHeatmapRange { + /** Inclusive start date of the window (ISO date string). */ + start_date: string; + /** Inclusive end date of the window (ISO date string). */ + end_date: string; + /** Number of days in the window (typically 84). */ + days: number; +} + +export interface EngagementHeatmapSummary { + tasks_completed: number; + active_days: number; + current_streak: number; +} + +export interface EngagementHeatmapResponse { + project_id: number; + unit_id: number; + range: EngagementHeatmapRange; + days: EngagementHeatmapDay[]; + summary: EngagementHeatmapSummary; +} diff --git a/src/app/api/services/engagement-heatmap.service.spec.ts b/src/app/api/services/engagement-heatmap.service.spec.ts new file mode 100644 index 0000000000..9876274db1 --- /dev/null +++ b/src/app/api/services/engagement-heatmap.service.spec.ts @@ -0,0 +1,112 @@ +import {TestBed} from '@angular/core/testing'; +import {HttpClientTestingModule, HttpTestingController} from '@angular/common/http/testing'; + +import API_URL from 'src/app/config/constants/apiUrl'; +import {EngagementHeatmapResponse} from '../models/engagement-heatmap'; +import {EngagementHeatmapService} from './engagement-heatmap.service'; + +function buildResponse(projectId: number): EngagementHeatmapResponse { + return { + project_id: projectId, + unit_id: 7, + range: {start_date: '2026-01-21', end_date: '2026-04-14', days: 84}, + days: [], + summary: {tasks_completed: 0, active_days: 0, current_streak: 0}, + }; +} + +describe('EngagementHeatmapService', () => { + let service: EngagementHeatmapService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [EngagementHeatmapService], + }); + service = TestBed.inject(EngagementHeatmapService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('fetches from the backend on a cold cache and emits the response', () => { + const received: EngagementHeatmapResponse[] = []; + service.get(42).subscribe((r) => received.push(r)); + + const req = httpMock.expectOne(`${API_URL}/projects/42/engagement_heatmap`); + expect(req.request.method).toBe('GET'); + req.flush(buildResponse(42)); + + expect(received.length).toBe(1); + expect(received[0].project_id).toBe(42); + }); + + it('serves a subsequent call from cache without a second HTTP request', () => { + service.get(42).subscribe(); + const req = httpMock.expectOne(`${API_URL}/projects/42/engagement_heatmap`); + req.flush(buildResponse(42)); + + const received: EngagementHeatmapResponse[] = []; + service.get(42).subscribe((r) => received.push(r)); + + httpMock.expectNone(`${API_URL}/projects/42/engagement_heatmap`); + expect(received.length).toBe(1); + expect(received[0].project_id).toBe(42); + }); + + it('caches each project independently', () => { + service.get(1).subscribe(); + httpMock.expectOne(`${API_URL}/projects/1/engagement_heatmap`).flush(buildResponse(1)); + + service.get(2).subscribe(); + httpMock.expectOne(`${API_URL}/projects/2/engagement_heatmap`).flush(buildResponse(2)); + + // Re-request project 1 — should hit cache, not refetch. + service.get(1).subscribe(); + httpMock.expectNone(`${API_URL}/projects/1/engagement_heatmap`); + }); + + it('invalidate(projectId) forces the next call to refetch', () => { + service.get(42).subscribe(); + httpMock.expectOne(`${API_URL}/projects/42/engagement_heatmap`).flush(buildResponse(42)); + + service.invalidate(42); + + service.get(42).subscribe(); + const req = httpMock.expectOne(`${API_URL}/projects/42/engagement_heatmap`); + req.flush(buildResponse(42)); + }); + + it('invalidate() with no argument clears the entire cache', () => { + service.get(1).subscribe(); + httpMock.expectOne(`${API_URL}/projects/1/engagement_heatmap`).flush(buildResponse(1)); + service.get(2).subscribe(); + httpMock.expectOne(`${API_URL}/projects/2/engagement_heatmap`).flush(buildResponse(2)); + + service.invalidate(); + + service.get(1).subscribe(); + httpMock.expectOne(`${API_URL}/projects/1/engagement_heatmap`).flush(buildResponse(1)); + service.get(2).subscribe(); + httpMock.expectOne(`${API_URL}/projects/2/engagement_heatmap`).flush(buildResponse(2)); + }); + + it('refetches once the cached entry is older than the TTL', () => { + // Freeze time so the TTL test is deterministic. + const base = 1_700_000_000_000; + spyOn(Date, 'now').and.returnValue(base); + + service.get(42).subscribe(); + httpMock.expectOne(`${API_URL}/projects/42/engagement_heatmap`).flush(buildResponse(42)); + + // Advance past the TTL (default 60 s). + (Date.now as jasmine.Spy).and.returnValue(base + EngagementHeatmapService.CACHE_TTL_MS + 1); + + service.get(42).subscribe(); + const req = httpMock.expectOne(`${API_URL}/projects/42/engagement_heatmap`); + req.flush(buildResponse(42)); + }); +}); diff --git a/src/app/api/services/engagement-heatmap.service.ts b/src/app/api/services/engagement-heatmap.service.ts new file mode 100644 index 0000000000..083b0ed7c0 --- /dev/null +++ b/src/app/api/services/engagement-heatmap.service.ts @@ -0,0 +1,73 @@ +import {HttpClient} from '@angular/common/http'; +import {Injectable} from '@angular/core'; +import {Observable, of} from 'rxjs'; +import {tap} from 'rxjs/operators'; +import API_URL from 'src/app/config/constants/apiUrl'; +import {EngagementHeatmapResponse} from '../models/engagement-heatmap'; + +/** + * Thin read-only client for the unit-specific engagement heatmap endpoint. + * + * Matches the pattern used by small read-only endpoints elsewhere in the app + * (see `TiiService`, `UnitService.loadLearningProgressClassStats`, etc.): a + * root-provided service with a single `httpClient.get` call. We intentionally + * do not go through `CachedEntityService` because the payload is derived / + * non-CRUD. + * + * A lightweight in-memory cache keyed by `projectId` sits in front of the HTTP + * call. Its purpose is *perceived load speed* — a student who navigates away + * from the Peer Progress page and back within a minute gets the card + * re-rendered from cache without the network round trip. The TTL is short + * enough that activity shown on the heatmap stays roughly fresh without + * requiring manual invalidation hooks elsewhere in the app. + */ +@Injectable({ + providedIn: 'root', +}) +export class EngagementHeatmapService { + /** How long a cached response is considered "fresh" (milliseconds). */ + public static readonly CACHE_TTL_MS = 60_000; + + private readonly cache = new Map< + number, + {data: EngagementHeatmapResponse; fetchedAt: number} + >(); + + constructor(private httpClient: HttpClient) {} + + /** + * Fetch the engagement heatmap for a single project/unit context. + * + * Returns a cached response synchronously (via `of(...)`) when one exists + * and is still within the TTL; otherwise falls through to the HTTP request + * and caches the result on success. + * + * The heatmap is intentionally scoped to one project (i.e. one unit) — it is + * never aggregated across a student's multiple units. + */ + public get(projectId: number): Observable { + const cached = this.cache.get(projectId); + if (cached && Date.now() - cached.fetchedAt < EngagementHeatmapService.CACHE_TTL_MS) { + return of(cached.data); + } + + const url = `${API_URL}/projects/${projectId}/engagement_heatmap`; + return this.httpClient.get(url).pipe( + tap((data) => { + this.cache.set(projectId, {data, fetchedAt: Date.now()}); + }), + ); + } + + /** + * Evict cached entries. Pass a `projectId` to evict a single entry, or call + * with no arguments to clear the whole cache (e.g. on logout). + */ + public invalidate(projectId?: number): void { + if (projectId === undefined) { + this.cache.clear(); + return; + } + this.cache.delete(projectId); + } +} diff --git a/src/app/common/header/task-dropdown/task-dropdown.component.html b/src/app/common/header/task-dropdown/task-dropdown.component.html index dcc4bf854d..a1c5f29193 100644 --- a/src/app/common/header/task-dropdown/task-dropdown.component.html +++ b/src/app/common/header/task-dropdown/task-dropdown.component.html @@ -63,11 +63,22 @@ > Dashboard - - + + @if (currentProject.unit.allowFlexibleDates) { + + + } + + } @else if (data) { + +
+
+ +
+ {{ data.summary.tasks_completed }} + Tasks completed +
+
+
+ +
+ {{ data.summary.active_days }} + Active days +
+
+
+ +
+ {{ data.summary.current_streak }} + Current streak +
+
+
+ + @if (isEmpty) { +
+ +

+ No activity in the last 12 weeks yet, submit or update a task to get started. +

+
+ } @else { + +
+
+ +
+
+ + +
+
+
+
+ +
+
+ } + } + + + diff --git a/src/app/projects/states/peer-progress/widgets/engagement-heatmap-card/engagement-heatmap-card.component.scss b/src/app/projects/states/peer-progress/widgets/engagement-heatmap-card/engagement-heatmap-card.component.scss new file mode 100644 index 0000000000..21dbd113bd --- /dev/null +++ b/src/app/projects/states/peer-progress/widgets/engagement-heatmap-card/engagement-heatmap-card.component.scss @@ -0,0 +1,368 @@ +:host { + display: block; +} + +// Heatmap sizing tokens — tuned for readability on the Peer Progress page. +// +// Cells are *flexible* (minmax) with an explicit aspect-ratio, so the grid +// fills the available card width up to a sensible cap while staying square at +// every size. +$hm-cell-min: 18px; +$hm-cell-max: 40px; +$hm-gap: 4px; +$hm-radius: 3px; +$hm-day-label-col: 36px; +$hm-month-row-height: 20px; + +// Intensity palette — sourced from the site's `formatif` blue palette defined +// in `theme.scss` so the widget stays on-brand without introducing new colours. +// Level 0 uses a neutral grey for "no activity". +$hm-color-empty: #edf0f3; +$hm-color-l1: #c4c4ff; // md-formatif 100 +$hm-color-l2: #9c9cff; // md-formatif 200 +$hm-color-l3: #5757ff; // md-formatif 400 +$hm-color-l4: #3939ff; // md-formatif 500 (primary) + +// Skeleton base colour — same neutral grey as the "no activity" cell so the +// skeleton reads as a "draft" of the eventual heatmap. +$hm-skeleton-bg: $hm-color-empty; + +// Screen-reader only utility. Keeps aria-live announcements accessible without +// polluting the visual layout. +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +/* ---------------- Summary tiles ---------------- */ +.hm-summary { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 16px; + + .hm-tile { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + border-radius: 8px; + background-color: rgba(57, 57, 255, 0.04); // subtle formatif-blue wash + } + + .hm-tile-icon { + color: $hm-color-l4; + font-size: 28px; + width: 28px; + height: 28px; + flex: 0 0 auto; + } + + .hm-tile-value { + font-size: 22px; + font-weight: 600; + line-height: 1.1; + } + + .hm-tile-label { + font-size: 12px; + color: rgba(0, 0, 0, 0.6); + } +} + +@media (max-width: 599px) { + .hm-summary { + grid-template-columns: 1fr; + } +} + +/* ---------------- Heatmap container ---------------- */ +// +// The container holds everything the grid cares about — the heatmap proper +// (day labels + month row + cells) and the legend — so both can read the +// `--hm-weeks` custom property set on it. Stacking them vertically keeps the +// legend from leaking height into `.hm-wrap`, which would break the +// day-labels ↔ cell-rows alignment. +.hm-container { + display: flex; + flex-direction: column; + padding: 4px 0 4px; +} + +/* ---------------- Heatmap wrapper ---------------- */ +// +// Flex row: [day labels column] · [scrollable body]. +// `align-items: stretch` lets the day-labels column match the body's height, +// which is driven purely by the cells (via aspect-ratio) and the fixed month +// row — nothing else lives in `.hm-body`, so each day-label row ends up the +// exact same height as a cell row. +.hm-wrap { + display: flex; + gap: 8px; + align-items: stretch; +} + +// Day labels column. Grid with an explicit top spacer row sized to match the +// month row, then all seven weekday labels on equal-height `1fr` rows. Each +// row stretches to the height of its corresponding cell row (see `.hm-wrap` +// comment), so labels stay row-centred at every cell size. +.hm-day-labels { + flex: 0 0 $hm-day-label-col; + width: $hm-day-label-col; + display: grid; + grid-template-rows: $hm-month-row-height repeat(7, 1fr); + row-gap: $hm-gap; + font-size: 10px; + line-height: 1; + color: rgba(0, 0, 0, 0.5); + letter-spacing: 0.02em; + + > span { + display: flex; + align-items: center; + justify-content: flex-end; + padding-right: 8px; + } + + .hm-day-labels-spacer { + // Reserved blank row — keeps the first day label aligned with the first + // grid row once the month row is rendered above the grid. + } +} + +// Horizontal scroll container. Only engages when the grid cannot fit even at +// its minimum cell width (very narrow cards / phones). Thin scrollbar so it +// doesn't dominate the card visually. +.hm-scroll { + flex: 1 1 auto; + min-width: 0; // allow flex child to shrink below its content width + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: thin; + + &::-webkit-scrollbar { + height: 6px; + } + &::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.15); + border-radius: 3px; + } +} + +// Shared container for the month row and the grid. Both inherit the same +// `--hm-weeks` custom property so their column templates match exactly and +// month labels stay aligned with their week columns even on wide viewports. +.hm-body { + display: flex; + flex-direction: column; + gap: $hm-gap; + // Cap total width so cells don't blow up past `$hm-cell-max` on very wide + // cards (e.g. xl breakpoint, `col-span-2`). Computed from the actual week + // count so the cap matches reality for both the heatmap and the month row. + max-width: calc(var(--hm-weeks) * (#{$hm-cell-max} + #{$hm-gap}) - #{$hm-gap}); +} + +/* ---------------- Month labels row ---------------- */ +.hm-month-row { + display: grid; + grid-template-columns: repeat(var(--hm-weeks), minmax($hm-cell-min, 1fr)); + column-gap: $hm-gap; + height: $hm-month-row-height; + font-size: 11px; + line-height: 1; + color: rgba(0, 0, 0, 0.55); + + .hm-month-label { + grid-row: 1; + align-self: end; + white-space: nowrap; + overflow: visible; + min-width: 0; + // `grid-column` set via inline style (template binding). + } + + // Hide visually but keep in the DOM for future hover/tooltip affordances. + // Single-week "sliver" months are skipped so they don't crowd the neighbour. + .hm-month-label-hidden { + visibility: hidden; + } +} + +/* ---------------- Heatmap grid ---------------- */ +.hm-grid { + display: grid; + grid-template-columns: repeat(var(--hm-weeks), minmax($hm-cell-min, 1fr)); + grid-template-rows: repeat(7, auto); + grid-auto-flow: column; + gap: $hm-gap; +} + +.hm-cell { + aspect-ratio: 1 / 1; + width: 100%; + border-radius: $hm-radius; + transition: outline 0.1s ease-out; + outline: 1px solid transparent; + outline-offset: 1px; + + &:hover, + &:focus-visible { + outline-color: rgba(0, 0, 0, 0.35); + } + + &.hm-pad { + background: transparent; + pointer-events: none; + } + + &.hm-level-0 { + background-color: $hm-color-empty; + } + &.hm-level-1 { + background-color: $hm-color-l1; + } + &.hm-level-2 { + background-color: $hm-color-l2; + } + &.hm-level-3 { + background-color: $hm-color-l3; + } + &.hm-level-4 { + background-color: $hm-color-l4; + } +} + +/* ---------------- Legend ---------------- */ +// +// The legend lives directly under the heatmap (sibling of `.hm-wrap`) so its +// vertical height can't leak into the day-labels stretch calc. The wrap +// indents past the day-labels gutter and caps at the same effective width as +// the grid — that makes "More" align flush with the right edge of the last +// cell column, which is what reads as "attached to the grid". +.hm-legend-wrap { + display: flex; + justify-content: flex-end; + margin-top: 8px; + padding-left: calc(#{$hm-day-label-col} + 8px); + max-width: calc( + #{$hm-day-label-col} + 8px + var(--hm-weeks) * (#{$hm-cell-max} + #{$hm-gap}) - #{$hm-gap} + ); +} + +.hm-legend { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: rgba(0, 0, 0, 0.55); + + .hm-legend-label { + line-height: 1; + } + + .hm-legend-cell { + width: 12px; + height: 12px; + border-radius: $hm-radius; + display: inline-block; + + &.hm-level-0 { + background-color: $hm-color-empty; + } + &.hm-level-1 { + background-color: $hm-color-l1; + } + &.hm-level-2 { + background-color: $hm-color-l2; + } + &.hm-level-3 { + background-color: $hm-color-l3; + } + &.hm-level-4 { + background-color: $hm-color-l4; + } + } +} + +/* ---------------- Loading skeleton ---------------- */ +// +// The skeleton mirrors the loaded layout (3 summary tiles + a ~13-week grid) +// so the card height is stable across loading → data. A soft opacity pulse +// is enough to signal activity without distracting animation — cheaper to +// paint than a moving gradient shimmer, and avoids the "busy shimmer" feel +// that can overwhelm a dashboard with multiple widgets. +.hm-skeleton { + position: relative; + + .hm-skeleton-tile { + height: 64px; // matches the loaded tile's vertical footprint + border-radius: 8px; + background-color: $hm-skeleton-bg; + animation: hm-skeleton-pulse 1.4s ease-in-out infinite; + } + + .hm-skeleton-body { + // Match the real grid's indent (past the day-labels gutter) so the + // skeleton grid sits in the same horizontal position as the final one. + padding-left: calc(#{$hm-day-label-col} + 8px); + max-width: calc( + #{$hm-day-label-col} + 8px + var(--hm-weeks) * (#{$hm-cell-max} + #{$hm-gap}) - + #{$hm-gap} + ); + } + + .hm-skeleton-month-row { + height: $hm-month-row-height; + margin-bottom: $hm-gap; + } + + .hm-skeleton-grid { + display: grid; + grid-template-columns: repeat(var(--hm-weeks), minmax($hm-cell-min, 1fr)); + grid-template-rows: repeat(7, auto); + grid-auto-flow: column; + gap: $hm-gap; + } + + .hm-skeleton-cell { + aspect-ratio: 1 / 1; + width: 100%; + border-radius: $hm-radius; + background-color: $hm-skeleton-bg; + animation: hm-skeleton-pulse 1.4s ease-in-out infinite; + // Tiny staggered phase so the pulse doesn't look like a sheet flash. The + // delay is scoped to nth-child pairs to keep the rule list short. + &:nth-child(2n) { + animation-delay: 0.15s; + } + &:nth-child(3n) { + animation-delay: 0.3s; + } + } +} + +@keyframes hm-skeleton-pulse { + 0%, + 100% { + opacity: 0.6; + } + 50% { + opacity: 1; + } +} + +// Respect users who have asked the OS to tone down motion. +@media (prefers-reduced-motion: reduce) { + .hm-skeleton-tile, + .hm-skeleton-cell { + animation: none; + opacity: 0.75; + } +} diff --git a/src/app/projects/states/peer-progress/widgets/engagement-heatmap-card/engagement-heatmap-card.component.spec.ts b/src/app/projects/states/peer-progress/widgets/engagement-heatmap-card/engagement-heatmap-card.component.spec.ts new file mode 100644 index 0000000000..72181f8b62 --- /dev/null +++ b/src/app/projects/states/peer-progress/widgets/engagement-heatmap-card/engagement-heatmap-card.component.spec.ts @@ -0,0 +1,240 @@ +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {HttpClientTestingModule, HttpTestingController} from '@angular/common/http/testing'; +import {MatCardModule} from '@angular/material/card'; +import {MatIconModule} from '@angular/material/icon'; +import {MatButtonModule} from '@angular/material/button'; +import {MatProgressSpinnerModule} from '@angular/material/progress-spinner'; +import {MatTooltipModule} from '@angular/material/tooltip'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; + +import API_URL from 'src/app/config/constants/apiUrl'; +import {Project} from 'src/app/api/models/doubtfire-model'; +import {EngagementHeatmapResponse} from 'src/app/api/models/engagement-heatmap'; +import {EngagementHeatmapCardComponent} from './engagement-heatmap-card.component'; + +function buildDayList(): EngagementHeatmapResponse['days'] { + // 84 consecutive days starting on a Wednesday (2026-01-21). + // Build the ISO string from local Date parts so the test is + // not sensitive to the runner's timezone (toISOString → UTC shift). + return Array.from({length: 84}, (_v, i) => { + const d = new Date(2026, 0, 21 + i); + const iso = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String( + d.getDate(), + ).padStart(2, '0')}`; + return {date: iso, activity_count: i % 10 === 0 ? i % 5 : 0}; + }); +} + +function buildResponse(overrides?: Partial): EngagementHeatmapResponse { + return { + project_id: 42, + unit_id: 7, + range: {start_date: '2026-01-21', end_date: '2026-04-14', days: 84}, + days: buildDayList(), + summary: {tasks_completed: 5, active_days: 3, current_streak: 2}, + ...overrides, + }; +} + +function buildEmptyResponse(): EngagementHeatmapResponse { + return buildResponse({ + days: buildDayList().map((d) => ({...d, activity_count: 0})), + summary: {tasks_completed: 0, active_days: 0, current_streak: 0}, + }); +} + +describe('EngagementHeatmapCardComponent', () => { + let component: EngagementHeatmapCardComponent; + let fixture: ComponentFixture; + let httpMock: HttpTestingController; + + const stubProject = {id: 42, unit: {code: 'UNIT101', name: 'Test Unit'}} as unknown as Project; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [EngagementHeatmapCardComponent], + imports: [ + HttpClientTestingModule, + MatCardModule, + MatIconModule, + MatButtonModule, + MatProgressSpinnerModule, + MatTooltipModule, + NoopAnimationsModule, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(EngagementHeatmapCardComponent); + component = fixture.componentInstance; + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('should create', () => { + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + it('shows the heatmap-shaped skeleton while the request is in flight', () => { + component.project = stubProject; + component.ngOnChanges({ + project: { + currentValue: stubProject, + previousValue: undefined, + firstChange: true, + isFirstChange: () => true, + }, + }); + fixture.detectChanges(); + + // Skeleton is visible before the backend responds. + const skeleton = fixture.nativeElement.querySelector('[data-testid="hm-skeleton"]'); + expect(skeleton).toBeTruthy(); + const skeletonCells: NodeListOf = + skeleton.querySelectorAll('.hm-skeleton-cell'); + expect(skeletonCells.length).toBeGreaterThan(0); + + // Flush the request so httpMock.verify() in afterEach is satisfied. + const req = httpMock.expectOne(`${API_URL}/projects/42/engagement_heatmap`); + req.flush(buildResponse()); + + fixture.detectChanges(); + + // Once data arrives the skeleton is replaced by the real grid. + expect(fixture.nativeElement.querySelector('[data-testid="hm-skeleton"]')).toBeFalsy(); + expect(fixture.nativeElement.querySelector('[data-testid="hm-grid"]')).toBeTruthy(); + }); + + it('renders the heatmap grid with one cell per day plus pad cells when data loads', () => { + component.project = stubProject; + component.ngOnChanges({ + project: { + currentValue: stubProject, + previousValue: undefined, + firstChange: true, + isFirstChange: () => true, + }, + }); + fixture.detectChanges(); + + const req = httpMock.expectOne(`${API_URL}/projects/42/engagement_heatmap`); + expect(req.request.method).toBe('GET'); + req.flush(buildResponse()); + + fixture.detectChanges(); + + const dayCells: NodeListOf = fixture.nativeElement.querySelectorAll( + '[data-testid="hm-cell"]', + ); + expect(dayCells.length).toBe(84); + + const padCells: NodeListOf = fixture.nativeElement.querySelectorAll('.hm-pad'); + // 2026-01-21 is a Wednesday → 2 pad cells to align to Monday-start. + expect(padCells.length).toBe(2); + + const summary = fixture.nativeElement.querySelector('[data-testid="hm-summary"]'); + expect(summary).toBeTruthy(); + expect(summary?.textContent).toContain('5'); // tasks completed + expect(summary?.textContent).toContain('3'); // active days + expect(summary?.textContent).toContain('2'); // current streak + + // All seven weekday labels are rendered (Mon…Sun), plus the top spacer. + const dayLabelSpans: NodeListOf = fixture.nativeElement.querySelectorAll( + '.hm-day-labels > span', + ); + expect(dayLabelSpans.length).toBe(8); // 1 spacer + 7 days + const labelText = Array.from(dayLabelSpans) + .map((el) => el.textContent?.trim()) + .filter((s) => s && s.length > 0); + expect(labelText).toEqual(['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']); + + // Month row renders one label per calendar month crossed by the window. + // The 84-day fixture (2026-01-21 → 2026-04-14) spans Jan → Apr. + const monthRow = fixture.nativeElement.querySelector('[data-testid="hm-month-row"]'); + expect(monthRow).toBeTruthy(); + const monthLabels: NodeListOf = + monthRow.querySelectorAll('.hm-month-label'); + expect(monthLabels.length).toBe(4); + + // Legend sits alongside the grid and always exposes 5 swatches (levels 0..4). + const legend = fixture.nativeElement.querySelector('[data-testid="hm-legend"]'); + expect(legend).toBeTruthy(); + const legendSwatches: NodeListOf = + legend.querySelectorAll('.hm-legend-cell'); + expect(legendSwatches.length).toBe(5); + }); + + it('precomputes a render cell per day so the template does not call into the component for each cell', () => { + component.project = stubProject; + component.ngOnChanges({ + project: { + currentValue: stubProject, + previousValue: undefined, + firstChange: true, + isFirstChange: () => true, + }, + }); + fixture.detectChanges(); + httpMock.expectOne(`${API_URL}/projects/42/engagement_heatmap`).flush(buildResponse()); + fixture.detectChanges(); + + expect(component.renderCells.length).toBe(84); + // Each render cell carries its precomputed bucket and tooltip, so the + // template binds plain data instead of invoking methods per cell. + for (const cell of component.renderCells) { + expect(cell.date).toBeTruthy(); + expect(cell.bucket).toBeGreaterThanOrEqual(0); + expect(cell.bucket).toBeLessThanOrEqual(4); + expect(cell.tooltip).toContain('—'); + } + }); + + it('shows the empty state when summary.active_days is zero', () => { + component.project = stubProject; + component.ngOnChanges({ + project: { + currentValue: stubProject, + previousValue: undefined, + firstChange: true, + isFirstChange: () => true, + }, + }); + fixture.detectChanges(); + + const req = httpMock.expectOne(`${API_URL}/projects/42/engagement_heatmap`); + req.flush(buildEmptyResponse()); + + fixture.detectChanges(); + + const emptyState = fixture.nativeElement.querySelector('[data-testid="hm-empty"]'); + expect(emptyState).toBeTruthy(); + + const grid = fixture.nativeElement.querySelector('[data-testid="hm-grid"]'); + expect(grid).toBeFalsy(); + }); + + it('shows an error message and a Retry button if the request fails', () => { + component.project = stubProject; + component.ngOnChanges({ + project: { + currentValue: stubProject, + previousValue: undefined, + firstChange: true, + isFirstChange: () => true, + }, + }); + fixture.detectChanges(); + + const req = httpMock.expectOne(`${API_URL}/projects/42/engagement_heatmap`); + req.flush('boom', {status: 500, statusText: 'Server Error'}); + + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toContain('Could not load engagement data'); + const retryBtn = fixture.nativeElement.querySelector('button'); + expect(retryBtn).toBeTruthy(); + }); +}); diff --git a/src/app/projects/states/peer-progress/widgets/engagement-heatmap-card/engagement-heatmap-card.component.ts b/src/app/projects/states/peer-progress/widgets/engagement-heatmap-card/engagement-heatmap-card.component.ts new file mode 100644 index 0000000000..affda03063 --- /dev/null +++ b/src/app/projects/states/peer-progress/widgets/engagement-heatmap-card/engagement-heatmap-card.component.ts @@ -0,0 +1,156 @@ +import { + Component, + Input, + OnChanges, + OnDestroy, + SimpleChanges, +} from '@angular/core'; +import {Subscription} from 'rxjs'; +import {Project} from 'src/app/api/models/doubtfire-model'; +import {EngagementHeatmapResponse} from 'src/app/api/models/engagement-heatmap'; +import {EngagementHeatmapService} from 'src/app/api/services/engagement-heatmap.service'; +import { + bucketForCount, + computeHeatmapLayout, + computeMonthLabels, + formatCellTooltip, + HEATMAP_INTENSITY_LEVELS, + HeatmapLayout, + HeatmapMonthLabel, +} from './engagement-heatmap.util'; + +/** + * Pre-rendered per-cell view model. Bucketing and the tooltip string are + * derived once when the response arrives rather than on every change-detection + * pass — for the default 84-day window this cuts work from ~168 function + * calls/CD down to zero. + */ +interface HeatmapRenderCell { + date: string; + bucket: number; + tooltip: string; +} + +/** + * Engagement heatmap card — first widget on the Peer Progress page. + * + * Scope: unit-specific. Receives the currently-selected `Project` from the + * page container and fetches `GET /api/projects/:id/engagement_heatmap`. + * Renders: + * - a heatmap-shaped skeleton while loading (keeps the card height stable) + * - three summary tiles (tasks completed / active days / current streak) + * - a GitHub-style heatmap (7 rows × N week columns, Monday-start) + * - a month row above the grid and an inline legend below it + * - loading / empty / error states + */ +@Component({ + selector: 'f-engagement-heatmap-card', + templateUrl: 'engagement-heatmap-card.component.html', + styleUrls: ['engagement-heatmap-card.component.scss'], +}) +export class EngagementHeatmapCardComponent implements OnChanges, OnDestroy { + @Input() project!: Project; + + public data: EngagementHeatmapResponse | null = null; + public isLoading = false; + public errorMessage: string | null = null; + + /** Cached grid layout — computed once per successful fetch. */ + public layout: HeatmapLayout = {padCells: 0, weekCount: 0, maxCount: 0}; + + /** Zero-filled array whose length equals `layout.padCells`, for `@for` iteration. */ + public padSlots: readonly null[] = []; + + /** Month labels aligned above the grid columns. Empty until data arrives. */ + public monthLabels: readonly HeatmapMonthLabel[] = []; + + /** Pre-rendered cells. Populated once per fetch; iterated by the template. */ + public renderCells: readonly HeatmapRenderCell[] = []; + + /** Legend swatches 0..N (0 = empty, then one per intensity level). */ + public readonly legendLevels: readonly number[] = Array.from( + {length: HEATMAP_INTENSITY_LEVELS + 1}, + (_v, i) => i, + ); + + /** + * Fixed-size placeholder array used by the skeleton grid. 13 × 7 = 91 cells + * matches the typical 12-week window rendered with a Monday-start pad. + */ + public readonly skeletonCells: readonly null[] = new Array(91).fill(null); + + /** `--hm-weeks` value used while the skeleton is visible. */ + public readonly skeletonWeeks = 13; + + private activeRequest?: Subscription; + + constructor(private engagementHeatmapService: EngagementHeatmapService) {} + + public ngOnChanges(changes: SimpleChanges): void { + // Re-fetch whenever the project changes (e.g. student switches unit). + if (changes['project']) { + this.load(); + } + } + + public ngOnDestroy(): void { + this.activeRequest?.unsubscribe(); + } + + /** + * Whether the 84-day window contains zero activity. Drives the empty state. + */ + public get isEmpty(): boolean { + if (!this.data) { + return false; + } + return this.data.summary.active_days === 0; + } + + /** Manual retry handler for the error state. */ + public retry(): void { + this.load(); + } + + private load(): void { + if (!this.project?.id) { + return; + } + + this.activeRequest?.unsubscribe(); + + this.isLoading = true; + this.errorMessage = null; + this.data = null; + this.layout = {padCells: 0, weekCount: 0, maxCount: 0}; + this.padSlots = []; + this.monthLabels = []; + this.renderCells = []; + + this.activeRequest = this.engagementHeatmapService.get(this.project.id).subscribe({ + next: (response) => { + this.data = response; + this.layout = computeHeatmapLayout(response.days); + this.padSlots = new Array(this.layout.padCells).fill(null); + this.monthLabels = computeMonthLabels( + response.days, + this.layout.padCells, + this.layout.weekCount, + ); + // Precompute bucket + tooltip for every cell so the template never has + // to call back into the component during change detection. + const maxCount = this.layout.maxCount; + this.renderCells = response.days.map((d) => ({ + date: d.date, + bucket: bucketForCount(d.activity_count, maxCount), + tooltip: formatCellTooltip(d), + })); + this.isLoading = false; + }, + error: () => { + this.isLoading = false; + this.errorMessage = 'Could not load engagement data. Please try again.'; + }, + }); + } +} diff --git a/src/app/projects/states/peer-progress/widgets/engagement-heatmap-card/engagement-heatmap.util.spec.ts b/src/app/projects/states/peer-progress/widgets/engagement-heatmap-card/engagement-heatmap.util.spec.ts new file mode 100644 index 0000000000..3042490395 --- /dev/null +++ b/src/app/projects/states/peer-progress/widgets/engagement-heatmap-card/engagement-heatmap.util.spec.ts @@ -0,0 +1,170 @@ +import { + HEATMAP_INTENSITY_LEVELS, + bucketForCount, + computeHeatmapLayout, + computeMonthLabels, + dayRowIndex, + formatCellTooltip, + parseHeatmapDate, +} from './engagement-heatmap.util'; + +describe('engagement-heatmap.util', () => { + describe('parseHeatmapDate', () => { + it('parses an ISO date as a local-time Date (no UTC shift)', () => { + const d = parseHeatmapDate('2026-01-21'); + expect(d.getFullYear()).toBe(2026); + expect(d.getMonth()).toBe(0); + expect(d.getDate()).toBe(21); + }); + }); + + describe('dayRowIndex', () => { + // 2026-01-19 is a Monday (known good reference). + it('returns 0 for Monday when week starts on Monday', () => { + expect(dayRowIndex(parseHeatmapDate('2026-01-19'))).toBe(0); + }); + + it('returns 6 for Sunday when week starts on Monday', () => { + expect(dayRowIndex(parseHeatmapDate('2026-01-25'))).toBe(6); + }); + + it('returns 4 for Friday when week starts on Monday', () => { + expect(dayRowIndex(parseHeatmapDate('2026-01-23'))).toBe(4); + }); + }); + + describe('bucketForCount', () => { + it('returns 0 for zero / non-positive counts', () => { + expect(bucketForCount(0, 10)).toBe(0); + expect(bucketForCount(-5, 10)).toBe(0); + }); + + it('returns 0 when max is zero or negative', () => { + expect(bucketForCount(3, 0)).toBe(0); + expect(bucketForCount(3, -1)).toBe(0); + }); + + it('buckets counts into quartiles of [1, max]', () => { + // max = 4, step = 1 → counts 1..4 map to buckets 1..4 + expect(bucketForCount(1, 4)).toBe(1); + expect(bucketForCount(2, 4)).toBe(2); + expect(bucketForCount(3, 4)).toBe(3); + expect(bucketForCount(4, 4)).toBe(4); + }); + + it('promotes any non-zero activity to at least level 1', () => { + expect(bucketForCount(1, 100)).toBe(1); + }); + + it('clamps counts greater than max to the top level', () => { + expect(bucketForCount(99, 4)).toBe(HEATMAP_INTENSITY_LEVELS); + }); + + it('handles a max of 1 gracefully', () => { + expect(bucketForCount(1, 1)).toBe(HEATMAP_INTENSITY_LEVELS); + }); + }); + + describe('computeHeatmapLayout', () => { + it('returns zeroed layout for empty input', () => { + expect(computeHeatmapLayout([])).toEqual({padCells: 0, weekCount: 0, maxCount: 0}); + }); + + it('uses zero pad cells when the first day is a Monday', () => { + // 2026-01-19 is a Monday. + const days = [{date: '2026-01-19', activity_count: 0}]; + expect(computeHeatmapLayout(days).padCells).toBe(0); + }); + + it('pads to Monday-start when the first day is mid-week', () => { + // 2026-01-21 is a Wednesday → row 2 → 2 pad cells. + const days = [{date: '2026-01-21', activity_count: 0}]; + expect(computeHeatmapLayout(days).padCells).toBe(2); + }); + + it('reports the max activity count across the window', () => { + const days = [ + {date: '2026-01-19', activity_count: 1}, + {date: '2026-01-20', activity_count: 5}, + {date: '2026-01-21', activity_count: 3}, + ]; + expect(computeHeatmapLayout(days).maxCount).toBe(5); + }); + + it('computes week count to fit pad + days in 7-row columns', () => { + // 84 days, Wednesday start → 2 pad + 84 = 86 cells → ceil(86/7) = 13 weeks. + const days = Array.from({length: 84}, (_v, i) => { + const d = new Date(2026, 0, 21 + i); + const iso = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String( + d.getDate(), + ).padStart(2, '0')}`; + return {date: iso, activity_count: 0}; + }); + expect(computeHeatmapLayout(days).weekCount).toBe(13); + }); + }); + + describe('computeMonthLabels', () => { + // 84 consecutive days from 2026-01-21 (Wed) → 2026-04-14 (Tue). + // → padCells = 2, weekCount = 13. Months crossed: Jan, Feb, Mar, Apr. + function buildDays() { + return Array.from({length: 84}, (_v, i) => { + const d = new Date(2026, 0, 21 + i); + const iso = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String( + d.getDate(), + ).padStart(2, '0')}`; + return {date: iso, activity_count: 0}; + }); + } + + it('returns an empty list for empty input', () => { + expect(computeMonthLabels([], 0, 0)).toEqual([]); + }); + + it('returns one entry per distinct calendar month in the window', () => { + const labels = computeMonthLabels(buildDays(), 2, 13); + expect(labels.map((l) => l.label)).toEqual(['Jan', 'Feb', 'Mar', 'Apr']); + }); + + it('assigns column indices in ascending, non-overlapping order', () => { + const labels = computeMonthLabels(buildDays(), 2, 13); + for (let i = 0; i < labels.length - 1; i++) { + expect(labels[i].columnIndex + labels[i].span).toBeLessThanOrEqual( + labels[i + 1].columnIndex, + ); + } + }); + + it('covers every week column with a label (spans sum to weekCount)', () => { + const labels = computeMonthLabels(buildDays(), 2, 13); + const totalSpan = labels.reduce((acc, l) => acc + l.span, 0); + expect(totalSpan).toBe(13); + }); + + it('anchors the first label at column 0', () => { + const labels = computeMonthLabels(buildDays(), 2, 13); + expect(labels[0].columnIndex).toBe(0); + }); + }); + + describe('formatCellTooltip', () => { + it('includes the date and the activity count', () => { + const tip = formatCellTooltip({date: '2026-01-21', activity_count: 3}); + // Locale formatting varies by environment, so assert on the bits we own: + // the separator and the pluralised count. + expect(tip).toContain('—'); + expect(tip).toContain('3 activities'); + }); + + it('uses singular "activity" for count === 1', () => { + const tip = formatCellTooltip({date: '2026-01-21', activity_count: 1}); + expect(tip).toContain('1 activity'); + expect(tip).not.toContain('activities'); + }); + + it('uses plural "activities" for count === 0', () => { + const tip = formatCellTooltip({date: '2026-01-21', activity_count: 0}); + expect(tip).toContain('0 activities'); + }); + }); +}); diff --git a/src/app/projects/states/peer-progress/widgets/engagement-heatmap-card/engagement-heatmap.util.ts b/src/app/projects/states/peer-progress/widgets/engagement-heatmap-card/engagement-heatmap.util.ts new file mode 100644 index 0000000000..a8f18ba972 --- /dev/null +++ b/src/app/projects/states/peer-progress/widgets/engagement-heatmap-card/engagement-heatmap.util.ts @@ -0,0 +1,176 @@ +import {EngagementHeatmapDay} from 'src/app/api/models/engagement-heatmap'; + +/** + * Pure helpers for the engagement heatmap card. Kept in a separate module + * (not on the component) so the bucketing / layout logic can be unit tested + * without bootstrapping the whole Angular component. + */ + +/** Day-of-week index where Monday = 0 … Sunday = 6. */ +export const WEEK_STARTS_ON_MONDAY = 1; + +/** Non-empty intensity levels (0 means "empty"). */ +export const HEATMAP_INTENSITY_LEVELS = 4; + +/** + * Parse a backend ISO date (`"YYYY-MM-DD"`) as a local-time Date. + * + * Using `new Date('YYYY-MM-DD')` directly parses as UTC which, depending on the + * browser's timezone, can shift the day back/forward. Construct the date + * explicitly so the day rendered on the heatmap always matches the day the + * backend sent. + */ +export function parseHeatmapDate(iso: string): Date { + const [year, month, day] = iso.split('-').map(Number); + return new Date(year, month - 1, day); +} + +/** + * Row index for a given date, with Monday at the top of the grid. + */ +export function dayRowIndex(date: Date, weekStartsOn: number = WEEK_STARTS_ON_MONDAY): number { + return (date.getDay() - weekStartsOn + 7) % 7; +} + +/** + * Bucket a single-day activity count into a discrete intensity level: + * 0 — no activity + * 1 .. 4 — quarter bands of [1, maxCount] + * + * Uses non-empty quartiles so that _any_ activity reads as visibly non-zero + * even when the student's most active day had only a few events. + */ +export function bucketForCount(count: number, maxCount: number): number { + if (!Number.isFinite(count) || count <= 0) { + return 0; + } + if (!Number.isFinite(maxCount) || maxCount <= 0) { + return 0; + } + + const step = maxCount / HEATMAP_INTENSITY_LEVELS; + for (let level = 1; level < HEATMAP_INTENSITY_LEVELS; level++) { + if (count <= step * level) { + return level; + } + } + return HEATMAP_INTENSITY_LEVELS; +} + +/** + * Summary of what the grid needs to render: + * - `padCells`: empty slots before day 0 so the grid aligns to Monday-start weeks. + * - `weekCount`: number of columns in the grid. + * - `maxCount`: most active day in the window; used to drive colour bucketing. + */ +export interface HeatmapLayout { + padCells: number; + weekCount: number; + maxCount: number; +} + +/** + * Compute grid layout metadata for the given days. + * Safe against empty / missing inputs. + */ +export function computeHeatmapLayout( + days: EngagementHeatmapDay[], + weekStartsOn: number = WEEK_STARTS_ON_MONDAY, +): HeatmapLayout { + if (!days || days.length === 0) { + return {padCells: 0, weekCount: 0, maxCount: 0}; + } + + const firstDate = parseHeatmapDate(days[0].date); + const padCells = dayRowIndex(firstDate, weekStartsOn); + const totalCells = padCells + days.length; + const weekCount = Math.ceil(totalCells / 7); + + let maxCount = 0; + for (const d of days) { + if (d.activity_count > maxCount) { + maxCount = d.activity_count; + } + } + + return {padCells, weekCount, maxCount}; +} + +/** + * Month-label entry for the month row above the heatmap grid. + * + * `columnIndex` and `span` are expressed in *week columns* (0-indexed) so the + * template can position the label via `grid-column: (columnIndex + 1) / span span`. + */ +export interface HeatmapMonthLabel { + columnIndex: number; + span: number; + label: string; +} + +/** + * Group consecutive week columns by calendar month and emit a label per group. + * Picks the first non-pad day visible in each week column as that column's + * representative date. Partial months at the edges of the window still get a + * label; very short spans are filtered out so a 1-week sliver doesn't crowd + * its neighbour. + */ +export function computeMonthLabels( + days: EngagementHeatmapDay[], + padCells: number, + weekCount: number, +): HeatmapMonthLabel[] { + if (!days || days.length === 0 || weekCount <= 0) { + return []; + } + + const labels: HeatmapMonthLabel[] = []; + let currentMonth = -1; + let current: HeatmapMonthLabel | null = null; + + for (let col = 0; col < weekCount; col++) { + const rawIndex = col === 0 ? 0 : col * 7 - padCells; + const safeIndex = Math.min(Math.max(rawIndex, 0), days.length - 1); + const day = days[safeIndex]; + if (!day) { + continue; + } + + const date = parseHeatmapDate(day.date); + const month = date.getMonth(); + + if (month !== currentMonth) { + if (current) { + labels.push(current); + } + current = { + columnIndex: col, + span: 1, + label: date.toLocaleDateString(undefined, {month: 'short'}), + }; + currentMonth = month; + } else if (current) { + current.span += 1; + } + } + + if (current) { + labels.push(current); + } + + return labels; +} + +/** + * Short, locale-aware label for a cell's tooltip, e.g. "Wed, 21 Jan — 3 activities". + */ +export function formatCellTooltip(day: EngagementHeatmapDay): string { + const date = parseHeatmapDate(day.date); + const dateLabel = date.toLocaleDateString(undefined, { + weekday: 'short', + day: 'numeric', + month: 'short', + }); + const noun = day.activity_count === 1 ? 'activity' : 'activities'; + return `${dateLabel} — ${day.activity_count} ${noun}`; +} From bf43755ec7405b697deb8a86d0143bd840d6c95a Mon Sep 17 00:00:00 2001 From: David Kamau Nganga Date: Fri, 1 May 2026 11:03:14 +1000 Subject: [PATCH 2/2] fix(task-dropdown): always show Task Planner regardless of allowFlexibleDates --- .../task-dropdown/task-dropdown.component.html | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/app/common/header/task-dropdown/task-dropdown.component.html b/src/app/common/header/task-dropdown/task-dropdown.component.html index a1c5f29193..a727c60754 100644 --- a/src/app/common/header/task-dropdown/task-dropdown.component.html +++ b/src/app/common/header/task-dropdown/task-dropdown.component.html @@ -72,13 +72,11 @@ Peer Progress - @if (currentProject.unit.allowFlexibleDates) { - - - } + +