src/store/index.ts breakup (no drama)
This commit is contained in:
@@ -0,0 +1,929 @@
|
||||
import { createEffect, createRoot } from "solid-js";
|
||||
import { toast } from "solid-sonner";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import { CONTEXTS_COLLECTION, NOTES_COLLECTION, TASGRID_COLLECTION } from "@/lib/constants";
|
||||
import { PROD_MIGRATION_ADMIN_EMAILS, PROD_MIGRATION_ENABLED, TASGRID_IS_DEV_DATA } from "@/lib/app-config";
|
||||
import { buildNoteTag, buildShareTag, normalizeNoteKey } from "@/lib/tags";
|
||||
import {
|
||||
activeNoteId,
|
||||
applyScopedPrefsToStore,
|
||||
currentTaskContext,
|
||||
getPersistedStoreSnapshot,
|
||||
getStorageKey,
|
||||
type Note,
|
||||
readScopedPrefs,
|
||||
setCurrentTaskContext,
|
||||
setNow,
|
||||
setStore,
|
||||
store,
|
||||
type Task,
|
||||
unwrapRelationId,
|
||||
writeScopedPrefs
|
||||
} from "./state";
|
||||
import {
|
||||
buildPersonalAccessFilter,
|
||||
getNoteByRef,
|
||||
getPersonalContext,
|
||||
getSubscribedBucketContexts,
|
||||
loadContextsIntoStore,
|
||||
mapContextToShareRule,
|
||||
mapRecordToContext,
|
||||
relationFilter,
|
||||
setContexts,
|
||||
shouldTaskBeVisible,
|
||||
syncSupervisedContexts,
|
||||
syncSystemTagsAndRules,
|
||||
syncUserContexts,
|
||||
taskHasPersonalContextAccess
|
||||
} from "./contexts";
|
||||
import { deleteNotePermanently, loadedNoteDetailIds, mapRecordToNote, scheduleDeferredNotesMetadataLoad } from "./notes";
|
||||
import { loadFilterTemplates, syncPreferences } from "./preferences";
|
||||
import {
|
||||
buildNoteLinkMap,
|
||||
buildTaskMigrationPayload,
|
||||
checkRecurringTasks,
|
||||
deleteTaskPermanently,
|
||||
ensureUpdatedByFieldSupport,
|
||||
getCombinedScore,
|
||||
getTaskListFields,
|
||||
mapRecordToTask,
|
||||
upsertTagDefinition
|
||||
} from "./tasks";
|
||||
|
||||
let realtimeRecoveryAttached = false;
|
||||
let realtimeRecoveryTimer: number | undefined;
|
||||
let lastRealtimeRecoveryAt = 0;
|
||||
let contextSyncInterval: number | undefined;
|
||||
let heartbeatInterval: number | undefined;
|
||||
let quickloadFadeTimer: number | undefined;
|
||||
|
||||
const scheduleRealtimeRecovery = (reason: string, delayMs = 400) => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (!pb.authStore.isValid) return;
|
||||
|
||||
const nowTs = Date.now();
|
||||
if (nowTs - lastRealtimeRecoveryAt < 10000) return;
|
||||
|
||||
if (realtimeRecoveryTimer) {
|
||||
window.clearTimeout(realtimeRecoveryTimer);
|
||||
}
|
||||
|
||||
realtimeRecoveryTimer = window.setTimeout(async () => {
|
||||
realtimeRecoveryTimer = undefined;
|
||||
if (!pb.authStore.isValid || store.isInitializing) return;
|
||||
lastRealtimeRecoveryAt = Date.now();
|
||||
console.log(`[REALTIME] Recovering subscriptions after ${reason}`);
|
||||
try {
|
||||
await initStore();
|
||||
} catch (err) {
|
||||
console.warn("[REALTIME] Recovery failed:", err);
|
||||
}
|
||||
}, delayMs);
|
||||
};
|
||||
|
||||
const ensureRealtimeRecovery = () => {
|
||||
if (realtimeRecoveryAttached || typeof window === "undefined") return;
|
||||
realtimeRecoveryAttached = true;
|
||||
|
||||
const recoverIfDisconnected = (reason: string) => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
if (pb.realtime.isConnected) return;
|
||||
scheduleRealtimeRecovery(reason);
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
recoverIfDisconnected("visibilitychange");
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("focus", () => recoverIfDisconnected("focus"));
|
||||
window.addEventListener("online", () => recoverIfDisconnected("online"));
|
||||
|
||||
pb.realtime.onDisconnect = () => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
};
|
||||
};
|
||||
|
||||
export const startContextPolling = () => {
|
||||
if (contextSyncInterval) {
|
||||
clearInterval(contextSyncInterval);
|
||||
contextSyncInterval = undefined;
|
||||
}
|
||||
|
||||
const ctx = currentTaskContext();
|
||||
const personalOrUserId =
|
||||
typeof ctx === "object" && "userId" in ctx
|
||||
? ctx.userId
|
||||
: typeof ctx === "object" && "bucketId" in ctx
|
||||
? store.contexts.find(context => context.id === ctx.bucketId && context.kind === "user")?.targetUserId
|
||||
: null;
|
||||
|
||||
if (personalOrUserId) {
|
||||
contextSyncInterval = window.setInterval(async () => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
|
||||
try {
|
||||
const timeStr = new Date(Date.now() - 30000).toISOString();
|
||||
const contextFilter = buildPersonalAccessFilter([personalOrUserId]);
|
||||
const records = await pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
filter: `${contextFilter} && updated >= "${timeStr}"`,
|
||||
sort: "-updated",
|
||||
fields: getTaskListFields(),
|
||||
requestKey: "context-poll"
|
||||
});
|
||||
|
||||
if (records.length === 0) return;
|
||||
|
||||
setStore("tasks", currentTasks => {
|
||||
const taskMap = new Map(currentTasks.map(t => [t.id, t]));
|
||||
let changed = false;
|
||||
|
||||
records.forEach((r: any) => {
|
||||
if (r.tags?.includes("__template__")) return;
|
||||
|
||||
const existing = taskMap.get(r.id);
|
||||
if (existing && existing.updated === r.updated) return;
|
||||
|
||||
const newTask = mapRecordToTask(r);
|
||||
if (existing && existing.content) {
|
||||
newTask.content = existing.content;
|
||||
}
|
||||
taskMap.set(r.id, newTask);
|
||||
changed = true;
|
||||
});
|
||||
|
||||
if (changed) return Array.from(taskMap.values());
|
||||
return currentTasks;
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (!err.isAbort) {
|
||||
console.warn("Context poll failed:", err);
|
||||
}
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
};
|
||||
|
||||
createRoot(() => {
|
||||
createEffect(() => {
|
||||
currentTaskContext();
|
||||
startContextPolling();
|
||||
});
|
||||
});
|
||||
|
||||
const runDailyCleanupIfNeeded = () => {
|
||||
const today = new Date().toLocaleDateString("en-CA");
|
||||
if (store.lastCompletedTaskCleanupDate === today) return;
|
||||
setStore("lastCompletedTaskCleanupDate", today);
|
||||
return true;
|
||||
};
|
||||
|
||||
const STALE_COMPLETED_TASK_DAYS = 90;
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
const getStaleCompletedTaskDeletedAt = (task: Pick<Task, "completed" | "updated" | "deletedAt">) => {
|
||||
if (task.deletedAt || !task.completed) return null;
|
||||
|
||||
const updatedAt = new Date(task.updated).getTime();
|
||||
if (Number.isNaN(updatedAt)) return null;
|
||||
|
||||
const deletedAt = updatedAt + (STALE_COMPLETED_TASK_DAYS * MS_PER_DAY);
|
||||
return deletedAt <= Date.now() ? deletedAt : null;
|
||||
};
|
||||
|
||||
const taskHasAnyActiveContextAssignment = (task: Pick<Task, "ownerId" | "shareRefs" | "noteRefs">) => {
|
||||
const currentUserId = pb.authStore.model?.id || "";
|
||||
if (currentUserId && taskHasPersonalContextAccess(task, currentUserId, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const hasActiveShareContext = task.shareRefs.some(ref => !!store.contexts.find(context => context.id === ref.contextId && !context.deletedAt));
|
||||
if (hasActiveShareContext) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return task.noteRefs.some(ref => !!getNoteByRef(ref));
|
||||
};
|
||||
|
||||
const runFinalInitTaskAutoTrash = async () => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
|
||||
const currentUserId = pb.authStore.model?.id;
|
||||
if (!currentUserId) return;
|
||||
|
||||
const visibleTasks = store.tasks.filter(task =>
|
||||
!task.deletedAt &&
|
||||
!task.tags?.includes("__template__") &&
|
||||
shouldTaskBeVisible(task, currentUserId)
|
||||
);
|
||||
|
||||
for (const task of visibleTasks) {
|
||||
if (task.deletedAt) continue;
|
||||
|
||||
const staleCompletedDeletedAt = getStaleCompletedTaskDeletedAt(task);
|
||||
if (staleCompletedDeletedAt) {
|
||||
const { updateTask } = await import("./tasks");
|
||||
await updateTask(task.id, { deletedAt: staleCompletedDeletedAt });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!taskHasAnyActiveContextAssignment(task)) {
|
||||
const { updateTask } = await import("./tasks");
|
||||
await updateTask(task.id, { deletedAt: Date.now() });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const purgeExpiredTrashIfNeeded = async () => {
|
||||
if (!runDailyCleanupIfNeeded()) return;
|
||||
|
||||
const expiryMs = 7 * 24 * 60 * 60 * 1000;
|
||||
const cutoff = Date.now() - expiryMs;
|
||||
const cutoffIso = new Date(cutoff).toISOString();
|
||||
const expiredTaskRecords = await pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
filter: `deletedAt != "" && deletedAt <= "${cutoffIso}"`,
|
||||
fields: "id,tags",
|
||||
requestKey: null
|
||||
}).catch(() => []);
|
||||
const expiredTaskIds = expiredTaskRecords
|
||||
.filter((record: any) => !record.tags?.includes("__template__"))
|
||||
.map((record: any) => record.id as string);
|
||||
const expiredNoteRecords = await pb.collection(NOTES_COLLECTION).getFullList({
|
||||
filter: `deletedAt != "" && deletedAt <= "${cutoffIso}"`,
|
||||
fields: "id,title,key,content,tags,isPrivate,user,tasks,created,updated,deletedAt",
|
||||
requestKey: null
|
||||
}).catch(() => []);
|
||||
const expiredNotes = expiredNoteRecords.map((record: any) => mapRecordToNote(record));
|
||||
|
||||
for (const taskId of expiredTaskIds) {
|
||||
await deleteTaskPermanently(taskId);
|
||||
}
|
||||
|
||||
for (const note of expiredNotes) {
|
||||
await deleteNotePermanently(note.id);
|
||||
}
|
||||
};
|
||||
|
||||
export const subscribeToRealtime = async () => {
|
||||
const currentUserId = pb.authStore.model?.id;
|
||||
|
||||
await pb.collection(TASGRID_COLLECTION).unsubscribe("*");
|
||||
|
||||
pb.collection(TASGRID_COLLECTION).subscribe("*", e => {
|
||||
console.log("Realtime event:", e.action, e.record.id);
|
||||
|
||||
if (e.action === "create") {
|
||||
const isTemplate = e.record.tags?.includes("__template__");
|
||||
if (isTemplate) {
|
||||
const templateExists = store.templates?.some(t => t.id === e.record.id);
|
||||
if (templateExists) return;
|
||||
|
||||
let meta: any = {};
|
||||
try { meta = JSON.parse(e.record.content || "{}"); } catch { }
|
||||
const newTemplate = {
|
||||
id: e.record.id,
|
||||
name: e.record.title,
|
||||
title: meta.title || "",
|
||||
priority: e.record.priority,
|
||||
urgency: meta.urgency || 5,
|
||||
tags: meta.tags || [],
|
||||
content: meta.content || ""
|
||||
};
|
||||
setStore("templates", t => [...(t || []), newTemplate]);
|
||||
return;
|
||||
}
|
||||
|
||||
const exists = store.tasks.find(t => t.id === e.record.id);
|
||||
if (!exists) {
|
||||
const authUserId = pb.authStore.model?.id;
|
||||
const isOwnTask = unwrapRelationId(e.record.user) === authUserId;
|
||||
const newTask = mapRecordToTask(e.record);
|
||||
|
||||
if (isOwnTask || (authUserId && shouldTaskBeVisible(newTask, authUserId))) {
|
||||
setStore("tasks", t => [newTask, ...t]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (e.action === "update") {
|
||||
const isTemplate = e.record.tags?.includes("__template__");
|
||||
if (isTemplate) {
|
||||
let meta: any = {};
|
||||
try { meta = JSON.parse(e.record.content || "{}"); } catch { }
|
||||
setStore("templates", t => t.id === e.record.id, {
|
||||
name: e.record.title,
|
||||
title: meta.title || "",
|
||||
priority: e.record.priority,
|
||||
urgency: meta.urgency || 5,
|
||||
tags: meta.tags || [],
|
||||
content: meta.content || ""
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const authUserId = pb.authStore.model?.id;
|
||||
const existingTask = store.tasks.find(t => t.id === e.record.id);
|
||||
const isOwnTask = unwrapRelationId(e.record.user) === authUserId;
|
||||
const incomingUpdated = new Date(e.record.updated).getTime();
|
||||
const updatedTask = mapRecordToTask(e.record);
|
||||
|
||||
if (existingTask) {
|
||||
const localUpdated = new Date(existingTask.updated).getTime();
|
||||
|
||||
if (!existingTask.updated || incomingUpdated >= localUpdated) {
|
||||
if (!isOwnTask && authUserId && !shouldTaskBeVisible(updatedTask, authUserId)) {
|
||||
setStore("tasks", t => t.filter(x => x.id !== e.record.id));
|
||||
return;
|
||||
}
|
||||
|
||||
setStore("tasks", t => t.id === e.record.id, updatedTask);
|
||||
} else {
|
||||
console.log("Skipping stale realtime update for task:", e.record.id);
|
||||
}
|
||||
} else if (authUserId && shouldTaskBeVisible(updatedTask, authUserId)) {
|
||||
setStore("tasks", t => [updatedTask, ...t]);
|
||||
}
|
||||
}
|
||||
|
||||
if (e.action === "delete") {
|
||||
setStore("tasks", t => t.filter(x => x.id !== e.record.id));
|
||||
setStore("templates", t => (t || []).filter(x => x.id !== e.record.id));
|
||||
}
|
||||
});
|
||||
|
||||
await pb.collection(NOTES_COLLECTION).unsubscribe("*");
|
||||
pb.collection(NOTES_COLLECTION).subscribe("*", e => {
|
||||
const authUserId = pb.authStore.model?.id;
|
||||
const isVisible = !e.record.isPrivate || unwrapRelationId(e.record.user) === authUserId;
|
||||
|
||||
if (e.action === "create" || e.action === "update") {
|
||||
if (isVisible) {
|
||||
const shouldSyncContent = loadedNoteDetailIds.has(e.record.id) || activeNoteId() === e.record.id;
|
||||
const updatedNote = mapRecordToNote(e.record, shouldSyncContent);
|
||||
setStore("notes", prev => {
|
||||
const exists = prev.find(n => n.id === updatedNote.id);
|
||||
if (exists) {
|
||||
const mergedNote = shouldSyncContent || exists.content === undefined
|
||||
? updatedNote
|
||||
: { ...updatedNote, content: exists.content };
|
||||
return prev.map(n => n.id === updatedNote.id ? mergedNote : n);
|
||||
}
|
||||
return [updatedNote, ...prev];
|
||||
});
|
||||
} else {
|
||||
setStore("notes", prev => prev.filter(n => n.id !== e.record.id));
|
||||
}
|
||||
}
|
||||
|
||||
if (e.action === "delete") {
|
||||
setStore("notes", prev => prev.filter(n => n.id !== e.record.id));
|
||||
}
|
||||
});
|
||||
|
||||
await pb.collection(CONTEXTS_COLLECTION).unsubscribe("*");
|
||||
pb.collection(CONTEXTS_COLLECTION).subscribe("*", e => {
|
||||
if (e.action === "delete") {
|
||||
const remaining = store.contexts.filter(context => context.id !== e.record.id);
|
||||
setContexts(remaining);
|
||||
setStore("shareRules", remaining.map(mapContextToShareRule));
|
||||
setStore("tasks", tasks => tasks.filter(task => shouldTaskBeVisible(task, pb.authStore.model?.id || "")));
|
||||
void syncSupervisedContexts();
|
||||
return;
|
||||
}
|
||||
|
||||
const context = mapRecordToContext(e.record);
|
||||
const nextContexts = store.contexts.some(existing => existing.id === context.id)
|
||||
? store.contexts.map(existing => existing.id === context.id ? context : existing)
|
||||
: [...store.contexts, context];
|
||||
setContexts(nextContexts);
|
||||
setStore("shareRules", nextContexts.map(mapContextToShareRule));
|
||||
if (context.targetUserId === pb.authStore.model?.id) {
|
||||
setStore("personalContextSupervisorIds", context.supervisorUserIds || []);
|
||||
}
|
||||
void syncSupervisedContexts();
|
||||
});
|
||||
|
||||
if (currentUserId) {
|
||||
await pb.collection("users").unsubscribe(currentUserId);
|
||||
pb.collection("users").subscribe(currentUserId, e => {
|
||||
if (e.action !== "update") return;
|
||||
|
||||
const prefs = readScopedPrefs(e.record?.Taskgrid_pref || {});
|
||||
applyScopedPrefsToStore({
|
||||
...prefs,
|
||||
subscribedBuckets: prefs.subscribedBuckets || e.record?.subscribedBuckets || []
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const runLegacyTagMigration = async (force = false) => {
|
||||
const currentUserId = pb.authStore.model?.id;
|
||||
if (!currentUserId) return;
|
||||
|
||||
try {
|
||||
const userRec = await pb.collection("users").getOne(currentUserId, { requestKey: null });
|
||||
const rawPrefs = userRec.Taskgrid_pref || {};
|
||||
const prefs = readScopedPrefs(rawPrefs);
|
||||
|
||||
if (!force && prefs.migratedStructuredTagsV2) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[MIGRATION] Starting structured tag migration...");
|
||||
|
||||
await syncUserContexts();
|
||||
const contexts = await loadContextsIntoStore();
|
||||
const ownerContextByUserId = new Map(
|
||||
contexts
|
||||
.filter(context => context.kind === "user" && !!context.targetUserId && !context.deletedAt)
|
||||
.map(context => [context.targetUserId as string, context])
|
||||
);
|
||||
|
||||
const notes = await pb.collection(NOTES_COLLECTION).getFullList({
|
||||
requestKey: null
|
||||
});
|
||||
const migratedNotes: Note[] = [];
|
||||
for (const noteRecord of notes) {
|
||||
const key = normalizeNoteKey(noteRecord.key || noteRecord.title || noteRecord.id);
|
||||
const existingTags = Array.isArray(noteRecord.tags) ? noteRecord.tags.filter(Boolean) : [];
|
||||
const nextTags = [...new Set([buildNoteTag(key), ...existingTags])];
|
||||
const nextTasks = [...new Set((Array.isArray(noteRecord.tasks) ? noteRecord.tasks : []).filter(Boolean))];
|
||||
|
||||
await pb.collection(NOTES_COLLECTION).update(noteRecord.id, {
|
||||
key,
|
||||
tags: nextTags,
|
||||
tasks: nextTasks
|
||||
}, { requestKey: null });
|
||||
|
||||
migratedNotes.push({
|
||||
...mapRecordToNote(noteRecord),
|
||||
key,
|
||||
tags: nextTags,
|
||||
tasks: nextTasks
|
||||
});
|
||||
}
|
||||
setStore("notes", migratedNotes);
|
||||
const noteLinksByTaskId = buildNoteLinkMap(migratedNotes);
|
||||
|
||||
const tasks = await pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
requestKey: null
|
||||
});
|
||||
for (const taskRecord of tasks) {
|
||||
if (taskRecord.tags?.includes("__template__")) continue;
|
||||
const task = mapRecordToTask(taskRecord);
|
||||
const payload = buildTaskMigrationPayload(
|
||||
task,
|
||||
ownerContextByUserId,
|
||||
noteLinksByTaskId.get(task.id) || [],
|
||||
currentUserId
|
||||
);
|
||||
await pb.collection(TASGRID_COLLECTION).update(task.id, payload, { requestKey: null });
|
||||
}
|
||||
|
||||
const nextPrefs = { ...prefs, migratedStructuredTagsV2: true };
|
||||
await pb.collection("users").update(currentUserId, {
|
||||
Taskgrid_pref: writeScopedPrefs(rawPrefs, nextPrefs)
|
||||
}, { requestKey: null });
|
||||
} catch (err) {
|
||||
console.error("[MIGRATION] Failed:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const runContextOwnershipMigration = async (force = false) => {
|
||||
const currentUserId = pb.authStore.model?.id;
|
||||
if (!currentUserId) return;
|
||||
|
||||
try {
|
||||
const userRec = await pb.collection("users").getOne(currentUserId, { requestKey: null });
|
||||
const rawPrefs = userRec.Taskgrid_pref || {};
|
||||
const prefs = readScopedPrefs(rawPrefs);
|
||||
|
||||
if (!force && prefs.migratedContextOwnershipV2) {
|
||||
return;
|
||||
}
|
||||
|
||||
await syncUserContexts();
|
||||
const contexts = store.contexts.length > 0 ? store.contexts : await loadContextsIntoStore();
|
||||
const ownerContextByUserId = new Map(
|
||||
contexts
|
||||
.filter(context => context.kind === "user" && !!context.targetUserId && !context.deletedAt)
|
||||
.map(context => [context.targetUserId as string, context])
|
||||
);
|
||||
const noteRecords = await pb.collection(NOTES_COLLECTION).getFullList({
|
||||
fields: "id,created,updated,title,key,tags,isPrivate,user,tasks,deletedAt",
|
||||
requestKey: null
|
||||
}).catch(() => []);
|
||||
const allNotes = noteRecords.map(record => mapRecordToNote(record));
|
||||
const noteLinksByTaskId = buildNoteLinkMap(allNotes);
|
||||
|
||||
const taskRecords = await pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
fields: getTaskListFields(),
|
||||
requestKey: null
|
||||
});
|
||||
|
||||
for (const taskRecord of taskRecords) {
|
||||
if (taskRecord.tags?.includes("__template__")) continue;
|
||||
|
||||
const task = mapRecordToTask(taskRecord);
|
||||
if (!task.ownerId) continue;
|
||||
const payload = buildTaskMigrationPayload(
|
||||
task,
|
||||
ownerContextByUserId,
|
||||
noteLinksByTaskId.get(task.id) || [],
|
||||
currentUserId
|
||||
);
|
||||
|
||||
const sameShareRefs = JSON.stringify(payload.shareRefs) === JSON.stringify(task.shareRefs);
|
||||
const sameNoteRefs = JSON.stringify(payload.noteRefs) === JSON.stringify(task.noteRefs);
|
||||
const sameTags = JSON.stringify(payload.tags) === JSON.stringify(task.tags);
|
||||
const sameOwnerId = payload.ownerId === task.ownerId;
|
||||
if (sameShareRefs && sameNoteRefs && sameTags && payload.createdBy === task.createdBy && sameOwnerId) continue;
|
||||
|
||||
const pbPayload = {
|
||||
...payload,
|
||||
user: payload.ownerId || task.ownerId || null
|
||||
} as any;
|
||||
delete pbPayload.ownerId;
|
||||
|
||||
await pb.collection(TASGRID_COLLECTION).update(task.id, pbPayload, { requestKey: null });
|
||||
}
|
||||
|
||||
await pb.collection("users").update(currentUserId, {
|
||||
Taskgrid_pref: writeScopedPrefs(rawPrefs, {
|
||||
...prefs,
|
||||
migratedPersonalContextRefsV1: true,
|
||||
migratedContextOwnershipV2: true
|
||||
})
|
||||
}, { requestKey: null });
|
||||
} catch (err) {
|
||||
console.error("[MIGRATION] Failed context ownership migration:", err);
|
||||
}
|
||||
};
|
||||
|
||||
export const runDevDataMigration = async () => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
if (!TASGRID_IS_DEV_DATA) {
|
||||
toast.error("Dev migration is only available in dev data mode.");
|
||||
return;
|
||||
}
|
||||
|
||||
await toast.promise((async () => {
|
||||
await runLegacyTagMigration(true);
|
||||
await runContextOwnershipMigration(true);
|
||||
await loadContextsIntoStore();
|
||||
await syncSupervisedContexts();
|
||||
return "Dev data migration completed.";
|
||||
})(), {
|
||||
loading: "Running dev data migration...",
|
||||
success: message => message,
|
||||
error: "Dev data migration failed."
|
||||
});
|
||||
};
|
||||
|
||||
export const canRunProdMigration = () => {
|
||||
if (TASGRID_IS_DEV_DATA || !PROD_MIGRATION_ENABLED) return false;
|
||||
const email = (pb.authStore.model?.email || "").trim().toLowerCase();
|
||||
return !!email && PROD_MIGRATION_ADMIN_EMAILS.includes(email);
|
||||
};
|
||||
|
||||
export const runProdDataMigration = async () => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
if (!canRunProdMigration()) {
|
||||
toast.error("Prod migration is not enabled for this account.");
|
||||
return;
|
||||
}
|
||||
|
||||
await toast.promise((async () => {
|
||||
await runLegacyTagMigration(true);
|
||||
await runContextOwnershipMigration(true);
|
||||
await loadContextsIntoStore();
|
||||
await syncSupervisedContexts();
|
||||
return "Production data migration completed.";
|
||||
})(), {
|
||||
loading: "Running production data migration...",
|
||||
success: message => message,
|
||||
error: "Production data migration failed."
|
||||
});
|
||||
};
|
||||
|
||||
export const initStore = async () => {
|
||||
ensureRealtimeRecovery();
|
||||
if (!pb.authStore.isValid || store.isInitializing) return;
|
||||
setStore("isInitializing", true);
|
||||
|
||||
const currentUserId = pb.authStore.model?.id;
|
||||
const refreshedTaskIds = new Set<string>();
|
||||
|
||||
if (currentUserId) {
|
||||
try {
|
||||
const prefs = readScopedPrefs(pb.authStore.model?.Taskgrid_pref || {});
|
||||
const cachedQuickload = prefs.quickloadTasks || [];
|
||||
|
||||
let focusRecords;
|
||||
if (cachedQuickload.length > 0) {
|
||||
const idFilter = cachedQuickload.map((id: string) => `id = "${id}"`).join(" || ");
|
||||
const res = await pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
filter: `(${idFilter}) && deletedAt = ""`,
|
||||
requestKey: null
|
||||
});
|
||||
|
||||
res.sort((a, b) => cachedQuickload.indexOf(a.id) - cachedQuickload.indexOf(b.id));
|
||||
focusRecords = { items: res };
|
||||
} else {
|
||||
const personalAccessFilter = buildPersonalAccessFilter([currentUserId]);
|
||||
focusRecords = await pb.collection(TASGRID_COLLECTION).getList(1, 30, {
|
||||
filter: `${personalAccessFilter} && completed = false && deletedAt = ""`,
|
||||
sort: "-priority,dueDate",
|
||||
requestKey: null
|
||||
});
|
||||
}
|
||||
|
||||
const focusTasks = focusRecords.items
|
||||
.map(mapRecordToTask)
|
||||
.filter(t => !t.tags?.includes("__template__"));
|
||||
|
||||
setStore("tasks", focusTasks);
|
||||
setStore("quickloadFadeTaskIds", focusTasks.map(task => task.id));
|
||||
focusTasks.forEach(task => refreshedTaskIds.add(task.id));
|
||||
if (quickloadFadeTimer) clearTimeout(quickloadFadeTimer);
|
||||
quickloadFadeTimer = window.setTimeout(() => {
|
||||
setStore("quickloadFadeTaskIds", []);
|
||||
quickloadFadeTimer = undefined;
|
||||
}, 200);
|
||||
} catch (focusErr) {
|
||||
console.warn("Stage 1 load failed:", focusErr);
|
||||
}
|
||||
}
|
||||
|
||||
const key = getStorageKey();
|
||||
if (key) {
|
||||
const saved = localStorage.getItem(key);
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
const cachedTasks = Array.isArray(parsed?.tasks) ? parsed.tasks : [];
|
||||
const mergedCachedTasks = [
|
||||
...store.tasks,
|
||||
...cachedTasks.filter((task: Task) => !refreshedTaskIds.has(task.id))
|
||||
];
|
||||
setStore({
|
||||
...parsed,
|
||||
tasks: mergedCachedTasks,
|
||||
isInitializing: true
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to parse cached data", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (heartbeatInterval) clearInterval(heartbeatInterval);
|
||||
heartbeatInterval = setInterval(() => {
|
||||
setNow(Date.now());
|
||||
checkRecurringTasks();
|
||||
}, 60000);
|
||||
|
||||
try {
|
||||
try {
|
||||
const userId = pb.authStore.model?.id;
|
||||
if (userId) {
|
||||
const user = await pb.collection("users").getOne(userId, { requestKey: null });
|
||||
const prefs = readScopedPrefs(user.Taskgrid_pref || {});
|
||||
applyScopedPrefsToStore({
|
||||
...prefs,
|
||||
subscribedBuckets: prefs.subscribedBuckets || user.subscribedBuckets || []
|
||||
});
|
||||
}
|
||||
} catch (prefErr) {
|
||||
console.warn("Failed to load preferences:", prefErr);
|
||||
}
|
||||
|
||||
try {
|
||||
const contextRecords = await pb.collection(CONTEXTS_COLLECTION).getFullList({
|
||||
sort: "displayName,key",
|
||||
requestKey: null
|
||||
});
|
||||
const contexts = contextRecords.map(mapRecordToContext);
|
||||
setContexts(contexts);
|
||||
setStore("shareRules", contexts.map(mapContextToShareRule));
|
||||
await syncUserContexts();
|
||||
await syncSupervisedContexts();
|
||||
const personalContext = getPersonalContext(pb.authStore.model?.id);
|
||||
if (personalContext) {
|
||||
setStore("personalContextSupervisorIds", personalContext.supervisorUserIds || []);
|
||||
}
|
||||
if (personalContext && currentTaskContext() === "mine") {
|
||||
setCurrentTaskContext({ bucketId: personalContext.id, name: "My Bucket", isPersonal: true });
|
||||
}
|
||||
} catch (contextErr) {
|
||||
console.warn("Failed to load contexts (collection might not exist yet):", contextErr);
|
||||
}
|
||||
|
||||
await syncSystemTagsAndRules();
|
||||
|
||||
const authUserId = pb.authStore.model?.id;
|
||||
if (!authUserId) return;
|
||||
|
||||
const backgroundSync = async () => {
|
||||
const subscribedBucketTags = getSubscribedBucketContexts()
|
||||
.map(context => buildShareTag(context.displayName));
|
||||
const supervisedUserIds = store.supervisedContexts.map(context => context.ownerUserId);
|
||||
const currentUserAccessFilter = buildPersonalAccessFilter(authUserId ? [authUserId] : []);
|
||||
const supervisedAccessFilter = buildPersonalAccessFilter(supervisedUserIds);
|
||||
|
||||
const incompletePromises = [
|
||||
pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
filter: `${currentUserAccessFilter} && completed = false`,
|
||||
sort: "-created",
|
||||
fields: getTaskListFields(),
|
||||
requestKey: null
|
||||
}),
|
||||
subscribedBucketTags.length > 0 ? pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
filter: `(${subscribedBucketTags.map(tag => `tags ~ "${tag}"`).join(" || ")}) && completed = false`,
|
||||
sort: "-created",
|
||||
fields: getTaskListFields(),
|
||||
requestKey: null
|
||||
}).catch(() => []) : Promise.resolve([]),
|
||||
supervisedUserIds.length > 0 ? pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
filter: `${supervisedAccessFilter} && completed = false`,
|
||||
sort: "-created",
|
||||
fields: getTaskListFields(),
|
||||
requestKey: null
|
||||
}).catch(() => []) : Promise.resolve([])
|
||||
];
|
||||
|
||||
const stage2Results = await Promise.all(incompletePromises);
|
||||
const allIncomplete = stage2Results.flat();
|
||||
|
||||
setStore("tasks", currentTasks => {
|
||||
const taskMap = new Map(currentTasks.map(t => [t.id, t]));
|
||||
allIncomplete.forEach((r: any) => {
|
||||
if (r.tags?.includes("__template__")) return;
|
||||
if (refreshedTaskIds.has(r.id)) return;
|
||||
|
||||
const existing = taskMap.get(r.id);
|
||||
if (existing && existing.updated === r.updated) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newTask = mapRecordToTask(r);
|
||||
if (existing && existing.content) {
|
||||
newTask.content = existing.content;
|
||||
}
|
||||
taskMap.set(r.id, newTask);
|
||||
refreshedTaskIds.add(r.id);
|
||||
});
|
||||
return Array.from(taskMap.values());
|
||||
});
|
||||
|
||||
const tasksNeedingContent = store.tasks
|
||||
.filter(t => !t.completed && t.content === undefined && !refreshedTaskIds.has(t.id))
|
||||
.map(t => t.id);
|
||||
if (tasksNeedingContent.length > 0) {
|
||||
const chunkSize = 20;
|
||||
for (let i = 0; i < tasksNeedingContent.length; i += chunkSize) {
|
||||
const ids = tasksNeedingContent.slice(i, i + chunkSize);
|
||||
try {
|
||||
const contentRecords = await pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
filter: ids.map(id => `id="${id}"`).join(" || "),
|
||||
fields: "id,content",
|
||||
requestKey: null
|
||||
});
|
||||
|
||||
setStore("tasks", tasks => tasks.map(t => {
|
||||
const match = contentRecords.find((r: any) => r.id === t.id);
|
||||
if (match) return { ...t, content: match.content };
|
||||
return t;
|
||||
}));
|
||||
} catch (e) {
|
||||
console.warn("Backfill chunk failed", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const currentCount = store.tasks.length;
|
||||
const remainingSlots = 150 - currentCount;
|
||||
|
||||
if (remainingSlots > 0) {
|
||||
try {
|
||||
const completedAccessFilter = buildPersonalAccessFilter(authUserId ? [authUserId] : []);
|
||||
const recentCompleted = await pb.collection(TASGRID_COLLECTION).getList(1, remainingSlots, {
|
||||
filter: `${completedAccessFilter} && completed = true`,
|
||||
sort: "-updated",
|
||||
fields: getTaskListFields(),
|
||||
requestKey: null
|
||||
});
|
||||
|
||||
setStore("tasks", prev => {
|
||||
const newTasks = [...prev];
|
||||
recentCompleted.items.forEach((r: any) => {
|
||||
if (!newTasks.some(t => t.id === r.id)) {
|
||||
newTasks.push(mapRecordToTask(r));
|
||||
}
|
||||
});
|
||||
return newTasks;
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn("Stage 3 load failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
checkRecurringTasks();
|
||||
|
||||
const foundTagNames = new Set<string>();
|
||||
store.tasks.forEach(t => t.tags?.forEach(tag => foundTagNames.add(tag)));
|
||||
for (const tagName of foundTagNames) {
|
||||
if (tagName === "__template__" || tagName.startsWith("@") || tagName.startsWith("#")) continue;
|
||||
if (!store.tagDefinitions.find(d => d.name === tagName)) {
|
||||
await upsertTagDefinition(tagName, 5, undefined, document.documentElement.classList.contains("dark") ? "dark" : "light");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await backgroundSync();
|
||||
await ensureUpdatedByFieldSupport();
|
||||
|
||||
if (TASGRID_IS_DEV_DATA) {
|
||||
await runLegacyTagMigration();
|
||||
await runContextOwnershipMigration();
|
||||
}
|
||||
|
||||
try {
|
||||
const templates = await pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
filter: `${relationFilter("user", authUserId)} && tags ~ "__template__"`,
|
||||
requestKey: null
|
||||
});
|
||||
const loadedTemplates = templates.map(r => {
|
||||
let meta: any = {};
|
||||
try { meta = JSON.parse(r.content || "{}"); } catch { }
|
||||
return {
|
||||
id: r.id,
|
||||
name: r.title,
|
||||
title: meta.title || "",
|
||||
priority: r.priority,
|
||||
urgency: meta.urgency || 5,
|
||||
tags: meta.tags || [],
|
||||
content: meta.content || ""
|
||||
};
|
||||
});
|
||||
setStore("templates", loadedTemplates);
|
||||
} catch { }
|
||||
|
||||
await subscribeToRealtime();
|
||||
await loadFilterTemplates();
|
||||
scheduleDeferredNotesMetadataLoad();
|
||||
|
||||
await purgeExpiredTrashIfNeeded();
|
||||
await runFinalInitTaskAutoTrash();
|
||||
} catch (err) {
|
||||
if (store.tasks.length === 0) {
|
||||
console.error("Failed to load data:", err);
|
||||
toast.error("Failed to sync with server.");
|
||||
}
|
||||
} finally {
|
||||
setStore("isInitializing", false);
|
||||
}
|
||||
};
|
||||
|
||||
export const setupPersistence = () => {
|
||||
initStore();
|
||||
};
|
||||
|
||||
createRoot(() => {
|
||||
createEffect(() => {
|
||||
const key = getStorageKey();
|
||||
if (key) {
|
||||
localStorage.setItem(key, JSON.stringify(getPersistedStoreSnapshot()));
|
||||
}
|
||||
});
|
||||
|
||||
let debouncedSync: number | undefined;
|
||||
createEffect(() => {
|
||||
if (store.isInitializing || !pb.authStore.model?.id) return;
|
||||
|
||||
const incompleteTasks = store.tasks.filter(t =>
|
||||
!t.completed &&
|
||||
taskHasPersonalContextAccess(t, pb.authStore.model?.id || "", true) &&
|
||||
!t.tags?.includes("__template__") &&
|
||||
!t.deletedAt
|
||||
);
|
||||
|
||||
const scoredTasks = incompleteTasks.map(t => ({ id: t.id, score: getCombinedScore(t) }));
|
||||
scoredTasks.sort((a, b) => b.score - a.score);
|
||||
|
||||
const top20Ids = scoredTasks.slice(0, 20).map(t => t.id);
|
||||
const currentIds = store.quickloadTasks || [];
|
||||
const isDifferent = top20Ids.length !== currentIds.length || top20Ids.some((id, i) => id !== currentIds[i]);
|
||||
|
||||
if (isDifferent) {
|
||||
setStore("quickloadTasks", top20Ids);
|
||||
|
||||
if (debouncedSync) window.clearTimeout(debouncedSync);
|
||||
debouncedSync = window.setTimeout(() => {
|
||||
syncPreferences();
|
||||
}, 10000);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,864 @@
|
||||
import { reconcile } from "solid-js/store";
|
||||
import { toast } from "solid-sonner";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import { CONTEXTS_COLLECTION, NOTES_COLLECTION, TASGRID_COLLECTION } from "@/lib/constants";
|
||||
import { buildNoteTag, buildShareTag, normalizeContextKey, normalizeNoteKey, parseTags } from "@/lib/tags";
|
||||
import {
|
||||
activeNoteId,
|
||||
currentTaskContext,
|
||||
type Bucket,
|
||||
type Note,
|
||||
type NoteRef,
|
||||
readScopedPrefs,
|
||||
type ShareContext,
|
||||
type ShareRule,
|
||||
setStore,
|
||||
store,
|
||||
type TagDefinition,
|
||||
type Task,
|
||||
type TaskContext,
|
||||
type TaskShareRef,
|
||||
unwrapRelationId,
|
||||
unwrapRelationIds,
|
||||
writeScopedPrefs
|
||||
} from "./state";
|
||||
|
||||
export const relationFilter = (field: string, id: string | undefined | null) =>
|
||||
id ? `(${field} = "${id}" || ${field} ?= "${id}")` : "false";
|
||||
|
||||
export const buildUserContextKey = (userId: string) => `user-${userId}`;
|
||||
|
||||
const DEFAULT_LABEL_THEME: "light" | "dark" = "dark";
|
||||
|
||||
const mapContextToBucket = (context: ShareContext): Bucket => ({
|
||||
id: context.id,
|
||||
name: context.displayName,
|
||||
color: context.color || "#64748b",
|
||||
description: context.description,
|
||||
policy: context.policy,
|
||||
key: context.key,
|
||||
deletedAt: context.deletedAt ?? null
|
||||
});
|
||||
|
||||
const deriveBuckets = (contexts: ShareContext[]) =>
|
||||
contexts
|
||||
.filter(context => context.kind === "bucket" && !context.deletedAt)
|
||||
.sort((a, b) => a.displayName.localeCompare(b.displayName))
|
||||
.map(mapContextToBucket);
|
||||
|
||||
const deriveSystemTagDefinitions = (contexts: ShareContext[]): TagDefinition[] =>
|
||||
contexts
|
||||
.filter(context => !context.deletedAt)
|
||||
.map(context => ({
|
||||
id: context.id,
|
||||
name: buildShareTag(context.displayName),
|
||||
value: 5,
|
||||
color: context.color,
|
||||
theme: DEFAULT_LABEL_THEME,
|
||||
isUser: context.kind === "user",
|
||||
isBucket: context.kind === "bucket"
|
||||
}));
|
||||
|
||||
const findContextForShareTag = (tag: { key: string; raw: string }) =>
|
||||
store.contexts.find(context => !context.deletedAt && context.key === tag.key) ||
|
||||
store.contexts.find(context => !context.deletedAt && context.kind === "user" && normalizeContextKey(context.displayName) === tag.key) ||
|
||||
store.contexts.find(context => !context.deletedAt && normalizeContextKey(context.displayName) === tag.key) ||
|
||||
store.contexts.find(context => !context.deletedAt && buildShareTag(context.displayName).toLowerCase() === tag.raw.toLowerCase()) ||
|
||||
null;
|
||||
|
||||
const buildNoteContext = (note: Note): ShareContext => ({
|
||||
id: note.id,
|
||||
key: note.key,
|
||||
displayName: note.key || note.title,
|
||||
kind: "note",
|
||||
policy: getNoteSharePolicy(note),
|
||||
targetUserId: note.user,
|
||||
deletedAt: note.deletedAt ?? null
|
||||
});
|
||||
|
||||
export const getContextByRef = (ref: TaskShareRef) =>
|
||||
(ref.kind === "note"
|
||||
? (() => {
|
||||
const note = store.notes.find(existing => !existing.deletedAt && (existing.id === ref.contextId || existing.key === ref.key));
|
||||
return note ? buildNoteContext(note) : null;
|
||||
})()
|
||||
: null) ||
|
||||
store.contexts.find(context => !context.deletedAt && context.id === ref.contextId) ||
|
||||
store.contexts.find(context => !context.deletedAt && context.key === ref.key && context.kind === ref.kind) ||
|
||||
store.contexts.find(context => !context.deletedAt && context.kind === ref.kind && normalizeContextKey(context.displayName) === ref.key) ||
|
||||
store.contexts.find(context => !context.deletedAt && normalizeContextKey(context.displayName) === ref.key) ||
|
||||
null;
|
||||
|
||||
export const getNoteByRef = (ref: NoteRef) =>
|
||||
store.notes.find(note => !note.deletedAt && note.id === ref.noteId) ||
|
||||
store.notes.find(note => !note.deletedAt && note.key === ref.key);
|
||||
|
||||
const DEFAULT_SHARE_POLICY_BY_KIND: Record<TaskShareRef["kind"], "collaborative" | "handoff"> = {
|
||||
user: "collaborative",
|
||||
bucket: "handoff",
|
||||
note: "handoff"
|
||||
};
|
||||
|
||||
const NOTE_HANDOFF_POLICY_TAG = "__note_policy_handoff__";
|
||||
|
||||
export const getNoteSharePolicy = (note: Pick<Note, "tags"> | null | undefined): "collaborative" | "handoff" =>
|
||||
note?.tags?.includes(NOTE_HANDOFF_POLICY_TAG) ? "handoff" : "collaborative";
|
||||
|
||||
export const getTaskSharePolicy = (ref: Pick<TaskShareRef, "kind" | "key" | "policy" | "contextId">): "collaborative" | "handoff" => {
|
||||
if (ref.kind === "note") {
|
||||
if (ref.policy) return ref.policy;
|
||||
const note = store.notes.find(existing => !existing.deletedAt && (existing.id === ref.contextId || existing.key === ref.key));
|
||||
return getNoteSharePolicy(note);
|
||||
}
|
||||
|
||||
const context = getContextByRef(ref as TaskShareRef);
|
||||
if (ref.policy) return ref.policy;
|
||||
if (context?.policy) return context.policy;
|
||||
|
||||
return DEFAULT_SHARE_POLICY_BY_KIND[ref.kind];
|
||||
};
|
||||
|
||||
export const buildTaskTags = (
|
||||
labelTags: string[],
|
||||
shareRefs: TaskShareRef[],
|
||||
noteRefs: NoteRef[],
|
||||
favoriteTag?: string
|
||||
) => {
|
||||
const tags = [
|
||||
...labelTags,
|
||||
...shareRefs.map(ref => {
|
||||
const context = getContextByRef(ref);
|
||||
if (ref.kind === "note") {
|
||||
return buildNoteTag(context?.key || ref.key);
|
||||
}
|
||||
if (context?.displayName) {
|
||||
return buildShareTag(context.displayName);
|
||||
}
|
||||
if (ref.kind === "user" && ref.key.startsWith("user-")) {
|
||||
return null;
|
||||
}
|
||||
return buildShareTag(ref.key);
|
||||
}),
|
||||
...noteRefs.map(ref => {
|
||||
const note = getNoteByRef(ref);
|
||||
return buildNoteTag(note?.key || ref.key);
|
||||
})
|
||||
];
|
||||
|
||||
if (favoriteTag && !tags.includes(favoriteTag)) {
|
||||
tags.push(favoriteTag);
|
||||
}
|
||||
|
||||
return [...new Set(tags.filter((tag): tag is string => !!tag))];
|
||||
};
|
||||
|
||||
export const dedupeShareRefs = (refs: TaskShareRef[]) => {
|
||||
const seen = new Set<string>();
|
||||
return refs.filter(ref => {
|
||||
const key = `${ref.kind}:${ref.contextId}:${ref.key}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
export const buildTaskShareRef = (
|
||||
parsedTag: Extract<ReturnType<typeof parseTags>[number], { kind: "share" | "note" }>,
|
||||
existingRefs: TaskShareRef[] = [],
|
||||
shareModeOverrides: Record<string, "collaborative" | "handoff"> = {}
|
||||
): TaskShareRef => {
|
||||
if (parsedTag.kind === "note") {
|
||||
const note = store.notes.find(existing => !existing.deletedAt && existing.key === parsedTag.key);
|
||||
const nextRefBase = {
|
||||
contextId: note?.id || parsedTag.key,
|
||||
key: parsedTag.key,
|
||||
kind: "note" as const
|
||||
};
|
||||
const existing = existingRefs.find(ref => ref.kind === "note" && (ref.contextId === nextRefBase.contextId || ref.key === nextRefBase.key));
|
||||
return {
|
||||
...nextRefBase,
|
||||
policy: shareModeOverrides[parsedTag.raw.toLowerCase()] || existing?.policy || getTaskSharePolicy(nextRefBase)
|
||||
};
|
||||
}
|
||||
|
||||
const context = findContextForShareTag(parsedTag);
|
||||
const nextRefBase = {
|
||||
contextId: context?.id || parsedTag.key,
|
||||
key: parsedTag.key,
|
||||
kind: (context?.kind || "bucket") as "user" | "bucket"
|
||||
};
|
||||
const existing = existingRefs.find(ref =>
|
||||
ref.kind === nextRefBase.kind &&
|
||||
(ref.contextId === nextRefBase.contextId || ref.key === nextRefBase.key)
|
||||
);
|
||||
return {
|
||||
...nextRefBase,
|
||||
policy: shareModeOverrides[parsedTag.raw.toLowerCase()] || existing?.policy || getTaskSharePolicy(nextRefBase)
|
||||
};
|
||||
};
|
||||
|
||||
export const getTaskShareModeForTag = (task: Pick<Task, "shareRefs">, tag: string): "collaborative" | "handoff" | undefined => {
|
||||
const parsed = parseTags([tag])[0];
|
||||
if (!parsed || (parsed.kind !== "share" && parsed.kind !== "note")) return undefined;
|
||||
|
||||
const ref = task.shareRefs.find(existing =>
|
||||
existing.key === parsed.key && (
|
||||
(parsed.kind === "note" && existing.kind === "note") ||
|
||||
(parsed.kind === "share" && existing.kind !== "note")
|
||||
)
|
||||
);
|
||||
|
||||
return ref ? getTaskSharePolicy(ref) : undefined;
|
||||
};
|
||||
|
||||
export const deriveTaskRelationsFromTags = (
|
||||
tags: string[],
|
||||
existingShareRefs: TaskShareRef[] = [],
|
||||
shareModeOverrides: Record<string, "collaborative" | "handoff"> = {}
|
||||
) => {
|
||||
const parsedTags = parseTags(tags);
|
||||
const labelTags = parsedTags.filter(tag => tag.kind === "label").map(tag => tag.display);
|
||||
const shareRefs = parsedTags
|
||||
.filter((tag): tag is Extract<typeof tag, { kind: "share" | "note" }> => tag.kind === "share" || tag.kind === "note")
|
||||
.map(tag => buildTaskShareRef(tag, existingShareRefs, shareModeOverrides));
|
||||
const noteRefs = parsedTags
|
||||
.filter(tag => tag.kind === "note")
|
||||
.map(tag => {
|
||||
const note = store.notes.find(existing => !existing.deletedAt && existing.key === tag.key);
|
||||
return {
|
||||
noteId: note?.id || tag.key,
|
||||
key: tag.key
|
||||
} satisfies NoteRef;
|
||||
});
|
||||
|
||||
return {
|
||||
labelTags,
|
||||
shareRefs: dedupeShareRefs(shareRefs),
|
||||
noteRefs: dedupeNoteRefs(noteRefs)
|
||||
};
|
||||
};
|
||||
|
||||
export const dedupeNoteRefs = (refs: NoteRef[]) => {
|
||||
const seen = new Set<string>();
|
||||
return refs.filter(ref => {
|
||||
const key = `${ref.noteId}:${ref.key}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
export const setContexts = (contexts: ShareContext[]) => {
|
||||
setStore("contexts", reconcile(contexts));
|
||||
setStore("buckets", reconcile(deriveBuckets(contexts)));
|
||||
setStore("tasks", tasks => tasks.map(task => {
|
||||
const favoriteTag = task.tags.find(tag => tag.endsWith("_favorite__"));
|
||||
return {
|
||||
...task,
|
||||
tags: buildTaskTags(task.labelTags, task.shareRefs, task.noteRefs, favoriteTag)
|
||||
};
|
||||
}));
|
||||
|
||||
const labelDefinitions = store.tagDefinitions.filter(def => !def.name.startsWith("@"));
|
||||
const mergedDefs = [...labelDefinitions, ...deriveSystemTagDefinitions(contexts)];
|
||||
setStore("tagDefinitions", reconcile(mergedDefs));
|
||||
};
|
||||
|
||||
export const mapContextToShareRule = (context: ShareContext): ShareRule => ({
|
||||
id: context.id,
|
||||
ownerId: pb.authStore.model?.id || "",
|
||||
targetUserId: context.targetUserId || pb.authStore.model?.id || "",
|
||||
type: "tag",
|
||||
tagName: buildShareTag(context.displayName),
|
||||
share_mode: context.policy === "handoff" ? "SEND_TASK" : "ADD_USER"
|
||||
});
|
||||
|
||||
export const mapRecordToContext = (r: any): ShareContext => ({
|
||||
id: r.id,
|
||||
key: r.key || normalizeContextKey(r.displayName || r.name || ""),
|
||||
displayName: r.displayName || r.name || r.key,
|
||||
kind: (r.kind || "bucket") as "user" | "bucket",
|
||||
policy: (r.policy || (r.kind === "user" ? "collaborative" : "handoff")) as "collaborative" | "handoff",
|
||||
targetUserId: unwrapRelationId(r.targetUserId),
|
||||
supervisorUserIds: unwrapRelationIds(r.supervisorUserIds),
|
||||
color: r.color,
|
||||
description: r.description,
|
||||
deletedAt: r.deletedAt ? new Date(r.deletedAt).getTime() : null,
|
||||
deletedBy: unwrapRelationId(r.deletedBy)
|
||||
});
|
||||
|
||||
export const loadContextsIntoStore = async () => {
|
||||
const contextRecords = await pb.collection(CONTEXTS_COLLECTION).getFullList({
|
||||
sort: "displayName,key",
|
||||
requestKey: null
|
||||
}).catch(() => []);
|
||||
const nextContexts = contextRecords.map(mapRecordToContext);
|
||||
setContexts(nextContexts);
|
||||
setStore("shareRules", nextContexts.map(mapContextToShareRule));
|
||||
return nextContexts;
|
||||
};
|
||||
|
||||
export const getPersonalContext = (userId = pb.authStore.model?.id) =>
|
||||
userId
|
||||
? store.contexts.find(context =>
|
||||
!context.deletedAt &&
|
||||
context.kind === "user" &&
|
||||
context.targetUserId === userId
|
||||
) || null
|
||||
: null;
|
||||
|
||||
const getCurrentUserDisplayName = () =>
|
||||
(pb.authStore.model?.name || pb.authStore.model?.email || "").trim() || null;
|
||||
|
||||
const getPersonalContextTag = (userId = pb.authStore.model?.id) => {
|
||||
const context = getPersonalContext(userId);
|
||||
if (context?.displayName) {
|
||||
return buildShareTag(context.displayName);
|
||||
}
|
||||
|
||||
if (userId && userId === pb.authStore.model?.id) {
|
||||
const displayName = getCurrentUserDisplayName();
|
||||
if (displayName) {
|
||||
return buildShareTag(displayName);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const escapeFilterValue = (value: string) => value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
|
||||
export const buildPersonalAccessFilter = (userIds: string[], includeLegacyUser = true) => {
|
||||
const clauses = userIds.flatMap(userId => {
|
||||
const nextClauses: string[] = [];
|
||||
const personalTag = getPersonalContextTag(userId);
|
||||
if (personalTag) {
|
||||
nextClauses.push(`tags ~ "${escapeFilterValue(personalTag)}"`);
|
||||
}
|
||||
if (includeLegacyUser) {
|
||||
nextClauses.push(relationFilter("user", userId));
|
||||
}
|
||||
return nextClauses;
|
||||
}).filter(Boolean);
|
||||
|
||||
return clauses.length > 0 ? `(${clauses.join(" || ")})` : "false";
|
||||
};
|
||||
|
||||
export const getPersonalContextUserIdFromRef = (ref: TaskShareRef) => {
|
||||
if (ref.kind !== "user") return null;
|
||||
|
||||
const context = getContextByRef(ref);
|
||||
if (context?.targetUserId) {
|
||||
return context.targetUserId;
|
||||
}
|
||||
|
||||
if (ref.key.startsWith("user-")) {
|
||||
return ref.key.replace(/^user-/, "") || null;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getTaskPersonalRefs = (task: Pick<Task, "shareRefs">) =>
|
||||
task.shareRefs.filter(ref => ref.kind === "user" && !!getPersonalContextUserIdFromRef(ref));
|
||||
|
||||
const getTaskPersonalUserIds = (task: Pick<Task, "shareRefs">) =>
|
||||
[...new Set(getTaskPersonalRefs(task).map(ref => getPersonalContextUserIdFromRef(ref)).filter((value): value is string => !!value))];
|
||||
|
||||
const taskHasLegacyPersonalAccess = (task: Pick<Task, "ownerId" | "shareRefs">, userId: string) =>
|
||||
task.ownerId === userId &&
|
||||
getTaskPersonalRefs(task).length === 0 &&
|
||||
!task.shareRefs.some(ref => ref.kind !== "user" && getTaskSharePolicy(ref) === "handoff");
|
||||
|
||||
export const taskHasPersonalContextAccess = (task: Pick<Task, "ownerId" | "shareRefs">, userId: string, allowLegacyFallback = true) =>
|
||||
getTaskPersonalUserIds(task).includes(userId) ||
|
||||
(allowLegacyFallback && taskHasLegacyPersonalAccess(task, userId));
|
||||
|
||||
export const resolveMirroredOwnerId = (
|
||||
currentOwnerId: string | null | undefined,
|
||||
shareRefs: TaskShareRef[],
|
||||
preferredOwnerId?: string | null
|
||||
) => {
|
||||
const personalUserIds = [...new Set(
|
||||
shareRefs
|
||||
.filter(ref => ref.kind === "user")
|
||||
.map(ref => getPersonalContextUserIdFromRef(ref))
|
||||
.filter((value): value is string => !!value)
|
||||
)];
|
||||
|
||||
if (personalUserIds.length === 1) {
|
||||
return personalUserIds[0];
|
||||
}
|
||||
|
||||
if (personalUserIds.length > 1 && preferredOwnerId && personalUserIds.includes(preferredOwnerId)) {
|
||||
return preferredOwnerId;
|
||||
}
|
||||
|
||||
return currentOwnerId || null;
|
||||
};
|
||||
|
||||
export const withMirroredOwnerId = (
|
||||
currentOwnerId: string | null | undefined,
|
||||
shareRefs: TaskShareRef[],
|
||||
updates: Partial<Task>,
|
||||
preferredOwnerId?: string | null
|
||||
) => ({
|
||||
...updates,
|
||||
ownerId: resolveMirroredOwnerId(currentOwnerId, shareRefs, preferredOwnerId)
|
||||
});
|
||||
|
||||
export const withActiveContextTags = (rawTags: string[]) => {
|
||||
const nextTags = [...rawTags];
|
||||
const ctx = currentTaskContext();
|
||||
|
||||
if (ctx === "mine") {
|
||||
const personalContext = getPersonalContext();
|
||||
if (personalContext) {
|
||||
const personalTag = buildShareTag(personalContext.displayName);
|
||||
if (!nextTags.some(tag => tag.toLowerCase() === personalTag.toLowerCase())) {
|
||||
nextTags.push(personalTag);
|
||||
}
|
||||
}
|
||||
} else if (typeof ctx === "object") {
|
||||
if ("bucketId" in ctx && ctx.name) {
|
||||
const bucketContext = store.contexts.find(context => context.id === ctx.bucketId);
|
||||
const bucketTag = buildShareTag(bucketContext?.displayName || ctx.name);
|
||||
if (!nextTags.some(tag => tag.toLowerCase() === bucketTag.toLowerCase())) {
|
||||
nextTags.push(bucketTag);
|
||||
}
|
||||
} else if ("userId" in ctx && ctx.name) {
|
||||
const userTag = buildShareTag(ctx.name);
|
||||
if (!nextTags.some(tag => tag.toLowerCase() === userTag.toLowerCase())) {
|
||||
nextTags.push(userTag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (store.isNotepadMode) {
|
||||
const noteId = activeNoteId();
|
||||
const note = noteId ? store.notes.find(existing => existing.id === noteId) : null;
|
||||
if (note) {
|
||||
const noteTag = buildNoteTag(note.key || note.title);
|
||||
if (!nextTags.some(tag => tag.toLowerCase() === noteTag.toLowerCase())) {
|
||||
nextTags.push(noteTag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(nextTags)];
|
||||
};
|
||||
|
||||
export const isCurrentUserContextTag = (tag: string) => {
|
||||
const normalizedTag = tag.trim().toLowerCase();
|
||||
if (!normalizedTag.startsWith("@")) return false;
|
||||
|
||||
const personalContext = getPersonalContext();
|
||||
const currentUserDisplayName = (pb.authStore.model?.name || pb.authStore.model?.email || "").trim();
|
||||
const hiddenTags = new Set<string>();
|
||||
|
||||
if (personalContext?.displayName) {
|
||||
hiddenTags.add(buildShareTag(personalContext.displayName).toLowerCase());
|
||||
}
|
||||
|
||||
if (currentUserDisplayName) {
|
||||
hiddenTags.add(buildShareTag(currentUserDisplayName).toLowerCase());
|
||||
}
|
||||
|
||||
return hiddenTags.has(normalizedTag);
|
||||
};
|
||||
|
||||
export const isTaskLockedNoteTag = (task: Pick<Task, "tags" | "shareRefs" | "noteRefs">, tag: string) => {
|
||||
const normalizedTag = tag.trim();
|
||||
if (!normalizedTag.startsWith("#")) return false;
|
||||
|
||||
const noteKey = normalizeNoteKey(normalizedTag);
|
||||
const matchingNoteRefs = task.noteRefs.filter(ref => ref.key === noteKey || ref.noteId === noteKey);
|
||||
if (matchingNoteRefs.length === 0) return false;
|
||||
|
||||
const nonNoteContexts = task.shareRefs.filter(ref => ref.kind !== "note");
|
||||
const uniqueNoteRefs = dedupeNoteRefs(task.noteRefs);
|
||||
return uniqueNoteRefs.length === matchingNoteRefs.length && nonNoteContexts.length === 0;
|
||||
};
|
||||
|
||||
export const getVisibleTaskTags = (task: Pick<Task, "tags" | "shareRefs" | "noteRefs">, context: TaskContext = currentTaskContext()) => {
|
||||
const tags = task.tags || [];
|
||||
|
||||
return tags.filter(tag => {
|
||||
if (tag.endsWith("_favorite__") || isCurrentUserContextTag(tag)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isTaskLockedNoteTag(task, tag)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof context === "object" && "bucketId" in context) {
|
||||
const bucketContext = store.contexts.find(existing => existing.id === context.bucketId);
|
||||
const bucketTagName = buildShareTag(bucketContext?.displayName || context.name).toLowerCase();
|
||||
if (tag.toLowerCase() === bucketTagName) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
export const getSubscribedBucketContexts = () =>
|
||||
store.subscribedBuckets
|
||||
.map(id => store.contexts.find(context => context.id === id))
|
||||
.filter((context): context is ShareContext => !!context && !context.deletedAt && context.kind === "bucket");
|
||||
|
||||
export const shouldTaskBeVisible = (task: Task, currentUserId: string): boolean => {
|
||||
if (taskHasPersonalContextAccess(task, currentUserId)) return true;
|
||||
if (store.supervisedContexts.some(context => taskHasPersonalContextAccess(task, context.ownerUserId))) return true;
|
||||
|
||||
const hasSubscribedBucket = task.shareRefs.some(ref =>
|
||||
ref.kind === "bucket" && (
|
||||
store.subscribedBuckets.includes(ref.contextId) ||
|
||||
store.subscribedBuckets.some(bucketId => store.contexts.find(context => context.id === bucketId)?.key === ref.key)
|
||||
)
|
||||
);
|
||||
if (hasSubscribedBucket) return true;
|
||||
|
||||
const hasAccessibleNote = task.noteRefs.some(ref => {
|
||||
const note = getNoteByRef(ref);
|
||||
return !!note && (!note.isPrivate || note.user === currentUserId);
|
||||
});
|
||||
if (hasAccessibleNote) return true;
|
||||
|
||||
return task.shareRefs.some(ref => {
|
||||
if (ref.kind !== "user" || getTaskSharePolicy(ref) !== "collaborative") return false;
|
||||
const context = getContextByRef(ref);
|
||||
return !!context && !context.deletedAt && context.targetUserId === currentUserId;
|
||||
});
|
||||
};
|
||||
|
||||
export const syncUserContexts = async () => {
|
||||
const allUsers = await pb.collection("users").getFullList({
|
||||
filter: "verified = true",
|
||||
fields: "id,name,email",
|
||||
requestKey: null
|
||||
});
|
||||
const existingContexts = await pb.collection(CONTEXTS_COLLECTION).getFullList({
|
||||
requestKey: null
|
||||
}).catch(() => []);
|
||||
let createdAny = false;
|
||||
|
||||
for (const user of allUsers) {
|
||||
const displayName = user.name || user.email;
|
||||
const existingUserContext = existingContexts.find((context: any) =>
|
||||
(context.kind || "bucket") === "user" &&
|
||||
unwrapRelationId(context.targetUserId) === user.id
|
||||
);
|
||||
if (!displayName || existingUserContext) continue;
|
||||
|
||||
const created = await pb.collection(CONTEXTS_COLLECTION).create({
|
||||
key: buildUserContextKey(user.id),
|
||||
displayName,
|
||||
kind: "user",
|
||||
policy: "collaborative",
|
||||
targetUserId: user.id
|
||||
}, { requestKey: null }).catch(() => null);
|
||||
if (created) {
|
||||
createdAny = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (createdAny || store.contexts.length === 0) {
|
||||
await loadContextsIntoStore();
|
||||
}
|
||||
};
|
||||
|
||||
export const syncSupervisedContexts = async () => {
|
||||
const currentUserId = pb.authStore.model?.id;
|
||||
if (!currentUserId) {
|
||||
setStore("supervisedContexts", []);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const contextRecords = await pb.collection(CONTEXTS_COLLECTION).getFullList({
|
||||
sort: "displayName,key",
|
||||
requestKey: null
|
||||
}).catch(() => []);
|
||||
const contexts = contextRecords.length > 0 ? contextRecords.map(mapRecordToContext) : store.contexts;
|
||||
|
||||
if (contextRecords.length > 0) {
|
||||
setContexts(contexts);
|
||||
setStore("shareRules", contexts.map(mapContextToShareRule));
|
||||
}
|
||||
|
||||
const nextSupervisedContexts = contexts
|
||||
.filter(context =>
|
||||
!context.deletedAt &&
|
||||
context.kind === "user" &&
|
||||
!!context.targetUserId &&
|
||||
context.targetUserId !== currentUserId &&
|
||||
(context.supervisorUserIds || []).includes(currentUserId)
|
||||
)
|
||||
.map(context => ({
|
||||
ownerUserId: context.targetUserId as string,
|
||||
ownerName: context.displayName,
|
||||
contextId: context.id,
|
||||
contextName: context.displayName
|
||||
}))
|
||||
.sort((a, b) => a.ownerName.localeCompare(b.ownerName));
|
||||
|
||||
setStore("supervisedContexts", nextSupervisedContexts);
|
||||
} catch (err) {
|
||||
console.error("Failed to load supervised contexts:", err);
|
||||
}
|
||||
};
|
||||
|
||||
export const toggleBucketSubscription = async (bucketId: string) => {
|
||||
const currentSubscribed = store.subscribedBuckets;
|
||||
const isSubscribed = currentSubscribed.includes(bucketId);
|
||||
|
||||
const newSubscribed = isSubscribed
|
||||
? currentSubscribed.filter(id => id !== bucketId)
|
||||
: [...currentSubscribed, bucketId];
|
||||
|
||||
setStore("subscribedBuckets", newSubscribed);
|
||||
|
||||
const userId = pb.authStore.model?.id;
|
||||
if (userId) {
|
||||
try {
|
||||
const user = await pb.collection("users").getOne(userId, { requestKey: null });
|
||||
const rawPrefs = user.Taskgrid_pref || {};
|
||||
const scopedPrefs = readScopedPrefs(rawPrefs);
|
||||
await pb.collection("users").update(userId, {
|
||||
Taskgrid_pref: writeScopedPrefs(rawPrefs, {
|
||||
...scopedPrefs,
|
||||
subscribedBuckets: newSubscribed
|
||||
})
|
||||
});
|
||||
|
||||
if (!isSubscribed) {
|
||||
const bucket = store.buckets.find(b => b.id === bucketId);
|
||||
if (bucket) {
|
||||
const { getTaskListFields, mapRecordToTask } = await import("./tasks");
|
||||
const records = await pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
filter: `tags ~ "${buildShareTag(bucket.name)}"`,
|
||||
sort: "-created",
|
||||
fields: getTaskListFields()
|
||||
});
|
||||
|
||||
const newTasks: Task[] = [];
|
||||
records.forEach((r: any) => {
|
||||
const exists = store.tasks.find(t => t.id === r.id);
|
||||
if (!exists && !r.tags?.includes("__template__")) {
|
||||
newTasks.push(mapRecordToTask(r));
|
||||
}
|
||||
});
|
||||
|
||||
if (newTasks.length > 0) {
|
||||
setStore("tasks", t => [...newTasks, ...t]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to update subscriptions:", err);
|
||||
setStore("subscribedBuckets", currentSubscribed);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const createBucket = async (name: string, color: string, description?: string) => {
|
||||
try {
|
||||
const key = normalizeContextKey(name);
|
||||
await pb.collection(CONTEXTS_COLLECTION).create({
|
||||
key,
|
||||
displayName: name,
|
||||
kind: "bucket",
|
||||
policy: "handoff",
|
||||
color,
|
||||
description
|
||||
});
|
||||
toast.success("Bucket created");
|
||||
} catch (err) {
|
||||
console.error("Failed to create bucket:", err);
|
||||
toast.error("Failed to create bucket");
|
||||
}
|
||||
};
|
||||
|
||||
export const updateBucket = async (id: string, data: { name?: string, color?: string, description?: string, policy?: "collaborative" | "handoff" }) => {
|
||||
try {
|
||||
const payload: any = { ...data };
|
||||
if (data.name) payload.key = normalizeContextKey(data.name);
|
||||
if (data.name) payload.displayName = data.name;
|
||||
await pb.collection(CONTEXTS_COLLECTION).update(id, payload);
|
||||
toast.success("Bucket updated");
|
||||
} catch (err) {
|
||||
console.error("Failed to update bucket:", err);
|
||||
toast.error("Failed to update bucket");
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteBucket = async (id: string) => {
|
||||
try {
|
||||
await pb.collection(CONTEXTS_COLLECTION).update(id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
deletedBy: pb.authStore.model?.id || null
|
||||
});
|
||||
toast.success("Bucket moved to trash");
|
||||
} catch (err) {
|
||||
console.error("Failed to delete bucket:", err);
|
||||
toast.error("Failed to delete bucket");
|
||||
}
|
||||
};
|
||||
|
||||
export const updateUserContextPolicy = async (contextId: string, policy: "collaborative" | "handoff") => {
|
||||
const success = await setContextPolicy(contextId, policy);
|
||||
if (success) {
|
||||
toast.success("User context updated");
|
||||
}
|
||||
};
|
||||
|
||||
export const setContextPolicy = async (contextId: string, policy: "collaborative" | "handoff") => {
|
||||
const previousContext = store.contexts.find(context => context.id === contextId);
|
||||
const previousPolicy = previousContext?.policy;
|
||||
|
||||
if (previousContext && previousPolicy !== policy) {
|
||||
setStore("contexts", context => context.id === contextId, "policy", policy);
|
||||
setStore("shareRules", store.contexts.map(mapContextToShareRule));
|
||||
}
|
||||
|
||||
try {
|
||||
await pb.collection(CONTEXTS_COLLECTION).update(contextId, { policy }, { requestKey: null });
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("Failed to update user context:", err);
|
||||
if (previousContext && previousPolicy) {
|
||||
setStore("contexts", context => context.id === contextId, "policy", previousPolicy);
|
||||
setStore("shareRules", store.contexts.map(mapContextToShareRule));
|
||||
}
|
||||
toast.error("Failed to update user context");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const setNoteContextPolicy = async (noteId: string, policy: "collaborative" | "handoff") => {
|
||||
if (!pb.authStore.isValid || !noteId) return false;
|
||||
|
||||
const note = store.notes.find(existing => existing.id === noteId);
|
||||
if (!note) return false;
|
||||
|
||||
const baseTags = (note.tags || []).filter(tag => tag !== NOTE_HANDOFF_POLICY_TAG);
|
||||
const nextTags = policy === "handoff" ? [...baseTags, NOTE_HANDOFF_POLICY_TAG] : baseTags;
|
||||
const previousTags = note.tags || [];
|
||||
|
||||
setStore("notes", n => n.id === noteId, "tags", nextTags);
|
||||
|
||||
try {
|
||||
await pb.collection(NOTES_COLLECTION).update(noteId, { tags: nextTags }, { requestKey: null });
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("Failed to update note context policy:", err);
|
||||
setStore("notes", n => n.id === noteId, "tags", previousTags);
|
||||
toast.error("Failed to update note sharing mode");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const savePersonalContextSupervisors = async (supervisorUserIds: string[]) => {
|
||||
const userId = pb.authStore.model?.id;
|
||||
if (!userId) return;
|
||||
|
||||
const uniqueSupervisorIds = [...new Set(supervisorUserIds.filter(Boolean))];
|
||||
setStore("personalContextSupervisorIds", uniqueSupervisorIds);
|
||||
|
||||
try {
|
||||
await syncUserContexts();
|
||||
const personalContext = getPersonalContext(userId);
|
||||
if (!personalContext) {
|
||||
throw new Error("Missing personal context record");
|
||||
}
|
||||
|
||||
await pb.collection(CONTEXTS_COLLECTION).update(personalContext.id, {
|
||||
supervisorUserIds: uniqueSupervisorIds
|
||||
}, { requestKey: null });
|
||||
|
||||
const user = await pb.collection("users").getOne(userId, { requestKey: null });
|
||||
const rawPrefs = user.Taskgrid_pref || {};
|
||||
const scopedPrefs = readScopedPrefs(rawPrefs);
|
||||
await pb.collection("users").update(userId, {
|
||||
Taskgrid_pref: writeScopedPrefs(rawPrefs, {
|
||||
...scopedPrefs,
|
||||
supervisorUserIds: uniqueSupervisorIds
|
||||
})
|
||||
}, { requestKey: null });
|
||||
await syncSupervisedContexts();
|
||||
toast.success("Personal supervision updated");
|
||||
} catch (err) {
|
||||
console.error("Failed to update personal supervision:", err);
|
||||
toast.error("Failed to update personal supervision");
|
||||
}
|
||||
};
|
||||
|
||||
export const loadShareRules = async () => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
setStore("shareRules", reconcile(store.contexts.map(mapContextToShareRule)));
|
||||
};
|
||||
|
||||
export const addShareRule = async (type: "tag" | "all", targetUserId: string, tagName?: string, share_mode: "ADD_USER" | "SEND_TASK" = "SEND_TASK") => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
if (type !== "tag" || !tagName?.startsWith("@")) {
|
||||
toast.error("Only @tag contexts are supported now.");
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = store.contexts.find(context => context.targetUserId === targetUserId && buildShareTag(context.displayName) === tagName);
|
||||
if (existing) return;
|
||||
|
||||
try {
|
||||
await pb.collection(CONTEXTS_COLLECTION).create({
|
||||
key: normalizeContextKey(tagName),
|
||||
displayName: tagName.replace(/^@/, ""),
|
||||
kind: "user",
|
||||
policy: share_mode === "SEND_TASK" ? "handoff" : "collaborative",
|
||||
targetUserId
|
||||
}, { requestKey: null });
|
||||
toast.success("Shared context created");
|
||||
} catch (err) {
|
||||
console.error("Failed to create shared context:", err);
|
||||
toast.error("Failed to create shared context.");
|
||||
}
|
||||
};
|
||||
|
||||
export const updateShareRule = async (ruleId: string, updates: Partial<ShareRule>) => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
|
||||
try {
|
||||
const context = store.contexts.find(existing => existing.id === ruleId);
|
||||
if (!context) return;
|
||||
await pb.collection(CONTEXTS_COLLECTION).update(ruleId, {
|
||||
policy: updates.share_mode === "SEND_TASK" ? "handoff" : "collaborative"
|
||||
}, { requestKey: null });
|
||||
toast.success("Context updated");
|
||||
} catch (err) {
|
||||
console.error("Failed to update share rule:", err);
|
||||
toast.error("Failed to update share rule.");
|
||||
}
|
||||
};
|
||||
|
||||
export const removeShareRule = async (ruleId: string) => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
|
||||
try {
|
||||
await pb.collection(CONTEXTS_COLLECTION).delete(ruleId);
|
||||
toast.success("Context removed");
|
||||
} catch (err) {
|
||||
console.error("Failed to delete share rule:", err);
|
||||
toast.error("Failed to delete share rule.");
|
||||
}
|
||||
};
|
||||
|
||||
export const syncSystemTagsAndRules = async () => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
setStore("shareRules", store.contexts.map(mapContextToShareRule));
|
||||
setStore("tagDefinitions", defs => {
|
||||
const plainDefs = defs.filter(def => !def.name.startsWith("@"));
|
||||
return [...plainDefs, ...deriveSystemTagDefinitions(store.contexts)];
|
||||
});
|
||||
};
|
||||
+6
-4025
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,365 @@
|
||||
import { toast } from "solid-sonner";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import { NOTES_COLLECTION, TASGRID_COLLECTION } from "@/lib/constants";
|
||||
import { buildNoteTag, normalizeNoteKey } from "@/lib/tags";
|
||||
import { relationFilter } from "./contexts";
|
||||
import {
|
||||
activeNoteId,
|
||||
setActiveNoteId,
|
||||
type Note,
|
||||
type Task,
|
||||
setStore,
|
||||
store,
|
||||
unwrapRelationId
|
||||
} from "./state";
|
||||
import { getTaskListFields, mapRecordToTask, updateTask } from "./tasks";
|
||||
|
||||
let notesLoadPromise: Promise<void> | null = null;
|
||||
let deferredNotesLoadTimer: number | undefined;
|
||||
|
||||
export const loadedNoteDetailIds = new Set<string>();
|
||||
export const failedNoteDetailIds = new Set<string>();
|
||||
|
||||
export const mapRecordToNote = (r: any, includeContent = true): Note => {
|
||||
return {
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
key: r.key || normalizeNoteKey(r.title),
|
||||
content: includeContent ? r.content : undefined,
|
||||
tags: Array.isArray(r.tags) && r.tags.length > 0 ? r.tags : [buildNoteTag(r.key || r.title)],
|
||||
isPrivate: r.isPrivate || false,
|
||||
user: unwrapRelationId(r.user) || "",
|
||||
tasks: r.tasks || [],
|
||||
created: r.created,
|
||||
updated: r.updated,
|
||||
deletedAt: r.deletedAt ? new Date(r.deletedAt).getTime() : undefined
|
||||
};
|
||||
};
|
||||
|
||||
const reconcileFetchedNotes = (records: any[], includeContent = false) => {
|
||||
const existingById = new Map(store.notes.map(note => [note.id, note]));
|
||||
const nextNotes = records.map((record: any) => {
|
||||
const mapped = mapRecordToNote(record, includeContent);
|
||||
const existing = existingById.get(mapped.id);
|
||||
|
||||
if (!includeContent && existing?.content !== undefined) {
|
||||
mapped.content = existing.content;
|
||||
}
|
||||
|
||||
return mapped;
|
||||
});
|
||||
|
||||
setStore("notes", nextNotes);
|
||||
};
|
||||
|
||||
export const fetchNoteById = async (id: string): Promise<Note | null> => {
|
||||
try {
|
||||
const record = await pb.collection(NOTES_COLLECTION).getOne(id);
|
||||
if (!record) return null;
|
||||
return mapRecordToNote(record);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch note by ID:", err);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const updateStoreWithNote = (note: Note) => {
|
||||
failedNoteDetailIds.delete(note.id);
|
||||
if (note.content !== undefined) {
|
||||
loadedNoteDetailIds.add(note.id);
|
||||
}
|
||||
setStore("notes", currentNotes => {
|
||||
const index = currentNotes.findIndex(n => n.id === note.id);
|
||||
if (index !== -1) {
|
||||
const newNotes = [...currentNotes];
|
||||
newNotes[index] = note;
|
||||
return newNotes;
|
||||
}
|
||||
return [...currentNotes, note];
|
||||
});
|
||||
};
|
||||
|
||||
export const loadNotesMetadata = async () => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
if (store.hasLoadedNotesMetadata) return;
|
||||
if (notesLoadPromise) return notesLoadPromise;
|
||||
|
||||
const currentUserId = pb.authStore.model?.id;
|
||||
setStore("isNotesLoading", true);
|
||||
|
||||
notesLoadPromise = (async () => {
|
||||
try {
|
||||
const notesRecords = await pb.collection(NOTES_COLLECTION).getFullList({
|
||||
filter: `isPrivate = false || ${relationFilter("user", currentUserId)}`,
|
||||
sort: "-created",
|
||||
fields: "id,created,updated,title,key,tags,isPrivate,user,tasks,deletedAt",
|
||||
requestKey: null
|
||||
});
|
||||
|
||||
reconcileFetchedNotes(notesRecords, false);
|
||||
setStore("hasLoadedNotesMetadata", true);
|
||||
} catch (notesErr) {
|
||||
console.warn("Failed to load notes (collection might not exist yet):", notesErr);
|
||||
} finally {
|
||||
setStore("isNotesLoading", false);
|
||||
notesLoadPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return notesLoadPromise;
|
||||
};
|
||||
|
||||
export const scheduleDeferredNotesMetadataLoad = () => {
|
||||
if (!pb.authStore.isValid || store.hasLoadedNotesMetadata || notesLoadPromise || deferredNotesLoadTimer) return;
|
||||
|
||||
deferredNotesLoadTimer = window.setTimeout(() => {
|
||||
deferredNotesLoadTimer = undefined;
|
||||
void loadNotesMetadata();
|
||||
}, 0);
|
||||
};
|
||||
|
||||
export const requestImmediateNotesMetadataLoad = async () => {
|
||||
if (deferredNotesLoadTimer) {
|
||||
clearTimeout(deferredNotesLoadTimer);
|
||||
deferredNotesLoadTimer = undefined;
|
||||
}
|
||||
|
||||
await loadNotesMetadata();
|
||||
};
|
||||
|
||||
export const loadNoteContent = async (id: string) => {
|
||||
if (!id) return;
|
||||
if (loadedNoteDetailIds.has(id)) return;
|
||||
if (failedNoteDetailIds.has(id)) return;
|
||||
if (store.loadingNoteContentIds.includes(id)) return;
|
||||
|
||||
setStore("loadingNoteContentIds", ids => ids.includes(id) ? ids : [...ids, id]);
|
||||
|
||||
try {
|
||||
const currentUserId = pb.authStore.model?.id;
|
||||
const visibilityFilter = `id = "${id}" && (isPrivate = false || ${relationFilter("user", currentUserId)})`;
|
||||
const record = await pb.collection(NOTES_COLLECTION).getFirstListItem(visibilityFilter, {
|
||||
requestKey: `note-content-${id}`
|
||||
});
|
||||
const note = mapRecordToNote(record, true);
|
||||
updateStoreWithNote(note);
|
||||
loadedNoteDetailIds.add(id);
|
||||
} catch (err) {
|
||||
const status = (err as any)?.status;
|
||||
if (status === 404) {
|
||||
failedNoteDetailIds.add(id);
|
||||
return;
|
||||
}
|
||||
console.error("Failed to load note content:", err);
|
||||
} finally {
|
||||
setStore("loadingNoteContentIds", ids => ids.filter(existingId => existingId !== id));
|
||||
}
|
||||
};
|
||||
|
||||
export const uploadNoteAttachment = async (noteId: string, file: File): Promise<{ url: string, filename: string }> => {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("attachments+", file);
|
||||
|
||||
const record = await pb.collection(NOTES_COLLECTION).update(noteId, formData);
|
||||
|
||||
if (record.attachments && record.attachments.length > 0) {
|
||||
const latestFilename = record.attachments[record.attachments.length - 1];
|
||||
return {
|
||||
url: pb.files.getURL(record, latestFilename),
|
||||
filename: latestFilename
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error("File uploaded but no attachment returned");
|
||||
} catch (err: any) {
|
||||
console.error("Error uploading note attachment. Validation details:", JSON.stringify(err.data, null, 2));
|
||||
toast.error("Failed to upload image to note.");
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
export const cleanupNoteAttachments = async (noteId: string) => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
|
||||
try {
|
||||
const record = await pb.collection(NOTES_COLLECTION).getOne(noteId);
|
||||
if (!record.attachments || record.attachments.length === 0) return;
|
||||
|
||||
const content = record.content || "";
|
||||
const filesToDelete: string[] = [];
|
||||
const decodedContent = decodeURIComponent(content);
|
||||
|
||||
for (const filename of record.attachments) {
|
||||
if (!content.includes(filename) && !decodedContent.includes(filename)) {
|
||||
filesToDelete.push(filename);
|
||||
}
|
||||
}
|
||||
|
||||
if (filesToDelete.length > 0) {
|
||||
await pb.collection(NOTES_COLLECTION).update(noteId, {
|
||||
"attachments-": filesToDelete
|
||||
});
|
||||
console.log(`[CLEANUP] Deleted ${filesToDelete.length} orphaned attachment(s) for note ${noteId}`);
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as any)?.status === 404) return;
|
||||
console.error("[CLEANUP] Failed to clean up orphaned note attachments:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const removeDeletedNoteFromStore = (id: string) => {
|
||||
if (activeNoteId() === id) {
|
||||
setActiveNoteId(null);
|
||||
}
|
||||
loadedNoteDetailIds.delete(id);
|
||||
failedNoteDetailIds.delete(id);
|
||||
setStore("loadingNoteContentIds", ids => ids.filter(existingId => existingId !== id));
|
||||
setStore("notes", notes => notes.filter(note => note.id !== id));
|
||||
};
|
||||
|
||||
const removeNoteReferencesFromTask = (task: Task, note: Note): Partial<Task> | null => {
|
||||
const noteKey = (note.key || normalizeNoteKey(note.title)).toLowerCase();
|
||||
const noteTag = buildNoteTag(note.key || note.title).toLowerCase();
|
||||
const nextNoteRefs = task.noteRefs.filter(ref =>
|
||||
ref.noteId !== note.id &&
|
||||
ref.key.toLowerCase() !== noteKey
|
||||
);
|
||||
const nextShareRefs = task.shareRefs.filter(ref =>
|
||||
!(ref.kind === "note" && (ref.contextId === note.id || ref.key.toLowerCase() === noteKey))
|
||||
);
|
||||
const nextTags = (task.tags || []).filter(tag => tag.toLowerCase() !== noteTag);
|
||||
|
||||
const noteRefsChanged = nextNoteRefs.length !== task.noteRefs.length;
|
||||
const shareRefsChanged = nextShareRefs.length !== task.shareRefs.length;
|
||||
const tagsChanged = nextTags.length !== (task.tags || []).length;
|
||||
|
||||
if (!noteRefsChanged && !shareRefsChanged && !tagsChanged) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
tags: nextTags,
|
||||
noteRefs: nextNoteRefs,
|
||||
shareRefs: nextShareRefs
|
||||
};
|
||||
};
|
||||
|
||||
const cleanupTasksForPermanentlyDeletedNote = async (note: Note) => {
|
||||
const taskRecords = await pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
fields: getTaskListFields(),
|
||||
requestKey: null
|
||||
});
|
||||
const affectedTasks = taskRecords
|
||||
.filter((record: any) => !record.tags?.includes("__template__"))
|
||||
.map((record: any) => mapRecordToTask(record))
|
||||
.filter(task => !!removeNoteReferencesFromTask(task, note));
|
||||
|
||||
for (const task of affectedTasks) {
|
||||
const cleanup = removeNoteReferencesFromTask(task, note);
|
||||
if (!cleanup) continue;
|
||||
await updateTask(task.id, cleanup);
|
||||
}
|
||||
};
|
||||
|
||||
export const updateNote = async (id: string, updates: Partial<Note>) => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
|
||||
if (updates.content !== undefined) {
|
||||
loadedNoteDetailIds.add(id);
|
||||
}
|
||||
|
||||
const optimisticUpdates = {
|
||||
...updates,
|
||||
key: updates.key || (updates.title ? normalizeNoteKey(updates.title) : undefined),
|
||||
updated: new Date().toISOString()
|
||||
};
|
||||
setStore("notes", n => n.id === id, optimisticUpdates);
|
||||
|
||||
try {
|
||||
const pbUpdates: any = { ...updates };
|
||||
|
||||
if ("deletedAt" in updates) {
|
||||
if (updates.deletedAt) {
|
||||
pbUpdates.deletedAt = new Date(updates.deletedAt).toISOString();
|
||||
} else {
|
||||
pbUpdates.deletedAt = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!pbUpdates.key && updates.title) {
|
||||
pbUpdates.key = normalizeNoteKey(updates.title);
|
||||
}
|
||||
|
||||
await pb.collection(NOTES_COLLECTION).update(id, pbUpdates, { requestKey: null });
|
||||
} catch (err) {
|
||||
console.error("Note update failed", err);
|
||||
toast.error("Failed to update note.");
|
||||
}
|
||||
};
|
||||
|
||||
export const removeNote = (id: string) => {
|
||||
updateNote(id, { deletedAt: Date.now() });
|
||||
};
|
||||
|
||||
export const restoreNote = (id: string) => {
|
||||
updateNote(id, { deletedAt: null });
|
||||
};
|
||||
|
||||
export const deleteNotePermanently = async (id: string) => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
|
||||
const note = store.notes.find(existing => existing.id === id) || await fetchNoteById(id);
|
||||
if (note) {
|
||||
await cleanupTasksForPermanentlyDeletedNote(note);
|
||||
}
|
||||
|
||||
removeDeletedNoteFromStore(id);
|
||||
|
||||
try {
|
||||
await pb.collection(NOTES_COLLECTION).delete(id);
|
||||
} catch (err) {
|
||||
console.error("Note delete failed", err);
|
||||
toast.error("Failed to delete note.");
|
||||
}
|
||||
};
|
||||
|
||||
export const loadTasksLinkedToNote = async (note: Pick<Note, "id" | "key" | "title" | "tasks">) => {
|
||||
if (!pb.authStore.isValid || !note?.id) return;
|
||||
|
||||
const noteTag = buildNoteTag(note.key || note.title);
|
||||
const escapedNoteTag = noteTag.replace(/"/g, '\\"');
|
||||
|
||||
try {
|
||||
const listRecords = await pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
filter: `(tags ~ "${escapedNoteTag}") && tags !~ "__template__"`,
|
||||
sort: "-updated",
|
||||
fields: getTaskListFields(),
|
||||
requestKey: `note-linked-${note.id}`
|
||||
}).catch(() => []);
|
||||
|
||||
const recordMap = new Map<string, any>();
|
||||
listRecords.forEach(record => {
|
||||
if (!record?.id) return;
|
||||
if (record.tags?.includes("__template__")) return;
|
||||
recordMap.set(record.id, record);
|
||||
});
|
||||
|
||||
if (recordMap.size === 0) return;
|
||||
|
||||
setStore("tasks", currentTasks => {
|
||||
const taskMap = new Map(currentTasks.map(task => [task.id, task]));
|
||||
recordMap.forEach((record: any) => {
|
||||
const mapped = mapRecordToTask(record);
|
||||
const existing = taskMap.get(mapped.id);
|
||||
if (existing?.content) {
|
||||
mapped.content = existing.content;
|
||||
}
|
||||
taskMap.set(mapped.id, mapped);
|
||||
});
|
||||
return Array.from(taskMap.values());
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error("Failed to load note-linked tasks:", err?.status, err?.response || err);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,176 @@
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import { toast } from "solid-sonner";
|
||||
import {
|
||||
createDefaultFilter,
|
||||
type Filter,
|
||||
type FilterTemplate,
|
||||
readScopedPrefs,
|
||||
setStore,
|
||||
store,
|
||||
writeScopedPrefs
|
||||
} from "./state";
|
||||
|
||||
export const syncPreferences = async () => {
|
||||
const userId = pb.authStore.model?.id;
|
||||
if (!userId) return;
|
||||
|
||||
try {
|
||||
const user = await pb.collection("users").getOne(userId, { requestKey: null });
|
||||
const rawPrefs = user.Taskgrid_pref || {};
|
||||
const prefs = {
|
||||
pWeight: store.pWeight,
|
||||
uWeight: store.uWeight,
|
||||
matrixScaleDays: store.matrixScaleDays,
|
||||
tagDefinitions: store.tagDefinitions.filter(def => !def.name.startsWith("@")),
|
||||
filterTemplates: store.filterTemplates,
|
||||
quickloadTasks: store.quickloadTasks,
|
||||
dismissedTaskUpdateIndicators: store.dismissedTaskUpdateIndicators,
|
||||
collapsedUntilUpdatedTasks: store.collapsedUntilUpdatedTasks,
|
||||
subscribedBuckets: store.subscribedBuckets,
|
||||
supervisorUserIds: store.personalContextSupervisorIds,
|
||||
lastCompletedTaskCleanupDate: store.lastCompletedTaskCleanupDate
|
||||
};
|
||||
|
||||
await pb.collection("users").update(userId, {
|
||||
Taskgrid_pref: writeScopedPrefs(rawPrefs, {
|
||||
...readScopedPrefs(rawPrefs),
|
||||
...prefs
|
||||
})
|
||||
}, { requestKey: null });
|
||||
} catch (e: any) {
|
||||
if (e.isAbort) return;
|
||||
console.error("Failed to sync preferences", e);
|
||||
}
|
||||
};
|
||||
|
||||
export const setFilter = (update: Partial<Filter>) => {
|
||||
setStore("filter", f => ({ ...f, ...update }));
|
||||
};
|
||||
|
||||
export const loadFilterTemplates = async () => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
const userId = pb.authStore.model?.id;
|
||||
if (!userId) return;
|
||||
const user = await pb.collection("users").getOne(userId, { requestKey: null });
|
||||
const prefs = readScopedPrefs(user.Taskgrid_pref || {});
|
||||
setStore("filterTemplates", prefs.filterTemplates || []);
|
||||
};
|
||||
|
||||
export const saveFilterTemplate = async (name: string) => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
try {
|
||||
const newTemplate: FilterTemplate = {
|
||||
id: `filter-${Date.now()}`,
|
||||
name,
|
||||
filter: store.filter
|
||||
};
|
||||
|
||||
setStore("filterTemplates", prev => [...prev, newTemplate]);
|
||||
await syncPreferences();
|
||||
toast.success(`Filter template "${name}" saved`);
|
||||
} catch (err) {
|
||||
console.error("Failed to save filter template:", err);
|
||||
toast.error("Failed to save filter template.");
|
||||
}
|
||||
};
|
||||
|
||||
export const applyFilterTemplate = (id: string) => {
|
||||
const template = store.filterTemplates.find(t => t.id === id);
|
||||
if (template) {
|
||||
setFilter(template.filter);
|
||||
toast.success(`Applied filter: ${template.name}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const removeFilterTemplate = async (id: string) => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
try {
|
||||
setStore("filterTemplates", prev => prev.filter(t => t.id !== id));
|
||||
await syncPreferences();
|
||||
toast.success("Filter template deleted");
|
||||
} catch (err) {
|
||||
console.error("Failed to delete filter template:", err);
|
||||
toast.error("Failed to delete filter template.");
|
||||
}
|
||||
};
|
||||
|
||||
export const clearFilter = () => {
|
||||
setStore("filter", createDefaultFilter());
|
||||
};
|
||||
|
||||
export const setNoteFilter = (update: Partial<Filter>) => {
|
||||
setStore("noteFilter", f => ({ ...f, ...update }));
|
||||
};
|
||||
|
||||
export const loadNoteFilterTemplates = async () => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
const userId = pb.authStore.model?.id;
|
||||
if (!userId) return;
|
||||
const user = await pb.collection("users").getOne(userId, { requestKey: null });
|
||||
const prefs = readScopedPrefs(user.Taskgrid_pref || {});
|
||||
setStore("filterTemplates", prefs.noteFilterTemplates || []);
|
||||
};
|
||||
|
||||
export const saveNoteFilterTemplate = async (name: string) => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
try {
|
||||
const newTemplate: FilterTemplate = {
|
||||
id: `note-filter-${Date.now()}`,
|
||||
name,
|
||||
filter: store.noteFilter
|
||||
};
|
||||
|
||||
setStore("filterTemplates", prev => [...prev, newTemplate]);
|
||||
const userId = pb.authStore.model?.id;
|
||||
if (userId) {
|
||||
const user = await pb.collection("users").getOne(userId, { requestKey: null });
|
||||
const rawPrefs = user.Taskgrid_pref || {};
|
||||
const prefs = readScopedPrefs(rawPrefs);
|
||||
await pb.collection("users").update(userId, {
|
||||
Taskgrid_pref: writeScopedPrefs(rawPrefs, {
|
||||
...prefs,
|
||||
noteFilterTemplates: [...(prefs.noteFilterTemplates || []), newTemplate]
|
||||
})
|
||||
}, { requestKey: null });
|
||||
}
|
||||
toast.success(`Note filter template "${name}" saved`);
|
||||
} catch (err) {
|
||||
console.error("Failed to save note filter template:", err);
|
||||
toast.error("Failed to save note filter template.");
|
||||
}
|
||||
};
|
||||
|
||||
export const applyNoteFilterTemplate = (id: string) => {
|
||||
const template = store.filterTemplates.find(t => t.id === id);
|
||||
if (template) {
|
||||
setNoteFilter(template.filter);
|
||||
toast.success(`Applied note filter: ${template.name}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const removeNoteFilterTemplate = async (id: string) => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
try {
|
||||
setStore("filterTemplates", prev => prev.filter(t => t.id !== id));
|
||||
const userId = pb.authStore.model?.id;
|
||||
if (userId) {
|
||||
const user = await pb.collection("users").getOne(userId, { requestKey: null });
|
||||
const rawPrefs = user.Taskgrid_pref || {};
|
||||
const prefs = readScopedPrefs(rawPrefs);
|
||||
await pb.collection("users").update(userId, {
|
||||
Taskgrid_pref: writeScopedPrefs(rawPrefs, {
|
||||
...prefs,
|
||||
noteFilterTemplates: (prefs.noteFilterTemplates || []).filter((template: FilterTemplate) => template.id !== id)
|
||||
})
|
||||
}, { requestKey: null });
|
||||
}
|
||||
toast.success("Note filter template deleted");
|
||||
} catch (err) {
|
||||
console.error("Failed to delete note filter template:", err);
|
||||
toast.error("Failed to delete note filter template.");
|
||||
}
|
||||
};
|
||||
|
||||
export const clearNoteFilter = () => {
|
||||
setStore("noteFilter", createDefaultFilter());
|
||||
};
|
||||
@@ -0,0 +1,352 @@
|
||||
import { createSignal } from "solid-js";
|
||||
import { createStore } from "solid-js/store";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import { STORAGE_KEY_PREFIX, TASGRID_DATA_MODE } from "@/lib/app-config";
|
||||
|
||||
// Helper to get the current user's personalized favorite tag
|
||||
export const getFavoriteTag = () => {
|
||||
const userId = pb.authStore.model?.id;
|
||||
return userId ? `__${userId}_favorite__` : "__favorite__";
|
||||
};
|
||||
|
||||
export const [now, setNow] = createSignal(Date.now());
|
||||
|
||||
// Context: 'mine' is a temporary bootstrap alias for the current user's personal context bucket.
|
||||
export type TaskContext = "mine" | { userId: string, name: string } | { bucketId: string, name: string, isPersonal?: boolean };
|
||||
export const [activeTaskId, setActiveTaskId] = createSignal<string | null>(null);
|
||||
export const [activeNoteId, setActiveNoteId] = createSignal<string | null>(null);
|
||||
export const [currentTaskContext, setCurrentTaskContext] = createSignal<TaskContext>("mine");
|
||||
|
||||
export type UrgencyLevel = number;
|
||||
|
||||
export interface TaskShareRef {
|
||||
contextId: string;
|
||||
key: string;
|
||||
kind: "user" | "bucket" | "note";
|
||||
policy?: "collaborative" | "handoff";
|
||||
}
|
||||
|
||||
export interface NoteRef {
|
||||
noteId: string;
|
||||
key: string;
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
startDate: string;
|
||||
dueDate: string;
|
||||
priority: number;
|
||||
status: number;
|
||||
completed: boolean;
|
||||
tags: string[];
|
||||
labelTags: string[];
|
||||
shareRefs: TaskShareRef[];
|
||||
noteRefs: NoteRef[];
|
||||
ownerId: string | null;
|
||||
createdBy: string | null;
|
||||
updatedBy?: string | null;
|
||||
content?: string;
|
||||
deletedAt?: number | null;
|
||||
created: string;
|
||||
updated: string;
|
||||
recurrence?: {
|
||||
type: "daily" | "weekly" | "monthly";
|
||||
days?: number[];
|
||||
dayOfMonth?: number;
|
||||
lastUncompleted?: string;
|
||||
};
|
||||
size?: number;
|
||||
sharedWith?: Array<{ userId: string; access: "view" | "edit" }>;
|
||||
attachments?: string[];
|
||||
}
|
||||
|
||||
export interface TaskTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
title: string;
|
||||
priority: number;
|
||||
urgency: number;
|
||||
tags: string[];
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface TagDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
value: number;
|
||||
color?: string;
|
||||
theme?: "light" | "dark";
|
||||
isUser?: boolean;
|
||||
isBucket?: boolean;
|
||||
}
|
||||
|
||||
export interface Note {
|
||||
id: string;
|
||||
title: string;
|
||||
key: string;
|
||||
content?: string;
|
||||
tags: string[];
|
||||
isPrivate: boolean;
|
||||
user: string;
|
||||
tasks: string[];
|
||||
created: string;
|
||||
updated: string;
|
||||
deletedAt?: number | null;
|
||||
}
|
||||
|
||||
export interface FilterTag {
|
||||
name: string;
|
||||
excluded: boolean;
|
||||
}
|
||||
|
||||
export interface Filter {
|
||||
query: string;
|
||||
tags: FilterTag[];
|
||||
priorityMin: number;
|
||||
priorityMax: number;
|
||||
urgencyMin: number;
|
||||
urgencyMax: number;
|
||||
editedToday: boolean;
|
||||
ownedByMe: boolean;
|
||||
starred: boolean;
|
||||
jobStatus: FilterTag[];
|
||||
jobDivision: FilterTag[];
|
||||
}
|
||||
|
||||
export interface FilterTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
filter: Filter;
|
||||
}
|
||||
|
||||
export interface ShareContext {
|
||||
id: string;
|
||||
key: string;
|
||||
displayName: string;
|
||||
kind: "user" | "bucket" | "note";
|
||||
policy: "collaborative" | "handoff";
|
||||
targetUserId?: string | null;
|
||||
supervisorUserIds?: string[];
|
||||
color?: string;
|
||||
description?: string;
|
||||
deletedAt?: number | null;
|
||||
deletedBy?: string | null;
|
||||
}
|
||||
|
||||
export interface ShareRule {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
targetUserId: string;
|
||||
type: "tag" | "all";
|
||||
tagName?: string;
|
||||
share_mode?: "ADD_USER" | "SEND_TASK";
|
||||
}
|
||||
|
||||
export interface SupervisedContextAccess {
|
||||
ownerUserId: string;
|
||||
ownerName: string;
|
||||
contextId: string;
|
||||
contextName: string;
|
||||
}
|
||||
|
||||
export interface Bucket {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
description?: string;
|
||||
policy?: "collaborative" | "handoff";
|
||||
key?: string;
|
||||
deletedAt?: number | null;
|
||||
}
|
||||
|
||||
export interface TaskStore {
|
||||
tasks: Task[];
|
||||
isInitializing: boolean;
|
||||
quickloadFadeTaskIds: string[];
|
||||
dismissedTaskUpdateIndicators: Record<string, string>;
|
||||
collapsedUntilUpdatedTasks: Record<string, string>;
|
||||
isNotesLoading: boolean;
|
||||
hasLoadedNotesMetadata: boolean;
|
||||
loadingNoteContentIds: string[];
|
||||
pWeight: number;
|
||||
uWeight: number;
|
||||
matrixScaleDays: number;
|
||||
prefId?: string;
|
||||
tagDefinitions: TagDefinition[];
|
||||
templates: TaskTemplate[];
|
||||
filter: Filter;
|
||||
shareRules: ShareRule[];
|
||||
contexts: ShareContext[];
|
||||
supervisedContexts: SupervisedContextAccess[];
|
||||
filterTemplates: FilterTemplate[];
|
||||
buckets: Bucket[];
|
||||
subscribedBuckets: string[];
|
||||
personalContextSupervisorIds: string[];
|
||||
notes: Note[];
|
||||
isNotepadMode: boolean;
|
||||
quickloadTasks: string[];
|
||||
noteFilter: Filter;
|
||||
lastCompletedTaskCleanupDate: string;
|
||||
}
|
||||
|
||||
export interface ScopedTaskgridPrefs {
|
||||
pWeight?: number;
|
||||
uWeight?: number;
|
||||
matrixScaleDays?: number;
|
||||
tagDefinitions?: TagDefinition[];
|
||||
filter?: Filter;
|
||||
filterTemplates?: FilterTemplate[];
|
||||
noteFilterTemplates?: FilterTemplate[];
|
||||
quickloadTasks?: string[];
|
||||
noteFilter?: Filter;
|
||||
subscribedBuckets?: string[];
|
||||
supervisorUserIds?: string[];
|
||||
dismissedTaskUpdateIndicators?: Record<string, string>;
|
||||
collapsedUntilUpdatedTasks?: Record<string, string>;
|
||||
migratedStructuredTagsV2?: boolean;
|
||||
migratedPersonalContextRefsV1?: boolean;
|
||||
migratedContextOwnershipV2?: boolean;
|
||||
lastCompletedTaskCleanupDate?: string;
|
||||
}
|
||||
|
||||
export const createDefaultFilter = (): Filter => ({
|
||||
query: "",
|
||||
tags: [],
|
||||
priorityMin: 1,
|
||||
priorityMax: 10,
|
||||
urgencyMin: 1,
|
||||
urgencyMax: 10,
|
||||
editedToday: false,
|
||||
ownedByMe: false,
|
||||
starred: false,
|
||||
jobStatus: [],
|
||||
jobDivision: []
|
||||
});
|
||||
|
||||
export const [store, setStore] = createStore<TaskStore>({
|
||||
tasks: [],
|
||||
isInitializing: false,
|
||||
quickloadFadeTaskIds: [],
|
||||
dismissedTaskUpdateIndicators: {},
|
||||
collapsedUntilUpdatedTasks: {},
|
||||
isNotesLoading: false,
|
||||
hasLoadedNotesMetadata: false,
|
||||
loadingNoteContentIds: [],
|
||||
pWeight: 1.0,
|
||||
uWeight: 1.0,
|
||||
matrixScaleDays: 30,
|
||||
tagDefinitions: [],
|
||||
templates: [],
|
||||
filter: createDefaultFilter(),
|
||||
shareRules: [],
|
||||
contexts: [],
|
||||
supervisedContexts: [],
|
||||
filterTemplates: [],
|
||||
buckets: [],
|
||||
subscribedBuckets: [],
|
||||
personalContextSupervisorIds: [],
|
||||
notes: [],
|
||||
isNotepadMode: false,
|
||||
quickloadTasks: [],
|
||||
lastCompletedTaskCleanupDate: "",
|
||||
noteFilter: createDefaultFilter()
|
||||
});
|
||||
|
||||
const PREF_ENVIRONMENTS_KEY = "environments";
|
||||
|
||||
export const getStorageKey = () => {
|
||||
const userId = pb.authStore.model?.id;
|
||||
return userId ? `${STORAGE_KEY_PREFIX}_data_${userId}` : null;
|
||||
};
|
||||
|
||||
export const getPersistedStoreSnapshot = () => {
|
||||
const { isInitializing, quickloadFadeTaskIds, isNotesLoading, hasLoadedNotesMetadata, loadingNoteContentIds, ...persisted } = store;
|
||||
return persisted;
|
||||
};
|
||||
|
||||
export const readScopedPrefs = (rawPrefs: any): ScopedTaskgridPrefs => {
|
||||
const scoped = rawPrefs?.[PREF_ENVIRONMENTS_KEY]?.[TASGRID_DATA_MODE];
|
||||
return (scoped || rawPrefs || {}) as ScopedTaskgridPrefs;
|
||||
};
|
||||
|
||||
export const writeScopedPrefs = (rawPrefs: any, scopedPrefs: ScopedTaskgridPrefs) => ({
|
||||
...(rawPrefs || {}),
|
||||
[PREF_ENVIRONMENTS_KEY]: {
|
||||
...(rawPrefs?.[PREF_ENVIRONMENTS_KEY] || {}),
|
||||
[TASGRID_DATA_MODE]: scopedPrefs
|
||||
}
|
||||
});
|
||||
|
||||
export const applyScopedPrefsToStore = (prefs: ScopedTaskgridPrefs) => {
|
||||
const currentUserId = pb.authStore.model?.id;
|
||||
const personalContext = currentUserId
|
||||
? store.contexts.find(context =>
|
||||
!context.deletedAt &&
|
||||
context.kind === "user" &&
|
||||
context.targetUserId === currentUserId
|
||||
) || null
|
||||
: null;
|
||||
|
||||
setStore({
|
||||
pWeight: prefs.pWeight || 1.0,
|
||||
uWeight: prefs.uWeight || 1.0,
|
||||
matrixScaleDays: prefs.matrixScaleDays || 30,
|
||||
prefId: currentUserId,
|
||||
subscribedBuckets: prefs.subscribedBuckets || [],
|
||||
personalContextSupervisorIds: personalContext?.supervisorUserIds || prefs.supervisorUserIds || [],
|
||||
tagDefinitions: (prefs.tagDefinitions || []).filter((def: TagDefinition) => !def.name.startsWith("@")),
|
||||
filterTemplates: prefs.filterTemplates || [],
|
||||
quickloadTasks: prefs.quickloadTasks || [],
|
||||
dismissedTaskUpdateIndicators: prefs.dismissedTaskUpdateIndicators || {},
|
||||
collapsedUntilUpdatedTasks: prefs.collapsedUntilUpdatedTasks || {},
|
||||
noteFilter: store.noteFilter,
|
||||
filter: store.filter
|
||||
});
|
||||
};
|
||||
|
||||
export const unwrapRelationId = (value: unknown): string | null => {
|
||||
if (typeof value === "string") {
|
||||
return value || null;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const first = value[0];
|
||||
return typeof first === "string" ? first : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const unwrapRelationIds = (value: unknown): string[] => {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map(item => {
|
||||
if (typeof item === "string") return item;
|
||||
if (item && typeof item === "object" && "id" in item && typeof (item as any).id === "string") {
|
||||
return (item as any).id;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((item): item is string => !!item);
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
return unwrapRelationIds(parsed);
|
||||
} catch {
|
||||
return trimmed.includes(",")
|
||||
? trimmed.split(",").map(item => item.trim()).filter(Boolean)
|
||||
: [trimmed];
|
||||
}
|
||||
}
|
||||
|
||||
if (value && typeof value === "object" && "id" in value && typeof (value as any).id === "string") {
|
||||
return [(value as any).id];
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
+1219
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user