mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-01-23 02:36:05 +00:00
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:
parent
7bf9e9393e
commit
73690c3766
12 changed files with 676 additions and 293 deletions
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
@if (heatmapData(); as data) {
|
||||
<heatmap
|
||||
[data]="data"
|
||||
[showLegend]="false"
|
||||
[scrollToEnd]="true"
|
||||
/>
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
:host {
|
||||
display: block;
|
||||
max-width: 500px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
48
src/app/ui/heatmap/heatmap.component.html
Normal file
48
src/app/ui/heatmap/heatmap.component.html
Normal 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>
|
||||
}
|
||||
211
src/app/ui/heatmap/heatmap.component.scss
Normal file
211
src/app/ui/heatmap/heatmap.component.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
90
src/app/ui/heatmap/heatmap.component.ts
Normal file
90
src/app/ui/heatmap/heatmap.component.ts
Normal 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`;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue