feat(plainspace): add integration for shared projects (#8424)

* docs(plainspace): add integration plan for shared projects

* feat(plainspace): prototype 'assigned to others' work-view panel

UI-only prototype of the dual-list shared-project view: a read-only
component that lists Plainspace tasks owned by other members, grouped by
assignee, rendered as a collapsible panel in the project work view.

Uses hard-coded sample data; foreign tasks never enter the SP task store
or op-log sync. See docs/plainspace-integration-plan.md.

* refactor(plainspace): apply review fixes to prototype panel

- use defined --text-color-muted / --s-half / --s2 tokens instead of an
  undefined token and redundant spacing fallbacks
- drop unused PLAINSPACE.DONE i18n key
- note avatarUrl sanitization needed before live API data
- clarify plan doc: icon registration in GlobalThemeService, issueType
  union widening, and which phases are design-only vs implemented

* feat(plainspace): add mock-backed issue provider + share-on-create toggle

Phase 1: register PLAINSPACE as an issue provider (config form, API
service with mock mode, IssueServiceInterface impl, issue.model/const/
service registration, icon, issue-content config). Tasks assigned to me or
unassigned import via the normal issue->backlog pipeline; tasks assigned to
others are excluded here (shown read-only by the separate panel).

Phase 3: add a 'Share on Plainspace' toggle to the create-project dialog
that provisions a (mock) space and a bound provider via
PlainspaceShareService.

All Plainspace calls are mocked (PLAINSPACE_USE_MOCK) so the flow works
without a live backend; the real HTTP contract is isolated in
PlainspaceApiService. Build + lint:ts + lint:scss pass; added a spec for
the provider's mock/filter logic (unit run blocked: no browser in env).

* refactor(plainspace): address review feedback on provider + share flow

- add a Plainspace tile to the issue-provider setup overview so the
  provider is addable manually (connect to an existing space), not only
  via the share-on-create toggle
- make PlainspaceShareService self-contained: catch errors, surface a
  snack, never reject (safe to fire-and-forget); log ids only
- strip the transient isShareOnPlainspace flag before it can reach
  sessionStorage on the dialog cancel path
- add a getIssueProviderTooltip case for PLAINSPACE (was falling through
  to the raw key)
- drop a redundant cast now that PlainspaceIssue is in IssueDataReduced

* feat(plainspace): add account login / identity (Phase 2, mock)

- PlainspaceAccountService: signals (account/isLoggedIn/currentUserId),
  mock login/logout, localStorage-persisted (local-only, never synced)
- 'mine' in the import filter now derives from the signed-in identity
  instead of a hard-coded constant; not-logged-in => only unassigned
- the Share-on-Plainspace flow prompts (mock) sign-in if needed and
  refuses to provision a space when the user declines
- move the mock user-id constant to the plainspace feature folder to
  avoid a cross-folder import cycle
- specs for the account service and updated api-service spec (sign in
  before asserting the mine/unassigned split)

Identity is mocked (fixed user id) so the assigned/unassigned split lines
up with the mock space data; real OAuth/token exchange is future work.

* feat(plainspace): feed 'assigned to others' panel from live mock data

Wire the work-view panel to a new PlainspaceSharedTasksService instead of
hard-coded sample data: for a project with a bound enabled PLAINSPACE
provider, it fetches the space's tasks, keeps only those assigned to
others (assigneeId !== me), maps them to read-only rows, and threads them
through project-task-page -> work-view as an input. The panel now appears
only for shared projects (not every project), and foreign tasks still
never enter the SP task store / op-log.

Removes the prototype data const; adds a service spec.

* refactor(plainspace): replace 'assigned to others' list with a claim pool

Reframe around task ownership: SP shows only tasks assigned to me (first-
class) plus a read-only 'claim pool' of unclaimed tasks. Tasks assigned to
others are no longer represented in SP.

- import filter is now assigned-to-me only (was mine+unassigned)
- new claim flow: PlainspaceApiService.claimTask$ (assign-to-self) +
  PlainspaceClaimPoolService.claim -> IssueService.addTaskFromIssue, with
  pool refresh; claimed task leaves the pool
- rename AssignedToOthers component/service -> claim-pool; panel is
  collapsed by default (a pool you reach for, not active work)
- drop assignee badges (redundant: list is all 'mine'; provider icon
  already marks Plainspace tasks)
- mock data made resettable for test isolation; specs updated/added
- docs: capture the ownership model + rationale for dropping the list

* docs(plainspace): fix remaining stale claim-pool reference

* feat(plainspace): connect provider to the real integration API

Replace the mock-backed PlainspaceApiService with the real PAT-authed
{host}/api/integration endpoints (me, tasks, claimable-tasks, claim,
spaces, done PATCH) and map the wire SPTask -> the internal PlainspaceIssue
so the rest of the provider depends on one stable shape.

- the server scopes /tasks to the caller, so drop client-side identity and
  the mock data/identity consts; "mine" is decided server-side
- store the personal API token (PAT) in PlainspaceCfg like other providers'
  secrets; keep a local-only account cache to bootstrap share-on-create
- add a token field to the config form; the share flow prompts for and
  validates a PAT via /me before provisioning a space
- isEnabled now requires a token; testConnection hits /me; issueLink uses
  the task's real url

Adds docs/plainspace-api-extension-plan.md documenting the server contract
this client consumes (the endpoints added to Johannesjo/spaces).

* feat(plainspace): guided connect dialog with token steps + link

Replace the bare one-line API-token prompt with PlainspaceConnectDialog,
which shows where to create a personal API token and validates it before
closing:

- numbered step-by-step (Space -> People -> Advanced -> API tokens) plus an
  "Open Plainspace" link button
- inline validation via /me; an invalid token shows an error and keeps the
  dialog open instead of failing silently
- wire it into the share-on-create flow and sharpen the config-form token
  description to point at the same place

Adds the CONNECT i18n block (regenerates t.const) and drops the now-unused
LOGIN_PROMPT string; a new component spec covers connect/cancel + template.

* feat(plainspace): auto-import + two-way done sync

- default isAutoAddToBacklog to true so tasks assigned to me auto-import
  into the bound project's backlog (poll-to-backlog), matching the seamless
  intent
- add PlainspaceSyncAdapterService, registered for 'PLAINSPACE' in the
  two-way-sync effect, so completing/reopening a task in SP pushes the done
  state back to Plainspace via PATCH /tasks/:id { done }

Only isDone is pushed (the integration API's one writable field); title and
done changes from Plainspace are pulled by the existing update polling. Adds
an adapter spec and a Plainspace adapter spy in the two-way-sync effect spec.

* fix(plainspace): match bound space by slug or id so tasks import

getMyTasks$ filtered by `task.projectId === cfg.spaceId`, but the value users
copy from the space URL is the slug while SPTask.projectId is the UUID — so a
slug-configured provider matched zero tasks and imported nothing, even though
the /tasks response was full.

Match spaceId against both projectId and projectSlug (getMyTasks$ and the claim
pool), drop the server ?projectId= param (UUID-only) in favour of the same
client-side match, and point the Space ID help text at the slug in the URL.

* chore(plainspace): log import counts to diagnose missing imports

getMyTasks$ now logs total vs space-matched task counts (ids/counts only,
no task content) so we can tell whether the space filter or the import is
dropping tasks. Temporary diagnostic.

* feat: add picker for plainspace

* feat: improve wording

* feat(plainspace): use two-circle brand mark for provider icon

* refactor(plainspace): type create-project form to drop isShareOnPlainspace any-cast

* refactor(plainspace): remove temporary getMyTasks$ diagnostic log

* feat(plainspace): push scheduled time (dueWithTime → remindAt)

* fix(plainspace): seed two-way-sync baseline so write-back actually fires

* feat(plainspace): import remindAt as dueWithTime so schedule shows in app

* refactor(plainspace): align client to scheduledAt/isRecurring API contract

* feat(plainspace): flag recurring tasks in the claim pool

* feat(plainspace): pull scheduledAt into dueWithTime on poll (schedule existing + recurring tasks)

* feat(plainspace): push title changes back to Plainspace

* fix(plainspace): address multi-review findings (perf, UX, cleanup)

Performance:
- claim pool no longer re-fetches /claimable-tasks on every task
  add/complete/reorder (distinct on project id + provider identity)
- poll imported tasks via one getMyTasks$ instead of N getById$ calls

UX:
- gate the "Share on Plainspace" toggle to project create (was a dead
  control in edit mode)
- success/failure snacks on claim and share (silent 409/offline before)
- distinct error state in the space-picker (vs misleading "no spaces yet")

Correctness/cleanup:
- normalize scheduledAt to canonical UTC ISO on read so the push guard
  can't silently drop a reschedule on a benign reformat
- drop dead PLAINSPACE_INITIAL_POLL_DELAY, PlainspaceMember, assignee
- translate the "Advanced Config" form label

* docs(plainspace): reconcile token storage + forward-compat rollout note

- document that the PAT lives in synced provider cfg like other providers
  (the earlier "not stored here" note was never true in the shipped code)
- add the typia forward-compat rollout requirement for the new built-in
  PLAINSPACE issue-provider key to the Risks section
- fix stale "assigned to others" wording (now the claim pool)

* feat(plainspace): trim redundant Notes button + issue panel

Plainspace mirrors title/done/schedule onto native task fields, so the
detail panel only echoed the task and the chat button opened an
essentially empty drawer.

- Treat PLAINSPACE like ICAL: don't show the *persistent* toggle button
  just because the task carries an issueId. Keep it for real notes, a
  remote update (issueWasUpdated) and the open-panel close state.
- Keep the panel reachable: the hover-only "open detail panel" button
  (task-hover-controls) now covers PLAINSPACE too — it's the exact
  complement of the persistent button, so plain/iCal/Plainspace tasks all
  get a hover affordance and there's never a double button.
- Replace the redundant title-as-"Summary" link with the two things not
  already on the task: a clear "Open in Plainspace" link and a recurrence
  indicator rendered with the same `repeat` mat-icon the claim pool uses
  (new 'plainspace-recurring' custom field).
- Add unit coverage for the new content config.

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Johannes Millan 2026-06-18 17:58:06 +02:00 committed by GitHub
parent 0150ee23b3
commit 91d8dd80ba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
57 changed files with 3690 additions and 54 deletions

View file

@ -0,0 +1,462 @@
# Plainspace API extension plan — to enable the Super Productivity integration
**Audience:** the Plainspace server team (`Johannesjo/spaces`, `packages/server`).
**Author:** drafted from the SP side while wiring PR #8424 (SP ↔ Plainspace).
**Status:** proposal — three small PAT-authed endpoints + one optional polling
enhancement. No breaking changes; all additive.
> **Why this exists.** SP's Plainspace integration (PR #8424) was built against an
> _assumed_ API and is currently mock-backed. Connecting it to the real backend
> revealed that the live `/api/integration` surface supports only **read my
> assigned tasks** and **toggle done** — which covers SP's "import my tasks +
> sync completion" path, but **not** the two features in the PR's final commits:
> the **claim pool** (claim an unassigned task) and **Share on Plainspace**
> (create a space from SP). Those need new server endpoints. This document
> specifies them, grounded in the existing code so they drop into the current
> patterns.
---
## 1. What the SP client needs, and what exists today
| SP feature | SP API call (client) | Real endpoint today | Gap |
| -------------------------------------- | ------------------------------------------------------ | ---------------------------------- | -------------------------------------------------- |
| Verify token / identity | `getMe$` | `GET /api/integration/me` | ✅ exists |
| Import my assigned tasks | `getMyTasks$` | `GET /api/integration/tasks` | ✅ exists (filter client-side by space) |
| Refresh one task | `getById$` | `GET /api/integration/tasks/:id` | ✅ exists |
| Push completion back | done write-back | `PATCH /api/integration/tasks/:id` | ✅ exists |
| **Push scheduled time** | `patchTask$ { scheduledAt }` | `PATCH /api/integration/tasks/:id` | ⚠️ **extend** (accept `scheduledAt`) |
| **Read scheduled time** | `getMyTasks$`/`getById$``scheduledAt`/`isRecurring` | `serializeSPTask` | ⚠️ **extend** (expose `scheduledAt`/`isRecurring`) |
| **Claim pool (list unclaimed)** | `getUnclaimedTasks$` | — | ❌ **new endpoint** |
| **Claim a task (self-assign)** | `claimTask$` | — | ❌ **new endpoint** |
| **Share on Plainspace (create space)** | `createSpace$` | — | ❌ **new endpoint** |
| Efficient polling | poll loop | full refetch only | ⚠️ optional: `?updatedSince=` |
All three new endpoints reuse the existing `apiTokenMiddleware`, the
`loadIntegrationScope()` helper, and the `SPTask` DTO already defined in
`integration.ts`. No new auth machinery.
### Recap of the existing integration model (for context)
`apiTokenMiddleware` resolves a `pat_…` bearer token to a verified email
(blind-indexed `emailLookup`). `loadIntegrationScope(emailLookup)` then finds
every `members` row for that email where `emailVerified = true` and
`tosVersion = TOS_VERSION`, yielding the set of projects the token may act in.
A single PAT therefore spans **all** of the caller's Spaces — keep that property
for the new endpoints.
---
## 2. New endpoint 1 — Claim a task (self-assign)
```
POST /api/integration/tasks/:taskId/claim
Authorization: Bearer pat_…
→ 200 { task: SPTask }
```
Assigns an **unassigned**, not-deleted task to the caller's member in that task's
project — the server-side of SP's "Claim" button. Mirror the assignment path in
`routes/items.ts` (the `item.assigned` branch) and the transactional pattern in
the existing `PATCH /api/integration/tasks/:taskId`.
**Logic**
1. Load scope. If empty → `404 { error: 'Task not found' }`.
2. Look up the item by id, restricted to `scope.projectIds`, `isNull(deletedAt)`.
Not found → `404`.
3. Resolve the caller's member for that project:
`member = scope.memberByProjectId.get(item.projectId)`. Missing → `404`.
4. **Atomic claim** (handles the race where two members claim at once — same
conditional-update pattern as the verification-code claim in `projects.ts`):
```ts
const [claimed] = await tx
.update(items)
.set({ assignedTo: member.id })
.where(
and(
eq(items.id, taskId),
eq(items.projectId, item.projectId),
isNull(items.assignedTo), // only if still unclaimed
isNull(items.deletedAt),
),
)
.returning();
if (!claimed) return c.json({ error: 'Task already claimed' }, 409); // lost the race / already assigned
```
5. `recordActivity(tx, { action: 'item.assigned', targetType: 'item',
targetId: taskId, memberId: member.id, meta: { text: item.text,
assignedTo: member.id, source: 'sp' } })`.
6. After commit: `sseManager.broadcast(projectId, 'item.updated',
{ item: serializeItem(claimed), memberId: member.id })` and the `activity`
broadcast — exactly like the PATCH handler, so open web clients see the claim
live.
7. **Do not** enqueue an `assignmentNotifications` row: claiming is a
self-assignment, and that table already excludes self-assignments by design
(see the comment on `assignmentNotifications` and the `assignee !== member.id`
guard in `items.ts`). Pinging yourself about a task you just claimed is noise.
8. Return `{ task: serializeSPTask(claimed, list, proj, origin) }` (fetch `list`
- `proj` as the PATCH handler does, or inside the tx).
**Status codes:** `200` claimed · `409` already assigned (to anyone, incl. you) ·
`404` unknown task / not a member / deleted · `401` bad token.
> **Design note — why claim is its own endpoint, not `PATCH …{ assignedTo }`.**
> Exposing arbitrary `assignedTo` over a PAT would let SP assign tasks to _other_
> members, which the integration's security model deliberately forbids (a PAT
> acts only as its own member). `claim` is the one safe, self-scoped assignment:
> it can only ever set `assignedTo = me`, and only from unassigned. This matches
> the asymmetry in `docs/plans/2026-06-02-super-productivity-integration.md`
> ("never let SP dictate assignment into a third party").
---
## 3. New endpoint 2 — List claimable (unassigned) tasks
```
GET /api/integration/claimable-tasks → { tasks: SPTask[] }
GET /api/integration/claimable-tasks?projectId=<uuid> (optional filter)
```
The read side of the claim pool: unassigned, not-done, not-deleted items in the
projects the caller is a member of. Same `SPTask` DTO as `/tasks`, so SP's mapper
is unchanged. Structurally identical to the existing `GET /tasks`, with the
assignment predicate inverted:
```ts
const scope = await loadIntegrationScope(emailLookup);
if (scope.memberRows.length === 0) return c.json({ tasks: [] });
const rows = await db.query.items.findMany({
where: and(
inArray(items.projectId, projectIdFilter ?? scope.projectIds),
isNull(items.assignedTo), // unclaimed
eq(items.checked, false), // not done
isNull(items.deletedAt),
),
});
// then the same list/project hydration + serializeSPTask loop as GET /tasks
```
- `?projectId=` (when present) **must** be intersected with `scope.projectIds`
so it can't be used to probe foreign projects. SP passes its bound
`spaceId` here to avoid over-fetching unclaimed tasks from unrelated Spaces.
- Visibility is membership-based and independent of `sharingMode`: members of a
`private` Space still see its unclaimed items (sharingMode gates _joining_,
not member visibility).
> **Cheaper alternative if you'd rather not add a path:** a query param on the
> existing endpoint — `GET /api/integration/tasks?scope=claimable` (default
> `scope=assigned`). The dedicated path keeps `/tasks` semantics crisp and reads
> better client-side; either is fine. (This is also the natural place the
> `?updatedSince=` param from §6 would live.)
---
## 4. New endpoint 3 — Create a space (Share on Plainspace)
```
POST /api/integration/spaces
Authorization: Bearer pat_…
Body: { name: string, purpose?: string, displayName?: string }
→ 201 { project: { id, slug, name, purpose, sharingMode }, memberId }
```
Provisions a new Space owned by the PAT's email and returns the ids SP binds its
provider to (`PlainspaceCfg.spaceId = project.id`, link via `project.url`).
Mirror the `POST /api/projects` transaction in `routes/projects.ts`, minus the
email-code gate.
**Logic**
1. Decrypt the PAT's email: `decryptStoredEmail(row)` is already done in
`apiTokenMiddleware`; expose it via `c.get('apiTokenEmail')` (the middleware
sets it). Normalize with `normalizeEmail`.
2. Validate body with a new `CreateSpaceViaTokenSchema` (zod, in
`packages/shared/src/validation.ts`):
`name 1..MAX_PROJECT_NAME_LENGTH`, `purpose ≤ MAX_PURPOSE_LENGTH default ''`,
`displayName 1..MAX_DISPLAY_NAME_LENGTH` (default to the email local-part if
omitted). `safeParse` failure → `422` with `details: error.flatten()`.
3. Transaction (mirrors `projects.ts`):
```ts
const slug = nanoid(SLUG_LENGTH);
const [project] = await tx
.insert(projects)
.values({ slug, name, purpose })
.returning();
const [member] = await tx
.insert(members)
.values({
projectId: project.id,
tokenHash: hashToken(nanoid(TOKEN_LENGTH)), // web-session token; unused by SP, see note
displayName,
...encryptedEmailFields(memberEmail),
emailVerified: true, // the PAT already proves email ownership
color: MEMBER_COLORS[0],
avatarIndex: 0,
isCreator: true,
role: 'admin',
tosVersion: TOS_VERSION,
tosAcceptedAt: new Date(),
})
.returning();
await ensureProjectDefaults(tx, { projectId: project.id, memberId: member.id });
```
4. Return `201 { project: serializeProject(project), memberId: member.id }`.
**Why no email-verification code here.** `POST /api/projects` gates creation on a
6-digit emailed code _or_ a `proofToken` from an existing Space (see
`resolveProofEmail` / `proofVerified`). A valid PAT is a strictly stronger proof
of the same email ownership — it was minted (`api-tokens.ts`) only after that
email was verified inside a Space. So `emailVerified: true` and skipping the code
is consistent with the existing `proofToken` shortcut, not a new trust
assumption.
**The new member's token.** SP does **not** need the returned web-session
`token`: because the new member shares the PAT's `emailLookup`, is
`emailVerified`, and carries the current `tosVersion`, `loadIntegrationScope`
**immediately** includes the new Space for the same PAT. So the existing PAT can
read/claim/patch in the new Space with no re-auth. Returning `memberId` is enough;
omit the session token (or return it for parity — your call).
**Rate limiting.** `POST /api/projects` is IP-rate-limited + code-gated. This
endpoint has neither, so add a per-email cap to stop a leaked PAT from mass-
creating Spaces — e.g. `checkRateLimit('create-space-token:' + apiTokenId, N,
window)` (reuse `lib/rate-limit.ts`). Suggest something conservative (e.g. 10 /
hour).
**Status codes:** `201` created · `422` validation · `429` rate-limited ·
`401` bad token.
---
## 4b. Scheduled time — expose `scheduledAt` / `isRecurring`, accept `scheduledAt`
SP syncs a task's **scheduled time** (`task.dueWithTime`) to a Plainspace item's
existing `remindAt` column. This is **not a new endpoint** — it extends the
`SPTask` DTO (read) and the existing `PATCH /tasks/:id` (write). No new tables;
`items.remindAt` + `items.repeat` and the whole reminder/repeat machinery already
exist. The DTO uses **SP-facing names** that map to those columns:
| DTO field (`SPTask`) | DB column | Meaning |
| ----------------------------- | ---------------------- | ---------------------------------------------- |
| `scheduledAt: string \| null` | `items.remindAt` | ISO instant the task is scheduled for, or null |
| `isRecurring: boolean` | `items.repeat != null` | whether it repeats (cadence stays server-side) |
### Read — add `scheduledAt` + `isRecurring` to `serializeSPTask`
```ts
return {
// …existing fields…
scheduledAt: item.remindAt ? item.remindAt.toISOString() : null,
isRecurring: item.repeat != null,
};
```
So `getMyTasks$`/`getById$`/`claimable-tasks` all carry them. `isRecurring` is the
yes/no flag SP needs to surface recurrence; the rule itself never crosses the wire.
### Write — accept `scheduledAt` on `PATCH /tasks/:id`
Today the integration PATCH only accepts `{ done: boolean }`. Widen its body
schema to also accept `scheduledAt` (mapped to the `remindAt` column):
```
PATCH /api/integration/tasks/:taskId
Body: { done?: boolean, scheduledAt?: string | null } // ISO instant, or null to unschedule
```
- Apply the **same `remindAt`/`repeat`/`anchor` invariants** the in-app PATCH uses
(`applyRepeatUpdate` in `items.ts`): clearing `scheduledAt` (→ `remindAt = null`)
cascades to `repeat:null`; re-scheduling a repeating item re-anchors the rule.
SP never sends a rule, so a `scheduledAt`-only PATCH on a repeating item hits
exactly the "re-anchor existing rule" branch.
- Still **member-scoped**: the PAT can only patch items in `scope.projectIds`,
and only ever its own caller's item — same guard as the done write-back. SP
setting `scheduledAt` is self-scoped scheduling, not assignment.
- Validation: reject a `scheduledAt` that isn't a valid ISO instant or `null`
(`422`).
### Recurrence — server stays authoritative, SP just tracks `scheduledAt`
Deliberately **no rule translation** between Plainspace `RepeatRule` and SP's
`TaskRepeatCfg` (different execution models — Plainspace = one persistent row the
sweep advances; SP = a template that spawns instances — and SP's recurrence is
mid-refactor). Instead:
- **Plainspace → SP:** a repeating item imports as a single ordinary SP task with
`dueWithTime = scheduledAt` (the next occurrence); `isRecurring` flags it. When
the sweep advances `remindAt`, SP's poll re-pulls the new `scheduledAt` and
reschedules the same task. SP needs zero knowledge of the rule.
- **SP → Plainspace:** SP only ever PATCHes a concrete `scheduledAt` (never a
rule). An SP-recurring task pushes each occurrence as a one-off; Plainspace
keeps owning any rule it created.
> Behavior to expect (not a bug): completing an imported **recurring** task in SP
> write-backs `done`, the sweep then advances + un-checks the item, and SP's next
> poll reopens it at the new time. Correct for a recurring item; the
> done-write-back ↔ scheduledAt-re-pull interaction wants an idempotency test.
### Client scope in PR #8424
The SP side imports `scheduledAt → dueWithTime` on task add (schedule shows in the
app, with `isRecurring` available to flag recurrence) and pushes
`dueWithTime → scheduledAt` (incl. `null` on unschedule), mirroring the done
write-back. The recurrence-tracking poll extension (re-pull an advanced
`scheduledAt`) is a documented follow-up. `dueDay` (date-only SP scheduling) is
intentionally **not** synced — `scheduledAt` always carries a time, so mapping a
day-only task would fabricate one.
---
## 5. Onboarding caveat (important for the SP "Share" UX)
A PAT can only be minted from **inside an existing Space**
(`POST /api/projects/:slug/auth/api-tokens` requires a logged-in, email-verified
member). Therefore:
- **Creating an _additional_ Space from SP** (the user already has a PAT) →
fully covered by §4. ✅
- **Creating a user's _first_ Space from SP** (no Space, no PAT yet) → **not**
possible with PAT-only, because there's nothing to mint a PAT from. This is the
chicken-and-egg the **device-code flow** in
`docs/plans/2026-06-02-super-productivity-integration.md` ("Auth: device-code,
not copy-paste") is meant to solve. Until that lands, SP should gate "Share on
Plainspace" behind "paste a PAT" (i.e. the user is already a Plainspace member)
and word the empty state accordingly.
Recommend scoping device-code as its own follow-up (the SP doc already proposes
the `POST /api/integration/device-code` + `…/device-token` pair). It is the
single biggest UX unlock but is independent of the three endpoints above.
---
## 6. Optional — efficient polling (`?updatedSince=`)
Already flagged in the SP-integration brainstorm under "Where this work lives".
Not required for correctness; worth it once many tasks sync.
- Add an `updatedAt timestamptz` column to `items` (`defaultNow()`, bumped on
every mutating write — check/uncheck, assign, edit, restore). One migration +
touching the existing item writes to set it.
- Accept `GET /api/integration/tasks?updatedSince=<ISO>``and(…, gt(items.updatedAt, since))`.
- Add `updatedAt` to the `SPTask` DTO so SP can store a high-water mark.
SP would then poll with its last-seen timestamp instead of refetching the full
assigned set each interval. Defer until the read volume justifies it.
---
## 7. Shared types to add (`packages/shared/src/types.ts`)
```ts
// response of POST /api/integration/tasks/:id/claim
export interface SPClaimTaskResponse {
task: SPTask;
}
// response of GET /api/integration/claimable-tasks (can reuse SPTasksResponse)
export type SPClaimableTasksResponse = SPTasksResponse;
// response of POST /api/integration/spaces
export interface SPCreateSpaceResponse {
project: Pick<Project, 'id' | 'slug' | 'name' | 'purpose' | 'sharingMode'>;
memberId: string;
}
```
And in `packages/shared/src/validation.ts`:
```ts
export const CreateSpaceViaTokenSchema = z.object({
name: z.string().min(1).max(MAX_PROJECT_NAME_LENGTH),
purpose: z.string().max(MAX_PURPOSE_LENGTH).default(''),
displayName: z.string().min(1).max(MAX_DISPLAY_NAME_LENGTH).optional(),
});
```
`SPTask` itself is unchanged (add `updatedAt` only if §6 is taken).
---
## 8. Tests (mirror `routes/integration.test.ts`)
`integration.test.ts` already has the harness (PAT minting + `app.request`).
Add, in the same style:
**claim**
- claims an unassigned task → `200`, row `assignedTo === myMember`, `item.assigned`
activity row written, SSE `item.updated` emitted.
- claiming an already-assigned task → `409`, row unchanged.
- claiming a task in a project I'm **not** a member of → `404` (isolation).
- two concurrent claims → exactly one `200`, one `409` (atomic-update race).
- self-assignment does **not** insert an `assignmentNotifications` row.
**claimable-tasks**
- returns only `assignedTo IS NULL AND checked = false AND deletedAt IS NULL`
within my projects; excludes mine/others'/done/deleted.
- `?projectId=` outside my scope returns `[]` (no foreign-project probe).
**create-space**
- `201`; `projects` + creator `members` + default `lists`/`scratchpads` rows
exist; **the same PAT** can immediately `GET /tasks` scoped to the new project.
- validation failure → `422`; over-limit → `429`.
---
## 9. How SP consumes each (so the contract is mutually legible)
All isolated in SP's `PlainspaceApiService` (one file) — see
`docs/plainspace-integration-plan.md`:
| SP method | Endpoint | Notes |
| --------------------------- | -------------------------------------------- | ----------------------------------------------- |
| `getMe$` / `testConnection` | `GET /me` | identity + space list |
| `getMyTasks$` | `GET /tasks` | client-filters `task.projectId === cfg.spaceId` |
| `getById$` / poll | `GET /tasks/:id` | freshness for imported tasks |
| done write-back | `PATCH /tasks/:id { done }` | on SP task complete/reopen |
| scheduled-time sync | `PATCH /tasks/:id { scheduledAt }` + read | `dueWithTime ↔ scheduledAt` (§4b) |
| `getUnclaimedTasks$` | `GET /claimable-tasks?projectId=cfg.spaceId` | claim pool feed |
| `claimTask$` | `POST /tasks/:id/claim` | then `addTaskFromIssue` imports it |
| `createSpace$` | `POST /spaces` | bind provider `spaceId = project.id` |
Two **client-side** fixes SP must make when going real (server unaffected, noting
for completeness): send `Authorization: Bearer <PAT>` on every call (the PAT
lives in `PlainspaceCfg`, not a mock account), and use `SPTask.url` directly for
"open in Plainspace" instead of constructing `…/spaces/:id/tasks/:id` (the real
link is `itemUrl` = `{origin}/{slug}/item/{id}`).
---
## 10. Out of scope (separate plans)
- **Device-code auth** (`/api/integration/device-code` + `/device-token`) — the
real onboarding fix; see §5 and the SP brainstorm doc.
- **SP → Plainspace promotion** (assign an SP task to someone → seed a Space +
invite) — the dominant flow in the product vision, larger than this PR's needs.
- **Assignee/“waiting-on” surfacing**, presence, comments, attachments.
- Per-occurrence reminders / repeat rules over the integration channel.
---
## 11. Summary — minimum to unblock SP PR #8424
1. `POST /api/integration/tasks/:taskId/claim` (§2) — **required** for the claim pool.
2. `GET /api/integration/claimable-tasks` (§3) — **required** for the claim pool.
3. `POST /api/integration/spaces` (§4) — **required** for "Share on Plainspace"
(additional Spaces; first-Space onboarding waits on device-code, §5).
4. `scheduledAt`/`isRecurring` on `serializeSPTask` + `scheduledAt` on
`PATCH /tasks/:id` (§4b) — **required** for scheduled-time sync. No new
endpoint/table; extends the read DTO + PATCH via the in-app `applyRepeatUpdate`
path (DTO `scheduledAt` ↔ db `remindAt`).
5. `?updatedSince=` + `items.updatedAt` (§6) — **optional**, polling efficiency.
All three required endpoints are ~1 handler each, reuse `apiTokenMiddleware` /
`loadIntegrationScope` / `serializeSPTask` / `recordActivity` / `sseManager`, and
add no new tables. Estimated surface: one new file or ~150 lines appended to
`routes/integration.ts`, a handful of shared-type lines, and the tests in §8.

View file

@ -0,0 +1,458 @@
# Plainspace Integration Plan
Integrating **Plainspace** (plainspace.org — repo `Johannesjo/spaces`) into Super
Productivity (SP) so that:
1. A project can be made **shared on Plainspace** directly from the project
create/edit dialog.
2. For such shared projects, SP shows — by **task ownership** (see the model in
§1):
- **My list** — tasks assigned to me, as regular, editable SP tasks.
- **A readonly claim pool** — unclaimed (unassigned) tasks you can **claim**;
claiming assigns the task to you in Plainspace and imports it as an SP task.
- Tasks **assigned to others are not represented in SP** at all.
> **Conceptual note (revised):** an earlier draft mirrored "assigned to others"
> into SP as a standing read-only list. We dropped that — SP is a personal focus
> tool, and a permanent wall of others' non-actionable tasks works against it and
> creates a stale second copy of the Plainspace board. The model is now: **only
> _mine_ + _unclaimed_ appear in SP; claiming is the bridge** that turns shared
> work into your work. See the conversation rationale captured in §7.
> Status: planning + prototype. The Plainspace HTTP API contract is not yet
> pinned down in this document (see [Open questions](#10-open-questions--blocking-decisions));
> the prototype is built against an **assumed contract** isolated behind a single
> API service so it can be corrected in one place once the real API is known.
>
> **Implemented today (mock-backed):**
>
> - §4 — the `PLAINSPACE` issue provider (`providers/plainspace/`): config form,
> `PlainspaceApiService` (mock mode via `PLAINSPACE_USE_MOCK`),
> `PlainspaceCommonInterfacesService` implementing `IssueServiceInterface`,
> registered in `issue.model.ts` / `issue.const.ts` / `issue.service.ts` +
> icon. **Only tasks assigned to me** import via the issue→backlog pipeline.
> - §5 — account / identity: `PlainspaceAccountService` (signals: `account`,
> `isLoggedIn`, `currentUserId`; localStorage-persisted, never synced) with a
> mock `login`/`logout`. "Mine" comes from the signed-in identity, and the
> share toggle prompts sign-in if needed.
> - §6 — the "Share on Plainspace" toggle in the create-project dialog, which
> (after sign-in) provisions a (mock) space and a bound provider via
> `PlainspaceShareService`.
> - §7 — the read-only **claim pool**: `PlainspaceClaimPoolService` feeds
> unclaimed tasks (mock) through `project-task-page``work-view` into a
> collapsed-by-default panel (`PlainspaceClaimPoolComponent`). A **Claim**
> action assigns the task to me and imports it as an SP task. Shows only for
> shared projects.
>
> **Still design-only:** §8 write-back, and the real HTTP API + real auth (all
> `PlainspaceApiService` calls, the login, and claim are mocked — see §10). The
> claim pool does not yet auto-poll (loads on project open / provider change /
> after a claim).
---
## 1. Guiding decisions (agreed)
| Decision | Choice |
| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| **Source of truth** for shared data | **Plainspace backend.** SP reads/writes shared tasks via Plainspace's API as a separate channel from SP's own oplog sync. |
| **Integration shape** | Model Plainspace as a **regular issue provider** (like Jira/Redmine) "for the most part". |
| **Identity** | **Plainspace account login** (token-based). The authenticated account defines "me" — which tasks are mine vs unclaimed. |
| **v1 scope** | Plan the full feature; build a working **prototype** (UI + provider scaffold against an assumed/mock API). |
### Why "issue provider" is the right host
SP already has a mature, well-factored issue-provider system (Jira, GitLab,
CalDAV, OpenProject, Trello, Redmine, Azure DevOps, Nextcloud Deck). It gives us,
for free:
- Per-provider config + Formly config form, stored in the issue-provider NgRx
store and bindable to a specific project via `defaultProjectId`.
- Search in the add-task bar, "add issue as task", attachments mapping.
- **Auto-import to backlog** (`getNewIssuesToAddToBacklog`) and **polling for
fresh data** (`getFreshDataForIssueTask`) with a configurable `pollInterval`.
- A clean single interface to implement: `IssueServiceInterface`.
This means "Plainspace issues assigned to me" flow through the **existing**
issue→task pipeline with almost no new core code. The only genuinely new surface
is the read-only **claim pool**, because that data is _not_ imported as SP tasks
until claimed.
### The one important nuance
The standard issue-provider flow turns issues **into** SP tasks. We only do that
for tasks **assigned to me**. Unclaimed tasks are _shown_ (a read-only pool) but
**not** auto-imported — claiming is a deliberate act that assigns the task to me
and then imports it. Tasks assigned to others are not represented in SP at all.
(Auto-importing _unclaimed_ work as if it were yours has the same problem as the
old others-list, just subtler: two members connecting the same space would both
"own" the same unclaimed task locally.) See §7 for the full ownership model.
---
## 2. Architecture overview
```
┌─────────────────────────────────────────────┐
│ Plainspace API │
│ spaces (projects) · tasks · members · auth │
└───────────────┬───────────────┬─────────────┘
│ │
(issue-provider channel) (shared-project channel)
│ │
┌────────────────────────────────▼──┐ ┌────────▼───────────────────────────┐
│ PlainspaceApiService (HTTP) │ │ PlainspaceAccountService (auth/me) │
│ PlainspaceCommonInterfacesService │ │ PlainspaceClaimPoolService │
│ implements IssueServiceInterface │ │ (unclaimed tasks + claim action) │
└───────────────┬────────────────────┘ └───────────────┬────────────────────┘
│ │
┌───────────────▼────────────────┐ ┌────────────────▼───────────────────┐
│ Existing issue→task pipeline │ │ Read-only claim-pool panel │
│ → real SP tasks (assigned to me)│ claim │ in work-view (unclaimed tasks) │
└─────────────────────────────────┘◄───────┴─────────────────────────────────────┘
│ │
└──────────────► Project work view ◄───────┘
(My list) (Claim pool)
```
- **Issue-provider channel** = the Jira-like path. Registers a `PLAINSPACE`
provider, bound per project via `defaultProjectId` (= the SP project the space
maps to). Imports **assigned-to-me** issues, polls them for freshness.
- **Shared-project channel** = the new bits: account login, the unclaimed claim
pool (+ claim → import), and creating a space when a project is shared.
SP's own op-log/vector-clock sync is **untouched**: shared data does not flow
through it. (Doing so would mean teaching the single-user op-log to carry
multi-user ops — explicitly rejected as too risky.)
---
## 3. Data model changes
### 3.1 New issue-provider config (`PlainspaceCfg`)
New folder `src/app/features/issue/providers/plainspace/`. Config interface (mirrors
`RedmineCfg`):
```ts
export interface PlainspaceCfg extends BaseIssueProviderCfg {
host: string | null; // plainspace.org or self-hosted base URL
spaceId: string | null; // the Plainspace "space" this provider is bound to
token?: string | null; // PAT (pat_…) authorizing this provider's API calls
}
```
> **Where the token lives (as built).** The PAT is stored on `PlainspaceCfg.token`
> and authorizes every `PlainspaceApiService` call — exactly like Jira's
> `password` or CalDAV's `password`, and like them it is part of synced
> issue-provider state and is included in plaintext backups/exports. This is a
> deliberate parity choice (a provider works on a fresh device after sync without
> re-pasting), and is the accepted secret-handling posture for issue providers.
> The account store (§3.3, local-only `localStorage`) holds a token **too**, but
> only to bootstrap the "Share on Plainspace" flow, which needs a token *before*
> any provider exists; the provider runtime reads only `cfg.token`. An earlier
> draft said the token was not stored in the cfg — that was never the case in the
> shipped code.
### 3.2 Plainspace issue/task shapes (assumed — single source to fix later)
```ts
// src/app/features/issue/providers/plainspace/plainspace-issue.model.ts
export interface PlainspaceMember {
id: string;
name: string;
avatarUrl?: string;
}
export interface PlainspaceIssue {
id: string;
title: string;
isDone: boolean;
assigneeId: string | null; // null = unassigned
assignee?: PlainspaceMember | null;
updatedAt: string; // ISO
url?: string;
// ...extend once the real API is known
}
```
### 3.3 Account / identity (new, small store)
```ts
// src/app/features/plainspace/plainspace-account.model.ts
export interface PlainspaceAccount {
host: string; // which plainspace instance
userId: string; // "me"
displayName: string;
token: string; // bearer token (stored like other provider creds)
}
```
Stored per SP profile alongside other credentials (same mechanism existing
providers use for secrets). One account → many spaces.
### 3.4 No change to the SP `Task` model in v1
- "Mine/unassigned" tasks are normal SP tasks; their Plainspace origin is already
captured by the existing `issueId` / `issueProviderId` / `issueType` fields.
- "Assigned to others" tasks are **not** SP tasks, so they need no `Task` field.
An `assignee` field on SP tasks is **explicitly deferred** (would touch the
hot-path task component and sync) — see [Future work](#11-future-work).
### 3.5 Project ↔ space link
The link is expressed entirely through the issue-provider instance:
`IssueProviderPlainspace.defaultProjectId` = SP project id, and
`PlainspaceCfg.spaceId` = remote space id. No new field on `Project` is strictly
required. (Optional convenience flag `Project.isSharedOnPlainspace` could be added
later for menu/badge rendering, but is not needed for correctness.)
---
## 4. Phase 1 — Plainspace issue provider scaffold
Goal: `PLAINSPACE` exists as a first-class issue provider; my/unassigned issues
import as tasks and poll. Pattern reference: **Redmine** (simplest built-in).
### 4.1 Central registration (4 edits)
- `src/app/features/issue/issue.model.ts`
- add `'PLAINSPACE'` to `BuiltInIssueProviderKey` + `BUILT_IN_KEYS`
- add `PlainspaceCfg` to `IssueIntegrationCfg` union and
`IssueIntegrationCfgs` map
- add issue type to `IssueData` / `IssueDataReduced` (+ `IssueDataReducedMap`)
- add `IssueProviderPlainspace extends IssueProviderBase, PlainspaceCfg`
(`issueProviderKey: 'PLAINSPACE'`) and add it to the `IssueProvider` union and
`IssueProviderTypeMap`.
- `src/app/features/issue/issue.const.ts`
- `PLAINSPACE_TYPE`, add to `ISSUE_PROVIDER_TYPES`,
`ISSUE_PROVIDER_ICON_MAP`, `ISSUE_PROVIDER_HUMANIZED`,
`DEFAULT_ISSUE_PROVIDER_CFGS`, `ISSUE_PROVIDER_FORM_CFGS_MAP`, `ISSUE_STR_MAP`.
- `src/app/features/issue/issue.service.ts`
- import + inject `PlainspaceCommonInterfacesService`, add to
`ISSUE_SERVICE_MAP`.
- Provider icon: add `src/assets/icons/plainspace.svg` **and** register it in
`GlobalThemeService` (`_initIcons()`, the `addSvgIcon(...)` block) — the
`ISSUE_PROVIDER_ICON_MAP` value only names the icon, it does not register it.
Note `ISSUE_PROVIDER_HUMANIZED` is a plain string ('Plainspace'), not a `T`
key, so no translation entry is needed for the provider name itself.
> Not strictly 4 files: adding `'PLAINSPACE'` to `BuiltInIssueProviderKey` also
> widens `IssueProviderKey`, so the existing `Task.issueType` field gains
> `'PLAINSPACE'` as a valid value. No new `Task` field, but it is a (safe,
> additive) type-surface change to be aware of.
### 4.2 New provider files (`providers/plainspace/`)
| File | Responsibility |
| ----------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| `plainspace.model.ts` | `PlainspaceCfg` |
| `plainspace-issue.model.ts` | `PlainspaceIssue`, `PlainspaceMember` |
| `plainspace.const.ts` | `DEFAULT_PLAINSPACE_CFG`, `PLAINSPACE_POLL_INTERVAL` |
| `plainspace-cfg-form.const.ts` | Formly config form + `..._CONFIG_FORM_SECTION` (host, advanced common fields) |
| `plainspace-api.service.ts` | All HTTP: `searchIssues$`, `getById$`, `getTasksForSpace$`, `getMembers$`, `createSpace$`, plus mock mode |
| `plainspace-common-interfaces.service.ts` | implements `IssueServiceInterface` (extends `BaseIssueProviderService`) |
| `plainspace-issue-map.util.ts` | `PlainspaceIssue → SearchResultItem` and `→ getAddTaskData` |
### 4.3 `IssueServiceInterface` implementation notes
- `isEnabled(cfg)``cfg.isEnabled && !!cfg.host && !!cfg.spaceId` and an account
token present.
- `getAddTaskData(issue)``{ title, isDone, issuePoints? }`. **Filter at the
source**: only my/unassigned issues are ever offered to this path (see 4.4).
- `getNewIssuesToAddToBacklog(providerId, existingIds)` → fetch space tasks
where `assigneeId === me || assigneeId == null`, minus `existingIds`.
- `getFreshDataForIssueTask(task)` → re-fetch by id, return `isDone`/title
changes (never overwrite user scheduling).
- `pollInterval``PLAINSPACE_POLL_INTERVAL` (e.g. 5 min). Reuses existing
`poll-issue-updates.effects.ts` and `poll-to-backlog.effects.ts`.
### 4.4 The mine/unassigned filter
Centralize in `plainspace-api.service` (`getMyAndUnassignedTasks$`) so both the
backlog import and the search path only ever see items that are valid to import.
"Assigned to others" is fetched by a sibling method and never reaches the issue
pipeline.
---
## 5. Phase 2 — Account login / identity
Goal: establish "me" so the assigned/unassigned split is meaningful.
- `src/app/features/plainspace/plainspace-account.service.ts` — login (token
exchange), store/clear account, expose `me$` (signal) and `currentUserId`.
- Login UI: a button in the Plainspace provider config form (`testConnection`
doubles as "verify login"), and/or a small dialog. Token persisted via the same
secret-storage path other providers use.
- `isEnabled` and the shared-tasks fetch both depend on a valid account; surface a
clear "not logged in" state.
Auth mechanism (token vs OAuth redirect) depends on what Plainspace exposes — see
[Open questions](#10-open-questions--blocking-decisions). If OAuth is required,
reuse the existing `src/app/plugins/oauth/` helpers.
---
## 6. Phase 3 — "Share on Plainspace" in the project create/edit dialog
Goal: a toggle in `dialog-create-project` that provisions a Plainspace space and
wires up the provider binding.
- **Form**: add an `isShareOnPlainspace` checkbox to
`CREATE_PROJECT_BASIC_CONFIG_FORM_CONFIG`
(`src/app/features/project/project-form-cfg.const.ts`). Gate it behind "account
logged in" (show a login affordance if not).
- **On submit** (in `dialog-create-project.component.ts`), after the project is
created via `projectService.add()`:
1. If `isShareOnPlainspace` and logged in → `PlainspaceApiService.createSpace$(
{ title })` → returns `spaceId`.
2. Create a `PLAINSPACE` issue-provider instance with
`{ host, spaceId, isEnabled: true, defaultProjectId: <newProjectId>,
isAutoAddToBacklog: true }` via the issue-provider store.
- **Edit mode**: same toggle reflects whether a bound Plainspace provider exists;
turning it on later provisions the space + provider; turning it off should
prompt (unlink vs delete remote) — keep v1 to **unlink only** (disable provider,
leave remote space intact) to avoid destructive surprises.
- **i18n**: new strings via `T`/`en.json` only.
---
## 7. Ownership model & the claim pool (revised)
**The conceptual question that reshaped this feature:** _which tasks from a
shared space should appear in a personal focus app at all?_ Answer — by
ownership state:
| Plainspace state | In SP? | Treatment |
| -------------------- | ------------------- | ------------------------------------------------------------- |
| Assigned to me | Yes — the point | First-class SP tasks: schedule, time-track, complete. |
| Unclaimed | As a pool, not list | Read-only "claim pool"; **Claim** → assign-to-self → SP task. |
| Assigned to others | No | Not represented in SP (Plainspace board is the team view). |
| Done (others/unass.) | No | Irrelevant to the individual. |
**Claiming is the bridge** between the collective space and the personal app:
the only way unclaimed work becomes yours. This avoids mirroring a stale,
non-actionable copy of other people's tasks into a focus tool. (We also skip
per-task assignee badges: with only _mine_ + _unclaimed_ shown, ownership is
implicit, and SP already shows a provider icon on issue-linked tasks.)
### 7.1 Read-only claim-pool component
`src/app/features/plainspace/claim-pool/claim-pool.component.ts`
- Input: `tasks: PlainspaceSharedTask[]` (unclaimed) + `projectId`.
- Flat read-only rows: title, "open in Plainspace" link, **Claim** button. No
drag/drop, scheduling, time tracking, or task-store interaction.
> Deliberately a **lightweight standalone component**, not the hot-path
> `task.component` — these are foreign tasks until claimed.
### 7.2 Data flow
- `PlainspaceClaimPoolService.unclaimedTasksForProject$(projectId)` finds the
bound enabled `PLAINSPACE` provider and returns its unclaimed tasks
(`assigneeId === null && !isDone`), refreshing after a claim.
- `claim(projectId, taskId)``PlainspaceApiService.claimTask$` (assign-to-me)
`IssueService.addTaskFromIssue(... isAddToBacklog)` → pool refreshes.
- `project-task-page` derives `unclaimedTasks` and passes it (+ `projectId`)
into `work-view`.
### 7.3 Layout
A `collapsible` section (mirrors overdue/done panels), **collapsed by default**
(it's a pool you reach for, not your active work); state persisted in
`localStorage` (`LS.PLAINSPACE_CLAIM_POOL_HIDDEN`).
---
## 8. Phase 5 — Polling, refresh & write-back
- **Reads**: reuse issue polling for my tasks; add a light timer in
`PlainspaceClaimPoolService` to refresh the claim pool (same interval), only
while the shared project is open. Today it refreshes on project open / provider
change / after a claim — no timer yet.
- **Writes (mine)**: completing/editing a _my_ imported task should optionally
push back to Plainspace via `updateIssueFromTask` (the optional interface hook).
Start **read-mostly**: import + status sync for done-state only; expand later.
- **Claim**: the one write already implemented — assign an unclaimed task to me
(`claimTask$`), then import it. Real mode would POST the assignment.
- **Offline**: all Plainspace calls must fail soft (empty lists, cached last
values) and never block the SP UI; SP remains fully usable offline.
---
## 9. Prototype scope (this iteration)
A runnable prototype that demonstrates the UX end-to-end against a **mock**
Plainspace backend (toggled by a flag in `PlainspaceApiService`), so it works with
no live server and is trivially swapped for the real API:
1. `PLAINSPACE` provider registered + config form (Phase 1 skeleton).
2. `PlainspaceApiService` with a **mock mode** returning canned spaces, members,
and tasks (mix of mine / unassigned / others).
3. "Share on Plainspace" toggle in the create dialog that, in mock mode, fakes
space creation and provisions the provider binding (Phase 3).
4. The **"Assigned to others"** read-only panel wired into the work view for
shared projects (Phase 4) — the visually novel part.
5. My/unassigned issues importing into the normal list via the issue pipeline.
Out of prototype scope: real auth handshake, write-back, attachments, subtasks,
production error/empty states polish.
---
## 10. Open questions / blocking decisions
These need answers (ideally the Plainspace API docs / the `Johannesjo/spaces`
repo, which I could not access from this environment) to move the prototype onto
the real backend:
1. **Auth**: token/API-key, or OAuth redirect flow? Endpoint(s)? How is "me"
(current user id) returned?
2. **Spaces API**: create space (`POST`?), list spaces, get members of a space.
3. **Tasks API**: list/get tasks for a space; fields available (esp. `assigneeId`,
done state, ordering); search; pagination.
4. **Write-back**: can SP create/update tasks and assignments? Required for the
"share" flow to push SP tasks up, vs. pull-only.
5. **Hosting**: is it always plainspace.org, or self-hostable (host field needed)?
6. **CORS/Electron**: does the API allow browser-origin calls, or must requests go
through the Electron main process (like some providers do)?
## 11. Future work
- Optional `Project.isSharedOnPlainspace` flag for menu badges.
- A real `assignee` concept on SP tasks (hot-path + sync implications — separate
design).
- Reassign-from-SP, presence/avatars, comments, two-way task creation.
## 12. Risks
- **Cross-client forward-compat (rollout-gating).** Adding `'PLAINSPACE'` to the
built-in `IssueProviderKey` union widens a **synced, typia-validated** type
(`issueProvider` state, and `task.issueType`). A client built before this change
has an AOT-baked validator that does not know the `'PLAINSPACE'` literal, so
when a newer client creates a Plainspace provider and syncs it, the older client
will reject the incoming model as corrupt (the documented "typia rejects unknown
union members" failure → false data-corruption dialog / rejected sync). This is
inherent to adding any new built-in provider key, and the mitigation is
**release sequencing**: the validator-aware build must reach the fleet **before**
any client can emit a `PLAINSPACE` provider. Do not back-port the ability to
create a Plainspace provider to a client that can't validate the key. (The
alternative — modelling Plainspace as a `plugin:`-style opaque key, which the
validator already accepts — is forward-compatible but is a larger change and is
the wrong shape for a built-in provider; revisit only if simultaneous rollout
can't be guaranteed.)
- **Sync correctness**: keep shared data **out** of the op-log; never route
Plainspace fetches through NgRx persisted actions. Imported "my" tasks follow
the existing, already-correct issue-task path.
- **Hot path**: the claim pool is a new lightweight component, not
`task.component`; verify against large lists.
- **API assumptions**: all isolated in `PlainspaceApiService` +
`plainspace-issue.model.ts` so the real contract changes one layer.
- **Privacy**: the PAT is stored in synced provider cfg like other provider
secrets (see §3.1); no analytics; log only ids (`Log.log({ id })`).
```
```

View file

@ -71,6 +71,9 @@ export enum LS {
LATER_TODAY_TASKS_HIDDEN = 'LATER_TODAY_TASKS_HIDDEN',
OVERDUE_TASKS_HIDDEN = 'OVERDUE_TASKS_HIDDEN',
REPEAT_CFGS_HIDDEN = 'REPEAT_CFGS_HIDDEN',
PLAINSPACE_CLAIM_POOL_HIDDEN = 'PLAINSPACE_CLAIM_POOL_HIDDEN',
// Plainspace account/identity — local-only, never synced (device identity).
PLAINSPACE_ACCOUNT = 'SUP_PLAINSPACE_ACCOUNT',
// Magic side nav
NAV_SIDEBAR_EXPANDED = 'SUP_NAV_SIDEBAR_EXPANDED',

View file

@ -205,6 +205,7 @@ export class GlobalThemeService {
['trello', 'assets/icons/trello.svg'],
['azure_devops', 'assets/icons/azure_devops.svg'],
['nextcloud_deck', 'assets/icons/nextcloud_deck.svg'],
['plainspace', 'assets/icons/plainspace.svg'],
];
// todo test if can be removed with airplane mode and wifi without internet

View file

@ -117,6 +117,14 @@
<mat-icon svgIcon="nextcloud_deck"></mat-icon>
<span>Nextcloud Deck</span>
</button>
<button
class="provider-tile focus-ring"
type="button"
(click)="openSetupDialog('PLAINSPACE')"
>
<mat-icon svgIcon="plainspace"></mat-icon>
<span>Plainspace</span>
</button>
@for (provider of pluginProviders; track provider.pluginId) {
<button
class="provider-tile focus-ring"

View file

@ -15,6 +15,7 @@ import { OPEN_PROJECT_ISSUE_CONTENT_CONFIG } from '../providers/open-project/ope
// Linear is now a plugin — content config lives in the plugin's issueDisplay
import { AZURE_DEVOPS_ISSUE_CONTENT_CONFIG } from '../providers/azure-devops/azure-devops-issue/azure-devops-issue-content.const';
import { NEXTCLOUD_DECK_ISSUE_CONTENT_CONFIG } from '../providers/nextcloud-deck/nextcloud-deck-issue-content.const';
import { PLAINSPACE_ISSUE_CONTENT_CONFIG } from '../providers/plainspace/plainspace-issue-content.const';
// Re-export types for backwards compatibility
export { IssueFieldType, IssueFieldConfig, IssueCommentConfig, IssueContentConfig };
@ -30,6 +31,7 @@ export const ISSUE_CONTENT_CONFIGS: Record<
OPEN_PROJECT: OPEN_PROJECT_ISSUE_CONTENT_CONFIG,
AZURE_DEVOPS: AZURE_DEVOPS_ISSUE_CONTENT_CONFIG,
NEXTCLOUD_DECK: NEXTCLOUD_DECK_ISSUE_CONTENT_CONFIG,
PLAINSPACE: PLAINSPACE_ISSUE_CONTENT_CONFIG,
ICAL: {
issueType: 'ICAL',
fields: [],

View file

@ -29,4 +29,8 @@
[task]="task()"
></caldav-time>
}
@case ('plainspace-recurring') {
<!-- Static badge; the field's label cell already names it "Recurring task". -->
<mat-icon>repeat</mat-icon>
}
}

View file

@ -5,13 +5,19 @@ import { OpenProjectAttachmentsComponent } from './open-project-attachments/open
import { JiraLinkComponent } from './jira-link/jira-link.component';
import { TaskCopy } from '../../../tasks/task.model';
import { CaldavTimeComponent } from './caldav-time/caldav-time.component';
import { MatIcon } from '@angular/material/icon';
@Component({
selector: 'issue-content-custom',
templateUrl: './issue-content-custom.component.html',
styleUrls: ['./issue-content-custom.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [OpenProjectAttachmentsComponent, JiraLinkComponent, CaldavTimeComponent],
imports: [
OpenProjectAttachmentsComponent,
JiraLinkComponent,
CaldavTimeComponent,
MatIcon,
],
})
export class IssueContentCustomComponent {
readonly field = input.required<IssueFieldConfig>();

View file

@ -34,6 +34,10 @@ import { AZURE_DEVOPS_INITIAL_CFG } from './providers/azure-devops/azure-devops.
import { DEFAULT_NEXTCLOUD_DECK_CFG } from './providers/nextcloud-deck/nextcloud-deck.const';
import { AZURE_DEVOPS_CONFIG_FORM_SECTION } from './providers/azure-devops/azure-devops-cfg-form.const';
import { NEXTCLOUD_DECK_CONFIG_FORM_SECTION } from './providers/nextcloud-deck/nextcloud-deck.const';
import {
DEFAULT_PLAINSPACE_CFG,
PLAINSPACE_CONFIG_FORM_SECTION,
} from './providers/plainspace/plainspace.const';
export const DELAY_BEFORE_ISSUE_POLLING = 8000;
@ -48,6 +52,7 @@ export const TRELLO_TYPE: MigratedIssueProviderKey = 'TRELLO';
export const CLICKUP_TYPE: MigratedIssueProviderKey = 'CLICKUP';
export const AZURE_DEVOPS_TYPE: BuiltInIssueProviderKey = 'AZURE_DEVOPS';
export const NEXTCLOUD_DECK_TYPE: BuiltInIssueProviderKey = 'NEXTCLOUD_DECK';
export const PLAINSPACE_TYPE: BuiltInIssueProviderKey = 'PLAINSPACE';
export const ISSUE_PROVIDER_TYPES: BuiltInIssueProviderKey[] = [
GITLAB_TYPE,
@ -58,6 +63,7 @@ export const ISSUE_PROVIDER_TYPES: BuiltInIssueProviderKey[] = [
REDMINE_TYPE,
AZURE_DEVOPS_TYPE,
NEXTCLOUD_DECK_TYPE,
PLAINSPACE_TYPE,
] as const;
export const ISSUE_PROVIDER_ICON_MAP = {
@ -69,6 +75,7 @@ export const ISSUE_PROVIDER_ICON_MAP = {
[REDMINE_TYPE]: 'redmine',
[AZURE_DEVOPS_TYPE]: 'azure_devops',
[NEXTCLOUD_DECK_TYPE]: 'nextcloud_deck',
[PLAINSPACE_TYPE]: 'plainspace',
} as const;
export const ISSUE_PROVIDER_HUMANIZED = {
@ -80,6 +87,7 @@ export const ISSUE_PROVIDER_HUMANIZED = {
[REDMINE_TYPE]: 'Redmine',
[AZURE_DEVOPS_TYPE]: 'Azure DevOps',
[NEXTCLOUD_DECK_TYPE]: 'Nextcloud Deck',
[PLAINSPACE_TYPE]: 'Plainspace',
} as const;
export const DEFAULT_ISSUE_PROVIDER_CFGS = {
@ -91,6 +99,7 @@ export const DEFAULT_ISSUE_PROVIDER_CFGS = {
[REDMINE_TYPE]: DEFAULT_REDMINE_CFG,
[AZURE_DEVOPS_TYPE]: AZURE_DEVOPS_INITIAL_CFG,
[NEXTCLOUD_DECK_TYPE]: DEFAULT_NEXTCLOUD_DECK_CFG,
[PLAINSPACE_TYPE]: DEFAULT_PLAINSPACE_CFG,
} as const;
export const ISSUE_PROVIDER_FORM_CFGS_MAP = {
@ -102,6 +111,7 @@ export const ISSUE_PROVIDER_FORM_CFGS_MAP = {
[REDMINE_TYPE]: REDMINE_CONFIG_FORM_SECTION,
[AZURE_DEVOPS_TYPE]: AZURE_DEVOPS_CONFIG_FORM_SECTION,
[NEXTCLOUD_DECK_TYPE]: NEXTCLOUD_DECK_CONFIG_FORM_SECTION,
[PLAINSPACE_TYPE]: PLAINSPACE_CONFIG_FORM_SECTION,
} as const;
export const DEFAULT_ISSUE_STRS: { ISSUE_STR: string; ISSUES_STR: string } = {
@ -127,6 +137,7 @@ export const ISSUE_STR_MAP: Record<
[REDMINE_TYPE]: DEFAULT_ISSUE_STRS,
[AZURE_DEVOPS_TYPE]: DEFAULT_ISSUE_STRS,
[NEXTCLOUD_DECK_TYPE]: DEFAULT_ISSUE_STRS,
[PLAINSPACE_TYPE]: DEFAULT_ISSUE_STRS,
} as const;
export const ISSUE_PROVIDER_DEFAULT_COMMON_CFG: Omit<

View file

@ -28,6 +28,8 @@ import {
NextcloudDeckIssue,
NextcloudDeckIssueReduced,
} from './providers/nextcloud-deck/nextcloud-deck-issue.model';
import { PlainspaceCfg } from './providers/plainspace/plainspace.model';
import { PlainspaceIssue } from './providers/plainspace/plainspace-issue.model';
import {
PluginIssue,
PluginSearchResult,
@ -46,7 +48,8 @@ export type BuiltInIssueProviderKey =
| 'OPEN_PROJECT'
| 'REDMINE'
| 'AZURE_DEVOPS'
| 'NEXTCLOUD_DECK';
| 'NEXTCLOUD_DECK'
| 'PLAINSPACE';
// Keys migrated from built-in to plugin — still valid as IssueProviderKey
export type MigratedIssueProviderKey =
@ -80,6 +83,7 @@ const BUILT_IN_KEYS: ReadonlySet<string> = new Set<BuiltInIssueProviderKey>([
'REDMINE',
'AZURE_DEVOPS',
'NEXTCLOUD_DECK',
'PLAINSPACE',
]);
const MIGRATED_KEYS: ReadonlySet<string> = new Set<MigratedIssueProviderKey>([
@ -102,7 +106,8 @@ export type IssueIntegrationCfg =
| OpenProjectCfg
| RedmineCfg
| AzureDevOpsCfg
| NextcloudDeckCfg;
| NextcloudDeckCfg
| PlainspaceCfg;
export enum IssueLocalState {
OPEN = 'OPEN',
@ -120,6 +125,7 @@ export interface IssueIntegrationCfgs {
REDMINE?: RedmineCfg;
AZURE_DEVOPS?: AzureDevOpsCfg;
NEXTCLOUD_DECK?: NextcloudDeckCfg;
PLAINSPACE?: PlainspaceCfg;
}
export type IssueData =
@ -131,6 +137,7 @@ export type IssueData =
| RedmineIssue
| AzureDevOpsIssue
| NextcloudDeckIssue
| PlainspaceIssue
| PluginIssue;
export type IssueDataReduced =
@ -142,6 +149,7 @@ export type IssueDataReduced =
| RedmineIssue
| AzureDevOpsIssueReduced
| NextcloudDeckIssueReduced
| PlainspaceIssue
| PluginSearchResult;
export type IssueDataReducedMap = {
@ -161,11 +169,13 @@ export type IssueDataReducedMap = {
? AzureDevOpsIssueReduced
: K extends 'NEXTCLOUD_DECK'
? NextcloudDeckIssueReduced
: K extends MigratedIssueProviderKey
? PluginSearchResult
: K extends PluginIssueProviderKey
: K extends 'PLAINSPACE'
? PlainspaceIssue
: K extends MigratedIssueProviderKey
? PluginSearchResult
: never;
: K extends PluginIssueProviderKey
? PluginSearchResult
: never;
};
// TODO: add issue model to the IssueDataReducedMap
@ -267,6 +277,10 @@ export interface IssueProviderNextcloudDeck extends IssueProviderBase, Nextcloud
issueProviderKey: 'NEXTCLOUD_DECK';
}
export interface IssueProviderPlainspace extends IssueProviderBase, PlainspaceCfg {
issueProviderKey: 'PLAINSPACE';
}
export interface IssueProviderPluginType extends IssueProviderBase {
issueProviderKey: PluginIssueProviderKey | MigratedIssueProviderKey;
pluginId: string;
@ -286,6 +300,7 @@ export type IssueProvider =
| IssueProviderLinear
| IssueProviderAzureDevOps
| IssueProviderNextcloudDeck
| IssueProviderPlainspace
| IssueProviderPluginType;
export type IssueProviderTypeMap<T extends IssueProviderKey> = T extends 'JIRA'
@ -312,8 +327,10 @@ export type IssueProviderTypeMap<T extends IssueProviderKey> = T extends 'JIRA'
? IssueProviderAzureDevOps
: T extends 'NEXTCLOUD_DECK'
? IssueProviderNextcloudDeck
: T extends PluginIssueProviderKey
? IssueProviderPluginType
: T extends MigratedIssueProviderKey
: T extends 'PLAINSPACE'
? IssueProviderPlainspace
: T extends PluginIssueProviderKey
? IssueProviderPluginType
: never;
: T extends MigratedIssueProviderKey
? IssueProviderPluginType
: never;

View file

@ -26,6 +26,7 @@ import {
REDMINE_TYPE,
AZURE_DEVOPS_TYPE,
NEXTCLOUD_DECK_TYPE,
PLAINSPACE_TYPE,
} from './issue.const';
import { TaskService } from '../tasks/task.service';
import { IssueTask, Task, TaskCopy } from '../tasks/task.model';
@ -43,6 +44,7 @@ import { RedmineCommonInterfacesService } from './providers/redmine/redmine-comm
// ClickUp is now a plugin — no built-in service needed
import { AzureDevOpsCommonInterfacesService } from './providers/azure-devops/azure-devops-common-interfaces.service';
import { NextcloudDeckCommonInterfacesService } from './providers/nextcloud-deck/nextcloud-deck-common-interfaces.service';
import { PlainspaceCommonInterfacesService } from './providers/plainspace/plainspace-common-interfaces.service';
import { SnackService } from '../../core/snack/snack.service';
import { T } from '../../t.const';
import { TranslateService } from '@ngx-translate/core';
@ -80,6 +82,7 @@ export class IssueService {
private _nextcloudDeckCommonInterfaceService = inject(
NextcloudDeckCommonInterfacesService,
);
private _plainspaceCommonInterfaceService = inject(PlainspaceCommonInterfacesService);
private _calendarCommonInterfaceService = inject(CalendarCommonInterfacesService);
private _issueProviderService = inject(IssueProviderService);
private _workContextService = inject(WorkContextService);
@ -102,6 +105,7 @@ export class IssueService {
[ICAL_TYPE]: this._calendarCommonInterfaceService,
[AZURE_DEVOPS_TYPE]: this._azureDevOpsCommonInterfaceService,
[NEXTCLOUD_DECK_TYPE]: this._nextcloudDeckCommonInterfaceService,
[PLAINSPACE_TYPE]: this._plainspaceCommonInterfaceService,
};
ISSUE_REFRESH_MAP: {
@ -654,7 +658,7 @@ export class IssueService {
const subTaskData = this._getAddTaskData(issueProviderKey, subtask);
const { title: subTaskTitle, ...subTaskAdditional } = subTaskData;
await this._taskService.addSubTaskTo(parentTaskId, {
this._taskService.addSubTaskTo(parentTaskId, {
title: subTaskTitle,
issueType: issueProviderKey,
issueProviderId: issueProviderId,
@ -703,7 +707,7 @@ export class IssueService {
// sub-task (has a parentId), attach to its root parent so the new task
// becomes a sibling of the parent rather than a grandchild.
const effectiveParentId = parentTask.task.parentId || parentTask.task.id;
const taskId = await this._taskService.addSubTaskTo(effectiveParentId, subTaskData);
const taskId = this._taskService.addSubTaskTo(effectiveParentId, subTaskData);
return { taskId, parentTaskId: effectiveParentId };
}

View file

@ -69,6 +69,8 @@ export const getIssueProviderTooltip = (issueProvider: IssueProvider): string =>
: undefined;
case 'AZURE_DEVOPS':
return issueProvider.project || undefined;
case 'PLAINSPACE':
return issueProvider.spaceId || undefined;
default:
return undefined;
}
@ -139,5 +141,7 @@ export const getIssueProviderInitials = (
return issueProvider.selectedBoardTitle?.substring(0, 2)?.toUpperCase();
case 'AZURE_DEVOPS':
return issueProvider.project?.substring(0, 2)?.toUpperCase() || 'AD';
case 'PLAINSPACE':
return issueProvider.spaceId?.substring(0, 2)?.toUpperCase() || 'PS';
}
};

View file

@ -0,0 +1,189 @@
import {
HttpClientTestingModule,
HttpTestingController,
} from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { firstValueFrom } from 'rxjs';
import { PlainspaceApiService } from './plainspace-api.service';
import { PlainspaceCfg } from './plainspace.model';
import { DEFAULT_PLAINSPACE_CFG } from './plainspace-cfg-form.const';
// Covers the real Plainspace integration API: PAT auth, the SPTask -> internal
// PlainspaceIssue mapping, per-space scoping, claim/create, and fail-soft reads.
describe('PlainspaceApiService', () => {
let service: PlainspaceApiService;
let httpMock: HttpTestingController;
const cfg: PlainspaceCfg = {
...DEFAULT_PLAINSPACE_CFG,
host: 'https://plainspace.org',
spaceId: 'space-1',
token: 'pat_test',
};
const BASE = 'https://plainspace.org/api/integration';
const spTask = (
id: string,
projectId: string,
done = false,
scheduledAt: string | null = null,
isRecurring = false,
): SPTaskLike => ({
id,
title: `Task ${id}`,
done,
projectId,
projectName: 'P',
projectSlug: 'p',
listId: 'l',
url: `https://plainspace.org/p/item/${id}`,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-02T00:00:00.000Z',
scheduledAt,
isRecurring,
});
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [PlainspaceApiService],
});
service = TestBed.inject(PlainspaceApiService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('getMyTasks$ sends the PAT, keeps only this space, and maps to PlainspaceIssue', async () => {
const p = firstValueFrom(service.getMyTasks$(cfg));
const req = httpMock.expectOne(`${BASE}/tasks`);
expect(req.request.headers.get('Authorization')).toBe('Bearer pat_test');
req.flush({ tasks: [spTask('a', 'space-1', true), spTask('b', 'other')] });
const tasks = await p;
expect(tasks.map((t) => t.id)).toEqual(['a']);
expect(tasks[0].isDone).toBe(true);
expect(tasks[0].url).toBe('https://plainspace.org/p/item/a');
});
it('getUnclaimedTasks$ fetches the claim pool and keeps only this space', async () => {
const p = firstValueFrom(service.getUnclaimedTasks$(cfg));
const req = httpMock.expectOne(`${BASE}/claimable-tasks`);
req.flush({ tasks: [spTask('u1', 'space-1'), spTask('u2', 'other')] });
expect((await p).map((t) => t.id)).toEqual(['u1']);
});
it('getMyTasks$ matches the bound space by slug, not just the id', async () => {
const slugCfg: PlainspaceCfg = { ...cfg, spaceId: 'my-slug' };
const p = firstValueFrom(service.getMyTasks$(slugCfg));
const req = httpMock.expectOne(`${BASE}/tasks`);
req.flush({
tasks: [
{ ...spTask('a', 'uuid-1'), projectSlug: 'my-slug' },
{ ...spTask('b', 'uuid-2'), projectSlug: 'other-slug' },
],
});
expect((await p).map((t) => t.id)).toEqual(['a']);
});
it('claimTask$ POSTs to the claim endpoint and maps the task', async () => {
const p = firstValueFrom(service.claimTask$('u1', cfg));
const req = httpMock.expectOne(`${BASE}/tasks/u1/claim`);
expect(req.request.method).toBe('POST');
req.flush({ task: spTask('u1', 'space-1') });
expect((await p)?.id).toBe('u1');
});
it('getById$ returns null on 404', async () => {
const p = firstValueFrom(service.getById$('missing', cfg));
httpMock
.expectOne(`${BASE}/tasks/missing`)
.flush({ error: 'Task not found' }, { status: 404, statusText: 'Not Found' });
expect(await p).toBeNull();
});
it('patchTask$ PATCHes the given fields and maps scheduledAt back', async () => {
const p = firstValueFrom(
service.patchTask$(
'a',
{ done: true, scheduledAt: '2026-01-02T09:00:00.000Z' },
cfg,
),
);
const req = httpMock.expectOne(`${BASE}/tasks/a`);
expect(req.request.method).toBe('PATCH');
expect(req.request.body).toEqual({
done: true,
scheduledAt: '2026-01-02T09:00:00.000Z',
});
req.flush({ task: spTask('a', 'space-1', true, '2026-01-02T09:00:00.000Z') });
const issue = await p;
expect(issue?.isDone).toBe(true);
expect(issue?.scheduledAt).toBe('2026-01-02T09:00:00.000Z');
});
it('getSpaces$ maps the account spaces from /me', async () => {
const p = firstValueFrom(service.getSpaces$(cfg));
const req = httpMock.expectOne(`${BASE}/me`);
req.flush({
email: 'a@b.c',
projects: [
{ id: 'p1', name: 'One', slug: 'one', memberDisplayName: 'Me', role: 'admin' },
{ id: 'p2', name: 'Two', slug: 'two', memberDisplayName: 'Me', role: 'member' },
],
});
expect(await p).toEqual([
{ id: 'p1', name: 'One', slug: 'one' },
{ id: 'p2', name: 'Two', slug: 'two' },
]);
});
it('getSpaces$ returns null on error (so callers can tell error from empty)', async () => {
const p = firstValueFrom(service.getSpaces$(cfg));
httpMock
.expectOne(`${BASE}/me`)
.flush('boom', { status: 500, statusText: 'Server Error' });
expect(await p).toBeNull();
});
it('createSpace$ returns the new project id', async () => {
const p = firstValueFrom(service.createSpace$('My Space', cfg));
const req = httpMock.expectOne(`${BASE}/spaces`);
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual({ name: 'My Space' });
req.flush({ project: { id: 'proj-new' }, url: 'x', memberId: 'm' });
expect((await p).id).toBe('proj-new');
});
it('searchIssues$ filters my tasks by title', async () => {
const p = firstValueFrom(service.searchIssues$('task a', cfg));
httpMock
.expectOne(`${BASE}/tasks`)
.flush({ tasks: [spTask('a', 'space-1'), spTask('b', 'space-1')] });
const res = await p;
expect(res.length).toBe(1);
expect(res[0].issueType).toBe('PLAINSPACE');
});
it('reads fail soft to [] on a network error', async () => {
const p = firstValueFrom(service.getMyTasks$(cfg));
httpMock
.expectOne(`${BASE}/tasks`)
.flush('boom', { status: 500, statusText: 'Server Error' });
expect(await p).toEqual([]);
});
});
interface SPTaskLike {
id: string;
title: string;
done: boolean;
projectId: string;
projectName: string;
projectSlug: string;
listId: string;
url: string;
createdAt: string;
updatedAt: string;
scheduledAt: string | null;
isRecurring: boolean;
}

View file

@ -0,0 +1,234 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Observable, of } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { SearchResultItem } from '../../issue.model';
import { PlainspaceCfg } from './plainspace.model';
import { PlainspaceIssue } from './plainspace-issue.model';
import { mapPlainspaceIssueToSearchResult } from './plainspace-issue-map.util';
/**
* HTTP access to the real Plainspace integration API (plainspace.org /
* `Johannesjo/spaces`). Every endpoint lives under `{host}/api/integration` and
* is authorized with the provider's personal API token (`Authorization: Bearer
* pat_`). The server already scopes `/tasks` to the caller, so "mine" is decided
* server-side no client-side identity filtering is needed.
*
* The wire format (`SPTask`) is mapped to the provider-internal `PlainspaceIssue`
* here, keeping the real contract isolated to this file. See
* docs/plainspace-api-extension-plan.md for the endpoint contract.
*
* Reads fail soft (empty list / null) so a Plainspace outage never blocks the SP
* UI; `createSpace$` lets errors propagate so the share flow can report them.
*/
@Injectable({ providedIn: 'root' })
export class PlainspaceApiService {
private _http = inject(HttpClient);
/** Verifies the token and returns the account's email + spaces, or null. */
getMe$(cfg: PlainspaceCfg): Observable<SPMeResponse | null> {
return this._http
.get<SPMeResponse>(`${this._base(cfg)}/me`, { headers: this._headers(cfg) })
.pipe(catchError(() => of(null)));
}
/**
* The spaces (Plainspace projects) the token can access used to let the user
* link an existing space instead of creating a new one. Returns `null` when the
* request fails (offline / invalid token) so callers can tell a genuine "no
* spaces yet" (empty list) apart from an error.
*/
getSpaces$(cfg: PlainspaceCfg): Observable<PlainspaceSpace[] | null> {
return this.getMe$(cfg).pipe(
map((me) =>
me ? me.projects.map((p) => ({ id: p.id, name: p.name, slug: p.slug })) : null,
),
);
}
/** Tasks assigned to me in this provider's space — imported as SP tasks. */
getMyTasks$(cfg: PlainspaceCfg): Observable<PlainspaceIssue[]> {
return this._http
.get<SPTasksResponse>(`${this._base(cfg)}/tasks`, { headers: this._headers(cfg) })
.pipe(
// /tasks spans all my spaces; keep only this provider's space.
map((res) =>
(Array.isArray(res?.tasks) ? res.tasks : [])
.filter((t) => matchesSpace(t, cfg.spaceId))
.map(mapSPTaskToIssue),
),
catchError(() => of([])),
);
}
/** Unclaimed (unassigned, not-done) tasks in this space — the claim pool. */
getUnclaimedTasks$(cfg: PlainspaceCfg): Observable<PlainspaceIssue[]> {
// Filter to the bound space client-side so `spaceId` works whether it holds
// the project UUID or the slug (the server `?projectId=` param only accepts
// the UUID).
return this._http
.get<SPTasksResponse>(`${this._base(cfg)}/claimable-tasks`, {
headers: this._headers(cfg),
})
.pipe(
map((res) =>
(Array.isArray(res?.tasks) ? res.tasks : [])
.filter((t) => matchesSpace(t, cfg.spaceId))
.map(mapSPTaskToIssue),
),
catchError(() => of([])),
);
}
getById$(id: string, cfg: PlainspaceCfg): Observable<PlainspaceIssue | null> {
return this._http
.get<SPTaskResponse>(`${this._base(cfg)}/tasks/${encodeURIComponent(id)}`, {
headers: this._headers(cfg),
})
.pipe(
map((res) => mapSPTaskToIssue(res.task)),
catchError(() => of(null)),
);
}
/**
* Self-assigns ("claims") an unclaimed task. Returns the claimed task, or null
* if it could not be claimed (already taken, offline, ).
*/
claimTask$(id: string, cfg: PlainspaceCfg): Observable<PlainspaceIssue | null> {
return this._http
.post<SPTaskResponse>(
`${this._base(cfg)}/tasks/${encodeURIComponent(id)}/claim`,
{},
{ headers: this._headers(cfg) },
)
.pipe(
map((res) => mapSPTaskToIssue(res.task)),
catchError(() => of(null)),
);
}
/**
* Pushes a field change back to Plainspace done state, title, and/or
* scheduled time (`scheduledAt`) in a single PATCH; null on failure.
* `scheduledAt` is an ISO instant, or null to unschedule. Used by the
* two-way-sync adapter.
*/
patchTask$(
id: string,
fields: { done?: boolean; title?: string; scheduledAt?: string | null },
cfg: PlainspaceCfg,
): Observable<PlainspaceIssue | null> {
return this._http
.patch<SPTaskResponse>(
`${this._base(cfg)}/tasks/${encodeURIComponent(id)}`,
fields,
{ headers: this._headers(cfg) },
)
.pipe(
map((res) => mapSPTaskToIssue(res.task)),
catchError(() => of(null)),
);
}
/** Creates a remote space and returns its id (used by the share flow). */
createSpace$(title: string, cfg: PlainspaceCfg): Observable<{ id: string }> {
return this._http
.post<SPCreateSpaceResponse>(
`${this._base(cfg)}/spaces`,
{ name: title },
{ headers: this._headers(cfg) },
)
.pipe(map((res) => ({ id: res.project.id })));
}
searchIssues$(query: string, cfg: PlainspaceCfg): Observable<SearchResultItem[]> {
const term = query.trim().toLowerCase();
return this.getMyTasks$(cfg).pipe(
map((issues) =>
issues
.filter((issue) => !term || issue.title.toLowerCase().includes(term))
.map((issue) => mapPlainspaceIssueToSearchResult(issue)),
),
);
}
private _base(cfg: PlainspaceCfg): string {
return `${cfg.host}/api/integration`;
}
private _headers(cfg: PlainspaceCfg): HttpHeaders {
return new HttpHeaders({ Authorization: `Bearer ${cfg.token ?? ''}` });
}
}
/** A Plainspace space (project) the connected account can bind a provider to. */
export interface PlainspaceSpace {
id: string;
name: string;
slug: string;
}
/** The Plainspace integration task DTO (`GET /api/integration/tasks`). */
interface SPTask {
id: string;
title: string;
done: boolean;
projectId: string;
projectName: string;
projectSlug: string;
listId: string;
url: string;
createdAt: string;
updatedAt: string;
// ISO instant the task is scheduled for, or null when unscheduled. For
// recurring items this is the next occurrence (server-advanced). See
// docs/plainspace-api-extension-plan.md §scheduling.
scheduledAt: string | null;
// Whether the task repeats in Plainspace (the cadence stays server-side).
isRecurring: boolean;
}
interface SPTaskResponse {
task: SPTask;
}
interface SPTasksResponse {
tasks: SPTask[];
}
interface SPCreateSpaceResponse {
project: { id: string };
}
interface SPMeResponse {
email: string;
projects: {
id: string;
name: string;
slug: string;
memberDisplayName: string;
role: string;
}[];
}
// `cfg.spaceId` may hold either the Plainspace project UUID or its slug (what
// users see in the space URL, e.g. plainspace.org/<slug>/…), so match both.
const matchesSpace = (t: SPTask, spaceId: string | null | undefined): boolean =>
!spaceId || t.projectId === spaceId || t.projectSlug === spaceId;
const mapSPTaskToIssue = (t: SPTask): PlainspaceIssue => ({
id: t.id,
title: t.title,
isDone: t.done,
updatedAt: t.updatedAt,
url: t.url,
projectId: t.projectId,
// Normalize to a canonical UTC ISO instant on read. The two-way-sync baseline
// and push both compare `scheduledAt` by exact string, and the push side emits
// `new Date(ms).toISOString()` — so an equivalent-but-differently-formatted
// server value (offset vs Z, ms precision) would otherwise read as a remote
// change and silently drop the user's reschedule.
scheduledAt: t.scheduledAt ? new Date(t.scheduledAt).toISOString() : null,
isRecurring: !!t.isRecurring,
});

View file

@ -0,0 +1,64 @@
import { T } from '../../../../t.const';
import {
ConfigFormSection,
LimitedFormlyFieldConfig,
} from '../../../config/global-config.model';
import { IssueProviderPlainspace } from '../../issue.model';
import { ISSUE_PROVIDER_COMMON_FORM_FIELDS } from '../../common-issue-form-stuff.const';
import { PlainspaceCfg } from './plainspace.model';
export const DEFAULT_PLAINSPACE_CFG: PlainspaceCfg = {
isEnabled: false,
host: 'https://plainspace.org',
spaceId: null,
token: null,
isAutoPoll: true,
// Tasks assigned to me auto-import into the bound project's backlog, and the
// poll keeps them in sync — Plainspace is meant to feel automatic.
isAutoAddToBacklog: true,
};
export const PLAINSPACE_CONFIG_FORM: LimitedFormlyFieldConfig<IssueProviderPlainspace>[] =
[
{
key: 'host',
type: 'input',
templateOptions: {
label: T.PLAINSPACE.FORM.HOST,
type: 'url',
required: true,
},
},
{
key: 'spaceId',
type: 'input',
templateOptions: {
label: T.PLAINSPACE.FORM.SPACE_ID,
required: false,
description: T.PLAINSPACE.FORM.SPACE_ID_DESCRIPTION,
},
},
{
key: 'token',
type: 'input',
templateOptions: {
label: T.PLAINSPACE.FORM.TOKEN,
type: 'password',
required: true,
description: T.PLAINSPACE.FORM.TOKEN_DESCRIPTION,
},
},
{
type: 'collapsible',
props: { label: T.G.ADVANCED_CFG },
fieldGroup: [...ISSUE_PROVIDER_COMMON_FORM_FIELDS],
},
];
export const PLAINSPACE_CONFIG_FORM_SECTION: ConfigFormSection<IssueProviderPlainspace> =
{
title: T.PLAINSPACE.FORM_SECTION.TITLE,
key: 'PLAINSPACE',
items: PLAINSPACE_CONFIG_FORM,
help: T.PLAINSPACE.FORM_SECTION.HELP,
};

View file

@ -0,0 +1,151 @@
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { PlainspaceCommonInterfacesService } from './plainspace-common-interfaces.service';
import { PlainspaceApiService } from './plainspace-api.service';
import { PlainspaceIssue } from './plainspace-issue.model';
import { IssueProviderService } from '../../issue-provider.service';
import { Task } from '../../../tasks/task.model';
describe('PlainspaceCommonInterfacesService', () => {
let service: PlainspaceCommonInterfacesService;
let api: PlainspaceApiService;
const issue = (scheduledAt: string | null, isDone = false): PlainspaceIssue => ({
id: 't1',
title: 'Buy milk',
isDone,
updatedAt: '2026-01-02T00:00:00.000Z',
url: 'https://plainspace.org/p/item/t1',
projectId: 'space-1',
scheduledAt,
isRecurring: false,
});
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
PlainspaceCommonInterfacesService,
// extractSyncValues is pure, so the API stub is never called here.
{ provide: PlainspaceApiService, useValue: {} },
{ provide: IssueProviderService, useValue: {} },
],
});
service = TestBed.inject(PlainspaceCommonInterfacesService);
api = TestBed.inject(PlainspaceApiService);
});
// Without a seeded baseline, computePushDecisions skips every push as
// 'no-baseline', so the done + scheduled-time write-back never fires.
it('getAddTaskData seeds the two-way-sync baseline (done + scheduledAt)', () => {
const data = service.getAddTaskData(issue('2026-01-02T09:00:00.000Z', true));
expect(data.title).toBe('Buy milk');
expect(data.isDone).toBe(true);
expect(data.issueLastSyncedValues).toEqual({
isDone: true,
title: 'Buy milk',
scheduledAt: '2026-01-02T09:00:00.000Z',
});
});
it('getAddTaskData baseline carries a null scheduledAt for unscheduled tasks', () => {
const data = service.getAddTaskData(issue(null));
expect(data.issueLastSyncedValues).toEqual({
isDone: false,
title: 'Buy milk',
scheduledAt: null,
});
});
it('getAddTaskData imports scheduledAt as dueWithTime (schedule shows in the app)', () => {
const iso = '2026-01-02T09:00:00.000Z';
const data = service.getAddTaskData(issue(iso));
expect(data.dueWithTime).toBe(new Date(iso).getTime());
});
it('getAddTaskData leaves dueWithTime unset for unscheduled tasks', () => {
const data = service.getAddTaskData(issue(null));
expect('dueWithTime' in data).toBe(false);
});
describe('getFreshDataForIssueTask (poll pulls scheduledAt → dueWithTime)', () => {
const stubCfg = (): void => {
spyOn(
service as unknown as { _getCfgOnce$: (id: string) => unknown },
'_getCfgOnce$',
).and.returnValue(of({}));
};
const setRemote = (i: PlainspaceIssue): void => {
(api as unknown as { getById$: () => unknown }).getById$ = () => of(i);
};
it('pulls dueWithTime when the remote task changed', async () => {
setRemote(issue('2026-01-02T09:00:00.000Z'));
stubCfg();
const task = { issueProviderId: 'p1', issueId: 't1', issueLastUpdated: 0 } as Task;
const res = await service.getFreshDataForIssueTask(task);
expect(res?.taskChanges.dueWithTime).toBe(
new Date('2026-01-02T09:00:00.000Z').getTime(),
);
expect(res?.taskChanges.issueWasUpdated).toBe(true);
});
it('clears dueWithTime when the remote task was unscheduled', async () => {
setRemote(issue(null));
stubCfg();
const task = { issueProviderId: 'p1', issueId: 't1', issueLastUpdated: 0 } as Task;
const res = await service.getFreshDataForIssueTask(task);
expect(res?.taskChanges.dueWithTime).toBeUndefined();
});
it('returns null when the remote task is unchanged', async () => {
setRemote(issue('2026-01-02T09:00:00.000Z'));
stubCfg();
const task = {
issueProviderId: 'p1',
issueId: 't1',
issueLastUpdated: new Date('2026-01-02T00:00:00.000Z').getTime(),
} as Task;
expect(await service.getFreshDataForIssueTask(task)).toBeNull();
});
});
describe('getFreshDataForIssueTasks (bulk poll = one fetch per provider)', () => {
it('fetches all tasks once via getMyTasks$, not one getById per task', async () => {
spyOn(
service as unknown as { _getCfgOnce$: (id: string) => unknown },
'_getCfgOnce$',
).and.returnValue(of({}));
const t1 = { ...issue('2026-01-02T09:00:00.000Z'), id: 't1' };
const t2 = { ...issue('2026-01-03T09:00:00.000Z'), id: 't2' };
const getMyTasks$ = jasmine.createSpy('getMyTasks$').and.returnValue(of([t1, t2]));
const getById$ = jasmine.createSpy('getById$');
(api as unknown as { getMyTasks$: unknown }).getMyTasks$ = getMyTasks$;
(api as unknown as { getById$: unknown }).getById$ = getById$;
const tasks = [
{ issueProviderId: 'p1', issueId: 't1', issueLastUpdated: 0 },
{ issueProviderId: 'p1', issueId: 't2', issueLastUpdated: 0 },
] as Task[];
const updates = await service.getFreshDataForIssueTasks(tasks);
expect(getMyTasks$).toHaveBeenCalledTimes(1);
expect(getById$).not.toHaveBeenCalled();
expect(updates.map((u) => u.task.issueId)).toEqual(['t1', 't2']);
});
it('skips tasks that are no longer returned (e.g. unassigned from me)', async () => {
spyOn(
service as unknown as { _getCfgOnce$: (id: string) => unknown },
'_getCfgOnce$',
).and.returnValue(of({}));
(api as unknown as { getMyTasks$: unknown }).getMyTasks$ = () =>
of([{ ...issue('2026-01-02T09:00:00.000Z'), id: 't1' }]);
const tasks = [
{ issueProviderId: 'p1', issueId: 't1', issueLastUpdated: 0 },
{ issueProviderId: 'p1', issueId: 'gone', issueLastUpdated: 0 },
] as Task[];
const updates = await service.getFreshDataForIssueTasks(tasks);
expect(updates.map((u) => u.task.issueId)).toEqual(['t1']);
});
});
});

View file

@ -0,0 +1,190 @@
import { Injectable, inject } from '@angular/core';
import { firstValueFrom, Observable } from 'rxjs';
import { map, switchMap } from 'rxjs/operators';
import { Task, TaskCopy } from '../../../tasks/task.model';
import { BaseIssueProviderService } from '../../base/base-issue-provider.service';
import { IssueData, IssueDataReduced, SearchResultItem } from '../../issue.model';
import { PLAINSPACE_POLL_INTERVAL } from './plainspace.const';
import { PlainspaceCfg } from './plainspace.model';
import { PlainspaceApiService } from './plainspace-api.service';
import { PlainspaceSyncAdapterService } from './plainspace-sync-adapter.service';
import { PlainspaceIssue } from './plainspace-issue.model';
@Injectable({
providedIn: 'root',
})
export class PlainspaceCommonInterfacesService extends BaseIssueProviderService<PlainspaceCfg> {
private readonly _plainspaceApiService = inject(PlainspaceApiService);
private readonly _syncAdapter = inject(PlainspaceSyncAdapterService);
readonly providerKey = 'PLAINSPACE' as const;
readonly pollInterval: number = PLAINSPACE_POLL_INTERVAL;
isEnabled(cfg: PlainspaceCfg): boolean {
return !!cfg && cfg.isEnabled && !!cfg.host && !!cfg.spaceId && !!cfg.token;
}
testConnection(cfg: PlainspaceCfg): Promise<boolean> {
return firstValueFrom(
this._plainspaceApiService.getMe$(cfg).pipe(map((res) => !!res)),
).then((result) => result ?? false);
}
issueLink(issueId: string | number, issueProviderId: string): Promise<string> {
// The canonical link (`{origin}/{slug}/item/{id}`) comes from the task's own
// `url`; fall back to the host root if the task can't be fetched (offline).
return firstValueFrom(
this._getCfgOnce$(issueProviderId).pipe(
switchMap((cfg) =>
this._plainspaceApiService
.getById$(String(issueId), cfg)
.pipe(map((issue) => issue?.url || `${cfg.host}`)),
),
),
).then((result) => result ?? '');
}
getAddTaskData(
issue: PlainspaceIssue,
): Partial<Readonly<TaskCopy>> & { title: string } {
// Import Plainspace's `scheduledAt` as the SP task's scheduled time so it
// shows in the app. A provider-supplied `dueWithTime` is routed by the import
// pipeline through `addAndSchedule` (sets the time + a reminder + Today
// membership). Poll updates keep it in sync via the override below.
const dueWithTime = issue.scheduledAt
? new Date(issue.scheduledAt).getTime()
: undefined;
return {
title: issue.title,
isDone: issue.isDone,
issueWasUpdated: false,
issueLastUpdated: new Date(issue.updatedAt).getTime(),
...(dueWithTime ? { dueWithTime } : {}),
// Seed the two-way-sync baseline (last-known remote values) so push-only
// fields — done and scheduled time — can detect a change. Without it
// computePushDecisions skips every push as 'no-baseline' and nothing is
// ever written back. Mirrors the CalDAV provider.
issueLastSyncedValues: this._syncAdapter.extractSyncValues(
issue as unknown as Record<string, unknown>,
),
};
}
/**
* Plainspace owns the schedule for shared tasks, so unlike the base, which
* drops `dueWithTime` on poll to protect user-set schedules we pull
* `scheduledAt` into `dueWithTime` here. This schedules already-imported tasks
* once they next change remotely and keeps recurring items in sync as the
* server advances `scheduledAt` to the next occurrence. User reschedules in SP
* push back via the two-way-sync adapter, so the values stay consistent.
* Mirrors the CalDAV provider's date-on-poll override.
*/
override async getFreshDataForIssueTask(task: Task): Promise<{
taskChanges: Partial<Task>;
issue: IssueData;
issueTitle: string;
} | null> {
if (!task.issueProviderId || !task.issueId) {
return null;
}
const cfg = await firstValueFrom(this._getCfgOnce$(task.issueProviderId));
const issue = await firstValueFrom(
this._plainspaceApiService.getById$(task.issueId, cfg),
);
return this._toFreshData(task, issue);
}
/**
* Poll all of a provider's imported tasks at once. `GET /tasks` already returns
* every task assigned to me in one call, so we fetch it once per provider and
* diff locally instead of the base's one `getById` HTTP request per task
* (which is N redundant calls of the same data). A task that is no longer
* assigned to me simply isn't in the response and is left untouched this cycle.
*/
override async getFreshDataForIssueTasks(
tasks: Task[],
): Promise<{ task: Task; taskChanges: Partial<Task>; issue: IssueData }[]> {
const tasksByProviderId = new Map<string, Task[]>();
for (const task of tasks) {
if (!task.issueProviderId || !task.issueId) {
continue;
}
const group = tasksByProviderId.get(task.issueProviderId) ?? [];
group.push(task);
tasksByProviderId.set(task.issueProviderId, group);
}
const updates: { task: Task; taskChanges: Partial<Task>; issue: IssueData }[] = [];
for (const [providerId, providerTasks] of tasksByProviderId) {
const cfg = await firstValueFrom(this._getCfgOnce$(providerId));
const issuesById = new Map(
(await firstValueFrom(this._plainspaceApiService.getMyTasks$(cfg))).map(
(issue) => [issue.id, issue] as const,
),
);
for (const task of providerTasks) {
const fresh = this._toFreshData(task, issuesById.get(task.issueId!) ?? null);
if (fresh) {
updates.push({ task, taskChanges: fresh.taskChanges, issue: fresh.issue });
}
}
}
return updates;
}
private _toFreshData(
task: Task,
issue: PlainspaceIssue | null,
): { taskChanges: Partial<Task>; issue: IssueData; issueTitle: string } | null {
if (!issue || new Date(issue.updatedAt).getTime() === task.issueLastUpdated) {
return null;
}
return {
taskChanges: {
...this.getAddTaskData(issue),
// Explicit (incl. undefined to unschedule) — the base deletes this.
dueWithTime: issue.scheduledAt
? new Date(issue.scheduledAt).getTime()
: undefined,
issueWasUpdated: true,
},
issue: issue as unknown as IssueData,
issueTitle: issue.title,
};
}
async getNewIssuesToAddToBacklog(
issueProviderId: string,
_allExistingIssueIds: string[],
): Promise<IssueDataReduced[]> {
const cfg = await firstValueFrom(this._getCfgOnce$(issueProviderId));
// Only tasks assigned to me become SP tasks; unclaimed tasks are claimed
// explicitly via the claim pool, never auto-imported.
return await firstValueFrom(this._plainspaceApiService.getMyTasks$(cfg));
}
protected _apiGetById$(
id: string | number,
cfg: PlainspaceCfg,
): Observable<IssueData | null> {
return this._plainspaceApiService.getById$(
String(id),
cfg,
) as Observable<IssueData | null>;
}
protected _apiSearchIssues$(
searchTerm: string,
cfg: PlainspaceCfg,
): Observable<SearchResultItem[]> {
return this._plainspaceApiService.searchIssues$(searchTerm, cfg);
}
protected _formatIssueTitleForSnack(issue: IssueData): string {
return (issue as PlainspaceIssue).title;
}
protected _getIssueLastUpdated(issue: IssueData): number {
return new Date((issue as PlainspaceIssue).updatedAt).getTime();
}
}

View file

@ -0,0 +1,60 @@
import { T } from '../../../../t.const';
import { IssueFieldType } from '../../issue-content/issue-content.model';
import { PLAINSPACE_ISSUE_CONTENT_CONFIG } from './plainspace-issue-content.const';
import { PlainspaceIssue } from './plainspace-issue.model';
const makeIssue = (over: Partial<PlainspaceIssue> = {}): PlainspaceIssue =>
({
id: 'ps-1',
title: 'Shared task',
isDone: false,
updatedAt: '2026-06-18T00:00:00.000Z',
url: 'https://plainspace.org/space/item/ps-1',
projectId: 'space-1',
scheduledAt: null,
isRecurring: false,
...over,
}) as PlainspaceIssue;
describe('PLAINSPACE_ISSUE_CONTENT_CONFIG', () => {
const recurringField = PLAINSPACE_ISSUE_CONTENT_CONFIG.fields.find(
(f) => f.label === T.PLAINSPACE.RECURRING,
);
const linkField = PLAINSPACE_ISSUE_CONTENT_CONFIG.fields.find(
(f) => f.label === T.PLAINSPACE.OPEN_IN_PLAINSPACE,
);
// title/done/schedule are mirrored onto the SP task, so the panel must not echo
// the title back as a redundant "Summary" row.
it('does not re-show the title as a redundant Summary field', () => {
expect(
PLAINSPACE_ISSUE_CONTENT_CONFIG.fields.some(
(f) => f.label === T.F.ISSUE.ISSUE_CONTENT.SUMMARY,
),
).toBe(false);
});
describe('recurrence indicator', () => {
it('renders the shared `repeat` icon via a custom field', () => {
expect(recurringField?.type).toBe(IssueFieldType.CUSTOM);
expect(recurringField?.customTemplate).toBe('plainspace-recurring');
});
it('shows only when the task recurs in Plainspace', () => {
expect(recurringField?.isVisible?.(makeIssue({ isRecurring: true }))).toBe(true);
expect(recurringField?.isVisible?.(makeIssue({ isRecurring: false }))).toBe(false);
});
});
describe('open-in-Plainspace link', () => {
it('links to the task url', () => {
expect(linkField?.type).toBe(IssueFieldType.LINK);
expect(linkField?.getLink?.(makeIssue({ url: 'https://x/y' }))).toBe('https://x/y');
});
it('hides when there is no url', () => {
expect(linkField?.isVisible?.(makeIssue({ url: 'https://x/y' }))).toBe(true);
expect(linkField?.isVisible?.(makeIssue({ url: null }))).toBe(false);
});
});
});

View file

@ -0,0 +1,32 @@
import { T } from '../../../../t.const';
import {
IssueContentConfig,
IssueFieldType,
} from '../../issue-content/issue-content.model';
import { IssueProviderKey } from '../../issue.model';
import { PlainspaceIssue } from './plainspace-issue.model';
export const PLAINSPACE_ISSUE_CONTENT_CONFIG: IssueContentConfig<PlainspaceIssue> = {
issueType: 'PLAINSPACE' as IssueProviderKey,
// Title, done state and schedule are mirrored onto the SP task itself, so the
// panel would only echo them. Surface just what the task doesn't already show:
// whether it recurs in Plainspace, and a link back to open it there.
fields: [
{
label: T.PLAINSPACE.RECURRING,
// Render the same `repeat` mat-icon the claim pool uses (a plain text value
// cell can't hold an icon and isn't translated).
type: IssueFieldType.CUSTOM,
customTemplate: 'plainspace-recurring',
value: 'isRecurring',
isVisible: (issue: PlainspaceIssue) => issue.isRecurring,
},
{
label: T.PLAINSPACE.OPEN_IN_PLAINSPACE,
type: IssueFieldType.LINK,
value: (issue: PlainspaceIssue) => issue.title,
getLink: (issue: PlainspaceIssue) => issue.url || '',
isVisible: (issue: PlainspaceIssue) => !!issue.url,
},
],
};

View file

@ -0,0 +1,13 @@
import { IssueDataReduced, IssueProviderKey, SearchResultItem } from '../../issue.model';
import { PlainspaceIssue } from './plainspace-issue.model';
export const mapPlainspaceIssueToSearchResult = (
issue: PlainspaceIssue,
): SearchResultItem => {
return {
title: issue.title,
titleHighlighted: issue.title,
issueType: 'PLAINSPACE' as IssueProviderKey,
issueData: issue as IssueDataReduced,
};
};

View file

@ -0,0 +1,32 @@
/**
* Internal Plainspace issue shape used across the SP provider.
*
* The real Plainspace integration API returns an `SPTask`
* (`GET {host}/api/integration/tasks`, see docs/plainspace-api-extension-plan.md).
* `PlainspaceApiService` maps that DTO to this shape, so the rest of the
* provider depends on one stable interface and the wire format stays isolated to
* the API service.
*/
export type PlainspaceIssue = Readonly<{
id: string;
title: string;
isDone: boolean;
/** ISO timestamp; used for poll-based update detection. */
updatedAt: string;
/** Absolute link to open the task in the Plainspace web UI. */
url: string | null;
/** Remote Plainspace project/space id — used to scope tasks to a provider. */
projectId: string;
/**
* ISO instant the task is scheduled for (Plainspace `scheduledAt`), or null
* when unscheduled. Maps to SP's `task.dueWithTime`. For recurring Plainspace
* items this is the *next* occurrence the server advances it, SP just tracks
* it.
*/
scheduledAt: string | null;
/**
* Whether the task repeats in Plainspace. The cadence stays Plainspace-side;
* SP only carries the yes/no to flag recurrence.
*/
isRecurring: boolean;
}>;

View file

@ -0,0 +1,130 @@
import { Injectable, inject } from '@angular/core';
import { Store } from '@ngrx/store';
import { MatDialog } from '@angular/material/dialog';
import { firstValueFrom } from 'rxjs';
import { nanoid } from 'nanoid';
import { IssueProviderActions } from '../../store/issue-provider.actions';
import { IssueProviderPlainspace } from '../../issue.model';
import { ISSUE_PROVIDER_DEFAULT_COMMON_CFG } from '../../issue.const';
import { PlainspaceApiService } from './plainspace-api.service';
import { PlainspaceCfg } from './plainspace.model';
import { DEFAULT_PLAINSPACE_CFG } from './plainspace-cfg-form.const';
import { SnackService } from '../../../../core/snack/snack.service';
import { Log } from '../../../../core/log';
import { T } from '../../../../t.const';
import { PlainspaceAccountService } from '../../../plainspace/plainspace-account.service';
import { PlainspaceConnectDialogComponent } from '../../../plainspace/connect-dialog/plainspace-connect-dialog.component';
import {
PlainspaceSpaceChoice,
PlainspaceSpacePickerDialogComponent,
} from '../../../plainspace/space-picker-dialog/plainspace-space-picker-dialog.component';
/**
* Provisions Plainspace sharing for a project: ensures the user is signed in,
* creates a remote space and registers a bound `PLAINSPACE` issue-provider
* instance (so tasks assigned to me / unassigned auto-import to the project
* backlog). Used by the "Share on Plainspace" toggle in the create-project
* dialog.
*/
@Injectable({ providedIn: 'root' })
export class PlainspaceShareService {
private _store = inject(Store);
private _plainspaceApiService = inject(PlainspaceApiService);
private _accountService = inject(PlainspaceAccountService);
private _snackService = inject(SnackService);
private _matDialog = inject(MatDialog);
/**
* Self-contained (never rejects) so it is safe to fire-and-forget from the
* create-project dialog. Prompts for sign-in if needed, then lets the user
* create a new space or link an existing one (so tasks already assigned to
* them import). On failure (or if the user cancels) it surfaces a snack /
* returns null.
*
* @returns the bound space id, or null if sharing could not be provisioned.
*/
async shareProjectOnPlainspace(
projectId: string,
title: string,
): Promise<string | null> {
try {
if (!(await this._ensureConnected())) {
this._snackService.open({ type: 'ERROR', msg: T.PLAINSPACE.LOGIN_REQUIRED });
return null;
}
const choice = await this._chooseSpace();
if (!choice) {
// User cancelled the space picker — nothing to provision.
return null;
}
const account = this._accountService.account();
const cfg: PlainspaceCfg = {
...DEFAULT_PLAINSPACE_CFG,
host: account?.host ?? DEFAULT_PLAINSPACE_CFG.host,
token: account?.token ?? null,
};
const spaceId =
choice.action === 'create'
? (await firstValueFrom(this._plainspaceApiService.createSpace$(title, cfg)))
?.id
: choice.spaceId;
if (!spaceId) {
return null;
}
const issueProvider: IssueProviderPlainspace = {
...ISSUE_PROVIDER_DEFAULT_COMMON_CFG,
...DEFAULT_PLAINSPACE_CFG,
id: nanoid(),
issueProviderKey: 'PLAINSPACE',
isEnabled: true,
defaultProjectId: projectId,
isAutoAddToBacklog: true,
host: cfg.host,
token: cfg.token,
spaceId,
};
this._store.dispatch(IssueProviderActions.addIssueProvider({ issueProvider }));
this._snackService.open({ type: 'SUCCESS', msg: T.PLAINSPACE.SHARE_SUCCESS });
return spaceId;
} catch {
// Log ids only — never user content (project title).
Log.err('Plainspace: failed to share project', { projectId });
this._snackService.open({ type: 'ERROR', msg: T.PLAINSPACE.SHARE_FAILED });
return null;
}
}
/**
* Opens the space picker (create new vs link existing). Returns the chosen
* action, or null if the user cancelled.
*/
private async _chooseSpace(): Promise<PlainspaceSpaceChoice | null> {
const choice = await firstValueFrom(
this._matDialog.open(PlainspaceSpacePickerDialogComponent).afterClosed(),
);
return choice ?? null;
}
/**
* Ensures a Plainspace account is connected, opening the guided connect dialog
* (link + step-by-step) if not. The dialog validates the pasted token against
* the host and resolves to whether a connection was established.
*/
private async _ensureConnected(): Promise<boolean> {
if (this._accountService.isLoggedIn()) {
return true;
}
const connected = await firstValueFrom(
this._matDialog
.open(PlainspaceConnectDialogComponent, {
data: { host: DEFAULT_PLAINSPACE_CFG.host },
})
.afterClosed(),
);
return connected === true;
}
}

View file

@ -0,0 +1,138 @@
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { PlainspaceSyncAdapterService } from './plainspace-sync-adapter.service';
import { PlainspaceApiService } from './plainspace-api.service';
import { PlainspaceCfg } from './plainspace.model';
import { DEFAULT_PLAINSPACE_CFG } from './plainspace-cfg-form.const';
describe('PlainspaceSyncAdapterService', () => {
let adapter: PlainspaceSyncAdapterService;
let api: jasmine.SpyObj<PlainspaceApiService>;
const cfg: PlainspaceCfg = {
...DEFAULT_PLAINSPACE_CFG,
host: 'https://plainspace.org',
spaceId: 'space-1',
token: 'pat_x',
};
beforeEach(() => {
api = jasmine.createSpyObj('PlainspaceApiService', ['getById$', 'patchTask$']);
TestBed.configureTestingModule({
providers: [
PlainspaceSyncAdapterService,
{ provide: PlainspaceApiService, useValue: api },
],
});
adapter = TestBed.inject(PlainspaceSyncAdapterService);
});
it('maps isDone, title and dueWithTime, all push-only', () => {
expect(adapter.getSyncConfig(cfg)).toEqual({
isDone: 'pushOnly',
title: 'pushOnly',
dueWithTime: 'pushOnly',
});
const mappings = adapter.getFieldMappings();
expect(mappings.map((m) => [m.taskField, m.issueField])).toEqual([
['isDone', 'isDone'],
['title', 'title'],
['dueWithTime', 'scheduledAt'],
]);
expect(mappings.every((m) => m.defaultDirection === 'pushOnly')).toBe(true);
});
it('dueWithTime <-> scheduledAt maps epoch-ms to ISO and back', () => {
const m = adapter.getFieldMappings().find((x) => x.taskField === 'dueWithTime')!;
const ms = Date.UTC(2026, 0, 2, 9, 0, 0);
expect(m.toIssueValue(ms, { issueId: 't1' })).toBe('2026-01-02T09:00:00.000Z');
expect(m.toTaskValue('2026-01-02T09:00:00.000Z', { issueId: 't1' })).toBe(ms);
// unschedule / absent -> null / undefined
expect(m.toIssueValue(undefined, { issueId: 't1' })).toBeNull();
expect(m.toTaskValue(null, { issueId: 't1' })).toBeUndefined();
});
it('pushChanges PATCHes the done state', async () => {
api.patchTask$.and.returnValue(of(null));
await adapter.pushChanges('t1', { isDone: true }, cfg);
expect(api.patchTask$).toHaveBeenCalledWith('t1', { done: true }, cfg);
});
it('pushChanges PATCHes a renamed title', async () => {
api.patchTask$.and.returnValue(of(null));
await adapter.pushChanges('t1', { title: 'New name' }, cfg);
expect(api.patchTask$).toHaveBeenCalledWith('t1', { title: 'New name' }, cfg);
});
it('pushChanges PATCHes scheduledAt, including null to unschedule', async () => {
api.patchTask$.and.returnValue(of(null));
await adapter.pushChanges('t1', { scheduledAt: '2026-01-02T09:00:00.000Z' }, cfg);
expect(api.patchTask$).toHaveBeenCalledWith(
't1',
{ scheduledAt: '2026-01-02T09:00:00.000Z' },
cfg,
);
api.patchTask$.calls.reset();
await adapter.pushChanges('t1', { scheduledAt: null }, cfg);
expect(api.patchTask$).toHaveBeenCalledWith('t1', { scheduledAt: null }, cfg);
});
it('pushChanges collapses done + scheduledAt into a single PATCH', async () => {
api.patchTask$.and.returnValue(of(null));
await adapter.pushChanges(
't1',
{ isDone: true, scheduledAt: '2026-01-02T09:00:00.000Z' },
cfg,
);
expect(api.patchTask$).toHaveBeenCalledTimes(1);
expect(api.patchTask$).toHaveBeenCalledWith(
't1',
{ done: true, scheduledAt: '2026-01-02T09:00:00.000Z' },
cfg,
);
});
it('pushChanges does nothing when no mapped field is in the changes', async () => {
await adapter.pushChanges('t1', { notes: 'x' }, cfg);
expect(api.patchTask$).not.toHaveBeenCalled();
});
it('fetchIssue returns the issue, or {} when it is missing', async () => {
api.getById$.and.returnValue(
of({
id: 't1',
title: 'T',
isDone: true,
updatedAt: '2026-01-02T00:00:00.000Z',
url: 'u',
projectId: 'space-1',
scheduledAt: null,
isRecurring: false,
}),
);
expect(await adapter.fetchIssue('t1', cfg)).toEqual(
jasmine.objectContaining({ isDone: true }),
);
api.getById$.and.returnValue(of(null));
expect(await adapter.fetchIssue('missing', cfg)).toEqual({});
});
it('extractSyncValues exposes isDone, title and scheduledAt (baseline)', () => {
expect(
adapter.extractSyncValues({
isDone: true,
title: 'x',
scheduledAt: '2026-01-02T09:00:00.000Z',
url: 'ignored',
}),
).toEqual({ isDone: true, title: 'x', scheduledAt: '2026-01-02T09:00:00.000Z' });
});
it('getIssueLastUpdated parses updatedAt, or 0 when absent', () => {
expect(adapter.getIssueLastUpdated({ updatedAt: '2026-01-02T00:00:00.000Z' })).toBe(
new Date('2026-01-02T00:00:00.000Z').getTime(),
);
expect(adapter.getIssueLastUpdated({})).toBe(0);
});
});

View file

@ -0,0 +1,115 @@
import { Injectable, inject } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { IssueSyncAdapter } from '../../two-way-sync/issue-sync-adapter.interface';
import { FieldMapping, FieldSyncConfig } from '../../two-way-sync/issue-sync.model';
import { PlainspaceCfg } from './plainspace.model';
import { PlainspaceApiService } from './plainspace-api.service';
/**
* Push fields, written via PATCH /tasks/:id:
* - `isDone` `done`
* - `title` `title` (SP rename Plainspace item text)
* - `dueWithTime` `scheduledAt` (SP scheduled time Plainspace). SP stores an
* epoch-ms number; Plainspace wants an ISO instant, or null to unschedule.
* Plainspace's own reminder sweep then fires it for the team.
*
* Direction is `pushOnly` for all three: the reverse direction (Plainspace SP)
* is handled by issue-update polling (getFreshDataForIssueTask applies
* getAddTaskData, which already carries title/isDone/scheduledAt), not this
* adapter.
*
* `dueDay` (date-only scheduling, no time) is intentionally NOT mapped: Plainspace
* `scheduledAt` always carries a time, so mapping a day-only task would fabricate
* a time-of-day. There is no separate day field on Plainspace to clear, so no
* `mutuallyExclusive` entry is needed.
*/
const PLAINSPACE_FIELD_MAPPINGS: FieldMapping[] = [
{
taskField: 'isDone',
issueField: 'isDone',
defaultDirection: 'pushOnly',
toIssueValue: (taskValue: unknown): boolean => !!taskValue,
toTaskValue: (issueValue: unknown): boolean => !!issueValue,
},
{
taskField: 'title',
issueField: 'title',
defaultDirection: 'pushOnly',
toIssueValue: (taskValue: unknown): string => (taskValue as string) ?? '',
toTaskValue: (issueValue: unknown): string => (issueValue as string) ?? '',
},
{
taskField: 'dueWithTime',
issueField: 'scheduledAt',
defaultDirection: 'pushOnly',
toIssueValue: (taskValue: unknown): string | null =>
typeof taskValue === 'number' ? new Date(taskValue).toISOString() : null,
toTaskValue: (issueValue: unknown): number | undefined =>
typeof issueValue === 'string' ? new Date(issueValue).getTime() : undefined,
},
];
/**
* Two-way sync adapter for Plainspace: pushes a task's done state and scheduled
* time back to Plainspace when it is completed/reopened or (re)scheduled in Super
* Productivity. Registered for the `PLAINSPACE` issue type in
* IssueTwoWaySyncEffects.
*/
@Injectable({ providedIn: 'root' })
export class PlainspaceSyncAdapterService implements IssueSyncAdapter<PlainspaceCfg> {
private readonly _api = inject(PlainspaceApiService);
getFieldMappings(): FieldMapping[] {
return PLAINSPACE_FIELD_MAPPINGS;
}
getSyncConfig(_cfg: PlainspaceCfg): FieldSyncConfig {
return { isDone: 'pushOnly', title: 'pushOnly', dueWithTime: 'pushOnly' };
}
async fetchIssue(
issueId: string,
cfg: PlainspaceCfg,
): Promise<Record<string, unknown>> {
const issue = await firstValueFrom(this._api.getById$(issueId, cfg));
return (issue ?? {}) as unknown as Record<string, unknown>;
}
async pushChanges(
issueId: string,
changes: Record<string, unknown>,
cfg: PlainspaceCfg,
): Promise<void> {
// `changes` is keyed by issue field (toPush from the effect). Collapse done
// and scheduled-time changes into a single PATCH.
const fields: { done?: boolean; title?: string; scheduledAt?: string | null } = {};
if ('isDone' in changes) {
fields.done = !!changes['isDone'];
}
if ('title' in changes) {
fields.title = (changes['title'] ?? '') as string;
}
if ('scheduledAt' in changes) {
fields.scheduledAt = (changes['scheduledAt'] ?? null) as string | null;
}
if (Object.keys(fields).length === 0) {
return;
}
await firstValueFrom(this._api.patchTask$(issueId, fields, cfg));
}
extractSyncValues(issue: Record<string, unknown>): Record<string, unknown> {
// Every push field needs a baseline here, else computePushDecisions skips it
// as 'no-baseline' and nothing ever pushes.
return {
isDone: issue['isDone'],
title: issue['title'],
scheduledAt: issue['scheduledAt'],
};
}
getIssueLastUpdated(issue: Record<string, unknown>): number {
const updatedAt = issue['updatedAt'];
return updatedAt ? new Date(updatedAt as string).getTime() : 0;
}
}

View file

@ -0,0 +1,7 @@
export {
PLAINSPACE_CONFIG_FORM_SECTION,
PLAINSPACE_CONFIG_FORM,
DEFAULT_PLAINSPACE_CFG,
} from './plainspace-cfg-form.const';
export const PLAINSPACE_POLL_INTERVAL = 5 * 60 * 1000;

View file

@ -0,0 +1,19 @@
import { BaseIssueProviderCfg } from '../../issue.model';
/**
* Per-instance config for the Plainspace (plainspace.org / `Johannesjo/spaces`)
* issue provider. One instance is bound to one SP project (via the provider's
* `defaultProjectId`) and one remote Plainspace space (`spaceId`).
*
* `token` is a Plainspace personal API token (PAT, `pat_…`) created in the
* Plainspace web UI (Space settings API tokens). It authorizes every call to
* `{host}/api/integration/*`. Stored per provider like other issue providers'
* secrets; a single PAT is valid across all of the user's spaces.
*/
export interface PlainspaceCfg extends BaseIssueProviderCfg {
host: string | null;
spaceId: string | null;
token?: string | null;
isAutoPoll?: boolean;
isAutoAddToBacklog?: boolean;
}

View file

@ -8,6 +8,7 @@ import { IssueProviderService } from '../issue-provider.service';
import { IssueSyncAdapterRegistryService } from './issue-sync-adapter-registry.service';
import { IssueSyncAdapterResolverService } from './issue-sync-adapter-resolver.service';
import { CaldavSyncAdapterService } from '../providers/caldav/caldav-sync-adapter.service';
import { PlainspaceSyncAdapterService } from '../providers/plainspace/plainspace-sync-adapter.service';
import { SnackService } from '../../../core/snack/snack.service';
import { TaskSharedActions } from '../../../root-store/meta/task-shared.actions';
import { PlannerActions } from '../../planner/store/planner.actions';
@ -125,6 +126,14 @@ describe('IssueTwoWaySyncEffects', () => {
'extractSyncValues',
]);
const plainspaceSpy = jasmine.createSpyObj('PlainspaceSyncAdapterService', [
'getFieldMappings',
'getSyncConfig',
'fetchIssue',
'pushChanges',
'extractSyncValues',
]);
TestBed.configureTestingModule({
providers: [
IssueTwoWaySyncEffects,
@ -136,6 +145,7 @@ describe('IssueTwoWaySyncEffects', () => {
{ provide: TaskService, useValue: taskServiceSpy },
{ provide: IssueProviderService, useValue: issueProviderServiceSpy },
{ provide: CaldavSyncAdapterService, useValue: caldavSpy },
{ provide: PlainspaceSyncAdapterService, useValue: plainspaceSpy },
{ provide: SnackService, useValue: snackServiceSpy },
{
provide: IssueSyncAdapterResolverService,

View file

@ -17,6 +17,7 @@ import { IssueProvider, IssueProviderKey } from '../issue.model';
import { IssueLog } from '../../../core/log';
import { HttpErrorResponse } from '@angular/common/http';
import { CaldavSyncAdapterService } from '../providers/caldav/caldav-sync-adapter.service';
import { PlainspaceSyncAdapterService } from '../providers/plainspace/plainspace-sync-adapter.service';
import { SnackService } from '../../../core/snack/snack.service';
import {
DeletedTaskIssueSidecarService,
@ -130,6 +131,8 @@ export class IssueTwoWaySyncEffects {
constructor() {
const caldavAdapter = inject(CaldavSyncAdapterService);
this._adapterRegistry.register('CALDAV', caldavAdapter);
const plainspaceAdapter = inject(PlainspaceSyncAdapterService);
this._adapterRegistry.register('PLAINSPACE', plainspaceAdapter);
}
pushFieldsOnTaskUpdate$: Observable<unknown> = createEffect(

View file

@ -0,0 +1,37 @@
@if (tasks().length) {
@for (task of tasks(); track task.id) {
<div class="claim-task">
<span class="title">{{ task.title }}</span>
@if (task.isRecurring) {
<mat-icon
class="recurring-icon"
[matTooltip]="T.PLAINSPACE.RECURRING | translate"
>repeat</mat-icon
>
}
@if (task.url) {
<a
class="open-link"
mat-icon-button
[href]="task.url"
target="_blank"
rel="noopener"
[matTooltip]="T.PLAINSPACE.OPEN_IN_PLAINSPACE | translate"
>
<mat-icon>open_in_new</mat-icon>
</a>
}
<button
mat-stroked-button
color="primary"
[disabled]="claimingIds().has(task.id)"
(click)="claim(task)"
>
<mat-icon>add</mat-icon>
{{ T.PLAINSPACE.CLAIM | translate }}
</button>
</div>
}
} @else {
<div class="no-tasks">{{ T.PLAINSPACE.NO_UNCLAIMED | translate }}</div>
}

View file

@ -0,0 +1,44 @@
:host {
display: block;
padding: 0 var(--s);
}
.claim-task {
display: flex;
align-items: center;
gap: var(--s);
padding: var(--s-half) var(--s);
border-bottom: 1px solid var(--extra-border-color);
color: var(--text-color);
}
.title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.open-link {
flex-shrink: 0;
opacity: 0.6;
&:hover {
opacity: 1;
}
}
.recurring-icon {
flex-shrink: 0;
font-size: 18px;
width: 18px;
height: 18px;
opacity: 0.6;
color: var(--text-color-muted);
}
.no-tasks {
padding: var(--s);
color: var(--text-color-muted);
font-style: italic;
}

View file

@ -0,0 +1,59 @@
import { ChangeDetectionStrategy, Component, inject, input, signal } from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { MatButton, MatIconButton } from '@angular/material/button';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { T } from '../../../t.const';
import { PlainspaceSharedTask } from '../plainspace-shared-task.model';
import { PlainspaceClaimPoolService } from '../plainspace-claim-pool.service';
/**
* Read-only list of unclaimed Plainspace tasks for a shared project, each with a
* "Claim" action. Claiming assigns the task to me in Plainspace and imports it
* as a first-class SP task; the claimed task then leaves the pool.
*
* Deliberately a lightweight, standalone component (not the hot-path
* `TaskComponent`): these are foreign tasks until claimed and must never be
* edited/scheduled or written into the SP task store. See
* docs/plainspace-integration-plan.md.
*/
@Component({
selector: 'plainspace-claim-pool',
templateUrl: './claim-pool.component.html',
styleUrls: ['./claim-pool.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [MatIcon, MatButton, MatIconButton, MatTooltip, TranslatePipe],
})
export class PlainspaceClaimPoolComponent {
readonly T = T;
readonly tasks = input<PlainspaceSharedTask[]>([]);
readonly projectId = input<string>('');
private _claimPoolService = inject(PlainspaceClaimPoolService);
readonly claimingIds = signal<ReadonlySet<string>>(new Set());
async claim(task: PlainspaceSharedTask): Promise<void> {
const projectId = this.projectId();
if (!projectId || this.claimingIds().has(task.id)) {
return;
}
this._setClaiming(task.id, true);
try {
await this._claimPoolService.claim(projectId, task.id);
} finally {
this._setClaiming(task.id, false);
}
}
private _setClaiming(id: string, isClaiming: boolean): void {
this.claimingIds.update((set) => {
const next = new Set(set);
if (isClaiming) {
next.add(id);
} else {
next.delete(id);
}
return next;
});
}
}

View file

@ -0,0 +1,63 @@
<h2 mat-dialog-title>{{ T.PLAINSPACE.CONNECT.TITLE | translate }}</h2>
<mat-dialog-content>
<p class="intro">{{ T.PLAINSPACE.CONNECT.INTRO | translate }}</p>
<ol class="steps">
<li>{{ T.PLAINSPACE.CONNECT.STEP_1 | translate }}</li>
<li>{{ T.PLAINSPACE.CONNECT.STEP_2 | translate }}</li>
<li>{{ T.PLAINSPACE.CONNECT.STEP_3 | translate }}</li>
<li>{{ T.PLAINSPACE.CONNECT.STEP_4 | translate }}</li>
</ol>
<p class="email-hint">{{ T.PLAINSPACE.CONNECT.EMAIL_HINT | translate }}</p>
<a
mat-stroked-button
color="primary"
class="open-link"
[href]="host"
target="_blank"
rel="noopener noreferrer"
>
<mat-icon>open_in_new</mat-icon>
{{ T.PLAINSPACE.CONNECT.OPEN_LINK | translate }}
</a>
<mat-form-field class="token-field">
<mat-label>{{ T.PLAINSPACE.CONNECT.TOKEN_LABEL | translate }}</mat-label>
<input
matInput
type="password"
autocomplete="off"
[(ngModel)]="token"
[placeholder]="T.PLAINSPACE.CONNECT.TOKEN_PLACEHOLDER | translate"
(keydown.enter)="connect()"
/>
</mat-form-field>
@if (hasError()) {
<p class="error">{{ T.PLAINSPACE.CONNECT.INVALID | translate }}</p>
}
</mat-dialog-content>
<mat-dialog-actions align="end">
<button
mat-button
[disabled]="isConnecting()"
(click)="cancel()"
>
{{ T.G.CANCEL | translate }}
</button>
<button
mat-flat-button
color="primary"
[disabled]="!token.trim() || isConnecting()"
(click)="connect()"
>
{{
(isConnecting() ? T.PLAINSPACE.CONNECT.CONNECTING : T.PLAINSPACE.CONNECT.CONNECT)
| translate
}}
</button>
</mat-dialog-actions>

View file

@ -0,0 +1,36 @@
:host {
display: block;
max-width: 440px;
}
.intro {
margin-top: 0;
}
.steps {
margin: 0 0 12px;
padding-inline-start: 20px;
}
.steps li {
margin-bottom: 4px;
}
.email-hint {
margin: 0 0 16px;
font-size: 12px;
color: var(--text-color-muted);
}
.open-link {
margin-bottom: 16px;
}
.token-field {
width: 100%;
}
.error {
margin: 4px 0 0;
color: var(--c-warn);
}

View file

@ -0,0 +1,73 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule } from '@ngx-translate/core';
import { PlainspaceConnectDialogComponent } from './plainspace-connect-dialog.component';
import { PlainspaceAccountService } from '../plainspace-account.service';
describe('PlainspaceConnectDialogComponent', () => {
let component: PlainspaceConnectDialogComponent;
let fixture: ComponentFixture<PlainspaceConnectDialogComponent>;
let dialogRef: jasmine.SpyObj<MatDialogRef<PlainspaceConnectDialogComponent, boolean>>;
let accountService: jasmine.SpyObj<PlainspaceAccountService>;
beforeEach(async () => {
dialogRef = jasmine.createSpyObj('MatDialogRef', ['close']);
accountService = jasmine.createSpyObj('PlainspaceAccountService', ['connect']);
await TestBed.configureTestingModule({
imports: [
PlainspaceConnectDialogComponent,
NoopAnimationsModule,
TranslateModule.forRoot(),
],
providers: [
{ provide: MatDialogRef, useValue: dialogRef },
{ provide: MAT_DIALOG_DATA, useValue: { host: 'https://plainspace.org' } },
{ provide: PlainspaceAccountService, useValue: accountService },
],
}).compileComponents();
fixture = TestBed.createComponent(PlainspaceConnectDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('renders and uses the provided host', () => {
expect(component).toBeTruthy();
expect(component.host).toBe('https://plainspace.org');
});
it('does nothing when the token is blank', async () => {
component.token = ' ';
await component.connect();
expect(accountService.connect).not.toHaveBeenCalled();
expect(dialogRef.close).not.toHaveBeenCalled();
});
it('connects with the trimmed token and closes with true on success', async () => {
accountService.connect.and.resolveTo(true);
component.token = ' pat_abc ';
await component.connect();
expect(accountService.connect).toHaveBeenCalledWith(
'pat_abc',
'https://plainspace.org',
);
expect(dialogRef.close).toHaveBeenCalledWith(true);
expect(component.hasError()).toBe(false);
});
it('shows an error and stays open on an invalid token', async () => {
accountService.connect.and.resolveTo(false);
component.token = 'bad';
await component.connect();
expect(component.hasError()).toBe(true);
expect(component.isConnecting()).toBe(false);
expect(dialogRef.close).not.toHaveBeenCalled();
});
it('cancel closes with false', () => {
component.cancel();
expect(dialogRef.close).toHaveBeenCalledWith(false);
});
});

View file

@ -0,0 +1,80 @@
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import {
MAT_DIALOG_DATA,
MatDialogActions,
MatDialogContent,
MatDialogRef,
MatDialogTitle,
} from '@angular/material/dialog';
import { FormsModule } from '@angular/forms';
import { MatFormField, MatLabel } from '@angular/material/form-field';
import { MatInput } from '@angular/material/input';
import { MatAnchor, MatButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
import { T } from '../../../t.const';
import { PlainspaceAccountService } from '../plainspace-account.service';
export interface PlainspaceConnectDialogData {
host?: string | null;
}
/**
* Guided "Connect to Plainspace" dialog: shows where to create a personal API
* token (link + step-by-step), takes the pasted token and validates it against
* the host before closing. Resolves to `true` once connected, `false` if the
* user cancels. Replaces the bare single-line token prompt.
*/
@Component({
selector: 'plainspace-connect-dialog',
templateUrl: './plainspace-connect-dialog.component.html',
styleUrls: ['./plainspace-connect-dialog.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
MatDialogTitle,
MatDialogContent,
MatDialogActions,
FormsModule,
MatFormField,
MatLabel,
MatInput,
MatButton,
MatAnchor,
MatIcon,
TranslatePipe,
],
})
export class PlainspaceConnectDialogComponent {
private _dialogRef =
inject<MatDialogRef<PlainspaceConnectDialogComponent, boolean>>(MatDialogRef);
private _accountService = inject(PlainspaceAccountService);
private _data = inject<PlainspaceConnectDialogData | null>(MAT_DIALOG_DATA, {
optional: true,
});
readonly T = T;
readonly host = this._data?.host || 'https://plainspace.org';
token = '';
readonly isConnecting = signal(false);
readonly hasError = signal(false);
async connect(): Promise<void> {
const token = this.token.trim();
if (!token || this.isConnecting()) {
return;
}
this.isConnecting.set(true);
this.hasError.set(false);
const ok = await this._accountService.connect(token, this.host);
if (ok) {
this._dialogRef.close(true);
} else {
this.hasError.set(true);
this.isConnecting.set(false);
}
}
cancel(): void {
this._dialogRef.close(false);
}
}

View file

@ -0,0 +1,12 @@
/**
* The connected Plainspace account: the personal API token (PAT) plus the host
* it was validated against, and the email it belongs to. Stored local-only (per
* device), never synced. Used by the share-on-create flow to provision a space
* before any provider exists, and to avoid re-entering the token.
*/
export interface PlainspaceAccount {
host: string;
/** Personal API token (`pat_…`). */
token: string;
email: string;
}

View file

@ -0,0 +1,68 @@
import {
HttpClientTestingModule,
HttpTestingController,
} from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { PlainspaceAccountService } from './plainspace-account.service';
import { LS } from '../../core/persistence/storage-keys.const';
describe('PlainspaceAccountService', () => {
let service: PlainspaceAccountService;
let httpMock: HttpTestingController;
const ME_URL = 'https://plainspace.org/api/integration/me';
beforeEach(() => {
localStorage.removeItem(LS.PLAINSPACE_ACCOUNT);
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [PlainspaceAccountService],
});
service = TestBed.inject(PlainspaceAccountService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
localStorage.removeItem(LS.PLAINSPACE_ACCOUNT);
httpMock.verify();
});
it('starts logged out', () => {
expect(service.isLoggedIn()).toBe(false);
expect(service.token()).toBeNull();
});
it('connect validates the token via /me, stores it, and persists', async () => {
const p = service.connect('pat_x');
const req = httpMock.expectOne(ME_URL);
expect(req.request.headers.get('Authorization')).toBe('Bearer pat_x');
req.flush({ email: 'me@example.com', projects: [] });
expect(await p).toBe(true);
expect(service.isLoggedIn()).toBe(true);
expect(service.token()).toBe('pat_x');
expect(service.account()?.email).toBe('me@example.com');
expect(localStorage.getItem(LS.PLAINSPACE_ACCOUNT)).toContain('pat_x');
});
it('connect returns false and stays logged out on an invalid token', async () => {
const p = service.connect('bad');
httpMock
.expectOne(ME_URL)
.flush({ error: 'nope' }, { status: 401, statusText: 'Unauthorized' });
expect(await p).toBe(false);
expect(service.isLoggedIn()).toBe(false);
expect(localStorage.getItem(LS.PLAINSPACE_ACCOUNT)).toBeNull();
});
it('logout clears the account and storage', async () => {
const p = service.connect('pat_x');
httpMock.expectOne(ME_URL).flush({ email: 'me@example.com', projects: [] });
await p;
service.logout();
expect(service.isLoggedIn()).toBe(false);
expect(service.token()).toBeNull();
expect(localStorage.getItem(LS.PLAINSPACE_ACCOUNT)).toBeNull();
});
});

View file

@ -0,0 +1,65 @@
import { Injectable, computed, inject, signal } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { LS } from '../../core/persistence/storage-keys.const';
import { Log } from '../../core/log';
import { PlainspaceAccount } from './plainspace-account.model';
import { PlainspaceApiService } from '../issue/providers/plainspace/plainspace-api.service';
import { DEFAULT_PLAINSPACE_CFG } from '../issue/providers/plainspace/plainspace-cfg-form.const';
const DEFAULT_HOST = 'https://plainspace.org';
/**
* Holds the connected Plainspace account (a personal API token + host) and
* exposes it as signals. Persisted to localStorage (local-only, never synced
* a PAT is per device). Used by the share-on-create flow, which needs a token
* before any provider/space exists.
*/
@Injectable({ providedIn: 'root' })
export class PlainspaceAccountService {
private readonly _api = inject(PlainspaceApiService);
private readonly _account = signal<PlainspaceAccount | null>(this._load());
readonly account = this._account.asReadonly();
readonly isLoggedIn = computed(() => !!this._account());
readonly token = computed(() => this._account()?.token ?? null);
readonly host = computed(() => this._account()?.host ?? null);
/**
* Validates a PAT against the host (`GET /api/integration/me`) and, on
* success, stores it. Returns whether the token was accepted.
*/
async connect(token: string, host: string = DEFAULT_HOST): Promise<boolean> {
const me = await firstValueFrom(
this._api.getMe$({ ...DEFAULT_PLAINSPACE_CFG, host, token }),
);
if (!me) {
return false;
}
const account: PlainspaceAccount = { host, token, email: me.email };
this._account.set(account);
this._save(account);
return true;
}
logout(): void {
this._account.set(null);
localStorage.removeItem(LS.PLAINSPACE_ACCOUNT);
}
private _load(): PlainspaceAccount | null {
const raw = localStorage.getItem(LS.PLAINSPACE_ACCOUNT);
if (!raw) {
return null;
}
try {
return JSON.parse(raw) as PlainspaceAccount;
} catch {
Log.err('Plainspace: failed to parse stored account');
return null;
}
}
private _save(account: PlainspaceAccount): void {
localStorage.setItem(LS.PLAINSPACE_ACCOUNT, JSON.stringify(account));
}
}

View file

@ -0,0 +1,100 @@
import { TestBed } from '@angular/core/testing';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { firstValueFrom, of } from 'rxjs';
import { PlainspaceClaimPoolService } from './plainspace-claim-pool.service';
import { PlainspaceApiService } from '../issue/providers/plainspace/plainspace-api.service';
import { PlainspaceIssue } from '../issue/providers/plainspace/plainspace-issue.model';
import { selectEnabledIssueProviders } from '../issue/store/issue-provider.selectors';
import { IssueService } from '../issue/issue.service';
import { IssueProviderPlainspace } from '../issue/issue.model';
import { SnackService } from '../../core/snack/snack.service';
const PLAINSPACE_PROVIDER = {
id: 'p1',
issueProviderKey: 'PLAINSPACE',
isEnabled: true,
defaultProjectId: 'proj-1',
host: 'https://plainspace.org',
spaceId: 'space-1',
token: 'pat_x',
} as IssueProviderPlainspace;
const issue = (id: string): PlainspaceIssue => ({
id,
title: id,
isDone: false,
updatedAt: '2026-01-01T00:00:00.000Z',
url: `https://plainspace.org/p/item/${id}`,
projectId: 'space-1',
scheduledAt: null,
isRecurring: false,
});
describe('PlainspaceClaimPoolService', () => {
let service: PlainspaceClaimPoolService;
let store: MockStore;
let apiSpy: jasmine.SpyObj<PlainspaceApiService>;
let addTaskFromIssueSpy: jasmine.Spy;
let snackSpy: jasmine.SpyObj<SnackService>;
beforeEach(() => {
apiSpy = jasmine.createSpyObj<PlainspaceApiService>('PlainspaceApiService', [
'getUnclaimedTasks$',
'claimTask$',
]);
apiSpy.getUnclaimedTasks$.and.returnValue(of([issue('ps-102'), issue('ps-105')]));
apiSpy.claimTask$.and.returnValue(of(issue('ps-102')));
addTaskFromIssueSpy = jasmine.createSpy('addTaskFromIssue').and.resolveTo('t1');
snackSpy = jasmine.createSpyObj<SnackService>('SnackService', ['open']);
TestBed.configureTestingModule({
providers: [
PlainspaceClaimPoolService,
provideMockStore(),
{ provide: PlainspaceApiService, useValue: apiSpy },
{ provide: IssueService, useValue: { addTaskFromIssue: addTaskFromIssueSpy } },
{ provide: SnackService, useValue: snackSpy },
],
});
service = TestBed.inject(PlainspaceClaimPoolService);
store = TestBed.inject(MockStore);
store.overrideSelector(selectEnabledIssueProviders, [PLAINSPACE_PROVIDER]);
store.refreshState();
});
it('returns the unclaimed tasks for a shared project', async () => {
const unclaimed = await firstValueFrom(service.unclaimedTasksForProject$('proj-1'));
expect(unclaimed.map((t) => t.id)).toEqual(['ps-102', 'ps-105']);
});
it('returns empty when the project has no bound Plainspace provider', async () => {
const none = await firstValueFrom(service.unclaimedTasksForProject$('other-proj'));
expect(none).toEqual([]);
});
it('claim assigns the task and imports it as an SP task', async () => {
await service.claim('proj-1', 'ps-102');
expect(apiSpy.claimTask$).toHaveBeenCalledWith('ps-102', PLAINSPACE_PROVIDER);
expect(addTaskFromIssueSpy).toHaveBeenCalledTimes(1);
const arg = addTaskFromIssueSpy.calls.mostRecent().args[0];
expect(arg.issueProviderKey).toBe('PLAINSPACE');
expect(arg.issueDataReduced.id).toBe('ps-102');
});
it('shows a success snack on a successful claim', async () => {
await service.claim('proj-1', 'ps-102');
expect(snackSpy.open).toHaveBeenCalledWith(
jasmine.objectContaining({ type: 'SUCCESS' }),
);
});
it('shows an error snack and does not import when the claim fails', async () => {
apiSpy.claimTask$.and.returnValue(of(null));
await service.claim('proj-1', 'ps-102');
expect(addTaskFromIssueSpy).not.toHaveBeenCalled();
expect(snackSpy.open).toHaveBeenCalledWith(
jasmine.objectContaining({ type: 'ERROR' }),
);
});
});

View file

@ -0,0 +1,109 @@
import { Injectable, inject } from '@angular/core';
import { Store } from '@ngrx/store';
import { Observable, Subject, combineLatest, firstValueFrom, of } from 'rxjs';
import { distinctUntilChanged, map, startWith, switchMap } from 'rxjs/operators';
import { selectEnabledIssueProviders } from '../issue/store/issue-provider.selectors';
import { IssueProvider, IssueProviderPlainspace } from '../issue/issue.model';
import { PlainspaceApiService } from '../issue/providers/plainspace/plainspace-api.service';
import { IssueService } from '../issue/issue.service';
import { SnackService } from '../../core/snack/snack.service';
import { T } from '../../t.const';
import { PlainspaceSharedTask } from './plainspace-shared-task.model';
/**
* The "claim pool": unclaimed (unassigned) Plainspace tasks for a shared
* project, shown read-only. Claiming assigns the task to the signed-in user in
* Plainspace and imports it as a first-class SP task the only way unclaimed
* work enters SP (docs §7). A project is "shared" when it has a bound, enabled
* `PLAINSPACE` provider (`defaultProjectId === projectId`).
*/
@Injectable({ providedIn: 'root' })
export class PlainspaceClaimPoolService {
private _store = inject(Store);
private _plainspaceApiService = inject(PlainspaceApiService);
private _issueService = inject(IssueService);
private _snackService = inject(SnackService);
// Pings the pool to re-fetch after a claim so the claimed task leaves the pool.
private readonly _refresh$ = new Subject<void>();
unclaimedTasksForProject$(projectId: string): Observable<PlainspaceSharedTask[]> {
// Editing any issue provider re-emits the list; collapse to this project's
// bound provider and only re-fetch when its identity actually changes...
const provider$ = this._store.select(selectEnabledIssueProviders).pipe(
map((providers) => this._findProvider(providers, projectId)),
distinctUntilChanged(
(a, b) => a?.id === b?.id && a?.token === b?.token && a?.spaceId === b?.spaceId,
),
);
// ...or on an explicit refresh (after a claim), so the claimed task leaves
// the pool even though the provider itself is unchanged.
return combineLatest([provider$, this._refresh$.pipe(startWith(undefined))]).pipe(
switchMap(([provider]) =>
provider ? this._unclaimedForProvider$(provider) : of([]),
),
);
}
/**
* Claims an unclaimed task: assigns it to me in Plainspace, then imports it as
* an SP task (added to the project backlog).
*/
async claim(projectId: string, taskId: string): Promise<void> {
const providers = await firstValueFrom(
this._store.select(selectEnabledIssueProviders),
);
const provider = this._findProvider(providers, projectId);
if (!provider) {
return;
}
const claimed = await firstValueFrom(
this._plainspaceApiService.claimTask$(taskId, provider),
);
if (claimed) {
await this._issueService.addTaskFromIssue({
issueDataReduced: claimed,
issueProviderId: provider.id,
issueProviderKey: 'PLAINSPACE',
isAddToBacklog: true,
});
this._snackService.open({ type: 'SUCCESS', msg: T.PLAINSPACE.CLAIM_SUCCESS });
} else {
// `claimTask$` fails soft to null — most often the race where someone else
// claimed first (or an outage). One friendly message covers both; the
// refresh below drops a now-claimed task from the pool.
// shortcut: generic message — distinguish 409 "taken" from offline once the
// server contract pins the status code.
this._snackService.open({ type: 'ERROR', msg: T.PLAINSPACE.CLAIM_FAILED });
}
this._refresh$.next();
}
private _findProvider(
providers: IssueProvider[],
projectId: string,
): IssueProviderPlainspace | undefined {
return providers.find(
(p): p is IssueProviderPlainspace =>
p.issueProviderKey === 'PLAINSPACE' && p.defaultProjectId === projectId,
);
}
private _unclaimedForProvider$(
provider: IssueProviderPlainspace,
): Observable<PlainspaceSharedTask[]> {
return this._plainspaceApiService.getUnclaimedTasks$(provider).pipe(
map((issues) =>
issues.map(
(issue): PlainspaceSharedTask => ({
id: issue.id,
title: issue.title,
isDone: issue.isDone,
url: issue.url,
isRecurring: issue.isRecurring,
}),
),
),
);
}
}

View file

@ -0,0 +1,17 @@
/**
* The view-model for an unclaimed Plainspace task shown in the claim pool.
*
* These are deliberately NOT Super Productivity `Task`s: unclaimed tasks are
* shown read-only and never enter the SP task store / op-log sync until claimed.
* Mapped from `PlainspaceIssue` (the API shape) in `PlainspaceClaimPoolService`.
*/
export interface PlainspaceSharedTask {
id: string;
title: string;
isDone: boolean;
/** Absolute link to open the task in the Plainspace web UI. */
url?: string | null;
/** Repeats in Plainspace flagged in the pool so claiming a recurring
* commitment is visible up front. The cadence stays Plainspace-side. */
isRecurring: boolean;
}

View file

@ -0,0 +1,48 @@
<h2 mat-dialog-title>{{ T.PLAINSPACE.SPACE_PICKER.TITLE | translate }}</h2>
<mat-dialog-content>
@if (isLoading()) {
<p>{{ T.PLAINSPACE.SPACE_PICKER.LOADING | translate }}</p>
} @else if (hasError()) {
<p class="error">{{ T.PLAINSPACE.SPACE_PICKER.ERROR | translate }}</p>
} @else if (spaces().length) {
<p class="intro">{{ T.PLAINSPACE.SPACE_PICKER.INTRO | translate }}</p>
<mat-form-field class="space-field">
<mat-label>{{ T.PLAINSPACE.SPACE_PICKER.SELECT_LABEL | translate }}</mat-label>
<mat-select [(ngModel)]="selectedSpaceId">
@for (space of spaces(); track space.id) {
<mat-option [value]="space.id">{{ space.name }}</mat-option>
}
</mat-select>
</mat-form-field>
} @else {
<p>{{ T.PLAINSPACE.SPACE_PICKER.NONE | translate }}</p>
}
</mat-dialog-content>
<mat-dialog-actions align="end">
<button
mat-button
(click)="cancel()"
>
{{ T.G.CANCEL | translate }}
</button>
<button
mat-stroked-button
[disabled]="hasError()"
(click)="createNew()"
>
{{ T.PLAINSPACE.SPACE_PICKER.CREATE_NEW | translate }}
</button>
@if (spaces().length) {
<button
mat-flat-button
color="primary"
[disabled]="!selectedSpaceId"
(click)="link()"
>
{{ T.PLAINSPACE.SPACE_PICKER.LINK | translate }}
</button>
}
</mat-dialog-actions>

View file

@ -0,0 +1,17 @@
:host {
display: block;
max-width: 440px;
}
.intro {
margin-top: 0;
}
.space-field {
width: 100%;
}
.error {
margin-top: 0;
color: var(--c-warn);
}

View file

@ -0,0 +1,102 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatDialogRef } from '@angular/material/dialog';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule } from '@ngx-translate/core';
import { of } from 'rxjs';
import { PlainspaceSpacePickerDialogComponent } from './plainspace-space-picker-dialog.component';
import { PlainspaceAccountService } from '../plainspace-account.service';
import { PlainspaceApiService } from '../../issue/providers/plainspace/plainspace-api.service';
const SPACES = [
{ id: 's1', name: 'Space One', slug: 'one' },
{ id: 's2', name: 'Space Two', slug: 'two' },
];
const ACCOUNT = { host: 'https://plainspace.org', token: 'pat_abc', email: 'a@b.c' };
describe('PlainspaceSpacePickerDialogComponent', () => {
let component: PlainspaceSpacePickerDialogComponent;
let fixture: ComponentFixture<PlainspaceSpacePickerDialogComponent>;
let dialogRef: jasmine.SpyObj<MatDialogRef<PlainspaceSpacePickerDialogComponent>>;
let accountStub: { account: jasmine.Spy };
let apiStub: { getSpaces$: jasmine.Spy };
beforeEach(async () => {
dialogRef = jasmine.createSpyObj('MatDialogRef', ['close']);
accountStub = { account: jasmine.createSpy('account').and.returnValue(ACCOUNT) };
apiStub = { getSpaces$: jasmine.createSpy('getSpaces$').and.returnValue(of(SPACES)) };
await TestBed.configureTestingModule({
imports: [
PlainspaceSpacePickerDialogComponent,
NoopAnimationsModule,
TranslateModule.forRoot(),
],
providers: [
{ provide: MatDialogRef, useValue: dialogRef },
{ provide: PlainspaceAccountService, useValue: accountStub },
{ provide: PlainspaceApiService, useValue: apiStub },
],
}).compileComponents();
});
const createComponent = (): void => {
fixture = TestBed.createComponent(PlainspaceSpacePickerDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
};
it('loads the spaces and preselects the first', async () => {
createComponent();
await fixture.whenStable();
expect(apiStub.getSpaces$).toHaveBeenCalled();
expect(component.spaces()).toEqual(SPACES);
expect(component.selectedSpaceId).toBe('s1');
expect(component.isLoading()).toBe(false);
});
it('link() closes with the selected existing space', async () => {
createComponent();
await fixture.whenStable();
component.selectedSpaceId = 's2';
component.link();
expect(dialogRef.close).toHaveBeenCalledWith({ action: 'link', spaceId: 's2' });
});
it('link() does nothing without a selection', async () => {
apiStub.getSpaces$.and.returnValue(of([]));
createComponent();
await fixture.whenStable();
component.selectedSpaceId = null;
component.link();
expect(dialogRef.close).not.toHaveBeenCalled();
});
it('createNew() closes with the create action', () => {
createComponent();
component.createNew();
expect(dialogRef.close).toHaveBeenCalledWith({ action: 'create' });
});
it('cancel() closes with no result', () => {
createComponent();
component.cancel();
expect(dialogRef.close).toHaveBeenCalledWith();
});
it('shows the error state (not empty) when the spaces request fails', async () => {
apiStub.getSpaces$.and.returnValue(of(null));
createComponent();
await fixture.whenStable();
expect(component.hasError()).toBe(true);
expect(component.isLoading()).toBe(false);
expect(component.spaces()).toEqual([]);
});
it('closes immediately when no account is connected', async () => {
accountStub.account.and.returnValue(null);
createComponent();
await fixture.whenStable();
expect(apiStub.getSpaces$).not.toHaveBeenCalled();
expect(dialogRef.close).toHaveBeenCalledWith();
});
});

View file

@ -0,0 +1,108 @@
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import {
MatDialogActions,
MatDialogContent,
MatDialogRef,
MatDialogTitle,
} from '@angular/material/dialog';
import { FormsModule } from '@angular/forms';
import { MatFormField, MatLabel } from '@angular/material/form-field';
import { MatSelect } from '@angular/material/select';
import { MatOption } from '@angular/material/core';
import { MatButton } from '@angular/material/button';
import { TranslatePipe } from '@ngx-translate/core';
import { firstValueFrom } from 'rxjs';
import { T } from '../../../t.const';
import { PlainspaceAccountService } from '../plainspace-account.service';
import {
PlainspaceApiService,
PlainspaceSpace,
} from '../../issue/providers/plainspace/plainspace-api.service';
import { DEFAULT_PLAINSPACE_CFG } from '../../issue/providers/plainspace/plainspace-cfg-form.const';
/** What the user chose: link one of their existing spaces, or create a new one. */
export type PlainspaceSpaceChoice =
| { action: 'create' }
| { action: 'link'; spaceId: string };
/**
* Lets the user link an existing Plainspace space (so the tasks already assigned
* to them import) instead of always creating a new one. Assumes a connected
* account (the share flow ensures that first); resolves to the chosen action, or
* `undefined` if cancelled.
*/
@Component({
selector: 'plainspace-space-picker-dialog',
templateUrl: './plainspace-space-picker-dialog.component.html',
styleUrls: ['./plainspace-space-picker-dialog.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
MatDialogTitle,
MatDialogContent,
MatDialogActions,
FormsModule,
MatFormField,
MatLabel,
MatSelect,
MatOption,
MatButton,
TranslatePipe,
],
})
export class PlainspaceSpacePickerDialogComponent {
private _dialogRef =
inject<MatDialogRef<PlainspaceSpacePickerDialogComponent, PlainspaceSpaceChoice>>(
MatDialogRef,
);
private _accountService = inject(PlainspaceAccountService);
private _api = inject(PlainspaceApiService);
readonly T = T;
readonly spaces = signal<PlainspaceSpace[]>([]);
readonly isLoading = signal(true);
readonly hasError = signal(false);
selectedSpaceId: string | null = null;
constructor() {
void this._loadSpaces();
}
link(): void {
if (!this.selectedSpaceId) {
return;
}
this._dialogRef.close({ action: 'link', spaceId: this.selectedSpaceId });
}
createNew(): void {
this._dialogRef.close({ action: 'create' });
}
cancel(): void {
this._dialogRef.close();
}
private async _loadSpaces(): Promise<void> {
const account = this._accountService.account();
if (!account) {
// Should not happen — the share flow connects first — but bail safely.
this._dialogRef.close();
return;
}
const spaces = await firstValueFrom(
this._api.getSpaces$({
...DEFAULT_PLAINSPACE_CFG,
host: account.host,
token: account.token,
}),
);
// null = the request failed (vs an empty list = genuinely no spaces yet).
if (spaces === null) {
this.hasError.set(true);
} else {
this.spaces.set(spaces);
this.selectedSpaceId = spaces[0]?.id ?? null;
}
this.isLoading.set(false);
}
}

View file

@ -18,7 +18,10 @@ import { FormlyFieldConfig, FormlyFormOptions, FormlyModule } from '@ngx-formly/
import { ProjectService } from '../../project.service';
import { DEFAULT_PROJECT } from '../../project.const';
import { JiraCfg } from '../../../issue/providers/jira/jira.model';
import { CREATE_PROJECT_BASIC_CONFIG_FORM_CONFIG } from '../../project-form-cfg.const';
import {
CREATE_PROJECT_BASIC_CONFIG_FORM_CONFIG,
CreateProjectFormModel,
} from '../../project-form-cfg.const';
import { SS } from '../../../../core/persistence/storage-keys.const';
import { Subscription } from 'rxjs';
import {
@ -36,6 +39,7 @@ import { removeDebounceFromFormItems } from '../../../../util/remove-debounce-fr
import { MatButton } from '@angular/material/button';
import { TranslatePipe } from '@ngx-translate/core';
import { Log } from '../../../../core/log';
import { PlainspaceShareService } from '../../../issue/providers/plainspace/plainspace-share.service';
@Component({
selector: 'dialog-create-project',
@ -56,6 +60,7 @@ import { Log } from '../../../../core/log';
export class DialogCreateProjectComponent implements OnInit, OnDestroy {
private _project = inject<Project>(MAT_DIALOG_DATA);
private _projectService = inject(ProjectService);
private _plainspaceShareService = inject(PlainspaceShareService);
private _matDialogRef =
inject<MatDialogRef<DialogCreateProjectComponent>>(MatDialogRef);
@ -118,6 +123,8 @@ export class DialogCreateProjectComponent implements OnInit, OnDestroy {
const projectDataToSave: Project | Partial<Project> = {
...this.projectData,
};
// Never persist the transient share flag (not part of the Project model).
delete (projectDataToSave as Partial<CreateProjectFormModel>).isShareOnPlainspace;
if (this._isSaveTmpProject) {
saveToSessionStorage(SS.PROJECT_TMP, projectDataToSave);
}
@ -145,12 +152,24 @@ export class DialogCreateProjectComponent implements OnInit, OnDestroy {
const projectDataToSave: Project | Partial<Project> = {
...this.projectData,
};
// `isShareOnPlainspace` is a transient form-only flag — read it, then strip
// it so it is never persisted onto the Project entity.
const isShareOnPlainspace = !!(projectDataToSave as Partial<CreateProjectFormModel>)
.isShareOnPlainspace;
delete (projectDataToSave as Partial<CreateProjectFormModel>).isShareOnPlainspace;
let newProjectId: string | undefined;
if (projectDataToSave.id) {
this._projectService.update(projectDataToSave.id, projectDataToSave);
} else {
newProjectId = this._projectService.add(projectDataToSave);
if (isShareOnPlainspace && newProjectId) {
// Fire-and-forget: provision a Plainspace space + bound issue provider.
this._plainspaceShareService.shareProjectOnPlainspace(
newProjectId,
projectDataToSave.title || '',
);
}
}
this._isSaveTmpProject = false;
sessionStorage.removeItem(SS.PROJECT_TMP);

View file

@ -2,44 +2,65 @@ import { ConfigFormSection } from '../config/global-config.model';
import { T } from '../../t.const';
import { Project } from './project.model';
export const CREATE_PROJECT_BASIC_CONFIG_FORM_CONFIG: ConfigFormSection<Project> = {
// TODO translate
title: 'Project Settings & Theme',
key: 'basic',
// The create-project form carries one transient field beyond the Project model:
// `isShareOnPlainspace` (read on submit, then stripped — never persisted). Typing
// the section with it keeps the field key type-safe instead of an `as any` cast.
export type CreateProjectFormModel = Project & { isShareOnPlainspace?: boolean };
help: `Very basic settings for your project.`,
export const CREATE_PROJECT_BASIC_CONFIG_FORM_CONFIG: ConfigFormSection<CreateProjectFormModel> =
{
// TODO translate
title: 'Project Settings & Theme',
key: 'basic',
items: [
{
key: 'title',
type: 'input',
templateOptions: {
required: true,
label: T.F.PROJECT.FORM_BASIC.L_TITLE,
help: `Very basic settings for your project.`,
items: [
{
key: 'title',
type: 'input',
templateOptions: {
required: true,
label: T.F.PROJECT.FORM_BASIC.L_TITLE,
},
},
},
{
key: 'theme.primary' as any,
type: 'color',
templateOptions: {
label: T.F.PROJECT.FORM_THEME.L_THEME_COLOR,
{
key: 'theme.primary' as any,
type: 'color',
templateOptions: {
label: T.F.PROJECT.FORM_THEME.L_THEME_COLOR,
},
},
},
{
key: 'icon',
type: 'icon',
templateOptions: {
label: T.F.TAG.FORM_BASIC.L_ICON,
description: T.G.ICON_INP_DESCRIPTION,
{
key: 'icon',
type: 'icon',
templateOptions: {
label: T.F.TAG.FORM_BASIC.L_ICON,
description: T.G.ICON_INP_DESCRIPTION,
},
},
},
{
key: 'isEnableBacklog',
type: 'checkbox',
defaultValue: false,
templateOptions: {
label: T.F.PROJECT.FORM_BASIC.L_ENABLE_BACKLOG,
{
key: 'isEnableBacklog',
type: 'checkbox',
defaultValue: false,
templateOptions: {
label: T.F.PROJECT.FORM_BASIC.L_ENABLE_BACKLOG,
},
},
},
],
};
{
// Transient form-only field (not persisted on the Project). When checked
// on create, the dialog provisions a Plainspace space + bound issue
// provider. See docs/plainspace-integration-plan.md §6.
// Create-only: editing an existing project (model has an id) can't
// provision sharing here, so hide it rather than show a dead control.
key: 'isShareOnPlainspace',
type: 'checkbox',
defaultValue: false,
hideExpression: '!!model.id',
templateOptions: {
label: T.PLAINSPACE.SHARE_LABEL,
description: T.PLAINSPACE.SHARE_DESCRIPTION,
},
},
],
};

View file

@ -50,7 +50,12 @@
</button>
}
@if (
!task().notes && (!task().issueId || task().issueType === ICAL_TYPE) && !isSelected()
!task().notes &&
(!task().issueId ||
task().issueType === ICAL_TYPE ||
task().issueType === PLAINSPACE_TYPE) &&
!task().issueWasUpdated &&
!isSelected()
) {
<button
(click)="parent.toggleShowDetailPanel()"

View file

@ -12,7 +12,7 @@ import { T } from 'src/app/t.const';
import { TaskComponent } from '../task.component';
import { TranslateModule } from '@ngx-translate/core';
import { KeyboardConfig } from '@sp/keyboard-config';
import { ICAL_TYPE } from '../../../issue/issue.const';
import { ICAL_TYPE, PLAINSPACE_TYPE } from '../../../issue/issue.const';
import { MatIconButton } from '@angular/material/button';
import { GlobalConfigService } from '../../../config/global-config.service';
@ -44,4 +44,5 @@ export class TaskHoverControlsComponent {
}
protected readonly ICAL_TYPE = ICAL_TYPE;
protected readonly PLAINSPACE_TYPE = PLAINSPACE_TYPE;
}

View file

@ -76,7 +76,7 @@ import { isDeadlineOverdue as isDeadlineOverdueFn } from '../util/is-deadline-ov
import { isDeadlineApproaching as isDeadlineApproachingFn } from '../util/is-deadline-approaching';
import { TaskContextMenuComponent } from '../task-context-menu/task-context-menu.component';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ICAL_TYPE } from '../../issue/issue.const';
import { ICAL_TYPE, PLAINSPACE_TYPE } from '../../issue/issue.const';
import { TaskTitleComponent } from '../../../ui/task-title/task-title.component';
import { MatIcon } from '@angular/material/icon';
import { MatIconButton, MatMiniFabButton } from '@angular/material/button';
@ -197,8 +197,17 @@ export class TaskComponent implements OnDestroy, AfterViewInit {
if (this.checklistProgress() && this.toggleButtonIcon() === 'chat') {
return false;
}
// iCal and Plainspace mirror all their issue data into native task fields, so
// their detail panel only repeats what's already on the task. Don't surface the
// button just because they carry an issueId — only for real notes, a remote
// update (the 'update' icon), or when the panel is open.
const isMirroredIssueType =
t.issueType === ICAL_TYPE || t.issueType === PLAINSPACE_TYPE;
return (
t.notes || (t.issueId && t.issueType !== ICAL_TYPE) || this.isShowCloseButton()
!!t.notes ||
(!!t.issueId && !isMirroredIssueType) ||
!!t.issueWasUpdated ||
this.isShowCloseButton()
);
});

View file

@ -380,6 +380,31 @@
</div>
}
@if (isShowClaimPool()) {
<div
@expand
class="collapsible-section"
>
<collapsible
[title]="
(T.PLAINSPACE.CLAIM_POOL_TITLE | translate) +
' (' +
unclaimedTasks().length +
')'
"
[isExpanded]="!isClaimPoolHidden()"
(isExpandedChange)="isClaimPoolHidden.set(!$event)"
[isIconBefore]="true"
[isGroup]="true"
>
<plainspace-claim-pool
[tasks]="unclaimedTasks()"
[projectId]="workContextService.activeWorkContextId || ''"
></plainspace-claim-pool>
</collapsible>
</div>
}
@if (isOnTodayList() && isFinishDayEnabled()) {
<finish-day-btn [hasDoneTasks]="hasDoneTasks()"></finish-day-btn>
}

View file

@ -90,6 +90,8 @@ import { dragDelayForTouch } from '../../util/input-intent';
import { DateService } from '../../core/date/date.service';
import { PluginIndexComponent } from '../../plugins/ui/plugin-index/plugin-index.component';
import { PluginBridgeService } from '../../plugins/plugin-bridge.service';
import { PlainspaceClaimPoolComponent } from '../plainspace/claim-pool/claim-pool.component';
import { PlainspaceSharedTask } from '../plainspace/plainspace-shared-task.model';
// Stable reference used as the toSignal initial value below so the deselect
// effect can tell "the customized list hasn't emitted yet" apart from a
@ -129,6 +131,7 @@ const INITIAL_CUSTOMIZED_UNDONE_TASKS: CustomizedUndoneTasks = { list: [] };
ScheduledDateGroupPipe,
RepeatCfgPreviewComponent,
PluginIndexComponent,
PlainspaceClaimPoolComponent,
],
})
export class WorkViewComponent implements OnInit, OnDestroy {
@ -220,6 +223,18 @@ export class WorkViewComponent implements OnInit, OnDestroy {
isLaterTodayHidden = signal(!!localStorage.getItem(LS.LATER_TODAY_TASKS_HIDDEN));
isOverdueHidden = signal(!!localStorage.getItem(LS.OVERDUE_TASKS_HIDDEN));
isRepeatCfgsHidden = signal(!!localStorage.getItem(LS.REPEAT_CFGS_HIDDEN));
// Claim pool is tucked away (collapsed) by default.
isClaimPoolHidden = signal(
localStorage.getItem(LS.PLAINSPACE_CLAIM_POOL_HIDDEN) !== 'false',
);
// Unclaimed Plainspace tasks for a shared project, fed from project-task-page
// (PlainspaceClaimPoolService). Read-only until claimed; never enters the SP
// task store / op-log sync. Empty for non-shared projects and tags.
readonly unclaimedTasks = input<PlainspaceSharedTask[]>([]);
isShowClaimPool = computed(
() => this.isProjectContext() && this.unclaimedTasks().length > 0,
);
repeatCfgsForContext = toSignal(
this.workContextService.activeWorkContextTypeAndId$.pipe(
@ -397,6 +412,15 @@ export class WorkViewComponent implements OnInit, OnDestroy {
}
});
effect(() => {
// Persist 'false' when expanded; default (absent) means collapsed.
if (this.isClaimPoolHidden()) {
localStorage.removeItem(LS.PLAINSPACE_CLAIM_POOL_HIDDEN);
} else {
localStorage.setItem(LS.PLAINSPACE_CLAIM_POOL_HIDDEN, 'false');
}
});
afterNextRender(() => this._initScrollTracking());
}

View file

@ -22,6 +22,7 @@
</div>
}
<work-view
[unclaimedTasks]="unclaimedTasks()"
[backlogTasks]="backlogTasks()"
[doneTasks]="doneTasks()"
[isShowBacklog]="isShowBacklog()"

View file

@ -3,9 +3,12 @@ import { MatButton } from '@angular/material/button';
import { TranslatePipe } from '@ngx-translate/core';
import { WorkContextService } from '../../features/work-context/work-context.service';
import { toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs/operators';
import { distinctUntilChanged, map, switchMap } from 'rxjs/operators';
import { of } from 'rxjs';
import { WorkViewComponent } from '../../features/work-view/work-view.component';
import { ProjectService } from '../../features/project/project.service';
import { PlainspaceClaimPoolService } from '../../features/plainspace/plainspace-claim-pool.service';
import { PlainspaceSharedTask } from '../../features/plainspace/plainspace-shared-task.model';
import { T } from '../../t.const';
@Component({
@ -18,9 +21,28 @@ import { T } from '../../t.const';
export class ProjectTaskPageComponent {
workContextService = inject(WorkContextService);
private readonly _projectService = inject(ProjectService);
private readonly _plainspaceClaimPoolService = inject(PlainspaceClaimPoolService);
readonly T = T;
// Unclaimed Plainspace tasks (only for projects shared on Plainspace); fed
// into the work view's read-only "claim pool" panel. `currentProject$` re-emits
// on every task add/complete/reorder (the project entity carries taskIds), so
// distinct on the id first — otherwise the pool re-fetches `/claimable-tasks`
// on every task change.
readonly unclaimedTasks = toSignal(
this._projectService.currentProject$.pipe(
map((project) => project?.id ?? null),
distinctUntilChanged(),
switchMap((projectId) =>
projectId
? this._plainspaceClaimPoolService.unclaimedTasksForProject$(projectId)
: of([] as PlainspaceSharedTask[]),
),
),
{ initialValue: [] as PlainspaceSharedTask[] },
);
isShowBacklog = toSignal(
this.workContextService.activeWorkContext$.pipe(
map((workContext) => !!workContext.isEnableBacklog),

View file

@ -2892,6 +2892,56 @@ const T = {
TIME_SPENT_TODAY_BY_TAG: 'PDS.TIME_SPENT_TODAY_BY_TAG',
WEEK: 'PDS.WEEK',
},
PLAINSPACE: {
CLAIM: 'PLAINSPACE.CLAIM',
CLAIM_FAILED: 'PLAINSPACE.CLAIM_FAILED',
CLAIM_POOL_TITLE: 'PLAINSPACE.CLAIM_POOL_TITLE',
CLAIM_SUCCESS: 'PLAINSPACE.CLAIM_SUCCESS',
CONNECT: {
CONNECT: 'PLAINSPACE.CONNECT.CONNECT',
CONNECTING: 'PLAINSPACE.CONNECT.CONNECTING',
EMAIL_HINT: 'PLAINSPACE.CONNECT.EMAIL_HINT',
INTRO: 'PLAINSPACE.CONNECT.INTRO',
INVALID: 'PLAINSPACE.CONNECT.INVALID',
OPEN_LINK: 'PLAINSPACE.CONNECT.OPEN_LINK',
STEP_1: 'PLAINSPACE.CONNECT.STEP_1',
STEP_2: 'PLAINSPACE.CONNECT.STEP_2',
STEP_3: 'PLAINSPACE.CONNECT.STEP_3',
STEP_4: 'PLAINSPACE.CONNECT.STEP_4',
TITLE: 'PLAINSPACE.CONNECT.TITLE',
TOKEN_LABEL: 'PLAINSPACE.CONNECT.TOKEN_LABEL',
TOKEN_PLACEHOLDER: 'PLAINSPACE.CONNECT.TOKEN_PLACEHOLDER',
},
FORM: {
HOST: 'PLAINSPACE.FORM.HOST',
SPACE_ID: 'PLAINSPACE.FORM.SPACE_ID',
SPACE_ID_DESCRIPTION: 'PLAINSPACE.FORM.SPACE_ID_DESCRIPTION',
TOKEN: 'PLAINSPACE.FORM.TOKEN',
TOKEN_DESCRIPTION: 'PLAINSPACE.FORM.TOKEN_DESCRIPTION',
},
FORM_SECTION: {
HELP: 'PLAINSPACE.FORM_SECTION.HELP',
TITLE: 'PLAINSPACE.FORM_SECTION.TITLE',
},
LOGIN_REQUIRED: 'PLAINSPACE.LOGIN_REQUIRED',
NO_UNCLAIMED: 'PLAINSPACE.NO_UNCLAIMED',
OPEN_IN_PLAINSPACE: 'PLAINSPACE.OPEN_IN_PLAINSPACE',
RECURRING: 'PLAINSPACE.RECURRING',
SHARE_DESCRIPTION: 'PLAINSPACE.SHARE_DESCRIPTION',
SHARE_FAILED: 'PLAINSPACE.SHARE_FAILED',
SHARE_LABEL: 'PLAINSPACE.SHARE_LABEL',
SHARE_SUCCESS: 'PLAINSPACE.SHARE_SUCCESS',
SPACE_PICKER: {
CREATE_NEW: 'PLAINSPACE.SPACE_PICKER.CREATE_NEW',
ERROR: 'PLAINSPACE.SPACE_PICKER.ERROR',
INTRO: 'PLAINSPACE.SPACE_PICKER.INTRO',
LINK: 'PLAINSPACE.SPACE_PICKER.LINK',
LOADING: 'PLAINSPACE.SPACE_PICKER.LOADING',
NONE: 'PLAINSPACE.SPACE_PICKER.NONE',
SELECT_LABEL: 'PLAINSPACE.SPACE_PICKER.SELECT_LABEL',
TITLE: 'PLAINSPACE.SPACE_PICKER.TITLE',
},
},
PLUGINS: {
ACTION_TYPE_NOT_ALLOWED: 'PLUGINS.ACTION_TYPE_NOT_ALLOWED',
ALREADY_INITIALIZED: 'PLUGINS.ALREADY_INITIALIZED',

View file

@ -2827,6 +2827,56 @@
"TIME_SPENT_TODAY_BY_TAG": "Time Spent today by Tag",
"WEEK": "Week"
},
"PLAINSPACE": {
"CLAIM": "Claim",
"CLAIM_FAILED": "Couldn't claim this task — it may have just been claimed by someone else, or you're offline.",
"CLAIM_POOL_TITLE": "Available to claim",
"CLAIM_SUCCESS": "Task claimed and added to your list.",
"CONNECT": {
"CONNECT": "Connect",
"CONNECTING": "Connecting…",
"EMAIL_HINT": "A Space needs a verified email before it can create tokens.",
"INTRO": "Paste a Plainspace API token to import the tasks assigned to you and to share projects. Creating one in Plainspace only takes a moment:",
"INVALID": "That token didn't work. Make sure you copied the whole token (it starts with \"pat_\") and try again.",
"OPEN_LINK": "Open Plainspace",
"STEP_1": "Open Plainspace and pick one of your Spaces (or create one).",
"STEP_2": "Open the People panel, then expand Advanced → API tokens.",
"STEP_3": "Name a token (e.g. \"Super Productivity\") and create it.",
"STEP_4": "Copy the token and paste it below.",
"TITLE": "Connect to Plainspace",
"TOKEN_LABEL": "API token",
"TOKEN_PLACEHOLDER": "pat_…"
},
"FORM": {
"HOST": "Plainspace host",
"SPACE_ID": "Space ID",
"SPACE_ID_DESCRIPTION": "The space this provider syncs with — paste the id or the slug shown in the space URL (plainspace.org/<slug>/…).",
"TOKEN": "API token",
"TOKEN_DESCRIPTION": "Personal API token (starts with \"pat_\"). In Plainspace, open a Space → People → Advanced → API tokens to create one."
},
"FORM_SECTION": {
"HELP": "Connect a Plainspace space with a personal API token to import tasks assigned to you and claim unclaimed tasks.",
"TITLE": "Plainspace"
},
"LOGIN_REQUIRED": "A Plainspace API token is required to share a project.",
"NO_UNCLAIMED": "No unclaimed tasks right now.",
"OPEN_IN_PLAINSPACE": "Open in Plainspace",
"RECURRING": "Recurring task",
"SHARE_DESCRIPTION": "Creates a shared Plainspace space and links it to this project so you can collaborate with others.",
"SHARE_FAILED": "Could not share this project on Plainspace.",
"SHARE_LABEL": "Collaborate on this project on Plainspace (Beta)",
"SHARE_SUCCESS": "Project shared on Plainspace. Tasks assigned to you will import automatically.",
"SPACE_PICKER": {
"CREATE_NEW": "Create new space",
"ERROR": "Couldn't reach Plainspace. Check your connection and that your token is still valid, then try again.",
"INTRO": "Link an existing space to import the tasks assigned to you, or create a new one.",
"LINK": "Link space",
"LOADING": "Loading your spaces…",
"NONE": "You don't have any spaces yet — create one to get started.",
"SELECT_LABEL": "Existing space",
"TITLE": "Choose a Plainspace space"
}
},
"PLUGINS": {
"ACTION_TYPE_NOT_ALLOWED": "Action type \"{{type}}\" is not allowed",
"ALREADY_INITIALIZED": "Plugin is already initialized",

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="29 29 70 70" fill="currentColor">
<circle cx="56" cy="64" r="27" opacity="0.85" />
<circle cx="72" cy="64" r="27" opacity="0.55" />
</svg>

After

Width:  |  Height:  |  Size: 192 B