feat(metrics): polish heatmap

This commit is contained in:
Johannes Millan 2025-10-21 12:05:47 +02:00
parent d1eb1b49b1
commit ca93982db8
4 changed files with 87 additions and 48 deletions

View file

@ -11,9 +11,13 @@
<div class="heatmap-grid">
<div class="day-labels">
<div class="day-label">Sun</div>
<div class="day-label">Mon</div>
<div class="day-label">Tue</div>
<div class="day-label">Wed</div>
<div class="day-label">Thu</div>
<div class="day-label">Fri</div>
<div class="day-label">Sat</div>
</div>
<div class="weeks">

View file

@ -36,11 +36,12 @@
.day-labels {
display: flex;
flex-direction: column;
justify-content: space-around;
gap: 2px;
font-size: 10px;
color: rgba(0, 0, 0, 0.6);
padding-right: 4px;
width: 40px;
text-align: right;
.day-label {
height: 12px;
@ -101,19 +102,19 @@
}
&.level-1 {
background: #b2ebf2;
background: color-mix(in srgb, var(--c-primary) 20%, transparent);
}
&.level-2 {
background: #4dd0e1;
background: color-mix(in srgb, var(--c-primary) 40%, transparent);
}
&.level-3 {
background: #00bcd4;
background: color-mix(in srgb, var(--c-primary) 60%, transparent);
}
&.level-4 {
background: #0097a7;
background: var(--c-primary);
}
&:not(.empty):hover {
@ -146,19 +147,19 @@
}
&.level-1 {
background: #b2ebf2;
background: color-mix(in srgb, var(--c-primary) 20%, transparent);
}
&.level-2 {
background: #4dd0e1;
background: color-mix(in srgb, var(--c-primary) 40%, transparent);
}
&.level-3 {
background: #00bcd4;
background: color-mix(in srgb, var(--c-primary) 60%, transparent);
}
&.level-4 {
background: #0097a7;
background: var(--c-primary);
}
}
}

View file

