From bef05dfae283d5a66b5ecab39eec7ebc73bf519b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 01:18:08 +0000 Subject: [PATCH] P3: Wire Vue recurring edit scopes to live callables Route series This one / This and future / Whole series through updateRepeatingTask and deleteRepeatingTask (including scope this), narrow optimistic local updates to the matched instance, and add scope matching unit tests. Co-authored-by: Sander Vonk --- .github/workflows/ci.yml | 3 + package.json | 1 + scripts/test-repeating-scope.mjs | 112 ++++++++++++++++++++++++++++++ src/common/repeatingScope.ts | 114 +++++++++++++++++++++++++++++++ src/store/index.ts | 73 +++++++++++++------- src/views/Portal/EditTask.vue | 71 +++++++++++++------ 6 files changed, 328 insertions(+), 46 deletions(-) create mode 100644 scripts/test-repeating-scope.mjs create mode 100644 src/common/repeatingScope.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3689f9b4..d8159a93 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,5 +43,8 @@ jobs: - name: Acting-as chip tests run: npm run test:acting-as + - name: Repeating edit-scope tests + run: npm run test:repeating-scope + - name: Build run: npm run build diff --git a/package.json b/package.json index ef713496..2c3d0b75 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "test:class-listeners": "node --experimental-strip-types scripts/test-class-listeners.mjs", "test:me-board": "node --experimental-strip-types scripts/test-me-board.mjs", "test:acting-as": "node --experimental-strip-types scripts/test-acting-as.mjs", + "test:repeating-scope": "node --experimental-strip-types scripts/test-repeating-scope.mjs", "deploy": "node deploy.js", "postbuild": "cross-env OS_TYPE=$(uname -s) npm-run-all --parallel copy-files echo-message", "copy-files": "npm run copy-win || npm run copy-nix", diff --git a/scripts/test-repeating-scope.mjs b/scripts/test-repeating-scope.mjs new file mode 100644 index 00000000..ea4f38b1 --- /dev/null +++ b/scripts/test-repeating-scope.mjs @@ -0,0 +1,112 @@ +/** + * Unit tests for repeating edit-scope matching + toast copy. + * Run: node --experimental-strip-types scripts/test-repeating-scope.mjs + */ +import { + matchesRepeatingInstance, + repeatingArchiveToast, + repeatingUpdateToast, + shouldTouchRepeatingTask, +} from "../src/common/repeatingScope.ts"; + +let failed = 0; + +function assert(cond, msg) { + if (!cond) { + failed++; + console.error("FAIL:", msg); + } else { + console.log("ok:", msg); + } +} + +function assertEq(actual, expected, label) { + const ok = actual === expected; + if (!ok) { + failed++; + console.error(`FAIL: ${label}\n expected: ${JSON.stringify(expected)}\n actual: ${JSON.stringify(actual)}`); + } else { + console.log(`ok: ${label}`); + } +} + +const org = "mvla.net"; +const group = "rep-group-1"; +const preferredRef = "classA~task1"; +const matchOpts = { + preferredRef, + classId: "classA", + taskId: "task1", + task_date: "2026-03-10", + orgDomain: org, +}; + +const target = { ref: "classA/task1", date: "2026-03-10", repetition_group_id: group }; +const sibling = { ref: "classA/task2", date: "2026-03-17", repetition_group_id: group }; +const otherSeries = { ref: "classA/task9", date: "2026-03-10", repetition_group_id: "other" }; +const pastSibling = { ref: "classA/task0", date: "2026-03-03", repetition_group_id: group }; + +console.log("--- matchesRepeatingInstance ---\n"); + +assert(matchesRepeatingInstance(target, matchOpts), "match flat classId/taskId ref"); +assert( + matchesRepeatingInstance({ ref: "classA~task1", date: "2026-03-17" }, matchOpts), + "match short share ref even if date differs" +); +assert(matchesRepeatingInstance({ ref: "other", date: "2026-03-10" }, matchOpts), "match by date fallback"); +assert(!matchesRepeatingInstance(sibling, matchOpts), "sibling different id+date does not match"); + +console.log("\n--- shouldTouchRepeatingTask scope this ---\n"); + +assert( + shouldTouchRepeatingTask(target, group, "this", matchOpts), + "this: touches matching instance" +); +assert( + !shouldTouchRepeatingTask(sibling, group, "this", matchOpts), + "this: does not touch sibling in same series" +); +assert( + !shouldTouchRepeatingTask(otherSeries, group, "this", matchOpts), + "this: does not touch other series" +); + +console.log("\n--- shouldTouchRepeatingTask scope future / all ---\n"); + +const futureOpts = { ...matchOpts, referenceDate: new Date("2026-03-10T00:00:00") }; +assert( + shouldTouchRepeatingTask(target, group, "future", futureOpts), + "future: touches on-or-after reference" +); +assert( + shouldTouchRepeatingTask(sibling, group, "future", futureOpts), + "future: touches later sibling" +); +assert( + !shouldTouchRepeatingTask(pastSibling, group, "future", futureOpts), + "future: skips earlier sibling" +); +assert( + shouldTouchRepeatingTask(pastSibling, group, "all", futureOpts), + "all: touches earlier sibling" +); +assert( + !shouldTouchRepeatingTask(otherSeries, group, "all", futureOpts), + "all: skips other series" +); + +console.log("\n--- toast copy ---\n"); + +assertEq(repeatingUpdateToast("this", 1), "Updated this task", "update toast singular this"); +assertEq(repeatingUpdateToast("this", 2), "Updated 2 tasks in series", "update toast this count!=1"); +assertEq(repeatingUpdateToast("future", 1), "Updated 1 tasks in series", "update toast future keeps count"); +assertEq(repeatingUpdateToast("all", 5), "Updated 5 tasks in series", "update toast all"); +assertEq(repeatingArchiveToast("this", 1), "Archived this task", "archive toast singular this"); +assertEq(repeatingArchiveToast("all", 3), "Archived 3 tasks in series", "archive toast all"); + +console.log(""); +if (failed) { + console.error(`${failed} failing assertion(s)`); + process.exit(1); +} +console.log("all repeating-scope tests passed"); diff --git a/src/common/repeatingScope.ts b/src/common/repeatingScope.ts new file mode 100644 index 00000000..c0c4ad58 --- /dev/null +++ b/src/common/repeatingScope.ts @@ -0,0 +1,114 @@ +/** + * Repeating-task edit/archive scopes and optimistic local matching. + * Pure leaf module (no Firebase / Vue) so node tests can load it directly. + * Mirrors mvtt-server updateRepeatingTask / deleteRepeatingTask scopes. + * + * @module common/repeatingScope + */ + +export type RepeatingEditScope = "this" | "future" | "all"; + +export interface RepeatingInstanceMatchOpts { + preferredRef?: string | null; + classId?: string | null; + taskId?: string | null; + /** YYYY-MM-DD (or date-prefix) of the instance being edited/archived. */ + task_date?: string | null; + /** Reserved for callers that already resolve ids via writeTaskIds; unused in matching. */ + orgDomain?: string; +} + +function datePrefix(value: unknown): string { + if (typeof value !== "string" || !value) return ""; + return value.split("T")[0]; +} + +/** Normalize ~ and / so classId~taskId matches classId/taskId. */ +function normalizeRef(ref: string | undefined | null): string { + if (!ref || typeof ref !== "string") return ""; + return ref.trim().split("/").join("~"); +} + +function refsEqual(a: string | undefined | null, b: string | undefined | null): boolean { + const na = normalizeRef(a); + const nb = normalizeRef(b); + return !!na && !!nb && na === nb; +} + +/** Last segment of a normalized classId~taskId (or longer) ref. */ +function taskIdFromRef(ref: string | undefined | null): string | null { + const parts = normalizeRef(ref).split("~").filter(Boolean); + if (parts.length < 2) return null; + return parts[parts.length - 1] || null; +} + +function classIdFromRef(ref: string | undefined | null): string | null { + const parts = normalizeRef(ref).split("~").filter(Boolean); + if (parts.length < 2) return null; + // classId~taskId → classId; email-local~classId~taskId → classId + return parts.length >= 3 ? parts[parts.length - 2] : parts[0]; +} + +/** + * True when `task` is the same series instance as preferredRef / taskId / task_date. + * Used for scope `"this"` optimistic map/filter (do not touch the whole series). + */ +export function matchesRepeatingInstance( + task: { ref?: string; date?: unknown } | null | undefined, + opts: RepeatingInstanceMatchOpts +): boolean { + if (!task) return false; + const { preferredRef, classId, taskId, task_date } = opts; + + if (preferredRef && refsEqual(task.ref, preferredRef)) return true; + + if (classId && taskId) { + const flat = `${classId}/${taskId}`; + const short = `${classId}~${taskId}`; + if (refsEqual(task.ref, flat) || refsEqual(task.ref, short)) return true; + if (classIdFromRef(task.ref) === classId && taskIdFromRef(task.ref) === taskId) return true; + } + + if (task_date) { + const want = datePrefix(task_date); + const have = datePrefix(task.date); + if (want && have && want === have) return true; + } + + return false; +} + +/** + * Whether optimistic local update/archive should touch this series task for the given scope. + */ +export function shouldTouchRepeatingTask( + task: { repetition_group_id?: string; ref?: string; date?: unknown } | null | undefined, + repetition_group_id: string, + scope: RepeatingEditScope, + opts: RepeatingInstanceMatchOpts & { referenceDate?: Date | null } +): boolean { + if (!task || task.repetition_group_id !== repetition_group_id) return false; + + if (scope === "this") { + return matchesRepeatingInstance(task, opts); + } + + if (scope === "future" && opts.referenceDate) { + const taskDateStr = datePrefix(task.date); + const taskDate = taskDateStr ? new Date(taskDateStr + "T00:00:00") : null; + if (taskDate && taskDate < opts.referenceDate) return false; + } + + // "all" (and "future" past the cutoff) → touch + return true; +} + +export function repeatingUpdateToast(scope: RepeatingEditScope, count: number): string { + if (scope === "this" && count === 1) return "Updated this task"; + return `Updated ${count} tasks in series`; +} + +export function repeatingArchiveToast(scope: RepeatingEditScope, count: number): string { + if (scope === "this" && count === 1) return "Archived this task"; + return `Archived ${count} tasks in series`; +} diff --git a/src/store/index.ts b/src/store/index.ts index e719ec00..7c83860c 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -105,6 +105,12 @@ import { writeClassId, writeTaskIds, } from "@/common/paths"; +import { + repeatingArchiveToast, + repeatingUpdateToast, + shouldTouchRepeatingTask, + type RepeatingEditScope, +} from "@/common/repeatingScope"; import { getClassDoc, getTaskDoc, @@ -2575,9 +2581,16 @@ export const useMainStore: StoreDefinition = defineStore({ /** * @memberOf .main.actions * @function update_repeating_task - * @description Update a repeating task series - */ - async update_repeating_task(repetition_group_id: string, updates: any, scope: "future" | "all", task_ref: string, task_date: string): Promise { + * @description Update a repeating task series (or a single instance when scope is `"this"`). + * @param scope `"this"` | `"future"` | `"all"` — passed through to `updateRepeatingTask` unchanged + */ + async update_repeating_task( + repetition_group_id: string, + updates: any, + scope: RepeatingEditScope, + task_ref: string, + task_date: string + ): Promise { try { const safeUpdates = this.sanitize_repeating_task_updates(updates); const ids = writeTaskIds(task_ref, this.ORG_DOMAIN); @@ -2596,21 +2609,23 @@ export const useMainStore: StoreDefinition = defineStore({ if (data.error) throw data.error; - _status.log(`📝 Updated repeating task series (${data.count} tasks)`); - new SuccessToast(`Updated ${data.count} tasks in series`, 2000); + _status.log(`📝 Updated repeating task (${scope}, ${data.count} tasks)`); + new SuccessToast(repeatingUpdateToast(scope, data.count), 2000); // Update local state immediately to prevent UI desync const referenceDate = task_date ? new Date(task_date + "T00:00:00") : null; + const matchOpts = { + preferredRef, + classId: ids?.classId ?? null, + taskId: ids?.taskId ?? null, + task_date, + orgDomain: this.ORG_DOMAIN, + referenceDate, + }; const updatedClasses = this.classes.map((classInfo: ClassInfo) => { if (!classInfo.tasks) return classInfo; classInfo.tasks = classInfo.tasks.map((task: TaskInfo) => { - if (task.repetition_group_id !== repetition_group_id) return task; - // For "future" scope, only update tasks on or after the reference date - if (scope === "future" && referenceDate) { - const taskDateStr = typeof task.date === "string" ? task.date : ""; - const taskDate = taskDateStr ? new Date(taskDateStr.split("T")[0] + "T00:00:00") : null; - if (taskDate && taskDate < referenceDate) return task; - } + if (!shouldTouchRepeatingTask(task, repetition_group_id, scope, matchOpts)) return task; return { ...task, ...safeUpdates }; }); return classInfo; @@ -2629,9 +2644,15 @@ export const useMainStore: StoreDefinition = defineStore({ /** * @memberOf .main.actions * @function delete_repeating_task - * @description Delete a repeating task series - */ - async delete_repeating_task(repetition_group_id: string, scope: "future" | "all", task_ref: string, task_date: string): Promise { + * @description Archive a repeating task series (or a single instance when scope is `"this"`). + * @param scope `"this"` | `"future"` | `"all"` — passed through to `deleteRepeatingTask` unchanged + */ + async delete_repeating_task( + repetition_group_id: string, + scope: RepeatingEditScope, + task_ref: string, + task_date: string + ): Promise { try { const ids = writeTaskIds(task_ref, this.ORG_DOMAIN); const preferredRef = ids ? shortShareRef(ids.classId, ids.taskId) : task_ref; @@ -2647,22 +2668,24 @@ export const useMainStore: StoreDefinition = defineStore({ if (data.error) throw data.error; - _status.log(`🗑️ Deleted repeating task series (${data.count} tasks)`); - new SuccessToast(`Archived ${data.count} tasks in series`, 2000); + _status.log(`🗑️ Archived repeating task (${scope}, ${data.count} tasks)`); + new SuccessToast(repeatingArchiveToast(scope, data.count), 2000); // Update local state immediately to prevent UI desync const referenceDate = task_date ? new Date(task_date + "T00:00:00") : null; + const matchOpts = { + preferredRef, + classId: ids?.classId ?? null, + taskId: ids?.taskId ?? null, + task_date, + orgDomain: this.ORG_DOMAIN, + referenceDate, + }; const updatedClasses = this.classes.map((classInfo: ClassInfo) => { if (!classInfo.tasks) return classInfo; classInfo.tasks = classInfo.tasks.filter((task: TaskInfo) => { - if (task.repetition_group_id !== repetition_group_id) return true; - // For "future" scope, only remove tasks on or after the reference date - if (scope === "future" && referenceDate) { - const taskDateStr = typeof task.date === "string" ? task.date : ""; - const taskDate = taskDateStr ? new Date(taskDateStr.split("T")[0] + "T00:00:00") : null; - if (taskDate && taskDate < referenceDate) return true; - } - return false; + // Keep tasks we should not touch; drop those in scope + return !shouldTouchRepeatingTask(task, repetition_group_id, scope, matchOpts); }); return classInfo; }); diff --git a/src/views/Portal/EditTask.vue b/src/views/Portal/EditTask.vue index 64b81701..2b3199c8 100644 --- a/src/views/Portal/EditTask.vue +++ b/src/views/Portal/EditTask.vue @@ -4,7 +4,7 @@ Loading Icon -
Save changes to this {{ task.type || "task" }} series to
+
+ Choose what save or archive will affect in this {{ task.type || "task" }} series +
- - - + + +
- +
@@ -155,16 +157,38 @@ export default { day: "numeric", }); }, - scope_text() { - if (!this.task.repetition_group_id || this.edit_scope === "this") { - return ""; - } - return this.edit_scope === "future" ? " future" : " all"; + save_button_text() { + const type = this.task.type || "task"; + if (!this.task.repetition_group_id || this.edit_scope === "this") return `Save ${type}`; + if (this.edit_scope === "future") return `Save this and future ${type}s`; + return `Save whole series`; + }, + archive_button_text() { + if (!this.task.repetition_group_id || this.edit_scope === "this") return "Archive"; + if (this.edit_scope === "future") return "Archive this and future"; + return "Archive whole series"; + }, + archiveConfirmTitle() { + if (this.edit_scope === "this") return "Archive this task"; + if (this.edit_scope === "future") return "Archive this and future"; + return "Archive whole series"; }, archiveConfirmHtml() { - const scopeText = this.edit_scope === "future" ? "this and all future tasks in the series" : "all tasks in this series"; + let scopeText; + if (this.edit_scope === "this") { + scopeText = "this task only (other tasks in the series stay)"; + } else if (this.edit_scope === "future") { + scopeText = "this and all future tasks in the series"; + } else { + scopeText = "the whole series (every task in the series)"; + } return `
Are you sure you want to archive ${scopeText}?

This action cannot be undone.
`; }, + /** Original instance date for callable identity / future cutoff (not an edited date). */ + series_task_date() { + const d = this.original?.date ?? this.task?.date; + return typeof d === "string" ? d.split("T")[0] : d; + }, class_obj_for_task() { if (this.task?._class) return this.task._class; const classId = this.original?.class_id || this.task?.class_id; @@ -207,12 +231,16 @@ export default { } this.loading = true; - let action; - if (this.edit_scope === "this") { - action = this.$store.update_task(this.task.ref, this.task); - } else { - action = this.$store.update_repeating_task(this.task.repetition_group_id, this.task_updates(), this.edit_scope, this.task.ref, this.task.date); - } + // Series instances always use updateRepeatingTask (including scope "this" so date changes go through the callable). + const action = this.task.repetition_group_id + ? this.$store.update_repeating_task( + this.task.repetition_group_id, + this.task_updates(), + this.edit_scope, + this.task.ref, + this.series_task_date + ) + : this.$store.update_task(this.task.ref, this.task); action .then(() => { @@ -233,7 +261,8 @@ export default { }); }, archive_task() { - if (this.edit_scope !== "this" && this.task.repetition_group_id) { + if (this.task.repetition_group_id) { + // All series scopes (including "this") use deleteRepeatingTask after confirm. this.showArchiveConfirm = true; return; } @@ -253,7 +282,7 @@ export default { this.showArchiveConfirm = false; this.loading = true; this.$store - .delete_repeating_task(this.task.repetition_group_id, this.edit_scope, this.task.ref, this.task.date) + .delete_repeating_task(this.task.repetition_group_id, this.edit_scope, this.task.ref, this.series_task_date) .then(() => { this.$emit("close"); })