update tags and add description editor to add-task
This commit is contained in:
@@ -1,12 +1,15 @@
|
|||||||
import { type Component, createSignal, createEffect, onCleanup, onMount, For } from "solid-js";
|
import { type Component, createSignal, createEffect, onCleanup, onMount, For } from "solid-js";
|
||||||
import { Search, Command, Clock, ArrowUpCircle, Plus } from "lucide-solid";
|
import { Search, Command, Clock, ArrowUpCircle, Plus } from "lucide-solid";
|
||||||
import { addTask, calculateDateFromUrgency, calculateUrgencyFromDate, store, upsertTagDefinition } from "@/store";
|
import { addTask, calculateDateFromUrgency, calculateUrgencyFromDate, store, upsertMultipleTagDefinitions } from "@/store";
|
||||||
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 { 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 { lazy, Suspense } from "solid-js";
|
||||||
|
|
||||||
|
const TaskEditor = lazy(() => import("./TaskEditor").then(m => ({ default: m.TaskEditor })));
|
||||||
|
|
||||||
export const QuickEntry: Component = () => {
|
export const QuickEntry: Component = () => {
|
||||||
// Diff tracking for reactive tags
|
// Diff tracking for reactive tags
|
||||||
@@ -17,6 +20,8 @@ export const QuickEntry: Component = () => {
|
|||||||
const [priority, setPriority] = createSignal<string>("5");
|
const [priority, setPriority] = createSignal<string>("5");
|
||||||
const [urgency, setUrgency] = createSignal<string>("5");
|
const [urgency, setUrgency] = createSignal<string>("5");
|
||||||
const [tags, setTags] = createSignal<string[]>([]);
|
const [tags, setTags] = createSignal<string[]>([]);
|
||||||
|
const [description, setDescription] = createSignal("");
|
||||||
|
const [editorInstance, setEditorInstance] = createSignal<any>(null);
|
||||||
|
|
||||||
// Reactive shorthand parsing
|
// Reactive shorthand parsing
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -49,10 +54,6 @@ export const QuickEntry: Component = () => {
|
|||||||
const tagName = match[1].trim();
|
const tagName = match[1].trim();
|
||||||
if (tagName) {
|
if (tagName) {
|
||||||
newParsedTags.push(tagName);
|
newParsedTags.push(tagName);
|
||||||
if (match[2]) {
|
|
||||||
const val = Math.min(10, Math.max(0, parseInt(match[2])));
|
|
||||||
upsertTagDefinition(tagName, val);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,51 +125,40 @@ export const QuickEntry: Component = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const parseAndAdd = async () => {
|
const parseAndAdd = async () => {
|
||||||
const text = input();
|
let text = input();
|
||||||
if (!text || !priority() || !urgency()) return;
|
if (!text || !priority() || !urgency()) return;
|
||||||
|
|
||||||
let finalPriority = parseInt(priority());
|
let finalPriority = parseInt(priority());
|
||||||
let finalUrgency = parseInt(urgency());
|
let finalUrgency = parseInt(urgency());
|
||||||
const finalTags = [...tags()];
|
const finalTags = [...tags()];
|
||||||
let title = text;
|
|
||||||
|
|
||||||
// 1. Handle Priority / Urgency (Simple regexes as they don't have spaces)
|
// 1. Handle Priority / Urgency and clean them from text
|
||||||
const pMatch = text.match(/\/p(\d+)/);
|
const pMatch = text.match(/\/p\s*(\d+)/);
|
||||||
if (pMatch) {
|
if (pMatch) {
|
||||||
finalPriority = Math.min(10, Math.max(1, parseInt(pMatch[1])));
|
finalPriority = Math.min(10, Math.max(1, parseInt(pMatch[1])));
|
||||||
title = title.replace(pMatch[0], "");
|
text = text.replace(pMatch[0], "");
|
||||||
}
|
}
|
||||||
|
|
||||||
const uMatchNumeric = text.match(/\/u(\d+)/);
|
const uMatch = text.match(/\/u\s*(\d+)/);
|
||||||
if (uMatchNumeric) {
|
if (uMatch) {
|
||||||
finalUrgency = Math.min(10, Math.max(1, parseInt(uMatchNumeric[1])));
|
finalUrgency = Math.min(10, Math.max(1, parseInt(uMatch[1])));
|
||||||
title = title.replace(uMatchNumeric[0], "");
|
text = text.replace(uMatch[0], "");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Handle Tags: /t [name]:[value]
|
// 2. Handle Tags: /t [name]:[value]
|
||||||
// We look for /t, then name, then optional :value.
|
|
||||||
// We split by /t to find all segments.
|
|
||||||
const segments = text.split(/\/t\s*/);
|
const segments = text.split(/\/t\s*/);
|
||||||
// segments[0] is the part before first /t
|
let title = segments[0];
|
||||||
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];
|
||||||
// Name ends at first : or / (but / is handled by split) or end of string
|
|
||||||
const firstColon = seg.indexOf(':');
|
const firstColon = seg.indexOf(':');
|
||||||
let tagName = "";
|
let tagName = "";
|
||||||
let tagValue: number | undefined;
|
let tagValue: number | undefined;
|
||||||
let remainingText = "";
|
let remainingText = "";
|
||||||
|
|
||||||
if (firstColon === -1) {
|
if (firstColon === -1) {
|
||||||
// No colon in this segment. Segment might be "Tag Name Title" or "Tag Name /t"
|
|
||||||
// But the user said / or : are endpoints.
|
|
||||||
// So if no colon, the WHOLE segment is the tag name?
|
|
||||||
// Wait, if I type "/t Tag Title", the user said / and : are endpoints.
|
|
||||||
// So if neither is there, the tag continues to end of string?
|
|
||||||
// Actually, let's treat the FIRST SPACE after some non-space text as a fallback?
|
|
||||||
// No, user specifically said: "It should allow spaces in this particular parse... use / or : as end points."
|
|
||||||
// So if I type "/t Tag Name", it IS the tag name.
|
|
||||||
tagName = seg.trim();
|
tagName = seg.trim();
|
||||||
} else {
|
} else {
|
||||||
tagName = seg.slice(0, firstColon).trim();
|
tagName = seg.slice(0, firstColon).trim();
|
||||||
@@ -179,7 +169,6 @@ export const QuickEntry: Component = () => {
|
|||||||
tagValue = Math.min(10, Math.max(0, parseInt(valueMatch[1])));
|
tagValue = Math.min(10, Math.max(0, parseInt(valueMatch[1])));
|
||||||
remainingText = afterColon.slice(valueMatch[0].length);
|
remainingText = afterColon.slice(valueMatch[0].length);
|
||||||
} else {
|
} else {
|
||||||
// Colon was just a separator for title
|
|
||||||
remainingText = afterColon;
|
remainingText = afterColon;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -189,12 +178,18 @@ export const QuickEntry: Component = () => {
|
|||||||
finalTags.push(tagName);
|
finalTags.push(tagName);
|
||||||
}
|
}
|
||||||
if (tagValue !== undefined) {
|
if (tagValue !== undefined) {
|
||||||
upsertTagDefinition(tagName, tagValue);
|
tagsToUpsert[tagName] = tagValue;
|
||||||
|
} else if (store.tagDefinitions?.[tagName] === undefined) {
|
||||||
|
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());
|
||||||
@@ -207,16 +202,21 @@ export const QuickEntry: Component = () => {
|
|||||||
addTask(title.replace(/\s+/g, " ").trim(), {
|
addTask(title.replace(/\s+/g, " ").trim(), {
|
||||||
priority: finalPriority,
|
priority: finalPriority,
|
||||||
dueDate: finalDueDate || undefined,
|
dueDate: finalDueDate || undefined,
|
||||||
tags: finalTags
|
tags: finalTags,
|
||||||
|
content: description()
|
||||||
});
|
});
|
||||||
|
|
||||||
setInput("");
|
setInput("");
|
||||||
setPriority("5");
|
setPriority("5");
|
||||||
setUrgency("5");
|
setUrgency("5");
|
||||||
setTags([]);
|
setTags([]);
|
||||||
|
setDescription("");
|
||||||
lastParsedTags = [];
|
lastParsedTags = [];
|
||||||
setDateString("");
|
setDateString("");
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
|
// Reset editor content
|
||||||
|
const instance = editorInstance();
|
||||||
|
if (instance) instance.commands.setContent("");
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -335,6 +335,17 @@ export const QuickEntry: Component = () => {
|
|||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="px-4 py-3 bg-card border-b border-border min-h-[120px] max-h-[300px] overflow-y-auto">
|
||||||
|
<Suspense fallback={<div class="h-20 animate-pulse bg-muted/20 rounded-lg" />}>
|
||||||
|
<TaskEditor
|
||||||
|
content={description()}
|
||||||
|
onUpdate={setDescription}
|
||||||
|
onEditorReady={setEditorInstance}
|
||||||
|
class="min-h-[100px] prose-sm"
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="p-4 bg-muted/30 flex justify-between items-center">
|
<div class="p-4 bg-muted/30 flex justify-between items-center">
|
||||||
<div class="flex items-center space-x-2 text-[10px] font-bold text-muted-foreground uppercase tracking-widest">
|
<div class="flex items-center space-x-2 text-[10px] font-bold text-muted-foreground uppercase tracking-widest">
|
||||||
<Command size={10} />
|
<Command size={10} />
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export const TagPicker: Component<TagPickerProps> = (props) => {
|
|||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<label class="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Value (Influence)</label>
|
<label class="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Value (Influence)</label>
|
||||||
<select
|
<select
|
||||||
value={valueScore()}
|
value={valueScore().toString()}
|
||||||
onInput={(e) => setValueScore(parseInt(e.currentTarget.value))}
|
onInput={(e) => setValueScore(parseInt(e.currentTarget.value))}
|
||||||
class="flex h-9 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm ring-offset-background focus:outline-none focus:ring-1 focus:ring-ring appearance-none"
|
class="flex h-9 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm ring-offset-background focus:outline-none focus:ring-1 focus:ring-ring appearance-none"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { type Component, createEffect, createSignal, For } from "solid-js";
|
import { type Component, createEffect, createSignal, For } from "solid-js";
|
||||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||||
import { type Task, removeTask, restoreTask, updateTask } from "@/store";
|
import { type Task, removeTask, restoreTask, updateTask, copyTask } from "@/store";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
|
||||||
import { ArrowUpCircle, Clock, Calendar, Type, Trash2 } from "lucide-solid";
|
import { ArrowUpCircle, Clock, Calendar, Type, Trash2, Copy } from "lucide-solid";
|
||||||
import { calculateDateFromUrgency, calculateUrgencyFromDate } from "@/store";
|
import { calculateDateFromUrgency, calculateUrgencyFromDate } from "@/store";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { TagPicker } from "./TagPicker";
|
import { TagPicker } from "./TagPicker";
|
||||||
@@ -188,8 +188,20 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Delete Button */}
|
{/* Copy button */}
|
||||||
<div class="flex items-center gap-1 shrink-0 ml-auto">
|
<div class="flex items-center gap-1 shrink-0 ml-auto">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-8 w-8 px-0 hover:bg-muted/50 text-muted-foreground transition-colors"
|
||||||
|
onClick={() => copyTask(props.task.id)}
|
||||||
|
>
|
||||||
|
<Copy size={16} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete Button */}
|
||||||
|
<div class="flex items-center gap-1 shrink-0">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const ThemeToggle: Component = () => {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => setTheme(theme() === "dark" ? "light" : "dark")}
|
onClick={() => setTheme(theme() === "dark" ? "light" : "dark")}
|
||||||
class="w-9 h-9 px-0"
|
class="w-9 h-9 px-0 relative"
|
||||||
>
|
>
|
||||||
<Sun class="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
<Sun class="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||||
<Moon class="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
<Moon class="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||||
|
|||||||
+53
-11
@@ -186,7 +186,7 @@ export const initStore = async () => {
|
|||||||
|
|
||||||
// -- Actions --
|
// -- Actions --
|
||||||
|
|
||||||
export const addTask = async (title: string, options?: { dueDate?: Date | string, priority?: number, tags?: string[] }) => {
|
export const addTask = async (title: string, options?: { dueDate?: Date | string, priority?: number, tags?: string[], content?: string }) => {
|
||||||
if (!pb.authStore.isValid) return;
|
if (!pb.authStore.isValid) return;
|
||||||
|
|
||||||
// Default to ~24h from now if no date provided
|
// Default to ~24h from now if no date provided
|
||||||
@@ -198,6 +198,7 @@ export const addTask = async (title: string, options?: { dueDate?: Date | string
|
|||||||
const startDate = new Date().toISOString();
|
const startDate = new Date().toISOString();
|
||||||
const priority = options?.priority ?? 5;
|
const priority = options?.priority ?? 5;
|
||||||
const tags = options?.tags ?? ["work"];
|
const tags = options?.tags ?? ["work"];
|
||||||
|
const content = options?.content ?? "";
|
||||||
|
|
||||||
const tempId = "temp-" + Date.now();
|
const tempId = "temp-" + Date.now();
|
||||||
const newTask: Task = {
|
const newTask: Task = {
|
||||||
@@ -207,7 +208,8 @@ export const addTask = async (title: string, options?: { dueDate?: Date | string
|
|||||||
dueDate,
|
dueDate,
|
||||||
priority,
|
priority,
|
||||||
completed: false,
|
completed: false,
|
||||||
tags
|
tags,
|
||||||
|
content
|
||||||
};
|
};
|
||||||
|
|
||||||
// Optimistic UI update
|
// Optimistic UI update
|
||||||
@@ -221,7 +223,8 @@ export const addTask = async (title: string, options?: { dueDate?: Date | string
|
|||||||
dueDate,
|
dueDate,
|
||||||
priority,
|
priority,
|
||||||
completed: false,
|
completed: false,
|
||||||
tags
|
tags,
|
||||||
|
content
|
||||||
});
|
});
|
||||||
|
|
||||||
// Replace temp task with real record
|
// Replace temp task with real record
|
||||||
@@ -234,6 +237,21 @@ export const addTask = async (title: string, options?: { dueDate?: Date | string
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const copyTask = async (id: string) => {
|
||||||
|
const original = store.tasks.find(t => t.id === id);
|
||||||
|
if (!original) return;
|
||||||
|
|
||||||
|
// Create a new task based on the original
|
||||||
|
await addTask(`${original.title} (Copy)`, {
|
||||||
|
priority: original.priority,
|
||||||
|
dueDate: original.dueDate,
|
||||||
|
tags: [...(original.tags || [])],
|
||||||
|
content: original.content
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("Task duplicated");
|
||||||
|
};
|
||||||
|
|
||||||
export const updateTask = async (id: string, updates: Partial<Task>) => {
|
export const updateTask = async (id: string, updates: Partial<Task>) => {
|
||||||
if (!pb.authStore.isValid) return;
|
if (!pb.authStore.isValid) return;
|
||||||
|
|
||||||
@@ -380,12 +398,6 @@ export const upsertTagDefinition = async (name: string, value: number) => {
|
|||||||
const userId = pb.authStore.model?.id;
|
const userId = pb.authStore.model?.id;
|
||||||
if (userId) {
|
if (userId) {
|
||||||
try {
|
try {
|
||||||
// Need to fetch user record first? Or iterate local?
|
|
||||||
// Pocketbase update can take partial JSON?
|
|
||||||
// "Taskgrid_pref" is a JSON field. We usually need to send the whole object or merge it manually if the backend doesn't merge.
|
|
||||||
// But here we rely on store state having everything.
|
|
||||||
|
|
||||||
// Re-construct full prefs object
|
|
||||||
const prefs = {
|
const prefs = {
|
||||||
pWeight: store.pWeight,
|
pWeight: store.pWeight,
|
||||||
uWeight: store.uWeight,
|
uWeight: store.uWeight,
|
||||||
@@ -395,14 +407,44 @@ export const upsertTagDefinition = async (name: string, value: number) => {
|
|||||||
|
|
||||||
await pb.collection('users').update(userId, {
|
await pb.collection('users').update(userId, {
|
||||||
Taskgrid_pref: prefs
|
Taskgrid_pref: prefs
|
||||||
});
|
}, { requestKey: null });
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
|
// Ignore abort errors
|
||||||
|
if (e.isAbort) return;
|
||||||
console.error("Failed to persist tag definition", e);
|
console.error("Failed to persist tag definition", e);
|
||||||
toast.error("Failed to save tag definition");
|
toast.error("Failed to save tag definition");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const upsertMultipleTagDefinitions = async (updates: Record<string, number>) => {
|
||||||
|
// Optimistic update
|
||||||
|
setStore("tagDefinitions", (defs) => ({ ...(defs || {}), ...updates }));
|
||||||
|
|
||||||
|
// Persist
|
||||||
|
const map = { ...store.tagDefinitions };
|
||||||
|
const userId = pb.authStore.model?.id;
|
||||||
|
if (userId) {
|
||||||
|
try {
|
||||||
|
const prefs = {
|
||||||
|
pWeight: store.pWeight,
|
||||||
|
uWeight: store.uWeight,
|
||||||
|
matrixScaleDays: store.matrixScaleDays,
|
||||||
|
tagDefinitions: map
|
||||||
|
};
|
||||||
|
|
||||||
|
await pb.collection('users').update(userId, {
|
||||||
|
Taskgrid_pref: prefs
|
||||||
|
}, { requestKey: null });
|
||||||
|
} catch (e: any) {
|
||||||
|
// Ignore abort errors
|
||||||
|
if (e.isAbort) return;
|
||||||
|
console.error("Failed to persist tag definitions", e);
|
||||||
|
toast.error("Failed to save tag definitions");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const removeTagDefinition = async (name: string) => {
|
export const removeTagDefinition = async (name: string) => {
|
||||||
// 1. Remove from all tasks
|
// 1. Remove from all tasks
|
||||||
const tasksWithTag = store.tasks.filter(t => t.tags?.includes(name));
|
const tasksWithTag = store.tasks.filter(t => t.tags?.includes(name));
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ 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={val}
|
value={String(val)}
|
||||||
onChange={(e) => upsertTagDefinition(tagName, parseInt(e.currentTarget.value))}
|
onChange={(e) => upsertTagDefinition(tagName, parseInt(e.currentTarget.value))}
|
||||||
class="flex h-8 w-16 items-center justify-between rounded-md border border-input bg-background px-2 py-1 text-xs shadow-sm ring-offset-background focus:outline-none focus:ring-1 focus:ring-ring appearance-none"
|
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"
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user