From c45080527ef06f484de8aca752b893f207aea679 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Fri, 17 Oct 2025 16:34:47 +0200 Subject: [PATCH 01/36] feat(customizerMenu): first draft for component --- .../desktop-panel-buttons.component.ts | 16 +- .../mobile-side-panel-menu.component.ts | 14 +- .../mobile-bottom-nav.component.html | 5 +- .../mobile-bottom-nav.component.ts | 6 +- .../features/panels/panel-content.service.ts | 24 +- .../right-panel-content.component.html | 8 - .../right-panel-content.component.ts | 12 +- .../task-view-customizer-panel.component.html | 239 ++++++++++-------- .../task-view-customizer-panel.component.scss | 56 ++-- .../task-view-customizer-panel.component.ts | 8 +- 10 files changed, 194 insertions(+), 194 deletions(-) diff --git a/src/app/core-ui/main-header/desktop-panel-buttons/desktop-panel-buttons.component.ts b/src/app/core-ui/main-header/desktop-panel-buttons/desktop-panel-buttons.component.ts index 443da1f879..2407b52948 100644 --- a/src/app/core-ui/main-header/desktop-panel-buttons/desktop-panel-buttons.component.ts +++ b/src/app/core-ui/main-header/desktop-panel-buttons/desktop-panel-buttons.component.ts @@ -5,20 +5,28 @@ import { MatTooltip } from '@angular/material/tooltip'; import { TranslatePipe } from '@ngx-translate/core'; import { LayoutService } from '../../layout/layout.service'; import { TaskViewCustomizerService } from '../../../features/task-view-customizer/task-view-customizer.service'; +import { TaskViewCustomizerPanelComponent } from '../../../features/task-view-customizer/task-view-customizer-panel/task-view-customizer-panel.component'; import { T } from '../../../t.const'; import { KeyboardConfig } from '../../../features/config/keyboard-config.model'; +import { MatMenuTrigger } from '@angular/material/menu'; @Component({ selector: 'desktop-panel-buttons', standalone: true, - imports: [MatIconButton, MatIcon, MatTooltip, TranslatePipe], + imports: [ + MatIconButton, + MatIcon, + MatTooltip, + TranslatePipe, + TaskViewCustomizerPanelComponent, + MatMenuTrigger, + ], template: ` + + + + - +

{{ 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 + }} + } + + + } +
+ + + + 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..29fbba6d7c 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,37 @@ -.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%; +::ng-deep .customizer-menu { + .menu-content { + padding: 16px; + max-width: 400px; + min-width: 300px; - h3 { - text-align: center; - margin-bottom: 20px; - } - - .form-group { - margin-bottom: 15px; - display: flex; - flex-direction: column; - - label { - margin-bottom: 5px; + h3 { + text-align: center; + margin-top: 0; + margin-bottom: 20px; } - mat-form-field { + .form-group { + margin-bottom: 15px; + display: flex; + flex-direction: column; + + label { + margin-bottom: 5px; + font-weight: 500; + } + + mat-form-field { + width: 100%; + } + + mat-slide-toggle { + align-self: flex-start; + } + } + + button { width: 100%; - } - - mat-slide-toggle { - align-self: flex-start; + margin-top: 8px; } } } 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..4af9a96594 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,7 @@ 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 { TaskViewCustomizerService } from '../task-view-customizer.service'; import { TranslatePipe } from '@ngx-translate/core'; import { T } from 'src/app/t.const'; @@ -17,6 +18,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 +27,16 @@ import { T } from 'src/app/t.const'; MatInputModule, MatButtonModule, MatSlideToggleModule, + MatMenuModule, TranslatePipe, ], }) export class TaskViewCustomizerPanelComponent implements OnInit { customizerService = inject(TaskViewCustomizerService); + @ViewChild('customizerMenu', { static: false }) + menu!: MatMenu; + T = T; selectedSort: string = 'default'; selectedGroup: string = 'default'; From 008134d2d537c7fab5bbaead644c428a8ac14d75 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Fri, 17 Oct 2025 16:43:44 +0200 Subject: [PATCH 02/36] feat(customizerMenu): second draft for component --- .../task-view-customizer-panel.component.html | 374 ++++++++++++------ .../task-view-customizer-panel.component.scss | 47 +-- .../task-view-customizer-panel.component.ts | 35 ++ 3 files changed, 311 insertions(+), 145 deletions(-) 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 a9f69c5d9c..d8aed7d856 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 @@ -2,127 +2,265 @@ #customizerMenu="matMenu" class="customizer-menu" > + + + + + + + + + + + + + + + + + + @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 29fbba6d7c..1ef2ef4765 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,37 +1,30 @@ ::ng-deep .customizer-menu { - .menu-content { - padding: 16px; - max-width: 400px; - min-width: 300px; + .mat-mdc-menu-item { + position: relative; - h3 { - text-align: center; - margin-top: 0; - margin-bottom: 20px; + &.active { + background-color: rgba(0, 0, 0, 0.04); } - .form-group { - margin-bottom: 15px; - display: flex; - flex-direction: column; - - label { - margin-bottom: 5px; - font-weight: 500; - } - - mat-form-field { - width: 100%; - } - - mat-slide-toggle { - align-self: flex-start; - } + .current-value { + margin-left: auto; + padding-left: 16px; + font-size: 0.875em; + opacity: 0.7; + font-style: italic; } - button { + mat-icon:first-child { + margin-right: 8px; + } + } + + .menu-input-wrapper { + padding: 8px 16px; + min-width: 250px; + + .menu-input { width: 100%; - margin-top: 8px; } } } 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 4af9a96594..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 @@ -8,6 +8,8 @@ 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'; @@ -28,6 +30,8 @@ import { T } from 'src/app/t.const'; MatButtonModule, MatSlideToggleModule, MatMenuModule, + MatIconModule, + MatDividerModule, TranslatePipe, ], }) @@ -95,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(); } From 4f4021ee3eef1a56034a1579ad4ac4e8101746c1 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Fri, 17 Oct 2025 18:43:31 +0200 Subject: [PATCH 03/36] feat(customizerMenu): try to move checkmark to the right --- .../task-view-customizer-panel.component.html | 128 ++++++++++-------- .../task-view-customizer-panel.component.scss | 56 +++++--- 2 files changed, 114 insertions(+), 70 deletions(-) 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 d8aed7d856..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 @@ -64,10 +64,12 @@ (click)="customizerService.setSort(opt.value)" [class.active]="customizerService.selectedSort() === opt.value" > - @if (customizerService.selectedSort() === opt.value) { - check - } - {{ opt.label | translate }} + + {{ opt.label | translate }} + @if (customizerService.selectedSort() === opt.value) { + check + } + } @@ -80,10 +82,12 @@ (click)="customizerService.setGroup(opt.value)" [class.active]="customizerService.selectedGroup() === opt.value" > - @if (customizerService.selectedGroup() === opt.value) { - check - } - {{ opt.label | translate }} + + {{ opt.label | translate }} + @if (customizerService.selectedGroup() === opt.value) { + check + } + } @@ -95,10 +99,12 @@ (click)="onFilterSelect('default')" [class.active]="customizerService.selectedFilter() === 'default'" > - @if (customizerService.selectedFilter() === 'default') { - check - } - {{ T.F.TASK_VIEW.CUSTOMIZER.FILTER_DEFAULT | translate }} + + {{ T.F.TASK_VIEW.CUSTOMIZER.FILTER_DEFAULT | translate }} + @if (customizerService.selectedFilter() === 'default') { + check + } + @@ -210,13 +226,15 @@ customizerService.filterInputValue() === option.value " > - @if ( - customizerService.selectedFilter() === 'scheduledDate' && - customizerService.filterInputValue() === option.value - ) { - check - } - {{ option.label | translate }} + + {{ option.label | translate }} + @if ( + customizerService.selectedFilter() === 'scheduledDate' && + customizerService.filterInputValue() === option.value + ) { + check + } + } @@ -232,13 +250,15 @@ customizerService.filterInputValue() === option.value " > - @if ( - customizerService.selectedFilter() === 'estimatedTime' && - customizerService.filterInputValue() === option.value - ) { - check - } - {{ option.label | translate }} + + {{ option.label | translate }} + @if ( + customizerService.selectedFilter() === 'estimatedTime' && + customizerService.filterInputValue() === option.value + ) { + check + } + } @@ -254,13 +274,15 @@ customizerService.filterInputValue() === option.value " > - @if ( - customizerService.selectedFilter() === 'timeSpent' && - customizerService.filterInputValue() === option.value - ) { - check - } - {{ option.label | translate }} + + {{ option.label | translate }} + @if ( + customizerService.selectedFilter() === 'timeSpent' && + customizerService.filterInputValue() === option.value + ) { + check + } + } 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 1ef2ef4765..4bbc21c23b 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,30 +1,52 @@ ::ng-deep .customizer-menu { .mat-mdc-menu-item { position: relative; + display: flex !important; + align-items: center; &.active { background-color: rgba(0, 0, 0, 0.04); } - - .current-value { - margin-left: auto; - padding-left: 16px; - font-size: 0.875em; - opacity: 0.7; - font-style: italic; - } - - mat-icon:first-child { - margin-right: 8px; - } } - .menu-input-wrapper { - padding: 8px 16px; - min-width: 250px; + .current-value { + padding-left: 16px; + font-size: 0.875em; + opacity: 0.7; + font-style: italic; + } - .menu-input { - width: 100%; + mat-icon:first-child:not(.check-icon) { + margin-right: 8px; + } + + .menu-item-content { + display: flex; + align-items: center; + width: 100%; + flex: 1; + flex-grow: 1; + margin-right: 0 !important; + border: 1px solid red !important; + + > span:first-child { + flex: 1; + } + + .check-icon { + border: 1px solid deeppink !important; + margin-left: auto !important; + flex-shrink: 0; + margin-right: 0 !important; } } } + +.menu-input-wrapper { + padding: 8px 16px; + min-width: 250px; + + .menu-input { + width: 100%; + } +} From 5889c8601b85647d47d38612f19542ec206e9baf Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Fri, 17 Oct 2025 18:56:36 +0200 Subject: [PATCH 04/36] feat(customizerMenu): move filter button left of play button --- .../desktop-panel-buttons.component.ts | 32 +------------------ .../main-header/main-header.component.html | 21 ++++++++++-- .../main-header/main-header.component.scss | 26 +++++++++++++++ .../main-header/main-header.component.ts | 9 ++++-- 4 files changed, 52 insertions(+), 36 deletions(-) diff --git a/src/app/core-ui/main-header/desktop-panel-buttons/desktop-panel-buttons.component.ts b/src/app/core-ui/main-header/desktop-panel-buttons/desktop-panel-buttons.component.ts index 2407b52948..d0a0fd048b 100644 --- a/src/app/core-ui/main-header/desktop-panel-buttons/desktop-panel-buttons.component.ts +++ b/src/app/core-ui/main-header/desktop-panel-buttons/desktop-panel-buttons.component.ts @@ -4,41 +4,14 @@ import { MatIcon } from '@angular/material/icon'; import { MatTooltip } from '@angular/material/tooltip'; import { TranslatePipe } from '@ngx-translate/core'; import { LayoutService } from '../../layout/layout.service'; -import { TaskViewCustomizerService } from '../../../features/task-view-customizer/task-view-customizer.service'; -import { TaskViewCustomizerPanelComponent } from '../../../features/task-view-customizer/task-view-customizer-panel/task-view-customizer-panel.component'; import { T } from '../../../t.const'; import { KeyboardConfig } from '../../../features/config/keyboard-config.model'; -import { MatMenuTrigger } from '@angular/material/menu'; @Component({ selector: 'desktop-panel-buttons', standalone: true, - imports: [ - MatIconButton, - MatIcon, - MatTooltip, - TranslatePipe, - TaskViewCustomizerPanelComponent, - MatMenuTrigger, - ], + imports: [MatIconButton, MatIcon, MatTooltip, TranslatePipe], template: ` - - - - + + + } + diff --git a/src/app/core-ui/main-header/main-header.component.scss b/src/app/core-ui/main-header/main-header.component.scss index 226c7c9775..1042e818e9 100644 --- a/src/app/core-ui/main-header/main-header.component.scss +++ b/src/app/core-ui/main-header/main-header.component.scss @@ -98,6 +98,32 @@ button.isActive2 { margin-left: var(--s); } } + + .task-filter-btn { + position: relative; + transition: all 0.2s ease; + overflow: visible !important; + + .mat-icon { + transition: transform 0.2s ease; + display: block; + } + + &.isCustomized { + box-shadow: 0px -2px 3px 0px var(--separator-alpha); + background: var(--c-accent); + } + + &:hover:not(.isCustomized):not(:disabled) { + background-color: var(--hover-color, rgba(0, 0, 0, 0.04)); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + background: transparent !important; + } + } } @keyframes pulse { diff --git a/src/app/core-ui/main-header/main-header.component.ts b/src/app/core-ui/main-header/main-header.component.ts index 0bbaa21a2d..a106a3cfe8 100644 --- a/src/app/core-ui/main-header/main-header.component.ts +++ b/src/app/core-ui/main-header/main-header.component.ts @@ -44,6 +44,9 @@ import { toSignal } from '@angular/core/rxjs-interop'; import { MetricService } from '../../features/metric/metric.service'; import { DateService } from '../../core/date/date.service'; import { MsToMinuteClockStringPipe } from '../../ui/duration/ms-to-minute-clock-string.pipe'; +import { TaskViewCustomizerService } from '../../features/task-view-customizer/task-view-customizer.service'; +import { TaskViewCustomizerPanelComponent } from '../../features/task-view-customizer/task-view-customizer-panel/task-view-customizer-panel.component'; +import { MatMenuTrigger } from '@angular/material/menu'; @Component({ selector: 'main-header', @@ -64,6 +67,8 @@ import { MsToMinuteClockStringPipe } from '../../ui/duration/ms-to-minute-clock- PageTitleComponent, PlayButtonComponent, DesktopPanelButtonsComponent, + TaskViewCustomizerPanelComponent, + MatMenuTrigger, MsToMinuteClockStringPipe, ], }) @@ -83,6 +88,7 @@ export class MainHeaderComponent implements OnDestroy { private readonly _configService = inject(GlobalConfigService); private readonly _metricService = inject(MetricService); private readonly _dateService = inject(DateService); + readonly taskViewCustomizerService = inject(TaskViewCustomizerService); T: typeof T = T; isShowSimpleCounterBtnsMobile = signal(false); @@ -145,9 +151,6 @@ export class MainHeaderComponent implements OnDestroy { enabledSimpleCounters = toSignal(this.simpleCounterService.enabledSimpleCounters$, { initialValue: [], }); - isShowTaskViewCustomizerPanel = computed(() => - this.layoutService.isShowTaskViewCustomizerPanel(), - ); isShowIssuePanel = computed(() => this.layoutService.isShowIssuePanel()); isShowNotes = computed(() => this.layoutService.isShowNotes()); syncIsEnabledAndReady = toSignal(this.syncWrapperService.isEnabledAndReady$); From 1c4b12531daad43fdef578f7671b2b6eada4f3ae Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Fri, 17 Oct 2025 19:03:37 +0200 Subject: [PATCH 05/36] feat(customizerMenu): move filter button to right of project title --- .../main-header/main-header.component.html | 19 ---- .../main-header/main-header.component.scss | 26 ------ .../main-header/main-header.component.ts | 6 -- .../page-title/page-title.component.ts | 92 ++++++++++++++++--- 4 files changed, 81 insertions(+), 62 deletions(-) diff --git a/src/app/core-ui/main-header/main-header.component.html b/src/app/core-ui/main-header/main-header.component.html index ddcc877645..7095b1ecdb 100644 --- a/src/app/core-ui/main-header/main-header.component.html +++ b/src/app/core-ui/main-header/main-header.component.html @@ -85,25 +85,6 @@ - @if (showDesktopButtons()) { - - - - } - @if (!isXxxs()) { - +
+ + + +
} @@ -84,23 +108,56 @@ import { WorkContextService } from '../../../features/work-context/work-context. } } + .page-title-actions { + display: flex; + align-items: center; + gap: var(--s-quarter); + margin-left: calc(-1 * var(--s)); + margin-right: var(--s2); + } + .project-settings-btn { display: none; @media (min-width: 600px) { display: block; transition: var(--transition-standard); opacity: 0; - margin-right: var(--s2); - margin-left: calc(-1 * var(--s)); position: relative; z-index: 1; } &:hover, - .page-title:hover + & { + .page-title:hover + .page-title-actions &, + .page-title-actions:hover & { opacity: 1; } } + + .task-filter-btn { + position: relative; + transition: all 0.2s ease; + overflow: visible !important; + + .mat-icon { + transition: transform 0.2s ease; + display: block; + } + + &.isCustomized { + box-shadow: 0px -2px 3px 0px var(--separator-alpha); + background: var(--c-accent); + } + + &:hover:not(.isCustomized):not(:disabled) { + background-color: var(--hover-color, rgba(0, 0, 0, 0.04)); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + background: transparent !important; + } + } `, ], changeDetection: ChangeDetectionStrategy.OnPush, @@ -109,6 +166,8 @@ export class PageTitleComponent { private _breakpointObserver = inject(BreakpointObserver); private _router = inject(Router); private _workContextService = inject(WorkContextService); + readonly taskViewCustomizerService = inject(TaskViewCustomizerService); + private readonly _configService = inject(GlobalConfigService); readonly T = T; @@ -140,6 +199,13 @@ export class PageTitleComponent { ); isBoardsSection = toSignal(this._isBoardsSection$, { initialValue: false }); + private _isWorkViewPage$ = this._router.events.pipe( + filter((event): event is NavigationEnd => event instanceof NavigationEnd), + map((event) => !!event.urlAfterRedirects.match(/tasks$/)), + startWith(!!this._router.url.match(/tasks$/)), + ); + isWorkViewPage = toSignal(this._isWorkViewPage$, { initialValue: false }); + // Override title for special routes displayTitle = computed(() => { if (this.isScheduleSection()) { @@ -158,4 +224,8 @@ export class PageTitleComponent { isXxxs = toSignal(this._isXxxs$.pipe(map((result) => result.matches)), { initialValue: false, }); + + get kb(): KeyboardConfig { + return (this._configService.cfg()?.keyboard as KeyboardConfig) || {}; + } } From aaf731652fbbb8e2d016dcae4e1dddef45dfe533 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Fri, 17 Oct 2025 19:07:58 +0200 Subject: [PATCH 06/36] feat(customizerMenu): change color of active filter button --- .../core-ui/main-header/page-title/page-title.component.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/core-ui/main-header/page-title/page-title.component.ts b/src/app/core-ui/main-header/page-title/page-title.component.ts index 9dcd6ee294..c132d17f1d 100644 --- a/src/app/core-ui/main-header/page-title/page-title.component.ts +++ b/src/app/core-ui/main-header/page-title/page-title.component.ts @@ -144,8 +144,8 @@ import { KeyboardConfig } from '../../../features/config/keyboard-config.model'; } &.isCustomized { - box-shadow: 0px -2px 3px 0px var(--separator-alpha); - background: var(--c-accent); + color: var(--c-accent); + box-shadow: none; } &:hover:not(.isCustomized):not(:disabled) { From ee9b4e47f920123139bdbe4dddb75c33257db6f1 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Sat, 18 Oct 2025 11:41:48 +0200 Subject: [PATCH 07/36] feat: improve styling for focus mode mode selection --- .../focus-mode-overlay.component.html | 21 +-- .../focus-mode-overlay.component.scss | 5 +- .../focus-mode-overlay.component.ts | 36 +++- src/app/t.const.ts | 4 + .../segmented-button-group.component.html | 31 ++++ .../segmented-button-group.component.scss | 98 +++++++++++ .../segmented-button-group.component.ts | 158 ++++++++++++++++++ src/assets/i18n/en.json | 4 + 8 files changed, 335 insertions(+), 22 deletions(-) create mode 100644 src/app/ui/segmented-button-group/segmented-button-group.component.html create mode 100644 src/app/ui/segmented-button-group/segmented-button-group.component.scss create mode 100644 src/app/ui/segmented-button-group/segmented-button-group.component.ts diff --git a/src/app/features/focus-mode/focus-mode-overlay/focus-mode-overlay.component.html b/src/app/features/focus-mode/focus-mode-overlay/focus-mode-overlay.component.html index b72c578374..33151c9e49 100644 --- a/src/app/features/focus-mode/focus-mode-overlay/focus-mode-overlay.component.html +++ b/src/app/features/focus-mode/focus-mode-overlay/focus-mode-overlay.component.html @@ -15,22 +15,13 @@ - - {{ T.F.FOCUS_MODE.FLOWTIME | translate }} - - {{ T.F.FOCUS_MODE.POMODORO | translate }} - - {{ T.F.FOCUS_MODE.COUNTDOWN | translate }} - - + [options]="modeOptions" + [selectedId]="selectedMode()" + [ariaLabel]="T.F.FOCUS_MODE.SELECT_MODE | translate" + (selectionChange)="selectMode($event)" + > diff --git a/src/app/features/focus-mode/focus-mode-overlay/focus-mode-overlay.component.scss b/src/app/features/focus-mode/focus-mode-overlay/focus-mode-overlay.component.scss index b627b959ee..e1be4866f9 100644 --- a/src/app/features/focus-mode/focus-mode-overlay/focus-mode-overlay.component.scss +++ b/src/app/features/focus-mode/focus-mode-overlay/focus-mode-overlay.component.scss @@ -85,8 +85,9 @@ main { } .focus-mode-select-mode { - margin-bottom: auto; - margin-top: 16px; + width: min(100%, 560px); + margin-top: var(--s2); + margin-bottom: calc(-1 * var(--s4)); } focus-mode-task-selection, diff --git a/src/app/features/focus-mode/focus-mode-overlay/focus-mode-overlay.component.ts b/src/app/features/focus-mode/focus-mode-overlay/focus-mode-overlay.component.ts index 865359ec7a..637a327029 100644 --- a/src/app/features/focus-mode/focus-mode-overlay/focus-mode-overlay.component.ts +++ b/src/app/features/focus-mode/focus-mode-overlay/focus-mode-overlay.component.ts @@ -32,9 +32,12 @@ import { TranslatePipe } from '@ngx-translate/core'; import { BannerService } from '../../../core/banner/banner.service'; import { BannerId } from '../../../core/banner/banner.model'; import { toSignal } from '@angular/core/rxjs-interop'; -import { MatButtonToggle, MatButtonToggleGroup } from '@angular/material/button-toggle'; import { FocusModeService } from '../focus-mode.service'; import { FocusModeMode, FocusScreen } from '../focus-mode.model'; +import { + SegmentedButtonGroupComponent, + SegmentedButtonOption, +} from '../../../ui/segmented-button-group/segmented-button-group.component'; @Component({ selector: 'focus-mode-overlay', @@ -54,8 +57,7 @@ import { FocusModeMode, FocusScreen } from '../focus-mode.model'; FocusModeBreakComponent, MatButton, TranslatePipe, - MatButtonToggleGroup, - MatButtonToggle, + SegmentedButtonGroupComponent, NgTemplateOutlet, ], }) @@ -70,6 +72,27 @@ export class FocusModeOverlayComponent implements OnDestroy { FocusScreen: typeof FocusScreen = FocusScreen; FocusModeMode: typeof FocusModeMode = FocusModeMode; + readonly modeOptions: ReadonlyArray = [ + { + id: FocusModeMode.Flowtime, + icon: 'auto_awesome', + labelKey: T.F.FOCUS_MODE.FLOWTIME, + hintKey: T.F.FOCUS_MODE.FLOWTIME_HINT, + }, + { + id: FocusModeMode.Pomodoro, + icon: 'timer', + labelKey: T.F.FOCUS_MODE.POMODORO, + hintKey: T.F.FOCUS_MODE.POMODORO_HINT, + }, + { + id: FocusModeMode.Countdown, + icon: 'hourglass_bottom', + labelKey: T.F.FOCUS_MODE.COUNTDOWN, + hintKey: T.F.FOCUS_MODE.COUNTDOWN_HINT, + }, + ]; + selectedMode = this.focusModeService.mode; activePage = this.focusModeService.currentScreen; isSessionRunning = this.focusModeService.isSessionRunning; @@ -195,8 +218,11 @@ export class FocusModeOverlayComponent implements OnDestroy { this._store.dispatch(cancelFocusSession()); } - selectMode(mode: FocusModeMode): void { - this._store.dispatch(setFocusModeMode({ mode })); + selectMode(mode: FocusModeMode | string): void { + if (!Object.values(FocusModeMode).includes(mode as FocusModeMode)) { + return; + } + this._store.dispatch(setFocusModeMode({ mode: mode as FocusModeMode })); } deactivatePomodoro(): void { diff --git a/src/app/t.const.ts b/src/app/t.const.ts index 48ff0c283d..831a8e51b1 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', 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/assets/i18n/en.json b/src/assets/i18n/en.json index ae25c8a444..c86b771a84 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", From 1cf16868212386266c3bc6bd6edb1b025c4c7cbb Mon Sep 17 00:00:00 2001 From: Michael Huynh Date: Sun, 19 Oct 2025 13:47:54 +0800 Subject: [PATCH 08/36] fix(task): prefer shortest project title prefix match (#4225) Intended to help overcome misassigning a project to a task if multiple projects with common prefixes exist. --- src/app/features/tasks/short-syntax.spec.ts | 19 +++++++++++++++++++ src/app/features/tasks/short-syntax.ts | 17 ++++++++++++----- 2 files changed, 31 insertions(+), 5 deletions(-) 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) { From 911e82bea5c3ca2ba14adc222c0350cb89a5cc4e Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Sun, 19 Oct 2025 11:01:20 +0200 Subject: [PATCH 09/36] fix: spell checker phoning home #5314 --- electron/main-window.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/electron/main-window.ts b/electron/main-window.ts index cd8d4faaf8..5569ce4fef 100644 --- a/electron/main-window.ts +++ b/electron/main-window.ts @@ -93,6 +93,9 @@ export const createWindow = ({ contextIsolation: true, // Additional settings for better Linux/Wayland compatibility enableBlinkFeatures: 'OverlayScrollbar', + // Disable spell checker to prevent connections to Google services (#5314) + // This maintains our "offline-first with zero data collection" promise + spellcheck: false, }, icon: ICONS_FOLDER + '/icon_256x256.png', // Wayland compatibility: disable transparent/frameless features that can cause issues From b618a70727098c029d11786293a985918fdd097f Mon Sep 17 00:00:00 2001 From: Trang Le Date: Mon, 20 Oct 2025 09:14:04 +0700 Subject: [PATCH 10/36] import type PluginHooks --- packages/plugin-dev/boilerplate-solid-js/src/plugin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/plugin-dev/boilerplate-solid-js/src/plugin.ts b/packages/plugin-dev/boilerplate-solid-js/src/plugin.ts index 180dac1ea7..425d79042b 100644 --- a/packages/plugin-dev/boilerplate-solid-js/src/plugin.ts +++ b/packages/plugin-dev/boilerplate-solid-js/src/plugin.ts @@ -1,10 +1,10 @@ import { AnyTaskUpdatePayload, PluginAPI, - PluginHooks, TaskCompletePayload, TaskUpdatePayload, } from '@super-productivity/plugin-api'; +import type { PluginHooks } from '@super-productivity/plugin-api'; declare const plugin: PluginAPI; From d1eb1b49b19b91799f6ee2252c33ae29a52dce2c Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Tue, 21 Oct 2025 11:48:35 +0200 Subject: [PATCH 11/36] feat(metrics): add heatmap --- .../activity-heatmap.component.html | 46 +++ .../activity-heatmap.component.scss | 201 ++++++++++ .../activity-heatmap.component.ts | 357 ++++++++++++++++++ src/app/features/metric/metric.component.html | 3 + src/app/features/metric/metric.component.ts | 9 +- src/assets/i18n/en.json | 1 + 6 files changed, 616 insertions(+), 1 deletion(-) create mode 100644 src/app/features/metric/activity-heatmap/activity-heatmap.component.html create mode 100644 src/app/features/metric/activity-heatmap/activity-heatmap.component.scss create mode 100644 src/app/features/metric/activity-heatmap/activity-heatmap.component.ts diff --git a/src/app/features/metric/activity-heatmap/activity-heatmap.component.html b/src/app/features/metric/activity-heatmap/activity-heatmap.component.html new file mode 100644 index 0000000000..840b533def --- /dev/null +++ b/src/app/features/metric/activity-heatmap/activity-heatmap.component.html @@ -0,0 +1,46 @@ +
+

{{ T.F.METRIC.CMP.ACTIVITY_HEATMAP | translate }}

+ + @if (heatmapData(); as data) { +
+
+ @for (month of data.monthLabels; track $index) { +
{{ month }}
+ } +
+ +
+
+
Mon
+
Wed
+
Fri
+
+ +
+ @for (week of data.weeks; track $index) { +
+ @for (day of week.days; track $index) { +
+ } +
+ } +
+
+ +
+ Less +
+
+
+
+
+ More +
+
+ } @else { +

{{ T.F.METRIC.CMP.NO_ADDITIONAL_DATA_YET | translate }}

+ } +
diff --git a/src/app/features/metric/activity-heatmap/activity-heatmap.component.scss b/src/app/features/metric/activity-heatmap/activity-heatmap.component.scss new file mode 100644 index 0000000000..478bbdb7ec --- /dev/null +++ b/src/app/features/metric/activity-heatmap/activity-heatmap.component.scss @@ -0,0 +1,201 @@ +.activity-heatmap { + margin: 24px 0; + + h3 { + margin-bottom: 16px; + } +} + +.heatmap-container { + display: flex; + flex-direction: column; + gap: 8px; + padding: 16px; + background: rgba(0, 0, 0, 0.02); + border-radius: 4px; +} + +.heatmap-months { + display: flex; + gap: 2px; + padding-left: 48px; + font-size: 12px; + color: rgba(0, 0, 0, 0.6); + + .month-label { + flex: 0 0 auto; + width: calc(4 * 12px + 4 * 2px); // 4 weeks * (cell width + gap) + } +} + +.heatmap-grid { + display: flex; + gap: 8px; +} + +.day-labels { + display: flex; + flex-direction: column; + justify-content: space-around; + font-size: 10px; + color: rgba(0, 0, 0, 0.6); + padding-right: 4px; + width: 40px; + + .day-label { + height: 12px; + line-height: 12px; + } +} + +.weeks { + display: flex; + gap: 2px; + flex-wrap: nowrap; + overflow-x: auto; + padding-bottom: 8px; + + // Smooth scrolling + scroll-behavior: smooth; + + // Better scrollbar styling + &::-webkit-scrollbar { + height: 8px; + } + + &::-webkit-scrollbar-track { + background: rgba(0, 0, 0, 0.05); + border-radius: 4px; + } + + &::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.2); + border-radius: 4px; + + &:hover { + background: rgba(0, 0, 0, 0.3); + } + } +} + +.week { + display: flex; + flex-direction: column; + gap: 2px; +} + +.day { + width: 12px; + height: 12px; + border-radius: 2px; + cursor: pointer; + transition: all 0.1s ease; + + &.empty { + background: transparent; + cursor: default; + } + + &.level-0 { + background: rgba(0, 0, 0, 0.05); + } + + &.level-1 { + background: #b2ebf2; + } + + &.level-2 { + background: #4dd0e1; + } + + &.level-3 { + background: #00bcd4; + } + + &.level-4 { + background: #0097a7; + } + + &:not(.empty):hover { + transform: scale(1.3); + outline: 1px solid rgba(0, 0, 0, 0.2); + z-index: 1; + } +} + +.heatmap-legend { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 4px; + font-size: 11px; + color: rgba(0, 0, 0, 0.6); + padding-right: 4px; + + span { + margin: 0 4px; + } + + .legend-item { + width: 12px; + height: 12px; + border-radius: 2px; + + &.level-0 { + background: rgba(0, 0, 0, 0.05); + } + + &.level-1 { + background: #b2ebf2; + } + + &.level-2 { + background: #4dd0e1; + } + + &.level-3 { + background: #00bcd4; + } + + &.level-4 { + background: #0097a7; + } + } +} + +// Dark theme support +@media (prefers-color-scheme: dark) { + .heatmap-container { + background: rgba(255, 255, 255, 0.02); + } + + .heatmap-months, + .day-labels, + .heatmap-legend { + color: rgba(255, 255, 255, 0.6); + } + + .weeks { + &::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.05); + } + + &::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2); + + &:hover { + background: rgba(255, 255, 255, 0.3); + } + } + } + + .day { + &.level-0 { + background: rgba(255, 255, 255, 0.05); + } + + &:not(.empty):hover { + outline-color: rgba(255, 255, 255, 0.2); + } + } +} diff --git a/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts b/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts new file mode 100644 index 0000000000..32fe4ecb4f --- /dev/null +++ b/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts @@ -0,0 +1,357 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { WorklogService } from '../../worklog/worklog.service'; +import { WorkContextService } from '../../work-context/work-context.service'; +import { TaskService } from '../../tasks/task.service'; +import { map, switchMap } from 'rxjs/operators'; +import { DatePipe } from '@angular/common'; +import { MsToStringPipe } from '../../../ui/duration/ms-to-string.pipe'; +import { TranslatePipe } from '@ngx-translate/core'; +import { T } from '../../../t.const'; +import { TODAY_TAG } from '../../tag/tag.const'; +import { Task } from '../../tasks/task.model'; + +interface DayData { + date: Date; + dateStr: string; + taskCount: number; + timeSpent: number; + level: number; // 0-4 for color intensity +} + +interface WeekData { + days: (DayData | null)[]; +} + +@Component({ + selector: 'activity-heatmap', + templateUrl: './activity-heatmap.component.html', + styleUrls: ['./activity-heatmap.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [DatePipe, MsToStringPipe, TranslatePipe], +}) +export class ActivityHeatmapComponent { + private readonly _worklogService = inject(WorklogService); + private readonly _workContextService = inject(WorkContextService); + private readonly _taskService = inject(TaskService); + + T: typeof T = T; + monthLabels: string[] = []; + weeks: WeekData[] = []; + + // Compute heatmap data + // NOTE: Reacts to work context changes + // - For TODAY tag: shows ALL tasks from all projects/tags + // - For other tags/projects: shows only tasks from that context + heatmapData = toSignal( + this._workContextService.activeWorkContext$.pipe( + switchMap((context) => { + // Special case: TODAY tag shows ALL data + if (context.id === TODAY_TAG.id) { + return this._taskService.allTasks$.pipe( + map((tasks) => this._buildHeatmapDataFromTasks(tasks)), + ); + } + + // Normal case: use context-filtered worklog + return this._worklogService.worklog$.pipe( + map((worklog) => this._buildHeatmapData(worklog)), + ); + }), + ), + { initialValue: null }, + ); + + private _buildHeatmapDataFromTasks(tasks: Task[]): { + weeks: WeekData[]; + monthLabels: string[]; + } | null { + const dayMap = new Map(); + 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); + } + + // Extract time spent data from all tasks + let maxTasks = 0; + let maxTime = 0; + const taskCountPerDay = new Map>(); + + tasks.forEach((task) => { + if (task.timeSpentOnDay) { + Object.keys(task.timeSpentOnDay).forEach((dateStr) => { + const timeSpent = task.timeSpentOnDay[dateStr]; + const dayData = dayMap.get(dateStr); + + if (dayData && timeSpent > 0) { + dayData.timeSpent += timeSpent; + maxTime = Math.max(maxTime, dayData.timeSpent); + + // Track unique tasks per day + if (!taskCountPerDay.has(dateStr)) { + taskCountPerDay.set(dateStr, new Set()); + } + taskCountPerDay.get(dateStr)!.add(task.id); + } + }); + } + }); + + // Update task counts + taskCountPerDay.forEach((taskIds, dateStr) => { + const dayData = dayMap.get(dateStr); + if (dayData) { + dayData.taskCount = taskIds.size; + maxTasks = Math.max(maxTasks, dayData.taskCount); + } + }); + + // Calculate levels (0-4) based on activity + dayMap.forEach((day) => { + if (day.taskCount === 0 && day.timeSpent === 0) { + day.level = 0; + } else { + const taskRatio = maxTasks > 0 ? day.taskCount / maxTasks : 0; + const timeRatio = maxTime > 0 ? day.timeSpent / maxTime : 0; + const combinedRatio = (taskRatio + timeRatio) / 2; + + if (combinedRatio > 0.75) { + day.level = 4; + } else if (combinedRatio > 0.5) { + day.level = 3; + } else if (combinedRatio > 0.25) { + day.level = 2; + } else { + day.level = 1; + } + } + }); + + return this._buildWeeksGrid(dayMap, oneYearAgo, now); + } + + private _buildHeatmapData(worklog: any): { + weeks: WeekData[]; + monthLabels: string[]; + } | null { + if (!worklog) { + return null; + } + + const dayMap = new Map(); + 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); + } + + // Extract data from worklog + let maxTasks = 0; + let maxTime = 0; + + Object.keys(worklog).forEach((yearKeyIN) => { + const yearKey = +yearKeyIN; + const year = worklog[yearKey]; + + if (year && year.ent) { + Object.keys(year.ent).forEach((monthKeyIN) => { + const monthKey = +monthKeyIN; + const month = year.ent[monthKey]; + + if (month && month.ent) { + Object.keys(month.ent).forEach((dayKeyIN) => { + const dayKey = +dayKeyIN; + const day = month.ent[dayKey]; + + if (day) { + const dateStr = day.dateStr; + const existing = dayMap.get(dateStr); + + if (existing) { + const taskCount = day.logEntries.length; + const timeSpent = day.timeSpent; + + existing.taskCount = taskCount; + existing.timeSpent = timeSpent; + + maxTasks = Math.max(maxTasks, taskCount); + maxTime = Math.max(maxTime, timeSpent); + } + } + }); + } + }); + } + }); + + // Calculate levels (0-4) based on activity + // Use a combined metric of tasks and time + dayMap.forEach((day) => { + if (day.taskCount === 0 && day.timeSpent === 0) { + day.level = 0; + } else { + // Normalize based on both tasks and time + const taskRatio = maxTasks > 0 ? day.taskCount / maxTasks : 0; + const timeRatio = maxTime > 0 ? day.timeSpent / maxTime : 0; + const combinedRatio = (taskRatio + timeRatio) / 2; + + // Map to levels 1-4 + if (combinedRatio > 0.75) { + day.level = 4; + } else if (combinedRatio > 0.5) { + day.level = 3; + } else if (combinedRatio > 0.25) { + day.level = 2; + } else { + day.level = 1; + } + } + }); + + return this._buildWeeksGrid(dayMap, oneYearAgo, now); + } + + 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, + startDate: Date, + endDate: Date, + ): { weeks: WeekData[]; monthLabels: string[] } { + const weeks: WeekData[] = []; + const monthLabels: string[] = []; + let currentMonth = -1; + + // Find the first Sunday before or on the start date + const firstDay = new Date(startDate); + const dayOfWeek = firstDay.getDay(); + firstDay.setDate(firstDay.getDate() - dayOfWeek); + + // Build weeks + const currentDate = new Date(firstDay); + let weekCount = 0; + + while (currentDate <= endDate || weeks.length === 0) { + const week: WeekData = { days: [] }; + + // Add 7 days for this week + for (let i = 0; i < 7; i++) { + const dateStr = this._getDateStr(currentDate); + const dayData = dayMap.get(dateStr); + + // Only include days within our range + if (currentDate >= startDate && currentDate <= endDate) { + week.days.push(dayData || null); + + // Track month changes for labels + const month = currentDate.getMonth(); + if (month !== currentMonth && currentDate.getDate() <= 7 && weekCount > 0) { + // Add month label at the start of the month + 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) { + // Add first month + 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++; + + // Safety limit + if (weeks.length > 54) { + break; + } + } + + 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`; + } +} diff --git a/src/app/features/metric/metric.component.html b/src/app/features/metric/metric.component.html index 0a1bb49e3c..c6a7de399c 100644 --- a/src/app/features/metric/metric.component.html +++ b/src/app/features/metric/metric.component.html @@ -63,6 +63,9 @@ } + + + @if (!metricService.hasData()) {

{{ T.F.METRIC.CMP.NO_ADDITIONAL_DATA_YET | translate }} diff --git a/src/app/features/metric/metric.component.ts b/src/app/features/metric/metric.component.ts index a1904a4cca..b53c804d90 100644 --- a/src/app/features/metric/metric.component.ts +++ b/src/app/features/metric/metric.component.ts @@ -10,6 +10,7 @@ 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'; @Component({ selector: 'metric', @@ -17,7 +18,13 @@ 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, + ], }) export class MetricComponent { workContextService = inject(WorkContextService); diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index c86b771a84..501ded5c80 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -579,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", From ca93982db84eabb2308f952a9e1494c3fa7fc0ca Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Tue, 21 Oct 2025 12:05:47 +0200 Subject: [PATCH 12/36] feat(metrics): polish heatmap --- .../activity-heatmap.component.html | 4 ++ .../activity-heatmap.component.scss | 19 +++--- .../activity-heatmap.component.ts | 46 ++++++++++--- src/app/features/metric/metric.component.html | 66 ++++++++++--------- 4 files changed, 87 insertions(+), 48 deletions(-) diff --git a/src/app/features/metric/activity-heatmap/activity-heatmap.component.html b/src/app/features/metric/activity-heatmap/activity-heatmap.component.html index 840b533def..f36ed4f7a1 100644 --- a/src/app/features/metric/activity-heatmap/activity-heatmap.component.html +++ b/src/app/features/metric/activity-heatmap/activity-heatmap.component.html @@ -11,9 +11,13 @@

+
Sun
Mon
+
Tue
Wed
+
Thu
Fri
+
Sat
diff --git a/src/app/features/metric/activity-heatmap/activity-heatmap.component.scss b/src/app/features/metric/activity-heatmap/activity-heatmap.component.scss index 478bbdb7ec..70e66641a8 100644 --- a/src/app/features/metric/activity-heatmap/activity-heatmap.component.scss +++ b/src/app/features/metric/activity-heatmap/activity-heatmap.component.scss @@ -36,11 +36,12 @@ .day-labels { display: flex; flex-direction: column; - justify-content: space-around; + gap: 2px; font-size: 10px; color: rgba(0, 0, 0, 0.6); padding-right: 4px; width: 40px; + text-align: right; .day-label { height: 12px; @@ -101,19 +102,19 @@ } &.level-1 { - background: #b2ebf2; + background: color-mix(in srgb, var(--c-primary) 20%, transparent); } &.level-2 { - background: #4dd0e1; + background: color-mix(in srgb, var(--c-primary) 40%, transparent); } &.level-3 { - background: #00bcd4; + background: color-mix(in srgb, var(--c-primary) 60%, transparent); } &.level-4 { - background: #0097a7; + background: var(--c-primary); } &:not(.empty):hover { @@ -146,19 +147,19 @@ } &.level-1 { - background: #b2ebf2; + background: color-mix(in srgb, var(--c-primary) 20%, transparent); } &.level-2 { - background: #4dd0e1; + background: color-mix(in srgb, var(--c-primary) 40%, transparent); } &.level-3 { - background: #00bcd4; + background: color-mix(in srgb, var(--c-primary) 60%, transparent); } &.level-4 { - background: #0097a7; + background: var(--c-primary); } } } diff --git a/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts b/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts index 32fe4ecb4f..3b28d17cbe 100644 --- a/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts +++ b/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts @@ -3,7 +3,9 @@ import { toSignal } from '@angular/core/rxjs-interop'; import { WorklogService } from '../../worklog/worklog.service'; import { WorkContextService } from '../../work-context/work-context.service'; import { TaskService } from '../../tasks/task.service'; -import { map, switchMap } from 'rxjs/operators'; +import { TaskArchiveService } from '../../time-tracking/task-archive.service'; +import { from } from 'rxjs'; +import { first, map, switchMap } from 'rxjs/operators'; import { DatePipe } from '@angular/common'; import { MsToStringPipe } from '../../../ui/duration/ms-to-string.pipe'; import { TranslatePipe } from '@ngx-translate/core'; @@ -34,6 +36,7 @@ export class ActivityHeatmapComponent { private readonly _worklogService = inject(WorklogService); private readonly _workContextService = inject(WorkContextService); private readonly _taskService = inject(TaskService); + private readonly _taskArchiveService = inject(TaskArchiveService); T: typeof T = T; monthLabels: string[] = []; @@ -41,14 +44,14 @@ export class ActivityHeatmapComponent { // Compute heatmap data // NOTE: Reacts to work context changes - // - For TODAY tag: shows ALL tasks from all projects/tags + // - For TODAY tag: shows ALL tasks from all projects/tags (current + archived) // - For other tags/projects: shows only tasks from that context heatmapData = toSignal( this._workContextService.activeWorkContext$.pipe( switchMap((context) => { // Special case: TODAY tag shows ALL data if (context.id === TODAY_TAG.id) { - return this._taskService.allTasks$.pipe( + return from(this._loadAllTasks()).pipe( map((tasks) => this._buildHeatmapDataFromTasks(tasks)), ); } @@ -62,6 +65,32 @@ export class ActivityHeatmapComponent { { initialValue: null }, ); + private async _loadAllTasks(): Promise { + // Load both current tasks and archived tasks + const [archive, currentTasks] = await Promise.all([ + this._taskArchiveService.load(), + this._taskService.allTasks$.pipe(first()).toPromise(), + ]); + + const allTasks: Task[] = [...(currentTasks || [])]; + + // Add archived tasks from all projects + if (archive) { + Object.values(archive).forEach((projectArchive) => { + if (projectArchive?.ids) { + projectArchive.ids.forEach((taskId) => { + const archivedTask = projectArchive.entities[taskId]; + if (archivedTask) { + allTasks.push(archivedTask); + } + }); + } + }); + } + + return allTasks; + } + private _buildHeatmapDataFromTasks(tasks: Task[]): { weeks: WeekData[]; monthLabels: string[]; @@ -120,13 +149,15 @@ export class ActivityHeatmapComponent { }); // Calculate levels (0-4) based on activity + // Prioritize time spent (80%) over task count (20%) dayMap.forEach((day) => { if (day.taskCount === 0 && day.timeSpent === 0) { day.level = 0; } else { const taskRatio = maxTasks > 0 ? day.taskCount / maxTasks : 0; const timeRatio = maxTime > 0 ? day.timeSpent / maxTime : 0; - const combinedRatio = (taskRatio + timeRatio) / 2; + // eslint-disable-next-line no-mixed-operators + const combinedRatio = timeRatio * 0.8 + taskRatio * 0.2; if (combinedRatio > 0.75) { day.level = 4; @@ -210,17 +241,16 @@ export class ActivityHeatmapComponent { }); // Calculate levels (0-4) based on activity - // Use a combined metric of tasks and time + // Prioritize time spent (80%) over task count (20%) dayMap.forEach((day) => { if (day.taskCount === 0 && day.timeSpent === 0) { day.level = 0; } else { - // Normalize based on both tasks and time const taskRatio = maxTasks > 0 ? day.taskCount / maxTasks : 0; const timeRatio = maxTime > 0 ? day.timeSpent / maxTime : 0; - const combinedRatio = (taskRatio + timeRatio) / 2; + // eslint-disable-next-line no-mixed-operators + const combinedRatio = timeRatio * 0.8 + taskRatio * 0.2; - // Map to levels 1-4 if (combinedRatio > 0.75) { day.level = 4; } else if (combinedRatio > 0.5) { diff --git a/src/app/features/metric/metric.component.html b/src/app/features/metric/metric.component.html index c6a7de399c..0d623ee168 100644 --- a/src/app/features/metric/metric.component.html +++ b/src/app/features/metric/metric.component.html @@ -64,7 +64,10 @@ } - +
+

Activity

+ +
@if (!metricService.hasData()) {

@@ -74,36 +77,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) {
@@ -134,6 +107,37 @@ } }
+ +
+ @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()) { From 0d9c3c2d0ab57e2b2ad6513be6549035c3dd6626 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Tue, 21 Oct 2025 12:53:41 +0200 Subject: [PATCH 13/36] feat(metrics): make today case work --- .../activity-heatmap.component.ts | 23 ++++++++----------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts b/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts index 3b28d17cbe..1097c9a79d 100644 --- a/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts +++ b/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts @@ -4,7 +4,7 @@ import { WorklogService } from '../../worklog/worklog.service'; import { WorkContextService } from '../../work-context/work-context.service'; import { TaskService } from '../../tasks/task.service'; import { TaskArchiveService } from '../../time-tracking/task-archive.service'; -import { from } from 'rxjs'; +import { defer, from } from 'rxjs'; import { first, map, switchMap } from 'rxjs/operators'; import { DatePipe } from '@angular/common'; import { MsToStringPipe } from '../../../ui/duration/ms-to-string.pipe'; @@ -49,9 +49,10 @@ export class ActivityHeatmapComponent { heatmapData = toSignal( this._workContextService.activeWorkContext$.pipe( switchMap((context) => { - // Special case: TODAY tag shows ALL data + // Special case: TODAY tag shows ALL data from all tasks if (context.id === TODAY_TAG.id) { - return from(this._loadAllTasks()).pipe( + // Use defer to ensure the Promise is created fresh each time + return defer(() => from(this._loadAllTasks())).pipe( map((tasks) => this._buildHeatmapDataFromTasks(tasks)), ); } @@ -74,16 +75,12 @@ export class ActivityHeatmapComponent { const allTasks: Task[] = [...(currentTasks || [])]; - // Add archived tasks from all projects - if (archive) { - Object.values(archive).forEach((projectArchive) => { - if (projectArchive?.ids) { - projectArchive.ids.forEach((taskId) => { - const archivedTask = projectArchive.entities[taskId]; - if (archivedTask) { - allTasks.push(archivedTask); - } - }); + // Add archived tasks (archive is a single TaskArchive object with all tasks) + if (archive && archive.ids) { + archive.ids.forEach((taskId) => { + const archivedTask = archive.entities[taskId]; + if (archivedTask) { + allTasks.push(archivedTask as Task); } }); } From d9b4ba5cbf67ee773bfee538a6e4577ecac588c6 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Tue, 21 Oct 2025 13:39:02 +0200 Subject: [PATCH 14/36] feat: improve context menus --- .../task-context-menu-inner.component.html | 5 +++++ .../task-context-menu-inner.component.ts | 9 ++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) 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), From ebb628fc5806fba25fcc106cce33e43e2ccccc57 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Tue, 21 Oct 2025 13:52:47 +0200 Subject: [PATCH 15/36] feat(metric): improve responsiveness --- .../activity-heatmap.component.html | 37 ++++---- .../activity-heatmap.component.scss | 85 ++++++++++++------- 2 files changed, 72 insertions(+), 50 deletions(-) diff --git a/src/app/features/metric/activity-heatmap/activity-heatmap.component.html b/src/app/features/metric/activity-heatmap/activity-heatmap.component.html index f36ed4f7a1..eb5c76bd90 100644 --- a/src/app/features/metric/activity-heatmap/activity-heatmap.component.html +++ b/src/app/features/metric/activity-heatmap/activity-heatmap.component.html @@ -3,14 +3,9 @@ @if (heatmapData(); as data) {
-
- @for (month of data.monthLabels; track $index) { -
{{ month }}
- } -
-
+
Sun
Mon
Tue
@@ -20,17 +15,25 @@
Sat
-
- @for (week of data.weeks; track $index) { -
- @for (day of week.days; track $index) { -
- } -
- } +
+
+ @for (month of data.monthLabels; track $index) { +
{{ month }}
+ } +
+ +
+ @for (week of data.weeks; track $index) { +
+ @for (day of week.days; track $index) { +
+ } +
+ } +
diff --git a/src/app/features/metric/activity-heatmap/activity-heatmap.component.scss b/src/app/features/metric/activity-heatmap/activity-heatmap.component.scss index 70e66641a8..2e6372e5c2 100644 --- a/src/app/features/metric/activity-heatmap/activity-heatmap.component.scss +++ b/src/app/features/metric/activity-heatmap/activity-heatmap.component.scss @@ -15,46 +15,18 @@ border-radius: 4px; } -.heatmap-months { - display: flex; - gap: 2px; - padding-left: 48px; - font-size: 12px; - color: rgba(0, 0, 0, 0.6); - - .month-label { - flex: 0 0 auto; - width: calc(4 * 12px + 4 * 2px); // 4 weeks * (cell width + gap) - } -} - .heatmap-grid { display: flex; gap: 8px; + align-items: flex-start; } -.day-labels { +.scrollable-content { display: flex; flex-direction: column; - gap: 2px; - font-size: 10px; - color: rgba(0, 0, 0, 0.6); - padding-right: 4px; - width: 40px; - text-align: right; - - .day-label { - height: 12px; - line-height: 12px; - } -} - -.weeks { - display: flex; - gap: 2px; - flex-wrap: nowrap; + gap: 8px; overflow-x: auto; - padding-bottom: 8px; + overflow-y: hidden; // Smooth scrolling scroll-behavior: smooth; @@ -79,10 +51,56 @@ } } +.heatmap-months { + display: flex; + gap: 2px; + font-size: 12px; + line-height: 12px; + height: 12px; + color: rgba(0, 0, 0, 0.6); + 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: rgba(0, 0, 0, 0.6); + 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 { @@ -91,6 +109,7 @@ border-radius: 2px; cursor: pointer; transition: all 0.1s ease; + flex-shrink: 0; &.empty { background: transparent; @@ -176,7 +195,7 @@ color: rgba(255, 255, 255, 0.6); } - .weeks { + .scrollable-content { &::-webkit-scrollbar-track { background: rgba(255, 255, 255, 0.05); } From d05409e6296053fdaab9b54db99589743c415240 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Tue, 21 Oct 2025 15:21:32 +0200 Subject: [PATCH 16/36] feat(metric): add share function --- .../activity-heatmap.component.html | 20 +- .../activity-heatmap.component.scss | 32 ++- .../activity-heatmap.component.ts | 196 +++++++++++++++++- src/app/features/metric/metric.component.html | 1 - src/app/t.const.ts | 1 + 5 files changed, 242 insertions(+), 8 deletions(-) diff --git a/src/app/features/metric/activity-heatmap/activity-heatmap.component.html b/src/app/features/metric/activity-heatmap/activity-heatmap.component.html index eb5c76bd90..909f10be41 100644 --- a/src/app/features/metric/activity-heatmap/activity-heatmap.component.html +++ b/src/app/features/metric/activity-heatmap/activity-heatmap.component.html @@ -1,5 +1,23 @@
-

{{ T.F.METRIC.CMP.ACTIVITY_HEATMAP | translate }}

+
+

{{ T.F.METRIC.CMP.ACTIVITY_HEATMAP | translate }}

+
+ @if (heatmapData()) { + + } +
+
@if (heatmapData(); as data) {
diff --git a/src/app/features/metric/activity-heatmap/activity-heatmap.component.scss b/src/app/features/metric/activity-heatmap/activity-heatmap.component.scss index 2e6372e5c2..610dd950c8 100644 --- a/src/app/features/metric/activity-heatmap/activity-heatmap.component.scss +++ b/src/app/features/metric/activity-heatmap/activity-heatmap.component.scss @@ -1,8 +1,38 @@ .activity-heatmap { margin: 24px 0; - h3 { + .heatmap-header { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; margin-bottom: 16px; + + .heatmap-title { + margin: 0; + flex: 0 0 auto; + width: auto; + display: inline-block; + } + + .heatmap-header__actions { + display: flex; + align-items: center; + } + + button { + opacity: 0.7; + transition: opacity 0.2s; + flex: 0 0 auto; + + &:hover:not(:disabled) { + opacity: 1; + } + + &:disabled { + opacity: 0.3; + } + } } } diff --git a/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts b/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts index 1097c9a79d..17af574660 100644 --- a/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts +++ b/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { WorklogService } from '../../worklog/worklog.service'; import { WorkContextService } from '../../work-context/work-context.service'; @@ -6,12 +6,14 @@ import { TaskService } from '../../tasks/task.service'; import { TaskArchiveService } from '../../time-tracking/task-archive.service'; import { defer, from } from 'rxjs'; import { first, map, switchMap } from 'rxjs/operators'; -import { DatePipe } from '@angular/common'; -import { MsToStringPipe } from '../../../ui/duration/ms-to-string.pipe'; import { TranslatePipe } from '@ngx-translate/core'; import { T } from '../../../t.const'; import { TODAY_TAG } from '../../tag/tag.const'; import { Task } from '../../tasks/task.model'; +import { MatIconButton } from '@angular/material/button'; +import { MatTooltip } from '@angular/material/tooltip'; +import { MatIcon } from '@angular/material/icon'; +import { SnackService } from '../../../core/snack/snack.service'; interface DayData { date: Date; @@ -30,17 +32,18 @@ interface WeekData { templateUrl: './activity-heatmap.component.html', styleUrls: ['./activity-heatmap.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, - imports: [DatePipe, MsToStringPipe, TranslatePipe], + imports: [TranslatePipe, MatIconButton, MatTooltip, MatIcon], }) export class ActivityHeatmapComponent { private readonly _worklogService = inject(WorklogService); private readonly _workContextService = inject(WorkContextService); private readonly _taskService = inject(TaskService); private readonly _taskArchiveService = inject(TaskArchiveService); + private readonly _snackService = inject(SnackService); T: typeof T = T; - monthLabels: string[] = []; weeks: WeekData[] = []; + isSharing = signal(false); // Compute heatmap data // NOTE: Reacts to work context changes @@ -381,4 +384,187 @@ export class ActivityHeatmapComponent { } return `${minutes}m`; } + + async shareHeatmap(): Promise { + const data = this.heatmapData(); + if (!data) { + return; + } + + this.isSharing.set(true); + + try { + // Render heatmap to canvas + const canvas = this._renderToCanvas(data); + + // 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[]; + }): HTMLCanvasElement { + const cellSize = 12; + const gap = 2; + const dayLabelWidth = 40; + const monthLabelHeight = 20; + const padding = 16; + + // Calculate dimensions + const numWeeks = data.weeks.length; + // eslint-disable-next-line no-mixed-operators + const canvasWidth = dayLabelWidth + numWeeks * (cellSize + gap) + padding * 2; + // eslint-disable-next-line no-mixed-operators + const canvasHeight = monthLabelHeight + 7 * (cellSize + gap) + padding * 2; + + // 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 = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + 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); + } + }); + }); + + 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/metric.component.html b/src/app/features/metric/metric.component.html index 0d623ee168..fc789c7733 100644 --- a/src/app/features/metric/metric.component.html +++ b/src/app/features/metric/metric.component.html @@ -65,7 +65,6 @@ }
-

Activity

diff --git a/src/app/t.const.ts b/src/app/t.const.ts index 831a8e51b1..7a317410cb 100644 --- a/src/app/t.const.ts +++ b/src/app/t.const.ts @@ -586,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', From ee26ec3fca00282606ac6ee237f256ab5c9a52d6 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Tue, 21 Oct 2025 15:31:30 +0200 Subject: [PATCH 17/36] feat(metric): improve on share function --- .../activity-heatmap.component.ts | 43 +++++++++++++++---- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts b/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts index 17af574660..0578c2d19c 100644 --- a/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts +++ b/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts @@ -44,6 +44,10 @@ export class ActivityHeatmapComponent { T: typeof T = T; weeks: WeekData[] = []; isSharing = signal(false); + private readonly _activeWorkContextTitle = toSignal( + this._workContextService.activeWorkContextTitle$, + { initialValue: '' }, + ); // Compute heatmap data // NOTE: Reacts to work context changes @@ -395,7 +399,8 @@ export class ActivityHeatmapComponent { try { // Render heatmap to canvas - const canvas = this._renderToCanvas(data); + const contextTitle = this._activeWorkContextTitle(); + const canvas = this._renderToCanvas(data, contextTitle); // Convert to blob const blob: Blob | null = await new Promise((resolve) => { @@ -437,22 +442,29 @@ export class ActivityHeatmapComponent { } } - private _renderToCanvas(data: { - weeks: WeekData[]; - monthLabels: string[]; - }): HTMLCanvasElement { + 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; - // eslint-disable-next-line no-mixed-operators - const canvasWidth = dayLabelWidth + numWeeks * (cellSize + gap) + padding * 2; - // eslint-disable-next-line no-mixed-operators - const canvasHeight = monthLabelHeight + 7 * (cellSize + gap) + padding * 2; + const weeksWidth = numWeeks * (cellSize + gap); + const canvasWidth = dayLabelWidth + weeksWidth + doublePadding; + const canvasHeight = baseCanvasHeight + taglineHeight; // Create canvas const canvas = document.createElement('canvas'); @@ -516,6 +528,19 @@ export class ActivityHeatmapComponent { }); }); + 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; } From 9299db4468d7a61e069dffbdc1225aa5bda84b43 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Tue, 21 Oct 2025 16:19:25 +0200 Subject: [PATCH 18/36] feat(metric): make all charts shareable --- .../metric/lazy-chart/lazy-chart.component.ts | 114 +++++++++++++++++- src/app/features/metric/metric.component.html | 6 + 2 files changed, 119 insertions(+), 1 deletion(-) 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 fc789c7733..fc9775f2ae 100644 --- a/src/app/features/metric/metric.component.html +++ b/src/app/features/metric/metric.component.html @@ -87,6 +87,7 @@ [legend]="true" [options]="lineChartOptions" height="400px" + [shareFileName]="'mood-productivity-over-time.png'" >
} @@ -101,6 +102,7 @@ [legend]="true" [options]="lineChartOptions" height="400px" + [shareFileName]="'focus-session-trends.png'" >
} @@ -118,6 +120,7 @@ [legend]="improvementCounts?.datasets[0].data.length < 12" [options]="pieChartOptions" height="300px" + [shareFileName]="'improvement-selection-count.png'" > @@ -132,6 +135,7 @@ [legend]="obstructionCounts?.datasets[0].data.length < 12" [options]="pieChartOptions" height="300px" + [shareFileName]="'obstruction-selection-count.png'" > @@ -153,6 +157,7 @@ [options]="lineChartOptions" [legend]="true" height="400px" + [shareFileName]="'simple-click-counters-over-time.png'" > @@ -167,6 +172,7 @@ [options]="lineChartOptions" [legend]="true" height="400px" + [shareFileName]="'simple-stopwatch-counters-over-time.png'" > From 763b201dad2b3f549fdf2213eb7ac2bb7eca0e24 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Tue, 21 Oct 2025 16:29:35 +0200 Subject: [PATCH 19/36] feat(schedule): refresh current time badge too --- .../schedule-day-panel/schedule-day-panel.component.ts | 4 ++-- src/app/features/schedule/schedule.service.ts | 4 ++-- src/app/features/schedule/schedule/schedule.component.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) 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(); From 832f7a95a0be25b7f349ee0a0cbd69bb7add8dd7 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Tue, 21 Oct 2025 16:37:29 +0200 Subject: [PATCH 20/36] feat(customizerMenu): polish new filter menu button and menu --- .../page-title/page-title.component.ts | 28 ++++++------ .../task-view-customizer-panel.component.scss | 45 +------------------ src/assets/i18n/en.json | 4 +- src/styles/components/_components.scss | 1 + src/styles/components/_customizer-menu.scss | 41 +++++++++++++++++ 5 files changed, 61 insertions(+), 58 deletions(-) create mode 100644 src/styles/components/_customizer-menu.scss diff --git a/src/app/core-ui/main-header/page-title/page-title.component.ts b/src/app/core-ui/main-header/page-title/page-title.component.ts index c132d17f1d..e1822bb0d9 100644 --- a/src/app/core-ui/main-header/page-title/page-title.component.ts +++ b/src/app/core-ui/main-header/page-title/page-title.component.ts @@ -117,20 +117,22 @@ import { KeyboardConfig } from '../../../features/config/keyboard-config.model'; } .project-settings-btn { - display: none; - @media (min-width: 600px) { - display: block; - transition: var(--transition-standard); - opacity: 0; - position: relative; - z-index: 1; - } + opacity: 1; - &:hover, - .page-title:hover + .page-title-actions &, - .page-title-actions:hover & { - opacity: 1; - } + /*display: none;*/ + /*@media (min-width: 600px) {*/ + /* display: block;*/ + /* transition: var(--transition-standard);*/ + /* opacity: 0;*/ + /* position: relative;*/ + /* z-index: 1;*/ + /*}*/ + + /*&:hover,*/ + /*.page-title:hover + .page-title-actions &,*/ + /*.page-title-actions:hover & {*/ + /* opacity: 1;*/ + /*}*/ } .task-filter-btn { 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 4bbc21c23b..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,46 +1,5 @@ -::ng-deep .customizer-menu { - .mat-mdc-menu-item { - position: relative; - display: flex !important; - align-items: center; - - &.active { - background-color: rgba(0, 0, 0, 0.04); - } - } - - .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; - } - - .menu-item-content { - display: flex; - align-items: center; - width: 100%; - flex: 1; - flex-grow: 1; - margin-right: 0 !important; - border: 1px solid red !important; - - > span:first-child { - flex: 1; - } - - .check-icon { - border: 1px solid deeppink !important; - margin-left: auto !important; - flex-shrink: 0; - margin-right: 0 !important; - } - } -} +// Menu styles are in global styles: src/styles/components/_customizer-menu.scss +// This is necessary because mat-menu renders in a global overlay container .menu-input-wrapper { padding: 8px 16px; diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 39989effac..a391ace338 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -1459,8 +1459,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", 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; + } +} From 24e1f3b6a4108f9d3a7f0803c3f4d0ba5606c648 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Tue, 21 Oct 2025 18:50:49 +0200 Subject: [PATCH 21/36] feat(schedule): remove filter button from right --- .../desktop-panel-buttons.component.ts | 32 ++----------------- .../main-header/main-header.component.html | 2 -- .../main-header/main-header.component.ts | 10 ------ 3 files changed, 2 insertions(+), 42 deletions(-) diff --git a/src/app/core-ui/main-header/desktop-panel-buttons/desktop-panel-buttons.component.ts b/src/app/core-ui/main-header/desktop-panel-buttons/desktop-panel-buttons.component.ts index 3e23e33e17..d746ecf787 100644 --- a/src/app/core-ui/main-header/desktop-panel-buttons/desktop-panel-buttons.component.ts +++ b/src/app/core-ui/main-header/desktop-panel-buttons/desktop-panel-buttons.component.ts @@ -6,7 +6,6 @@ import { TranslatePipe } from '@ngx-translate/core'; import { LayoutService } from '../../layout/layout.service'; import { T } from '../../../t.const'; import { KeyboardConfig } from '../../../features/config/keyboard-config.model'; -import { TaskViewCustomizerService } from '../../../features/task-view-customizer/task-view-customizer.service'; @Component({ selector: 'desktop-panel-buttons', @@ -24,22 +23,6 @@ import { TaskViewCustomizerService } from '../../../features/task-view-customize - - - - + @if (isWorkViewPage()) { + + + + }
} From b5747e7d0d949843f1eec44b5fb2a77fbbb2dfbe Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Wed, 22 Oct 2025 12:14:04 +0200 Subject: [PATCH 24/36] feat(metrics): add support for first day of week setting --- .../activity-heatmap.component.html | 10 +-- .../activity-heatmap.component.ts | 78 +++++++++++++++---- 2 files changed, 66 insertions(+), 22 deletions(-) diff --git a/src/app/features/metric/activity-heatmap/activity-heatmap.component.html b/src/app/features/metric/activity-heatmap/activity-heatmap.component.html index 909f10be41..b50bbcb97c 100644 --- a/src/app/features/metric/activity-heatmap/activity-heatmap.component.html +++ b/src/app/features/metric/activity-heatmap/activity-heatmap.component.html @@ -24,13 +24,9 @@
-
Sun
-
Mon
-
Tue
-
Wed
-
Thu
-
Fri
-
Sat
+ @for (label of dayLabels(); track $index) { +
{{ label }}
+ }
diff --git a/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts b/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts index 0578c2d19c..18f2a8a223 100644 --- a/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts +++ b/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts @@ -1,4 +1,10 @@ -import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + signal, +} from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { WorklogService } from '../../worklog/worklog.service'; import { WorkContextService } from '../../work-context/work-context.service'; @@ -14,6 +20,7 @@ import { MatIconButton } from '@angular/material/button'; import { MatTooltip } from '@angular/material/tooltip'; import { MatIcon } from '@angular/material/icon'; import { SnackService } from '../../../core/snack/snack.service'; +import { GlobalConfigService } from '../../config/global-config.service'; interface DayData { date: Date; @@ -40,6 +47,7 @@ export class ActivityHeatmapComponent { private readonly _taskService = inject(TaskService); private readonly _taskArchiveService = inject(TaskArchiveService); private readonly _snackService = inject(SnackService); + private readonly _globalConfigService = inject(GlobalConfigService); T: typeof T = T; weeks: WeekData[] = []; @@ -49,11 +57,20 @@ export class ActivityHeatmapComponent { { initialValue: '' }, ); - // Compute heatmap data - // NOTE: Reacts to work context changes - // - For TODAY tag: shows ALL tasks from all projects/tags (current + archived) - // - For other tags/projects: shows only tasks from that context - heatmapData = toSignal( + // Get first day of week setting (0 = Sunday, 1 = Monday, etc.) + private readonly _firstDayOfWeek = computed(() => { + return this._globalConfigService.misc()?.firstDayOfWeek ?? 1; + }); + + // Day labels adjusted for first day of week + readonly dayLabels = computed(() => { + const allDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + const firstDay = this._firstDayOfWeek(); + return [...allDays.slice(firstDay), ...allDays.slice(0, firstDay)]; + }); + + // Raw data signals + private readonly _rawHeatmapData = toSignal( this._workContextService.activeWorkContext$.pipe( switchMap((context) => { // Special case: TODAY tag shows ALL data from all tasks @@ -73,6 +90,24 @@ export class ActivityHeatmapComponent { { initialValue: null }, ); + // Compute heatmap data - reacts to both data changes AND firstDayOfWeek setting changes + heatmapData = computed(() => { + const rawData = this._rawHeatmapData(); + const firstDay = this._firstDayOfWeek(); + + if (!rawData || !rawData.dayMap) { + return null; + } + + // Rebuild the weeks grid with the current firstDayOfWeek setting + return this._buildWeeksGrid( + rawData.dayMap, + rawData.startDate, + rawData.endDate, + firstDay, + ); + }); + private async _loadAllTasks(): Promise { // Load both current tasks and archived tasks const [archive, currentTasks] = await Promise.all([ @@ -96,8 +131,9 @@ export class ActivityHeatmapComponent { } private _buildHeatmapDataFromTasks(tasks: Task[]): { - weeks: WeekData[]; - monthLabels: string[]; + dayMap: Map; + startDate: Date; + endDate: Date; } | null { const dayMap = new Map(); const now = new Date(); @@ -175,12 +211,17 @@ export class ActivityHeatmapComponent { } }); - return this._buildWeeksGrid(dayMap, oneYearAgo, now); + return { + dayMap, + startDate: oneYearAgo, + endDate: now, + }; } private _buildHeatmapData(worklog: any): { - weeks: WeekData[]; - monthLabels: string[]; + dayMap: Map; + startDate: Date; + endDate: Date; } | null { if (!worklog) { return null; @@ -267,7 +308,11 @@ export class ActivityHeatmapComponent { } }); - return this._buildWeeksGrid(dayMap, oneYearAgo, now); + return { + dayMap, + startDate: oneYearAgo, + endDate: now, + }; } private _getDateStr(date: Date): string { @@ -281,15 +326,18 @@ export class ActivityHeatmapComponent { dayMap: Map, startDate: Date, endDate: Date, + firstDayOfWeek: number = 0, ): { weeks: WeekData[]; monthLabels: string[] } { const weeks: WeekData[] = []; const monthLabels: string[] = []; let currentMonth = -1; - // Find the first Sunday before or on the start date + // Find the first day (based on firstDayOfWeek setting) before or on the start date const firstDay = new Date(startDate); const dayOfWeek = firstDay.getDay(); - firstDay.setDate(firstDay.getDate() - dayOfWeek); + // Calculate days to go back to reach the first day of the week + const daysToGoBack = (dayOfWeek - firstDayOfWeek + 7) % 7; + firstDay.setDate(firstDay.getDate() - daysToGoBack); // Build weeks const currentDate = new Date(firstDay); @@ -482,7 +530,7 @@ export class ActivityHeatmapComponent { ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; - const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + const dayNames = this.dayLabels(); dayNames.forEach((day, i) => { // eslint-disable-next-line no-mixed-operators const y = padding + monthLabelHeight + i * (cellSize + gap) + cellSize / 2; From f7d6e06041cd521ff50eb7c3c93de6b9bf195d0d Mon Sep 17 00:00:00 2001 From: Michael Huynh Date: Wed, 22 Oct 2025 18:26:05 +0800 Subject: [PATCH 25/36] fix(settings): add color input for cross-browser consistency (#3931) --- .../color-input/color-input.component.html | 11 +++++++ .../color-input/color-input.component.scss | 3 ++ .../color-input/color-input.component.ts | 32 +++++++++++++++++++ .../project/project-form-cfg.const.ts | 3 +- src/app/features/tag/tag-form-cfg.const.ts | 3 +- .../work-context/work-context.const.ts | 9 ++---- src/app/ui/formly-config.module.ts | 5 +++ src/app/util/adjust-to-live-formly-form.ts | 3 +- 8 files changed, 58 insertions(+), 11 deletions(-) create mode 100644 src/app/features/config/color-input/color-input.component.html create mode 100644 src/app/features/config/color-input/color-input.component.scss create mode 100644 src/app/features/config/color-input/color-input.component.ts diff --git a/src/app/features/config/color-input/color-input.component.html b/src/app/features/config/color-input/color-input.component.html new file mode 100644 index 0000000000..13740fec7d --- /dev/null +++ b/src/app/features/config/color-input/color-input.component.html @@ -0,0 +1,11 @@ + + {{ to.label }} + + diff --git a/src/app/features/config/color-input/color-input.component.scss b/src/app/features/config/color-input/color-input.component.scss new file mode 100644 index 0000000000..c7acb4bf6e --- /dev/null +++ b/src/app/features/config/color-input/color-input.component.scss @@ -0,0 +1,3 @@ +mat-form-field { + width: 100%; +} diff --git a/src/app/features/config/color-input/color-input.component.ts b/src/app/features/config/color-input/color-input.component.ts new file mode 100644 index 0000000000..054e113ead --- /dev/null +++ b/src/app/features/config/color-input/color-input.component.ts @@ -0,0 +1,32 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ReactiveFormsModule } from '@angular/forms'; +import { MatFormField, MatInput, MatLabel } from '@angular/material/input'; +import { FieldType, FormlyModule } from '@ngx-formly/core'; +import { IS_FIREFOX } from '../../../util/is-firefox'; +import { IS_MOBILE } from '../../../util/is-mobile'; + +/** + * This component deliberately avoids Formly's field type abstractions for the + * Material UI components because the form field wrapper implementation uses + * a focus monitoring service that is counterproductive for certain native + * color input controls. + */ +@Component({ + selector: 'color-input', + templateUrl: './color-input.component.html', + styleUrls: ['./color-input.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [FormlyModule, MatFormField, MatInput, MatLabel, ReactiveFormsModule], +}) +export class ColorInputComponent extends FieldType { + onFocus(event: FocusEvent): void { + if (!IS_FIREFOX || IS_MOBILE) return; + // Desktop Firefox oddly fires another focus event when the native color + // input closes rather than a blur event, so determine whether a value + // change has occurred and force a blur to have it persisted + const input = event.target as HTMLInputElement; + if (input.value !== this.formControl.value) { + input.blur(); + } + } +} 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/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/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 Date: Wed, 22 Oct 2025 13:38:26 +0200 Subject: [PATCH 26/36] feat: add option to share list as markdown --- .../work-context-menu.component.html | 8 + .../work-context-menu.component.ts | 26 +++ .../work-context-markdown.service.ts | 154 ++++++++++++++++++ src/app/t.const.ts | 2 + src/assets/i18n/en.json | 2 + 5 files changed, 192 insertions(+) create mode 100644 src/app/features/work-context/work-context-markdown.service.ts diff --git a/src/app/core-ui/work-context-menu/work-context-menu.component.html b/src/app/core-ui/work-context-menu/work-context-menu.component.html index 62191a3066..dfafda7176 100644 --- a/src/app/core-ui/work-context-menu/work-context-menu.component.html +++ b/src/app/core-ui/work-context-menu/work-context-menu.component.html @@ -24,6 +24,14 @@ } + + + `, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [MatButtonModule, MatIconModule, MatTooltipModule], +}) +export class ShareButtonComponent { + private _shareService = inject(ShareService); + + /** The payload to share when button is clicked */ + readonly payload = input.required(); + + /** Tooltip text (default: 'Share') */ + readonly tooltip = input('Share'); + + /** Whether button is disabled */ + readonly disabled = input(false); + + /** + * Trigger share action + */ + async share(): Promise { + await this._shareService.share(this.payload()); + } +} diff --git a/src/app/core/share/share-dialog/share-dialog.component.html b/src/app/core/share/share-dialog/share-dialog.component.html new file mode 100644 index 0000000000..6b6ab3df16 --- /dev/null +++ b/src/app/core/share/share-dialog/share-dialog.component.html @@ -0,0 +1,73 @@ +

Share

+ +
+ + + +
+ + Mastodon Instance + + language + +
+ + +
+ + +
+
+ +
+ +
diff --git a/src/app/core/share/share-dialog/share-dialog.component.scss b/src/app/core/share/share-dialog/share-dialog.component.scss new file mode 100644 index 0000000000..fb54cf66ae --- /dev/null +++ b/src/app/core/share/share-dialog/share-dialog.component.scss @@ -0,0 +1,67 @@ +:host { + display: block; +} + +.share-options { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 8px; + margin-bottom: 16px; +} + +.share-target-button { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 8px; + padding: 12px 16px; + text-align: left; + width: 100%; + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + } + + span { + flex: 1; + } +} + +.native-button { + grid-column: 1 / -1; +} + +.mastodon-instance { + margin-bottom: 16px; + + mat-form-field { + width: 100%; + } +} + +.clipboard-actions { + display: flex; + gap: 8px; + padding-top: 8px; + border-top: 1px solid rgba(0, 0, 0, 0.12); + + button { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } + } +} + +mat-dialog-content { + min-width: 400px; +} diff --git a/src/app/core/share/share-dialog/share-dialog.component.ts b/src/app/core/share/share-dialog/share-dialog.component.ts new file mode 100644 index 0000000000..1729d190f9 --- /dev/null +++ b/src/app/core/share/share-dialog/share-dialog.component.ts @@ -0,0 +1,107 @@ +import { Component, ChangeDetectionStrategy, inject } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { FormsModule } from '@angular/forms'; +import { CommonModule } from '@angular/common'; +import { ShareService } from '../share.service'; +import { ShareDialogOptions, ShareResult, ShareTarget } from '../share.model'; +import { ShareFormatter } from '../share-formatter'; + +interface ShareTargetButton { + target: ShareTarget; + label: string; + icon: string; + color?: string; +} + +@Component({ + selector: 'share-dialog', + templateUrl: './share-dialog.component.html', + styleUrls: ['./share-dialog.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + MatDialogModule, + MatButtonModule, + MatIconModule, + MatInputModule, + MatFormFieldModule, + FormsModule, + ], +}) +export class ShareDialogComponent { + private _dialogRef = inject(MatDialogRef); + private _shareService = inject(ShareService); + readonly data = inject(MAT_DIALOG_DATA); + + mastodonInstance = this.data.mastodonInstance || 'mastodon.social'; + + readonly shareTargets: ShareTargetButton[] = [ + { target: 'twitter', label: 'Twitter / X', icon: 'link' }, + { target: 'linkedin', label: 'LinkedIn', icon: 'link' }, + { target: 'reddit', label: 'Reddit', icon: 'link' }, + { target: 'facebook', label: 'Facebook', icon: 'link' }, + { target: 'whatsapp', label: 'WhatsApp', icon: 'chat' }, + { target: 'telegram', label: 'Telegram', icon: 'send' }, + { target: 'email', label: 'Email', icon: 'email' }, + { target: 'mastodon', label: 'Mastodon', icon: 'link' }, + ]; + + async shareToTarget(target: ShareTarget): Promise { + let payload = this.data.payload; + + // Optimize for Twitter + if (target === 'twitter') { + payload = ShareFormatter.optimizeForTwitter(payload); + } + + const result: ShareResult = await this._shareService['_shareToTarget']( + payload, + target, + ); + + if (result.success) { + this._dialogRef.close(result); + } + } + + async shareNative(): Promise { + const result: ShareResult = await this._shareService['_tryNativeShare']( + this.data.payload, + ); + + if (result.success) { + this._dialogRef.close(result); + } + } + + async copyLink(): Promise { + const result: ShareResult = await this._shareService['_copyToClipboard']( + this.data.payload.url || '', + 'Link', + ); + + if (result.success) { + this._dialogRef.close(result); + } + } + + async copyText(): Promise { + const text = this._shareService['_formatTextForClipboard'](this.data.payload); + const result: ShareResult = await this._shareService['_copyToClipboard']( + text, + 'Text', + ); + + if (result.success) { + this._dialogRef.close(result); + } + } + + close(): void { + this._dialogRef.close(); + } +} diff --git a/src/app/core/share/share-formatter.spec.ts b/src/app/core/share/share-formatter.spec.ts new file mode 100644 index 0000000000..91a07c2a8a --- /dev/null +++ b/src/app/core/share/share-formatter.spec.ts @@ -0,0 +1,145 @@ +import { ShareFormatter, WorkSummaryData } from './share-formatter'; + +describe('ShareFormatter', () => { + describe('formatWorkSummary', () => { + it('should format basic work summary', () => { + const data: WorkSummaryData = { + totalTimeSpent: 3600000, // 1 hour + tasksCompleted: 5, + }; + + const payload = ShareFormatter.formatWorkSummary(data); + + expect(payload.text).toContain('📊 My productivity summary:'); + expect(payload.text).toContain('1h'); + expect(payload.text).toContain('5 tasks completed'); + expect(payload.url).toBeDefined(); + }); + + it('should include date range when provided', () => { + const data: WorkSummaryData = { + totalTimeSpent: 3600000, + tasksCompleted: 5, + dateRange: { + start: '2024-01-01', + end: '2024-01-07', + }, + }; + + const payload = ShareFormatter.formatWorkSummary(data); + + expect(payload.text).toContain('2024-01-01'); + expect(payload.text).toContain('2024-01-07'); + }); + + it('should include top tasks when provided', () => { + const data: WorkSummaryData = { + totalTimeSpent: 3600000, + tasksCompleted: 5, + topTasks: [ + { title: 'Task 1', timeSpent: 1800000 }, + { title: 'Task 2', timeSpent: 1200000 }, + ], + }; + + const payload = ShareFormatter.formatWorkSummary(data); + + expect(payload.text).toContain('Task 1'); + expect(payload.text).toContain('Task 2'); + }); + + it('should include UTM parameters when requested', () => { + const data: WorkSummaryData = { + totalTimeSpent: 3600000, + tasksCompleted: 5, + }; + + const payload = ShareFormatter.formatWorkSummary(data, { + includeUTM: true, + }); + + expect(payload.url).toContain('utm_source'); + expect(payload.url).toContain('utm_medium'); + expect(payload.url).toContain('utm_campaign'); + }); + + it('should include hashtags when requested', () => { + const data: WorkSummaryData = { + totalTimeSpent: 3600000, + tasksCompleted: 5, + }; + + const payload = ShareFormatter.formatWorkSummary(data, { + includeHashtags: true, + }); + + expect(payload.text).toContain('#productivity'); + expect(payload.text).toContain('#SuperProductivity'); + }); + + it('should set project name in title when provided', () => { + const data: WorkSummaryData = { + totalTimeSpent: 3600000, + tasksCompleted: 5, + projectName: 'My Project', + }; + + const payload = ShareFormatter.formatWorkSummary(data); + + expect(payload.title).toBe('Work Summary - My Project'); + }); + }); + + describe('formatPromotion', () => { + it('should format default promotional text', () => { + const payload = ShareFormatter.formatPromotion(); + + expect(payload.text).toContain('Super Productivity'); + expect(payload.title).toBe('Super Productivity'); + expect(payload.url).toBeDefined(); + }); + + it('should use custom text when provided', () => { + const customText = 'Check out this awesome app!'; + const payload = ShareFormatter.formatPromotion(customText); + + expect(payload.text).toBe(customText); + }); + + it('should include UTM parameters when requested', () => { + const payload = ShareFormatter.formatPromotion(undefined, { + includeUTM: true, + utmSource: 'custom', + }); + + expect(payload.url).toContain('utm_source=custom'); + }); + }); + + describe('optimizeForTwitter', () => { + it('should truncate long text for Twitter', () => { + const longText = 'a'.repeat(300); + const payload = { + text: longText, + url: 'https://example.com', + }; + + const optimized = ShareFormatter.optimizeForTwitter(payload); + + expect(optimized.text!.length).toBeLessThanOrEqual(280 - 23 - 1); // 280 - URL length - space + expect(optimized.text).toEndWith('...'); + }); + + it('should not truncate short text', () => { + const shortText = 'Short text'; + const payload = { + text: shortText, + url: 'https://example.com', + }; + + const optimized = ShareFormatter.optimizeForTwitter(payload); + + expect(optimized.text).toBe(shortText); + }); + }); +}); diff --git a/src/app/core/share/share-formatter.ts b/src/app/core/share/share-formatter.ts new file mode 100644 index 0000000000..441b72ee55 --- /dev/null +++ b/src/app/core/share/share-formatter.ts @@ -0,0 +1,178 @@ +import { msToClockString } from '../../ui/duration/ms-to-clock-string.pipe'; +import { msToString } from '../../ui/duration/ms-to-string.pipe'; +import { SharePayload } from './share.model'; + +/** + * Data for creating a work summary share + */ +export interface WorkSummaryData { + /** Total time spent in milliseconds */ + totalTimeSpent: number; + /** Number of tasks completed */ + tasksCompleted: number; + /** Optional: Date range for the summary */ + dateRange?: { + start: string; + end: string; + }; + /** Optional: Top tasks by time spent */ + topTasks?: Array<{ title: string; timeSpent: number }>; + /** Optional: Project name */ + projectName?: string; +} + +/** + * Options for formatting share content + */ +export interface ShareFormatterOptions { + /** Include UTM parameters in the URL */ + includeUTM?: boolean; + /** UTM source override (default: 'share') */ + utmSource?: string; + /** UTM medium override (default: 'social') */ + utmMedium?: string; + /** Base URL for the app (default: https://super-productivity.com) */ + baseUrl?: string; + /** Maximum length for text (for Twitter, etc.) */ + maxLength?: number; + /** Include hashtags */ + includeHashtags?: boolean; +} + +const DEFAULT_BASE_URL = 'https://super-productivity.com'; +const DEFAULT_UTM_SOURCE = 'share'; +const DEFAULT_UTM_MEDIUM = 'social'; +const TWITTER_MAX_LENGTH = 280; + +/** + * Formats work summary data into a shareable payload + */ +export class ShareFormatter { + /** + * Create a share payload from work summary data + */ + static formatWorkSummary( + data: WorkSummaryData, + options: ShareFormatterOptions = {}, + ): SharePayload { + const url = this._buildUrl(options); + const text = this._buildWorkSummaryText(data, options); + + return { + text, + url, + title: this._buildTitle(data), + }; + } + + /** + * Create a generic promotional share payload + */ + static formatPromotion( + customText?: string, + options: ShareFormatterOptions = {}, + ): SharePayload { + const url = this._buildUrl(options); + const text = + customText || + 'Check out Super Productivity - an advanced todo list and time tracking app with focus on flexibility and privacy!'; + + return { + text, + url, + title: 'Super Productivity', + }; + } + + /** + * Optimize payload for Twitter (character limit) + */ + static optimizeForTwitter(payload: SharePayload): SharePayload { + const { text } = payload; + // Twitter counts URLs as 23 characters + const urlLength = 23; + const maxTextLength = TWITTER_MAX_LENGTH - urlLength - 1; // -1 for space + + let optimizedText = text || ''; + if (optimizedText.length > maxTextLength) { + optimizedText = optimizedText.substring(0, maxTextLength - 3) + '...'; + } + + return { + ...payload, + text: optimizedText, + }; + } + + private static _buildUrl(options: ShareFormatterOptions): string { + const baseUrl = options.baseUrl || DEFAULT_BASE_URL; + + if (!options.includeUTM) { + return baseUrl; + } + + const utmSource = options.utmSource || DEFAULT_UTM_SOURCE; + const utmMedium = options.utmMedium || DEFAULT_UTM_MEDIUM; + + const params = new URLSearchParams({ + utm_source: utmSource, + utm_medium: utmMedium, + utm_campaign: 'app_share', + }); + + return `${baseUrl}?${params.toString()}`; + } + + private static _buildTitle(data: WorkSummaryData): string { + if (data.projectName) { + return `Work Summary - ${data.projectName}`; + } + return 'My Work Summary'; + } + + private static _buildWorkSummaryText( + data: WorkSummaryData, + options: ShareFormatterOptions, + ): string { + const parts: string[] = []; + + // Header + if (data.dateRange) { + parts.push( + `📊 My productivity from ${data.dateRange.start} to ${data.dateRange.end}:`, + ); + } else { + parts.push('📊 My productivity summary:'); + } + + // Main stats + const timeStr = msToString(data.totalTimeSpent); + const clockStr = msToClockString(data.totalTimeSpent); + parts.push(`⏱️ ${timeStr} (${clockStr}) of focused work`); + parts.push(`✅ ${data.tasksCompleted} tasks completed`); + + // Top tasks (if provided and not too long) + if (data.topTasks && data.topTasks.length > 0 && !options.maxLength) { + parts.push('\nTop tasks:'); + data.topTasks.slice(0, 3).forEach((task) => { + const taskTime = msToString(task.timeSpent); + parts.push(`• ${task.title} (${taskTime})`); + }); + } + + + // Hashtags + if (options.includeHashtags) { + parts.push('\n#productivity #timetracking #SuperProductivity'); + } + + let text = parts.join('\n'); + + // Apply max length if specified + if (options.maxLength && text.length > options.maxLength) { + text = text.substring(0, options.maxLength - 3) + '...'; + } + + return text; + } +} diff --git a/src/app/core/share/share.model.ts b/src/app/core/share/share.model.ts new file mode 100644 index 0000000000..562b9aaf64 --- /dev/null +++ b/src/app/core/share/share.model.ts @@ -0,0 +1,69 @@ +/** + * Payload for sharing content + */ +export interface SharePayload { + /** The main text content to share */ + text?: string; + /** URL to share (e.g., app landing page with UTM parameters) */ + url?: string; + /** Optional title (used by Reddit, Email) */ + title?: string; + /** Optional file paths for native share (images, files) */ + files?: string[]; +} + +/** + * Available share targets + */ +export type ShareTarget = + | 'twitter' + | 'linkedin' + | 'reddit' + | 'facebook' + | 'whatsapp' + | 'telegram' + | 'email' + | 'mastodon' + | 'clipboard-link' + | 'clipboard-text' + | 'native'; + +/** + * Result of a share operation + */ +export interface ShareResult { + /** Whether the share was successful */ + success: boolean; + /** Error message if failed */ + error?: string; + /** The target that was used */ + target?: ShareTarget; + /** Whether native share was attempted */ + usedNative?: boolean; +} + +/** + * Configuration for share targets + */ +export interface ShareTargetConfig { + /** Display label for the target */ + label: string; + /** Material icon name */ + icon: string; + /** Whether this target is available on the current platform */ + available: boolean; + /** Optional color for the target button */ + color?: string; +} + +/** + * Options for the share dialog + */ +export interface ShareDialogOptions { + /** The payload to share */ + payload: SharePayload; + /** Whether to show the native share option */ + showNative?: boolean; + /** Pre-selected Mastodon instance */ + mastodonInstance?: string; +} diff --git a/src/app/core/share/share.service.spec.ts b/src/app/core/share/share.service.spec.ts new file mode 100644 index 0000000000..62ea0c8838 --- /dev/null +++ b/src/app/core/share/share.service.spec.ts @@ -0,0 +1,175 @@ +import { TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { ShareService } from './share.service'; +import { SnackService } from '../snack/snack.service'; +import { SharePayload } from './share.model'; + +describe('ShareService', () => { + let service: ShareService; + let mockSnackService: jasmine.SpyObj; + let mockMatDialog: jasmine.SpyObj; + + beforeEach(() => { + mockSnackService = jasmine.createSpyObj('SnackService', ['open']); + mockMatDialog = jasmine.createSpyObj('MatDialog', ['open']); + + TestBed.configureTestingModule({ + providers: [ + ShareService, + { provide: SnackService, useValue: mockSnackService }, + { provide: MatDialog, useValue: mockMatDialog }, + ], + }); + + service = TestBed.inject(ShareService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('getShareTargets', () => { + it('should return all share target configurations', () => { + const targets = service.getShareTargets(); + + expect(targets.length).toBeGreaterThan(0); + expect(targets[0]).toHaveProperty('label'); + expect(targets[0]).toHaveProperty('icon'); + expect(targets[0]).toHaveProperty('available'); + }); + + it('should include Twitter target', () => { + const targets = service.getShareTargets(); + const twitter = targets.find((t) => t.label === 'Twitter'); + + expect(twitter).toBeDefined(); + expect(twitter!.available).toBe(true); + }); + + it('should include Email target', () => { + const targets = service.getShareTargets(); + const email = targets.find((t) => t.label === 'Email'); + + expect(email).toBeDefined(); + expect(email!.icon).toBe('email'); + }); + }); + + describe('share', () => { + it('should return error when no content provided', async () => { + const payload: SharePayload = {}; + + const result = await service.share(payload); + + expect(result.success).toBe(false); + expect(result.error).toBe('No content to share'); + }); + + it('should accept text-only payload', async () => { + const payload: SharePayload = { text: 'Test content' }; + + // This will attempt native share or show dialog + const result = await service.share(payload); + + // Result depends on platform capabilities + expect(result).toBeDefined(); + }); + + it('should accept URL-only payload', async () => { + const payload: SharePayload = { url: 'https://example.com' }; + + const result = await service.share(payload); + + expect(result).toBeDefined(); + }); + }); + + describe('_buildShareUrl', () => { + it('should build Twitter share URL', () => { + const payload: SharePayload = { + text: 'Test tweet', + url: 'https://example.com', + }; + + const url = service['_buildShareUrl'](payload, 'twitter'); + + expect(url).toContain('twitter.com/intent/tweet'); + expect(url).toContain(encodeURIComponent('Test tweet')); + }); + + it('should build LinkedIn share URL', () => { + const payload: SharePayload = { url: 'https://example.com' }; + + const url = service['_buildShareUrl'](payload, 'linkedin'); + + expect(url).toContain('linkedin.com'); + expect(url).toContain(encodeURIComponent('https://example.com')); + }); + + it('should build Email share URL', () => { + const payload: SharePayload = { + title: 'Check this out', + text: 'Great content', + url: 'https://example.com', + }; + + const url = service['_buildShareUrl'](payload, 'email'); + + expect(url).toContain('mailto:'); + expect(url).toContain('subject='); + expect(url).toContain('body='); + }); + + it('should build WhatsApp share URL', () => { + const payload: SharePayload = { + text: 'Check this out', + url: 'https://example.com', + }; + + const url = service['_buildShareUrl'](payload, 'whatsapp'); + + expect(url).toContain('wa.me'); + expect(url).toContain('text='); + }); + + it('should throw error for unknown target', () => { + const payload: SharePayload = { text: 'Test' }; + + expect(() => { + service['_buildShareUrl'](payload, 'unknown' as any); + }).toThrow(); + }); + }); + + describe('_formatTextForClipboard', () => { + it('should format payload with title, text, and URL', () => { + const payload: SharePayload = { + title: 'My Title', + text: 'My text content', + url: 'https://example.com', + }; + + const formatted = service['_formatTextForClipboard'](payload); + + expect(formatted).toContain('My Title'); + expect(formatted).toContain('My text content'); + expect(formatted).toContain('https://example.com'); + }); + + it('should handle text-only payload', () => { + const payload: SharePayload = { text: 'Just text' }; + + const formatted = service['_formatTextForClipboard'](payload); + + expect(formatted).toBe('Just text'); + }); + + it('should handle URL-only payload', () => { + const payload: SharePayload = { url: 'https://example.com' }; + + const formatted = service['_formatTextForClipboard'](payload); + + expect(formatted).toBe('https://example.com'); + }); + }); +}); diff --git a/src/app/core/share/share.service.ts b/src/app/core/share/share.service.ts index daf065f099..379747949e 100644 --- a/src/app/core/share/share.service.ts +++ b/src/app/core/share/share.service.ts @@ -1,6 +1,11 @@ -import { Injectable } from '@angular/core'; +import { Injectable, inject } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; import { Capacitor } from '@capacitor/core'; -import { Share } from '@capacitor/share'; +import { Share as CapacitorShare } from '@capacitor/share'; +import { IS_ELECTRON } from '../../app.constants'; +import { IS_ANDROID_WEB_VIEW } from '../../util/is-android-web-view'; +import { SnackService } from '../snack/snack.service'; +import { SharePayload, ShareResult, ShareTarget, ShareTargetConfig } from './share.model'; export type ShareOutcome = 'shared' | 'cancelled' | 'unavailable' | 'failed'; export type ShareSupport = 'native' | 'web' | 'none'; @@ -10,56 +15,43 @@ interface ShareParams { text: string; } +/** + * Share service for multi-platform content sharing. + * Supports Electron (Desktop), Capacitor (Android), and Web (PWA/Browser). + * Provides a legacy shareText API that only triggers native/web share without UI fallbacks. + */ @Injectable({ providedIn: 'root', }) export class ShareService { private _shareSupportPromise?: Promise; + private _snackService = inject(SnackService); + private _matDialog = inject(MatDialog); + async shareText({ title, text }: ShareParams): Promise { - if (!text) { + if (!text || typeof window === 'undefined') { return 'failed'; } - if (typeof window === 'undefined') { + const result = await this._tryNativeShare({ + title: title ?? undefined, + text, + }); + + if (result.success) { + return 'shared'; + } + + if (result.error === 'Share cancelled') { + return 'cancelled'; + } + + if (result.error === 'Native share not available') { return 'unavailable'; } - const support = await this.getShareSupport(); - - if (support === 'native') { - try { - await Share.share({ - title: title ?? undefined, - text, - }); - return 'shared'; - } catch (err) { - if (this._isCancelled(err)) { - return 'cancelled'; - } - console.error('Native share failed:', err); - return 'failed'; - } - } - - if (support === 'web' && typeof navigator.share === 'function') { - try { - await navigator.share({ - title: title ?? undefined, - text, - }); - return 'shared'; - } catch (err) { - if (this._isCancelled(err)) { - return 'cancelled'; - } - console.error('Web share failed:', err); - return 'failed'; - } - } - - return 'unavailable'; + return 'failed'; } async getShareSupport(): Promise { @@ -74,32 +66,397 @@ export class ShareService { return this._shareSupportPromise; } - private _isCancelled(err: unknown): boolean { - if (!err) { - return false; + /** + * Main share method - automatically detects platform and uses best method. + */ + async share(payload: SharePayload, target?: ShareTarget): Promise { + if (!payload.text && !payload.url) { + return { + success: false, + error: 'No content to share', + }; } - const message = (err as Error)?.message ?? ''; - const name = (err as Error)?.name ?? ''; - return ( - name === 'AbortError' || - name === 'NotAllowedError' || - message.toLowerCase().includes('cancel') || - message.toLowerCase().includes('abort') - ); + if (target) { + return this._shareToTarget(payload, target); + } + + const nativeResult = await this._tryNativeShare(payload); + if (nativeResult.success) { + return nativeResult; + } + + return this._showShareDialog(payload); + } + + /** + * Share to a specific target. + */ + private async _shareToTarget( + payload: SharePayload, + target: ShareTarget, + ): Promise { + try { + switch (target) { + case 'native': + return this._tryNativeShare(payload); + case 'clipboard-link': + return this._copyToClipboard(payload.url || '', 'Link'); + case 'clipboard-text': + return this._copyToClipboard(this._formatTextForClipboard(payload), 'Text'); + default: + return this._openShareUrl(payload, target); + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + target, + }; + } + } + + /** + * Try to use native share (Electron, Android, Web Share API). + */ + private async _tryNativeShare(payload: SharePayload): Promise { + if (IS_ELECTRON && typeof window.ea?.shareNative === 'function') { + try { + const result = await window.ea.shareNative(payload); + if (result.success) { + this._snackService.open('Shared successfully!'); + return { + success: true, + usedNative: true, + target: 'native', + }; + } + } catch (error) { + console.warn('Electron native share failed:', error); + } + } + + if (await this._isCapacitorShareAvailable()) { + try { + await CapacitorShare.share({ + title: payload.title, + text: payload.text, + url: payload.url, + files: payload.files, + dialogTitle: 'Share via', + }); + this._snackService.open('Shared successfully!'); + return { + success: true, + usedNative: true, + target: 'native', + }; + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + return { + success: false, + error: 'Share cancelled', + }; + } + console.warn('Capacitor share failed:', error); + } + } + + if (IS_ANDROID_WEB_VIEW) { + try { + const win = window as any; + if (win.Capacitor?.Plugins?.Share) { + await win.Capacitor.Plugins.Share.share({ + title: payload.title, + text: payload.text, + url: payload.url, + dialogTitle: 'Share via', + }); + this._snackService.open('Shared successfully!'); + return { + success: true, + usedNative: true, + target: 'native', + }; + } + } catch (error) { + console.warn('Capacitor share via window failed:', error); + } + } + + if (typeof navigator !== 'undefined' && 'share' in navigator) { + try { + await navigator.share({ + title: payload.title, + text: payload.text, + url: payload.url, + }); + this._snackService.open('Shared successfully!'); + return { + success: true, + usedNative: true, + target: 'native', + }; + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + return { success: false, error: 'Share cancelled' }; + } + console.warn('Web Share API failed:', error); + } + } + + return { + success: false, + error: 'Native share not available', + }; + } + + /** + * Open share dialog with all available targets. + */ + private async _showShareDialog(payload: SharePayload): Promise { + try { + const { DialogShareComponent } = await import( + './dialog-share/dialog-share.component' + ); + + const dialogRef = this._matDialog.open(DialogShareComponent, { + width: '500px', + data: { + payload, + showNative: await this._isSystemShareAvailable(), + }, + }); + + const result = await dialogRef.afterClosed().toPromise(); + + if (result) { + return result; + } + + return { + success: false, + error: 'Share cancelled', + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to open share dialog', + }; + } + } + + /** + * Open a share URL in the default browser. + */ + private async _openShareUrl( + payload: SharePayload, + target: ShareTarget, + ): Promise { + const url = this._buildShareUrl(payload, target); + + if (IS_ELECTRON && window.ea?.openExternalUrl) { + window.ea.openExternalUrl(url); + } else { + window.open(url, '_blank', 'noopener,noreferrer'); + } + + this._snackService.open('Opening share window...'); + + return { + success: true, + target, + }; + } + + /** + * Build share URL for a specific target. + */ + private _buildShareUrl(payload: SharePayload, target: ShareTarget): string { + const enc = encodeURIComponent; + const textAndUrl = [payload.text, payload.url].filter(Boolean).join(' '); + + const urlBuilders: Record string> = { + twitter: () => `https://twitter.com/intent/tweet?text=${enc(textAndUrl)}`, + linkedin: () => + `https://www.linkedin.com/sharing/share-offsite/?url=${enc(payload.url || '')}`, + reddit: () => + `https://www.reddit.com/submit?url=${enc(payload.url || '')}&title=${enc(payload.title || payload.text || '')}`, + facebook: () => + `https://www.facebook.com/sharer/sharer.php?u=${enc(payload.url || '')}`, + whatsapp: () => `https://wa.me/?text=${enc(textAndUrl)}`, + telegram: () => + `https://t.me/share/url?url=${enc(payload.url || '')}&text=${enc(payload.text || '')}`, + email: () => + `mailto:?subject=${enc(payload.title || 'Check this out')}&body=${enc(textAndUrl)}`, + mastodon: () => { + const instance = 'mastodon.social'; + return `https://${instance}/share?text=${enc(textAndUrl)}`; + }, + }; + + const builder = urlBuilders[target]; + if (!builder) { + throw new Error(`Unknown share target: ${target}`); + } + + return builder(); + } + + /** + * Copy text to clipboard. + */ + private async _copyToClipboard(text: string, label: string): Promise { + try { + await navigator.clipboard.writeText(text); + this._snackService.open(`${label} copied to clipboard!`); + return { + success: true, + target: label === 'Link' ? 'clipboard-link' : 'clipboard-text', + }; + } catch (error) { + try { + const textArea = document.createElement('textarea'); + textArea.value = text; + textArea.style.position = 'fixed'; + textArea.style.left = '-999999px'; + document.body.appendChild(textArea); + textArea.select(); + document.execCommand('copy'); + document.body.removeChild(textArea); + + this._snackService.open(`${label} copied to clipboard!`); + return { + success: true, + target: label === 'Link' ? 'clipboard-link' : 'clipboard-text', + }; + } catch (fallbackError) { + return { + success: false, + error: 'Failed to copy to clipboard', + }; + } + } + } + + /** + * Format payload as plain text for clipboard. + */ + private _formatTextForClipboard(payload: SharePayload): string { + const parts: string[] = []; + + if (payload.title) { + parts.push(payload.title); + parts.push(''); + } + + if (payload.text) { + parts.push(payload.text); + } + + if (payload.url) { + if (payload.text) { + parts.push(''); + } + parts.push(payload.url); + } + + return parts.join('\n'); + } + + /** + * Check if native/system share is available on current platform. + */ + private async _isSystemShareAvailable(): Promise { + if (IS_ELECTRON && typeof window.ea?.shareNative === 'function') { + return true; + } + + if (await this._isCapacitorShareAvailable()) { + return true; + } + + if (IS_ANDROID_WEB_VIEW) { + const win = window as any; + return !!win.Capacitor?.Plugins?.Share; + } + + if (typeof navigator !== 'undefined' && 'share' in navigator) { + return true; + } + + return false; + } + + /** + * Get available share targets with their configurations. + */ + getShareTargets(): ShareTargetConfig[] { + return [ + { + label: 'Twitter', + icon: 'link', + available: true, + color: '#1DA1F2', + }, + { + label: 'LinkedIn', + icon: 'link', + available: true, + color: '#0A66C2', + }, + { + label: 'Reddit', + icon: 'link', + available: true, + color: '#FF4500', + }, + { + label: 'Facebook', + icon: 'link', + available: true, + color: '#1877F2', + }, + { + label: 'WhatsApp', + icon: 'chat', + available: true, + color: '#25D366', + }, + { + label: 'Telegram', + icon: 'send', + available: true, + color: '#0088CC', + }, + { + label: 'Email', + icon: 'email', + available: true, + }, + { + label: 'Mastodon', + icon: 'link', + available: true, + color: '#6364FF', + }, + ]; } private async _detectShareSupport(): Promise { - if (Capacitor.isNativePlatform()) { - try { - const canShare = await Share.canShare(); - if (canShare.value) { - return 'native'; - } - } catch (err) { - console.warn('Share.canShare failed:', err); + if (IS_ELECTRON && typeof window.ea?.shareNative === 'function') { + return 'native'; + } + + if (await this._isCapacitorShareAvailable()) { + return 'native'; + } + + if (IS_ANDROID_WEB_VIEW) { + const win = window as any; + if (win.Capacitor?.Plugins?.Share) { + return 'native'; } - return 'none'; } if (typeof navigator !== 'undefined' && typeof navigator.share === 'function') { @@ -108,4 +465,18 @@ export class ShareService { return 'none'; } + + private async _isCapacitorShareAvailable(): Promise { + if (!Capacitor.isNativePlatform()) { + return false; + } + + try { + const canShare = await CapacitorShare.canShare(); + return !!canShare?.value; + } catch (error) { + console.warn('Capacitor Share.canShare failed:', error); + return false; + } + } } From fb418ba755de8dc13851071056948a75abb02837 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Wed, 22 Oct 2025 13:42:13 +0200 Subject: [PATCH 29/36] feat(share): implement for basic project metrics --- src/app/core/share/share-formatter.ts | 75 +++++++++++++++---- src/app/features/metric/metric.component.html | 15 ++-- src/app/features/metric/metric.component.scss | 12 +++ src/app/features/metric/metric.component.ts | 44 ++++++++++- 4 files changed, 126 insertions(+), 20 deletions(-) diff --git a/src/app/core/share/share-formatter.ts b/src/app/core/share/share-formatter.ts index 441b72ee55..2b87f00cbe 100644 --- a/src/app/core/share/share-formatter.ts +++ b/src/app/core/share/share-formatter.ts @@ -19,6 +19,18 @@ export interface WorkSummaryData { topTasks?: Array<{ title: string; timeSpent: number }>; /** Optional: Project name */ projectName?: string; + /** Optional: Detailed metrics table data */ + detailedMetrics?: { + timeEstimate?: number; + totalTasks?: number; + daysWorked?: number; + avgTasksPerDay?: number; + avgBreakNr?: number; + avgTimeSpentOnDay?: number; + avgTimeSpentOnTask?: number; + avgTimeSpentOnTaskIncludingSubTasks?: number; + avgBreakTime?: number; + }; } /** @@ -136,24 +148,61 @@ export class ShareFormatter { ): string { const parts: string[] = []; - // Header - if (data.dateRange) { - parts.push( - `📊 My productivity from ${data.dateRange.start} to ${data.dateRange.end}:`, - ); - } else { - parts.push('📊 My productivity summary:'); + // Header with project name + let header = '📊 '; + if (data.projectName) { + header += `${data.projectName} - `; } + if (data.dateRange) { + header += `${data.dateRange.start} to ${data.dateRange.end}`; + } else { + header += 'My productivity summary'; + } + parts.push(header); + parts.push(''); - // Main stats - const timeStr = msToString(data.totalTimeSpent); - const clockStr = msToClockString(data.totalTimeSpent); - parts.push(`⏱️ ${timeStr} (${clockStr}) of focused work`); - parts.push(`✅ ${data.tasksCompleted} tasks completed`); + // Detailed metrics table + if (data.detailedMetrics) { + const dm = data.detailedMetrics; + + parts.push(`⏱️ Time Spent: ${msToString(data.totalTimeSpent)}`); + if (dm.timeEstimate) { + parts.push(`📋 Time Estimated: ${msToString(dm.timeEstimate)}`); + } + parts.push( + `✅ Tasks Done: ${data.tasksCompleted}${dm.totalTasks ? ` / ${dm.totalTasks}` : ''}`, + ); + + if (dm.daysWorked) { + parts.push(`📅 Days Worked: ${dm.daysWorked}`); + } + if (dm.avgTasksPerDay) { + parts.push(`📊 Avg Tasks/Day: ${dm.avgTasksPerDay.toFixed(1)}`); + } + if (dm.avgBreakNr !== undefined) { + parts.push(`☕ Avg Breaks/Day: ${dm.avgBreakNr.toFixed(1)}`); + } + if (dm.avgTimeSpentOnDay) { + parts.push(`⏳ Avg Time/Day: ${msToString(dm.avgTimeSpentOnDay)}`); + } + if (dm.avgTimeSpentOnTask) { + parts.push(`⚡ Avg Time/Task: ${msToString(dm.avgTimeSpentOnTask)}`); + } + if (dm.avgBreakTime) { + parts.push(`🧘 Avg Break Time: ${msToString(dm.avgBreakTime)}`); + } + } else { + // Simple summary if no detailed metrics + const timeStr = msToString(data.totalTimeSpent); + const clockStr = msToClockString(data.totalTimeSpent); + parts.push(`⏱️ ${timeStr} (${clockStr}) of focused work`); + parts.push(`✅ ${data.tasksCompleted} tasks completed`); + } // Top tasks (if provided and not too long) if (data.topTasks && data.topTasks.length > 0 && !options.maxLength) { - parts.push('\nTop tasks:'); + parts.push(''); + parts.push('Top tasks:'); data.topTasks.slice(0, 3).forEach((task) => { const taskTime = msToString(task.timeSpent); parts.push(`• ${task.title} (${taskTime})`); diff --git a/src/app/features/metric/metric.component.html b/src/app/features/metric/metric.component.html index fc9775f2ae..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 }} -

+
+

+ {{ T.PM.TITLE | translate }} +

+ +

{{ sm.start }} – {{ sm.end }} 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 b53c804d90..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'; @@ -11,6 +11,9 @@ 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', @@ -24,6 +27,7 @@ import { ActivityHeatmapComponent } from './activity-heatmap/activity-heatmap.co MsToStringPipe, TranslatePipe, ActivityHeatmapComponent, + ShareButtonComponent, ], }) export class MetricComponent { @@ -33,6 +37,8 @@ export class MetricComponent { T: typeof T = T; + activeWorkContext = toSignal(this.workContextService.activeWorkContext$); + productivityHappiness = toSignal( this.metricService.getProductivityHappinessChartData$(), ); @@ -88,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, + }, + ); + }); } From 28510df7fbe5adc9bef760ed1c3f975a6425ac4e Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Wed, 22 Oct 2025 14:30:00 +0200 Subject: [PATCH 30/36] feat(share): improve share --- src/app/core/share/README.md | 10 +++---- .../dialog-share.component.html} | 7 ----- .../dialog-share.component.scss} | 0 .../dialog-share.component.ts} | 21 ++++---------- .../share-button/share-button.component.ts | 18 ++++++++++++ src/app/core/share/share-formatter.ts | 2 +- src/app/core/share/share.service.ts | 29 ++++++++++++------- 7 files changed, 47 insertions(+), 40 deletions(-) rename src/app/core/share/{share-dialog/share-dialog.component.html => dialog-share/dialog-share.component.html} (91%) rename src/app/core/share/{share-dialog/share-dialog.component.scss => dialog-share/dialog-share.component.scss} (100%) rename src/app/core/share/{share-dialog/share-dialog.component.ts => dialog-share/dialog-share.component.ts} (85%) diff --git a/src/app/core/share/README.md b/src/app/core/share/README.md index e43833aad4..eb341460ec 100644 --- a/src/app/core/share/README.md +++ b/src/app/core/share/README.md @@ -156,10 +156,10 @@ src/app/core/share/ ├── share-formatter.spec.ts # Formatter tests ├── share.service.ts # Main share service ├── share.service.spec.ts # Service tests -├── share-dialog/ # Material dialog component -│ ├── share-dialog.component.ts -│ ├── share-dialog.component.html -│ └── share-dialog.component.scss +├── dialog-share/ # Material dialog component +│ ├── dialog-share.component.ts +│ ├── dialog-share.component.html +│ └── dialog-share.component.scss └── share-button/ # Reusable button component └── share-button.component.ts ``` @@ -241,7 +241,7 @@ When adding new share targets: 1. Add target to `ShareTarget` type in `share.model.ts` 2. Add URL builder to `_buildShareUrl()` in `share.service.ts` -3. Add button config to `shareTargets` array in `share-dialog.component.ts` +3. Add button config to `shareTargets` array in `dialog-share.component.ts` 4. Add tests in `share.service.spec.ts` ## License diff --git a/src/app/core/share/share-dialog/share-dialog.component.html b/src/app/core/share/dialog-share/dialog-share.component.html similarity index 91% rename from src/app/core/share/share-dialog/share-dialog.component.html rename to src/app/core/share/dialog-share/dialog-share.component.html index 6b6ab3df16..02c9aaa629 100644 --- a/src/app/core/share/share-dialog/share-dialog.component.html +++ b/src/app/core/share/dialog-share/dialog-share.component.html @@ -43,13 +43,6 @@

- `, + styles: [ + ` + :host { + display: inline-flex; + } + + button { + opacity: 0.7; + transition: opacity 120ms ease-in-out; + } + + button:hover, + button:focus-visible, + button:active { + opacity: 1; + } + `, + ], changeDetection: ChangeDetectionStrategy.OnPush, imports: [MatButtonModule, MatIconModule, MatTooltipModule], }) diff --git a/src/app/core/share/share-formatter.ts b/src/app/core/share/share-formatter.ts index 2b87f00cbe..f2eae8d511 100644 --- a/src/app/core/share/share-formatter.ts +++ b/src/app/core/share/share-formatter.ts @@ -165,7 +165,7 @@ export class ShareFormatter { if (data.detailedMetrics) { const dm = data.detailedMetrics; - parts.push(`⏱️ Time Spent: ${msToString(data.totalTimeSpent)}`); + parts.push(`⏱️ Time Spent: ${msToString(data.totalTimeSpent)}`); if (dm.timeEstimate) { parts.push(`📋 Time Estimated: ${msToString(dm.timeEstimate)}`); } diff --git a/src/app/core/share/share.service.ts b/src/app/core/share/share.service.ts index 379747949e..5cdc255d94 100644 --- a/src/app/core/share/share.service.ts +++ b/src/app/core/share/share.service.ts @@ -1,7 +1,6 @@ import { Injectable, inject } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { Capacitor } from '@capacitor/core'; -import { Share as CapacitorShare } from '@capacitor/share'; import { IS_ELECTRON } from '../../app.constants'; import { IS_ANDROID_WEB_VIEW } from '../../util/is-android-web-view'; import { SnackService } from '../snack/snack.service'; @@ -136,9 +135,10 @@ export class ShareService { } } - if (await this._isCapacitorShareAvailable()) { + const capacitorShare = await this._getCapacitorSharePlugin(); + if (capacitorShare) { try { - await CapacitorShare.share({ + await capacitorShare.share({ title: payload.title, text: payload.text, url: payload.url, @@ -216,6 +216,7 @@ export class ShareService { */ private async _showShareDialog(payload: SharePayload): Promise { try { + // Import dialog component dynamically to avoid circular dependencies const { DialogShareComponent } = await import( './dialog-share/dialog-share.component' ); @@ -467,16 +468,22 @@ export class ShareService { } private async _isCapacitorShareAvailable(): Promise { - if (!Capacitor.isNativePlatform()) { - return false; + const sharePlugin = await this._getCapacitorSharePlugin(); + return !!sharePlugin; + } + + private async _getCapacitorSharePlugin(): Promise { + if (!Capacitor.isNativePlatform() || typeof window === 'undefined') { + return null; } - try { - const canShare = await CapacitorShare.canShare(); - return !!canShare?.value; - } catch (error) { - console.warn('Capacitor Share.canShare failed:', error); - return false; + const win = window as any; + const sharePlugin = win.Capacitor?.Plugins?.Share; + + if (sharePlugin && typeof sharePlugin.share === 'function') { + return sharePlugin; } + + return null; } } From 459b189e269291bbfcd43babc709c81c6fd4bbb4 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Wed, 22 Oct 2025 15:54:06 +0200 Subject: [PATCH 31/36] feat(share): add native share --- electron/ipc-handler.ts | 136 ++++++++++++++++-- .../dialog-share/dialog-share.component.ts | 14 +- src/app/core/share/share-formatter.ts | 1 - src/app/core/share/share.service.ts | 30 ++-- 4 files changed, 146 insertions(+), 35 deletions(-) diff --git a/electron/ipc-handler.ts b/electron/ipc-handler.ts index 8cb40dd055..fe67ee4952 100644 --- a/electron/ipc-handler.ts +++ b/electron/ipc-handler.ts @@ -22,6 +22,106 @@ import { quitApp, showOrFocus } from './various-shared'; import { loadSimpleStoreAll, saveSimpleStore } from './simple-store'; import { BACKUP_DIR, BACKUP_DIR_WINSTORE } from './backup'; import { pluginNodeExecutor } from './plugin-node-executor'; +import { clipboard } from 'electron'; + +interface SharePayload { + text?: string; + url?: string; + title?: string; +} + +/** + * Handle share on macOS using AppleScript to invoke system share dialog. + * Falls back to clipboard if AppleScript fails. + */ +const handleMacOSShare = async ( + payload: SharePayload, +): Promise<{ + success: boolean; + error?: string; +}> => { + const { text, url, title } = payload; + const contentToShare = [title, text, url].filter(Boolean).join('\n\n'); + + if (!contentToShare) { + return { success: false, error: 'No content to share' }; + } + + return new Promise((resolve) => { + // Use AppleScript to trigger native share + // This creates a share menu at the mouse cursor position + const appleScript = ` + tell application "System Events" + set the clipboard to "${contentToShare.replace(/"/g, '\\"').replace(/\n/g, '\\n')}" + end tell + + display dialog "Content copied to clipboard. Use Cmd+V to paste in your desired app." buttons {"OK"} default button "OK" with icon note + `; + + exec(`osascript -e '${appleScript.replace(/'/g, "'\\''")}'`, (error) => { + if (error) { + log('AppleScript share failed, falling back to clipboard:', error); + // Fallback: just copy to clipboard + try { + clipboard.writeText(contentToShare); + resolve({ success: true }); + } catch (clipboardError) { + resolve({ + success: false, + error: 'Failed to copy to clipboard', + }); + } + } else { + clipboard.writeText(contentToShare); + resolve({ success: true }); + } + }); + }); +}; + +/** + * Handle share on Windows using clipboard. + * Note: Proper Windows Share UI requires UWP/WinRT APIs which need native modules. + * This implementation copies to clipboard as a practical fallback. + */ +const handleWindowsShare = async ( + payload: SharePayload, +): Promise<{ + success: boolean; + error?: string; +}> => { + const { text, url, title } = payload; + const contentToShare = [title, text, url].filter(Boolean).join('\n\n'); + + if (!contentToShare) { + return { success: false, error: 'No content to share' }; + } + + try { + // Copy to clipboard + clipboard.writeText(contentToShare); + + // Show notification dialog + const mainWin = getWin(); + if (mainWin) { + await dialog.showMessageBox(mainWin, { + type: 'info', + title: 'Content Copied', + message: 'Content has been copied to clipboard', + detail: 'You can now paste it in any application using Ctrl+V', + buttons: ['OK'], + }); + } + + return { success: true }; + } catch (error) { + log('Windows share failed:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to copy to clipboard', + }; + } +}; export const initIpcInterfaces = (): void => { // Initialize plugin node executor (registers IPC handlers) @@ -80,14 +180,34 @@ export const initIpcInterfaces = (): void => { }); ipcMain.handle(IPC.SHARE_NATIVE, async (ev, payload) => { - // TODO: Implement native share for macOS (NSSharingService) and Windows (WinRT Share UI) - // For now, return false to fall back to web-based sharing - // Native implementation would require: - // - macOS: Swift/Objective-C bridge using NSSharingServicePicker - // - Windows: C#/C++ bridge using Windows.ApplicationModel.DataTransfer.DataTransferManager - // - Linux: No native share, always use fallback - log('Native share requested but not implemented, falling back to web share'); - return { success: false, error: 'Native share not yet implemented' }; + const { text, url, title } = payload; + const platform = process.platform; + + try { + // macOS: Use system share via AppleScript + if (platform === 'darwin') { + return await handleMacOSShare({ text, url, title }); + } + + // Windows: Use clipboard + notification as fallback + // Note: Proper Windows Share UI requires UWP/WinRT which needs native module + if (platform === 'win32') { + return await handleWindowsShare({ text, url, title }); + } + + // Linux: No native share available + log('Linux platform - no native share available, using fallback'); + return { + success: false, + error: 'Native share not available on Linux', + }; + } catch (error) { + log('Native share error:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Share failed', + }; + } }); ipcMain.on(IPC.LOCK_SCREEN, () => { diff --git a/src/app/core/share/dialog-share/dialog-share.component.ts b/src/app/core/share/dialog-share/dialog-share.component.ts index a8a502f24d..4a5729d9b2 100644 --- a/src/app/core/share/dialog-share/dialog-share.component.ts +++ b/src/app/core/share/dialog-share/dialog-share.component.ts @@ -58,10 +58,7 @@ export class DialogShareComponent { payload = ShareFormatter.optimizeForTwitter(payload); } - const result: ShareResult = await this._shareService['_shareToTarget']( - payload, - target, - ); + const result: ShareResult = await this._shareService.shareToTarget(payload, target); if (result.success) { this._dialogRef.close(result); @@ -69,7 +66,7 @@ export class DialogShareComponent { } async shareNative(): Promise { - const result: ShareResult = await this._shareService['_tryNativeShare']( + const result: ShareResult = await this._shareService.tryNativeShare( this.data.payload, ); @@ -79,11 +76,8 @@ export class DialogShareComponent { } async copyText(): Promise { - const text = this._shareService['_formatTextForClipboard'](this.data.payload); - const result: ShareResult = await this._shareService['_copyToClipboard']( - text, - 'Text', - ); + const text = this._shareService.formatTextForClipboard(this.data.payload); + const result: ShareResult = await this._shareService.copyToClipboard(text, 'Text'); if (result.success) { this._dialogRef.close(result); diff --git a/src/app/core/share/share-formatter.ts b/src/app/core/share/share-formatter.ts index f2eae8d511..8f20751ebd 100644 --- a/src/app/core/share/share-formatter.ts +++ b/src/app/core/share/share-formatter.ts @@ -209,7 +209,6 @@ export class ShareFormatter { }); } - // Hashtags if (options.includeHashtags) { parts.push('\n#productivity #timetracking #SuperProductivity'); diff --git a/src/app/core/share/share.service.ts b/src/app/core/share/share.service.ts index 5cdc255d94..a73936ce06 100644 --- a/src/app/core/share/share.service.ts +++ b/src/app/core/share/share.service.ts @@ -33,7 +33,7 @@ export class ShareService { return 'failed'; } - const result = await this._tryNativeShare({ + const result = await this.tryNativeShare({ title: title ?? undefined, text, }); @@ -77,10 +77,10 @@ export class ShareService { } if (target) { - return this._shareToTarget(payload, target); + return this.shareToTarget(payload, target); } - const nativeResult = await this._tryNativeShare(payload); + const nativeResult = await this.tryNativeShare(payload); if (nativeResult.success) { return nativeResult; } @@ -89,20 +89,17 @@ export class ShareService { } /** - * Share to a specific target. + * Share to a specific target (public API for dialog component). */ - private async _shareToTarget( - payload: SharePayload, - target: ShareTarget, - ): Promise { + async shareToTarget(payload: SharePayload, target: ShareTarget): Promise { try { switch (target) { case 'native': - return this._tryNativeShare(payload); + return this.tryNativeShare(payload); case 'clipboard-link': - return this._copyToClipboard(payload.url || '', 'Link'); + return this.copyToClipboard(payload.url || '', 'Link'); case 'clipboard-text': - return this._copyToClipboard(this._formatTextForClipboard(payload), 'Text'); + return this.copyToClipboard(this.formatTextForClipboard(payload), 'Text'); default: return this._openShareUrl(payload, target); } @@ -117,8 +114,9 @@ export class ShareService { /** * Try to use native share (Electron, Android, Web Share API). + * Public API for dialog component. */ - private async _tryNativeShare(payload: SharePayload): Promise { + async tryNativeShare(payload: SharePayload): Promise { if (IS_ELECTRON && typeof window.ea?.shareNative === 'function') { try { const result = await window.ea.shareNative(payload); @@ -305,9 +303,9 @@ export class ShareService { } /** - * Copy text to clipboard. + * Copy text to clipboard (public API for dialog component). */ - private async _copyToClipboard(text: string, label: string): Promise { + async copyToClipboard(text: string, label: string): Promise { try { await navigator.clipboard.writeText(text); this._snackService.open(`${label} copied to clipboard!`); @@ -341,9 +339,9 @@ export class ShareService { } /** - * Format payload as plain text for clipboard. + * Format payload as plain text for clipboard (public API for dialog component). */ - private _formatTextForClipboard(payload: SharePayload): string { + formatTextForClipboard(payload: SharePayload): string { const parts: string[] = []; if (payload.title) { From c58e269678f155e3c5904a45381db76656db0456 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Wed, 22 Oct 2025 16:05:29 +0200 Subject: [PATCH 32/36] feat(share): remove electron share again --- electron/ipc-handler.ts | 133 +--------------------------- src/app/core/share/share.service.ts | 33 +------ 2 files changed, 6 insertions(+), 160 deletions(-) diff --git a/electron/ipc-handler.ts b/electron/ipc-handler.ts index fe67ee4952..b0f2814c1c 100644 --- a/electron/ipc-handler.ts +++ b/electron/ipc-handler.ts @@ -22,106 +22,6 @@ import { quitApp, showOrFocus } from './various-shared'; import { loadSimpleStoreAll, saveSimpleStore } from './simple-store'; import { BACKUP_DIR, BACKUP_DIR_WINSTORE } from './backup'; import { pluginNodeExecutor } from './plugin-node-executor'; -import { clipboard } from 'electron'; - -interface SharePayload { - text?: string; - url?: string; - title?: string; -} - -/** - * Handle share on macOS using AppleScript to invoke system share dialog. - * Falls back to clipboard if AppleScript fails. - */ -const handleMacOSShare = async ( - payload: SharePayload, -): Promise<{ - success: boolean; - error?: string; -}> => { - const { text, url, title } = payload; - const contentToShare = [title, text, url].filter(Boolean).join('\n\n'); - - if (!contentToShare) { - return { success: false, error: 'No content to share' }; - } - - return new Promise((resolve) => { - // Use AppleScript to trigger native share - // This creates a share menu at the mouse cursor position - const appleScript = ` - tell application "System Events" - set the clipboard to "${contentToShare.replace(/"/g, '\\"').replace(/\n/g, '\\n')}" - end tell - - display dialog "Content copied to clipboard. Use Cmd+V to paste in your desired app." buttons {"OK"} default button "OK" with icon note - `; - - exec(`osascript -e '${appleScript.replace(/'/g, "'\\''")}'`, (error) => { - if (error) { - log('AppleScript share failed, falling back to clipboard:', error); - // Fallback: just copy to clipboard - try { - clipboard.writeText(contentToShare); - resolve({ success: true }); - } catch (clipboardError) { - resolve({ - success: false, - error: 'Failed to copy to clipboard', - }); - } - } else { - clipboard.writeText(contentToShare); - resolve({ success: true }); - } - }); - }); -}; - -/** - * Handle share on Windows using clipboard. - * Note: Proper Windows Share UI requires UWP/WinRT APIs which need native modules. - * This implementation copies to clipboard as a practical fallback. - */ -const handleWindowsShare = async ( - payload: SharePayload, -): Promise<{ - success: boolean; - error?: string; -}> => { - const { text, url, title } = payload; - const contentToShare = [title, text, url].filter(Boolean).join('\n\n'); - - if (!contentToShare) { - return { success: false, error: 'No content to share' }; - } - - try { - // Copy to clipboard - clipboard.writeText(contentToShare); - - // Show notification dialog - const mainWin = getWin(); - if (mainWin) { - await dialog.showMessageBox(mainWin, { - type: 'info', - title: 'Content Copied', - message: 'Content has been copied to clipboard', - detail: 'You can now paste it in any application using Ctrl+V', - buttons: ['OK'], - }); - } - - return { success: true }; - } catch (error) { - log('Windows share failed:', error); - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to copy to clipboard', - }; - } -}; export const initIpcInterfaces = (): void => { // Initialize plugin node executor (registers IPC handlers) @@ -179,35 +79,10 @@ export const initIpcInterfaces = (): void => { return { success: false }; }); - ipcMain.handle(IPC.SHARE_NATIVE, async (ev, payload) => { - const { text, url, title } = payload; - const platform = process.platform; - - try { - // macOS: Use system share via AppleScript - if (platform === 'darwin') { - return await handleMacOSShare({ text, url, title }); - } - - // Windows: Use clipboard + notification as fallback - // Note: Proper Windows Share UI requires UWP/WinRT which needs native module - if (platform === 'win32') { - return await handleWindowsShare({ text, url, title }); - } - - // Linux: No native share available - log('Linux platform - no native share available, using fallback'); - return { - success: false, - error: 'Native share not available on Linux', - }; - } catch (error) { - log('Native share error:', error); - return { - success: false, - error: error instanceof Error ? error.message : 'Share failed', - }; - } + ipcMain.handle(IPC.SHARE_NATIVE, async () => { + // Desktop platforms use the share dialog instead of native share + // This allows for more flexibility and better UX with social media options + return { success: false, error: 'Native share not available on desktop' }; }); ipcMain.on(IPC.LOCK_SCREEN, () => { diff --git a/src/app/core/share/share.service.ts b/src/app/core/share/share.service.ts index a73936ce06..d6347827b9 100644 --- a/src/app/core/share/share.service.ts +++ b/src/app/core/share/share.service.ts @@ -1,7 +1,6 @@ import { Injectable, inject } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { Capacitor } from '@capacitor/core'; -import { IS_ELECTRON } from '../../app.constants'; import { IS_ANDROID_WEB_VIEW } from '../../util/is-android-web-view'; import { SnackService } from '../snack/snack.service'; import { SharePayload, ShareResult, ShareTarget, ShareTargetConfig } from './share.model'; @@ -113,26 +112,10 @@ export class ShareService { } /** - * Try to use native share (Electron, Android, Web Share API). + * Try to use native share (Android, Web Share API). * Public API for dialog component. */ async tryNativeShare(payload: SharePayload): Promise { - if (IS_ELECTRON && typeof window.ea?.shareNative === 'function') { - try { - const result = await window.ea.shareNative(payload); - if (result.success) { - this._snackService.open('Shared successfully!'); - return { - success: true, - usedNative: true, - target: 'native', - }; - } - } catch (error) { - console.warn('Electron native share failed:', error); - } - } - const capacitorShare = await this._getCapacitorSharePlugin(); if (capacitorShare) { try { @@ -254,11 +237,7 @@ export class ShareService { ): Promise { const url = this._buildShareUrl(payload, target); - if (IS_ELECTRON && window.ea?.openExternalUrl) { - window.ea.openExternalUrl(url); - } else { - window.open(url, '_blank', 'noopener,noreferrer'); - } + window.open(url, '_blank', 'noopener,noreferrer'); this._snackService.open('Opening share window...'); @@ -367,10 +346,6 @@ export class ShareService { * Check if native/system share is available on current platform. */ private async _isSystemShareAvailable(): Promise { - if (IS_ELECTRON && typeof window.ea?.shareNative === 'function') { - return true; - } - if (await this._isCapacitorShareAvailable()) { return true; } @@ -443,10 +418,6 @@ export class ShareService { } private async _detectShareSupport(): Promise { - if (IS_ELECTRON && typeof window.ea?.shareNative === 'function') { - return 'native'; - } - if (await this._isCapacitorShareAvailable()) { return 'native'; } From b53341a6ad416c2be46e9df82d0695626a460696 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Wed, 22 Oct 2025 16:15:02 +0200 Subject: [PATCH 33/36] refactor(share): cleanup unused clipboard link --- src/app/core/share/README.md | 1 - src/app/core/share/share.model.ts | 1 - src/app/core/share/share.service.ts | 6 ++---- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/app/core/share/README.md b/src/app/core/share/README.md index eb341460ec..c59cf8c1aa 100644 --- a/src/app/core/share/README.md +++ b/src/app/core/share/README.md @@ -122,7 +122,6 @@ Supported share targets: - `telegram` - Telegram - `email` - Email - `mastodon` - Mastodon (with custom instance support) -- `clipboard-link` - Copy link to clipboard - `clipboard-text` - Copy formatted text to clipboard - `native` - Use native OS share sheet diff --git a/src/app/core/share/share.model.ts b/src/app/core/share/share.model.ts index 562b9aaf64..619a31143f 100644 --- a/src/app/core/share/share.model.ts +++ b/src/app/core/share/share.model.ts @@ -24,7 +24,6 @@ export type ShareTarget = | 'telegram' | 'email' | 'mastodon' - | 'clipboard-link' | 'clipboard-text' | 'native'; diff --git a/src/app/core/share/share.service.ts b/src/app/core/share/share.service.ts index d6347827b9..30f9489e61 100644 --- a/src/app/core/share/share.service.ts +++ b/src/app/core/share/share.service.ts @@ -95,8 +95,6 @@ export class ShareService { switch (target) { case 'native': return this.tryNativeShare(payload); - case 'clipboard-link': - return this.copyToClipboard(payload.url || '', 'Link'); case 'clipboard-text': return this.copyToClipboard(this.formatTextForClipboard(payload), 'Text'); default: @@ -290,7 +288,7 @@ export class ShareService { this._snackService.open(`${label} copied to clipboard!`); return { success: true, - target: label === 'Link' ? 'clipboard-link' : 'clipboard-text', + target: 'clipboard-text', }; } catch (error) { try { @@ -306,7 +304,7 @@ export class ShareService { this._snackService.open(`${label} copied to clipboard!`); return { success: true, - target: label === 'Link' ? 'clipboard-link' : 'clipboard-text', + target: 'clipboard-text', }; } catch (fallbackError) { return { From 012b0b2fa5bd4fde49495a1f6e5721bf02aab9df Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Wed, 22 Oct 2025 17:00:55 +0200 Subject: [PATCH 34/36] feat(share): improve share --- src/app/core/share/share.service.spec.ts | 175 -------------- src/app/core/share/share.service.ts | 280 +++++++++++++++++++---- 2 files changed, 241 insertions(+), 214 deletions(-) delete mode 100644 src/app/core/share/share.service.spec.ts diff --git a/src/app/core/share/share.service.spec.ts b/src/app/core/share/share.service.spec.ts deleted file mode 100644 index 62ea0c8838..0000000000 --- a/src/app/core/share/share.service.spec.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import { MatDialog } from '@angular/material/dialog'; -import { ShareService } from './share.service'; -import { SnackService } from '../snack/snack.service'; -import { SharePayload } from './share.model'; - -describe('ShareService', () => { - let service: ShareService; - let mockSnackService: jasmine.SpyObj; - let mockMatDialog: jasmine.SpyObj; - - beforeEach(() => { - mockSnackService = jasmine.createSpyObj('SnackService', ['open']); - mockMatDialog = jasmine.createSpyObj('MatDialog', ['open']); - - TestBed.configureTestingModule({ - providers: [ - ShareService, - { provide: SnackService, useValue: mockSnackService }, - { provide: MatDialog, useValue: mockMatDialog }, - ], - }); - - service = TestBed.inject(ShareService); - }); - - it('should be created', () => { - expect(service).toBeTruthy(); - }); - - describe('getShareTargets', () => { - it('should return all share target configurations', () => { - const targets = service.getShareTargets(); - - expect(targets.length).toBeGreaterThan(0); - expect(targets[0]).toHaveProperty('label'); - expect(targets[0]).toHaveProperty('icon'); - expect(targets[0]).toHaveProperty('available'); - }); - - it('should include Twitter target', () => { - const targets = service.getShareTargets(); - const twitter = targets.find((t) => t.label === 'Twitter'); - - expect(twitter).toBeDefined(); - expect(twitter!.available).toBe(true); - }); - - it('should include Email target', () => { - const targets = service.getShareTargets(); - const email = targets.find((t) => t.label === 'Email'); - - expect(email).toBeDefined(); - expect(email!.icon).toBe('email'); - }); - }); - - describe('share', () => { - it('should return error when no content provided', async () => { - const payload: SharePayload = {}; - - const result = await service.share(payload); - - expect(result.success).toBe(false); - expect(result.error).toBe('No content to share'); - }); - - it('should accept text-only payload', async () => { - const payload: SharePayload = { text: 'Test content' }; - - // This will attempt native share or show dialog - const result = await service.share(payload); - - // Result depends on platform capabilities - expect(result).toBeDefined(); - }); - - it('should accept URL-only payload', async () => { - const payload: SharePayload = { url: 'https://example.com' }; - - const result = await service.share(payload); - - expect(result).toBeDefined(); - }); - }); - - describe('_buildShareUrl', () => { - it('should build Twitter share URL', () => { - const payload: SharePayload = { - text: 'Test tweet', - url: 'https://example.com', - }; - - const url = service['_buildShareUrl'](payload, 'twitter'); - - expect(url).toContain('twitter.com/intent/tweet'); - expect(url).toContain(encodeURIComponent('Test tweet')); - }); - - it('should build LinkedIn share URL', () => { - const payload: SharePayload = { url: 'https://example.com' }; - - const url = service['_buildShareUrl'](payload, 'linkedin'); - - expect(url).toContain('linkedin.com'); - expect(url).toContain(encodeURIComponent('https://example.com')); - }); - - it('should build Email share URL', () => { - const payload: SharePayload = { - title: 'Check this out', - text: 'Great content', - url: 'https://example.com', - }; - - const url = service['_buildShareUrl'](payload, 'email'); - - expect(url).toContain('mailto:'); - expect(url).toContain('subject='); - expect(url).toContain('body='); - }); - - it('should build WhatsApp share URL', () => { - const payload: SharePayload = { - text: 'Check this out', - url: 'https://example.com', - }; - - const url = service['_buildShareUrl'](payload, 'whatsapp'); - - expect(url).toContain('wa.me'); - expect(url).toContain('text='); - }); - - it('should throw error for unknown target', () => { - const payload: SharePayload = { text: 'Test' }; - - expect(() => { - service['_buildShareUrl'](payload, 'unknown' as any); - }).toThrow(); - }); - }); - - describe('_formatTextForClipboard', () => { - it('should format payload with title, text, and URL', () => { - const payload: SharePayload = { - title: 'My Title', - text: 'My text content', - url: 'https://example.com', - }; - - const formatted = service['_formatTextForClipboard'](payload); - - expect(formatted).toContain('My Title'); - expect(formatted).toContain('My text content'); - expect(formatted).toContain('https://example.com'); - }); - - it('should handle text-only payload', () => { - const payload: SharePayload = { text: 'Just text' }; - - const formatted = service['_formatTextForClipboard'](payload); - - expect(formatted).toBe('Just text'); - }); - - it('should handle URL-only payload', () => { - const payload: SharePayload = { url: 'https://example.com' }; - - const formatted = service['_formatTextForClipboard'](payload); - - expect(formatted).toBe('https://example.com'); - }); - }); -}); diff --git a/src/app/core/share/share.service.ts b/src/app/core/share/share.service.ts index 30f9489e61..89569c2568 100644 --- a/src/app/core/share/share.service.ts +++ b/src/app/core/share/share.service.ts @@ -5,6 +5,8 @@ import { IS_ANDROID_WEB_VIEW } from '../../util/is-android-web-view'; import { SnackService } from '../snack/snack.service'; import { SharePayload, ShareResult, ShareTarget, ShareTargetConfig } from './share.model'; +const FALLBACK_SHARE_URL = 'https://super-productivity.com'; + export type ShareOutcome = 'shared' | 'cancelled' | 'unavailable' | 'failed'; export type ShareSupport = 'native' | 'web' | 'none'; @@ -68,6 +70,8 @@ export class ShareService { * Main share method - automatically detects platform and uses best method. */ async share(payload: SharePayload, target?: ShareTarget): Promise { + const normalizedPayload = this._ensureShareText(payload); + if (!payload.text && !payload.url) { return { success: false, @@ -79,26 +83,28 @@ export class ShareService { return this.shareToTarget(payload, target); } - const nativeResult = await this.tryNativeShare(payload); + const nativeResult = await this.tryNativeShare(normalizedPayload); if (nativeResult.success) { return nativeResult; } - return this._showShareDialog(payload); + return this._showShareDialog(normalizedPayload); } /** * Share to a specific target (public API for dialog component). */ async shareToTarget(payload: SharePayload, target: ShareTarget): Promise { + const normalized = this._ensureShareText(payload); + try { switch (target) { case 'native': - return this.tryNativeShare(payload); + return this.tryNativeShare(normalized); case 'clipboard-text': return this.copyToClipboard(this.formatTextForClipboard(payload), 'Text'); default: - return this._openShareUrl(payload, target); + return this._openShareUrl(normalized, target); } } catch (error) { return { @@ -114,14 +120,16 @@ export class ShareService { * Public API for dialog component. */ async tryNativeShare(payload: SharePayload): Promise { + const normalized = this._ensureShareText(payload); + const capacitorShare = await this._getCapacitorSharePlugin(); if (capacitorShare) { try { await capacitorShare.share({ - title: payload.title, - text: payload.text, - url: payload.url, - files: payload.files, + title: normalized.title, + text: normalized.text, + url: normalized.url, + files: normalized.files, dialogTitle: 'Share via', }); this._snackService.open('Shared successfully!'); @@ -146,9 +154,9 @@ export class ShareService { const win = window as any; if (win.Capacitor?.Plugins?.Share) { await win.Capacitor.Plugins.Share.share({ - title: payload.title, - text: payload.text, - url: payload.url, + title: normalized.title, + text: normalized.text, + url: normalized.url, dialogTitle: 'Share via', }); this._snackService.open('Shared successfully!'); @@ -166,9 +174,9 @@ export class ShareService { if (typeof navigator !== 'undefined' && 'share' in navigator) { try { await navigator.share({ - title: payload.title, - text: payload.text, - url: payload.url, + title: normalized.title, + text: normalized.text, + url: normalized.url, }); this._snackService.open('Shared successfully!'); return { @@ -233,7 +241,8 @@ export class ShareService { payload: SharePayload, target: ShareTarget, ): Promise { - const url = this._buildShareUrl(payload, target); + const normalized = this._ensureShareText(payload); + const url = this._buildShareUrl(normalized, target); window.open(url, '_blank', 'noopener,noreferrer'); @@ -250,33 +259,45 @@ export class ShareService { */ private _buildShareUrl(payload: SharePayload, target: ShareTarget): string { const enc = encodeURIComponent; - const textAndUrl = [payload.text, payload.url].filter(Boolean).join(' '); + const shareUrl = payload.url?.trim() || FALLBACK_SHARE_URL; + const baseTitle = this._getShareTitle(payload); - const urlBuilders: Record string> = { - twitter: () => `https://twitter.com/intent/tweet?text=${enc(textAndUrl)}`, - linkedin: () => - `https://www.linkedin.com/sharing/share-offsite/?url=${enc(payload.url || '')}`, - reddit: () => - `https://www.reddit.com/submit?url=${enc(payload.url || '')}&title=${enc(payload.title || payload.text || '')}`, - facebook: () => - `https://www.facebook.com/sharer/sharer.php?u=${enc(payload.url || '')}`, - whatsapp: () => `https://wa.me/?text=${enc(textAndUrl)}`, - telegram: () => - `https://t.me/share/url?url=${enc(payload.url || '')}&text=${enc(payload.text || '')}`, - email: () => - `mailto:?subject=${enc(payload.title || 'Check this out')}&body=${enc(textAndUrl)}`, - mastodon: () => { + const providerText = this._buildProviderText(payload, target); + const providerTitle = this._buildProviderTitle(baseTitle, target); + const inlineText = this._inlineShareText(providerText || providerTitle); + + const encodedUrl = enc(shareUrl); + const encodedText = enc(providerText || providerTitle || shareUrl); + const encodedInline = enc(inlineText || providerTitle || shareUrl); + const encodedTitle = enc(providerTitle || 'Check this out'); + + switch (target) { + case 'twitter': + return `https://twitter.com/intent/tweet?text=${encodedInline}`; + case 'linkedin': + // LinkedIn ignores summary/message params in the modern share dialog (policy + // to prevent prefilled spam). We still pass summary for legacy/preview rendering. + return `https://www.linkedin.com/shareArticle?mini=true&url=${encodedUrl}&title=${encodedTitle}&summary=${encodedText}`; + case 'reddit': + // New reddit drops text params. The legacy reddit (old.reddit.com) still honors + // them so we point there for best-effort prefill. + return `https://old.reddit.com/submit?title=${encodedTitle}&kind=self&text=${encodedText}`; + case 'facebook': + // Facebook ignores prefilled body text since 2018. quote= is preserved as a caption. + return `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}"e=${encodedText}`; + case 'whatsapp': + return `https://wa.me/?text=${this._encodeForWhatsApp(providerText || providerTitle || shareUrl)}`; + case 'telegram': + return `https://t.me/share/url?url=${encodedUrl}&text=${enc(providerText || providerTitle || shareUrl)}`; + case 'email': + return `mailto:?subject=${encodedTitle}&body=${encodedText}`; + case 'mastodon': { const instance = 'mastodon.social'; - return `https://${instance}/share?text=${enc(textAndUrl)}`; - }, - }; - - const builder = urlBuilders[target]; - if (!builder) { - throw new Error(`Unknown share target: ${target}`); + return `https://${instance}/share?text=${enc(providerText || shareUrl)}`; + } + default: + throw new Error(`Unknown share target: ${target}`); } - - return builder(); } /** @@ -415,6 +436,187 @@ export class ShareService { ]; } + private _ensureShareText(payload: SharePayload): SharePayload { + const existingText = typeof payload.text === 'string' ? payload.text.trim() : ''; + if (existingText.length > 0) { + if (payload.text === existingText) { + return payload; + } + return { + ...payload, + text: existingText, + }; + } + + const fallbackParts: string[] = []; + const title = payload.title?.trim(); + if (title) { + fallbackParts.push(title); + } + + const url = payload.url?.trim(); + if (url) { + fallbackParts.push(url); + } + + const fallbackText = fallbackParts.join('\n\n').trim(); + if (!fallbackText) { + return payload; + } + + return { + ...payload, + text: fallbackText, + }; + } + + private _buildShareText(payload: SharePayload): string { + const text = payload.text?.trim() ?? ''; + const url = payload.url?.trim() ?? ''; + + if (!text && !url) { + return ''; + } + + if (!url) { + return text; + } + + if (!text) { + return url; + } + + if (text.includes(url)) { + return text; + } + + return `${text}\n\n${url}`; + } + + private _cleanupText(text: string): string { + if (!text) { + return ''; + } + + const lines = text.split(/\r?\n/); + const cleaned: string[] = []; + let pendingBlank = false; + + for (const rawLine of lines) { + const normalizedLine = rawLine.replace(/\s{2,}/g, ' ').trim(); + if (!normalizedLine) { + if (cleaned.length > 0) { + pendingBlank = true; + } + continue; + } + if (pendingBlank) { + cleaned.push(''); + pendingBlank = false; + } + cleaned.push(normalizedLine); + } + + return cleaned.join('\n').trim(); + } + + private _inlineShareText(text: string): string { + const normalized = this._cleanupText(text); + if (!normalized) { + return ''; + } + + return normalized + .split(/\r?\n+/) + .map((segment) => segment.trim()) + .filter(Boolean) + .join(' ') + .replace(/\s{2,}/g, ' ') + .trim(); + } + + private _getShareTitle(payload: SharePayload): string { + const title = payload.title?.trim(); + if (title) { + return title.slice(0, 300); + } + + const text = payload.text?.trim(); + if (text) { + const firstNonEmptyLine = text + .split(/\r?\n+/) + .map((line) => line.trim()) + .find((line) => line.length > 0); + if (firstNonEmptyLine) { + return firstNonEmptyLine.slice(0, 300); + } + } + + const url = payload.url?.trim(); + if (url) { + return url; + } + + return 'Check this out'; + } + + private _buildProviderText(payload: SharePayload, provider: ShareTarget): string { + let text = this._cleanupText(this._buildShareText(payload)); + + if (!text) { + return payload.url?.trim() || ''; + } + + if (provider !== 'twitter' && provider !== 'mastodon') { + text = this._cleanupText(this._stripHashtags(text)); + } + + if (provider === 'whatsapp') { + text = this._cleanupText(this._stripEmojis(text)); + } + + return text || payload.url?.trim() || ''; + } + + private _buildProviderTitle(baseTitle: string, provider: ShareTarget): string { + let title = baseTitle?.trim() || ''; + + if (!title) { + return title; + } + + if (provider !== 'twitter' && provider !== 'mastodon') { + title = this._stripHashtags(title); + } + + if (provider === 'whatsapp') { + title = this._stripEmojis(title); + } + + return title.replace(/\s{2,}/g, ' ').trim(); + } + + private _encodeForWhatsApp(text: string): string { + const cleaned = this._cleanupText(text); + return encodeURIComponent(cleaned || FALLBACK_SHARE_URL); + } + + private _stripHashtags(text: string): string { + if (!text) { + return ''; + } + + return text.replace(/(^|[\s])#[\p{L}\p{N}_-]+/gu, (match, prefix) => prefix); + } + + private _stripEmojis(text: string): string { + if (!text) { + return ''; + } + + return text.replace(/\p{Extended_Pictographic}|\uFE0F|\uFE0E|\u200D/gu, ''); + } + private async _detectShareSupport(): Promise { if (await this._isCapacitorShareAvailable()) { return 'native'; From d49192e2201b8abc9b693dc2f5f9e4e59c411ae0 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Wed, 22 Oct 2025 19:17:34 +0200 Subject: [PATCH 35/36] test: fix failing --- src/app/core/share/share-formatter.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/core/share/share-formatter.spec.ts b/src/app/core/share/share-formatter.spec.ts index 91a07c2a8a..278e4001a5 100644 --- a/src/app/core/share/share-formatter.spec.ts +++ b/src/app/core/share/share-formatter.spec.ts @@ -10,7 +10,7 @@ describe('ShareFormatter', () => { const payload = ShareFormatter.formatWorkSummary(data); - expect(payload.text).toContain('📊 My productivity summary:'); + expect(payload.text).toContain('📊 My productivity summary'); expect(payload.text).toContain('1h'); expect(payload.text).toContain('5 tasks completed'); expect(payload.url).toBeDefined(); @@ -127,7 +127,7 @@ describe('ShareFormatter', () => { const optimized = ShareFormatter.optimizeForTwitter(payload); expect(optimized.text!.length).toBeLessThanOrEqual(280 - 23 - 1); // 280 - URL length - space - expect(optimized.text).toEndWith('...'); + expect(optimized.text ?? '').toMatch(/\.\.\.$/); }); it('should not truncate short text', () => { From a6b876c897a1bf4f31a9cfecfae05490d489bf7d Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Wed, 22 Oct 2025 20:34:21 +0200 Subject: [PATCH 36/36] feat: improve error handling for #5330 --- .../pfapi/api/sync/model-sync.service.spec.ts | 70 +++++++++++++++++++ src/app/pfapi/api/sync/model-sync.service.ts | 17 +++++ 2 files changed, 87 insertions(+) diff --git a/src/app/pfapi/api/sync/model-sync.service.spec.ts b/src/app/pfapi/api/sync/model-sync.service.spec.ts index 0db97670c2..e98e1848a1 100644 --- a/src/app/pfapi/api/sync/model-sync.service.spec.ts +++ b/src/app/pfapi/api/sync/model-sync.service.spec.ts @@ -281,6 +281,76 @@ describe('ModelSyncService', () => { 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(