Files
TasGrid/src/store/index.ts
T

598 lines
19 KiB
TypeScript

import { createStore, reconcile, produce } from "solid-js/store";
import { createSignal, createEffect, createRoot } from "solid-js";
import { pb, TASGRID_COLLECTION } from "@/lib/pocketbase";
import { toast } from "solid-sonner";
const getStorageKey = () => {
const userId = pb.authStore.model?.id;
return userId ? `tasgrid_data_${userId}` : null;
};
export const [now, setNow] = createSignal(Date.now());
export const [activeTaskId, setActiveTaskId] = createSignal<string | null>(null);
export type UrgencyLevel = number;
export interface Task {
id: string;
title: string;
startDate: string;
dueDate: string;
priority: number; // 1-10
completed: boolean;
tags: string[];
content?: string; // HTML content from Tiptap
deletedAt?: number; // Timestamp of soft delete
}
export interface Filter {
query: string;
tags: string[];
priorityMin: number;
priorityMax: number;
urgencyMin: number;
urgencyMax: number;
}
interface TaskStore {
tasks: Task[];
pWeight: number;
uWeight: number;
matrixScaleDays: number;
prefId?: string; // ID of the user_preferences record
tagDefinitions?: Record<string, number>;
filter: Filter;
}
// Initial empty state
export const [store, setStore] = createStore<TaskStore>({
tasks: [],
pWeight: 1.0,
uWeight: 1.0,
matrixScaleDays: 30,
tagDefinitions: {}, // Map<Name, Value 0-10>
filter: {
query: "",
tags: [],
priorityMin: 1,
priorityMax: 10,
urgencyMin: 1,
urgencyMax: 10
}
});
export const matchesFilter = (task: Task) => {
const f = store.filter;
// Query search (title or content)
if (f.query) {
const q = f.query.toLowerCase();
const inTitle = task.title.toLowerCase().includes(q);
const inContent = task.content?.toLowerCase().includes(q);
if (!inTitle && !inContent) return false;
}
// Tags (OR logic if multiple selected?)
if (f.tags.length > 0) {
const hasTag = f.tags.some(tag => task.tags?.includes(tag));
if (!hasTag) return false;
}
// Priority
if (task.priority < f.priorityMin || task.priority > f.priorityMax) return false;
// Urgency (calculated)
const urgency = calculateUrgencyFromDate(task.dueDate);
if (urgency < f.urgencyMin || urgency > f.urgencyMax) return false;
return true;
};
// Auto-persist changes to localStorage (wrapped in root to avoid console warning)
createRoot(() => {
createEffect(() => {
const key = getStorageKey();
if (key) {
localStorage.setItem(key, JSON.stringify(store));
}
});
});
// -- Calibration --
export const calculateUrgencyScore = (dueDateStr: string): number => {
const due = new Date(dueDateStr).getTime();
const diffHours = (due - now()) / (1000 * 60 * 60);
// Geometric formulation
// Base is derived so that urgency 1 ~ matrixScaleDays
const base = Math.pow(Math.max(1, store.matrixScaleDays), 1 / 9);
// Reverse formula: Level = 10 - (ln(hours/24) / ln(base))
// If hours <= 24, log(<=1) is negative or 0, so Level >= 10.
if (diffHours <= 24) return 10;
const level = 10 - (Math.log(diffHours / 24) / Math.log(base));
return Math.max(1, Math.min(10, level));
};
export const calculateDateFromUrgency = (level: number): string => {
// Inverse of above
if (level >= 10) {
return new Date(now() + 24 * 60 * 60 * 1000).toISOString();
}
const base = Math.pow(Math.max(1, store.matrixScaleDays), 1 / 9);
const hours = 24 * Math.pow(base, 10 - level);
return new Date(now() + hours * 60 * 60 * 1000).toISOString();
};
export const calculateUrgencyFromDate = (dateStr: string): number => {
return Math.round(calculateUrgencyScore(dateStr));
};
export const getCombinedScore = (task: Task): number => {
const urgencyScore = calculateUrgencyScore(task.dueDate);
let baseScore = (task.priority * store.pWeight) + (urgencyScore * store.uWeight);
// Tag adjustments
if (task.tags && task.tags.length > 0) {
task.tags.forEach(tagName => {
const defVal = store.tagDefinitions?.[tagName];
if (defVal !== undefined) {
// Formula: (Value - 5) * 0.1
// 5 -> 0, 10 -> 0.5, 0 -> -0.5
baseScore += (defVal - 5) * 0.1;
}
});
}
return baseScore;
};
// -- Persistence & Sync --
let heartbeatInterval: number | undefined;
export const initStore = async () => {
if (!pb.authStore.isValid) return;
// 0. Synchronous hydration from localStorage for "instant" first paint
const key = getStorageKey();
if (key) {
const saved = localStorage.getItem(key);
if (saved) {
try {
setStore(JSON.parse(saved));
} catch (e) {
console.error("Failed to parse cached data", e);
}
}
}
// Heartbeat - Ensure only one interval is running
if (heartbeatInterval) clearInterval(heartbeatInterval);
heartbeatInterval = setInterval(() => setNow(Date.now()), 60000);
try {
// 1. Fetch Preferences from User Profile
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 || {},
prefId: userId // The ID to update is the User ID
});
}
} catch (prefErr) {
// Non-blocking error for preferences
console.warn("Failed to load preferences from user profile:", prefErr);
}
// 2. Fetch Tasks - Explicitly filtered by user and sorted
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
}));
// Use reconcile for efficient store update (keeps observers happy)
setStore("tasks", reconcile(loadedTasks));
} 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);
}
}
};
// -- Actions --
export const addTask = async (title: string, options?: { dueDate?: Date | string, priority?: number, tags?: string[], content?: string }) => {
if (!pb.authStore.isValid) return;
// Default to ~24h from now if no date provided
let dueDate = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
if (options?.dueDate) {
dueDate = options.dueDate instanceof Date ? options.dueDate.toISOString() : options.dueDate;
}
const startDate = new Date().toISOString();
const priority = options?.priority ?? 5;
const tags = options?.tags ?? ["work"];
const content = options?.content ?? "";
const tempId = "temp-" + Date.now();
const newTask: Task = {
id: tempId,
title,
startDate,
dueDate,
priority,
completed: false,
tags,
content
};
// Optimistic UI update
setStore("tasks", (t) => [newTask, ...t]);
try {
const record = await pb.collection(TASGRID_COLLECTION).create({
user: pb.authStore.model?.id,
title,
startDate,
dueDate,
priority,
completed: false,
tags,
content
});
// Replace temp task with real record
setStore("tasks", (t) => t.map(task => task.id === tempId ? { ...task, id: record.id } : task));
} catch (err) {
console.error("create failed", err);
toast.error("Failed to save task.");
// Rollback?
setStore("tasks", (t) => t.filter(task => task.id !== tempId));
}
};
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>) => {
if (!pb.authStore.isValid) return;
// Optimistic
setStore("tasks", (t) => t.id === id, updates);
try {
// Map Task fields to PB fields if needed (dates are ISO strings, so should matches)
// Check for specific fields being updated
const pbUpdates: any = { ...updates };
if (updates.deletedAt !== undefined) {
// PB expects a date string or null for date fields usually,
// but if we defined deletedAt as a 'date' field in PB.
// If we defined it as a number in PB? User didn't specify.
// Assuming it's a 'Date' field in PB based on previous conversation
// "deletedAt | Date | No"
if (updates.deletedAt) {
pbUpdates.deletedAt = new Date(updates.deletedAt).toISOString();
} else {
pbUpdates.deletedAt = null; // restore
}
}
// Disable auto-cancellation for updates to prevent aborted errors during rapid typing
await pb.collection(TASGRID_COLLECTION).update(id, pbUpdates, { requestKey: null });
} catch (err) {
console.error("update failed", err);
toast.error("Failed to update task.");
}
};
// Wrapper for updateTask to fit setStore style usage in components if needed,
// but for now we'll just intercept the setStore calls or provide specific actions.
// The components currently use `setStore("tasks", ...)` directly in some places.
// We need to verify if we should refactor those components to use actions or use a store effect.
// Ideally, we use a createEffect to watch the store, but that can trigger on every load.
// Better to export specific actions.
// -- Legacy Adapters --
// Many components use setStore directly. To support them without full refactor,
// we can watch specific fields or just migrate them to use actions.
// Given strict instructions, let's look at what we've seen.
// TaskDetail uses `removeTask`.
// QuickEntry uses `addTask`.
// TaskDetail uses `setStore` for title/priority updates. THIS IS A PROBLEM for sync.
// We should provide a way to sync these changes.
// Let's adding a store effect to sync changes?
// It's risky. Let's provide an explicit hook or functions and fix the components.
// OR, we overwrite the `setStore` export? No, that's internal.
// Let's updated `setStore` usage in this file to be purely local,
// and add a `syncTask` helper that components should call?
// Or better: Replace the `setStore` export with a wrapped version? Hard with type inference.
//
// Best approach for now:
// Keep `setStore` for local UI state.
// Add a `createEffect` that watches `store.tasks`?
// No, deep watching is expensive.
//
// Let's sticking to: Components SHOULD call actions.
// But TaskDetail calls `setStore`.
// I will start by modifying `setStore` usages in TaskDetail/etc in a subsequent step if needed.
// FOR NOW: I will export a `updateTaskField` helper and update components to use it.
// Actually, `TaskDetail` was edited to use `setStore` in Step 1520.
// line 33: setStore("tasks", (t) => t.id === props.task.id, "content", html);
// line 38, 44, 52, 60... all use setStore.
// I MUST refactor TaskDetail to use an action.
export const updateTaskField = (id: string, field: keyof Task, value: any) => {
setStore("tasks", (t) => t.id === id, field, value);
// Debounce this?
// For now, fire and forget update
updateTask(id, { [field]: value });
};
// -- Matrix Scale --
export const setMatrixScaleDays = async (days: number) => {
setStore("matrixScaleDays", days);
// Sync to users collection
if (store.prefId) {
try {
const prefs = {
pWeight: store.pWeight,
uWeight: store.uWeight,
matrixScaleDays: days
};
await pb.collection('users').update(store.prefId, {
Taskgrid_pref: prefs
});
} catch (err) {
console.error("Failed to update preferences", err);
}
}
};
export const toggleTask = (id: string) => {
const task = store.tasks.find(t => t.id === id);
if (task) {
updateTask(id, { completed: !task.completed });
}
};
export const removeTask = (id: string) => {
// Soft delete
const nowTs = Date.now();
updateTask(id, { deletedAt: nowTs });
};
export const restoreTask = (id: string) => {
// This requires passing undefined or null to updateTask
// createStore handles undefined ok.
setStore("tasks", (t) => t.id === id, "deletedAt", undefined);
// For PB, we need to send null explicitly for date clear?
// updateTask handles this check.
updateTask(id, { deletedAt: undefined });
};
export const deleteTaskPermanently = async (id: string) => {
if (!pb.authStore.isValid) return;
// Optimistic
setStore("tasks", (t) => t.filter((t) => t.id !== id));
try {
await pb.collection(TASGRID_COLLECTION).delete(id);
} catch (err) {
console.error("delete failed", err);
toast.error("Failed to delete task.");
// Reload tasks?
}
};
export const upsertTagDefinition = async (name: string, value: number) => {
// Optimistic update
setStore("tagDefinitions", name, value);
// 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 definition", e);
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) => {
// 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 });
}
// 2. Remove definition locally
setStore("tagDefinitions", produce((defs) => {
if (defs) delete defs[name];
}));
// 3. 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
});
toast.success(`Tag "${name}" deleted`);
} catch (e) {
console.error("Failed to delete tag definition", e);
toast.error("Failed to delete tag");
}
}
};
export const renameTagDefinition = async (oldName: string, newName: string) => {
if (!newName || !newName.trim() || newName === oldName) return;
const finalName = newName.trim();
// 1. Get current value
const val = store.tagDefinitions?.[oldName] ?? 5;
// 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];
}
}));
// 4. 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
});
toast.success(`Tag renamed to "${finalName}"`);
} catch (e) {
console.error("Failed to rename tag", e);
toast.error("Failed to rename tag");
}
}
};
export const setFilter = (update: Partial<Filter>) => {
setStore("filter", (f) => ({ ...f, ...update }));
};
export const clearFilter = () => {
setStore("filter", {
query: "",
tags: [],
priorityMin: 1,
priorityMax: 10,
urgencyMin: 1,
urgencyMax: 10
});
};
// Legacy cleanup - We don't need local storage persistence setup anymore
export const setupPersistence = () => {
// Moved logic to initStore which is called on auth.
// Keeping function signature to avoid breaking index.js calls immediately,
// but it will be empty or a no-op if called before auth.
// Actually, index.tsx calls this. We can leave it empty or have it check auth.
initStore();
};