added tags and tag manager

This commit is contained in:
2026-01-31 14:14:39 -06:00
parent 9ca490075b
commit d6864a9443
11 changed files with 633 additions and 43 deletions
+128 -28
View File
@@ -1,15 +1,22 @@
import { type Component, createSignal, createEffect, onCleanup, onMount } from "solid-js";
import { type Component, createSignal, createEffect, onCleanup, onMount, For } from "solid-js";
import { Search, Command, Clock, ArrowUpCircle, Plus } from "lucide-solid";
import { addTask, calculateDateFromUrgency, calculateUrgencyFromDate } from "@/store";
import { addTask, calculateDateFromUrgency, calculateUrgencyFromDate, store, upsertTagDefinition } from "@/store";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { TextField, TextFieldInput } from "@/components/ui/textfield";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
import { TagPicker } from "@/components/TagPicker";
export const QuickEntry: Component = () => {
// Diff tracking for reactive tags
let lastParsedTags: string[] = [];
const [isOpen, setIsOpen] = createSignal(false);
const [input, setInput] = createSignal("");
const [priority, setPriority] = createSignal<string>("5");
const [urgency, setUrgency] = createSignal<string>("5");
const [tags, setTags] = createSignal<string[]>([]);
// Reactive shorthand parsing
createEffect(() => {
@@ -28,6 +35,41 @@ export const QuickEntry: Component = () => {
onUrgencySimpleChange(val);
}
}
// Tag Shortcut Parsing: /t [tag name]:[optional value]
// This is reactive. To avoid "prefix storm", only add if terminated by : or /
// Tag Shortcut Parsing: /t [tag name]:[optional value]
// Regex: /t, name (no : or /), lookahead for terminator (: or /), optional value group
const tagRegex = /\/t\s*([^:/]+)(?=(?:[:/]))(?:[:](\d+))?/g;
const newParsedTags: string[] = [];
let match;
while ((match = tagRegex.exec(text)) !== null) {
const tagName = match[1].trim();
if (tagName) {
newParsedTags.push(tagName);
if (match[2]) {
const val = Math.min(10, Math.max(0, parseInt(match[2])));
upsertTagDefinition(tagName, val);
}
}
}
const currentTags = [...tags()];
// Determine changes
const tagsToRemove = lastParsedTags.filter(t => !newParsedTags.includes(t));
const tagsToAdd = newParsedTags.filter(t => !currentTags.includes(t));
// Apply changes
let nextTags = currentTags.filter(t => !tagsToRemove.includes(t));
nextTags = [...nextTags, ...tagsToAdd];
if (JSON.stringify(nextTags) !== JSON.stringify(currentTags)) {
setTags(nextTags);
}
lastParsedTags = newParsedTags;
});
// Explicit date string for the input (YYYY-MM-DDTHH:mm)
@@ -81,56 +123,98 @@ export const QuickEntry: Component = () => {
}
};
const parseAndAdd = () => {
const parseAndAdd = async () => {
const text = input();
if (!text || !priority() || !urgency()) return;
let finalPriority = parseInt(priority());
let finalUrgency = parseInt(urgency());
const finalTags = [...tags()];
let title = text;
// Parse shorthand overrides if present
// 1. Handle Priority / Urgency (Simple regexes as they don't have spaces)
const pMatch = text.match(/\/p(\d+)/);
if (pMatch) finalPriority = Math.min(10, Math.max(1, parseInt(pMatch[1])));
if (pMatch) {
finalPriority = Math.min(10, Math.max(1, parseInt(pMatch[1])));
title = title.replace(pMatch[0], "");
}
// Check for urgency override
const uMatchNumeric = text.match(/\/u(\d+)/);
if (uMatchNumeric) {
finalUrgency = Math.min(10, Math.max(1, parseInt(uMatchNumeric[1])));
title = title.replace(uMatchNumeric[0], "");
}
// 2. Handle Tags: /t [name]:[value]
// We look for /t, then name, then optional :value.
// We split by /t to find all segments.
const segments = text.split(/\/t\s*/);
// segments[0] is the part before first /t
title = segments[0];
for (let i = 1; i < segments.length; i++) {
const seg = segments[i];
// Name ends at first : or / (but / is handled by split) or end of string
const firstColon = seg.indexOf(':');
let tagName = "";
let tagValue: number | undefined;
let remainingText = "";
if (firstColon === -1) {
// No colon in this segment. Segment might be "Tag Name Title" or "Tag Name /t"
// But the user said / or : are endpoints.
// So if no colon, the WHOLE segment is the tag name?
// Wait, if I type "/t Tag Title", the user said / and : are endpoints.
// So if neither is there, the tag continues to end of string?
// Actually, let's treat the FIRST SPACE after some non-space text as a fallback?
// No, user specifically said: "It should allow spaces in this particular parse... use / or : as end points."
// So if I type "/t Tag Name", it IS the tag name.
tagName = seg.trim();
} else {
tagName = seg.slice(0, firstColon).trim();
const afterColon = seg.slice(firstColon + 1);
const valueMatch = afterColon.match(/^\s*(\d+)/);
if (valueMatch) {
tagValue = Math.min(10, Math.max(0, parseInt(valueMatch[1])));
remainingText = afterColon.slice(valueMatch[0].length);
} else {
// Colon was just a separator for title
remainingText = afterColon;
}
}
if (tagName) {
if (!finalTags.includes(tagName)) {
finalTags.push(tagName);
}
if (tagValue !== undefined) {
upsertTagDefinition(tagName, tagValue);
}
}
title += " " + remainingText;
}
let finalDueDate: Date | null = null;
if (dateString()) {
finalDueDate = new Date(dateString());
} else {
// If urgency is selected, calculate date
const u = parseInt(urgency());
if (!isNaN(u)) {
// Check if override happened
if (uMatchNumeric) {
const dateStr = calculateDateFromUrgency(finalUrgency);
finalDueDate = new Date(dateStr);
} else {
const dateStr = calculateDateFromUrgency(u);
finalDueDate = new Date(dateStr);
}
}
const u = finalUrgency;
const dateStr = calculateDateFromUrgency(u);
finalDueDate = new Date(dateStr);
}
const title = text
.replace(/\/p\d+/, "")
.replace(/\/u\d+/, "")
.trim();
// Use the new signature: addTask(title, options)
addTask(title, {
addTask(title.replace(/\s+/g, " ").trim(), {
priority: finalPriority,
dueDate: finalDueDate || undefined, // undefined will let store pick default if that was the intent, but here we probably want strictly calculated date
tags: []
dueDate: finalDueDate || undefined,
tags: finalTags
});
setInput("");
setPriority("5");
setUrgency("5");
setTags([]);
lastParsedTags = [];
setDateString("");
setIsOpen(false);
};
@@ -172,7 +256,7 @@ export const QuickEntry: Component = () => {
<Select
value={priority()}
onChange={(val) => val && setPriority(val)}
options={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
options={["10", "9", "8", "7", "6", "5", "4", "3", "2", "1"]}
placeholder="Priority"
itemComponent={(props) => (
<SelectItem item={props.item}>
@@ -235,6 +319,22 @@ export const QuickEntry: Component = () => {
</div>
</div>
<div class="px-4 pb-4 bg-muted/10 border-b border-border flex flex-wrap gap-2 items-center">
<TagPicker selectedTags={tags()} onTagsChange={setTags} />
<For each={tags()}>
{(tag) => (
<Badge
variant="secondary"
class="h-7 px-2 text-xs gap-1 bg-background border border-border/50 cursor-pointer hover:bg-destructive/10 hover:border-destructive/30 group/tag transition-all"
onClick={() => setTags(tags().filter(t => t !== tag))}
>
<div class={cn("w-1.5 h-1.5 rounded-full transition-colors group-hover/tag:bg-destructive", (store.tagDefinitions?.[tag] ?? 5) > 5 ? "bg-green-500" : (store.tagDefinitions?.[tag] ?? 5) < 5 ? "bg-red-500" : "bg-gray-400")} />
{tag}
</Badge>
)}
</For>
</div>
<div class="p-4 bg-muted/30 flex justify-between items-center">
<div class="flex items-center space-x-2 text-[10px] font-bold text-muted-foreground uppercase tracking-widest">
<Command size={10} />