From ed2b30b5389c3b4df94f862b330c664607a68eeb Mon Sep 17 00:00:00 2001 From: Jason Hyde Date: Wed, 16 Sep 2026 19:17:15 +0200 Subject: [PATCH 1/2] archive projects with an archived flag on the note --- CHANGELOG.md | 6 ++ README.md | 1 + docs/api.md | 5 +- packages/api/src/client.test.ts | 9 +++ packages/api/src/client.ts | 8 ++- packages/api/src/contract.ts | 6 +- packages/api/src/mcp.test.ts | 15 +++- packages/api/src/mcp.ts | 13 +++- packages/api/src/router.test.ts | 13 ++++ packages/api/src/router.ts | 7 +- packages/api/test/fakeApi.ts | 19 ++++- packages/core/src/store/YamlHydrator.ts | 1 + .../core/src/store/YamlSerializer.test.ts | 12 ++++ packages/core/src/store/YamlSerializer.ts | 2 + packages/core/src/types.ts | 16 ++++- packages/ui/src/composites/ProjectRow.ts | 3 + packages/viewer/src/main.test.ts | 1 + src/api/LocalApi.test.ts | 22 ++++++ src/api/LocalApi.ts | 24 ++++++- src/components/IdRepair.test.ts | 1 + src/export/snapshot.ts | 1 + src/main.ts | 14 +++- src/migration.ts | 10 +-- src/settings.ts | 2 +- src/store/ProjectScope.test.ts | 11 +++ src/store/ProjectScope.ts | 7 +- src/store/ProjectStore.ts | 2 +- src/store/VaultIndex.test.ts | 34 +++++++++ src/store/VaultIndex.ts | 71 ++++++++++++++----- src/styles/overview.css | 16 +++++ src/views/ProjectEditView.ts | 27 ++++++- src/views/ProjectListRenderer.ts | 53 ++++++++++++-- src/views/ProjectOverviewView.ts | 42 +++++++++-- src/views/styleguide/StyleguideView.ts | 26 ++++++- 34 files changed, 441 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28dce2c2..1cb2b3ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Projects can now be archived from the project list, the overview page and the project settings +- Added an "Archived" toggle to the project list; archived projects stay hidden until it is on +- The local API and MCP server can now archive a project and list archived ones on request + ## [2.4.1] - 2026-09-15 ### Changed diff --git a/README.md b/README.md index a2501f19..b88ffcd3 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,7 @@ One column per status. Dragging a card to another column changes its status. Car - Sub-projects, with an overview page per project: progress, milestones, sub-projects, properties - One view over a project, its subtree, a folder or the whole vault +- Archive a project that is done or no longer relevant; its sub-projects go with it - Saved views: named filter and sort combinations - Bulk edit from the table - People notes for assignees, with a per-person task list diff --git a/docs/api.md b/docs/api.md index 3879a53b..216fd3fe 100644 --- a/docs/api.md +++ b/docs/api.md @@ -39,7 +39,7 @@ Cursor, Claude Desktop and other clients that take a JSON config: A client that can only launch a command, or cannot send a header, can use `dotpm mcp` from the command line package instead. It bridges stdio to this endpoint and finds the token itself. See [cli.md](cli.md). -Tools: `list_projects`, `get_project`, `list_tasks`, `get_task`, `search_tasks`, `create_task`, `update_task`, `move_task`, `archive_task`, `delete_task`, `list_changes`. +Tools: `list_projects`, `get_project`, `archive_project`, `list_tasks`, `get_task`, `search_tasks`, `create_task`, `update_task`, `move_task`, `archive_task`, `delete_task`, `list_changes`. Resources: `dotpm://projects/{id}` (the project with its tasks) and `dotpm://tasks/{id}`. @@ -61,9 +61,10 @@ Send `Authorization: Bearer ` with every request except `GET /v1/health`. | Method and path | What it does | | --- | --- | | `GET /v1/health` | `{ ok, name, version }`, no token needed | -| `GET /v1/projects` | Every project: id, path, title, icon, color, parentId, taskCount, doneCount | +| `GET /v1/projects` | Every project: id, path, title, icon, color, parentId, archived, taskCount, doneCount; `?includeArchived=true` adds archived ones | | `GET /v1/projects/{id}` | One project with description, team, custom fields, statuses and priorities | | `POST /v1/projects` | Create a project from `title`, `description`, `icon`, `color`, `teamMembers` and an optional `parentId`; answers `201` | +| `POST /v1/projects/{id}/archive` | Body `{ "archived": true }` (the default) or `false`; sub-projects follow their parent | | `GET /v1/projects/{id}/tasks` | Its tasks in tree order; `?includeArchived=true` adds archived ones | | `POST /v1/projects/{id}/tasks` | Create a task from the fields below plus an optional `parentId`; answers `201` | | `GET /v1/tasks/{id}` | One task | diff --git a/packages/api/src/client.test.ts b/packages/api/src/client.test.ts index 1bf611e2..601f534a 100644 --- a/packages/api/src/client.test.ts +++ b/packages/api/src/client.test.ts @@ -40,6 +40,15 @@ describe('HttpApi', () => { expect(seen[0].url).toBe('http://127.0.0.1:27140/v1/projects/p1/tasks?includeArchived=true') }) + it('archives a project and asks for archived projects only when told to', async () => { + expect((await api.archiveProject('p1', true)).archived).toBe(true) + expect(host.calls.at(-1)).toBe('archiveProject p1 true') + expect(await api.listProjects()).toEqual([]) + expect((await api.listProjects(true)).map((p) => p.id)).toEqual(['p1']) + expect(seen.at(-1)?.url).toBe('http://127.0.0.1:27140/v1/projects?includeArchived=true') + expect((await api.archiveProject('p1', false)).archived).toBe(false) + }) + it('puts a search on the query string', async () => { const found = await api.searchTasks({ query: 'sec', projectId: 'p1', includeArchived: true, limit: 5 }) expect(found.map((t) => t.id)).toEqual(['t2']) diff --git a/packages/api/src/client.ts b/packages/api/src/client.ts index 70c7c781..c920c50d 100644 --- a/packages/api/src/client.ts +++ b/packages/api/src/client.ts @@ -65,8 +65,12 @@ export class HttpApi implements DomainApi { return this.request('GET', '/v1/health') } - listProjects(): Promise { - return this.request('GET', '/v1/projects') + listProjects(includeArchived = false): Promise { + return this.request('GET', includeArchived ? '/v1/projects?includeArchived=true' : '/v1/projects') + } + + archiveProject(projectId: string, archived: boolean): Promise { + return this.request('POST', `/v1/projects/${id(projectId)}/archive`, { archived }) } getProject(projectId: string): Promise { diff --git a/packages/api/src/contract.ts b/packages/api/src/contract.ts index 074d148a..751170f0 100644 --- a/packages/api/src/contract.ts +++ b/packages/api/src/contract.ts @@ -8,6 +8,8 @@ export interface ProjectSummary { icon: string color: string parentId: string | null + /** Set on the project itself or inherited from an archived ancestor. */ + archived: boolean taskCount: number doneCount: number } @@ -143,9 +145,11 @@ export const ERROR_STATUS: Record = { * `ApiRequestError`. */ export interface DomainApi { - listProjects(): Promise + listProjects(includeArchived?: boolean): Promise getProject(projectId: string): Promise createProject(input: unknown): Promise + /** Archiving a project archives everything under it; only the project flagged itself can be brought back. */ + archiveProject(projectId: string, archived: boolean): Promise listTasks(projectId: string, includeArchived?: boolean): Promise getTask(taskId: string): Promise searchTasks(search: TaskSearch): Promise diff --git a/packages/api/src/mcp.test.ts b/packages/api/src/mcp.test.ts index 9c8fdbec..0c59ccff 100644 --- a/packages/api/src/mcp.test.ts +++ b/packages/api/src/mcp.test.ts @@ -59,6 +59,7 @@ describe('createMcpHandler', () => { expect(listed.map((tool) => tool.name)).toEqual([ 'list_projects', 'get_project', + 'archive_project', 'create_project', 'list_tasks', 'get_task', @@ -71,7 +72,11 @@ describe('createMcpHandler', () => { 'list_changes' ]) expect(listed[1].inputSchema).toMatchObject({ required: ['projectId'] }) - expect(listed[2].inputSchema).toMatchObject({ required: ['title'], properties: { color: { type: 'string' } } }) + expect(listed[2].inputSchema).toMatchObject({ + required: ['projectId'], + properties: { archived: { type: 'boolean' } } + }) + expect(listed[3].inputSchema).toMatchObject({ required: ['title'], properties: { color: { type: 'string' } } }) expect((await rpc('nope/method')).error?.code).toBe(-32601) }) @@ -114,6 +119,14 @@ describe('createMcpHandler', () => { await call('update_task', { taskId: 't3', status: 'done', expectedUpdatedAt: 'stale' }) expect(api.calls.at(-1)).toBe('updateTask t3 if stale') + + expect(parsed(await call('archive_project', { projectId: 'p1' }))).toMatchObject({ id: 'p1', archived: true }) + expect(parsed(await rpc('tools/call', { name: 'list_projects' }))).toEqual([]) + expect(parsed(await call('list_projects', { includeArchived: true }))).toEqual([ + expect.objectContaining({ id: 'p1', archived: true }) + ]) + await call('archive_project', { projectId: 'p1', archived: false }) + expect(api.calls.at(-1)).toBe('archiveProject p1 false') }) it('reports a client mistake as a tool error instead of a protocol error', async () => { diff --git a/packages/api/src/mcp.ts b/packages/api/src/mcp.ts index 86e9a9a2..5812edd2 100644 --- a/packages/api/src/mcp.ts +++ b/packages/api/src/mcp.ts @@ -88,9 +88,10 @@ function tool( function registerTools(server: McpServer, api: DomainApi): void { tool(server, 'list_projects', { - description: 'Every project in the vault with its id, title, parent and task counts.', - inputSchema: v.object({}), - run: () => api.listProjects() + description: + 'Every project in the vault with its id, title, parent and task counts. Archived ones only when asked.', + inputSchema: v.object({ includeArchived: v.optional(v.boolean(), false) }), + run: (args) => api.listProjects(args.includeArchived) }) tool(server, 'get_project', { @@ -99,6 +100,12 @@ function registerTools(server: McpServer, api: DomainApi): void { run: (args) => api.getProject(args.projectId) }) + tool(server, 'archive_project', { + description: 'Archive a project and its sub-projects, or bring it back with archived: false.', + inputSchema: v.object({ projectId, archived: v.optional(v.boolean('archived must be true or false'), true) }), + run: (args) => api.archiveProject(args.projectId, args.archived) + }) + tool(server, 'create_project', { description: 'Create a project, at the root or under parentId. It starts with the global statuses and priorities; read it back with get_project before adding tasks.', diff --git a/packages/api/src/router.test.ts b/packages/api/src/router.test.ts index bb98145b..7e041023 100644 --- a/packages/api/src/router.test.ts +++ b/packages/api/src/router.test.ts @@ -57,6 +57,19 @@ describe('createRouter', () => { expect((await json('GET', '/v1/tasks/t2')).body).toMatchObject({ id: 't2', position: 1 }) }) + it('archives a project and lists it again only when asked', async () => { + expect((await json('POST', '/v1/projects/p1/archive')).body).toMatchObject({ id: 'p1', archived: true }) + expect((await json('GET', '/v1/projects')).body).toEqual([]) + expect((await json('GET', '/v1/projects?includeArchived=true')).body).toEqual([ + expect.objectContaining({ id: 'p1', archived: true }) + ]) + expect((await json('POST', '/v1/projects/p1/archive', { body: { archived: false } })).body).toMatchObject({ + archived: false + }) + expect(api.calls).toContain('archiveProject p1 true') + expect(api.calls).toContain('listProjects archived') + }) + it('maps client mistakes to statuses', async () => { expect((await json('GET', '/v1/projects/nope')).status).toBe(404) const unknown = await json('GET', '/v1/nothing/here') diff --git a/packages/api/src/router.ts b/packages/api/src/router.ts index ac426026..fd986a52 100644 --- a/packages/api/src/router.ts +++ b/packages/api/src/router.ts @@ -88,12 +88,17 @@ export function createRouter(host: HttpHost): (request: Request) => Response | P app.all('/mcp', (c) => mcp(c.req.raw)) - app.get('/v1/projects', async (c) => c.json(await api.listProjects())) + app.get('/v1/projects', async (c) => c.json(await api.listProjects(c.req.query('includeArchived') === 'true'))) app.post('/v1/projects', async (c) => c.json(await api.createProject(await body(c)), 201)) app.get('/v1/projects/:id', async (c) => c.json(await api.getProject(c.req.param('id')))) + app.post('/v1/projects/:id/archive', async (c) => { + const asked = ((await body(c)) ?? {}) as { archived?: unknown } + return c.json(await api.archiveProject(c.req.param('id'), asked.archived !== false)) + }) + app.get('/v1/projects/:id/tasks', async (c) => c.json(await api.listTasks(c.req.param('id'), c.req.query('includeArchived') === 'true')) ) diff --git a/packages/api/test/fakeApi.ts b/packages/api/test/fakeApi.ts index 2726b9ad..5bae96a3 100644 --- a/packages/api/test/fakeApi.ts +++ b/packages/api/test/fakeApi.ts @@ -57,6 +57,7 @@ export class FakeApi implements DomainApi { icon: this.project.icon, color: this.project.color, parentId: null, + archived: this.project.archived === true, taskCount: this.project.tasks.length, doneCount: this.project.tasks.filter((t) => t.status === 'done').length } @@ -72,14 +73,25 @@ export class FakeApi implements DomainApi { if (projectId !== this.project.id) throw new ApiRequestError('not_found', `project ${projectId} not found`) } - async listProjects(): Promise { - this.calls.push('listProjects') - return [this.summary()] + async listProjects(includeArchived = false): Promise { + this.calls.push(`listProjects${includeArchived ? ' archived' : ''}`) + return includeArchived || !this.project.archived ? [this.summary()] : [] + } + + async archiveProject(projectId: string, archived: boolean): Promise { + this.calls.push(`archiveProject ${projectId} ${archived}`) + this.checkProject(projectId) + this.project.archived = archived + return this.resource() } async getProject(projectId: string): Promise { this.calls.push(`getProject ${projectId}`) this.checkProject(projectId) + return this.resource() + } + + private resource(): ProjectResource { return { ...this.summary(), description: this.project.description, @@ -110,6 +122,7 @@ export class FakeApi implements DomainApi { icon: project.icon, color: project.color, parentId: create.parentId ?? null, + archived: false, taskCount: 0, doneCount: 0, description: project.description, diff --git a/packages/core/src/store/YamlHydrator.ts b/packages/core/src/store/YamlHydrator.ts index 43101e53..af48ff3e 100644 --- a/packages/core/src/store/YamlHydrator.ts +++ b/packages/core/src/store/YamlHydrator.ts @@ -164,6 +164,7 @@ export function hydrateProjectFromFrontmatter( filePath, savedViews: hydrateSavedViews((frontmatter.savedViews as unknown[]) ?? []), config: hydrateProjectConfig(frontmatter.config), + ...(frontmatter.archived === true ? { archived: true } : {}), taskIndex: new Map() } } diff --git a/packages/core/src/store/YamlSerializer.test.ts b/packages/core/src/store/YamlSerializer.test.ts index bd79cff4..ab1f0d33 100644 --- a/packages/core/src/store/YamlSerializer.test.ts +++ b/packages/core/src/store/YamlSerializer.test.ts @@ -93,4 +93,16 @@ describe('serializer determinism', () => { const project = hydrateProjectFromFrontmatter(parsed.frontmatter, parsed.body, 'Projects/Test/Test.md', 'Test') expect(serializeProject(project, [], refs)).toBe(first) }) + + it('writes the archive flag only when set and reads it back', () => { + expect(serializeProject(fixtureProject(), [], refs)).not.toContain('archived') + const archived = { ...fixtureProject(), archived: true } + const text = serializeProject(archived, [], refs) + expect(text).toContain('archived: true') + const parsed = parseFrontmatter(text) + if (parsed.kind !== 'frontmatter') throw new Error('frontmatter missing') + const project = hydrateProjectFromFrontmatter(parsed.frontmatter, parsed.body, 'Projects/Test/Test.md', 'Test') + expect(project.archived).toBe(true) + expect(serializeProject(project, [], refs)).toBe(text) + }) }) diff --git a/packages/core/src/store/YamlSerializer.ts b/packages/core/src/store/YamlSerializer.ts index c7bea6dd..423749bd 100644 --- a/packages/core/src/store/YamlSerializer.ts +++ b/packages/core/src/store/YamlSerializer.ts @@ -22,6 +22,7 @@ export const PROJECT_FRONTMATTER_KEYS: ReadonlySet = new Set([ 'icon', 'taskIds', 'parent', + 'archived', 'customFields', 'teamMembers', 'savedViews', @@ -119,6 +120,7 @@ export function serializeProject( icon: project.icon, taskIds, ...(project.parentPath ? { parent: `[[${project.parentPath.replace(/\.md$/, '')}]]` } : {}), + ...(project.archived ? { archived: true } : {}), customFields: project.customFields, teamMembers: project.teamMembers, savedViews: project.savedViews.length ? project.savedViews : [], diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 5a3bcb61..2f17e21a 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -82,6 +82,8 @@ export interface Project { savedViews: SavedView[] /** The project this one sits under, resolved from its `parent` link. */ parentPath?: string + /** Kept out of lists, pickers and sweeps. Sub-projects follow their parent. */ + archived?: boolean /** Per-project overrides for the global settings. Absent fields inherit. */ config?: ProjectConfig /** Not serialized. Rebuilt on load, maintained by the store's mutators. */ @@ -92,7 +94,16 @@ export interface Project { export type ProjectPatch = Partial< Pick< Project, - 'title' | 'description' | 'color' | 'icon' | 'customFields' | 'teamMembers' | 'savedViews' | 'config' | 'parentPath' + | 'title' + | 'description' + | 'color' + | 'icon' + | 'customFields' + | 'teamMembers' + | 'savedViews' + | 'config' + | 'parentPath' + | 'archived' > > @@ -233,6 +244,8 @@ export interface PMSettings { collapsedTasks: Record /** Paths of projects whose sub-projects are collapsed in the project list. */ collapsedProjects: string[] + /** Whether the project list shows archived projects. */ + showArchivedProjects: boolean /** A localhost HTTP and MCP server for other tools on this machine. Desktop only. */ localApiEnabled: boolean localApiPort: number @@ -298,6 +311,7 @@ export const DEFAULT_SETTINGS: PMSettings = { scopeViews: {}, collapsedTasks: {}, collapsedProjects: [], + showArchivedProjects: false, localApiEnabled: false, localApiPort: LOCAL_API_PORT_BASE, localApiToken: '', diff --git a/packages/ui/src/composites/ProjectRow.ts b/packages/ui/src/composites/ProjectRow.ts index 8c9e3bfe..f70451e1 100644 --- a/packages/ui/src/composites/ProjectRow.ts +++ b/packages/ui/src/composites/ProjectRow.ts @@ -18,6 +18,7 @@ export interface ProjectRowProps { /** Sub-projects below this one. Above zero, the counts are the caller's subtree rollup. */ childCount: number collapsed: boolean + archived: boolean tasksDone: number tasksTotal: number overdue: number @@ -38,6 +39,7 @@ export class ProjectRow { constructor(tbody: HTMLElement, props: ProjectRowProps) { this.el = tbody.createEl('tr', { cls: 'pm-table-row pm-project-row' }) + if (props.archived) this.el.addClass('pm-table-row--archived') this.el.style.setProperty('--depth', String(props.depth)) const expand = this.el.createEl('td', { cls: 'pm-table-cell-expand' }) @@ -54,6 +56,7 @@ export class ProjectRow { const inner = title.createDiv('pm-table-title-inner') renderGlyph(inner.createSpan({ cls: 'pm-project-row-icon' }), { icon: props.icon, color: props.color }) inner.createSpan({ text: props.title, cls: 'pm-task-title-text' }) + if (props.archived) new Chip(inner).setLabel('Archived').setVariant('outline').setSize('sm') const progress = this.el.createEl('td', { cls: 'pm-table-cell pm-table-cell-progress' }) new ProgressBar(progress) diff --git a/packages/viewer/src/main.test.ts b/packages/viewer/src/main.test.ts index d5540f36..77bf6822 100644 --- a/packages/viewer/src/main.test.ts +++ b/packages/viewer/src/main.test.ts @@ -31,6 +31,7 @@ function snapshot(): Snapshot { icon: 'rocket', color: '#8b72be', parentId: null, + archived: false, taskCount: 2, doneCount: 1, description: '', diff --git a/src/api/LocalApi.test.ts b/src/api/LocalApi.test.ts index 732d4e43..025e61e7 100644 --- a/src/api/LocalApi.test.ts +++ b/src/api/LocalApi.test.ts @@ -144,6 +144,28 @@ describe('LocalApi over the vault', () => { await rejectsWith(api.deleteTask('a'), 'not_found') }) + it('archives a project with its sub-projects and brings it back', async () => { + const child = await store.createProject('Phase 1', 'Projects/Roadmap', { + parentPath: 'Projects/Roadmap/Roadmap.md' + }) + index.build() + + expect(await api.archiveProject(projectId, true)).toMatchObject({ id: projectId, archived: true }) + index.build() + expect((await api.listProjects()).map((p) => p.title)).toEqual([]) + expect((await api.listProjects(true)).map((p) => [p.title, p.archived])).toEqual([ + ['Phase 1', true], + ['Roadmap', true] + ]) + expect((await api.getProject(child.id)).archived).toBe(true) + expect(refreshes()).toBeGreaterThan(0) + + expect(await api.archiveProject(projectId, false)).toMatchObject({ archived: false }) + index.build() + expect((await api.listProjects()).map((p) => p.title).sort()).toEqual(['Phase 1', 'Roadmap']) + await rejectsWith(api.archiveProject('missing', true), 'not_found') + }) + it('records what changed for pollers', async () => { const off = api.attach() const start = await api.changes(null) diff --git a/src/api/LocalApi.ts b/src/api/LocalApi.ts index cd26c4c7..54760414 100644 --- a/src/api/LocalApi.ts +++ b/src/api/LocalApi.ts @@ -39,6 +39,7 @@ function refOf(project: Project): ProjectRef { teamMembers: project.teamMembers, customFields: project.customFields, parentPath: project.parentPath, + archived: project.archived === true, ownStatusIds: project.config?.statuses?.map((status) => status.id) ?? null, completeStatusIds: project.config?.statuses?.filter((status) => status.complete).map((status) => status.id) ?? null, autoArchiveDays: project.config?.autoArchiveDays ?? null @@ -76,8 +77,24 @@ export class LocalApi implements DomainApi { } } - async listProjects(): Promise { - return this.plugin.index.projectRefs().map((ref) => this.summary(ref)) + async listProjects(includeArchived = false): Promise { + return this.plugin.index.projectRefs(includeArchived).map((ref) => this.summary(ref)) + } + + async archiveProject(projectId: string, archived: boolean): Promise { + const ref = this.refById(projectId) + const project = await this.load(ref) + if (Boolean(project.archived) !== archived) { + await this.plugin.store.updateProject(project, { archived: archived || undefined }) + this.plugin.refreshViews() + } + // The index reads the note back later; the answer reflects the write just made. + const parent = this.plugin.index.parentOf(ref.path) + const summary = { + ...this.summary(ref), + archived: archived || (parent ? this.plugin.index.isArchived(parent.path) : false) + } + return toProjectResource(project, this.plugin.store.configFor(project), summary) } async getProject(projectId: string): Promise { @@ -224,7 +241,7 @@ export class LocalApi implements DomainApi { } private refById(projectId: string): ProjectRef { - const indexed = this.plugin.index.projectRefs().find((ref) => ref.id === projectId) + const indexed = this.plugin.index.projectRefs(true).find((ref) => ref.id === projectId) if (indexed) { this.created.delete(projectId) return indexed @@ -241,6 +258,7 @@ export class LocalApi implements DomainApi { icon: ref.icon, color: ref.color, parentId: this.parentIdOf(ref), + archived: this.plugin.index.isArchived(ref.path), taskCount: counts.total, doneCount: counts.done } diff --git a/src/components/IdRepair.test.ts b/src/components/IdRepair.test.ts index 27d7afd9..d3356ea9 100644 --- a/src/components/IdRepair.test.ts +++ b/src/components/IdRepair.test.ts @@ -40,6 +40,7 @@ function projectRef(id: string, path: string): ProjectRef { teamMembers: [], customFields: [], parentPath: undefined, + archived: false, ownStatusIds: null, completeStatusIds: null, autoArchiveDays: null diff --git a/src/export/snapshot.ts b/src/export/snapshot.ts index 66347616..9b3a6daa 100644 --- a/src/export/snapshot.ts +++ b/src/export/snapshot.ts @@ -53,6 +53,7 @@ function summarize(plugin: PMPlugin, project: Project): ProjectSummary { icon: project.icon, color: project.color, parentId: plugin.index.parentOf(project.filePath)?.id ?? null, + archived: plugin.index.isArchived(project.filePath), taskCount: live.length, doneCount: live.filter((f) => complete.has(f.task.status)).length } diff --git a/src/main.ts b/src/main.ts index fe6a3344..33dae0ec 100644 --- a/src/main.ts +++ b/src/main.ts @@ -215,7 +215,7 @@ export default class PMPlugin extends Plugin { name: 'Rebuild project index', callback: () => { this.index.build() - this.showNotice(`Found ${this.index.projectRefs().length} project(s).`) + this.showNotice(`Found ${this.index.projectRefs(true).length} project(s).`) } }) @@ -463,6 +463,14 @@ export default class PMPlugin extends Plugin { return path === '' || this.app.vault.getAbstractFileByPath(path) !== null } + /** Flags the note; sub-projects follow it without a write of their own. */ + async setProjectArchived(path: string, archived: boolean): Promise { + const project = await this.store.loadProjectByPath(path) + if (!project) return + await this.store.updateProject(project, { archived: archived || undefined }) + this.showNotice(archived ? `Archived "${project.title}".` : `Unarchived "${project.title}".`) + } + /** Prompts for a title, copies the project with fresh task ids, and opens the copy. */ async duplicateProjectFlow(source: Project): Promise { const title = await promptText(this.app, `Duplicate "${source.title}" as`, 'Project name', `${source.title} copy`) @@ -631,7 +639,7 @@ export default class PMPlugin extends Plugin { private async linkPeopleToNotes(): Promise { const plain: string[] = [] for (const ref of this.index.allTaskRefs()) plain.push(...ref.assignees) - for (const ref of this.index.projectRefs()) plain.push(...ref.teamMembers) + for (const ref of this.index.projectRefs(true)) plain.push(...ref.teamMembers) const names = dedupePeople(plain.filter((value) => !value.trim().startsWith('[['))) if (names.length === 0) { this.showNotice('Every assignee already links to a note.') @@ -682,7 +690,7 @@ export default class PMPlugin extends Plugin { tasksChanged += taskIds.length } - for (const ref of this.index.projectRefs()) { + for (const ref of this.index.projectRefs(true)) { if (!ref.teamMembers.some((value) => linkFor.has(value.trim().toLowerCase()))) continue const project = await this.store.loadProjectByPath(ref.path) if (!project) continue diff --git a/src/migration.ts b/src/migration.ts index 4bfa5e39..f9869ba9 100644 --- a/src/migration.ts +++ b/src/migration.ts @@ -8,7 +8,7 @@ import { parseFrontmatter, isOldFormat } from '@dotpm/core' export async function migrateProjects(plugin: PMPlugin): Promise { let migrated = 0 - for (const path of plugin.index.projectPaths()) { + for (const path of plugin.index.projectPaths(true)) { const file = plugin.app.vault.getAbstractFileByPath(path) if (!(file instanceof TFile)) continue try { @@ -61,7 +61,7 @@ function frontmatterOf(plugin: PMPlugin, path: string): Record export async function migrateTaskRefs(plugin: PMPlugin): Promise { let rewritten = 0 - for (const path of plugin.index.projectPaths()) { + for (const path of plugin.index.projectPaths(true)) { const stale = plugin.index .taskRefs(path) .filter((ref) => holdsBareId(frontmatterOf(plugin, ref.path))) @@ -102,12 +102,12 @@ export async function migrateProjectLayout(plugin: PMPlugin): Promise { // Read before the first move: a sub-project's parent link stops resolving once the // parent note moves, so the index can no longer answer who belongs to whom. const childrenOf = new Map() - for (const path of plugin.index.projectPaths()) { - const children = plugin.index.childRefs(path).map((ref) => ref.path) + for (const path of plugin.index.projectPaths(true)) { + const children = plugin.index.childRefs(path, true).map((ref) => ref.path) if (children.length) childrenOf.set(path, children) } - for (const path of plugin.index.projectPaths()) { + for (const path of plugin.index.projectPaths(true)) { try { const to = await plugin.store.moveProjectIntoOwnFolder(path) if (!to) continue diff --git a/src/settings.ts b/src/settings.ts index 7f9d918b..05bcfae1 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -650,7 +650,7 @@ export class PMSettingTab extends PluginSettingTab { const fallback = configs[0] // Only projects the index says still use the deleted value are worth loading. const affected = this.plugin.index - .projectRefs() + .projectRefs(true) .filter((ref) => this.plugin.index.taskRefs(ref.path).some((task) => task[field] === deletedId)) .map((ref) => ref.path) const projects = await this.plugin.store.loadProjects(affected) diff --git a/src/store/ProjectScope.test.ts b/src/store/ProjectScope.test.ts index 15bfd105..a0731a66 100644 --- a/src/store/ProjectScope.test.ts +++ b/src/store/ProjectScope.test.ts @@ -34,10 +34,21 @@ describe('resolveScopePaths', () => { await fake.vault.create('Work/Billing.md', note('p2', 'Billing', 'Platform')) await fake.vault.create('Work/Deep.md', note('p3', 'Deep', 'Billing')) await fake.vault.create('Personal/Garden.md', note('p4', 'Garden')) + await fake.vault.create('Personal/Old.md', note('p5', 'Old').replace(/---\n$/, 'archived: true\n---\n')) + await fake.vault.create('Personal/Older.md', note('p6', 'Older', 'Old')) index = new VaultIndex(fake.app as unknown as App, () => SETTINGS) index.build() }) + it('leaves archived projects out of a folder and the vault, but opens an archived subtree', () => { + expect(resolveScopePaths({ kind: 'folder', path: 'Personal' }, index)).toEqual(['Personal/Garden.md']) + expect(resolveScopePaths({ kind: 'vault' }, index)).toHaveLength(4) + expect(resolveScopePaths({ kind: 'subtree', path: 'Personal/Old.md' }, index)).toEqual([ + 'Personal/Old.md', + 'Personal/Older.md' + ]) + }) + it('resolves one project', () => { expect(resolveScopePaths({ kind: 'project', path: 'Work/Platform.md' }, index)).toEqual(['Work/Platform.md']) }) diff --git a/src/store/ProjectScope.ts b/src/store/ProjectScope.ts index 51ef92d2..861a5768 100644 --- a/src/store/ProjectScope.ts +++ b/src/store/ProjectScope.ts @@ -26,8 +26,11 @@ export function resolveScopePaths(spec: ScopeSpec, index: VaultIndex): string[] switch (spec.kind) { case 'project': return index.projectRef(spec.path) ? [spec.path] : [] - case 'subtree': - return index.projectRef(spec.path) ? [spec.path, ...index.descendantRefs(spec.path).map((ref) => ref.path)] : [] + case 'subtree': { + if (!index.projectRef(spec.path)) return [] + const archived = index.isArchived(spec.path) + return [spec.path, ...index.descendantRefs(spec.path, archived).map((ref) => ref.path)] + } case 'folder': { const prefix = spec.path === '' ? '' : `${spec.path}/` return index.projectPaths().filter((path) => path.startsWith(prefix)) diff --git a/src/store/ProjectStore.ts b/src/store/ProjectStore.ts index d94dc0de..d8fd07b6 100644 --- a/src/store/ProjectStore.ts +++ b/src/store/ProjectStore.ts @@ -1522,7 +1522,7 @@ export class ProjectStore implements TaskSource { } private projectPathsInside(folder: string): string[] { - return (this.index?.projectPaths() ?? []).filter((path) => path.startsWith(folder + '/')) + return (this.index?.projectPaths(true) ?? []).filter((path) => path.startsWith(folder + '/')) } private async deleteFolderRecursive(folder: TFolder): Promise { diff --git a/src/store/VaultIndex.test.ts b/src/store/VaultIndex.test.ts index a695715e..774298a0 100644 --- a/src/store/VaultIndex.test.ts +++ b/src/store/VaultIndex.test.ts @@ -240,6 +240,40 @@ describe('VaultIndex', () => { expect(index.rollupDueSummary(root)).toEqual({ overdue: 2, latestDue: '2020-03-01' }) }) + it('leaves an archived project and its children out unless asked', async () => { + await vault.create('A.md', projectNote('p1', 'A', 'archived: true\n')) + await vault.create('B.md', projectNote('p2', 'B', 'parent: "[[A]]"\n')) + await vault.create('C.md', projectNote('p3', 'C')) + await vault.create('D.md', projectNote('p4', 'D', 'parent: "[[C]]"\narchived: true\n')) + index.build() + + expect(index.projectRefs().map((r) => r.title)).toEqual(['C']) + expect(index.projectRefs(true).map((r) => r.title)).toEqual(['A', 'B', 'C', 'D']) + expect(index.rootRefs().map((r) => r.title)).toEqual(['C']) + expect(index.rootRefs(true).map((r) => r.title)).toEqual(['A', 'C']) + expect(index.childRefs('A.md').map((r) => r.title)).toEqual([]) + expect(index.childRefs('A.md', true).map((r) => r.title)).toEqual(['B']) + expect(index.childRefs('C.md').map((r) => r.title)).toEqual([]) + expect(index.descendantRefs('A.md', true).map((r) => r.title)).toEqual(['B']) + expect(index.isArchived('A.md')).toBe(true) + expect(index.isArchived('B.md')).toBe(true) + expect(index.isArchived('C.md')).toBe(false) + expect(index.isArchived('D.md')).toBe(true) + expect(expectDefined(index.projectRef('B.md')).archived).toBe(false) + expect(index.parentOf('B.md')?.title).toBe('A') + }) + + it('follows an archive flag added after the build', async () => { + const note = await vault.create('A.md', projectNote('p1', 'A')) + await vault.create('B.md', projectNote('p2', 'B', 'parent: "[[A]]"\n')) + index.build() + index.register(fakePlugin()) + + await vault.process(note, (c) => c.replace('title: A', 'title: A\narchived: true')) + expect(index.projectRefs().map((r) => r.title)).toEqual([]) + expect(index.isArchived('B.md')).toBe(true) + }) + it('follows a parent link added after the build', async () => { await vault.create('A.md', projectNote('p1', 'A')) const child = await vault.create('B.md', projectNote('p2', 'B')) diff --git a/src/store/VaultIndex.ts b/src/store/VaultIndex.ts index 5cd1c395..48a0139c 100644 --- a/src/store/VaultIndex.ts +++ b/src/store/VaultIndex.ts @@ -27,6 +27,8 @@ export interface ProjectRef { customFields: CustomFieldDef[] /** Where its `parent` link points, before cycles are taken out. Use `parentOf`. */ parentPath: string | undefined + /** The flag the note carries itself. A sub-project inherits its parent's; use `isArchived`. */ + archived: boolean /** Status ids the project's own palette defines. Null inherits the global palette. */ ownStatusIds: string[] | null /** Which of those its own palette marks complete. Null inherits the global palette. */ @@ -55,6 +57,13 @@ function str(raw: unknown, fallback = ''): string { return typeof raw === 'string' ? raw : fallback } +interface ProjectTree { + parents: Map + children: Map + /** Projects archived themselves or through an ancestor. */ + archived: Set +} + /** A project note dropped inside a project's task storage is task storage, not a project. */ function insideTaskFolder(path: string): boolean { const parts = path.split('/') @@ -94,10 +103,7 @@ export class VaultIndex { private taskById = new Map() private tasksByProject = new Map>() private changeHandlers = new Set<() => void>() - private cachedTree: { parents: Map; children: Map } = { - parents: new Map(), - children: new Map() - } + private cachedTree: ProjectTree = { parents: new Map(), children: new Map(), archived: new Set() } private treeDirty = true private cachedDependents = new Map() private dependentsDirty = true @@ -173,25 +179,36 @@ export class VaultIndex { return () => this.changeHandlers.delete(handler) } - projectRefs(): ProjectRef[] { - return [...this.projects.values()].sort((a, b) => a.title.localeCompare(b.title)) + /** Archived projects are left out unless asked for, so a list that forgets shows less, not more. */ + projectRefs(includeArchived = false): ProjectRef[] { + const archived = this.tree().archived + return [...this.projects.values()] + .filter((ref) => includeArchived || !archived.has(ref.path)) + .sort((a, b) => a.title.localeCompare(b.title)) } /** Projects with no parent, plus any whose parent link is broken or circular. */ - rootRefs(): ProjectRef[] { + rootRefs(includeArchived = false): ProjectRef[] { const parents = this.tree().parents - return this.projectRefs().filter((ref) => !parents.get(ref.path)) + return this.projectRefs(includeArchived).filter((ref) => !parents.get(ref.path)) } - childRefs(path: string): ProjectRef[] { - const paths = this.tree().children.get(normalizePath(path)) + childRefs(path: string, includeArchived = false): ProjectRef[] { + const { children, archived } = this.tree() + const paths = children.get(normalizePath(path)) if (!paths) return [] return paths + .filter((child) => includeArchived || !archived.has(child)) .map((child) => this.projects.get(child)) .filter((ref): ref is ProjectRef => ref !== undefined) .sort((a, b) => a.title.localeCompare(b.title)) } + /** Archived on its own note or through any ancestor. */ + isArchived(path: string): boolean { + return this.tree().archived.has(normalizePath(path)) + } + /** The effective parent: a link that resolves to a live project without closing a cycle. */ parentOf(path: string): ProjectRef | null { const parent = this.tree().parents.get(normalizePath(path)) @@ -206,10 +223,10 @@ export class VaultIndex { } /** Every descendant, depth first. */ - descendantRefs(path: string): ProjectRef[] { + descendantRefs(path: string, includeArchived = false): ProjectRef[] { const out: ProjectRef[] = [] const walk = (from: string): void => { - for (const child of this.childRefs(from)) { + for (const child of this.childRefs(from, includeArchived)) { out.push(child) walk(child.path) } @@ -235,7 +252,7 @@ export class VaultIndex { /** The same across a project and everything under it. */ rollupDueSummary(ref: ProjectRef): { overdue: number; latestDue: string } { const totals = this.dueSummary(ref) - for (const descendant of this.descendantRefs(ref.path)) { + for (const descendant of this.descendantRefs(ref.path, this.isArchived(ref.path))) { const summary = this.dueSummary(descendant) totals.overdue += summary.overdue if (summary.latestDue > totals.latestDue) totals.latestDue = summary.latestDue @@ -243,10 +260,13 @@ export class VaultIndex { return totals } - /** Counts for a project and everything under it, for a program or portfolio card. */ + /** + * Counts for a project and everything under it, for a program or portfolio card. A live + * project rolls up its live sub-projects; an archived one rolls up the whole subtree. + */ rollupCounts(ref: ProjectRef): { total: number; done: number } { const totals = this.counts(ref) - for (const descendant of this.descendantRefs(ref.path)) { + for (const descendant of this.descendantRefs(ref.path, this.isArchived(ref.path))) { const counts = this.counts(descendant) totals.total += counts.total totals.done += counts.done @@ -258,8 +278,8 @@ export class VaultIndex { return this.projects.get(normalizePath(path)) ?? null } - projectPaths(): string[] { - return this.projectRefs().map((ref) => ref.path) + projectPaths(includeArchived = false): string[] { + return this.projectRefs(includeArchived).map((ref) => ref.path) } taskRefs(projectPath: string): TaskRef[] { @@ -435,6 +455,7 @@ export class VaultIndex { teamMembers: stringList(frontmatter.teamMembers), customFields: customFieldList(frontmatter.customFields), parentPath: resolveVaultLink(this.app, frontmatter.parent, path), + archived: frontmatter.archived === true, ownStatusIds: own ? own.map((entry) => entry.id as string) : null, completeStatusIds: own ? own.filter((entry) => entry.complete === true).map((entry) => entry.id as string) : null, autoArchiveDays: ownAutoArchiveDays(frontmatter) @@ -599,7 +620,7 @@ export class VaultIndex { * Parent and child edges, with cycles taken out. A project whose ancestors lead back to * it is treated as a root, so a bad link costs the tree one edge rather than hanging it. */ - private tree(): { parents: Map; children: Map } { + private tree(): ProjectTree { if (!this.treeDirty) return this.cachedTree const parents = new Map() for (const [path, ref] of this.projects) { @@ -624,7 +645,19 @@ export class VaultIndex { if (siblings) siblings.push(path) else children.set(parent, [path]) } - this.cachedTree = { parents, children } + const archived = new Set() + for (const [path, ref] of this.projects) { + if (ref.archived) archived.add(path) + } + for (const path of parents.keys()) { + for (let current = parents.get(path); current; current = parents.get(current)) { + if (archived.has(current)) { + archived.add(path) + break + } + } + } + this.cachedTree = { parents, children, archived } this.treeDirty = false return this.cachedTree } diff --git a/src/styles/overview.css b/src/styles/overview.css index 9332d22c..597fea9e 100644 --- a/src/styles/overview.css +++ b/src/styles/overview.css @@ -60,6 +60,22 @@ gap: 12px; } +.pm-overview-banner { + max-width: 1160px; + margin: 0 auto 16px; + display: flex; + align-items: center; + gap: 12px; + padding: 10px 14px; + border-radius: var(--radius-m); + background: var(--background-secondary); + color: var(--text-muted); + font-size: var(--font-ui-smaller); +} +.pm-overview-banner span { + flex: 1; +} + .pm-overview-icon { width: 40px; height: 40px; diff --git a/src/views/ProjectEditView.ts b/src/views/ProjectEditView.ts index ad52a081..6c225943 100644 --- a/src/views/ProjectEditView.ts +++ b/src/views/ProjectEditView.ts @@ -199,7 +199,7 @@ export class ProjectEditView extends ItemView { const cell = createDiv('pm-prop-value') const excluded = new Set([ project.filePath, - ...this.plugin.index.descendantRefs(project.filePath).map((ref) => ref.path) + ...this.plugin.index.descendantRefs(project.filePath, true).map((ref) => ref.path) ]) renderSelectControl({ container: cell, @@ -209,8 +209,9 @@ export class ProjectEditView extends ItemView { options: [ { id: '', label: 'No parent' }, ...this.plugin.index - .projectRefs() + .projectRefs(true) .filter((ref) => !excluded.has(ref.path)) + .filter((ref) => ref.path === project.parentPath || !this.plugin.index.isArchived(ref.path)) .map((ref) => ({ id: ref.path, label: ref.title, color: ref.color })) ], onChange: (path) => { @@ -221,6 +222,28 @@ export class ProjectEditView extends ItemView { return cell }) + renderPropRow(props, 'Archived', () => { + const cell = createDiv('pm-prop-value') + const ancestor = this.plugin.index.ancestorRefs(project.filePath).find((ref) => ref.archived) + if (!project.archived && ancestor) { + cell.createSpan({ text: `Yes, with "${ancestor.title}"` }) + return cell + } + renderSelectControl({ + container: cell, + value: project.archived ? 'yes' : 'no', + options: [ + { id: 'no', label: 'No' }, + { id: 'yes', label: 'Yes' } + ], + onChange: (id) => { + this.save({ archived: id === 'yes' || undefined }) + this.render() + } + }) + return cell + }) + const desc = section.createDiv('pm-edit-block') desc.createEl('label', { text: 'Description', cls: 'pm-label' }) const area = desc.createEl('textarea', { cls: 'pm-input pm-edit-desc' }) diff --git a/src/views/ProjectListRenderer.ts b/src/views/ProjectListRenderer.ts index deb5a0b1..6fb72d4c 100644 --- a/src/views/ProjectListRenderer.ts +++ b/src/views/ProjectListRenderer.ts @@ -2,7 +2,7 @@ import { Menu, ButtonComponent } from 'obsidian' import type PMPlugin from '#main' import type { ProjectRef } from '#store' import { formatDateShort, dateUrgency } from '@dotpm/core' -import { safeAsync, EmptyState, ProjectRow, childTreeGuides } from '@dotpm/ui' +import { safeAsync, ChipButton, EmptyState, ProjectRow, childTreeGuides } from '@dotpm/ui' import { openProjectCreate } from '#ui/ModalFactory' import { linkedRefs } from './linkedRefs' @@ -30,6 +30,24 @@ export function renderProjectListToolbar(ctx: ProjectListContext): void { const line = countLine(ctx) if (line) left.createSpan({ cls: 'pm-project-list-count', text: line }) + const { settings } = ctx.plugin + if ( + settings.showArchivedProjects || + ctx.plugin.index.projectRefs(true).length > ctx.plugin.index.projectRefs().length + ) { + new ChipButton(ctx.toolbarEl) + .setLabel('Archived') + .setActive(settings.showArchivedProjects) + .onClick( + safeAsync(async () => { + settings.showArchivedProjects = !settings.showArchivedProjects + await ctx.plugin.saveSettings() + renderProjectListToolbar(ctx) + renderProjectListContent(ctx) + }) + ) + } + new ButtonComponent(ctx.toolbarEl) .setButtonText('+ new project') .setCta() @@ -38,15 +56,18 @@ export function renderProjectListToolbar(ctx: ProjectListContext): void { function countLine(ctx: ProjectListContext): string { const refs = ctx.plugin.index.projectRefs() - if (refs.length === 0) return '' + const archived = ctx.plugin.index.projectRefs(true).length - refs.length + if (refs.length === 0 && archived === 0) return '' const behind = refs.filter((ref) => ctx.plugin.index.dueSummary(ref).overdue > 0).length const bits = [refs.length === 1 ? '1 project' : `${refs.length} projects`] if (behind) bits.push(`${behind} with tasks past due`) + if (archived) bits.push(`${archived} archived`) return bits.join(' ยท ') } export function renderProjectListContent(ctx: ProjectListContext): void { - const roots = ctx.plugin.index.rootRefs() + const showArchived = ctx.plugin.settings.showArchivedProjects + const roots = ctx.plugin.index.rootRefs(showArchived) ctx.contentEl.empty() if (roots.length === 0) { @@ -54,6 +75,13 @@ export function renderProjectListContent(ctx: ProjectListContext): void { new EmptyState(ctx.contentEl).setIcon('๐Ÿ“‹').setTitle('Looking for projects') return } + if (ctx.plugin.index.projectRefs(true).length) { + new EmptyState(ctx.contentEl) + .setIcon('๐Ÿ“‹') + .setTitle('Every project is archived') + .setBody('Turn on "Archived" above to see them.') + return + } new EmptyState(ctx.contentEl) .setIcon('๐Ÿ“‹') .setTitle('No projects yet') @@ -72,8 +100,9 @@ export function renderProjectListContent(ctx: ProjectListContext): void { function renderRows(ctx: ProjectListContext, tbody: HTMLElement, refs: ProjectRef[], trail: boolean[]): void { const index = ctx.plugin.index + const showArchived = ctx.plugin.settings.showArchivedProjects refs.forEach((ref, i) => { - const children = index.childRefs(ref.path) + const children = index.childRefs(ref.path, showArchived) const collapsed = ctx.plugin.isProjectCollapsed(ref.path) const { total, done } = children.length ? index.rollupCounts(ref) : index.counts(ref) const { overdue, latestDue } = children.length ? index.rollupDueSummary(ref) : index.dueSummary(ref) @@ -88,6 +117,7 @@ function renderRows(ctx: ProjectListContext, tbody: HTMLElement, refs: ProjectRe isLastChild, childCount: children.length, collapsed, + archived: index.isArchived(ref.path), tasksDone: done, tasksTotal: total, overdue, @@ -147,6 +177,21 @@ function openProjectContextMenu(ctx: ProjectListContext, ref: ProjectRef, e: Mou .setIcon('settings') .onClick(safeAsync(() => ctx.plugin.router.openProjectEdit(ref.path))) ) + if (!ctx.plugin.index.isArchived(ref.path)) { + menu.addItem((item) => + item + .setTitle('Archive project') + .setIcon('archive') + .onClick(safeAsync(() => ctx.plugin.setProjectArchived(ref.path, true))) + ) + } else if (ref.archived) { + menu.addItem((item) => + item + .setTitle('Unarchive project') + .setIcon('archive-restore') + .onClick(safeAsync(() => ctx.plugin.setProjectArchived(ref.path, false))) + ) + } menu.addItem((item) => item .setTitle('Delete project') diff --git a/src/views/ProjectOverviewView.ts b/src/views/ProjectOverviewView.ts index a1bd1d06..60716a2b 100644 --- a/src/views/ProjectOverviewView.ts +++ b/src/views/ProjectOverviewView.ts @@ -20,6 +20,7 @@ import { personKeyer, type ProjectRef } from '#store' import { safeAsync, Avatar, + Chip, EmptyState, ProgressBar, renderPropRow, @@ -144,6 +145,7 @@ export class ProjectOverviewView extends ItemView { const rollup = summarize(tasks, config) this.renderBreadcrumbs(project) + this.renderArchivedBanner(project) this.renderHeader(project, rollup) const grid = this.container.createDiv('pm-overview-grid') const main = grid.createDiv('pm-overview-main') @@ -158,12 +160,35 @@ export class ProjectOverviewView extends ItemView { private signTree(project: Project): string { const parent = this.plugin.index.parentOf(project.filePath)?.path ?? '' - return `${parent}|${this.plugin.index - .childRefs(project.filePath) + const archived = this.plugin.index.isArchived(project.filePath) ? 'archived' : '' + return `${parent}|${archived}|${this.children(project) .map((child) => child.path) .join(',')}` } + /** An archived project shows its sub-projects, which are archived with it; a live one shows the live ones. */ + private children(project: Project): ProjectRef[] { + const archived = this.plugin.index.isArchived(project.filePath) + return this.plugin.index.childRefs(project.filePath, archived || this.plugin.settings.showArchivedProjects) + } + + private renderArchivedBanner(project: Project): void { + if (!this.plugin.index.isArchived(project.filePath)) return + const banner = this.container.createDiv('pm-overview-banner') + const ancestor = this.plugin.index.ancestorRefs(project.filePath).find((ref) => ref.archived) + banner.createSpan({ + text: + project.archived || !ancestor + ? 'This project is archived. It stays out of the project list, pickers and reminders.' + : `This project is archived with "${ancestor.title}". Unarchive that project to bring it back.` + }) + if (project.archived) { + new ButtonComponent(banner) + .setButtonText('Unarchive') + .onClick(safeAsync(() => this.plugin.setProjectArchived(project.filePath, false))) + } + } + private renderHeader(project: Project, rollup: Rollup): void { const header = this.container.createDiv('pm-overview-header') const tile = header.createDiv({ cls: 'pm-overview-icon' }) @@ -172,12 +197,18 @@ export class ProjectOverviewView extends ItemView { const identity = header.createDiv('pm-overview-identity') identity.createDiv({ cls: 'pm-overview-title', text: project.title }) - const children = this.plugin.index.childRefs(project.filePath).length + const children = this.children(project).length const bits = [`${rollup.done} of ${rollup.total} tasks done`] if (children) bits.push(children === 1 ? '1 sub-project' : `${children} sub-projects`) if (project.teamMembers.length) bits.push(`${project.teamMembers.length} members`) identity.createDiv({ cls: 'pm-overview-subline', text: bits.join(' ยท ') }) + if (!this.plugin.index.isArchived(project.filePath)) { + new ButtonComponent(header) + .setButtonText('Archive') + .setTooltip('Archive this project and its sub-projects') + .onClick(safeAsync(() => this.plugin.setProjectArchived(project.filePath, true))) + } new ButtonComponent(header) .setButtonText('Edit project') .onClick(safeAsync(() => this.plugin.router.openProjectEdit(project.filePath, this.leaf))) @@ -302,7 +333,7 @@ export class ProjectOverviewView extends ItemView { } private renderSubProjects(parent: HTMLElement, project: Project): void { - const children = this.plugin.index.childRefs(project.filePath) + const children = this.children(project) if (children.length === 0) return const section = this.section(parent, 'Sub-projects') for (const child of children) { @@ -310,6 +341,9 @@ export class ProjectOverviewView extends ItemView { const row = section.createDiv('pm-overview-child') renderGlyph(row.createSpan({ cls: 'pm-overview-child-icon' }), { icon: child.icon, color: child.color }) row.createSpan({ cls: 'pm-overview-child-title', text: child.title }) + if (this.plugin.index.isArchived(child.path)) { + new Chip(row).setLabel('Archived').setVariant('outline').setSize('sm') + } new ProgressBar(row) .setSize('sm') .setValue(total ? (done / total) * 100 : 0) diff --git a/src/views/styleguide/StyleguideView.ts b/src/views/styleguide/StyleguideView.ts index ddc813f7..34b70a43 100644 --- a/src/views/styleguide/StyleguideView.ts +++ b/src/views/styleguide/StyleguideView.ts @@ -469,7 +469,7 @@ export class StyleguideView extends ItemView { const sec = this.section('ProjectRow', 'project-row') sec.createDiv({ cls: 'pm-sg-caption', - text: 'The project list: a parent with its rolled-up counts, a child on a tree connector, and a collapsed one.' + text: 'The project list: a parent with its rolled-up counts, a child on a tree connector, a collapsed one, and an archived one.' }) const table = sec.createEl('table', { cls: 'pm-table pm-project-table' }) const head = table.createEl('thead').createEl('tr') @@ -485,6 +485,7 @@ export class StyleguideView extends ItemView { treeGuides: [], isLastChild: false, childCount: 2, + archived: false, collapsed: false, tasksDone: 12, tasksTotal: 40, @@ -505,6 +506,7 @@ export class StyleguideView extends ItemView { treeGuides: [true], isLastChild: true, childCount: 0, + archived: false, collapsed: false, tasksDone: 4, tasksTotal: 10, @@ -525,6 +527,7 @@ export class StyleguideView extends ItemView { treeGuides: [], isLastChild: true, childCount: 1, + archived: false, collapsed: true, tasksDone: 31, tasksTotal: 38, @@ -537,6 +540,27 @@ export class StyleguideView extends ItemView { onContextMenu: noop, onActions: noop }) + new ProjectRow(tbody, { + title: 'Old campaign', + icon: '๐Ÿ“ฃ', + color: '#c47a7a', + depth: 0, + treeGuides: [], + isLastChild: true, + childCount: 0, + collapsed: false, + archived: true, + tasksDone: 8, + tasksTotal: 8, + overdue: 0, + members: [], + dueLabel: '', + dueUrgency: 'normal', + onToggleCollapsed: noop, + onClick: noop, + onContextMenu: noop, + onActions: noop + }) } private renderCards(): void { From 4b76423973d4b27f66e53bfbf6f64e1f96a264d0 Mon Sep 17 00:00:00 2001 From: Jason Hyde Date: Wed, 16 Sep 2026 19:24:39 +0200 Subject: [PATCH 2/2] bump the core and api packages to 0.2.0 --- packages/api/package.json | 2 +- packages/core/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/api/package.json b/packages/api/package.json index fbc56869..89cf7c7a 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -1,6 +1,6 @@ { "name": "@dotpm/api", - "version": "0.1.0", + "version": "0.2.0", "description": "The dotpm API contract: the domain interface, wire resources, HTTP router, MCP handler and client", "license": "MIT", "repository": { diff --git a/packages/core/package.json b/packages/core/package.json index ac40a570..ef65dee9 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@dotpm/core", - "version": "0.1.0", + "version": "0.2.0", "description": "The dotpm domain: project and task types, dates, filtering, scheduling and the YAML pipeline", "license": "MIT", "repository": {