{
+ const data = this.heatmapData();
+ if (!data) {
+ return;
+ }
+
+ this.isSharing.set(true);
+
+ try {
+ // Render heatmap to canvas
+ const contextTitle = this._activeWorkContextTitle();
+ const canvas = this._renderToCanvas(data, contextTitle);
+
+ // Convert to blob
+ const blob: Blob | null = await new Promise((resolve) => {
+ canvas.toBlob((b) => resolve(b), 'image/png', 1.0);
+ });
+
+ if (!blob) {
+ throw new Error('Failed to generate image');
+ }
+
+ const file = new File([blob], 'activity-heatmap.png', { type: 'image/png' });
+
+ // Try native share API first
+ if (navigator.canShare && navigator.canShare({ files: [file] })) {
+ await navigator.share({
+ files: [file],
+ title: 'Activity Heatmap',
+ });
+ } else {
+ // Fallback: Download the file
+ this._downloadFile(blob, 'activity-heatmap.png');
+ }
+
+ this._snackService.open({
+ type: 'SUCCESS',
+ msg: 'Heatmap shared successfully',
+ });
+ } catch (error: any) {
+ // User cancelled or error occurred
+ if (error?.name !== 'AbortError') {
+ console.error('Share failed:', error);
+ this._snackService.open({
+ type: 'ERROR',
+ msg: 'Failed to share heatmap',
+ });
+ }
+ } finally {
+ this.isSharing.set(false);
+ }
+ }
+
+ private _renderToCanvas(
+ data: {
+ weeks: WeekData[];
+ monthLabels: string[];
+ },
+ contextTitle: string,
+ ): HTMLCanvasElement {
+ const cellSize = 12;
+ const gap = 2;
+ const dayLabelWidth = 40;
+ const monthLabelHeight = 20;
+ const padding = 16;
+ const weekHeight = 7 * (cellSize + gap);
+ const doublePadding = padding * 2;
+ const heatmapHeight = monthLabelHeight + weekHeight;
+ const baseCanvasHeight = heatmapHeight + doublePadding;
+ const taglineHeight = 32;
+
+ // Calculate dimensions
+ const numWeeks = data.weeks.length;
+ const weeksWidth = numWeeks * (cellSize + gap);
+ const canvasWidth = dayLabelWidth + weeksWidth + doublePadding;
+ const canvasHeight = baseCanvasHeight + taglineHeight;
+
+ // Create canvas
+ const canvas = document.createElement('canvas');
+ canvas.width = canvasWidth;
+ canvas.height = canvasHeight;
+ const ctx = canvas.getContext('2d')!;
+
+ // Background
+ ctx.fillStyle = '#ffffff';
+ ctx.fillRect(0, 0, canvasWidth, canvasHeight);
+
+ // Day labels (Sun, Mon, etc.)
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.6)';
+ ctx.font = '10px system-ui, -apple-system, sans-serif';
+ ctx.textAlign = 'right';
+ ctx.textBaseline = 'middle';
+
+ const dayNames = this.dayLabels();
+ dayNames.forEach((day, i) => {
+ // eslint-disable-next-line no-mixed-operators
+ const y = padding + monthLabelHeight + i * (cellSize + gap) + cellSize / 2;
+ ctx.fillText(day, padding + dayLabelWidth - 4, y);
+ });
+
+ // Month labels
+ ctx.font = '12px system-ui, -apple-system, sans-serif';
+ ctx.textAlign = 'left';
+ ctx.textBaseline = 'top';
+ data.monthLabels.forEach((month, i) => {
+ // eslint-disable-next-line no-mixed-operators
+ const x = padding + dayLabelWidth + i * 4 * (cellSize + gap);
+ ctx.fillText(month, x, padding);
+ });
+
+ // Get primary color from CSS variable or use default
+ const primaryColor =
+ getComputedStyle(document.documentElement).getPropertyValue('--c-primary').trim() ||
+ '#3f51b5';
+
+ // Draw heatmap cells
+ data.weeks.forEach((week, weekIndex) => {
+ week.days.forEach((day, dayIndex) => {
+ if (day) {
+ // eslint-disable-next-line no-mixed-operators
+ const x = padding + dayLabelWidth + weekIndex * (cellSize + gap);
+ // eslint-disable-next-line no-mixed-operators
+ const y = padding + monthLabelHeight + dayIndex * (cellSize + gap);
+
+ // Set color based on level
+ if (day.level === 0) {
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.05)';
+ } else {
+ // Mix primary color with transparency
+ const opacity = day.level * 0.2; // 0.2, 0.4, 0.6, 0.8, 1.0
+ ctx.fillStyle = this._mixColor(primaryColor, opacity);
+ }
+
+ // Draw rounded rectangle
+ this._roundRect(ctx, x, y, cellSize, cellSize, 2);
+ }
+ });
+ });
+
+ const normalizedTitle = contextTitle?.trim().length
+ ? contextTitle.trim()
+ : 'Super Productivity';
+ const shareLabel = `${normalizedTitle} – With the Super Productivity App`;
+
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.6)';
+ ctx.font = '14px system-ui, -apple-system, sans-serif';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ const taglineOffset = taglineHeight / 2;
+ const taglineY = baseCanvasHeight + taglineOffset;
+ ctx.fillText(shareLabel, canvasWidth / 2, taglineY);
+
+ return canvas;
+ }
+
+ private _mixColor(color: string, opacity: number): string {
+ // Simple color mixing - assumes hex or rgb color
+ if (color.startsWith('#')) {
+ // Convert hex to rgb
+ const r = parseInt(color.slice(1, 3), 16);
+ const g = parseInt(color.slice(3, 5), 16);
+ const b = parseInt(color.slice(5, 7), 16);
+ return `rgba(${r}, ${g}, ${b}, ${opacity})`;
+ }
+ // Assume it's already in rgb/rgba format
+ return color.replace(
+ /rgba?\([^)]+\)/,
+ `rgba(${color.match(/\d+/g)?.slice(0, 3).join(',')}, ${opacity})`,
+ );
+ }
+
+ private _roundRect(
+ ctx: CanvasRenderingContext2D,
+ x: number,
+ y: number,
+ width: number,
+ height: number,
+ radius: number,
+ ): void {
+ ctx.beginPath();
+ ctx.moveTo(x + radius, y);
+ ctx.lineTo(x + width - radius, y);
+ ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
+ ctx.lineTo(x + width, y + height - radius);
+ ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
+ ctx.lineTo(x + radius, y + height);
+ ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
+ ctx.lineTo(x, y + radius);
+ ctx.quadraticCurveTo(x, y, x + radius, y);
+ ctx.closePath();
+ ctx.fill();
+ }
+
+ private _downloadFile(blob: Blob, filename: string): void {
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ }
+}
diff --git a/src/app/features/metric/lazy-chart/lazy-chart.component.ts b/src/app/features/metric/lazy-chart/lazy-chart.component.ts
index 107e42349d..a993bb9530 100644
--- a/src/app/features/metric/lazy-chart/lazy-chart.component.ts
+++ b/src/app/features/metric/lazy-chart/lazy-chart.component.ts
@@ -22,6 +22,10 @@ import type {
} from 'chart.js';
import { CommonModule } from '@angular/common';
import { ChartLazyLoaderService } from '../chart-lazy-loader.service';
+import { MatIconButton } from '@angular/material/button';
+import { MatTooltip } from '@angular/material/tooltip';
+import { MatIcon } from '@angular/material/icon';
+import { TranslatePipe } from '@ngx-translate/core';
interface ChartClickEvent {
active: ActiveElement[];
@@ -31,6 +35,19 @@ interface ChartClickEvent {
selector: 'lazy-chart',
template: `
+
@if (!isLoaded) {
Loading chart...
}
@@ -65,10 +82,24 @@ interface ChartClickEvent {
height: 200px;
color: #666;
}
+ .share-btn {
+ position: absolute;
+ top: 4px;
+ right: 4px;
+ opacity: 0.6;
+ transition: opacity 0.2s;
+ z-index: 10;
+ }
+ .share-btn:hover:not(:disabled) {
+ opacity: 1;
+ }
+ .share-btn:disabled {
+ opacity: 0.3;
+ }
`,
],
standalone: true,
- imports: [CommonModule],
+ imports: [CommonModule, MatIconButton, MatTooltip, MatIcon, TranslatePipe],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class LazyChartComponent implements OnInit, OnDestroy {
@@ -78,6 +109,7 @@ export class LazyChartComponent implements OnInit, OnDestroy {
@Input() options?: ChartOptions;
@Input() legend = true;
@Input() height = '400px';
+ @Input() shareFileName = 'chart.png';
@Output() chartClick = new EventEmitter
();
@@ -88,6 +120,7 @@ export class LazyChartComponent implements OnInit, OnDestroy {
private readonly cdr = inject(ChangeDetectorRef);
isLoaded = false;
+ isSharing = false;
private chartInstance?: ChartJS;
async ngOnInit(): Promise {
@@ -160,4 +193,83 @@ export class LazyChartComponent implements OnInit, OnDestroy {
this.chartInstance = undefined;
}
}
+
+ async shareChart(): Promise {
+ if (!this.chartInstance?.canvas) {
+ return;
+ }
+
+ this.isSharing = true;
+ this.cdr.markForCheck();
+
+ try {
+ const decoratedCanvas = this._createCanvasWithTagline(this.chartInstance.canvas);
+
+ const blob: Blob | null = await new Promise((resolve) => {
+ decoratedCanvas.toBlob((b) => resolve(b), 'image/png', 1.0);
+ });
+
+ if (!blob) {
+ throw new Error('Failed to export chart');
+ }
+
+ const filename = this.shareFileName?.trim() || 'chart.png';
+ const file = new File([blob], filename, { type: 'image/png' });
+
+ if (navigator.canShare && navigator.canShare({ files: [file] })) {
+ await navigator.share({
+ files: [file],
+ title: filename,
+ });
+ } else {
+ this._downloadFile(blob, filename);
+ }
+ } catch (error) {
+ console.error('Share failed:', error);
+ } finally {
+ this.isSharing = false;
+ this.cdr.markForCheck();
+ }
+ }
+
+ private _createCanvasWithTagline(sourceCanvas: HTMLCanvasElement): HTMLCanvasElement {
+ const taglineHeight = 48;
+ const newCanvas = document.createElement('canvas');
+ newCanvas.width = sourceCanvas.width;
+ newCanvas.height = sourceCanvas.height + taglineHeight;
+
+ const ctx = newCanvas.getContext('2d');
+ if (!ctx) {
+ return sourceCanvas;
+ }
+
+ ctx.fillStyle = '#ffffff';
+ ctx.fillRect(0, 0, newCanvas.width, newCanvas.height);
+ ctx.drawImage(sourceCanvas, 0, 0);
+
+ const shareLabel = `With the Super Productivity App`;
+
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.6)';
+
+ const baseFontSize = Math.max(20, Math.round(newCanvas.width / 40));
+ ctx.font = `${baseFontSize}px system-ui, -apple-system, sans-serif`;
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ const taglineOffset = taglineHeight / 2;
+ const taglineY = sourceCanvas.height + taglineOffset;
+ ctx.fillText(shareLabel, newCanvas.width / 2, taglineY);
+
+ return newCanvas;
+ }
+
+ private _downloadFile(blob: Blob, filename: string): void {
+ const url = URL.createObjectURL(blob);
+ const anchor = document.createElement('a');
+ anchor.href = url;
+ anchor.download = filename;
+ document.body.appendChild(anchor);
+ anchor.click();
+ document.body.removeChild(anchor);
+ URL.revokeObjectURL(url);
+ }
}
diff --git a/src/app/features/metric/metric.component.html b/src/app/features/metric/metric.component.html
index 0a1bb49e3c..3da2fb4ebf 100644
--- a/src/app/features/metric/metric.component.html
+++ b/src/app/features/metric/metric.component.html
@@ -4,12 +4,15 @@
class="basic-stats"
[@fade]
>
-
- {{ T.PM.TITLE | translate }}
-
+
{{ sm.start }} – {{ sm.end }}
@@ -63,6 +66,11 @@
}
+
+
+
@if (!metricService.hasData()) {
{{ T.F.METRIC.CMP.NO_ADDITIONAL_DATA_YET | translate }}
@@ -71,36 +79,6 @@
@if (metricService.hasData()) {
{{ T.F.METRIC.CMP.GLOBAL_METRICS | translate }}
-
- @if (metricService.improvementCountsPieChartData(); as improvementCounts) {
-
- {{ T.F.METRIC.CMP.IMPROVEMENT_SELECTION_COUNT | translate }}
-
-
-
- }
- @if (metricService.obstructionCountsPieChartData(); as obstructionCounts) {
-
- {{ T.F.METRIC.CMP.OBSTRUCTION_SELECTION_COUNT | translate }}
-
-
-
- }
-
@if (productivityHappiness(); as productivityHappiness) {
@@ -112,6 +90,7 @@
[legend]="true"
[options]="lineChartOptions"
height="400px"
+ [shareFileName]="'mood-productivity-over-time.png'"
>
}
@@ -126,11 +105,45 @@
[legend]="true"
[options]="lineChartOptions"
height="400px"
+ [shareFileName]="'focus-session-trends.png'"
>
}
}
+
+
+ @if (metricService.improvementCountsPieChartData(); as improvementCounts) {
+
+ {{ T.F.METRIC.CMP.IMPROVEMENT_SELECTION_COUNT | translate }}
+
+
+
+ }
+ @if (metricService.obstructionCountsPieChartData(); as obstructionCounts) {
+
+ {{ T.F.METRIC.CMP.OBSTRUCTION_SELECTION_COUNT | translate }}
+
+
+
+ }
+
}
@if (metricService.hasData()) {
@@ -147,6 +160,7 @@
[options]="lineChartOptions"
[legend]="true"
height="400px"
+ [shareFileName]="'simple-click-counters-over-time.png'"
>
@@ -161,6 +175,7 @@
[options]="lineChartOptions"
[legend]="true"
height="400px"
+ [shareFileName]="'simple-stopwatch-counters-over-time.png'"
>
diff --git a/src/app/features/metric/metric.component.scss b/src/app/features/metric/metric.component.scss
index b003c1494a..924678840b 100644
--- a/src/app/features/metric/metric.component.scss
+++ b/src/app/features/metric/metric.component.scss
@@ -15,6 +15,18 @@ h3 {
text-align: center;
}
+.metrics-header {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ position: relative;
+
+ h1 {
+ margin: 0;
+ }
+}
+
table {
tr {
th {
diff --git a/src/app/features/metric/metric.component.ts b/src/app/features/metric/metric.component.ts
index a1904a4cca..70d0d3f26b 100644
--- a/src/app/features/metric/metric.component.ts
+++ b/src/app/features/metric/metric.component.ts
@@ -1,4 +1,4 @@
-import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
+import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
import { ChartConfiguration, ChartType } from 'chart.js';
import { MetricService } from './metric.service';
import { toSignal } from '@angular/core/rxjs-interop';
@@ -10,6 +10,10 @@ import { LazyChartComponent } from './lazy-chart/lazy-chart.component';
import { DecimalPipe } from '@angular/common';
import { MsToStringPipe } from '../../ui/duration/ms-to-string.pipe';
import { TranslatePipe } from '@ngx-translate/core';
+import { ActivityHeatmapComponent } from './activity-heatmap/activity-heatmap.component';
+import { ShareButtonComponent } from '../../core/share/share-button/share-button.component';
+import { ShareFormatter } from '../../core/share/share-formatter';
+import { SharePayload } from '../../core/share/share.model';
@Component({
selector: 'metric',
@@ -17,7 +21,14 @@ import { TranslatePipe } from '@ngx-translate/core';
styleUrls: ['./metric.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [fadeAnimation],
- imports: [LazyChartComponent, DecimalPipe, MsToStringPipe, TranslatePipe],
+ imports: [
+ LazyChartComponent,
+ DecimalPipe,
+ MsToStringPipe,
+ TranslatePipe,
+ ActivityHeatmapComponent,
+ ShareButtonComponent,
+ ],
})
export class MetricComponent {
workContextService = inject(WorkContextService);
@@ -26,6 +37,8 @@ export class MetricComponent {
T: typeof T = T;
+ activeWorkContext = toSignal(this.workContextService.activeWorkContext$);
+
productivityHappiness = toSignal(
this.metricService.getProductivityHappinessChartData$(),
);
@@ -81,4 +94,40 @@ export class MetricComponent {
},
};
lineChartType: ChartType = 'line';
+
+ sharePayload = computed(() => {
+ const sm = this.projectMetricsService.simpleMetrics();
+ const workContext = this.activeWorkContext();
+
+ if (!sm) {
+ return ShareFormatter.formatPromotion();
+ }
+
+ return ShareFormatter.formatWorkSummary(
+ {
+ totalTimeSpent: sm.timeSpent,
+ tasksCompleted: sm.nrOfCompletedTasks,
+ dateRange: {
+ start: sm.start,
+ end: sm.end,
+ },
+ projectName: workContext?.title,
+ detailedMetrics: {
+ timeEstimate: sm.timeEstimate,
+ totalTasks: sm.nrOfAllTasks,
+ daysWorked: sm.daysWorked,
+ avgTasksPerDay: sm.avgTasksPerDay,
+ avgBreakNr: sm.avgBreakNr,
+ avgTimeSpentOnDay: sm.avgTimeSpentOnDay,
+ avgTimeSpentOnTask: sm.avgTimeSpentOnTask,
+ avgTimeSpentOnTaskIncludingSubTasks: sm.avgTimeSpentOnTaskIncludingSubTasks,
+ avgBreakTime: sm.avgBreakTime,
+ },
+ },
+ {
+ includeUTM: true,
+ includeHashtags: true,
+ },
+ );
+ });
}
diff --git a/src/app/features/project/project-form-cfg.const.ts b/src/app/features/project/project-form-cfg.const.ts
index 0879ae3e3e..9bc0933c38 100644
--- a/src/app/features/project/project-form-cfg.const.ts
+++ b/src/app/features/project/project-form-cfg.const.ts
@@ -57,10 +57,9 @@ export const CREATE_PROJECT_BASIC_CONFIG_FORM_CONFIG: ConfigFormSection
},
{
key: 'theme.primary' as any,
- type: 'input',
+ type: 'color',
templateOptions: {
label: T.F.PROJECT.FORM_THEME.L_THEME_COLOR,
- type: 'color',
},
},
{
diff --git a/src/app/features/right-panel/right-panel-content.component.html b/src/app/features/right-panel/right-panel-content.component.html
index 91b481f3de..330d2e4afb 100644
--- a/src/app/features/right-panel/right-panel-content.component.html
+++ b/src/app/features/right-panel/right-panel-content.component.html
@@ -2,14 +2,6 @@
} @else if (panelContent() === 'NOTES') {
-} @else if (panelContent() === 'TASK_VIEW_CUSTOMIZER_PANEL') {
-
-
-
-
} @else if (panelContent() === 'PLUGIN') {
@for (key of pluginPanelKeys(); track key) {
diff --git a/src/app/features/right-panel/right-panel-content.component.ts b/src/app/features/right-panel/right-panel-content.component.ts
index be24c1b9ed..9a86cfd636 100644
--- a/src/app/features/right-panel/right-panel-content.component.ts
+++ b/src/app/features/right-panel/right-panel-content.component.ts
@@ -18,7 +18,6 @@ import { taskDetailPanelTaskChangeAnimation } from '../tasks/task-detail-panel/t
import { IssuePanelComponent } from '../issue-panel/issue-panel.component';
import { NotesComponent } from '../note/notes/notes.component';
import { TaskDetailPanelComponent } from '../tasks/task-detail-panel/task-detail-panel.component';
-import { TaskViewCustomizerPanelComponent } from '../task-view-customizer/task-view-customizer-panel/task-view-customizer-panel.component';
import { PluginService } from '../../plugins/plugin.service';
import { PluginPanelContainerComponent } from '../../plugins/ui/plugin-panel-container/plugin-panel-container.component';
import { ScheduleDayPanelComponent } from '../schedule/schedule-day-panel/schedule-day-panel.component';
@@ -56,7 +55,6 @@ export type RightPanelContentPanelType = PanelContentType;
IssuePanelComponent,
NotesComponent,
TaskDetailPanelComponent,
- TaskViewCustomizerPanelComponent,
PluginPanelContainerComponent,
ScheduleDayPanelComponent,
],
diff --git a/src/app/features/schedule/schedule-day-panel/schedule-day-panel.component.ts b/src/app/features/schedule/schedule-day-panel/schedule-day-panel.component.ts
index 2d47fad3ff..7add44c10e 100644
--- a/src/app/features/schedule/schedule-day-panel/schedule-day-panel.component.ts
+++ b/src/app/features/schedule/schedule-day-panel/schedule-day-panel.component.ts
@@ -122,8 +122,8 @@ export class ScheduleDayPanelComponent implements AfterViewInit, OnDestroy {
});
currentTimeRow = computed(() => {
- // Trigger re-computation via today change
- this._todayDateStr();
+ // Trigger re-computation every 2 minutes
+ this._scheduleService.scheduleRefreshTick();
const now = new Date();
const hours = now.getHours();
const minutes = now.getMinutes();
diff --git a/src/app/features/schedule/schedule.service.ts b/src/app/features/schedule/schedule.service.ts
index af48106ae2..3c42ee5672 100644
--- a/src/app/features/schedule/schedule.service.ts
+++ b/src/app/features/schedule/schedule.service.ts
@@ -42,13 +42,13 @@ export class ScheduleService {
private _icalEvents = toSignal(this._calendarIntegrationService.icalEvents$, {
initialValue: [],
});
- private _scheduleRefreshTick = toSignal(interval(2 * 60 * 1000).pipe(startWith(0)), {
+ scheduleRefreshTick = toSignal(interval(2 * 60 * 1000).pipe(startWith(0)), {
initialValue: 0,
});
createScheduleDaysComputed(daysToShow: Signal): Signal {
return computed(() => {
- this._scheduleRefreshTick();
+ this.scheduleRefreshTick();
const timelineTasks = this._timelineTasks();
const taskRepeatCfgs = this._taskRepeatCfgs();
const timelineCfg = this._timelineConfig();
diff --git a/src/app/features/schedule/schedule/schedule.component.ts b/src/app/features/schedule/schedule/schedule.component.ts
index 86a9bd5bd6..8acc39ada7 100644
--- a/src/app/features/schedule/schedule/schedule.component.ts
+++ b/src/app/features/schedule/schedule/schedule.component.ts
@@ -141,8 +141,8 @@ export class ScheduleComponent implements AfterViewInit {
beyondBudget = computed(() => this._eventsAndBeyondBudget().beyondBudgetDays);
currentTimeRow = computed(() => {
- // Trigger re-computation
- this.scheduleDays();
+ // Trigger re-computation every 2 minutes
+ this.scheduleService.scheduleRefreshTick();
const now = new Date();
const hours = now.getHours();
const minutes = now.getMinutes();
diff --git a/src/app/features/tag/tag-form-cfg.const.ts b/src/app/features/tag/tag-form-cfg.const.ts
index 5f4b29a230..01078b469a 100644
--- a/src/app/features/tag/tag-form-cfg.const.ts
+++ b/src/app/features/tag/tag-form-cfg.const.ts
@@ -24,10 +24,9 @@ export const BASIC_TAG_CONFIG_FORM_CONFIG: ConfigFormSection = {
},
{
key: 'color',
- type: 'input',
+ type: 'color',
templateOptions: {
label: T.F.TAG.FORM_BASIC.L_COLOR,
- type: 'color',
},
},
],
diff --git a/src/app/features/task-view-customizer/task-view-customizer-panel/task-view-customizer-panel.component.html b/src/app/features/task-view-customizer/task-view-customizer-panel/task-view-customizer-panel.component.html
index 70dc262db5..47a00f174a 100644
--- a/src/app/features/task-view-customizer/task-view-customizer-panel/task-view-customizer-panel.component.html
+++ b/src/app/features/task-view-customizer/task-view-customizer-panel/task-view-customizer-panel.component.html
@@ -1,113 +1,288 @@
-
-
{{ T.F.TASK_VIEW.CUSTOMIZER.TITLE | translate }}
-
-
-
-
- @for (opt of sortOptions; track opt.value) {
- {{ opt.label | translate }}
- }
-
-
-
-
-
-
-
-
- @for (opt of groupOptions; track opt.value) {
- {{ opt.label | translate }}
- }
-
-
-
-
-
-
-
-
- @for (opt of filterOptions; track opt.value) {
- {{ opt.label | translate }}
- }
-
-
-
- @if (customizerService.selectedFilter() === 'tag') {
-
-
-
- }
-
- @if (customizerService.selectedFilter() === 'project') {
-
-
-
- }
-
- @if (customizerService.selectedFilter() === 'scheduledDate') {
-
-
- @for (option of scheduledPresets; track option.value) {
- {{ option.label | translate }}
- }
-
-
- }
-
- @if (customizerService.selectedFilter() === 'estimatedTime') {
-
-
- @for (option of timePresets; track option.value) {
- {{ option.label | translate }}
- }
-
-
- }
-
- @if (customizerService.selectedFilter() === 'timeSpent') {
-
-
- @for (option of timePresets; track option.value) {
- {{ option.label | translate }}
- }
-
-
- }
-
-
+
+
+
+
+
+ @for (opt of sortOptions; track opt.value) {
+
+ }
+
+
+
+
+ @for (opt of groupOptions; track opt.value) {
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ @for (option of scheduledPresets; track option.value) {
+
+ }
+
+
+
+
+ @for (option of timePresets; track option.value) {
+
+ }
+
+
+
+
+ @for (option of timePresets; track option.value) {
+
+ }
+
diff --git a/src/app/features/task-view-customizer/task-view-customizer-panel/task-view-customizer-panel.component.scss b/src/app/features/task-view-customizer/task-view-customizer-panel/task-view-customizer-panel.component.scss
index c6cc0d9150..092c645048 100644
--- a/src/app/features/task-view-customizer/task-view-customizer-panel/task-view-customizer-panel.component.scss
+++ b/src/app/features/task-view-customizer/task-view-customizer-panel/task-view-customizer-panel.component.scss
@@ -1,33 +1,11 @@
-.customizer-panel {
- // Align layout with other right-panel contents (e.g. issue-panel)
- // Fill the container and let the parent handle scrolling
- position: absolute;
- inset: 8px 16px;
- padding: 8px 0;
- display: flex;
- flex-direction: column;
- height: 100%;
+// Menu styles are in global styles: src/styles/components/_customizer-menu.scss
+// This is necessary because mat-menu renders in a global overlay container
- h3 {
- text-align: center;
- margin-bottom: 20px;
- }
+.menu-input-wrapper {
+ padding: 8px 16px;
+ min-width: 250px;
- .form-group {
- margin-bottom: 15px;
- display: flex;
- flex-direction: column;
-
- label {
- margin-bottom: 5px;
- }
-
- mat-form-field {
- width: 100%;
- }
-
- mat-slide-toggle {
- align-self: flex-start;
- }
+ .menu-input {
+ width: 100%;
}
}
diff --git a/src/app/features/task-view-customizer/task-view-customizer-panel/task-view-customizer-panel.component.ts b/src/app/features/task-view-customizer/task-view-customizer-panel/task-view-customizer-panel.component.ts
index af1594eef4..13d42590fc 100644
--- a/src/app/features/task-view-customizer/task-view-customizer-panel/task-view-customizer-panel.component.ts
+++ b/src/app/features/task-view-customizer/task-view-customizer-panel/task-view-customizer-panel.component.ts
@@ -1,4 +1,4 @@
-import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
+import { ChangeDetectionStrategy, Component, inject, ViewChild } from '@angular/core';
import { OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
@@ -7,6 +7,9 @@ import { MatSelectModule } from '@angular/material/select';
import { MatInputModule } from '@angular/material/input';
import { MatButtonModule } from '@angular/material/button';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
+import { MatMenuModule, MatMenu } from '@angular/material/menu';
+import { MatIconModule } from '@angular/material/icon';
+import { MatDividerModule } from '@angular/material/divider';
import { TaskViewCustomizerService } from '../task-view-customizer.service';
import { TranslatePipe } from '@ngx-translate/core';
import { T } from 'src/app/t.const';
@@ -17,6 +20,7 @@ import { T } from 'src/app/t.const';
styleUrls: ['./task-view-customizer-panel.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
+ exportAs: 'customizerMenu',
imports: [
CommonModule,
FormsModule,
@@ -25,12 +29,18 @@ import { T } from 'src/app/t.const';
MatInputModule,
MatButtonModule,
MatSlideToggleModule,
+ MatMenuModule,
+ MatIconModule,
+ MatDividerModule,
TranslatePipe,
],
})
export class TaskViewCustomizerPanelComponent implements OnInit {
customizerService = inject(TaskViewCustomizerService);
+ @ViewChild('customizerMenu', { static: false })
+ menu!: MatMenu;
+
T = T;
selectedSort: string = 'default';
selectedGroup: string = 'default';
@@ -89,6 +99,37 @@ export class TaskViewCustomizerPanelComponent implements OnInit {
this.filterInputValue = this.customizerService.filterInputValue();
}
+ getSortLabel(value: string): string {
+ const option = this.sortOptions.find((opt) => opt.value === value);
+ return option ? option.label : '';
+ }
+
+ getGroupLabel(value: string): string {
+ const option = this.groupOptions.find((opt) => opt.value === value);
+ return option ? option.label : '';
+ }
+
+ getFilterLabel(value: string): string {
+ const option = this.filterOptions.find((opt) => opt.value === value);
+ return option ? option.label : '';
+ }
+
+ onFilterSelect(filterType: string): void {
+ this.customizerService.setFilter(filterType);
+ }
+
+ onFilterInputChange(filterType: string, value: string): void {
+ if (this.customizerService.selectedFilter() !== filterType) {
+ this.customizerService.setFilter(filterType);
+ }
+ this.customizerService.setFilterInputValue(value);
+ }
+
+ onFilterWithValue(filterType: string, value: string): void {
+ this.customizerService.setFilter(filterType);
+ this.customizerService.setFilterInputValue(value);
+ }
+
onResetAll(): void {
this.customizerService.resetAll();
}
diff --git a/src/app/features/tasks/short-syntax.spec.ts b/src/app/features/tasks/short-syntax.spec.ts
index f6de41ddfa..1d906d765f 100644
--- a/src/app/features/tasks/short-syntax.spec.ts
+++ b/src/app/features/tasks/short-syntax.spec.ts
@@ -892,6 +892,25 @@ describe('shortSyntax', () => {
const r = shortSyntax(t, CONFIG, [], projects);
expect(r).toEqual(undefined);
});
+
+ it('should prefer shortest prefix full project title match', () => {
+ const t = {
+ ...TASK,
+ title: 'Task +print',
+ };
+ projects = ['printer', 'imprints', 'print', 'printable'].map(
+ (title) => ({ id: title, title }) as Project,
+ );
+ const r = shortSyntax(t, CONFIG, [], projects);
+ expect(r).toEqual({
+ newTagTitles: [],
+ remindAt: null,
+ projectId: 'print',
+ taskChanges: {
+ title: 'Task',
+ },
+ });
+ });
});
describe('combined', () => {
diff --git a/src/app/features/tasks/short-syntax.ts b/src/app/features/tasks/short-syntax.ts
index c9bf18e25d..5d2db2908d 100644
--- a/src/app/features/tasks/short-syntax.ts
+++ b/src/app/features/tasks/short-syntax.ts
@@ -187,7 +187,7 @@ const parseProjectChanges = (
if (rr && rr[0]) {
const projectTitle: string = rr[0].trim().replace(CH_PRO, '');
- const projectTitleToMatch = projectTitle.replace(' ', '').toLowerCase();
+ const projectTitleToMatch = projectTitle.replaceAll(' ', '').toLowerCase();
const indexBeforePlus =
task.title.toLowerCase().lastIndexOf(CH_PRO + projectTitleToMatch) - 1;
const charBeforePlus = task.title.charAt(indexBeforePlus);
@@ -197,9 +197,15 @@ const parseProjectChanges = (
return {};
}
- const existingProject = allProjects.find(
+ // Prefer shortest prefix-based project title match
+ const sortedAllProjects = allProjects
+ .slice()
+ .sort((p1, p2) => p1.title.length - p2.title.length);
+
+ const existingProject = sortedAllProjects.find(
(project) =>
- project.title.replace(' ', '').toLowerCase().indexOf(projectTitleToMatch) === 0,
+ project.title.replaceAll(' ', '').toLowerCase().indexOf(projectTitleToMatch) ===
+ 0,
);
if (existingProject) {
@@ -215,9 +221,10 @@ const parseProjectChanges = (
// also try only first word after special char
const projectTitleFirstWordOnly = projectTitle.split(' ')[0];
const projectTitleToMatch2 = projectTitleFirstWordOnly.replace(' ', '').toLowerCase();
- const existingProjectForFirstWordOnly = allProjects.find(
+ const existingProjectForFirstWordOnly = sortedAllProjects.find(
(project) =>
- project.title.replace(' ', '').toLowerCase().indexOf(projectTitleToMatch2) === 0,
+ project.title.replaceAll(' ', '').toLowerCase().indexOf(projectTitleToMatch2) ===
+ 0,
);
if (existingProjectForFirstWordOnly) {
diff --git a/src/app/features/tasks/task-context-menu/task-context-menu-inner/task-context-menu-inner.component.html b/src/app/features/tasks/task-context-menu/task-context-menu-inner/task-context-menu-inner.component.html
index 2ddac82ca4..61061c50a5 100644
--- a/src/app/features/tasks/task-context-menu/task-context-menu-inner/task-context-menu-inner.component.html
+++ b/src/app/features/tasks/task-context-menu/task-context-menu-inner/task-context-menu-inner.component.html
@@ -360,6 +360,11 @@
mat-menu-item
menuTouchFix
>
+ {{ project.icon || 'list' }}
{{ project.title }}
}
diff --git a/src/app/features/tasks/task-context-menu/task-context-menu-inner/task-context-menu-inner.component.ts b/src/app/features/tasks/task-context-menu/task-context-menu-inner/task-context-menu-inner.component.ts
index aba78bc56c..e699e278c1 100644
--- a/src/app/features/tasks/task-context-menu/task-context-menu-inner/task-context-menu-inner.component.ts
+++ b/src/app/features/tasks/task-context-menu/task-context-menu-inner/task-context-menu-inner.component.ts
@@ -149,8 +149,15 @@ export class TaskContextMenuInnerComponent implements AfterViewInit {
map((t) => t.projectId),
distinctUntilChanged(),
switchMap((pid) => this._projectService.getProjectsWithoutId$(pid || null)),
+ map((projects) => projects.slice().sort((a, b) => a.title.localeCompare(b.title))),
+ );
+ private readonly _toggleTagListUnsorted = toSignal(
+ this._tagService.tagsNoMyDayAndNoList$,
+ { initialValue: [] },
+ );
+ toggleTagList = computed(() =>
+ [...this._toggleTagListUnsorted()].sort((a, b) => a.title.localeCompare(b.title)),
);
- toggleTagList = toSignal(this._tagService.tagsNoMyDayAndNoList$, { initialValue: [] });
// isShowMoveFromAndToBacklogBtns$: Observable = this._task$.pipe(
// take(1),
diff --git a/src/app/features/work-context/work-context-markdown.service.ts b/src/app/features/work-context/work-context-markdown.service.ts
new file mode 100644
index 0000000000..d6716d685f
--- /dev/null
+++ b/src/app/features/work-context/work-context-markdown.service.ts
@@ -0,0 +1,189 @@
+import { Injectable, inject } from '@angular/core';
+import { ProjectService } from '../project/project.service';
+import { TagService } from '../tag/tag.service';
+import { Store } from '@ngrx/store';
+import { selectTasksWithSubTasksByIds } from '../tasks/store/task.selectors';
+import { Task, TaskWithSubTasks } from '../tasks/task.model';
+import { first } from 'rxjs/operators';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class WorkContextMarkdownService {
+ private _projectService = inject(ProjectService);
+ private _tagService = inject(TagService);
+ private _store = inject(Store);
+
+ async copyTasksAsMarkdown(
+ contextId: string,
+ isProjectContext: boolean,
+ ): Promise<'copied' | 'empty' | 'failed'> {
+ const { status, markdown } = await this.getMarkdownForContext(
+ contextId,
+ isProjectContext,
+ );
+
+ if (status === 'empty' || !markdown) {
+ return 'empty';
+ }
+
+ const isSuccess = await this.copyMarkdownText(markdown);
+ return isSuccess ? 'copied' : 'failed';
+ }
+
+ async getMarkdownForContext(
+ contextId: string,
+ isProjectContext: boolean,
+ ): Promise<{
+ status: 'empty' | 'ok';
+ markdown?: string;
+ contextTitle?: string | null;
+ }> {
+ const { tasks, contextTitle } = await this._loadTasks(contextId, isProjectContext);
+
+ if (!tasks.length) {
+ return { status: 'empty', contextTitle };
+ }
+
+ return {
+ status: 'ok',
+ markdown: this._buildMarkdownChecklist(tasks),
+ contextTitle,
+ };
+ }
+
+ async copyMarkdownText(markdown: string): Promise {
+ if (!markdown) {
+ return false;
+ }
+ return this._copyToClipboard(markdown);
+ }
+
+ private async _loadTasks(
+ contextId: string,
+ isProjectContext: boolean,
+ ): Promise<{ tasks: TaskWithSubTasks[]; contextTitle: string | null }> {
+ const { ids, contextTitle } = await this._getTaskIds(contextId, isProjectContext);
+
+ if (!ids.length) {
+ return { tasks: [], contextTitle };
+ }
+
+ const tasks =
+ (await this._store
+ .select(selectTasksWithSubTasksByIds, { ids })
+ .pipe(first())
+ .toPromise()) || [];
+
+ return {
+ tasks: tasks.filter((task): task is TaskWithSubTasks => !!task),
+ contextTitle,
+ };
+ }
+
+ private async _getTaskIds(
+ contextId: string,
+ isProjectContext: boolean,
+ ): Promise<{ ids: string[]; contextTitle: string | null }> {
+ if (isProjectContext) {
+ const project = await this._projectService.getByIdOnce$(contextId).toPromise();
+ if (!project) {
+ return { ids: [], contextTitle: null };
+ }
+ return {
+ ids: this._uniqueIds([
+ ...(project.taskIds || []),
+ ...(project.backlogTaskIds || []),
+ ]),
+ contextTitle: project.title,
+ };
+ }
+
+ const tag = await this._tagService.getTagById$(contextId).pipe(first()).toPromise();
+
+ if (!tag) {
+ return { ids: [], contextTitle: null };
+ }
+
+ return { ids: this._uniqueIds(tag.taskIds || []), contextTitle: tag.title };
+ }
+
+ private _uniqueIds(ids: (string | null | undefined)[]): string[] {
+ const seen = new Set();
+ const unique: string[] = [];
+
+ ids.forEach((id) => {
+ if (!id || seen.has(id)) {
+ return;
+ }
+ seen.add(id);
+ unique.push(id);
+ });
+
+ return unique;
+ }
+
+ private _buildMarkdownChecklist(tasks: TaskWithSubTasks[]): string {
+ const lines: string[] = [];
+
+ tasks.forEach((task) => {
+ lines.push(this._formatTaskLine(task));
+
+ if (task.subTasks?.length) {
+ task.subTasks.forEach((subTask) => {
+ lines.push(this._formatTaskLine(subTask, 1));
+ });
+ }
+ });
+
+ return lines.join('\n');
+ }
+
+ private _formatTaskLine(task: Task | TaskWithSubTasks, depth: number = 0): string {
+ const indent = depth > 0 ? ' '.repeat(depth) : '';
+ const checkbox = task.isDone ? '[x]' : '[ ]';
+ const title = (task.title || '').replace(/\r?\n/g, ' ');
+ return `${indent}- ${checkbox} ${title}`;
+ }
+
+ private async _copyToClipboard(text: string): Promise {
+ if (!text) {
+ return false;
+ }
+
+ if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
+ try {
+ await navigator.clipboard.writeText(text);
+ return true;
+ } catch (err) {
+ console.warn('Clipboard write failed, trying fallback method:', err);
+ }
+ }
+
+ if (typeof document === 'undefined') {
+ return false;
+ }
+
+ const textarea = document.createElement('textarea');
+ textarea.value = text;
+ textarea.style.position = 'fixed';
+ textarea.style.opacity = '0';
+ textarea.style.pointerEvents = 'none';
+
+ document.body.appendChild(textarea);
+ textarea.focus();
+ textarea.select();
+
+ let isSuccess = false;
+ try {
+ isSuccess = document.execCommand('copy');
+ } catch (err) {
+ console.error('Fallback copy failed:', err);
+ isSuccess = false;
+ } finally {
+ document.body.removeChild(textarea);
+ }
+
+ return isSuccess;
+ }
+}
diff --git a/src/app/features/work-context/work-context.const.ts b/src/app/features/work-context/work-context.const.ts
index c13d51b28c..a82d76b3c3 100644
--- a/src/app/features/work-context/work-context.const.ts
+++ b/src/app/features/work-context/work-context.const.ts
@@ -65,26 +65,23 @@ export const WORK_CONTEXT_THEME_CONFIG_FORM_CONFIG: ConfigFormSection {
expect(mockModelControllers.mainModel.save).not.toHaveBeenCalled();
expect(mockModelControllers.singleModel.save).not.toHaveBeenCalled();
});
+
+ it('should throw error for unregistered models to prevent data loss', async () => {
+ const remoteMeta = {
+ revMap: {},
+ lastUpdate: 1000,
+ crossModelVersion: 1,
+ mainModelData: {
+ unknownModel: { data: 'unknown-model-data' },
+ },
+ };
+
+ // Should throw ModelIdWithoutCtrlError
+ await expectAsync(
+ service.updateLocalMainModelsFromRemoteMetaFile({
+ ...remoteMeta,
+ } as RemoteMeta),
+ ).toBeRejectedWithError(/Remote metadata contains models not registered locally/);
+
+ // No saves should have been called due to early error
+ expect(mockModelControllers.mainModel.save).not.toHaveBeenCalled();
+ expect(mockModelControllers.singleModel.save).not.toHaveBeenCalled();
+ });
+
+ it('should throw error listing all unregistered models', async () => {
+ const remoteMeta = {
+ revMap: {},
+ lastUpdate: 1000,
+ crossModelVersion: 1,
+ mainModelData: {
+ unknownModel1: { data: 'unknown-1' },
+ unknownModel2: { data: 'unknown-2' },
+ unknownModel3: { data: 'unknown-3' },
+ },
+ };
+
+ // Should throw with all model IDs listed
+ await expectAsync(
+ service.updateLocalMainModelsFromRemoteMetaFile({
+ ...remoteMeta,
+ } as RemoteMeta),
+ ).toBeRejectedWithError(/unknownModel1, unknownModel2, unknownModel3/);
+
+ // No saves should have been called
+ expect(mockModelControllers.mainModel.save).not.toHaveBeenCalled();
+ expect(mockModelControllers.singleModel.save).not.toHaveBeenCalled();
+ });
+
+ it('should throw error when mix of valid and invalid models to prevent partial sync', async () => {
+ const remoteMeta = {
+ revMap: {},
+ lastUpdate: 1000,
+ crossModelVersion: 1,
+ mainModelData: {
+ mainModel: { data: 'valid-main-model' },
+ unknownModel: { data: 'unknown-model-data' },
+ singleModel: { data: 'valid-single-model' },
+ },
+ };
+
+ // Should throw error even with valid models present
+ await expectAsync(
+ service.updateLocalMainModelsFromRemoteMetaFile({
+ ...remoteMeta,
+ } as RemoteMeta),
+ ).toBeRejectedWithError(/unknownModel/);
+
+ // No saves should have been called to prevent partial data sync
+ expect(mockModelControllers.mainModel.save).not.toHaveBeenCalled();
+ expect(mockModelControllers.singleModel.save).not.toHaveBeenCalled();
+ });
});
describe('getMainFileModelDataForUpload', () => {
diff --git a/src/app/pfapi/api/sync/model-sync.service.ts b/src/app/pfapi/api/sync/model-sync.service.ts
index 01119dbaba..e274316ae8 100644
--- a/src/app/pfapi/api/sync/model-sync.service.ts
+++ b/src/app/pfapi/api/sync/model-sync.service.ts
@@ -192,6 +192,23 @@ export class ModelSyncService {
Object.keys(mainModelData),
);
+ // Check for unregistered models before processing to prevent data loss
+ const unregisteredModels: string[] = [];
+ Object.keys(mainModelData).forEach((modelId) => {
+ if (!this.m[modelId]) {
+ unregisteredModels.push(modelId);
+ }
+ });
+
+ if (unregisteredModels.length > 0) {
+ throw new ModelIdWithoutCtrlError(
+ `Remote metadata contains models not registered locally: ${unregisteredModels.join(', ')}. ` +
+ `This may indicate a version mismatch between synced devices. ` +
+ `To prevent data loss, sync has been blocked. ` +
+ `Please ensure all devices are running the same version of the app.`,
+ );
+ }
+
Object.keys(mainModelData).forEach((modelId) => {
if (modelId in mainModelData) {
this.m[modelId].save(
diff --git a/src/app/t.const.ts b/src/app/t.const.ts
index a425d1a02a..a64fb9257e 100644
--- a/src/app/t.const.ts
+++ b/src/app/t.const.ts
@@ -221,8 +221,10 @@ const T = {
BACK_TO_PLANNING: 'F.FOCUS_MODE.BACK_TO_PLANNING',
CONGRATS: 'F.FOCUS_MODE.CONGRATS',
COUNTDOWN: 'F.FOCUS_MODE.COUNTDOWN',
+ COUNTDOWN_HINT: 'F.FOCUS_MODE.COUNTDOWN_HINT',
FINISH_TASK_AND_SELECT_NEXT: 'F.FOCUS_MODE.FINISH_TASK_AND_SELECT_NEXT',
FLOWTIME: 'F.FOCUS_MODE.FLOWTIME',
+ FLOWTIME_HINT: 'F.FOCUS_MODE.FLOWTIME_HINT',
FOR_TASK: 'F.FOCUS_MODE.FOR_TASK',
GET_READY: 'F.FOCUS_MODE.GET_READY',
GO_TO_PROCRASTINATION: 'F.FOCUS_MODE.GO_TO_PROCRASTINATION',
@@ -231,6 +233,7 @@ const T = {
ON: 'F.FOCUS_MODE.ON',
OPEN_ISSUE_IN_BROWSER: 'F.FOCUS_MODE.OPEN_ISSUE_IN_BROWSER',
POMODORO: 'F.FOCUS_MODE.POMODORO',
+ POMODORO_HINT: 'F.FOCUS_MODE.POMODORO_HINT',
POMODORO_BACK: 'F.FOCUS_MODE.POMODORO_BACK',
POMODORO_DISABLE: 'F.FOCUS_MODE.POMODORO_DISABLE',
POMODORO_INFO: 'F.FOCUS_MODE.POMODORO_INFO',
@@ -239,6 +242,7 @@ const T = {
PREP_STRETCH: 'F.FOCUS_MODE.PREP_STRETCH',
SELECT_ANOTHER_TASK: 'F.FOCUS_MODE.SELECT_ANOTHER_TASK',
SELECT_TASK: 'F.FOCUS_MODE.SELECT_TASK',
+ SELECT_MODE: 'F.FOCUS_MODE.SELECT_MODE',
SESSION_COMPLETED: 'F.FOCUS_MODE.SESSION_COMPLETED',
POMODORO_SESSION_COMPLETED: 'F.FOCUS_MODE.POMODORO_SESSION_COMPLETED',
SET_FOCUS_SESSION_DURATION: 'F.FOCUS_MODE.SET_FOCUS_SESSION_DURATION',
@@ -582,6 +586,7 @@ const T = {
CHECK: 'F.METRIC.BANNER.CHECK',
},
CMP: {
+ ACTIVITY_HEATMAP: 'F.METRIC.CMP.ACTIVITY_HEATMAP',
AVG_BREAKS_PER_DAY: 'F.METRIC.CMP.AVG_BREAKS_PER_DAY',
AVG_TASKS_PER_DAY_WORKED: 'F.METRIC.CMP.AVG_TASKS_PER_DAY_WORKED',
AVG_TIME_SPENT_ON_BREAKS: 'F.METRIC.CMP.AVG_TIME_SPENT_ON_BREAKS',
@@ -1834,7 +1839,6 @@ const T = {
IS_HIDE_NAV: 'GCF.MISC.IS_HIDE_NAV',
IS_MINIMIZE_TO_TRAY: 'GCF.MISC.IS_MINIMIZE_TO_TRAY',
IS_SHOW_TIP_LONGER: 'GCF.MISC.IS_SHOW_TIP_LONGER',
- IS_DISABLE_PRODUCTIVITY_TIPS: 'GCF.MISC.IS_DISABLE_PRODUCTIVITY_TIPS',
IS_TRAY_SHOW_CURRENT_COUNTDOWN: 'GCF.MISC.IS_TRAY_SHOW_CURRENT_COUNTDOWN',
IS_TRAY_SHOW_CURRENT_TASK: 'GCF.MISC.IS_TRAY_SHOW_CURRENT_TASK',
IS_OVERLAY_INDICATOR_ENABLED: 'GCF.MISC.IS_OVERLAY_INDICATOR_ENABLED',
@@ -1965,6 +1969,10 @@ const T = {
},
GLOBAL_SNACK: {
COPY_TO_CLIPPBOARD: 'GLOBAL_SNACK.COPY_TO_CLIPPBOARD',
+ NO_TASKS_TO_COPY: 'GLOBAL_SNACK.NO_TASKS_TO_COPY',
+ SHARE_UNAVAILABLE_FALLBACK: 'GLOBAL_SNACK.SHARE_UNAVAILABLE_FALLBACK',
+ SHARE_FAILED_FALLBACK: 'GLOBAL_SNACK.SHARE_FAILED_FALLBACK',
+ SHARE_FAILED: 'GLOBAL_SNACK.SHARE_FAILED',
ERR_COMPRESSION: 'GLOBAL_SNACK.ERR_COMPRESSION',
FILE_DOWNLOADED: 'GLOBAL_SNACK.FILE_DOWNLOADED',
FILE_DOWNLOADED_BTN: 'GLOBAL_SNACK.FILE_DOWNLOADED_BTN',
@@ -2032,6 +2040,8 @@ const T = {
TOGGLE_SHOW_NOTES: 'MH.TOGGLE_SHOW_NOTES',
TOGGLE_TRACK_TIME: 'MH.TOGGLE_TRACK_TIME',
TRIGGER_SYNC: 'MH.TRIGGER_SYNC',
+ SHARE_TASK_LIST_MARKDOWN: 'MH.SHARE_TASK_LIST_MARKDOWN',
+ COPY_TASK_LIST_MARKDOWN: 'MH.COPY_TASK_LIST_MARKDOWN',
WORKLOG: 'MH.WORKLOG',
SIDE_PANEL_MENU: 'MH.SIDE_PANEL_MENU',
},
diff --git a/src/app/ui/formly-config.module.ts b/src/app/ui/formly-config.module.ts
index 97fb5192b1..f7b0283d14 100644
--- a/src/app/ui/formly-config.module.ts
+++ b/src/app/ui/formly-config.module.ts
@@ -22,6 +22,7 @@ import { FormlyMatSliderModule } from '@ngx-formly/material/slider';
import { FormlyTagSelectionComponent } from './formly-tag-selection/formly-tag-selection.component';
import { FormlyBtnComponent } from './formly-button/formly-btn.component';
import { FormlyImageInputComponent } from './formly-image-input/formly-image-input.component';
+import { ColorInputComponent } from '../features/config/color-input/color-input.component';
@NgModule({
imports: [
@@ -64,6 +65,10 @@ import { FormlyImageInputComponent } from './formly-image-input/formly-image-inp
extends: 'input',
wrappers: ['form-field'],
},
+ {
+ name: 'color',
+ component: ColorInputComponent,
+ },
{
name: 'project-select',
component: SelectProjectComponent,
diff --git a/src/app/ui/segmented-button-group/segmented-button-group.component.html b/src/app/ui/segmented-button-group/segmented-button-group.component.html
new file mode 100644
index 0000000000..15b23bc4f8
--- /dev/null
+++ b/src/app/ui/segmented-button-group/segmented-button-group.component.html
@@ -0,0 +1,31 @@
+
+ @for (option of options(); track option.id; let index = $index) {
+
+ }
+
diff --git a/src/app/ui/segmented-button-group/segmented-button-group.component.scss b/src/app/ui/segmented-button-group/segmented-button-group.component.scss
new file mode 100644
index 0000000000..5f7b71518a
--- /dev/null
+++ b/src/app/ui/segmented-button-group/segmented-button-group.component.scss
@@ -0,0 +1,98 @@
+@use '../../../styles/_globals.scss' as *;
+
+:host {
+ display: block;
+ width: 100%;
+}
+
+.segmented-button-group {
+ display: flex;
+ gap: var(--s2);
+ padding: var(--s);
+ border-radius: var(--s4);
+ //background: var(--bg-lightest);
+ //box-shadow: 0 18px 45px var(--c-dark-20);
+}
+
+.segment {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ gap: var(--s-quarter);
+ flex: 1 1 0;
+ border-radius: 32px;
+ padding: var(--s) var(--s2);
+ border: 1px solid var(--options-border-color);
+ background: var(--bg-lighter);
+ background: transparent;
+ cursor: pointer;
+ color: var(--text-color);
+ text-align: left;
+ box-shadow: none;
+ border-width: 3px;
+ transition:
+ transform var(--transition-duration-m) var(--ani-enter-timing),
+ box-shadow var(--transition-duration-m) var(--ani-enter-timing),
+ border-color var(--transition-duration-m) var(--ani-enter-timing),
+ background var(--transition-duration-m) var(--ani-enter-timing),
+ color var(--transition-duration-m) var(--ani-enter-timing);
+
+ @include mousePrimaryDevice {
+ &:not(.is-active):hover {
+ border-color: var(--options-border-color);
+ transform: translateY(-1px);
+ box-shadow: 0 12px 28px var(--c-dark-20);
+ }
+ }
+
+ &.is-active {
+ border-color: var(--c-primary);
+ border-width: 3px;
+ color: var(--c-contrast);
+ box-shadow: 0 12px 32px var(--c-dark-30);
+ transform: scale(1.1);
+ }
+}
+
+.segment.is-active .segment-icon,
+.segment.is-active .segment-hint {
+ color: var(--c-contrast);
+}
+
+.segment-icon {
+ font-size: 22px;
+ height: 22px;
+ width: 22px;
+ color: var(--text-color-more-intense);
+ transition: color var(--transition-duration-m) var(--ani-enter-timing);
+}
+
+.segment-label {
+ font-weight: 600;
+ font-size: 15px;
+}
+
+.segment-hint {
+ font-size: 12px;
+ font-weight: 500;
+ color: var(--text-color-muted);
+ letter-spacing: 0.015em;
+ transition:
+ color var(--transition-duration-m) var(--ani-enter-timing),
+ opacity var(--transition-duration-m) var(--ani-enter-timing);
+}
+
+.segment.is-disabled {
+ cursor: not-allowed;
+ opacity: 0.6;
+}
+
+.segment:focus-visible {
+ outline: 2px solid var(--c-primary);
+ outline-offset: 2px;
+}
+
+:host([data-size='md']) .segment {
+ padding: var(--s-half) var(--s);
+}
diff --git a/src/app/ui/segmented-button-group/segmented-button-group.component.ts b/src/app/ui/segmented-button-group/segmented-button-group.component.ts
new file mode 100644
index 0000000000..de0628f7f9
--- /dev/null
+++ b/src/app/ui/segmented-button-group/segmented-button-group.component.ts
@@ -0,0 +1,158 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ ElementRef,
+ HostBinding,
+ input,
+ output,
+ viewChildren,
+} from '@angular/core';
+import { TranslateModule } from '@ngx-translate/core';
+import { MatIconModule } from '@angular/material/icon';
+import { MatTooltip } from '@angular/material/tooltip';
+
+export interface SegmentedButtonOption {
+ id: string | number;
+ labelKey: string;
+ hintKey?: string;
+ icon?: string;
+ disabled?: boolean;
+}
+
+@Component({
+ selector: 'segmented-button-group',
+ standalone: true,
+ imports: [TranslateModule, MatIconModule, MatTooltip],
+ templateUrl: './segmented-button-group.component.html',
+ styleUrls: ['./segmented-button-group.component.scss'],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class SegmentedButtonGroupComponent {
+ readonly options = input.required();
+ readonly selectedId = input(null);
+ readonly ariaLabel = input('');
+ readonly size = input<'md' | 'lg'>('lg');
+
+ readonly selectionChange = output();
+
+ @HostBinding('attr.data-size')
+ get sizeAttr(): string {
+ return this.size();
+ }
+
+ private readonly _buttonRefs =
+ viewChildren>('segmentButton');
+
+ readonly focusableIndex = computed(() => {
+ const options = this.options();
+ const selectedIdx = options.findIndex((option) => option.id === this.selectedId());
+
+ if (selectedIdx >= 0 && !options[selectedIdx].disabled) {
+ return selectedIdx;
+ }
+
+ return options.findIndex((option) => !option.disabled) ?? 0;
+ });
+
+ isActive(option: SegmentedButtonOption): boolean {
+ return option.id === this.selectedId();
+ }
+
+ onSelect(id: string | number | undefined): void {
+ if (id === undefined) {
+ return;
+ }
+
+ const option = this.options().find((opt) => opt.id === id);
+
+ if (!option || option.disabled) {
+ return;
+ }
+
+ if (id !== this.selectedId()) {
+ this.selectionChange.emit(id);
+ }
+ }
+
+ handleKeyDown(event: KeyboardEvent, index: number): void {
+ const options = this.options();
+
+ if (!options.length) {
+ return;
+ }
+
+ let targetIndex = index;
+
+ switch (event.key) {
+ case 'ArrowRight':
+ case 'ArrowDown': {
+ event.preventDefault();
+ targetIndex = this._findNextEnabledIndex(index, 1);
+ break;
+ }
+ case 'ArrowLeft':
+ case 'ArrowUp': {
+ event.preventDefault();
+ targetIndex = this._findNextEnabledIndex(index, -1);
+ break;
+ }
+ case 'Home': {
+ event.preventDefault();
+ targetIndex = this._findNextEnabledIndex(-1, 1);
+ break;
+ }
+ case 'End': {
+ event.preventDefault();
+ targetIndex = this._findNextEnabledIndex(options.length, -1);
+ break;
+ }
+ case ' ':
+ case 'Enter': {
+ event.preventDefault();
+ this.onSelect(options[index]?.id);
+ this._focusButton(index);
+ return;
+ }
+ default:
+ return;
+ }
+
+ if (targetIndex !== index && options[targetIndex]) {
+ this.onSelect(options[targetIndex].id);
+ queueMicrotask(() => {
+ this._focusButton(targetIndex);
+ });
+ }
+ }
+
+ private _findNextEnabledIndex(start: number, direction: 1 | -1): number {
+ const options = this.options();
+ const len = options.length;
+
+ if (!len) {
+ return start;
+ }
+
+ let current = start;
+
+ for (let i = 0; i < len; i++) {
+ current = (current + direction + len) % len;
+ const option = options[current];
+
+ if (option && !option.disabled) {
+ return current;
+ }
+ }
+
+ return start;
+ }
+
+ private _focusButton(index: number): void {
+ const buttons = this._buttonRefs();
+ const button = buttons[index]?.nativeElement;
+ if (button) {
+ button.focus();
+ }
+ }
+}
diff --git a/src/app/util/adjust-to-live-formly-form.ts b/src/app/util/adjust-to-live-formly-form.ts
index fa950f2cee..022328cb31 100644
--- a/src/app/util/adjust-to-live-formly-form.ts
+++ b/src/app/util/adjust-to-live-formly-form.ts
@@ -15,7 +15,8 @@ export const adjustToLiveFormlyForm = (
item.type === 'input' ||
item.type === 'textarea' ||
item.type === 'duration' ||
- item.type === 'icon'
+ item.type === 'icon' ||
+ item.type === 'color'
) {
return {
...item,
diff --git a/src/assets/i18n/de.json b/src/assets/i18n/de.json
index 04e9e34e88..c63f310afd 100644
--- a/src/assets/i18n/de.json
+++ b/src/assets/i18n/de.json
@@ -1804,7 +1804,6 @@
"IS_HIDE_NAV": "Navigation verbergen, bis die Hauptüberschrift angezeigt wird (nur Desktop)",
"IS_MINIMIZE_TO_TRAY": "Anwendung als Trayicon minimieren (nur Deskop)",
"IS_SHOW_TIP_LONGER": "Zeige den Produktivitätstipp beim Start der App etwas länger an",
- "IS_DISABLE_PRODUCTIVITY_TIPS": "Produktivitätstipps beim Start der Anwendung deaktivieren",
"IS_TRAY_SHOW_CURRENT_COUNTDOWN": "Aktuellen Countdown im Tray / Statusmenü anzeigen (nur Desktop Mac)",
"IS_TRAY_SHOW_CURRENT_TASK": "Aktuelle Aufgabe im Tray / Status-Menu zeigen (nur Desktop)",
"IS_OVERLAY_INDICATOR_ENABLED": "Enable overlay indicator window (desktop linux/gnome)",
diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json
index 76feaa43c9..9cd69770f4 100644
--- a/src/assets/i18n/en.json
+++ b/src/assets/i18n/en.json
@@ -219,8 +219,10 @@
"BACK_TO_PLANNING": "Back to Planning",
"CONGRATS": "Congrats for completing this session!",
"COUNTDOWN": "Countdown",
+ "COUNTDOWN_HINT": "Focus until the clock hits zero",
"FINISH_TASK_AND_SELECT_NEXT": "Finish task and select next",
"FLOWTIME": "Flowtime",
+ "FLOWTIME_HINT": "Flexible pacing without strict timers",
"FOR_TASK": "for task",
"GET_READY": "Get ready for your focus session!",
"GO_TO_PROCRASTINATION": "Get help, when procrastinating",
@@ -229,6 +231,7 @@
"ON": "on",
"OPEN_ISSUE_IN_BROWSER": "Open issue in Browser",
"POMODORO": "Pomodoro",
+ "POMODORO_HINT": "Structured sprints with planned breaks",
"POMODORO_BACK": "Back",
"POMODORO_DISABLE": "Disable Pomodoro",
"POMODORO_INFO": "Focus sessions cannot be used together with the pomodoro timer enabled.",
@@ -237,6 +240,7 @@
"PREP_STRETCH": "Do some mild stretching",
"SELECT_ANOTHER_TASK": "Select another Task",
"SELECT_TASK": "Select Task to focus on",
+ "SELECT_MODE": "Choose your focus mode",
"SESSION_COMPLETED": "Focus Session Completed!",
"POMODORO_SESSION_COMPLETED": "Pomodoro Session Completed!",
"SET_FOCUS_SESSION_DURATION": "Set Focus Session Duration",
@@ -575,6 +579,7 @@
"CHECK": "I did it!"
},
"CMP": {
+ "ACTIVITY_HEATMAP": "Activity Heatmap",
"AVG_BREAKS_PER_DAY": "Avg. breaks per day",
"AVG_TASKS_PER_DAY_WORKED": "Avg. tasks per day worked",
"AVG_TIME_SPENT_ON_BREAKS": "Avg. time spent on breaks",
@@ -1462,8 +1467,8 @@
},
"TASK_VIEW": {
"CUSTOMIZER": {
- "ENTER_PROJECT": "Enter project",
- "ENTER_TAG": "Enter tag",
+ "ENTER_PROJECT": "Filter Projects",
+ "ENTER_TAG": "Filter Tag",
"ESTIMATED_TIME": "Estimated Time",
"FILTER_BY": "Filter By",
"FILTER_DEFAULT": "No Filter",
@@ -1807,7 +1812,6 @@
"IS_HIDE_NAV": "Hide navigation until main header is hovered (desktop only)",
"IS_MINIMIZE_TO_TRAY": "Minimize to tray (desktop only)",
"IS_SHOW_TIP_LONGER": "Show productivity tip on app start a little longer",
- "IS_DISABLE_PRODUCTIVITY_TIPS": "Disable productivity tips on app start",
"IS_TRAY_SHOW_CURRENT_COUNTDOWN": "Show current countdown in the tray / status menu (desktop mac only)",
"IS_TRAY_SHOW_CURRENT_TASK": "Show current task in the tray / status menu (desktop mac/windows only)",
"IS_OVERLAY_INDICATOR_ENABLED": "Enable overlay indicator window (desktop linux/gnome)",
@@ -1935,6 +1939,10 @@
},
"GLOBAL_SNACK": {
"COPY_TO_CLIPPBOARD": "Copied to clipboard",
+ "NO_TASKS_TO_COPY": "No tasks to copy",
+ "SHARE_UNAVAILABLE_FALLBACK": "Copied to clipboard.",
+ "SHARE_FAILED_FALLBACK": "Sharing failed. Copied to clipboard instead.",
+ "SHARE_FAILED": "Sharing failed. Please copy manually.",
"ERR_COMPRESSION": "Error for compression interface",
"FILE_DOWNLOADED": "{{fileName}} downloaded",
"FILE_DOWNLOADED_BTN": "Open folder",
@@ -2001,6 +2009,8 @@
"TOGGLE_SHOW_NOTES": "Show/Hide Project Notes",
"TOGGLE_TRACK_TIME": "Start/Stop tracking time",
"TRIGGER_SYNC": "Sync!",
+ "SHARE_TASK_LIST_MARKDOWN": "Share Task List",
+ "COPY_TASK_LIST_MARKDOWN": "Copy to Clipboard",
"WORKLOG": "Worklog",
"SIDE_PANEL_MENU": "Side Panel Menu"
},
diff --git a/src/assets/i18n/fi.json b/src/assets/i18n/fi.json
index 41bcbd9228..7ba5d53e44 100644
--- a/src/assets/i18n/fi.json
+++ b/src/assets/i18n/fi.json
@@ -1801,7 +1801,6 @@
"IS_HIDE_NAV": "Piilota navigointi, kunnes päänimikettä hoveroidaan (vain työpöytä)",
"IS_MINIMIZE_TO_TRAY": "Pienennä tehtäväpalkkiin (vain työpöytä)",
"IS_SHOW_TIP_LONGER": "Näytä tuottavuusvinkki sovelluksen käynnistyksessä hieman pidempään",
- "IS_DISABLE_PRODUCTIVITY_TIPS": "Poista tuottavuusvinkit käytöstä sovelluksen käynnistyksessä",
"IS_TRAY_SHOW_CURRENT_COUNTDOWN": "Näytä nykyinen laskuri tehtäväpalkissa / tilavalikossa (vain työpöytä mac)",
"IS_TRAY_SHOW_CURRENT_TASK": "Näytä nykyinen tehtävä tehtäväpalkissa / tilavalikossa (vain työpöytä mac/windows)",
"IS_OVERLAY_INDICATOR_ENABLED": "Ota päällekkäisyysindikaattori-ikkuna käyttöön (työpöytä linux/gnome)",
diff --git a/src/assets/i18n/tr.json b/src/assets/i18n/tr.json
index 6bacdf310e..79d8b30878 100644
--- a/src/assets/i18n/tr.json
+++ b/src/assets/i18n/tr.json
@@ -1798,7 +1798,6 @@
"IS_HIDE_NAV": "Ana başlık yönlendirilene kadar gezinmeyi gizle (yalnızca masaüstü)",
"IS_MINIMIZE_TO_TRAY": "Tepsiye küçült (yalnızca masaüstü)",
"IS_SHOW_TIP_LONGER": "Uygulamada üretkenlik ipucunu biraz daha uzun süre başlatın",
- "IS_DISABLE_PRODUCTIVITY_TIPS": "Üretkenlik ipuçlarını uygulama başlangıcında devre dışı bırak",
"IS_TRAY_SHOW_CURRENT_COUNTDOWN": "Mevcut geri sayımı tepsi / durum menüsünde göster (sadece masaüstü mac için)",
"IS_TRAY_SHOW_CURRENT_TASK": "Mevcut görevi tepsi / Durum menüsünde göster (yalnızca masaüstü)",
"IS_OVERLAY_INDICATOR_ENABLED": "Panel gösterge penceresini etkinleştir (masaüstü linux/gnome)",
diff --git a/src/styles/components/_components.scss b/src/styles/components/_components.scss
index d4d19e7ea5..e766e64843 100644
--- a/src/styles/components/_components.scss
+++ b/src/styles/components/_components.scss
@@ -16,5 +16,6 @@
@use './planner-shared';
@use './mentions';
@use './bottom-panel';
+@use './customizer-menu';
//@import '../../app/ui/custom-datetime-picker/sass/picker';
diff --git a/src/styles/components/_customizer-menu.scss b/src/styles/components/_customizer-menu.scss
new file mode 100644
index 0000000000..1c5e9a753f
--- /dev/null
+++ b/src/styles/components/_customizer-menu.scss
@@ -0,0 +1,41 @@
+// TASK VIEW CUSTOMIZER MENU
+// Styles for the task view customizer menu component
+// Menu renders in global overlay, so styles must be global
+
+.menu-item-content {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+ gap: 16px;
+
+ > span:first-child {
+ flex: 1;
+ }
+
+ .check-icon {
+ flex-shrink: 0;
+ margin-left: auto;
+ margin-right: 0 !important;
+ }
+}
+
+.customizer-menu {
+ .mat-mdc-menu-item {
+ &.active {
+ background-color: rgba(0, 0, 0, 0.08);
+ font-weight: 500;
+ }
+ }
+
+ .current-value {
+ padding-left: 16px;
+ font-size: 0.875em;
+ opacity: 0.7;
+ font-style: italic;
+ }
+
+ mat-icon:first-child:not(.check-icon) {
+ margin-right: 8px;
+ }
+}