feat(task-repeat): add history heatmap to repeat config dialog

Extract reusable heatmap component from activity-heatmap and add
a new repeat-task-heatmap that shows time spent history for
repeatable task instances in the configuration dialog.
This commit is contained in:
Johannes Millan 2026-01-09 15:55:17 +01:00
parent 7bf9e9393e
commit 73690c3766
12 changed files with 676 additions and 293 deletions

View file

@ -20,47 +20,7 @@
</div>
@if (heatmapData(); as data) {
<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

@ -35,215 +35,3 @@
}
}
}
.heatmap-container {
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px;
background: var(--c-dark-10);
border-radius: 4px;
}
.heatmap-grid {
display: flex;
gap: 8px;
align-items: flex-start;
}
.scrollable-content {
display: flex;
flex-direction: column;
gap: 8px;
overflow-x: auto;
overflow-y: hidden;
// Smooth scrolling
scroll-behavior: smooth;
// Better scrollbar styling
&::-webkit-scrollbar {
height: 8px;
}
&::-webkit-scrollbar-track {
background: var(--c-dark-10);
border-radius: 4px;
}
&::-webkit-scrollbar-thumb {
background: var(--c-dark-20);
border-radius: 4px;
&:hover {
background: var(--c-dark-30);
}
}
}
.heatmap-months {
display: flex;
gap: 2px;
font-size: 12px;
line-height: 12px;
height: 12px;
color: var(--text-color-muted);
flex-shrink: 0;
.month-label {
flex: 0 0 auto;
width: calc(4 * 12px + 4 * 2px); // 4 weeks * (cell width + gap)
}
}
.day-labels {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 10px;
color: var(--text-color-muted);
padding-right: 4px;
width: 40px;
text-align: right;
flex-shrink: 0;
.month-spacer {
height: 17px; // 12px (heatmap-months height) + 8px (gap in scrollable-content)
flex-shrink: 0;
}
.day-label {
height: 12px;
line-height: 12px;
flex-shrink: 0;
}
}
.weeks {
display: flex;
gap: 2px;
flex-wrap: nowrap;
flex-shrink: 0;
}
.week {
display: flex;
flex-direction: column;
gap: 2px;
flex-shrink: 0;
}
.day {
width: 12px;
height: 12px;
border-radius: 2px;
cursor: pointer;
transition: all 0.1s ease;
flex-shrink: 0;
&.empty {
background: transparent;
cursor: default;
}
&.level-0 {
background: var(--c-dark-10);
}
&.level-1 {
background: color-mix(in srgb, var(--c-primary) 20%, transparent);
}
&.level-2 {
background: color-mix(in srgb, var(--c-primary) 40%, transparent);
}
&.level-3 {
background: color-mix(in srgb, var(--c-primary) 60%, transparent);
}
&.level-4 {
background: var(--c-primary);
}
&:not(.empty):hover {
transform: scale(1.3);
outline: 1px solid var(--c-dark-20);
z-index: 1;
}
}
.heatmap-legend {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
font-size: 11px;
color: var(--text-color-muted);
padding-right: 4px;
span {
margin: 0 4px;
}
.legend-item {
width: 12px;
height: 12px;
border-radius: 2px;
&.level-0 {
background: var(--c-dark-10);
}
&.level-1 {
background: color-mix(in srgb, var(--c-primary) 20%, transparent);
}
&.level-2 {
background: color-mix(in srgb, var(--c-primary) 40%, transparent);
}
&.level-3 {
background: color-mix(in srgb, var(--c-primary) 60%, transparent);
}
&.level-4 {
background: var(--c-primary);
}
}
}
// Dark theme support
:host-context(.isDarkTheme) {
.heatmap-container {
background: var(--c-light-05);
}
.scrollable-content {
&::-webkit-scrollbar-track {
background: var(--c-light-05);
}
&::-webkit-scrollbar-thumb {
background: var(--c-light-10);
&:hover {
background: var(--c-light-33);
}
}
}
.day {
&.level-0 {
background: var(--c-light-05);
}
&:not(.empty):hover {
outline-color: var(--c-light-10);
}
}
.heatmap-legend .legend-item.level-0 {
background: var(--c-light-05);
}
}

View file

