mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-21 02:20:12 +00:00
* fix(android): restore share title derivation and dedupe shared tasks Commitd32f7037a3accidentally reverted the EXTRA_SUBJECT handling fromedb102534e, so the share handler stopped sending the page subject and defaulted the title to the literal "Shared Content". The frontend's subject -> title -> derived title chain then always fell through to that placeholder, producing blank-looking shared tasks. - Restore EXTRA_SUBJECT extraction; leave title/subject empty when absent so the frontend can derive a meaningful title from the URL or note. - Ignore empty/blank shared text instead of creating a useless task. - Skip handleIntent() on Activity recreation (config change) so the same share Intent isn't re-processed into a duplicate task. - Extract buildTaskTitle/readableUrl as pure functions with unit tests and guard the effect against empty payloads. * fix(snack): scale error/warning snack duration with message length Long error messages (e.g. multi-sentence sync errors) were auto-dismissed after a fixed 8s, too short to read. Error/warning snacks now stay visible proportional to message length (~90ms/char), clamped to 10-30s. * feat(tasks): skip undo snack when deleting a blank task A sub task or parent task with an empty title and no data (notes, time, estimate, attachments, issue link, reminder, repeat, scheduling, deadline, non-blank sub tasks) no longer shows the undo-delete snack. * fix(sync): include archive data in REPAIR operations validateAndRepairCurrentState built the REPAIR op from the synchronous getStateSnapshot(), which hardcodes empty archiveYoung/archiveOld (archives live in IndexedDB, not NgRx state). The resulting REPAIR op carried empty archives, so every other client that applied it overwrote its archive with nothing — wiping archived tasks on all devices except the one that ran the repair. - Use getStateSnapshotAsync() so the REPAIR op carries real archives. - Extend the empty-archive overwrite guard in ArchiveOperationHandler._handleLoadAllData() to also cover OpType.Repair (previously only SYNC_IMPORT/BACKUP_IMPORT), as defense in depth. * test(sync): add archive REPAIR round-trip integration test Wires the real StateSnapshotService, ArchiveDbAdapter, ArchiveStoreService and ArchiveOperationHandler against real IndexedDB to verify archive data survives the REPAIR-op round-trip: - getStateSnapshotAsync() loads IndexedDB archives; getStateSnapshot() does not - archive round-trips from client A's IndexedDB through a REPAIR op into a fresh client B's IndexedDB - a REPAIR op carrying empty archives no longer wipes a client that has archive data (empty-archive guard regression) * perf(sync): skip archive IndexedDB reads when post-sync state is valid validateAndRepairCurrentState validated the full async snapshot (two IndexedDB archive reads + structured-clone deserialization) on every Checkpoint D, even when state was valid and no repair was needed. It now validates the cheap synchronous snapshot first and only loads the async snapshot (with archives) when a repair is actually required — the rare path. The REPAIR op still carries archive data. Also addresses multi-review follow-ups: - archive-operation-handler: reword the empty-archive guard comment so it no longer over-promises reconciliation for REPAIR ops. - archive-repair-roundtrip test: add isPersistent to the applied-op meta to match the real applier; scope the file docstring accurately. * fix(task-repeat-cfg): schedule inbox task for today when made recurring When an Inbox task (no dueDay) was made repeatable via the dialog with a recurrence starting today, it stayed unscheduled. The TODAY-first-occurrence branch of updateTaskAfterMakingItRepeatable$ derived currentDueDay from task.created as a fallback, so a task created today looked already scheduled and dueDay was never set. Key the decision on task.dueDay directly. Skip timed tasks and tasks that already have dueWithTime, since dueDay/dueWithTime are mutually exclusive and timed scheduling is handled by addRepeatCfgToTaskUpdateTask$. Closes #7725 * fix(tasks): correct monthly first/last-day recurrence anchoring The "Every month on the first day" and "Every month on the last day" quick settings scheduled the first task instance in the past. Both presets produced a backdated startDate (1st of the current month; hardcoded January 31), which getFirstRepeatOccurrence returns verbatim for monthly recurrences. - MONTHLY_FIRST_DAY now anchors startDate to the next 1st-of-month that is today or later. - MONTHLY_LAST_DAY anchors startDate to the current month's last day and sets a new monthlyLastDay flag, so the occurrence engine clamps to month-end every month regardless of startDate's day-of-month. - _normalizeMonthlyAnchor strips a stale monthlyLastDay flag when a config leaves the preset (CUSTOM mode has no control for it). Closes #7726 * fix(task-repeat-cfg): re-anchor start date after instance deleted When the user moved a repeat config's startDate earlier after deleting its only live task instance, the stale lastTaskCreationDay anchor kept suppressing every projected/created instance between the new startDate and the old anchor. rescheduleTaskOnRepeatCfgUpdate$ only re-anchored lastTaskCreationDay when a live task instance existed (the #7423 fix) — it returned early before the re-anchoring when there was none. Hoist the isStartDateMovedEarlier detection above that early return: when no live instance exists but startDate moved earlier, re-anchor to the day before the new first occurrence so it and every following day is created and projected fresh. Closes #7724 * test(task-repeat-cfg): cover startDate re-anchor with no live instance Add coverage for the #7724 fix beyond the effect unit test: - Selector integration tests: feed a config re-anchored to the day before the new startDate through selectTaskRepeatCfgsForExactDay and assert it projects the new startDate and every following day, while still excluding the anchor day and earlier. Also documents that the stale anchor suppresses the gap days. - E2E reproduction (recurring-move-start-date-earlier-no-instance): create a recurring task, delete its live instance, move startDate earlier via a transparent projection, and assert the new days appear in the planner. Verified to fail on pre-fix code. * feat(sync): move clientId from pf into SUP_OPS for atomic rotation Migrate the sync clientId out of the legacy `pf` IndexedDB database into `SUP_OPS` (new `client_id` store, schema v6). The clientId write now joins the atomic transaction in `runDestructiveStateReplacement`, so destructive flows (clean-slate, backup-restore) rotate it atomically with OPS/STATE_CACHE/VECTOR_CLOCK instead of a hand-rolled cross-database two-phase commit. - ClientIdService rewritten: SUP_OPS-backed via an independent connection, inline one-time pf->SUP_OPS migration, error-aware resolver. Read failures propagate (getOrGenerateClientId never mints a fresh id over a transient error — that would orphan the device's non-regenerable identity); loadClientId never throws. - Delete withRotation, generateNewClientId and the CAS/rollback machinery. - Extract pure generateClientId() + isValidClientIdFormat() into core/util/generate-client-id.ts. - pf becomes a read-only, one-time migration source (never written/deleted). Closes #7732 * fix(sync): dedup SUP_OPS connection open in ClientIdService Address multi-agent review findings on the clientId migration: - _getSupOpsDb() shares a single in-flight open via _supOpsDbPromise; concurrent cold-start callers previously each opened their own SUP_OPS connection, leaking all but the last. - _putClientIdIfAbsent() collapsed to a single tx.done / exit point. - db-upgrade.spec.ts: cover the v6 client_id store; the createObjectStore count assertions were stale and failing after the schema bump. - operation-log-migration.service.ts: correct a misleading comment about the genesis-op clientId fallback. * refactor(sync): align ClientIdService SUP_OPS open with in-house idiom Re-review of the connection-leak fix recommended matching OperationLogStoreService._ensureInit's pattern: - _getSupOpsDb() clears the in-flight promise in .catch (failure only) instead of an unconditional finally — the resolved handle lives in _supOpsDb, so the promise field is pure in-flight coordination state. - close/versionchange handlers now also null _supOpsDbPromise, so a stale (closed) connection is never re-handed-out. - Add a regression test asserting _openSupOpsDb runs exactly once for concurrent cold-start callers. * docs(sync): link the single-connection follow-up to #7735 Reference the tracked follow-up issue from the ClientIdService JSDoc and the plan's out-of-scope section, so the deliberate trade-off (one extra SUP_OPS connection) is traceable rather than forgotten. * test(sync): update ClientIdService spies for getOrGenerateClientId The op-log capture effect now resolves the clientId via getOrGenerateClientId() (was loadClientId() ?? generateNewClientId()). These two specs still mocked only loadClientId, so the effect called an undefined method, captured no op, and 4 tests failed in the full suite. - task-done-replay.integration.spec.ts - operation-log-lock-reentry.regression.spec.ts * test(sync): open SUP_OPS versionless in e2e read helpers DB_VERSION was bumped 5->6; five e2e helpers still opened SUP_OPS at the hardcoded old version 5 to read state after the app had already upgraded it to v6, throwing VersionError. Open versionless instead — matches the ~10 other e2e files that already do, and is future-proof against the next schema bump. - migration/legacy-data-migration.spec.ts (x2) - sync/supersync-legacy-migration-sync.spec.ts - sync/webdav-legacy-migration-sync.spec.ts - recurring/invalid-clock-string-bug-7067.spec.ts
208 lines
8.1 KiB
TypeScript
208 lines
8.1 KiB
TypeScript
import { expect, test } from '../../fixtures/test.fixture';
|
|
|
|
/**
|
|
* Bug: https://github.com/super-productivity/super-productivity/issues/7067
|
|
*
|
|
* An invalid `startTime` on a TaskRepeatCfg (e.g. from sync/import/migration)
|
|
* causes `getDateTimeFromClockString()` to throw "Invalid clock string" at
|
|
* runtime whenever a task list renders the repeat info chip.
|
|
*
|
|
* Reproduction steps (from the issue's first stack trace):
|
|
* 1. A TaskRepeatCfg has startTime = "INVALID_CLOCK_STRING" in the store
|
|
* (arriving via sync or a legacy corrupt save).
|
|
* 2. The tag-list component computes indicator chips for every task.
|
|
* 3. `getTaskRepeatInfoText()` calls `getDateTimeFromClockString(startTime)`
|
|
* which throws: "Invalid clock string".
|
|
*
|
|
* Expected: The app should handle an invalid startTime gracefully (skip it or
|
|
* fall back to no-time display) without crashing.
|
|
* Actual: Error is thrown inside the computed signal, crashing the view.
|
|
*/
|
|
test('should not crash when a repeat config has an invalid startTime in the store (bug #7067)', async ({
|
|
page,
|
|
workViewPage,
|
|
taskPage,
|
|
}) => {
|
|
// ── Phase 1: Create a task with a timed repeat config ─────────────────────
|
|
|
|
await workViewPage.waitForTaskList();
|
|
await workViewPage.addTask('RepeatBug7067');
|
|
const task = taskPage.getTaskByText('RepeatBug7067').first();
|
|
await expect(task).toBeVisible({ timeout: 10000 });
|
|
|
|
await taskPage.openTaskDetail(task);
|
|
await page
|
|
.locator('task-detail-item')
|
|
.filter({ has: page.locator('mat-icon', { hasText: /^repeat$/ }) })
|
|
.click();
|
|
|
|
const repeatDialog = page.locator('mat-dialog-container');
|
|
await repeatDialog.waitFor({ state: 'visible', timeout: 10000 });
|
|
|
|
// Set a valid startTime so the config has startTime + remindAt in the store
|
|
await repeatDialog.locator('collapsible .collapsible-header').last().click();
|
|
const startTimeField = repeatDialog.getByLabel(/Scheduled start time/i);
|
|
await expect(startTimeField).toBeVisible({ timeout: 5000 });
|
|
await startTimeField.fill('10:30');
|
|
await startTimeField.blur();
|
|
await page.waitForTimeout(300);
|
|
|
|
const saveBtn = repeatDialog.getByRole('button', { name: /Save/i });
|
|
await expect(saveBtn).toBeEnabled({ timeout: 5000 });
|
|
await saveBtn.click();
|
|
await repeatDialog.waitFor({ state: 'hidden', timeout: 10000 });
|
|
await page.keyboard.press('Escape');
|
|
// Wait for the op to be flushed to IndexedDB before we try to read it
|
|
await page.waitForTimeout(1500);
|
|
|
|
// ── Phase 2: Corrupt the startTime ────────────────────────────────────────
|
|
// Strategy A (dev mode): use ng.getComponent to dispatch directly to the
|
|
// in-memory NgRx store — no page reload needed.
|
|
// Strategy B (production mode): ng.getComponent is stripped; instead we
|
|
// inject a corrupt op directly into IndexedDB and reload the page so the
|
|
// hydrator replays it. The ops store accepts unencoded full-format ops
|
|
// alongside compact-encoded ones (isCompactOperation guard in store service).
|
|
|
|
const pageErrors: string[] = [];
|
|
page.on('pageerror', (err) => pageErrors.push(err.message));
|
|
|
|
const devModeCorrupted = await page.evaluate((): boolean => {
|
|
const ng = (window as any).ng;
|
|
if (!ng?.getComponent) return false;
|
|
|
|
for (const el of Array.from(document.querySelectorAll('*'))) {
|
|
try {
|
|
const comp = ng.getComponent(el) as any;
|
|
if (!comp) continue;
|
|
const store = comp._store ?? comp.store ?? comp.__store;
|
|
if (!store?.dispatch) continue;
|
|
|
|
let state: any = null;
|
|
store
|
|
.subscribe((s: any) => {
|
|
state = s;
|
|
})
|
|
.unsubscribe();
|
|
|
|
if (!state?.taskRepeatCfg?.ids?.length) continue;
|
|
|
|
// Find the config we just created (startTime === '10:30')
|
|
const cfgId = state.taskRepeatCfg.ids.find(
|
|
(id: string) => state.taskRepeatCfg.entities[id]?.startTime === '10:30',
|
|
);
|
|
if (!cfgId) continue;
|
|
|
|
store.dispatch({
|
|
type: '[TaskRepeatCfg] Update TaskRepeatCfg',
|
|
taskRepeatCfg: { id: cfgId, changes: { startTime: 'INVALID_CLOCK_STRING' } },
|
|
});
|
|
return true;
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
return false;
|
|
});
|
|
|
|
if (devModeCorrupted) {
|
|
// Dev mode: store already has the corrupt value; just navigate within the SPA.
|
|
await page.goto('/#/tag/TODAY/tasks');
|
|
await page.waitForTimeout(2000);
|
|
} else {
|
|
// Production mode: write a corrupt op to IndexedDB and reload so the
|
|
// hydrator replays it.
|
|
const idbCorrupted = await page.evaluate(async (): Promise<boolean> => {
|
|
return new Promise<boolean>((resolve) => {
|
|
const req = indexedDB.open('SUP_OPS');
|
|
req.onerror = () => resolve(false);
|
|
req.onsuccess = () => {
|
|
const db = req.result;
|
|
|
|
// Read all ops to find the TASK_REPEAT_CFG entry with startTime '10:30'
|
|
const readTx = db.transaction(['ops'], 'readonly');
|
|
const opsStore = readTx.objectStore('ops');
|
|
const allOpsReq = opsStore.getAll();
|
|
|
|
allOpsReq.onerror = () => resolve(false);
|
|
allOpsReq.onsuccess = () => {
|
|
const entries: any[] = allOpsReq.result;
|
|
|
|
// Find the most recent TASK_REPEAT_CFG op
|
|
let cfgId: string | null = null;
|
|
let clientId: string | null = null;
|
|
let vectorClock: Record<string, number> = {};
|
|
let schemaVersion = 2;
|
|
|
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
const entry = entries[i];
|
|
const op = entry.op;
|
|
if (!op) continue;
|
|
|
|
// Handle both compact format (short keys) and full format
|
|
const entityType = op.e ?? op.entityType;
|
|
if (entityType !== 'TASK_REPEAT_CFG') continue;
|
|
|
|
// Compact: op.d = entityId; full: op.entityId
|
|
cfgId = op.d ?? op.entityId ?? null;
|
|
clientId = op.c ?? op.clientId ?? null;
|
|
vectorClock = op.v ?? op.vectorClock ?? {};
|
|
schemaVersion = op.s ?? op.schemaVersion ?? 2;
|
|
break;
|
|
}
|
|
|
|
if (!cfgId) {
|
|
resolve(false);
|
|
return;
|
|
}
|
|
|
|
// Write a new full-format op (the store decodes compact ops but
|
|
// passes full ops straight through — see isCompactOperation guard).
|
|
const writeTx = db.transaction(['ops'], 'readwrite');
|
|
const writeStore = writeTx.objectStore('ops');
|
|
|
|
const corruptEntry = {
|
|
// seq omitted → IndexedDB auto-increments to max+1
|
|
op: {
|
|
id: crypto.randomUUID(),
|
|
actionType: '[TaskRepeatCfg] Update TaskRepeatCfg',
|
|
opType: 'UPD',
|
|
entityType: 'TASK_REPEAT_CFG',
|
|
entityId: cfgId,
|
|
payload: {
|
|
actionPayload: {
|
|
taskRepeatCfg: {
|
|
id: cfgId,
|
|
changes: { startTime: 'INVALID_CLOCK_STRING' },
|
|
},
|
|
},
|
|
entityChanges: [],
|
|
},
|
|
clientId: clientId ?? 'e2e-test-client',
|
|
vectorClock,
|
|
timestamp: Date.now(),
|
|
schemaVersion,
|
|
},
|
|
appliedAt: Date.now(),
|
|
source: 'local' as const,
|
|
};
|
|
|
|
const addReq = writeStore.add(corruptEntry);
|
|
addReq.onerror = () => resolve(false);
|
|
addReq.onsuccess = () => resolve(true);
|
|
};
|
|
};
|
|
});
|
|
});
|
|
|
|
expect(idbCorrupted).toBe(true); // guard: verify IDB injection worked
|
|
|
|
// Reload so the hydrator replays the corrupt op, then navigate to TODAY tag
|
|
await page.reload({ waitUntil: 'networkidle' });
|
|
await page.goto('/#/tag/TODAY/tasks');
|
|
await page.waitForTimeout(2000);
|
|
}
|
|
|
|
// ── Phase 3: Verify no "Invalid clock string" crash ───────────────────────
|
|
const clockErrors = pageErrors.filter((e) => e.includes('Invalid clock string'));
|
|
expect(clockErrors).toHaveLength(0);
|
|
});
|