Compare commits

..

2 Commits

Author SHA1 Message Date
tcardoza 0f4d374658 handoff and collab modes support per task for @user @bucket and #note
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m15s
2026-03-23 17:59:44 -05:00
tcardoza 609163f933 notes better 2026-03-23 12:56:56 -05:00
7 changed files with 297 additions and 202 deletions
+1 -1
View File
@@ -223,7 +223,7 @@ export const ImportTool: Component = () => {
<div class="space-y-1">
<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">
<TagPicker selectedTags={bulkTags()} onTagsChange={setBulkTags} />
<TagPicker selectedTags={bulkTags()} onTagsChange={(tags) => setBulkTags(tags)} />
<For each={bulkTags()}>
{(tag) => (
<Badge
+26 -5
View File
@@ -12,6 +12,7 @@ import { getThemeAdjustedColor } from "@/lib/colors";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { toast } from "solid-sonner";
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 })));
@@ -27,6 +28,7 @@ const [input, setInput] = createSignal("");
const [priority, setPriority] = createSignal<string>("");
const [urgency, setUrgency] = createSignal<string>("");
const [tags, setTags] = createSignal<string[]>([]);
const [tagShareModes, setTagShareModes] = createSignal<Record<string, "handoff" | "collaborative">>({});
const [description, setDescription] = createSignal("");
const [size, setSize] = createSignal<string>("3");
const [dateString, setDateString] = createSignal<string>("");
@@ -39,6 +41,7 @@ const resetQuickEntryState = () => {
setPriority("");
setUrgency("");
setTags([]);
setTagShareModes({});
setDescription("");
setSize("3");
setDateString("");
@@ -153,6 +156,7 @@ export const QuickEntryForm: Component<QuickEntryFormProps> = (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<QuickEntryFormProps> = (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<QuickEntryFormProps> = (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<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-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))}>
{(tag) => (
<Badge
@@ -435,7 +449,14 @@ export const QuickEntryForm: Component<QuickEntryFormProps> = (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;
});
}}
>
<div
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 { store, upsertTagDefinition, isCurrentUserContextTag, setContextPolicy, setNoteContextPolicy, getNoteSharePolicy } from "@/store";
import { store, upsertTagDefinition, isCurrentUserContextTag, getNoteSharePolicy } from "@/store";
import { useTheme } from "./ThemeProvider";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
@@ -10,7 +10,8 @@ import { cn } from "@/lib/utils";
interface TagPickerProps {
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) => {
@@ -75,6 +76,11 @@ export const TagPicker: Component<TagPickerProps> = (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<TagPickerProps> = (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<TagPickerProps> = (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<TagPickerProps> = (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<TagPickerProps> = (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("");
+5 -16
View File
@@ -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)
<span class="text-[0.625rem] font-medium">P{props.task.priority}</span>
</div>
{/* 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 (
<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 { 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<TaskDetailProps> = (props) => {
}
};
const updateTags = (tags: string[]) => {
const updateTags = (tags: string[], shareModeOverrides?: Record<string, "handoff" | "collaborative">) => {
const ctx = currentTaskContext();
const currentTags = props.task.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) => {
@@ -207,16 +216,7 @@ export const TaskDetail: Component<TaskDetailProps> = (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<TaskDetailProps> = (props) => {
<div class="flex flex-nowrap overflow-x-auto no-scrollbar items-center gap-1.5 min-w-0 flex-1 pb-1">
<TagPicker
selectedTags={visibleTags()}
getShareModeForTag={(tag) => getTaskShareModeForTag(props.task, tag)}
onTagsChange={updateTags}
/>
<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">
<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
<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>
<Show
when={props.task.shareRefs.length > 0}
@@ -702,13 +703,25 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
>
<div class="flex flex-wrap gap-1.5">
<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);
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 (
<Badge variant="secondary" class="h-6 px-2 text-[0.625rem] gap-1 bg-muted/40 border-border/40">
<span>{label}</span>
+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);
+33 -18
View File
@@ -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";
@@ -81,7 +81,7 @@ export const NotepadView: Component<{
});
createEffect(() => {
const note = activeNote();
const note = taskLinkOwnerNote();
if (!note) return;
const linkageSignature = `${note.id}|${note.key || note.title}|${(note.tasks || []).join(",")}|${note.updated}`;
void linkageSignature;
@@ -94,6 +94,22 @@ export const NotepadView: Component<{
return store.notes.find(n => n.id === id) || null;
});
const getImmediateParentNote = (note: Note | null): Note | null => {
if (!note) return null;
const parentTag = note.tags?.find(t => t.startsWith("child-of-"));
if (!parentTag) return null;
const parentId = parentTag.replace("child-of-", "");
return store.notes.find(n => n.id === parentId) || null;
};
const taskLinkOwnerNote = createMemo(() => {
const note = activeNote();
if (!note) return null;
return getImmediateParentNote(note) || note;
});
const isChildNote = createMemo(() => !!getImmediateParentNote(activeNote()));
const handleUpdateNote = async (id: string, data: Partial<Note>) => {
await updateNote(id, data);
};
@@ -141,12 +157,10 @@ export const NotepadView: Component<{
};
const handleLinkTask = async (taskId: string) => {
const note = activeNote();
const note = taskLinkOwnerNote();
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] });
}
@@ -155,12 +169,11 @@ export const NotepadView: Component<{
};
const handleUnlinkTask = async (taskId: string) => {
const note = activeNote();
if (isChildNote()) return;
const note = taskLinkOwnerNote();
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) });
}
@@ -168,20 +181,22 @@ export const NotepadView: Component<{
// Linked tasks calculation
const linkedTasks = createMemo(() => {
const note = activeNote();
const note = taskLinkOwnerNote();
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);
});
});
const unlinkedTasks = createMemo(() => {
const note = activeNote();
const note = taskLinkOwnerNote();
if (!note) return [];
const q = linkSearchQuery().toLowerCase();
@@ -204,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;
@@ -271,6 +285,10 @@ export const NotepadView: Component<{
};
const openQuickEntry = () => {
const ownerNote = taskLinkOwnerNote();
if (ownerNote) {
(window as any)._activeNoteId = ownerNote.id;
}
window.dispatchEvent(new KeyboardEvent('keydown', { metaKey: true, key: 'k' }));
};
@@ -390,9 +408,6 @@ export const NotepadView: Component<{
return s.startsWith('<div data-type="file-viewer-wrapper"') ||
(s.startsWith('<div data-type="file-attachment"') && s.includes('data-filename') && s.toLowerCase().includes('.pdf'));
});
const isChildNote = createMemo(() => {
return (note()?.tags || []).some(t => t.startsWith("child-of-"));
});
return (
<>
@@ -744,14 +759,14 @@ export const NotepadView: Component<{
<div class="text-center text-xs text-muted-foreground p-6 bg-muted/10 border border-dashed border-border/50 rounded-xl flex flex-col items-center gap-2">
<LinkIcon size={24} class="opacity-20" />
<p>No tasks linked yet.</p>
<p class="text-[0.6rem] opacity-70 max-w-[200px]">Link an existing task, create a new one, or add the tag #{note().title} to a task.</p>
<p class="text-[0.6rem] opacity-70 max-w-[200px]">Link an existing task, create a new one, or add the tag #{taskLinkOwnerNote()?.title || note().title} to a task.</p>
</div>
}>
{(task) => (
<div class="relative group">
<TaskCard task={task} />
{/* If explicitly linked, allow unlinking. If linked by tag, they should remove the tag from the task, which we can't easily do here unless we implement it. */}
<Show when={note().tasks && note().tasks.includes(task.id)}>
<Show when={!isChildNote() && taskLinkOwnerNote()?.tasks?.includes(task.id)}>
<button
class="absolute -top-2 -right-2 bg-destructive text-destructive-foreground rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity shadow-sm hover:scale-110"
onClick={(e) => {