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
+1 -1
View File
@@ -223,7 +223,7 @@ export const ImportTool: Component = () => {
<div class="space-y-1"> <div class="space-y-1">
<label class="text-[0.5625rem] font-bold uppercase tracking-wider text-muted-foreground/70 ml-0.5">Tags</label> <label class="text-[0.5625rem] font-bold uppercase tracking-wider text-muted-foreground/70 ml-0.5">Tags</label>
<div class="flex items-center gap-1 flex-wrap"> <div class="flex items-center gap-1 flex-wrap">
<TagPicker selectedTags={bulkTags()} onTagsChange={setBulkTags} /> <TagPicker selectedTags={bulkTags()} onTagsChange={(tags) => setBulkTags(tags)} />
<For each={bulkTags()}> <For each={bulkTags()}>
{(tag) => ( {(tag) => (
<Badge <Badge
+26 -5
View File
@@ -12,6 +12,7 @@ import { getThemeAdjustedColor } from "@/lib/colors";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { toast } from "solid-sonner"; import { toast } from "solid-sonner";
import { PRIORITY_OPTIONS, URGENCY_OPTIONS, SIZE_OPTIONS } from "@/lib/constants"; import { PRIORITY_OPTIONS, URGENCY_OPTIONS, SIZE_OPTIONS } from "@/lib/constants";
import { buildNoteTag } from "@/lib/tags";
const TaskEditor = lazy(() => import("./TaskEditor").then(m => ({ default: m.TaskEditor }))); const TaskEditor = lazy(() => import("./TaskEditor").then(m => ({ default: m.TaskEditor })));
@@ -27,6 +28,7 @@ const [input, setInput] = createSignal("");
const [priority, setPriority] = createSignal<string>(""); const [priority, setPriority] = createSignal<string>("");
const [urgency, setUrgency] = createSignal<string>(""); const [urgency, setUrgency] = createSignal<string>("");
const [tags, setTags] = createSignal<string[]>([]); const [tags, setTags] = createSignal<string[]>([]);
const [tagShareModes, setTagShareModes] = createSignal<Record<string, "handoff" | "collaborative">>({});
const [description, setDescription] = createSignal(""); const [description, setDescription] = createSignal("");
const [size, setSize] = createSignal<string>("3"); const [size, setSize] = createSignal<string>("3");
const [dateString, setDateString] = createSignal<string>(""); const [dateString, setDateString] = createSignal<string>("");
@@ -39,6 +41,7 @@ const resetQuickEntryState = () => {
setPriority(""); setPriority("");
setUrgency(""); setUrgency("");
setTags([]); setTags([]);
setTagShareModes({});
setDescription(""); setDescription("");
setSize("3"); setSize("3");
setDateString(""); setDateString("");
@@ -153,6 +156,7 @@ export const QuickEntryForm: Component<QuickEntryFormProps> = (props) => {
let finalPriority = parseInt(priority()); let finalPriority = parseInt(priority());
let finalUrgency = parseInt(urgency()); let finalUrgency = parseInt(urgency());
const finalTags = [...tags()]; const finalTags = [...tags()];
const finalTagShareModes = { ...tagShareModes() };
const pMatch = text.match(/\/p\s*(\d+)/); const pMatch = text.match(/\/p\s*(\d+)/);
if (pMatch) { if (pMatch) {
@@ -217,8 +221,8 @@ export const QuickEntryForm: Component<QuickEntryFormProps> = (props) => {
const activeNoteId = (window as any)._activeNoteId; const activeNoteId = (window as any)._activeNoteId;
if (activeNoteId) { if (activeNoteId) {
const note = store.notes.find(n => n.id === activeNoteId); const note = store.notes.find(n => n.id === activeNoteId);
if (note && note.title) { if (note) {
const noteTagName = note.title.trim(); const noteTagName = buildNoteTag(note.key || note.title);
if (noteTagName && !finalTags.includes(noteTagName)) finalTags.push(noteTagName); if (noteTagName && !finalTags.includes(noteTagName)) finalTags.push(noteTagName);
} }
} }
@@ -243,7 +247,8 @@ export const QuickEntryForm: Component<QuickEntryFormProps> = (props) => {
dueDate: finalDueDate || undefined, dueDate: finalDueDate || undefined,
tags: finalTags, tags: finalTags,
content: description(), content: description(),
size: parseInt(size()) size: parseInt(size()),
shareModeByTag: finalTagShareModes
}); });
resetQuickEntryState(); resetQuickEntryState();
@@ -413,7 +418,16 @@ export const QuickEntryForm: Component<QuickEntryFormProps> = (props) => {
<div class="flex flex-col md:flex-row items-stretch md:items-center px-4 py-3 gap-4 bg-muted/10 border-b border-border"> <div class="flex flex-col md:flex-row items-stretch md:items-center px-4 py-3 gap-4 bg-muted/10 border-b border-border">
<div class="flex-1 flex flex-wrap gap-2 items-center"> <div class="flex-1 flex flex-wrap gap-2 items-center">
<TagPicker selectedTags={tags()} onTagsChange={setTags} /> <TagPicker
selectedTags={tags()}
getShareModeForTag={(tag) => tagShareModes()[tag.toLowerCase()]}
onTagsChange={(nextTags, shareModeOverrides) => {
setTags(nextTags);
if (shareModeOverrides) {
setTagShareModes(prev => ({ ...prev, ...shareModeOverrides }));
}
}}
/>
<For each={tags().filter(t => !t.endsWith("_favorite__") && !isCurrentUserContextTag(t))}> <For each={tags().filter(t => !t.endsWith("_favorite__") && !isCurrentUserContextTag(t))}>
{(tag) => ( {(tag) => (
<Badge <Badge
@@ -435,7 +449,14 @@ export const QuickEntryForm: Component<QuickEntryFormProps> = (props) => {
return getThemeAdjustedColor(def?.color, def?.theme, resolvedTheme()) || "inherit"; 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;
});
}}
> >
<div <div
class="w-1.5 h-1.5 rounded-full transition-colors group-hover/tag:bg-destructive" class="w-1.5 h-1.5 rounded-full transition-colors group-hover/tag:bg-destructive"
+15 -18
View File
@@ -1,5 +1,5 @@
import { type Component, createSignal, For, createEffect } from "solid-js"; import { type Component, createSignal, For, createEffect } from "solid-js";
import { store, upsertTagDefinition, isCurrentUserContextTag, setContextPolicy, setNoteContextPolicy, getNoteSharePolicy } from "@/store"; import { store, upsertTagDefinition, isCurrentUserContextTag, getNoteSharePolicy } from "@/store";
import { useTheme } from "./ThemeProvider"; import { useTheme } from "./ThemeProvider";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -10,7 +10,8 @@ import { cn } from "@/lib/utils";
interface TagPickerProps { interface TagPickerProps {
selectedTags: string[]; selectedTags: string[];
onTagsChange: (tags: string[]) => void; onTagsChange: (tags: string[], shareModeOverrides?: Record<string, "handoff" | "collaborative">) => void;
getShareModeForTag?: (tag: string) => "handoff" | "collaborative" | undefined;
} }
export const TagPicker: Component<TagPickerProps> = (props) => { export const TagPicker: Component<TagPickerProps> = (props) => {
@@ -75,6 +76,11 @@ export const TagPicker: Component<TagPickerProps> = (props) => {
}); });
createEffect(() => { createEffect(() => {
const explicitMode = props.getShareModeForTag?.(selectedTagName().trim());
if (explicitMode) {
setShareMode(explicitMode);
return;
}
const context = selectedShareContext(); const context = selectedShareContext();
if (context) { if (context) {
setShareMode(context.policy); setShareMode(context.policy);
@@ -94,9 +100,6 @@ export const TagPicker: Component<TagPickerProps> = (props) => {
const name = selectedTagName().trim(); const name = selectedTagName().trim();
if (!name) return; if (!name) return;
const context = selectedShareContext();
const noteContext = selectedNoteContext();
if (name.startsWith("@")) { if (name.startsWith("@")) {
const normalized = normalizeContextKey(name); const normalized = normalizeContextKey(name);
const exists = store.contexts.some(context => const exists = store.contexts.some(context =>
@@ -109,11 +112,6 @@ export const TagPicker: Component<TagPickerProps> = (props) => {
toast.error("Create the shared context first before tagging tasks with it."); toast.error("Create the shared context first before tagging tasks with it.");
return; return;
} }
if (context && context.policy !== shareMode()) {
const updated = await setContextPolicy(context.id, shareMode());
if (!updated) return;
}
} }
if (name.startsWith("#")) { if (name.startsWith("#")) {
@@ -122,11 +120,6 @@ export const TagPicker: Component<TagPickerProps> = (props) => {
toast.error("Create the canonical note first before linking it with a #tag."); toast.error("Create the canonical note first before linking it with a #tag.");
return; return;
} }
if (noteContext && getNoteSharePolicy(noteContext) !== shareMode()) {
const updated = await setNoteContextPolicy(noteContext.id, shareMode());
if (!updated) return;
}
} }
// update global definition // update global definition
@@ -138,9 +131,13 @@ export const TagPicker: Component<TagPickerProps> = (props) => {
// Toggle in current task selection if not already there // Toggle in current task selection if not already there
const currentSelected = props.selectedTags; const currentSelected = props.selectedTags;
if (!currentSelected.includes(name)) { const nextTags = currentSelected.includes(name) ? currentSelected : [...currentSelected, name];
props.onTagsChange([...currentSelected, name]); props.onTagsChange(
} nextTags,
name.startsWith("@") || name.startsWith("#")
? { [name.toLowerCase()]: shareMode() }
: undefined
);
// Cleanup // Cleanup
setSelectedTagName(""); setSelectedTagName("");
+5 -16
View File
@@ -1,5 +1,5 @@
import { type Component, createMemo, For, Show } from "solid-js"; 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 { cn } from "@/lib/utils";
import { Clock, ArrowUpCircle, Copy, Users2, Star, ChevronRight } from "lucide-solid"; import { Clock, ArrowUpCircle, Copy, Users2, Star, ChevronRight } from "lucide-solid";
import { Badge } from "@/components/ui/badge"; 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 { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
import { getStatusOptionsForTask } from "@/lib/constants"; import { getStatusOptionsForTask } from "@/lib/constants";
import { getRecurrenceProgress } from "@/lib/recurrence"; import { getRecurrenceProgress } from "@/lib/recurrence";
import { buildShareTag } from "@/lib/tags";
import { StatusCircle } from "./StatusCircle"; import { StatusCircle } from "./StatusCircle";
export const TaskCard: Component<{ task: Task, isShaking?: boolean }> = (props) => { 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' }); 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 visibleTags = createMemo(() => {
const tags = props.task.tags || []; return getVisibleTaskTags(props.task, currentTaskContext());
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));
}); });
const isDueWithin12Hours = createMemo(() => { const isDueWithin12Hours = createMemo(() => {
@@ -187,13 +176,13 @@ export const TaskCard: Component<{ task: Task, isShaking?: boolean }> = (props)
<span class="text-[0.625rem] font-medium">P{props.task.priority}</span> <span class="text-[0.625rem] font-medium">P{props.task.priority}</span>
</div> </div>
{/* Shared Indicator - driven by @tag share contexts */} {/* Shared Indicator - driven by task context tags */}
{(() => { {(() => {
if (props.task.shareRefs.length === 0) return null; if (props.task.shareRefs.length === 0) return null;
const tooltip = props.task.shareRefs.length === 1 const tooltip = props.task.shareRefs.length === 1
? 'Shared via 1 @context' ? 'Shared via 1 context'
: `Shared via ${props.task.shareRefs.length} @contexts`; : `Shared via ${props.task.shareRefs.length} contexts`;
return ( return (
<div class="flex items-center gap-1 shrink-0" title={tooltip}> <div class="flex items-center gap-1 shrink-0" title={tooltip}>
+31 -18
View File
@@ -1,6 +1,6 @@
import { type Component, createEffect, createSignal, For, createMemo, onCleanup, Show } from "solid-js"; import { type Component, createEffect, createSignal, For, createMemo, onCleanup, Show } from "solid-js";
import { Sheet, SheetContent } from "@/components/ui/sheet"; 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { ArrowUpCircle, Clock, Calendar, Type, Trash2, X, Copy, MoreHorizontal, Gauge, Star, FileText } from "lucide-solid"; 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 { useTheme } from "./ThemeProvider";
import { getThemeAdjustedColor } from "@/lib/colors"; import { getThemeAdjustedColor } from "@/lib/colors";
import { getRecurrenceProgress } from "@/lib/recurrence"; import { getRecurrenceProgress } from "@/lib/recurrence";
import { buildShareTag } from "@/lib/tags"; import { buildNoteTag, buildShareTag } from "@/lib/tags";
import { TextBubbleMenu } from "@/components/TextBubbleMenu"; import { TextBubbleMenu } from "@/components/TextBubbleMenu";
import { TableBubbleMenu } from "@/components/TableBubbleMenu"; import { TableBubbleMenu } from "@/components/TableBubbleMenu";
@@ -168,7 +168,7 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
} }
}; };
const updateTags = (tags: string[]) => { const updateTags = (tags: string[], shareModeOverrides?: Record<string, "handoff" | "collaborative">) => {
const ctx = currentTaskContext(); const ctx = currentTaskContext();
const currentTags = props.task.tags || []; const currentTags = props.task.tags || [];
let newTags = [...tags]; let newTags = [...tags];
@@ -191,7 +191,16 @@ export const TaskDetail: Component<TaskDetailProps> = (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) => { const onDateInputChange = (e: Event) => {
@@ -207,16 +216,7 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
// Filter out bucket tags if we are currently INSIDE that bucket view // Filter out bucket tags if we are currently INSIDE that bucket view
const visibleTags = createMemo(() => { const visibleTags = createMemo(() => {
const tags = props.task.tags || []; return getVisibleTaskTags(props.task, currentTaskContext());
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));
}); });
const statusOptions = createMemo(() => getStatusOptionsForTask(props.task.recurrence)); const statusOptions = createMemo(() => getStatusOptionsForTask(props.task.recurrence));
@@ -511,6 +511,7 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
<div class="flex flex-nowrap overflow-x-auto no-scrollbar items-center gap-1.5 min-w-0 flex-1 pb-1"> <div class="flex flex-nowrap overflow-x-auto no-scrollbar items-center gap-1.5 min-w-0 flex-1 pb-1">
<TagPicker <TagPicker
selectedTags={visibleTags()} selectedTags={visibleTags()}
getShareModeForTag={(tag) => getTaskShareModeForTag(props.task, tag)}
onTagsChange={updateTags} onTagsChange={updateTags}
/> />
<div class="flex flex-nowrap items-center gap-1.5 shrink-0 pr-2"> <div class="flex flex-nowrap items-center gap-1.5 shrink-0 pr-2">
@@ -694,7 +695,7 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
<div class="px-2 py-1.5 rounded-lg bg-muted/20 border border-border/30 space-y-2"> <div class="px-2 py-1.5 rounded-lg bg-muted/20 border border-border/30 space-y-2">
<p class="text-[0.6875rem] text-muted-foreground leading-relaxed"> <p class="text-[0.6875rem] text-muted-foreground leading-relaxed">
Sharing is driven by the task&apos;s <span class="font-semibold text-foreground">@tags</span>. Add or remove Sharing is driven by the task&apos;s <span class="font-semibold text-foreground">@tags</span>. Add or remove
<span class="font-semibold text-foreground"> @people</span> and <span class="font-semibold text-foreground">@buckets</span> in the tag list above. <span class="font-semibold text-foreground"> @people</span>, <span class="font-semibold text-foreground">@buckets</span>, and <span class="font-semibold text-foreground">#notes</span> in the tag list above.
</p> </p>
<Show <Show
when={props.task.shareRefs.length > 0} when={props.task.shareRefs.length > 0}
@@ -702,13 +703,25 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
> >
<div class="flex flex-wrap gap-1.5"> <div class="flex flex-wrap gap-1.5">
<For each={props.task.shareRefs.filter(ref => { <For each={props.task.shareRefs.filter(ref => {
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); const context = store.contexts.find(existing => existing.id === ref.contextId) || store.contexts.find(existing => existing.key === ref.key);
return !isCurrentUserContextTag(`@${context?.displayName || ref.key}`); return !isCurrentUserContextTag(`@${context?.displayName || ref.key}`);
})}> })}>
{(ref) => { {(ref) => {
const context = store.contexts.find(existing => existing.id === ref.contextId) || store.contexts.find(existing => existing.key === ref.key); const note = ref.kind === "note"
const label = `@${context?.displayName || ref.key}`; ? store.notes.find(existing => existing.id === ref.contextId || existing.key === ref.key)
const policy = context?.policy || (ref.kind === "bucket" ? "handoff" : "collaborative"); : 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 ( return (
<Badge variant="secondary" class="h-6 px-2 text-[0.625rem] gap-1 bg-muted/40 border-border/40"> <Badge variant="secondary" class="h-6 px-2 text-[0.625rem] gap-1 bg-muted/40 border-border/40">
<span>{label}</span> <span>{label}</span>
+185 -125
View File
@@ -373,6 +373,7 @@ export interface TaskShareRef {
contextId: string; contextId: string;
key: string; key: string;
kind: 'user' | 'bucket' | 'note'; kind: 'user' | 'bucket' | 'note';
policy?: 'collaborative' | 'handoff';
} }
export interface NoteRef { export interface NoteRef {
@@ -545,11 +546,31 @@ const getNoteByRef = (ref: NoteRef) =>
store.notes.find(note => note.id === ref.noteId) || store.notes.find(note => note.id === ref.noteId) ||
store.notes.find(note => note.key === ref.key); 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__"; const NOTE_HANDOFF_POLICY_TAG = "__note_policy_handoff__";
export const getNoteSharePolicy = (note: Pick<Note, "tags"> | null | undefined): "collaborative" | "handoff" => export const getNoteSharePolicy = (note: Pick<Note, "tags"> | null | undefined): "collaborative" | "handoff" =>
note?.tags?.includes(NOTE_HANDOFF_POLICY_TAG) ? "handoff" : "collaborative"; 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 => ({ const buildNoteContext = (note: Note): ShareContext => ({
id: note.id, id: note.id,
key: note.key, 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 dedupeNoteRefs = (refs: NoteRef[]) => {
const seen = new Set<string>(); const seen = new Set<string>();
return refs.filter(ref => { return refs.filter(ref => {
@@ -790,6 +887,43 @@ export const isCurrentUserContextTag = (tag: string) => {
return hiddenTags.has(normalizedTag); 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 = () => const getSubscribedBucketContexts = () =>
store.subscribedBuckets store.subscribedBuckets
.map(id => store.contexts.find(context => context.id === id)) .map(id => store.contexts.find(context => context.id === id))
@@ -803,16 +937,15 @@ export const matchesFilter = (task: Task) => {
const activeShareContexts = task.shareRefs const activeShareContexts = task.shareRefs
.map(ref => getContextByRef(ref)) .map(ref => getContextByRef(ref))
.filter((context): context is ShareContext => !!context && !context.deletedAt); .filter((context): context is ShareContext => !!context && !context.deletedAt);
const collaborativeUserShare = activeShareContexts.some(context => const collaborativeUserShare = task.shareRefs.some(ref => {
context.kind === "user" && if (ref.kind !== "user" || getTaskSharePolicy(ref) !== "collaborative") return false;
context.policy === "collaborative" &&
context.targetUserId === currentUserId
);
const bucketRefs = activeShareContexts.filter(context => context.kind === "bucket");
const hasHandoffBucket = rawBucketRefs.some(ref => {
const context = getContextByRef(ref); 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 => const inPinnedBucket = rawBucketRefs.some(ref =>
store.subscribedBuckets.includes(ref.contextId) || store.subscribedBuckets.includes(ref.contextId) ||
store.subscribedBuckets.some(bucketId => store.contexts.find(context => context.id === bucketId)?.key === ref.key) 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 // 0. Context Filter
if (isPersonalView) { if (isPersonalView) {
if (hasHandoffBucket) return false; if (hasHandoffContext) return false;
const isOwner = task.ownerId === currentUserId; const isOwner = task.ownerId === currentUserId;
if (!isOwner && !collaborativeUserShare && !inPersonalContext) return false; if (!isOwner && !collaborativeUserShare && !inPersonalContext) return false;
} else if (typeof ctx === 'object' && 'bucketId' in ctx) { } else if (typeof ctx === 'object' && 'bucketId' in ctx) {
@@ -940,7 +1073,7 @@ export const isTaskCollaborativelyShared = (task: Task) =>
task.shareRefs.some(ref => { task.shareRefs.some(ref => {
const currentUserId = pb.authStore.model?.id; const currentUserId = pb.authStore.model?.id;
const context = getContextByRef(ref); const context = getContextByRef(ref);
const policy = context?.policy || (ref.kind === "user" ? "collaborative" : "handoff"); const policy = getTaskSharePolicy(ref);
const targetUserId = context?.targetUserId || null; const targetUserId = context?.targetUserId || null;
return ref.kind === "user" && policy === "collaborative" && !!targetUserId && targetUserId !== currentUserId; return ref.kind === "user" && policy === "collaborative" && !!targetUserId && targetUserId !== currentUserId;
}); });
@@ -1091,25 +1224,15 @@ const mapRecordToTask = (r: any): Task => {
const legacyTags: string[] = r.tags || []; const legacyTags: string[] = r.tags || [];
const parsedTags = parseTags(legacyTags).filter(tag => tag.raw !== "__template__"); const parsedTags = parseTags(legacyTags).filter(tag => tag.raw !== "__template__");
const shareRefs: TaskShareRef[] = Array.isArray(r.shareRefs) 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 : parsedTags
.filter(tag => tag.kind === "share" || tag.kind === "note") .filter((tag): tag is Extract<(typeof parsedTags)[number], { kind: "share" | "note" }> => tag.kind === "share" || tag.kind === "note")
.map(tag => { .map(tag => buildTaskShareRef(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"
};
});
const noteRefs: NoteRef[] = Array.isArray(r.noteRefs) const noteRefs: NoteRef[] = Array.isArray(r.noteRefs)
? r.noteRefs ? r.noteRefs
: parsedTags : parsedTags
@@ -1124,7 +1247,8 @@ const mapRecordToTask = (r: any): Task => {
const noteShareRefs: TaskShareRef[] = noteRefs.map(ref => ({ const noteShareRefs: TaskShareRef[] = noteRefs.map(ref => ({
contextId: ref.noteId || ref.key, contextId: ref.noteId || ref.key,
key: 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 mergedShareRefs = dedupeShareRefs([...shareRefs, ...noteShareRefs]);
const labelTags: string[] = Array.isArray(r.labelTags) const labelTags: string[] = Array.isArray(r.labelTags)
@@ -1182,10 +1306,7 @@ const buildTaskMigrationPayload = (
const ownerContext = task.ownerId ? ownerContextByUserId.get(task.ownerId) : undefined; const ownerContext = task.ownerId ? ownerContextByUserId.get(task.ownerId) : undefined;
let nextShareRefs = dedupeShareRefs(task.shareRefs); let nextShareRefs = dedupeShareRefs(task.shareRefs);
let nextNoteRefs = dedupeNoteRefs([...task.noteRefs, ...linkedNoteRefs]); let nextNoteRefs = dedupeNoteRefs([...task.noteRefs, ...linkedNoteRefs]);
const hasHandoffBucket = nextShareRefs.some(ref => { const hasHandoffContext = nextShareRefs.some(ref => ref.kind !== "user" && getTaskSharePolicy(ref) === "handoff");
const context = getContextByRef(ref);
return context?.kind === "bucket" && context.policy === "handoff";
});
if (ownerContext) { if (ownerContext) {
const matchesOwnerContext = (ref: TaskShareRef) => const matchesOwnerContext = (ref: TaskShareRef) =>
@@ -1195,14 +1316,15 @@ const buildTaskMigrationPayload = (
normalizeContextKey(ownerContext.displayName) === ref.key normalizeContextKey(ownerContext.displayName) === ref.key
); );
if (hasHandoffBucket) { if (hasHandoffContext) {
nextShareRefs = nextShareRefs.filter(ref => !matchesOwnerContext(ref)); nextShareRefs = nextShareRefs.filter(ref => !matchesOwnerContext(ref));
} else if (!nextShareRefs.some(matchesOwnerContext)) { } else if (!nextShareRefs.some(matchesOwnerContext)) {
nextShareRefs = [ nextShareRefs = [
{ {
contextId: ownerContext.id, contextId: ownerContext.id,
key: ownerContext.key, key: ownerContext.key,
kind: "user" as const kind: "user" as const,
policy: getTaskSharePolicy({ contextId: ownerContext.id, key: ownerContext.key, kind: "user" })
}, },
...nextShareRefs ...nextShareRefs
]; ];
@@ -1495,15 +1617,11 @@ const shouldTaskBeVisible = (task: any, currentUserId: string): boolean => {
if (mappedTask.shareRefs.some(ref => ref.kind === "bucket")) return true; if (mappedTask.shareRefs.some(ref => ref.kind === "bucket")) return true;
const contexts = mappedTask.shareRefs return mappedTask.shareRefs.some(ref => {
.map(ref => getContextByRef(ref)) if (ref.kind !== "user" || getTaskSharePolicy(ref) !== "collaborative") return false;
.filter((context): context is ShareContext => !!context && !context.deletedAt); const context = getContextByRef(ref);
return !!context && !context.deletedAt && context.targetUserId === currentUserId;
return contexts.some(context => });
context.kind === "user" &&
context.policy === "collaborative" &&
context.targetUserId === currentUserId
);
}; };
export const subscribeToRealtime = async () => { export const subscribeToRealtime = async () => {
@@ -2092,7 +2210,6 @@ export const initStore = async () => {
.filter(context => .filter(context =>
!context.deletedAt && !context.deletedAt &&
context.kind === "user" && context.kind === "user" &&
context.policy === "collaborative" &&
context.targetUserId === currentUserId context.targetUserId === currentUserId
) )
.map(context => buildShareTag(context.displayName)); .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; if (!pb.authStore.isValid) return;
// Default to ~24h from now if no date provided // 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 tags = withActiveContextTags(options?.tags ?? ["work"]);
const content = options?.content ?? ""; const content = options?.content ?? "";
const size = options?.size ?? 3; const size = options?.size ?? 3;
const parsedTags = parseTags(tags); const { labelTags, shareRefs, noteRefs } = deriveTaskRelationsFromTags(tags, [], options?.shareModeByTag || {});
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 tempId = "temp-" + Date.now(); const tempId = "temp-" + Date.now();
const initialTask: Task = { const initialTask: Task = {
@@ -2596,13 +2685,24 @@ export const copyTask = async (id: string) => {
const original = store.tasks.find(t => t.id === id); const original = store.tasks.find(t => t.id === id);
if (!original) return; 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 // Create a new task based on the original
await addTask(`${original.title} (Copy)`, { await addTask(`${original.title} (Copy)`, {
priority: original.priority, priority: original.priority,
dueDate: original.dueDate, dueDate: original.dueDate,
tags: [...(original.tags || [])], tags: [...(original.tags || [])],
content: original.content, content: original.content,
size: original.size size: original.size,
shareModeByTag
}); });
toast.success("Task duplicated"); toast.success("Task duplicated");
@@ -2624,7 +2724,7 @@ const checkForHandoffs = (task: Task, updates: Partial<Task>): Partial<Task> =>
for (const ref of refs) { for (const ref of refs) {
const context = getContextByRef(ref); const context = getContextByRef(ref);
if (!context || context.policy !== "handoff") continue; if (!context || getTaskSharePolicy(ref) !== "handoff") continue;
const nextShareRefs = stripSenderPersonalContext(refs); const nextShareRefs = stripSenderPersonalContext(refs);
const labelTags = updates.labelTags || task.labelTags; const labelTags = updates.labelTags || task.labelTags;
const noteRefs = updates.noteRefs || task.noteRefs; 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 { return {
...updates, ...updates,
shareRefs: nextShareRefs, shareRefs: nextShareRefs,
@@ -2680,35 +2780,12 @@ export const updateTask = async (id: string, updates: Partial<Task>) => {
let favoriteTagOverride: string | null | undefined; let favoriteTagOverride: string | null | undefined;
if (finalUpdates.tags) { if (finalUpdates.tags) {
const parsedTags = parseTags(finalUpdates.tags); if (!finalUpdates.shareRefs || !finalUpdates.noteRefs || !finalUpdates.labelTags) {
finalUpdates.labelTags = parsedTags.filter(tag => tag.kind === "label").map(tag => tag.display); const derived = deriveTaskRelationsFromTags(finalUpdates.tags, currentTask.shareRefs);
finalUpdates.shareRefs = parsedTags finalUpdates.labelTags = finalUpdates.labelTags || derived.labelTags;
.filter(tag => tag.kind === "share" || tag.kind === "note") finalUpdates.shareRefs = finalUpdates.shareRefs || derived.shareRefs;
.map(tag => { finalUpdates.noteRefs = finalUpdates.noteRefs || derived.noteRefs;
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;
});
favoriteTagOverride = finalUpdates.tags.find(tag => tag.endsWith("_favorite__")) || null; favoriteTagOverride = finalUpdates.tags.find(tag => tag.endsWith("_favorite__")) || null;
} }
@@ -3068,7 +3145,6 @@ export const loadAllHistory = async () => {
.filter(context => .filter(context =>
!context.deletedAt && !context.deletedAt &&
context.kind === "user" && context.kind === "user" &&
context.policy === "collaborative" &&
context.targetUserId === userId context.targetUserId === userId
) )
.map(context => buildShareTag(context.displayName)); .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 noteTag = buildNoteTag(note.key || note.title);
const escapedNoteTag = noteTag.replace(/"/g, '\\"'); 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 { 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({ const listRecords = await pb.collection(TASGRID_COLLECTION).getFullList({
filter: `((tags ~ "${escapedNoteTag}")${idFilter}) && tags !~ "__template__"`, filter: `(tags ~ "${escapedNoteTag}") && tags !~ "__template__"`,
sort: "-updated", sort: "-updated",
fields: getTaskListFields(), fields: getTaskListFields(),
requestKey: `note-linked-${note.id}` requestKey: `note-linked-${note.id}`
}).catch(() => []); }).catch(() => []);
const recordMap = new Map<string, any>(); const recordMap = new Map<string, any>();
[...idRecords, ...listRecords].forEach(record => { listRecords.forEach(record => {
if (!record?.id) return; if (!record?.id) return;
if (record.tags?.includes("__template__")) return; if (record.tags?.includes("__template__")) return;
recordMap.set(record.id, record); recordMap.set(record.id, record);
+5 -8
View File
@@ -1,5 +1,5 @@
import { type Component, createSignal, createMemo, createEffect, onCleanup, For, Show } from "solid-js"; 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 { pb } from "@/lib/pocketbase";
import { type Note } from "@/store"; import { type Note } from "@/store";
import { Plus, X, Lock, Unlock, Trash2, ChevronDown, Link as LinkIcon, MoreHorizontal, Type, FileText, Search, Star, ChevronRight } from "lucide-solid"; 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; if (!note) return;
const task = store.tasks.find(existing => existing.id === taskId); const task = store.tasks.find(existing => existing.id === taskId);
const noteTag = buildNoteTag(note.key || note.title); 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())) { if (task && !task.tags.some(tag => tag.toLowerCase() === noteTag.toLowerCase())) {
await updateTask(taskId, { tags: [...task.tags, noteTag] }); await updateTask(taskId, { tags: [...task.tags, noteTag] });
} }
@@ -176,8 +174,6 @@ export const NotepadView: Component<{
if (!note) return; if (!note) return;
const task = store.tasks.find(existing => existing.id === taskId); const task = store.tasks.find(existing => existing.id === taskId);
const noteTag = buildNoteTag(note.key || note.title).toLowerCase(); 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)) { if (task && task.tags.some(tag => tag.toLowerCase() === noteTag)) {
await updateTask(taskId, { tags: task.tags.filter(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 []; if (!note) return [];
const noteTagName = buildNoteTag(note.key || note.title).toLowerCase(); const noteTagName = buildNoteTag(note.key || note.title).toLowerCase();
return store.tasks.filter(t => { return store.tasks.filter(t => {
// Explicit relation if (t.deletedAt) return false;
if (note.tasks && note.tasks.includes(t.id)) return true;
if (t.noteRefs && t.noteRefs.some(ref => ref.key === note.key || ref.noteId === note.id)) return true; 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; if (t.tags && t.tags.some(tag => tag.toLowerCase() === noteTagName)) return true;
return false; 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 = const isAlreadyLinked =
(note.tasks && note.tasks.includes(t.id)) ||
t.noteRefs?.some(ref => ref.key === note.key || ref.noteId === note.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()); t.tags?.some(tag => tag.toLowerCase() === buildNoteTag(note.key || note.title).toLowerCase());
if (isAlreadyLinked) return false; if (isAlreadyLinked) return false;