Files
TasGrid/src/components/TaskDetail.tsx
T
tcardoza 5b2fefbca4
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m23s
load in optimization and error correction
2026-03-20 13:08:15 -05:00

793 lines
45 KiB
TypeScript

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 { 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";
import { StatusCircle } from "./StatusCircle";
import { calculateDateFromUrgency, calculateUrgencyFromDate } from "@/store";
import { Button } from "./ui/button";
import { TagPicker } from "./TagPicker";
import { Badge } from "./ui/badge";
import { PRIORITY_OPTIONS, URGENCY_OPTIONS, SIZE_OPTIONS, getStatusOptionsForTask } from "@/lib/constants";
import { store } from "@/store";
import { cn } from "@/lib/utils";
import { toast } from "solid-sonner";
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 { TextBubbleMenu } from "@/components/TextBubbleMenu";
import { TableBubbleMenu } from "@/components/TableBubbleMenu";
const TaskEditor = lazy(() => import("./TaskEditor").then(m => ({ default: m.TaskEditor })));
interface TaskDetailProps {
task: Task;
isOpen: boolean;
onClose: () => void;
}
export const TaskDetail: Component<TaskDetailProps> = (props) => {
const { resolvedTheme } = useTheme();
// Local state for immediate feedback, synced with props
const [title, setTitle] = createSignal(props.task.title);
const [editorInstance, setEditorInstance] = createSignal<any>(null);
// Sync input string for datetime-local
const [dateString, setDateString] = createSignal<string>("");
createEffect(() => {
setTitle(props.task.title);
// Format for datetime-local: yyyy-MM-ddThh:mm (local time)
const dateObj = new Date(props.task.dueDate);
const localIso = new Date(dateObj.getTime() - (dateObj.getTimezoneOffset() * 60000)).toISOString().slice(0, 16);
setDateString(localIso);
// Load content if missing and panel is open
if (props.isOpen && props.task.content === undefined) {
loadTaskContent(props.task.id);
}
});
const [trackedMentions, setTrackedMentions] = createSignal<Set<string>>(new Set());
createEffect(() => {
if (props.isOpen && props.task.content) {
const html = props.task.content;
const mentionRegex = /data-type="mention"[^>]*data-id="([^"]+)"/g;
let match;
const initial = new Set<string>();
while ((match = mentionRegex.exec(html)) !== null) {
initial.add(match[1]);
}
setTrackedMentions(initial);
}
});
let debounceTimer: number | undefined;
let pendingContent: string | null = null;
const updateTaskContent = (html: string) => {
pendingContent = html;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
if (pendingContent !== null) {
updateTask(props.task.id, { content: pendingContent });
// Sync mentions deletion
const mentionRegex = /data-type="mention"[^>]*data-id="([^"]+)"/g;
let match;
const currentMentions = new Set<string>();
while ((match = mentionRegex.exec(pendingContent)) !== null) {
currentMentions.add(match[1].toLowerCase());
}
const currentTags = props.task.tags || [];
let tagsChanged = false;
const newTags = currentTags.filter(t => {
const tagLower = t.toLowerCase();
const isTracked = [...trackedMentions()].some(tm => tm.toLowerCase() === tagLower);
if (isTracked && !currentMentions.has(tagLower)) {
tagsChanged = true;
// Also remove from tracked
setTrackedMentions(prev => {
const next = new Set(prev);
for (let p of next) {
if (p.toLowerCase() === tagLower) next.delete(p);
}
return next;
});
return false; // remove from tags
}
return true;
});
if (tagsChanged) {
updateTags(newTags);
}
pendingContent = null;
}
}, 1000);
};
// Sync activeTaskId for slash commands and run cleanup on unmount/change
createEffect(() => {
const currentTaskId = props.task.id;
onCleanup(() => {
// 1. Immediately flush any pending debounce updates for the outgoing task
if (debounceTimer) {
clearTimeout(debounceTimer);
if (pendingContent !== null) {
updateTask(currentTaskId, { content: pendingContent });
}
}
// 2. Clear pending state
debounceTimer = undefined;
pendingContent = null;
// 3. Trigger cleanup after a tiny delay so the updateTask above processes first
if (currentTaskId) {
setTimeout(() => {
cleanupTaskAttachments(currentTaskId).catch(console.error);
}, 100);
}
});
});
const updateTitle = (val: string) => {
setTitle(val);
updateTask(props.task.id, { title: val });
};
const updatePriority = (val: any) => {
const value = typeof val === 'object' ? val.value : val;
const p = parseInt(value);
if (!isNaN(p)) {
updateTask(props.task.id, { priority: p });
}
};
const updateStatus = (val: any) => {
const value = typeof val === 'object' ? val.value : val;
const s = parseInt(value);
if (!isNaN(s)) {
updateTask(props.task.id, { status: s });
}
};
const updateUrgency = (val: any) => {
const value = typeof val === 'object' ? val.value : val;
const u = parseInt(value);
if (!isNaN(u)) {
const newDate = calculateDateFromUrgency(u);
updateTask(props.task.id, { dueDate: newDate });
}
};
const updateTags = (tags: string[]) => {
const ctx = currentTaskContext();
const currentTags = props.task.tags || [];
let newTags = [...tags];
currentTags
.filter(tag => isCurrentUserContextTag(tag))
.forEach(tag => {
if (!newTags.some(existing => existing.toLowerCase() === tag.toLowerCase())) {
newTags.push(tag);
}
});
// If in a bucket view, ensure the bucket tag is preserved (it was hidden from the UI so it's missing from 'tags')
if (typeof ctx === 'object' && 'bucketId' in ctx) {
const bucketContext = store.contexts.find(context => context.id === ctx.bucketId);
const bucketTagName = buildShareTag(bucketContext?.displayName || ctx.name);
// Check case-insensitive existence
if (!newTags.some(t => t.toLowerCase() === bucketTagName.toLowerCase())) {
newTags.push(bucketTagName);
}
}
updateTask(props.task.id, { tags: newTags });
};
const onDateInputChange = (e: Event) => {
const val = (e.currentTarget as HTMLInputElement).value;
if (val) {
const iso = new Date(val).toISOString();
updateTask(props.task.id, { dueDate: iso });
}
};
// Calculate current urgency level for display
const currentUrgency = () => calculateUrgencyFromDate(props.task.dueDate).toString();
// 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));
});
const statusOptions = createMemo(() => getStatusOptionsForTask(props.task.recurrence));
const recurrenceProgress = createMemo(() => {
if (props.task.status !== 10) return null;
return getRecurrenceProgress(props.task.recurrence, props.task.updated, now());
});
return (
<Sheet open={props.isOpen} onOpenChange={(open) => {
if (!open) {
// Flash save before closing to avoid lockup
if (pendingContent !== null) {
updateTask(props.task.id, { content: pendingContent });
pendingContent = null;
}
props.onClose();
}
}}>
<SheetContent
hideCloseButton
onOpenAutoFocus={(e) => {
// Prevent auto-focus on mobile to avoid keyboard popup
if (window.innerWidth < 768) {
e.preventDefault();
}
}}
class="w-full sm:max-w-2xl p-0 gap-0 flex flex-col h-full bg-background border-l border-border shadow-2xl"
>
{/* Header Area */}
<div class="px-6 pt-6 pb-0 shrink-0">
<div class="flex items-start gap-4">
<div class="shrink-0 mt-0.5">
<Select<any>
value={(props.task.status ?? 0).toString()}
onChange={(val: string | null) => val && updateStatus(val)}
options={statusOptions()}
optionValue="value"
optionTextValue="label"
placeholder="Status"
itemComponent={(props: any) => <SelectItem item={props.item}>{props.item.rawValue.label}</SelectItem>}
>
<SelectTrigger
class="h-9 w-9 p-0 bg-transparent border-transparent hover:bg-muted/50 data-[expanded]:bg-muted/50 px-0 shadow-none focus:ring-0 shrink-0 overflow-hidden flex items-center justify-center [&>svg]:hidden"
onDoubleClick={(e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
updateStatus("10");
}}
>
<StatusCircle status={props.task.status ?? 0} size={28} recurrenceProgress={recurrenceProgress()} />
</SelectTrigger>
<SelectContent />
</Select>
</div>
<textarea
id="task-detail-title"
name="task-title"
value={title()}
onInput={(e) => {
updateTitle(e.currentTarget.value);
// Auto-resize
e.currentTarget.style.height = 'auto';
e.currentTarget.style.height = e.currentTarget.scrollHeight + 'px';
}}
ref={(el) => {
setTimeout(() => {
el.style.height = 'auto';
el.style.height = el.scrollHeight + 'px';
}, 0);
}}
rows={1}
class="text-xl sm:text-2xl font-bold bg-transparent border-none focus:outline-none focus:ring-0 w-full placeholder:text-muted-foreground/50 resize-none overflow-hidden leading-tight flex-1 min-w-0"
placeholder="Untitled Task"
/>
</div>
{/* Properties Bar */}
<div class="flex flex-wrap items-center gap-2 sm:gap-4 mt-2 text-sm">
{/* Priority */}
<div class="flex items-center gap-1 group shrink-0">
<span class="text-[0.625rem] uppercase font-bold tracking-wider text-muted-foreground/70 hidden sm:inline">Priority</span>
<Select<any>
value={props.task.priority.toString()}
onChange={(val: string | null) => val && updatePriority(val)}
options={PRIORITY_OPTIONS}
optionValue="value"
optionTextValue="label"
placeholder="Priority"
itemComponent={(props: any) => <SelectItem item={props.item}>{props.item.rawValue.label}</SelectItem>}
>
<SelectTrigger class="h-8 min-w-[40px] max-w-[180px] bg-transparent border-transparent hover:bg-muted/50 px-1 sm:px-2 shadow-none focus:ring-0 shrink-0 overflow-hidden">
<div class="flex items-center gap-1 sm:gap-1.5 text-foreground font-medium truncate">
<ArrowUpCircle size={14} class="text-primary opacity-70 group-hover:opacity-100 transition-opacity shrink-0" />
<span class="text-xs sm:text-sm truncate">
{PRIORITY_OPTIONS.find(o => o.value === props.task.priority.toString())?.label || props.task.priority}
</span>
</div>
</SelectTrigger>
<SelectContent />
</Select>
</div>
{/* Urgency */}
<div class="flex items-center gap-1 group shrink-0">
<span class="text-[0.625rem] uppercase font-bold tracking-wider text-muted-foreground/70 hidden sm:inline">Urgency</span>
<Select<any>
value={currentUrgency()}
onChange={(val: string | null) => val && updateUrgency(val)}
options={URGENCY_OPTIONS}
optionValue="value"
optionTextValue="label"
placeholder="Urgency"
itemComponent={(props: any) => <SelectItem item={props.item}>{props.item.rawValue.label}</SelectItem>}
>
<SelectTrigger class="h-8 min-w-[40px] max-w-[180px] bg-transparent border-transparent hover:bg-muted/50 px-1 sm:px-2 shadow-none focus:ring-0 shrink-0 overflow-hidden">
<div class="flex items-center gap-1 sm:gap-1.5 text-foreground font-medium truncate">
<Clock size={14} class="text-primary opacity-70 group-hover:opacity-100 transition-opacity shrink-0" />
<span class="text-xs sm:text-sm truncate">
{URGENCY_OPTIONS.find(o => o.value === currentUrgency())?.label || currentUrgency()}
</span>
</div>
</SelectTrigger>
<SelectContent />
</Select>
</div>
{/* Size */}
<div class="flex items-center gap-1 group shrink-0">
<span class="text-[0.625rem] uppercase font-bold tracking-wider text-muted-foreground/70 hidden sm:inline">Size</span>
<Select<any>
value={(props.task.size ?? 3).toString()}
onChange={(val: any) => {
const value = typeof val === 'object' ? val?.value : val;
if (value) {
const s = parseInt(value);
if (!isNaN(s)) updateTask(props.task.id, { size: s });
}
}}
options={SIZE_OPTIONS}
optionValue="value"
optionTextValue="label"
placeholder="Size"
itemComponent={(props: any) => <SelectItem item={props.item}>{props.item.rawValue.label}</SelectItem>}
>
<SelectTrigger class="h-8 min-w-[40px] max-w-[180px] bg-transparent border-transparent hover:bg-muted/50 px-1 sm:px-2 shadow-none focus:ring-0 shrink-0 overflow-hidden">
<div class="flex items-center gap-1 sm:gap-1.5 text-foreground font-medium truncate">
<Gauge size={14} class="text-primary opacity-70 group-hover:opacity-100 transition-opacity shrink-0" />
<span class="text-xs sm:text-sm truncate">
{SIZE_OPTIONS.find(o => o.value === (props.task.size ?? 3).toString())?.label || props.task.size}
</span>
</div>
</SelectTrigger>
<SelectContent />
</Select>
</div>
{/* Favorite */}
<div class="flex items-center gap-1 group shrink-0">
<span class="text-[0.625rem] uppercase font-bold tracking-wider text-muted-foreground/70 hidden sm:inline"></span>
<Button
variant="ghost"
size="sm"
class={cn("h-8 px-1 sm:px-2 text-[0.625rem] font-bold uppercase tracking-wider transition-colors shrink-0", props.task.tags?.includes(getFavoriteTag()) ? "text-amber-500 hover:bg-amber-500/10" : "text-muted-foreground hover:bg-muted/50 hover:text-foreground")}
onClick={() => {
const favTag = getFavoriteTag();
const currentTags = props.task.tags || [];
const isFav = currentTags.includes(favTag);
const newTags = isFav ? currentTags.filter(t => t !== favTag) : [...currentTags, favTag];
updateTags(newTags);
}}
>
<Star size={14} class={cn("opacity-70 group-hover:opacity-100 transition-opacity shrink-0", props.task.tags?.includes(getFavoriteTag()) && "fill-amber-500")} />
<span class="hidden sm:inline sm:ml-1.5">{props.task.tags?.includes(getFavoriteTag()) ? "Starred" : "Star"}</span>
</Button>
</div>
{/* Commands Shortcut */}
<div class="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="sm"
class="h-8 px-1 sm:px-2 text-[0.625rem] font-bold uppercase tracking-wider hover:bg-muted/50 flex items-center gap-1 sm:gap-1.5 text-muted-foreground hover:text-foreground transition-colors"
onClick={() => {
const instance = editorInstance();
if (instance) {
instance.chain().focus().insertContent("/").run();
}
}}
>
<Type size={14} class="opacity-70" />
<span class="hidden sm:inline">Commands</span>
</Button>
</div>
{/* Unified Media Upload Shortcut */}
<div class="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="sm"
class="h-8 px-1 sm:px-2 text-[0.625rem] font-bold uppercase tracking-wider hover:bg-muted/50 flex items-center gap-1 sm:gap-1.5 text-muted-foreground hover:text-foreground transition-colors"
onClick={() => {
const instance = editorInstance();
if (instance) {
instance.chain().focus().uploadMedia().run();
}
}}
>
<FileText size={14} class="opacity-70" />
<span class="hidden sm:inline">Upload</span>
</Button>
</div>
</div>
{/* Metadata Row (Dates/Timestamps) */}
<div class="flex flex-wrap items-center gap-x-6 gap-y-2 mt-4 px-1">
<MetadataItem
label="Due"
date={new Date(props.task.dueDate).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
time={new Date(props.task.dueDate).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })}
icon={<Calendar size={12} />}
editable={true}
onClick={() => (document.getElementById('task-detail-date') as HTMLInputElement)?.showPicker?.()}
>
<input
id="task-detail-date"
name="due-date"
type="datetime-local"
value={dateString()}
onInput={onDateInputChange}
class="absolute inset-0 opacity-0 cursor-pointer w-full h-full"
/>
</MetadataItem>
<MetadataItem
label="Created"
date={new Date(props.task.created).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
time={new Date(props.task.created).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })}
/>
<MetadataItem
label="Updated"
date={new Date(props.task.updated).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
time={new Date(props.task.updated).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })}
/>
</div>
<div class="h-px w-full bg-border mt-5" />
<Show when={editorInstance()}>
{(editor: any) => (
<div class="flex flex-col pt-1 pb-1 relative z-30">
<TextBubbleMenu editor={editor()} />
<TableBubbleMenu editor={editor()} />
</div>
)}
</Show>
</div>
{/* Content Area */}
<div class="flex-1 overflow-y-auto px-6 pb-20 pt-4 flex flex-col">
<Suspense fallback={<div class="h-32 animate-pulse bg-muted/20 rounded-lg" />}>
<TaskEditor
content={props.task.content}
onUpdate={updateTaskContent}
onEditorReady={setEditorInstance}
onNoteOpen={(noteId) => {
props.onClose();
window.dispatchEvent(new CustomEvent('open-note', { detail: { noteId } }));
}}
onUserMention={(name) => {
const tagName = '@' + name;
setTrackedMentions(prev => new Set([...prev, tagName]));
const currentTags = props.task.tags || [];
if (!currentTags.some(t => t.toLowerCase() === tagName.toLowerCase())) {
updateTags([...currentTags, tagName]);
toast.success(`Added tag ${tagName} to share task.`);
}
}}
/>
</Suspense>
</div>
{/* Sticky Tags Area */}
<div class="px-6 py-3 border-t border-border/50 shrink-0 bg-card/80 backdrop-blur-sm z-10 w-full overflow-hidden">
<div class="flex flex-wrap items-center gap-3">
<span class="text-[0.625rem] uppercase font-bold tracking-wider text-muted-foreground/60 shrink-0">Tags</span>
<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()}
onTagsChange={updateTags}
/>
<div class="flex flex-nowrap items-center gap-1.5 shrink-0 pr-2">
<For each={visibleTags()}>
{(tag) => (
<Badge
variant="secondary"
class="h-7 px-2 text-xs gap-1.5 bg-muted/30 border-border/50 hover:bg-destructive/10 hover:border-destructive/30 cursor-pointer group/tag transition-all whitespace-nowrap shrink-0"
style={{
"background-color": (() => {
const def = store.tagDefinitions.find(d => d.name === tag);
const color = getThemeAdjustedColor(def?.color, def?.theme, resolvedTheme());
return color ? `${color}15` : undefined;
})(),
"border-color": (() => {
const def = store.tagDefinitions.find(d => d.name === tag);
const color = getThemeAdjustedColor(def?.color, def?.theme, resolvedTheme());
return color ? `${color}30` : undefined;
})(),
"color": (() => {
const def = store.tagDefinitions.find(d => d.name === tag);
return getThemeAdjustedColor(def?.color, def?.theme, resolvedTheme()) || "inherit";
})()
}}
onClick={() => updateTags(visibleTags().filter(t => t !== tag))}
>
<div
class="w-1.5 h-1.5 rounded-full"
style={{
"background-color": (store.tagDefinitions.find(d => d.name === tag)?.value ?? 5) > 5 ? "#22c55e" : (store.tagDefinitions.find(d => d.name === tag)?.value ?? 5) < 5 ? "#ef4444" : "#94a3b8"
}}
/>
{tag}
<X size={12} class="opacity-0 group-hover/tag:opacity-50 transition-opacity" />
</Badge>
)}
</For>
</div>
</div>
</div>
</div>
{/* Sticky Footer Actions */}
<div class="px-6 py-4 border-t border-border/50 flex items-center justify-between shrink-0 bg-background/80 backdrop-blur-sm z-20 w-full relative">
{/* Delete button (persistent) */}
<Button
variant="ghost"
size="sm"
class="h-9 px-3 gap-2 hover:bg-destructive/10 hover:text-destructive text-muted-foreground transition-all rounded-xl border border-border/40"
onClick={() => {
const taskId = props.task.id;
removeTask(taskId);
props.onClose();
toast.info("Task moved to trash", {
action: {
label: "Undo",
onClick: () => restoreTask(taskId)
}
});
}}
>
<Trash2 size={16} />
<span class="text-xs font-bold uppercase tracking-wider">Delete</span>
</Button>
<div class="flex items-center gap-2">
{/* More Options Menu */}
<Popover modal={true}>
<PopoverTrigger as={Button} variant="ghost" size="sm" class="h-9 w-9 p-0 rounded-xl border border-border/40 hover:bg-muted text-muted-foreground hover:text-foreground">
<MoreHorizontal size={18} />
</PopoverTrigger>
<PopoverContent class="w-72 p-0 overflow-hidden" align="end">
<div class="flex flex-col">
<div class="p-2 border-b border-border/50">
<div class="text-[0.625rem] font-bold uppercase tracking-wider text-muted-foreground/70 px-2 py-1">Recurrence</div>
<div class="flex flex-col gap-2 p-2 pt-0">
<Select
value={props.task.recurrence?.type || "none"}
onChange={(val: string | null) => {
if (!val || val === "none") {
updateTask(props.task.id, { recurrence: null as any });
} else {
updateTask(props.task.id, {
recurrence: {
type: val as any,
days: val === 'weekly' ? [] : undefined,
dayOfMonth: val === 'monthly' ? 1 : undefined
}
});
}
}}
options={["none", "daily", "weekly", "monthly"]}
placeholder="None"
itemComponent={(props: any) => (
<SelectItem item={props.item} class="capitalize">
{props.item.rawValue}
</SelectItem>
)}
>
<SelectTrigger class="h-8 text-xs capitalize">
<SelectValue<string>>{state => state.selectedOption() || "None"}</SelectValue>
</SelectTrigger>
<SelectContent />
</Select>
{/* Weekly Options */}
{props.task.recurrence?.type === 'weekly' && (
<div class="flex flex-wrap gap-1 mt-1 justify-between">
<For each={['S', 'M', 'T', 'W', 'T', 'F', 'S']}>
{(day, index) => (
<button
class={cn(
"w-7 h-7 rounded-full text-[0.625rem] font-bold flex items-center justify-center transition-colors shadow-sm",
props.task.recurrence?.days?.includes(index())
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-muted/80"
)}
onClick={() => {
const currentDays = props.task.recurrence?.days || [];
const newDays = currentDays.includes(index())
? currentDays.filter(d => d !== index())
: [...currentDays, index()].sort();
updateTask(props.task.id, {
recurrence: { ...props.task.recurrence!, days: newDays }
});
}}
>
{day}
</button>
)}
</For>
</div>
)}
{/* Monthly Options */}
{props.task.recurrence?.type === 'monthly' && (
<div class="flex items-center gap-2 mt-1">
<span class="text-xs text-muted-foreground">Day of month:</span>
<input
type="number"
min="1"
max="31"
class="h-7 w-16 rounded-md border border-input bg-background px-2 text-xs"
value={props.task.recurrence?.dayOfMonth || 1}
onInput={(e) => {
const val = parseInt(e.currentTarget.value);
if (!isNaN(val) && val >= 1 && val <= 31) {
updateTask(props.task.id, {
recurrence: { ...props.task.recurrence!, dayOfMonth: val }
});
}
}}
/>
</div>
)}
</div>
</div>
<div class="p-2">
<Button
variant="ghost"
size="sm"
class="w-full justify-start h-8 px-2 gap-2 text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
onClick={async () => {
const name = prompt("Enter a name for this template:");
if (name) {
await saveTaskAsTemplate(props.task.id, name);
toast.success(`Template "${name}" created`);
}
}}
>
<Copy size={14} />
<span class="text-xs">Create Template from Task</span>
</Button>
</div>
{/* Sharing Section */}
<div class="p-2 border-t border-border/50">
<div class="text-[0.625rem] font-bold uppercase tracking-wider text-muted-foreground/70 px-2 py-1 mb-1">Sharing</div>
<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.
</p>
<Show
when={props.task.shareRefs.length > 0}
fallback={<p class="text-[0.6875rem] text-muted-foreground/70">No shared contexts on this task.</p>}
>
<div class="flex flex-wrap gap-1.5">
<For each={props.task.shareRefs.filter(ref => {
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");
return (
<Badge variant="secondary" class="h-6 px-2 text-[0.625rem] gap-1 bg-muted/40 border-border/40">
<span>{label}</span>
<span class="uppercase text-[0.5rem] tracking-wider text-muted-foreground">{policy}</span>
</Badge>
);
}}
</For>
</div>
</Show>
</div>
</div>
</div>
</PopoverContent>
</Popover>
<Button
variant="ghost"
size="sm"
class="md:hidden h-9 px-3 gap-2 hover:bg-muted text-muted-foreground transition-all rounded-xl border border-border/40"
onClick={() => props.onClose()}
>
<X size={16} />
<span class="text-xs font-bold uppercase tracking-wider">Close</span>
</Button>
</div>
</div>
</SheetContent>
</Sheet>
);
};
// Reusable Metadata Item with slide transition
const MetadataItem: Component<{
label: string,
date: string,
time: string,
icon?: any,
editable?: boolean,
urgency?: string,
children?: any,
onClick?: () => void
}> = (props) => {
const [isToggled, setIsToggled] = createSignal(false);
return (
<div
class="group relative flex flex-col gap-0.5 cursor-pointer select-none"
onMouseEnter={() => setIsToggled(true)}
onMouseLeave={() => setIsToggled(false)}
onClick={() => {
setIsToggled(!isToggled());
props.onClick?.();
}}
>
<span class="text-[9px] font-bold uppercase tracking-wider text-muted-foreground/60 transition-colors group-hover:text-primary/70">
{props.label}
</span>
<div class="h-4 overflow-hidden relative min-w-[100px]">
<div class={cn(
"flex flex-col transition-transform duration-500 cubic-bezier(0.4, 0, 0.2, 1)",
isToggled() ? "-translate-y-4" : "translate-y-0"
)}>
{/* Layer 1: Date */}
<div class="h-4 flex items-center gap-1.5 text-[0.6875rem] font-medium text-muted-foreground/80 whitespace-nowrap">
{props.icon}
{props.date}
</div>
{/* Layer 2: Time */}
<div class="h-4 flex items-center gap-2 text-[0.6875rem] font-bold text-foreground whitespace-nowrap">
<Clock size={11} class="text-primary/60 shrink-0" />
<span>{props.time}</span>
</div>
</div>
</div>
{props.children}
</div>
);
};