mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-08-03 04:52:12 +00:00
Merge branch 'johannesjo:master' into master
This commit is contained in:
commit
e2dd422e4e
76 changed files with 4174 additions and 351 deletions
1
android/.idea/gradle.xml
generated
1
android/.idea/gradle.xml
generated
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
7
electron/electronAPI.d.ts
vendored
7
electron/electronAPI.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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
11
package-lock.json
generated
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -112,9 +112,7 @@
|
|||
<desktop-panel-buttons
|
||||
[kb]="kb"
|
||||
[isRouteWithSidePanel]="isRouteWithSidePanel()"
|
||||
[isWorkViewPage]="isWorkViewPage()"
|
||||
[isShowScheduleDayPanel]="isShowScheduleDayPanel()"
|
||||
[isShowTaskViewCustomizerPanel]="isShowTaskViewCustomizerPanel()"
|
||||
[isShowIssuePanel]="isShowIssuePanel()"
|
||||
[isShowNotes]="isShowNotes()"
|
||||
></desktop-panel-buttons>
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) || {};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
248
src/app/core/share/README.md
Normal file
248
src/app/core/share/README.md
Normal 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.
|
||||
66
src/app/core/share/dialog-share/dialog-share.component.html
Normal file
66
src/app/core/share/dialog-share/dialog-share.component.html
Normal 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>
|
||||
67
src/app/core/share/dialog-share/dialog-share.component.scss
Normal file
67
src/app/core/share/dialog-share/dialog-share.component.scss
Normal 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;
|
||||
}
|
||||
90
src/app/core/share/dialog-share/dialog-share.component.ts
Normal file
90
src/app/core/share/dialog-share/dialog-share.component.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
63
src/app/core/share/share-button/share-button.component.ts
Normal file
63
src/app/core/share/share-button/share-button.component.ts
Normal 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());
|
||||
}
|
||||
}
|
||||
145
src/app/core/share/share-formatter.spec.ts
Normal file
145
src/app/core/share/share-formatter.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
226
src/app/core/share/share-formatter.ts
Normal file
226
src/app/core/share/share-formatter.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
68
src/app/core/share/share.model.ts
Normal file
68
src/app/core/share/share.model.ts
Normal 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;
|
||||
}
|
||||
658
src/app/core/share/share.service.ts
Normal file
658
src/app/core/share/share.service.ts
Normal 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}"e=${encodedText}`;
|
||||
case 'whatsapp':
|
||||
return `https://wa.me/?text=${this._encodeForWhatsApp(providerText || providerTitle || shareUrl)}`;
|
||||
case 'telegram':
|
||||
return `https://t.me/share/url?url=${encodedUrl}&text=${enc(providerText || providerTitle || shareUrl)}`;
|
||||
case 'email':
|
||||
return `mailto:?subject=${encodedTitle}&body=${encodedText}`;
|
||||
case 'mastodon': {
|
||||
const instance = 'mastodon.social';
|
||||
return `https://${instance}/share?text=${enc(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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
mat-form-field {
|
||||
width: 100%;
|
||||
}
|
||||
32
src/app/features/config/color-input/color-input.component.ts
Normal file
32
src/app/features/config/color-input/color-input.component.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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?**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
<!-- -->
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
189
src/app/features/work-context/work-context-markdown.service.ts
Normal file
189
src/app/features/work-context/work-context-markdown.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -16,5 +16,6 @@
|
|||
@use './planner-shared';
|
||||
@use './mentions';
|
||||
@use './bottom-panel';
|
||||
@use './customizer-menu';
|
||||
|
||||
//@import '../../app/ui/custom-datetime-picker/sass/picker';
|
||||
|
|
|
|||
41
src/styles/components/_customizer-menu.scss
Normal file
41
src/styles/components/_customizer-menu.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue