Super Productivity

super-productivity.com
Super Productivity

Privacy-respecting, open-source task manager and time tracker. All data is stored locally on your device - no account required, no cloud dependency, and zero telemetry. Supports Jira, GitLab, GitHub, Gitea, OpenProject and CalDAV integration. Features include time tracking, Pomodoro timer, break reminders, and idle detection.

Open Source

Super Productivity Source Code

Author

super-productivity

Description

Super Productivity is an advanced todo list app with integrated Timeboxing and time tracking capabilities. It also comes with integrations for Jira, GitLab, GitHub and Open Project.

#android#flowmodoro#flowtime#habit-tracker#ios#issue-tracker#linux#local-first#macos#open-source#pomodoro#pomodoro-timer#privacy#productivity#task-manager#time-tracker#time-tracking#to-do-list#todo#windows

Homepage

http://super-productivity.com?ref=github

Repository

  • LicenseMIT
  • Created06 Jan 17
  • Primary languageTypeScript
  • Size167,818 KB
  • Stars21,397
  • Forks1,958
  • Watchers21,397

Language Usage

Language Usage

Project Health

  • Last commit3 days ago
  • Open issues1,454
  • Latest releasev18.20.1

Recent Commits

  • Johannes Millan(15 Aug 26)

    18.20.1

  • Johannes Millan(15 Aug 26)

    fix(header): stop the sync state badge covering the sync icon The badge sits dead centre on the 24px `sync` glyph, so at 15px plus 3px padding its fill covers 21px of it — knocking the glyph out down to four corner nubs. The fill was written against `--c-bg`, which is declared nowhere. That made the declaration invalid at computed-value time, so it had never once painted; #9304 mapped the phantom name to the real `--bg` and turned it on for the first time. The same is true of the `text-shadow` outline it replaced in cfa25fc871, which used that same phantom name — so this "solid circle for clean outline" has never worked in either form, and the check reads fine inside the ring unaided. Drop the fill rather than repoint it: no token can stand in for what is behind the badge. The header is `background: transparent`, so that is `--sidenav-bg` once the action row teleports to the vertical strip, nothing at all on liquid-glass (`--bg` is `transparent` there), and the user's own wallpaper wherever `.bg-image` is set — where any opaque disc is just a blob. `border-radius` and `padding` stay: they still size and round the forced-colors ring. Verified by compiling the post-fix rule and rendering it against the real button geometry — glyph intact with the badge legible inside the ring on light, dark, the vertical strip and over a wallpaper, and the forced-colors ring unchanged (it never depended on the fill).

  • Johannes Millan(15 Aug 26)

    test(sync): harden end-to-end coverage and remove false oracles (#9171) * test(sync): harden end-to-end coverage * test(sync): fix reviewed convergence oracles * test(sync): align provider-switch coverage claim * test(sync): harden reviewed webdav convergence oracles * test(sync): correct reviewed coverage claims * docs(wiki): document Flatpak data location and sandbox permissions The Snap uninstall warning recommended Flatpak as a way to keep data in the standard ~/.config/ location, but Flatpak redirects XDG_CONFIG_HOME into the sandbox at ~/.var/app/ — so that advice pointed users at the wrong path while trying to steer them away from Snap data loss (#6031). Correct that sentence and give Flatpak the same coverage Snap already had: a data-location subsection in the user-data reference, and a known- issues subsection covering the Flatseal filesystem permission the app already tells users about via ERROR_PERMISSION_FLATPAK. * test(sync): scope import-choice native confirms and dedupe production copy Fixes the 14 SuperSync CI failures (shards 3 and 6). All of them were the same mechanism, in specs the PR never touched. Root cause: production raises a native window.confirm() before the sync-import-conflict dialog closes (FIRST_SYNC_USE_LOCAL_CONFIRM on a never-synced device, USE_REMOTE_CONFIRM when the import discards local changes). Playwright only auto-dismisses native dialogs while *no* dialog listener is registered, and installDevErrorDialogHandler() registers one on every page — so an unanswered confirm blocks the click forever. Before this PR the prompt was answered by accident: setupSuperSync() leaked a page.once('dialog') auto-accept that outlived setup. The PR correctly scoped that handler with page.off() in a finally, which left every raw syncImportUseLocalBtn/syncImportUseRemoteBtn click hanging until timeout. - Add SuperSyncPage.chooseSyncImportUseLocal()/chooseSyncImportUseRemote(), which install the strict _handleNativeSyncDialog for exactly the click plus the dialog-close wait, with the expected choice, and then remove it again. An unexpected confirm is still recorded and rethrown — no page-lifetime auto-accept is restored. - Migrate all 15 raw click sites across 9 specs; no raw clicks remain. - Add e2e/utils/i18n-strings.ts: one place that reads src/assets/i18n/en.json and turns entries into anchored regexes tolerant of {{count}} interpolation. Used by the SuperSyncPage confirm whitelist, the WebDAV fresh-client handler, and the wrong-password overwrite assertions, all of which previously duplicated the English sentences. - Document the armed-witness contract on SyncPage.triggerSync(), setupWebdavSync(), waitForSyncComplete(), waitForTriggeredSyncResponse(), prepareForNextSyncCycle() and completeTriggeredSyncCycle(), including why a double-wait throws 'No sync cycle is pending'. - syncAndWait() now deducts the trigger's elapsed time from the settle budget, so the caller's timeout is a real ceiling instead of 2x. * test(sync): close remaining native-confirm gaps in sync helpers * test(sync): drop unverified client-A oracle pending #9170

  • Johannes Millan(15 Aug 26)

    test(tools): stop css-vars specs echoing their fixture reports execFileSync without an explicit stdio captures the child's stderr AND echoes it to the parent, so every expected-failure case printed a full unresolvable-property report to the terminal on a passing run.

  • Johannes Millan(15 Aug 26)

    fix(styles): replace phantom CSS variable references and guard against regressions (#9304) * fix(styles): replace phantom CSS variable references with real tokens Component and global SCSS referenced ~50 CSS custom properties that are declared nowhere — not in src/styles/, not in src/assets/themes/*.css, not in any component SCSS, and not injected at runtime. An undefined custom property invalidates the whole declaration at computed-value time, so the property silently falls back to inherit/unset (`gap: var(--s1)` collapsed to 0 in focus mode); the sites that carried a hardcoded fallback rendered but were permanently theme-blind. Each reference is mapped to the semantically equivalent existing token and hardcoded fallbacks are dropped: - spacing: --s1 -> --s - semantic color: --warn-color/--success-color and the #f44336/#ff9800/ #4caf50 fallbacks -> --color-danger/--color-warning/--color-success; --palette-green-500/--palette-red-500/--palette-red-700 (no such palettes exist) -> --color-success/--color-danger - text: --text-color-secondary, --text-tertiary, --text-muted, --text-color-dimmed, --fg-secondary, --c-text-secondary, --palette-text-secondary, --color-fg-less-important -> --text-color-muted; --fg-primary -> --text-color - brand: --primary, --primary-color, --palette-primary-main -> --c-primary; --color-accent, --palette-accent -> --c-accent; --palette-warn -> --c-warn; --c-primary-dark -> --palette-primary-700; --text-on-accent, --c-on-accent, --text-on-primary -> --c-contrast - borders/surfaces: --border-color, --border-light, --border-dark, --c-border, --color-border, --standard-border-color -> --extra-border-color (the input in select-task-minimal uses --input-border-color); --bg-color, --c-bg-card -> --card-bg; --c-bg, --background-dark -> --bg; --background-color-lighter, --bg-color-lighter -> --bg-lighter - state overlays: --hover-color, --palette-background-hover and the misused --mdc-dialog-subhead-color (a text token used as a background) -> --state-hover - rgb channels: rgba(var(--c-accent-rgb), a) / rgba(var(--c-primary-rgb), a) -> color-mix(in srgb, var(--c-accent|--c-primary) N%, transparent); rgba(var(--text-color-rgb, 0,0,0), a) -> rgba(var(--ink-on-channel), a) - misc: --whiteframe-shadow-20dp (scale has no 20dp step) -> 24dp for the drag elevation; --monospace-font -> --font-mono-stack; --z-done-toggle fallback chain collapsed to --z-drag-handle; --standard-border-radius (declared only inside schedule-event's component scope) -> --card-border-radius - app.component: the side-nav `border-right-color: var(--sidebar-border-color)` now uses `transparent`, matching the "remove right border" intent stated in the comment directly above it. Runtime-injected names are left untouched: the --palette-*/--mat-*/--mdc-* tokens, host/style bindings (--title-line-clamp, --project-color, --cols, --progress-circle-color, --tree-indent), --safe-area-inset-* and --android-webview-icon-scale set from TypeScript, and --selected-bg-opacity, an optional theme-override hook consumed with a fallback. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01APNZh3NksdojNDvpRAU11S * build(lint): fail lint on references to undefined CSS custom properties Nothing in the toolchain catches `var(--does-not-exist)`: SCSS compiles it, stylelint accepts it, and the browser silently drops the declaration at computed-value time, so the property falls back to inherit/unset. With a hardcoded fallback it renders but is permanently theme-blind. That is how ~80 phantom references accumulated across component SCSS. tools/check-css-vars.js collects every `--x:` declaration from src/**/*.scss and src/assets/themes/*.css, then reports every `var(--x)` in src/**/*.scss with no matching declaration, exiting non-zero with a file:line list grouped by variable name. Comments are blanked before scanning so commented-out code is not treated as a usage. Plain Node, no new dependencies. Names that only exist at runtime cannot be discovered statically, so the script carries an explicit allowlist, each entry annotated with the file that sets it: --safe-area-inset-* and --android-webview-icon-scale (set from TypeScript), the host/style bindings --title-line-clamp, --project-color, --cols, --progress-circle-color and --tree-indent, and --selected-bg-opacity (an optional theme-override hook consumed with a fallback). --mat-*, --mdc-* (Angular Material theme output) and --palette-* (injected by angular-material-css-vars) are allowed by prefix, since those token sets cannot be enumerated. Wired in as `lint:css-vars` in the existing `npm run lint` chain, after `lint:scss`. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01APNZh3NksdojNDvpRAU11S * refactor(styles): one shared .callout primitive for dialog info/warning boxes 15+ dialogs hand-rolled their own info/warning/success box with literal `rgba(255, 152, 0, …)` / `rgba(33, 150, 243, …)` / `#2196f3`, so none of them followed the user's theme — the shared `_dialog-encryption-common.scss` partial fanned the same three literals out to every encryption dialog. Add `src/styles/components/_callout.scss`: a global `.callout` with `--info/--success/--warning/--danger` modifiers. A single tone token (`--callout-c`) drives icon color, border and fill; border and fill derive from it with `color-mix()`, so every tone is theme-aware and a new tone is one declaration. A global class rather than a component — the markup is a plain icon + text row that consumers already write, and half the call sites are a bare `<p>`. Migrated (hardcoded color literals removed): - `_dialog-encryption-common.scss` — 6 literals; the `dialog-warning-box` / `dialog-info-box` mixins are gone, `dialog-note-warn` now color-mixes `--color-warning` - `dialog-enable-encryption`, `dialog-change-encryption-password`, `dialog-import-encryption-warning` — `.warning-box` / `.info-box` markup now uses `.callout` - `dialog-restore-point`, `dialog-archive-compression` — local `.warning` boxes dropped (3 more literals); the archive `.stat-item.highlight` tint now color-mixes `--color-success` Also replaced the theme-blind neutral literals in the same dialog family with the existing state/border tokens: `dialog-clipboard-images-manager` (5), `dialog-focus-session-edit`, `dialog-import-encryption-warning`, `dialog-handle-decrypt-error`, `dialog-share`, `dialog-user-profile-management`, `dialog-logs`. Verified in-browser (light, dark, and the Dracula / Nord / Everforest themes), including inside a real MatDialog surface. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01APNZh3NksdojNDvpRAU11S * refactor(styles): adopt the font-size token scale in the side nav and settings The scale exists (`--font-size-xs`…`-3xl`) but almost nothing consumes it. Move the text sizes on two high-traffic surfaces onto it and document the scale — including the two cases that must stay hardcoded, so a future sweep doesn't "fix" them into misalignment. nav-item: - `.task-count` 10px → `--font-size-xs` (11px, nearest step; the only visible pixel change in the whole commit — the sidebar count badge, verified) - touch-primary nav button 14px → `--font-size-md` - `.nav-icon` 18px → `--font-size-xl` (×2), `.nav-icon-emoji` 16px → `--font-size-lg` — exact-value swaps, and these glyphs sit in a 24px box rather than a matched icon set config-page: `.section__pill` 12px → `--font-size-sm` (exact). Deliberately left: `.tab-icon` (20/24px) and `&__provider mat-icon` (20px) in config-page, every size in main-header (20/24/15px), and `.version mat-icon` (`1em`). Those are Material glyphs whose `font-size` matches their `width` / `height`; the scale has no 20/24px step, so tokenizing them decenters the glyph in its box. main-header therefore has no text sizes left to migrate. Verified before/after at 1440px and 390px on the settings page and side nav: a 39-pixel diff, all of it the count badge. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01APNZh3NksdojNDvpRAU11S * fix(styles): address review findings on the visual-improvements branch - check-css-vars: enumerate the real angular-material-css-vars palette token set (hue x step x contrast x -rgb) instead of allowing the whole --palette- prefix, so invented names like --palette-green-500 or --palette-primary-main fail lint again (the second-largest phantom class the tool was built to catch) - schedule-week time badge + focus-mode extend label sit on --c-accent fills but used --c-contrast (primary contrast); switch to --palette-accent-contrast-500 so themes with differing primary/accent lightness keep readable badge text - dialog-disable-profiles-confirmation: migrate the last hand-rolled info box (inline #2196f3/rgba styles in TS) to the shared .callout primitive and the hardcoded heading orange to --color-warning Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01APNZh3NksdojNDvpRAU11S * fix(styles): replace phantom CSS variables in inline component styles The SCSS sweep missed the four references that live in Angular inline `styles:` blocks and an `[ngStyle]` binding, because neither the sweep nor the new guard looked outside `.scss`. All four carry the hardcoded fallback that makes them render while staying permanently theme-blind: - `var(--hover-color, rgba(0, 0, 0, 0.04))` in desktop-panel-buttons, page-title and plugin-side-panel-btns painted a black wash for hover on every theme, dark included -> `--state-hover` - the nav-list-tree drag preview was pinned to `#3f51b5` with white text -> `--c-primary` / `--c-contrast`, the established pairing Neither name is declared in any `.scss` or theme `.css`, and neither is `setProperty`-ed from TypeScript. * build(lint): let the CSS variable guard see inline styles and scope The guard walked only `src/**/*.scss`, so it could not see two of the phantom classes it exists to prevent. Both are now covered, and pinned by tests — the tool had none, unlike its sibling `theme-assets.test.js`. Inline styles. Angular components carry `styles:` blocks, `[ngStyle]` and `[style.--x]`, all as invisible to stylelint as SCSS is. `.ts`/ `.html` are now scanned for references and declarations. Declarations in `.ts` are read only from template literals: harvesting the whole file would let any ordinary string containing `--x:` register as a declaration and silence a real phantom of that name everywhere. `[style.--x]` bindings self-declare, which retires five hand-listed ALLOWLIST entries — only five of the nine such bindings were listed, the rest passing by accident via an unrelated SCSS default. Scope. Custom properties inherit only downwards, so a global stylesheet cannot see one declared in a component's `:host` — as broken as an undefined name, and previously undetectable. That is how `--standard-border-radius` (declared only in schedule-event, consumed by the global formly-rows.scss) stayed broken. Flagged `(component-scoped)`. It is a heuristic — a global rule can legitimately match inside a component subtree — so ALLOWLIST is the escape hatch, and the Sass mixin libraries under `src/styles/` are excluded, since their bodies land in whichever component `@use`s them. Also fixed, each verified by sabotage against a planted phantom: - comment stripping ran `/* */` before `//`, so the `//* {` in tree.component.scss opened a phantom block comment that swallowed ~50 lines of live CSS, exempting four real references. One alternation makes leftmost-match resolve it correctly. - the declaration matcher was unanchored, so `$grid--gap: 2px` registered `--gap`, and `.mdc-text-field--outlined:not(…)` in liquid-glass.css registered `--outlined`/`--filled`. Anchoring also drops a quadratic worst case (100k identifier chars: 11s -> 0.4ms). - `src/index.html`'s document-level `<style>` block was getting the lenient component rule. - a dangling symlink under `src/` crashed with a raw stack trace that read like a lint failure. Guard runtime is unchanged at ~0.2s over 1780 files. * fix(styles): follow the callout contract and de-hardcode dialog fills `.callout` is a flex row, so the class on a bare `<p>` makes every inline child its own flex item: `<strong>Note:</strong> …` rendered as two columns with a gap and a hanging indent on the wrap. The three `<p class="callout">` sites become `<div class="callout">` + `<p>`, which is what the primitive's own docblock specifies; all nine call sites now match. Those three also regained `aria-hidden` on the decorative icon. The guide drops its "also works on a bare `<p>`" claim. Remaining theme-blind values in dialog-disable-profiles-confirmation: `rgba(0, 0, 0, 0.05)` on the info panels and `rgba(0, 0, 0, 0.1)` on the inline `code` both vanish against the dark card (1.03:1) and recess rather than raise. They move to the state ladder — `--state-hover` for the panels, one step up to `--state-pressed` for the chip so it stays legible inside its own container. Its local `font-family` also went: the global `code` rule in page.scss already supplies `--font-mono-stack`. dialog-sync-import-conflict's `.import-info` mapped to `--bg-lighter`, which resolves to `--surface-3` — identical to the `--surface-2` dialog card on the base light theme, so the panel stayed invisible there. `--state-hover` matches the other in-dialog inset panels. plugin-index's `.dark-theme` block is dead twice over: the app sets `body.isDarkTheme`, never `.dark-theme`, and the `.header` element it targeted does not exist in that component's template. * fix(a11y): label the task context menu quick-access buttons The quick-access row and the schedule/unschedule buttons are icon-only, so a screen reader announced them as unlabelled. `matTooltip` sets `aria-describedby`, which is supplementary — it does not supply the accessible name. Add `aria-label` from the same translation keys. * test(tools): assert on substring instead of a hand-built regex `new RegExp(name.replace(/-/g, '\\-'))` escaped hyphens — which are not metacharacters outside a character class — while leaving backslashes unescaped, which CodeQL flags as incomplete sanitization (js/incomplete- sanitization). The assertion only ever needed to check the name appears in the output, so drop the regex entirely. --------- Co-authored-by: Claude <[email protected]>

  • Johannes Millan(15 Aug 26)

    fix(op-log): compact at startup when the op-log grows large (#9580) * fix(op-log): compact at startup when the op-log grows large Compaction is the only path that prunes old synced ops, and it is driven by an in-memory counter that resets on every restart (the persisted counter is never incremented in production). A user whose sessions stay below COMPACTION_THRESHOLD never prunes, so the op-log grows unbounded across restarts. Check the actual op count at startup (O(1)) and trigger a fire-and-forget compaction once it exceeds STARTUP_COMPACTION_OP_THRESHOLD (5000) — above a heavy user's ~7-day steady state so it stays self-limiting. The check is self-contained so a failure can never abort hydration. Also documents that the persisted compaction counter is now superseded and a removal candidate. * test(op-log): stage the #9084 race end to end against the real hydrator Drives the REAL hydrateStore() with a one-shot barrier on getOpsAfterSeq, parking it between the snapshot's loadAllData dispatch and the tail-op replay — the exact #9084 window — then runs the real compaction pipeline against the real IndexedDB store: it must skip via the hydration guard (cache and tail ops untouched) and compact cleanly once hydration completes. Verified to fail when the hydrator stops setting the hydration-in-progress flag. * docs(wiki): document Flatpak data location and sandbox permissions The Snap uninstall warning recommended Flatpak as a way to keep data in the standard ~/.config/ location, but Flatpak redirects XDG_CONFIG_HOME into the sandbox at ~/.var/app/ — so that advice pointed users at the wrong path while trying to steer them away from Snap data loss (#6031). Correct that sentence and give Flatpak the same coverage Snap already had: a data-location subsection in the user-data reference, and a known- issues subsection covering the Flatseal filesystem permission the app already tells users about via ERROR_PERMISSION_FLATPAK. * refactor(op-log): drop no-op legacy placeholder hooks in hydrator Both were empty for-the-future stubs (YAGNI); removing them brings the hydrator back under the 1200-line cap after the master merge. * docs(op-log): fix countOps cost note, pin startup-compaction metric choice - countOps: engines walk keys for count(), so drop the false O(1) claim - record why total op count beats the delta metric #8336 proposed (the >10-tail-op boot snapshot re-zeroes the delta without pruning) - name the real per-boot cost of the offline re-fire case - point the superseded-counter note at compactIfBloated()'s actual home * fix(op-log): gate startup compaction on prunable ops, keep it off boot path - skip compactIfBloated when hasSyncedOps() is false: compaction can only prune synced ops, so a never-synced (local-only) log past the threshold would otherwise pay a full snapshot write + op scan on every boot and delete nothing, forever - compactIfBloated now awaits compact() internally and never rejects; the hydrator fire-and-forgets the call instead of awaiting it, so the boot path no longer carries the count round-trip (nor a hung IDB with it) * test(op-log): prove startup compaction prunes end to end, pin fire-and-forget - integration: backdated synced ops are really deleted through the real hydrateStore -> compactIfBloated -> compact() pipeline (fails if the trigger never fires — the #9045 class of bug) - hydrator spec: hydrateStore must resolve while the check is still pending and when it rejects (call-site fire-and-forget + belt catch) - compaction spec: no-synced-ops gate, count-first ordering, and the failure-swallow test is now meaningful since compact() is awaited - barrier test: release the barrier in finally and race hydration so an early error fails loudly instead of hanging into the jasmine timeout

  • Johannes Millan(15 Aug 26)

    ci(build): let a failed Windows release job be re-run (#9611) The v18.20.0 signing failure (run 31883046355) leaves the release stuck with a re-run as the obvious next step — but a re-run of `windows-bin` would not reach the signing step at all: artifacts are scoped to the run rather than the attempt, and `unsigned-windows-executables` from the first attempt is still there, so the upload fails before SignPath is ever contacted. Set `overwrite: true` there and on the E2E result upload that has the same problem. Also record in the runbook how to diagnose a SignPath failure. The step prints only what the connector returns, and for v18.20.0 that was a bare `Invalid request to SignPath API.` with no detail — the signing request record in the SignPath web UI is the only place the real reason exists. Claude-Session: https://claude.ai/code/session_017qwRuGmJVHn7j3AsKUrQkW Co-authored-by: Claude <[email protected]>

  • Johannes Millan(15 Aug 26)

    fix(release): strip emoji from the App Store "What's New" text (#9608) * fix(release): strip emoji from the App Store "What's New" text Both Apple release lanes failed for 18.20.0 with An attribute value has invalid characters. - What's New in This Version can't contain the following character(s): 🚨. - /data/attributes/whatsNew App Store Connect blocks some emoji in whatsNew, and no one wrote that one by hand: the bug-report issue template prefixes titles with "🚨 ", the squash merge for #9530 carried it into the commit subject, and the changelog generator turned it into a release-note bullet. Apple documents neither the blocked set nor when it changes, so the App Store transform now strips pictographs wholesale rather than tracking a blocklist -- the same deliberately aggressive bias already used for non-Apple platform names, where a miss costs a failed release and an over-eager match only costs decoration. Lines left as bare bullets or headings are dropped so nothing dangles. Only the App Store text is affected; the GitHub and Play Store changelogs keep their emoji. Verified end to end by regenerating the real 18.20.0 notes: the rejected bullet now reads "• Cannot connect to CalDAV Todo issue provider (#9530)" and the output is emoji-free. Co-Authored-By: Claude <[email protected]> Claude-Session: https://claude.ai/code/session_01Vg413FSRvrm92TTe2x4UDv * test(release): pin every emoji class the App Store transform strips Review of the previous commit mutation-tested EMOJI_PATTERN and found two members whose removal left the whole suite green: \p{Emoji_Modifier} and \u{FE0E}. Both leave residue that is invisible in a diff -- dropping the skin-tone half of "👋🏽" ships a bare U+1F3FD to Apple -- so the gap would have surfaced as a failed release lane, which is exactly what these tests exist to prevent. Cause was assertNoEmoji checking a hand-written subset of what the code strips. It now builds its regex from the exported EMOJI_PATTERN, so the assertion can never drift narrower than the implementation again, with a case per member to pin them. (The /g flag is dropped when rebuilding: a global regex carries lastIndex between test() calls.) Also closes the one gap the review could still construct an input for: emoji tag sequences (U+E0020-U+E007F). "🏴󠁧󠁢󠁥󠁮󠁧󠁿" lost its base pictograph but kept six invisible tag characters, so Apple would have rejected the text naming a character that renders as nothing. Widen LINE_MARKERS_ONLY to the "+", "_" and ">" markers the AI generation path can emit while here, so an emoji-only line under those leaves no stray marker either. Mutation-verified: each of the eight members of EMOJI_PATTERN now fails at least one test when removed. Co-Authored-By: Claude <[email protected]> Claude-Session: https://claude.ai/code/session_01Vg413FSRvrm92TTe2x4UDv --------- Co-authored-by: Claude <[email protected]>

  • Johannes Millan(15 Aug 26)

    18.20.0

  • Johannes Millan(15 Aug 26)

    docs(wiki): document Flatpak data location and sandbox permissions The Snap uninstall warning recommended Flatpak as a way to keep data in the standard ~/.config/ location, but Flatpak redirects XDG_CONFIG_HOME into the sandbox at ~/.var/app/ — so that advice pointed users at the wrong path while trying to steer them away from Snap data loss (#6031). Correct that sentence and give Flatpak the same coverage Snap already had: a data-location subsection in the user-data reference, and a known- issues subsection covering the Flatseal filesystem permission the app already tells users about via ERROR_PERMISSION_FLATPAK.

  • Johannes Millan(15 Aug 26)

    fix(sync): resolve remote RoundTimeSpentForDay multi-entity conflicts (#9601) (#9607) * fix(sync): resolve remote bulk rounding conflicts (#9601) A remote multi-task RoundTimeSpentForDay op ("Finish day" rounding on another device) racing any concurrent local edit failed the multi-entity preflight with SYNC_MULTI_ENTITY_UNSUPPORTED side=remote and wedged sync permanently — the action type had a local-side decomposable path (#9561) but no remote-side exemption. Allow validated remote rounding ops through the preflight and let the existing mixed-winner machinery resolve them: the atomic row replays once (uncontested siblings converge exactly), local winners are re-asserted by compensation snapshots applied after it and re-uploaded. The exemption is gated on the static-payload check so a row whose taskIds exceed its declared entityIds keeps the fail-closed stop. Pinned by an integration spec reproducing the reported wedge and a two-client SuperSync E2E driving the real daily-summary rounding UI; recordTaskTimeDelta moves to supersync-helpers for reuse. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(sync): harden remote bulk rounding conflict resolution (#9601) Multi-agent review follow-up closing three verified gaps in the remote RoundTimeSpentForDay exemption: - The rounding reducer now skips task ids missing from the store instead of throwing, so a replayed op listing tasks this client archived or deleted meanwhile no longer aborts (and, on the conflict path, no longer escalates to IncompleteRemoteOperationsError re-wedging sync). - Declared-but-unwritten conflict targets (the payload's project limit and parents-with-subtasks — production ops declare EVERY task id of the day) are forced to LOCAL wins before partitioning. A remote win on such a row rejected the local pending edit while the replay wrote nothing, silently dropping the edit from the upload stream; the compensation snapshot now re-asserts and re-uploads it. The sender's own dispatch filtered the same ids, so nothing was rounded anywhere. - The payload validator is total: a rounding op with a null or missing actionPayload yields the typed fail-closed error instead of a raw TypeError thrown from inside the preflight. The guard's doc comment now states the write-set invariants accurately (direct writes conflict-checked; parent recalc derived-only; same-batch delta inversion bound; residual fail-closed throw). New coverage: a real-reducer #9601 convergence scenario incl. restart replay, reducer spec for unknown ids, forced-local-win and typed-error integration tests, an e2e premise assertion pinning that the conflict actually forms, and expectExactTaskTime moved to the shared e2e helpers. Co-Authored-By: Claude Fable 5 <[email protected]> * test(sync): pin the both-finish-day remote rounding overlap (#9601) Real-reducer scenario: the remote client rounds three tasks in one atomic op while this client holds a pending bulk archive of one of them. Pins that resolution completes (with the pre-fix throwing reducer this escalated to IncompleteRemoteOperationsError and re-wedged sync — the test fails with 'Task not found' when the reducer skip is reverted), that the uncontested siblings still round, that the archive intent survives as an uploadable op, and that both clients plus a status-blind restart replay converge. Co-Authored-By: Claude Fable 5 <[email protected]> --------- Co-authored-by: Claude Fable 5 <[email protected]>

  • Johannes Millan(15 Aug 26)

    fix(add-task-bar): stop autosize placeholder measure resetting caret (#9603) * fix(add-task-bar): stop autosize placeholder measure resetting caret * test(add-task-bar): pin detached note placeholder while note has text

  • John Costa(15 Aug 26)

    fix(config): commit image-input fields on blur to stop per-keystroke ops (#9596) `image-input` was missing from the `updateOn: 'blur'` list, so the project and tag background image URL committed on every keystroke. That path runs through `onModelChange` into `ProjectService.update` / `TagService.updateTag`, which are op-log captured, so typing a 60-character URL emitted roughly 60 persisted and synced ops instead of one, plus the matching IndexedDB writes. Unlike #9548 there is no visible symptom: the settings dialog assigns the emitted model straight back, which satisfies formly's `_modelChangeValue` guard, so the field is not re-created mid-edit and no input state is lost. This is only the write-amplification half of the same root cause. The other `image-input` site, the wallpaper dialog, builds its fields without `adjustToLiveFormlyForm` and only writes in `save()`, so it never had the per-keystroke commit and is unaffected.

  • Aleksandr Petrov(15 Aug 26)

    fix(work-context): reset the active context when its project is deleted (#9602) Deleting the project you are looking at can leave the app pointing at an id that no longer resolves, and new tasks then land nowhere. The menu item navigates to '/' before removing, which normally moves the context off the doomed project. It does not when misc.defaultStartPage names that same project: the navigation resolves BEFORE the delete, so DefaultStartPageGuard still finds the project and routes straight back to it (its missing-project fallback to Today cannot fire yet), and WorkContextService then early-returns because the URL id equals the active id. Nothing afterwards resets it — the reducer ignores deleteProject, validateContextAfterDataLoad$ only covers loadAllData (sync/import), and the delete effects touch config, not the context. Left in that state, activeWorkContextType stays PROJECT with a dead id, so TaskService.add() stamps new tasks with that projectId while the shared reducer skips the project-list update because the entity is gone: the task exists in task state, belongs to no project and no tag, and syncs that way. An effect on TaskSharedActions.deleteProject covers every route into the cascade, including callers that delete without navigating first.

  • calvin-fisher(15 Aug 26)

    fix(task): unique icons for move-to-bottom and move-to-backlog (#9555) * fix(task): unique icons for move-to-bottom and move-to-backlog * Better icon selection

  • Johannes Millan(14 Aug 26)

    fix(search): reveal tasks hidden inside collapsed containers (#8780) (#9595) Builds on #9590, which fixed the collapsed parent and the collapsed customizer group. The same failure remains for every OTHER container that unmounts a task row, because `collapsible` renders its content behind `@if (isExpanded)`: a hidden task has no `#t-<id>` node at all, so the reveal loop polls for an element that can never appear and expires silently. Covers the remaining containers by enumerating every gate between "task is in the store" and "row is in the DOM", rather than the ones that came to mind: - collapsed SECTION (synced `updateSection`) - collapsed Done / Overdue / Later Today panel (local signal) - plugin embed replacing the task list: bails out instead of expanding Each reveal is gated on the container being RENDERED, not merely on list membership, because several of these lists are broader than their containers. The overdue and later-today lists are global while their panels render on the Today list only, so plain membership would flip a Today panel from a project page that never shows it — and persist that to localStorage. The section branch mirrors the template's `@if` chain in the same order, so it never writes a synced op for a section that is not on screen. Also from reviewing the reveal path as a whole: - The reveal expanded containers AFTER its retry budget was already exhausted. - `_pendingFocusItemTaskId` was never cleared on giving up, so the `splitTopEl` setter replayed the whole loop on every later context change, re-opening containers the user had since collapsed by hand. - A running reveal was not cancelled when `focusItem` left the URL. - The parent guard read `=== HideAll`, but the template hides on `!!_hideSubTasksMode`, so a legacy or out-of-enum value hid the row while the reveal decided nothing was wrong — the original bug, for that data. Known gaps, documented in `_focusTaskElement` rather than fixed here: the same-context branch (tracked-task pill, notifications, issue creation, calendar events) gets only the parent reveal, since `focusItem` is set on the route-change branch alone — global search always routes, so this is not the reported bug; and the backlog renders outside `#splitTopEl`, which both reveal loops require, so a backlog task is shown but never focused. Both pre-existing. Testing: 34 unit tests in work-view.component.spec.ts, 22 in navigate-to-task.service.spec.ts, and an E2E for the collapsed section plus one stacking a collapsed parent inside a collapsed section — the only check that one navigation drives both synced reveals. Every behaviour verified red first, and every branch mutation-checked by disabling it and confirming a test fails. Refs #8780

  • Johannes Millan(14 Aug 26)

    feat(i18n): translate "From previous days" for all locales

  • Johannes Millan(14 Aug 26)

    feat(i18n): rename planned-past "Overdue" list to "From previous days" (#9594) * chore(git): ignore every .env variant except the example The rules listed .local variants one by one, so a hand-made backup like .env.bak.20260720-114653 was untracked but not ignored - it sat in the production checkout holding JWT_SECRET, POSTGRES_PASSWORD and SMTP_PASS, one `git add -A` away from being committed. Ignore .env.* wholesale and re-include the checked-in .env.example, which is the only file in that namespace that belongs in the repo. This also subsumes the previous .env.local and .env.*.local rules. Verified no currently tracked file becomes ignored (`git ls-files | git check-ignore --stdin` is empty), and that .env.example and packages/super-sync-server/env.example stay visible. * chore(lint): enforce #9438 frontier reporting on OPS appends Every OperationLogStoreService method that appends rows to STORE_NAMES.OPS must report the committed seqs to TabSeqFrontierService — until now this wiring invariant was enforced only by a comment and a per-method spec that cannot see a NEW append method. The failure mode of a missed observeOwnWrite is silent and severe: the tab's next own write looks like a foreign seq gap, sets sticky divergence, and disables snapshot saves AND compaction for the rest of the session on all platforms. New local rule require-frontier-report-on-ops-append flags any method containing tx.add(STORE_NAMES.OPS, ...) without an observeOwnWrite / establishFrontier call in the same method. Scoped to src/app/op-log/** (specs exempt). Verified: zero findings on the current store service, and removing the append() report call produces two errors (mutation check). * feat(i18n): rename planned-past Overdue list to "From previous days" Resolve the confusion where the Today-view "Overdue" panel and the Planner overdue block (both fed by planned days, not deadlines) read as "past deadline". Per the discussion in #7069, keep the planned vs deadline concepts strictly separate and only relabel the planned-past list; deadline-passed keeps its own "Past deadline!" label from #8819. en.json value only; the G.OVERDUE key and all internal names are unchanged, so other locales fall back to their existing translations until re-translated. Wiki mentions of the section name updated. Closes #7069. Also settles the decision tracked in #8877.

  • Johannes Millan(14 Aug 26)

    fix(sync): apply LWW conflict winners for array entities (#9526) (#9592) * chore(git): ignore every .env variant except the example The rules listed .local variants one by one, so a hand-made backup like .env.bak.20260720-114653 was untracked but not ignored - it sat in the production checkout holding JWT_SECRET, POSTGRES_PASSWORD and SMTP_PASS, one `git add -A` away from being committed. Ignore .env.* wholesale and re-include the checked-in .env.example, which is the only file in that namespace that belongs in the repo. This also subsumes the previous .env.local and .env.*.local rules. Verified no currently tracked file becomes ignored (`git ls-files | git check-ignore --stdin` is empty), and that .env.example and packages/super-sync-server/env.example stay visible. * fix(sync): apply LWW conflict winners for array entities (#9526) Since v18.10.0 conflict resolution emits synthetic LWW Update ops for array-pattern entities (PLUGIN_USER_DATA, PLUGIN_METADATA, BOARD, REMINDER), but lwwUpdateMetaReducer only supported singleton + adapter patterns — every receiving client dropped these winners with "Unsupported storage pattern" and advanced its cursor, silently diverging plugin data across devices (reported in #9526; both attached user logs replay dead PLUGIN_USER_DATA LWW ops on every boot). Add an array branch: items are addressed by payload id, merged in place when present, appended for replace-mode recreates, and patch deltas for absent items are skipped. Existing items are shallow-merged in both modes because entity fields named `type`/`meta` cannot survive the action representation (e.g. Reminder.type). Widen isLwwPayloadIdCanonical to array entities in lockstep, as required by assertDecryptedOpMetadataIntegrity's retarget defense (GHSA-8pxh-mgc7-gp3g): array LWW ops are now applied by payload identity, so tampered envelope ids must fail closed. Receiver-side only — no new op semantics, no schema bump; old clients keep their current drop behavior. Map (PLANNER) stays unsupported: its wire payload needs a producer-side redesign first. * fix(plugins): define PluginAPI before plugin scripts run (#9526) The iframe API bridge script was appended before </body>, after all plugin markup — any classic (non-defer) plugin script executing at parse time saw window.PluginAPI as undefined and fell back to local storage, silently bypassing sync (diagnosed in #9526 for the vision board plugin). Inject the bridge script at the start of <head> instead, right after the host CSS, so PluginAPI exists synchronously for every plugin script. The script touches only window/window.parent (no DOM access) and the host's sole reaction to the earlier READY message is a log line, so head placement is safe; plugins that wait for DOMContentLoaded keep working unchanged. Also fix the generated hook-event handler to report errors via console.error — PluginLog does not exist inside the iframe. * test(e2e): cover plugin-data LWW sync and iframe API timing (#9526) Two e2e tests for the #9526 fixes: - supersync-plugin-user-data.spec.ts (@supersync): two clients write plugin data concurrently via the real iframe bridge; after the LWW conflict resolves, the winner's data must reach the losing client. Without the array branch in lwwUpdateMetaReducer the synthetic [PLUGIN_USER_DATA] LWW Update op is dropped and the clients diverge. Also covers the non-conflict baseline (plain upsert sync A -> B). - plugin-iframe.spec.ts: the bundled API Test Plugin now records `typeof window.PluginAPI` at the top of its parse-time inline script; the test asserts the bridge exists by then (head injection). Verified failing against the pre-fix end-of-body injection. * fix(sync): skip REMINDER LWW recreate that cannot carry its type The flat LWW action envelope shadows entity fields named `type`, so a recreate-append for a deleted REMINDER could only produce an item that fails typia validation on the next hydration. Skip the append in both modes for such entities (deterministic on every client; the item stays deleted, mirroring SIMPLE_COUNTER's documented `type` limitation). A RECREATE_FALLBACK entry is deliberately not used: it would opt the type into SPAP-14 disjoint-merge and invent a Reminder `type` that misfires notifications. Also pin the producer side of the #9526 integrity lockstep: array LWW ops must carry payload.id === entityId or every legitimate conflict winner would be rejected by assertDecryptedOpMetadataIntegrity.

  • Johannes Millan(14 Aug 26)

    fix(search): reveal tasks hidden by a collapsed parent or group (#8780) (#9590) * chore(git): ignore every .env variant except the example The rules listed .local variants one by one, so a hand-made backup like .env.bak.20260720-114653 was untracked but not ignored - it sat in the production checkout holding JWT_SECRET, POSTGRES_PASSWORD and SMTP_PASS, one `git add -A` away from being committed. Ignore .env.* wholesale and re-include the checked-in .env.example, which is the only file in that namespace that belongs in the repo. This also subsumes the previous .env.local and .env.*.local rules. Verified no currently tracked file becomes ignored (`git ls-files | git check-ignore --stdin` is empty), and that .env.example and packages/super-sync-server/env.example stay visible. * fix(search): reveal tasks hidden by a collapsed parent or group (#8780) The reporter's debug trace shows navigation resolving correctly and then `hasDirectMatch: false` on all 21 focus attempts: the reveal step polled for a row that could never render. A collapsed parent renders no `<task>` host for its subtasks (`filterDoneTasks` returns `[]` for `isHideAll`), and `_hideSubTasksMode` is persisted (#8781), so the search result stayed permanently unreachable and the retry loop gave up silently after ~5s. Expand the parent before routing, and expand a collapsed customizer group when the reveal loop cannot find its target. Both are guarded to fire only when the row really is hidden, so navigating never emits a synced updateTaskUi op it does not need, and never touches live task UI state when navigating into the archive. The three earlier attempts (#8801, #9052, #9354) all worked on *where* to navigate; this failure is entirely in revealing the row afterwards. * fix(search): address multi-agent review of the #8780 reveal fix - Never log `groupKey`: it is a project/tag TITLE (task-view-customizer .service.ts:414,565), and log history is exportable — rule 9. Log the group's index instead, which is all a reporter's trace needs. - Do not expand for the currently TRACKED task: `filterDoneTasks` exempts it from HideAll, so its row already renders. The tracked-task pill navigates to exactly that task, so every pill click on a subtask of a collapsed parent was emitting a synced updateTaskUi op to reveal a row that was already on screen. - Drop `find-collapsed-group-for-task.ts` and inline the lookup onto the existing `_hasTaskInList`, which already did the same one-level subtask traversal 30 lines below the call site. - Move the group expansion below the give-up guard, so the final attempt no longer expands and persists a group it can never focus. - Record `navigateToTask:expandParent` after `navigateToTask:start` so a captured trace reads in causal order. - Add the missing test for the collapsed-group path (6 cases, 2 of which fail without the fix) — it previously shipped with only a pure-helper spec that could not fail, and a guard test for the tracked-task case. * test(search): prove the collapsed-group reveal end-to-end (#8780) The group half shipped with unit tests that stub the template away, so they proved `_expandCollapsedGroupFor` fires but not that expanding actually renders and focuses the row. This drives the real customizer menu (the `C` shortcut opens a bottom panel whose template is menu-only and renders nothing, so it uses the header `.task-filter-btn` trigger), groups by project, collapses the group through its header, then searches. Verified red without the fix: the row is never found.

  • Johannes Millan(14 Aug 26)

    style(liquid-glass): fix active nav highlight, lift dark variant - express nav selection via --sidenav-item-active-bg/--sidenav-active-text (brand-tinted glass pill) so anchor rows, button rows and the mobile bottom nav highlight identically; drop the anchor-only overrides - lift the dark wallpaper (incl. the disable-tint default the TODAY tag uses) out of pitch black and strengthen the primary radials - raise + desaturate the dark surface family so tasks read neutral - drop the visually inert per-chip backdrop blur (compositing cost) - respect prefers-reduced-motion for the springy press-scale - raise dark gridline contrast on the schedule

  • Johannes Millan(14 Aug 26)

    chore(lint): enforce #9438 frontier reporting on OPS appends Every OperationLogStoreService method that appends rows to STORE_NAMES.OPS must report the committed seqs to TabSeqFrontierService — until now this wiring invariant was enforced only by a comment and a per-method spec that cannot see a NEW append method. The failure mode of a missed observeOwnWrite is silent and severe: the tab's next own write looks like a foreign seq gap, sets sticky divergence, and disables snapshot saves AND compaction for the rest of the session on all platforms. New local rule require-frontier-report-on-ops-append flags any method containing tx.add(STORE_NAMES.OPS, ...) without an observeOwnWrite / establishFrontier call in the same method. Scoped to src/app/op-log/** (specs exempt). Verified: zero findings on the current store service, and removing the append() report call produces two errors (mutation check).

  • Johannes Millan(14 Aug 26)

    fix(android): stop logging task titles and shared content to logcat (#9587) Logcat is readable over adb and is captured in bug reports, so per the project's "never log user content" rule these must carry shape, not content. Twelve sites across the share, reminder, alarm, focus-mode, tracking and widget paths interpolated task titles or the whole shared payload (URLs, note bodies, email subjects) into Log.d, none of them gated on BuildConfig.DEBUG. IDs, durations and flags are kept — they are what the logs were actually useful for, and they are the pattern already used elsewhere (ReminderActionReceiver's sanitized taskId line).

  • Johannes Millan(14 Aug 26)

    fix(worklog): disambiguate duplicate Titles column in export (#9586) Exporting both title columns produced two columns literally headed "Titles", so a sub-task row could not be attributed to its parent task. TITLES now exports as "Parent Titles". TITLES_INCLUDING_SUB keeps the plain name: it is the column that emits the row's own task title, and it is the one in WORKLOG_EXPORT_DEFAULTS, so the default export stays byte-identical. Also correct the two column labels, which described behaviour the columns never had: "only" read as a row filter although the column falls back to the task's own title, and the other never emitted both titles, just the task actually worked on. Move the header mapping to the util as getHeadlineCol() so the pair is covered by a unit test.

  • Johannes Millan(14 Aug 26)

    fix(android): stop false WebView block screens on healthy devices (#9585) * fix(android): stop false WebView block screens on healthy devices (#7229) Two different failures surfaced as the same "update Android System WebView" screen, which is why users experienced it as random. Closes #7518 as the same root cause. Remove the WebView prewarm from App.onCreate. It gained nothing: WebViewCompatibilityChecker.evaluate() already calls getDefaultUserAgent() as the first statement of both launch paths, before either builds a WebView, so the Chromium load was already triggered at the earliest useful moment on any UI start. Moving it to a background thread would not help either, since Chromium startup blocks on the WebView UI thread whichever thread asks. It did cost: onCreate runs on every process start, including the headless ones from reminder alarms, the widget and the sync worker, so this loaded Chromium in processes that never render anything. That made every headless wake a kill target on each WebView provider update, and — inferred from Android internals, not measured — a load that fails there may leave the process unable to start a WebView for its lifetime, which the user meets as a block screen on their next launch (WebViewRecovery relaunches same-process and cannot clear it). Stop presenting a version on the init-failure screen. It usually came from the non-authoritative PackageManager fallback — a disabled or never-updated package that is not the active provider — so the screen contradicted its own "This is not an outdated WebView version" copy. Users acted on the number by removing their WebView updates, which resets the Chromium profile and took their local data with it. It is logged instead, since evaluate()'s own log is behind an isLoggable(DEBUG) gate that is off in release. Stop asserting the provider package too. On this path it is usually the fallback scan's first *installed* match, and that list checks Chrome ahead of the non-Google providers, so on a de-Googled device the "clear this app's storage" dialog could have pointed at the user's browser. The package is still used to aim the intent — dropping it would name a package that may not be installed, and the details intent resolves anyway, landing the button on a dead page — but the dialog now names it as a best guess. Also: replace the duplicated init-failure result builders in both activities with WebViewCompatibilityChecker.initFailureResult(), and rename FullscreenActivity.showWebViewInitFailure to blockForWebViewInitFailure, since the same name in CapacitorMainActivity is the recovery entry point and a fix ported across by name would have skipped the one-shot relaunch. * fix(android): resolve the WebView provider package once for the reset dialog The dialog message and the button's intent each called providerPackageOrDefault independently. They agree today, but the whole point of that message is telling the user which app is about to have its storage cleared, so it must not be able to name one package while the button opens another.

  • Johannes Millan(14 Aug 26)

    test(config): tighten the #9548 regression test and correct the mechanism Review follow-ups on the time-field blur fix: - state the measured cause. The element is not "rebuilt" — under formly's `extras: { immutable: true }` a new `[model]` identity replaces the root field config, so the focused <input> is destroyed and re-created. Focus follows the replacement, so typing continues but the browser's segment typeahead buffer is gone. This is also why #9554's identical-value writeValue guard could not work: writeValue lands on a fresh element. - E2E: use SettingsPage.expandSection instead of a third copy of the collapsible logic, drop a toPass retry loop that wrapped a state-mutating helper (it could toggle isWorkStartEndEnabled off mid-assertion) in favour of waitForStatePersistence, use focus() over a layout-dependent click(), merge two overlapping cases into one, and document the en-GB 24h locale dependency. - unit: also assert `time` opts into the Enter-to-commit keydown handler.

  • Johannes Millan(14 Aug 26)

    fix(monitoring): bound active-users by the active-user set `monitor active-users` was failing on the hosted instance with "canceling statement due to statement_timeout". It printed the two user counts and then stopped, which reads as truncated output rather than a query failure — and survived five earlier fix attempts. Every windowed count filtered on `operations.received_at`, the NON-leading column of the only index covering it, (user_id, received_at). PostgreSQL can never make that a bounded range scan, so each window walked essentially the whole index: cost set by the size of `operations` rather than by the size of the answer, and paid four times per report. Measured on a production-shaped fixture (10,561 users, 3M operations over the 45-day retention window, 1.8 GB heap, production's postgres settings): four windows 110,047 buffers / 4 round trips -> 11,165 / 1 engaged users 25,644 buffers -> not run by default whole report 136,190 buffers (~1.06 GB) -> ~11,700 (~91 MB) The windows now come from one bucketed pass driven off users holding a device heartbeat inside the widest window, because sync.service.ts upserts `sync_devices.last_seen_at` inside the upload transaction: an operation in a window implies a heartbeat in that window. That holds only up to retention, so the widest window IS retention (45d, was 90d). Cleanup deletes device rows unseen for RETENTION_DAYS unconditionally, while the old-ops sweep skips any user whose snapshot predates the same cutoff — the two are inverted, not merely asymmetric, so the long-lapsed cohort keeps its operations and loses its heartbeat. A 90-day window would have counted those users before and silently stops counting them now. It also reported nothing a 45-day window does not, since both tables are pruned at that bound. A per-user probe was tried once before and reverted. That one was driven from `users`, paying an index descent for every registered account including the ~7k on the fixture that never synced at all. The driver is the whole point, and is now pinned by a test: widening it is a pure cost regression that emits byte-identical output, so only an assertion on the SQL can catch it. Engagement becomes opt-in (--engaged). Counting DISTINCT active days has to visit every operation in a two-week window and no rewrite removes that: at 25,644 buffers it cost MORE than the window query that was already timing out, so leaving it in the default path meant the report still could not finish, just one section later. Giving `operations` an index with received_at leading would fix it properly (the same fixture: 20,034 -> 255 buffers for a windowed count) and is worth a planned maintenance window; until then the default report stays answerable from the active-user set alone. `--threshold` now says so rather than being silently ignored. Its table is paged like every other table in the report — it was printing one row per matching account (2,499 on the fixture), burying the count the section exists to show. `COUNT(*) OVER ()` keeps that count exact, and one row is always fetched so `--limit 0` reports the true total instead of zero. The PGlite fixture gains the two users that make any of this falsifiable: one lapsed (heartbeat 10 days old, newest operation 40 days old) so "connected" and "syncing" cross window boundaries in different places, and one whose device row has been pruned while its operations survive — the state daily cleanup actually produces, and the one that fails if the window is ever widened past retention. Previously every fixture user sat inside every window, so the per-window counts could not be told apart at all.

  • Johannes Millan(14 Aug 26)

    fix(monitoring): bound active-users by the active-user set `monitor active-users` was failing on the hosted instance with "canceling statement due to statement_timeout". It printed the two user counts and then stopped, which reads as truncated output rather than a query failure — and survived five earlier fix attempts. Every windowed count filtered on `operations.received_at`, the NON-leading column of the only index covering it, (user_id, received_at). PostgreSQL can never make that a bounded range scan, so each window walked essentially the whole index: cost set by the size of `operations` rather than by the size of the answer, and paid four times per report. Measured on a production-shaped fixture (10,561 users, 3M operations over the 45-day retention window, 1.8 GB heap, production's postgres settings): four windows 110,047 buffers / 4 round trips -> 11,165 / 1 engaged users 25,644 buffers -> not run by default whole report 136,190 buffers (~1.06 GB) -> ~11,700 (~91 MB) The windows now come from one bucketed pass driven off users holding a device heartbeat inside the widest window. That bound is exact, not an approximation: sync.service.ts upserts `sync_devices.last_seen_at` inside the upload transaction, so an operation in a window implies a heartbeat in that window. Old and new counts agree in all four windows. A per-user probe was tried once before and reverted. That one was driven from `users`, paying an index descent for every registered account including the ~7k on the fixture that never synced at all. The driver is the whole point. Engagement becomes opt-in (--engaged). Counting DISTINCT active days has to visit every operation in a two-week window and no rewrite removes that: at 25,644 buffers it cost MORE than the window query that was already timing out, so leaving it in the default path meant the report still could not finish, just one section later. Giving `operations` an index with received_at leading would fix it properly (the same fixture: 20,034 -> 255 buffers for a windowed count) and is worth a planned maintenance window; until then the default report stays answerable from the active-user set alone. Its table is paged like every other table in the report — it was printing one row per matching account (2,499 on the fixture), burying the count the section exists to show. `COUNT(*) OVER ()` keeps that count exact, and one row is always fetched so `--limit 0` reports the true total instead of zero. The PGlite fixture gains a lapsed user (heartbeat 10 days old, newest operation 40 days old) so "connected" and "syncing" cross the window boundaries in different places. Previously every user sat inside every window, so the per-window counts could not be told apart and no window bug was detectable at all.

  • Johannes Millan(14 Aug 26)

    chore(git): ignore every .env variant except the example The rules listed .local variants one by one, so a hand-made backup like .env.bak.20260720-114653 was untracked but not ignored - it sat in the production checkout holding JWT_SECRET, POSTGRES_PASSWORD and SMTP_PASS, one `git add -A` away from being committed. Ignore .env.* wholesale and re-include the checked-in .env.example, which is the only file in that namespace that belongs in the repo. This also subsumes the previous .env.local and .env.*.local rules. Verified no currently tracked file becomes ignored (`git ls-files | git check-ignore --stdin` is empty), and that .env.example and packages/super-sync-server/env.example stay visible.

  • Johannes Millan(14 Aug 26)

    fix(config): commit time fields on blur so typing keeps both hour digits (#9582) The config form binds `[model]="cfg()"`, so a field committing on every keystroke round-trips through the store and hands formly a fresh model object. Formly rebuilds the field mid-edit, discarding the native time control's in-progress editing state: typing `18` into the hour segment produced `08`, and leaving the field wrote `00:00` to the config. `adjustToLiveFormlyForm` already forces `updateOn: 'blur'` for the other text-like types; the `time` type added in #8270 was never added to that list. This also drops a time edit from ~4 store dispatches to one. Closes #9548

Super Productivity Security

5.9/10

Repo Security Summary

Updated 27 Jul 26

  • Code-Review2/10
  • Maintained10/10
  • Security-Policy10/10
  • CII-Best-Practices0/10
  • Dangerous-Workflow10/10
  • Token-Permissions0/10
  • License10/10
  • Binary-Artifacts9/10
  • Branch-Protection5/10
  • Signed-Releases0/10
  • Packaging10/10
  • SAST10/10
  • Pinned-Dependencies7/10
  • Fuzzing0/10

Security Advisories (2)

  • mediumPatched

    GHSA-9v8x-68pf-p5x7SuperSync E2EE setup uploads and stores all user operations in plaintext before password is set

  • highPatchedCVSS 7.8

    CVE-2026-71551Arbitrary OS Command Execution via IPC EXEC Handler with Persistent Whitelist

Super Productivity Website

Website

Super Productivity – Open-Source Deep Work Task Manager

Free, open-source, privacy-first task manager with built-in time tracking, timeboxing, and focus mode. Works offline, no account required, developer-friendly.

Redirects

Does not redirect

Security Checks

All 65 security checks passed

Server Details

  • IP Address89.22.100.24
  • Hostnamevps54630.alfahosting-vps.de
  • LocationDortmund,Nordrhein-Westfalen,Germany,EU
  • ISPdogado GmbH
  • ASNAS8648

Associated Countries

  • DEDE

Safety Score

Website marked as safe

100%

Blacklist Check

super-productivity.com was found on 0 blacklists

  • AntiSocial Blacklist
  • Artists Against 419
  • Badbitcoin
  • Bambenek Consulting
  • CERT Polska
  • CoinBlockerLists
  • CRDF
  • CryptoScamDB
  • EtherAddressLookup
  • EtherScamDB
  • Fake Website Buster
  • MetaMask EthPhishing
  • NABP Not Recommended Sites
  • OpenPhish
  • PetScams
  • PhishFeed
  • PhishFort
  • Phishing.Database
  • PhishStats
  • PhishTank
  • Phishunt
  • RPiList Not Serious
  • Scam.Directory
  • SecureReload Phishing List
  • Spam404
  • StopGunScams
  • Suspicious Hosting IP
  • ThreatFox
  • ThreatLog
  • TweetFeed
  • URLhaus
  • ViriBack C2 Tracker

Website Preview

Website preview

Super Productivity Android App

APK Info

De-Googled Compatibility

Native4.00/ 41 ratings
microG4.00/ 42 ratings

Tested on Android 14–16 · Updated 13 Oct 25 · View on Plexus →

Trackers

No trackers found

    Permissions

    • Access Network State
    • Foreground Service
    • Internet
    • Post Notifications
    • Read External Storage
    • Write External Storage
    • Dynamic Receiver Not Exported Permission

    Super Productivity Reviews

    More Task Management

    • A Getting Things Done + Pomodoro productivity system for desktop and mobile. Local-first, no account required, can sync via WebDAV/Dropbox/local file or self-hosted deployment. With a CLI, REST API and MCP for scripting, has optional BYOK AI.

    About the Data: Super Productivity

    Change History

    Edit Super Productivity Data

    You can edit Super Productivity's entry in this section of awesome-privacy.yml by submitting a PR to our GitHub repo.
    Note that some of the information shown above has been aggregated from external sources, a list of these can be found data documentation.

    Origin Data

    Modify Data

    API

    You can access Super Productivity's data programmatically via our API. Simply make a GET request to:

    https://api.awesome-privacy.xyz/v1/services/super-productivity

    The REST API is free, no-auth and CORS-enabled. To learn more, view the API Docs or read the API Usage Guide.

    Share Super Productivity

    Help your friends compare Task Management, and pick privacy-respecting software and services.
    Share Super Productivity and Awesome Privacy with your network!