diff --git a/src/components/QuickEntry.tsx b/src/components/QuickEntry.tsx index 1f84e8d..78756d5 100644 --- a/src/components/QuickEntry.tsx +++ b/src/components/QuickEntry.tsx @@ -1,15 +1,22 @@ -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, upsertTagDefinition } 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"; 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("5"); const [urgency, setUrgency] = createSignal("5"); + const [tags, setTags] = createSignal([]); // Reactive shorthand parsing createEffect(() => { @@ -28,6 +35,41 @@ 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); + if (match[2]) { + const val = Math.min(10, Math.max(0, parseInt(match[2]))); + upsertTagDefinition(tagName, val); + } + } + } + + 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,56 +123,98 @@ export const QuickEntry: Component = () => { } }; - const parseAndAdd = () => { + const parseAndAdd = async () => { const text = input(); if (!text || !priority() || !urgency()) return; let finalPriority = parseInt(priority()); let finalUrgency = parseInt(urgency()); + const finalTags = [...tags()]; + let title = text; - // Parse shorthand overrides if present + // 1. Handle Priority / Urgency (Simple regexes as they don't have spaces) const pMatch = text.match(/\/p(\d+)/); - if (pMatch) finalPriority = Math.min(10, Math.max(1, parseInt(pMatch[1]))); + if (pMatch) { + finalPriority = Math.min(10, Math.max(1, parseInt(pMatch[1]))); + title = title.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]))); + title = title.replace(uMatchNumeric[0], ""); + } + + // 2. Handle Tags: /t [name]:[value] + // We look for /t, then name, then optional :value. + // We split by /t to find all segments. + const segments = text.split(/\/t\s*/); + // segments[0] is the part before first /t + title = segments[0]; + + for (let i = 1; i < segments.length; i++) { + const seg = segments[i]; + // Name ends at first : or / (but / is handled by split) or end of string + const firstColon = seg.indexOf(':'); + let tagName = ""; + let tagValue: number | undefined; + let remainingText = ""; + + if (firstColon === -1) { + // No colon in this segment. Segment might be "Tag Name Title" or "Tag Name /t" + // But the user said / or : are endpoints. + // So if no colon, the WHOLE segment is the tag name? + // Wait, if I type "/t Tag Title", the user said / and : are endpoints. + // So if neither is there, the tag continues to end of string? + // Actually, let's treat the FIRST SPACE after some non-space text as a fallback? + // No, user specifically said: "It should allow spaces in this particular parse... use / or : as end points." + // So if I type "/t Tag Name", it IS the tag name. + 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 { + // Colon was just a separator for title + remainingText = afterColon; + } + } + + if (tagName) { + if (!finalTags.includes(tagName)) { + finalTags.push(tagName); + } + if (tagValue !== undefined) { + upsertTagDefinition(tagName, tagValue); + } + } + title += " " + remainingText; } 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 }); setInput(""); setPriority("5"); setUrgency("5"); + setTags([]); + lastParsedTags = []; setDateString(""); setIsOpen(false); }; @@ -172,7 +256,7 @@ export const QuickEntry: Component = () => { 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" + /> + + + {(tag) => + + + + +
+ + +
+ + + + + ); +}; diff --git a/src/components/TaskCard.tsx b/src/components/TaskCard.tsx index 1e98641..e22680a 100644 --- a/src/components/TaskCard.tsx +++ b/src/components/TaskCard.tsx @@ -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 } from "@/store"; import { cn } from "@/lib/utils"; import { CheckCircle2, Circle, Clock, ArrowUpCircle } 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)); @@ -52,6 +54,20 @@ export const TaskCard: Component<{ task: Task }> = (props) => { P{props.task.priority} + {/* Tags */} + {props.task.tags && props.task.tags.length > 0 && ( +
+ + {(tag) => ( + +
5 ? "bg-green-500" : (store.tagDefinitions?.[tag] ?? 5) < 5 ? "bg-red-500" : "bg-gray-400")} /> + {tag} + + )} + +
+ )} + {/* Due Date + Urgency Slide-out */}
{/* Due Date - Static Layer on Top */} diff --git a/src/components/TaskDetail.tsx b/src/components/TaskDetail.tsx index dadf0f7..4ef98a3 100644 --- a/src/components/TaskDetail.tsx +++ b/src/components/TaskDetail.tsx @@ -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 { 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"; @@ -61,6 +65,10 @@ export const TaskDetail: Component = (props) => { } }; + const updateTags = (tags: string[]) => { + updateTask(props.task.id, { tags }); + }; + const onDateInputChange = (e: Event) => { const val = (e.currentTarget as HTMLInputElement).value; if (val) { @@ -106,7 +114,7 @@ export const TaskDetail: Component = (props) => { +
{/* Commands Shortcut */} @@ -202,6 +211,31 @@ export const TaskDetail: Component = (props) => {
+ {/* Tags Row */} +
+ Tags +
+ +
+ + {(tag) => ( + updateTags((props.task.tags || []).filter(t => t !== tag))} + > +
5 ? "bg-green-500" : (store.tagDefinitions?.[tag] ?? 5) < 5 ? "bg-red-500" : "bg-gray-400")} /> + {tag} + + )} + +
+
+
+
@@ -216,6 +250,6 @@ export const TaskDetail: Component = (props) => {
- + ); }; diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..7913157 --- /dev/null +++ b/src/components/ui/badge.tsx @@ -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 { } + +const Badge: Component = (props) => { + const [local, others] = splitProps(props, ["class", "variant"]) + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx new file mode 100644 index 0000000..0939d06 --- /dev/null +++ b/src/components/ui/command.tsx @@ -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> = (props) => { + const [local, others] = splitProps(props, ["class"]) + return ( +
+ ) +} + +const CommandInput: Component & { onValueChange?: (value: string) => void }> = (props) => { + const [local, others] = splitProps(props, ["class", "onValueChange"]) + return ( +
+ + local.onValueChange?.(e.currentTarget.value)} + {...others} + /> +
+ ) +} + +const CommandList: Component> = (props) => { + const [local, others] = splitProps(props, ["class"]) + return ( +
+ ) +} + +const CommandEmpty: Component> = (props) => { + return
+} + +const CommandGroup: Component & { heading?: string }> = (props) => { + const [local, others] = splitProps(props, ["class", "heading"]) + return ( +
+ {local.heading && ( +
+ {local.heading} +
+ )} + {others.children} +
+ ) +} + +const CommandItem: Component & { onSelect?: () => void, value?: string }> = (props) => { + const [local, others] = splitProps(props, ["class", "onSelect", "value"]) + return ( +
+ ) +} + +export { + Command, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, +} diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx index de07dab..8f34d4e 100644 --- a/src/components/ui/popover.tsx +++ b/src/components/ui/popover.tsx @@ -11,7 +11,7 @@ const PopoverContent = (props: import("solid-js").ComponentProps ; } // Initial empty state @@ -39,6 +40,7 @@ export const [store, setStore] = createStore({ pWeight: 1.0, uWeight: 1.0, matrixScaleDays: 30, + tagDefinitions: {}, // Map }); // Auto-persist changes to localStorage (wrapped in root to avoid console warning) @@ -84,7 +86,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 +142,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 }); } @@ -354,6 +371,119 @@ 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 { + // Need to fetch user record first? Or iterate local? + // Pocketbase update can take partial JSON? + // "Taskgrid_pref" is a JSON field. We usually need to send the whole object or merge it manually if the backend doesn't merge. + // But here we rely on store state having everything. + + // Re-construct full prefs object + const prefs = { + pWeight: store.pWeight, + uWeight: store.uWeight, + matrixScaleDays: store.matrixScaleDays, + tagDefinitions: map + }; + + await pb.collection('users').update(userId, { + Taskgrid_pref: prefs + }); + } catch (e) { + console.error("Failed to persist tag definition", e); + toast.error("Failed to save tag definition"); + } + } +}; + +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"); + } + } +}; + // Legacy cleanup - We don't need local storage persistence setup anymore export const setupPersistence = () => { diff --git a/src/views/PriorityView.tsx b/src/views/PriorityView.tsx index 7b9c81e..16c5ceb 100644 --- a/src/views/PriorityView.tsx +++ b/src/views/PriorityView.tsx @@ -1,15 +1,13 @@ import { type Component, For, createMemo } from "solid-js"; -import { store, calculateUrgencyScore } from "@/store"; +import { store, getCombinedScore } 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; + // Use combined score to account for priority, urgency, and tags + return getCombinedScore(b) - getCombinedScore(a); }); }); diff --git a/src/views/SettingsView.tsx b/src/views/SettingsView.tsx index 6bf75c8..ddbe6a0 100644 --- a/src/views/SettingsView.tsx +++ b/src/views/SettingsView.tsx @@ -1,11 +1,14 @@ 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 ( @@ -56,6 +59,91 @@ export const SettingsView: Component = () => {
+
+
+

