added tags and tag manager

This commit is contained in:
2026-01-31 14:14:39 -06:00
parent 9ca490075b
commit d6864a9443
11 changed files with 633 additions and 43 deletions
+126 -26
View File
@@ -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<string>("5");
const [urgency, setUrgency] = createSignal<string>("5");
const [tags, setTags] = createSignal<string[]>([]);
// 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 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 = () => {
<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,22 @@ 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="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} />
+95
View File
@@ -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-[260px] 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()}
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>
);
};
+17 -1
View File
@@ -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) => {
<span>P{props.task.priority}</span>
</div>
{/* Tags */}
{props.task.tags && props.task.tags.length > 0 && (
<div class="flex items-center gap-1">
<For each={props.task.tags}>
{(tag) => (
<Badge variant="secondary" class="h-5 px-1.5 text-[10px] gap-1 bg-muted/50 border-border/50">
<div class={cn("w-1.5 h-1.5 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">
{/* Due Date - Static Layer on Top */}
+36 -2
View File
@@ -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<TaskDetailProps> = (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<TaskDetailProps> = (props) => {
<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>}
>
@@ -138,6 +146,7 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
</SelectTrigger>
<SelectContent />
</Select>
</div>
{/* Commands Shortcut */}
@@ -202,6 +211,31 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
</div>
</div>
{/* Tags Row */}
<div class="flex items-center gap-2 mt-4 overflow-x-auto no-scrollbar pb-1">
<span class="text-[10px] uppercase font-bold tracking-wider text-muted-foreground/70 shrink-0">Tags</span>
<div class="flex items-center gap-1.5 min-w-0">
<TagPicker
selectedTags={props.task.tags || []}
onTagsChange={updateTags}
/>
<div class="flex items-center gap-1.5 flex-nowrap overflow-x-auto no-scrollbar">
<For each={props.task.tags || []}>
{(tag) => (
<Badge
variant="secondary"
class="h-7 px-2 text-xs gap-1 bg-muted/40 border-border/50 hover:bg-destructive/10 hover:border-destructive/30 cursor-pointer group/tag transition-all whitespace-nowrap"
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 class="h-px w-full bg-border mt-4" />
</div>
+36
View File
@@ -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 }
+90
View File
@@ -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,
}
+1 -1
View File
@@ -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}
+132 -2
View File
@@ -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";
@@ -31,6 +31,7 @@ interface TaskStore {
uWeight: number;
matrixScaleDays: number;
prefId?: string; // ID of the user_preferences record
tagDefinitions?: Record<string, number>;
}
// Initial empty state
@@ -39,6 +40,7 @@ export const [store, setStore] = createStore<TaskStore>({
pWeight: 1.0,
uWeight: 1.0,
matrixScaleDays: 30,
tagDefinitions: {}, // Map<Name, Value 0-10>
});
// 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 = () => {
+3 -5
View File
@@ -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);
});
});
+91 -1
View File
@@ -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 = () => {
</div>
</section>
<section class="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">
<Hash size={16} />
Global Tags
</h3>
<p class="text-sm text-muted-foreground">Manage your global tag definitions and values.</p>
</div>
<div class="space-y-2">
<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">
No specific tag definitions found.
</div>
}>
{([tagName, val]) => {
const [isEditingName, setIsEditingName] = createSignal(false);
const [tempName, setTempName] = createSignal(tagName);
const handleRename = () => {
if (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">
<div class="flex items-center gap-3 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 w-48 rounded-md border border-input bg-background px-2 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"
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"
onClick={() => setIsEditingName(true)}
title="Click to rename"
>
{tagName}
</span>
)}
</div>
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<span class="text-[10px] text-muted-foreground font-bold uppercase tracking-wider">Val</span>
<select
value={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-destructive/10 hover:text-destructive"
onClick={() => {
if (confirm(`Delete tag "${tagName}" globally? This will remove it from all tasks.`)) {
removeTagDefinition(tagName);
}
}}
>
<Trash2 size={14} />
</Button>
</div>
</div>
);
}}
</For>
</div>
</section>
<section class="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">
@@ -115,6 +203,8 @@ export const SettingsView: Component = () => {
</For>
</div>
</section>
</div>
</div>
);
+3 -2
View File
@@ -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);
});
});