Files
TasGrid/src/store/index.ts
T
tcardoza 6a963a32fd
CI / build (push) Has been skipped
CI / deploy_prod (push) Successful in 1m19s
CI / deploy_dev (push) Has been skipped
Added custom PWA reloading - 10 minutes of idle or when moved to the background or when loading initially or manually with the new button in settings
2026-03-04 19:29:18 -06:00

2701 lines
101 KiB
TypeScript

import { createStore, reconcile } from "solid-js/store";
import { createSignal, createEffect, createRoot } from "solid-js";
import { pb } from "@/lib/pocketbase";
import { toast } from "solid-sonner";
import { TASGRID_COLLECTION, TAGS_COLLECTION, SHARE_RULES_COLLECTION, BUCKETS_COLLECTION, NOTES_COLLECTION, URGENCY_HOURS, SIZE_HOURS } from "@/lib/constants";
const LIST_FIELDS = 'id,collectionId,collectionName,created,updated,title,startDate,dueDate,priority,status,completed,tags,user,size,recurrence,sharedWith,deletedAt';
const getStorageKey = () => {
const userId = pb.authStore.model?.id;
return userId ? `tasgrid_data_${userId}` : null;
};
export const [now, setNow] = createSignal(Date.now());
// Context: 'mine' = My Bucket (default), { userId: string } = Oversight View for that user, { bucketId: string, name: string } = Bucket View
type TaskContext = 'mine' | { userId: string, name: string } | { bucketId: string, name: string };
export const [activeTaskId, setActiveTaskId] = createSignal<string | null>(null);
export const [activeNoteId, setActiveNoteId] = createSignal<string | null>(null);
export const [currentTaskContext, setCurrentTaskContext] = createSignal<TaskContext>('mine');
export type UrgencyLevel = number;
export interface Task {
id: string;
title: string;
startDate: string;
dueDate: string;
priority: number; // 1-10
status: number; // 0-10
completed: boolean; // Deprecated, kept for compat/computed from status = 10
tags: string[];
ownerId: string | null; // Add ownerId to interface
content?: string; // HTML content from Tiptap
deletedAt?: number | null; // Timestamp of soft delete or null if restored
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
sharedWith?: Array<{ userId: string; access: 'view' | 'edit' }>; // Users this task is shared with
attachments?: string[]; // Array of filenames from PocketBase
}
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.status !== 10 || !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, {
status: 0,
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";
isUser?: boolean; // New flag to identify user-based tags
isBucket?: boolean; // New flag to identify bucket-based tags
}
export interface Note {
id: string;
title: string;
content?: string;
tags: string[];
isPrivate: boolean;
user: string;
tasks: string[]; // List of related task IDs
created: string;
updated: string;
deletedAt?: number | null; // Timestamp of soft delete or null if restored
}
export interface FilterTag {
name: string;
excluded: boolean;
}
export interface Filter {
query: string;
tags: FilterTag[];
priorityMin: number;
priorityMax: number;
urgencyMin: number;
urgencyMax: number;
editedToday: boolean;
}
// Background sync for Context Switcher (polling fallback for realtime)
let contextSyncInterval: number | undefined;
export const startContextPolling = () => {
if (contextSyncInterval) {
clearInterval(contextSyncInterval);
contextSyncInterval = undefined;
}
const ctx = currentTaskContext();
if (typeof ctx === 'object' && 'userId' in ctx) {
contextSyncInterval = window.setInterval(async () => {
if (!pb.authStore.isValid) return;
try {
// Fetch only recent updates (last 30s) to minimize payload
const timeStr = new Date(Date.now() - 30000).toISOString();
const records = await pb.collection(TASGRID_COLLECTION).getFullList({
filter: `user = "${ctx.userId}" && updated >= "${timeStr}"`,
sort: '-updated',
fields: LIST_FIELDS,
requestKey: 'context-poll'
});
if (records.length === 0) return;
setStore("tasks", (currentTasks) => {
const taskMap = new Map(currentTasks.map(t => [t.id, t]));
let changed = false;
records.forEach((r: any) => {
if (r.tags?.includes("__template__")) return;
const existing = taskMap.get(r.id);
if (existing && existing.updated === r.updated) return;
const newTask = mapRecordToTask(r);
if (existing && existing.content) {
newTask.content = existing.content;
}
taskMap.set(r.id, newTask);
changed = true;
});
if (changed) return Array.from(taskMap.values());
return currentTasks;
});
} catch (err: any) {
if (!err.isAbort) {
console.warn("Context poll failed:", err);
}
}
}, 5000); // 5 seconds
}
};
createRoot(() => {
createEffect(() => {
// Re-run whenever ctx changes
currentTaskContext();
startContextPolling();
});
});
export interface FilterTemplate {
id: string;
name: string;
filter: Filter;
}
export interface ShareRule {
id: string;
ownerId: string;
targetUserId: string;
type: 'tag' | 'all';
tagName?: string;
share_mode?: 'ADD_USER' | 'SEND_TASK'; // Default to ADD_USER if undefined
}
export interface Bucket {
id: string;
name: string;
color: string;
description?: string;
}
interface TaskStore {
tasks: Task[];
isInitializing: boolean;
pWeight: number;
uWeight: number;
matrixScaleDays: number;
prefId?: string; // ID of the user record
tagDefinitions: TagDefinition[];
templates: TaskTemplate[];
filter: Filter;
shareRules: ShareRule[];
filterTemplates: FilterTemplate[];
buckets: Bucket[];
subscribedBuckets: string[]; // IDs of buckets I'm subscribed to
notes: Note[];
isNotepadMode: boolean;
quickloadTasks: string[];
}
// Initial empty state
export const [store, setStore] = createStore<TaskStore>({
tasks: [],
isInitializing: false,
pWeight: 1.0,
uWeight: 1.0,
matrixScaleDays: 30,
tagDefinitions: [],
templates: [],
filter: {
query: "",
tags: [],
priorityMin: 1,
priorityMax: 10,
urgencyMin: 1,
urgencyMax: 10,
editedToday: false
},
shareRules: [],
filterTemplates: [],
buckets: [],
subscribedBuckets: [],
notes: [],
isNotepadMode: false,
quickloadTasks: []
});
export const matchesFilter = (task: Task) => {
const currentUserId = pb.authStore.model?.id;
const ctx = currentTaskContext();
// 0. Context Filter
if (ctx === 'mine') {
const isOwner = task.ownerId === currentUserId;
const isExplicitlyShared = task.sharedWith?.some(s => s.userId === currentUserId);
// Tag-based share rules targeting me
const isTagRuleShared = store.shareRules.some(r => {
if (r.targetUserId !== currentUserId) return false;
// Global Rule / Self-Rule: Match purely on tag, ignore owner check
// logic: If I have a rule for tag "T" assigned to myself, show ANY task with tag "T"
if (r.ownerId === r.targetUserId && r.type === 'tag') {
const tagName = r.tagName || "";
// EXCLUDE Buckets from the main "Mine" view
// The user wants to see "Direct Shares" (User Tags), not "Bucket Subscriptions" here.
const isBucket = store.buckets.some(b => b.name.toLowerCase() === tagName.toLowerCase());
if (isBucket) return false;
return task.tags?.some(t => t.toLowerCase() === tagName.toLowerCase());
}
// Standard Rule: Match owner AND tag
return r.ownerId === task.ownerId &&
r.type === 'tag' &&
task.tags?.includes(r.tagName || "");
});
// Bucket tasks are now tagged with '@' + name.
if (!isOwner && !isExplicitlyShared && !isTagRuleShared) return false;
} else if (typeof ctx === 'object' && 'bucketId' in ctx) {
// Bucket View
const bucketProxy = store.buckets.find(b => b.id === (ctx as { bucketId: string }).bucketId);
if (!bucketProxy) return false;
// Show tasks that have the bucket name (prefixed with @) as a tag
const bucketTagName = `@${bucketProxy.name}`;
if (!task.tags?.includes(bucketTagName)) return false;
} else {
// Context is Oversight for specific user
// Show ONLY tasks owned by that user
// Note: We might want to EXCLUDE bucket tasks from oversight if they are purely bucket tasks,
// but typically oversight means "everything that user owns".
if (task.ownerId !== (ctx as { userId: string }).userId) return false;
}
// 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));
}
});
let debouncedSync: number | undefined;
createEffect(() => {
if (store.isInitializing || !pb.authStore.model?.id) return;
// Calculate Top 20 Tasks
const incompleteTasks = store.tasks.filter(t =>
!t.completed &&
t.ownerId === pb.authStore.model?.id &&
!t.tags?.includes("__template__") &&
!t.deletedAt
);
const scoredTasks = incompleteTasks.map(t => ({ id: t.id, score: getCombinedScore(t) }));
scoredTasks.sort((a, b) => b.score - a.score);
const top20Ids = scoredTasks.slice(0, 20).map(t => t.id);
// Compare with current
const currentIds = store.quickloadTasks || [];
const isDifferent = top20Ids.length !== currentIds.length || top20Ids.some((id, i) => id !== currentIds[i]);
if (isDifferent) {
setStore("quickloadTasks", top20Ids);
// Debounce save by 10s
if (debouncedSync) window.clearTimeout(debouncedSync);
debouncedSync = window.setTimeout(() => {
syncPreferences();
}, 10000);
}
});
});
// -- 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 => {
// 1. Calculate an effective due date by subtracting size hours
const size = task.size ?? 3; // Default size 3
const sizeHours = SIZE_HOURS[size] ?? 12;
const dueTime = new Date(task.dueDate).getTime();
if (isNaN(dueTime)) return 0;
// effective due date = due date - size task time
const effectiveDueTime = dueTime - (sizeHours * 60 * 60 * 1000);
const effectiveDueDateStr = new Date(effectiveDueTime).toISOString();
const urgencyScore = calculateUrgencyScore(effectiveDueDateStr);
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: linear scale from +1.9 (for size 0) to 0 (for size 10)
const sizeScore = (10 - size) * 0.19;
baseScore += sizeScore;
// Status adjustment: 0-9 -> 0.0-2.0 weight
if (task.status < 10) {
// Map 0-9 to 0-2 range
const statusWeight = (task.status / 9) * 2.0;
baseScore += statusWeight;
}
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,
status: r.status ?? (r.completed ? 10 : 0),
completed: r.status === 10 || r.completed,
tags: tags,
ownerId: r.user || null,
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,
sharedWith: r.sharedWith,
attachments: r.attachments || []
};
};
export const uploadTaskAttachment = async (taskId: string, file: File): Promise<string> => {
try {
const formData = new FormData();
formData.append('attachments', file);
const record = await pb.collection(TASGRID_COLLECTION).update(taskId, formData);
// Return the URL for the newly uploaded file. PocketBase appends new files to the array.
// We need to find the specific filename that was just uploaded to get its URL.
// Since we don't know the exact generated filename, we can return the URL of the last file in the array.
if (record.attachments && record.attachments.length > 0) {
const latestFilename = record.attachments[record.attachments.length - 1];
return pb.files.getURL(record, latestFilename);
}
throw new Error("File uploaded but no attachment returned");
} catch (err: any) {
console.error("Error uploading attachment:", err);
toast.error("Failed to upload image.");
throw err;
}
};
export const uploadNoteAttachment = async (noteId: string, file: File): Promise<string> => {
try {
const formData = new FormData();
formData.append('attachments', file);
const record = await pb.collection(NOTES_COLLECTION).update(noteId, formData);
if (record.attachments && record.attachments.length > 0) {
const latestFilename = record.attachments[record.attachments.length - 1];
return pb.files.getURL(record, latestFilename);
}
throw new Error("File uploaded but no attachment returned");
} catch (err: any) {
console.error("Error uploading note attachment:", err);
toast.error("Failed to upload image to note.");
throw err;
}
};
export const cleanupNoteAttachments = async (noteId: string) => {
if (!pb.authStore.isValid) return;
try {
const note = store.notes.find(n => n.id === noteId);
if (!note) return;
const record = await pb.collection(NOTES_COLLECTION).getOne(noteId);
if (!record.attachments || record.attachments.length === 0) return;
const content = note.content || "";
const filesToDelete: string[] = [];
for (const filename of record.attachments) {
if (!content.includes(filename)) {
filesToDelete.push(filename);
}
}
if (filesToDelete.length > 0) {
await pb.collection(NOTES_COLLECTION).update(noteId, {
"attachments-": filesToDelete
});
console.log(`[CLEANUP] Deleted ${filesToDelete.length} orphaned attachment(s) for note ${noteId}`);
}
} catch (err) {
console.error("[CLEANUP] Failed to clean up orphaned note attachments:", err);
}
};
export const cleanupTaskAttachments = async (taskId: string) => {
if (!pb.authStore.isValid) return;
try {
const task = store.tasks.find(t => t.id === taskId);
if (!task) return;
const record = await pb.collection(TASGRID_COLLECTION).getOne(taskId);
if (!record.attachments || record.attachments.length === 0) return;
const content = task.content || "";
const filesToDelete: string[] = [];
for (const filename of record.attachments) {
if (!content.includes(filename)) {
filesToDelete.push(filename);
}
}
if (filesToDelete.length > 0) {
await pb.collection(TASGRID_COLLECTION).update(taskId, {
"attachments-": filesToDelete
});
console.log(`[CLEANUP] Deleted ${filesToDelete.length} orphaned attachment(s) for task ${taskId}`);
}
} catch (err) {
console.error("[CLEANUP] Failed to clean up orphaned task attachments:", err);
}
};
const mapRecordToShareRule = (r: any): ShareRule => {
return {
id: r.id,
ownerId: r.ownerId,
targetUserId: r.targetUserId,
type: r.type as 'tag' | 'all',
tagName: r.tagName,
share_mode: r.share_mode || 'ADD_USER'
};
};
const mapRecordToNote = (r: any): Note => {
return {
id: r.id,
title: r.title,
content: r.content,
tags: r.tags || [],
isPrivate: r.isPrivate || false,
user: r.user,
tasks: r.tasks || [],
created: r.created,
updated: r.updated,
deletedAt: r.deletedAt ? new Date(r.deletedAt).getTime() : undefined
};
};
// Helper to check if a task should be visible based on ownership, shares, or rules
const shouldTaskBeVisible = (task: any, currentUserId: string): boolean => {
// Own task
if (task.user === currentUserId) return true;
// Individually shared
const sharedWith = task.sharedWith || [];
if (sharedWith.some((s: any) => s.userId === currentUserId)) return true;
// ShareRule match
const matchingRule = store.shareRules.find(rule => {
if (rule.targetUserId !== currentUserId) return false;
// Global Rule Logic: If owner == target, it's a "Global Subscription" to that tag.
// We ignore the rule.ownerId check (which would match the task.user)
// and instead match purely on the tag.
if (rule.ownerId === rule.targetUserId && rule.type === 'tag') {
const tags: string[] = task.tags || [];
return tags.some(t => t.toLowerCase() === (rule.tagName || "").toLowerCase());
}
// Standard Rule Logic: Match specific owner
if (task.user !== rule.ownerId) return false;
if (rule.type === 'all') return true;
if (rule.type === 'tag') {
const tags = task.tags || [];
return tags.includes(rule.tagName);
}
return false;
});
// Bucket check
if (isTaskInSubscribedBucket(task)) return true;
return !!matchingRule;
};
// Check if task is visible because it belongs to a subscribed bucket
const isTaskInSubscribedBucket = (task: any): boolean => {
const taskTags = task.tags || [];
if (taskTags.length === 0) return false;
// Check if any of the task's tags match a bucket we are subscribed to
return store.subscribedBuckets.some(subId => {
const bucket = store.buckets.find(b => b.id === subId);
return bucket && taskTags.includes(`@${bucket.name}`);
});
};
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) {
// Check if template already exists (from optimistic update)
const templateExists = store.templates?.some(t => t.id === e.record.id);
if (templateExists) return;
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 - check if already exists and if it should be visible
const exists = store.tasks.find(t => t.id === e.record.id);
if (!exists) {
const currentUserId = pb.authStore.model?.id;
const isOwnTask = e.record.user === currentUserId;
// Only add if it's own task or should be visible via sharing
if (isOwnTask || (currentUserId && shouldTaskBeVisible(e.record, currentUserId))) {
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;
}
const currentUserId = pb.authStore.model?.id;
const existingTask = store.tasks.find(t => t.id === e.record.id);
const isOwnTask = e.record.user === currentUserId;
const incomingUpdated = new Date(e.record.updated).getTime();
if (existingTask) {
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) {
// For shared tasks, re-evaluate visibility (e.g., tags may have changed)
if (!isOwnTask && currentUserId) {
if (!shouldTaskBeVisible(e.record, currentUserId)) {
// No longer visible, remove it
setStore("tasks", t => t.filter(x => x.id !== e.record.id));
return;
}
}
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 - check if it should be visible now
if (currentUserId && shouldTaskBeVisible(e.record, currentUserId)) {
const newTask = mapRecordToTask(e.record);
setStore("tasks", t => [newTask, ...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));
}
});
// Subscribe to Notes
await pb.collection(NOTES_COLLECTION).unsubscribe('*');
pb.collection(NOTES_COLLECTION).subscribe('*', (e) => {
const currentUserId = pb.authStore.model?.id;
// Only process if public or owned by current user
const isVisible = !e.record.isPrivate || e.record.user === currentUserId;
if (e.action === 'create' || e.action === 'update') {
if (isVisible) {
const updatedNote = mapRecordToNote(e.record);
setStore("notes", prev => {
const exists = prev.find(n => n.id === updatedNote.id);
if (exists) return prev.map(n => n.id === updatedNote.id ? updatedNote : n);
return [updatedNote, ...prev];
});
} else {
// If it became private and we're not the owner, remove it
setStore("notes", prev => prev.filter(n => n.id !== e.record.id));
}
}
if (e.action === 'delete') {
setStore("notes", prev => prev.filter(n => n.id !== e.record.id));
}
});
// Subscribe to Buckets
// We need to know if buckets are added/removed/renamed
await pb.collection(BUCKETS_COLLECTION).unsubscribe('*');
pb.collection(BUCKETS_COLLECTION).subscribe('*', async (e) => {
if (e.action === 'create' || e.action === 'update') {
const bucket = {
id: e.record.id,
name: e.record.name,
color: e.record.color,
description: e.record.description
};
setStore("buckets", (prev) => {
const exists = prev.find(b => b.id === bucket.id);
if (exists) return prev.map(b => b.id === bucket.id ? bucket : b);
return [...prev, bucket];
});
}
if (e.action === 'delete') {
setStore("buckets", (prev) => prev.filter(b => b.id !== e.record.id));
// Also remove from subscribed if present?
// The user record might still have it, but we can filter display.
}
});
// Subscribe to Share Rules
await pb.collection(SHARE_RULES_COLLECTION).unsubscribe('*');
pb.collection(SHARE_RULES_COLLECTION).subscribe('*', async (e) => {
const currentUserId = pb.authStore.model?.id;
if (e.action === 'create') {
const newRule = mapRecordToShareRule(e.record);
// Add to shareRules store
setStore("shareRules", (rules) => {
if (rules.some(r => r.id === newRule.id)) return rules;
return [...rules, newRule];
});
// If I'm the target, fetch matching tasks from the owner
if (newRule.targetUserId === currentUserId) {
try {
let filter = `user = "${newRule.ownerId}"`;
if (newRule.type === 'tag' && newRule.tagName) {
filter += ` && tags ~ "${newRule.tagName}"`;
}
const records = await pb.collection(TASGRID_COLLECTION).getFullList({
filter,
sort: '-created',
requestKey: null
});
records.forEach((r: any) => {
if (!r.tags?.includes("__template__") && !store.tasks.find(t => t.id === r.id)) {
const newTask = mapRecordToTask(r);
setStore("tasks", t => [newTask, ...t]);
}
});
} catch (err) {
console.warn("Failed to fetch tasks for new share rule:", err);
}
}
}
if (e.action === 'delete') {
const deletedRuleId = e.record.id;
const deletedRule = store.shareRules.find(r => r.id === deletedRuleId);
// Remove from shareRules store
setStore("shareRules", (rules) => rules.filter(r => r.id !== deletedRuleId));
// If I was the target, remove tasks that are no longer visible
if (deletedRule && deletedRule.targetUserId === currentUserId) {
setStore("tasks", tasks =>
tasks.filter(task => shouldTaskBeVisible(task, currentUserId!))
);
}
}
if (e.action === 'update') {
const updatedRule = mapRecordToShareRule(e.record);
setStore("shareRules", (rules) => rules.map(r => r.id === updatedRule.id ? updatedRule : r));
}
});
};
const runLegacyTagMigration = async () => {
const currentUserId = pb.authStore.model?.id;
if (!currentUserId) return;
try {
const userRec = await pb.collection('users').getOne(currentUserId, { requestKey: null });
const prefs = userRec.Taskgrid_pref || {};
if (prefs.migratedLegacyTagsV1) {
return; // Already migrated
}
console.log("[MIGRATION] Starting legacy tag migration...");
// Fetch users and buckets
const [allUsers, allBuckets] = await Promise.all([
pb.collection('users').getFullList({
filter: 'verified = true',
fields: 'name,email',
requestKey: null
}),
pb.collection(BUCKETS_COLLECTION).getFullList({
fields: 'name',
requestKey: null
})
]);
const validNames = new Set([
...allBuckets.map(b => b.name.toLowerCase()),
...allUsers.map(u => (u.name || u.email)?.toLowerCase()).filter(Boolean)
]);
// Fetch all Tasks for this user
const tasks = await pb.collection(TASGRID_COLLECTION).getFullList({
filter: `user = "${currentUserId}"`,
requestKey: null
});
let tasksUpdated = 0;
for (const t of tasks) {
if (!t.tags || t.tags.length === 0) continue;
let changed = false;
const newTags = t.tags.map((tag: string) => {
if (!tag.startsWith("@") && validNames.has(tag.toLowerCase())) {
changed = true;
return `@${tag}`;
}
return tag;
});
if (changed) {
await pb.collection(TASGRID_COLLECTION).update(t.id, { tags: newTags }, { requestKey: null });
tasksUpdated++;
}
}
// Fetch all Notes for this user
const notes = await pb.collection(NOTES_COLLECTION).getFullList({
filter: `user = "${currentUserId}"`,
requestKey: null
});
let notesUpdated = 0;
for (const n of notes) {
if (!n.tags || n.tags.length === 0) continue;
let changed = false;
const newTags = n.tags.map((tag: string) => {
if (!tag.startsWith("@") && validNames.has(tag.toLowerCase())) {
changed = true;
return `@${tag}`;
}
return tag;
});
if (changed) {
await pb.collection(NOTES_COLLECTION).update(n.id, { tags: newTags }, { requestKey: null });
notesUpdated++;
}
}
prefs.migratedLegacyTagsV1 = true;
await pb.collection('users').update(currentUserId, { Taskgrid_pref: prefs }, { requestKey: null });
if (pb.authStore.model) {
pb.authStore.model.Taskgrid_pref = prefs;
}
console.log(`[MIGRATION] Completed. Updated ${tasksUpdated} tasks and ${notesUpdated} notes.`);
// Update local state directly so it reflects immediately
setStore("tasks", (tasks) => tasks.map(t => {
let localChanged = false;
const nt = t.tags?.map(tag => {
if (!tag.startsWith("@") && validNames.has(tag.toLowerCase())) {
localChanged = true;
return `@${tag}`;
}
return tag;
});
if (localChanged) return { ...t, tags: nt };
return t;
}));
setStore("notes", (items) => items.map(n => {
let localChanged = false;
const nt = n.tags?.map(tag => {
if (!tag.startsWith("@") && validNames.has(tag.toLowerCase())) {
localChanged = true;
return `@${tag}`;
}
return tag;
});
if (localChanged) return { ...n, tags: nt };
return n;
}));
} catch (err) {
console.error("[MIGRATION] Failed:", err);
}
};
export const initStore = async () => {
if (!pb.authStore.isValid || store.isInitializing) return;
setStore("isInitializing", true);
const currentUserId = pb.authStore.model?.id;
// STAGE 1: Instant Focus from Local Auth Model (Top 20 + Fallback)
// Goal: < 100ms First Paint. Doesn't wait for any other network requests.
if (currentUserId) {
try {
const prefs = pb.authStore.model?.Taskgrid_pref || {};
const cachedQuickload = prefs.quickloadTasks || [];
let focusRecords;
if (cachedQuickload.length > 0) {
const idFilter = cachedQuickload.map((id: string) => `id = "${id}"`).join(' || ');
const res = await pb.collection(TASGRID_COLLECTION).getFullList({
filter: `(${idFilter}) && deletedAt = ""`,
requestKey: null
});
// Sort manually to match quickload order
res.sort((a, b) => cachedQuickload.indexOf(a.id) - cachedQuickload.indexOf(b.id));
focusRecords = { items: res };
} else {
focusRecords = await pb.collection(TASGRID_COLLECTION).getList(1, 30, {
filter: `user = "${currentUserId}" && completed = false && deletedAt = ""`,
sort: '-priority,dueDate',
requestKey: null
});
}
// Filter out templates client-side to ensure tasks with empty/null tags aren't excluded by DB
const focusTasks = focusRecords.items
.map(mapRecordToTask)
.filter(t => !t.tags?.includes("__template__"));
setStore("tasks", focusTasks);
} catch (focusErr) {
console.warn("Stage 1 load failed:", focusErr);
}
}
// 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,
subscribedBuckets: user.subscribedBuckets || [],
quickloadTasks: prefs.quickloadTasks || []
});
}
} catch (prefErr) {
console.warn("Failed to load preferences:", prefErr);
}
// 1.2 Fetch Buckets
try {
const buckets = await pb.collection(BUCKETS_COLLECTION).getFullList({ sort: 'name', requestKey: null });
setStore("buckets", buckets.map((b: any) => ({
id: b.id,
name: b.name,
color: b.color,
description: b.description
})));
} catch (bucketErr) {
console.warn("Failed to load buckets (collection might not exist yet):", bucketErr);
}
// 1.3 Fetch Notes
try {
const currentUserId = pb.authStore.model?.id;
const notesRecords = await pb.collection(NOTES_COLLECTION).getFullList({
filter: `isPrivate = false || user = "${currentUserId}"`,
sort: '-created',
requestKey: null
});
setStore("notes", notesRecords.map(mapRecordToNote));
} catch (notesErr) {
console.warn("Failed to load notes (collection might not exist yet):", notesErr);
}
// 1.5. Fetch Tag Definitions
try {
const tagRecords = await pb.collection(TAGS_COLLECTION).getFullList({
filter: `user = "${pb.authStore.model?.id}"`,
requestKey: null
});
const loadedTags: TagDefinition[] = tagRecords.map(r => ({
id: r.id,
name: r.name,
value: r.value,
color: r.color,
theme: r.theme,
isUser: r.isUser || false,
isBucket: r.isBucket || false
}));
setStore("tagDefinitions", reconcile(loadedTags));
} catch (tagErr) {
console.warn("Failed to load tags:", tagErr);
}
// 1.8 Fetch Share Rules (CRITICAL for filter construction)
// We need these loaded BEFORE we construct the focus filter
await loadShareRules();
const currentUserId = pb.authStore.model?.id;
if (!currentUserId) return;
// --- PHASE 2: Multi-Stage Loading ---
// --- WINDOWED SYNC STRATEGY ---
// STAGE 2 & 3: Background Sync
const backgroundSync = async () => {
const bucketNames = store.subscribedBuckets
.map(id => store.buckets.find(b => b.id === id)?.name)
.filter(Boolean);
// Sync System Tags & Share Rules FIRST to ensure Self-Rules exist for query construction
await syncSystemTagsAndRules();
// Add rules from shareRules store (which was just synced)
const incomingRules = store.shareRules.filter(r => r.targetUserId === currentUserId);
// 2a. Fetch ALL Incomplete Tasks (Metadata Only) to ensure availability
// We must replicate the complex sharing logic here
const incompletePromises = [
// Own Incomplete
pb.collection(TASGRID_COLLECTION).getFullList({
filter: `user = "${currentUserId}" && completed = false`,
sort: '-created',
fields: LIST_FIELDS,
requestKey: null
}),
// Shared With Individual Incomplete
pb.collection(TASGRID_COLLECTION).getFullList({
filter: `sharedWith ~ "${currentUserId}" && completed = false`,
sort: '-created',
fields: LIST_FIELDS,
requestKey: null
}).catch(() => []),
// Bucket Incomplete
(bucketNames.length > 0) ? pb.collection(TASGRID_COLLECTION).getFullList({
filter: `(${bucketNames.map(name => `tags ~ "${name}"`).join(' || ')}) && completed = false`,
sort: '-created',
fields: LIST_FIELDS,
requestKey: null
}).catch(() => []) : Promise.resolve([])
];
// Share Rules Incomplete
if (incomingRules.length > 0) {
// Separate "Global Rules" (Self-Rules) from "Specific User Rules"
const globalTagRules: string[] = [];
const specificUserRules = new Map<string, { type: 'all' | 'tag', tagName?: string }[]>();
incomingRules.forEach((rule: any) => {
if (rule.ownerId === rule.targetUserId && rule.type === 'tag' && rule.tagName) {
globalTagRules.push(rule.tagName);
} else {
const existing = specificUserRules.get(rule.ownerId) || [];
existing.push({ type: rule.type, tagName: rule.tagName });
specificUserRules.set(rule.ownerId, existing);
}
});
// 1. Fetch Global Rule Tasks (from ANY user)
// 1. Fetch Global Rule Tasks (from ANY user)
if (globalTagRules.length > 0) {
// We want tasks that have ANY of these tags, AND are not completed
// filter: (tags ~ "A" || tags ~ "B") && completed = false
const tagFilter = globalTagRules.map(t => `tags ~ "${t}"`).join(' || ');
incompletePromises.push(pb.collection(TASGRID_COLLECTION).getFullList({
filter: `(${tagFilter}) && completed = false`,
sort: '-created',
fields: LIST_FIELDS,
requestKey: null
}).catch(() => []));
}
// 2. Fetch Specific User Rule Tasks (from specific owner)
Array.from(specificUserRules.entries()).forEach(([ownerId, rules]) => {
const hasAllRule = rules.some(r => r.type === 'all');
const tagRules = rules.filter(r => r.type === 'tag').map(r => r.tagName).filter(Boolean);
let filter = `user = "${ownerId}" && completed = false`;
if (hasAllRule) {
// Get everything from this user
} else if (tagRules.length > 0) {
// Get specific tags from this user
filter += ` && (${tagRules.map(t => `tags ~ "${t}"`).join(' || ')})`;
} else {
return; // No valid rules for this user
}
incompletePromises.push(pb.collection(TASGRID_COLLECTION).getFullList({
filter,
sort: '-created',
fields: LIST_FIELDS,
requestKey: null
}).catch(() => []));
});
}
const stage2Results = await Promise.all(incompletePromises);
const allIncomplete = stage2Results.flat();
setStore("tasks", (currentTasks) => {
const taskMap = new Map(currentTasks.map(t => [t.id, t]));
allIncomplete.forEach((r: any) => {
if (r.tags?.includes("__template__")) return;
const existing = taskMap.get(r.id);
// Prevent DOM rebuilding/flickering for tasks that haven't changed
if (existing && existing.updated === r.updated) {
return; // Keeps the exact same reference in the map
}
// If existing has content, keep it. Otherwise use new record (which likely has no content yet)
const newTask = mapRecordToTask(r);
if (existing && existing.content) {
newTask.content = existing.content;
}
taskMap.set(r.id, newTask);
});
return Array.from(taskMap.values());
});
// 2b. Content Backfill for Incomplete Tasks
// Fetch content for tasks that don't have it yet, in chunks
const tasksNeedingContent = store.tasks.filter(t => !t.completed && t.content === undefined).map(t => t.id);
if (tasksNeedingContent.length > 0) {
// Fetch in chunks of 20 to avoid URL length limits
const chunkSize = 20;
for (let i = 0; i < tasksNeedingContent.length; i += chunkSize) {
const ids = tasksNeedingContent.slice(i, i + chunkSize);
// We fetch just ID and Content
try {
const contentRecords = await pb.collection(TASGRID_COLLECTION).getFullList({
filter: ids.map(id => `id="${id}"`).join(' || '),
fields: 'id,content',
requestKey: null
});
setStore("tasks", (tasks) => tasks.map(t => {
const match = contentRecords.find((r: any) => r.id === t.id);
if (match) return { ...t, content: match.content };
return t;
}));
} catch (e) { console.warn("Backfill chunk failed", e); }
}
}
// STAGE 3: Recent History (Cap 150)
const currentCount = store.tasks.length;
const remainingSlots = 150 - currentCount;
if (remainingSlots > 0) {
try {
const recentCompleted = await pb.collection(TASGRID_COLLECTION).getList(1, remainingSlots, {
filter: `user = "${currentUserId}" && completed = true`,
sort: '-updated',
fields: LIST_FIELDS,
requestKey: null
});
setStore("tasks", (prev) => {
const newTasks = [...prev];
recentCompleted.items.forEach((r: any) => {
if (!newTasks.some(t => t.id === r.id)) {
newTasks.push(mapRecordToTask(r));
}
});
return newTasks;
});
} catch (e) {
console.warn("Stage 3 load failed", e);
}
}
checkRecurringTasks();
// Reconstructive migration
const foundTagNames = new Set<string>();
store.tasks.forEach(t => t.tags?.forEach(tag => foundTagNames.add(tag)));
for (const tagName of foundTagNames) {
if (tagName === "__template__") continue;
if (!store.tagDefinitions.find(d => d.name === tagName)) {
await upsertTagDefinition(tagName, 5, undefined, document.documentElement.classList.contains("dark") ? "dark" : "light");
}
}
// Sync System Tags & Share Rules (Moved to top of backgroundSync to ensure rules exist before use)
// await syncSystemTagsAndRules();
};
// Fire off background sync
await backgroundSync();
// Run one-time migration for legacy tags
await runLegacyTagMigration();
// Separate Templates load (less critical)
try {
const templates = await pb.collection(TASGRID_COLLECTION).getFullList({
filter: `user = "${currentUserId}" && tags ~ "__template__"`,
requestKey: null
});
const loadedTemplates = templates.map(r => {
let meta: any = {};
try { meta = JSON.parse(r.content || "{}"); } catch (e) { }
return {
id: r.id,
name: r.title,
title: meta.title || "",
priority: r.priority,
urgency: meta.urgency || 5,
tags: meta.tags || [],
content: meta.content || ""
};
});
setStore("templates", loadedTemplates);
} catch (err) { }
// Start real-time subscription
await subscribeToRealtime();
// 2.8 Fetch Filter Templates
await loadFilterTemplates();
} catch (err) {
if (store.tasks.length === 0) {
console.error("Failed to load data:", err);
toast.error("Failed to sync with server.");
}
} finally {
setStore("isInitializing", false);
}
};
// -- Actions --
export const toggleBucketSubscription = async (bucketId: string) => {
const currentSubscribed = store.subscribedBuckets;
const isSubscribed = currentSubscribed.includes(bucketId);
let newSubscribed = [];
if (isSubscribed) {
newSubscribed = currentSubscribed.filter(id => id !== bucketId);
} else {
newSubscribed = [...currentSubscribed, bucketId];
}
setStore("subscribedBuckets", newSubscribed);
// Persist to user profile
const userId = pb.authStore.model?.id;
if (userId) {
try {
await pb.collection('users').update(userId, {
subscribedBuckets: newSubscribed
});
// If we just subscribed, we should fetch the tasks for this bucket immediately
if (!isSubscribed) {
const bucketName = store.buckets.find(b => b.id === bucketId)?.name;
if (bucketName) {
const records = await pb.collection(TASGRID_COLLECTION).getFullList({
filter: `tags ~ "${bucketName}"`,
sort: '-created',
fields: LIST_FIELDS
});
const newTasks: Task[] = [];
records.forEach((r: any) => {
const exists = store.tasks.find(t => t.id === r.id);
if (!exists && !r.tags?.includes("__template__")) {
newTasks.push(mapRecordToTask(r));
}
});
if (newTasks.length > 0) {
setStore("tasks", t => [...newTasks, ...t]);
}
}
}
} catch (err) {
console.error("Failed to update subscriptions:", err);
// Revert on error
setStore("subscribedBuckets", currentSubscribed);
}
}
};
export const createBucket = async (name: string, color: string, description?: string) => {
try {
await pb.collection(BUCKETS_COLLECTION).create({
name,
color,
description
});
await syncSystemTagsAndRules();
toast.success("Bucket created");
} catch (err) {
console.error("Failed to create bucket:", err);
toast.error("Failed to create bucket");
}
};
export const updateBucket = async (id: string, data: { name?: string, color?: string, description?: string }) => {
try {
await pb.collection(BUCKETS_COLLECTION).update(id, data);
if (data.name) {
await syncSystemTagsAndRules();
}
toast.success("Bucket updated");
} catch (err) {
console.error("Failed to update bucket:", err);
toast.error("Failed to update bucket");
}
};
export const deleteBucket = async (id: string) => {
try {
await pb.collection(BUCKETS_COLLECTION).delete(id);
toast.success("Bucket deleted");
} catch (err) {
console.error("Failed to delete bucket:", err);
toast.error("Failed to delete bucket");
}
};
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 ?? 3;
const tempId = "temp-" + Date.now();
const initialTask: Task = {
id: tempId,
title,
startDate,
dueDate,
priority,
status: 0,
completed: false,
tags,
ownerId: pb.authStore.model?.id || "",
content,
size,
created: new Date().toISOString(),
updated: new Date().toISOString()
};
// Apply Handoff Logic (e.g. transfer ownership if tagging for another user)
// We treat the entire new task as "updates" to check against itself
const handoffUpdates = checkForHandoffs(initialTask, initialTask);
const newTask = { ...initialTask, ...handoffUpdates };
// Optimistic UI update
setStore("tasks", (t) => [newTask, ...t]);
try {
const record = await pb.collection(TASGRID_COLLECTION).create({
user: newTask.ownerId, // Use the (potentially updated) ownerId
title,
startDate,
dueDate,
priority,
status: 0,
completed: false,
tags,
content,
size,
// If checking for handoffs added specific sharedWith, we might need to handle that,
// but currently checkForHandoffs modifies ownerId/sharedWith.
// PB API expects 'sharedWith' if we want to set it, but our types might be complex?
// Wait, checkForHandoffs returns Partial<Task>.
// If it sets ownerId to someone else, we rely on 'user' field in create.
// If it sets sharedWith (e.g. for bucket rule clearing), we need to send that too?
// Standard create doesn't support 'sharedWith' directly in this code block usually?
// Let's check mapRecordToTask. sharedWith comes from record expansion usually.
// But if we are creating, we might need to set standard relation fields if they exist.
// However, the original code didn't set sharedWith.
// The handoff logic usually just changes OWNER.
// If it changes owner, 'user' field covers it.
});
// 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,
ownerId: record.user // Add missing ownerId from record
} : 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");
};
const checkForHandoffs = (task: Task, updates: Partial<Task>): Partial<Task> => {
// Only check if tags are being modified
if (!updates.tags) return updates;
const currentUserId = pb.authStore.model?.id;
if (!currentUserId || task.ownerId !== currentUserId) return updates;
// Check for any tag that triggers a SEND_TASK rule
for (const tag of updates.tags) {
// Handle @ prefix in rule matching
const rule = store.shareRules.find(r =>
r.ownerId === pb.authStore.model?.id &&
r.type === 'tag' &&
r.tagName === tag &&
r.share_mode === 'SEND_TASK'
);
if (rule) {
console.log(`Handoff triggered by tag "${tag}" -> Transferring to ${rule.targetUserId}`);
// Check if it's a bucket tag
const isBucketRule = rule.targetUserId === currentUserId && store.tagDefinitions.find(t => t.name === tag)?.isBucket;
if (isBucketRule) {
return {
...updates,
ownerId: null, // Unassign from current user (null is better for PB relations)
// Remove current user from sharedWith to ensure it disappears from their view
sharedWith: (task.sharedWith || []).filter(s => s.userId !== currentUserId)
};
}
return {
...updates,
ownerId: rule.targetUserId,
// Also clear sharedWith for the specific target user if they were already shared
sharedWith: (task.sharedWith || []).filter(s => s.userId !== rule.targetUserId)
};
}
}
return updates;
};
export const updateTask = async (id: string, updates: Partial<Task>) => {
if (!pb.authStore.isValid) return;
// 0. Check for Hand-off Logic (Tag-based ownership transfer)
const currentTask = store.tasks.find(t => t.id === id);
let finalUpdates = { ...updates };
// Sync status and completed
if (finalUpdates.status !== undefined) {
finalUpdates.completed = finalUpdates.status === 10;
} else if (finalUpdates.completed !== undefined) {
// If only completed is passed, sync status
// But if both pass, status takes precedence (above)
// Wait, if I change completed to false, status should be 0? Or previous status?
// Let's assume toggleTask handles that. updateTask is lower level.
// But for safety:
if (finalUpdates.completed) {
finalUpdates.status = 10;
} else if (currentTask && currentTask.status === 10) {
// Uncompleting a completed task -> reset to 0
finalUpdates.status = 0;
}
// If uncompleting a non-completed task (weird), keep status?
}
if (currentTask) {
finalUpdates = checkForHandoffs(currentTask, finalUpdates);
}
// Optimistic update with timestamp to prevent stale realtime events
const optimisticUpdates = {
...finalUpdates,
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 = { ...finalUpdates };
if (finalUpdates.ownerId !== undefined) {
pbUpdates.user = finalUpdates.ownerId || null; // Map ownerId to 'user' field in PB
delete pbUpdates.ownerId;
}
if ("deletedAt" in finalUpdates) {
// PB expects a date string or null for date fields.
// Using finalUpdates.deletedAt check to distinguish between soft-delete (timestamp) and restore (null/falsy)
if (finalUpdates.deletedAt) {
pbUpdates.deletedAt = new Date(finalUpdates.deletedAt).toISOString();
} else {
pbUpdates.deletedAt = null; // restore task
}
}
// 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) {
const newCompleted = !task.completed;
updateTask(id, {
completed: newCompleted,
status: newCompleted ? 10 : 0
});
}
};
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 null for deletedAt to explicitly clear it in PB
updateTask(id, { deletedAt: null });
};
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 {
// Check for case-insensitive duplicate
const nameLower = name.toLowerCase();
const duplicate = store.tagDefinitions.find(d => d.name.toLowerCase() === nameLower);
if (duplicate) {
if (duplicate.name !== name) {
// Case difference only -> Update to new casing?
// Or just warn? "Tag already exists with different casing".
// For now, we'll just return the existing one to be safe, or update it if needed.
// A strict interpretation of "There should never be two tags with the same name"
// usually implies normalization.
// But if the user typed "Work" and "work" exists, maybe they mean "work".
toast.error(`Tag "${duplicate.name}" already exists.`);
return;
}
// Exact match logic handled by 'existing' check above usually, but 'existing' uses strict equality?
// The existing check at top of function is strict: `existing = store.tagDefinitions.find(d => d.name === name);`
// So this block handles case-insensitive duplicates that weren't caught by strict check.
// We should update the existing one if we want to change casing, or just reject.
// Rejection is safer for unique constraint.
toast.error(`Tag "${duplicate.name}" already exists.`);
return;
}
const record = await pb.collection(TAGS_COLLECTION).create({
user: pb.authStore.model?.id,
name,
value,
color: color || "#6366f1", // Default indigo
theme: finalTheme
}, { requestKey: null });
const newDef: TagDefinition = {
id: record.id,
name: record.name,
value: record.value,
color: record.color,
theme: record.theme as "light" | "dark",
isBucket: record.isBucket || false
};
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 updateNote = async (id: string, updates: Partial<Note>) => {
if (!pb.authStore.isValid) return;
// Optimistic update
const optimisticUpdates = {
...updates,
updated: new Date().toISOString()
};
setStore("notes", (n) => n.id === id, optimisticUpdates);
try {
const pbUpdates: any = { ...updates };
if ("deletedAt" in updates) {
if (updates.deletedAt) {
pbUpdates.deletedAt = new Date(updates.deletedAt).toISOString();
} else {
pbUpdates.deletedAt = null;
}
}
await pb.collection(NOTES_COLLECTION).update(id, pbUpdates, { requestKey: null });
} catch (err) {
console.error("Note update failed", err);
toast.error("Failed to update note.");
}
};
export const removeNote = (id: string) => {
const nowTs = Date.now();
updateNote(id, { deletedAt: nowTs });
};
export const restoreNote = (id: string) => {
updateNote(id, { deletedAt: null });
};
export const deleteNotePermanently = async (id: string) => {
if (!pb.authStore.isValid) return;
// Optimistic
setStore("notes", (n) => n.filter((n) => n.id !== id));
try {
await pb.collection(NOTES_COLLECTION).delete(id);
} catch (err) {
console.error("Note delete failed", err);
toast.error("Failed to delete note.");
}
};
export const syncSystemTagsAndRules = async () => {
if (!pb.authStore.isValid) return;
const currentUserId = pb.authStore.model?.id;
try {
// 1. Fetch data
const [users, pbRules, tagDefinitions] = await Promise.all([
pb.collection('users').getFullList({
filter: 'verified = true',
fields: 'id,name,email,verified',
requestKey: null
}),
pb.collection(SHARE_RULES_COLLECTION).getFullList({
filter: `ownerId = "${currentUserId}"`,
requestKey: null
}),
pb.collection(TAGS_COLLECTION).getFullList({
filter: `user = "${currentUserId}"`,
requestKey: null
})
]);
const normalizedRules = pbRules.map(r => ({
id: r.id,
ownerId: r.ownerId,
targetUserId: r.targetUserId,
type: r.type,
tagName: r.tagName
}));
// Fetch task content on demand
// We'll call this when a task is opened
// --- Sync Bucket Tags ---
for (const bucket of store.buckets) {
const bucketTagName = `@${bucket.name}`;
const tagExistsExact = tagDefinitions.find(t => t.name.toLowerCase() === bucketTagName.toLowerCase());
if (!tagExistsExact) {
try {
const record = await pb.collection(TAGS_COLLECTION).create({
user: currentUserId,
name: bucketTagName,
value: 5,
color: bucket.color || "#64748b",
theme: "dark",
isBucket: true
}, { requestKey: null });
setStore("tagDefinitions", prev => [...prev, {
id: record.id,
name: record.name,
value: record.value,
color: record.color,
theme: record.theme,
isBucket: true
}]);
} catch (e) {
console.error(`Failed to create bucket tag ${bucketTagName}`, e);
}
} else if (!tagExistsExact.isBucket) {
try {
await pb.collection(TAGS_COLLECTION).update(tagExistsExact.id, { isBucket: true }, { requestKey: null });
setStore("tagDefinitions", d => d.id === tagExistsExact.id, { isBucket: true });
} catch (e) {
console.error(`Failed to flag tag ${bucketTagName} as bucket`, e);
}
}
// --- Sync Bucket System Rule ---
const bucketRuleExact = normalizedRules.find(r =>
r.ownerId === currentUserId &&
r.targetUserId === currentUserId &&
r.type === 'tag' &&
r.tagName === bucketTagName
);
const bucketRuleLegacy = normalizedRules.find(r =>
r.ownerId === currentUserId &&
r.targetUserId === currentUserId &&
r.type === 'tag' &&
r.tagName === bucket.name
);
if (bucketRuleLegacy && !bucketRuleExact) {
// Rename legacy rule
try {
await pb.collection(SHARE_RULES_COLLECTION).update(bucketRuleLegacy.id, { tagName: bucketTagName }, { requestKey: null });
setStore("shareRules", r => r.id === bucketRuleLegacy.id, { tagName: bucketTagName });
console.log(`Renamed legacy bucket rule: ${bucket.name} -> ${bucketTagName}`);
} catch (e) {
console.error(`Failed to rename legacy bucket rule ${bucket.name}`, e);
}
} else if (!bucketRuleExact) {
try {
await pb.collection(SHARE_RULES_COLLECTION).create({
ownerId: currentUserId,
targetUserId: currentUserId,
type: 'tag',
tagName: bucketTagName,
share_mode: 'SEND_TASK'
}, { requestKey: null });
} catch (e) {
console.error(`Failed to ensure system rule for bucket ${bucketTagName}`, e);
}
}
}
// --- Sync User Tags ---
for (const user of users) {
const originalName = user.name || user.email;
if (!originalName) continue;
const userName = `@${originalName}`;
const tagExistsExact = tagDefinitions.find(t => t.name.toLowerCase() === userName.toLowerCase());
if (!tagExistsExact) {
try {
const record = await pb.collection(TAGS_COLLECTION).create({
user: currentUserId,
name: userName,
value: 5,
color: "#64748b",
theme: "dark",
isUser: true
}, { requestKey: null });
setStore("tagDefinitions", prev => [...prev, {
id: record.id,
name: record.name,
value: record.value,
color: record.color,
theme: record.theme,
isUser: true
}]);
} catch (e) {
// console.error(`Failed to create user tag ${userName}`, e);
}
} else if (!tagExistsExact.isUser) {
try {
await pb.collection(TAGS_COLLECTION).update(tagExistsExact.id, { isUser: true }, { requestKey: null });
setStore("tagDefinitions", d => d.id === tagExistsExact.id, { isUser: true });
} catch (e) {
console.error(`Failed to flag tag ${userName} as user`, e);
}
}
// --- Ensure Self-Rule for Current User (Global Subscription) ---
if (user.id === currentUserId) {
const selfRuleExact = normalizedRules.find(r =>
r.ownerId === currentUserId &&
r.targetUserId === currentUserId &&
r.type === 'tag' &&
r.tagName === userName
);
const selfRuleLegacy = normalizedRules.find(r =>
r.ownerId === currentUserId &&
r.targetUserId === currentUserId &&
r.type === 'tag' &&
r.tagName === originalName
);
if (selfRuleLegacy && !selfRuleExact) {
// Rename legacy self-rule
try {
await pb.collection(SHARE_RULES_COLLECTION).update(selfRuleLegacy.id, { tagName: userName }, { requestKey: null });
setStore("shareRules", r => r.id === selfRuleLegacy.id, { tagName: userName });
console.log(`Renamed legacy self-rule: ${originalName} -> ${userName}`);
} catch (e) {
console.error(`Failed to rename legacy self-rule ${originalName}`, e);
}
} else if (!selfRuleExact) {
try {
const record = await pb.collection(SHARE_RULES_COLLECTION).create({
ownerId: currentUserId,
targetUserId: currentUserId,
type: 'tag',
tagName: userName,
share_mode: 'ADD_USER'
}, { requestKey: null });
const newRule: ShareRule = {
id: record.id,
ownerId: currentUserId,
targetUserId: currentUserId,
type: 'tag',
tagName: userName,
share_mode: 'ADD_USER'
};
setStore("shareRules", prev => [...prev, newRule]);
console.log(`Created self-rule for global sharing: ${userName}`);
} catch (e) {
console.error(`Failed to create self-rule for ${userName}`, e);
}
}
}
}
} catch (err) {
console.error("Failed to sync system tags/rules:", err);
}
};
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();
const finalNameLower = finalName.toLowerCase();
// Check availability (case-insensitive)
const duplicate = store.tagDefinitions.find(d => d.name.toLowerCase() === finalNameLower && d.id !== def.id);
if (duplicate) {
toast.error(`Tag "${duplicate.name}" already exists.`);
return;
}
try {
await pb.collection(TAGS_COLLECTION).update(def.id, { name: finalName }, { requestKey: null });
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 });
}
// Sync Share Rules
const rulesWithTag = store.shareRules.filter(r => r.tagName === oldName);
for (const rule of rulesWithTag) {
try {
await pb.collection(SHARE_RULES_COLLECTION).update(rule.id, { tagName: finalName }, { requestKey: null });
setStore("shareRules", (r) => r.id === rule.id, { tagName: finalName });
console.log(`Synced ShareRule ${rule.id}: ${oldName} -> ${finalName}`);
} catch (ruleErr) {
console.error(`Failed to sync ShareRule ${rule.id} during tag rename:`, ruleErr);
}
}
} catch (err) {
console.error("Failed to rename tag:", err);
toast.error("Failed to rename tag.");
}
};
// --- HISTORY ACTIONS ---
export const loadAllHistory = async () => {
if (!pb.authStore.isValid) return;
const userId = pb.authStore.model?.id;
if (!userId) return;
try {
toast.promise(
(async () => {
// Fetch ALL tasks for user (including completed)
// We use a simplified query for the user's own history + shared items
// Note: This matches the components of the background sync but without the 'completed=false' restriction
const bucketNames = store.subscribedBuckets
.map(id => store.buckets.find(b => b.id === id)?.name)
.filter(Boolean);
const promises = [
pb.collection(TASGRID_COLLECTION).getFullList({ filter: `user = "${userId}"`, sort: '-created', fields: LIST_FIELDS, requestKey: null }),
pb.collection(TASGRID_COLLECTION).getFullList({ filter: `sharedWith ~ "${userId}"`, sort: '-created', fields: LIST_FIELDS, requestKey: null }).catch(() => [])
];
if (bucketNames.length > 0) {
promises.push(pb.collection(TASGRID_COLLECTION).getFullList({
filter: bucketNames.map(name => `tags ~ "${name}"`).join(' || '),
sort: '-created',
fields: LIST_FIELDS,
requestKey: null
}).catch(() => []));
}
// Add Share Rules
const incomingRules = store.shareRules.filter(r => r.targetUserId === userId);
console.log("[DEBUG] loadAllHistory - Incoming Rules for User:", userId, incomingRules);
if (incomingRules.length > 0) {
const rulesToFetch: string[] = [];
incomingRules.forEach((rule: any) => {
// Global Rule (Self-Targeted)
if (rule.ownerId === rule.targetUserId && rule.type === 'tag' && rule.tagName) {
console.log("[DEBUG] Found Self-Rule:", rule.tagName);
rulesToFetch.push(`tags ~ "${rule.tagName}"`);
} else {
// Standard Rule
let f = `user = "${rule.ownerId}"`;
if (rule.type === 'tag' && rule.tagName) f += ` && tags ~ "${rule.tagName}"`;
rulesToFetch.push(f);
}
});
// Optimization: Group Global Rules?
// For now, simpler to just push them all.
// But if we have many global rules (e.g. buckets), we might want to group them.
// The Global Rules usually behave like buckets.
// Let's just push them as individual queries for safety.
rulesToFetch.forEach(filter => {
console.log("[DEBUG] Fetching with filter:", filter);
promises.push(pb.collection(TASGRID_COLLECTION).getFullList({ filter, sort: '-created', fields: LIST_FIELDS, requestKey: null }).then(res => {
console.log(`[DEBUG] Filter "${filter}" returned ${res.length} items`);
return res;
}).catch(err => {
console.error(`[DEBUG] Filter "${filter}" FAILED:`, err);
return [];
}));
});
}
const results = await Promise.all(promises);
const allRecords = results.flat();
setStore("tasks", (currentTasks) => {
const taskMap = new Map(currentTasks.map(t => [t.id, t]));
let addedCount = 0;
allRecords.forEach((r: any) => {
if (r.tags?.includes("__template__")) return;
if (!taskMap.has(r.id)) {
taskMap.set(r.id, mapRecordToTask(r));
addedCount++;
}
});
return Array.from(taskMap.values());
});
return `Loaded history.`;
})(),
{
loading: 'Loading full history...',
success: (msg) => msg,
error: 'Failed to load history'
}
);
} catch (err) {
console.error("Failed to load history:", err);
}
};
export const loadTaskContent = async (id: string) => {
if (!pb.authStore.isValid) return;
try {
const record = await pb.collection(TASGRID_COLLECTION).getOne(id, { requestKey: null });
// Only update if content is still undefined (i.e. not edited locally while loading)
setStore("tasks", t => t.id === id && t.content === undefined, { content: record.content });
} catch (err) {
console.error("Failed to load task content:", err);
}
};
// -- 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,
quickloadTasks: store.quickloadTasks
};
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()
}, { requestKey: null });
const newTemplate = { ...template, id: record.id };
// Check if already added by realtime subscription
setStore("templates", (prev) => {
if (prev?.some(t => t.id === record.id)) return prev;
return [...(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 }));
};
// -- Sharing Functions --
export const shareTask = async (taskId: string, userId: string, access: 'view' | 'edit' = 'view') => {
if (!pb.authStore.isValid) return;
const task = store.tasks.find(t => t.id === taskId);
if (!task) return;
const existingShares = task.sharedWith || [];
if (existingShares.some(s => s.userId === userId)) {
toast.info(`Task is already shared with this user`);
return;
}
const newShares = [...existingShares, { userId, access }];
// Update task with new sharedWith
await updateTask(taskId, { sharedWith: newShares });
toast.success(`Task shared successfully`);
};
export const revokeShare = async (taskId: string, userId: string) => {
if (!pb.authStore.isValid) return;
const task = store.tasks.find(t => t.id === taskId);
if (!task) return;
const existingShares = task.sharedWith || [];
const newShares = existingShares.filter(s => s.userId !== userId);
await updateTask(taskId, { sharedWith: newShares });
toast.success(`Share revoked`);
};
export const splitTask = async (taskId: string, userId: string) => {
if (!pb.authStore.isValid) return;
const task = store.tasks.find(t => t.id === taskId);
if (!task) return;
try {
// Create a duplicate task owned by the target user
const duplicateData = {
user: userId,
title: task.title,
content: task.content,
priority: task.priority,
size: task.size,
tags: task.tags,
startDate: task.startDate,
dueDate: task.dueDate,
status: task.status,
completed: task.completed,
deletedAt: null,
sharedWith: [], // Start fresh with no shares
recurrence: task.recurrence
};
await pb.collection(TASGRID_COLLECTION).create(duplicateData, { requestKey: null });
// Remove the user from the original task's sharedWith
const existingShares = task.sharedWith || [];
const newShares = existingShares.filter(s => s.userId !== userId);
await updateTask(taskId, { sharedWith: newShares });
toast.success(`Task split - user now has their own copy`);
} catch (err: any) {
console.error("Failed to split task:", err);
console.error("Error details:", err.response?.data || err.data || err.message);
toast.error("Failed to split task: " + (err.response?.data?.message || err.message || "Unknown error"));
}
};
// -- Share Rules (for tag-based and all-tasks sharing) --
export const loadShareRules = async () => {
if (!pb.authStore.isValid) return;
try {
const currentUserId = pb.authStore.model?.id;
const records = await pb.collection(SHARE_RULES_COLLECTION).getFullList({
filter: `ownerId = "${currentUserId}" || targetUserId = "${currentUserId}"`,
requestKey: null
});
const rules: ShareRule[] = records.map(r => ({
id: r.id,
ownerId: r.ownerId,
targetUserId: r.targetUserId,
type: r.type as 'tag' | 'all',
tagName: r.tagName,
share_mode: r.share_mode || 'ADD_USER'
}));
setStore("shareRules", reconcile(rules));
} catch (err) {
console.error("Failed to load share rules:", err);
}
};
export const addShareRule = async (type: 'tag' | 'all', targetUserId: string, tagName?: string, share_mode: 'ADD_USER' | 'SEND_TASK' = 'ADD_USER') => {
if (!pb.authStore.isValid) return;
// Check for duplicates
// We check against the local store to avoid network round-trip delay issues
const currentUserId = pb.authStore.model?.id;
const exists = store.shareRules.some(r =>
r.ownerId === currentUserId &&
r.targetUserId === targetUserId &&
r.type === type &&
(type === 'all' || r.tagName === tagName)
// We don't strictly check share_mode because having two rules for same tag/person with different modes is confusing.
// Usually we update the existing one if mode changes.
);
if (exists) {
// toast.info("Share rule already exists.");
return; // Silent return or update?
}
try {
await pb.collection(SHARE_RULES_COLLECTION).create({
ownerId: pb.authStore.model?.id,
targetUserId,
type,
tagName: type === 'tag' ? tagName : null,
share_mode: share_mode || 'ADD_USER'
}, { requestKey: null });
// Realtime subscription will handle adding to store
toast.success(`Share rule created`);
} catch (err) {
console.error("Failed to create share rule:", err);
toast.error("Failed to create share rule. Make sure the collection exists in PocketBase.");
}
};
export const updateShareRule = async (ruleId: string, updates: Partial<ShareRule>) => {
if (!pb.authStore.isValid) return;
try {
await pb.collection(SHARE_RULES_COLLECTION).update(ruleId, updates, { requestKey: null });
setStore("shareRules", (rules) =>
rules.map(r => r.id === ruleId ? { ...r, ...updates } : r)
);
toast.success("Share rule updated");
} catch (err) {
console.error("Failed to update share rule:", err);
toast.error("Failed to update share rule.");
}
};
export const removeShareRule = async (ruleId: string) => {
if (!pb.authStore.isValid) return;
try {
await pb.collection(SHARE_RULES_COLLECTION).delete(ruleId);
setStore("shareRules", (rules) => rules.filter(r => r.id !== ruleId));
toast.success("Share rule removed");
} catch (err) {
console.error("Failed to delete share rule:", err);
toast.error("Failed to delete share rule.");
}
};
// -- Filter Templates --
const FILTER_TEMPLATES_COLLECTION = 'TasGrid_FilterTemplates';
export const loadFilterTemplates = async () => {
if (!pb.authStore.isValid) return;
try {
const records = await pb.collection(FILTER_TEMPLATES_COLLECTION).getFullList({
filter: `user = "${pb.authStore.model?.id}"`,
requestKey: null
});
const templates: FilterTemplate[] = records.map(r => ({
id: r.id,
name: r.name,
filter: r.filter as Filter
}));
setStore("filterTemplates", templates);
} catch (err) {
console.error("Failed to load filter templates:", err);
}
};
export const saveFilterTemplate = async (name: string) => {
if (!pb.authStore.isValid) return;
try {
const record = await pb.collection(FILTER_TEMPLATES_COLLECTION).create({
user: pb.authStore.model?.id,
name,
filter: store.filter
}, { requestKey: null });
const newTemplate: FilterTemplate = {
id: record.id,
name: record.name,
filter: record.filter as Filter
};
setStore("filterTemplates", prev => [...prev, newTemplate]);
toast.success(`Filter template "${name}" saved`);
} catch (err) {
console.error("Failed to save filter template:", err);
toast.error("Failed to save filter template.");
}
};
export const applyFilterTemplate = (id: string) => {
const template = store.filterTemplates.find(t => t.id === id);
if (template) {
setFilter(template.filter);
toast.success(`Applied filter: ${template.name}`);
}
};
export const removeFilterTemplate = async (id: string) => {
if (!pb.authStore.isValid) return;
try {
await pb.collection(FILTER_TEMPLATES_COLLECTION).delete(id);
setStore("filterTemplates", prev => prev.filter(t => t.id !== id));
toast.success("Filter template deleted");
} catch (err) {
console.error("Failed to delete filter template:", err);
toast.error("Failed to delete filter template.");
}
};
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();
};