No description
Find a file
Johannes Millan 77557077f8
fix(supersync): split the conflict entity lookup to avoid a full-history scan (#9195)
* fix(supersync): split conflict entity lookup to avoid full-history scan

detectConflictForEntity ran one Prisma filter with
`OR: [{ entityId }, { entityIds: { has: entityId } }]` and
`orderBy: { serverSeq: 'desc' }`. The OR spans the
(user_id, entity_type, entity_id, server_seq) btree and the entity_ids
GIN index, and GIN cannot supply server_seq ordering — so with LIMIT 1
the planner abandoned both index paths and walked server_seq backwards
applying the OR as a filter, betting it would exit early. For an entity
with no matching rows (the first op for a new task, the most common
upload there is) that bet loses and it scans the user's whole history.

The function runs twice per operation on the default serial upload path,
so every upload paid it twice.

Production incident: 47 backends stuck on this query, longest 75 minutes,
wait_event=DataFileRead, 61 of 66 connections consumed. Prisma pool
exhaustion made every user's upload AND download fail with "Unable to
start a transaction in the given time". No index was missing or invalid.

Split into separately-indexed lookups:
- scalar branch: findFirst on {userId, entityType, entityId} — the btree
  covers all equality columns plus the sort column
- array branch: aggregate({_max: {serverSeq}}) — Prisma's OFFSET 0
  subquery is a planner fence that forbids the LIMIT-driven walk and
  forces the GIN bitmap path
- winner fetch: findUnique on the (userId, serverSeq) unique key, only
  when the array max beats the scalar

Measured on production Postgres (88k-op user, zero-match probe) after an
ANALYZE and a GIN pending-list flush: old shape 12 buffers, new shape 9
(scalar 4 + array 5). In that regime the two are comparable — the value
of this split is NOT throughput. It is that neither branch offers the
planner a LIMIT-driven early-exit bet, so it cannot degenerate into the
backward walk regardless of statistics. Even post-ANALYZE the planner
still estimates rows=96 for a zero-match `entity_ids @>` key, so the old
shape stays one autovacuum re-sample away from the incident plan.

Wall-clock is not quoted: on a live server the same old-shape query
measured 255ms and 915ms on consecutive runs while its buffer count
fell. Buffers are the stable metric here.

Semantics are unchanged: both forms return the highest-serverSeq row
across the union of the two predicates. Ties need no tie-break because
@@unique([userId, serverSeq]) makes an equal serverSeq the same row.
The legacy GLOBAL_CONFIG:misc alias path is untouched.

The batch paths (detectConflictForEntities, prefetchLatestEntityOpsForBatch)
are not exposed to this trap — no LIMIT, and DISTINCT ON forces full
evaluation — and are left unchanged.

* fix(supersync): force the GIN path with a MATERIALIZED CTE

The previous commit's array branch used `aggregate({_max: {serverSeq}})`,
relying on Prisma's `OFFSET 0` subquery as a planner fence. Production
EXPLAIN under `SET plan_cache_mode = force_generic_plan` shows that does
not work:

  Aggregate
    -> Index Scan using operations_user_id_entity_type_entity_id_server_seq_idx
         Index Cond: ((user_id = $1) AND (entity_type = $2))
         Filter: (entity_ids @> ARRAY[$3])

It scans the whole (user_id, entity_type) slice — ~88k rows for a
deep-history user. Dropping LIMIT removed the early-exit bet but did not
force the GIN path: the planner costs btree+filter at ~80 against ~875
for the GIN bitmap and takes the btree.

The root reason both earlier designs were misjudged: Prisma sends
parameterized prepared statements, so after ~5 executions Postgres builds
a GENERIC plan that cannot see parameter values. Every EXPLAIN run with
literal constants measures a custom plan production never receives. Two
designs looked correct that way and were catastrophic in production.

Array branch is now raw SQL:

  WITH cand AS MATERIALIZED (
    SELECT user_id, entity_type, server_seq FROM operations
    WHERE entity_ids @> ARRAY[$1]::text[]
  )
  SELECT MAX(server_seq)::int AS "maxSeq" FROM cand
  WHERE user_id = $2 AND entity_type = $3

Inside the CTE the only predicate is `entity_ids @>`, so the composite
btree has no usable leading column and GIN is the only option — the plan
is forced structurally rather than won on cost, which is what makes it
survive statistics drift. MATERIALIZED prevents the outer predicates
being pushed down, which would hand the btree back.

Adding `server_seq > <scalar>` to bound the read was evaluated and
rejected: under generic planning the value is invisible, so it lands as a
post-GIN filter and buys nothing, and placing it inside the CTE creates a
new cliff (no leading-column anchor, so cost scales with total table size
rather than the probing user's history).

Semantics unchanged: verified by differential testing old-vs-new across
20,000 randomized in-memory histories and 1,500 against real Postgres,
zero divergences, with 18 sabotage mutations confirming the harness can
fail.

Tests: mocks moved from `aggregate` to `$queryRaw` behind a shared
discriminator; all plan assertions now run under force_generic_plan; the
plan spec's block accounting no longer double-counts (Postgres buffer
counts are cumulative, so parents already include children).

* test(supersync): make the conflict-lookup plan spec able to fail

The plan spec could not catch the outage it was written for. Three
independent problems, each of which made a load-bearing property
invisible:

1. The spec seeded ONE user and ONE entity_type. In that shape the GIN
   estimate (DEFAULT_CONTAIN_SEL * whole table) and the btree-slice
   estimate (N / users*entity_types) are the same rows, so GIN always
   wins on cost and no regression is detectable. Reseeded to 20k rows for
   the probed user plus 20k across ~20k other users over 8 entity types,
   which reproduces production's plan node-for-node. Seeds in ~490ms.

2. The measuring shim rebuilt the array-branch SQL from a CONSTANT, so
   changing MAX(server_seq) to MIN in conflict.ts could never be
   observed. It now reconstructs the SQL from the real tagged template,
   putting the aggregate, the MATERIALIZED fence and the CTE shape
   genuinely under test.

3. The shim ignored `select`, so dropping actionType from the array
   branch was invisible — that field decides whether concurrent
   time-tracking deltas merge or get rejected. It now honors `select` and
   throws on an unmapped key.

Before this, all three of these mutations left the suite green:
MAX->MIN (silently accepts a write that should conflict, overwriting a
concurrent remote edit), dropping actionType (rejects a client's tracked
time), and hardcoding userId in the findUnique.

Measured after reseeding: shipped CTE 2 blocks / 0 rows filtered; the old
OR, the flat MAX, Prisma's aggregate form, and MATERIALIZED-dropped all
land at 806 blocks / 2500 filtered. One budget assertion (<100 blocks)
now catches every known route back to the incident, with a 403x margin.
All plan assertions run under force_generic_plan; plan-NAME assertions
were removed after measurement showed the old OR no longer plans as a
Backward walk at this seed.

Also corrects a false claim this change introduced. The comment justified
the cross-tenant CTE scan with "entity ids are client-generated nanoids,
so overlap is not expected". The client hard-codes globally shared ids
(TODAY, EM_URGENT, EM_IMPORTANT, KANBAN_IN_PROGRESS, INBOX_PROJECT,
EISENHOWER_MATRIX, KANBAN_DEFAULT, plus fixed GLOBAL_CONFIG keys) which
are byte-identical for every user, and updateBoard({id:'KANBAN_DEFAULT'})
routes here as a single-entity op. Measured: 3885 blocks to reach the 10
rows belonging to the probing user, versus 4 for a unique id. Correctness
is preserved by the outer WHERE user_id; cost is not bounded per-user.
The fix is a btree_gin composite index, which needs a real-Postgres
EXPLAIN (PGlite lacks the extension) and is filed separately.

Deletions: the entity-ids-conflict helper that hand-wrote SQL production
never sends and modelled the abandoned aggregate design (-71 lines), a
dead aggregate mock that would have MASKED a regression by returning an
undefined max, the server_seq-bound test that asserted a rejected design
still works (so it could never fail informatively), and four stale
comments describing a call that no longer exists.

sync-operations.spec.ts had a $queryRaw mock never wired to the array
branch; it fell through to a vestigial value that reads as null, so every
conflict assertion there ran with the array branch stubbed out. Wired up,
and all six raw-query sites now throw on an unrecognised query rather
than falling through.

* test(supersync): seed the conflict-lookup plan spec in production order

Building the GIN after the bulk load produced a pending-list-free index and
measured 2 blocks for the array branch - a state production only sees right
after a vacuum. With the index pre-existing and rows arriving one at a time,
as in production, the same query reads 140 blocks against a 14x larger index.

Seed in production order and never vacuum, so the spec measures the worst
realistic steady state, and re-derive the budget (100 -> 300) against it.

Also move the GIN-not-btree structural assertions onto the real tagged
template, and replace the five hardcoded regression shapes with a single
canary whose stated job is proving the seed still reproduces the mis-plan.
The five EXPLAINed strings that exist only in the test file and responded to
no source mutation; the budget and plan assertions on the real SQL do.

* docs(supersync): correct two false claims in the conflict-lookup comment

The cross-tenant scan vector was wrong. updateTag({id:'TODAY'}) and
updateBoard({id:'KANBAN_DEFAULT'}) are single-entity, so getStoredEntityIds
persists '{}' and they never enter the GIN under that id. The actual vector is
the bulk sortBoards action, which stores the shared board ids in entity_ids.

'Needs btree_gin, unverifiable in PGlite' was also wrong: a GIN index on
(ARRAY['u:'||user_id] || entity_ids) has a text[] operand served by the
built-in array_ops, builds in PGlite with no extension, and was measured
flat against a baseline that scales with tenant count. Recorded as a
tradeoff rather than a free win - it needs the predicate rewritten to match
and indexes every row rather than the multi-entity minority.

Drop the force_generic_plan methodology lecture duplicated from the spec
header, keeping a pointer.

Also make the #8334 mock honest: StoredRow now requires actionType and
findUnique honours select, so a dropped column changes what it returns
instead of silently yielding undefined.

* test(supersync): cover the scalar branch picking the newest op

Flipping detectConflictForEntity's scalar orderBy from 'desc' to 'asc' passed
the entire 914-test server suite. Every existing case gave an entity at most
one scalar row, so the ordering was unobservable.

The bug it hides is silent data loss: with the oldest row returned, conflict
detection compares the incoming clock against a stale one, so an op that is a
clean successor of the OLD state but CONCURRENT with the current one is
accepted and overwrites a remote edit.

Seed two scalar rows whose verdicts differ, and verify by mutation that the
test is red under 'asc' and green under 'desc'.

* docs(supersync): stop the vector-clock doc recommending the outage query

vector-clocks.md still described detectConflictForEntity as the combined
OR + orderBy filter and vouched for it - 'a BitmapOr + sort bounded by the
entity's stored version depth (op-log pruning keeps that small)'. That is the
exact false assumption behind the 2026-07-20 outage, in the authoritative doc.
Worse, the escalation it recommended (two ordered LIMIT 1 lookups) is itself a
measured-catastrophic shape: the array side still cannot order on GIN.

Also fix a test that could not fail. 'does not alias a POST-split misc write
onto tasks' probed 'tasks-v2-only', but the legacy-misc branch is entered only
for entityId === 'tasks', so it never reached the gate it claimed to guard -
breaking the gate to 'lte' left all 915 tests green. It now probes 'tasks'
under its own tenant, and is verified red under that mutation.

Correct three overclaims in the comments: production does get custom plans for
the first ~5 executions before settling generic; a partial expression GIN can
skip the single-entity majority; fastupdate=off stops new pending entries but
does not flush existing ones, and 140 blocks is not a ceiling.

* docs(supersync): pin the two fail-open invariants in the conflict lookup

Both are unreachable today and both fail toward ACCEPTING a conflicting
write if a future edit breaks them, which is silent data loss rather than
an error. No runtime guards - they could never fire, and the sync layer does
not need more unreproducible hardening. Documented at the exact spot an
editor would stand instead.

- The aggregate fold is safe only because a bare aggregate returns exactly
  one row; a GROUP BY (or a revert to Prisma aggregate()) makes zero rows
  possible, which reads as 'no prior op'.
- 'arrayOp ?? scalarOp' conflates 'array branch lost' with 'array row
  vanished'. The second needs RepeatableRead to stay unreachable.

* docs(supersync): stop overclaiming GIN is forced; fix the batch-lookup doc

Three misleading safety claims, all in text a future contributor would rely on:

- vector-clocks.md still documented the batch lookup as the mutually exclusive
  'CASE WHEN cardinality(entity_ids) > 0 THEN entity_ids ELSE ARRAY[entity_id]'.
  Production unions the two columns instead, and the exclusive form is the
  #8334 bug: it drops a scalar that is not a member of its own entity_ids, so
  a later concurrent write to that entity is wrongly accepted - silent data
  loss. The section above it was rewritten last commit; this one was missed.

- 'The CTE wins STRUCTURALLY, not on cost' overstated. Removing the competing
  btree is structural; GIN being chosen is not. A sequential scan is always
  available and wins for an unselective id - reproducible on PG16, and exactly
  what a globally shared entity id produces. Reframed as a measured outcome,
  since a confident comment asserting what the planner will do is what
  preceded the outage.

- The plan spec still said production receives 'a CUSTOM plan nobody
  receives'. It receives custom plans for roughly the first 5 executions
  before settling generic, and this file covers only the generic mode.

* docs(supersync): correct the plan-cache and aggregate claims; match Int in tests

Round-4 review. Four inaccuracies, all in text a contributor would rely on.

- plan_cache_mode=auto does NOT unconditionally switch to generic after 5
  executions: it compares the generic cost against the average custom cost
  and may keep using custom plans indefinitely. Stated as an observation for
  THIS statement (custom_plans=5, generic_plans=15 on production) rather than
  a universal rule, in conflict.ts, the plan spec and vector-clocks.md.

  Note: commit fdf0f62011 claimed this fix for conflict.ts. It did not
  contain it - the edit was lost and the commit message was not verified
  against the diff. Applied here for real.

- Prisma's bare aggregate() cannot return zero rows; it returns one object
  with _max.serverSeq null. Only GROUP BY can produce zero rows. The fold
  comment claimed otherwise.

- The test schema declared server_seq bigint while production maps it as
  Prisma Int. MAX() therefore returned bigint and the shim's Number()
  coercion hid it, so dropping the ::int cast from the production SQL would
  have stayed green. Test schema now matches production.

- detectConflictForEntity's JSDoc still described the path as using Prisma's
  typed model API; its array branch is raw SQL. vector-clocks.md's flow
  summary likewise still described a single findFirst.

* docs(supersync): correct the remaining false claims in the conflict lookup

Comment/doc only; no runtime change. 915 server tests still pass.

conflict.ts:
- the expression-GIN paragraph argued against the index because it "indexes
  EVERY row". A PARTIAL index (WHERE entity_ids <> '{}') avoids that and is
  lossless: single-entity ops store '{}', so their indexed expression is just
  ARRAY['u:<id>'] and can never contain the probed id. Names the real caveat
  instead — the query must carry a matching predicate or the index is ignored.
- the array-only findFirst was labelled "the outage"; the outage was the
  combined OR, as stated 30 lines earlier.
- dropped the cross-reference to a "shared id produces a Seq Scan" measurement
  that the cross-tenant note does not contain (added in 27d1baa9b7).
- single-entity writes against a shared id are not a GIN *population* vector
  but ARE a *probe* vector: updateBoard({id:'KANBAN_DEFAULT'}) routes through
  detectConflictForEntity and probes that literal across every tenant.
- the INVARIANT note's hazard does not materialise: GROUP BY user_id yields
  zero rows only when zero rows matched, identical to MAX -> NULL. The real
  hazard is a grouping that returns MORE than one row.
- "batch unnest paths are NOT exposed" excluded only the early-exit
  degeneracy; they carry the same two-index OR, so the slice-scan one is not
  excluded and neither batch query is EXPLAINed (#9205).

plan spec:
- budget arithmetic: 816/300 is 2.7x over budget, not 6x/5.7x (5.7 is
  816/143, regression-to-measured). Real headroom is the smaller number.
- drops "reproduces the production plan node-for-node", which contradicted
  the stated different-major-version fidelity limit two paragraphs down.
- the index set is a deliberate subset, not a mirror of schema.prisma.
- the server_seq integer change is a fidelity fix; it does not make dropping
  ::int catchable (MAX over integer already returns integer).

vector-clocks.md:
- 816 blocks/2500 rows is the outage query specifically and the only shape
  still pinned by a test, not all five.
- the forward-only paragraph pointed at the removed CASE expression, two
  paragraphs below the warning that that form IS the #8334 data-loss bug.

* docs(supersync): mark the partial-index suggestion as UNMEASURED

The paragraph recommending GIN(...) WHERE entity_ids <> '{}' steers whoever
picks up #9199, and it conflated two claims of very different strength.

Splits them: losslessness is a claim about the DATA and holds by inspection
(getStoredEntityIds collapses single-entity sets to [], entity_ids is NOT NULL
DEFAULT '{}', '{}' @> ARRAY[<id>] is false). Whether the partial form is
USABLE is a claim about the planner — it needs Postgres to prove a query-side
`entity_ids <> '{}'` implies the index predicate for an array `<>` — and that
was reasoned about, never run.

Says so explicitly rather than leaving a confident-sounding recommendation.
A comment asserting unmeasured planner behaviour is what preceded the outage.

* fix(supersync): model the entity_ids GIN in schema.prisma and cover tenant isolation

Two defects found in review of the conflict-lookup split.

1. The GIN existed ONLY as raw migration SQL, so `prisma db push` never
   created it. Verified with `prisma migrate diff --from-empty`: db push
   built four btree/unique indexes on operations and no GIN. CI and the
   SuperSync E2E jobs use `db push --skip-generate`, and README's manual
   setup and db-push baselining path do too — the last one can mark
   20260613000001 applied without ever executing it.

   On such a database this split is a REGRESSION, not merely un-helped:
   the old query filtered user_id/entity_type first, so a bad plan scanned
   one user's slice, while the new CTE deliberately carries no user
   predicate and is MATERIALIZED so none can push down. Without the index
   every probe Seq Scans every tenant's rows, twice per accepted op.

   The schema comment claiming Prisma cannot model GIN array indexes was
   wrong. `@@index([entityIds], type: Gin, map: ...)` validates on 5.22 and
   emits the same DDL; stored indexdef is byte-identical to the migration's,
   so migrate-based deployments see no drift. Also notes the restore-point
   PARTIAL index, which genuinely cannot be modelled and is still missing on
   db-push databases (#9192).

2. The CTE's outer user_id / entity_type predicates had no real-SQL
   coverage: replacing both with typed tautologies left 915/915 green. The
   failure is silent because server_seq is per-user, so a leaked MAX still
   resolves to a real row of the requesting user and an unrelated op becomes
   the conflict basis. Adds two PGlite cases seeding exactly that collision.
   Sabotage-verified: both predicates tautologized fails both; user-only
   fails only the cross-tenant case; type-only fails only the cross-type one.

Also corrects vector-clocks.md, which claimed four alternative shapes were
unguarded. Verified by mutation that dropping MATERIALIZED (2 failures) and
MAX -> MIN (1) do fail the budget spec; only their historical block counts
are unpinned.
2026-07-20 19:33:57 +02:00
.agents/skills/commit-messages chore(config): adopt AGENTS.md as shared AI-agent config with skills (#8864) 2026-07-09 15:30:56 +02:00
.air 18.4.2 2026-05-01 23:07:19 +02:00
.codex chore: add project-scoped Angular MCP 2026-07-13 10:31:37 +02:00
.devcontainer chore: add git and testing tools out of the box in devcontainers 2025-05-12 11:13:06 +02:00
.github ci: stop running redundant unit tests on the macOS build job (#9129) 2026-07-17 23:15:22 +02:00
.husky fix(build): auto-generate env.generated.ts on checkout via husky hook 2026-03-06 16:39:21 +01:00
.signpath/policies/super-productivity build: sign path setup 4 2026-01-28 12:51:50 +01:00
.vscode chore: add git and testing tools out of the box in devcontainers 2025-05-12 11:13:06 +02:00
android 18.15.1 2026-07-17 23:17:53 +02:00
build 18.15.1 2026-07-17 23:17:53 +02:00
docs fix(supersync): split the conflict entity lookup to avoid a full-history scan (#9195) 2026-07-20 19:33:57 +02:00
e2e test(planner): use distinct dates in subtask visibility spec (#9193) 2026-07-20 13:19:36 +02:00
electron fix(sync): defer LocalFile folder pick commit to settings Save (#9075) (#9085) 2026-07-16 19:11:02 +02:00
eslint-local-rules fix(locale): consolidate textLocale, fix planner month label, enforce via lint (#8987) (#9065) 2026-07-16 22:33:23 +02:00
fastlane fix(sync): name the discarded title in LWW conflict banner + fix fr dismiss label (#8694) (#8724) 2026-07-03 14:13:43 +02:00
ios feat(rate-dialog): calm, recurring, win-timed store rating prompt (#8704) 2026-07-02 13:52:10 +02:00
nginx refactor(e2e): migrate to production Dockerfile for E2E tests 2026-01-21 14:30:24 +01:00
packages fix(supersync): split the conflict entity lookup to avoid a full-history scan (#9195) 2026-07-20 19:33:57 +02:00
scripts chore(scripts): remove one-off codemod scripts [#8260 - Tier A] (#8893) 2026-07-11 10:36:24 +02:00
snap/hooks fix(snap): add filesystem and desktop integration plugs 2026-01-17 12:44:30 +01:00
src fix(metrics): remove productivity score drop when focus passes target (#9177) 2026-07-20 17:57:11 +02:00
tools fix(theme): polish, atomic activation, and validator hardening (#9157) 2026-07-18 18:49:00 +02:00
.browserslistrc build: update browser support list 2025-08-13 19:47:44 +02:00
.dockerignore fix(docker): simplify env handling for Docker builds 2025-08-09 12:16:31 +02:00
.editorconfig chore: update gradle/java indent_size to 4 2024-09-29 09:40:49 +08:00
.env.example docs: change template of the .env file to include the mandatory unsplash key 2025-08-12 18:10:59 +02:00
.gitattributes chore: fix LF/CRLF for errant SCSS file (again) (#7117) 2026-04-09 19:42:54 +02:00
.gitignore fix(sync): recover from migration-path hydration failures via op-log replay (#9153) 2026-07-18 18:22:08 +02:00
.gitmodules chore: Update android submodule to use feat/platform-android-offline branch (for capacitor) 2024-09-12 09:49:41 +08:00
.gitpod.yml refactor: make prettier work for angular 2025-02-21 14:31:22 +01:00
.npmrc chore(deps): add cooldown for NPM and GH Actions to reduce supply chain attack risk (#7685) 2026-05-20 11:50:30 +02:00
.nvmrc feat: add .nvmrc file with Node.js v22.18.0 2025-08-13 19:47:44 +02:00
.prettierignore feat(sync): add Helm chart and WebSocket push for SuperSync (#6971) 2026-03-30 21:34:30 +02:00
.prettierrc.json refactor: make prettier work for angular 2025-02-21 14:31:22 +01:00
.stylelintrc.mjs build(stylelint): fix font-family-no-missing-generic-family-keyword 2025-01-04 13:49:50 +01:00
AGENTS.md fix(sync): recover from migration-path hydration failures via op-log replay (#9153) 2026-07-18 18:22:08 +02:00
angular.json refactor(sync-providers): extract local file provider 2026-05-13 11:36:19 +02:00
ARCHITECTURE-DECISIONS.md fix(sync): make marked project deletions win LWW conflicts (#9009) 2026-07-14 19:58:33 +02:00
capacitor.config.ts feat(android): migrate edge-to-edge to built-in SystemBars (#8543) 2026-06-22 16:07:06 +02:00
CLAUDE.md chore(config): adopt AGENTS.md as shared AI-agent config with skills (#8864) 2026-07-09 15:30:56 +02:00
CONTRIBUTING.md docs(sync): consolidate sync docs + enforce the contributor model 2026-05-15 16:51:50 +02:00
docker-compose.e2e.fast.yaml fix(ci): fix WebDAV config path for hacdias/webdav v5 2026-02-16 11:07:52 +01:00
docker-compose.e2e.yaml fix(ci): fix WebDAV config path for hacdias/webdav v5 2026-02-16 11:07:52 +01:00
docker-compose.supersync.yaml fix(dev): update default SuperSync port to 1901 for local development 2026-01-24 21:14:57 +01:00
docker-compose.yaml fix(infra): close db-startup race in supersync e2e stack 2026-04-29 16:17:56 +02:00
docker-entrypoint.sh refactor(e2e): migrate to production Dockerfile for E2E tests 2026-01-21 14:30:24 +01:00
Dockerfile fix(docker): include sync packages in image build 2026-05-16 20:48:38 +02:00
Dockerfile.e2e.dev feat(e2e): add Docker-based E2E test isolation 2026-01-04 17:09:39 +01:00
Dockerfile.e2e.dev.fast build(e2e): add fast local Docker Compose setup for E2E tests 2026-01-09 18:00:24 +01:00
electron-builder.yaml fix(caldav-plugin): make recurring-occurrence edits/deletes safe and quiet #7492 (#8149) 2026-06-08 16:05:52 +02:00
eslint.config.js refactor(sync): centralize clock pruning in store, make merge atomic (#9107) 2026-07-17 13:12:53 +02:00
funding.json chore(funding): drop broken repositoryUrl.wellKnown line 2026-05-14 17:21:39 +02:00
Gemfile 10.1.1 2024-11-06 19:44:38 +01:00
Gemfile.lock chore(deps): bump faraday in the bundler group across 1 directory (#8625) 2026-06-29 13:09:27 +02:00
LICENSE fix: typo in license 2019-01-29 18:21:51 +00:00
ngsw-config.json fix(pwa): cache the hashed icon font so it renders offline on iOS #8138 2026-06-08 18:29:55 +02:00
package-lock.json 18.15.1 2026-07-17 23:17:53 +02:00
package.json fix(theme): polish, atomic activation, and validator hardening (#9157) 2026-07-18 18:49:00 +02:00
README.md docs(readme): fix typo/missing words (#8453) 2026-06-17 16:02:08 +02:00
SECURITY.md build: update links to match our new organization 2026-01-05 14:45:06 +01:00
tsconfig.base.json fix(keyboard): resolve macOS global shortcut layout mismatch (#8378) (#8381) 2026-06-17 12:57:47 +02:00
tsconfig.json build: try to get rid of inline compilation to js 2025-04-25 12:58:16 +02:00
webdav.yaml build: simplify docker setup and fix e2e 2025-07-18 20:00:10 +02:00

Banner

An advanced todo list app with timeboxing & time tracking capabilities that supports importing tasks from your calendar, Jira, GitHub and others

🌐 Open Web App or 💻 Download


MIT license   GitHub Discussions

Reddit Community   Super Productivity on Mastodon   Tweet

animated

💻 Downloads & Install

Get it on Flathub Get it from the Snap Store English badge Play Store Badge F-Droid Badge Obtanium Badge App Store Badge

For all current downloads, package links, and platform-specific notes: check the wiki
Get it on GitHub


Ukraine Flag
Humanitarian Aid for Ukraine
Support humanitarian relief via the official National Bank of Ukraine account.


✔️ Features

  • Keep organized and focused! Plan and categorize your tasks using sub-tasks, projects and tags and color code them as needed.
  • Use timeboxing and track your time. Create time sheets and work summaries in a breeze to easily export them to your company's time tracking system.
  • Helps you to establish healthy & productive habits:
    • A break reminder reminds you when it's time to step away.
    • The anti-procrastination feature helps you gain perspective when you really need to.
    • Need some extra focus? A Pomodoro timer is also always at hand.
    • Collect personal metrics to see, which of your work routines need adjustments.
  • Integrate with Jira, Trello, GitHub, GitLab, Gitea, OpenProject, Linear, ClickUp and Azure DevOps. Auto import tasks assigned to you, plan the details locally, automatically create work logs, and get notified immediately, when something changes.
  • Basic CalDAV integration.
  • Back up and synchronize your data across multiple devices with Dropbox and WebDAV support
  • Attach context information to tasks and projects. Create notes, attach files or create project-level bookmarks for links, files, and even commands.
  • Super Productivity respects your privacy and does NOT collect any data and there are no user accounts or registration. You decide where you store your data!
  • It's free and open source and always will be.

And much more!

Work View with global links

Note

The web version has some limitations: See the Web App vs Desktop comparison for more details.

📖 Documentation and Guides

Getting Started

Starting Point in Wiki:
First stepsReferenceHow-To

Productivity Tips:
Keyboard ShortcutsShort Syntax

Need Help?
Visit the discussions page

See the bottom of the README for more information on the documentation.

Advanced Topics

Here are some other topics covered in the official wiki:

Development:
Run dev serverPackage the appBuild for AndroidRun with Docker

Data Management:
User DataIssue ProvidersSync Providers

Customization:
PluginsThemes

APIs:
Sync ServerPluginsREST

Community

The development of Super Productivity is driven by a wonderful community of users and contributors. Thank you all so much for your support!

👀 Check out our awesome curated list of community-created resources about Super Productivity

♥️ Contributing

If you want to get involved, please check out the CONTRIBUTING.md

There are several ways to help.

  1. Spread the word: More users mean more people testing and contributing to the app which in turn means better stability and possibly more and better features. You can vote for Super Productivity on Slant, Product Hunt, Softpedia or on AlternativeTo, you can tweet about it, share it on LinkedIn, reddit or any of your favorite social media platforms. Every little bit helps!

  2. Provide a Pull Request: Here is a list of the most popular community requests and here some info on how to run the development build (wiki). Please make sure that you're following the commit message format and to also include the issue number in your commit message, if you're fixing a particular issue (e.g.: feat: add nice feature #31).

  3. Answer questions: You know the answer to another user's problem? Share your knowledge!

  4. Provide your opinion: Some community suggestions are controversial. Your input might be helpful and if it is just an up- or down-vote.

  5. Provide a more refined UI spec for existing feature requests

  6. Report bugs

  7. Make a feature or improvement request: Something can be done better? Something essential missing? Let us know!

  8. Translations, Icons, etc.: You don't have to be a programmer to help; learn how to contribute translations!

  1. Sponsor the project

  2. Create custom plugins or custom themes

Special Thanks to our Sponsors!!!

Recently support for Super Productivity has been growing! A big thank you to all our sponsors!

(If you are, intend to or have been a sponsor and want to be shown here, please let me know!)

Code Signing

Windows binaries are signed. Free code signing is provided by SignPath.io, certificate by SignPath Foundation.

Documentation: Manual versus Automated

There are two wikis: the official one hosted in by GitHub and the autonomously generated variant using DeepWiki.com. The manually curated version is a more stable and approachable resource designed to help you understand the app from a more human-focused perspective whereas DeepWiki is optimized for explaining the code itself with little regard for context beyond that.

Official Wiki

It is preferable to maintain local documentation rather than rely on an external service. It also preferable that the documentation is updated in tandem with the code changes as demonstrated in this commit.

Changes to files within ./docs/wiki are linted in CI before being automatically sync'd to the repository's official Wiki hosted by GitHub.

Migrating to Docusaurus is a long-term goal once the content and structure of the wiki has matured and the remaining "legacy docs" have either been reworked or removed. There are some automations in development to help reduce the difference between the published docs and the state of the code while retaining a human-in-the-loop.

DeepWiki.com

If you have very specific questions about how the code works or why a bug might be producing a particular message it might be useful to Ask DeepWiki . It can help "cite your sources" when discussing functionality and code that you don't fully understand as part of feature requests or bug reports.

This automated reference does come with some significant drawbacks:

  1. Intent: Describes what code does, not why decisions or tradeoffs were made.
  2. Staleness: Will *always* lag behind the code.
  3. Code-Focused: Does not provide guides or conceptual explanations.
  4. Cost: Potential future cost and higher resource usage than static docs.