handoff and collab modes support per task for @user @bucket and #note
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m15s

This commit is contained in:
2026-03-23 17:59:44 -05:00
parent 609163f933
commit 0f4d374658
7 changed files with 269 additions and 192 deletions
+186 -126
View File
@@ -373,6 +373,7 @@ export interface TaskShareRef {
contextId: string;
key: string;
kind: 'user' | 'bucket' | 'note';
policy?: 'collaborative' | 'handoff';
}
export interface NoteRef {
@@ -545,11 +546,31 @@ const getNoteByRef = (ref: NoteRef) =>
store.notes.find(note => note.id === ref.noteId) ||
store.notes.find(note => 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.policy) return ref.policy;
if (ref.kind === "note") {
const note = store.notes.find(existing => existing.id === ref.contextId || existing.key === ref.key);
return getNoteSharePolicy(note);
}
const context = getContextByRef(ref as TaskShareRef);
if (context?.policy) return context.policy;
return DEFAULT_SHARE_POLICY_BY_KIND[ref.kind];
};
const buildNoteContext = (note: Note): ShareContext => ({
id: note.id,
key: note.key,
@@ -604,6 +625,82 @@ const dedupeShareRefs = (refs: TaskShareRef[]) => {
});
};
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.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.key === tag.key);
return {
noteId: note?.id || tag.key,
key: tag.key
} satisfies NoteRef;
});
return {
labelTags,
shareRefs: dedupeShareRefs(shareRefs),
noteRefs: dedupeNoteRefs(noteRefs)
};
};
const dedupeNoteRefs = (refs: NoteRef[]) => {
const seen = new Set<string>();
return refs.filter(ref => {
@@ -790,6 +887,43 @@ export const isCurrentUserContextTag = (tag: string) => {
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;
});
};
const getSubscribedBucketContexts = () =>
store.subscribedBuckets
.map(id => store.contexts.find(context => context.id === id))
@@ -803,16 +937,15 @@ export const matchesFilter = (task: Task) => {
const activeShareContexts = task.shareRefs
.map(ref => getContextByRef(ref))
.filter((context): context is ShareContext => !!context && !context.deletedAt);
const collaborativeUserShare = activeShareContexts.some(context =>
context.kind === "user" &&
context.policy === "collaborative" &&
context.targetUserId === currentUserId
);
const bucketRefs = activeShareContexts.filter(context => context.kind === "bucket");
const hasHandoffBucket = rawBucketRefs.some(ref => {
const collaborativeUserShare = task.shareRefs.some(ref => {
if (ref.kind !== "user" || getTaskSharePolicy(ref) !== "collaborative") return false;
const context = getContextByRef(ref);
return !context || context.policy === "handoff";
return context?.targetUserId === currentUserId;
});
const bucketRefs = activeShareContexts.filter(context => context.kind === "bucket");
const hasHandoffContext = task.shareRefs.some(ref =>
ref.kind !== "user" && getTaskSharePolicy(ref) === "handoff"
);
const inPinnedBucket = rawBucketRefs.some(ref =>
store.subscribedBuckets.includes(ref.contextId) ||
store.subscribedBuckets.some(bucketId => store.contexts.find(context => context.id === bucketId)?.key === ref.key)
@@ -835,7 +968,7 @@ export const matchesFilter = (task: Task) => {
// 0. Context Filter
if (isPersonalView) {
if (hasHandoffBucket) return false;
if (hasHandoffContext) return false;
const isOwner = task.ownerId === currentUserId;
if (!isOwner && !collaborativeUserShare && !inPersonalContext) return false;
} else if (typeof ctx === 'object' && 'bucketId' in ctx) {
@@ -940,7 +1073,7 @@ export const isTaskCollaborativelyShared = (task: Task) =>
task.shareRefs.some(ref => {
const currentUserId = pb.authStore.model?.id;
const context = getContextByRef(ref);
const policy = context?.policy || (ref.kind === "user" ? "collaborative" : "handoff");
const policy = getTaskSharePolicy(ref);
const targetUserId = context?.targetUserId || null;
return ref.kind === "user" && policy === "collaborative" && !!targetUserId && targetUserId !== currentUserId;
});
@@ -1091,25 +1224,15 @@ const mapRecordToTask = (r: any): Task => {
const legacyTags: string[] = r.tags || [];
const parsedTags = parseTags(legacyTags).filter(tag => tag.raw !== "__template__");
const shareRefs: TaskShareRef[] = Array.isArray(r.shareRefs)
? r.shareRefs
? r.shareRefs.map((ref: any) => ({
contextId: ref.contextId,
key: ref.key,
kind: ref.kind,
policy: ref.policy
}))
: parsedTags
.filter(tag => tag.kind === "share" || tag.kind === "note")
.map(tag => {
if (tag.kind === "note") {
const note = store.notes.find(existing => existing.key === tag.key);
return {
contextId: note?.id || tag.key,
key: tag.key,
kind: "note"
};
}
const context = findContextForShareTag(tag);
return {
contextId: context?.id || tag.key,
key: tag.key,
kind: context?.kind || "bucket"
};
});
.filter((tag): tag is Extract<(typeof parsedTags)[number], { kind: "share" | "note" }> => tag.kind === "share" || tag.kind === "note")
.map(tag => buildTaskShareRef(tag));
const noteRefs: NoteRef[] = Array.isArray(r.noteRefs)
? r.noteRefs
: parsedTags
@@ -1124,7 +1247,8 @@ const mapRecordToTask = (r: any): Task => {
const noteShareRefs: TaskShareRef[] = noteRefs.map(ref => ({
contextId: ref.noteId || ref.key,
key: ref.key,
kind: "note"
kind: "note",
policy: shareRefs.find(existing => existing.kind === "note" && (existing.contextId === ref.noteId || existing.key === ref.key))?.policy
}));
const mergedShareRefs = dedupeShareRefs([...shareRefs, ...noteShareRefs]);
const labelTags: string[] = Array.isArray(r.labelTags)
@@ -1182,10 +1306,7 @@ const buildTaskMigrationPayload = (
const ownerContext = task.ownerId ? ownerContextByUserId.get(task.ownerId) : undefined;
let nextShareRefs = dedupeShareRefs(task.shareRefs);
let nextNoteRefs = dedupeNoteRefs([...task.noteRefs, ...linkedNoteRefs]);
const hasHandoffBucket = nextShareRefs.some(ref => {
const context = getContextByRef(ref);
return context?.kind === "bucket" && context.policy === "handoff";
});
const hasHandoffContext = nextShareRefs.some(ref => ref.kind !== "user" && getTaskSharePolicy(ref) === "handoff");
if (ownerContext) {
const matchesOwnerContext = (ref: TaskShareRef) =>
@@ -1195,14 +1316,15 @@ const buildTaskMigrationPayload = (
normalizeContextKey(ownerContext.displayName) === ref.key
);
if (hasHandoffBucket) {
if (hasHandoffContext) {
nextShareRefs = nextShareRefs.filter(ref => !matchesOwnerContext(ref));
} else if (!nextShareRefs.some(matchesOwnerContext)) {
nextShareRefs = [
{
contextId: ownerContext.id,
key: ownerContext.key,
kind: "user" as const
kind: "user" as const,
policy: getTaskSharePolicy({ contextId: ownerContext.id, key: ownerContext.key, kind: "user" })
},
...nextShareRefs
];
@@ -1495,15 +1617,11 @@ const shouldTaskBeVisible = (task: any, currentUserId: string): boolean => {
if (mappedTask.shareRefs.some(ref => ref.kind === "bucket")) return true;
const contexts = mappedTask.shareRefs
.map(ref => getContextByRef(ref))
.filter((context): context is ShareContext => !!context && !context.deletedAt);
return contexts.some(context =>
context.kind === "user" &&
context.policy === "collaborative" &&
context.targetUserId === currentUserId
);
return mappedTask.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 subscribeToRealtime = async () => {
@@ -2092,7 +2210,6 @@ export const initStore = async () => {
.filter(context =>
!context.deletedAt &&
context.kind === "user" &&
context.policy === "collaborative" &&
context.targetUserId === currentUserId
)
.map(context => buildShareTag(context.displayName));
@@ -2464,7 +2581,7 @@ export const savePersonalContextSupervisors = async (supervisorUserIds: string[]
}
};
export const addTask = async (title: string, options?: { dueDate?: Date | string, priority?: number, tags?: string[], content?: string, size?: number }) => {
export const addTask = async (title: string, options?: { dueDate?: Date | string, priority?: number, tags?: string[], content?: string, size?: number, shareModeByTag?: Record<string, "collaborative" | "handoff"> }) => {
if (!pb.authStore.isValid) return;
// Default to ~24h from now if no date provided
@@ -2478,35 +2595,7 @@ export const addTask = async (title: string, options?: { dueDate?: Date | string
const tags = withActiveContextTags(options?.tags ?? ["work"]);
const content = options?.content ?? "";
const size = options?.size ?? 3;
const parsedTags = parseTags(tags);
const labelTags = parsedTags.filter(tag => tag.kind === "label").map(tag => tag.display);
const shareRefs = parsedTags
.filter(tag => tag.kind === "share" || tag.kind === "note")
.map(tag => {
if (tag.kind === "note") {
const note = store.notes.find(existing => existing.key === tag.key);
return {
contextId: note?.id || tag.key,
key: tag.key,
kind: "note"
} satisfies TaskShareRef;
}
const context = findContextForShareTag(tag);
return {
contextId: context?.id || tag.key,
key: tag.key,
kind: context?.kind || "bucket"
} satisfies TaskShareRef;
});
const noteRefs = parsedTags
.filter(tag => tag.kind === "note")
.map(tag => {
const note = store.notes.find(existing => existing.key === tag.key);
return {
noteId: note?.id || tag.key,
key: tag.key
} satisfies NoteRef;
});
const { labelTags, shareRefs, noteRefs } = deriveTaskRelationsFromTags(tags, [], options?.shareModeByTag || {});
const tempId = "temp-" + Date.now();
const initialTask: Task = {
@@ -2596,13 +2685,24 @@ export const copyTask = async (id: string) => {
const original = store.tasks.find(t => t.id === id);
if (!original) return;
const shareModeByTag = Object.fromEntries(
original.shareRefs.map(ref => {
const context = getContextByRef(ref);
const tag = ref.kind === "note"
? buildNoteTag(context?.key || ref.key)
: buildShareTag(context?.displayName || ref.key);
return [tag.toLowerCase(), getTaskSharePolicy(ref)];
})
) as Record<string, "collaborative" | "handoff">;
// Create a new task based on the original
await addTask(`${original.title} (Copy)`, {
priority: original.priority,
dueDate: original.dueDate,
tags: [...(original.tags || [])],
content: original.content,
size: original.size
size: original.size,
shareModeByTag
});
toast.success("Task duplicated");
@@ -2624,7 +2724,7 @@ const checkForHandoffs = (task: Task, updates: Partial<Task>): Partial<Task> =>
for (const ref of refs) {
const context = getContextByRef(ref);
if (!context || context.policy !== "handoff") continue;
if (!context || getTaskSharePolicy(ref) !== "handoff") continue;
const nextShareRefs = stripSenderPersonalContext(refs);
const labelTags = updates.labelTags || task.labelTags;
const noteRefs = updates.noteRefs || task.noteRefs;
@@ -2639,7 +2739,7 @@ const checkForHandoffs = (task: Task, updates: Partial<Task>): Partial<Task> =>
};
}
if (context.kind === "bucket") {
if (context.kind === "bucket" || context.kind === "note") {
return {
...updates,
shareRefs: nextShareRefs,
@@ -2680,35 +2780,12 @@ export const updateTask = async (id: string, updates: Partial<Task>) => {
let favoriteTagOverride: string | null | undefined;
if (finalUpdates.tags) {
const parsedTags = parseTags(finalUpdates.tags);
finalUpdates.labelTags = parsedTags.filter(tag => tag.kind === "label").map(tag => tag.display);
finalUpdates.shareRefs = parsedTags
.filter(tag => tag.kind === "share" || tag.kind === "note")
.map(tag => {
if (tag.kind === "note") {
const note = store.notes.find(existing => existing.key === tag.key);
return {
contextId: note?.id || tag.key,
key: tag.key,
kind: "note"
} satisfies TaskShareRef;
}
const context = findContextForShareTag(tag);
return {
contextId: context?.id || tag.key,
key: tag.key,
kind: context?.kind || "bucket"
} satisfies TaskShareRef;
});
finalUpdates.noteRefs = parsedTags
.filter(tag => tag.kind === "note")
.map(tag => {
const note = store.notes.find(existing => existing.key === tag.key);
return {
noteId: note?.id || tag.key,
key: tag.key
} satisfies NoteRef;
});
if (!finalUpdates.shareRefs || !finalUpdates.noteRefs || !finalUpdates.labelTags) {
const derived = deriveTaskRelationsFromTags(finalUpdates.tags, currentTask.shareRefs);
finalUpdates.labelTags = finalUpdates.labelTags || derived.labelTags;
finalUpdates.shareRefs = finalUpdates.shareRefs || derived.shareRefs;
finalUpdates.noteRefs = finalUpdates.noteRefs || derived.noteRefs;
}
favoriteTagOverride = finalUpdates.tags.find(tag => tag.endsWith("_favorite__")) || null;
}
@@ -3068,7 +3145,6 @@ export const loadAllHistory = async () => {
.filter(context =>
!context.deletedAt &&
context.kind === "user" &&
context.policy === "collaborative" &&
context.targetUserId === userId
)
.map(context => buildShareTag(context.displayName));
@@ -3149,33 +3225,17 @@ export const loadTasksLinkedToNote = async (note: Pick<Note, "id" | "key" | "tit
const noteTag = buildNoteTag(note.key || note.title);
const escapedNoteTag = noteTag.replace(/"/g, '\\"');
const linkedTaskIds = [...new Set((note.tasks || []).filter(Boolean))];
const idFilter = linkedTaskIds.length > 0
? ` || (${linkedTaskIds.map(taskId => `id = "${taskId}"`).join(" || ")})`
: "";
try {
const idFetchResults = await Promise.allSettled(
linkedTaskIds.map(taskId =>
pb.collection(TASGRID_COLLECTION).getOne(taskId, {
fields: getTaskListFields(),
requestKey: null
})
)
);
const idRecords = idFetchResults
.filter((result): result is PromiseFulfilledResult<any> => result.status === "fulfilled")
.map(result => result.value);
const listRecords = await pb.collection(TASGRID_COLLECTION).getFullList({
filter: `((tags ~ "${escapedNoteTag}")${idFilter}) && tags !~ "__template__"`,
filter: `(tags ~ "${escapedNoteTag}") && tags !~ "__template__"`,
sort: "-updated",
fields: getTaskListFields(),
requestKey: `note-linked-${note.id}`
}).catch(() => []);
const recordMap = new Map<string, any>();
[...idRecords, ...listRecords].forEach(record => {
listRecords.forEach(record => {
if (!record?.id) return;
if (record.tags?.includes("__template__")) return;
recordMap.set(record.id, record);