tag colors and tag list tracking fix

This commit is contained in:
2026-02-02 15:10:29 -06:00
parent 3212402788
commit e36ae92441
12 changed files with 548 additions and 208 deletions
+49 -10
View File
@@ -1,13 +1,16 @@
import { type Component, createSignal, For, Show } from "solid-js"; import { type Component, createSignal, For, Show } from "solid-js";
import { Search, X, Hash, ArrowUpCircle, Clock } from "lucide-solid"; import { Search, X, Hash, ArrowUpCircle, Clock, Minus } from "lucide-solid";
import { store, setFilter, clearFilter } from "@/store"; import { store, setFilter, clearFilter } from "@/store";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Badge } from "./ui/badge"; import { Badge } from "./ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger } from "./ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger } from "./ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { useTheme } from "./ThemeProvider";
import { getThemeAdjustedColor } from "@/lib/colors";
export const FilterBar: Component = () => { export const FilterBar: Component = () => {
const { resolvedTheme } = useTheme();
const [isOpen, setIsOpen] = createSignal(false); const [isOpen, setIsOpen] = createSignal(false);
const hasActiveFilters = () => { const hasActiveFilters = () => {
@@ -15,7 +18,7 @@ export const FilterBar: Component = () => {
return f.query !== "" || f.tags.length > 0 || f.priorityMin !== 1 || f.priorityMax !== 10 || f.urgencyMin !== 1 || f.urgencyMax !== 10; 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 allTags = () => store.tagDefinitions.map(d => d.name).sort();
return ( return (
<div class="fixed top-4 right-5 sm:right-6 z-[60]"> <div class="fixed top-4 right-5 sm:right-6 z-[60]">
@@ -162,27 +165,63 @@ export const FilterBar: Component = () => {
<div class="space-y-2.5"> <div class="space-y-2.5">
<label class="text-[10px] font-black uppercase tracking-widest text-muted-foreground ml-1 flex items-center justify-between"> <label class="text-[10px] font-black uppercase tracking-widest text-muted-foreground ml-1 flex items-center justify-between">
Filter by Tags Filter by Tags
<span class="text-[9px] lowercase font-medium opacity-50 tracking-normal">Click to remove</span> <span class="text-[9px] lowercase font-medium opacity-50 tracking-normal text-right">
Click tag name to toggle exclude<br />
Click X to remove
</span>
</label> </label>
<div class="flex flex-wrap gap-2 min-h-[44px] p-2 bg-muted/10 border border-dashed border-border/60 rounded-xl"> <div class="flex flex-wrap gap-2 min-h-[44px] p-2 bg-muted/10 border border-dashed border-border/60 rounded-xl">
<For each={store.filter.tags}> <For each={store.filter.tags}>
{(tag) => ( {(tag) => (
<Badge <Badge
variant="secondary" variant="secondary"
class="h-7 px-2.5 rounded-lg text-xs gap-2 cursor-pointer transition-all hover:bg-destructive/10 hover:text-destructive hover:border-destructive/30 border-border/40" class={cn(
onClick={() => setFilter({ tags: store.filter.tags.filter(t => t !== tag) })} "h-7 px-2.5 rounded-lg text-xs gap-2 transition-all border shadow-sm",
tag.excluded
? "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20 hover:bg-red-500/20"
: "bg-indigo-500/10 text-indigo-600 dark:text-indigo-300 border-indigo-500/20 hover:bg-indigo-500/20"
)}
style={!tag.excluded ? (() => {
const def = store.tagDefinitions.find(d => d.name === tag.name);
const color = getThemeAdjustedColor(def?.color, def?.theme, resolvedTheme());
return color ? {
"background-color": `${color}15`,
"color": color,
"border-color": `${color}30`
} : {};
})() : {}}
> >
<Hash size={12} class="opacity-50" /> <div
{tag} class="flex items-center gap-2 cursor-pointer h-full"
<X size={10} /> onClick={() => {
const newTags = store.filter.tags.map(t =>
t.name === tag.name ? { ...t, excluded: !t.excluded } : t
);
setFilter({ tags: newTags });
}}
>
<Show when={tag.excluded} fallback={<Hash size={12} class="opacity-70" />}>
<Minus size={12} class="opacity-100" />
</Show>
<span class={cn(tag.excluded && "line-through opacity-70")}>{tag.name}</span>
</div>
<button
class="p-0.5 hover:bg-foreground/10 rounded transition-colors"
onClick={(e) => {
e.stopPropagation();
setFilter({ tags: store.filter.tags.filter(t => t.name !== tag.name) });
}}
>
<X size={10} />
</button>
</Badge> </Badge>
)} )}
</For> </For>
<Select <Select
value="" value=""
onChange={(val) => { onChange={(val) => {
if (val && !store.filter.tags.includes(val)) { if (val && !store.filter.tags.some(t => t.name === val)) {
setFilter({ tags: [...store.filter.tags, val] }); setFilter({ tags: [...store.filter.tags, { name: val, excluded: false }] });
} }
}} }}
options={allTags()} options={allTags()}
+1 -1
View File
@@ -58,7 +58,7 @@ export const Sidebar: Component<{
> >
<div class={cn("p-4 flex items-center justify-between", !props.isLocked && "pl-14")}> <div class={cn("p-4 flex items-center justify-between", !props.isLocked && "pl-14")}>
<h1 class={cn( <h1 class={cn(
"text-xl font-bold tracking-tighter text-primary px-2 transition-opacity", "text-xl font-bold tracking-tighter text-foreground px-2 transition-opacity",
(props.isLocked || props.isPeeking) (props.isLocked || props.isPeeking)
? "opacity-100 duration-500 delay-[50ms]" ? "opacity-100 duration-500 delay-[50ms]"
: "opacity-0 duration-116 delay-0" : "opacity-0 duration-116 delay-0"
+28 -12
View File
@@ -1,12 +1,13 @@
import { type Component, createSignal, createEffect, onCleanup, onMount, For, Show } from "solid-js"; import { type Component, createSignal, createEffect, onCleanup, onMount, For, Show } from "solid-js";
import { addTask, calculateDateFromUrgency, calculateUrgencyFromDate, store, upsertMultipleTagDefinitions, type TaskTemplate } from "@/store"; import { addTask, calculateDateFromUrgency, calculateUrgencyFromDate, store, upsertTagDefinition, type TaskTemplate } from "@/store";
import { Search, Command, Clock, ArrowUpCircle, Plus, Copy, ChevronDown } from "lucide-solid"; import { Search, Command, Clock, ArrowUpCircle, Plus, Copy, ChevronDown } from "lucide-solid";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { TextField, TextFieldInput } from "@/components/ui/textfield"; import { TextField, TextFieldInput } from "@/components/ui/textfield";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
import { TagPicker } from "@/components/TagPicker"; import { TagPicker } from "@/components/TagPicker";
import { useTheme } from "./ThemeProvider";
import { getThemeAdjustedColor } from "@/lib/colors";
import { lazy, Suspense } from "solid-js"; import { lazy, Suspense } from "solid-js";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { toast } from "solid-sonner"; import { toast } from "solid-sonner";
@@ -15,6 +16,7 @@ import { PRIORITY_OPTIONS, URGENCY_OPTIONS } from "@/lib/constants";
const TaskEditor = lazy(() => import("./TaskEditor").then(m => ({ default: m.TaskEditor }))); const TaskEditor = lazy(() => import("./TaskEditor").then(m => ({ default: m.TaskEditor })));
export const QuickEntry: Component = () => { export const QuickEntry: Component = () => {
const { resolvedTheme } = useTheme();
// Diff tracking for reactive tags // Diff tracking for reactive tags
let lastParsedTags: string[] = []; let lastParsedTags: string[] = [];
@@ -156,8 +158,6 @@ export const QuickEntry: Component = () => {
const segments = text.split(/\/t\s*/); const segments = text.split(/\/t\s*/);
let title = segments[0]; let title = segments[0];
const tagsToUpsert: Record<string, number> = {};
for (let i = 1; i < segments.length; i++) { for (let i = 1; i < segments.length; i++) {
const seg = segments[i]; const seg = segments[i];
const firstColon = seg.indexOf(':'); const firstColon = seg.indexOf(':');
@@ -185,18 +185,13 @@ export const QuickEntry: Component = () => {
finalTags.push(tagName); finalTags.push(tagName);
} }
if (tagValue !== undefined) { if (tagValue !== undefined) {
tagsToUpsert[tagName] = tagValue; // update global definition
} else if (store.tagDefinitions?.[tagName] === undefined) { await upsertTagDefinition(tagName, tagValue, undefined, resolvedTheme());
tagsToUpsert[tagName] = 5;
} }
} }
title += " " + remainingText; title += " " + remainingText;
} }
if (Object.keys(tagsToUpsert).length > 0) {
upsertMultipleTagDefinitions(tagsToUpsert);
}
let finalDueDate: Date | null = null; let finalDueDate: Date | null = null;
if (dateString()) { if (dateString()) {
finalDueDate = new Date(dateString()); finalDueDate = new Date(dateString());
@@ -365,9 +360,30 @@ export const QuickEntry: Component = () => {
<Badge <Badge
variant="secondary" 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" 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"
style={{
"background-color": (() => {
const def = store.tagDefinitions.find(d => d.name === tag);
const color = getThemeAdjustedColor(def?.color, def?.theme, resolvedTheme());
return color ? `${color}15` : undefined;
})(),
"border-color": (() => {
const def = store.tagDefinitions.find(d => d.name === tag);
const color = getThemeAdjustedColor(def?.color, def?.theme, resolvedTheme());
return color ? `${color}30` : undefined;
})(),
"color": (() => {
const def = store.tagDefinitions.find(d => d.name === tag);
return getThemeAdjustedColor(def?.color, def?.theme, resolvedTheme()) || "inherit";
})()
}}
onClick={() => setTags(tags().filter(t => t !== tag))} 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")} /> <div
class="w-1.5 h-1.5 rounded-full transition-colors group-hover/tag:bg-destructive"
style={{
"background-color": (store.tagDefinitions.find(d => d.name === tag)?.value ?? 5) > 5 ? "#22c55e" : (store.tagDefinitions.find(d => d.name === tag)?.value ?? 5) < 5 ? "#ef4444" : "#94a3b8"
}}
/>
{tag} {tag}
</Badge> </Badge>
)} )}
+8 -6
View File
@@ -1,5 +1,6 @@
import { type Component, createSignal, For, createEffect } from "solid-js"; import { type Component, createSignal, For, createEffect } from "solid-js";
import { store, upsertTagDefinition } from "@/store"; import { store, upsertTagDefinition } from "@/store";
import { useTheme } from "./ThemeProvider";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Hash, Plus } from "lucide-solid"; import { Hash, Plus } from "lucide-solid";
@@ -10,20 +11,21 @@ interface TagPickerProps {
} }
export const TagPicker: Component<TagPickerProps> = (props) => { export const TagPicker: Component<TagPickerProps> = (props) => {
const { resolvedTheme } = useTheme();
const [open, setOpen] = createSignal(false); const [open, setOpen] = createSignal(false);
const [selectedTagName, setSelectedTagName] = createSignal(""); const [selectedTagName, setSelectedTagName] = createSignal("");
const [valueScore, setValueScore] = createSignal(5); const [valueScore, setValueScore] = createSignal(5);
// Filter tags for the "Name" part // Filter tags for the "Name" part
const allTagNames = () => Object.keys(store.tagDefinitions || {}).sort(); const allTagNames = () => store.tagDefinitions.map(d => d.name).sort();
// When a tag name is selected, update the value score to match its current definition // When a tag name is selected, update the value score to match its current definition
createEffect(() => { createEffect(() => {
const name = selectedTagName().trim(); const name = selectedTagName().trim();
if (name) { if (name) {
const existingVal = store.tagDefinitions?.[name]; const existing = store.tagDefinitions.find(d => d.name === name);
if (existingVal !== undefined) { if (existing) {
setValueScore(existingVal); setValueScore(existing.value);
} }
} }
}); });
@@ -33,7 +35,7 @@ export const TagPicker: Component<TagPickerProps> = (props) => {
if (!name) return; if (!name) return;
// update global definition // update global definition
upsertTagDefinition(name, valueScore()); upsertTagDefinition(name, valueScore(), undefined, resolvedTheme());
// Toggle in current task selection if not already there // Toggle in current task selection if not already there
const currentSelected = props.selectedTags; const currentSelected = props.selectedTags;
@@ -87,7 +89,7 @@ export const TagPicker: Component<TagPickerProps> = (props) => {
<Button size="sm" class="w-full mt-1" onClick={handleApply}> <Button size="sm" class="w-full mt-1" onClick={handleApply}>
<Plus size={14} class="mr-2" /> <Plus size={14} class="mr-2" />
{store.tagDefinitions?.[selectedTagName().trim()] !== undefined ? "Update & Add" : "Create & Add"} {store.tagDefinitions.find(d => d.name === selectedTagName().trim()) ? "Update & Add" : "Create & Add"}
</Button> </Button>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
+29 -2
View File
@@ -4,8 +4,11 @@ import { cn } from "@/lib/utils";
import { CheckCircle2, Circle, Clock, ArrowUpCircle, Copy } from "lucide-solid"; import { CheckCircle2, Circle, Clock, ArrowUpCircle, Copy } from "lucide-solid";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { For } from "solid-js"; import { For } from "solid-js";
import { useTheme } from "./ThemeProvider";
import { getThemeAdjustedColor } from "@/lib/colors";
export const TaskCard: Component<{ task: Task }> = (props) => { export const TaskCard: Component<{ task: Task }> = (props) => {
const { resolvedTheme } = useTheme();
const urgencyLevel = createMemo(() => calculateUrgencyFromDate(props.task.dueDate)); const urgencyLevel = createMemo(() => calculateUrgencyFromDate(props.task.dueDate));
const urgencyColor = () => { const urgencyColor = () => {
@@ -59,8 +62,32 @@ export const TaskCard: Component<{ task: Task }> = (props) => {
<div class="flex flex-wrap items-center gap-1 min-w-0"> <div class="flex flex-wrap items-center gap-1 min-w-0">
<For each={props.task.tags}> <For each={props.task.tags}>
{(tag) => ( {(tag) => (
<Badge variant="secondary" class="h-4 px-1.5 text-[9px] gap-1 bg-muted/50 border-border/50 shrink-0"> <Badge
<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")} /> variant="secondary"
class="h-4 px-1.5 text-[9px] gap-1 bg-muted/50 border-border/50 shrink-0"
style={{
"background-color": (() => {
const def = store.tagDefinitions.find(d => d.name === tag);
const color = getThemeAdjustedColor(def?.color, def?.theme, resolvedTheme());
return color ? `${color}15` : undefined;
})(),
"border-color": (() => {
const def = store.tagDefinitions.find(d => d.name === tag);
const color = getThemeAdjustedColor(def?.color, def?.theme, resolvedTheme());
return color ? `${color}30` : undefined;
})(),
"color": (() => {
const def = store.tagDefinitions.find(d => d.name === tag);
return getThemeAdjustedColor(def?.color, def?.theme, resolvedTheme()) || "inherit";
})()
}}
>
<div
class="w-1 h-1 rounded-full"
style={{
"background-color": (store.tagDefinitions.find(d => d.name === tag)?.value ?? 5) > 5 ? "#22c55e" : (store.tagDefinitions.find(d => d.name === tag)?.value ?? 5) < 5 ? "#ef4444" : "#94a3b8"
}}
/>
{tag} {tag}
</Badge> </Badge>
)} )}
+26 -1
View File
@@ -12,6 +12,8 @@ import { store } from "@/store";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { toast } from "solid-sonner"; import { toast } from "solid-sonner";
import { lazy, Suspense } from "solid-js"; import { lazy, Suspense } from "solid-js";
import { useTheme } from "./ThemeProvider";
import { getThemeAdjustedColor } from "@/lib/colors";
const TaskEditor = lazy(() => import("./TaskEditor").then(m => ({ default: m.TaskEditor }))); const TaskEditor = lazy(() => import("./TaskEditor").then(m => ({ default: m.TaskEditor })));
@@ -22,6 +24,7 @@ interface TaskDetailProps {
} }
export const TaskDetail: Component<TaskDetailProps> = (props) => { export const TaskDetail: Component<TaskDetailProps> = (props) => {
const { resolvedTheme } = useTheme();
// Local state for immediate feedback, synced with props // Local state for immediate feedback, synced with props
const [title, setTitle] = createSignal(props.task.title); const [title, setTitle] = createSignal(props.task.title);
const [editorInstance, setEditorInstance] = createSignal<any>(null); const [editorInstance, setEditorInstance] = createSignal<any>(null);
@@ -247,10 +250,32 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
<Badge <Badge
variant="secondary" 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" class="h-7 px-2 text-xs gap-1.5 bg-muted/30 border-border/50 hover:bg-destructive/10 hover:border-destructive/30 cursor-pointer group/tag transition-all whitespace-nowrap shrink-0"
style={{
"background-color": (() => {
const def = store.tagDefinitions.find(d => d.name === tag);
const color = getThemeAdjustedColor(def?.color, def?.theme, resolvedTheme());
return color ? `${color}15` : undefined;
})(),
"border-color": (() => {
const def = store.tagDefinitions.find(d => d.name === tag);
const color = getThemeAdjustedColor(def?.color, def?.theme, resolvedTheme());
return color ? `${color}30` : undefined;
})(),
"color": (() => {
const def = store.tagDefinitions.find(d => d.name === tag);
return getThemeAdjustedColor(def?.color, def?.theme, resolvedTheme()) || "inherit";
})()
}}
onClick={() => updateTags((props.task.tags || []).filter(t => t !== tag))} 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")} /> <div
class="w-1.5 h-1.5 rounded-full"
style={{
"background-color": (store.tagDefinitions.find(d => d.name === tag)?.value ?? 5) > 5 ? "#22c55e" : (store.tagDefinitions.find(d => d.name === tag)?.value ?? 5) < 5 ? "#ef4444" : "#94a3b8"
}}
/>
{tag} {tag}
<X size={12} class="opacity-0 group-hover/tag:opacity-50 transition-opacity" />
</Badge> </Badge>
)} )}
</For> </For>
+9 -4
View File
@@ -5,12 +5,14 @@ type Theme = "dark" | "light" | "system";
interface ThemeProviderState { interface ThemeProviderState {
theme: () => Theme; theme: () => Theme;
setTheme: (theme: Theme) => void; setTheme: (theme: Theme) => void;
resolvedTheme: () => "light" | "dark";
} }
const ThemeProviderContext = createContext<ThemeProviderState>(); const ThemeProviderContext = createContext<ThemeProviderState>();
export const ThemeProvider: ParentComponent = (props) => { export const ThemeProvider: ParentComponent = (props) => {
const [theme, setTheme] = createSignal<Theme>("system"); const [theme, setTheme] = createSignal<Theme>("system");
const [resolvedTheme, setResolvedTheme] = createSignal<"light" | "dark">("light");
// Load from local storage on mount // Load from local storage on mount
const storedTheme = localStorage.getItem("tasgrid-theme") as Theme | null; const storedTheme = localStorage.getItem("tasgrid-theme") as Theme | null;
@@ -23,19 +25,22 @@ export const ThemeProvider: ParentComponent = (props) => {
root.classList.remove("light", "dark"); root.classList.remove("light", "dark");
const currentTheme = theme(); const currentTheme = theme();
let resolved: "light" | "dark";
if (currentTheme === "system") { if (currentTheme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; resolved = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
root.classList.add(systemTheme); root.classList.add(resolved);
} else { } else {
root.classList.add(currentTheme); resolved = currentTheme === "dark" ? "dark" : "light";
root.classList.add(resolved);
} }
setResolvedTheme(resolved);
localStorage.setItem("tasgrid-theme", currentTheme); localStorage.setItem("tasgrid-theme", currentTheme);
}); });
return ( return (
<ThemeProviderContext.Provider value={{ theme, setTheme }}> <ThemeProviderContext.Provider value={{ theme, setTheme, resolvedTheme }}>
{props.children} {props.children}
</ThemeProviderContext.Provider> </ThemeProviderContext.Provider>
); );
+44 -44
View File
@@ -57,51 +57,51 @@
} }
} }
@layer base { :root {
:root { --background: hsl(0 0% 100%);
--background: hsl(0 0% 100%); --foreground: hsl(222.2 84% 4.9%);
--foreground: hsl(222.2 84% 4.9%); --card: hsl(0 0% 100%);
--card: hsl(0 0% 100%); --card-foreground: hsl(222.2 84% 4.9%);
--card-foreground: hsl(222.2 84% 4.9%); --popover: hsl(0 0% 100%);
--popover: hsl(0 0% 100%); --popover-foreground: hsl(222.2 84% 4.9%);
--popover-foreground: hsl(222.2 84% 4.9%); --primary: hsl(250 89% 60%);
--primary: hsl(222.2 47.4% 11.2%); /* Vibrant Indigo */
--primary-foreground: hsl(210 40% 98%); --primary-foreground: hsl(0 0% 100%);
--secondary: hsl(210 40% 96.1%); --secondary: hsl(210 40% 96.1%);
--secondary-foreground: hsl(222.2 47.4% 11.2%); --secondary-foreground: hsl(222.2 47.4% 11.2%);
--muted: hsl(210 40% 96.1%); --muted: hsl(210 40% 96.1%);
--muted-foreground: hsl(215.4 16.3% 46.9%); --muted-foreground: hsl(215.4 16.3% 46.9%);
--accent: hsl(210 40% 96.1%); --accent: hsl(210 40% 96.1%);
--accent-foreground: hsl(222.2 47.4% 11.2%); --accent-foreground: hsl(222.2 47.4% 11.2%);
--destructive: hsl(0 84.2% 60.2%); --destructive: hsl(0 84.2% 60.2%);
--destructive-foreground: hsl(210 40% 98%); --destructive-foreground: hsl(210 40% 98%);
--border: hsl(214.3 31.8% 91.4%); --border: hsl(214.3 31.8% 91.4%);
--input: hsl(214.3 31.8% 91.4%); --input: hsl(214.3 31.8% 91.4%);
--ring: hsl(222.2 84% 4.9%); --ring: hsl(250 89% 60%);
--radius: 0.5rem; --radius: 0.5rem;
} }
.dark { .dark {
--background: hsl(222.2 84% 4.9%); --background: hsl(222.2 84% 4.9%);
--foreground: hsl(210 40% 98%); --foreground: hsl(210 40% 98%);
--card: hsl(222.2 84% 4.9%); --card: hsl(222.2 84% 4.9%);
--card-foreground: hsl(210 40% 98%); --card-foreground: hsl(210 40% 98%);
--popover: hsl(222.2 84% 4.9%); --popover: hsl(222.2 84% 4.9%);
--popover-foreground: hsl(210 40% 98%); --popover-foreground: hsl(210 40% 98%);
--primary: hsl(210 40% 98%); --primary: hsl(250 89% 65%);
--primary-foreground: hsl(222.2 47.4% 11.2%); /* Brighter Indigo for Dark Mode */
--secondary: hsl(217.2 32.6% 17.5%); --primary-foreground: hsl(0 0% 100%);
--secondary-foreground: hsl(210 40% 98%); --secondary: hsl(217.2 32.6% 17.5%);
--muted: hsl(217.2 32.6% 17.5%); --secondary-foreground: hsl(210 40% 98%);
--muted-foreground: hsl(215 20.2% 65.1%); --muted: hsl(217.2 32.6% 17.5%);
--accent: hsl(217.2 32.6% 17.5%); --muted-foreground: hsl(215 20.2% 65.1%);
--accent-foreground: hsl(210 40% 98%); --accent: hsl(217.2 32.6% 17.5%);
--destructive: hsl(0 62.8% 30.6%); --accent-foreground: hsl(210 40% 98%);
--destructive-foreground: hsl(210 40% 98%); --destructive: hsl(0 62.8% 30.6%);
--border: hsl(217.2 32.6% 17.5%); --destructive-foreground: hsl(210 40% 98%);
--input: hsl(217.2 32.6% 17.5%); --border: hsl(217.2 32.6% 17.5%);
--ring: hsl(212.7 26.8% 83.9%); --input: hsl(217.2 32.6% 17.5%);
} --ring: hsl(212.7 26.8% 83.9%);
} }
@layer base { @layer base {
+79
View File
@@ -0,0 +1,79 @@
/**
* Converts a hex color string to HSL.
*/
export function hexToHsl(hex: string): { h: number; s: number; l: number } {
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0, s = 0, l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
case g: h = (b - r) / d + 2; break;
case b: h = (r - g) / d + 4; break;
}
h /= 6;
}
return { h: h * 360, s: s * 100, l: l * 100 };
}
/**
* Converts HSL values to a hex string.
*/
export function hslToHex(h: number, s: number, l: number): string {
s /= 100;
l /= 100;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs((h / 60) % 2 - 1));
const m = l - c / 2;
let r = 0, g = 0, b = 0;
if (h >= 0 && h < 60) { r = c; g = x; b = 0; }
else if (h >= 60 && h < 120) { r = x; g = c; b = 0; }
else if (h >= 120 && h < 180) { r = 0; g = c; b = x; }
else if (h >= 180 && h < 240) { r = 0; g = x; b = c; }
else if (h >= 240 && h < 300) { r = x; g = 0; b = c; }
else if (h >= 300 && h < 360) { r = c; g = 0; b = x; }
const toHex = (n: number) => {
const hex = Math.round((n + m) * 255).toString(16);
return hex.length === 1 ? '0' + hex : hex;
};
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}
/**
* Adjusts a color based on the theme change.
* If baseTheme matches currentTheme, returns the original color.
* Otherwise, inverts the lightness (L = 100 - L).
*/
export function getThemeAdjustedColor(
color: string | undefined,
baseTheme: "light" | "dark" | undefined,
currentTheme: "light" | "dark"
): string | undefined {
if (!color || !color.startsWith("#")) return color;
if (!baseTheme || baseTheme === currentTheme) return color;
const { h, s, l } = hexToHsl(color);
// Invert lightness: 100 -> 0, 0 -> 100
// To keep it readable, we might want to clamp it or shift it
// But the user asked for "invert brightness", so 100 - L is the most direct implementation.
// However, 100-L can make 50% stay 50%.
// For tags, we usually want them to be "light" in dark mode (bright on dark)
// and "dark" in light mode (dark on light).
// If L = 20 (dark blue) in dark mode, it's 80 (light blue) in light mode.
const newL = 100 - l;
return hslToHex(h, s, newL);
}
+1
View File
@@ -3,3 +3,4 @@ import PocketBase from 'pocketbase';
export const pb = new PocketBase('https://pocketbase.ccllc.pro'); export const pb = new PocketBase('https://pocketbase.ccllc.pro');
export const TASGRID_COLLECTION = 'TasGrid'; export const TASGRID_COLLECTION = 'TasGrid';
export const TAGS_COLLECTION = 'TasGrid_Tags';
+246 -114
View File
@@ -1,6 +1,6 @@
import { createStore, reconcile, produce } from "solid-js/store"; import { createStore, reconcile } from "solid-js/store";
import { createSignal, createEffect, createRoot } from "solid-js"; import { createSignal, createEffect, createRoot } from "solid-js";
import { pb, TASGRID_COLLECTION } from "@/lib/pocketbase"; import { pb, TASGRID_COLLECTION, TAGS_COLLECTION } from "@/lib/pocketbase";
import { toast } from "solid-sonner"; import { toast } from "solid-sonner";
import { URGENCY_HOURS } from "@/lib/constants"; import { URGENCY_HOURS } from "@/lib/constants";
@@ -38,9 +38,22 @@ export interface TaskTemplate {
content: string; content: string;
} }
export interface TagDefinition {
id: string;
name: string;
value: number;
color?: string;
theme?: "light" | "dark";
}
export interface FilterTag {
name: string;
excluded: boolean;
}
export interface Filter { export interface Filter {
query: string; query: string;
tags: string[]; tags: FilterTag[];
priorityMin: number; priorityMin: number;
priorityMax: number; priorityMax: number;
urgencyMin: number; urgencyMin: number;
@@ -52,9 +65,9 @@ interface TaskStore {
pWeight: number; pWeight: number;
uWeight: number; uWeight: number;
matrixScaleDays: number; matrixScaleDays: number;
prefId?: string; // ID of the user_preferences record prefId?: string; // ID of the user record
tagDefinitions?: Record<string, number>; tagDefinitions: TagDefinition[];
templates?: TaskTemplate[]; templates: TaskTemplate[];
filter: Filter; filter: Filter;
} }
@@ -64,7 +77,7 @@ export const [store, setStore] = createStore<TaskStore>({
pWeight: 1.0, pWeight: 1.0,
uWeight: 1.0, uWeight: 1.0,
matrixScaleDays: 30, matrixScaleDays: 30,
tagDefinitions: {}, // Map<Name, Value 0-10> tagDefinitions: [],
templates: [], templates: [],
filter: { filter: {
query: "", query: "",
@@ -77,6 +90,9 @@ export const [store, setStore] = createStore<TaskStore>({
}); });
export const matchesFilter = (task: Task) => { export const matchesFilter = (task: Task) => {
// Hide templates from all regular views
if (task.tags?.includes("__template__")) return false;
const f = store.filter; const f = store.filter;
// Query search (title or content) // Query search (title or content)
@@ -87,10 +103,22 @@ export const matchesFilter = (task: Task) => {
if (!inTitle && !inContent) return false; if (!inTitle && !inContent) return false;
} }
// Tags (OR logic if multiple selected?) // Tags
if (f.tags.length > 0) { if (f.tags.length > 0) {
const hasTag = f.tags.some(tag => task.tags?.includes(tag)); const includedTags = f.tags.filter(t => !t.excluded).map(t => t.name);
if (!hasTag) return false; const excludedTags = f.tags.filter(t => t.excluded).map(t => t.name);
// 1. Task must NOT have any of the excluded tags
if (excludedTags.length > 0) {
const hasExcluded = excludedTags.some(tag => task.tags?.includes(tag));
if (hasExcluded) return false;
}
// 2. Task must have at least one of the included tags (if any are specified)
if (includedTags.length > 0) {
const hasIncluded = includedTags.some(tag => task.tags?.includes(tag));
if (!hasIncluded) return false;
}
} }
// Priority // Priority
@@ -157,11 +185,11 @@ export const getCombinedScore = (task: Task): number => {
// Tag adjustments // Tag adjustments
if (task.tags && task.tags.length > 0) { if (task.tags && task.tags.length > 0) {
task.tags.forEach(tagName => { task.tags.forEach(tagName => {
const defVal = store.tagDefinitions?.[tagName]; const def = store.tagDefinitions.find(d => d.name === tagName);
if (defVal !== undefined) { if (def) {
// Formula: (Value - 5) * 0.1 // Formula: (Value - 5) * 0.1
// 5 -> 0, 10 -> 0.5, 0 -> -0.5 // 5 -> 0, 10 -> 0.5, 0 -> -0.5
baseScore += (defVal - 5) * 0.1; baseScore += (def.value - 5) * 0.1;
} }
}); });
} }
@@ -194,61 +222,103 @@ export const initStore = async () => {
heartbeatInterval = setInterval(() => setNow(Date.now()), 60000); heartbeatInterval = setInterval(() => setNow(Date.now()), 60000);
try { try {
// 1. Fetch Preferences from User Profile // 1. Fetch Preferences from User Profile (Weights, Scale, Tags)
try { try {
const userId = pb.authStore.model?.id; const userId = pb.authStore.model?.id;
if (userId) { if (userId) {
// Fetch fresh user record
const user = await pb.collection('users').getOne(userId, { requestKey: null }); const user = await pb.collection('users').getOne(userId, { requestKey: null });
// Field: Taskgrid_pref (JSON)
const prefs = user.Taskgrid_pref || {}; const prefs = user.Taskgrid_pref || {};
setStore({ setStore({
pWeight: prefs.pWeight || 1.0, pWeight: prefs.pWeight || 1.0,
uWeight: prefs.uWeight || 1.0, uWeight: prefs.uWeight || 1.0,
matrixScaleDays: prefs.matrixScaleDays || 30, matrixScaleDays: prefs.matrixScaleDays || 30,
tagDefinitions: prefs.tagDefinitions || {}, prefId: userId
templates: prefs.templates || [],
prefId: userId // The ID to update is the User ID
}); });
} }
} catch (prefErr) { } catch (prefErr) {
// Non-blocking error for preferences console.warn("Failed to load preferences:", prefErr);
console.warn("Failed to load preferences from user profile:", prefErr);
} }
// 2. Fetch Tasks - Explicitly filtered by user and sorted // 1.5. Fetch Tag Definitions
try {
const tagRecords = await pb.collection(TAGS_COLLECTION).getFullList({
filter: `user = "${pb.authStore.model?.id}"`,
});
const loadedTags: TagDefinition[] = tagRecords.map(r => ({
id: r.id,
name: r.name,
value: r.value,
color: r.color,
theme: r.theme
}));
setStore("tagDefinitions", reconcile(loadedTags));
} catch (tagErr) {
console.warn("Failed to load tags:", tagErr);
}
// 2. Fetch Tasks & Templates
const records = await pb.collection(TASGRID_COLLECTION).getFullList({ const records = await pb.collection(TASGRID_COLLECTION).getFullList({
filter: `user = "${pb.authStore.model?.id}"`, filter: `user = "${pb.authStore.model?.id}"`,
sort: '-created', sort: '-created',
}); });
// Map PB records to Task interface const allTasks: Task[] = [];
const loadedTasks: Task[] = records.map(r => ({ const loadedTemplates: TaskTemplate[] = [];
id: r.id,
title: r.title,
startDate: r.startDate,
dueDate: r.dueDate,
priority: r.priority,
completed: r.completed,
tags: r.tags || [],
content: r.content,
deletedAt: r.deletedAt ? new Date(r.deletedAt).getTime() : undefined,
created: r.created,
updated: r.updated
}));
// Use reconcile for efficient store update (keeps observers happy) records.forEach(r => {
setStore("tasks", reconcile(loadedTasks)); const tags = r.tags || [];
if (tags.includes("__template__")) {
let meta: any = {};
try { meta = JSON.parse(r.content || "{}"); } catch (e) { }
loadedTemplates.push({
id: r.id,
name: r.title,
title: meta.title || "",
priority: r.priority,
urgency: meta.urgency || 5,
tags: meta.tags || [],
content: meta.content || ""
});
} else {
allTasks.push({
id: r.id,
title: r.title,
startDate: r.startDate,
dueDate: r.dueDate,
priority: r.priority,
completed: r.completed,
tags: tags,
content: r.content,
deletedAt: r.deletedAt ? new Date(r.deletedAt).getTime() : undefined,
created: r.created,
updated: r.updated
});
}
});
setStore("tasks", reconcile(allTasks));
setStore("templates", loadedTemplates);
// 4. Reconstructive Migration: Verify all tags in tasks have definitions
const foundTagNames = new Set<string>();
allTasks.forEach(t => t.tags?.forEach(tag => foundTagNames.add(tag)));
loadedTemplates.forEach(t => t.tags?.forEach(tag => foundTagNames.add(tag)));
for (const tagName of foundTagNames) {
if (tagName === "__template__") continue;
const exists = store.tagDefinitions.find(d => d.name === tagName);
if (!exists) {
console.log(`Reconstructing missing tag: ${tagName}`);
// Default to dark mode for reconstruction if we can't tell
const currentTheme = document.documentElement.classList.contains("dark") ? "dark" : "light";
await upsertTagDefinition(tagName, 5, undefined, currentTheme);
}
}
} catch (err) { } catch (err) {
// If we fail but have cached data, don't toast an error unless it's a hard failure
if (store.tasks.length === 0) { if (store.tasks.length === 0) {
console.error("Failed to load data:", err); console.error("Failed to load data:", err);
toast.error("Failed to sync with server."); toast.error("Failed to sync with server.");
} else {
console.warn("Sync failed, using cached data", err);
} }
} }
}; };
@@ -448,86 +518,94 @@ export const deleteTaskPermanently = async (id: string) => {
} }
}; };
export const upsertTagDefinition = async (name: string, value: number) => { export const upsertTagDefinition = async (name: string, value: number, color?: string, theme?: "light" | "dark") => {
// Optimistic update if (!pb.authStore.isValid) return;
setStore("tagDefinitions", name, value);
// Persist const existing = store.tagDefinitions.find(d => d.name === name);
const map = { ...store.tagDefinitions }; const finalTheme = theme || (document.documentElement.classList.contains("dark") ? "dark" : "light");
const userId = pb.authStore.model?.id;
if (userId) { try {
try { if (existing) {
const prefs = { const updateData: any = { value };
pWeight: store.pWeight, if (color !== undefined) updateData.color = color;
uWeight: store.uWeight, if (theme !== undefined) updateData.theme = theme;
matrixScaleDays: store.matrixScaleDays,
tagDefinitions: map await pb.collection(TAGS_COLLECTION).update(existing.id, updateData, { requestKey: null });
setStore("tagDefinitions", (d) => d.id === existing.id, {
value,
color: color !== undefined ? color : existing.color,
theme: theme !== undefined ? theme : existing.theme
});
} else {
const record = await pb.collection(TAGS_COLLECTION).create({
user: pb.authStore.model?.id,
name,
value,
color: color || "#6366f1", // Default indigo
theme: finalTheme
});
const newDef: TagDefinition = {
id: record.id,
name: record.name,
value: record.value,
color: record.color,
theme: record.theme as "light" | "dark"
}; };
setStore("tagDefinitions", (prev) => [...prev, newDef]);
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");
} }
} catch (err) {
console.error("Failed to upsert tag:", err);
toast.error("Failed to save tag definition.");
} }
}; };
export const upsertMultipleTagDefinitions = async (updates: Record<string, number>) => {
// Optimistic update
setStore("tagDefinitions", (defs) => ({ ...(defs || {}), ...updates }));
// Persist
await syncPreferences();
};
export const removeTagDefinition = async (name: string) => { export const removeTagDefinition = async (name: string) => {
// 1. Remove from all tasks if (!pb.authStore.isValid) return;
const tasksWithTag = store.tasks.filter(t => t.tags?.includes(name)); const def = store.tagDefinitions.find(d => d.name === name);
for (const task of tasksWithTag) { if (!def) return;
const newTags = task.tags.filter(t => t !== name);
updateTask(task.id, { tags: newTags }); try {
await pb.collection(TAGS_COLLECTION).delete(def.id);
setStore("tagDefinitions", (prev) => prev.filter(d => d.id !== def.id));
const tasksWithTag = store.tasks.filter(t => t.tags?.includes(name));
for (const task of tasksWithTag) {
const nextTags = (task.tags || []).filter(t => t !== name);
updateTask(task.id, { tags: nextTags });
}
toast.success(`Tag "${name}" deleted`);
} catch (err) {
console.error("Failed to delete tag:", err);
toast.error("Failed to delete tag.");
} }
// 2. Remove definition locally
setStore("tagDefinitions", produce((defs) => {
if (defs) delete defs[name];
}));
// 3. Persist
await syncPreferences();
toast.success(`Tag "${name}" deleted`);
}; };
export const renameTagDefinition = async (oldName: string, newName: string) => { export const renameTagDefinition = async (oldName: string, newName: string) => {
if (!newName || !newName.trim() || newName === oldName) return; if (!newName || !newName.trim() || newName === oldName) return;
if (!pb.authStore.isValid) return;
const def = store.tagDefinitions.find(d => d.name === oldName);
if (!def) return;
const finalName = newName.trim(); const finalName = newName.trim();
// 1. Get current value try {
const val = store.tagDefinitions?.[oldName] ?? 5; await pb.collection(TAGS_COLLECTION).update(def.id, { name: finalName });
setStore("tagDefinitions", (d) => d.id === def.id, { name: finalName });
// 2. Update all tasks const tasksWithTag = store.tasks.filter(t => t.tags?.includes(oldName));
const tasksWithTag = store.tasks.filter(t => t.tags?.includes(oldName)); for (const task of tasksWithTag) {
for (const task of tasksWithTag) { const newTags = task.tags.map(t => t === oldName ? finalName : t);
const newTags = task.tags.map(t => t === oldName ? finalName : t); const uniqueTags = [...new Set(newTags)];
// Dedupe updateTask(task.id, { tags: uniqueTags });
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];
} }
})); } catch (err) {
console.error("Failed to rename tag:", err);
// 4. Persist toast.error("Failed to rename tag.");
await syncPreferences(); }
}; };
@@ -542,8 +620,7 @@ export const syncPreferences = async () => {
pWeight: store.pWeight, pWeight: store.pWeight,
uWeight: store.uWeight, uWeight: store.uWeight,
matrixScaleDays: store.matrixScaleDays, matrixScaleDays: store.matrixScaleDays,
tagDefinitions: store.tagDefinitions, tagDefinitions: store.tagDefinitions
templates: store.templates
}; };
await pb.collection('users').update(userId, { await pb.collection('users').update(userId, {
@@ -557,20 +634,75 @@ export const syncPreferences = async () => {
}; };
export const addTemplate = async (template: Omit<TaskTemplate, "id">) => { export const addTemplate = async (template: Omit<TaskTemplate, "id">) => {
const newTemplate = { ...template, id: crypto.randomUUID() }; if (!pb.authStore.isValid) return;
setStore("templates", (prev) => [...(prev || []), newTemplate]);
await syncPreferences(); const meta = {
return newTemplate; title: template.title,
urgency: template.urgency,
tags: template.tags,
content: template.content
};
try {
const record = await pb.collection(TASGRID_COLLECTION).create({
user: pb.authStore.model?.id,
title: template.name,
priority: template.priority,
tags: ["__template__"],
content: JSON.stringify(meta),
completed: false,
startDate: new Date().toISOString(),
dueDate: new Date().toISOString()
});
const newTemplate = { ...template, id: record.id };
setStore("templates", (prev) => [...(prev || []), newTemplate]);
return newTemplate;
} catch (err) {
console.error("Failed to add template:", err);
toast.error("Failed to save template.");
}
}; };
export const updateTemplate = async (id: string, updates: Partial<TaskTemplate>) => { export const updateTemplate = async (id: string, updates: Partial<TaskTemplate>) => {
if (!pb.authStore.isValid) return;
// Optimistic
setStore("templates", (t) => t!.id === id, updates); setStore("templates", (t) => t!.id === id, updates);
await syncPreferences();
const template = store.templates?.find(t => t.id === id);
if (!template) return;
const meta = {
title: template.title,
urgency: template.urgency,
tags: template.tags,
content: template.content
};
try {
await pb.collection(TASGRID_COLLECTION).update(id, {
title: template.name,
priority: template.priority,
content: JSON.stringify(meta)
}, { requestKey: null });
} catch (err) {
console.error("Failed to update template:", err);
toast.error("Failed to update template.");
}
}; };
export const removeTemplate = async (id: string) => { export const removeTemplate = async (id: string) => {
if (!pb.authStore.isValid) return;
setStore("templates", (t) => (t || []).filter(tmpl => tmpl.id !== id)); setStore("templates", (t) => (t || []).filter(tmpl => tmpl.id !== id));
await syncPreferences();
try {
await pb.collection(TASGRID_COLLECTION).delete(id);
} catch (err) {
console.error("Failed to delete template:", err);
toast.error("Failed to delete template.");
}
}; };
export const saveTaskAsTemplate = async (taskId: string, name: string) => { export const saveTaskAsTemplate = async (taskId: string, name: string) => {
+28 -14
View File
@@ -1,6 +1,7 @@
import { type Component, For, Show, createSignal, lazy, Suspense } from "solid-js"; import { type Component, For, Show, createSignal, lazy, Suspense } from "solid-js";
import { ThemeToggle } from "../components/ThemeToggle"; import { ThemeToggle } from "../components/ThemeToggle";
import { store, restoreTask, deleteTaskPermanently, setMatrixScaleDays, addTemplate, removeTemplate, updateTemplate, upsertTagDefinition, removeTagDefinition, renameTagDefinition } from "@/store"; import { store, restoreTask, deleteTaskPermanently, setMatrixScaleDays, addTemplate, removeTemplate, updateTemplate, upsertTagDefinition, removeTagDefinition, renameTagDefinition } from "@/store";
import { useTheme } from "@/components/ThemeProvider";
import { Trash2, Undo2, ArrowLeftRight, Hash, Copy, Plus, ChevronDown, ChevronRight, Type } from "lucide-solid"; import { Trash2, Undo2, ArrowLeftRight, Hash, Copy, Plus, ChevronDown, ChevronRight, Type } from "lucide-solid";
import { TagPicker } from "@/components/TagPicker"; import { TagPicker } from "@/components/TagPicker";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -13,6 +14,7 @@ import { pb } from "@/lib/pocketbase";
const TaskEditor = lazy(() => import("../components/TaskEditor").then(m => ({ default: m.TaskEditor }))); const TaskEditor = lazy(() => import("../components/TaskEditor").then(m => ({ default: m.TaskEditor })));
export const SettingsView: Component = () => { export const SettingsView: Component = () => {
const { resolvedTheme } = useTheme();
const [isTagsOpen, setIsTagsOpen] = createSignal(false); const [isTagsOpen, setIsTagsOpen] = createSignal(false);
const [isTemplatesOpen, setIsTemplatesOpen] = createSignal(false); const [isTemplatesOpen, setIsTemplatesOpen] = createSignal(false);
const [isTrashOpen, setIsTrashOpen] = createSignal(false); const [isTrashOpen, setIsTrashOpen] = createSignal(false);
@@ -98,18 +100,18 @@ export const SettingsView: Component = () => {
<Show when={isTagsOpen()}> <Show when={isTagsOpen()}>
<div class="space-y-2 pt-2 animate-in fade-in slide-in-from-top-2 duration-300"> <div class="space-y-2 pt-2 animate-in fade-in slide-in-from-top-2 duration-300">
<For each={Object.entries(store.tagDefinitions || {}).sort((a, b) => a[0].localeCompare(b[0]))} fallback={ <For each={store.tagDefinitions.sort((a, b) => a.name.localeCompare(b.name))} fallback={
<div class="text-center py-8 text-muted-foreground text-sm italic border border-dashed border-border rounded-xl"> <div class="text-center py-8 text-muted-foreground text-sm italic border border-dashed border-border rounded-xl">
No specific tag definitions found. No specific tag definitions found.
</div> </div>
}> }>
{([tagName, val]) => { {(tag) => {
const [isEditingName, setIsEditingName] = createSignal(false); const [isEditingName, setIsEditingName] = createSignal(false);
const [tempName, setTempName] = createSignal(tagName); const [tempName, setTempName] = createSignal(tag.name);
const handleRename = () => { const handleRename = () => {
if (tempName() && tempName() !== tagName) { if (tempName() && tempName() !== tag.name) {
renameTagDefinition(tagName, tempName()); renameTagDefinition(tag.name, tempName());
} }
setIsEditingName(false); setIsEditingName(false);
}; };
@@ -117,7 +119,13 @@ export const SettingsView: Component = () => {
return ( return (
<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 group"> <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 group">
<div class="flex items-center gap-3 min-w-0 flex-1"> <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")} /> <input
type="color"
value={tag.color || "#6366f1"}
onInput={(e) => upsertTagDefinition(tag.name, tag.value, e.currentTarget.value, resolvedTheme())}
class="w-4 h-4 rounded-full border-none p-0 bg-transparent cursor-pointer shrink-0 overflow-hidden"
title="Tag accent color"
/>
{isEditingName() ? ( {isEditingName() ? (
<input <input
@@ -134,7 +142,7 @@ export const SettingsView: Component = () => {
onClick={() => setIsEditingName(true)} onClick={() => setIsEditingName(true)}
title="Click to rename" title="Click to rename"
> >
{tagName} {tag.name}
</span> </span>
)} )}
</div> </div>
@@ -143,8 +151,8 @@ export const SettingsView: Component = () => {
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-[10px] text-muted-foreground font-bold uppercase tracking-wider">Val</span> <span class="text-[10px] text-muted-foreground font-bold uppercase tracking-wider">Val</span>
<select <select
value={String(val)} value={String(tag.value)}
onChange={(e) => upsertTagDefinition(tagName, parseInt(e.currentTarget.value))} onChange={(e) => upsertTagDefinition(tag.name, parseInt(e.currentTarget.value), tag.color, resolvedTheme())}
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" 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"]}> <For each={["10", "9", "8", "7", "6", "5", "4", "3", "2", "1", "0"]}>
@@ -156,10 +164,10 @@ export const SettingsView: Component = () => {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-8 w-8 hover:bg-destructive/10 hover:text-destructive" class="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
onClick={() => { onClick={() => {
if (confirm(`Delete tag "${tagName}" globally? This will remove it from all tasks.`)) { if (confirm(`Delete tag definition for "${tag.name}" ? `)) {
removeTagDefinition(tagName); removeTagDefinition(tag.name);
} }
}} }}
> >
@@ -291,7 +299,7 @@ export const SettingsView: Component = () => {
class="h-8 w-8 hover:bg-red-500/10 hover:text-red-600 rounded-xl" class="h-8 w-8 hover:bg-red-500/10 hover:text-red-600 rounded-xl"
onClick={(e: MouseEvent) => { onClick={(e: MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
if (confirm(`Delete template "${template.name}"?`)) { if (confirm(`Delete template "${template.name}" ? `)) {
removeTemplate(template.id); removeTemplate(template.id);
toast.error("Template removed"); toast.error("Template removed");
} }
@@ -356,7 +364,13 @@ export const SettingsView: Component = () => {
class="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-background border border-border/50 text-xs hover:bg-destructive/10 hover:border-destructive/30 group/tag transition-all" class="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-background border border-border/50 text-xs hover:bg-destructive/10 hover:border-destructive/30 group/tag transition-all"
onClick={() => updateTemplate(template.id, { tags: (template.tags || []).filter(t => t !== tag) })} onClick={() => updateTemplate(template.id, { tags: (template.tags || []).filter(t => t !== tag) })}
> >
<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")} /> <div
class="w-1.5 h-1.5 rounded-full"
style={{
"background-color": store.tagDefinitions.find(d => d.name === tag)?.color ||
((store.tagDefinitions.find(d => d.name === tag)?.value ?? 5) > 5 ? "#22c55e" : (store.tagDefinitions.find(d => d.name === tag)?.value ?? 5) < 5 ? "#ef4444" : "#94a3b8")
}}
/>
{tag} {tag}
</button> </button>
)} )}