From c9cb8dddfaf727d678b1e09c6a965018eef7ae47 Mon Sep 17 00:00:00 2001
From: Maikel Hajiabadi <89179997+hajiboy95@users.noreply.github.com>
Date: Tue, 9 Jun 2026 19:18:10 +0200
Subject: [PATCH] =?UTF-8?q?feat(search):=20include=20note=20content=20in?=
=?UTF-8?q?=20global=20search=20and=20support=20folde=E2=80=A6=20(#8044)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(search): include note content in global search and support folder path disambiguation
* fix(i18n): revert accidental changes to de.json
* fix(notes): implement robust retry mechanism for focusing notes
* fix(search): improve reactivity for folder path labels and archive cache invalidation
* fix(notes): prevent stale focus retry loops
* test(search): add regression spec for folder path reactivity
* fix(notes): track and cancel stale focusItem retry loops
* test(search): add regression test for archive cache invalidation on folder map changes
* fix(search): improve note highlighting cleanup and remove debug log
---
docs/wiki/4.10-Task-Notes.md | 2 +-
.../features/note/notes/notes.component.html | 1 +
.../features/note/notes/notes.component.scss | 8 +
.../features/note/notes/notes.component.ts | 58 ++++++
.../search-page/search-page.component.html | 3 +
.../search-page/search-page.component.spec.ts | 141 ++++++++++++++
.../search-page/search-page.component.ts | 178 ++++++++++++++++--
.../pages/search-page/search-page.model.ts | 1 +
src/assets/i18n/en.json | 6 +-
9 files changed, 375 insertions(+), 23 deletions(-)
diff --git a/docs/wiki/4.10-Task-Notes.md b/docs/wiki/4.10-Task-Notes.md
index 4517932dba..55f1cb84be 100644
--- a/docs/wiki/4.10-Task-Notes.md
+++ b/docs/wiki/4.10-Task-Notes.md
@@ -28,7 +28,7 @@ The note system balances structure and flexibility so notes can serve as both pl
2. **Markdown flexibility** — Notes support standard markdown: headings, lists, bold, links, and so on. You can write free-form text or use list syntax that the app can treat as a checklist. Rendered checklist items can be toggled from the checkbox or item text, while links keep their normal link behavior. You are not locked into a fixed form.
-3. **Context-aware storage** — Notes are stored as simple text attached to a specific task (or, for project notes, to a project). That keeps them easy to find and keeps the data model simple—no heavy schema, just content tied to the right context.
+3. **Context-aware storage** — Notes are stored as simple text attached to a specific task (or, for project notes, to a project). That keeps them easy to find and keeps the data model simple—no heavy schema, just content tied to the right context. Both project notes and task notes can also be found via the global search (Shift+F), allowing you to quickly jump to and highlight note content across different projects.
4. **Minimal schema** — The note model stays minimal: content, optional image reference, and basic metadata. That avoids over-structuring and keeps notes fast to use for ad hoc capture while still supporting templates and checklists when you want them.
diff --git a/src/app/features/note/notes/notes.component.html b/src/app/features/note/notes/notes.component.html
index 6216d8b955..c44a654b48 100644
--- a/src/app/features/note/notes/notes.component.html
+++ b/src/app/features/note/notes/notes.component.html
@@ -32,6 +32,7 @@
@for (note of notes; track note.id) {
diff --git a/src/app/features/note/notes/notes.component.scss b/src/app/features/note/notes/notes.component.scss
index 2f58f8bde2..4c0bee2d3b 100644
--- a/src/app/features/note/notes/notes.component.scss
+++ b/src/app/features/note/notes/notes.component.scss
@@ -45,3 +45,11 @@ note {
display: flex;
z-index: 2;
}
+
+.highlight-searched-item note ::ng-deep .note {
+ outline: 2px solid var(--c-accent) !important;
+ box-shadow: var(--whiteframe-shadow-6dp) !important;
+ transition:
+ outline 0.2s ease,
+ box-shadow 0.2s ease;
+}
diff --git a/src/app/features/note/notes/notes.component.ts b/src/app/features/note/notes/notes.component.ts
index 486a8ad875..5ddad4ca8b 100644
--- a/src/app/features/note/notes/notes.component.ts
+++ b/src/app/features/note/notes/notes.component.ts
@@ -5,7 +5,9 @@ import {
inject,
viewChild,
OnInit,
+ DestroyRef,
} from '@angular/core';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NoteService } from '../note.service';
import { MatButton } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
@@ -25,6 +27,8 @@ import { HISTORY_STATE } from '../../../app.constants';
import { dragDelayForTouch } from '../../../util/input-intent';
import { LayoutService } from 'src/app/core-ui/layout/layout.service';
import { IS_MOBILE } from 'src/app/util/is-mobile';
+import { ActivatedRoute } from '@angular/router';
+import { distinctUntilChanged, map } from 'rxjs/operators';
@Component({
selector: 'notes',
@@ -47,6 +51,11 @@ export class NotesComponent implements OnInit {
workContextService = inject(WorkContextService);
private _matDialog = inject(MatDialog);
private _layoutService = inject(LayoutService);
+ private _activatedRoute = inject(ActivatedRoute);
+ private _destroyRef = inject(DestroyRef);
+
+ private _focusToken?: object;
+ private _focusNoteTimeout?: number;
T: typeof T = T;
isElementWasAdded: boolean = false;
@@ -93,6 +102,55 @@ export class NotesComponent implements OnInit {
window.history.pushState({ [HISTORY_STATE.NOTES]: true }, '');
}
}
+
+ this._destroyRef.onDestroy(() => {
+ this._focusToken = undefined;
+ window.clearTimeout(this._focusNoteTimeout);
+ });
+
+ this._activatedRoute.queryParams
+ .pipe(
+ map((params) => params.focusItem),
+ distinctUntilChanged(),
+ takeUntilDestroyed(this._destroyRef),
+ )
+ .subscribe((focusItem) => {
+ if (focusItem) {
+ this._focusNote(focusItem);
+ } else {
+ this._focusToken = undefined;
+ window.clearTimeout(this._focusNoteTimeout);
+ }
+ });
+ }
+
+ private _focusNote(noteId: string): void {
+ document.querySelectorAll('.highlight-searched-item').forEach((el) => {
+ el.classList.remove('highlight-searched-item');
+ });
+ const id = `n-${noteId}`;
+ const startTime = Date.now();
+ const timeout = 4000;
+ const token = {};
+ this._focusToken = token;
+ window.clearTimeout(this._focusNoteTimeout);
+
+ const tryFocus = (): void => {
+ if (this._focusToken !== token) {
+ return;
+ }
+ const el = document.getElementById(id);
+ if (el) {
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ el.classList.add('highlight-searched-item');
+ setTimeout(() => {
+ el.classList.remove('highlight-searched-item');
+ }, 3000);
+ } else if (Date.now() - startTime < timeout) {
+ this._focusNoteTimeout = window.setTimeout(tryFocus, 100);
+ }
+ };
+ tryFocus();
}
@HostListener('window:popstate')
diff --git a/src/app/pages/search-page/search-page.component.html b/src/app/pages/search-page/search-page.component.html
index 7e3d31b59f..add8ec3c2d 100644
--- a/src/app/pages/search-page/search-page.component.html
+++ b/src/app/pages/search-page/search-page.component.html
@@ -57,6 +57,9 @@
class="issue-icon"
>
}
+ @if (item.isNote) {
+
comment
+ }
@if (item.isArchiveTask) {
archive
{
let archivedTasks: Task[];
let projectList$: BehaviorSubject;
let tags$: BehaviorSubject;
+ let notes$: BehaviorSubject;
+ let projectFolderMapSignal: WritableSignal>;
+ let tagFolderMapSignal: WritableSignal>;
let taskServiceSpy: jasmine.SpyObj;
let navigateToTaskServiceSpy: jasmine.SpyObj;
+ let routerSpy: jasmine.SpyObj;
+ let layoutServiceSpy: jasmine.SpyObj;
beforeEach(async () => {
allTasks$ = new BehaviorSubject([]);
archivedTasks = [];
projectList$ = new BehaviorSubject([createProject()]);
tags$ = new BehaviorSubject([createTag()]);
+ notes$ = new BehaviorSubject([]);
+ projectFolderMapSignal = signal>(new Map());
+ tagFolderMapSignal = signal>(new Map());
taskServiceSpy = jasmine.createSpyObj('TaskService', ['getArchivedTasks'], {
allTasks$,
@@ -75,6 +90,13 @@ describe('SearchPageComponent', () => {
]);
navigateToTaskServiceSpy.navigate.and.returnValue(Promise.resolve());
+ routerSpy = jasmine.createSpyObj('Router', ['navigate']);
+ routerSpy.navigate.and.returnValue(Promise.resolve(true));
+
+ layoutServiceSpy = jasmine.createSpyObj('LayoutService', ['toggleNotes'], {
+ isShowNotes: signal(false),
+ });
+
await TestBed.configureTestingModule({
imports: [SearchPageComponent, NoopAnimationsModule, TranslateModule.forRoot()],
providers: [
@@ -88,6 +110,16 @@ describe('SearchPageComponent', () => {
provide: NavigateToTaskService,
useValue: navigateToTaskServiceSpy,
},
+ { provide: NoteService, useValue: { notes$ } },
+ { provide: Router, useValue: routerSpy },
+ { provide: LayoutService, useValue: layoutServiceSpy },
+ {
+ provide: MenuTreeService,
+ useValue: {
+ projectFolderMap: projectFolderMapSignal,
+ tagFolderMap: tagFolderMapSignal,
+ },
+ },
],
})
.overrideComponent(SearchPageComponent, {
@@ -384,4 +416,113 @@ describe('SearchPageComponent', () => {
expect(latestResults[0].searchText).toContain('hello world');
expect(latestResults[0].searchText).toContain('some notes');
}));
+
+ it('should find notes by content (case-insensitive)', fakeAsync(() => {
+ notes$.next([
+ {
+ id: 'n1',
+ content: 'Check out this important note\nSecond line',
+ created: 1000,
+ } as any,
+ ]);
+ initAndFlush();
+ typeAndFlush('IMPORTANT');
+ expect(latestResults.length).toBe(1);
+ expect(latestResults[0].id).toBe('n1');
+ expect(latestResults[0].title).toBe('Check out this important note');
+ expect(latestResults[0].isNote).toBe(true);
+ }));
+
+ it('should navigate to note correctly in navigateToItem', fakeAsync(() => {
+ const item = {
+ id: 'n1',
+ isNote: true,
+ projectId: 'proj-1',
+ } as SearchItem;
+ component.navigateToItem(item);
+ tick();
+ expect(routerSpy.navigate).toHaveBeenCalledWith(['/project/proj-1/tasks'], {
+ queryParams: { focusItem: 'n1' },
+ });
+ expect(layoutServiceSpy.toggleNotes).toHaveBeenCalled();
+ }));
+
+ it('should navigate to Today notes correctly in navigateToItem when projectId is null', fakeAsync(() => {
+ const item = {
+ id: 'n2',
+ isNote: true,
+ projectId: null,
+ } as SearchItem;
+ component.navigateToItem(item);
+ tick();
+ expect(routerSpy.navigate).toHaveBeenCalledWith(['/tag/TODAY/tasks'], {
+ queryParams: { focusItem: 'n2' },
+ });
+ expect(layoutServiceSpy.toggleNotes).toHaveBeenCalled();
+ }));
+
+ it('should include full folder path in context tag title for task', fakeAsync(() => {
+ projectFolderMapSignal.set(new Map([['proj-1', 'Folder 1 › Subfolder A']]));
+ projectList$.next([createProject({ id: 'proj-1', title: 'My Project' })]);
+ allTasks$.next([createTask({ id: 't1', title: 'Folder Task', projectId: 'proj-1' })]);
+ initAndFlush();
+ typeAndFlush('Folder');
+ expect(latestResults.length).toBe(1);
+ expect(latestResults[0].ctx.title).toBe('Folder 1 > Subfolder A > My Project');
+ }));
+
+ it('should include full folder path in context tag title for note', fakeAsync(() => {
+ projectFolderMapSignal.set(new Map([['proj-1', 'Folder 1 › Subfolder A']]));
+ projectList$.next([createProject({ id: 'proj-1', title: 'My Project' })]);
+ notes$.next([
+ {
+ id: 'n1',
+ content: 'Folder Note Content',
+ projectId: 'proj-1',
+ created: 1000,
+ } as any,
+ ]);
+ initAndFlush();
+ typeAndFlush('Folder Note');
+ expect(latestResults.length).toBe(1);
+ expect(latestResults[0].ctx.title).toBe('Folder 1 > Subfolder A > My Project');
+ }));
+
+ it('should update folder path reactively when projectFolderMap changes after init', fakeAsync(() => {
+ projectList$.next([createProject({ id: 'proj-1', title: 'My Project' })]);
+ allTasks$.next([createTask({ id: 't1', title: 'Folder Task', projectId: 'proj-1' })]);
+ initAndFlush();
+ typeAndFlush('Folder');
+ expect(latestResults[0].ctx.title).toBe('My Project');
+
+ projectFolderMapSignal.set(new Map([['proj-1', 'Folder 1 › Subfolder A']]));
+ fixture.detectChanges();
+ tick(150); // combineLatest debounce
+ expect(latestResults[0].ctx.title).toBe('Folder 1 > Subfolder A > My Project');
+ }));
+
+ it('should update folder path reactively for ARCHIVED tasks when projectFolderMap changes after init', fakeAsync(() => {
+ const archiveTask = createTask({
+ id: 'archived-1',
+ title: 'Old Task',
+ projectId: 'proj-1',
+ });
+ taskServiceSpy.getArchivedTasks.and.returnValue(Promise.resolve([archiveTask]));
+ projectList$.next([createProject({ id: 'proj-1', title: 'My Project' })]);
+
+ // Recreate component so the new archive promise is used
+ fixture = TestBed.createComponent(SearchPageComponent);
+ component = fixture.componentInstance;
+ initAndFlush();
+ typeAndFlush('Old');
+
+ expect(latestResults.length).toBe(1);
+ expect(latestResults[0].isArchiveTask).toBe(true);
+ expect(latestResults[0].ctx.title).toBe('My Project');
+
+ projectFolderMapSignal.set(new Map([['proj-1', 'Folder 1 › Subfolder A']]));
+ fixture.detectChanges();
+ tick(150); // combineLatest debounce
+ expect(latestResults[0].ctx.title).toBe('Folder 1 > Subfolder A > My Project');
+ }));
});
diff --git a/src/app/pages/search-page/search-page.component.ts b/src/app/pages/search-page/search-page.component.ts
index 833949bb55..6a6b630a7d 100644
--- a/src/app/pages/search-page/search-page.component.ts
+++ b/src/app/pages/search-page/search-page.component.ts
@@ -6,12 +6,17 @@ import {
OnInit,
viewChild,
} from '@angular/core';
+import { toObservable } from '@angular/core/rxjs-interop';
import { T } from '../../t.const';
import { ReactiveFormsModule, UntypedFormControl } from '@angular/forms';
import { combineLatest, Observable } from 'rxjs';
-import { debounceTime, filter, map, startWith, withLatestFrom } from 'rxjs/operators';
+import { debounceTime, filter, map, startWith } from 'rxjs/operators';
import { TaskService } from '../../features/tasks/task.service';
-import { DEFAULT_TAG } from '../../features/tag/tag.const';
+import { DEFAULT_TAG, TODAY_TAG } from '../../features/tag/tag.const';
+import { NoteService } from '../../features/note/note.service';
+import { Note } from '../../features/note/note.model';
+import { Router } from '@angular/router';
+import { LayoutService } from '../../core-ui/layout/layout.service';
import { Project } from '../../features/project/project.model';
import { Tag } from '../../features/tag/tag.model';
import { ProjectService } from '../../features/project/project.service';
@@ -32,6 +37,7 @@ import { MatFormField, MatLabel } from '@angular/material/form-field';
import { MatDialog } from '@angular/material/dialog';
import { DialogViewArchivedTaskComponent } from '../../features/tasks/dialog-view-archived-task/dialog-view-archived-task.component';
import { Log } from '../../core/log';
+import { MenuTreeService } from '../../features/menu-tree/menu-tree.service';
const MAX_RESULTS = 50;
@@ -61,6 +67,10 @@ export class SearchPageComponent implements OnInit {
private _tagService = inject(TagService);
private _navigateToTaskService = inject(NavigateToTaskService);
private _matDialog = inject(MatDialog);
+ private _noteService = inject(NoteService);
+ private _router = inject(Router);
+ private _layoutService = inject(LayoutService);
+ private _menuTreeService = inject(MenuTreeService);
readonly inputEl = viewChild.required('inputEl');
@@ -69,26 +79,71 @@ export class SearchPageComponent implements OnInit {
filteredResults$: Observable = new Observable();
private _cachedArchiveItems: SearchItem[] | null = null;
+ private _archiveCacheInputs?: {
+ archiveTasks: Task[];
+ projects: Project[];
+ tags: Tag[];
+ projectFolderMap: Map;
+ tagFolderMap: Map;
+ };
private _searchableItems$: Observable = combineLatest([
this._taskService.allTasks$,
this._taskService.getArchivedTasks(),
+ this._noteService.notes$,
+ this._projectService.list$,
+ this._tagService.tags$,
+ toObservable(this._menuTreeService.projectFolderMap),
+ toObservable(this._menuTreeService.tagFolderMap),
]).pipe(
- withLatestFrom(this._projectService.list$, this._tagService.tags$),
- map(([[allTasks, archiveTasks], projects, tags]) => {
- if (!this._cachedArchiveItems) {
- this._cachedArchiveItems = this._mapTasksToSearchItems(
- true,
- archiveTasks,
- projects,
- tags,
- );
- }
- return [
- ...this._mapTasksToSearchItems(false, allTasks, projects, tags),
- ...this._cachedArchiveItems,
- ];
- }),
+ map(
+ ([
+ allTasks,
+ archiveTasks,
+ notes,
+ projects,
+ tags,
+ projectFolderMap,
+ tagFolderMap,
+ ]) => {
+ if (
+ !this._cachedArchiveItems ||
+ this._archiveCacheInputs?.archiveTasks !== archiveTasks ||
+ this._archiveCacheInputs?.projects !== projects ||
+ this._archiveCacheInputs?.tags !== tags ||
+ this._archiveCacheInputs?.projectFolderMap !== projectFolderMap ||
+ this._archiveCacheInputs?.tagFolderMap !== tagFolderMap
+ ) {
+ this._archiveCacheInputs = {
+ archiveTasks,
+ projects,
+ tags,
+ projectFolderMap,
+ tagFolderMap,
+ };
+ this._cachedArchiveItems = this._mapTasksToSearchItems(
+ true,
+ archiveTasks,
+ projects,
+ tags,
+ projectFolderMap,
+ tagFolderMap,
+ );
+ }
+ return [
+ ...this._mapTasksToSearchItems(
+ false,
+ allTasks,
+ projects,
+ tags,
+ projectFolderMap,
+ tagFolderMap,
+ ),
+ ...this._mapNotesToSearchItems(notes, projects, projectFolderMap),
+ ...this._cachedArchiveItems,
+ ];
+ },
+ ),
);
private _mapTasksToSearchItems(
@@ -96,6 +151,8 @@ export class SearchPageComponent implements OnInit {
tasks: Task[],
projects: Project[],
tags: Tag[],
+ projectFolderMap: Map,
+ tagFolderMap: Map,
): SearchItem[] {
const taskMap = new Map(tasks.map((t) => [t.id, t]));
const projectMap = new Map(projects.map((p) => [p.id, p]));
@@ -118,18 +175,76 @@ export class SearchPageComponent implements OnInit {
timeSpentOnDay: task.timeSpentOnDay,
created: task.created,
issueType: task.issueType || null,
- ctx: this._getContextIcon(task, projectMap, tagMap, tagId),
+ ctx: this._getContextIcon(
+ task,
+ projectMap,
+ tagMap,
+ tagId,
+ projectFolderMap,
+ tagFolderMap,
+ ),
isArchiveTask,
isDone: !!task.isDone,
};
});
}
+ private _mapNotesToSearchItems(
+ notes: Note[],
+ projects: Project[],
+ projectFolderMap: Map,
+ ): SearchItem[] {
+ const projectMap = new Map(projects.map((p) => [p.id, p]));
+
+ return notes.map((note) => {
+ const title = note.content ? note.content.split('\n')[0] : 'Note';
+ const ctx = note.projectId
+ ? projectMap.get(note.projectId) || {
+ ...DEFAULT_TAG,
+ icon: 'comment',
+ color: 'black',
+ }
+ : TODAY_TAG;
+
+ let ctxTitle = ctx.title;
+ if (note.projectId) {
+ const folderPath = projectFolderMap.get(note.projectId);
+ if (folderPath) {
+ ctxTitle = `${folderPath.replace(/ › /g, ' > ')} > ${ctx.title}`;
+ }
+ }
+
+ return {
+ id: note.id,
+ title,
+ taskNotes: note.content || '',
+ searchText: (note.content || '').toLowerCase(),
+ projectId: note.projectId || null,
+ parentId: null,
+ parentTitle: null,
+ tagId: note.projectId ? '' : TODAY_TAG.id,
+ timeSpentOnDay: {},
+ created: note.created,
+ issueType: null,
+ ctx: {
+ ...ctx,
+ title: ctxTitle,
+ icon: (ctx as Tag).icon || (note.projectId && 'list') || 'comment',
+ } as Tag | Project,
+ isArchiveTask: false,
+ isDone: false,
+ isNote: true,
+ };
+ });
+ }
+
private _getContextIcon(
task: Task,
projectMap: Map,
tagMap: Map,
tagId: string,
+ projectFolderMap: Map,
+ tagFolderMap: Map,
): Tag | Project {
let context: Tag | Project | undefined = task.projectId
? projectMap.get(task.projectId)
@@ -140,8 +255,22 @@ export class SearchPageComponent implements OnInit {
context = { ...DEFAULT_TAG, icon: 'help_outline', color: 'black' };
}
+ let title = context.title;
+ if (task.projectId) {
+ const folderPath = projectFolderMap.get(task.projectId);
+ if (folderPath) {
+ title = `${folderPath.replace(/ › /g, ' > ')} > ${context.title}`;
+ }
+ } else if (tagId && tagId !== TODAY_TAG.id) {
+ const folderPath = tagFolderMap.get(tagId);
+ if (folderPath) {
+ title = `${folderPath.replace(/ › /g, ' > ')} > ${context.title}`;
+ }
+ }
+
return {
...context,
+ title,
icon: (context as Tag).icon || (task.projectId && 'list') || null,
};
}
@@ -177,7 +306,18 @@ export class SearchPageComponent implements OnInit {
navigateToItem(item: SearchItem): void {
if (!item) return;
- this._navigateToTaskService.navigate(item.id, item.isArchiveTask).then(() => {});
+ if (item.isNote) {
+ const path = item.projectId
+ ? `/project/${item.projectId}/tasks`
+ : `/tag/TODAY/tasks`;
+ this._router.navigate([path], { queryParams: { focusItem: item.id } }).then(() => {
+ if (!this._layoutService.isShowNotes()) {
+ this._layoutService.toggleNotes();
+ }
+ });
+ } else {
+ this._navigateToTaskService.navigate(item.id, item.isArchiveTask).then(() => {});
+ }
}
clearSearch(): void {
diff --git a/src/app/pages/search-page/search-page.model.ts b/src/app/pages/search-page/search-page.model.ts
index 2089290810..9d418fab37 100644
--- a/src/app/pages/search-page/search-page.model.ts
+++ b/src/app/pages/search-page/search-page.model.ts
@@ -27,6 +27,7 @@ export interface SearchItem {
searchText: string;
// jira only
titleHighlighted?: string;
+ isNote?: boolean;
}
export interface SearchQueryParams {
diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json
index 2f6c823506..8c09ba9a0c 100644
--- a/src/assets/i18n/en.json
+++ b/src/assets/i18n/en.json
@@ -1125,9 +1125,9 @@
"START": "Work Start"
},
"SEARCH_BAR": {
- "INFO": "Start typing to search for tasks",
- "NO_RESULTS": "No tasks found matching your search",
- "PLACEHOLDER": "Search for task or task notes"
+ "INFO": "Start typing to search for tasks or notes",
+ "NO_RESULTS": "No tasks or notes found matching your search",
+ "PLACEHOLDER": "Search for task, task notes or project notes"
},
"SIMPLE_COUNTER": {
"D_CONFIRM_REMOVE": {