366 lines
12 KiB
TypeScript
366 lines
12 KiB
TypeScript
import { createStore, reconcile } 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
|
|
}
|
|
|
|
interface TaskStore {
|
|
tasks: Task[];
|
|
pWeight: number;
|
|
uWeight: number;
|
|
matrixScaleDays: number;
|
|
prefId?: string; // ID of the user_preferences record
|
|
}
|
|
|
|
// Initial empty state
|
|
export const [store, setStore] = createStore<TaskStore>({
|
|
tasks: [],
|
|
pWeight: 1.0,
|
|
uWeight: 1.0,
|
|
matrixScaleDays: 30,
|
|
});
|
|
|
|
// 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);
|
|
return (task.priority * store.pWeight) + (urgencyScore * store.uWeight);
|
|
};
|
|
|
|
// -- 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,
|
|
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[] }) => {
|
|
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 tempId = "temp-" + Date.now();
|
|
const newTask: Task = {
|
|
id: tempId,
|
|
title,
|
|
startDate,
|
|
dueDate,
|
|
priority,
|
|
completed: false,
|
|
tags
|
|
};
|
|
|
|
// 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
|
|
});
|
|
|
|
// 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 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?
|
|
}
|
|
};
|
|
|
|
|
|
// 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();
|
|
};
|