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">
<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>