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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"test:color": "node --experimental-strip-types scripts/test-color.mjs",
"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:me-tasks": "node --experimental-strip-types scripts/test-me-tasks.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",
"test:join-class": "node --experimental-strip-types scripts/test-join-class.mjs",
Expand Down
61 changes: 61 additions & 0 deletions scripts/test-me-tasks.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* Unit tests for me/tasks API helpers (patch + board state merge).
* Run: node --experimental-strip-types scripts/test-me-tasks.mjs
*/
import {
applyMeTaskState,
meTaskStateFromBoardTask,
} from "../src/common/meTaskState.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 = JSON.stringify(actual) === JSON.stringify(expected);
if (!ok) {
failed++;
console.error(`FAIL: ${label}\n expected: ${JSON.stringify(expected)}\n actual: ${JSON.stringify(actual)}`);
} else {
console.log("ok:", label);
}
}

console.log("--- me/tasks state tests ---\n");

const boardRow = {
path: "c1/t1",
ref: "c1/t1",
completed: true,
completed_at: "2026-09-01T12:00:00Z",
note: "study notes",
note_updated_at: "2026-09-01T11:00:00Z",
workspace_id: "ws-abc",
};

const fromBoard = meTaskStateFromBoardTask(boardRow, "c1", "t1");
assert(fromBoard.completed === true, "board completed");
assertEq(fromBoard.note, "study notes", "board note");
assertEq(fromBoard.workspace_id, "ws-abc", "board workspace_id");

let map = {};
map = applyMeTaskState(map, fromBoard);
assert(map["c1/t1"]?.note === "study notes", "map by path");
assert(map["c1~t1"]?.workspace_id === "ws-abc", "map by tilde ref alias");

const undone = { ...fromBoard, completed: false, completed_at: null };
map = applyMeTaskState(map, undone);
assert(map["c1/t1"]?.completed === false, "patch completed false");

