Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
112 changes: 112 additions & 0 deletions scripts/test-repeating-scope.mjs
Original file line number Diff line number Diff line change
@@ -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");
114 changes: 114 additions & 0 deletions src/common/repeatingScope.ts
Original file line number Diff line number Diff line change
@@ -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`;
}
73 changes: 48 additions & 25 deletions src/store/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,12 @@ import {
writeClassId,
writeTaskIds,
} from "@/common/paths";
import {
repeatingArchiveToast,
repeatingUpdateToast,
shouldTouchRepeatingTask,
type RepeatingEditScope,
} from "@/common/repeatingScope";
import {
getClassDoc,
getTaskDoc,
Expand Down Expand Up @@ -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<void> {
* @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<void> {
try {
const safeUpdates = this.sanitize_repeating_task_updates(updates);
const ids = writeTaskIds(task_ref, this.ORG_DOMAIN);
Expand All @@ -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;
Expand All @@ -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<void> {
* @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<void> {
try {
const ids = writeTaskIds(task_ref, this.ORG_DOMAIN);
const preferredRef = ids ? shortShareRef(ids.classId, ids.taskId) : task_ref;
Expand All @@ -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;
});
Expand Down
Loading
Loading