Merge branch 'johannesjo:master' into master

This commit is contained in:
Maximilian Liesegang 2025-10-23 08:06:43 +02:00 committed by GitHub
commit e2dd422e4e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
76 changed files with 4174 additions and 351 deletions

View file

@ -15,6 +15,7 @@
<option value="$PROJECT_DIR$/../node_modules/@capacitor/app/android" />
<option value="$PROJECT_DIR$/../node_modules/@capacitor/filesystem/android" />
<option value="$PROJECT_DIR$/../node_modules/@capacitor/local-notifications/android" />
<option value="$PROJECT_DIR$/../node_modules/@capacitor/share/android" />
<option value="$PROJECT_DIR$/../node_modules/@capawesome/capacitor-android-dark-mode-support/android" />
<option value="$PROJECT_DIR$/../node_modules/@capawesome/capacitor-background-task/android" />
</set>

View file

@ -12,6 +12,7 @@ dependencies {
implementation project(':capacitor-app')
implementation project(':capacitor-filesystem')
implementation project(':capacitor-local-notifications')
implementation project(':capacitor-share')
implementation project(':capawesome-capacitor-android-dark-mode-support')
implementation project(':capawesome-capacitor-background-task')

View file

@ -11,6 +11,9 @@ project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacit
include ':capacitor-local-notifications'
project(':capacitor-local-notifications').projectDir = new File('../node_modules/@capacitor/local-notifications/android')
include ':capacitor-share'
project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/share/android')
include ':capawesome-capacitor-android-dark-mode-support'
project(':capawesome-capacitor-android-dark-mode-support').projectDir = new File('../node_modules/@capawesome/capacitor-android-dark-mode-support/android')

View file

@ -70,6 +70,13 @@ export interface ElectronAPI {
data: string,
): Promise<{ success: boolean; path?: string }>;
shareNative(payload: {
text?: string;
url?: string;
title?: string;
files?: string[];
}): Promise<{ success: boolean; error?: string }>;
isLinux(): boolean;
isMacOS(): boolean;

View file

@ -79,6 +79,12 @@ export const initIpcInterfaces = (): void => {
return { success: false };
});
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, () => {
if ((app as any).isLocked) {
return;

View file

@ -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

View file

@ -80,6 +80,16 @@ const ea: ElectronAPI = {
success: boolean;
path?: string;
}>,
shareNative: (payload: {
text?: string;
url?: string;
title?: string;
files?: string[];
}) =>
_invoke('SHARE_NATIVE', payload) as Promise<{
success: boolean;
error?: string;
}>,
scheduleRegisterBeforeClose: (id) => _send('REGISTER_BEFORE_CLOSE', { id }),
unscheduleRegisterBeforeClose: (id) => _send('UNREGISTER_BEFORE_CLOSE', { id }),
setDoneRegisterBeforeClose: (id) => _send('BEFORE_CLOSE_DONE', { id }),

View file

@ -63,6 +63,8 @@ export enum IPC {
SAVE_FILE_DIALOG = 'SAVE_FILE_DIALOG',
SHARE_NATIVE = 'SHARE_NATIVE',
// Plugin Node Execution
PLUGIN_EXEC_NODE_SCRIPT = 'PLUGIN_EXEC_NODE_SCRIPT',

11
package-lock.json generated
View file

@ -49,6 +49,7 @@
"@capacitor/core": "^7.4.3",
"@capacitor/filesystem": "^7.1.1",
"@capacitor/local-notifications": "^7.0.1",
"@capacitor/share": "^7.0.2",
"@capawesome/capacitor-android-dark-mode-support": "^7.0.0",
"@capawesome/capacitor-background-task": "^7.0.1",
"@csstools/stylelint-formatter-github": "^1.0.0",
@ -4617,6 +4618,16 @@
"@capacitor/core": ">=7.0.0"
}
},
"node_modules/@capacitor/share": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@capacitor/share/-/share-7.0.2.tgz",
"integrity": "sha512-VyNPo/9831xnL17IMDeft5yNdBjoKNb451P95sRcr69hulRDqHc+kndqOVaMXnaA6IyBdWnnFv/n1HUf4cXpGw==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@capacitor/core": ">=7.0.0"
}
},
"node_modules/@capacitor/synapse": {
"version": "1.0.2",
"dev": true,

View file

@ -168,6 +168,7 @@
"@capacitor/core": "^7.4.3",
"@capacitor/filesystem": "^7.1.1",
"@capacitor/local-notifications": "^7.0.1",
"@capacitor/share": "^7.0.2",
"@capawesome/capacitor-android-dark-mode-support": "^7.0.0",
"@capawesome/capacitor-background-task": "^7.0.1",
"@csstools/stylelint-formatter-github": "^1.0.0",

View file

@ -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;

View file

@ -240,7 +240,7 @@ export class AppComponent implements OnDestroy, AfterViewInit {
this._initOfflineBanner();
const miscCfg = this._globalConfigService.misc();
if (!miscCfg?.isDisableProductivityTips && !this._isTourLikelyToBeShown()) {
if (miscCfg?.isShowProductivityTipLonger && !this._isTourLikelyToBeShown()) {
this._snackService.open({
ico: 'lightbulb',
config: {
@ -251,12 +251,6 @@ export class AppComponent implements OnDestroy, AfterViewInit {
w.productivityTips![w.randomIndex!][0] +
':</strong> ' +
w.productivityTips![w.randomIndex!][1],
actionStr: T.G.DONT_SHOW_AGAIN,
actionFn: () => {
this._globalConfigService.updateSection('misc', {
isDisableProductivityTips: true,
});
},
});
}

View file

@ -4,7 +4,6 @@ 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 { T } from '../../../t.const';
import { KeyboardConfig } from '../../../features/config/keyboard-config.model';
@ -24,22 +23,6 @@ import { KeyboardConfig } from '../../../features/config/keyboard-config.model';
<mat-icon svgIcon="early_on"></mat-icon>
</button>
<button
class="panel-btn"
[disabled]="!isWorkViewPage()"
[class.isActive]="isShowTaskViewCustomizerPanel()"
[class.isCustomized]="taskViewCustomizerService.isCustomized()"
(click)="layoutService.toggleTaskViewCustomizerPanel()"
mat-icon-button
matTooltip="{{ T.GCF.KEYBOARD.TOGGLE_TASK_VIEW_CUSTOMIZER_PANEL | translate }} {{
kb()?.toggleTaskViewCustomizerPanel
? '[' + kb()?.toggleTaskViewCustomizerPanel + ']'
: ''
}}"
>
<mat-icon>filter_list</mat-icon>
</button>
<button
class="panel-btn e2e-toggle-issue-provider-panel"
[disabled]="!isRouteWithSidePanel()"
@ -82,12 +65,8 @@ import { KeyboardConfig } from '../../../features/config/keyboard-config.model';
display: block;
}
&.isActive,
&.isCustomized {
box-shadow: 0px -2px 3px 0px var(--separator-alpha);
}
&.isActive {
box-shadow: 0px -2px 3px 0px var(--separator-alpha);
background-color: transparent;
&::after {
@ -99,11 +78,7 @@ import { KeyboardConfig } from '../../../features/config/keyboard-config.model';
}
}
&.isCustomized {
background: var(--c-accent);
}
&:hover:not(.isActive):not(.isCustomized):not(:disabled) {
&:hover:not(.isActive):not(:disabled) {
background-color: var(--hover-color, rgba(0, 0, 0, 0.04));
}
@ -124,13 +99,10 @@ import { KeyboardConfig } from '../../../features/config/keyboard-config.model';
export class DesktopPanelButtonsComponent {
readonly T = T;
readonly layoutService = inject(LayoutService);
readonly taskViewCustomizerService = inject(TaskViewCustomizerService);
readonly kb = input<KeyboardConfig | null>();
readonly isRouteWithSidePanel = input.required<boolean>();
readonly isWorkViewPage = input.required<boolean>();
readonly isShowScheduleDayPanel = input.required<boolean>();
readonly isShowTaskViewCustomizerPanel = input.required<boolean>();
readonly isShowIssuePanel = input.required<boolean>();
readonly isShowNotes = input.required<boolean>();
}

View file

@ -112,9 +112,7 @@
<desktop-panel-buttons
[kb]="kb"
[isRouteWithSidePanel]="isRouteWithSidePanel()"
[isWorkViewPage]="isWorkViewPage()"
[isShowScheduleDayPanel]="isShowScheduleDayPanel()"
[isShowTaskViewCustomizerPanel]="isShowTaskViewCustomizerPanel()"
[isShowIssuePanel]="isShowIssuePanel()"
[isShowNotes]="isShowNotes()"
></desktop-panel-buttons>

View file

@ -128,13 +128,6 @@ export class MainHeaderComponent implements OnDestroy {
);
isScheduleSection = toSignal(this._isScheduleSection$, { 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 });
// Convert more observables to signals
currentTask = toSignal(this.taskService.currentTask$);
@ -145,9 +138,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());
isShowScheduleDayPanel = computed(() => this.layoutService.isShowScheduleDayPanel());

View file

@ -3,9 +3,11 @@ import { CommonModule } from '@angular/common';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatMenuModule } from '@angular/material/menu';
import { TranslateModule } 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 { GlobalConfigService } from '../../../features/config/global-config.service';
@ -31,8 +33,10 @@ import { BreakpointObserver } from '@angular/cdk/layout';
MatIconModule,
MatButtonModule,
MatTooltipModule,
MatMenuModule,
TranslateModule,
PluginIconComponent,
TaskViewCustomizerPanelComponent,
],
template: `
<div class="mobile-dropdown-wrapper">
@ -68,15 +72,16 @@ import { BreakpointObserver } from '@angular/cdk/layout';
<button
mat-mini-fab
color=""
[class.active]="isShowTaskViewCustomizerPanel()"
[class.isCustomized]="taskViewCustomizerService.isCustomized()"
[disabled]="!isWorkViewPage()"
(click)="toggleTaskViewCustomizer()"
[matMenuTriggerFor]="customizerPanel.menu"
[matTooltip]="T.GCF.KEYBOARD.TOGGLE_TASK_VIEW_CUSTOMIZER_PANEL | translate"
>
<mat-icon>filter_list</mat-icon>
</button>
<task-view-customizer-panel #customizerPanel></task-view-customizer-panel>
<!-- Issue Panel -->
<button
mat-mini-fab
@ -199,11 +204,6 @@ export class MobileSidePanelMenuComponent {
this.isShowMobileMenu.set(false);
}
toggleTaskViewCustomizer(): void {
this.layoutService.toggleTaskViewCustomizerPanel();
this.isShowMobileMenu.set(false);
}
toggleIssuePanel(): void {
this.layoutService.toggleAddTaskPanel();
this.isShowMobileMenu.set(false);

View file

@ -12,6 +12,10 @@ import { BreakpointObserver } from '@angular/cdk/layout';
import { toSignal } from '@angular/core/rxjs-interop';
import { filter, map, startWith } from 'rxjs/operators';
import { WorkContextService } from '../../../features/work-context/work-context.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 { GlobalConfigService } from '../../../features/config/global-config.service';
import { KeyboardConfig } from '../../../features/config/keyboard-config.model';
@Component({
selector: 'page-title',
@ -26,6 +30,7 @@ import { WorkContextService } from '../../../features/work-context/work-context.
MatMenuContent,
MatMenuTrigger,
WorkContextMenuComponent,
TaskViewCustomizerPanelComponent,
TranslatePipe,
],
template: `
@ -39,14 +44,35 @@ import { WorkContextService } from '../../../features/work-context/work-context.
{{ displayTitle() }}
</div>
@if (!isXxxs()) {
<button
[mat-menu-trigger-for]="activeWorkContextMenu"
[matTooltip]="T.MH.PROJECT_MENU | translate"
class="project-settings-btn"
mat-icon-button
>
<mat-icon>more_vert</mat-icon>
</button>
<div class="page-title-actions">
<button
[mat-menu-trigger-for]="activeWorkContextMenu"
[matTooltip]="T.MH.PROJECT_MENU | translate"
class="project-settings-btn"
mat-icon-button
>
<mat-icon>more_vert</mat-icon>
</button>
@if (isWorkViewPage()) {
<button
class="task-filter-btn"
[class.isCustomized]="taskViewCustomizerService.isCustomized()"
[matMenuTriggerFor]="customizerPanel.menu"
mat-icon-button
matTooltip="{{
T.GCF.KEYBOARD.TOGGLE_TASK_VIEW_CUSTOMIZER_PANEL | translate
}} {{
kb.toggleTaskViewCustomizerPanel
? '[' + kb.toggleTaskViewCustomizerPanel + ']'
: ''
}}"
>
<mat-icon>filter_list</mat-icon>
</button>
<task-view-customizer-panel #customizerPanel></task-view-customizer-panel>
}
</div>
}
<mat-menu #activeWorkContextMenu="matMenu">
<ng-template matMenuContent>
@ -84,21 +110,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) {
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 {
position: relative;
transition: all 0.2s ease;
overflow: visible !important;
.mat-icon {
transition: transform 0.2s ease;
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 + & {
opacity: 1;
&.isCustomized {
color: var(--c-accent);
box-shadow: none;
}
&: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;
}
}
`,
@ -109,6 +170,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 +203,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 +228,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) || {};
}
}

View file

@ -62,17 +62,6 @@
</button>
}
<button
mat-menu-item
[disabled]="!isWorkViewPage()"
[class.active]="isShowTaskViewCustomizerPanel()"
[class.isCustomized]="taskViewCustomizerService.isCustomized()"
(click)="toggleTaskViewCustomizer()"
>
<mat-icon>filter_list</mat-icon>
<span>{{ T.BN.SHOW_TASK_VIEW_CUSTOMIZER_PANEL | translate }}</span>
</button>
<button
mat-menu-item
[disabled]="!isRouteWithSidePanel()"

View file

@ -10,7 +10,6 @@ import { toSignal } from '@angular/core/rxjs-interop';
import { filter, map, startWith } from 'rxjs/operators';
import { LayoutService } from '../layout/layout.service';
import { TaskViewCustomizerService } from '../../features/task-view-customizer/task-view-customizer.service';
import { PluginBridgeService } from '../../plugins/plugin-bridge.service';
import { PluginIconComponent } from '../../plugins/ui/plugin-icon/plugin-icon.component';
import { Store } from '@ngrx/store';
@ -43,7 +42,6 @@ import { WorkContextService } from '../../features/work-context/work-context.ser
export class MobileBottomNavComponent {
private readonly _router = inject(Router);
private readonly _layoutService = inject(LayoutService);
private readonly _taskViewCustomizerService = inject(TaskViewCustomizerService);
private readonly _pluginBridge = inject(PluginBridgeService);
private readonly _store = inject(Store);
private readonly _workContextService = inject(WorkContextService);
@ -55,7 +53,6 @@ export class MobileBottomNavComponent {
// Services for template access
readonly layoutService = this._layoutService;
readonly taskViewCustomizerService = this._taskViewCustomizerService;
// Output events
toggleMobileNavEvent = output<void>();
@ -88,20 +85,9 @@ export class MobileBottomNavComponent {
{ initialValue: true },
);
readonly isWorkViewPage = toSignal(
this._router.events.pipe(
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
map((event) => !!event.urlAfterRedirects.match(/tasks$/)),
startWith(!!this._router.url.match(/tasks$/)),
),
{ initialValue: !!this._router.url.match(/tasks$/) },
);
// Panel state signals from layout service
readonly isShowNotes = this._layoutService.isShowNotes;
readonly isShowIssuePanel = this._layoutService.isShowIssuePanel;
readonly isShowTaskViewCustomizerPanel =
this._layoutService.isShowTaskViewCustomizerPanel;
// Navigation methods
showAddTaskBar(): void {
@ -126,10 +112,6 @@ export class MobileBottomNavComponent {
}
}
toggleTaskViewCustomizer(): void {
this._layoutService.toggleTaskViewCustomizerPanel();
}
toggleIssuePanel(): void {
this._layoutService.toggleAddTaskPanel();
}

View file

@ -24,6 +24,21 @@
</button>
}
<button
(click)="shareTasksAsMarkdown()"
mat-menu-item
>
<mat-icon>{{ shareSupport === 'none' ? 'content_copy' : 'ios_share' }}</mat-icon>
<span class="text">
{{
(shareSupport === 'none'
? T.MH.COPY_TASK_LIST_MARKDOWN
: T.MH.SHARE_TASK_LIST_MARKDOWN
) | translate
}}
</span>
</button>
<button
[routerLink]="[base, contextId, 'settings']"
mat-menu-item

View file

@ -1,4 +1,11 @@
import { ChangeDetectionStrategy, Component, inject, Input } from '@angular/core';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
OnInit,
inject,
Input,
} from '@angular/core';
import { WorkContextType } from '../../features/work-context/work-context.model';
import { T } from 'src/app/t.const';
import { TODAY_TAG } from '../../features/tag/tag.const';
@ -14,6 +21,9 @@ import { MatMenuItem } from '@angular/material/menu';
import { TranslatePipe } from '@ngx-translate/core';
import { MatIcon } from '@angular/material/icon';
import { INBOX_PROJECT } from '../../features/project/project.const';
import { SnackService } from '../../core/snack/snack.service';
import { WorkContextMarkdownService } from '../../features/work-context/work-context-markdown.service';
import { ShareService, ShareSupport } from '../../core/share/share.service';
@Component({
selector: 'work-context-menu',
@ -23,12 +33,16 @@ import { INBOX_PROJECT } from '../../features/project/project.const';
imports: [RouterLink, RouterModule, MatMenuItem, TranslatePipe, MatIcon],
standalone: true,
})
export class WorkContextMenuComponent {
export class WorkContextMenuComponent implements OnInit {
private _matDialog = inject(MatDialog);
private _tagService = inject(TagService);
private _projectService = inject(ProjectService);
private _workContextService = inject(WorkContextService);
private _router = inject(Router);
private _snackService = inject(SnackService);
private _markdownService = inject(WorkContextMarkdownService);
private _shareService = inject(ShareService);
private _cd = inject(ChangeDetectorRef);
// TODO: Skipped for migration because:
// This input is used in a control flow expression (e.g. `@if` or `*ngIf`)
@ -38,6 +52,7 @@ export class WorkContextMenuComponent {
TODAY_TAG_ID: string = TODAY_TAG.id as string;
isForProject: boolean = true;
base: string = 'project';
shareSupport: ShareSupport = 'none';
// TODO: Skipped for migration because:
// Accessor inputs cannot be migrated as they are too complex.
@ -46,6 +61,11 @@ export class WorkContextMenuComponent {
this.base = this.isForProject ? 'project' : 'tag';
}
async ngOnInit(): Promise<void> {
const support = await this._shareService.getShareSupport();
this._setShareSupport(support);
}
async deleteTag(): Promise<void> {
const tag = await this._tagService
.getTagById$(this.contextId)
@ -94,4 +114,59 @@ export class WorkContextMenuComponent {
}
protected readonly INBOX_PROJECT = INBOX_PROJECT;
async shareTasksAsMarkdown(): Promise<void> {
const { status, markdown, contextTitle } =
await this._markdownService.getMarkdownForContext(
this.contextId,
this.isForProject,
);
if (status === 'empty' || !markdown) {
this._snackService.open(T.GLOBAL_SNACK.NO_TASKS_TO_COPY);
return;
}
const shareResult = await this._shareService.shareText({
title: contextTitle ?? 'Super Productivity',
text: markdown,
});
if (shareResult === 'shared') {
if (this.shareSupport === 'none') {
const support = await this._shareService.getShareSupport();
this._setShareSupport(support);
}
return;
}
if (shareResult === 'cancelled') {
return;
}
const didCopy = await this._markdownService.copyMarkdownText(markdown);
if (didCopy) {
if (shareResult === 'unavailable') {
this._snackService.open(T.GLOBAL_SNACK.SHARE_UNAVAILABLE_FALLBACK);
this._setShareSupport('none');
} else if (shareResult === 'failed') {
this._snackService.open(T.GLOBAL_SNACK.SHARE_FAILED_FALLBACK);
this._setShareSupport('none');
} else {
this._snackService.open(T.GLOBAL_SNACK.COPY_TO_CLIPPBOARD);
}
return;
}
this._snackService.open({
msg: T.GLOBAL_SNACK.SHARE_FAILED,
type: 'ERROR',
});
this._setShareSupport('none');
}
private _setShareSupport(support: ShareSupport): void {
this.shareSupport = support;
this._cd.markForCheck();
}
}

View file

@ -0,0 +1,248 @@
# Share Component
Multi-platform share functionality for Super Productivity.
## Overview
This module provides a reusable share system that works across all platforms:
- **Desktop (Electron)**: Opens share URLs in browser via shell
- **Mobile (Android)**: Uses Capacitor Share plugin when available
- **Web (PWA)**: Uses Web Share API when available
- **Fallback**: Material dialog with intent URLs for all social platforms
## Quick Start
### Basic Usage
```typescript
import { ShareService } from './core/share/share.service';
import { ShareFormatter } from './core/share/share-formatter';
// In your component
constructor(private shareService: ShareService) {}
async shareWorkSummary() {
const payload = ShareFormatter.formatWorkSummary({
totalTimeSpent: 3600000, // 1 hour in ms
tasksCompleted: 5,
dateRange: {
start: '2024-01-01',
end: '2024-01-07',
},
}, {
includeUTM: true,
includeHashtags: true,
});
await this.shareService.share(payload);
}
```
### Using the Share Button Component
```html
<share-button
[payload]="mySharePayload"
tooltip="Share your achievements"
/>
```
```typescript
// In component
import { ShareButtonComponent } from './core/share/share-button/share-button.component';
import { ShareFormatter } from './core/share/share-formatter';
@Component({
imports: [ShareButtonComponent],
// ...
})
export class MyComponent {
readonly sharePayload = ShareFormatter.formatWorkSummary({
totalTimeSpent: this.totalTime,
tasksCompleted: this.completedTaskCount,
});
}
```
## API Reference
### ShareService
Main service for sharing content.
#### Methods
- `share(payload: SharePayload, target?: ShareTarget): Promise<ShareResult>`
- Main share method that automatically detects platform and uses best method
- If target is specified, shares directly to that target
- Otherwise, tries native share first, then shows dialog
- `getShareTargets(): ShareTargetConfig[]`
- Returns list of available share targets with their configurations
### ShareFormatter
Utility class for formatting content into shareable payloads.
#### Methods
- `formatWorkSummary(data: WorkSummaryData, options?: ShareFormatterOptions): SharePayload`
- Formats work statistics as shareable text with time spent, tasks completed, etc.
- `formatPromotion(customText?: string, options?: ShareFormatterOptions): SharePayload`
- Creates a promotional share payload for the app
- `optimizeForTwitter(payload: SharePayload): SharePayload`
- Truncates text to fit Twitter's character limit
### SharePayload Interface
```typescript
interface SharePayload {
text?: string; // Main text content
url?: string; // URL to share
title?: string; // Optional title (used by Reddit, Email)
files?: string[]; // Optional file paths for native share (future use)
}
```
### ShareTarget Type
Supported share targets:
- `twitter` - Twitter/X
- `linkedin` - LinkedIn
- `reddit` - Reddit
- `facebook` - Facebook
- `whatsapp` - WhatsApp
- `telegram` - Telegram
- `email` - Email
- `mastodon` - Mastodon (with custom instance support)
- `clipboard-text` - Copy formatted text to clipboard
- `native` - Use native OS share sheet
## Platform Support
### Desktop (Electron)
- Uses `shell.openExternal()` to open share URLs
- Native share handler stubbed out (ready for macOS/Windows native implementation)
- IPC event: `SHARE_NATIVE`
### Mobile (Android via Capacitor)
- Checks for Capacitor Share plugin at runtime via `window.Capacitor?.Plugins?.Share`
- No build-time dependency required
- Falls back gracefully if plugin not installed
### Web (PWA/Browser)
- Uses Web Share API when available
- Falls back to share dialog with intent URLs
## Architecture
### Files Structure
```
src/app/core/share/
├── share.model.ts # TypeScript interfaces
├── share-formatter.ts # Work summary formatter
├── share-formatter.spec.ts # Formatter tests
├── share.service.ts # Main share service
├── share.service.spec.ts # Service tests
├── 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
```
### Electron Integration
```
electron/
├── shared-with-frontend/
│ └── ipc-events.const.ts # Added SHARE_NATIVE event
├── electronAPI.d.ts # Added shareNative method
├── preload.ts # Exposed shareNative to renderer
└── ipc-handler.ts # IPC handler (fallback stub)
```
## Future Enhancements
### Native OS Share Implementation
The Electron IPC handler currently returns a fallback error. To implement true native share:
#### macOS
Create a Swift/Objective-C bridge using `NSSharingServicePicker`:
```swift
import Cocoa
@objc class ShareHelper: NSObject {
@objc static func share(text: String, url: String, files: [String]) {
let items = [text, URL(string: url)!] + files.map { URL(fileURLWithPath: $0) }
let picker = NSSharingServicePicker(items: items)
// Show picker at mouse location
}
}
```
#### Windows
Create a C#/C++ bridge using WinRT `DataTransferManager`:
```csharp
using Windows.ApplicationModel.DataTransfer;
var dataTransferManager = DataTransferManager.GetForCurrentView();
dataTransferManager.DataRequested += (sender, args) => {
args.Request.Data.SetText(text);
args.Request.Data.SetWebLink(new Uri(url));
};
DataTransferManager.ShowShareUI();
```
### Capacitor Plugin Installation
To enable native Android sharing, install the Capacitor Share plugin:
```bash
npm install @capacitor/share
```
The service will automatically detect and use it when available.
## Testing
Unit tests are included for:
- `share-formatter.spec.ts` - Tests formatting logic
- `share.service.spec.ts` - Tests service methods and URL building
Run tests:
```bash
npm test
```
## Contributing
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 `dialog-share.component.ts`
4. Add tests in `share.service.spec.ts`
## License
Part of Super Productivity - see main project LICENSE.

View file

@ -0,0 +1,66 @@
<h2 mat-dialog-title>Share</h2>
<div mat-dialog-content>
<div class="share-options">
<!-- Native Share Button (if available) -->
@if (data.showNative) {
<button
mat-raised-button
color="primary"
class="share-target-button native-button"
(click)="shareNative()"
>
<mat-icon>share</mat-icon>
<span>System Share</span>
</button>
}
<!-- Social Media Share Buttons -->
@for (shareTarget of shareTargets; track shareTarget.target) {
<button
mat-stroked-button
class="share-target-button"
(click)="shareToTarget(shareTarget.target)"
>
<mat-icon>{{ shareTarget.icon }}</mat-icon>
<span>{{ shareTarget.label }}</span>
</button>
}
</div>
<!-- Mastodon Instance Input -->
<div class="mastodon-instance">
<mat-form-field appearance="outline">
<mat-label>Mastodon Instance</mat-label>
<input
matInput
[(ngModel)]="mastodonInstance"
placeholder="mastodon.social"
/>
<mat-icon matPrefix>language</mat-icon>
</mat-form-field>
</div>
<!-- Clipboard Actions -->
<div class="clipboard-actions">
<button
mat-button
(click)="copyText()"
>
<mat-icon>content_copy</mat-icon>
<span>Copy Text</span>
</button>
</div>
</div>
<div
mat-dialog-actions
align="end"
>
<button
mat-button
(click)="close()"
>
Cancel
</button>
</div>

View file

@ -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;
}

View file

@ -0,0 +1,90 @@
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: 'dialog-share',
templateUrl: './dialog-share.component.html',
styleUrls: ['./dialog-share.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
MatDialogModule,
MatButtonModule,
MatIconModule,
MatInputModule,
MatFormFieldModule,
FormsModule,
],
})
export class DialogShareComponent {
private _dialogRef = inject(MatDialogRef<DialogShareComponent>);
private _shareService = inject(ShareService);
readonly data = inject<ShareDialogOptions>(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<void> {
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<void> {
const result: ShareResult = await this._shareService.tryNativeShare(
this.data.payload,
);
if (result.success) {
this._dialogRef.close(result);
}
}
async copyText(): Promise<void> {
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();
}
}

View file

@ -0,0 +1,63 @@
import { Component, ChangeDetectionStrategy, inject, input } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { ShareService } from '../share.service';
import { SharePayload } from '../share.model';
/**
* Reusable share button component
* Can be placed anywhere in the app to trigger sharing
*/
@Component({
selector: 'share-button',
template: `
<button
mat-icon-button
[matTooltip]="tooltip()"
(click)="share()"
[disabled]="disabled()"
>
<mat-icon>share</mat-icon>
</button>
`,
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],
})
export class ShareButtonComponent {
private _shareService = inject(ShareService);
/** The payload to share when button is clicked */
readonly payload = input.required<SharePayload>();
/** Tooltip text (default: 'Share') */
readonly tooltip = input<string>('Share');
/** Whether button is disabled */
readonly disabled = input<boolean>(false);
/**
* Trigger share action
*/
async share(): Promise<void> {
await this._shareService.share(this.payload());
}
}

View file

@ -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 ?? '').toMatch(/\.\.\.$/);
});
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);
});
});
});

View file

@ -0,0 +1,226 @@
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;
/** Optional: Detailed metrics table data */
detailedMetrics?: {
timeEstimate?: number;
totalTasks?: number;
daysWorked?: number;
avgTasksPerDay?: number;
avgBreakNr?: number;
avgTimeSpentOnDay?: number;
avgTimeSpentOnTask?: number;
avgTimeSpentOnTaskIncludingSubTasks?: number;
avgBreakTime?: number;
};
}
/**
* 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 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('');
// 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('');
parts.push('Top 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;
}
}

View file

@ -0,0 +1,68 @@
/**
* 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-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;
}

View file

@ -0,0 +1,658 @@
import { Injectable, inject } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { Capacitor } from '@capacitor/core';
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';
interface ShareParams {
title?: string | null;
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<ShareSupport>;
private _snackService = inject(SnackService);
private _matDialog = inject(MatDialog);
async shareText({ title, text }: ShareParams): Promise<ShareOutcome> {
if (!text || typeof window === 'undefined') {
return 'failed';
}
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';
}
return 'failed';
}
async getShareSupport(): Promise<ShareSupport> {
if (typeof window === 'undefined') {
return 'none';
}
if (!this._shareSupportPromise) {
this._shareSupportPromise = this._detectShareSupport();
}
return this._shareSupportPromise;
}
/**
* Main share method - automatically detects platform and uses best method.
*/
async share(payload: SharePayload, target?: ShareTarget): Promise<ShareResult> {
const normalizedPayload = this._ensureShareText(payload);
if (!payload.text && !payload.url) {
return {
success: false,
error: 'No content to share',
};
}
if (target) {
return this.shareToTarget(payload, target);
}
const nativeResult = await this.tryNativeShare(normalizedPayload);
if (nativeResult.success) {
return nativeResult;
}
return this._showShareDialog(normalizedPayload);
}
/**
* Share to a specific target (public API for dialog component).
*/
async shareToTarget(payload: SharePayload, target: ShareTarget): Promise<ShareResult> {
const normalized = this._ensureShareText(payload);
try {
switch (target) {
case 'native':
return this.tryNativeShare(normalized);
case 'clipboard-text':
return this.copyToClipboard(this.formatTextForClipboard(payload), 'Text');
default:
return this._openShareUrl(normalized, target);
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
target,
};
}
}
/**
* Try to use native share (Android, Web Share API).
* Public API for dialog component.
*/
async tryNativeShare(payload: SharePayload): Promise<ShareResult> {
const normalized = this._ensureShareText(payload);
const capacitorShare = await this._getCapacitorSharePlugin();
if (capacitorShare) {
try {
await capacitorShare.share({
title: normalized.title,
text: normalized.text,
url: normalized.url,
files: normalized.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: normalized.title,
text: normalized.text,
url: normalized.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: normalized.title,
text: normalized.text,
url: normalized.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<ShareResult> {
try {
// Import dialog component dynamically to avoid circular dependencies
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<ShareResult> {
const normalized = this._ensureShareText(payload);
const url = this._buildShareUrl(normalized, target);
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 shareUrl = payload.url?.trim() || FALLBACK_SHARE_URL;
const baseTitle = this._getShareTitle(payload);
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}&quote=${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(providerText || shareUrl)}`;
}
default:
throw new Error(`Unknown share target: ${target}`);
}
}
/**
* Copy text to clipboard (public API for dialog component).
*/
async copyToClipboard(text: string, label: string): Promise<ShareResult> {
try {
await navigator.clipboard.writeText(text);
this._snackService.open(`${label} copied to clipboard!`);
return {
success: true,
target: '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: 'clipboard-text',
};
} catch (fallbackError) {
return {
success: false,
error: 'Failed to copy to clipboard',
};
}
}
}
/**
* Format payload as plain text for clipboard (public API for dialog component).
*/
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<boolean> {
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 _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<ShareSupport> {
if (await this._isCapacitorShareAvailable()) {
return 'native';
}
if (IS_ANDROID_WEB_VIEW) {
const win = window as any;
if (win.Capacitor?.Plugins?.Share) {
return 'native';
}
}
if (typeof navigator !== 'undefined' && typeof navigator.share === 'function') {
return 'web';
}
return 'none';
}
private async _isCapacitorShareAvailable(): Promise<boolean> {
const sharePlugin = await this._getCapacitorSharePlugin();
return !!sharePlugin;
}
private async _getCapacitorSharePlugin(): Promise<any | null> {
if (!Capacitor.isNativePlatform() || typeof window === 'undefined') {
return null;
}
const win = window as any;
const sharePlugin = win.Capacitor?.Plugins?.Share;
if (sharePlugin && typeof sharePlugin.share === 'function') {
return sharePlugin;
}
return null;
}
}

