954 lines
32 KiB
TypeScript
954 lines
32 KiB
TypeScript
import { createStore, reconcile } from "solid-js/store";
|
|
import { createSignal, createEffect, createRoot } from "solid-js";
|
|
import { pb, TASGRID_COLLECTION, TAGS_COLLECTION } from "@/lib/pocketbase";
|
|
import { toast } from "solid-sonner";
|
|
import { URGENCY_HOURS } from "@/lib/constants";
|
|
|
|
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
|
|
created: string; // ISO string from PB
|
|
updated: string; // ISO string from PB
|
|
recurrence?: {
|
|
type: 'daily' | 'weekly' | 'monthly';
|
|
days?: number[]; // For weekly (0-6, 0=Sunday)
|
|
dayOfMonth?: number; // For monthly (1-31)
|
|
lastUncompleted?: string; // ISO date of last automatic reset
|
|
};
|
|
size?: number; // 0-10, task complexity/size
|
|
}
|
|
|
|
export const checkRecurringTasks = () => {
|
|
const tasks = store.tasks;
|
|
const nowObj = new Date();
|
|
const todayStr = nowObj.toLocaleDateString('en-CA'); // YYYY-MM-DD in local time
|
|
|
|
tasks.forEach(task => {
|
|
if (!task.completed || !task.recurrence) return;
|
|
|
|
// If deleted, we don't recurse?
|
|
if (task.deletedAt) return;
|
|
|
|
const { type, days, dayOfMonth, lastUncompleted } = task.recurrence;
|
|
let shouldReset = false;
|
|
|
|
// Check if we already reset it today/this cycle
|
|
if (lastUncompleted) {
|
|
const lastDate = new Date(lastUncompleted).toLocaleDateString('en-CA');
|
|
if (lastDate === todayStr) return; // Already reset today
|
|
}
|
|
|
|
// Also check 'updated' date. If it was marked completed TODAY, don't reset immediately.
|
|
// We only reset if the completion happened BEFORE the current cycle trigger.
|
|
// E.g. Completed yesterday, today is a new day -> Reset.
|
|
const updatedDate = new Date(task.updated).toLocaleDateString('en-CA');
|
|
if (updatedDate === todayStr) {
|
|
// If manual loop: user completes it, we shouldn't immediately uncomplete it.
|
|
// But what if it's supposed to be uncompleted today?
|
|
// "Tasks that 'uncomplete' at given intervals" implies:
|
|
// If I complete it today, it stays completed until the NEXT interval.
|
|
// So we only reset if it was completed BEFORE today (for daily).
|
|
if (type === 'daily') return;
|
|
}
|
|
|
|
if (type === 'daily') {
|
|
// If completed before today, reset.
|
|
if (updatedDate < todayStr) {
|
|
shouldReset = true;
|
|
}
|
|
}
|
|
else if (type === 'weekly') {
|
|
// If today is one of the recurrence days
|
|
const currentDay = nowObj.getDay();
|
|
if (days?.includes(currentDay)) {
|
|
// Reset if it was completed BEFORE today.
|
|
if (updatedDate < todayStr) {
|
|
shouldReset = true;
|
|
}
|
|
}
|
|
}
|
|
else if (type === 'monthly') {
|
|
const currentDate = nowObj.getDate();
|
|
if (dayOfMonth === currentDate) {
|
|
if (updatedDate < todayStr) {
|
|
shouldReset = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (shouldReset) {
|
|
console.log(`Resetting recurring task: ${task.title}`);
|
|
updateTask(task.id, {
|
|
completed: false,
|
|
recurrence: {
|
|
...task.recurrence,
|
|
lastUncompleted: new Date().toISOString()
|
|
}
|
|
});
|
|
}
|
|
});
|
|
};
|
|
|
|
export interface TaskTemplate {
|
|
id: string;
|
|
name: string;
|
|
title: string;
|
|
priority: number;
|
|
urgency: number;
|
|
tags: string[];
|
|
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: FilterTag[];
|
|
priorityMin: number;
|
|
priorityMax: number;
|
|
urgencyMin: number;
|
|
urgencyMax: number;
|
|
editedToday: boolean;
|
|
}
|
|
|
|
interface TaskStore {
|
|
tasks: Task[];
|
|
pWeight: number;
|
|
uWeight: number;
|
|
matrixScaleDays: number;
|
|
prefId?: string; // ID of the user record
|
|
tagDefinitions: TagDefinition[];
|
|
templates: TaskTemplate[];
|
|
filter: Filter;
|
|
}
|
|
|
|
// Initial empty state
|
|
export const [store, setStore] = createStore<TaskStore>({
|
|
tasks: [],
|
|
pWeight: 1.0,
|
|
uWeight: 1.0,
|
|
matrixScaleDays: 30,
|
|
tagDefinitions: [],
|
|
templates: [],
|
|
filter: {
|
|
query: "",
|
|
tags: [],
|
|
priorityMin: 1,
|
|
priorityMax: 10,
|
|
urgencyMin: 1,
|
|
urgencyMax: 10,
|
|
editedToday: false
|
|
}
|
|
});
|
|
|
|
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)
|
|
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
|
|
if (f.tags.length > 0) {
|
|
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
|
|
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;
|
|
|
|
// Edited Today
|
|
if (f.editedToday) {
|
|
const today = new Date().toLocaleDateString('en-CA');
|
|
const updated = new Date(task.updated).toLocaleDateString('en-CA');
|
|
if (today !== updated) 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);
|
|
|
|
if (diffHours <= URGENCY_HOURS[10]) return 10;
|
|
if (diffHours >= URGENCY_HOURS[1]) return 1;
|
|
|
|
// Linear interpolation between breakpoints
|
|
const levels = Object.keys(URGENCY_HOURS).map(Number).sort((a, b) => b - a); // 10, 9, 8...
|
|
for (let i = 0; i < levels.length - 1; i++) {
|
|
const highLevel = levels[i];
|
|
const lowLevel = levels[i + 1];
|
|
const highHours = URGENCY_HOURS[highLevel];
|
|
const lowHours = URGENCY_HOURS[lowLevel];
|
|
|
|
if (diffHours >= highHours && diffHours <= lowHours) {
|
|
// Level decreases as hours increase
|
|
const range = lowHours - highHours;
|
|
const progress = (diffHours - highHours) / range;
|
|
return highLevel - progress * (highLevel - lowLevel);
|
|
}
|
|
}
|
|
|
|
return 1;
|
|
};
|
|
|
|
export const calculateDateFromUrgency = (level: number): string => {
|
|
const roundedLevel = Math.max(1, Math.min(10, Math.round(level)));
|
|
const hours = URGENCY_HOURS[roundedLevel] || 24;
|
|
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 def = store.tagDefinitions.find(d => d.name === tagName);
|
|
if (def) {
|
|
// Formula: (Value - 5) * 0.1
|
|
// 5 -> 0, 10 -> 0.5, 0 -> -0.5
|
|
baseScore += (def.value - 5) * 0.1;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Size adjustment: (size - 5) * -0.4
|
|
// Small tasks (0-4) get a slight boost, large tasks (6-10) get a slight penalty
|
|
const sizeScore = ((task.size ?? 5) - 5) * -0.4;
|
|
baseScore += sizeScore;
|
|
|
|
return baseScore;
|
|
};
|
|
|
|
// -- Persistence & Sync --
|
|
|
|
let heartbeatInterval: number | undefined;
|
|
|
|
const mapRecordToTask = (r: any): Task => {
|
|
// Check if it's a template? The caller handles filtering templates usually,
|
|
// but here we just map fields.
|
|
const tags = r.tags || [];
|
|
return {
|
|
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,
|
|
recurrence: r.recurrence,
|
|
size: (r.size === null || r.size === undefined) ? undefined : r.size
|
|
};
|
|
};
|
|
|
|
export const subscribeToRealtime = async () => {
|
|
// Unsubscribe first to avoid duplicates
|
|
await pb.collection(TASGRID_COLLECTION).unsubscribe('*');
|
|
|
|
pb.collection(TASGRID_COLLECTION).subscribe('*', (e) => {
|
|
console.log("Realtime event:", e.action, e.record.id);
|
|
|
|
if (e.action === 'create') {
|
|
// Check if it's a template
|
|
const isTemplate = e.record.tags?.includes("__template__");
|
|
if (isTemplate) {
|
|
// We don't auto-add templates to the main task list usually?
|
|
// Wait, `store.templates` is separate.
|
|
// We should handle templates too if we want sync on templates.
|
|
let meta: any = {};
|
|
try { meta = JSON.parse(e.record.content || "{}"); } catch (e) { }
|
|
const newTemplate: TaskTemplate = {
|
|
id: e.record.id,
|
|
name: e.record.title,
|
|
title: meta.title || "",
|
|
priority: e.record.priority,
|
|
urgency: meta.urgency || 5,
|
|
tags: meta.tags || [],
|
|
content: meta.content || ""
|
|
};
|
|
setStore("templates", t => [...(t || []), newTemplate]);
|
|
return;
|
|
}
|
|
|
|
// Regular Task
|
|
const exists = store.tasks.find(t => t.id === e.record.id);
|
|
if (!exists) {
|
|
const newTask = mapRecordToTask(e.record);
|
|
setStore("tasks", t => [newTask, ...t]);
|
|
}
|
|
}
|
|
|
|
if (e.action === 'update') {
|
|
// Is it a template?
|
|
const isTemplate = e.record.tags?.includes("__template__");
|
|
if (isTemplate) {
|
|
// Update template store
|
|
let meta: any = {};
|
|
try { meta = JSON.parse(e.record.content || "{}"); } catch (e) { }
|
|
setStore("templates", t => t.id === e.record.id, {
|
|
name: e.record.title,
|
|
title: meta.title || "",
|
|
priority: e.record.priority,
|
|
urgency: meta.urgency || 5,
|
|
tags: meta.tags || [],
|
|
content: meta.content || ""
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Normal Task - only apply if incoming record is newer or equal
|
|
const existingTask = store.tasks.find(t => t.id === e.record.id);
|
|
if (existingTask) {
|
|
const incomingUpdated = new Date(e.record.updated).getTime();
|
|
const localUpdated = new Date(existingTask.updated).getTime();
|
|
|
|
// Only apply update if incoming is newer (or we don't have a timestamp)
|
|
if (!existingTask.updated || incomingUpdated >= localUpdated) {
|
|
const updatedTask = mapRecordToTask(e.record);
|
|
setStore("tasks", t => t.id === e.record.id, updatedTask);
|
|
} else {
|
|
console.log("Skipping stale realtime update for task:", e.record.id);
|
|
}
|
|
} else {
|
|
// Task doesn't exist locally, add it
|
|
const updatedTask = mapRecordToTask(e.record);
|
|
setStore("tasks", t => [updatedTask, ...t]);
|
|
}
|
|
}
|
|
|
|
if (e.action === 'delete') {
|
|
// Try deleting from both just in case
|
|
setStore("tasks", t => t.filter(x => x.id !== e.record.id));
|
|
setStore("templates", t => (t || []).filter(x => x.id !== e.record.id));
|
|
}
|
|
});
|
|
};
|
|
|
|
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());
|
|
checkRecurringTasks(); // Check for recurrence triggers (minutely, etc.)
|
|
}, 60000);
|
|
|
|
try {
|
|
// 1. Fetch Preferences from User Profile (Weights, Scale, Tags)
|
|
try {
|
|
const userId = pb.authStore.model?.id;
|
|
if (userId) {
|
|
const user = await pb.collection('users').getOne(userId, { requestKey: null });
|
|
const prefs = user.Taskgrid_pref || {};
|
|
|
|
setStore({
|
|
pWeight: prefs.pWeight || 1.0,
|
|
uWeight: prefs.uWeight || 1.0,
|
|
matrixScaleDays: prefs.matrixScaleDays || 30,
|
|
prefId: userId
|
|
});
|
|
}
|
|
} catch (prefErr) {
|
|
console.warn("Failed to load preferences:", prefErr);
|
|
}
|
|
|
|
// 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',
|
|
});
|
|
|
|
const allTasks: Task[] = [];
|
|
const loadedTemplates: TaskTemplate[] = [];
|
|
|
|
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(mapRecordToTask(r));
|
|
}
|
|
});
|
|
|
|
setStore("tasks", reconcile(allTasks));
|
|
setStore("templates", loadedTemplates);
|
|
|
|
// Check recurring tasks after load
|
|
checkRecurringTasks();
|
|
|
|
// 3. Subscribe to Realtime Updates
|
|
await subscribeToRealtime();
|
|
|
|
// 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 (store.tasks.length === 0) {
|
|
console.error("Failed to load data:", err);
|
|
toast.error("Failed to sync with server.");
|
|
}
|
|
}
|
|
};
|
|
|
|
// -- Actions --
|
|
|
|
export const addTask = async (title: string, options?: { dueDate?: Date | string, priority?: number, tags?: string[], content?: string, size?: number }) => {
|
|
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 size = options?.size ?? 5;
|
|
|
|
const tempId = "temp-" + Date.now();
|
|
const newTask: Task = {
|
|
id: tempId,
|
|
title,
|
|
startDate,
|
|
dueDate,
|
|
priority,
|
|
completed: false,
|
|
tags,
|
|
content,
|
|
size,
|
|
created: new Date().toISOString(),
|
|
updated: new Date().toISOString()
|
|
};
|
|
|
|
// 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,
|
|
size
|
|
});
|
|
|
|
// Replace temp task with real record (merging server fields like id, created, updated)
|
|
setStore("tasks", (t) => {
|
|
// Check if REAL ID exists already (from subscription race)
|
|
const realExists = t.some(x => x.id === record.id);
|
|
if (realExists) {
|
|
// If real exists, just remove temp.
|
|
return t.filter(x => x.id !== tempId);
|
|
}
|
|
// Otherwise, replace temp with real
|
|
return t.map(task => task.id === tempId ? {
|
|
...task,
|
|
id: record.id,
|
|
created: record.created,
|
|
updated: record.updated
|
|
} : 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,
|
|
size: original.size
|
|
});
|
|
|
|
toast.success("Task duplicated");
|
|
};
|
|
|
|
export const updateTask = async (id: string, updates: Partial<Task>) => {
|
|
if (!pb.authStore.isValid) return;
|
|
|
|
// Optimistic update with timestamp to prevent stale realtime events
|
|
const optimisticUpdates = {
|
|
...updates,
|
|
updated: new Date().toISOString()
|
|
};
|
|
setStore("tasks", (t) => t.id === id, optimisticUpdates);
|
|
|
|
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);
|
|
await syncPreferences();
|
|
};
|
|
|
|
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) => {
|
|
// updateTask handles both optimistic update and PB sync
|
|
// Pass undefined for deletedAt, updateTask will convert to null for PB
|
|
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, color?: string, theme?: "light" | "dark") => {
|
|
if (!pb.authStore.isValid) return;
|
|
|
|
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"
|
|
};
|
|
setStore("tagDefinitions", (prev) => [...prev, newDef]);
|
|
}
|
|
} catch (err) {
|
|
console.error("Failed to upsert tag:", err);
|
|
toast.error("Failed to save tag definition.");
|
|
}
|
|
};
|
|
|
|
export const removeTagDefinition = async (name: string) => {
|
|
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.");
|
|
}
|
|
};
|
|
|
|
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();
|
|
|
|
try {
|
|
await pb.collection(TAGS_COLLECTION).update(def.id, { name: finalName });
|
|
setStore("tagDefinitions", (d) => d.id === def.id, { name: finalName });
|
|
|
|
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 });
|
|
}
|
|
} catch (err) {
|
|
console.error("Failed to rename tag:", err);
|
|
toast.error("Failed to rename tag.");
|
|
}
|
|
};
|
|
|
|
|
|
// -- Templates --
|
|
|
|
export const syncPreferences = async () => {
|
|
const userId = pb.authStore.model?.id;
|
|
if (!userId) return;
|
|
|
|
try {
|
|
const prefs = {
|
|
pWeight: store.pWeight,
|
|
uWeight: store.uWeight,
|
|
matrixScaleDays: store.matrixScaleDays,
|
|
tagDefinitions: store.tagDefinitions
|
|
};
|
|
|
|
await pb.collection('users').update(userId, {
|
|
Taskgrid_pref: prefs
|
|
}, { requestKey: null });
|
|
} catch (e: any) {
|
|
if (e.isAbort) return;
|
|
console.error("Failed to sync preferences", e);
|
|
// We could toast here, but many of these are background syncs
|
|
}
|
|
};
|
|
|
|
export const addTemplate = async (template: Omit<TaskTemplate, "id">) => {
|
|
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);
|
|
|
|
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));
|
|
|
|
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) => {
|
|
const task = store.tasks.find(t => t.id === taskId);
|
|
if (!task) return;
|
|
|
|
const template: Omit<TaskTemplate, "id"> = {
|
|
name,
|
|
title: task.title,
|
|
priority: task.priority,
|
|
urgency: calculateUrgencyFromDate(task.dueDate),
|
|
tags: [...(task.tags || [])],
|
|
content: task.content || ""
|
|
};
|
|
|
|
return await addTemplate(template);
|
|
};
|
|
|
|
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,
|
|
editedToday: false
|
|
});
|
|
};
|
|
|
|
// 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();
|
|
};
|