refactor: observables to signals

This commit is contained in:
Johannes Millan 2025-08-12 21:47:44 +02:00
parent 9447a97237
commit be991a4192
20 changed files with 248 additions and 236 deletions

View file

@ -1,5 +1,5 @@
@if (isShowUi$ | async) {
@if (globalThemeService.backgroundImg$ | async; as bgImage) {
@if (globalThemeService.backgroundImg(); as bgImage) {
<div
[style.background]="'url(' + bgImage + ')'"
class="bg-image"
@ -21,7 +21,7 @@
></div>
}
<!-- -->
@if (isShowFocusOverlay$ | async) {
@if (isShowFocusOverlay()) {
<focus-mode-overlay @warp></focus-mode-overlay>
} @else {
<mat-sidenav-container [dir]="isRTL ? 'rtl' : 'ltr'">

View file

@ -12,6 +12,7 @@ import {
OnDestroy,
ViewChild,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ChromeExtensionInterfaceService } from './core/chrome-extension-interface/chrome-extension-interface.service';
import { ShortcutService } from './core-ui/shortcut/shortcut.service';
import { GlobalConfigService } from './features/config/global-config.service';
@ -189,9 +190,9 @@ export class AppComponent implements OnDestroy, AfterViewInit {
),
);
isShowFocusOverlay$: Observable<boolean> = this._store.select(
selectIsFocusOverlayShown,
);
isShowFocusOverlay = toSignal(this._store.select(selectIsFocusOverlayShown), {
initialValue: false,
});
private _subs: Subscription = new Subscription();
private _intervalTimer?: NodeJS.Timeout;
@ -432,53 +433,52 @@ export class AppComponent implements OnDestroy, AfterViewInit {
}
changeBackgroundFromUnsplash(): void {
this.globalThemeService.isDarkTheme$.pipe(take(1)).subscribe((isDarkMode) => {
const contextKey = isDarkMode ? 'backgroundImageDark' : 'backgroundImageLight';
const isDarkMode = this.globalThemeService.isDarkTheme();
const contextKey = isDarkMode ? 'backgroundImageDark' : 'backgroundImageLight';
const dialogRef = this._matDialog.open(DialogUnsplashPickerComponent, {
width: '900px',
maxWidth: '95vw',
data: {
context: contextKey,
},
});
const dialogRef = this._matDialog.open(DialogUnsplashPickerComponent, {
width: '900px',
maxWidth: '95vw',
data: {
context: contextKey,
},
});
dialogRef.afterClosed().subscribe((result) => {
if (result) {
// Get current work context
this.workContextService.activeWorkContext$
.pipe(take(1))
.subscribe((activeContext) => {
if (!activeContext) {
this._snackService.open({
type: 'ERROR',
msg: 'No active work context',
});
return;
}
dialogRef.afterClosed().subscribe((result) => {
if (result) {
// Get current work context
this.workContextService.activeWorkContext$
.pipe(take(1))
.subscribe((activeContext) => {
if (!activeContext) {
this._snackService.open({
type: 'ERROR',
msg: 'No active work context',
});
return;
}
// Extract the URL from the result object
const backgroundUrl = result.url || result;
// Extract the URL from the result object
const backgroundUrl = result.url || result;
// Update the theme based on context type
if (activeContext.type === 'PROJECT') {
this._projectService.update(activeContext.id, {
theme: {
...activeContext.theme,
[contextKey]: backgroundUrl,
},
});
} else if (activeContext.type === 'TAG') {
this._tagService.updateTag(activeContext.id, {
theme: {
...activeContext.theme,
[contextKey]: backgroundUrl,
},
});
}
});
}
});
// Update the theme based on context type
if (activeContext.type === 'PROJECT') {
this._projectService.update(activeContext.id, {
theme: {
...activeContext.theme,
[contextKey]: backgroundUrl,
},
});
} else if (activeContext.type === 'TAG') {
this._tagService.updateTag(activeContext.id, {
theme: {
...activeContext.theme,
[contextKey]: backgroundUrl,
},
});
}
});
}
});
}

View file

@ -54,7 +54,7 @@
}
</section>
@if (nonHiddenProjects$ | async; as projectList) {
@if (nonHiddenProjects(); as projectList) {
<section class="projects tour-projects">
<div class="g-multi-btn-wrapper e2e-projects-btn">
<button
@ -62,7 +62,7 @@
#projectExpandBtn
(click)="toggleExpandProjects()"
(keydown)="toggleExpandProjectsLeftRight($event)"
[class.isExpanded]="isProjectsExpanded"
[class.isExpanded]="isProjectsExpanded()"
class="expand-btn"
mat-menu-item
>
@ -134,19 +134,19 @@
></side-nav-item>
}
</div>
@if (!projectList.length && isProjectsExpanded) {
@if (!projectList.length && isProjectsExpanded()) {
<div class="no-tags-info">{{ T.MH.NO_PROJECT_INFO | translate }}</div>
}
</section>
}
@if (tagList$ | async; as tagList) {
@if (tagList(); as tagList) {
<section class="tags">
<button
#menuEntry
#tagExpandBtn
(click)="toggleExpandTags()"
(keydown)="toggleExpandTagsLeftRight($event)"
[class.isExpanded]="isTagsExpanded"
[class.isExpanded]="isTagsExpanded()"
class="expand-btn"
mat-menu-item
>
@ -172,7 +172,7 @@
></side-nav-item>
}
</div>
@if (!tagList.length && isTagsExpanded) {
@if (!tagList.length && isTagsExpanded()) {
<div class="no-tags-info">{{ T.MH.NO_TAG_INFO | translate }}</div>
}
<!-- <button (click)="addTag()"-->

View file

@ -1,12 +1,14 @@
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
ElementRef,
HostBinding,
HostListener,
inject,
OnDestroy,
signal,
viewChild,
viewChildren,
} from '@angular/core';
@ -16,10 +18,10 @@ import { DialogCreateProjectComponent } from '../../features/project/dialogs/cre
import { Project } from '../../features/project/project.model';
import { MatDialog } from '@angular/material/dialog';
import { DRAG_DELAY_FOR_TOUCH_LONGER } from '../../app.constants';
import { BehaviorSubject, combineLatest, Observable, Subscription } from 'rxjs';
import { Subscription } from 'rxjs';
import { WorkContextService } from '../../features/work-context/work-context.service';
import { standardListAnimation } from '../../ui/animations/standard-list.ani';
import { first, map, switchMap } from 'rxjs/operators';
import { first } from 'rxjs/operators';
import { TagService } from '../../features/tag/tag.service';
import { Tag } from '../../features/tag/tag.model';
import { WorkContextType } from '../../features/work-context/work-context.model';
@ -96,50 +98,52 @@ export class SideNavComponent implements OnDestroy {
private readonly _globalConfigService = inject(GlobalConfigService);
private readonly _store = inject(Store);
readonly navEntries = viewChildren<MatMenuItem>('menuEntry');
navEntries = viewChildren<MatMenuItem>('menuEntry');
IS_MOUSE_PRIMARY = IS_MOUSE_PRIMARY;
IS_TOUCH_PRIMARY = IS_TOUCH_PRIMARY;
DRAG_DELAY_FOR_TOUCH_LONGER = DRAG_DELAY_FOR_TOUCH_LONGER;
keyboardFocusTimeout?: number;
readonly projectExpandBtn = viewChild('projectExpandBtn', { read: ElementRef });
isProjectsExpanded: boolean = this.fetchProjectListState();
isProjectsExpanded$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(
this.isProjectsExpanded,
);
projectExpandBtn = viewChild('projectExpandBtn', { read: ElementRef });
isProjectsExpanded = signal(this.fetchProjectListState());
allNonInboxProjects = toSignal(this._store.select(selectAllProjectsExceptInbox), {
initialValue: [],
});
nonHiddenProjects$: Observable<Project[]> = this.isProjectsExpanded$.pipe(
switchMap((isExpanded) =>
isExpanded
? this._store.select(selectUnarchivedVisibleProjects)
: combineLatest([
this._store.select(selectUnarchivedVisibleProjects),
this.workContextService.activeWorkContextId$,
]).pipe(map(([projects, id]) => projects.filter((p) => p.id === id))),
),
private _allVisibleProjects = toSignal(
this._store.select(selectUnarchivedVisibleProjects),
{ initialValue: [] },
);
private _activeWorkContextId = toSignal(this.workContextService.activeWorkContextId$, {
initialValue: null,
});
readonly tagExpandBtn = viewChild('tagExpandBtn', { read: ElementRef });
isTagsExpanded: boolean = this.fetchTagListState();
isTagsExpanded$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(
this.isTagsExpanded,
);
nonHiddenProjects = computed(() => {
const isExpanded = this.isProjectsExpanded();
const projects = this._allVisibleProjects();
if (isExpanded) {
return projects;
}
const activeId = this._activeWorkContextId();
return projects.filter((p) => p.id === activeId);
});
tagListToDisplay$: Observable<Tag[]> = this.tagService.tagsNoMyDayAndNoList$;
tagExpandBtn = viewChild('tagExpandBtn', { read: ElementRef });
isTagsExpanded = signal(this.fetchTagListState());
tagList$: Observable<Tag[]> = this.isTagsExpanded$.pipe(
switchMap((isExpanded) =>
isExpanded
? this.tagListToDisplay$
: combineLatest([
this.tagListToDisplay$,
this.workContextService.activeWorkContextId$,
]).pipe(map(([tags, id]) => tags.filter((t) => t.id === id))),
),
);
private _tagListToDisplay = toSignal(this.tagService.tagsNoMyDayAndNoList$, {
initialValue: [],
});
tagList = computed(() => {
const isExpanded = this.isTagsExpanded();
const tags = this._tagListToDisplay();
if (isExpanded) {
return tags;
}
const activeId = this._activeWorkContextId();
return tags.filter((t) => t.id === activeId);
});
T: typeof T = T;
activeWorkContextId?: string | null;
WorkContextType: typeof WorkContextType = WorkContextType;
@ -198,8 +202,8 @@ export class SideNavComponent implements OnDestroy {
return localStorage.getItem(LS.IS_PROJECT_LIST_EXPANDED) === 'true';
}
storeProjectListState(isExpanded: boolean): void {
this.isProjectsExpanded = isExpanded;
private _storeProjectListState(isExpanded: boolean): void {
this.isProjectsExpanded.set(isExpanded);
localStorage.setItem(LS.IS_PROJECT_LIST_EXPANDED, isExpanded.toString());
}
@ -208,23 +212,20 @@ export class SideNavComponent implements OnDestroy {
}
storeTagListState(isExpanded: boolean): void {
this.isTagsExpanded = isExpanded;
this.isTagsExpanded.set(isExpanded);
localStorage.setItem(LS.IS_TAG_LIST_EXPANDED, isExpanded.toString());
}
toggleExpandProjects(): void {
const newState: boolean = !this.isProjectsExpanded;
this.storeProjectListState(newState);
this.isProjectsExpanded$.next(newState);
const newState: boolean = !this.isProjectsExpanded();
this._storeProjectListState(newState);
}
toggleExpandProjectsLeftRight(ev: KeyboardEvent): void {
if (ev.key === 'ArrowLeft' && this.isProjectsExpanded) {
this.storeProjectListState(false);
this.isProjectsExpanded$.next(this.isProjectsExpanded);
} else if (ev.key === 'ArrowRight' && !this.isProjectsExpanded) {
this.storeProjectListState(true);
this.isProjectsExpanded$.next(this.isProjectsExpanded);
if (ev.key === 'ArrowLeft' && this.isProjectsExpanded()) {
this._storeProjectListState(false);
} else if (ev.key === 'ArrowRight' && !this.isProjectsExpanded()) {
this._storeProjectListState(true);
}
}
@ -244,18 +245,15 @@ export class SideNavComponent implements OnDestroy {
}
toggleExpandTags(): void {
const newState: boolean = !this.isTagsExpanded;
const newState: boolean = !this.isTagsExpanded();
this.storeTagListState(newState);
this.isTagsExpanded$.next(newState);
}
toggleExpandTagsLeftRight(ev: KeyboardEvent): void {
if (ev.key === 'ArrowLeft' && this.isTagsExpanded) {
if (ev.key === 'ArrowLeft' && this.isTagsExpanded()) {
this.storeTagListState(false);
this.isTagsExpanded$.next(this.isTagsExpanded);
} else if (ev.key === 'ArrowRight' && !this.isTagsExpanded) {
} else if (ev.key === 'ArrowRight' && !this.isTagsExpanded()) {
this.storeTagListState(true);
this.isTagsExpanded$.next(this.isTagsExpanded);
}
}

View file

@ -1,4 +1,5 @@
import { effect, inject, Injectable } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { BodyClass, IS_ELECTRON } from '../../app.constants';
import { IS_MAC } from '../../util/is-mac';
import {
@ -51,7 +52,7 @@ export class GlobalThemeService {
(localStorage.getItem(LS.DARK_MODE) as DarkModeCfg) || 'system',
);
isDarkTheme$: Observable<boolean> = this.darkMode$.pipe(
private _isDarkThemeObs$: Observable<boolean> = this.darkMode$.pipe(
switchMap((darkMode) => {
switch (darkMode) {
case 'dark':
@ -69,9 +70,11 @@ export class GlobalThemeService {
distinctUntilChanged(),
);
backgroundImg$: Observable<string | null | undefined> = combineLatest([
isDarkTheme = toSignal(this._isDarkThemeObs$, { initialValue: false });
private _backgroundImgObs$: Observable<string | null | undefined> = combineLatest([
this._workContextService.currentTheme$,
this.isDarkTheme$,
this._isDarkThemeObs$,
]).pipe(
map(([theme, isDarkMode]) =>
isDarkMode ? theme.backgroundImageDark : theme.backgroundImageLight,
@ -79,6 +82,8 @@ export class GlobalThemeService {
distinctUntilChanged(),
);
backgroundImg = toSignal(this._backgroundImgObs$);
init(): void {
// This is here to make web page reloads on non-work-context pages at least usable
this._setBackgroundGradient(true);
@ -190,7 +195,7 @@ export class GlobalThemeService {
this._workContextService.currentTheme$.subscribe((theme: WorkContextThemeCfg) =>
this._setColorTheme(theme),
);
this.isDarkTheme$.subscribe((isDarkTheme) => this._setDarkTheme(isDarkTheme));
this._isDarkThemeObs$.subscribe((isDarkTheme) => this._setDarkTheme(isDarkTheme));
}
private _initHandlersForInitialBodyClasses(): void {

View file

@ -1,6 +1,6 @@
<div class="mac-os-drag-bar"></div>
@if (!(isPomodoroEnabled$ | async)) {
@if (!isPomodoroEnabled()) {
<header>
<banner></banner>
</header>

View file

@ -1,6 +1,6 @@
import { ChangeDetectionStrategy, Component, inject, OnDestroy } from '@angular/core';
import { TaskService } from '../../tasks/task.service';
import { Observable, Subject } from 'rxjs';
import { Subject } from 'rxjs';
import { first, takeUntil } from 'rxjs/operators';
import { GlobalConfigService } from '../../config/global-config.service';
import { expandAnimation } from '../../../ui/animations/expand.ani';
@ -31,7 +31,7 @@ import { FocusModeDurationSelectionComponent } from '../focus-mode-duration-sele
import { FocusModePreparationComponent } from '../focus-mode-preparation/focus-mode-preparation.component';
import { FocusModeMainComponent } from '../focus-mode-main/focus-mode-main.component';
import { FocusModeTaskDoneComponent } from '../focus-mode-task-done/focus-mode-task-done.component';
import { AsyncPipe, NgTemplateOutlet } from '@angular/common';
import { NgTemplateOutlet } from '@angular/common';
import { TranslatePipe } from '@ngx-translate/core';
import { BannerService } from '../../../core/banner/banner.service';
import { BannerId } from '../../../core/banner/banner.model';
@ -55,7 +55,6 @@ import { FocusModeService } from '../focus-mode.service';
FocusModeMainComponent,
FocusModeTaskDoneComponent,
MatButton,
AsyncPipe,
TranslatePipe,
MatButtonToggleGroup,
MatButtonToggle,
@ -83,7 +82,9 @@ export class FocusModeOverlayComponent implements OnDestroy {
initialValue: undefined,
});
isPomodoroEnabled$: Observable<boolean> = this._store.select(selectIsPomodoroEnabled);
isPomodoroEnabled = toSignal(this._store.select(selectIsPomodoroEnabled), {
initialValue: false,
});
T: typeof T = T;

View file

@ -8,11 +8,11 @@
<div class="msg">
<div class="worked-for-label">{{ T.F.FOCUS_MODE.WORKED_FOR | translate }}</div>
<div class="worked-for-value">
{{ lastSessionTotalDuration$ | async | msToString: true }}
{{ lastSessionTotalDuration() | msToString: true }}
</div>
<div class="task-title-label">{{ T.F.FOCUS_MODE.ON | translate }}</div>
<div class="task-title">{{ taskTitle$ | async }}</div>
<div class="task-title">{{ taskTitle() }}</div>
</div>
<div class="btn-wrapper">
@ -24,7 +24,7 @@
{{ T.F.FOCUS_MODE.BACK_TO_PLANNING | translate }}
</button>
@if (!(currentTask$ | async)) {
@if (!currentTask()) {
<button
mat-raised-button
color="primary"
@ -33,7 +33,7 @@
{{ T.F.FOCUS_MODE.START_NEXT_FOCUS_SESSION | translate }}
</button>
}
@if (currentTask$ | async) {
@if (currentTask()) {
<button
mat-raised-button
color="primary"

View file

@ -1,5 +1,5 @@
import { AsyncPipe } from '@angular/common';
import { AfterViewInit, ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatButton } from '@angular/material/button';
import { of } from 'rxjs';
@ -31,25 +31,26 @@ import {
templateUrl: './focus-mode-task-done.component.html',
styleUrls: ['./focus-mode-task-done.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [MatButton, AsyncPipe, MsToStringPipe, TranslatePipe],
imports: [MatButton, MsToStringPipe, TranslatePipe],
})
export class FocusModeTaskDoneComponent implements AfterViewInit {
private _store = inject(Store);
private readonly _confettiService = inject(ConfettiService);
mode$ = this._store.select(selectFocusModeMode);
currentTask$ = this._store.select(selectCurrentTask);
lastCurrentTask$ = this._store.select(selectLastCurrentTask);
taskTitle$ = this.lastCurrentTask$.pipe(
switchMap((lastCurrentTask) =>
lastCurrentTask
? of(lastCurrentTask.title)
: this.currentTask$.pipe(map((task) => task?.title)),
mode = toSignal(this._store.select(selectFocusModeMode));
currentTask = toSignal(this._store.select(selectCurrentTask));
taskTitle = toSignal(
this._store.select(selectLastCurrentTask).pipe(
switchMap((lastCurrentTask) =>
lastCurrentTask
? of(lastCurrentTask.title)
: this._store.select(selectCurrentTask).pipe(map((task) => task?.title)),
),
take(1),
),
take(1),
);
lastSessionTotalDuration$ = this._store.select(
selectLastSessionTotalDurationOrTimeElapsedFallback,
lastSessionTotalDuration = toSignal(
this._store.select(selectLastSessionTotalDurationOrTimeElapsedFallback),
);
T: typeof T = T;

View file

@ -1,5 +1,5 @@
<div class="wrapper">
@if (projectMetricsService.simpleMetrics$ | async; as sm) {
@if (projectMetricsService.simpleMetrics(); as sm) {
<section
class="basic-stats"
[@fade]
@ -63,16 +63,16 @@
</div>
</section>
}
@if (!(metricService.hasData$ | async)) {
@if (!metricService.hasData()) {
<p style="margin-top: 32px">
{{ T.F.METRIC.CMP.NO_ADDITIONAL_DATA_YET | translate }}
</p>
}
@if (metricService.hasData$ | async) {
@if (metricService.hasData()) {
<section class="metric-metrics">
<h1>{{ T.F.METRIC.CMP.GLOBAL_METRICS | translate }}</h1>
<section class="pie-charts">
@if (metricService.improvementCountsPieChartData$ | async; as improvementCounts) {
@if (metricService.improvementCountsPieChartData(); as improvementCounts) {
<section>
<h3>{{ T.F.METRIC.CMP.IMPROVEMENT_SELECTION_COUNT | translate }}</h3>
<lazy-chart
@ -86,7 +86,7 @@
</lazy-chart>
</section>
}
@if (metricService.obstructionCountsPieChartData$ | async; as obstructionCounts) {
@if (metricService.obstructionCountsPieChartData(); as obstructionCounts) {
<section>
<h3>{{ T.F.METRIC.CMP.OBSTRUCTION_SELECTION_COUNT | translate }}</h3>
<lazy-chart
@ -102,7 +102,7 @@
}
</section>
<section class="line-charts">
@if (productivityHappiness$ | async; as productivityHappiness) {
@if (productivityHappiness(); as productivityHappiness) {
<section>
<h3>{{ T.F.METRIC.CMP.MOOD_PRODUCTIVITY_OVER_TIME | translate }}</h3>
<lazy-chart
@ -118,11 +118,11 @@
</section>
</section>
}
@if (metricService.hasData$ | async) {
@if (metricService.hasData()) {
<section class="metric-metrics">
<h2>{{ T.F.METRIC.CMP.SIMPLE_COUNTERS | translate }}</h2>
<section class="line-charts">
@if (simpleClickCounterData$ | async; as simpleCounterClickData) {
@if (simpleClickCounterData(); as simpleCounterClickData) {
<section>
<h3>{{ T.F.METRIC.CMP.SIMPLE_CLICK_COUNTERS_OVER_TIME | translate }}</h3>
<lazy-chart
@ -136,7 +136,7 @@
</lazy-chart>
</section>
}
@if (simpleCounterStopWatchData$ | async; as simpleCounterStopWatchData) {
@if (simpleCounterStopWatchData(); as simpleCounterStopWatchData) {
<section>
<h3>{{ T.F.METRIC.CMP.SIMPLE_STOPWATCH_COUNTERS_OVER_TIME | translate }}</h3>
<lazy-chart

View file

@ -1,8 +1,7 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ChartConfiguration, ChartType } from 'chart.js';
import { MetricService } from './metric.service';
import { Observable } from 'rxjs';
import { LineChartData } from './metric.model';
import { toSignal } from '@angular/core/rxjs-interop';
import { fadeAnimation } from '../../ui/animations/fade.ani';
import { T } from '../../t.const';
import { ProjectMetricsService } from './project-metrics.service';
@ -27,14 +26,15 @@ export class MetricComponent {
T: typeof T = T;
productivityHappiness$: Observable<LineChartData> =
this.metricService.getProductivityHappinessChartData$();
productivityHappiness = toSignal(
this.metricService.getProductivityHappinessChartData$(),
);
simpleClickCounterData$: Observable<LineChartData> =
this.metricService.getSimpleClickCounterMetrics$();
simpleClickCounterData = toSignal(this.metricService.getSimpleClickCounterMetrics$());
simpleCounterStopWatchData$: Observable<LineChartData> =
this.metricService.getSimpleCounterStopwatchMetrics$();
simpleCounterStopWatchData = toSignal(
this.metricService.getSimpleCounterStopwatchMetrics$(),
);
pieChartOptions: ChartConfiguration<'pie', number[], string>['options'] = {
scales: {

View file

@ -1,4 +1,5 @@
import { Injectable, inject } from '@angular/core';
import { inject, Injectable } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { select, Store } from '@ngrx/store';
import {
addMetric,
@ -7,11 +8,10 @@ import {
upsertMetric,
} from './store/metric.actions';
import { combineLatest, Observable, of } from 'rxjs';
import { LineChartData, Metric, MetricState, PieChartData } from './metric.model';
import { LineChartData, Metric, MetricState } from './metric.model';
import {
selectImprovementCountsPieChartData,
selectMetricById,
selectMetricFeatureState,
selectMetricHasData,
selectObstructionCountsPieChartData,
selectProductivityHappinessLineChartData,
@ -24,7 +24,6 @@ import {
selectCheckedImprovementIdsForDay,
selectRepeatedImprovementIds,
} from './improvement/store/improvement.reducer';
import { selectHasSimpleCounterData } from '../simple-counter/store/simple-counter.reducer';
import { DateService } from 'src/app/core/date/date.service';
@Injectable({
@ -34,18 +33,17 @@ export class MetricService {
private _store$ = inject<Store<MetricState>>(Store);
private _dateService = inject(DateService);
// metrics$: Observable<Metric[]> = this._store$.pipe(select(selectAllMetrics));
hasData$: Observable<boolean> = this._store$.pipe(select(selectMetricHasData));
hasSimpleCounterData$: Observable<boolean> = this._store$.pipe(
select(selectHasSimpleCounterData),
hasData = toSignal(this._store$.pipe(select(selectMetricHasData)), {
initialValue: false,
});
improvementCountsPieChartData = toSignal(
this._store$.pipe(select(selectImprovementCountsPieChartData)),
{ initialValue: null },
);
state$: Observable<MetricState> = this._store$.pipe(select(selectMetricFeatureState));
// lastTrackedMetric$: Observable<Metric> = this._store$.pipe(select(selectLastTrackedMetric));
improvementCountsPieChartData$: Observable<PieChartData | null> = this._store$.pipe(
select(selectImprovementCountsPieChartData),
);
obstructionCountsPieChartData$: Observable<PieChartData | null> = this._store$.pipe(
select(selectObstructionCountsPieChartData),
obstructionCountsPieChartData = toSignal(
this._store$.pipe(select(selectObstructionCountsPieChartData)),
{ initialValue: null },
);
// getMetricForDay$(id: string = getWorklogStr()): Observable<Metric> {

View file

@ -1,4 +1,5 @@
import { Injectable, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { combineLatest, EMPTY, from, Observable } from 'rxjs';
import { SimpleMetrics } from './metric.model';
import { delay, map, switchMap, take } from 'rxjs/operators';
@ -18,7 +19,7 @@ export class ProjectMetricsService {
private _worklogService = inject(WorklogService);
private _workContextService = inject(WorkContextService);
simpleMetrics$: Observable<SimpleMetrics> =
private _simpleMetricsObs$: Observable<SimpleMetrics> =
this._workContextService.activeWorkContextTypeAndId$.pipe(
// wait for current projectId to settle in :(
delay(100),
@ -38,4 +39,6 @@ export class ProjectMetricsService {
: EMPTY;
}),
);
simpleMetrics = toSignal(this._simpleMetricsObs$);
}

View file

@ -76,7 +76,7 @@
}
</button>
@if (isSearchIssueProvidersAvailable$ | async) {
@if (isSearchIssueProvidersAvailable()) {
<button
(click)="isSearchIssueProviders.set(!isSearchIssueProviders())"
[matTooltip]="'Toggle searching issue providers'"
@ -92,7 +92,7 @@
}
</button>
}
@if (isAddToBacklogAvailable$ | async) {
@if (isAddToBacklogAvailable()) {
<button
(click)="isAddToBacklog.set(!isAddToBacklog())"
[matTooltip]="T.F.TASK.ADD_TASK_BAR.TOGGLE_ADD_TO_BACKLOG_TODAY | translate"

View file

@ -29,7 +29,7 @@ import { MentionConfig } from 'angular-mentions/lib/mention-config';
import { AddTaskBarService } from './add-task-bar.service';
import { map } from 'rxjs/operators';
import { selectEnabledIssueProviders } from '../../issue/store/issue-provider.selectors';
import { toObservable } from '@angular/core/rxjs-interop';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import {
MatAutocomplete,
MatAutocompleteOrigin,
@ -138,12 +138,17 @@ export class AddTaskBarComponent implements AfterViewInit, OnDestroy {
mentionConfig$: Observable<MentionConfig> = this._addTaskBarService.getMentionConfig$();
isAddToBacklogAvailable$: Observable<boolean> =
this._workContextService.activeWorkContext$.pipe(map((ctx) => !!ctx.isEnableBacklog));
isAddToBacklogAvailable = toSignal(
this._workContextService.activeWorkContext$.pipe(map((ctx) => !!ctx.isEnableBacklog)),
{ initialValue: false },
);
isSearchIssueProvidersAvailable$: Observable<boolean> = this._store
.select(selectEnabledIssueProviders)
.pipe(map((issueProviders) => issueProviders.length > 0));
isSearchIssueProvidersAvailable = toSignal(
this._store
.select(selectEnabledIssueProviders)
.pipe(map((issueProviders) => issueProviders.length > 0)),
{ initialValue: false },
);
private _isAddInProgress?: boolean;
private _lastAddedTaskId?: string;

View file

@ -1,6 +1,6 @@
<work-view
[backlogTasks]="workContextService.backlogTasks$ | async"
[doneTasks]="workContextService.doneTasks$ | async"
[isShowBacklog]="isShowBacklog$ | async"
[undoneTasks]="workContextService.undoneTasks$ | async"
[backlogTasks]="backlogTasks()"
[doneTasks]="doneTasks()"
[isShowBacklog]="isShowBacklog()"
[undoneTasks]="undoneTasks()"
></work-view>

View file

@ -1,8 +1,7 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { WorkContextService } from '../../features/work-context/work-context.service';
import { Observable } from 'rxjs';
import { toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs/operators';
import { AsyncPipe } from '@angular/common';
import { WorkViewComponent } from '../../features/work-view/work-view.component';
@Component({
@ -10,12 +9,19 @@ import { WorkViewComponent } from '../../features/work-view/work-view.component'
templateUrl: './project-task-page.component.html',
styleUrls: ['./project-task-page.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [AsyncPipe, WorkViewComponent],
imports: [WorkViewComponent],
})
export class ProjectTaskPageComponent {
workContextService = inject(WorkContextService);
isShowBacklog$: Observable<boolean> = this.workContextService.activeWorkContext$.pipe(
map((workContext) => !!workContext.isEnableBacklog),
isShowBacklog = toSignal(
this.workContextService.activeWorkContext$.pipe(
map((workContext) => !!workContext.isEnableBacklog),
),
{ initialValue: false },
);
backlogTasks = toSignal(this.workContextService.backlogTasks$, { initialValue: [] });
doneTasks = toSignal(this.workContextService.doneTasks$, { initialValue: [] });
undoneTasks = toSignal(this.workContextService.undoneTasks$, { initialValue: [] });
}

View file

@ -19,13 +19,7 @@ import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Subject } from 'rxjs';
import {
debounceTime,
distinctUntilChanged,
switchMap,
takeUntil,
take,
} from 'rxjs/operators';
import { debounceTime, distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
import { UnsplashService, UnsplashPhoto } from '../../core/unsplash/unsplash.service';
import { GlobalThemeService } from '../../core/theme/global-theme.service';
import { TranslateModule } from '@ngx-translate/core';
@ -108,13 +102,9 @@ export class DialogUnsplashPickerComponent implements OnInit, OnDestroy {
this.isLoading.set(true);
// If empty query, use context-aware default
if (!query.trim()) {
return this._globalThemeService.isDarkTheme$.pipe(
take(1),
switchMap((isDark) => {
const defaultQuery = this.getDefaultSearchQuery(isDark);
return this._unsplashService.searchPhotos(defaultQuery);
}),
);
const isDark = this._globalThemeService.isDarkTheme();
const defaultQuery = this.getDefaultSearchQuery(isDark);
return this._unsplashService.searchPhotos(defaultQuery);
}
return this._unsplashService.searchPhotos(query);
}),
@ -132,9 +122,8 @@ export class DialogUnsplashPickerComponent implements OnInit, OnDestroy {
});
// Initial load with context-aware defaults
this._globalThemeService.isDarkTheme$.pipe(take(1)).subscribe((isDark) => {
this.onSearchChange(this.getDefaultSearchQuery(isDark));
});
const isDark = this._globalThemeService.isDarkTheme();
this.onSearchChange(this.getDefaultSearchQuery(isDark));
}
ngOnDestroy(): void {

View file

@ -1,9 +1,9 @@
<div
#wrapperEl
[class.isHideOverflow]="isHideOverflow"
[class.isHideOverflow]="isHideOverflow()"
class="markdown-wrapper"
>
@if (isShowEdit || isTurnOffMarkdownParsing()) {
@if (isShowEdit() || isTurnOffMarkdownParsing()) {
<textarea
#textareaEl
(blur)="untoggleShowEdit(); setBlur($event)"
@ -12,7 +12,7 @@
(keydown)="keypressHandler($event)"
(keypress)="keypressHandler($event)"
[@fadeIn]
[ngModel]="modelCopy"
[ngModel]="modelCopy()"
class="mat-body-2 markdown-unparsed"
rows="1"
></textarea>
@ -24,7 +24,7 @@
#previewEl
(click)="clickPreview($event)"
[data]="model"
[hidden]="isShowEdit"
[hidden]="isShowEdit()"
class="mat-body-2 markdown-parsed markdown"
markdown
tabindex="0"

View file

@ -11,6 +11,7 @@ import {
OnDestroy,
OnInit,
output,
signal,
viewChild,
} from '@angular/core';
import { fadeInAnimation } from '../animations/fade.ani';
@ -53,10 +54,10 @@ export class InlineMarkdownComponent implements OnInit, OnDestroy {
readonly textareaEl = viewChild<ElementRef>('textareaEl');
readonly previewEl = viewChild<MarkdownComponent>('previewEl');
isHideOverflow: boolean = false;
isChecklistMode: boolean = false;
isShowEdit: boolean = false;
modelCopy: string | undefined;
isHideOverflow = signal(false);
isChecklistMode = signal(false);
isShowEdit = signal(false);
modelCopy = signal<string | undefined>(undefined);
isTurnOffMarkdownParsing = computed(() => {
const misc = this._globalConfigService.misc();
@ -69,7 +70,7 @@ export class InlineMarkdownComponent implements OnInit, OnDestroy {
}
@HostBinding('class.isFocused') get isFocused(): boolean {
return this.isShowEdit;
return this.isShowEdit();
}
private _model: string | undefined;
@ -82,25 +83,26 @@ export class InlineMarkdownComponent implements OnInit, OnDestroy {
// Accessor inputs cannot be migrated as they are too complex.
@Input() set model(v: string | undefined) {
this._model = v;
this.modelCopy = v;
this.modelCopy.set(v);
if (!this.isShowEdit) {
if (!this.isShowEdit()) {
window.setTimeout(() => {
this.resizeParsedToFit();
});
}
this.isChecklistMode =
this.isChecklistMode &&
this.isShowChecklistToggle() &&
!!v &&
isMarkdownChecklist(v);
this.isChecklistMode.set(
this.isChecklistMode() &&
this.isShowChecklistToggle() &&
!!v &&
isMarkdownChecklist(v),
);
}
// TODO: Skipped for migration because:
// Accessor inputs cannot be migrated as they are too complex.
@Input() set isFocus(val: boolean) {
if (!this.isShowEdit && val) {
if (!this.isShowEdit() && val) {
this._toggleShowEdit();
}
}
@ -123,7 +125,7 @@ export class InlineMarkdownComponent implements OnInit, OnDestroy {
}
checklistToggle(): void {
this.isChecklistMode = !this.isChecklistMode;
this.isChecklistMode.set(!this.isChecklistMode());
}
keypressHandler(ev: KeyboardEvent): void {
@ -154,17 +156,17 @@ export class InlineMarkdownComponent implements OnInit, OnDestroy {
untoggleShowEdit(): void {
if (!this.isLock()) {
this.resizeParsedToFit();
this.isShowEdit = false;
this.isShowEdit.set(false);
}
const textareaEl = this.textareaEl();
if (!textareaEl) {
throw new Error('Textarea not visible');
}
this.modelCopy = textareaEl.nativeElement.value;
this.modelCopy.set(textareaEl.nativeElement.value);
if (this.modelCopy !== this.model) {
this.model = this.modelCopy;
this.changed.emit(this.modelCopy as string);
if (this.modelCopy() !== this.model) {
this.model = this.modelCopy();
this.changed.emit(this.modelCopy() as string);
}
}
@ -190,13 +192,13 @@ export class InlineMarkdownComponent implements OnInit, OnDestroy {
height: '100vh',
restoreFocus: true,
data: {
content: this.modelCopy,
content: this.modelCopy(),
},
})
.afterClosed()
.subscribe((res) => {
if (typeof res === 'string') {
this.modelCopy = res;
this.modelCopy.set(res);
this.changed.emit(res);
}
});
@ -236,40 +238,44 @@ export class InlineMarkdownComponent implements OnInit, OnDestroy {
toggleChecklistMode(ev: Event): void {
ev.preventDefault();
ev.stopPropagation();
this.isChecklistMode = true;
this.isChecklistMode.set(true);
this._toggleShowEdit();
if (this.isDefaultText()) {
this.modelCopy = '- [ ] ';
this.modelCopy.set('- [ ] ');
} else {
this.modelCopy += '\n- [ ] ';
this.modelCopy.set(this.modelCopy() + '\n- [ ] ');
// cleanup string on add
this.modelCopy = this.modelCopy?.replace(/\n\n- \[/g, '\n- [').replace(/^\n/g, '');
this.modelCopy.set(
this.modelCopy()
?.replace(/\n\n- \[/g, '\n- [')
.replace(/^\n/g, ''),
);
}
}
private _toggleShowEdit(): void {
this.isShowEdit = true;
this.modelCopy = this.model || '';
this.isShowEdit.set(true);
this.modelCopy.set(this.model || '');
setTimeout(() => {
const textareaEl = this.textareaEl();
if (!textareaEl) {
throw new Error('Textarea not visible');
}
textareaEl.nativeElement.value = this.modelCopy;
textareaEl.nativeElement.value = this.modelCopy();
textareaEl.nativeElement.focus();
this.resizeTextareaToFit();
});
}
private _hideOverflow(): void {
this.isHideOverflow = true;
this.isHideOverflow.set(true);
if (this._hideOverFlowTimeout) {
window.clearTimeout(this._hideOverFlowTimeout);
}
this._hideOverFlowTimeout = window.setTimeout(() => {
this.isHideOverflow = false;
this.isHideOverflow.set(false);
this._cd.detectChanges();
}, HIDE_OVERFLOW_TIMEOUT_DURATION);
}
@ -311,12 +317,12 @@ export class InlineMarkdownComponent implements OnInit, OnDestroy {
allLines[itemIndex] = item.includes('[ ]')
? item.replace('[ ]', '[x]').replace('[]', '[x]')
: item.replace('[x]', '[ ]');
this.modelCopy = allLines.join('\n');
this.modelCopy.set(allLines.join('\n'));
// Update the markdown string
if (this.modelCopy !== this.model) {
this.model = this.modelCopy;
this.changed.emit(this.modelCopy);
if (this.modelCopy() !== this.model) {
this.model = this.modelCopy();
this.changed.emit(this.modelCopy() as string);
}
}
}