Compare commits
4 Commits
9ca490075b
...
0554b6bb6b
| Author | SHA1 | Date | |
|---|---|---|---|
| 0554b6bb6b | |||
| 2c83b91e66 | |||
| ce58ee8fc5 | |||
| d6864a9443 |
@@ -0,0 +1,243 @@
|
||||
import { type Component, createSignal, For, Show } from "solid-js";
|
||||
import { Search, X, Hash, ChevronDown, ArrowUpCircle, Clock } from "lucide-solid";
|
||||
import { store, setFilter, clearFilter } from "@/store";
|
||||
import { Button } from "./ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "./ui/select";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
|
||||
export const FilterBar: Component = () => {
|
||||
const [isExpanded, setIsExpanded] = createSignal(false);
|
||||
|
||||
const hasActiveFilters = () => {
|
||||
const f = store.filter;
|
||||
return f.query !== "" || f.tags.length > 0 || f.priorityMin !== 1 || f.priorityMax !== 10 || f.urgencyMin !== 1 || f.urgencyMax !== 10;
|
||||
};
|
||||
|
||||
const allTags = () => Object.keys(store.tagDefinitions || {}).sort();
|
||||
|
||||
const FilterControls: Component<{ class?: string }> = (props) => (
|
||||
<div class={cn("flex flex-col md:flex-row items-stretch md:items-center gap-3", props.class)}>
|
||||
{/* Main Search */}
|
||||
<div class="flex items-center gap-2 px-2 border-b md:border-b-0 md:border-r border-border/50 pb-2 md:pb-0 min-w-0">
|
||||
<Search size={16} class="text-muted-foreground shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search tasks..."
|
||||
value={store.filter.query}
|
||||
onInput={(e) => setFilter({ query: e.currentTarget.value })}
|
||||
class="bg-transparent border-none focus:ring-0 text-sm flex-1 md:w-40 h-8 outline-none placeholder:text-muted-foreground/50 min-w-0"
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Priority Range */}
|
||||
<div class="flex items-center gap-2 px-2 border-b md:border-b-0 md:border-r border-border/50 pb-2 md:pb-0">
|
||||
<span class="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Priority</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<Select
|
||||
value={store.filter.priorityMin.toString()}
|
||||
onChange={(val) => val && setFilter({ priorityMin: parseInt(val) })}
|
||||
options={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
|
||||
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
|
||||
>
|
||||
<SelectTrigger class="h-7 w-12 p-0 bg-transparent border-none shadow-none focus:ring-0 text-xs font-bold text-foreground flex items-center gap-1">
|
||||
<ArrowUpCircle size={12} class="text-muted-foreground/70" />
|
||||
<span>{store.filter.priorityMin}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent />
|
||||
</Select>
|
||||
<span class="text-muted-foreground/50">-</span>
|
||||
<Select
|
||||
value={store.filter.priorityMax.toString()}
|
||||
onChange={(val) => val && setFilter({ priorityMax: parseInt(val) })}
|
||||
options={["10", "9", "8", "7", "6", "5", "4", "3", "2", "1"]}
|
||||
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
|
||||
>
|
||||
<SelectTrigger class="h-7 w-8 p-0 bg-transparent border-none shadow-none focus:ring-0 text-xs font-bold text-foreground justify-center">
|
||||
<span>{store.filter.priorityMax}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent />
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Urgency Range */}
|
||||
<div class="flex items-center gap-2 px-2 border-b md:border-b-0 md:border-r border-border/50 pb-2 md:pb-0">
|
||||
<span class="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Urgency</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<Select
|
||||
value={store.filter.urgencyMin.toString()}
|
||||
onChange={(val) => val && setFilter({ urgencyMin: parseInt(val) })}
|
||||
options={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
|
||||
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
|
||||
>
|
||||
<SelectTrigger class="h-7 w-12 p-0 bg-transparent border-none shadow-none focus:ring-0 text-xs font-bold text-foreground flex items-center gap-1">
|
||||
<Clock size={12} class="text-muted-foreground/70" />
|
||||
<span>{store.filter.urgencyMin}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent />
|
||||
</Select>
|
||||
<span class="text-muted-foreground/50">-</span>
|
||||
<Select
|
||||
value={store.filter.urgencyMax.toString()}
|
||||
onChange={(val) => val && setFilter({ urgencyMax: parseInt(val) })}
|
||||
options={["10", "9", "8", "7", "6", "5", "4", "3", "2", "1"]}
|
||||
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
|
||||
>
|
||||
<SelectTrigger class="h-7 w-8 p-0 bg-transparent border-none shadow-none focus:ring-0 text-xs font-bold text-foreground justify-center">
|
||||
<span>{store.filter.urgencyMax}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent />
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div class="px-2 flex items-center gap-2 min-w-[120px]">
|
||||
<Hash size={14} class="text-muted-foreground" />
|
||||
<div class="flex flex-wrap gap-1 max-w-[200px]">
|
||||
<Show when={store.filter.tags.length === 0}>
|
||||
<Select
|
||||
value=""
|
||||
onChange={(val) => {
|
||||
if (val && !store.filter.tags.includes(val)) {
|
||||
setFilter({ tags: [...store.filter.tags, val] });
|
||||
}
|
||||
}}
|
||||
options={allTags()}
|
||||
placeholder="Add Tag..."
|
||||
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
|
||||
>
|
||||
<SelectTrigger class="h-7 p-0 bg-transparent border-none shadow-none focus:ring-0 text-[10px] italic text-muted-foreground">
|
||||
<span>Add Tag...</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent />
|
||||
</Select>
|
||||
</Show>
|
||||
<For each={store.filter.tags}>
|
||||
{(tag) => (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
class="h-5 px-1 text-[10px] gap-0.5 cursor-pointer hover:bg-destructive/20 border-border/50"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setFilter({ tags: store.filter.tags.filter(t => t !== tag) });
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
<X size={8} />
|
||||
</Badge>
|
||||
)}
|
||||
</For>
|
||||
<Show when={store.filter.tags.length > 0}>
|
||||
<Select
|
||||
value=""
|
||||
onChange={(val) => {
|
||||
if (val && !store.filter.tags.includes(val)) {
|
||||
setFilter({ tags: [...store.filter.tags, val] });
|
||||
}
|
||||
}}
|
||||
options={allTags()}
|
||||
placeholder="+"
|
||||
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
|
||||
>
|
||||
<SelectTrigger class="h-5 w-5 p-0 bg-muted/50 border-none shadow-none focus:ring-0 text-[10px] rounded-full flex items-center justify-center">
|
||||
<span>+</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent />
|
||||
</Select>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions for Desktop */}
|
||||
<div class="hidden md:flex items-center gap-1 pl-2 ml-auto">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||
onClick={clearFilter}
|
||||
title="Clear all filters"
|
||||
>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
>
|
||||
<ChevronDown size={16} class="rotate-90" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Clear All for Mobile */}
|
||||
<div class="flex md:hidden items-center pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="w-full text-xs h-8 gap-2"
|
||||
onClick={clearFilter}
|
||||
>
|
||||
<X size={14} />
|
||||
Clear All Filters
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div class="flex items-center gap-2">
|
||||
{/* Desktop View: Inline expansion */}
|
||||
<div class="hidden md:flex items-center">
|
||||
{!isExpanded() ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={cn(
|
||||
"h-9 px-3 gap-2 text-muted-foreground hover:text-foreground transition-all duration-300",
|
||||
hasActiveFilters() && "text-primary bg-primary/10 hover:bg-primary/20"
|
||||
)}
|
||||
onClick={() => setIsExpanded(true)}
|
||||
>
|
||||
<Search size={18} />
|
||||
<span class="text-sm font-medium">Filter</span>
|
||||
{hasActiveFilters() && (
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-primary animate-pulse" />
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<div class="bg-card border border-border/50 p-2 rounded-xl shadow-lg animate-in fade-in slide-in-from-top-4 duration-300 w-auto overflow-hidden">
|
||||
<FilterControls />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile View: Popover */}
|
||||
<div class="flex md:hidden items-center">
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
as={Button}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={cn(
|
||||
"h-9 px-3 gap-2 text-muted-foreground hover:text-foreground transition-all duration-300",
|
||||
hasActiveFilters() && "text-primary bg-primary/10 hover:bg-primary/20"
|
||||
)}
|
||||
>
|
||||
<Search size={18} />
|
||||
<span class="text-sm font-medium">Filter</span>
|
||||
{hasActiveFilters() && (
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-primary animate-pulse" />
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-[calc(100vw-2rem)] sm:w-96 p-4">
|
||||
<FilterControls class="w-full" />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -7,6 +7,7 @@ const TaskDetail = lazy(() => import("./TaskDetail").then(m => ({ default: m.Tas
|
||||
const QuickEntry = lazy(() => import("./QuickEntry").then(m => ({ default: m.QuickEntry })));
|
||||
import { Button } from "./ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { FilterBar } from "./FilterBar";
|
||||
|
||||
interface LayoutProps {
|
||||
children: JSX.Element;
|
||||
@@ -32,7 +33,7 @@ export const Layout: Component<LayoutProps> = (props) => {
|
||||
const activeTask = () => store.tasks.find(t => t.id === activeTaskId());
|
||||
|
||||
return (
|
||||
<div class="flex h-screen w-full bg-background overflow-hidden relative font-sans antialiased text-foreground">
|
||||
<div class="flex h-screen w-full min-w-0 bg-background overflow-hidden relative font-sans antialiased text-foreground">
|
||||
<Sidebar
|
||||
currentView={props.currentView}
|
||||
setView={props.setView}
|
||||
@@ -62,8 +63,14 @@ export const Layout: Component<LayoutProps> = (props) => {
|
||||
)}
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main class="flex-1 overflow-y-auto overflow-x-hidden p-4 sm:p-6 lg:p-8 pb-32 sm:pb-6">
|
||||
<div class="max-w-5xl mx-auto w-full">
|
||||
<main class="flex-1 min-w-0 overflow-y-auto overflow-x-hidden pb-32 sm:pb-6 relative scroll-smooth">
|
||||
<div class="w-full max-w-screen-2xl mx-auto p-3 sm:p-6 lg:p-10 space-y-6 sm:space-y-8">
|
||||
<Show when={props.currentView.toLowerCase() !== "settings"}>
|
||||
<div class="sticky top-0 z-40 flex items-center justify-end py-2 -mx-3 px-3 md:relative md:top-auto md:z-auto md:py-0 md:mx-0 md:px-0 bg-background/80 backdrop-blur-md md:bg-transparent md:backdrop-blur-none transition-all duration-300">
|
||||
<FilterBar />
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{props.children}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
+144
-33
@@ -1,15 +1,27 @@
|
||||
import { type Component, createSignal, createEffect, onCleanup, onMount } from "solid-js";
|
||||
import { type Component, createSignal, createEffect, onCleanup, onMount, For } from "solid-js";
|
||||
import { Search, Command, Clock, ArrowUpCircle, Plus } from "lucide-solid";
|
||||
import { addTask, calculateDateFromUrgency, calculateUrgencyFromDate } from "@/store";
|
||||
import { addTask, calculateDateFromUrgency, calculateUrgencyFromDate, store, upsertMultipleTagDefinitions } from "@/store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TextField, TextFieldInput } from "@/components/ui/textfield";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
|
||||
import { TagPicker } from "@/components/TagPicker";
|
||||
import { lazy, Suspense } from "solid-js";
|
||||
|
||||
const TaskEditor = lazy(() => import("./TaskEditor").then(m => ({ default: m.TaskEditor })));
|
||||
|
||||
export const QuickEntry: Component = () => {
|
||||
// Diff tracking for reactive tags
|
||||
let lastParsedTags: string[] = [];
|
||||
|
||||
const [isOpen, setIsOpen] = createSignal(false);
|
||||
const [input, setInput] = createSignal("");
|
||||
const [priority, setPriority] = createSignal<string>("5");
|
||||
const [urgency, setUrgency] = createSignal<string>("5");
|
||||
const [tags, setTags] = createSignal<string[]>([]);
|
||||
const [description, setDescription] = createSignal("");
|
||||
const [editorInstance, setEditorInstance] = createSignal<any>(null);
|
||||
|
||||
// Reactive shorthand parsing
|
||||
createEffect(() => {
|
||||
@@ -28,6 +40,37 @@ export const QuickEntry: Component = () => {
|
||||
onUrgencySimpleChange(val);
|
||||
}
|
||||
}
|
||||
|
||||
// Tag Shortcut Parsing: /t [tag name]:[optional value]
|
||||
// This is reactive. To avoid "prefix storm", only add if terminated by : or /
|
||||
// Tag Shortcut Parsing: /t [tag name]:[optional value]
|
||||
// Regex: /t, name (no : or /), lookahead for terminator (: or /), optional value group
|
||||
const tagRegex = /\/t\s*([^:/]+)(?=(?:[:/]))(?:[:](\d+))?/g;
|
||||
|
||||
const newParsedTags: string[] = [];
|
||||
let match;
|
||||
|
||||
while ((match = tagRegex.exec(text)) !== null) {
|
||||
const tagName = match[1].trim();
|
||||
if (tagName) {
|
||||
newParsedTags.push(tagName);
|
||||
}
|
||||
}
|
||||
|
||||
const currentTags = [...tags()];
|
||||
// Determine changes
|
||||
const tagsToRemove = lastParsedTags.filter(t => !newParsedTags.includes(t));
|
||||
const tagsToAdd = newParsedTags.filter(t => !currentTags.includes(t));
|
||||
|
||||
// Apply changes
|
||||
let nextTags = currentTags.filter(t => !tagsToRemove.includes(t));
|
||||
nextTags = [...nextTags, ...tagsToAdd];
|
||||
|
||||
if (JSON.stringify(nextTags) !== JSON.stringify(currentTags)) {
|
||||
setTags(nextTags);
|
||||
}
|
||||
|
||||
lastParsedTags = newParsedTags;
|
||||
});
|
||||
|
||||
// Explicit date string for the input (YYYY-MM-DDTHH:mm)
|
||||
@@ -81,58 +124,99 @@ export const QuickEntry: Component = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const parseAndAdd = () => {
|
||||
const text = input();
|
||||
const parseAndAdd = async () => {
|
||||
let text = input();
|
||||
if (!text || !priority() || !urgency()) return;
|
||||
|
||||
let finalPriority = parseInt(priority());
|
||||
let finalUrgency = parseInt(urgency());
|
||||
const finalTags = [...tags()];
|
||||
|
||||
// Parse shorthand overrides if present
|
||||
const pMatch = text.match(/\/p(\d+)/);
|
||||
if (pMatch) finalPriority = Math.min(10, Math.max(1, parseInt(pMatch[1])));
|
||||
// 1. Handle Priority / Urgency and clean them from text
|
||||
const pMatch = text.match(/\/p\s*(\d+)/);
|
||||
if (pMatch) {
|
||||
finalPriority = Math.min(10, Math.max(1, parseInt(pMatch[1])));
|
||||
text = text.replace(pMatch[0], "");
|
||||
}
|
||||
|
||||
// Check for urgency override
|
||||
const uMatchNumeric = text.match(/\/u(\d+)/);
|
||||
if (uMatchNumeric) {
|
||||
finalUrgency = Math.min(10, Math.max(1, parseInt(uMatchNumeric[1])));
|
||||
const uMatch = text.match(/\/u\s*(\d+)/);
|
||||
if (uMatch) {
|
||||
finalUrgency = Math.min(10, Math.max(1, parseInt(uMatch[1])));
|
||||
text = text.replace(uMatch[0], "");
|
||||
}
|
||||
|
||||
// 2. Handle Tags: /t [name]:[value]
|
||||
const segments = text.split(/\/t\s*/);
|
||||
let title = segments[0];
|
||||
|
||||
const tagsToUpsert: Record<string, number> = {};
|
||||
|
||||
for (let i = 1; i < segments.length; i++) {
|
||||
const seg = segments[i];
|
||||
const firstColon = seg.indexOf(':');
|
||||
let tagName = "";
|
||||
let tagValue: number | undefined;
|
||||
let remainingText = "";
|
||||
|
||||
if (firstColon === -1) {
|
||||
tagName = seg.trim();
|
||||
} else {
|
||||
tagName = seg.slice(0, firstColon).trim();
|
||||
const afterColon = seg.slice(firstColon + 1);
|
||||
const valueMatch = afterColon.match(/^\s*(\d+)/);
|
||||
|
||||
if (valueMatch) {
|
||||
tagValue = Math.min(10, Math.max(0, parseInt(valueMatch[1])));
|
||||
remainingText = afterColon.slice(valueMatch[0].length);
|
||||
} else {
|
||||
remainingText = afterColon;
|
||||
}
|
||||
}
|
||||
|
||||
if (tagName) {
|
||||
if (!finalTags.includes(tagName)) {
|
||||
finalTags.push(tagName);
|
||||
}
|
||||
if (tagValue !== undefined) {
|
||||
tagsToUpsert[tagName] = tagValue;
|
||||
} else if (store.tagDefinitions?.[tagName] === undefined) {
|
||||
tagsToUpsert[tagName] = 5;
|
||||
}
|
||||
}
|
||||
title += " " + remainingText;
|
||||
}
|
||||
|
||||
if (Object.keys(tagsToUpsert).length > 0) {
|
||||
upsertMultipleTagDefinitions(tagsToUpsert);
|
||||
}
|
||||
|
||||
let finalDueDate: Date | null = null;
|
||||
if (dateString()) {
|
||||
finalDueDate = new Date(dateString());
|
||||
} else {
|
||||
// If urgency is selected, calculate date
|
||||
const u = parseInt(urgency());
|
||||
if (!isNaN(u)) {
|
||||
// Check if override happened
|
||||
if (uMatchNumeric) {
|
||||
const dateStr = calculateDateFromUrgency(finalUrgency);
|
||||
finalDueDate = new Date(dateStr);
|
||||
} else {
|
||||
const dateStr = calculateDateFromUrgency(u);
|
||||
finalDueDate = new Date(dateStr);
|
||||
}
|
||||
}
|
||||
const u = finalUrgency;
|
||||
const dateStr = calculateDateFromUrgency(u);
|
||||
finalDueDate = new Date(dateStr);
|
||||
}
|
||||
|
||||
const title = text
|
||||
.replace(/\/p\d+/, "")
|
||||
.replace(/\/u\d+/, "")
|
||||
.trim();
|
||||
|
||||
// Use the new signature: addTask(title, options)
|
||||
addTask(title, {
|
||||
addTask(title.replace(/\s+/g, " ").trim(), {
|
||||
priority: finalPriority,
|
||||
dueDate: finalDueDate || undefined, // undefined will let store pick default if that was the intent, but here we probably want strictly calculated date
|
||||
tags: []
|
||||
dueDate: finalDueDate || undefined,
|
||||
tags: finalTags,
|
||||
content: description()
|
||||
});
|
||||
|
||||
setInput("");
|
||||
setPriority("5");
|
||||
setUrgency("5");
|
||||
setTags([]);
|
||||
setDescription("");
|
||||
lastParsedTags = [];
|
||||
setDateString("");
|
||||
setIsOpen(false);
|
||||
// Reset editor content
|
||||
const instance = editorInstance();
|
||||
if (instance) instance.commands.setContent("");
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -172,7 +256,7 @@ export const QuickEntry: Component = () => {
|
||||
<Select
|
||||
value={priority()}
|
||||
onChange={(val) => val && setPriority(val)}
|
||||
options={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
|
||||
options={["10", "9", "8", "7", "6", "5", "4", "3", "2", "1"]}
|
||||
placeholder="Priority"
|
||||
itemComponent={(props) => (
|
||||
<SelectItem item={props.item}>
|
||||
@@ -235,6 +319,33 @@ export const QuickEntry: Component = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-4 pb-4 bg-muted/10 border-b border-border flex flex-wrap gap-2 items-center">
|
||||
<TagPicker selectedTags={tags()} onTagsChange={setTags} />
|
||||
<For each={tags()}>
|
||||
{(tag) => (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
class="h-7 px-2 text-xs gap-1 bg-background border border-border/50 cursor-pointer hover:bg-destructive/10 hover:border-destructive/30 group/tag transition-all"
|
||||
onClick={() => setTags(tags().filter(t => t !== tag))}
|
||||
>
|
||||
<div class={cn("w-1.5 h-1.5 rounded-full transition-colors group-hover/tag:bg-destructive", (store.tagDefinitions?.[tag] ?? 5) > 5 ? "bg-green-500" : (store.tagDefinitions?.[tag] ?? 5) < 5 ? "bg-red-500" : "bg-gray-400")} />
|
||||
{tag}
|
||||
</Badge>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-3 bg-card border-b border-border min-h-[120px] max-h-[300px] overflow-y-auto">
|
||||
<Suspense fallback={<div class="h-20 animate-pulse bg-muted/20 rounded-lg" />}>
|
||||
<TaskEditor
|
||||
content={description()}
|
||||
onUpdate={setDescription}
|
||||
onEditorReady={setEditorInstance}
|
||||
class="min-h-[100px] prose-sm"
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-muted/30 flex justify-between items-center">
|
||||
<div class="flex items-center space-x-2 text-[10px] font-bold text-muted-foreground uppercase tracking-widest">
|
||||
<Command size={10} />
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { type Component, createSignal, For, createEffect } from "solid-js";
|
||||
import { store, upsertTagDefinition } from "@/store";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Hash, Plus } from "lucide-solid";
|
||||
|
||||
interface TagPickerProps {
|
||||
selectedTags: string[];
|
||||
onTagsChange: (tags: string[]) => void;
|
||||
}
|
||||
|
||||
export const TagPicker: Component<TagPickerProps> = (props) => {
|
||||
const [open, setOpen] = createSignal(false);
|
||||
const [selectedTagName, setSelectedTagName] = createSignal("");
|
||||
const [valueScore, setValueScore] = createSignal(5);
|
||||
|
||||
// Filter tags for the "Name" part
|
||||
const allTagNames = () => Object.keys(store.tagDefinitions || {}).sort();
|
||||
|
||||
// When a tag name is selected, update the value score to match its current definition
|
||||
createEffect(() => {
|
||||
const name = selectedTagName().trim();
|
||||
if (name) {
|
||||
const existingVal = store.tagDefinitions?.[name];
|
||||
if (existingVal !== undefined) {
|
||||
setValueScore(existingVal);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const handleApply = () => {
|
||||
const name = selectedTagName().trim();
|
||||
if (!name) return;
|
||||
|
||||
// update global definition
|
||||
upsertTagDefinition(name, valueScore());
|
||||
|
||||
// Toggle in current task selection if not already there
|
||||
const currentSelected = props.selectedTags;
|
||||
if (!currentSelected.includes(name)) {
|
||||
props.onTagsChange([...currentSelected, name]);
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
setSelectedTagName("");
|
||||
setValueScore(5);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open()} onOpenChange={setOpen}>
|
||||
<PopoverTrigger as={Button} variant="ghost" size="sm" class="h-8 border-dashed flex gap-2 items-center text-muted-foreground hover:text-foreground">
|
||||
<Hash size={14} />
|
||||
<span>Add Tag</span>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="p-3 w-[min(calc(100vw-2rem),280px)] flex flex-col gap-3" align="start">
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Tag Name</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
list="existing-tags"
|
||||
value={selectedTagName()}
|
||||
onInput={(e) => setSelectedTagName(e.currentTarget.value)}
|
||||
placeholder="Select or type name..."
|
||||
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
<datalist id="existing-tags">
|
||||
<For each={allTagNames()}>
|
||||
{(tag) => <option value={tag} />}
|
||||
</For>
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Value (Influence)</label>
|
||||
<select
|
||||
value={valueScore().toString()}
|
||||
onInput={(e) => setValueScore(parseInt(e.currentTarget.value))}
|
||||
class="flex h-9 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm ring-offset-background focus:outline-none focus:ring-1 focus:ring-ring appearance-none"
|
||||
>
|
||||
<For each={["10", "9", "8", "7", "6", "5", "4", "3", "2", "1", "0"]}>
|
||||
{(val) => <option value={val}>{val}</option>}
|
||||
</For>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Button size="sm" class="w-full mt-1" onClick={handleApply}>
|
||||
<Plus size={14} class="mr-2" />
|
||||
{store.tagDefinitions?.[selectedTagName().trim()] !== undefined ? "Update & Add" : "Create & Add"}
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
+42
-19
@@ -1,7 +1,9 @@
|
||||
import { type Component, createMemo } from "solid-js";
|
||||
import { type Task, toggleTask, calculateUrgencyFromDate, setActiveTaskId } from "@/store";
|
||||
import { type Task, toggleTask, calculateUrgencyFromDate, setActiveTaskId, store, copyTask } from "@/store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CheckCircle2, Circle, Clock, ArrowUpCircle } from "lucide-solid";
|
||||
import { CheckCircle2, Circle, Clock, ArrowUpCircle, Copy } from "lucide-solid";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { For } from "solid-js";
|
||||
|
||||
export const TaskCard: Component<{ task: Task }> = (props) => {
|
||||
const urgencyLevel = createMemo(() => calculateUrgencyFromDate(props.task.dueDate));
|
||||
@@ -22,42 +24,56 @@ export const TaskCard: Component<{ task: Task }> = (props) => {
|
||||
return (
|
||||
<div
|
||||
class={cn(
|
||||
"group flex items-center p-4 bg-card border border-border rounded-2xl transition-all duration-300 hover:shadow-xl hover:shadow-primary/5 hover:-translate-y-0.5 cursor-pointer",
|
||||
"group flex items-center p-3 sm:p-4 bg-card border border-border rounded-2xl transition-all duration-300 hover:shadow-xl hover:shadow-primary/5 hover:-translate-y-0.5 cursor-pointer w-full min-w-0 overflow-hidden",
|
||||
props.task.completed && "opacity-60"
|
||||
)}
|
||||
onClick={() => setActiveTaskId(props.task.id)}
|
||||
>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); toggleTask(props.task.id); }}
|
||||
class="mr-4 text-muted-foreground hover:text-primary transition-colors shrink-0"
|
||||
class="mr-3 sm:mr-4 text-muted-foreground hover:text-primary transition-colors shrink-0"
|
||||
>
|
||||
{props.task.completed ? (
|
||||
<CheckCircle2 class="text-green-500" size={24} />
|
||||
<CheckCircle2 class="text-green-500" size={22} />
|
||||
) : (
|
||||
<Circle size={24} />
|
||||
<Circle size={22} />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex-1 min-w-0 flex flex-col">
|
||||
<h3 class={cn(
|
||||
"text-base font-semibold truncate transition-all",
|
||||
"text-sm sm:text-base font-semibold truncate transition-all",
|
||||
props.task.completed && "line-through"
|
||||
)}>
|
||||
{props.task.title}
|
||||
</h3>
|
||||
<div class="flex items-center space-x-4 mt-1">
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-1.5 mt-1 min-w-0">
|
||||
{/* Priority */}
|
||||
<div class="flex items-center gap-1.5 min-w-[32px]">
|
||||
<div class="flex items-center gap-1.5 shrink-0">
|
||||
<ArrowUpCircle size={12} class="text-primary opacity-60" />
|
||||
<span>P{props.task.priority}</span>
|
||||
<span class="text-[10px] font-medium">P{props.task.priority}</span>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{props.task.tags && props.task.tags.length > 0 && (
|
||||
<div class="flex flex-wrap items-center gap-1 min-w-0">
|
||||
<For each={props.task.tags}>
|
||||
{(tag) => (
|
||||
<Badge variant="secondary" class="h-4 px-1.5 text-[9px] gap-1 bg-muted/50 border-border/50 shrink-0">
|
||||
<div class={cn("w-1 h-1 rounded-full", (store.tagDefinitions?.[tag] ?? 5) > 5 ? "bg-green-500" : (store.tagDefinitions?.[tag] ?? 5) < 5 ? "bg-red-500" : "bg-gray-400")} />
|
||||
{tag}
|
||||
</Badge>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Due Date + Urgency Slide-out */}
|
||||
<div class="relative group/date flex items-center h-6 cursor-help">
|
||||
<div class="relative group/date flex items-center h-5 cursor-help shrink-0 min-w-0">
|
||||
{/* Due Date - Static Layer on Top */}
|
||||
<div class="flex items-center space-x-1.5 text-[10px] font-bold text-muted-foreground uppercase tracking-wider bg-card relative z-20 pr-1">
|
||||
<Clock size={12} class="text-primary/60" />
|
||||
<span>{formattedDate()}</span>
|
||||
<div class="flex items-center space-x-1 text-[9px] font-bold text-muted-foreground uppercase tracking-wider bg-card relative z-20 pr-1">
|
||||
<Clock size={11} class="text-primary/60" />
|
||||
<span class="truncate">{formattedDate()}</span>
|
||||
</div>
|
||||
|
||||
{/* Urgency Number - Slides out from behind with fade */}
|
||||
@@ -65,7 +81,7 @@ export const TaskCard: Component<{ task: Task }> = (props) => {
|
||||
"absolute left-4 top-0 bottom-0 flex items-center transition-all duration-300 ease-out z-10 opacity-0 group-hover/date:opacity-100 group-hover/date:left-full whitespace-nowrap",
|
||||
urgencyColor()
|
||||
)}>
|
||||
<span class="text-[10px] font-bold ml-2 transition-all duration-300 transform translate-x-[-8px] group-hover/date:translate-x-0 group-hover/date:blur-none blur-[2px]">
|
||||
<span class="text-[9px] font-bold ml-2 transition-all duration-300 transform translate-x-[-8px] group-hover/date:translate-x-0 group-hover/date:blur-none blur-[2px]">
|
||||
Urgency {urgencyLevel()}
|
||||
</span>
|
||||
</div>
|
||||
@@ -73,9 +89,16 @@ export const TaskCard: Component<{ task: Task }> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ml-4 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
||||
<button class="p-2 hover:bg-muted rounded-full transition-colors">
|
||||
<div class="w-1.5 h-1.5 bg-muted-foreground rounded-full" />
|
||||
<div class="ml-2 sm:ml-4 opacity-100 sm:opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
||||
<button
|
||||
class="p-1.5 hover:bg-muted rounded-full transition-colors text-muted-foreground hover:text-primary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copyTask(props.task.id);
|
||||
}}
|
||||
title="Duplicate Task"
|
||||
>
|
||||
<Copy size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+150
-43
@@ -1,10 +1,14 @@
|
||||
import { type Component, createEffect, createSignal } from "solid-js";
|
||||
import { type Component, createEffect, createSignal, For } from "solid-js";
|
||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||
import { type Task, removeTask, restoreTask, updateTask } from "@/store";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
|
||||
import { ArrowUpCircle, Clock, Calendar, Type, Trash2 } from "lucide-solid";
|
||||
import { ArrowUpCircle, Clock, Calendar, Type, Trash2, X } from "lucide-solid";
|
||||
import { calculateDateFromUrgency, calculateUrgencyFromDate } from "@/store";
|
||||
import { Button } from "./ui/button";
|
||||
import { TagPicker } from "./TagPicker";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { store } from "@/store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "solid-sonner";
|
||||
import { lazy, Suspense } from "solid-js";
|
||||
|
||||
@@ -42,7 +46,6 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
||||
|
||||
const updateTitle = (val: string) => {
|
||||
setTitle(val);
|
||||
// Debouncing could be added here, but direct update is fine for now if PB handles it well
|
||||
updateTask(props.task.id, { title: val });
|
||||
};
|
||||
|
||||
@@ -61,6 +64,10 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
||||
}
|
||||
};
|
||||
|
||||
const updateTags = (tags: string[]) => {
|
||||
updateTask(props.task.id, { tags });
|
||||
};
|
||||
|
||||
const onDateInputChange = (e: Event) => {
|
||||
const val = (e.currentTarget as HTMLInputElement).value;
|
||||
if (val) {
|
||||
@@ -74,7 +81,7 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
||||
|
||||
return (
|
||||
<Sheet open={props.isOpen} onOpenChange={(open) => !open && props.onClose()}>
|
||||
<SheetContent class="w-full sm:max-w-2xl px-0 sm:px-0 flex flex-col h-full bg-background border-l border-border shadow-2xl">
|
||||
<SheetContent hideCloseButton class="w-full sm:max-w-2xl px-0 sm:px-0 flex flex-col h-full bg-background border-l border-border shadow-2xl">
|
||||
{/* Header Area */}
|
||||
<div class="px-6 pt-6 pb-2 shrink-0">
|
||||
<textarea
|
||||
@@ -99,14 +106,14 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
||||
/>
|
||||
|
||||
{/* Properties Bar */}
|
||||
<div class="flex flex-row items-center gap-1.5 sm:gap-4 mt-2 text-sm overflow-x-auto no-scrollbar flex-nowrap">
|
||||
<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-[10px] uppercase font-bold tracking-wider text-muted-foreground/70 hidden sm:inline">Priority</span>
|
||||
<Select
|
||||
value={props.task.priority.toString()}
|
||||
onChange={(val: string | null) => val && updatePriority(val)}
|
||||
options={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
|
||||
options={["10", "9", "8", "7", "6", "5", "4", "3", "2", "1"]}
|
||||
placeholder="Priority"
|
||||
itemComponent={(props: any) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
|
||||
>
|
||||
@@ -157,56 +164,46 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
||||
<span class="hidden sm:inline">Commands</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date - Editable */}
|
||||
<div class="flex items-center gap-1 text-muted-foreground group relative shrink-0 min-w-0 flex-1 sm:flex-initial">
|
||||
<Calendar size={14} class="group-hover:text-primary transition-colors shrink-0" />
|
||||
{/* 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}
|
||||
onClick={(e) => (e.target as HTMLInputElement).showPicker?.()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Tab" && e.key !== "Escape") {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
step="900"
|
||||
class="bg-transparent border-none p-0 text-[11px] sm:text-xs font-medium focus:ring-0 focus:outline-none cursor-pointer hover:text-foreground appearance-none min-w-[125px] w-full"
|
||||
title="Change Due Date"
|
||||
class="absolute inset-0 opacity-0 cursor-pointer w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
</MetadataItem>
|
||||
|
||||
{/* Delete Button */}
|
||||
<div class="flex items-center gap-1 shrink-0 ml-auto">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 w-8 px-0 hover:bg-red-500/10 hover:text-red-500 text-muted-foreground transition-colors"
|
||||
onClick={() => {
|
||||
const taskId = props.task.id;
|
||||
removeTask(taskId);
|
||||
props.onClose();
|
||||
toast.info("Task moved to trash. Find inside Settings.", {
|
||||
action: {
|
||||
label: "Undo",
|
||||
onClick: () => restoreTask(taskId)
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
<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-4" />
|
||||
<div class="h-px w-full bg-border mt-5" />
|
||||
</div>
|
||||
|
||||
{/* Editor Content */}
|
||||
<div class="flex-1 overflow-y-auto px-6 py-4 pb-20">
|
||||
{/* Content Area */}
|
||||
<div class="flex-1 overflow-y-auto px-6 py-4 pb-20 flex flex-col gap-6">
|
||||
<Suspense fallback={<div class="h-32 animate-pulse bg-muted/20 rounded-lg" />}>
|
||||
<TaskEditor
|
||||
content={props.task.content}
|
||||
@@ -214,8 +211,118 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
||||
onEditorReady={setEditorInstance}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
{/* Tags at the bottom */}
|
||||
<div class="mt-auto pt-6 border-t border-border/50">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<span class="text-[10px] uppercase font-bold tracking-wider text-muted-foreground/60 shrink-0">Tags</span>
|
||||
<div class="flex flex-wrap items-center gap-1.5 min-w-0 flex-1">
|
||||
<TagPicker
|
||||
selectedTags={props.task.tags || []}
|
||||
onTagsChange={updateTags}
|
||||
/>
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<For each={props.task.tags || []}>
|
||||
{(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"
|
||||
onClick={() => updateTags((props.task.tags || []).filter(t => t !== tag))}
|
||||
>
|
||||
<div class={cn("w-1.5 h-1.5 rounded-full transition-colors group-hover/tag:bg-destructive", (store.tagDefinitions?.[tag] ?? 5) > 5 ? "bg-green-500" : (store.tagDefinitions?.[tag] ?? 5) < 5 ? "bg-red-500" : "bg-gray-400")} />
|
||||
{tag}
|
||||
</Badge>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</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">
|
||||
{/* 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>
|
||||
|
||||
{/* Close button (Mobile only) */}
|
||||
<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>
|
||||
</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-[11px] 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-[11px] 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,7 +11,7 @@ export const ThemeToggle: Component = () => {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setTheme(theme() === "dark" ? "light" : "dark")}
|
||||
class="w-9 h-9 px-0"
|
||||
class="w-9 h-9 px-0 relative"
|
||||
>
|
||||
<Sun class="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon class="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { splitProps, type Component, type ComponentProps } from "solid-js"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends ComponentProps<"div">,
|
||||
VariantProps<typeof badgeVariants> { }
|
||||
|
||||
const Badge: Component<BadgeProps> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class", "variant"])
|
||||
return (
|
||||
<div class={cn(badgeVariants({ variant: local.variant }), local.class)} {...others} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -39,6 +39,7 @@ export interface ButtonProps extends VariantProps<typeof buttonVariants> {
|
||||
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link"
|
||||
type?: "button" | "submit" | "reset"
|
||||
disabled?: boolean
|
||||
title?: string
|
||||
}
|
||||
|
||||
const Button = (props: ButtonProps) => {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { type Component, type ComponentProps, splitProps } from "solid-js"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Search } from "lucide-solid"
|
||||
|
||||
const Command: Component<ComponentProps<"div">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return (
|
||||
<div
|
||||
class={cn(
|
||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const CommandInput: Component<ComponentProps<"input"> & { onValueChange?: (value: string) => void }> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class", "onValueChange"])
|
||||
return (
|
||||
<div class="flex items-center border-b px-3">
|
||||
<Search class="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<input
|
||||
class={cn(
|
||||
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
local.class
|
||||
)}
|
||||
onInput={(e) => local.onValueChange?.(e.currentTarget.value)}
|
||||
{...others}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const CommandList: Component<ComponentProps<"div">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return (
|
||||
<div
|
||||
class={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", local.class)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const CommandEmpty: Component<ComponentProps<"div">> = (props) => {
|
||||
return <div class="py-6 text-center text-sm" {...props} />
|
||||
}
|
||||
|
||||
const CommandGroup: Component<ComponentProps<"div"> & { heading?: string }> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class", "heading"])
|
||||
return (
|
||||
<div
|
||||
class={cn(
|
||||
"overflow-hidden p-1 text-foreground",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
>
|
||||
{local.heading && (
|
||||
<div class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
{local.heading}
|
||||
</div>
|
||||
)}
|
||||
{others.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const CommandItem: Component<ComponentProps<"div"> & { onSelect?: () => void, value?: string }> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class", "onSelect", "value"])
|
||||
return (
|
||||
<div
|
||||
class={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 cursor-pointer",
|
||||
local.class
|
||||
)}
|
||||
onClick={local.onSelect}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
}
|
||||
@@ -11,7 +11,7 @@ const PopoverContent = (props: import("solid-js").ComponentProps<typeof PopoverP
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
class={cn(
|
||||
"z-[110] w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
||||
"z-[200] w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
|
||||
@@ -20,8 +20,8 @@ const SheetOverlay: Component<ComponentProps<typeof SheetPrimitive.Overlay>> = (
|
||||
);
|
||||
};
|
||||
|
||||
const SheetContent: Component<ComponentProps<typeof SheetPrimitive.Content>> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class", "children"]);
|
||||
const SheetContent: Component<ComponentProps<typeof SheetPrimitive.Content> & { hideCloseButton?: boolean }> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class", "children", "hideCloseButton"]);
|
||||
return (
|
||||
<SheetPrimitive.Portal>
|
||||
<SheetOverlay />
|
||||
@@ -33,10 +33,12 @@ const SheetContent: Component<ComponentProps<typeof SheetPrimitive.Content>> = (
|
||||
{...others}
|
||||
>
|
||||
{/* Close Button */}
|
||||
<SheetPrimitive.CloseButton class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X class="h-4 w-4" />
|
||||
<span class="sr-only">Close</span>
|
||||
</SheetPrimitive.CloseButton>
|
||||
{!local.hideCloseButton && (
|
||||
<SheetPrimitive.CloseButton class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X class="h-4 w-4" />
|
||||
<span class="sr-only">Close</span>
|
||||
</SheetPrimitive.CloseButton>
|
||||
)}
|
||||
{local.children}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPrimitive.Portal>
|
||||
|
||||
+251
-8
@@ -1,4 +1,4 @@
|
||||
import { createStore, reconcile } from "solid-js/store";
|
||||
import { createStore, reconcile, produce } from "solid-js/store";
|
||||
import { createSignal, createEffect, createRoot } from "solid-js";
|
||||
import { pb, TASGRID_COLLECTION } from "@/lib/pocketbase";
|
||||
import { toast } from "solid-sonner";
|
||||
@@ -23,6 +23,17 @@ export interface Task {
|
||||
tags: string[];
|
||||
content?: string; // HTML content from Tiptap
|
||||
deletedAt?: number; // Timestamp of soft delete
|
||||
created: string; // ISO string from PB
|
||||
updated: string; // ISO string from PB
|
||||
}
|
||||
|
||||
export interface Filter {
|
||||
query: string;
|
||||
tags: string[];
|
||||
priorityMin: number;
|
||||
priorityMax: number;
|
||||
urgencyMin: number;
|
||||
urgencyMax: number;
|
||||
}
|
||||
|
||||
interface TaskStore {
|
||||
@@ -31,6 +42,8 @@ interface TaskStore {
|
||||
uWeight: number;
|
||||
matrixScaleDays: number;
|
||||
prefId?: string; // ID of the user_preferences record
|
||||
tagDefinitions?: Record<string, number>;
|
||||
filter: Filter;
|
||||
}
|
||||
|
||||
// Initial empty state
|
||||
@@ -39,8 +52,44 @@ export const [store, setStore] = createStore<TaskStore>({
|
||||
pWeight: 1.0,
|
||||
uWeight: 1.0,
|
||||
matrixScaleDays: 30,
|
||||
tagDefinitions: {}, // Map<Name, Value 0-10>
|
||||
filter: {
|
||||
query: "",
|
||||
tags: [],
|
||||
priorityMin: 1,
|
||||
priorityMax: 10,
|
||||
urgencyMin: 1,
|
||||
urgencyMax: 10
|
||||
}
|
||||
});
|
||||
|
||||
export const matchesFilter = (task: Task) => {
|
||||
const f = store.filter;
|
||||
|
||||
// Query search (title or content)
|
||||
if (f.query) {
|
||||
const q = f.query.toLowerCase();
|
||||
const inTitle = task.title.toLowerCase().includes(q);
|
||||
const inContent = task.content?.toLowerCase().includes(q);
|
||||
if (!inTitle && !inContent) return false;
|
||||
}
|
||||
|
||||
// Tags (OR logic if multiple selected?)
|
||||
if (f.tags.length > 0) {
|
||||
const hasTag = f.tags.some(tag => task.tags?.includes(tag));
|
||||
if (!hasTag) return false;
|
||||
}
|
||||
|
||||
// Priority
|
||||
if (task.priority < f.priorityMin || task.priority > f.priorityMax) return false;
|
||||
|
||||
// Urgency (calculated)
|
||||
const urgency = calculateUrgencyFromDate(task.dueDate);
|
||||
if (urgency < f.urgencyMin || urgency > f.urgencyMax) return false;
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Auto-persist changes to localStorage (wrapped in root to avoid console warning)
|
||||
createRoot(() => {
|
||||
createEffect(() => {
|
||||
@@ -84,7 +133,21 @@ export const calculateUrgencyFromDate = (dateStr: string): number => {
|
||||
|
||||
export const getCombinedScore = (task: Task): number => {
|
||||
const urgencyScore = calculateUrgencyScore(task.dueDate);
|
||||
return (task.priority * store.pWeight) + (urgencyScore * store.uWeight);
|
||||
let baseScore = (task.priority * store.pWeight) + (urgencyScore * store.uWeight);
|
||||
|
||||
// Tag adjustments
|
||||
if (task.tags && task.tags.length > 0) {
|
||||
task.tags.forEach(tagName => {
|
||||
const defVal = store.tagDefinitions?.[tagName];
|
||||
if (defVal !== undefined) {
|
||||
// Formula: (Value - 5) * 0.1
|
||||
// 5 -> 0, 10 -> 0.5, 0 -> -0.5
|
||||
baseScore += (defVal - 5) * 0.1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return baseScore;
|
||||
};
|
||||
|
||||
// -- Persistence & Sync --
|
||||
@@ -126,6 +189,7 @@ export const initStore = async () => {
|
||||
pWeight: prefs.pWeight || 1.0,
|
||||
uWeight: prefs.uWeight || 1.0,
|
||||
matrixScaleDays: prefs.matrixScaleDays || 30,
|
||||
tagDefinitions: prefs.tagDefinitions || {},
|
||||
prefId: userId // The ID to update is the User ID
|
||||
});
|
||||
}
|
||||
@@ -150,7 +214,9 @@ export const initStore = async () => {
|
||||
completed: r.completed,
|
||||
tags: r.tags || [],
|
||||
content: r.content,
|
||||
deletedAt: r.deletedAt ? new Date(r.deletedAt).getTime() : undefined
|
||||
deletedAt: r.deletedAt ? new Date(r.deletedAt).getTime() : undefined,
|
||||
created: r.created,
|
||||
updated: r.updated
|
||||
}));
|
||||
|
||||
// Use reconcile for efficient store update (keeps observers happy)
|
||||
@@ -169,7 +235,7 @@ export const initStore = async () => {
|
||||
|
||||
// -- Actions --
|
||||
|
||||
export const addTask = async (title: string, options?: { dueDate?: Date | string, priority?: number, tags?: string[] }) => {
|
||||
export const addTask = async (title: string, options?: { dueDate?: Date | string, priority?: number, tags?: string[], content?: string }) => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
|
||||
// Default to ~24h from now if no date provided
|
||||
@@ -181,6 +247,7 @@ export const addTask = async (title: string, options?: { dueDate?: Date | string
|
||||
const startDate = new Date().toISOString();
|
||||
const priority = options?.priority ?? 5;
|
||||
const tags = options?.tags ?? ["work"];
|
||||
const content = options?.content ?? "";
|
||||
|
||||
const tempId = "temp-" + Date.now();
|
||||
const newTask: Task = {
|
||||
@@ -190,7 +257,10 @@ export const addTask = async (title: string, options?: { dueDate?: Date | string
|
||||
dueDate,
|
||||
priority,
|
||||
completed: false,
|
||||
tags
|
||||
tags,
|
||||
content,
|
||||
created: new Date().toISOString(),
|
||||
updated: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Optimistic UI update
|
||||
@@ -204,11 +274,17 @@ export const addTask = async (title: string, options?: { dueDate?: Date | string
|
||||
dueDate,
|
||||
priority,
|
||||
completed: false,
|
||||
tags
|
||||
tags,
|
||||
content
|
||||
});
|
||||
|
||||
// Replace temp task with real record
|
||||
setStore("tasks", (t) => t.map(task => task.id === tempId ? { ...task, id: record.id } : task));
|
||||
// Replace temp task with real record (merging server fields like id, created, updated)
|
||||
setStore("tasks", (t) => t.map(task => task.id === tempId ? {
|
||||
...task,
|
||||
id: record.id,
|
||||
created: record.created,
|
||||
updated: record.updated
|
||||
} : task));
|
||||
} catch (err) {
|
||||
console.error("create failed", err);
|
||||
toast.error("Failed to save task.");
|
||||
@@ -217,6 +293,21 @@ export const addTask = async (title: string, options?: { dueDate?: Date | string
|
||||
}
|
||||
};
|
||||
|
||||
export const copyTask = async (id: string) => {
|
||||
const original = store.tasks.find(t => t.id === id);
|
||||
if (!original) return;
|
||||
|
||||
// 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
|
||||
});
|
||||
|
||||
toast.success("Task duplicated");
|
||||
};
|
||||
|
||||
export const updateTask = async (id: string, updates: Partial<Task>) => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
|
||||
@@ -354,6 +445,158 @@ export const deleteTaskPermanently = async (id: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const upsertTagDefinition = async (name: string, value: number) => {
|
||||
// Optimistic update
|
||||
setStore("tagDefinitions", name, value);
|
||||
|
||||
// Persist
|
||||
const map = { ...store.tagDefinitions };
|
||||
const userId = pb.authStore.model?.id;
|
||||
if (userId) {
|
||||
try {
|
||||
const prefs = {
|
||||
pWeight: store.pWeight,
|
||||
uWeight: store.uWeight,
|
||||
matrixScaleDays: store.matrixScaleDays,
|
||||
tagDefinitions: map
|
||||
};
|
||||
|
||||
await pb.collection('users').update(userId, {
|
||||
Taskgrid_pref: prefs
|
||||
}, { requestKey: null });
|
||||
} catch (e: any) {
|
||||
// Ignore abort errors
|
||||
if (e.isAbort) return;
|
||||
console.error("Failed to persist tag definition", e);
|
||||
toast.error("Failed to save tag definition");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const upsertMultipleTagDefinitions = async (updates: Record<string, number>) => {
|
||||
// Optimistic update
|
||||
setStore("tagDefinitions", (defs) => ({ ...(defs || {}), ...updates }));
|
||||
|
||||
// Persist
|
||||
const map = { ...store.tagDefinitions };
|
||||
const userId = pb.authStore.model?.id;
|
||||
if (userId) {
|
||||
try {
|
||||
const prefs = {
|
||||
pWeight: store.pWeight,
|
||||
uWeight: store.uWeight,
|
||||
matrixScaleDays: store.matrixScaleDays,
|
||||
tagDefinitions: map
|
||||
};
|
||||
|
||||
await pb.collection('users').update(userId, {
|
||||
Taskgrid_pref: prefs
|
||||
}, { requestKey: null });
|
||||
} catch (e: any) {
|
||||
// Ignore abort errors
|
||||
if (e.isAbort) return;
|
||||
console.error("Failed to persist tag definitions", e);
|
||||
toast.error("Failed to save tag definitions");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const removeTagDefinition = async (name: string) => {
|
||||
// 1. Remove from all tasks
|
||||
const tasksWithTag = store.tasks.filter(t => t.tags?.includes(name));
|
||||
for (const task of tasksWithTag) {
|
||||
const newTags = task.tags.filter(t => t !== name);
|
||||
updateTask(task.id, { tags: newTags });
|
||||
}
|
||||
|
||||
// 2. Remove definition locally
|
||||
setStore("tagDefinitions", produce((defs) => {
|
||||
if (defs) delete defs[name];
|
||||
}));
|
||||
|
||||
// 3. Persist
|
||||
const map = { ...store.tagDefinitions };
|
||||
const userId = pb.authStore.model?.id;
|
||||
if (userId) {
|
||||
try {
|
||||
const prefs = {
|
||||
pWeight: store.pWeight,
|
||||
uWeight: store.uWeight,
|
||||
matrixScaleDays: store.matrixScaleDays,
|
||||
tagDefinitions: map
|
||||
};
|
||||
await pb.collection('users').update(userId, {
|
||||
Taskgrid_pref: prefs
|
||||
});
|
||||
toast.success(`Tag "${name}" deleted`);
|
||||
} catch (e) {
|
||||
console.error("Failed to delete tag definition", e);
|
||||
toast.error("Failed to delete tag");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const renameTagDefinition = async (oldName: string, newName: string) => {
|
||||
if (!newName || !newName.trim() || newName === oldName) return;
|
||||
const finalName = newName.trim();
|
||||
|
||||
// 1. Get current value
|
||||
const val = store.tagDefinitions?.[oldName] ?? 5;
|
||||
|
||||
// 2. Update all tasks
|
||||
const tasksWithTag = store.tasks.filter(t => t.tags?.includes(oldName));
|
||||
for (const task of tasksWithTag) {
|
||||
const newTags = task.tags.map(t => t === oldName ? finalName : t);
|
||||
// Dedupe
|
||||
const uniqueTags = [...new Set(newTags)];
|
||||
updateTask(task.id, { tags: uniqueTags });
|
||||
}
|
||||
|
||||
// 3. Update definition locally
|
||||
setStore("tagDefinitions", produce((defs) => {
|
||||
if (defs) {
|
||||
defs[finalName] = val;
|
||||
delete defs[oldName];
|
||||
}
|
||||
}));
|
||||
|
||||
// 4. Persist
|
||||
const map = { ...store.tagDefinitions };
|
||||
const userId = pb.authStore.model?.id;
|
||||
if (userId) {
|
||||
try {
|
||||
const prefs = {
|
||||
pWeight: store.pWeight,
|
||||
uWeight: store.uWeight,
|
||||
matrixScaleDays: store.matrixScaleDays,
|
||||
tagDefinitions: map
|
||||
};
|
||||
await pb.collection('users').update(userId, {
|
||||
Taskgrid_pref: prefs
|
||||
});
|
||||
toast.success(`Tag renamed to "${finalName}"`);
|
||||
} catch (e) {
|
||||
console.error("Failed to rename tag", e);
|
||||
toast.error("Failed to rename tag");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
export const setFilter = (update: Partial<Filter>) => {
|
||||
setStore("filter", (f) => ({ ...f, ...update }));
|
||||
};
|
||||
|
||||
export const clearFilter = () => {
|
||||
setStore("filter", {
|
||||
query: "",
|
||||
tags: [],
|
||||
priorityMin: 1,
|
||||
priorityMax: 10,
|
||||
urgencyMin: 1,
|
||||
urgencyMax: 10
|
||||
});
|
||||
};
|
||||
|
||||
// Legacy cleanup - We don't need local storage persistence setup anymore
|
||||
export const setupPersistence = () => {
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { type Component, For, createMemo } from "solid-js";
|
||||
import { store, getCombinedScore } from "@/store";
|
||||
import { store, getCombinedScore, matchesFilter } from "@/store";
|
||||
import { TaskCard } from "@/components/TaskCard";
|
||||
|
||||
export const CriticalView: Component = () => {
|
||||
const sortedTasks = createMemo(() => {
|
||||
return [...store.tasks].filter(t => !t.deletedAt).sort((a, b) => {
|
||||
if (a.completed !== b.completed) return a.completed ? 1 : -1;
|
||||
return getCombinedScore(b) - getCombinedScore(a);
|
||||
});
|
||||
return [...store.tasks]
|
||||
.filter(t => !t.deletedAt && matchesFilter(t))
|
||||
.sort((a, b) => {
|
||||
if (a.completed !== b.completed) return a.completed ? 1 : -1;
|
||||
return getCombinedScore(b) - getCombinedScore(a);
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
+10
-10
@@ -1,10 +1,10 @@
|
||||
import { type Component, For, createMemo } from "solid-js";
|
||||
import { store, calculateUrgencyScore, setActiveTaskId, setStore, getCombinedScore } from "@/store";
|
||||
import { store, calculateUrgencyScore, setActiveTaskId, setStore, getCombinedScore, matchesFilter } from "@/store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
|
||||
|
||||
export const MatrixView: Component = () => {
|
||||
const activeTasks = createMemo(() => store.tasks.filter(t => !t.completed && !t.deletedAt));
|
||||
const activeTasks = createMemo(() => store.tasks.filter(t => !t.completed && !t.deletedAt && matchesFilter(t)));
|
||||
|
||||
const scaleOptions = ["1", "7", "30", "60", "90"];
|
||||
|
||||
@@ -15,12 +15,12 @@ export const MatrixView: Component = () => {
|
||||
<p class="text-muted-foreground mt-1 text-lg">Urgency vs Priority mapping.</p>
|
||||
</header>
|
||||
|
||||
<div class="flex-1 relative border-2 border-dashed border-muted rounded-3xl overflow-hidden bg-muted/20">
|
||||
<div class="flex-1 relative border-2 border-dashed border-muted rounded-2xl md:rounded-3xl overflow-hidden bg-muted/20">
|
||||
{/* Axis Labels */}
|
||||
<div class="absolute bottom-4 left-1/2 -translate-x-1/2 text-[10px] font-bold uppercase tracking-widest text-muted-foreground/50 flex items-center gap-2 z-30">
|
||||
<span>← Soon</span>
|
||||
<div class="flex items-center gap-1 bg-background/50 backdrop-blur-sm px-2 py-0.5 rounded-full border border-border/50 group hover:bg-background/80 transition-colors cursor-pointer">
|
||||
<span class="text-[9px]">Time:</span>
|
||||
<div class="absolute bottom-4 left-1/2 -translate-x-1/2 text-[9px] md:text-[10px] font-bold uppercase tracking-widest text-muted-foreground/50 flex items-center gap-1 md:gap-2 z-30 w-[calc(100%-2rem)] justify-center">
|
||||
<span class="truncate">← Soon</span>
|
||||
<div class="flex items-center gap-1 bg-background/50 backdrop-blur-sm px-1.5 md:px-2 py-0.5 rounded-full border border-border/50 group hover:bg-background/80 transition-colors cursor-pointer shrink-0">
|
||||
<span class="text-[8px] md:text-[9px]">Time:</span>
|
||||
<Select
|
||||
value={store.matrixScaleDays.toString()}
|
||||
onChange={(val) => val && setStore("matrixScaleDays", parseInt(val))}
|
||||
@@ -32,15 +32,15 @@ export const MatrixView: Component = () => {
|
||||
</SelectItem>
|
||||
)}
|
||||
>
|
||||
<SelectTrigger class="h-auto p-0 min-w-[50px] bg-transparent border-none shadow-none focus:ring-0 text-[10px] font-black text-foreground">
|
||||
<SelectTrigger class="h-auto p-0 min-w-[40px] md:min-w-[50px] bg-transparent border-none shadow-none focus:ring-0 text-[8px] md:text-[10px] font-black text-foreground">
|
||||
<span>{store.matrixScaleDays} {store.matrixScaleDays === 1 ? 'day' : 'days'}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent class="z-[150]" />
|
||||
</Select>
|
||||
</div>
|
||||
<span>Later →</span>
|
||||
<span class="truncate">Later →</span>
|
||||
</div>
|
||||
<div class="absolute left-4 top-1/2 -translate-y-1/2 -rotate-90 text-[10px] font-bold uppercase tracking-widest text-muted-foreground/50 pointer-events-none">
|
||||
<div class="absolute left-2 md:left-4 top-1/2 -translate-y-1/2 -rotate-90 text-[8px] md:text-[10px] font-bold uppercase tracking-widest text-muted-foreground/30 pointer-events-none">
|
||||
← Low (Priority) High →
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { type Component, For, createMemo } from "solid-js";
|
||||
import { store, calculateUrgencyScore } from "@/store";
|
||||
import { store, getCombinedScore, matchesFilter } from "@/store";
|
||||
import { TaskCard } from "@/components/TaskCard";
|
||||
|
||||
export const PriorityView: Component = () => {
|
||||
const sortedTasks = createMemo(() => {
|
||||
return [...store.tasks].filter(t => !t.deletedAt).sort((a, b) => {
|
||||
if (a.completed !== b.completed) return a.completed ? 1 : -1;
|
||||
if (a.priority !== b.priority) return b.priority - a.priority;
|
||||
const uA = calculateUrgencyScore(a.dueDate);
|
||||
const uB = calculateUrgencyScore(b.dueDate);
|
||||
return uB - uA;
|
||||
});
|
||||
return [...store.tasks]
|
||||
.filter(t => !t.deletedAt && matchesFilter(t))
|
||||
.sort((a, b) => {
|
||||
if (a.completed !== b.completed) return a.completed ? 1 : -1;
|
||||
// Use combined score to account for priority, urgency, and tags
|
||||
return getCombinedScore(b) - getCombinedScore(a);
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
+146
-48
@@ -1,45 +1,59 @@
|
||||
import { type Component, For } from "solid-js";
|
||||
import { ThemeToggle } from "../components/ThemeToggle";
|
||||
import { store, restoreTask, deleteTaskPermanently, setMatrixScaleDays } from "@/store";
|
||||
import { Trash2, Undo2, ArrowLeftRight } from "lucide-solid";
|
||||
import { Trash2, Undo2, ArrowLeftRight, Hash } from "lucide-solid";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "solid-sonner";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import { upsertTagDefinition, removeTagDefinition, renameTagDefinition } from "@/store";
|
||||
import { createSignal } from "solid-js";
|
||||
|
||||
export const SettingsView: Component = () => {
|
||||
return (
|
||||
<div class="px-6 py-10 max-w-2xl mx-auto space-y-10 animate-in fade-in slide-in-from-bottom-2 duration-500">
|
||||
<header class="space-y-1 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold tracking-tight">Settings</h1>
|
||||
<p class="text-muted-foreground">{pb.authStore.model?.email}</p>
|
||||
<div class="w-full max-w-2xl mx-auto py-4 sm:py-10 px-4 sm:px-0 space-y-8 sm:space-y-12 animate-in fade-in slide-in-from-bottom-2 duration-500 min-w-0 overflow-hidden text-balance">
|
||||
<header class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h1 class="text-2xl sm:text-4xl font-black tracking-tighter">Settings</h1>
|
||||
<p class="text-[10px] sm:text-xs text-muted-foreground truncate opacity-70 font-mono tracking-tight" title={pb.authStore.model?.email}>
|
||||
{pb.authStore.model?.email}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => { pb.authStore.clear(); location.reload(); }}>Sign Out</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="w-full sm:w-auto font-black uppercase tracking-widest text-[10px] h-10 sm:h-8 rounded-xl shadow-sm border-border/60"
|
||||
onClick={() => { pb.authStore.clear(); location.reload(); }}
|
||||
>
|
||||
Sign Out
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div class="grid gap-6">
|
||||
<section class="p-5 rounded-2xl border border-border bg-card shadow-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<section class="p-4 sm:p-5 rounded-2xl border border-border bg-card shadow-sm">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div class="space-y-0.5">
|
||||
<h3 class="text-base font-semibold">Appearance</h3>
|
||||
<p class="text-sm text-muted-foreground">Select your preferred color scheme.</p>
|
||||
</div>
|
||||
<ThemeToggle />
|
||||
<div class="flex justify-start sm:justify-end">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Matrix Configuration */}
|
||||
<section class="p-5 rounded-2xl border border-border bg-card shadow-sm space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-0.5">
|
||||
<section class="p-4 sm:p-5 rounded-2xl border border-border bg-card shadow-sm space-y-4">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div class="space-y-0.5 min-w-0">
|
||||
<h3 class="text-base font-semibold flex items-center gap-2">
|
||||
<ArrowLeftRight size={16} />
|
||||
Matrix Horizon
|
||||
</h3>
|
||||
<p class="text-sm text-muted-foreground">Adjust the time scale for your strategy grid.</p>
|
||||
<p class="text-sm text-muted-foreground truncate sm:whitespace-normal">Adjust the time scale for your strategy grid.</p>
|
||||
</div>
|
||||
<div class="w-[120px]">
|
||||
<div class="w-full sm:w-[120px] shrink-0">
|
||||
<Select
|
||||
value={store.matrixScaleDays.toString()}
|
||||
onChange={(val) => val && setMatrixScaleDays(parseInt(val))}
|
||||
@@ -47,7 +61,7 @@ export const SettingsView: Component = () => {
|
||||
placeholder="Select days"
|
||||
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue} days</SelectItem>}
|
||||
>
|
||||
<SelectTrigger class="h-9">
|
||||
<SelectTrigger class="h-9 w-full">
|
||||
<span class="text-sm font-medium">{store.matrixScaleDays} days</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent />
|
||||
@@ -56,53 +70,78 @@ export const SettingsView: Component = () => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="p-5 rounded-2xl border border-border bg-card shadow-sm space-y-4">
|
||||
<section class="p-4 sm:p-5 rounded-2xl border border-border bg-card shadow-sm space-y-4">
|
||||
<div class="space-y-0.5">
|
||||
<h3 class="text-base font-semibold flex items-center gap-2">
|
||||
<Trash2 size={16} />
|
||||
Trash
|
||||
<Hash size={16} />
|
||||
Global Tags
|
||||
</h3>
|
||||
<p class="text-sm text-muted-foreground">Items are permanently deleted after 7 days.</p>
|
||||
<p class="text-sm text-muted-foreground">Manage your global tag definitions and values.</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<For each={store.tasks.filter(t => t.deletedAt)} fallback={
|
||||
<For each={Object.entries(store.tagDefinitions || {}).sort((a, b) => a[0].localeCompare(b[0]))} fallback={
|
||||
<div class="text-center py-8 text-muted-foreground text-sm italic border border-dashed border-border rounded-xl">
|
||||
Trash is empty.
|
||||
No specific tag definitions found.
|
||||
</div>
|
||||
}>
|
||||
{(task) => {
|
||||
const daysLeft = Math.ceil(((task.deletedAt || 0) + (7 * 24 * 60 * 60 * 1000) - Date.now()) / (1000 * 60 * 60 * 24));
|
||||
{([tagName, val]) => {
|
||||
const [isEditingName, setIsEditingName] = createSignal(false);
|
||||
const [tempName, setTempName] = createSignal(tagName);
|
||||
|
||||
const handleRename = () => {
|
||||
if (tempName() && tempName() !== tagName) {
|
||||
renameTagDefinition(tagName, tempName());
|
||||
}
|
||||
setIsEditingName(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="flex items-center justify-between p-3 rounded-xl bg-muted/30 border border-border/50 group hover:bg-muted/50 transition-colors">
|
||||
<div class="min-w-0 flex-1 mr-4">
|
||||
<p class="font-medium truncate">{task.title || "Untitled"}</p>
|
||||
<p class="text-[10px] text-muted-foreground">Expires in {Math.max(0, daysLeft)} days</p>
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between p-3 rounded-xl bg-muted/30 border border-border/50 gap-3 sm:gap-4">
|
||||
<div class="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div class={cn("w-2 h-2 rounded-full shrink-0", val > 5 ? "bg-green-500" : val < 5 ? "bg-red-500" : "bg-gray-400")} />
|
||||
|
||||
{isEditingName() ? (
|
||||
<input
|
||||
class="flex h-7 min-w-0 w-full rounded-md border border-input bg-background px-2 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
value={tempName()}
|
||||
onInput={(e) => setTempName(e.currentTarget.value)}
|
||||
onBlur={handleRename}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleRename()}
|
||||
autofocus
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
class="font-medium cursor-pointer hover:underline decoration-dashed underline-offset-4 truncate"
|
||||
onClick={() => setIsEditingName(true)}
|
||||
title="Click to rename"
|
||||
>
|
||||
{tagName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 px-2 text-xs hover:bg-green-500/10 hover:text-green-600"
|
||||
onClick={() => {
|
||||
restoreTask(task.id);
|
||||
toast.success("Task restored");
|
||||
}}
|
||||
>
|
||||
<Undo2 size={14} class="mr-1" />
|
||||
Recover
|
||||
</Button>
|
||||
|
||||
<div class="flex items-center grow sm:grow-0 justify-between sm:justify-end gap-3 sm:gap-4 shrink-0 border-t sm:border-t-0 pt-2 sm:pt-0 border-border/20">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[10px] text-muted-foreground font-bold uppercase tracking-wider">Val</span>
|
||||
<select
|
||||
value={String(val)}
|
||||
onChange={(e) => upsertTagDefinition(tagName, parseInt(e.currentTarget.value))}
|
||||
class="flex h-8 w-16 items-center justify-between rounded-md border border-input bg-background px-2 py-1 text-xs shadow-sm ring-offset-background focus:outline-none focus:ring-1 focus:ring-ring appearance-none"
|
||||
>
|
||||
<For each={["10", "9", "8", "7", "6", "5", "4", "3", "2", "1", "0"]}>
|
||||
{(v) => <option value={v}>{v}</option>}
|
||||
</For>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 hover:bg-red-500/10 hover:text-red-600"
|
||||
class="h-8 w-8 hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={() => {
|
||||
// For permanent deletion, still using confirm for safety but ensuring toast follows
|
||||
if (confirm("Permanently delete this task? This cannot be undone.")) {
|
||||
deleteTaskPermanently(task.id);
|
||||
toast.error("Task permanently deleted", {
|
||||
duration: 3000
|
||||
});
|
||||
if (confirm(`Delete tag "${tagName}" globally? This will remove it from all tasks.`)) {
|
||||
removeTagDefinition(tagName);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -115,6 +154,65 @@ export const SettingsView: Component = () => {
|
||||
</For>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="p-4 sm:p-5 rounded-2xl border border-border bg-card shadow-sm space-y-4 min-w-0 overflow-hidden">
|
||||
<div class="space-y-0.5 min-w-0">
|
||||
<h3 class="text-base font-semibold flex items-center gap-2">
|
||||
<Trash2 size={16} />
|
||||
Trash
|
||||
</h3>
|
||||
<p class="text-sm text-muted-foreground truncate">Items are permanently deleted after 7 days.</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<For each={store.tasks.filter(t => t.deletedAt)} fallback={
|
||||
<div class="text-center py-8 text-muted-foreground text-sm italic border border-dashed border-border rounded-xl">
|
||||
Trash is empty.
|
||||
</div>
|
||||
}>
|
||||
{(task) => {
|
||||
const daysLeft = Math.ceil(((task.deletedAt || 0) + (7 * 24 * 60 * 60 * 1000) - Date.now()) / (1000 * 60 * 60 * 24));
|
||||
return (
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between p-3.5 rounded-2xl bg-muted/20 border border-border/40 group hover:bg-muted/40 transition-all gap-3 min-w-0">
|
||||
<div class="min-w-0 flex-1 px-1">
|
||||
<p class="font-semibold truncate text-sm sm:text-base">{task.title || "Untitled"}</p>
|
||||
<p class="text-[11px] text-muted-foreground opacity-70">Expires in {Math.max(0, daysLeft)} days</p>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-2 shrink-0 border-t sm:border-t-0 pt-3 sm:pt-0 border-border/10">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-9 px-4 text-[10px] font-bold uppercase tracking-widest hover:bg-green-500/10 hover:text-green-600 bg-background/50 border border-border/50 transition-all rounded-xl shadow-sm"
|
||||
onClick={() => {
|
||||
restoreTask(task.id);
|
||||
toast.success("Task restored");
|
||||
}}
|
||||
>
|
||||
<Undo2 size={14} class="mr-2" />
|
||||
Recover
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-9 w-9 hover:bg-red-500/10 hover:text-red-600 rounded-xl border border-border/50 bg-background/30"
|
||||
onClick={() => {
|
||||
if (confirm("Permanently delete this task? This cannot be undone.")) {
|
||||
deleteTaskPermanently(task.id);
|
||||
toast.error("Task permanently deleted");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { type Component, For, createMemo } from "solid-js";
|
||||
import { store, calculateUrgencyScore } from "@/store";
|
||||
import { store, calculateUrgencyScore, getCombinedScore, matchesFilter } from "@/store";
|
||||
import { TaskCard } from "@/components/TaskCard";
|
||||
|
||||
export const UrgencyView: Component = () => {
|
||||
const sortedTasks = createMemo(() => {
|
||||
return [...store.tasks].filter(t => !t.deletedAt).sort((a, b) => {
|
||||
if (a.completed !== b.completed) return a.completed ? 1 : -1;
|
||||
const uA = calculateUrgencyScore(a.dueDate);
|
||||
const uB = calculateUrgencyScore(b.dueDate);
|
||||
if (uA !== uB) return uB - uA;
|
||||
return b.priority - a.priority;
|
||||
});
|
||||
return [...store.tasks]
|
||||
.filter(t => !t.deletedAt && matchesFilter(t))
|
||||
.sort((a, b) => {
|
||||
if (a.completed !== b.completed) return a.completed ? 1 : -1;
|
||||
const uA = calculateUrgencyScore(a.dueDate);
|
||||
const uB = calculateUrgencyScore(b.dueDate);
|
||||
if (uA !== uB) return uB - uA;
|
||||
// Tie-break with combined score (Priority + Tags)
|
||||
return getCombinedScore(b) - getCombinedScore(a);
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user