+ + Global Tags +

+

Manage your global tag definitions and values.

+
+ +
+ a[0].localeCompare(b[0]))} fallback={ +
+ No specific tag definitions found. +
+ }> + {([tagName, val]) => { + const [isEditingName, setIsEditingName] = createSignal(false); + const [tempName, setTempName] = createSignal(tagName); + + const handleRename = () => { + if (tempName() !== tagName) { + renameTagDefinition(tagName, tempName()); + } + setIsEditingName(false); + }; + + return ( +
+
+
5 ? "bg-green-500" : val < 5 ? "bg-red-500" : "bg-gray-400")} /> + + {isEditingName() ? ( + setTempName(e.currentTarget.value)} + onBlur={handleRename} + onKeyDown={(e) => e.key === "Enter" && handleRename()} + autofocus + /> + ) : ( + setIsEditingName(true)} + title="Click to rename" + > + {tagName} + + )} +
+ +
+
+ Val + +
+ + +
+
+ ); + }} + +
+
+

@@ -115,6 +203,8 @@ export const SettingsView: Component = () => {

+ +
); diff --git a/src/views/UrgencyView.tsx b/src/views/UrgencyView.tsx index c5eed31..a20a466 100644 --- a/src/views/UrgencyView.tsx +++ b/src/views/UrgencyView.tsx @@ -1,5 +1,5 @@ import { type Component, For, createMemo } from "solid-js"; -import { store, calculateUrgencyScore } from "@/store"; +import { store, calculateUrgencyScore, getCombinedScore } from "@/store"; import { TaskCard } from "@/components/TaskCard"; export const UrgencyView: Component = () => { @@ -9,7 +9,8 @@ export const UrgencyView: Component = () => { const uA = calculateUrgencyScore(a.dueDate); const uB = calculateUrgencyScore(b.dueDate); if (uA !== uB) return uB - uA; - return b.priority - a.priority; + // Tie-break with combined score (Priority + Tags) + return getCombinedScore(b) - getCombinedScore(a); }); });