From af4638a91cf8ef5866bb7974fa4e01227b3fd532 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 17:41:32 +0000 Subject: [PATCH 1/2] Add task workspaces UI and migrate done/notes to API - Route set_finished and set_note through PATCH /api/v1/me/tasks (done/undo shims, 404 shims) - Hydrate task_states from GET /api/v1/me/board (completed, note, workspace_id) - Add TaskWorkspace panel in ViewTask (enable/link/destroy, drop zone, Drive connect) - Teacher workspace uses class teacher-workspace routes when caller can manage class - Calendar and study views read completion via is_task_completed from API state Co-authored-by: Sander Vonk --- package.json | 1 + scripts/test-me-tasks.mjs | 61 ++++ src/common/meBoard.ts | 28 ++ src/common/meTaskState.ts | 76 ++++ src/common/meTasks.ts | 133 +++++++ src/common/workspace.ts | 331 ++++++++++++++++++ src/components/Portal/CalendarBlock.vue | 4 +- src/components/Portal/StudyBlock.vue | 6 +- src/components/Portal/TaskWorkspace.vue | 441 ++++++++++++++++++++++++ src/store/index.ts | 141 ++++++-- src/views/Portal/ViewTask.vue | 42 ++- 11 files changed, 1230 insertions(+), 34 deletions(-) create mode 100644 scripts/test-me-tasks.mjs create mode 100644 src/common/meTaskState.ts create mode 100644 src/common/meTasks.ts create mode 100644 src/common/workspace.ts create mode 100644 src/components/Portal/TaskWorkspace.vue diff --git a/package.json b/package.json index de42f09d..182d3466 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/test-me-tasks.mjs b/scripts/test-me-tasks.mjs new file mode 100644 index 00000000..a99e0254 --- /dev/null +++ b/scripts/test-me-tasks.mjs @@ -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"); diff --git a/src/common/meBoard.ts b/src/common/meBoard.ts index 27e601d5..70cb6609 100644 --- a/src/common/meBoard.ts +++ b/src/common/meBoard.ts @@ -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; @@ -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 diff --git a/src/common/meTaskState.ts b/src/common/meTaskState.ts new file mode 100644 index 00000000..ca8455ec --- /dev/null +++ b/src/common/meTaskState.ts @@ -0,0 +1,76 @@ +/** + * Pure per-task state types and map helpers (no API / Firebase). + * @module common/meTaskState + */ + +import { flatTaskPath } from "./paths.ts"; + +/** 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, + 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, + state: MeTaskState +): Record { + 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; +} diff --git a/src/common/meTasks.ts b/src/common/meTasks.ts new file mode 100644 index 00000000..78d6e0aa --- /dev/null +++ b/src/common/meTasks.ts @@ -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(); + +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; + 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 { + const normalized = taskPath.replace(/~/g, "/"); + const encoded = encodeTaskPath(normalized); + const base = `/api/v1/me/tasks/${encoded}`; + + try { + const payload = await apiFetch(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(`${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(`${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); +} diff --git a/src/common/workspace.ts b/src/common/workspace.ts new file mode 100644 index 00000000..53131ef7 --- /dev/null +++ b/src/common/workspace.ts @@ -0,0 +1,331 @@ +/** + * Task workspace API — personal task workspaces, teacher shared workspaces, Drive OAuth. + * Stubs locally when routes 404 mid-dev (mvtt-server sibling contract). + * + * @module common/workspace + */ + +import { ApiFetchError, apiFetch } from "@/common/apiFetch"; + +export interface WorkspaceFile { + id: string; + name: string; + mime_type?: string | null; + size?: number | null; + url?: string | null; + created_at?: string | null; + source?: "upload" | "drive" | string; +} + +export interface Workspace { + id: string; + kind: "task" | "teacher" | string; + files: WorkspaceFile[]; + drive_connected?: boolean; + linked_task_refs?: string[]; +} + +const stubWorkspaces = new Map(); +const stubTaskWorkspaceLinks = new Map(); + +function encodeTaskPath(taskPath: string): string { + return encodeURIComponent(taskPath.replace(/~/g, "/")); +} + +function normalizeWorkspace(raw: unknown): Workspace | null { + if (!raw || typeof raw !== "object") return null; + const row = raw as Record; + const id = typeof row.id === "string" ? row.id : typeof row.workspace_id === "string" ? row.workspace_id : ""; + if (!id) return null; + const files = Array.isArray(row.files) + ? row.files + .map((f) => { + if (!f || typeof f !== "object") return null; + const file = f as Record; + const fileId = typeof file.id === "string" ? file.id : ""; + const name = typeof file.name === "string" ? file.name : "file"; + if (!fileId) return null; + return { + id: fileId, + name, + mime_type: typeof file.mime_type === "string" ? file.mime_type : null, + size: typeof file.size === "number" ? file.size : null, + url: typeof file.url === "string" ? file.url : null, + created_at: typeof file.created_at === "string" ? file.created_at : null, + source: typeof file.source === "string" ? file.source : undefined, + } as WorkspaceFile; + }) + .filter((f): f is WorkspaceFile => !!f) + : []; + return { + id, + kind: typeof row.kind === "string" ? row.kind : "task", + files, + drive_connected: row.drive_connected === true, + linked_task_refs: Array.isArray(row.linked_task_refs) + ? row.linked_task_refs.filter((r): r is string => typeof r === "string") + : undefined, + }; +} + +function stubWorkspaceId(taskPath: string, kind: "task" | "teacher"): string { + const key = `${kind}:${taskPath.replace(/~/g, "/")}`; + const existing = stubTaskWorkspaceLinks.get(key); + if (existing) return existing; + const id = `stub-ws-${kind}-${Math.random().toString(36).slice(2, 10)}`; + stubTaskWorkspaceLinks.set(key, id); + stubWorkspaces.set(id, { id, kind, files: [], drive_connected: false }); + return id; +} + +function stubGetWorkspace(workspaceId: string): Workspace { + const existing = stubWorkspaces.get(workspaceId); + if (existing) return existing; + const ws: Workspace = { id: workspaceId, kind: "task", files: [], drive_connected: false }; + stubWorkspaces.set(workspaceId, ws); + return ws; +} + +function isMissingEndpoint(err: unknown): boolean { + return err instanceof ApiFetchError && (err.status === 404 || err.status === 405 || err.status === 501); +} + +/** GET workspace linked to a task (student personal workspace). */ +export async function fetchTaskWorkspace(taskPath: string): Promise { + const normalized = taskPath.replace(/~/g, "/"); + const encoded = encodeTaskPath(normalized); + try { + const payload = await apiFetch(`/api/v1/me/tasks/${encoded}/workspace`); + return normalizeWorkspace(payload); + } catch (err) { + if (isMissingEndpoint(err)) { + const id = stubTaskWorkspaceLinks.get(`task:${normalized}`); + return id ? stubGetWorkspace(id) : null; + } + throw err; + } +} + +/** POST create a new task workspace. */ +export async function createTaskWorkspace(taskPath: string): Promise { + const normalized = taskPath.replace(/~/g, "/"); + const encoded = encodeTaskPath(normalized); + try { + const payload = await apiFetch(`/api/v1/me/tasks/${encoded}/workspace`, { + method: "POST", + body: { kind: "task" }, + }); + const ws = normalizeWorkspace(payload); + if (ws) return ws; + } catch (err) { + if (isMissingEndpoint(err)) { + const id = stubWorkspaceId(normalized, "task"); + return stubGetWorkspace(id); + } + throw err; + } + const id = stubWorkspaceId(normalized, "task"); + return stubGetWorkspace(id); +} + +/** POST link an existing workspace_id to this task (shareable across project steps). */ +export async function linkTaskWorkspace(taskPath: string, workspaceId: string): Promise { + const normalized = taskPath.replace(/~/g, "/"); + const encoded = encodeTaskPath(normalized); + try { + const payload = await apiFetch(`/api/v1/me/tasks/${encoded}/workspace/link`, { + method: "POST", + body: { workspace_id: workspaceId }, + }); + const ws = normalizeWorkspace(payload); + if (ws) return ws; + } catch (err) { + if (isMissingEndpoint(err)) { + stubTaskWorkspaceLinks.set(`task:${normalized}`, workspaceId); + return stubGetWorkspace(workspaceId); + } + throw err; + } + stubTaskWorkspaceLinks.set(`task:${normalized}`, workspaceId); + return stubGetWorkspace(workspaceId); +} + +/** DELETE unlink/destroy workspace for a task. */ +export async function destroyTaskWorkspace(taskPath: string): Promise { + const normalized = taskPath.replace(/~/g, "/"); + const encoded = encodeTaskPath(normalized); + try { + await apiFetch(`/api/v1/me/tasks/${encoded}/workspace`, { method: "DELETE" }); + return; + } catch (err) { + if (isMissingEndpoint(err)) { + stubTaskWorkspaceLinks.delete(`task:${normalized}`); + return; + } + throw err; + } +} + +/** Teacher shared workspace for a class (class teachers only). */ +export async function fetchTeacherWorkspace(classId: string): Promise { + try { + const payload = await apiFetch( + `/api/v1/me/classes/${encodeURIComponent(classId)}/teacher-workspace` + ); + const ws = normalizeWorkspace(payload); + if (ws) return { ...ws, kind: "teacher" }; + return null; + } catch (err) { + if (isMissingEndpoint(err)) { + const id = stubTaskWorkspaceLinks.get(`teacher:${classId}`); + return id ? { ...stubGetWorkspace(id), kind: "teacher" } : null; + } + throw err; + } +} + +export async function createTeacherWorkspace(classId: string): Promise { + try { + const payload = await apiFetch( + `/api/v1/me/classes/${encodeURIComponent(classId)}/teacher-workspace`, + { method: "POST", body: { kind: "teacher" } } + ); + const ws = normalizeWorkspace(payload); + if (ws) return { ...ws, kind: "teacher" }; + } catch (err) { + if (isMissingEndpoint(err)) { + const id = stubWorkspaceId(classId, "teacher"); + return { ...stubGetWorkspace(id), kind: "teacher" }; + } + throw err; + } + const id = stubWorkspaceId(classId, "teacher"); + return { ...stubGetWorkspace(id), kind: "teacher" }; +} + +export async function linkTeacherWorkspace(classId: string, workspaceId: string): Promise { + try { + const payload = await apiFetch( + `/api/v1/me/classes/${encodeURIComponent(classId)}/teacher-workspace/link`, + { method: "POST", body: { workspace_id: workspaceId } } + ); + const ws = normalizeWorkspace(payload); + if (ws) return { ...ws, kind: "teacher" }; + } catch (err) { + if (isMissingEndpoint(err)) { + stubTaskWorkspaceLinks.set(`teacher:${classId}`, workspaceId); + return { ...stubGetWorkspace(workspaceId), kind: "teacher" }; + } + throw err; + } + stubTaskWorkspaceLinks.set(`teacher:${classId}`, workspaceId); + return { ...stubGetWorkspace(workspaceId), kind: "teacher" }; +} + +export async function destroyTeacherWorkspace(classId: string): Promise { + try { + await apiFetch(`/api/v1/me/classes/${encodeURIComponent(classId)}/teacher-workspace`, { + method: "DELETE", + }); + return; + } catch (err) { + if (isMissingEndpoint(err)) { + stubTaskWorkspaceLinks.delete(`teacher:${classId}`); + return; + } + throw err; + } +} + +/** GET workspace by id (files list). */ +export async function fetchWorkspace(workspaceId: string): Promise { + try { + const payload = await apiFetch(`/api/v1/workspaces/${encodeURIComponent(workspaceId)}`); + const ws = normalizeWorkspace(payload); + if (ws) return ws; + } catch (err) { + if (isMissingEndpoint(err)) { + return stubGetWorkspace(workspaceId); + } + throw err; + } + return stubGetWorkspace(workspaceId); +} + +/** Upload a file to a workspace (multipart). */ +export async function uploadWorkspaceFile(workspaceId: string, file: File): Promise { + const user = (await import("@/firebase")).auth.currentUser; + if (!user) throw new Error("Not signed in"); + const token = await user.getIdToken(); + const API_BASE = `https://${process.env.VUE_APP_BRAND_DOMAIN__API}`; + const url = `${API_BASE}/api/v1/workspaces/${encodeURIComponent(workspaceId)}/files`; + const form = new FormData(); + form.append("file", file, file.name); + + try { + const response = await fetch(url, { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + body: form, + }); + if (!response.ok) { + throw new ApiFetchError(`Upload failed (${response.status})`, response.status); + } + const payload = (await response.json()) as Record; + const wsFile = normalizeWorkspace({ id: workspaceId, files: [payload] })?.files[0]; + if (wsFile) return wsFile; + return { + id: String(payload.id || `stub-file-${Date.now()}`), + name: String(payload.name || file.name), + mime_type: file.type || null, + size: file.size, + source: "upload", + }; + } catch (err) { + if (isMissingEndpoint(err)) { + const ws = stubGetWorkspace(workspaceId); + const stubFile: WorkspaceFile = { + id: `stub-file-${Date.now()}`, + name: file.name, + mime_type: file.type || null, + size: file.size, + source: "upload", + created_at: new Date().toISOString(), + }; + ws.files = [...ws.files, stubFile]; + stubWorkspaces.set(workspaceId, ws); + return stubFile; + } + throw err; + } +} + +export async function deleteWorkspaceFile(workspaceId: string, fileId: string): Promise { + try { + await apiFetch(`/api/v1/workspaces/${encodeURIComponent(workspaceId)}/files/${encodeURIComponent(fileId)}`, { + method: "DELETE", + }); + } catch (err) { + if (isMissingEndpoint(err)) { + const ws = stubGetWorkspace(workspaceId); + ws.files = ws.files.filter((f) => f.id !== fileId); + stubWorkspaces.set(workspaceId, ws); + return; + } + throw err; + } +} + +/** Start Google Drive OAuth — returns redirect URL. */ +export async function startDriveOAuth(): Promise { + try { + const payload = await apiFetch<{ url?: string }>("/api/v1/oauth/drive/start", { method: "POST" }); + if (payload?.url) return payload.url; + } catch (err) { + if (isMissingEndpoint(err)) { + return "#drive-oauth-stub"; + } + throw err; + } + return "#drive-oauth-stub"; +} diff --git a/src/components/Portal/CalendarBlock.vue b/src/components/Portal/CalendarBlock.vue index 7dacc6b0..1b28a8e6 100644 --- a/src/components/Portal/CalendarBlock.vue +++ b/src/components/Portal/CalendarBlock.vue @@ -118,7 +118,7 @@ @click=" $event.preventDefault(); if ($event.ctrlKey || $event.metaKey) { - if (task.type != 'note') $store.set_finished(!$store.finished_tasks.includes(task.ref), task.ref); + if (task.type != 'note') $store.set_finished(!$store.is_task_completed(task.ref), task.ref); } else { $emit('taskclick', task); } @@ -357,7 +357,7 @@ export default { return day1.getDate() === day2.getDate() && day1.getMonth() === day2.getMonth() && day1.getFullYear() === day2.getFullYear(); }, is_completed(task) { - return this.$store.finished_tasks?.includes(task.ref); + return this.$store.is_task_completed(task.ref); }, get_day_tasks(day) { return this.tasks diff --git a/src/components/Portal/StudyBlock.vue b/src/components/Portal/StudyBlock.vue index 2a55affd..0a79db82 100644 --- a/src/components/Portal/StudyBlock.vue +++ b/src/components/Portal/StudyBlock.vue @@ -235,8 +235,8 @@ export default { // sort those with is_finished = false to the top filtered.sort((a, b) => { - let a_finished = this.$store.finished_tasks.includes(a.ref); - let b_finished = this.$store.finished_tasks.includes(b.ref); + let a_finished = this.$store.is_task_completed(a.ref); + let b_finished = this.$store.is_task_completed(b.ref); if (a_finished && !b_finished) return 1; if (!a_finished && b_finished) return -1; return 0; @@ -321,7 +321,7 @@ export default { this.is_ready = true; }, is_finished(ref) { - return this.$store.finished_tasks?.includes(ref); + return this.$store.is_task_completed(ref); }, toggle_finished(ref) { this.$store diff --git a/src/components/Portal/TaskWorkspace.vue b/src/components/Portal/TaskWorkspace.vue new file mode 100644 index 00000000..a92d4509 --- /dev/null +++ b/src/components/Portal/TaskWorkspace.vue @@ -0,0 +1,441 @@ + + + + + diff --git a/src/store/index.ts b/src/store/index.ts index 7c83860c..eeb7f393 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -60,6 +60,12 @@ import { type TeacherStatsResponse, } from "@/common/apiFetch"; import { mapMeBoardToClassInfos, teacherEmailFromBoardClass } from "@/common/meBoard"; +import { + applyMeTaskState, + meTaskStateFromBoardTask, + patchMeTask, + type MeTaskState, +} from "@/common/meTasks"; import { actingAsLabel, isActingAsLinked, @@ -309,6 +315,12 @@ export const useMainStore: StoreDefinition = defineStore({ * @default null */ teacher_stats_cache: null as (TeacherStatsResponse & { updated: number }) | null, + /** + * @memberOf .main.state + * @property {Object} task_states Per-task personal state from GET /api/v1/me/board and PATCH /api/v1/me/tasks + * @default {} + */ + task_states: {} as Record, }; // setting up store let local: string | null = window.localStorage.getItem(`${process.env.VUE_APP_BRAND_NAME_SHORT}_app_state`); @@ -402,7 +414,7 @@ export const useMainStore: StoreDefinition = defineStore({ */ upcoming_todo(): ProcessedTaskInfo[] { if (!this.upcoming) return []; - return this.upcoming.filter((task: ProcessedTaskInfo) => !this.finished_tasks?.includes(task.ref)); + return this.upcoming.filter((task: ProcessedTaskInfo) => !this.is_task_completed(task.ref)); }, /** * @memberOf .main.getters @@ -656,7 +668,15 @@ export const useMainStore: StoreDefinition = defineStore({ */ finished_tasks(): string[] { try { - if (!this.active_doc) throw "No active doc"; + const refs = new Set(); + for (const state of Object.values(this.task_states || {})) { + if (state?.completed) { + if (state.ref) refs.add(state.ref); + if (state.path) refs.add(state.path); + } + } + if (refs.size) return [...refs]; + if (!this.active_doc) return []; return this.active_doc.finished || []; } catch (err) { _status.warn("🔗 Couldn't get finished tasks", err); @@ -978,6 +998,12 @@ export const useMainStore: StoreDefinition = defineStore({ */ note_for(ref: string): string | null { const path = this.ref_to_path(ref); + const flat = ref?.replace(/~/g, "/"); + const state = + (ref && this.task_states?.[ref]) || + (path && this.task_states?.[path]) || + (flat && this.task_states?.[flat]); + if (state?.note) return state.note; return (this.notes && path && this.notes[path]) || null; }, /** @@ -995,18 +1021,10 @@ export const useMainStore: StoreDefinition = defineStore({ if (!this.active_doc) throw "No active doc"; if (!ref) throw "No ref provided"; const path: string | null = this.ref_to_path(ref); - if (!path) throw "Invalid ref"; - let doc: DocumentData = this.active_doc; - if (!doc.notes) { - doc.notes = {}; - } - - doc.notes[path] = note || null; - - this.set_active(doc); - await this.update_remote(); + const state = await patchMeTask(path, { note: note || null }); + this.apply_task_state(state); return Promise.resolve(); } catch (err) { return Promise.reject(err); @@ -1027,23 +1045,12 @@ export const useMainStore: StoreDefinition = defineStore({ if (!ref) throw "No reference(s) provided"; const paths: string[] = Array.isArray(ref) ? ref : [ref]; - let doc = this.active_doc; - if (!doc.finished) { - doc.finished = []; + for (const taskRef of paths) { + const path = this.ref_to_path(taskRef) || String(taskRef).replace(/~/g, "/"); + if (!path) throw "Invalid ref"; + const state = await patchMeTask(path, { completed: finished }); + this.apply_task_state(state); } - // if finished, add to finished array, else remove from finished array - if (finished) { - for (let p of paths) { - if (!doc.finished.includes(p)) { - doc.finished.push(p); - } - } - } else { - doc.finished = doc.finished.filter((p) => !paths.includes(p)); - } - - this.set_active(doc); - await this.update_remote(); new SuccessToast((paths.length > 1 ? "Tasks" : useMagic().done_prefix(this.tasks.find((e) => e.ref == paths[0]))) + (finished ? " marked as finished" : " marked as unfinished"), 2000); @@ -1053,6 +1060,79 @@ export const useMainStore: StoreDefinition = defineStore({ return Promise.reject(err); } }, + /** + * Whether a task is completed — prefers API/board task_states, then legacy finished[]. + */ + is_task_completed(ref: string): boolean { + if (!ref) return false; + const path = this.ref_to_path(ref) || ref.replace(/~/g, "/"); + const state = + this.task_states?.[ref] || + (path && this.task_states?.[path]) || + (ref.includes("~") && this.task_states?.[ref.split("~").join("/")]); + if (state) return state.completed === true; + if (this.active_doc?.finished?.includes(ref)) return true; + const task = (this.tasks as ProcessedTaskInfo[])?.find( + (t) => t.ref === ref || t.ref === path + ); + return task?.completed === true; + }, + /** + * Personal task state DTO for a ref (note, completed, workspace_id). + */ + task_state_for(ref: string): MeTaskState | null { + if (!ref) return null; + const path = this.ref_to_path(ref) || ref.replace(/~/g, "/"); + return ( + this.task_states?.[ref] || + (path && this.task_states?.[path]) || + null + ); + }, + /** + * Merge API task state into store and refresh derived task flags. + */ + apply_task_state(state: MeTaskState): void { + this.task_states = applyMeTaskState(this.task_states || {}, state); + this.patch_task_completion_flags(); + }, + /** + * Hydrate task_states from GET /api/v1/me/board envelope. + */ + ingest_board_task_states(board: MeBoardResponse): void { + let map = { ...(this.task_states || {}) }; + for (const task of board.tasks || []) { + const row = task as Record; + const state = meTaskStateFromBoardTask(row, task.class_id, task.id); + map = applyMeTaskState(map, state); + } + for (const entry of board.finished || []) { + const path = String(entry).replace(/~/g, "/"); + const existing = map[path] || map[entry]; + const state: MeTaskState = existing || { + ref: path, + path, + completed: true, + completed_at: null, + note: null, + note_updated_at: null, + workspace_id: null, + }; + map = applyMeTaskState(map, { ...state, completed: true }); + } + this.task_states = map; + this.patch_task_completion_flags(); + }, + /** Sync completed flags on processed tasks from task_states. */ + patch_task_completion_flags(): void { + if (!this.tasks?.length) return; + this.tasks = (this.tasks as ProcessedTaskInfo[]).map((task) => ({ + ...task, + completed: this.is_task_completed(task.ref), + note: this.note_for(task.ref), + workspace_id: this.task_state_for(task.ref)?.workspace_id ?? task.workspace_id, + })); + }, /** * @memberOf .main.actions * @function path_to_ref @@ -1328,6 +1408,9 @@ export const useMainStore: StoreDefinition = defineStore({ class_name: this.class_text(classes[i]), class_id: classes[i].id, ...(ref ? { ref } : {}), + completed: ref ? this.is_task_completed(ref) : false, + note: ref ? this.note_for(ref) : null, + workspace_id: ref ? this.task_state_for(ref)?.workspace_id ?? null : null, }); } } @@ -1370,6 +1453,7 @@ export const useMainStore: StoreDefinition = defineStore({ this.id_token_claims = null; this.stats_cache = null; this.teacher_stats_cache = null; + this.task_states = {}; this.teacher = { doc_ref: null, collection_ref: null, @@ -2098,6 +2182,7 @@ export const useMainStore: StoreDefinition = defineStore({ const payload = await apiFetch("/api/v1/me/board"); const board = parseMeBoardResponse(payload, this.ORG_DOMAIN); const mapped = mapMeBoardToClassInfos(board, this.active_doc?.classes, this.ORG_DOMAIN); + this.ingest_board_task_states(board); const classes: ClassInfo[] = mapped.map((cls) => { const enrollmentPath = cls.id; diff --git a/src/views/Portal/ViewTask.vue b/src/views/Portal/ViewTask.vue index 0ae84664..730c8e26 100644 --- a/src/views/Portal/ViewTask.vue +++ b/src/views/Portal/ViewTask.vue @@ -66,6 +66,16 @@ + +
This {{ task.type || "task" }} is archived and hidden from the calendar. @@ -133,6 +143,7 @@ import { WarningToast, ErrorToast } from "@svonk/util"; import { compatDateObj } from "@/common"; import { shareUrl } from "@/common/share"; import ClassNameChip from "@/components/Portal/ClassNameChip.vue"; +import TaskWorkspace from "@/components/Portal/TaskWorkspace.vue"; import smoothReflow from "vue-smooth-reflow"; import showdown from "showdown"; import "@/assets/style/markdown.css"; @@ -140,7 +151,7 @@ let converter = new showdown.Converter(); export default { name: "ViewTaskView", emits: ["close", "notes"], - components: { ClassNameChip }, + components: { ClassNameChip, TaskWorkspace }, mixins: [smoothReflow], data() { return { @@ -196,6 +207,18 @@ export default { can_manage_task() { return !!(this.$store.user && this.$store.can_manage_class(this.task_class_obj)); }, + taskPath() { + const ref = this.task?.ref || this.$route?.params?.ref; + if (!ref) return null; + return this.$store.ref_to_path(ref) || String(ref).replace(/~/g, "/"); + }, + taskState() { + const ref = this.$route?.params?.ref || this.task?.ref; + return ref ? this.$store.task_state_for(ref) : null; + }, + showTeacherWorkspace() { + return this.can_manage_task && this.$store.is_teacher; + }, }, mounted() { if (this.$route.name == "viewtask" || this.$route.name == "publicviewtask") { @@ -260,6 +283,23 @@ export default { this.$emit("notes", this.task); } }, + onWorkspaceChanged(workspaceId) { + if (!this.task) return; + this.task = { ...this.task, workspace_id: workspaceId }; + const ref = this.task.ref || this.$route?.params?.ref; + if (ref && workspaceId) { + const path = this.$store.ref_to_path(ref) || String(ref).replace(/~/g, "/"); + this.$store.apply_task_state({ + ref, + path, + completed: this.$store.is_task_completed(ref), + completed_at: this.taskState?.completed_at ?? null, + note: this.$store.note_for(ref), + note_updated_at: this.taskState?.note_updated_at ?? null, + workspace_id: workspaceId, + }); + } + }, async get_task() { // get task ref from route params if (!this.$route.params.ref) { From 8acdb95ccae9798b1be559f1b31a31883b2f5a7a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 17:47:47 +0000 Subject: [PATCH 2/2] Fix TypeScript build errors for task workspace PR - Drop .ts extension from meTaskState paths import - Use finished_tasks in upcoming_todo getter (avoid action call from getter) - Narrow MeTaskState lookup in note_for before reading .note Co-authored-by: Sander Vonk --- src/common/meTaskState.ts | 2 +- src/store/index.ts | 15 ++++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/common/meTaskState.ts b/src/common/meTaskState.ts index ca8455ec..5b4ff647 100644 --- a/src/common/meTaskState.ts +++ b/src/common/meTaskState.ts @@ -3,7 +3,7 @@ * @module common/meTaskState */ -import { flatTaskPath } from "./paths.ts"; +import { flatTaskPath } from "./paths"; /** Per-task personal state returned by me/tasks and board payloads. */ export interface MeTaskState { diff --git a/src/store/index.ts b/src/store/index.ts index eeb7f393..8f6c186a 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -414,7 +414,8 @@ export const useMainStore: StoreDefinition = defineStore({ */ upcoming_todo(): ProcessedTaskInfo[] { if (!this.upcoming) return []; - return this.upcoming.filter((task: ProcessedTaskInfo) => !this.is_task_completed(task.ref)); + const finished = this.finished_tasks; + return this.upcoming.filter((task: ProcessedTaskInfo) => !finished.includes(task.ref)); }, /** * @memberOf .main.getters @@ -999,10 +1000,10 @@ export const useMainStore: StoreDefinition = defineStore({ note_for(ref: string): string | null { const path = this.ref_to_path(ref); const flat = ref?.replace(/~/g, "/"); - const state = - (ref && this.task_states?.[ref]) || - (path && this.task_states?.[path]) || - (flat && this.task_states?.[flat]); + const state: MeTaskState | undefined = + (ref ? this.task_states?.[ref] : undefined) || + (path ? this.task_states?.[path] : undefined) || + (flat ? this.task_states?.[flat] : undefined); if (state?.note) return state.note; return (this.notes && path && this.notes[path]) || null; }, @@ -1068,8 +1069,8 @@ export const useMainStore: StoreDefinition = defineStore({ const path = this.ref_to_path(ref) || ref.replace(/~/g, "/"); const state = this.task_states?.[ref] || - (path && this.task_states?.[path]) || - (ref.includes("~") && this.task_states?.[ref.split("~").join("/")]); + (path ? this.task_states?.[path] : undefined) || + (ref.includes("~") ? this.task_states?.[ref.split("~").join("/")] : undefined); if (state) return state.completed === true; if (this.active_doc?.finished?.includes(ref)) return true; const task = (this.tasks as ProcessedTaskInfo[])?.find(