This commit is contained in:
Trang Le 2026-01-22 17:38:15 +01:00 committed by GitHub
commit 9d1e1f6cb2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 254 additions and 85 deletions

View file

@ -20,6 +20,65 @@
</div>
@if (heatmapData(); as data) {
@if (availableYears().length > 0) {
<div class="select-menu">
<mat-form-field
appearance="outline"
subscriptSizing="dynamic"
>
<mat-label>{{ T.F.METRIC.CMP.YEAR | translate }}</mat-label>
<mat-select
[value]="selectedYear()"
(selectionChange)="onYearChange($event.value)"
>
@for (year of availableYears(); track year) {
<mat-option [value]="year">{{ year }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
}
<div class="heatmap-container">
<div class="heatmap-grid">
<div class="day-labels">
<div class="month-spacer"></div>
@for (label of dayLabels(); track $index) {
<div class="day-label">{{ label }}</div>
}
</div>
<div class="scrollable-content">
<div class="heatmap-months">
@for (month of data.monthLabels; track $index) {
<div class="month-label">{{ month }}</div>
}
</div>
<div class="weeks">
@for (week of data.weeks; track $index) {
<div class="week">
@for (day of week.days; track $index) {
<div
[class]="getDayClass(day)"
[title]="getDayTitle(day)"
></div>
}
</div>
}
</div>
</div>
</div>
<div class="heatmap-legend">
<span>Less</span>
<div class="legend-item level-0"></div>
<div class="legend-item level-1"></div>
<div class="legend-item level-2"></div>
<div class="legend-item level-3"></div>
<div class="legend-item level-4"></div>
<span>More</span>
</div>
</div>
<heatmap [data]="data" />
} @else {
<p>{{ T.F.METRIC.CMP.NO_ADDITIONAL_DATA_YET | translate }}</p>

View file

@ -1,3 +1,6 @@
.select-menu {
margin-bottom: 16px;
}
.activity-heatmap {
margin: 24px 0;

View file

@ -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<string, DayData>;
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<number | null>(null);
availableYears = signal<number[]>([]);
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<string, DayData>;
startDate: Date;
endDate: Date;
} | null {
private _buildHeatmapDataForGivenYear(
tasks: Task[],
year: number,
): YearlyActivityData | null {
const dayMap = new Map<string, DayData>();
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<string, Set<string>>();
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<string, DayData>;
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<string, DayData>();
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<number>();
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<number>();
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<void> {
const data = this.heatmapData();
if (!data) {

View file

@ -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',

View file

@ -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",