This commit is contained in:
Iván Velázquez 2026-01-22 18:08:44 +01:00 committed by GitHub
commit 62e4a8d448
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 774 additions and 15 deletions

View file

@ -27,6 +27,7 @@ export const ENTITY_TYPES = [
'MENU_TREE',
'METRIC',
'BOARD',
'SECTION',
'REMINDER',
'PLUGIN_USER_DATA',
'PLUGIN_METADATA',

View file

@ -65,6 +65,13 @@
<mat-icon class="color-warn-i">delete_forever</mat-icon>
<span class="text">{{ T.MH.DELETE_TAG | translate }}</span>
</button>
}
@if (isForProject) {
<button (click)="addSection()" mat-menu-item>
<mat-icon>list</mat-icon>
<span class="text">{{ T.MH.ADD_SECTION | translate }}</span>
</button>
}
@if (isForProject && contextId !== INBOX_PROJECT.id) {
<button

View file

@ -17,6 +17,8 @@ import { WorkContextService } from '../../features/work-context/work-context.ser
import { Router, RouterLink, RouterModule } from '@angular/router';
import { ProjectService } from '../../features/project/project.service';
import { SectionService } from '../../features/section/section.service';
import { DialogPromptComponent } from '../../ui/dialog-prompt/dialog-prompt.component';
import { MatMenuItem } from '@angular/material/menu';
import { TranslatePipe } from '@ngx-translate/core';
import { MatIcon } from '@angular/material/icon';
@ -40,6 +42,7 @@ export class WorkContextMenuComponent implements OnInit {
private _matDialog = inject(MatDialog);
private _tagService = inject(TagService);
private _projectService = inject(ProjectService);
private _sectionService = inject(SectionService);
private _workContextService = inject(WorkContextService);
private _router = inject(Router);
private _snackService = inject(SnackService);
@ -130,6 +133,23 @@ export class WorkContextMenuComponent implements OnInit {
}
}
addSection(): void {
this._matDialog
.open(DialogPromptComponent, {
data: {
placeholder: T.G.TITLE,
defaultValue: '',
message: T.CONFIRM.ADD_SECTION,
},
})
.afterClosed()
.subscribe((title: string) => {
if (title) {
this._sectionService.addSection(title, this.contextId);
}
});
}
protected readonly INBOX_PROJECT = INBOX_PROJECT;
async shareTasksAsMarkdown(): Promise<void> {

View file

@ -147,6 +147,12 @@ export const ACTION_TYPE_TO_CODE: Record<ActionType, string> = {
[ActionType.REPEAT_CFG_DELETE_INSTANCE]: 'RDI',
[ActionType.REPEAT_CFG_UPSERT]: 'RX',
// Section
[ActionType.SECTION_ADD]: 'S1',
[ActionType.SECTION_DELETE]: 'S2',
[ActionType.SECTION_UPDATE]: 'S3',
[ActionType.SECTION_UPDATE_ORDER]: 'S4',
// SimpleCounter actions (S)
[ActionType.COUNTER_ADD]: 'SA',
[ActionType.COUNTER_UPDATE]: 'SU',

View file

@ -265,3 +265,13 @@ export const moveAllProjectBacklogTasksToRegularList = createAction(
} satisfies PersistentActionMeta,
}),
);
export const moveProjectTaskToSection = createAction(
'[Project] Move Project Task to Section',
props<{
taskId: string;
sectionId: string | null;
afterTaskId: string | null;
workContextId: string;
}>(),
);

View file

