From 0f4d374658ba3730fdebbe77c1b41223c4c20145 Mon Sep 17 00:00:00 2001 From: Timothy Cardoza Date: Mon, 23 Mar 2026 17:59:44 -0500 Subject: [PATCH] handoff and collab modes support per task for @user @bucket and #note --- src/components/ImportTool.tsx | 2 +- src/components/QuickEntryForm.tsx | 31 ++- src/components/TagPicker.tsx | 33 ++-- src/components/TaskCard.tsx | 21 +- src/components/TaskDetail.tsx | 49 +++-- src/store/index.ts | 312 ++++++++++++++++++------------ src/views/NotepadView.tsx | 13 +- 7 files changed, 269 insertions(+), 192 deletions(-) diff --git a/src/components/ImportTool.tsx b/src/components/ImportTool.tsx index dd8a620..0bcc94c 100644 --- a/src/components/ImportTool.tsx +++ b/src/components/ImportTool.tsx @@ -223,7 +223,7 @@ export const ImportTool: Component = () => {
- + setBulkTags(tags)} /> {(tag) => ( import("./TaskEditor").then(m => ({ default: m.TaskEditor }))); @@ -27,6 +28,7 @@ const [input, setInput] = createSignal(""); const [priority, setPriority] = createSignal(""); const [urgency, setUrgency] = createSignal(""); const [tags, setTags] = createSignal([]); +const [tagShareModes, setTagShareModes] = createSignal>({}); const [description, setDescription] = createSignal(""); const [size, setSize] = createSignal("3"); const [dateString, setDateString] = createSignal(""); @@ -39,6 +41,7 @@ const resetQuickEntryState = () => { setPriority(""); setUrgency(""); setTags([]); + setTagShareModes({}); setDescription(""); setSize("3"); setDateString(""); @@ -153,6 +156,7 @@ export const QuickEntryForm: Component = (props) => { let finalPriority = parseInt(priority()); let finalUrgency = parseInt(urgency()); const finalTags = [...tags()]; + const finalTagShareModes = { ...tagShareModes() }; const pMatch = text.match(/\/p\s*(\d+)/); if (pMatch) { @@ -217,8 +221,8 @@ export const QuickEntryForm: Component = (props) => { const activeNoteId = (window as any)._activeNoteId; if (activeNoteId) { const note = store.notes.find(n => n.id === activeNoteId); - if (note && note.title) { - const noteTagName = note.title.trim(); + if (note) { + const noteTagName = buildNoteTag(note.key || note.title); if (noteTagName && !finalTags.includes(noteTagName)) finalTags.push(noteTagName); } } @@ -243,7 +247,8 @@ export const QuickEntryForm: Component = (props) => { dueDate: finalDueDate || undefined, tags: finalTags, content: description(), - size: parseInt(size()) + size: parseInt(size()), + shareModeByTag: finalTagShareModes }); resetQuickEntryState(); @@ -413,7 +418,16 @@ export const QuickEntryForm: Component = (props) => {
- + tagShareModes()[tag.toLowerCase()]} + onTagsChange={(nextTags, shareModeOverrides) => { + setTags(nextTags); + if (shareModeOverrides) { + setTagShareModes(prev => ({ ...prev, ...shareModeOverrides })); + } + }} + /> !t.endsWith("_favorite__") && !isCurrentUserContextTag(t))}> {(tag) => ( = (props) => { return getThemeAdjustedColor(def?.color, def?.theme, resolvedTheme()) || "inherit"; })() }} - onClick={() => setTags(tags().filter(t => t !== tag))} + onClick={() => { + setTags(tags().filter(t => t !== tag)); + setTagShareModes(prev => { + const next = { ...prev }; + delete next[tag.toLowerCase()]; + return next; + }); + }} >
void; + onTagsChange: (tags: string[], shareModeOverrides?: Record) => void; + getShareModeForTag?: (tag: string) => "handoff" | "collaborative" | undefined; } export const TagPicker: Component = (props) => { @@ -75,6 +76,11 @@ export const TagPicker: Component = (props) => { }); createEffect(() => { + const explicitMode = props.getShareModeForTag?.(selectedTagName().trim()); + if (explicitMode) { + setShareMode(explicitMode); + return; + } const context = selectedShareContext(); if (context) { setShareMode(context.policy); @@ -94,9 +100,6 @@ export const TagPicker: Component = (props) => { const name = selectedTagName().trim(); if (!name) return; - const context = selectedShareContext(); - const noteContext = selectedNoteContext(); - if (name.startsWith("@")) { const normalized = normalizeContextKey(name); const exists = store.contexts.some(context => @@ -109,11 +112,6 @@ export const TagPicker: Component = (props) => { toast.error("Create the shared context first before tagging tasks with it."); return; } - - if (context && context.policy !== shareMode()) { - const updated = await setContextPolicy(context.id, shareMode()); - if (!updated) return; - } } if (name.startsWith("#")) { @@ -122,11 +120,6 @@ export const TagPicker: Component = (props) => { toast.error("Create the canonical note first before linking it with a #tag."); return; } - - if (noteContext && getNoteSharePolicy(noteContext) !== shareMode()) { - const updated = await setNoteContextPolicy(noteContext.id, shareMode()); - if (!updated) return; - } } // update global definition @@ -138,9 +131,13 @@ export const TagPicker: Component = (props) => { // Toggle in current task selection if not already there const currentSelected = props.selectedTags; - if (!currentSelected.includes(name)) { - props.onTagsChange([...currentSelected, name]); - } + const nextTags = currentSelected.includes(name) ? currentSelected : [...currentSelected, name]; + props.onTagsChange( + nextTags, + name.startsWith("@") || name.startsWith("#") + ? { [name.toLowerCase()]: shareMode() } + : undefined + ); // Cleanup setSelectedTagName(""); diff --git a/src/components/TaskCard.tsx b/src/components/TaskCard.tsx index 213d450..ee64dce 100644 --- a/src/components/TaskCard.tsx +++ b/src/components/TaskCard.tsx @@ -1,5 +1,5 @@ import { type Component, createMemo, For, Show } from "solid-js"; -import { type Task, calculateUrgencyFromDate, setActiveTaskId, store, copyTask, updateTask, currentTaskContext, now, getFavoriteTag, isCurrentUserContextTag, dismissTaskUpdateIndicator, isTaskUpdatedByAnotherUser, collapseTaskUntilUpdated, expandCollapsedTask, isTaskCollapsedUntilUpdated, isTaskCollaborativelyShared } from "@/store"; +import { type Task, calculateUrgencyFromDate, setActiveTaskId, store, copyTask, updateTask, currentTaskContext, now, getFavoriteTag, dismissTaskUpdateIndicator, isTaskUpdatedByAnotherUser, collapseTaskUntilUpdated, expandCollapsedTask, isTaskCollapsedUntilUpdated, isTaskCollaborativelyShared, getVisibleTaskTags } from "@/store"; import { cn } from "@/lib/utils"; import { Clock, ArrowUpCircle, Copy, Users2, Star, ChevronRight } from "lucide-solid"; import { Badge } from "@/components/ui/badge"; @@ -8,7 +8,6 @@ import { getThemeAdjustedColor } from "@/lib/colors"; import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select"; import { getStatusOptionsForTask } from "@/lib/constants"; import { getRecurrenceProgress } from "@/lib/recurrence"; -import { buildShareTag } from "@/lib/tags"; import { StatusCircle } from "./StatusCircle"; export const TaskCard: Component<{ task: Task, isShaking?: boolean }> = (props) => { @@ -28,18 +27,8 @@ export const TaskCard: Component<{ task: Task, isShaking?: boolean }> = (props) return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); }; - // Filter out bucket tags if we are currently INSIDE that bucket view const visibleTags = createMemo(() => { - const tags = props.task.tags || []; - const ctx = currentTaskContext(); - - if (typeof ctx === 'object' && 'bucketId' in ctx) { - const bucketContext = store.contexts.find(context => context.id === ctx.bucketId); - const bucketTagName = buildShareTag(bucketContext?.displayName || ctx.name).toLowerCase(); - return tags.filter(t => t.toLowerCase() !== bucketTagName && !t.endsWith("_favorite__") && !isCurrentUserContextTag(t)); - } - - return tags.filter(t => !t.endsWith("_favorite__") && !isCurrentUserContextTag(t)); + return getVisibleTaskTags(props.task, currentTaskContext()); }); const isDueWithin12Hours = createMemo(() => { @@ -187,13 +176,13 @@ export const TaskCard: Component<{ task: Task, isShaking?: boolean }> = (props) P{props.task.priority}
- {/* Shared Indicator - driven by @tag share contexts */} + {/* Shared Indicator - driven by task context tags */} {(() => { if (props.task.shareRefs.length === 0) return null; const tooltip = props.task.shareRefs.length === 1 - ? 'Shared via 1 @context' - : `Shared via ${props.task.shareRefs.length} @contexts`; + ? 'Shared via 1 context' + : `Shared via ${props.task.shareRefs.length} contexts`; return (
diff --git a/src/components/TaskDetail.tsx b/src/components/TaskDetail.tsx index f81f04d..f371ca1 100644 --- a/src/components/TaskDetail.tsx +++ b/src/components/TaskDetail.tsx @@ -1,6 +1,6 @@ import { type Component, createEffect, createSignal, For, createMemo, onCleanup, Show } from "solid-js"; import { Sheet, SheetContent } from "@/components/ui/sheet"; -import { type Task, removeTask, restoreTask, updateTask, saveTaskAsTemplate, loadTaskContent, currentTaskContext, cleanupTaskAttachments, getFavoriteTag, now, isCurrentUserContextTag } from "@/store"; +import { type Task, removeTask, restoreTask, updateTask, saveTaskAsTemplate, loadTaskContent, currentTaskContext, cleanupTaskAttachments, getFavoriteTag, now, isCurrentUserContextTag, getVisibleTaskTags, isTaskLockedNoteTag, deriveTaskRelationsFromTags, getTaskShareModeForTag, getTaskSharePolicy } from "@/store"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { ArrowUpCircle, Clock, Calendar, Type, Trash2, X, Copy, MoreHorizontal, Gauge, Star, FileText } from "lucide-solid"; @@ -17,7 +17,7 @@ import { lazy, Suspense } from "solid-js"; import { useTheme } from "./ThemeProvider"; import { getThemeAdjustedColor } from "@/lib/colors"; import { getRecurrenceProgress } from "@/lib/recurrence"; -import { buildShareTag } from "@/lib/tags"; +import { buildNoteTag, buildShareTag } from "@/lib/tags"; import { TextBubbleMenu } from "@/components/TextBubbleMenu"; import { TableBubbleMenu } from "@/components/TableBubbleMenu"; @@ -168,7 +168,7 @@ export const TaskDetail: Component = (props) => { } }; - const updateTags = (tags: string[]) => { + const updateTags = (tags: string[], shareModeOverrides?: Record) => { const ctx = currentTaskContext(); const currentTags = props.task.tags || []; let newTags = [...tags]; @@ -191,7 +191,16 @@ export const TaskDetail: Component = (props) => { } } - updateTask(props.task.id, { tags: newTags }); + props.task.tags + .filter(tag => isTaskLockedNoteTag(props.task, tag)) + .forEach(tag => { + if (!newTags.some(existing => existing.toLowerCase() === tag.toLowerCase())) { + newTags.push(tag); + } + }); + + const { labelTags, shareRefs, noteRefs } = deriveTaskRelationsFromTags(newTags, props.task.shareRefs, shareModeOverrides); + updateTask(props.task.id, { tags: newTags, labelTags, shareRefs, noteRefs }); }; const onDateInputChange = (e: Event) => { @@ -207,16 +216,7 @@ export const TaskDetail: Component = (props) => { // Filter out bucket tags if we are currently INSIDE that bucket view const visibleTags = createMemo(() => { - const tags = props.task.tags || []; - const ctx = currentTaskContext(); - - if (typeof ctx === 'object' && 'bucketId' in ctx) { - const bucketContext = store.contexts.find(context => context.id === ctx.bucketId); - const bucketTagName = buildShareTag(bucketContext?.displayName || ctx.name).toLowerCase(); - return tags.filter(t => t.toLowerCase() !== bucketTagName && !t.endsWith("_favorite__") && !isCurrentUserContextTag(t)); - } - - return tags.filter(t => !t.endsWith("_favorite__") && !isCurrentUserContextTag(t)); + return getVisibleTaskTags(props.task, currentTaskContext()); }); const statusOptions = createMemo(() => getStatusOptionsForTask(props.task.recurrence)); @@ -511,6 +511,7 @@ export const TaskDetail: Component = (props) => {
getTaskShareModeForTag(props.task, tag)} onTagsChange={updateTags} />
@@ -694,7 +695,7 @@ export const TaskDetail: Component = (props) => {

Sharing is driven by the task's @tags. Add or remove - @people and @buckets in the tag list above. + @people, @buckets, and #notes in the tag list above.

0} @@ -702,13 +703,25 @@ export const TaskDetail: Component = (props) => { >
{ + if (ref.kind === "note") { + const label = buildNoteTag(store.notes.find(note => note.id === ref.contextId || note.key === ref.key)?.key || ref.key); + return !isTaskLockedNoteTag(props.task, label); + } + const context = store.contexts.find(existing => existing.id === ref.contextId) || store.contexts.find(existing => existing.key === ref.key); return !isCurrentUserContextTag(`@${context?.displayName || ref.key}`); })}> {(ref) => { - const context = store.contexts.find(existing => existing.id === ref.contextId) || store.contexts.find(existing => existing.key === ref.key); - const label = `@${context?.displayName || ref.key}`; - const policy = context?.policy || (ref.kind === "bucket" ? "handoff" : "collaborative"); + const note = ref.kind === "note" + ? store.notes.find(existing => existing.id === ref.contextId || existing.key === ref.key) + : null; + const context = ref.kind === "note" + ? null + : store.contexts.find(existing => existing.id === ref.contextId) || store.contexts.find(existing => existing.key === ref.key); + const label = ref.kind === "note" + ? buildNoteTag(note?.key || ref.key) + : `@${context?.displayName || ref.key}`; + const policy = getTaskSharePolicy(ref); return ( {label} diff --git a/src/store/index.ts b/src/store/index.ts index 9365c04..063b81e 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -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 = { + user: "collaborative", + bucket: "handoff", + note: "handoff" +}; + const NOTE_HANDOFF_POLICY_TAG = "__note_policy_handoff__"; export const getNoteSharePolicy = (note: Pick | null | undefined): "collaborative" | "handoff" => note?.tags?.includes(NOTE_HANDOFF_POLICY_TAG) ? "handoff" : "collaborative"; +export const getTaskSharePolicy = (ref: Pick): "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[number], { kind: "share" | "note" }>, + existingRefs: TaskShareRef[] = [], + shareModeOverrides: Record = {} +): 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, 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 = {} +) => { + const parsedTags = parseTags(tags); + const labelTags = parsedTags.filter(tag => tag.kind === "label").map(tag => tag.display); + const shareRefs = parsedTags + .filter((tag): tag is Extract => 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(); return refs.filter(ref => { @@ -790,6 +887,43 @@ export const isCurrentUserContextTag = (tag: string) => { return hiddenTags.has(normalizedTag); }; +export const isTaskLockedNoteTag = (task: Pick, 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, 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 }) => { 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; + // 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): Partial => 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): Partial => }; } - 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) => { 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 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 => 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(); - [...idRecords, ...listRecords].forEach(record => { + listRecords.forEach(record => { if (!record?.id) return; if (record.tags?.includes("__template__")) return; recordMap.set(record.id, record); diff --git a/src/views/NotepadView.tsx b/src/views/NotepadView.tsx index 9b634bc..0776ae1 100644 --- a/src/views/NotepadView.tsx +++ b/src/views/NotepadView.tsx @@ -1,5 +1,5 @@ import { type Component, createSignal, createMemo, createEffect, onCleanup, For, Show } from "solid-js"; -import { store, renameTagDefinition, updateNote, updateTask, removeNote, restoreNote, setActiveNoteId, cleanupNoteAttachments, getFavoriteTag, loadNoteContent, requestImmediateNotesMetadataLoad, loadTasksLinkedToNote } from "@/store"; +import { store, renameTagDefinition, updateNote, updateTask, removeNote, restoreNote, setActiveNoteId, cleanupNoteAttachments, getFavoriteTag, loadNoteContent, requestImmediateNotesMetadataLoad, loadTasksLinkedToNote, getCombinedScore } from "@/store"; import { pb } from "@/lib/pocketbase"; import { type Note } from "@/store"; import { Plus, X, Lock, Unlock, Trash2, ChevronDown, Link as LinkIcon, MoreHorizontal, Type, FileText, Search, Star, ChevronRight } from "lucide-solid"; @@ -161,8 +161,6 @@ export const NotepadView: Component<{ if (!note) return; const task = store.tasks.find(existing => existing.id === taskId); const noteTag = buildNoteTag(note.key || note.title); - const newTasks = [...(note.tasks || []), taskId]; - await handleUpdateNote(note.id, { tasks: newTasks }); if (task && !task.tags.some(tag => tag.toLowerCase() === noteTag.toLowerCase())) { await updateTask(taskId, { tags: [...task.tags, noteTag] }); } @@ -176,8 +174,6 @@ export const NotepadView: Component<{ if (!note) return; const task = store.tasks.find(existing => existing.id === taskId); const noteTag = buildNoteTag(note.key || note.title).toLowerCase(); - const newTasks = (note.tasks || []).filter(id => id !== taskId); - await handleUpdateNote(note.id, { tasks: newTasks }); if (task && task.tags.some(tag => tag.toLowerCase() === noteTag)) { await updateTask(taskId, { tags: task.tags.filter(tag => tag.toLowerCase() !== noteTag) }); } @@ -189,11 +185,13 @@ export const NotepadView: Component<{ if (!note) return []; const noteTagName = buildNoteTag(note.key || note.title).toLowerCase(); return store.tasks.filter(t => { - // Explicit relation - if (note.tasks && note.tasks.includes(t.id)) return true; + if (t.deletedAt) return false; if (t.noteRefs && t.noteRefs.some(ref => ref.key === note.key || ref.noteId === note.id)) return true; if (t.tags && t.tags.some(tag => tag.toLowerCase() === noteTagName)) return true; return false; + }).sort((a, b) => { + if (a.completed !== b.completed) return a.completed ? 1 : -1; + return getCombinedScore(b) - getCombinedScore(a); }); }); @@ -221,7 +219,6 @@ export const NotepadView: Component<{ } const isAlreadyLinked = - (note.tasks && note.tasks.includes(t.id)) || t.noteRefs?.some(ref => ref.key === note.key || ref.noteId === note.id) || t.tags?.some(tag => tag.toLowerCase() === buildNoteTag(note.key || note.title).toLowerCase()); if (isAlreadyLinked) return false;