Compare commits

...

2 Commits

Author SHA1 Message Date
tcardoza e36ae92441 tag colors and tag list tracking fix 2026-02-02 15:10:29 -06:00
tcardoza 3212402788 UI improvements and initial loading screen color 2026-02-02 12:08:30 -06:00
15 changed files with 876 additions and 436 deletions
+98 -1
View File
@@ -6,10 +6,107 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>tasgrid</title>
<script>
(function () {
const storedTheme = localStorage.getItem("tasgrid-theme");
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
const theme = (storedTheme === "system" || !storedTheme) ? systemTheme : storedTheme;
document.documentElement.classList.add(theme);
})();
</script>
<style>
:root {
--bg-light: #ffffff;
--bg-dark: #020617;
--primary: #0f172a;
--primary-dark: #f8fafc;
}
html.dark {
--bg: var(--bg-dark);
--text: var(--primary-dark);
}
html.light {
--bg: var(--bg-light);
--text: var(--primary);
}
body {
margin: 0;
padding: 0;
background-color: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
#splash {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 9999;
background-color: var(--bg);
}
.logo-text {
font-size: 2.5rem;
font-weight: 800;
letter-spacing: -0.05em;
margin-bottom: 1rem;
opacity: 0;
transform: translateY(10px);
animation: slideUpFade 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.loader-bar {
width: 140px;
height: 2px;
background: rgba(128, 128, 128, 0.2);
border-radius: 99px;
overflow: hidden;
position: relative;
}
.loader-progress {
position: absolute;
height: 100%;
width: 30%;
background: var(--text);
border-radius: 99px;
animation: progressMove 1.5s infinite ease-in-out;
}
@keyframes slideUpFade {
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes progressMove {
0% {
left: -30%;
}
100% {
left: 100%;
}
}
</style>
</head>
<body>
<div id="root"></div>
<div id="root">
<div id="splash">
<div class="logo-text">Tasgrid</div>
<div class="loader-bar">
<div class="loader-progress"></div>
</div>
</div>
</div>
<script type="module" src="/src/index.tsx"></script>
</body>
+7
View File
@@ -34,6 +34,13 @@ const App: Component = () => {
import('./views/PriorityView');
import('./views/MatrixView');
import('./views/SettingsView');
// Remove splash screen after high priority work is done
const splash = document.getElementById('splash');
if (splash) {
splash.style.opacity = '0';
setTimeout(() => splash.remove(), 500);
}
});
});
+220 -220
View File
@@ -1,243 +1,243 @@
import { type Component, createSignal, For, Show } from "solid-js";
import { Search, X, Hash, ChevronDown, ArrowUpCircle, Clock } from "lucide-solid";
import { Search, X, Hash, ArrowUpCircle, Clock, Minus } from "lucide-solid";
import { store, setFilter, clearFilter } from "@/store";
import { Button } from "./ui/button";
import { cn } from "@/lib/utils";
import { Badge } from "./ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger } from "./ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { useTheme } from "./ThemeProvider";
import { getThemeAdjustedColor } from "@/lib/colors";
export const FilterBar: Component = () => {
const [isExpanded, setIsExpanded] = createSignal(false);
const { resolvedTheme } = useTheme();
const [isOpen, setIsOpen] = createSignal(false);
const hasActiveFilters = () => {
const f = store.filter;
return f.query !== "" || f.tags.length > 0 || f.priorityMin !== 1 || f.priorityMax !== 10 || f.urgencyMin !== 1 || f.urgencyMax !== 10;
};
const allTags = () => Object.keys(store.tagDefinitions || {}).sort();
const FilterControls: Component<{ class?: string }> = (props) => (
<div class={cn("flex flex-col md:flex-row items-stretch md:items-center gap-3", props.class)}>
{/* Main Search */}
<div class="flex items-center gap-2 px-2 border-b md:border-b-0 md:border-r border-border/50 pb-2 md:pb-0 min-w-0">
<Search size={16} class="text-muted-foreground shrink-0" />
<input
type="text"
placeholder="Search tasks..."
value={store.filter.query}
onInput={(e) => setFilter({ query: e.currentTarget.value })}
class="bg-transparent border-none focus:ring-0 text-sm flex-1 md:w-40 h-8 outline-none placeholder:text-muted-foreground/50 min-w-0"
autofocus
/>
</div>
{/* Priority Range */}
<div class="flex items-center gap-2 px-2 border-b md:border-b-0 md:border-r border-border/50 pb-2 md:pb-0">
<span class="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Priority</span>
<div class="flex items-center gap-1">
<Select
value={store.filter.priorityMin.toString()}
onChange={(val) => val && setFilter({ priorityMin: parseInt(val) })}
options={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
>
<SelectTrigger class="h-7 w-12 p-0 bg-transparent border-none shadow-none focus:ring-0 text-xs font-bold text-foreground flex items-center gap-1">
<ArrowUpCircle size={12} class="text-muted-foreground/70" />
<span>{store.filter.priorityMin}</span>
</SelectTrigger>
<SelectContent />
</Select>
<span class="text-muted-foreground/50">-</span>
<Select
value={store.filter.priorityMax.toString()}
onChange={(val) => val && setFilter({ priorityMax: parseInt(val) })}
options={["10", "9", "8", "7", "6", "5", "4", "3", "2", "1"]}
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
>
<SelectTrigger class="h-7 w-8 p-0 bg-transparent border-none shadow-none focus:ring-0 text-xs font-bold text-foreground justify-center">
<span>{store.filter.priorityMax}</span>
</SelectTrigger>
<SelectContent />
</Select>
</div>
</div>
{/* Urgency Range */}
<div class="flex items-center gap-2 px-2 border-b md:border-b-0 md:border-r border-border/50 pb-2 md:pb-0">
<span class="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Urgency</span>
<div class="flex items-center gap-1">
<Select
value={store.filter.urgencyMin.toString()}
onChange={(val) => val && setFilter({ urgencyMin: parseInt(val) })}
options={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
>
<SelectTrigger class="h-7 w-12 p-0 bg-transparent border-none shadow-none focus:ring-0 text-xs font-bold text-foreground flex items-center gap-1">
<Clock size={12} class="text-muted-foreground/70" />
<span>{store.filter.urgencyMin}</span>
</SelectTrigger>
<SelectContent />
</Select>
<span class="text-muted-foreground/50">-</span>
<Select
value={store.filter.urgencyMax.toString()}
onChange={(val) => val && setFilter({ urgencyMax: parseInt(val) })}
options={["10", "9", "8", "7", "6", "5", "4", "3", "2", "1"]}
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
>
<SelectTrigger class="h-7 w-8 p-0 bg-transparent border-none shadow-none focus:ring-0 text-xs font-bold text-foreground justify-center">
<span>{store.filter.urgencyMax}</span>
</SelectTrigger>
<SelectContent />
</Select>
</div>
</div>
{/* Tags */}
<div class="px-2 flex items-center gap-2 min-w-[120px]">
<Hash size={14} class="text-muted-foreground" />
<div class="flex flex-wrap gap-1 max-w-[200px]">
<Show when={store.filter.tags.length === 0}>
<Select
value=""
onChange={(val) => {
if (val && !store.filter.tags.includes(val)) {
setFilter({ tags: [...store.filter.tags, val] });
}
}}
options={allTags()}
placeholder="Add Tag..."
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
>
<SelectTrigger class="h-7 p-0 bg-transparent border-none shadow-none focus:ring-0 text-[10px] italic text-muted-foreground">
<span>Add Tag...</span>
</SelectTrigger>
<SelectContent />
</Select>
</Show>
<For each={store.filter.tags}>
{(tag) => (
<Badge
variant="secondary"
class="h-5 px-1 text-[10px] gap-0.5 cursor-pointer hover:bg-destructive/20 border-border/50"
onClick={(e) => {
e.stopPropagation();
setFilter({ tags: store.filter.tags.filter(t => t !== tag) });
}}
>
{tag}
<X size={8} />
</Badge>
)}
</For>
<Show when={store.filter.tags.length > 0}>
<Select
value=""
onChange={(val) => {
if (val && !store.filter.tags.includes(val)) {
setFilter({ tags: [...store.filter.tags, val] });
}
}}
options={allTags()}
placeholder="+"
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
>
<SelectTrigger class="h-5 w-5 p-0 bg-muted/50 border-none shadow-none focus:ring-0 text-[10px] rounded-full flex items-center justify-center">
<span>+</span>
</SelectTrigger>
<SelectContent />
</Select>
</Show>
</div>
</div>
{/* Actions for Desktop */}
<div class="hidden md:flex items-center gap-1 pl-2 ml-auto">
<Button
variant="ghost"
size="icon"
class="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={clearFilter}
title="Clear all filters"
>
<X size={16} />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 text-muted-foreground hover:text-foreground"
onClick={() => setIsExpanded(false)}
>
<ChevronDown size={16} class="rotate-90" />
</Button>
</div>
{/* Clear All for Mobile */}
<div class="flex md:hidden items-center pt-2">
<Button
variant="outline"
size="sm"
class="w-full text-xs h-8 gap-2"
onClick={clearFilter}
>
<X size={14} />
Clear All Filters
</Button>
</div>
</div>
);
const allTags = () => store.tagDefinitions.map(d => d.name).sort();
return (
<div class="flex items-center gap-2">
{/* Desktop View: Inline expansion */}
<div class="hidden md:flex items-center">
{!isExpanded() ? (
<Button
variant="ghost"
size="sm"
class={cn(
"h-9 px-3 gap-2 text-muted-foreground hover:text-foreground transition-all duration-300",
hasActiveFilters() && "text-primary bg-primary/10 hover:bg-primary/20"
<div class="fixed top-4 right-5 sm:right-6 z-[60]">
<Popover open={isOpen()} onOpenChange={setIsOpen}>
<PopoverTrigger
as={Button}
variant="ghost"
size="icon"
class={cn(
"bg-background/80 backdrop-blur-sm border border-border hover:bg-muted shadow-sm transition-all duration-300",
hasActiveFilters() && "border-primary/50 ring-1 ring-primary/20",
isOpen() && "bg-muted ring-1 ring-border"
)}
title="Filters & Search"
>
<div class="relative">
<Search size={18} class={cn("transition-transform duration-300", isOpen() && "scale-110")} />
{hasActiveFilters() && !isOpen() && (
<div class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-primary animate-pulse border border-background" />
)}
onClick={() => setIsExpanded(true)}
>
<Search size={18} />
<span class="text-sm font-medium">Filter</span>
{hasActiveFilters() && (
<div class="w-1.5 h-1.5 rounded-full bg-primary animate-pulse" />
)}
</Button>
) : (
<div class="bg-card border border-border/50 p-2 rounded-xl shadow-lg animate-in fade-in slide-in-from-top-4 duration-300 w-auto overflow-hidden">
<FilterControls />
</div>
)}
</div>
</PopoverTrigger>
<PopoverContent class="w-[calc(100vw-2rem)] sm:w-[480px] p-0 overflow-hidden bg-card border-border shadow-2xl rounded-2xl animate-in fade-in zoom-in-95 duration-200">
{/* Header */}
<div class="px-4 py-3 border-b border-border/50 bg-muted/20 flex items-center justify-between">
<h3 class="text-xs font-bold uppercase tracking-widest text-muted-foreground flex items-center gap-2">
<Search size={12} />
Filters & Search
</h3>
<div class="flex items-center gap-2">
<Show when={hasActiveFilters()}>
<Button
variant="ghost"
size="sm"
class="h-7 px-2 text-[10px] font-bold uppercase tracking-wider text-muted-foreground hover:text-destructive"
onClick={clearFilter}
>
Clear All
</Button>
</Show>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 rounded-lg"
onClick={() => setIsOpen(false)}
>
<X size={14} />
</Button>
</div>
</div>
{/* Mobile View: Popover */}
<div class="flex md:hidden items-center">
<Popover>
<PopoverTrigger
as={Button}
variant="ghost"
size="sm"
class={cn(
"h-9 px-3 gap-2 text-muted-foreground hover:text-foreground transition-all duration-300",
hasActiveFilters() && "text-primary bg-primary/10 hover:bg-primary/20"
)}
>
<Search size={18} />
<span class="text-sm font-medium">Filter</span>
{hasActiveFilters() && (
<div class="w-1.5 h-1.5 rounded-full bg-primary animate-pulse" />
)}
</PopoverTrigger>
<PopoverContent class="w-[calc(100vw-2rem)] sm:w-96 p-4">
<FilterControls class="w-full" />
</PopoverContent>
</Popover>
</div>
{/* Controls Grid */}
<div class="p-4 space-y-6">
{/* Search Input */}
<div class="space-y-2">
<div class="relative group">
<Search size={16} class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground/50 group-focus-within:text-primary transition-colors" />
<input
ref={(el) => {
if (el) setTimeout(() => el.focus(), 50);
}}
type="text"
placeholder="Search tasks, descriptions, tags..."
value={store.filter.query}
onInput={(e) => setFilter({ query: e.currentTarget.value })}
class="w-full bg-muted/30 border border-border/50 rounded-xl pl-10 pr-4 py-2.5 text-sm outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/40 transition-all placeholder:text-muted-foreground/30"
/>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
{/* Priority Range */}
<div class="space-y-2.5">
<label class="text-[10px] font-black uppercase tracking-widest text-muted-foreground ml-1">Priority Range</label>
<div class="flex items-center gap-2 bg-muted/20 p-1 rounded-xl border border-border/30">
<Select
value={store.filter.priorityMin.toString()}
onChange={(val) => val && setFilter({ priorityMin: parseInt(val) })}
options={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
>
<SelectTrigger class="h-9 flex-1 bg-transparent border-none shadow-none focus:ring-0 text-xs font-bold text-foreground">
<div class="flex items-center gap-2 justify-center">
<ArrowUpCircle size={14} class="text-muted-foreground/70" />
<span>Min: {store.filter.priorityMin}</span>
</div>
</SelectTrigger>
<SelectContent />
</Select>
<div class="w-px h-4 bg-border/50" />
<Select
value={store.filter.priorityMax.toString()}
onChange={(val) => val && setFilter({ priorityMax: parseInt(val) })}
options={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
>
<SelectTrigger class="h-9 flex-1 bg-transparent border-none shadow-none focus:ring-0 text-xs font-bold text-foreground">
<div class="flex items-center gap-2 justify-center">
<span>Max: {store.filter.priorityMax}</span>
</div>
</SelectTrigger>
<SelectContent />
</Select>
</div>
</div>
{/* Urgency Range */}
<div class="space-y-2.5">
<label class="text-[10px] font-black uppercase tracking-widest text-muted-foreground ml-1">Urgency Range</label>
<div class="flex items-center gap-2 bg-muted/20 p-1 rounded-xl border border-border/30">
<Select
value={store.filter.urgencyMin.toString()}
onChange={(val) => val && setFilter({ urgencyMin: parseInt(val) })}
options={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
>
<SelectTrigger class="h-9 flex-1 bg-transparent border-none shadow-none focus:ring-0 text-xs font-bold text-foreground">
<div class="flex items-center gap-2 justify-center">
<Clock size={14} class="text-muted-foreground/70" />
<span>Min: {store.filter.urgencyMin}</span>
</div>
</SelectTrigger>
<SelectContent />
</Select>
<div class="w-px h-4 bg-border/50" />
<Select
value={store.filter.urgencyMax.toString()}
onChange={(val) => val && setFilter({ urgencyMax: parseInt(val) })}
options={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
>
<SelectTrigger class="h-9 flex-1 bg-transparent border-none shadow-none focus:ring-0 text-xs font-bold text-foreground">
<div class="flex items-center gap-2 justify-center">
<span>Max: {store.filter.urgencyMax}</span>
</div>
</SelectTrigger>
<SelectContent />
</Select>
</div>
</div>
</div>
{/* Tags Multi-select */}
<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">
Filter by Tags
<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>
<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}>
{(tag) => (
<Badge
variant="secondary"
class={cn(
"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`
} : {};
})() : {}}
>
<div
class="flex items-center gap-2 cursor-pointer h-full"
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>
)}
</For>
<Select
value=""
onChange={(val) => {
if (val && !store.filter.tags.some(t => t.name === val)) {
setFilter({ tags: [...store.filter.tags, { name: val, excluded: false }] });
}
}}
options={allTags()}
placeholder="Add tag..."
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
>
<SelectTrigger class="h-7 px-3 bg-muted/40 border-none shadow-none focus:ring-0 text-[10px] font-bold uppercase tracking-wider rounded-lg hover:bg-muted/60 transition-colors">
<span>+ Tag</span>
</SelectTrigger>
<SelectContent />
</Select>
</div>
</div>
</div>
</PopoverContent>
</Popover>
</div>
);
};
+2 -4
View File
@@ -64,11 +64,9 @@ export const Layout: Component<LayoutProps> = (props) => {
{/* Main Content Area */}
<main class="flex-1 min-w-0 overflow-y-auto overflow-x-hidden pb-32 sm:pb-6 relative scroll-smooth">
<div class="w-full max-w-screen-2xl mx-auto p-3 sm:p-6 lg:p-10 space-y-6 sm:space-y-8">
<div class="w-full max-w-screen-2xl mx-auto px-4 sm:px-8 lg:px-12 pt-16 sm:pt-20 pb-8 space-y-6 sm:space-y-8">
<Show when={props.currentView.toLowerCase() !== "settings"}>
<div class="sticky top-0 z-40 flex items-center justify-end py-2 -mx-3 px-3 md:relative md:top-auto md:z-auto md:py-0 md:mx-0 md:px-0 bg-background/80 backdrop-blur-md md:bg-transparent md:backdrop-blur-none transition-all duration-300">
<FilterBar />
</div>
<FilterBar />
</Show>
{props.children}
+22 -13
View File
@@ -1,4 +1,4 @@
import { type Component, For } from "solid-js";
import { type Component, For, Show } from "solid-js";
import { LayoutDashboard, ListTodo, Settings, Clock, ArrowUpCircle, PanelLeftClose } from "lucide-solid";
import { cn } from "@/lib/utils";
import { Button } from "./ui/button";
@@ -57,19 +57,28 @@ export const Sidebar: Component<{
)}
>
<div class={cn("p-4 flex items-center justify-between", !props.isLocked && "pl-14")}>
<h1 class="text-xl font-bold tracking-tighter text-primary px-2">Tasgrid</h1>
<h1 class={cn(
"text-xl font-bold tracking-tighter text-foreground px-2 transition-opacity",
(props.isLocked || props.isPeeking)
? "opacity-100 duration-500 delay-[50ms]"
: "opacity-0 duration-116 delay-0"
)}>
Tasgrid
</h1>
{/* Close Button - Only visible when Locked (to collapse) or Peeking (manual close) */}
<Button
variant="ghost"
size="icon"
class="h-8 w-8 text-muted-foreground hover:text-foreground"
onClick={() => {
props.setIsLocked(false);
props.setIsPeeking(false);
}}
>
<PanelLeftClose size={16} />
</Button>
<Show when={props.isLocked}>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 text-muted-foreground hover:text-foreground"
onClick={() => {
props.setIsLocked(false);
props.setIsPeeking(false);
}}
>
<PanelLeftClose size={16} />
</Button>
</Show>
</div>
<nav class="flex-1 px-3 space-y-1 mt-2">
<For each={navItems.filter(i => i.view !== "settings")}>
+28 -12
View File
@@ -1,12 +1,13 @@
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 { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { TextField, TextFieldInput } from "@/components/ui/textfield";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
import { TagPicker } from "@/components/TagPicker";
import { useTheme } from "./ThemeProvider";
import { getThemeAdjustedColor } from "@/lib/colors";
import { lazy, Suspense } from "solid-js";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
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 })));
export const QuickEntry: Component = () => {
const { resolvedTheme } = useTheme();
// Diff tracking for reactive tags
let lastParsedTags: string[] = [];
@@ -156,8 +158,6 @@ export const QuickEntry: Component = () => {
const segments = text.split(/\/t\s*/);
let title = segments[0];
const tagsToUpsert: Record<string, number> = {};
for (let i = 1; i < segments.length; i++) {
const seg = segments[i];
const firstColon = seg.indexOf(':');
@@ -185,18 +185,13 @@ export const QuickEntry: Component = () => {
finalTags.push(tagName);
}
if (tagValue !== undefined) {
tagsToUpsert[tagName] = tagValue;
} else if (store.tagDefinitions?.[tagName] === undefined) {
tagsToUpsert[tagName] = 5;
// update global definition
await upsertTagDefinition(tagName, tagValue, undefined, resolvedTheme());
}
}
title += " " + remainingText;
}
if (Object.keys(tagsToUpsert).length > 0) {
upsertMultipleTagDefinitions(tagsToUpsert);
}
let finalDueDate: Date | null = null;
if (dateString()) {
finalDueDate = new Date(dateString());
@@ -365,9 +360,30 @@ export const QuickEntry: Component = () => {
<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"
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))}
>
<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}
</Badge>
)}
+8 -6
View File
@@ -1,5 +1,6 @@
import { type Component, createSignal, For, createEffect } from "solid-js";
import { store, upsertTagDefinition } from "@/store";
import { useTheme } from "./ThemeProvider";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { Hash, Plus } from "lucide-solid";
@@ -10,20 +11,21 @@ interface TagPickerProps {
}
export const TagPicker: Component<TagPickerProps> = (props) => {
const { resolvedTheme } = useTheme();
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();
const allTagNames = () => store.tagDefinitions.map(d => d.name).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 existing = store.tagDefinitions.find(d => d.name === name);
if (existing) {
setValueScore(existing.value);
}
}
});
@@ -33,7 +35,7 @@ export const TagPicker: Component<TagPickerProps> = (props) => {
if (!name) return;
// update global definition
upsertTagDefinition(name, valueScore());
upsertTagDefinition(name, valueScore(), undefined, resolvedTheme());
// Toggle in current task selection if not already there
const currentSelected = props.selectedTags;
@@ -87,7 +89,7 @@ export const TagPicker: Component<TagPickerProps> = (props) => {
<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"}
{store.tagDefinitions.find(d => d.name === selectedTagName().trim()) ? "Update & Add" : "Create & Add"}
</Button>
</PopoverContent>
</Popover>
+30 -3
View File
@@ -4,8 +4,11 @@ import { cn } from "@/lib/utils";
import { CheckCircle2, Circle, Clock, ArrowUpCircle, Copy } from "lucide-solid";
import { Badge } from "@/components/ui/badge";
import { For } from "solid-js";
import { useTheme } from "./ThemeProvider";
import { getThemeAdjustedColor } from "@/lib/colors";
export const TaskCard: Component<{ task: Task }> = (props) => {
const { resolvedTheme } = useTheme();
const urgencyLevel = createMemo(() => calculateUrgencyFromDate(props.task.dueDate));
const urgencyColor = () => {
@@ -24,7 +27,7 @@ export const TaskCard: Component<{ task: Task }> = (props) => {
return (
<div
class={cn(
"group flex items-center p-3 sm:p-4 bg-card border border-border rounded-2xl transition-all duration-300 hover:shadow-xl hover:shadow-primary/5 hover:-translate-y-0.5 cursor-pointer w-full min-w-0 overflow-hidden",
"group flex items-center p-3 sm:p-4 bg-card border border-border rounded-2xl transition-all duration-500 hover:duration-100 hover:shadow-xl hover:shadow-primary/5 hover:-translate-y-0.5 cursor-pointer w-full min-w-0 overflow-hidden",
props.task.completed && "opacity-60"
)}
onClick={() => setActiveTaskId(props.task.id)}
@@ -59,8 +62,32 @@ export const TaskCard: Component<{ task: Task }> = (props) => {
<div class="flex flex-wrap items-center gap-1 min-w-0">
<For each={props.task.tags}>
{(tag) => (
<Badge variant="secondary" class="h-4 px-1.5 text-[9px] gap-1 bg-muted/50 border-border/50 shrink-0">
<div class={cn("w-1 h-1 rounded-full", (store.tagDefinitions?.[tag] ?? 5) > 5 ? "bg-green-500" : (store.tagDefinitions?.[tag] ?? 5) < 5 ? "bg-red-500" : "bg-gray-400")} />
<Badge
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}
</Badge>
)}
+26 -1
View File
@@ -12,6 +12,8 @@ import { store } from "@/store";
import { cn } from "@/lib/utils";
import { toast } from "solid-sonner";
import { lazy, Suspense } from "solid-js";
import { useTheme } from "./ThemeProvider";
import { getThemeAdjustedColor } from "@/lib/colors";
const TaskEditor = lazy(() => import("./TaskEditor").then(m => ({ default: m.TaskEditor })));
@@ -22,6 +24,7 @@ interface TaskDetailProps {
}
export const TaskDetail: Component<TaskDetailProps> = (props) => {
const { resolvedTheme } = useTheme();
// Local state for immediate feedback, synced with props
const [title, setTitle] = createSignal(props.task.title);
const [editorInstance, setEditorInstance] = createSignal<any>(null);
@@ -247,10 +250,32 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
<Badge
variant="secondary"
class="h-7 px-2 text-xs gap-1.5 bg-muted/30 border-border/50 hover:bg-destructive/10 hover:border-destructive/30 cursor-pointer group/tag transition-all whitespace-nowrap shrink-0"
style={{
"background-color": (() => {
const def = store.tagDefinitions.find(d => d.name === tag);
const color = getThemeAdjustedColor(def?.color, def?.theme, resolvedTheme());
return color ? `${color}15` : undefined;
})(),
"border-color": (() => {
const def = store.tagDefinitions.find(d => d.name === tag);
const color = getThemeAdjustedColor(def?.color, def?.theme, resolvedTheme());
return color ? `${color}30` : undefined;
})(),
"color": (() => {
const def = store.tagDefinitions.find(d => d.name === tag);
return getThemeAdjustedColor(def?.color, def?.theme, resolvedTheme()) || "inherit";
})()
}}
onClick={() => updateTags((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}
<X size={12} class="opacity-0 group-hover/tag:opacity-50 transition-opacity" />
</Badge>
)}
</For>
+9 -4
View File
@@ -5,12 +5,14 @@ type Theme = "dark" | "light" | "system";
interface ThemeProviderState {
theme: () => Theme;
setTheme: (theme: Theme) => void;
resolvedTheme: () => "light" | "dark";
}
const ThemeProviderContext = createContext<ThemeProviderState>();
export const ThemeProvider: ParentComponent = (props) => {
const [theme, setTheme] = createSignal<Theme>("system");
const [resolvedTheme, setResolvedTheme] = createSignal<"light" | "dark">("light");
// Load from local storage on mount
const storedTheme = localStorage.getItem("tasgrid-theme") as Theme | null;
@@ -23,19 +25,22 @@ export const ThemeProvider: ParentComponent = (props) => {
root.classList.remove("light", "dark");
const currentTheme = theme();
let resolved: "light" | "dark";
if (currentTheme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
root.classList.add(systemTheme);
resolved = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
root.classList.add(resolved);
} else {
root.classList.add(currentTheme);
resolved = currentTheme === "dark" ? "dark" : "light";
root.classList.add(resolved);
}
setResolvedTheme(resolved);
localStorage.setItem("tasgrid-theme", currentTheme);
});
return (
<ThemeProviderContext.Provider value={{ theme, setTheme }}>
<ThemeProviderContext.Provider value={{ theme, setTheme, resolvedTheme }}>
{props.children}
</ThemeProviderContext.Provider>
);
+72 -44
View File
@@ -57,51 +57,51 @@
}
}
@layer base {
:root {
--background: hsl(0 0% 100%);
--foreground: hsl(222.2 84% 4.9%);
--card: hsl(0 0% 100%);
--card-foreground: hsl(222.2 84% 4.9%);
--popover: hsl(0 0% 100%);
--popover-foreground: hsl(222.2 84% 4.9%);
--primary: hsl(222.2 47.4% 11.2%);
--primary-foreground: hsl(210 40% 98%);
--secondary: hsl(210 40% 96.1%);
--secondary-foreground: hsl(222.2 47.4% 11.2%);
--muted: hsl(210 40% 96.1%);
--muted-foreground: hsl(215.4 16.3% 46.9%);
--accent: hsl(210 40% 96.1%);
--accent-foreground: hsl(222.2 47.4% 11.2%);
--destructive: hsl(0 84.2% 60.2%);
--destructive-foreground: hsl(210 40% 98%);
--border: hsl(214.3 31.8% 91.4%);
--input: hsl(214.3 31.8% 91.4%);
--ring: hsl(222.2 84% 4.9%);
--radius: 0.5rem;
}
:root {
--background: hsl(0 0% 100%);
--foreground: hsl(222.2 84% 4.9%);
--card: hsl(0 0% 100%);
--card-foreground: hsl(222.2 84% 4.9%);
--popover: hsl(0 0% 100%);
--popover-foreground: hsl(222.2 84% 4.9%);
--primary: hsl(250 89% 60%);
/* Vibrant Indigo */
--primary-foreground: hsl(0 0% 100%);
--secondary: hsl(210 40% 96.1%);
--secondary-foreground: hsl(222.2 47.4% 11.2%);
--muted: hsl(210 40% 96.1%);
--muted-foreground: hsl(215.4 16.3% 46.9%);
--accent: hsl(210 40% 96.1%);
--accent-foreground: hsl(222.2 47.4% 11.2%);
--destructive: hsl(0 84.2% 60.2%);
--destructive-foreground: hsl(210 40% 98%);
--border: hsl(214.3 31.8% 91.4%);
--input: hsl(214.3 31.8% 91.4%);
--ring: hsl(250 89% 60%);
--radius: 0.5rem;
}
.dark {
--background: hsl(222.2 84% 4.9%);
--foreground: hsl(210 40% 98%);
--card: hsl(222.2 84% 4.9%);
--card-foreground: hsl(210 40% 98%);
--popover: hsl(222.2 84% 4.9%);
--popover-foreground: hsl(210 40% 98%);
--primary: hsl(210 40% 98%);
--primary-foreground: hsl(222.2 47.4% 11.2%);
--secondary: hsl(217.2 32.6% 17.5%);
--secondary-foreground: hsl(210 40% 98%);
--muted: hsl(217.2 32.6% 17.5%);
--muted-foreground: hsl(215 20.2% 65.1%);
--accent: hsl(217.2 32.6% 17.5%);
--accent-foreground: hsl(210 40% 98%);
--destructive: hsl(0 62.8% 30.6%);
--destructive-foreground: hsl(210 40% 98%);
--border: hsl(217.2 32.6% 17.5%);
--input: hsl(217.2 32.6% 17.5%);
--ring: hsl(212.7 26.8% 83.9%);
}
.dark {
--background: hsl(222.2 84% 4.9%);
--foreground: hsl(210 40% 98%);
--card: hsl(222.2 84% 4.9%);
--card-foreground: hsl(210 40% 98%);
--popover: hsl(222.2 84% 4.9%);
--popover-foreground: hsl(210 40% 98%);
--primary: hsl(250 89% 65%);
/* Brighter Indigo for Dark Mode */
--primary-foreground: hsl(0 0% 100%);
--secondary: hsl(217.2 32.6% 17.5%);
--secondary-foreground: hsl(210 40% 98%);
--muted: hsl(217.2 32.6% 17.5%);
--muted-foreground: hsl(215 20.2% 65.1%);
--accent: hsl(217.2 32.6% 17.5%);
--accent-foreground: hsl(210 40% 98%);
--destructive: hsl(0 62.8% 30.6%);
--destructive-foreground: hsl(210 40% 98%);
--border: hsl(217.2 32.6% 17.5%);
--input: hsl(217.2 32.6% 17.5%);
--ring: hsl(212.7 26.8% 83.9%);
}
@layer base {
@@ -114,6 +114,34 @@
}
}
/* --- Custom Scrollbar --- */
::-webkit-scrollbar {
width: 10px;
}
::-webkit-scrollbar-track {
background: var(--background);
}
::-webkit-scrollbar-thumb {
background: var(--border);
border: 3px solid var(--background);
background-clip: content-box;
border-radius: 9999px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--muted-foreground);
border: 3px solid var(--background);
background-clip: content-box;
}
/* Firefox */
* {
scrollbar-width: thin;
scrollbar-color: var(--border) var(--background);
}
.fade-enter-active,
.fade-exit-active {
transition: opacity 0.2s ease;
+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 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 { pb, TASGRID_COLLECTION } from "@/lib/pocketbase";
import { pb, TASGRID_COLLECTION, TAGS_COLLECTION } from "@/lib/pocketbase";
import { toast } from "solid-sonner";
import { URGENCY_HOURS } from "@/lib/constants";
@@ -38,9 +38,22 @@ export interface TaskTemplate {
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 {
query: string;
tags: string[];
tags: FilterTag[];
priorityMin: number;
priorityMax: number;
urgencyMin: number;
@@ -52,9 +65,9 @@ interface TaskStore {
pWeight: number;
uWeight: number;
matrixScaleDays: number;
prefId?: string; // ID of the user_preferences record
tagDefinitions?: Record<string, number>;
templates?: TaskTemplate[];
prefId?: string; // ID of the user record
tagDefinitions: TagDefinition[];
templates: TaskTemplate[];
filter: Filter;
}
@@ -64,7 +77,7 @@ export const [store, setStore] = createStore<TaskStore>({
pWeight: 1.0,
uWeight: 1.0,
matrixScaleDays: 30,
tagDefinitions: {}, // Map<Name, Value 0-10>
tagDefinitions: [],
templates: [],
filter: {
query: "",
@@ -77,6 +90,9 @@ export const [store, setStore] = createStore<TaskStore>({
});
export const matchesFilter = (task: Task) => {
// Hide templates from all regular views
if (task.tags?.includes("__template__")) return false;
const f = store.filter;
// Query search (title or content)
@@ -87,10 +103,22 @@ export const matchesFilter = (task: Task) => {
if (!inTitle && !inContent) return false;
}
// Tags (OR logic if multiple selected?)
// Tags
if (f.tags.length > 0) {
const hasTag = f.tags.some(tag => task.tags?.includes(tag));
if (!hasTag) return false;
const includedTags = f.tags.filter(t => !t.excluded).map(t => t.name);
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
@@ -157,11 +185,11 @@ export const getCombinedScore = (task: Task): number => {
// Tag adjustments
if (task.tags && task.tags.length > 0) {
task.tags.forEach(tagName => {
const defVal = store.tagDefinitions?.[tagName];
if (defVal !== undefined) {
const def = store.tagDefinitions.find(d => d.name === tagName);
if (def) {
// Formula: (Value - 5) * 0.1
// 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);
try {
// 1. Fetch Preferences from User Profile
// 1. Fetch Preferences from User Profile (Weights, Scale, Tags)
try {
const userId = pb.authStore.model?.id;
if (userId) {
// Fetch fresh user record
const user = await pb.collection('users').getOne(userId, { requestKey: null });
// Field: Taskgrid_pref (JSON)
const prefs = user.Taskgrid_pref || {};
setStore({
pWeight: prefs.pWeight || 1.0,
uWeight: prefs.uWeight || 1.0,
matrixScaleDays: prefs.matrixScaleDays || 30,
tagDefinitions: prefs.tagDefinitions || {},
templates: prefs.templates || [],
prefId: userId // The ID to update is the User ID
prefId: userId
});
}
} catch (prefErr) {
// Non-blocking error for preferences
console.warn("Failed to load preferences from user profile:", prefErr);
console.warn("Failed to load preferences:", 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({
filter: `user = "${pb.authStore.model?.id}"`,
sort: '-created',
});
// Map PB records to Task interface
const loadedTasks: Task[] = records.map(r => ({
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
}));
const allTasks: Task[] = [];
const loadedTemplates: TaskTemplate[] = [];
// Use reconcile for efficient store update (keeps observers happy)
setStore("tasks", reconcile(loadedTasks));
records.forEach(r => {
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) {
// If we fail but have cached data, don't toast an error unless it's a hard failure
if (store.tasks.length === 0) {
console.error("Failed to load data:", err);
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) => {
// Optimistic update
setStore("tagDefinitions", name, value);
export const upsertTagDefinition = async (name: string, value: number, color?: string, theme?: "light" | "dark") => {
if (!pb.authStore.isValid) return;
// 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
const existing = store.tagDefinitions.find(d => d.name === name);
const finalTheme = theme || (document.documentElement.classList.contains("dark") ? "dark" : "light");
try {
if (existing) {
const updateData: any = { value };
if (color !== undefined) updateData.color = color;
if (theme !== undefined) updateData.theme = theme;
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"
};
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");
setStore("tagDefinitions", (prev) => [...prev, newDef]);
}
} 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) => {
// 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 });
if (!pb.authStore.isValid) return;
const def = store.tagDefinitions.find(d => d.name === name);
if (!def) return;
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) => {
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();
// 1. Get current value
const val = store.tagDefinitions?.[oldName] ?? 5;
try {
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));
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];
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);
const uniqueTags = [...new Set(newTags)];
updateTask(task.id, { tags: uniqueTags });
}
}));
// 4. Persist
await syncPreferences();
} catch (err) {
console.error("Failed to rename tag:", err);
toast.error("Failed to rename tag.");
}
};
@@ -542,8 +620,7 @@ export const syncPreferences = async () => {
pWeight: store.pWeight,
uWeight: store.uWeight,
matrixScaleDays: store.matrixScaleDays,
tagDefinitions: store.tagDefinitions,
templates: store.templates
tagDefinitions: store.tagDefinitions
};
await pb.collection('users').update(userId, {
@@ -557,20 +634,75 @@ export const syncPreferences = async () => {
};
export const addTemplate = async (template: Omit<TaskTemplate, "id">) => {
const newTemplate = { ...template, id: crypto.randomUUID() };
setStore("templates", (prev) => [...(prev || []), newTemplate]);
await syncPreferences();
return newTemplate;
if (!pb.authStore.isValid) return;
const meta = {
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>) => {
if (!pb.authStore.isValid) return;
// Optimistic
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) => {
if (!pb.authStore.isValid) return;
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) => {
+28 -14
View File
@@ -1,6 +1,7 @@
import { type Component, For, Show, createSignal, lazy, Suspense } from "solid-js";
import { ThemeToggle } from "../components/ThemeToggle";
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 { TagPicker } from "@/components/TagPicker";
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 })));
export const SettingsView: Component = () => {
const { resolvedTheme } = useTheme();
const [isTagsOpen, setIsTagsOpen] = createSignal(false);
const [isTemplatesOpen, setIsTemplatesOpen] = createSignal(false);
const [isTrashOpen, setIsTrashOpen] = createSignal(false);
@@ -98,18 +100,18 @@ export const SettingsView: Component = () => {
<Show when={isTagsOpen()}>
<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">
No specific tag definitions found.
</div>
}>
{([tagName, val]) => {
{(tag) => {
const [isEditingName, setIsEditingName] = createSignal(false);
const [tempName, setTempName] = createSignal(tagName);
const [tempName, setTempName] = createSignal(tag.name);
const handleRename = () => {
if (tempName() && tempName() !== tagName) {
renameTagDefinition(tagName, tempName());
if (tempName() && tempName() !== tag.name) {
renameTagDefinition(tag.name, tempName());
}
setIsEditingName(false);
};
@@ -117,7 +119,13 @@ export const SettingsView: Component = () => {
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 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() ? (
<input
@@ -134,7 +142,7 @@ export const SettingsView: Component = () => {
onClick={() => setIsEditingName(true)}
title="Click to rename"
>
{tagName}
{tag.name}
</span>
)}
</div>
@@ -143,8 +151,8 @@ export const SettingsView: Component = () => {
<div class="flex items-center gap-2">
<span class="text-[10px] text-muted-foreground font-bold uppercase tracking-wider">Val</span>
<select
value={String(val)}
onChange={(e) => upsertTagDefinition(tagName, parseInt(e.currentTarget.value))}
value={String(tag.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"
>
<For each={["10", "9", "8", "7", "6", "5", "4", "3", "2", "1", "0"]}>
@@ -156,10 +164,10 @@ export const SettingsView: Component = () => {
<Button
variant="ghost"
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={() => {
if (confirm(`Delete tag "${tagName}" globally? This will remove it from all tasks.`)) {
removeTagDefinition(tagName);
if (confirm(`Delete tag definition for "${tag.name}" ? `)) {
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"
onClick={(e: MouseEvent) => {
e.stopPropagation();
if (confirm(`Delete template "${template.name}"?`)) {
if (confirm(`Delete template "${template.name}" ? `)) {
removeTemplate(template.id);
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"
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}
</button>
)}