From 918d0727cd7db036ce0b08df485ecc381a3079be Mon Sep 17 00:00:00 2001 From: Trang Le Date: Mon, 17 Nov 2025 21:14:26 +0700 Subject: [PATCH 01/21] display heatmap periods in the form of select menu --- .../activity-heatmap.component.html | 12 ++++++ .../activity-heatmap.component.ts | 40 ++++++++++++++----- 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/src/app/features/metric/activity-heatmap/activity-heatmap.component.html b/src/app/features/metric/activity-heatmap/activity-heatmap.component.html index b50bbcb97..2da09f48f 100644 --- a/src/app/features/metric/activity-heatmap/activity-heatmap.component.html +++ b/src/app/features/metric/activity-heatmap/activity-heatmap.component.html @@ -20,6 +20,18 @@ @if (heatmapData(); as data) { +
+ Date range: + +
diff --git a/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts b/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts index 634e00ff9..91e1e721b 100644 --- a/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts +++ b/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts @@ -10,8 +10,8 @@ import { WorklogService } from '../../worklog/worklog.service'; import { WorkContextService } from '../../work-context/work-context.service'; import { TaskService } from '../../tasks/task.service'; import { TaskArchiveService } from '../../time-tracking/task-archive.service'; -import { defer, from } from 'rxjs'; -import { first, map, switchMap } from 'rxjs/operators'; +import { combineLatest, defer, from, Subject } from 'rxjs'; +import { first, map, startWith, switchMap } from 'rxjs/operators'; import { TranslatePipe } from '@ngx-translate/core'; import { T } from '../../../t.const'; import { TODAY_TAG } from '../../tag/tag.const'; @@ -50,6 +50,8 @@ export class ActivityHeatmapComponent { private readonly _snackService = inject(SnackService); private readonly _globalConfigService = inject(GlobalConfigService); private readonly _shareService = inject(ShareService); + private selectedPeriod: 'last365' | 'currentYear' = 'last365'; // Defaul to the last 365 days + private readonly _periodChange$ = new Subject(); T: typeof T = T; weeks: WeekData[] = []; @@ -73,8 +75,11 @@ export class ActivityHeatmapComponent { // Raw data signals private readonly _rawHeatmapData = toSignal( - this._workContextService.activeWorkContext$.pipe( - switchMap((context) => { + combineLatest([ + this._workContextService.activeWorkContext$, + this._periodChange$.pipe(startWith(null)), // Start with initial emission + ]).pipe( + switchMap(([context]) => { // Special case: TODAY tag shows ALL data from all tasks if (context.id === TODAY_TAG.id) { // Use defer to ensure the Promise is created fresh each time @@ -139,11 +144,20 @@ export class ActivityHeatmapComponent { } | null { const dayMap = new Map(); const now = new Date(); - const oneYearAgo = new Date(now); - oneYearAgo.setFullYear(now.getFullYear() - 1); - + const period = this.selectedPeriod; + // Depending on the selected period, start dates are calculated differently + // but end dates is always today + let startDate: Date; + if (period === 'currentYear') { + const currentYear = now.getFullYear(); + startDate = new Date(currentYear, 0, 1); + } else { + // start date is one year ago from today + startDate = new Date(now); + startDate.setFullYear(now.getFullYear() - 1); + } // Initialize all days in the past year - const currentDate = new Date(oneYearAgo); + const currentDate = new Date(startDate); while (currentDate <= now) { const dateStr = this._getDateStr(currentDate); dayMap.set(dateStr, { @@ -215,7 +229,7 @@ export class ActivityHeatmapComponent { return { dayMap, - startDate: oneYearAgo, + startDate, endDate: now, }; } @@ -439,6 +453,14 @@ export class ActivityHeatmapComponent { return `${minutes}m`; } + getSelectedPeriod(): 'last365' | 'currentYear' { + return this.selectedPeriod; + } + + onPeriodChange(period: 'last365' | 'currentYear'): void { + this.selectedPeriod = period; + this._periodChange$.next(); + } async shareHeatmap(): Promise { const data = this.heatmapData(); if (!data) { From eb8e106fc383fcd28880736a6e037cab5e9041ab Mon Sep 17 00:00:00 2001 From: Trang Le Date: Mon, 17 Nov 2025 21:16:31 +0700 Subject: [PATCH 02/21] add styling for select menu --- .../activity-heatmap.component.scss | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/app/features/metric/activity-heatmap/activity-heatmap.component.scss b/src/app/features/metric/activity-heatmap/activity-heatmap.component.scss index 610dd950c..b956878f7 100644 --- a/src/app/features/metric/activity-heatmap/activity-heatmap.component.scss +++ b/src/app/features/metric/activity-heatmap/activity-heatmap.component.scss @@ -1,3 +1,18 @@ +.select-menu { + align-items: center; + color: rgba(0, 0, 0, 0.6); + display: flex; + gap: 8px; + justify-content: center; + margin-bottom: 16px; +} +.custom-select { + background: transparent; + border: none; + color: inherit; + font-size: inherit; + font-weight: 600; +} .activity-heatmap { margin: 24px 0; From ac110466afd96d4d1f3829829b0b6ae7ba454f8f Mon Sep 17 00:00:00 2001 From: Trang Le Date: Mon, 8 Dec 2025 22:16:03 +0700 Subject: [PATCH 03/21] use mat-select component for the select menu --- .../activity-heatmap.component.html | 21 +++++++++++-------- .../activity-heatmap.component.scss | 5 ----- .../activity-heatmap.component.ts | 13 ++++++++++-- 3 files changed, 23 insertions(+), 16 deletions(-) diff --git a/src/app/features/metric/activity-heatmap/activity-heatmap.component.html b/src/app/features/metric/activity-heatmap/activity-heatmap.component.html index 2da09f48f..e0d08921e 100644 --- a/src/app/features/metric/activity-heatmap/activity-heatmap.component.html +++ b/src/app/features/metric/activity-heatmap/activity-heatmap.component.html @@ -21,16 +21,19 @@ @if (heatmapData(); as data) {
- Date range: - + Date Range + + Current Year + Last 365 days + +
diff --git a/src/app/features/metric/activity-heatmap/activity-heatmap.component.scss b/src/app/features/metric/activity-heatmap/activity-heatmap.component.scss index b956878f7..c26563cf5 100644 --- a/src/app/features/metric/activity-heatmap/activity-heatmap.component.scss +++ b/src/app/features/metric/activity-heatmap/activity-heatmap.component.scss @@ -1,9 +1,4 @@ .select-menu { - align-items: center; - color: rgba(0, 0, 0, 0.6); - display: flex; - gap: 8px; - justify-content: center; margin-bottom: 16px; } .custom-select { diff --git a/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts b/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts index 116d5c9df..9ce674867 100644 --- a/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts +++ b/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts @@ -16,7 +16,9 @@ import { TranslatePipe } from '@ngx-translate/core'; import { T } from '../../../t.const'; import { TODAY_TAG } from '../../tag/tag.const'; import { Task } from '../../tasks/task.model'; +import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconButton } from '@angular/material/button'; +import { MatSelectModule } from '@angular/material/select'; import { MatTooltip } from '@angular/material/tooltip'; import { MatIcon } from '@angular/material/icon'; import { SnackService } from '../../../core/snack/snack.service'; @@ -40,7 +42,14 @@ interface WeekData { templateUrl: './activity-heatmap.component.html', styleUrls: ['./activity-heatmap.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, - imports: [TranslatePipe, MatIconButton, MatTooltip, MatIcon], + imports: [ + TranslatePipe, + MatFormFieldModule, + MatIconButton, + MatSelectModule, + MatTooltip, + MatIcon, + ], }) export class ActivityHeatmapComponent { private readonly _worklogService = inject(WorklogService); @@ -49,7 +58,7 @@ export class ActivityHeatmapComponent { private readonly _taskArchiveService = inject(TaskArchiveService); private readonly _snackService = inject(SnackService); private readonly _shareService = inject(ShareService); - private selectedPeriod: 'last365' | 'currentYear' = 'last365'; // Defaul to the last 365 days + private selectedPeriod: 'last365' | 'currentYear' = 'currentYear'; // Defaul to current year private readonly _periodChange$ = new Subject(); private readonly _dateAdapter = inject(DateAdapter); From ac87c83b3f552ec95d5f5570a518f4e37ae8c310 Mon Sep 17 00:00:00 2001 From: Trang Le Date: Tue, 30 Dec 2025 15:50:35 +0700 Subject: [PATCH 04/21] display heatmap for current year by default --- .../activity-heatmap.component.ts | 26 +++---------------- 1 file changed, 4 insertions(+), 22 deletions(-) diff --git a/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts b/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts index 9ce674867..0f95845d2 100644 --- a/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts +++ b/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts @@ -58,7 +58,6 @@ export class ActivityHeatmapComponent { private readonly _taskArchiveService = inject(TaskArchiveService); private readonly _snackService = inject(SnackService); private readonly _shareService = inject(ShareService); - private selectedPeriod: 'last365' | 'currentYear' = 'currentYear'; // Defaul to current year private readonly _periodChange$ = new Subject(); private readonly _dateAdapter = inject(DateAdapter); @@ -148,19 +147,10 @@ export class ActivityHeatmapComponent { } | null { const dayMap = new Map(); const now = new Date(); - const period = this.selectedPeriod; - // Depending on the selected period, start dates are calculated differently - // but end dates is always today - let startDate: Date; - if (period === 'currentYear') { - const currentYear = now.getFullYear(); - startDate = new Date(currentYear, 0, 1); - } else { - // start date is one year ago from today - startDate = new Date(now); - startDate.setFullYear(now.getFullYear() - 1); - } - // Initialize all days in the past year + const currentYear = now.getFullYear(); + const startDate = new Date(currentYear, 0, 1); + + // Initialize all days in the current year const currentDate = new Date(startDate); while (currentDate <= now) { const dateStr = this._getDateStr(currentDate); @@ -457,14 +447,6 @@ export class ActivityHeatmapComponent { return `${minutes}m`; } - getSelectedPeriod(): 'last365' | 'currentYear' { - return this.selectedPeriod; - } - - onPeriodChange(period: 'last365' | 'currentYear'): void { - this.selectedPeriod = period; - this._periodChange$.next(); - } async shareHeatmap(): Promise { const data = this.heatmapData(); if (!data) { From bab837e2197c9d064d40551aec30e7a11f47f9d7 Mon Sep 17 00:00:00 2001 From: Trang Le Date: Tue, 30 Dec 2025 15:51:47 +0700 Subject: [PATCH 05/21] remove select menu --- .../metric/activity-heatmap/activity-heatmap.component.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/features/metric/activity-heatmap/activity-heatmap.component.html b/src/app/features/metric/activity-heatmap/activity-heatmap.component.html index e0d08921e..460a6a087 100644 --- a/src/app/features/metric/activity-heatmap/activity-heatmap.component.html +++ b/src/app/features/metric/activity-heatmap/activity-heatmap.component.html @@ -20,7 +20,7 @@
@if (heatmapData(); as data) { -
+
From 2ae0deace79dfcf2ba86340f1d0a7023403d1f6f Mon Sep 17 00:00:00 2001 From: Trang Le Date: Mon, 5 Jan 2026 16:35:28 +0700 Subject: [PATCH 06/21] add function for extracting all available years --- .../activity-heatmap.component.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts b/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts index 0f95845d2..fd05bad44 100644 --- a/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts +++ b/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts @@ -447,6 +447,31 @@ export class ActivityHeatmapComponent { return `${minutes}m`; } + private _extractAvailableYears(tasks: Task[]): number[] { + const yearsSet = new Set(); + const currentYear = new Date().getFullYear(); + tasks.forEach((task) => { + if (task.timeSpentOnDay) { + Object.keys(task.timeSpentOnDay).forEach((dateStr) => { + const timeSpent = task.timeSpentOnDay[dateStr]; + if (timeSpent > 0) { + // dateStr is in ISO format YYYY-MM-DD, so extract the + // first four characters to get the year + const year = parseInt(dateStr.substring(0, 4), 10); + // Validate and collect year + if (!isNaN(year) && year <= currentYear) { + yearsSet.add(year); + } + } + }); + } + }); + // Sort years in descending order, i.e. latest years + // come first so that they will be displayed first in the + // select menu + return Array.from(yearsSet).sort((a, b) => b - a); + } + async shareHeatmap(): Promise { const data = this.heatmapData(); if (!data) { From 83db0455ea33347e3aaaa773a4d5110b4daf2c69 Mon Sep 17 00:00:00 2001 From: Trang Le Date: Mon, 5 Jan 2026 16:36:41 +0700 Subject: [PATCH 07/21] display options for selecting past years if available --- .../activity-heatmap.component.html | 15 ++++++++------- .../activity-heatmap.component.ts | 7 ++++++- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/app/features/metric/activity-heatmap/activity-heatmap.component.html b/src/app/features/metric/activity-heatmap/activity-heatmap.component.html index 460a6a087..e0b4b065c 100644 --- a/src/app/features/metric/activity-heatmap/activity-heatmap.component.html +++ b/src/app/features/metric/activity-heatmap/activity-heatmap.component.html @@ -20,21 +20,22 @@
@if (heatmapData(); as data) { - +
diff --git a/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts b/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts index fd05bad44..7a7c88c07 100644 --- a/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts +++ b/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts @@ -60,7 +60,8 @@ export class ActivityHeatmapComponent { private readonly _shareService = inject(ShareService); private readonly _periodChange$ = new Subject(); private readonly _dateAdapter = inject(DateAdapter); - + availableYears = signal([]); + selectedYear = signal(new Date().getFullYear()); T: typeof T = T; weeks: WeekData[] = []; isSharing = signal(false); @@ -69,6 +70,10 @@ export class ActivityHeatmapComponent { { initialValue: '' }, ); + onYearChange(year: number): void { + this.selectedYear.set(year); + } + // Day labels adjusted for first day of week readonly dayLabels = computed(() => { const allDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; From 8bbfca841d1bc7c7a9de0af97bd503599051d39f Mon Sep 17 00:00:00 2001 From: Trang Le Date: Mon, 5 Jan 2026 16:38:20 +0700 Subject: [PATCH 08/21] add function for processing activity data for a given year --- .../activity-heatmap.component.ts | 38 +++++++++---------- 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts b/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts index 7a7c88c07..b2c052bfc 100644 --- a/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts +++ b/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts @@ -33,6 +33,12 @@ interface DayData { level: number; // 0-4 for color intensity } +interface YearlyActivityData { + dayMap: Map; + startDate: Date; + endDate: Date; +} + interface WeekData { days: (DayData | null)[]; } @@ -145,19 +151,17 @@ export class ActivityHeatmapComponent { return allTasks; } - private _buildHeatmapDataFromTasks(tasks: Task[]): { - dayMap: Map; - startDate: Date; - endDate: Date; - } | null { + private _buildHeatmapDataForGivenYear( + tasks: Task[], + year: number, + ): YearlyActivityData | null { const dayMap = new Map(); - const now = new Date(); - const currentYear = now.getFullYear(); - const startDate = new Date(currentYear, 0, 1); + const startDate = new Date(year, 0, 1); + const endDate = new Date(year, 11, 31); - // Initialize all days in the current year + // Initialize all days in the specified year const currentDate = new Date(startDate); - while (currentDate <= now) { + while (currentDate <= endDate) { const dateStr = this._getDateStr(currentDate); dayMap.set(dateStr, { date: new Date(currentDate), @@ -169,11 +173,10 @@ export class ActivityHeatmapComponent { currentDate.setDate(currentDate.getDate() + 1); } - // Extract time spent data from all tasks + // Extract time spent data for the specific year let maxTasks = 0; let maxTime = 0; const taskCountPerDay = new Map>(); - tasks.forEach((task) => { if (task.timeSpentOnDay) { Object.keys(task.timeSpentOnDay).forEach((dateStr) => { @@ -203,8 +206,7 @@ export class ActivityHeatmapComponent { } }); - // Calculate levels (0-4) based on activity - // Prioritize time spent (80%) over task count (20%) + // Calculate activity levels dayMap.forEach((day) => { if (day.taskCount === 0 && day.timeSpent === 0) { day.level = 0; @@ -213,7 +215,6 @@ export class ActivityHeatmapComponent { const timeRatio = maxTime > 0 ? day.timeSpent / maxTime : 0; // eslint-disable-next-line no-mixed-operators const combinedRatio = timeRatio * 0.8 + taskRatio * 0.2; - if (combinedRatio > 0.75) { day.level = 4; } else if (combinedRatio > 0.5) { @@ -225,12 +226,7 @@ export class ActivityHeatmapComponent { } } }); - - return { - dayMap, - startDate, - endDate: now, - }; + return { dayMap, startDate, endDate }; } private _buildHeatmapData(worklog: any): { From 68ee42484a9c666f532afb84b578265709777f58 Mon Sep 17 00:00:00 2001 From: Trang Le Date: Mon, 5 Jan 2026 16:42:41 +0700 Subject: [PATCH 09/21] set available years and build heatmap data for a given year --- .../activity-heatmap.component.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts b/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts index b2c052bfc..d40160696 100644 --- a/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts +++ b/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts @@ -5,13 +5,13 @@ import { inject, signal, } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; +import { toObservable, toSignal } from '@angular/core/rxjs-interop'; import { WorklogService } from '../../worklog/worklog.service'; import { WorkContextService } from '../../work-context/work-context.service'; import { TaskService } from '../../tasks/task.service'; import { TaskArchiveService } from '../../time-tracking/task-archive.service'; import { combineLatest, defer, from, Subject } from 'rxjs'; -import { first, map, startWith, switchMap } from 'rxjs/operators'; +import { first, map, switchMap } from 'rxjs/operators'; import { TranslatePipe } from '@ngx-translate/core'; import { T } from '../../../t.const'; import { TODAY_TAG } from '../../tag/tag.const'; @@ -91,14 +91,17 @@ export class ActivityHeatmapComponent { private readonly _rawHeatmapData = toSignal( combineLatest([ this._workContextService.activeWorkContext$, - this._periodChange$.pipe(startWith(null)), // Start with initial emission + toObservable(this.selectedYear), // Changes in selected year will trigger recalculation ]).pipe( - switchMap(([context]) => { + switchMap(([context, selectedYear]) => { // Special case: TODAY tag shows ALL data from all tasks if (context.id === TODAY_TAG.id) { // Use defer to ensure the Promise is created fresh each time return defer(() => from(this._loadAllTasks())).pipe( - map((tasks) => this._buildHeatmapDataFromTasks(tasks)), + map((tasks) => { + this.availableYears.set(this._extractAvailableYears(tasks)); + return this._buildHeatmapDataForGivenYear(tasks, selectedYear); + }), ); } @@ -180,13 +183,13 @@ export class ActivityHeatmapComponent { tasks.forEach((task) => { if (task.timeSpentOnDay) { Object.keys(task.timeSpentOnDay).forEach((dateStr) => { + const dateYear = parseInt(dateStr.substring(0, 4), 10); + if (dateYear !== year) return; const timeSpent = task.timeSpentOnDay[dateStr]; const dayData = dayMap.get(dateStr); - if (dayData && timeSpent > 0) { dayData.timeSpent += timeSpent; maxTime = Math.max(maxTime, dayData.timeSpent); - // Track unique tasks per day if (!taskCountPerDay.has(dateStr)) { taskCountPerDay.set(dateStr, new Set()); From a43fadac5ddc240d00ca63f527e85c6955b706fd Mon Sep 17 00:00:00 2001 From: Trang Le Date: Mon, 5 Jan 2026 19:47:33 +0700 Subject: [PATCH 10/21] remove unused signal --- .../metric/activity-heatmap/activity-heatmap.component.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts b/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts index d40160696..5884c21e0 100644 --- a/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts +++ b/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts @@ -10,7 +10,7 @@ import { WorklogService } from '../../worklog/worklog.service'; import { WorkContextService } from '../../work-context/work-context.service'; import { TaskService } from '../../tasks/task.service'; import { TaskArchiveService } from '../../time-tracking/task-archive.service'; -import { combineLatest, defer, from, Subject } from 'rxjs'; +import { combineLatest, defer, from } from 'rxjs'; import { first, map, switchMap } from 'rxjs/operators'; import { TranslatePipe } from '@ngx-translate/core'; import { T } from '../../../t.const'; @@ -64,7 +64,6 @@ export class ActivityHeatmapComponent { private readonly _taskArchiveService = inject(TaskArchiveService); private readonly _snackService = inject(SnackService); private readonly _shareService = inject(ShareService); - private readonly _periodChange$ = new Subject(); private readonly _dateAdapter = inject(DateAdapter); availableYears = signal([]); selectedYear = signal(new Date().getFullYear()); From 80286adac258ef9a23cf1c71fe136cf9a04536a9 Mon Sep 17 00:00:00 2001 From: Trang Le Date: Mon, 5 Jan 2026 19:52:56 +0700 Subject: [PATCH 11/21] remove unused .custom-select class --- .../activity-heatmap/activity-heatmap.component.scss | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/app/features/metric/activity-heatmap/activity-heatmap.component.scss b/src/app/features/metric/activity-heatmap/activity-heatmap.component.scss index c26563cf5..43d71bf8e 100644 --- a/src/app/features/metric/activity-heatmap/activity-heatmap.component.scss +++ b/src/app/features/metric/activity-heatmap/activity-heatmap.component.scss @@ -1,13 +1,6 @@ .select-menu { margin-bottom: 16px; } -.custom-select { - background: transparent; - border: none; - color: inherit; - font-size: inherit; - font-weight: 600; -} .activity-heatmap { margin: 24px 0; From 14c171657ecc842c02541ff3cd039d5c90052be0 Mon Sep 17 00:00:00 2001 From: Trang Le Date: Mon, 5 Jan 2026 19:59:38 +0700 Subject: [PATCH 12/21] only show the select menu if there are multiple years available --- .../activity-heatmap.component.html | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/src/app/features/metric/activity-heatmap/activity-heatmap.component.html b/src/app/features/metric/activity-heatmap/activity-heatmap.component.html index e0b4b065c..c15f8e664 100644 --- a/src/app/features/metric/activity-heatmap/activity-heatmap.component.html +++ b/src/app/features/metric/activity-heatmap/activity-heatmap.component.html @@ -20,22 +20,24 @@
@if (heatmapData(); as data) { -
- - Year - 1) { +
+ - @for (year of availableYears(); track year) { - {{ year }} - } - - -
+ Year + + @for (year of availableYears(); track year) { + {{ year }} + } + +
+
+ }
From d8c3adc786a8347c8438f07b7555025803dfd212 Mon Sep 17 00:00:00 2001 From: Trang Le Date: Mon, 5 Jan 2026 20:32:43 +0700 Subject: [PATCH 13/21] show the latest year with data when the current year has no data --- .../activity-heatmap/activity-heatmap.component.html | 2 +- .../activity-heatmap/activity-heatmap.component.ts | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/app/features/metric/activity-heatmap/activity-heatmap.component.html b/src/app/features/metric/activity-heatmap/activity-heatmap.component.html index c15f8e664..a5814ac10 100644 --- a/src/app/features/metric/activity-heatmap/activity-heatmap.component.html +++ b/src/app/features/metric/activity-heatmap/activity-heatmap.component.html @@ -20,7 +20,7 @@
@if (heatmapData(); as data) { - @if (availableYears().length > 1) { + @if (availableYears().length > 0) {
from(this._loadAllTasks())).pipe( map((tasks) => { - this.availableYears.set(this._extractAvailableYears(tasks)); + const yearsWithData = this._extractAvailableYears(tasks); + this.availableYears.set(yearsWithData); + // Set selectedYear to the most recent year with data + // if current year has no data + if (yearsWithData.length > 0 && !yearsWithData.includes(selectedYear)) { + this.selectedYear.set(yearsWithData[0]); + return this._buildHeatmapDataForGivenYear(tasks, yearsWithData[0]); + } return this._buildHeatmapDataForGivenYear(tasks, selectedYear); }), ); From a72cf6d40669fc6092e4e7c18a1ee9babdc37acc Mon Sep 17 00:00:00 2001 From: Trang Le Date: Tue, 6 Jan 2026 20:13:08 +0700 Subject: [PATCH 14/21] add translation for Year label --- .../metric/activity-heatmap/activity-heatmap.component.html | 2 +- src/app/t.const.ts | 1 + src/assets/i18n/en.json | 3 ++- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/app/features/metric/activity-heatmap/activity-heatmap.component.html b/src/app/features/metric/activity-heatmap/activity-heatmap.component.html index a5814ac10..f07b3bc2d 100644 --- a/src/app/features/metric/activity-heatmap/activity-heatmap.component.html +++ b/src/app/features/metric/activity-heatmap/activity-heatmap.component.html @@ -26,7 +26,7 @@ appearance="outline" subscriptSizing="dynamic" > - Year + {{ T.F.METRIC.CMP.YEAR | translate }} Date: Tue, 6 Jan 2026 20:19:49 +0700 Subject: [PATCH 15/21] use to update signals --- .../activity-heatmap/activity-heatmap.component.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts b/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts index 6450e1e6c..c70e69d2c 100644 --- a/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts +++ b/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts @@ -11,7 +11,7 @@ import { WorkContextService } from '../../work-context/work-context.service'; import { TaskService } from '../../tasks/task.service'; import { TaskArchiveService } from '../../time-tracking/task-archive.service'; import { combineLatest, defer, from } from 'rxjs'; -import { first, map, switchMap } from 'rxjs/operators'; +import { first, map, switchMap, tap } from 'rxjs/operators'; import { TranslatePipe } from '@ngx-translate/core'; import { T } from '../../../t.const'; import { TODAY_TAG } from '../../tag/tag.const'; @@ -97,16 +97,18 @@ export class ActivityHeatmapComponent { if (context.id === TODAY_TAG.id) { // Use defer to ensure the Promise is created fresh each time return defer(() => from(this._loadAllTasks())).pipe( - map((tasks) => { + tap((tasks) => { const yearsWithData = this._extractAvailableYears(tasks); this.availableYears.set(yearsWithData); // Set selectedYear to the most recent year with data // if current year has no data if (yearsWithData.length > 0 && !yearsWithData.includes(selectedYear)) { this.selectedYear.set(yearsWithData[0]); - return this._buildHeatmapDataForGivenYear(tasks, yearsWithData[0]); } - return this._buildHeatmapDataForGivenYear(tasks, selectedYear); + }), + map((tasks) => { + const currentlySelectedYear = this.selectedYear(); + return this._buildHeatmapDataForGivenYear(tasks, currentlySelectedYear); }), ); } From b766d44c213a295a7ceccae4d78ffa561eade4e5 Mon Sep 17 00:00:00 2001 From: Trang Le Date: Fri, 9 Jan 2026 15:54:48 +0700 Subject: [PATCH 16/21] extract available years from worklog data --- .../activity-heatmap.component.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts b/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts index c70e69d2c..127a43f02 100644 --- a/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts +++ b/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts @@ -484,6 +484,23 @@ export class ActivityHeatmapComponent { return Array.from(yearsSet).sort((a, b) => b - a); } + private _extractAvailableYearsFromWorklog(worklog: any): number[] { + if (!worklog) return []; + const yearSet = new Set(); + const curYear = new Date().getFullYear(); + Object.keys(worklog).forEach((key) => { + const year = parseInt(key, 10); + if (!isNaN(year) && year <= curYear) { + // Check if this year has any data + const yearData = worklog[year]; + if (yearData && yearData.ent && Object.keys(yearData.ent).length > 0) { + yearSet.add(year); + } + } + }); + return Array.from(yearSet).sort((a, b) => b - a); + } + async shareHeatmap(): Promise { const data = this.heatmapData(); if (!data) { From 57dadbb03d9733d51a8c911212689f7fb11b9ddf Mon Sep 17 00:00:00 2001 From: Trang Le Date: Fri, 9 Jan 2026 15:57:09 +0700 Subject: [PATCH 17/21] build heatmap from worklog data --- .../activity-heatmap.component.ts | 93 +++++++++---------- 1 file changed, 42 insertions(+), 51 deletions(-) diff --git a/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts b/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts index 127a43f02..8fe31c9eb 100644 --- a/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts +++ b/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts @@ -240,7 +240,10 @@ export class ActivityHeatmapComponent { return { dayMap, startDate, endDate }; } - private _buildHeatmapData(worklog: any): { + private _buildHeatmapDataFromWorklog( + worklog: any, + year: number, + ): { dayMap: Map; startDate: Date; endDate: Date; @@ -248,67 +251,56 @@ export class ActivityHeatmapComponent { if (!worklog) { return null; } - + // Day map contains properties for each day of the year, regardless + // whether that day has any logged work or not const dayMap = new Map(); - const now = new Date(); - const oneYearAgo = new Date(now); - oneYearAgo.setFullYear(now.getFullYear() - 1); + const startDate = new Date(year, 0, 1); + const endDate = new Date(year, 11, 31); - // Initialize all days in the past year - const currentDate = new Date(oneYearAgo); - while (currentDate <= now) { - const dateStr = this._getDateStr(currentDate); + // Initialize all days in the specified year + const curDate = new Date(startDate); + while (curDate <= endDate) { + const dateStr = this._getDateStr(curDate); dayMap.set(dateStr, { - date: new Date(currentDate), + date: new Date(curDate), dateStr, taskCount: 0, timeSpent: 0, level: 0, }); - currentDate.setDate(currentDate.getDate() + 1); + curDate.setDate(curDate.getDate() + 1); } - // Extract data from worklog + // Extract data from worklog for the specified year let maxTasks = 0; let maxTime = 0; - - Object.keys(worklog).forEach((yearKeyIN) => { - const yearKey = +yearKeyIN; - const year = worklog[yearKey]; - - if (year && year.ent) { - Object.keys(year.ent).forEach((monthKeyIN) => { - const monthKey = +monthKeyIN; - const month = year.ent[monthKey]; - - if (month && month.ent) { - Object.keys(month.ent).forEach((dayKeyIN) => { - const dayKey = +dayKeyIN; - const day = month.ent[dayKey]; - - if (day) { - const dateStr = day.dateStr; - const existing = dayMap.get(dateStr); - - if (existing) { - const taskCount = day.logEntries.length; - const timeSpent = day.timeSpent; - - existing.taskCount = taskCount; - existing.timeSpent = timeSpent; - - maxTasks = Math.max(maxTasks, taskCount); - maxTime = Math.max(maxTime, timeSpent); - } + const yearData = worklog[year]; + if (yearData && yearData.ent) { + Object.keys(yearData.ent).forEach((monthKey) => { + const month = +monthKey; + const monthData = yearData.ent[month]; + if (monthData && monthData.ent) { + Object.keys(monthData.ent).forEach((dayKey) => { + const day = +dayKey; + const dayData = monthData.ent[day]; + if (day) { + const dateStr = dayData.dateStr; + const existing = dayMap.get(dateStr); + if (existing) { + const taskCount = dayData.logEntries.length; + const timeSpent = dayData.timeSpent; + existing.taskCount = taskCount; + existing.timeSpent = timeSpent; + maxTasks = Math.max(maxTasks, taskCount); + maxTime = Math.max(maxTime, timeSpent); } - }); - } - }); - } - }); + } + }); + } + }); + } - // Calculate levels (0-4) based on activity - // Prioritize time spent (80%) over task count (20%) + // Calculate levels dayMap.forEach((day) => { if (day.taskCount === 0 && day.timeSpent === 0) { day.level = 0; @@ -329,11 +321,10 @@ export class ActivityHeatmapComponent { } } }); - return { dayMap, - startDate: oneYearAgo, - endDate: now, + startDate, + endDate, }; } From d6840a64f8836724101ec45b917a1e058e6e328b Mon Sep 17 00:00:00 2001 From: Trang Le Date: Fri, 9 Jan 2026 15:58:08 +0700 Subject: [PATCH 18/21] handle context-filtered worklog --- .../activity-heatmap.component.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts b/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts index 8fe31c9eb..4369aba90 100644 --- a/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts +++ b/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts @@ -115,7 +115,23 @@ export class ActivityHeatmapComponent { // Normal case: use context-filtered worklog return this._worklogService.worklog$.pipe( - map((worklog) => this._buildHeatmapData(worklog)), + tap((worklog) => { + // Extract years from worklog and populate availableYears + const yearsWithData = this._extractAvailableYearsFromWorklog(worklog); + this.availableYears.set(yearsWithData); + // Set selectedYear to the most recent year with data if current year + // has no data + if ( + yearsWithData.length > 0 && + !yearsWithData.includes(this.selectedYear()) + ) { + this.selectedYear.set(yearsWithData[0]); + } + }), + map((worklog) => { + const currentlySelectedYear = this.selectedYear(); + return this._buildHeatmapDataFromWorklog(worklog, currentlySelectedYear); + }), ); }), ), From bec4a17a768c9589d334bc1081a03d8ca6614eaa Mon Sep 17 00:00:00 2001 From: Trang Le Date: Tue, 13 Jan 2026 15:56:37 +0700 Subject: [PATCH 19/21] import data types --- .../activity-heatmap.component.ts | 22 +++++-------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts b/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts index 2cc2db32c..476ff5751 100644 --- a/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts +++ b/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts @@ -23,31 +23,19 @@ import { MatTooltip } from '@angular/material/tooltip'; import { MatIcon } from '@angular/material/icon'; import { SnackService } from '../../../core/snack/snack.service'; import { ShareService } from '../../../core/share/share.service'; +import { + DayData, + WeekData, + HeatmapComponent, +} from '../../../ui/heatmap/heatmap.component'; import { DateAdapter } from '@angular/material/core'; -interface DayData { - date: Date; - dateStr: string; - taskCount: number; - timeSpent: number; - level: number; // 0-4 for color intensity -} - interface YearlyActivityData { dayMap: Map; startDate: Date; endDate: Date; } -interface WeekData { - days: (DayData | null)[]; -} -import { - DayData, - WeekData, - HeatmapComponent, -} from '../../../ui/heatmap/heatmap.component'; - @Component({ selector: 'activity-heatmap', templateUrl: './activity-heatmap.component.html', From 7df01cbf99c1cbf444a669ce419d239ac795060f Mon Sep 17 00:00:00 2001 From: Trang Le Date: Tue, 13 Jan 2026 16:13:41 +0700 Subject: [PATCH 20/21] resolve circular dependency with --- .../activity-heatmap.component.ts | 27 +++++++++---------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts b/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts index 476ff5751..5512744a6 100644 --- a/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts +++ b/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts @@ -59,8 +59,18 @@ export class ActivityHeatmapComponent { private readonly _snackService = inject(SnackService); private readonly _shareService = inject(ShareService); private readonly _dateAdapter = inject(DateAdapter); + private readonly _userSelectedYear = signal(null); availableYears = signal([]); - selectedYear = signal(new Date().getFullYear()); + selectedYear = computed(() => { + const userSelection = this._userSelectedYear(); + const availableYears = this.availableYears(); + // If user has made a selection and it's valid, use it + if (userSelection !== null && availableYears.includes(userSelection)) { + return userSelection; + } + // Otherwise, default to most recent year with data or the current year + return availableYears.length > 0 ? availableYears[0] : new Date().getFullYear(); + }); T: typeof T = T; weeks: WeekData[] = []; isSharing = signal(false); @@ -70,7 +80,7 @@ export class ActivityHeatmapComponent { ); onYearChange(year: number): void { - this.selectedYear.set(year); + this._userSelectedYear.set(year); // Only update user selection } // Day labels adjusted for first day of week @@ -94,11 +104,6 @@ export class ActivityHeatmapComponent { tap((tasks) => { const yearsWithData = this._extractAvailableYears(tasks); this.availableYears.set(yearsWithData); - // Set selectedYear to the most recent year with data - // if current year has no data - if (yearsWithData.length > 0 && !yearsWithData.includes(selectedYear)) { - this.selectedYear.set(yearsWithData[0]); - } }), map((tasks) => { const currentlySelectedYear = this.selectedYear(); @@ -113,14 +118,6 @@ export class ActivityHeatmapComponent { // Extract years from worklog and populate availableYears const yearsWithData = this._extractAvailableYearsFromWorklog(worklog); this.availableYears.set(yearsWithData); - // Set selectedYear to the most recent year with data if current year - // has no data - if ( - yearsWithData.length > 0 && - !yearsWithData.includes(this.selectedYear()) - ) { - this.selectedYear.set(yearsWithData[0]); - } }), map((worklog) => { const currentlySelectedYear = this.selectedYear(); From c1e84b93243d116ff6222b9d858092531b165d12 Mon Sep 17 00:00:00 2001 From: Trang Le Date: Tue, 13 Jan 2026 16:47:29 +0700 Subject: [PATCH 21/21] change from combinateLatest to combineLatestWith --- .../activity-heatmap.component.ts | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts b/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts index b1500259e..2fe7f4c8e 100644 --- a/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts +++ b/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts @@ -11,7 +11,14 @@ import { WorkContextService } from '../../work-context/work-context.service'; import { TaskService } from '../../tasks/task.service'; import { TaskArchiveService } from '../../archive/task-archive.service'; import { defer, from } from 'rxjs'; -import { first, map, switchMap } from 'rxjs/operators'; +import { + combineLatest, + combineLatestWith, + first, + map, + switchMap, + tap, +} from 'rxjs/operators'; import { TranslatePipe } from '@ngx-translate/core'; import { T } from '../../../t.const'; import { TODAY_TAG } from '../../tag/tag.const'; @@ -92,20 +99,20 @@ export class ActivityHeatmapComponent { // Raw data signals private readonly _rawHeatmapData = toSignal( - combineLatest([ - this._workContextService.activeWorkContext$, - toObservable(this.selectedYear), // Changes in selected year will trigger recalculation - ]).pipe( - switchMap(([context, selectedYear]) => { + this._workContextService.activeWorkContext$.pipe( + combineLatestWith(toObservable(this.selectedYear)), + switchMap(([context, userSelectedYear]) => { // Special case: TODAY tag shows ALL data from all tasks if (context.id === TODAY_TAG.id) { - // Use defer to ensure the Promise is created fresh each time return defer(() => from(this._loadAllTasks())).pipe( tap((tasks) => { + // Only side effect: update available years const yearsWithData = this._extractAvailableYears(tasks); this.availableYears.set(yearsWithData); + // No selectedYear mutation here! }), map((tasks) => { + // Use computed selectedYear value const currentlySelectedYear = this.selectedYear(); return this._buildHeatmapDataForGivenYear(tasks, currentlySelectedYear); }), @@ -115,11 +122,13 @@ export class ActivityHeatmapComponent { // Normal case: use context-filtered worklog return this._worklogService.worklog$.pipe( tap((worklog) => { - // Extract years from worklog and populate availableYears + // Only side effect: update available years const yearsWithData = this._extractAvailableYearsFromWorklog(worklog); this.availableYears.set(yearsWithData); + // No selectedYear mutation here! }), map((worklog) => { + // Use computed selectedYear value const currentlySelectedYear = this.selectedYear(); return this._buildHeatmapDataFromWorklog(worklog, currentlySelectedYear); }),