if (failed) {
console.error(`\n${failed} failed`);
process.exit(1);
}
console.log("\nall passed");
28 changes: 28 additions & 0 deletions src/common/meBoard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,10 @@ export interface BoardTask {
color?: string;
date: string | null;
completed: boolean;
completed_at?: string | null;
note?: string | null;
note_updated_at?: string | null;
workspace_id?: string | null;
repetition_group_id: string | null;
name?: string;
type?: string;
Expand Down Expand Up @@ -316,6 +320,30 @@ function normalizeBoardTask(raw: unknown, _orgDomain?: string): BoardTask | null
class_id: classId,
date: typeof rec.date === "string" ? rec.date : rec.date === null ? null : null,
completed: rec.completed === true,
completed_at:
typeof rec.completed_at === "string"
? rec.completed_at
: rec.completed_at === null
? null
: null,
note:
typeof rec.note === "string"
? rec.note
: rec.note === null
? null
: undefined,
note_updated_at:
typeof rec.note_updated_at === "string"
? rec.note_updated_at
: rec.note_updated_at === null
? null
: undefined,
workspace_id:
typeof rec.workspace_id === "string"
? rec.workspace_id
: rec.workspace_id === null
? null
: undefined,
repetition_group_id:
typeof rec.repetition_group_id === "string"
? rec.repetition_group_id
Expand Down
76 changes: 76 additions & 0 deletions src/common/meTaskState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* Pure per-task state types and map helpers (no API / Firebase).
* @module common/meTaskState
*/

import { flatTaskPath } from "./paths";

/** Per-task personal state returned by me/tasks and board payloads. */
export interface MeTaskState {
ref: string;
path: string;
completed: boolean;
completed_at: string | null;
note: string | null;
note_updated_at: string | null;
workspace_id: string | null;
}

/** Merge board task row fields into a MeTaskState map entry. */
export function meTaskStateFromBoardTask(
task: Record<string, unknown>,
classId: string,
taskId: string
): MeTaskState {
const path =
(typeof task.path === "string" && task.path) || flatTaskPath(classId, taskId);
const ref = (typeof task.ref === "string" && task.ref) || path;
const note =
typeof task.note === "string"
? task.note
: task.note === null
? null
: null;
return {
ref,
path,
completed: task.completed === true,
completed_at:
typeof task.completed_at === "string"
? task.completed_at
: task.completed_at === null
? null
: null,
note,
note_updated_at:
typeof task.note_updated_at === "string"
? task.note_updated_at
: note
? null
: null,
workspace_id:
typeof task.workspace_id === "string"
? task.workspace_id
: task.workspace_id === null
? null
: null,
};
}

/** Apply a task-state patch onto an in-memory map keyed by ref and path. */
export function applyMeTaskState(
map: Record<string, MeTaskState>,
state: MeTaskState
): Record<string, MeTaskState> {
const next = { ...map };
next[state.ref] = state;
next[state.path] = state;
if (state.ref.includes("~")) {
next[state.ref.split("~").join("/")] = state;
}
if (state.path.includes("/")) {
const tilde = state.path.split("/").join("~");
next[tilde] = state;
}
return next;
}
133 changes: 133 additions & 0 deletions src/common/meTasks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* Personal task state API — PATCH /api/v1/me/tasks/:taskPath (completed, note).
* Falls back to done/undo shims; stubs locally when routes 404 mid-dev.
*
* @module common/meTasks
*/

import { ApiFetchError, apiFetch } from "@/common/apiFetch";
import { applyMeTaskState, type MeTaskState } from "@/common/meTaskState";
import { flatTaskPath } from "@/common/paths";

export type { MeTaskState };
export { applyMeTaskState, meTaskStateFromBoardTask } from "@/common/meTaskState";

export interface PatchMeTaskBody {
completed?: boolean;
note?: string | null;
}

const stubStates = new Map<string, MeTaskState>();

function encodeTaskPath(taskPath: string): string {
return encodeURIComponent(taskPath.replace(/~/g, "/"));
}

function normalizeTaskState(raw: unknown, fallbackPath: string): MeTaskState | null {
if (!raw || typeof raw !== "object") return null;
const row = raw as Record<string, unknown>;
const path =
(typeof row.path === "string" && row.path) ||
(typeof row.ref === "string" && row.ref.includes("/") ? row.ref : fallbackPath);
const ref = typeof row.ref === "string" && row.ref ? row.ref : path;
return {
ref,
path,
completed: row.completed === true,
completed_at:
typeof row.completed_at === "string"
? row.completed_at
: row.completed_at === null
? null
: null,
note:
typeof row.note === "string"
? row.note
: row.note === null
? null
: null,
note_updated_at:
typeof row.note_updated_at === "string"
? row.note_updated_at
: row.note_updated_at === null
? null
: null,
workspace_id:
typeof row.workspace_id === "string"
? row.workspace_id
: row.workspace_id === null
? null
: null,
};
}

function stubTaskState(taskPath: string, patch: PatchMeTaskBody): MeTaskState {
const path = taskPath.replace(/~/g, "/");
const prev = stubStates.get(path) || {
ref: path,
path,
completed: false,
completed_at: null,
note: null,
note_updated_at: null,
workspace_id: null,
};
const next: MeTaskState = { ...prev };
if (patch.completed !== undefined) {
next.completed = patch.completed;
next.completed_at = patch.completed ? new Date().toISOString() : null;
}
if (patch.note !== undefined) {
next.note = patch.note;
next.note_updated_at = patch.note ? new Date().toISOString() : null;
}
stubStates.set(path, next);
return next;
}

/** Build canonical flat task path from classId + taskId or an existing ref/path. */
export function taskPathFromParts(classId: string, taskId: string): string {
return flatTaskPath(classId, taskId);
}

/**
* PATCH personal task state. Tries PATCH, then done/undo shims; stubs on 404.
*/
export async function patchMeTask(taskPath: string, body: PatchMeTaskBody): Promise<MeTaskState> {
const normalized = taskPath.replace(/~/g, "/");
const encoded = encodeTaskPath(normalized);
const base = `/api/v1/me/tasks/${encoded}`;

try {
const payload = await apiFetch<unknown>(base, { method: "PATCH", body });
const parsed = normalizeTaskState(payload, normalized);
if (parsed) return parsed;
} catch (err) {
if (err instanceof ApiFetchError) {
if (err.status === 404 && body.completed === true) {
try {
const payload = await apiFetch<unknown>(`${base}/done`, { method: "POST" });
const parsed = normalizeTaskState(payload, normalized);
if (parsed) return parsed;
} catch {
/* fall through to stub */
}
}
if (err.status === 404 && body.completed === false) {
try {
const payload = await apiFetch<unknown>(`${base}/undo`, { method: "POST" });
const parsed = normalizeTaskState(payload, normalized);
if (parsed) return parsed;
} catch {
/* fall through to stub */
}
}
if (err.status === 404 || err.status === 405 || err.status === 501) {
return stubTaskState(normalized, body);
}
}
throw err;
}

return stubTaskState(normalized, body);
}
Loading
Loading