View file

@ -0,0 +1,11 @@
<mat-form-field>
<mat-label>{{ to.label }}</mat-label>
<input
type="color"
class="color-input"
[formControl]="formControl"
[formlyAttributes]="field"
(focus)="onFocus($event)"
matInput
/>
</mat-form-field>

View file

@ -0,0 +1,3 @@
mat-form-field {
width: 100%;
}

View file

@ -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();
}
}
}

View file

@ -24,7 +24,7 @@ export const DEFAULT_GLOBAL_CONFIG: GlobalConfigState = {
firstDayOfWeek: 1,
startOfNextDay: 0,
isDisableAnimations: false,
isDisableProductivityTips: false,
isShowProductivityTipLonger: false,
taskNotesTpl: `**How can I best achieve it now?**
**What do I want?**

View file

@ -127,10 +127,10 @@ export const MISC_SETTINGS_FORM_CFG: ConfigFormSection<MiscConfig> = {
},
},
{
key: 'isDisableProductivityTips',
key: 'isShowProductivityTipLonger',
type: 'checkbox',
templateOptions: {
label: T.GCF.MISC.IS_DISABLE_PRODUCTIVITY_TIPS,
label: T.GCF.MISC.IS_SHOW_TIP_LONGER,
},
},
{

View file

@ -20,7 +20,7 @@ export type MiscConfig = Readonly<{
taskNotesTpl: string;
isDisableAnimations: boolean;
// optional because it was added later
isDisableProductivityTips?: boolean;
isShowProductivityTipLonger?: boolean;
isTrayShowCurrentCountdown?: boolean;
isOverlayIndicatorEnabled?: boolean;
customTheme?: string;

View file

@ -15,22 +15,13 @@
</button>
<ng-template #selectModeTpl>
<mat-button-toggle-group
<segmented-button-group
class="focus-mode-select-mode"
[value]="selectedMode()"
style="margin-bottom: -32px"
(change)="selectMode($event.value)"
>
<mat-button-toggle [value]="FocusModeMode.Flowtime"
>{{ T.F.FOCUS_MODE.FLOWTIME | translate }}
</mat-button-toggle>
<mat-button-toggle [value]="FocusModeMode.Pomodoro"
>{{ T.F.FOCUS_MODE.POMODORO | translate }}
</mat-button-toggle>
<mat-button-toggle [value]="FocusModeMode.Countdown"
>{{ T.F.FOCUS_MODE.COUNTDOWN | translate }}
</mat-button-toggle>
</mat-button-toggle-group>
[options]="modeOptions"
[selectedId]="selectedMode()"
[ariaLabel]="T.F.FOCUS_MODE.SELECT_MODE | translate"
(selectionChange)="selectMode($event)"
></segmented-button-group>
</ng-template>
<!-- -->

View file

@ -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,

View file

@ -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<SegmentedButtonOption> = [
{
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 {

View file

@ -0,0 +1,67 @@
<section class="activity-heatmap">
<div class="heatmap-header">
<h1 class="heatmap-title">{{ T.F.METRIC.CMP.ACTIVITY_HEATMAP | translate }}</h1>
<div class="heatmap-header__actions">
@if (heatmapData()) {
<button
mat-icon-button
(click)="shareHeatmap()"
[disabled]="isSharing()"
[matTooltip]="'Share Heatmap' | translate"
>
@if (isSharing()) {
<mat-icon>hourglass_empty</mat-icon>
} @else {
<mat-icon>share</mat-icon>
}
</button>
}
</div>
</div>
@if (heatmapData(); as data) {
<div class="heatmap-container">
<div class="heatmap-grid">
<div class="day-labels">
<div class="month-spacer"></div>
@for (label of dayLabels(); track $index) {
<div class="day-label">{{ label }}</div>
}
</div>
<div class="scrollable-content">
<div class="heatmap-months">
@for (month of data.monthLabels; track $index) {
<div class="month-label">{{ month }}</div>
}
</div>
<div class="weeks">
@for (week of data.weeks; track $index) {
<div class="week">
@for (day of week.days; track $index) {
<div
[class]="getDayClass(day)"
[title]="getDayTitle(day)"
></div>
}
</div>
}
</div>
</div>
</div>
<div class="heatmap-legend">
<span>Less</span>
<div class="legend-item level-0"></div>
<div class="legend-item level-1"></div>
<div class="legend-item level-2"></div>
<div class="legend-item level-3"></div>
<div class="legend-item level-4"></div>
<span>More</span>
</div>
</div>
} @else {
<p>{{ T.F.METRIC.CMP.NO_ADDITIONAL_DATA_YET | translate }}</p>
}
</section>

View file

@ -0,0 +1,251 @@
.activity-heatmap {
margin: 24px 0;
.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;
}
}
}
}
.heatmap-container {
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px;
background: rgba(0, 0, 0, 0.02);
border-radius: 4px;
}
.heatmap-grid {
display: flex;
gap: 8px;
align-items: flex-start;
}
.scrollable-content {
display: flex;
flex-direction: column;
gap: 8px;
overflow-x: auto;
overflow-y: hidden;
// Smooth scrolling
scroll-behavior: smooth;
// Better scrollbar styling
&::-webkit-scrollbar {
height: 8px;
}
&::-webkit-scrollbar-track {
background: 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);
}
}
}
.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 {
width: 12px;
height: 12px;
border-radius: 2px;
cursor: pointer;
transition: all 0.1s ease;
flex-shrink: 0;
&.empty {
background: transparent;
cursor: default;
}
&.level-0 {
background: rgba(0, 0, 0, 0.05);
}
&.level-1 {
background: color-mix(in srgb, var(--c-primary) 20%, transparent);
}
&.level-2 {
background: color-mix(in srgb, var(--c-primary) 40%, transparent);
}
&.level-3 {
background: color-mix(in srgb, var(--c-primary) 60%, transparent);
}
&.level-4 {
background: var(--c-primary);
}
&:not(.empty):hover {
transform: scale(1.3);
outline: 1px solid 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: color-mix(in srgb, var(--c-primary) 20%, transparent);
}
&.level-2 {
background: color-mix(in srgb, var(--c-primary) 40%, transparent);
}
&.level-3 {
background: color-mix(in srgb, var(--c-primary) 60%, transparent);
}
&.level-4 {
background: var(--c-primary);
}
}
}
// Dark theme support
@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);
}
.scrollable-content {
&::-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);
}
}
}

View file

@ -0,0 +1,643 @@
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';
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 { 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';
import { GlobalConfigService } from '../../config/global-config.service';
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: [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);
private readonly _globalConfigService = inject(GlobalConfigService);
T: typeof T = T;
weeks: WeekData[] = [];
isSharing = signal(false);
private readonly _activeWorkContextTitle = toSignal(
this._workContextService.activeWorkContextTitle$,
{ initialValue: '' },
);
// 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
if (context.id === TODAY_TAG.id) {
// Use defer to ensure the Promise is created fresh each time
return defer(() => from(this._loadAllTasks())).pipe(
map((tasks) => this._buildHeatmapDataFromTasks(tasks)),
);
}
// Normal case: use context-filtered worklog
return this._worklogService.worklog$.pipe(
map((worklog) => this._buildHeatmapData(worklog)),
);
}),
),
{ 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<Task[]> {
// Load both current tasks and archived tasks
const [archive, currentTasks] = await Promise.all([
this._taskArchiveService.load(),
this._taskService.allTasks$.pipe(first()).toPromise(),
]);
const allTasks: Task[] = [...(currentTasks || [])];
// Add archived tasks (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);
}
});
}
return allTasks;
}
private _buildHeatmapDataFromTasks(tasks: Task[]): {
dayMap: Map<string, DayData>;
startDate: Date;
endDate: Date;
} | null {
const dayMap = new Map<string, DayData>();
const now = new Date();
const oneYearAgo = new Date(now);
oneYearAgo.setFullYear(now.getFullYear() - 1);
// Initialize all days in the past year
const currentDate = new Date(oneYearAgo);
while (currentDate <= now) {
const dateStr = this._getDateStr(currentDate);
dayMap.set(dateStr, {
date: new Date(currentDate),
dateStr,
taskCount: 0,
timeSpent: 0,
level: 0,
});
currentDate.setDate(currentDate.getDate() + 1);
}
// Extract time spent data from all tasks
let maxTasks = 0;
let maxTime = 0;
const taskCountPerDay = new Map<string, Set<string>>();
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
// 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;
// eslint-disable-next-line no-mixed-operators
const combinedRatio = timeRatio * 0.8 + taskRatio * 0.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 {
dayMap,
startDate: oneYearAgo,
endDate: now,
};
}
private _buildHeatmapData(worklog: any): {
dayMap: Map<string, DayData>;
startDate: Date;
endDate: Date;
} | null {
if (!worklog) {
return null;
}
const dayMap = new Map<string, DayData>();
const now = new Date();
const oneYearAgo = new Date(now);
oneYearAgo.setFullYear(now.getFullYear() - 1);
// Initialize all days in the past year
const currentDate = new Date(oneYearAgo);
while (currentDate <= now) {
const dateStr = this._getDateStr(currentDate);
dayMap.set(dateStr, {
date: new Date(currentDate),
dateStr,
taskCount: 0,
timeSpent: 0,
level: 0,
});
currentDate.setDate(currentDate.getDate() + 1);
}
// 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
// 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;
// eslint-disable-next-line no-mixed-operators
const combinedRatio = timeRatio * 0.8 + taskRatio * 0.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 {
dayMap,
startDate: oneYearAgo,
endDate: 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<string, DayData>,
startDate: Date,
endDate: Date,
firstDayOfWeek: number = 0,
): { weeks: WeekData[]; monthLabels: string[] } {
const weeks: WeekData[] = [];
const monthLabels: string[] = [];
let currentMonth = -1;
// Find the first day (based on firstDayOfWeek setting) before or on the start date
const firstDay = new Date(startDate);
const dayOfWeek = firstDay.getDay();
// 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);
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`;
}
async shareHeatmap(): Promise<void> {
const data = this.heatmapData();
if (!data) {
return;
}
this.isSharing.set(true);
try {
// Render heatmap to canvas
const contextTitle = this._activeWorkContextTitle();
const canvas = this._renderToCanvas(data, contextTitle);
// Convert to blob
const blob: Blob | null = await new Promise((resolve) => {
canvas.toBlob((b) => resolve(b), 'image/png', 1.0);
});
if (!blob) {
throw new Error('Failed to generate image');
}
const file = new File([blob], 'activity-heatmap.png', { type: 'image/png' });
// Try native share API first
if (navigator.canShare && navigator.canShare({ files: [file] })) {
await navigator.share({
files: [file],
title: 'Activity Heatmap',
});
} else {
// Fallback: Download the file
this._downloadFile(blob, 'activity-heatmap.png');
}
this._snackService.open({
type: 'SUCCESS',
msg: 'Heatmap shared successfully',
});
} catch (error: any) {
// User cancelled or error occurred
if (error?.name !== 'AbortError') {
console.error('Share failed:', error);
this._snackService.open({
type: 'ERROR',
msg: 'Failed to share heatmap',
});
}
} finally {
this.isSharing.set(false);
}
}
private _renderToCanvas(
data: {
weeks: WeekData[];
monthLabels: string[];
},
contextTitle: string,
): HTMLCanvasElement {
const cellSize = 12;
const gap = 2;
const dayLabelWidth = 40;
const monthLabelHeight = 20;
const padding = 16;
const weekHeight = 7 * (cellSize + gap);
const doublePadding = padding * 2;
const heatmapHeight = monthLabelHeight + weekHeight;
const baseCanvasHeight = heatmapHeight + doublePadding;
const taglineHeight = 32;
// Calculate dimensions
const numWeeks = data.weeks.length;
const weeksWidth = numWeeks * (cellSize + gap);
const canvasWidth = dayLabelWidth + weeksWidth + doublePadding;
const canvasHeight = baseCanvasHeight + taglineHeight;
// Create canvas
const canvas = document.createElement('canvas');
canvas.width = canvasWidth;
canvas.height = canvasHeight;
const ctx = canvas.getContext('2d')!;
// Background
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
// Day labels (Sun, Mon, etc.)
ctx.fillStyle = 'rgba(0, 0, 0, 0.6)';
ctx.font = '10px system-ui, -apple-system, sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
const dayNames = this.dayLabels();
dayNames.forEach((day, i) => {
// eslint-disable-next-line no-mixed-operators
const y = padding + monthLabelHeight + i * (cellSize + gap) + cellSize / 2;
ctx.fillText(day, padding + dayLabelWidth - 4, y);
});
// Month labels
ctx.font = '12px system-ui, -apple-system, sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
data.monthLabels.forEach((month, i) => {
// eslint-disable-next-line no-mixed-operators
const x = padding + dayLabelWidth + i * 4 * (cellSize + gap);
ctx.fillText(month, x, padding);
});
// Get primary color from CSS variable or use default
const primaryColor =
getComputedStyle(document.documentElement).getPropertyValue('--c-primary').trim() ||
'#3f51b5';
// Draw heatmap cells
data.weeks.forEach((week, weekIndex) => {
week.days.forEach((day, dayIndex) => {
if (day) {
// eslint-disable-next-line no-mixed-operators
const x = padding + dayLabelWidth + weekIndex * (cellSize + gap);
// eslint-disable-next-line no-mixed-operators
const y = padding + monthLabelHeight + dayIndex * (cellSize + gap);
// Set color based on level
if (day.level === 0) {
ctx.fillStyle = 'rgba(0, 0, 0, 0.05)';
} else {
// Mix primary color with transparency
const opacity = day.level * 0.2; // 0.2, 0.4, 0.6, 0.8, 1.0
ctx.fillStyle = this._mixColor(primaryColor, opacity);
}
// Draw rounded rectangle
this._roundRect(ctx, x, y, cellSize, cellSize, 2);
}
});
});
const normalizedTitle = contextTitle?.trim().length
? contextTitle.trim()
: 'Super Productivity';
const shareLabel = `${normalizedTitle} With the Super Productivity App`;
ctx.fillStyle = 'rgba(0, 0, 0, 0.6)';
ctx.font = '14px system-ui, -apple-system, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const taglineOffset = taglineHeight / 2;
const taglineY = baseCanvasHeight + taglineOffset;
ctx.fillText(shareLabel, canvasWidth / 2, taglineY);
return canvas;
}
private _mixColor(color: string, opacity: number): string {
// Simple color mixing - assumes hex or rgb color
if (color.startsWith('#')) {
// Convert hex to rgb
const r = parseInt(color.slice(1, 3), 16);
const g = parseInt(color.slice(3, 5), 16);
const b = parseInt(color.slice(5, 7), 16);
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
}
// Assume it's already in rgb/rgba format
return color.replace(
/rgba?\([^)]+\)/,
`rgba(${color.match(/\d+/g)?.slice(0, 3).join(',')}, ${opacity})`,
);
}
private _roundRect(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
radius: number,
): void {
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + width - radius, y);
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
ctx.lineTo(x + width, y + height - radius);
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
ctx.lineTo(x + radius, y + height);
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
ctx.lineTo(x, y + radius);
ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.closePath();
ctx.fill();
}
private _downloadFile(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
}

