diff --git a/src/components/TagPicker.tsx b/src/components/TagPicker.tsx index 316aec4..721dd9a 100644 --- a/src/components/TagPicker.tsx +++ b/src/components/TagPicker.tsx @@ -56,7 +56,13 @@ export const TagPicker: Component = (props) => { if (!name) return; if (name.startsWith("@")) { - const exists = store.contexts.some(context => context.key === normalizeContextKey(name)); + const normalized = normalizeContextKey(name); + const exists = store.contexts.some(context => + !context.deletedAt && ( + context.key === normalized || + normalizeContextKey(context.displayName) === normalized + ) + ); if (!exists) { toast.error("Create the shared context first before tagging tasks with it."); return; diff --git a/src/store/index.ts b/src/store/index.ts index ba19899..6e2d118 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -282,11 +282,6 @@ const readScopedPrefs = (rawPrefs: any): ScopedTaskgridPrefs => { return (scoped || rawPrefs || {}) as ScopedTaskgridPrefs; }; -const readScopedPrefsForMode = (rawPrefs: any, mode: string): ScopedTaskgridPrefs => { - const scoped = rawPrefs?.[PREF_ENVIRONMENTS_KEY]?.[mode]; - return (scoped || rawPrefs || {}) as ScopedTaskgridPrefs; -}; - const writeScopedPrefs = (rawPrefs: any, scopedPrefs: ScopedTaskgridPrefs) => ({ ...(rawPrefs || {}), [PREF_ENVIRONMENTS_KEY]: { @@ -308,9 +303,44 @@ const unwrapRelationId = (value: unknown): string | null => { return null; }; +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 []; +}; + const relationFilter = (field: string, id: string | undefined | null) => id ? `(${field} = "${id}" || ${field} ?= "${id}")` : "false"; +const buildUserContextKey = (userId: string) => `user-${userId}`; + export interface ShareContext { id: string; key: string; @@ -318,6 +348,7 @@ export interface ShareContext { kind: 'user' | 'bucket'; policy: 'collaborative' | 'handoff'; targetUserId?: string | null; + supervisorUserIds?: string[]; color?: string; description?: string; deletedAt?: number | null; @@ -462,9 +493,19 @@ const deriveSystemTagDefinitions = (contexts: ShareContext[]): TagDefinition[] = 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 getContextByRef = (ref: TaskShareRef) => - store.contexts.find(context => context.id === ref.contextId) || - store.contexts.find(context => context.key === ref.key && context.kind === ref.kind); + 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; const getNoteByRef = (ref: NoteRef) => store.notes.find(note => note.id === ref.noteId) || @@ -801,7 +842,7 @@ const mapRecordToTask = (r: any): Task => { : parsedTags .filter(tag => tag.kind === "share") .map(tag => { - const context = store.contexts.find(existing => existing.key === tag.key); + const context = findContextForShareTag(tag); return { contextId: context?.id || tag.key, key: tag.key, @@ -971,6 +1012,7 @@ const mapRecordToContext = (r: any): ShareContext => ({ 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, @@ -1172,6 +1214,7 @@ export const subscribeToRealtime = async () => { setContexts(remaining); setStore("shareRules", remaining.map(mapContextToShareRule)); setStore("tasks", tasks => tasks.filter(task => shouldTaskBeVisible(task, pb.authStore.model?.id || ""))); + void syncSupervisedContexts(); return; } @@ -1181,6 +1224,10 @@ export const subscribeToRealtime = async () => { : [...store.contexts, context]; setContexts(nextContexts); setStore("shareRules", nextContexts.map(mapContextToShareRule)); + if (context.targetUserId === pb.authStore.model?.id) { + setStore("personalContextSupervisorIds", context.supervisorUserIds || []); + } + void syncSupervisedContexts(); }); }; @@ -1245,28 +1292,34 @@ const syncUserContexts = async () => { const existingContexts = await pb.collection(CONTEXTS_COLLECTION).getFullList({ requestKey: null }).catch(() => []); - const contextByKey = new Map(existingContexts.map((context: any) => [context.key || normalizeContextKey(context.displayName || context.name), context])); let createdAny = false; for (const user of allUsers) { const displayName = user.name || user.email; - const key = normalizeContextKey(displayName); - if (!displayName || contextByKey.has(key)) continue; + 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, + key: buildUserContextKey(user.id), displayName, kind: "user", policy: "collaborative", targetUserId: user.id }, { requestKey: null }).catch(() => null); if (created) { - contextByKey.set(key, created); createdAny = true; } } if (createdAny || store.contexts.length === 0) { - const nextContexts = Array.from(contextByKey.values()).map(mapRecordToContext); + const nextContextRecords = await pb.collection(CONTEXTS_COLLECTION).getFullList({ + sort: 'displayName,key', + requestKey: null + }).catch(() => []); + const nextContexts = nextContextRecords.map(mapRecordToContext); setContexts(nextContexts); setStore("shareRules", nextContexts.map(mapContextToShareRule)); } @@ -1280,33 +1333,31 @@ const syncSupervisedContexts = async () => { } try { - const users = await pb.collection('users').getFullList({ - fields: 'id,name,email,Taskgrid_pref', + const contextRecords = await pb.collection(CONTEXTS_COLLECTION).getFullList({ + sort: 'displayName,key', requestKey: null - }); + }).catch(() => []); + const contexts = contextRecords.length > 0 ? contextRecords.map(mapRecordToContext) : store.contexts; - const nextSupervisedContexts = users - .filter((user: any) => user.id !== currentUserId) - .filter((user: any) => { - const prefs = readScopedPrefsForMode(user.Taskgrid_pref || {}, TASGRID_DATA_MODE); - return (prefs.supervisorUserIds || []).includes(currentUserId); - }) - .map((user: any) => { - const ownerName = user.name || user.email || user.id; - const personalContext = store.contexts.find(context => - !context.deletedAt && - context.kind === "user" && - context.targetUserId === user.id - ); + if (contextRecords.length > 0) { + setContexts(contexts); + setStore("shareRules", contexts.map(mapContextToShareRule)); + } - return personalContext ? { - ownerUserId: user.id, - ownerName, - contextId: personalContext.id, - contextName: personalContext.displayName - } : null; - }) - .filter((value): value is SupervisedContextAccess => !!value) + 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); @@ -1453,16 +1504,17 @@ export const initStore = async () => { 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 || {}); + const user = await pb.collection('users').getOne(userId, { requestKey: null }); + const prefs = readScopedPrefs(user.Taskgrid_pref || {}); + const personalContext = getPersonalContext(userId); - setStore({ + setStore({ pWeight: prefs.pWeight || 1.0, uWeight: prefs.uWeight || 1.0, matrixScaleDays: prefs.matrixScaleDays || 30, prefId: userId, subscribedBuckets: prefs.subscribedBuckets || user.subscribedBuckets || [], - personalContextSupervisorIds: prefs.supervisorUserIds || [], + personalContextSupervisorIds: personalContext?.supervisorUserIds || prefs.supervisorUserIds || [], tagDefinitions: (prefs.tagDefinitions || []).filter((def: TagDefinition) => !def.name.startsWith("@")), filterTemplates: prefs.filterTemplates || [], quickloadTasks: prefs.quickloadTasks || [], @@ -1502,6 +1554,9 @@ export const initStore = async () => { context.kind === "user" && context.targetUserId === pb.authStore.model?.id ); + if (personalContext) { + setStore("personalContextSupervisorIds", personalContext.supervisorUserIds || []); + } if (personalContext && currentTaskContext() === "mine") { setCurrentTaskContext({ bucketId: personalContext.id, name: "My Bucket", isPersonal: true }); } @@ -1836,6 +1891,16 @@ export const savePersonalContextSupervisors = async (supervisorUserIds: string[] 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); @@ -1845,6 +1910,7 @@ export const savePersonalContextSupervisors = async (supervisorUserIds: string[] supervisorUserIds: uniqueSupervisorIds }) }, { requestKey: null }); + await syncSupervisedContexts(); toast.success("Personal supervision updated"); } catch (err) { console.error("Failed to update personal supervision:", err); @@ -1871,7 +1937,7 @@ export const addTask = async (title: string, options?: { dueDate?: Date | string const shareRefs = parsedTags .filter(tag => tag.kind === "share") .map(tag => { - const context = store.contexts.find(existing => existing.key === tag.key); + const context = findContextForShareTag(tag); return { contextId: context?.id || tag.key, key: tag.key, @@ -2028,7 +2094,7 @@ export const updateTask = async (id: string, updates: Partial) => { finalUpdates.shareRefs = parsedTags .filter(tag => tag.kind === "share") .map(tag => { - const context = store.contexts.find(existing => existing.key === tag.key); + const context = findContextForShareTag(tag); return { contextId: context?.id || tag.key, key: tag.key,