Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}`.

Expand All @@ -61,9 +61,10 @@ Send `Authorization: Bearer <token>` 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 |
Expand Down
2 changes: 1 addition & 1 deletion packages/api/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
9 changes: 9 additions & 0 deletions packages/api/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
Expand Down
8 changes: 6 additions & 2 deletions packages/api/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,12 @@ export class HttpApi implements DomainApi {
return this.request('GET', '/v1/health')
}

listProjects(): Promise<ProjectSummary[]> {
return this.request('GET', '/v1/projects')
listProjects(includeArchived = false): Promise<ProjectSummary[]> {
return this.request('GET', includeArchived ? '/v1/projects?includeArchived=true' : '/v1/projects')
}

archiveProject(projectId: string, archived: boolean): Promise<ProjectResource> {
return this.request('POST', `/v1/projects/${id(projectId)}/archive`, { archived })
}

getProject(projectId: string): Promise<ProjectResource> {
Expand Down
6 changes: 5 additions & 1 deletion packages/api/src/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -143,9 +145,11 @@ export const ERROR_STATUS: Record<ApiErrorCode, number> = {
* `ApiRequestError`.
*/
export interface DomainApi {
listProjects(): Promise<ProjectSummary[]>
listProjects(includeArchived?: boolean): Promise<ProjectSummary[]>
getProject(projectId: string): Promise<ProjectResource>
createProject(input: unknown): Promise<ProjectResource>
/** Archiving a project archives everything under it; only the project flagged itself can be brought back. */
archiveProject(projectId: string, archived: boolean): Promise<ProjectResource>
listTasks(projectId: string, includeArchived?: boolean): Promise<TaskResource[]>
getTask(taskId: string): Promise<TaskResource>
searchTasks(search: TaskSearch): Promise<TaskResource[]>
Expand Down
15 changes: 14 additions & 1 deletion packages/api/src/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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)
})

Expand Down Expand Up @@ -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 () => {
Expand Down
13 changes: 10 additions & 3 deletions packages/api/src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,10 @@ function tool<S extends v.GenericSchema>(

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', {
Expand All @@ -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.',
Expand Down
13 changes: 13 additions & 0 deletions packages/api/src/router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
7 changes: 6 additions & 1 deletion packages/api/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
)
Expand Down
19 changes: 16 additions & 3 deletions packages/api/test/fakeApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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<ProjectSummary[]> {
this.calls.push('listProjects')
return [this.summary()]
async listProjects(includeArchived = false): Promise<ProjectSummary[]> {
this.calls.push(`listProjects${includeArchived ? ' archived' : ''}`)
return includeArchived || !this.project.archived ? [this.summary()] : []
}

async archiveProject(projectId: string, archived: boolean): Promise<ProjectResource> {
this.calls.push(`archiveProject ${projectId} ${archived}`)
this.checkProject(projectId)
this.project.archived = archived
return this.resource()
}

async getProject(projectId: string): Promise<ProjectResource> {
this.calls.push(`getProject ${projectId}`)
this.checkProject(projectId)
return this.resource()
}

private resource(): ProjectResource {
return {
...this.summary(),
description: this.project.description,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/store/YamlHydrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/store/YamlSerializer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
2 changes: 2 additions & 0 deletions packages/core/src/store/YamlSerializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const PROJECT_FRONTMATTER_KEYS: ReadonlySet<string> = new Set([
'icon',
'taskIds',
'parent',
'archived',
'customFields',
'teamMembers',
'savedViews',
Expand Down Expand Up @@ -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 : [],
Expand Down
16 changes: 15 additions & 1 deletion packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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'
>
>

Expand Down Expand Up @@ -233,6 +244,8 @@ export interface PMSettings {
collapsedTasks: Record<string, string[]>
/** 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
Expand Down Expand Up @@ -298,6 +311,7 @@ export const DEFAULT_SETTINGS: PMSettings = {
scopeViews: {},
collapsedTasks: {},
collapsedProjects: [],
showArchivedProjects: false,
localApiEnabled: false,
localApiPort: LOCAL_API_PORT_BASE,
localApiToken: '',
Expand Down
3 changes: 3 additions & 0 deletions packages/ui/src/composites/ProjectRow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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' })
Expand All @@ -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)
Expand Down
1 change: 1 addition & 0 deletions packages/viewer/src/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ function snapshot(): Snapshot {
icon: 'rocket',
color: '#8b72be',
parentId: null,
archived: false,
taskCount: 2,
doneCount: 1,
description: '',
Expand Down
Loading