@ -22,25 +22,18 @@ 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';
interface DayData {
date: Date;
dateStr: string;
taskCount: number;
timeSpent: number;
level: number; // 0-4 for color intensity
}
interface WeekData {
days: (DayData | null)[];
}
import {
DayData,
WeekData,
HeatmapComponent,
} from '../../../ui/heatmap/heatmap.component';
@Component({
selector: 'activity-heatmap',
templateUrl: './activity-heatmap.component.html',
styleUrls: ['./activity-heatmap.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TranslatePipe, MatIconButton, MatTooltip, MatIcon],
imports: [TranslatePipe, MatIconButton, MatTooltip, MatIcon, HeatmapComponent],
})
export class ActivityHeatmapComponent {
private readonly _worklogService = inject(WorklogService);
@ -410,30 +403,6 @@ 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`;
}
async shareHeatmap(): Promise<void> {
const data = this.heatmapData();
if (!data) {

View file

@ -11,7 +11,10 @@
</h1>
<mat-dialog-content>
<!-- <h3 class="mat-h3">{{task.title}}</h3>-->
@if (repeatCfgId(); as cfgId) {
<repeat-task-heatmap [repeatCfgId]="cfgId" />
}
<help-section>
<p>{{ T.F.TASK_REPEAT.D_EDIT.HELP1 | translate }}</p>
<p>{{ T.F.TASK_REPEAT.D_EDIT.HELP2 | translate }}</p>

View file

@ -1,5 +1,5 @@
import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { Component, NO_ERRORS_SCHEMA } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule } from '@ngx-translate/core';
@ -15,6 +15,15 @@ import { GlobalConfigService } from '../../config/global-config.service';
import { DateTimeFormatService } from '../../../core/date-time-format/date-time-format.service';
import { DEFAULT_TASK_REPEAT_CFG, TaskRepeatCfg } from '../task-repeat-cfg.model';
import { TaskCopy } from '../../tasks/task.model';
import { RepeatTaskHeatmapComponent } from '../repeat-task-heatmap/repeat-task-heatmap.component';
// Stub component to replace RepeatTaskHeatmapComponent which has heavy dependencies
@Component({
selector: 'repeat-task-heatmap',
template: '',
standalone: true,
})
class MockRepeatTaskHeatmapComponent {}
describe('DialogEditTaskRepeatCfgComponent', () => {
let mockDialogRef: jasmine.SpyObj<MatDialogRef<DialogEditTaskRepeatCfgComponent>>;
@ -87,6 +96,7 @@ describe('DialogEditTaskRepeatCfgComponent', () => {
TranslateModule.forRoot(),
FormlyConfigModule,
ReactiveFormsModule,
MockRepeatTaskHeatmapComponent,
],
schemas: [NO_ERRORS_SCHEMA],
providers: [
@ -98,7 +108,16 @@ describe('DialogEditTaskRepeatCfgComponent', () => {
{ provide: GlobalConfigService, useValue: mockGlobalConfigService },
{ provide: DateTimeFormatService, useValue: mockDateTimeFormatService },
],
}).compileComponents();
})
.overrideComponent(DialogEditTaskRepeatCfgComponent, {
remove: {
imports: [RepeatTaskHeatmapComponent],
},
add: {
imports: [MockRepeatTaskHeatmapComponent],
},
})
.compileComponents();
return TestBed.createComponent(DialogEditTaskRepeatCfgComponent);
};

View file

@ -48,6 +48,7 @@ import { DialogConfirmComponent } from '../../../ui/dialog-confirm/dialog-confir
import { GlobalConfigService } from '../../config/global-config.service';
import { DEFAULT_GLOBAL_CONFIG } from '../../config/default-global-config.const';
import { DateTimeFormatService } from 'src/app/core/date-time-format/date-time-format.service';
import { RepeatTaskHeatmapComponent } from '../repeat-task-heatmap/repeat-task-heatmap.component';
// TASK_REPEAT_CFG_FORM_CFG
@Component({
@ -65,6 +66,7 @@ import { DateTimeFormatService } from 'src/app/core/date-time-format/date-time-f
MatDialogActions,
MatButton,
MatIcon,
RepeatTaskHeatmapComponent,
],
})
export class DialogEditTaskRepeatCfgComponent {
@ -95,6 +97,14 @@ export class DialogEditTaskRepeatCfgComponent {
return false;
});
repeatCfgId = computed(() => {
const cfg = this.repeatCfg();
if ('id' in cfg && cfg.id) {
return cfg.id;
}
return this._data.repeatCfg?.id || this._data.task?.repeatCfgId || null;
});
TASK_REPEAT_CFG_FORM_CFG_BEFORE_TAGS = signal<FormlyFieldConfig[]>([]);
TASK_REPEAT_CFG_ADVANCED_FORM_CFG = signal<FormlyFieldConfig[]>(
TASK_REPEAT_CFG_ADVANCED_FORM_CFG,

View file

@ -0,0 +1,7 @@
@if (heatmapData(); as data) {
<heatmap
[data]="data"
[showLegend]="false"
[scrollToEnd]="true"
/>
}

View file

@ -0,0 +1,5 @@
:host {
display: block;
max-width: 500px;
margin-bottom: 16px;
}

View file

@ -0,0 +1,273 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
} from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { TaskService } from '../../tasks/task.service';
import { TaskArchiveService } from '../../time-tracking/task-archive.service';
import { from } from 'rxjs';
import { filter, first, map, switchMap } from 'rxjs/operators';
import { Task } from '../../tasks/task.model';
import { DateAdapter } from '@angular/material/core';
import {
DayData,
WeekData,
HeatmapData,
HeatmapComponent,
} from '../../../ui/heatmap/heatmap.component';
@Component({
selector: 'repeat-task-heatmap',
templateUrl: './repeat-task-heatmap.component.html',
styleUrls: ['./repeat-task-heatmap.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [HeatmapComponent],
})
export class RepeatTaskHeatmapComponent {
private readonly _taskService = inject(TaskService);
private readonly _taskArchiveService = inject(TaskArchiveService);
private readonly _dateAdapter = inject(DateAdapter);
readonly repeatCfgId = input.required<string>();
private readonly _rawHeatmapData = toSignal(
toObservable(this.repeatCfgId).pipe(
filter((id): id is string => !!id),
switchMap((repeatCfgId) => from(this._loadTasksForRepeatCfg(repeatCfgId))),
map((tasks) => this._buildHeatmapData(tasks)),
),
{ initialValue: null },
);
readonly heatmapData = computed<HeatmapData | null>(() => {
const rawData = this._rawHeatmapData();
const firstDay = this._dateAdapter.getFirstDayOfWeek();
if (!rawData || !rawData.dayMap) {
return null;
}
// Check if there's any actual data
if (!rawData.hasData) {
return null;
}
return this._buildWeeksGrid(
rawData.dayMap,
rawData.startDate,
rawData.endDate,
firstDay,
);
});
private async _loadTasksForRepeatCfg(repeatCfgId: string): Promise<Task[]> {
const [archive, currentTasks] = await Promise.all([
this._taskArchiveService.load(),
this._taskService.allTasks$.pipe(first()).toPromise(),
]);
const matchingTasks: Task[] = [];
// Filter current tasks by repeatCfgId
if (currentTasks) {
for (const task of currentTasks) {
if (task.repeatCfgId === repeatCfgId) {
matchingTasks.push(task);
}
}
}
// Filter archived tasks by repeatCfgId
if (archive && archive.ids) {
for (const taskId of archive.ids) {
const archivedTask = archive.entities[taskId];
if (archivedTask && archivedTask.repeatCfgId === repeatCfgId) {
matchingTasks.push(archivedTask as Task);
}
}
}
return matchingTasks;
}
private _buildHeatmapData(tasks: Task[]): {
dayMap: Map<string, DayData>;
startDate: Date;
endDate: Date;
hasData: boolean;
} {
const dayMap = new Map<string, DayData>();
const now = new Date();
const oneYearAgo = new Date(now);
oneYearAgo.setFullYear(now.getFullYear() - 1);
// Initialize all days in the past year
const currentDate = new Date(oneYearAgo);
while (currentDate <= now) {
const dateStr = this._getDateStr(currentDate);
dayMap.set(dateStr, {
date: new Date(currentDate),
dateStr,
taskCount: 0,
timeSpent: 0,
level: 0,
});
currentDate.setDate(currentDate.getDate() + 1);
}
// Aggregate time spent from all tasks
let maxTime = 0;
let hasData = false;
const taskCountPerDay = new Map<string, Set<string>>();
for (const task of tasks) {
if (task.timeSpentOnDay) {
for (const dateStr of Object.keys(task.timeSpentOnDay)) {
const timeSpent = task.timeSpentOnDay[dateStr];
const dayData = dayMap.get(dateStr);
if (dayData && timeSpent > 0) {
dayData.timeSpent += timeSpent;
maxTime = Math.max(maxTime, dayData.timeSpent);
hasData = true;
// Track unique tasks per day
if (!taskCountPerDay.has(dateStr)) {
taskCountPerDay.set(dateStr, new Set());
}
taskCountPerDay.get(dateStr)!.add(task.id);
}
}
}
}
// Update task counts
for (const [dateStr, taskIds] of taskCountPerDay) {
const dayData = dayMap.get(dateStr);
if (dayData) {
dayData.taskCount = taskIds.size;
}
}
// Calculate levels (0-4) based on time spent
for (const day of dayMap.values()) {
if (day.timeSpent === 0) {
day.level = 0;
} else {
const timeRatio = maxTime > 0 ? day.timeSpent / maxTime : 0;
if (timeRatio > 0.75) {
day.level = 4;
} else if (timeRatio > 0.5) {
day.level = 3;
} else if (timeRatio > 0.25) {
day.level = 2;
} else {
day.level = 1;
}
}
}
return {
dayMap,
startDate: oneYearAgo,
endDate: now,
hasData,
};
}
private _getDateStr(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
private _buildWeeksGrid(
dayMap: Map<string, DayData>,
startDate: Date,
endDate: Date,
firstDayOfWeek: number = 0,
): HeatmapData {
const weeks: WeekData[] = [];
const monthLabels: string[] = [];
let currentMonth = -1;
// Find the first day (based on firstDayOfWeek setting) before or on the start date
const firstDay = new Date(startDate);
const dayOfWeek = firstDay.getDay();
const daysToGoBack = (dayOfWeek - firstDayOfWeek + 7) % 7;
firstDay.setDate(firstDay.getDate() - daysToGoBack);
// Build weeks
const currentDate = new Date(firstDay);
let weekCount = 0;
while (currentDate <= endDate || weeks.length === 0) {
const week: WeekData = { days: [] };
for (let i = 0; i < 7; i++) {
const dateStr = this._getDateStr(currentDate);
const dayData = dayMap.get(dateStr);
if (currentDate >= startDate && currentDate <= endDate) {
week.days.push(dayData || null);
const month = currentDate.getMonth();
if (month !== currentMonth && currentDate.getDate() <= 7 && weekCount > 0) {
const monthNames = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
monthLabels.push(monthNames[month]);
currentMonth = month;
} else if (monthLabels.length === 0 && weekCount === 0) {
const monthNames = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
monthLabels.push(monthNames[month]);
currentMonth = month;
}
} else {
week.days.push(null);
}
currentDate.setDate(currentDate.getDate() + 1);
}
weeks.push(week);
weekCount++;
if (weeks.length > 54) {
break;
}
}
return { weeks, monthLabels };
}
}

View file

@ -0,0 +1,48 @@
@if (data(); as heatmapData) {
<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"
#scrollableContent
>
<div class="heatmap-months">
@for (month of heatmapData.monthLabels; track $index) {
<div class="month-label">{{ month }}</div>
}
</div>
<div class="weeks">
@for (week of heatmapData.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>
@if (showLegend()) {
<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>
}

View file

@ -0,0 +1,211 @@
.heatmap-container {
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px;
background: var(--c-dark-10);
border-radius: 4px;
}
.heatmap-grid {
display: flex;
gap: 8px;
align-items: flex-start;
}
.scrollable-content {
display: flex;
flex-direction: column;
gap: 8px;
overflow-x: auto;
overflow-y: hidden;
// Smooth scrolling
scroll-behavior: smooth;
// Better scrollbar styling
&::-webkit-scrollbar {
height: 8px;
}
&::-webkit-scrollbar-track {
background: var(--c-dark-10);
border-radius: 4px;
}
&::-webkit-scrollbar-thumb {
background: var(--c-dark-20);
border-radius: 4px;
&:hover {
background: var(--c-dark-30);
}
}
}
.heatmap-months {
display: flex;
gap: 2px;
font-size: 12px;
line-height: 12px;
height: 12px;
color: var(--text-color-muted);
flex-shrink: 0;
.month-label {
flex: 0 0 auto;
width: calc(4 * 12px + 4 * 2px); // 4 weeks * (cell width + gap)
}
}
.day-labels {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 10px;
color: var(--text-color-muted);
padding-right: 4px;
width: 40px;
text-align: right;
flex-shrink: 0;
.month-spacer {
height: 17px; // 12px (heatmap-months height) + 8px (gap in scrollable-content)
flex-shrink: 0;
}
.day-label {
height: 12px;
line-height: 12px;
flex-shrink: 0;
}
}
.weeks {
display: flex;
gap: 2px;
flex-wrap: nowrap;
flex-shrink: 0;
}
.week {
display: flex;
flex-direction: column;
gap: 2px;
flex-shrink: 0;
}
.day {
width: 12px;
height: 12px;
border-radius: 2px;
cursor: pointer;
transition: all 0.1s ease;
flex-shrink: 0;
&.empty {
background: transparent;
cursor: default;
}
&.level-0 {
background: var(--c-dark-10);
}
&.level-1 {
background: color-mix(in srgb, var(--c-primary) 20%, transparent);
}
&.level-2 {
background: color-mix(in srgb, var(--c-primary) 40%, transparent);
}
&.level-3 {
background: color-mix(in srgb, var(--c-primary) 60%, transparent);
}
&.level-4 {
background: var(--c-primary);
}
&:not(.empty):hover {
transform: scale(1.3);
outline: 1px solid var(--c-dark-20);
z-index: 1;
}
}
.heatmap-legend {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
font-size: 11px;
color: var(--text-color-muted);
padding-right: 4px;
span {
margin: 0 4px;
}
.legend-item {
width: 12px;
height: 12px;
border-radius: 2px;
&.level-0 {
background: var(--c-dark-10);
}
&.level-1 {
background: color-mix(in srgb, var(--c-primary) 20%, transparent);
}
&.level-2 {
background: color-mix(in srgb, var(--c-primary) 40%, transparent);
}
&.level-3 {
background: color-mix(in srgb, var(--c-primary) 60%, transparent);
}
&.level-4 {
background: var(--c-primary);
}
}
}
// Dark theme support
:host-context(.isDarkTheme) {
.heatmap-container {
background: var(--c-light-05);
}
.scrollable-content {
&::-webkit-scrollbar-track {
background: var(--c-light-05);
}
&::-webkit-scrollbar-thumb {
background: var(--c-light-10);
&:hover {
background: var(--c-light-33);
}
}
}
.day {
&.level-0 {
background: var(--c-light-05);
}
&:not(.empty):hover {
outline-color: var(--c-light-10);
}
}
.heatmap-legend .legend-item.level-0 {
background: var(--c-light-05);
}
}

View file

@ -0,0 +1,90 @@
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
ElementRef,
inject,
input,
viewChild,
} from '@angular/core';
import { DateAdapter } from '@angular/material/core';
export interface DayData {
date: Date;
dateStr: string;
taskCount: number;
timeSpent: number;
level: number; // 0-4 for color intensity
}
export interface WeekData {
days: (DayData | null)[];
}
export interface HeatmapData {
weeks: WeekData[];
monthLabels: string[];
}
@Component({
selector: 'heatmap',
templateUrl: './heatmap.component.html',
styleUrls: ['./heatmap.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [],
})
export class HeatmapComponent {
private readonly _dateAdapter = inject(DateAdapter);
readonly data = input.required<HeatmapData | null>();
readonly showLegend = input<boolean>(true);
readonly scrollToEnd = input<boolean>(false);
private readonly _scrollableContent =
viewChild<ElementRef<HTMLElement>>('scrollableContent');
constructor() {
effect(() => {
const data = this.data();
const scrollEl = this._scrollableContent()?.nativeElement;
if (data && scrollEl && this.scrollToEnd()) {
// Use setTimeout to ensure DOM is updated
setTimeout(() => {
scrollEl.scrollTo({ left: scrollEl.scrollWidth, behavior: 'instant' });
});
}
});
}
readonly dayLabels = computed(() => {
const allDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const firstDay = this._dateAdapter.getFirstDayOfWeek();
return [...allDays.slice(firstDay), ...allDays.slice(0, firstDay)];
});
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`;
}
}