View file

@ -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: `
<div class="chart-wrapper">
<button
mat-icon-button
class="share-btn"
(click)="shareChart()"
[disabled]="!isLoaded || isSharing"
[matTooltip]="'Share Chart' | translate"
>
@if (isSharing) {
<mat-icon>hourglass_empty</mat-icon>
} @else {
<mat-icon>share</mat-icon>
}
</button>
@if (!isLoaded) {
<div class="chart-loading">Loading chart...</div>
}
@ -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<ChartClickEvent>();
@ -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<void> {
@ -160,4 +193,83 @@ export class LazyChartComponent implements OnInit, OnDestroy {
this.chartInstance = undefined;
}
}
async shareChart(): Promise<void> {
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);
}
}

View file

@ -4,12 +4,15 @@
class="basic-stats"
[@fade]
>
<h1
class="mat-h1"
style="text-align: center"
>
{{ T.PM.TITLE | translate }}
</h1>
<div class="metrics-header">
<h1 class="mat-h1">
{{ T.PM.TITLE | translate }}
</h1>
<share-button
[payload]="sharePayload()"
[tooltip]="'Share your productivity stats'"
/>
</div>
<!-- <h2>Basic Metrics</h2>-->
<p>
<i>{{ sm.start }} {{ sm.end }}</i>
@ -63,6 +66,11 @@
</div>
</section>
}
<section style="max-width: 880px; margin: auto; margin-top: 32px">
<activity-heatmap></activity-heatmap>
</section>
@if (!metricService.hasData()) {
<p style="margin-top: 32px">
{{ T.F.METRIC.CMP.NO_ADDITIONAL_DATA_YET | translate }}
@ -71,36 +79,6 @@
@if (metricService.hasData()) {
<section class="metric-metrics">
<h1>{{ T.F.METRIC.CMP.GLOBAL_METRICS | translate }}</h1>
<section class="pie-charts">
@if (metricService.improvementCountsPieChartData(); as improvementCounts) {
<section>
<h3>{{ T.F.METRIC.CMP.IMPROVEMENT_SELECTION_COUNT | translate }}</h3>
<lazy-chart
[type]="pieChartType"
[datasets]="improvementCounts.datasets"
[labels]="improvementCounts.labels"
[legend]="improvementCounts?.datasets[0].data.length < 12"
[options]="pieChartOptions"
height="300px"
>
</lazy-chart>
</section>
}
@if (metricService.obstructionCountsPieChartData(); as obstructionCounts) {
<section>
<h3>{{ T.F.METRIC.CMP.OBSTRUCTION_SELECTION_COUNT | translate }}</h3>
<lazy-chart
[type]="pieChartType"
[datasets]="obstructionCounts.datasets"
[labels]="obstructionCounts.labels"
[legend]="obstructionCounts?.datasets[0].data.length < 12"
[options]="pieChartOptions"
height="300px"
>
</lazy-chart>
</section>
}
</section>
<section class="line-charts">
@if (productivityHappiness(); as productivityHappiness) {
<section>
@ -112,6 +90,7 @@
[legend]="true"
[options]="lineChartOptions"
height="400px"
[shareFileName]="'mood-productivity-over-time.png'"
></lazy-chart>
</section>
}
@ -126,11 +105,45 @@
[legend]="true"
[options]="lineChartOptions"
height="400px"
[shareFileName]="'focus-session-trends.png'"
></lazy-chart>
</section>
}
}
</section>
<section class="pie-charts">
@if (metricService.improvementCountsPieChartData(); as improvementCounts) {
<section>
<h3>{{ T.F.METRIC.CMP.IMPROVEMENT_SELECTION_COUNT | translate }}</h3>
<lazy-chart
[type]="pieChartType"
[datasets]="improvementCounts.datasets"
[labels]="improvementCounts.labels"
[legend]="improvementCounts?.datasets[0].data.length < 12"
[options]="pieChartOptions"
height="300px"
[shareFileName]="'improvement-selection-count.png'"
>
</lazy-chart>
</section>
}
@if (metricService.obstructionCountsPieChartData(); as obstructionCounts) {
<section>
<h3>{{ T.F.METRIC.CMP.OBSTRUCTION_SELECTION_COUNT | translate }}</h3>
<lazy-chart
[type]="pieChartType"
[datasets]="obstructionCounts.datasets"
[labels]="obstructionCounts.labels"
[legend]="obstructionCounts?.datasets[0].data.length < 12"
[options]="pieChartOptions"
height="300px"
[shareFileName]="'obstruction-selection-count.png'"
>
</lazy-chart>
</section>
}
</section>
</section>
}
@if (metricService.hasData()) {
@ -147,6 +160,7 @@
[options]="lineChartOptions"
[legend]="true"
height="400px"
[shareFileName]="'simple-click-counters-over-time.png'"
>
</lazy-chart>
</section>
@ -161,6 +175,7 @@
[options]="lineChartOptions"
[legend]="true"
height="400px"
[shareFileName]="'simple-stopwatch-counters-over-time.png'"
>
</lazy-chart>
</section>

View file

@ -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 {

View file

@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
import { ChartConfiguration, ChartType } from 'chart.js';
import { MetricService } from './metric.service';
import { toSignal } from '@angular/core/rxjs-interop';
@ -10,6 +10,10 @@ import { LazyChartComponent } from './lazy-chart/lazy-chart.component';
import { DecimalPipe } from '@angular/common';
import { MsToStringPipe } from '../../ui/duration/ms-to-string.pipe';
import { TranslatePipe } from '@ngx-translate/core';
import { ActivityHeatmapComponent } from './activity-heatmap/activity-heatmap.component';
import { ShareButtonComponent } from '../../core/share/share-button/share-button.component';
import { ShareFormatter } from '../../core/share/share-formatter';
import { SharePayload } from '../../core/share/share.model';
@Component({
selector: 'metric',
@ -17,7 +21,14 @@ import { TranslatePipe } from '@ngx-translate/core';
styleUrls: ['./metric.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [fadeAnimation],
imports: [LazyChartComponent, DecimalPipe, MsToStringPipe, TranslatePipe],
imports: [
LazyChartComponent,
DecimalPipe,
MsToStringPipe,
TranslatePipe,
ActivityHeatmapComponent,
ShareButtonComponent,
],
})
export class MetricComponent {
workContextService = inject(WorkContextService);
@ -26,6 +37,8 @@ export class MetricComponent {
T: typeof T = T;
activeWorkContext = toSignal(this.workContextService.activeWorkContext$);
productivityHappiness = toSignal(
this.metricService.getProductivityHappinessChartData$(),
);
@ -81,4 +94,40 @@ export class MetricComponent {
},
};
lineChartType: ChartType = 'line';
sharePayload = computed<SharePayload>(() => {
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,
},
);
});
}

View file

@ -57,10 +57,9 @@ export const CREATE_PROJECT_BASIC_CONFIG_FORM_CONFIG: ConfigFormSection<Project>
},
{
key: 'theme.primary' as any,
type: 'input',
type: 'color',
templateOptions: {
label: T.F.PROJECT.FORM_THEME.L_THEME_COLOR,
type: 'color',
},
},
{

View file

@ -2,14 +2,6 @@
<issue-panel [@slideInFromRight]></issue-panel>
} @else if (panelContent() === 'NOTES') {
<notes [@slideInFromRight]></notes>
} @else if (panelContent() === 'TASK_VIEW_CUSTOMIZER_PANEL') {
<!-- Wrap TVC to stabilize layout during panel width transitions -->
<div
class="panel-wrapper"
[@slideInFromRight]
>
<task-view-customizer-panel></task-view-customizer-panel>
</div>
} @else if (panelContent() === 'PLUGIN') {
@for (key of pluginPanelKeys(); track key) {
<plugin-panel-container [@slideInFromRight]></plugin-panel-container>

View file

@ -18,7 +18,6 @@ import { taskDetailPanelTaskChangeAnimation } from '../tasks/task-detail-panel/t
import { IssuePanelComponent } from '../issue-panel/issue-panel.component';
import { NotesComponent } from '../note/notes/notes.component';
import { TaskDetailPanelComponent } from '../tasks/task-detail-panel/task-detail-panel.component';
import { TaskViewCustomizerPanelComponent } from '../task-view-customizer/task-view-customizer-panel/task-view-customizer-panel.component';
import { PluginService } from '../../plugins/plugin.service';
import { PluginPanelContainerComponent } from '../../plugins/ui/plugin-panel-container/plugin-panel-container.component';
import { ScheduleDayPanelComponent } from '../schedule/schedule-day-panel/schedule-day-panel.component';
@ -56,7 +55,6 @@ export type RightPanelContentPanelType = PanelContentType;
IssuePanelComponent,
NotesComponent,
TaskDetailPanelComponent,
TaskViewCustomizerPanelComponent,
PluginPanelContainerComponent,
ScheduleDayPanelComponent,
],

View file

@ -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();

View file

@ -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<string[]>): Signal<ScheduleDay[]> {
return computed(() => {
this._scheduleRefreshTick();
this.scheduleRefreshTick();
const timelineTasks = this._timelineTasks();
const taskRepeatCfgs = this._taskRepeatCfgs();
const timelineCfg = this._timelineConfig();

View file

@ -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();

View file

@ -24,10 +24,9 @@ export const BASIC_TAG_CONFIG_FORM_CONFIG: ConfigFormSection<Tag> = {
},
{
key: 'color',
type: 'input',
type: 'color',
templateOptions: {
label: T.F.TAG.FORM_BASIC.L_COLOR,
type: 'color',
},
},
],

View file

@ -1,113 +1,288 @@
<div class="customizer-panel">
<h3>{{ T.F.TASK_VIEW.CUSTOMIZER.TITLE | translate }}</h3>
<div class="form-group">
<label>{{ T.F.TASK_VIEW.CUSTOMIZER.SORT_BY | translate }}</label>
<mat-form-field>
<mat-select
[ngModel]="customizerService.selectedSort()"
(ngModelChange)="customizerService.setSort($event)"
>
@for (opt of sortOptions; track opt.value) {
<mat-option [value]="opt.value">{{ opt.label | translate }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="form-group">
<label>{{ T.F.TASK_VIEW.CUSTOMIZER.GROUP_BY | translate }}</label>
<mat-form-field>
<mat-select
[ngModel]="customizerService.selectedGroup()"
(ngModelChange)="customizerService.setGroup($event)"
>
@for (opt of groupOptions; track opt.value) {
<mat-option [value]="opt.value">{{ opt.label | translate }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="form-group">
<label>{{ T.F.TASK_VIEW.CUSTOMIZER.FILTER_BY | translate }}</label>
<mat-form-field>
<mat-select
[ngModel]="customizerService.selectedFilter()"
(ngModelChange)="customizerService.setFilter($event)"
>
@for (opt of filterOptions; track opt.value) {
<mat-option [value]="opt.value">{{ opt.label | translate }}</mat-option>
}
</mat-select>
</mat-form-field>
@if (customizerService.selectedFilter() === 'tag') {
<mat-form-field>
<input
matInput
[ngModel]="customizerService.filterInputValue()"
(ngModelChange)="customizerService.setFilterInputValue($event)"
[placeholder]="T.F.TASK_VIEW.CUSTOMIZER.ENTER_TAG | translate"
/>
</mat-form-field>
}
@if (customizerService.selectedFilter() === 'project') {
<mat-form-field>
<input
matInput
[ngModel]="customizerService.filterInputValue()"
(ngModelChange)="customizerService.setFilterInputValue($event)"
[placeholder]="T.F.TASK_VIEW.CUSTOMIZER.ENTER_PROJECT | translate"
/>
</mat-form-field>
}
@if (customizerService.selectedFilter() === 'scheduledDate') {
<mat-form-field>
<mat-select
[ngModel]="customizerService.filterInputValue()"
(ngModelChange)="customizerService.setFilterInputValue($event)"
>
@for (option of scheduledPresets; track option.value) {
<mat-option [value]="option.value">{{ option.label | translate }}</mat-option>
}
</mat-select>
</mat-form-field>
}
@if (customizerService.selectedFilter() === 'estimatedTime') {
<mat-form-field>
<mat-select
[ngModel]="customizerService.filterInputValue()"
(ngModelChange)="customizerService.setFilterInputValue($event)"
>
@for (option of timePresets; track option.value) {
<mat-option [value]="option.value">{{ option.label | translate }}</mat-option>
}
</mat-select>
</mat-form-field>
}
@if (customizerService.selectedFilter() === 'timeSpent') {
<mat-form-field>
<mat-select
[ngModel]="customizerService.filterInputValue()"
(ngModelChange)="customizerService.setFilterInputValue($event)"
>
@for (option of timePresets; track option.value) {
<mat-option [value]="option.value">{{ option.label | translate }}</mat-option>
}
</mat-select>
</mat-form-field>
}
</div>
<mat-menu
#customizerMenu="matMenu"
class="customizer-menu"
>
<!-- Sort By -->
<button
mat-raised-button
color="primary"
mat-menu-item
[matMenuTriggerFor]="sortMenu"
>
<mat-icon>sort</mat-icon>
<span>{{ T.F.TASK_VIEW.CUSTOMIZER.SORT_BY | translate }}</span>
@if (customizerService.selectedSort() !== 'default') {
<span class="current-value">
{{ getSortLabel(customizerService.selectedSort()) | translate }}
</span>
}
</button>
<!-- Group By -->
<button
mat-menu-item
[matMenuTriggerFor]="groupMenu"
>
<mat-icon>group_work</mat-icon>
<span>{{ T.F.TASK_VIEW.CUSTOMIZER.GROUP_BY | translate }}</span>
@if (customizerService.selectedGroup() !== 'default') {
<span class="current-value">
{{ getGroupLabel(customizerService.selectedGroup()) | translate }}
</span>
}
</button>
<!-- Filter By -->
<button
mat-menu-item
[matMenuTriggerFor]="filterMenu"
>
<mat-icon>filter_alt</mat-icon>
<span>{{ T.F.TASK_VIEW.CUSTOMIZER.FILTER_BY | translate }}</span>
@if (customizerService.selectedFilter() !== 'default') {
<span class="current-value">
{{ getFilterLabel(customizerService.selectedFilter()) | translate }}
</span>
}
</button>
<mat-divider></mat-divider>
<!-- Reset All -->
<button
mat-menu-item
(click)="onResetAll()"
>
{{ T.F.TASK_VIEW.CUSTOMIZER.RESET_ALL | translate }}
<mat-icon>refresh</mat-icon>
<span>{{ T.F.TASK_VIEW.CUSTOMIZER.RESET_ALL | translate }}</span>
</button>
</div>
</mat-menu>
<!-- Sort submenu -->
<mat-menu #sortMenu="matMenu">
@for (opt of sortOptions; track opt.value) {
<button
mat-menu-item
(click)="customizerService.setSort(opt.value)"
[class.active]="customizerService.selectedSort() === opt.value"
>
<span class="menu-item-content">
<span>{{ opt.label | translate }}</span>
@if (customizerService.selectedSort() === opt.value) {
<mat-icon class="check-icon">check</mat-icon>
}
</span>
</button>
}
</mat-menu>
<!-- Group submenu -->
<mat-menu #groupMenu="matMenu">
@for (opt of groupOptions; track opt.value) {
<button
mat-menu-item
(click)="customizerService.setGroup(opt.value)"
[class.active]="customizerService.selectedGroup() === opt.value"
>
<span class="menu-item-content">
<span>{{ opt.label | translate }}</span>
@if (customizerService.selectedGroup() === opt.value) {
<mat-icon class="check-icon">check</mat-icon>
}
</span>
</button>
}
</mat-menu>
<!-- Filter submenu -->
<mat-menu #filterMenu="matMenu">
<button
mat-menu-item
(click)="onFilterSelect('default')"
[class.active]="customizerService.selectedFilter() === 'default'"
>
<span class="menu-item-content">
<span>{{ T.F.TASK_VIEW.CUSTOMIZER.FILTER_DEFAULT | translate }}</span>
@if (customizerService.selectedFilter() === 'default') {
<mat-icon class="check-icon">check</mat-icon>
}
</span>
</button>
<button
mat-menu-item
[matMenuTriggerFor]="filterTagMenu"
[class.active]="customizerService.selectedFilter() === 'tag'"
>
<span class="menu-item-content">
<span>{{ T.F.TASK_VIEW.CUSTOMIZER.FILTER_TAG | translate }}</span>
@if (customizerService.selectedFilter() === 'tag') {
<mat-icon class="check-icon">check</mat-icon>
}
</span>
</button>
<button
mat-menu-item
[matMenuTriggerFor]="filterProjectMenu"
[class.active]="customizerService.selectedFilter() === 'project'"
>
<span class="menu-item-content">
<span>{{ T.F.TASK_VIEW.CUSTOMIZER.FILTER_PROJECT | translate }}</span>
@if (customizerService.selectedFilter() === 'project') {
<mat-icon class="check-icon">check</mat-icon>
}
</span>
</button>
<button
mat-menu-item
[matMenuTriggerFor]="filterScheduledDateMenu"
[class.active]="customizerService.selectedFilter() === 'scheduledDate'"
>
<span class="menu-item-content">
<span>{{ T.F.TASK_VIEW.CUSTOMIZER.FILTER_SCHEDULED_DATE | translate }}</span>
@if (customizerService.selectedFilter() === 'scheduledDate') {
<mat-icon class="check-icon">check</mat-icon>
}
</span>
</button>
<button
mat-menu-item
[matMenuTriggerFor]="filterEstimatedTimeMenu"
[class.active]="customizerService.selectedFilter() === 'estimatedTime'"
>
<span class="menu-item-content">
<span>{{ T.F.TASK_VIEW.CUSTOMIZER.FILTER_ESTIMATED_TIME | translate }}</span>
@if (customizerService.selectedFilter() === 'estimatedTime') {
<mat-icon class="check-icon">check</mat-icon>
}
</span>
</button>
<button
mat-menu-item
[matMenuTriggerFor]="filterTimeSpentMenu"
[class.active]="customizerService.selectedFilter() === 'timeSpent'"
>
<span class="menu-item-content">
<span>{{ T.F.TASK_VIEW.CUSTOMIZER.FILTER_TIME_SPENT | translate }}</span>
@if (customizerService.selectedFilter() === 'timeSpent') {
<mat-icon class="check-icon">check</mat-icon>
}
</span>
</button>
</mat-menu>
<!-- Filter: Tag submenu -->
<mat-menu #filterTagMenu="matMenu">
<div
class="menu-input-wrapper"
(click)="$event.stopPropagation()"
>
<mat-form-field
class="menu-input"
subscriptSizing="dynamic"
>
<input
matInput
[ngModel]="customizerService.filterInputValue()"
(ngModelChange)="onFilterInputChange('tag', $event)"
[placeholder]="T.F.TASK_VIEW.CUSTOMIZER.ENTER_TAG | translate"
(keydown.enter)="$event.stopPropagation()"
/>
</mat-form-field>
</div>
</mat-menu>
<!-- Filter: Project submenu -->
<mat-menu #filterProjectMenu="matMenu">
<div
class="menu-input-wrapper"
(click)="$event.stopPropagation()"
>
<mat-form-field
class="menu-input"
subscriptSizing="dynamic"
>
<input
matInput
[ngModel]="customizerService.filterInputValue()"
(ngModelChange)="onFilterInputChange('project', $event)"
[placeholder]="T.F.TASK_VIEW.CUSTOMIZER.ENTER_PROJECT | translate"
(keydown.enter)="$event.stopPropagation()"
/>
</mat-form-field>
</div>
</mat-menu>
<!-- Filter: Scheduled Date submenu -->
<mat-menu #filterScheduledDateMenu="matMenu">
@for (option of scheduledPresets; track option.value) {
<button
mat-menu-item
(click)="onFilterWithValue('scheduledDate', option.value)"
[class.active]="
customizerService.selectedFilter() === 'scheduledDate' &&
customizerService.filterInputValue() === option.value
"
>
<span class="menu-item-content">
<span>{{ option.label | translate }}</span>
@if (
customizerService.selectedFilter() === 'scheduledDate' &&
customizerService.filterInputValue() === option.value
) {
<mat-icon class="check-icon">check</mat-icon>
}
</span>
</button>
}
</mat-menu>
<!-- Filter: Estimated Time submenu -->
<mat-menu #filterEstimatedTimeMenu="matMenu">
@for (option of timePresets; track option.value) {
<button
mat-menu-item
(click)="onFilterWithValue('estimatedTime', option.value)"
[class.active]="
customizerService.selectedFilter() === 'estimatedTime' &&
customizerService.filterInputValue() === option.value
"
>
<span class="menu-item-content">
<span>{{ option.label | translate }}</span>
@if (
customizerService.selectedFilter() === 'estimatedTime' &&
customizerService.filterInputValue() === option.value
) {
<mat-icon class="check-icon">check</mat-icon>
}
</span>
</button>
}
</mat-menu>
<!-- Filter: Time Spent submenu -->
<mat-menu #filterTimeSpentMenu="matMenu">
@for (option of timePresets; track option.value) {
<button
mat-menu-item
(click)="onFilterWithValue('timeSpent', option.value)"
[class.active]="
customizerService.selectedFilter() === 'timeSpent' &&
customizerService.filterInputValue() === option.value
"
>
<span class="menu-item-content">
<span>{{ option.label | translate }}</span>
@if (
customizerService.selectedFilter() === 'timeSpent' &&
customizerService.filterInputValue() === option.value
) {
<mat-icon class="check-icon">check</mat-icon>
}
</span>
</button>
}
</mat-menu>

View file

@ -1,33 +1,11 @@
.customizer-panel {
// Align layout with other right-panel contents (e.g. issue-panel)
// Fill the container and let the parent handle scrolling
position: absolute;
inset: 8px 16px;
padding: 8px 0;
display: flex;
flex-direction: column;
height: 100%;
// Menu styles are in global styles: src/styles/components/_customizer-menu.scss
// This is necessary because mat-menu renders in a global overlay container
h3 {
text-align: center;
margin-bottom: 20px;
}
.menu-input-wrapper {
padding: 8px 16px;
min-width: 250px;
.form-group {
margin-bottom: 15px;
display: flex;
flex-direction: column;
label {
margin-bottom: 5px;
}
mat-form-field {
width: 100%;
}
mat-slide-toggle {
align-self: flex-start;
}
.menu-input {
width: 100%;
}
}

View file

@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ChangeDetectionStrategy, Component, inject, ViewChild } from '@angular/core';
import { OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
@ -7,6 +7,9 @@ import { MatSelectModule } from '@angular/material/select';
import { MatInputModule } from '@angular/material/input';
import { MatButtonModule } from '@angular/material/button';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatMenuModule, MatMenu } from '@angular/material/menu';
import { MatIconModule } from '@angular/material/icon';
import { MatDividerModule } from '@angular/material/divider';
import { TaskViewCustomizerService } from '../task-view-customizer.service';
import { TranslatePipe } from '@ngx-translate/core';
import { T } from 'src/app/t.const';
@ -17,6 +20,7 @@ import { T } from 'src/app/t.const';
styleUrls: ['./task-view-customizer-panel.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
exportAs: 'customizerMenu',
imports: [
CommonModule,
FormsModule,
@ -25,12 +29,18 @@ import { T } from 'src/app/t.const';
MatInputModule,
MatButtonModule,
MatSlideToggleModule,
MatMenuModule,
MatIconModule,
MatDividerModule,
TranslatePipe,
],
})
export class TaskViewCustomizerPanelComponent implements OnInit {
customizerService = inject(TaskViewCustomizerService);
@ViewChild('customizerMenu', { static: false })
menu!: MatMenu;
T = T;
selectedSort: string = 'default';
selectedGroup: string = 'default';
@ -89,6 +99,37 @@ export class TaskViewCustomizerPanelComponent implements OnInit {
this.filterInputValue = this.customizerService.filterInputValue();
}
getSortLabel(value: string): string {
const option = this.sortOptions.find((opt) => opt.value === value);
return option ? option.label : '';
}
getGroupLabel(value: string): string {
const option = this.groupOptions.find((opt) => opt.value === value);
return option ? option.label : '';
}
getFilterLabel(value: string): string {
const option = this.filterOptions.find((opt) => opt.value === value);
return option ? option.label : '';
}
onFilterSelect(filterType: string): void {
this.customizerService.setFilter(filterType);
}
onFilterInputChange(filterType: string, value: string): void {
if (this.customizerService.selectedFilter() !== filterType) {
this.customizerService.setFilter(filterType);
}
this.customizerService.setFilterInputValue(value);
}
onFilterWithValue(filterType: string, value: string): void {
this.customizerService.setFilter(filterType);
this.customizerService.setFilterInputValue(value);
}
onResetAll(): void {
this.customizerService.resetAll();
}

View file

@ -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', () => {

View file

@ -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) {

View file

@ -360,6 +360,11 @@
mat-menu-item
menuTouchFix
>
<mat-icon
[style.color]="project.theme.primary"
class="tag-ico"
>{{ project.icon || 'list' }}</mat-icon
>
{{ project.title }}
</button>
}

View file

@ -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<boolean> = this._task$.pipe(
// take(1),

View file

@ -0,0 +1,189 @@
import { Injectable, inject } from '@angular/core';
import { ProjectService } from '../project/project.service';
import { TagService } from '../tag/tag.service';
import { Store } from '@ngrx/store';
import { selectTasksWithSubTasksByIds } from '../tasks/store/task.selectors';
import { Task, TaskWithSubTasks } from '../tasks/task.model';
import { first } from 'rxjs/operators';
@Injectable({
providedIn: 'root',
})
export class WorkContextMarkdownService {
private _projectService = inject(ProjectService);
private _tagService = inject(TagService);
private _store = inject(Store);
async copyTasksAsMarkdown(
contextId: string,
isProjectContext: boolean,
): Promise<'copied' | 'empty' | 'failed'> {
const { status, markdown } = await this.getMarkdownForContext(
contextId,
isProjectContext,
);
if (status === 'empty' || !markdown) {
return 'empty';
}
const isSuccess = await this.copyMarkdownText(markdown);
return isSuccess ? 'copied' : 'failed';
}
async getMarkdownForContext(
contextId: string,
isProjectContext: boolean,
): Promise<{
status: 'empty' | 'ok';
markdown?: string;
contextTitle?: string | null;
}> {
const { tasks, contextTitle } = await this._loadTasks(contextId, isProjectContext);
if (!tasks.length) {
return { status: 'empty', contextTitle };
}
return {
status: 'ok',
markdown: this._buildMarkdownChecklist(tasks),
contextTitle,
};
}
async copyMarkdownText(markdown: string): Promise<boolean> {
if (!markdown) {
return false;
}
return this._copyToClipboard(markdown);
}
private async _loadTasks(
contextId: string,
isProjectContext: boolean,
): Promise<{ tasks: TaskWithSubTasks[]; contextTitle: string | null }> {
const { ids, contextTitle } = await this._getTaskIds(contextId, isProjectContext);
if (!ids.length) {
return { tasks: [], contextTitle };
}
const tasks =
(await this._store
.select(selectTasksWithSubTasksByIds, { ids })
.pipe(first())
.toPromise()) || [];
return {
tasks: tasks.filter((task): task is TaskWithSubTasks => !!task),
contextTitle,
};
}
private async _getTaskIds(
contextId: string,
isProjectContext: boolean,
): Promise<{ ids: string[]; contextTitle: string | null }> {
if (isProjectContext) {
const project = await this._projectService.getByIdOnce$(contextId).toPromise();
if (!project) {
return { ids: [], contextTitle: null };
}
return {
ids: this._uniqueIds([
...(project.taskIds || []),
...(project.backlogTaskIds || []),
]),
contextTitle: project.title,
};
}
const tag = await this._tagService.getTagById$(contextId).pipe(first()).toPromise();
if (!tag) {
return { ids: [], contextTitle: null };
}
return { ids: this._uniqueIds(tag.taskIds || []), contextTitle: tag.title };
}
private _uniqueIds(ids: (string | null | undefined)[]): string[] {
const seen = new Set<string>();
const unique: string[] = [];
ids.forEach((id) => {
if (!id || seen.has(id)) {
return;
}
seen.add(id);
unique.push(id);
});
return unique;
}
private _buildMarkdownChecklist(tasks: TaskWithSubTasks[]): string {
const lines: string[] = [];
tasks.forEach((task) => {
lines.push(this._formatTaskLine(task));
if (task.subTasks?.length) {
task.subTasks.forEach((subTask) => {
lines.push(this._formatTaskLine(subTask, 1));
});
}
});
return lines.join('\n');
}
private _formatTaskLine(task: Task | TaskWithSubTasks, depth: number = 0): string {
const indent = depth > 0 ? ' '.repeat(depth) : '';
const checkbox = task.isDone ? '[x]' : '[ ]';
const title = (task.title || '').replace(/\r?\n/g, ' ');
return `${indent}- ${checkbox} ${title}`;
}
private async _copyToClipboard(text: string): Promise<boolean> {
if (!text) {
return false;
}
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (err) {
console.warn('Clipboard write failed, trying fallback method:', err);
}
}
if (typeof document === 'undefined') {
return false;
}
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
textarea.style.pointerEvents = 'none';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
let isSuccess = false;
try {
isSuccess = document.execCommand('copy');
} catch (err) {
console.error('Fallback copy failed:', err);
isSuccess = false;
} finally {
document.body.removeChild(textarea);
}
return isSuccess;
}
}

View file

@ -65,26 +65,23 @@ export const WORK_CONTEXT_THEME_CONFIG_FORM_CONFIG: ConfigFormSection<WorkContex
items: [
{
key: 'primary',
type: 'input',
type: 'color',
templateOptions: {
label: T.F.PROJECT.FORM_THEME.L_COLOR_PRIMARY,
type: 'color',
},
},
{
key: 'accent',
type: 'input',
type: 'color',
templateOptions: {
label: T.F.PROJECT.FORM_THEME.L_COLOR_ACCENT,
type: 'color',
},
},
{
key: 'warn',
type: 'input',
type: 'color',
templateOptions: {
label: T.F.PROJECT.FORM_THEME.L_COLOR_WARN,
type: 'color',
},
},
{

View file

@ -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', () => {

View file

@ -192,6 +192,23 @@ export class ModelSyncService<MD extends ModelCfgs> {
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(

View file

@ -221,8 +221,10 @@ const T = {
BACK_TO_PLANNING: 'F.FOCUS_MODE.BACK_TO_PLANNING',
CONGRATS: 'F.FOCUS_MODE.CONGRATS',
COUNTDOWN: 'F.FOCUS_MODE.COUNTDOWN',
COUNTDOWN_HINT: 'F.FOCUS_MODE.COUNTDOWN_HINT',
FINISH_TASK_AND_SELECT_NEXT: 'F.FOCUS_MODE.FINISH_TASK_AND_SELECT_NEXT',
FLOWTIME: 'F.FOCUS_MODE.FLOWTIME',
FLOWTIME_HINT: 'F.FOCUS_MODE.FLOWTIME_HINT',
FOR_TASK: 'F.FOCUS_MODE.FOR_TASK',
GET_READY: 'F.FOCUS_MODE.GET_READY',
GO_TO_PROCRASTINATION: 'F.FOCUS_MODE.GO_TO_PROCRASTINATION',
@ -231,6 +233,7 @@ const T = {
ON: 'F.FOCUS_MODE.ON',
OPEN_ISSUE_IN_BROWSER: 'F.FOCUS_MODE.OPEN_ISSUE_IN_BROWSER',
POMODORO: 'F.FOCUS_MODE.POMODORO',
POMODORO_HINT: 'F.FOCUS_MODE.POMODORO_HINT',
POMODORO_BACK: 'F.FOCUS_MODE.POMODORO_BACK',
POMODORO_DISABLE: 'F.FOCUS_MODE.POMODORO_DISABLE',
POMODORO_INFO: 'F.FOCUS_MODE.POMODORO_INFO',
@ -239,6 +242,7 @@ const T = {
PREP_STRETCH: 'F.FOCUS_MODE.PREP_STRETCH',
SELECT_ANOTHER_TASK: 'F.FOCUS_MODE.SELECT_ANOTHER_TASK',
SELECT_TASK: 'F.FOCUS_MODE.SELECT_TASK',
SELECT_MODE: 'F.FOCUS_MODE.SELECT_MODE',
SESSION_COMPLETED: 'F.FOCUS_MODE.SESSION_COMPLETED',
POMODORO_SESSION_COMPLETED: 'F.FOCUS_MODE.POMODORO_SESSION_COMPLETED',
SET_FOCUS_SESSION_DURATION: 'F.FOCUS_MODE.SET_FOCUS_SESSION_DURATION',
@ -582,6 +586,7 @@ const T = {
CHECK: 'F.METRIC.BANNER.CHECK',
},
CMP: {
ACTIVITY_HEATMAP: 'F.METRIC.CMP.ACTIVITY_HEATMAP',
AVG_BREAKS_PER_DAY: 'F.METRIC.CMP.AVG_BREAKS_PER_DAY',
AVG_TASKS_PER_DAY_WORKED: 'F.METRIC.CMP.AVG_TASKS_PER_DAY_WORKED',
AVG_TIME_SPENT_ON_BREAKS: 'F.METRIC.CMP.AVG_TIME_SPENT_ON_BREAKS',
@ -1834,7 +1839,6 @@ const T = {
IS_HIDE_NAV: 'GCF.MISC.IS_HIDE_NAV',
IS_MINIMIZE_TO_TRAY: 'GCF.MISC.IS_MINIMIZE_TO_TRAY',
IS_SHOW_TIP_LONGER: 'GCF.MISC.IS_SHOW_TIP_LONGER',
IS_DISABLE_PRODUCTIVITY_TIPS: 'GCF.MISC.IS_DISABLE_PRODUCTIVITY_TIPS',
IS_TRAY_SHOW_CURRENT_COUNTDOWN: 'GCF.MISC.IS_TRAY_SHOW_CURRENT_COUNTDOWN',
IS_TRAY_SHOW_CURRENT_TASK: 'GCF.MISC.IS_TRAY_SHOW_CURRENT_TASK',
IS_OVERLAY_INDICATOR_ENABLED: 'GCF.MISC.IS_OVERLAY_INDICATOR_ENABLED',
@ -1965,6 +1969,10 @@ const T = {
},
GLOBAL_SNACK: {
COPY_TO_CLIPPBOARD: 'GLOBAL_SNACK.COPY_TO_CLIPPBOARD',
NO_TASKS_TO_COPY: 'GLOBAL_SNACK.NO_TASKS_TO_COPY',
SHARE_UNAVAILABLE_FALLBACK: 'GLOBAL_SNACK.SHARE_UNAVAILABLE_FALLBACK',
SHARE_FAILED_FALLBACK: 'GLOBAL_SNACK.SHARE_FAILED_FALLBACK',
SHARE_FAILED: 'GLOBAL_SNACK.SHARE_FAILED',
ERR_COMPRESSION: 'GLOBAL_SNACK.ERR_COMPRESSION',
FILE_DOWNLOADED: 'GLOBAL_SNACK.FILE_DOWNLOADED',
FILE_DOWNLOADED_BTN: 'GLOBAL_SNACK.FILE_DOWNLOADED_BTN',
@ -2032,6 +2040,8 @@ const T = {
TOGGLE_SHOW_NOTES: 'MH.TOGGLE_SHOW_NOTES',
TOGGLE_TRACK_TIME: 'MH.TOGGLE_TRACK_TIME',
TRIGGER_SYNC: 'MH.TRIGGER_SYNC',
SHARE_TASK_LIST_MARKDOWN: 'MH.SHARE_TASK_LIST_MARKDOWN',
COPY_TASK_LIST_MARKDOWN: 'MH.COPY_TASK_LIST_MARKDOWN',
WORKLOG: 'MH.WORKLOG',
SIDE_PANEL_MENU: 'MH.SIDE_PANEL_MENU',
},

View file

@ -22,6 +22,7 @@ import { FormlyMatSliderModule } from '@ngx-formly/material/slider';
import { FormlyTagSelectionComponent } from './formly-tag-selection/formly-tag-selection.component';
import { FormlyBtnComponent } from './formly-button/formly-btn.component';
import { FormlyImageInputComponent } from './formly-image-input/formly-image-input.component';
import { ColorInputComponent } from '../features/config/color-input/color-input.component';
@NgModule({
imports: [
@ -64,6 +65,10 @@ import { FormlyImageInputComponent } from './formly-image-input/formly-image-inp
extends: 'input',
wrappers: ['form-field'],
},
{
name: 'color',
component: ColorInputComponent,
},
{
name: 'project-select',
component: SelectProjectComponent,

View file

@ -0,0 +1,31 @@
<div
class="segmented-button-group"
role="radiogroup"
[attr.aria-label]="ariaLabel() || null"
>
@for (option of options(); track option.id; let index = $index) {
<button
#segmentButton
type="button"
class="segment"
[class.is-active]="isActive(option)"
[class.is-disabled]="option.disabled"
role="radio"
[attr.aria-checked]="isActive(option)"
[disabled]="option.disabled"
(click)="onSelect(option.id)"
(keydown)="handleKeyDown($event, index)"
[matTooltip]="option.hintKey | translate"
>
@if (option.icon) {
<mat-icon
class="segment-icon"
aria-hidden="true"
>
{{ option.icon }}
</mat-icon>
}
<span class="segment-label">{{ option.labelKey | translate }}</span>
</button>
}
</div>

View file

@ -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);
}

View file

@ -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 SegmentedButtonOption[]>();
readonly selectedId = input<string | number | null>(null);
readonly ariaLabel = input<string>('');
readonly size = input<'md' | 'lg'>('lg');
readonly selectionChange = output<string | number>();
@HostBinding('attr.data-size')
get sizeAttr(): string {
return this.size();
}
private readonly _buttonRefs =
viewChildren<ElementRef<HTMLButtonElement>>('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();
}
}
}

View file

@ -15,7 +15,8 @@ export const adjustToLiveFormlyForm = (
item.type === 'input' ||
item.type === 'textarea' ||
item.type === 'duration' ||
item.type === 'icon'
item.type === 'icon' ||
item.type === 'color'
) {
return {
...item,

View file

@ -1804,7 +1804,6 @@
"IS_HIDE_NAV": "Navigation verbergen, bis die Hauptüberschrift angezeigt wird (nur Desktop)",
"IS_MINIMIZE_TO_TRAY": "Anwendung als Trayicon minimieren (nur Deskop)",
"IS_SHOW_TIP_LONGER": "Zeige den Produktivitätstipp beim Start der App etwas länger an",
"IS_DISABLE_PRODUCTIVITY_TIPS": "Produktivitätstipps beim Start der Anwendung deaktivieren",
"IS_TRAY_SHOW_CURRENT_COUNTDOWN": "Aktuellen Countdown im Tray / Statusmenü anzeigen (nur Desktop Mac)",
"IS_TRAY_SHOW_CURRENT_TASK": "Aktuelle Aufgabe im Tray / Status-Menu zeigen (nur Desktop)",
"IS_OVERLAY_INDICATOR_ENABLED": "Enable overlay indicator window (desktop linux/gnome)",

View file

@ -219,8 +219,10 @@
"BACK_TO_PLANNING": "Back to Planning",
"CONGRATS": "Congrats for completing this session!",
"COUNTDOWN": "Countdown",
"COUNTDOWN_HINT": "Focus until the clock hits zero",
"FINISH_TASK_AND_SELECT_NEXT": "Finish task and select next",
"FLOWTIME": "Flowtime",
"FLOWTIME_HINT": "Flexible pacing without strict timers",
"FOR_TASK": "for task",
"GET_READY": "Get ready for your focus session!",
"GO_TO_PROCRASTINATION": "Get help, when procrastinating",
@ -229,6 +231,7 @@
"ON": "on",
"OPEN_ISSUE_IN_BROWSER": "Open issue in Browser",
"POMODORO": "Pomodoro",
"POMODORO_HINT": "Structured sprints with planned breaks",
"POMODORO_BACK": "Back",
"POMODORO_DISABLE": "Disable Pomodoro",
"POMODORO_INFO": "Focus sessions cannot be used together with the pomodoro timer enabled.",
@ -237,6 +240,7 @@
"PREP_STRETCH": "Do some mild stretching",
"SELECT_ANOTHER_TASK": "Select another Task",
"SELECT_TASK": "Select Task to focus on",
"SELECT_MODE": "Choose your focus mode",
"SESSION_COMPLETED": "Focus Session Completed!",
"POMODORO_SESSION_COMPLETED": "Pomodoro Session Completed!",
"SET_FOCUS_SESSION_DURATION": "Set Focus Session Duration",
@ -575,6 +579,7 @@
"CHECK": "I did it!"
},
"CMP": {
"ACTIVITY_HEATMAP": "Activity Heatmap",
"AVG_BREAKS_PER_DAY": "Avg. breaks per day",
"AVG_TASKS_PER_DAY_WORKED": "Avg. tasks per day worked",
"AVG_TIME_SPENT_ON_BREAKS": "Avg. time spent on breaks",
@ -1462,8 +1467,8 @@
},
"TASK_VIEW": {
"CUSTOMIZER": {
"ENTER_PROJECT": "Enter project",
"ENTER_TAG": "Enter tag",
"ENTER_PROJECT": "Filter Projects",
"ENTER_TAG": "Filter Tag",
"ESTIMATED_TIME": "Estimated Time",
"FILTER_BY": "Filter By",
"FILTER_DEFAULT": "No Filter",
@ -1807,7 +1812,6 @@
"IS_HIDE_NAV": "Hide navigation until main header is hovered (desktop only)",
"IS_MINIMIZE_TO_TRAY": "Minimize to tray (desktop only)",
"IS_SHOW_TIP_LONGER": "Show productivity tip on app start a little longer",
"IS_DISABLE_PRODUCTIVITY_TIPS": "Disable productivity tips on app start",
"IS_TRAY_SHOW_CURRENT_COUNTDOWN": "Show current countdown in the tray / status menu (desktop mac only)",
"IS_TRAY_SHOW_CURRENT_TASK": "Show current task in the tray / status menu (desktop mac/windows only)",
"IS_OVERLAY_INDICATOR_ENABLED": "Enable overlay indicator window (desktop linux/gnome)",
@ -1935,6 +1939,10 @@
},
"GLOBAL_SNACK": {
"COPY_TO_CLIPPBOARD": "Copied to clipboard",
"NO_TASKS_TO_COPY": "No tasks to copy",
"SHARE_UNAVAILABLE_FALLBACK": "Copied to clipboard.",
"SHARE_FAILED_FALLBACK": "Sharing failed. Copied to clipboard instead.",
"SHARE_FAILED": "Sharing failed. Please copy manually.",
"ERR_COMPRESSION": "Error for compression interface",
"FILE_DOWNLOADED": "{{fileName}} downloaded",
"FILE_DOWNLOADED_BTN": "Open folder",
@ -2001,6 +2009,8 @@
"TOGGLE_SHOW_NOTES": "Show/Hide Project Notes",
"TOGGLE_TRACK_TIME": "Start/Stop tracking time",
"TRIGGER_SYNC": "Sync!",
"SHARE_TASK_LIST_MARKDOWN": "Share Task List",
"COPY_TASK_LIST_MARKDOWN": "Copy to Clipboard",
"WORKLOG": "Worklog",
"SIDE_PANEL_MENU": "Side Panel Menu"
},

View file

@ -1801,7 +1801,6 @@
"IS_HIDE_NAV": "Piilota navigointi, kunnes päänimikettä hoveroidaan (vain työpöytä)",
"IS_MINIMIZE_TO_TRAY": "Pienennä tehtäväpalkkiin (vain työpöytä)",
"IS_SHOW_TIP_LONGER": "Näytä tuottavuusvinkki sovelluksen käynnistyksessä hieman pidempään",
"IS_DISABLE_PRODUCTIVITY_TIPS": "Poista tuottavuusvinkit käytöstä sovelluksen käynnistyksessä",
"IS_TRAY_SHOW_CURRENT_COUNTDOWN": "Näytä nykyinen laskuri tehtäväpalkissa / tilavalikossa (vain työpöytä mac)",
"IS_TRAY_SHOW_CURRENT_TASK": "Näytä nykyinen tehtävä tehtäväpalkissa / tilavalikossa (vain työpöytä mac/windows)",
"IS_OVERLAY_INDICATOR_ENABLED": "Ota päällekkäisyysindikaattori-ikkuna käyttöön (työpöytä linux/gnome)",

View file

@ -1798,7 +1798,6 @@
"IS_HIDE_NAV": "Ana başlık yönlendirilene kadar gezinmeyi gizle (yalnızca masaüstü)",
"IS_MINIMIZE_TO_TRAY": "Tepsiye küçült (yalnızca masaüstü)",
"IS_SHOW_TIP_LONGER": "Uygulamada üretkenlik ipucunu biraz daha uzun süre başlatın",
"IS_DISABLE_PRODUCTIVITY_TIPS": "Üretkenlik ipuçlarını uygulama başlangıcında devre dışı bırak",
"IS_TRAY_SHOW_CURRENT_COUNTDOWN": "Mevcut geri sayımı tepsi / durum menüsünde göster (sadece masaüstü mac için)",
"IS_TRAY_SHOW_CURRENT_TASK": "Mevcut görevi tepsi / Durum menüsünde göster (yalnızca masaüstü)",
"IS_OVERLAY_INDICATOR_ENABLED": "Panel gösterge penceresini etkinleştir (masaüstü linux/gnome)",

View file

@ -16,5 +16,6 @@
@use './planner-shared';
@use './mentions';
@use './bottom-panel';
@use './customizer-menu';
//@import '../../app/ui/custom-datetime-picker/sass/picker';

View file

@ -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;
}
}