@ -1,10 +1,11 @@
import { inject, Injectable } from '@angular/core';
import { createEffect, ofType } from '@ngrx/effects';
import { LOCAL_ACTIONS } from '../../../util/local-actions.token';
import { filter, map, tap } from 'rxjs/operators';
import { filter, map, tap, switchMap, withLatestFrom } from 'rxjs/operators';
import {
addProject,
moveAllProjectBacklogTasksToRegularList,
moveProjectTaskToSection,
updateProject,
} from './project.actions';
import { TaskSharedActions } from '../../../root-store/meta/task-shared.actions';
@ -13,12 +14,16 @@ import { GlobalConfigService } from '../../config/global-config.service';
import { T } from '../../../t.const';
import { Project } from '../project.model';
import { Observable } from 'rxjs';
import { Store } from '@ngrx/store';
import { selectProjectFeatureState } from './project.selectors';
import { moveItemAfterAnchor } from '../../work-context/store/work-context-meta.helper';
@Injectable()
export class ProjectEffects {
private _actions$ = inject(LOCAL_ACTIONS);
private _snackService = inject(SnackService);
private _globalConfigService = inject(GlobalConfigService);
private _store$ = inject(Store);
/**
* Handles non-archive cleanup when a project is deleted.
@ -56,6 +61,42 @@ export class ProjectEffects {
),
);
moveProjectTaskToSection$: Observable<unknown> = createEffect(() =>
this._actions$.pipe(
ofType(moveProjectTaskToSection),
withLatestFrom(this._store$.select(selectProjectFeatureState)),
map(([{ taskId, sectionId, afterTaskId, workContextId }, projectState]) => {
const project = projectState.entities[workContextId];
if (!project) throw new Error('Project not found');
const currentTaskIds = project.taskIds || [];
let newTaskIds = [...currentTaskIds];
newTaskIds = moveItemAfterAnchor(taskId, afterTaskId, newTaskIds);
return {
taskId,
sectionId,
workContextId,
ids: newTaskIds,
};
}),
switchMap(({ taskId, sectionId, workContextId, ids }) => [
TaskSharedActions.updateTask({
task: {
id: taskId,
changes: { sectionId },
},
}),
updateProject({
project: {
id: workContextId,
changes: { taskIds: ids },
},
}),
]),
),
);
// CURRENTLY NOT IMPLEMENTED
// archiveProject: Observable<unknown> = createEffect(
// () =>

View file

@ -0,0 +1,11 @@
import { EntityState } from '@ngrx/entity';
export interface Section {
id: string;
projectId: string | null;
title: string;
}
export interface SectionState extends EntityState<Section> {
ids: string[];
}

View file

@ -0,0 +1,65 @@
import { Injectable, inject } from '@angular/core';
import { Store } from '@ngrx/store';
import { nanoid } from 'nanoid';
import { Observable } from 'rxjs';
import { Section } from './section.model';
import { addSection, deleteSection, updateSection, updateSectionOrder } from './store/section.actions';
import { selectAllSections, selectSectionsByProjectId } from './store/section.selectors';
@Injectable({
providedIn: 'root',
})
export class SectionService {
private _store = inject(Store);
// Expose selectors
sections$: Observable<Section[]> = this._store.select(selectAllSections);
getSectionsByProjectId$(projectId: string): Observable<Section[]> {
return this._store.select(selectSectionsByProjectId(projectId));
}
addSection(title: string, projectId: string | null = null): void {
const id = nanoid();
this._store.dispatch(addSection({
section: {
id,
title,
projectId,
}
}));
}
// Generate a section ID without dispatching
generateSectionId(): string {
return nanoid();
}
// Add section with a pre-generated ID
addSectionWithId(id: string, title: string, projectId: string | null = null): void {
this._store.dispatch(addSection({
section: {
id,
title,
projectId,
}
}));
}
deleteSection(id: string): void {
this._store.dispatch(deleteSection({ id }));
}
updateSection(id: string, sectionChanges: Partial<Section>): void {
this._store.dispatch(updateSection({
section: {
id,
changes: sectionChanges,
}
}));
}
updateSectionOrder(ids: string[]): void {
this._store.dispatch(updateSectionOrder({ ids }));
}
}

View file

@ -0,0 +1,63 @@
import { createAction, props } from '@ngrx/store';
import { Update } from '@ngrx/entity';
import { Section } from '../section.model';
import { PersistentActionMeta } from '../../../op-log/core/persistent-action.interface';
import { OpType } from '../../../op-log/core/operation.types';
export const loadSections = createAction(
'[Section] Load Sections',
props<{ sections: Section[] }>(),
);
export const addSection = createAction(
'[Section] Add Section',
(section: { section: Section }) => ({
...section,
meta: {
isPersistent: true,
entityType: 'SECTION',
entityId: section.section.id,
opType: OpType.Create,
} satisfies PersistentActionMeta,
}),
);
export const deleteSection = createAction(
'[Section] Delete Section',
(payload: { id: string }) => ({
...payload,
meta: {
isPersistent: true,
entityType: 'SECTION',
entityId: payload.id,
opType: OpType.Delete,
} satisfies PersistentActionMeta,
}),
);
export const updateSection = createAction(
'[Section] Update Section',
(payload: { section: Update<Section> }) => ({
...payload,
meta: {
isPersistent: true,
entityType: 'SECTION',
entityId: payload.section.id as string,
opType: OpType.Update,
} satisfies PersistentActionMeta,
}),
);
export const updateSectionOrder = createAction(
'[Section] Update Section Order',
(payload: { ids: string[] }) => ({
...payload,
meta: {
isPersistent: true,
entityType: 'SECTION',
// Uses ids[0] as random entity id reference, actual sync logic handles payload
entityId: payload.ids[0],
opType: OpType.Update,
} satisfies PersistentActionMeta,
}),
);

View file

@ -0,0 +1,27 @@
import { Injectable, inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { deleteSection } from './section.actions';
import { map, switchMap, withLatestFrom } from 'rxjs/operators';
import { Store } from '@ngrx/store';
import { selectAllTasks } from '../../tasks/store/task.selectors';
import { TaskSharedActions } from '../../../root-store/meta/task-shared.actions';
@Injectable()
export class SectionEffects {
private actions$ = inject(Actions);
private store = inject(Store);
deleteSection$ = createEffect(() =>
this.actions$.pipe(
ofType(deleteSection),
withLatestFrom(this.store.select(selectAllTasks)),
map(([{ id }, tasks]) => {
return tasks.filter((t) => t.sectionId === id).map((t) => t.id);
}),
switchMap((taskIds) => {
if (taskIds.length === 0) return [];
return [TaskSharedActions.deleteTasks({ taskIds })];
}),
),
);
}

View file

@ -0,0 +1,40 @@
import { createReducer, on } from '@ngrx/store';
import { createEntityAdapter, EntityAdapter } from '@ngrx/entity';
import * as SectionActions from './section.actions';
import { Section, SectionState } from '../section.model';
import { loadAllData } from '../../../root-store/meta/load-all-data.action';
export const SECTION_FEATURE_NAME = 'section';
export const adapter: EntityAdapter<Section> = createEntityAdapter<Section>();
export const initialSectionState: SectionState = adapter.getInitialState({
ids: [],
});
export const sectionReducer = createReducer(
initialSectionState,
on(SectionActions.addSection, (state, { section }) => adapter.addOne(section, state)),
on(SectionActions.deleteSection, (state, { id }) => adapter.removeOne(id, state)),
on(SectionActions.updateSection, (state, { section }) => adapter.updateOne(section, state)),
on(SectionActions.loadSections, (state, { sections }) => adapter.setAll(sections, state)),
on(SectionActions.updateSectionOrder, (state, { ids }) => {
const idsSet = new Set(ids);
return {
...state,
ids: [...state.ids.filter(id => !idsSet.has(id as string)), ...ids]
};
}),
on(loadAllData, (state, { appDataComplete }) =>
appDataComplete.section
? { ...(appDataComplete.section as SectionState) }
: state
),
);
export const {
selectIds,
selectEntities,
selectAll,
selectTotal,
} = adapter.getSelectors();

View file

@ -0,0 +1,12 @@
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { SectionState } from '../section.model';
import { sectionReducer, selectAll, SECTION_FEATURE_NAME } from './section.reducer';
export const selectSectionFeatureState = createFeatureSelector<SectionState>(SECTION_FEATURE_NAME);
export const selectAllSections = createSelector(selectSectionFeatureState, selectAll);
export const selectSectionsByProjectId = (projectId: string) => createSelector(
selectAllSections,
(sections) => sections.filter(section => section.projectId === projectId)
);

View file

@ -5,11 +5,14 @@ import {
parseMarkdownTasks,
convertToMarkdownNotes,
parseMarkdownTasksWithStructure,
parseMarkdownWithSections,
} from '../../util/parse-markdown-tasks';
import { DialogConfirmComponent } from '../../ui/dialog-confirm/dialog-confirm.component';
import { T } from '../../t.const';
import { TaskService } from './task.service';
import { addSubTask } from './store/task.actions';
import { SectionService } from '../section/section.service';
import { WorkContextService } from '../work-context/work-context.service';
@Injectable({
providedIn: 'root',
@ -18,6 +21,8 @@ export class MarkdownPasteService {
private _matDialog = inject(MatDialog);
private _taskService = inject(TaskService);
private _store = inject(Store);
private _sectionService = inject(SectionService);
private _workContextService = inject(WorkContextService);
async handleMarkdownPaste(
pastedText: string,
@ -62,6 +67,78 @@ export class MarkdownPasteService {
return;
}
// Try to parse with sections first (for markdown with H1 headers)
if (!selectedTaskId) {
const sectionsData = parseMarkdownWithSections(pastedText);
if (sectionsData && sectionsData.hasHeaders) {
// Confirm with user
const totalTasks = sectionsData.sections.reduce((sum, section) => sum + section.tasks.length, 0);
const dialogRef = this._matDialog.open(DialogConfirmComponent, {
data: {
okTxt: T.G.CONFIRM,
title: T.F.MARKDOWN_PASTE.DIALOG_TITLE,
titleIcon: 'content_paste',
message: T.F.MARKDOWN_PASTE.CONFIRM_SECTIONS,
translateParams: {
sectionsCount: sectionsData.sections.length,
tasksCount: totalTasks,
},
},
});
const isConfirmed = await dialogRef.afterClosed().toPromise();
if (!isConfirmed) {
return;
}
const workContextId = this._workContextService.activeWorkContextId;
// Create sections and tasks
for (const section of sectionsData.sections) {
let sectionId: string | null = null;
// Create section if it has a title
if (section.sectionTitle) {
// Generate ID locally to avoid async wait issues
sectionId = this._sectionService.generateSectionId();
this._sectionService.addSectionWithId(sectionId, section.sectionTitle, workContextId);
}
// Create tasks for this section
for (const task of section.tasks) {
const taskId = this._taskService.add(
task.title,
false,
{
isDone: task.isCompleted,
notes: task.notes,
sectionId: sectionId,
},
true,
);
// Create sub-tasks if any
if (task.subTasks && task.subTasks.length > 0) {
for (const subTask of task.subTasks) {
const subTaskObj = this._taskService.createNewTaskWithDefaults({
title: subTask.title,
additional: {
isDone: subTask.isCompleted,
parentId: taskId,
notes: subTask.notes,
},
});
this._store.dispatch(
addSubTask({ task: subTaskObj, parentId: taskId }),
);
}
}
}
}
return;
}
}
// Try to parse with structure first (for creating sub-tasks when no task selected)
if (!selectedTaskId) {
const structure = parseMarkdownTasksWithStructure(pastedText);
@ -177,6 +254,13 @@ export class MarkdownPasteService {
}
isMarkdownTaskList(text: string): boolean {
// Check for sections (H1 headers)
const sectionsData = parseMarkdownWithSections(text);
if (sectionsData && sectionsData.hasHeaders) {
return true;
}
// Check for regular task lists
const parsedTasks = parseMarkdownTasks(text);
return parsedTasks !== null && parsedTasks.length > 0;
}

View file

@ -261,6 +261,12 @@ export const taskReducer = createReducer<TaskState>(
}),
on(moveSubTask, (state, { taskId, srcTaskId, targetTaskId, afterTaskId }) => {
// Guard against invalid moves (e.g. 'UNDONE' passed as ID) which might be in the op log
if (!state.entities[srcTaskId] || !state.entities[targetTaskId]) {
console.warn('Ignoring invalid moveSubTask action', { taskId, srcTaskId, targetTaskId });
return state;
}
let newState = state;
const oldPar = getTaskById(srcTaskId, state);
const newPar = getTaskById(targetTaskId, state);

View file

@ -24,6 +24,7 @@ import {
moveProjectTaskInBacklogList,
moveProjectTaskToBacklogList,
moveProjectTaskToRegularList,
moveProjectTaskToSection,
} from '../../project/store/project.actions';
import { moveSubTask } from '../store/task.actions';
import { TaskSharedActions } from '../../../root-store/meta/task-shared.actions';
@ -47,6 +48,7 @@ export type ListModelId = DropListModelSource | string;
const PARENT_ALLOWED_LISTS = ['DONE', 'UNDONE', 'OVERDUE', 'BACKLOG', 'ADD_TASK_PANEL'];
export interface DropModelDataForList {
listId: TaskListId;
listModelId: ListModelId;
allTasks: TaskWithSubTasks[];
filteredTasks: TaskWithSubTasks[];
@ -92,6 +94,7 @@ export class TaskListComponent implements OnDestroy, AfterViewInit {
currentTaskId = toSignal(this._taskService.currentTaskId$);
dropModelDataForList = computed<DropModelDataForList>(() => {
return {
listId: this.listId(),
listModelId: this.listModelId(),
allTasks: this.tasks(),
filteredTasks: this.filteredTasks(),
@ -147,16 +150,19 @@ export class TaskListComponent implements OnDestroy, AfterViewInit {
const isSubtask = !!task.parentId;
// TaskLog.log(drag.data.id, { isSubtask, targetModelId, drag, drop });
// return true;
const targetIsSectionOrProjectList =
PARENT_ALLOWED_LISTS.includes(targetModelId) ||
// Allow if it's not a restricted list, likely a section ID
(!['LATER_TODAY', 'OVERDUE'].includes(targetModelId));
if (targetModelId === 'OVERDUE' || targetModelId === 'LATER_TODAY') {
return false;
} else if (isSubtask) {
if (!PARENT_ALLOWED_LISTS.includes(targetModelId)) {
return true;
}
// Subtasks can move to sections or parent lists
if (targetIsSectionOrProjectList) return true;
} else {
if (PARENT_ALLOWED_LISTS.includes(targetModelId)) {
return true;
}
// Main tasks can move to sections or parent lists
if (targetIsSectionOrProjectList) return true;
}
return false;
}
@ -286,6 +292,24 @@ export class TaskListComponent implements OnDestroy, AfterViewInit {
return;
}
if (workContextId && this._workContextService.activeWorkContextType === WorkContextType.PROJECT) {
// NOTE: sections are not sub task lists
const targetIsSection = !['DONE', 'UNDONE', 'BACKLOG', 'OVERDUE', 'LATER_TODAY'].includes(target);
const srcIsSection = !['DONE', 'UNDONE', 'BACKLOG', 'OVERDUE', 'LATER_TODAY'].includes(src);
if (targetIsSection || srcIsSection) {
const sectionId = targetIsSection ? target : null;
const afterTaskId = getAnchorFromDragDrop(taskId, newOrderedIds);
this._store.dispatch(moveProjectTaskToSection({
taskId,
sectionId,
afterTaskId,
workContextId,
}));
return;
}
}
if (isSrcRegularList && isTargetRegularList) {
// move inside today
const workContextType = this._workContextService

View file

@ -93,6 +93,7 @@ export interface TaskCopy
hasPlannedTime?: boolean;
attachments: TaskAttachment[];
reminderId?: string | null;
sectionId?: string | null;
// Ensure type compatibility for internal fields
modified?: number;

View file

@ -182,6 +182,40 @@
></task-list>
</collapsible>
}
} @else if (sections().length) {
<div class="sections-wrapper" cdkDropList (cdkDropListDropped)="dropSection($event)">
<div class="no-section" style="margin-bottom: 24px;">
<task-list [tasks]="undoneTasksBySection().noSection" [listId]="'PARENT'"
[listModelId]="'UNDONE'"></task-list>
</div>
@for (section of sections(); track section.id) {
<div class="section-container" style="margin-bottom: 24px;" cdkDrag [cdkDragLockAxis]="'y'">
<collapsible [title]="section.title" [isIconBefore]="true" [isExpanded]="true">
<ng-container actions>
<div class="drag-handle" cdkDragHandle>
<mat-icon>drag_indicator</mat-icon>
</div>
<button mat-icon-button [matMenuTriggerFor]="sectionMenu" (click)="$event.stopPropagation()">
<mat-icon>more_vert</mat-icon>
</button>
<mat-menu #sectionMenu="matMenu">
<button mat-menu-item (click)="editSection(section.id, section.title)">
<mat-icon>edit</mat-icon>
{{ T.G.EDIT | translate }}
</button>
<button mat-menu-item (click)="deleteSection(section.id)">
<mat-icon>delete</mat-icon>
{{ T.G.DELETE | translate }}
</button>
</mat-menu>
</ng-container>
<task-list [tasks]="undoneTasksBySection().dict[section.id] || []" [listId]="'PARENT'"
[listModelId]="section.id"></task-list>
</collapsible>
</div>
}
</div>
} @else {
<task-list
class="tour-undoneList"

View file

@ -330,3 +330,31 @@ finish-day-btn,
padding-bottom: 8px;
padding-top: 8px;
}
// Ensure section titles are left aligned and actions are right aligned
.section-container {
::ng-deep .collapsible-header {
justify-content: space-between;
}
::ng-deep .collapsible-title {
text-align: left;
// ensure title takes available space to push actions to right
flex-grow: 1;
}
.drag-handle {
display: inline-flex;
align-items: center;
cursor: move;
margin-right: 8px;
color: var(--text-color-secondary); // standard Material color variable often used
}
button {
background: transparent !important;
box-shadow: none !important;
color: var(--text-color-secondary) !important;
border: none !important;
}
}

View file

@ -13,7 +13,12 @@ import {
signal,
ViewChild,
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatMenu, MatMenuTrigger, MatMenuItem, MatMenuModule } from '@angular/material/menu';
import { TaskService } from '../tasks/task.service';
import { DialogConfirmComponent } from '../../ui/dialog-confirm/dialog-confirm.component';
import { DialogPromptComponent } from '../../ui/dialog-prompt/dialog-prompt.component';
import { expandAnimation, expandFadeAnimation } from '../../ui/animations/expand.ani';
import { LayoutService } from '../../core-ui/layout/layout.service';
import { TakeABreakService } from '../take-a-break/take-a-break.service';
@ -30,6 +35,7 @@ import {
} from 'rxjs';
import { TaskWithSubTasks } from '../tasks/task.model';
import { delay, filter, map, observeOn, switchMap } from 'rxjs/operators';
import { of } from 'rxjs';
import { fadeAnimation } from '../../ui/animations/fade.ani';
import { PlanningModeService } from '../planning-mode/planning-mode.service';
import { T } from '../../t.const';
@ -38,7 +44,16 @@ import { WorkContextService } from '../work-context/work-context.service';
import { ProjectService } from '../project/project.service';
import { TaskViewCustomizerService } from '../task-view-customizer/task-view-customizer.service';
import { toSignal } from '@angular/core/rxjs-interop';
import { CdkDropListGroup } from '@angular/cdk/drag-drop';
import { SectionService } from '../section/section.service';
import { Section } from '../section/section.model';
import {
CdkDrag,
CdkDragDrop,
CdkDragHandle,
CdkDropList,
CdkDropListGroup,
moveItemInArray,
} from '@angular/cdk/drag-drop';
import { CdkScrollable } from '@angular/cdk/scrolling';
import { MatTooltip } from '@angular/material/tooltip';
import { MatIcon } from '@angular/material/icon';
@ -77,6 +92,9 @@ import { ScheduledDateGroupPipe } from '../../ui/pipes/scheduled-date-group.pipe
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CdkDropListGroup,
CdkDropList,
CdkDrag,
CdkDragHandle,
CdkScrollable,
MatTooltip,
MatIcon,
@ -92,15 +110,18 @@ import { ScheduledDateGroupPipe } from '../../ui/pipes/scheduled-date-group.pipe
TranslatePipe,
CollapsibleComponent,
CommonModule,
MatMenuModule,
FinishDayBtnComponent,
ScheduledDateGroupPipe,
],
})
export class WorkViewComponent implements OnInit, OnDestroy {
taskService = inject(TaskService);
takeABreakService = inject(TakeABreakService);
planningModeService = inject(PlanningModeService);
layoutService = inject(LayoutService);
sectionService = inject(SectionService);
customizerService = inject(TaskViewCustomizerService);
workContextService = inject(WorkContextService);
private _activatedRoute = inject(ActivatedRoute);
@ -108,6 +129,7 @@ export class WorkViewComponent implements OnInit, OnDestroy {
private _cd = inject(ChangeDetectorRef);
private _store = inject(Store);
private _snackService = inject(SnackService);
private _matDialog = inject(MatDialog);
// TODO refactor all to signals
overdueTasks = toSignal(this._store.select(selectOverdueTasksWithSubTasks), {
@ -141,6 +163,34 @@ export class WorkViewComponent implements OnInit, OnDestroy {
isLaterTodayHidden = signal(!!localStorage.getItem(LS.LATER_TODAY_TASKS_HIDDEN));
isOverdueHidden = signal(!!localStorage.getItem(LS.OVERDUE_TASKS_HIDDEN));
// Section Logic
sections = toSignal(
this.workContextService.activeWorkContextId$.pipe(
switchMap((id) => (id ? this.sectionService.getSectionsByProjectId$(id) : of([]))),
),
{ initialValue: [] },
);
undoneTasksBySection = computed(() => {
const tasks = this.undoneTasks();
const sections = this.sections();
const dict: Record<string, TaskWithSubTasks[]> = {};
const noSection: TaskWithSubTasks[] = [];
tasks.forEach((task) => {
if (task.sectionId && sections.find((s) => s.id === task.sectionId)) {
if (!dict[task.sectionId]) dict[task.sectionId] = [];
dict[task.sectionId].push(task);
} else {
noSection.push(task);
}
});
return { dict, noSection };
});
isShowOverduePanel = computed(
() => this.isOnTodayList() && this.overdueTasks().length > 0,
);
@ -264,6 +314,53 @@ export class WorkViewComponent implements OnInit, OnDestroy {
this.planningModeService.enterPlanningMode();
}
addSection(): void {
this._matDialog
.open(DialogPromptComponent, {
data: {
placeholder: T.WW.ADD_SECTION_TITLE,
},
})
.afterClosed()
.subscribe((title: string | undefined) => {
if (title) {
this.sectionService.addSection(title, this.workContextService.activeWorkContextId);
}
});
}
deleteSection(id: string): void {
this._matDialog
.open(DialogConfirmComponent, {
data: {
message: T.CONFIRM.DELETE_SECTION_CASCADE,
},
})
.afterClosed()
.subscribe((isConfirm: boolean) => {
if (isConfirm) {
this.sectionService.deleteSection(id);
}
});
}
editSection(id: string, title: string): void {
this._matDialog
.open(DialogPromptComponent, {
data: {
placeholder: T.WW.ADD_SECTION_TITLE,
val: title,
},
})
.afterClosed()
.subscribe((newTitle: string | undefined) => {
if (newTitle) {
this.sectionService.updateSection(id, { title: newTitle });
}
});
}
startWork(): void {
this.planningModeService.leavePlanningMode();
}
@ -314,9 +411,26 @@ export class WorkViewComponent implements OnInit, OnDestroy {
);
}
dropSection(event: CdkDragDrop<Section[]>): void {
const sections = this.sections();
if (event.previousIndex === event.currentIndex) {
return;
}
// We can't mutate the array directly as it is from a signal/store
// So we copy it, move the item, and then extract the IDs
const newSections = [...sections];
moveItemInArray(newSections, event.previousIndex, event.currentIndex);
// Update the section order in the store
this.sectionService.updateSectionOrder(newSections.map((s) => s.id));
}
private _initScrollTracking(): void {
this._subs.add(
this.upperContainerScroll$.subscribe(({ target }) => {
this.upperContainerScroll$.subscribe(({
target
}) => {
if ((target as HTMLElement).scrollTop !== 0) {
this.layoutService.isScrolled.set(true);
} else {

View file

@ -1,3 +1,4 @@
import { initialSectionState } from '../../features/section/store/section.reducer';
import { initialProjectState } from '../../features/project/store/project.reducer';
import { initialTaskState } from '../../features/tasks/store/task.reducer';
import { initialTagState } from '../../features/tag/store/tag.reducer';
@ -67,6 +68,7 @@ export const DEFAULT_APP_BASE_DATA: AppBaseData = {
},
taskRepeatCfg: initialTaskRepeatCfgState,
note: initialNoteState,
section: initialSectionState,
metric: initialMetricState,
};

View file

@ -13,6 +13,7 @@ import { PlannerState } from '../../features/planner/store/planner.reducer';
import { IssueProviderState } from '../../features/issue/issue.model';
import { BoardsState } from '../../features/boards/store/boards.reducer';
import { MenuTreeState } from '../../features/menu-tree/store/menu-tree.model';
import { SectionState } from '../../features/section/section.model';
export interface AppBaseWithoutLastSyncModelChange {
project: ProjectState;
@ -30,6 +31,7 @@ export interface AppBaseWithoutLastSyncModelChange {
tag: TagState;
simpleCounter: SimpleCounterState;
taskRepeatCfg: TaskRepeatCfgState;
section: SectionState;
}
export interface AppMainFileNoRevsData extends AppBaseWithoutLastSyncModelChange {
@ -47,7 +49,7 @@ export interface AppArchiveFileData {
}
export interface AppBaseData
extends AppBaseWithoutLastSyncModelChange, AppArchiveFileData {}
extends AppBaseWithoutLastSyncModelChange, AppArchiveFileData { }
export interface LocalSyncMetaForProvider {
lastSync: number;

View file

@ -18,6 +18,7 @@ import { selectSimpleCounterFeatureState } from '../../features/simple-counter/s
import { selectTagFeatureState } from '../../features/tag/store/tag.reducer';
import { selectTaskFeatureState } from '../../features/tasks/store/task.selectors';
import { selectTaskRepeatCfgFeatureState } from '../../features/task-repeat-cfg/store/task-repeat-cfg.selectors';
import { selectSectionFeatureState } from '../../features/section/store/section.selectors';
import { selectTimeTrackingState } from '../../features/time-tracking/store/time-tracking.selectors';
import { environment } from '../../../environments/environment';
import { ArchiveModel } from '../../features/time-tracking/time-tracking.model';
@ -108,6 +109,7 @@ export class StateSnapshotService {
this._store.select(selectPluginUserDataFeatureState),
this._store.select(selectPluginMetadataFeatureState),
this._store.select(selectReminderFeatureState),
this._store.select(selectSectionFeatureState),
]).pipe(first()),
);
@ -128,6 +130,7 @@ export class StateSnapshotService {
pluginUserData,
pluginMetadata,
reminders,
section,
] = ngRxData;
return {
@ -153,6 +156,7 @@ export class StateSnapshotService {
pluginUserData,
pluginMetadata,
reminders,
section,
archiveYoung,
archiveOld,
};
@ -176,7 +180,8 @@ export class StateSnapshotService {
let simpleCounter: unknown,
taskRepeatCfg: unknown,
menuTree: unknown,
timeTracking: unknown;
timeTracking: unknown,
section: unknown;
let pluginUserData: unknown, pluginMetadata: unknown, reminders: unknown;
// Subscribe synchronously to get current values
@ -244,6 +249,10 @@ export class StateSnapshotService {
.select(selectReminderFeatureState)
.pipe(first())
.subscribe((v) => (reminders = v));
this._store
.select(selectSectionFeatureState)
.pipe(first())
.subscribe((v) => (section = v));
return {
task: {
@ -268,6 +277,7 @@ export class StateSnapshotService {
pluginUserData,
pluginMetadata,
reminders,
section,
};
}

View file

@ -128,6 +128,12 @@ export enum ActionType {
REPEAT_CFG_DELETE_INSTANCE = '[TaskRepeatCfg] Delete Single Instance',
REPEAT_CFG_UPSERT = '[TaskRepeatCfg] Upsert TaskRepeatCfg',
// Section actions (S)
SECTION_ADD = '[Section] Add Section',
SECTION_DELETE = '[Section] Delete Section',
SECTION_UPDATE = '[Section] Update Section',
SECTION_UPDATE_ORDER = '[Section] Update Section Order',
// SimpleCounter actions (S)
COUNTER_ADD = '[SimpleCounter] Add SimpleCounter',
COUNTER_UPDATE = '[SimpleCounter] Update SimpleCounter',

View file

@ -21,6 +21,7 @@ export interface AppStateSnapshot {
pluginUserData: unknown;
pluginMetadata: unknown;
reminders: unknown;
section: unknown;
archiveYoung: ArchiveModel;
archiveOld: ArchiveModel;
}

View file

@ -28,6 +28,8 @@ import { initialMetricState } from '../../features/metric/store/metric.reducer';
import { initialTaskState } from '../../features/tasks/store/task.reducer';
import { initialTagState } from '../../features/tag/store/tag.reducer';
import { initialSimpleCounterState } from '../../features/simple-counter/store/simple-counter.reducer';
import { initialSectionState } from '../../features/section/store/section.reducer';
import { SectionState } from '../../features/section/section.model';
import { initialTaskRepeatCfgState } from '../../features/task-repeat-cfg/store/task-repeat-cfg.reducer';
import { DROPBOX_APP_KEY } from '../../imex/sync/dropbox/dropbox.const';
import { Webdav } from '../sync-providers/file-based/webdav/webdav';
@ -70,6 +72,7 @@ export type AllModelConfig = {
task: ModelCfg<TaskState>;
tag: ModelCfg<TagState>;
simpleCounter: ModelCfg<SimpleCounterState>;
section: ModelCfg<SectionState>;
taskRepeatCfg: ModelCfg<TaskRepeatCfgState>;
reminders: ModelCfg<Reminder[]>;
timeTracking: ModelCfg<TimeTrackingState>;
@ -111,6 +114,12 @@ export const MODEL_CONFIGS: AllModelConfig = {
validate: appDataValidators.simpleCounter,
repair: fixEntityStateConsistency,
},
section: {
defaultData: initialSectionState,
isMainFileModel: true,
validate: appDataValidators.section,
repair: fixEntityStateConsistency,
},
note: {
defaultData: initialNoteState,
isMainFileModel: true,

View file

@ -39,6 +39,7 @@ import { BOARDS_FEATURE_NAME } from '../../features/boards/store/boards.reducer'
import { menuTreeFeatureKey } from '../../features/menu-tree/store/menu-tree.reducer';
import { plannerFeatureKey } from '../../features/planner/store/planner.reducer';
import { TIME_TRACKING_FEATURE_KEY } from '../../features/time-tracking/store/time-tracking.reducer';
import { initialSectionState } from '../../features/section/store/section.reducer';
/**
* Creates a minimal valid AppDataComplete state.
@ -85,6 +86,7 @@ export const createValidAppData = (
entities: {},
todayOrder: [],
},
section: initialSectionState,
menuTree: {
...menuTreeInitialState,
projectTree: [{ k: MenuTreeKind.PROJECT, id: 'INBOX' } as MenuTreeProjectNode],
@ -327,6 +329,7 @@ export const rootStateToAppData = (
reminders?: AppDataComplete['reminders'];
pluginUserData?: AppDataComplete['pluginUserData'];
pluginMetadata?: AppDataComplete['pluginMetadata'];
section?: AppDataComplete['section'];
} = {},
): AppDataComplete => {
return {
@ -339,6 +342,7 @@ export const rootStateToAppData = (
planner: state[plannerFeatureKey],
boards: state[BOARDS_FEATURE_NAME],
timeTracking: state[TIME_TRACKING_FEATURE_KEY],
section: additionalData.section || initialSectionState,
// These are either from additional data or defaults
simpleCounter: additionalData.simpleCounter || initialSimpleCounterState,
taskRepeatCfg: additionalData.taskRepeatCfg || initialTaskRepeatCfgState,

View file

@ -8,6 +8,7 @@ import {
TimeTrackingState,
} from '../../features/time-tracking/time-tracking.model';
import { ProjectState } from '../../features/project/project.model';
import { SectionState } from '../../features/section/section.model';
import { MenuTreeState } from '../../features/menu-tree/store/menu-tree.model';
import { TaskState } from '../../features/tasks/task.model';
import { createValidate } from 'typia';
@ -52,6 +53,7 @@ const _validateGlobalConfig = createValidate<GlobalConfigState>();
const _validateTimeTracking = createValidate<TimeTrackingState>();
const _validatePluginUserData = createValidate<PluginUserDataState>();
const _validatePluginMetadata = createValidate<PluginMetaDataState>();
const _validateSection = createValidate<SectionState>();
export const validateAllData = <R>(
d: AppDataComplete | R,
@ -101,6 +103,7 @@ export const appDataValidators: {
_wrapValidate(_validatePluginUserData(d)),
pluginMetadata: <R>(d: R | PluginMetaDataState) =>
_wrapValidate(_validatePluginMetadata(d)),
section: <R>(d: R | SectionState) => _wrapValidate(_validateSection(d), d, true),
} as const;
const validateArchiveModel = <R>(d: ArchiveModel | R): ValidationResult<ArchiveModel> => {

View file

@ -41,6 +41,11 @@ import {
simpleCounterReducer,
} from '../features/simple-counter/store/simple-counter.reducer';
import { SimpleCounterEffects } from '../features/simple-counter/store/simple-counter.effects';
import {
SECTION_FEATURE_NAME,
sectionReducer,
} from '../features/section/store/section.reducer';
import { SectionEffects } from '../features/section/store/section.effects';
import { TAG_FEATURE_NAME, tagReducer } from '../features/tag/store/tag.reducer';
import { TagEffects } from '../features/tag/store/tag.effects';
import {
@ -132,6 +137,10 @@ import {
StoreModule.forFeature(SIMPLE_COUNTER_FEATURE_NAME, simpleCounterReducer),
EffectsModule.forFeature([SimpleCounterEffects]),
StoreModule.forFeature(SECTION_FEATURE_NAME, sectionReducer),
EffectsModule.forFeature([SectionEffects]),
StoreModule.forFeature(TAG_FEATURE_NAME, tagReducer),
EffectsModule.forFeature([TagEffects]),
@ -184,4 +193,4 @@ import {
EffectsModule.forFeature([PluginHooksEffects]),
],
})
export class FeatureStoresModule {}
export class FeatureStoresModule { }

View file

@ -20,11 +20,14 @@ const T = {
SHOW_NOTES: 'BN.SHOW_NOTES',
},
CONFIRM: {
ADD_SECTION: 'CONFIRM.ADD_SECTION',
AUTO_FIX: 'CONFIRM.AUTO_FIX',
RELOAD_AFTER_IDB_ERROR: 'CONFIRM.RELOAD_AFTER_IDB_ERROR',
RESTORE_FILE_BACKUP: 'CONFIRM.RESTORE_FILE_BACKUP',
RESTORE_FILE_BACKUP_ANDROID: 'CONFIRM.RESTORE_FILE_BACKUP_ANDROID',
RESTORE_STRAY_BACKUP: 'CONFIRM.RESTORE_STRAY_BACKUP',
DELETE_SECTION: 'CONFIRM.DELETE_SECTION',
DELETE_SECTION_CASCADE: 'CONFIRM.DELETE_SECTION_CASCADE',
},
DATETIME_SCHEDULE: {
PRESS_ENTER_AGAIN: 'DATETIME_SCHEDULE.PRESS_ENTER_AGAIN',
@ -530,6 +533,7 @@ const T = {
CONFIRM_PARENT_TASKS_WITH_SUBS: 'F.MARKDOWN_PASTE.CONFIRM_PARENT_TASKS_WITH_SUBS',
CONFIRM_SUB_TASKS: 'F.MARKDOWN_PASTE.CONFIRM_SUB_TASKS',
CONFIRM_SUB_TASKS_WITH_PARENT: 'F.MARKDOWN_PASTE.CONFIRM_SUB_TASKS_WITH_PARENT',
CONFIRM_SECTIONS: 'F.MARKDOWN_PASTE.CONFIRM_SECTIONS',
DIALOG_TITLE: 'F.MARKDOWN_PASTE.DIALOG_TITLE',
},
METRIC: {
@ -2003,6 +2007,7 @@ const T = {
},
MH: {
ADD_NEW_TASK: 'MH.ADD_NEW_TASK',
ADD_SECTION: 'MH.ADD_SECTION',
ALL_PLANNED_LIST: 'MH.ALL_PLANNED_LIST',
BOARDS: 'MH.BOARDS',
COPY_TASK_LIST_MARKDOWN: 'MH.COPY_TASK_LIST_MARKDOWN',
@ -2280,6 +2285,8 @@ const T = {
WW: {
ADD_MORE: 'WW.ADD_MORE',
ADD_SCHEDULED_FOR_TOMORROW: 'WW.ADD_SCHEDULED_FOR_TOMORROW',
ADD_SECTION: 'WW.ADD_SECTION',
ADD_SECTION_TITLE: 'WW.ADD_SECTION_TITLE',
ADD_SOME_TASKS: 'WW.ADD_SOME_TASKS',
DONE_TASKS: 'WW.DONE_TASKS',
DONE_TASKS_IN_ARCHIVE: 'WW.DONE_TASKS_IN_ARCHIVE',

View file

@ -11,6 +11,8 @@
<div class="collapsible-title">{{ title() }}</div>
<ng-content select="[actions]"></ng-content>
@if (!isIconBefore()) {
<mat-icon class="collapsible-expand-icon">expand_more</mat-icon>
}

View file

@ -16,6 +16,17 @@ export interface MarkdownTaskStructure {
totalSubTasks: number;
}
export interface SectionWithTasks {
sectionTitle: string | null; // null for "No Section"
tasks: ParsedMarkdownTask[];
}
export interface MarkdownWithSections {
sections: SectionWithTasks[];
hasHeaders: boolean;
}
interface ParsedLine {
indentLevel: number;
content: string;
@ -309,3 +320,81 @@ export const parseMarkdownTasks = (text: string): ParsedMarkdownTask[] | null =>
// Return tasks only if we found at least one
return tasks.length > 0 ? tasks : null;
};
/**
* Parse markdown text to detect H1 headers (#) and group tasks under sections
* @param text - Markdown text with potential headers and task lists
* @returns Sections with tasks, or null if not valid markdown
*/
export const parseMarkdownWithSections = (text: string): MarkdownWithSections | null => {
if (!text || typeof text !== 'string') {
return null;
}
const lines = text.split('\n');
const sections: SectionWithTasks[] = [];
let currentSection: SectionWithTasks | null = null;
let hasHeaders = false;
const pendingTaskLines: string[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trim();
// Detect H1 header (only single #)
if (trimmed.match(/^#\s+(.+)$/)) {
hasHeaders = true;
// Process any pending tasks from previous section
if (pendingTaskLines.length > 0) {
const tasksText = pendingTaskLines.join('\n');
const parsedTasks = parseMarkdownTasksWithStructure(tasksText);
if (currentSection && parsedTasks) {
currentSection.tasks = parsedTasks.mainTasks;
}
pendingTaskLines.length = 0; // Clear
}
// Create new section
const headerText = trimmed.replace(/^#\s+/, '').trim();
currentSection = {
sectionTitle: headerText,
tasks: []
};
sections.push(currentSection);
}
// Detect task lines
else if (trimmed.match(/^[-*]\s+/) || trimmed.match(/^[-*]\s*\[([ x])\]/)) {
pendingTaskLines.push(line);
}
// Empty lines and other content are ignored for now
}
// Process remaining pending tasks
if (pendingTaskLines.length > 0) {
const tasksText = pendingTaskLines.join('\n');
const parsedTasks = parseMarkdownTasksWithStructure(tasksText);
if (currentSection && parsedTasks) {
// Add to current section
currentSection.tasks = parsedTasks.mainTasks;
} else if (parsedTasks) {
// Tasks before any header - create "No Section"
sections.unshift({
sectionTitle: null,
tasks: parsedTasks.mainTasks
});
}
}
// Only return if we found headers
if (!hasHeaders) {
return null;
}
return {
sections,
hasHeaders
};
};

View file

@ -20,11 +20,14 @@
"SHOW_NOTES": "Project Notes"
},
"CONFIRM": {
"ADD_SECTION": "Add Section",
"AUTO_FIX": "Your data seems to be damaged (\"{{validityError}}\"). Do you want to try to automatically fix it? This might result in partial data loss.",
"RELOAD_AFTER_IDB_ERROR": "Database Error - App Will Restart\n\nSuper Productivity cannot save data. This is usually caused by:\n• Low disk space (most common)\n• App update in background\n• Linux Snap users: run 'snap set core experimental.refresh-app-awareness=true'\n\nYour recent changes may not have been saved. Please free up disk space if low. The app will restart after you close this dialog.",
"RESTORE_FILE_BACKUP": "There seems to be NO DATA, but there are backups available at \"{{dir}}\". Do you want to restore the latest backup from {{from}}?",
"RESTORE_FILE_BACKUP_ANDROID": "There seems to be NO DATA, but there is a backup available. Do you want to load it?",
"RESTORE_STRAY_BACKUP": "During last sync there might have been some error. Do you want to restore the last backup?"
"RESTORE_STRAY_BACKUP": "During last sync there might have been some error. Do you want to restore the last backup?",
"DELETE_SECTION": "Are you sure you want to delete this section? Tasks in it will be moved to the task list.",
"DELETE_SECTION_CASCADE": "Are you sure you want to delete this section? All tasks in this section will also be deleted."
},
"DATETIME_SCHEDULE": {
"PRESS_ENTER_AGAIN": "Press enter again to save"
@ -519,6 +522,7 @@
"CONFIRM_PARENT_TASKS_WITH_SUBS": "Create <strong>{{tasksCount}} new tasks and {{subTasksCount}} sub-tasks</strong> from the pasted markdown list?",
"CONFIRM_SUB_TASKS": "Create {{tasksCount}} new sub-tasks from the pasted markdown list?",
"CONFIRM_SUB_TASKS_WITH_PARENT": "Create <strong>{{tasksCount}} new sub-tasks under \"{{parentTaskTitle}}\"</strong> from the pasted markdown list?",
"CONFIRM_SECTIONS": "Create {{sectionsCount}} section(s) with {{tasksCount}} task(s)?",
"DIALOG_TITLE": "Pasted Markdown List detected!"
},
"METRIC": {
@ -1993,6 +1997,7 @@
},
"MH": {
"ADD_NEW_TASK": "Add new Task",
"ADD_SECTION": "Add Section",
"ALL_PLANNED_LIST": "Planned / Repeat",
"BOARDS": "Boards",
"COPY_TASK_LIST_MARKDOWN": "Copy to Clipboard",
@ -2265,6 +2270,7 @@
"WW": {
"ADD_MORE": "Add more",
"ADD_SCHEDULED_FOR_TOMORROW": "Add tasks planned for tomorrow ({{nr}})",
"ADD_SECTION_TITLE": "Add Section",
"ADD_SOME_TASKS": "Add some tasks to plan your day!",
"DONE_TASKS": "Done Tasks",
"DONE_TASKS_IN_ARCHIVE": "There are currently no done tasks here, but there are some already archived.",
@ -2284,4 +2290,4 @@
"WORKING_TODAY": "Working today:",
"WORKING_TODAY_ARCHIVED": "Time worked today on archived tasks"
}
}
}

View file

@ -20,6 +20,7 @@
"SHOW_NOTES": "Mostrar notas del proyecto"
},
"CONFIRM": {
"ADD_SECTION": "Añadir Sección",
"AUTO_FIX": "Tus datos parecen estar dañados (\"{{validityError}}\"). ¿Quieres intentar arreglarlos automáticamente? Esto podría resultar en una pérdida parcial de datos.",
"RELOAD_AFTER_IDB_ERROR": "No se puede acceder a la base de datos :( Las causas posibles son una actualización de la aplicación en segundo plano o poco espacio en disco. Si instalaste la aplicación en Linux como snap, también querrás habilitar 'snap set core experimental.refresh-app-awareness=true' hasta que arreglen este problema por su parte. Pulsa OK para recargar la aplicación (puede requerir reiniciar manualmente la aplicación en algunas plataformas).",
"RESTORE_FILE_BACKUP": "Parece que NO HAY DATOS, pero hay copias de seguridad disponibles en \"{{dir}}\". ¿Quieres restaurar la última copia de seguridad de {{from}}?",
@ -2043,4 +2044,4 @@
"WORKING_TODAY": "Trabajando hoy:",
"WORKING_TODAY_ARCHIVED": "Tiempo trabajado hoy en tareas archivadas"
}
}
}

14
test-sections.js Normal file
View file

@ -0,0 +1,14 @@
// Test parseMarkdownWithSections function
const { parseMarkdownWithSections } = require('./src/app/util/parse-markdown-tasks.ts');
const testMarkdown = `# Backend Tasks
- Setup API routes
- Configure database
# Frontend Tasks
- Create login component
- Add authentication`;
console.log('Testing parseMarkdownWithSections...');
const result = parseMarkdownWithSections(testMarkdown);
console.log('Result:', JSON.stringify(result, null, 2));