tag colors and tag list tracking fix
This commit is contained in:
+246
-114
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user