@ -3,7 +3,9 @@ import { 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 { map, switchMap } from 'rxjs/operators';
import { TaskArchiveService } from '../../time-tracking/task-archive.service';
import { from } from 'rxjs';
import { first, map, switchMap } from 'rxjs/operators';
import { DatePipe } from '@angular/common';
import { MsToStringPipe } from '../../../ui/duration/ms-to-string.pipe';
import { TranslatePipe } from '@ngx-translate/core';
@ -34,6 +36,7 @@ export class ActivityHeatmapComponent {
private readonly _worklogService = inject(WorklogService);
private readonly _workContextService = inject(WorkContextService);
private readonly _taskService = inject(TaskService);
private readonly _taskArchiveService = inject(TaskArchiveService);
T: typeof T = T;
monthLabels: string[] = [];
@ -41,14 +44,14 @@ export class ActivityHeatmapComponent {
// Compute heatmap data
// NOTE: Reacts to work context changes
// - For TODAY tag: shows ALL tasks from all projects/tags
// - For TODAY tag: shows ALL tasks from all projects/tags (current + archived)
// - For other tags/projects: shows only tasks from that context
heatmapData = toSignal(
this._workContextService.activeWorkContext$.pipe(
switchMap((context) => {
// Special case: TODAY tag shows ALL data
if (context.id === TODAY_TAG.id) {
return this._taskService.allTasks$.pipe(
return from(this._loadAllTasks()).pipe(
map((tasks) => this._buildHeatmapDataFromTasks(tasks)),
);
}
@ -62,6 +65,32 @@ export class ActivityHeatmapComponent {
{ initialValue: null },
);
private async _loadAllTasks(): Promise<Task[]> {
// Load both current tasks and archived tasks
const [archive, currentTasks] = await Promise.all([
this._taskArchiveService.load(),
this._taskService.allTasks$.pipe(first()).toPromise(),
]);
const allTasks: Task[] = [...(currentTasks || [])];
// Add archived tasks from all projects
if (archive) {
Object.values(archive).forEach((projectArchive) => {
if (projectArchive?.ids) {
projectArchive.ids.forEach((taskId) => {
const archivedTask = projectArchive.entities[taskId];
if (archivedTask) {
allTasks.push(archivedTask);
}
});
}
});
}
return allTasks;
}
private _buildHeatmapDataFromTasks(tasks: Task[]): {
weeks: WeekData[];
monthLabels: string[];
@ -120,13 +149,15 @@ export class ActivityHeatmapComponent {
});
// Calculate levels (0-4) based on activity
// Prioritize time spent (80%) over task count (20%)
dayMap.forEach((day) => {
if (day.taskCount === 0 && day.timeSpent === 0) {
day.level = 0;
} else {
const taskRatio = maxTasks > 0 ? day.taskCount / maxTasks : 0;
const timeRatio = maxTime > 0 ? day.timeSpent / maxTime : 0;
const combinedRatio = (taskRatio + timeRatio) / 2;
// eslint-disable-next-line no-mixed-operators
const combinedRatio = timeRatio * 0.8 + taskRatio * 0.2;
if (combinedRatio > 0.75) {
day.level = 4;
@ -210,17 +241,16 @@ export class ActivityHeatmapComponent {
});
// Calculate levels (0-4) based on activity
// Use a combined metric of tasks and time
// Prioritize time spent (80%) over task count (20%)
dayMap.forEach((day) => {
if (day.taskCount === 0 && day.timeSpent === 0) {
day.level = 0;
} else {
// Normalize based on both tasks and time
const taskRatio = maxTasks > 0 ? day.taskCount / maxTasks : 0;
const timeRatio = maxTime > 0 ? day.timeSpent / maxTime : 0;
const combinedRatio = (taskRatio + timeRatio) / 2;
// eslint-disable-next-line no-mixed-operators
const combinedRatio = timeRatio * 0.8 + taskRatio * 0.2;
// Map to levels 1-4
if (combinedRatio > 0.75) {
day.level = 4;
} else if (combinedRatio > 0.5) {

View file

@ -64,7 +64,10 @@
</section>
}
<activity-heatmap></activity-heatmap>
<section style="max-width: 880px; margin: auto; margin-top: 32px">
<h1 style="margin-bottom: 0">Activity</h1>
<activity-heatmap></activity-heatmap>
</section>
@if (!metricService.hasData()) {
<p style="margin-top: 32px">
@ -74,36 +77,6 @@
@if (metricService.hasData()) {
<section class="metric-metrics">
<h1>{{ T.F.METRIC.CMP.GLOBAL_METRICS | translate }}</h1>
<section class="pie-charts">
@if (metricService.improvementCountsPieChartData(); as improvementCounts) {
<section>
<h3>{{ T.F.METRIC.CMP.IMPROVEMENT_SELECTION_COUNT | translate }}</h3>
<lazy-chart
[type]="pieChartType"
[datasets]="improvementCounts.datasets"
[labels]="improvementCounts.labels"
[legend]="improvementCounts?.datasets[0].data.length < 12"
[options]="pieChartOptions"
height="300px"
>
</lazy-chart>
</section>
}
@if (metricService.obstructionCountsPieChartData(); as obstructionCounts) {
<section>
<h3>{{ T.F.METRIC.CMP.OBSTRUCTION_SELECTION_COUNT | translate }}</h3>
<lazy-chart
[type]="pieChartType"
[datasets]="obstructionCounts.datasets"
[labels]="obstructionCounts.labels"
[legend]="obstructionCounts?.datasets[0].data.length < 12"
[options]="pieChartOptions"
height="300px"
>
</lazy-chart>
</section>
}
</section>
<section class="line-charts">
@if (productivityHappiness(); as productivityHappiness) {
<section>
@ -134,6 +107,37 @@
}
}
</section>
<section class="pie-charts">
@if (metricService.improvementCountsPieChartData(); as improvementCounts) {
<section>
<h3>{{ T.F.METRIC.CMP.IMPROVEMENT_SELECTION_COUNT | translate }}</h3>
<lazy-chart
[type]="pieChartType"
[datasets]="improvementCounts.datasets"
[labels]="improvementCounts.labels"
[legend]="improvementCounts?.datasets[0].data.length < 12"
[options]="pieChartOptions"
height="300px"
>
</lazy-chart>
</section>
}
@if (metricService.obstructionCountsPieChartData(); as obstructionCounts) {
<section>
<h3>{{ T.F.METRIC.CMP.OBSTRUCTION_SELECTION_COUNT | translate }}</h3>
<lazy-chart
[type]="pieChartType"
[datasets]="obstructionCounts.datasets"
[labels]="obstructionCounts.labels"
[legend]="obstructionCounts?.datasets[0].data.length < 12"
[options]="pieChartOptions"
height="300px"
>
</lazy-chart>
</section>
}
</section>
</section>
}
@if (metricService.hasData()) {