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 9d72a9c11..675c937a6 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,65 @@
@if (heatmapData(); as data) {
+ @if (availableYears().length > 0) {
+
+ }
+
+
+
+
+ @for (label of dayLabels(); track $index) {
+
{{ label }}
+ }
+
+
+
+
+ @for (month of data.monthLabels; track $index) {
+
{{ month }}
+ }
+
+
+
+ @for (week of data.weeks; track $index) {
+
+ @for (day of week.days; track $index) {
+
+ }
+
+ }
+
+
+
+
+
+
Less
+
+
+
+
+
+
More
+
+
} @else {
{{ T.F.METRIC.CMP.NO_ADDITIONAL_DATA_YET | translate }}
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 cfec0b4fc..91205da9e 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,6 @@
+.select-menu {
+ margin-bottom: 16px;
+}
.activity-heatmap {
margin: 24px 0;
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 4fceea827..2fe7f4c8e 100644
--- a/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts
+++ b/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts
@@ -5,35 +5,58 @@ 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 '../../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';
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';
import { ShareService } from '../../../core/share/share.service';
-import { DateAdapter } from '@angular/material/core';
import {
DayData,
WeekData,
HeatmapComponent,
} from '../../../ui/heatmap/heatmap.component';
+import { DateAdapter } from '@angular/material/core';
+
+interface YearlyActivityData {
+ dayMap: Map;
+ startDate: Date;
+ endDate: Date;
+}
@Component({
selector: 'activity-heatmap',
templateUrl: './activity-heatmap.component.html',
styleUrls: ['./activity-heatmap.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
- imports: [TranslatePipe, MatIconButton, MatTooltip, MatIcon, HeatmapComponent],
+ imports: [
+ HeatmapComponent,
+ TranslatePipe,
+ MatFormFieldModule,
+ MatIconButton,
+ MatSelectModule,
+ MatTooltip,
+ MatIcon,
+ ],
})
export class ActivityHeatmapComponent {
private readonly _worklogService = inject(WorklogService);
@@ -43,7 +66,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 = 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);
@@ -52,6 +86,10 @@ export class ActivityHeatmapComponent {
{ initialValue: '' },
);
+ onYearChange(year: number): void {
+ this._userSelectedYear.set(year); // Only update user selection
+ }
+
// Day labels adjusted for first day of week
readonly dayLabels = computed(() => {
const allDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
@@ -62,18 +100,38 @@ export class ActivityHeatmapComponent {
// Raw data signals
private readonly _rawHeatmapData = toSignal(
this._workContextService.activeWorkContext$.pipe(
- switchMap((context) => {
+ 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(
- map((tasks) => this._buildHeatmapDataFromTasks(tasks)),
+ 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);
+ }),
);
}
// Normal case: use context-filtered worklog
return this._worklogService.worklog$.pipe(
- map((worklog) => this._buildHeatmapData(worklog)),
+ tap((worklog) => {
+ // 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);
+ }),
);
}),
),
@@ -120,19 +178,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 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) {
+ // Initialize all days in the specified year
+ const currentDate = new Date(startDate);
+ while (currentDate <= endDate) {
const dateStr = this._getDateStr(currentDate);
dayMap.set(dateStr, {
date: new Date(currentDate),
@@ -144,21 +200,20 @@ 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) => {
+ 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());
@@ -178,8 +233,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;
@@ -188,7 +242,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) {
@@ -200,15 +253,13 @@ export class ActivityHeatmapComponent {
}
}
});
-
- return {
- dayMap,
- startDate: oneYearAgo,
- endDate: now,
- };
+ return { dayMap, startDate, endDate };
}
- private _buildHeatmapData(worklog: any): {
+ private _buildHeatmapDataFromWorklog(
+ worklog: any,
+ year: number,
+ ): {
dayMap: Map;
startDate: Date;
endDate: Date;
@@ -216,67 +267,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;
@@ -297,11 +337,10 @@ export class ActivityHeatmapComponent {
}
}
});
-
return {
dayMap,
- startDate: oneYearAgo,
- endDate: now,
+ startDate,
+ endDate,
};
}
@@ -403,6 +442,72 @@ export class ActivityHeatmapComponent {
return { weeks, monthLabels };
}
+ getDayClass(day: DayData | null): string {
+ if (!day) {
+ return 'day empty';
+ }
+ return `day level-${day.level}`;
+ }
+
+ getDayTitle(day: DayData | null): string {
+ if (!day) {
+ return '';
+ }
+ return `${day.dateStr}: ${day.taskCount} tasks, ${this._formatTime(day.timeSpent)}`;
+ }
+
+ private _formatTime(ms: number): string {
+ const hours = Math.floor(ms / (1000 * 60 * 60));
+ const minutes = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60));
+
+ if (hours > 0) {
+ return `${hours}h ${minutes}m`;
+ }
+ 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);
+ }
+
+ 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) {
diff --git a/src/app/t.const.ts b/src/app/t.const.ts
index afad89103..a59e3b97d 100644
--- a/src/app/t.const.ts
+++ b/src/app/t.const.ts
@@ -558,6 +558,7 @@ const T = {
TIME_FRAME_LABEL: 'F.METRIC.CMP.TIME_FRAME_LABEL',
TIME_FRAME_MAX: 'F.METRIC.CMP.TIME_FRAME_MAX',
TIME_SPENT: 'F.METRIC.CMP.TIME_SPENT',
+ YEAR: 'F.METRIC.CMP.YEAR',
},
EVAL_FORM: {
DAILY_STATE: 'F.METRIC.EVAL_FORM.DAILY_STATE',
diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json
index a48375d8e..05e53bfd9 100644
--- a/src/assets/i18n/en.json
+++ b/src/assets/i18n/en.json
@@ -544,7 +544,8 @@
"TIME_FRAME_2_WEEKS": "2 weeks",
"TIME_FRAME_LABEL": "Time frame",
"TIME_FRAME_MAX": "Max",
- "TIME_SPENT": "Time Spent"
+ "TIME_SPENT": "Time Spent",
+ "YEAR": "Year"
},
"EVAL_FORM": {
"DAILY_STATE": "Daily State",