src/store/index.ts breakup (no drama)

This commit is contained in:
2026-03-25 12:52:47 -05:00
parent 446d599ed2
commit 5fc1318c1d
7 changed files with 3911 additions and 4025 deletions
+929
View File
@@ -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);
}
});
});
+864
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+365
View File
@@ -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);
}
};
+176
View File
@@ -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());
};
+352
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff