Files
TasGrid/src/store/index.ts
T

2952 lines
108 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 { CONTEXTS_COLLECTION, NOTES_COLLECTION, TASGRID_COLLECTION, URGENCY_HOURS, SIZE_HOURS } from "@/lib/constants";
import { STORAGE_KEY_PREFIX, TASGRID_DATA_MODE } from "@/lib/app-config";
import { buildNoteTag, buildShareTag, normalizeContextKey, normalizeNoteKey, parseTags } from "@/lib/tags";
// Helper to get the current user's personalized favorite tag
export const getFavoriteTag = () => {
const userId = pb.authStore.model?.id;
return userId ? `__${userId}_favorite__` : "__favorite__";
};
const LIST_FIELDS = 'id,collectionId,collectionName,created,updated,title,startDate,dueDate,priority,status,completed,tags,labelTags,shareRefs,noteRefs,createdBy,user,size,recurrence,deletedAt';
const getStorageKey = () => {
const userId = pb.authStore.model?.id;
return userId ? `${STORAGE_KEY_PREFIX}_data_${userId}` : null;
};
export const [now, setNow] = createSignal(Date.now());
// Context: 'mine' is a temporary bootstrap alias for the current user's personal context bucket.
type TaskContext = 'mine' | { userId: string, name: string } | { bucketId: string, name: string, isPersonal?: boolean };
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[];
labelTags: string[];
shareRefs: TaskShareRef[];
noteRefs: NoteRef[];
ownerId: string | null; // Add ownerId to interface
createdBy: string | null;
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' }>;
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;
isBucket?: boolean;
}
export interface Note {
id: string;
title: string;
key: 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;
ownedByMe: boolean;
starred: boolean;
jobStatus: FilterTag[];
jobDivision: FilterTag[];
}
// 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();
const personalOrUserId =
typeof ctx === 'object' && 'userId' in ctx
? ctx.userId
: typeof ctx === 'object' && 'bucketId' in ctx
? store.contexts.find(context => context.id === ctx.bucketId && context.kind === "user")?.targetUserId
: null;
if (personalOrUserId) {
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: `${relationFilter("user", personalOrUserId)} && 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;
}
interface ScopedTaskgridPrefs {
pWeight?: number;
uWeight?: number;
matrixScaleDays?: number;
tagDefinitions?: TagDefinition[];
filter?: Filter;
filterTemplates?: FilterTemplate[];
noteFilterTemplates?: FilterTemplate[];
quickloadTasks?: string[];
noteFilter?: Filter;
subscribedBuckets?: string[];
supervisorUserIds?: string[];
migratedStructuredTagsV2?: boolean;
migratedPersonalContextRefsV1?: boolean;
}
const PREF_ENVIRONMENTS_KEY = "environments";
const readScopedPrefs = (rawPrefs: any): ScopedTaskgridPrefs => {
const scoped = rawPrefs?.[PREF_ENVIRONMENTS_KEY]?.[TASGRID_DATA_MODE];
return (scoped || rawPrefs || {}) as ScopedTaskgridPrefs;
};
const writeScopedPrefs = (rawPrefs: any, scopedPrefs: ScopedTaskgridPrefs) => ({
...(rawPrefs || {}),
[PREF_ENVIRONMENTS_KEY]: {
...(rawPrefs?.[PREF_ENVIRONMENTS_KEY] || {}),
[TASGRID_DATA_MODE]: scopedPrefs
}
});
const unwrapRelationId = (value: unknown): string | null => {
if (typeof value === "string") {
return value || null;
}
if (Array.isArray(value)) {
const first = value[0];
return typeof first === "string" ? first : null;
}
return null;
};
const unwrapRelationIds = (value: unknown): string[] => {
if (Array.isArray(value)) {
return value
.map(item => {
if (typeof item === "string") return item;
if (item && typeof item === "object" && "id" in item && typeof (item as any).id === "string") {
return (item as any).id;
}
return null;
})
.filter((item): item is string => !!item);
}
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed) return [];
try {
const parsed = JSON.parse(trimmed);
return unwrapRelationIds(parsed);
} catch {
return trimmed.includes(",")
? trimmed.split(",").map(item => item.trim()).filter(Boolean)
: [trimmed];
}
}
if (value && typeof value === "object" && "id" in value && typeof (value as any).id === "string") {
return [(value as any).id];
}
return [];
};
const relationFilter = (field: string, id: string | undefined | null) =>
id ? `(${field} = "${id}" || ${field} ?= "${id}")` : "false";
const buildUserContextKey = (userId: string) => `user-${userId}`;
export interface ShareContext {
id: string;
key: string;
displayName: string;
kind: 'user' | 'bucket';
policy: 'collaborative' | 'handoff';
targetUserId?: string | null;
supervisorUserIds?: string[];
color?: string;
description?: string;
deletedAt?: number | null;
deletedBy?: string | null;
}
export interface TaskShareRef {
contextId: string;
key: string;
kind: 'user' | 'bucket';
}
export interface NoteRef {
noteId: string;
key: string;
}
export interface ShareRule {
id: string;
ownerId: string;
targetUserId: string;
type: 'tag' | 'all';
tagName?: string;
share_mode?: 'ADD_USER' | 'SEND_TASK';
}
export interface SupervisedContextAccess {
ownerUserId: string;
ownerName: string;
contextId: string;
contextName: string;
}
export interface Bucket {
id: string;
name: string;
color: string;
description?: string;
policy?: 'collaborative' | 'handoff';
key?: string;
deletedAt?: number | null;
}
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[];
contexts: ShareContext[];
supervisedContexts: SupervisedContextAccess[];
filterTemplates: FilterTemplate[];
buckets: Bucket[];
subscribedBuckets: string[]; // IDs of buckets I'm subscribed to
personalContextSupervisorIds: string[];
notes: Note[];
isNotepadMode: boolean;
quickloadTasks: string[];
noteFilter: Filter;
}
// 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,
ownedByMe: false,
starred: false,
jobStatus: [],
jobDivision: []
},
shareRules: [],
contexts: [],
supervisedContexts: [],
filterTemplates: [],
buckets: [],
subscribedBuckets: [],
personalContextSupervisorIds: [],
notes: [],
isNotepadMode: false,
quickloadTasks: [],
noteFilter: {
query: "",
tags: [],
priorityMin: 1,
priorityMax: 10,
urgencyMin: 1,
urgencyMax: 10,
editedToday: false,
ownedByMe: false,
starred: false,
jobStatus: [],
jobDivision: []
}
});
const DEFAULT_LABEL_THEME: "light" | "dark" = "dark";
const mapContextToBucket = (context: ShareContext): Bucket => ({
id: context.id,
name: context.displayName,
color: context.color || "#64748b",
description: context.description,
policy: context.policy,
key: context.key,
deletedAt: context.deletedAt ?? null
});
const deriveBuckets = (contexts: ShareContext[]) =>
contexts
.filter(context => context.kind === "bucket" && !context.deletedAt)
.sort((a, b) => a.displayName.localeCompare(b.displayName))
.map(mapContextToBucket);
const deriveSystemTagDefinitions = (contexts: ShareContext[]): TagDefinition[] =>
contexts
.filter(context => !context.deletedAt)
.map(context => ({
id: context.id,
name: buildShareTag(context.displayName),
value: 5,
color: context.color,
theme: DEFAULT_LABEL_THEME,
isUser: context.kind === "user",
isBucket: context.kind === "bucket"
}));
const findContextForShareTag = (tag: { key: string; raw: string }) =>
store.contexts.find(context => !context.deletedAt && context.key === tag.key) ||
store.contexts.find(context => !context.deletedAt && context.kind === "user" && normalizeContextKey(context.displayName) === tag.key) ||
store.contexts.find(context => !context.deletedAt && normalizeContextKey(context.displayName) === tag.key) ||
store.contexts.find(context => !context.deletedAt && buildShareTag(context.displayName).toLowerCase() === tag.raw.toLowerCase()) ||
null;
const getContextByRef = (ref: TaskShareRef) =>
store.contexts.find(context => !context.deletedAt && context.id === ref.contextId) ||
store.contexts.find(context => !context.deletedAt && context.key === ref.key && context.kind === ref.kind) ||
store.contexts.find(context => !context.deletedAt && context.kind === ref.kind && normalizeContextKey(context.displayName) === ref.key) ||
store.contexts.find(context => !context.deletedAt && normalizeContextKey(context.displayName) === ref.key) ||
null;
const getNoteByRef = (ref: NoteRef) =>
store.notes.find(note => note.id === ref.noteId) ||
store.notes.find(note => note.key === ref.key);
const buildTaskTags = (
labelTags: string[],
shareRefs: TaskShareRef[],
noteRefs: NoteRef[],
favoriteTag?: string
) => {
const tags = [
...labelTags,
...shareRefs.map(ref => {
const context = getContextByRef(ref);
return buildShareTag(context?.displayName || ref.key);
}),
...noteRefs.map(ref => {
const note = getNoteByRef(ref);
return buildNoteTag(note?.key || ref.key);
})
];
if (favoriteTag && !tags.includes(favoriteTag)) {
tags.push(favoriteTag);
}
return [...new Set(tags.filter(Boolean))];
};
const withActiveContextTags = (rawTags: string[]) => {
const nextTags = [...rawTags];
const ctx = currentTaskContext();
if (ctx === "mine") {
const personalContext = getPersonalContext();
if (personalContext) {
const personalTag = buildShareTag(personalContext.displayName);
if (!nextTags.some(tag => tag.toLowerCase() === personalTag.toLowerCase())) {
nextTags.push(personalTag);
}
}
} else if (typeof ctx === "object") {
if ("bucketId" in ctx && ctx.name) {
const bucketContext = store.contexts.find(context => context.id === ctx.bucketId);
const bucketTag = buildShareTag(bucketContext?.displayName || ctx.name);
if (!nextTags.some(tag => tag.toLowerCase() === bucketTag.toLowerCase())) {
nextTags.push(bucketTag);
}
} else if ("userId" in ctx && ctx.name) {
const userTag = buildShareTag(ctx.name);
if (!nextTags.some(tag => tag.toLowerCase() === userTag.toLowerCase())) {
nextTags.push(userTag);
}
}
}
if (store.isNotepadMode) {
const noteId = activeNoteId();
const note = noteId ? store.notes.find(existing => existing.id === noteId) : null;
if (note) {
const noteTag = buildNoteTag(note.key || note.title);
if (!nextTags.some(tag => tag.toLowerCase() === noteTag.toLowerCase())) {
nextTags.push(noteTag);
}
}
}
return [...new Set(nextTags)];
};
const setContexts = (contexts: ShareContext[]) => {
setStore("contexts", reconcile(contexts));
setStore("buckets", reconcile(deriveBuckets(contexts)));
const labelDefinitions = store.tagDefinitions.filter(def => !def.name.startsWith("@"));
const mergedDefs = [...labelDefinitions, ...deriveSystemTagDefinitions(contexts)];
setStore("tagDefinitions", reconcile(mergedDefs));
};
const getPersonalContext = (userId = pb.authStore.model?.id) =>
userId
? store.contexts.find(context =>
!context.deletedAt &&
context.kind === "user" &&
context.targetUserId === userId
) || null
: null;
const getSubscribedBucketContexts = () =>
store.subscribedBuckets
.map(id => store.contexts.find(context => context.id === id))
.filter((context): context is ShareContext => !!context && !context.deletedAt && context.kind === "bucket");
export const matchesFilter = (task: Task) => {
const currentUserId = pb.authStore.model?.id;
const ctx = currentTaskContext();
const personalContext = getPersonalContext(currentUserId);
const rawBucketRefs = task.shareRefs.filter(ref => ref.kind === "bucket");
const activeShareContexts = task.shareRefs
.map(ref => getContextByRef(ref))
.filter((context): context is ShareContext => !!context && !context.deletedAt);
const collaborativeUserShare = activeShareContexts.some(context =>
context.kind === "user" &&
context.policy === "collaborative" &&
context.targetUserId === currentUserId
);
const bucketRefs = activeShareContexts.filter(context => context.kind === "bucket");
const hasHandoffBucket = rawBucketRefs.some(ref => {
const context = getContextByRef(ref);
return !context || context.policy === "handoff";
});
const inPinnedBucket = rawBucketRefs.some(ref =>
store.subscribedBuckets.includes(ref.contextId) ||
store.subscribedBuckets.some(bucketId => store.contexts.find(context => context.id === bucketId)?.key === ref.key)
);
const inAnyBucket = rawBucketRefs.length > 0;
const isPersonalView = ctx === "mine" || (
typeof ctx === "object" &&
"bucketId" in ctx &&
!!personalContext &&
ctx.bucketId === personalContext.id
);
const inPersonalContext = !!personalContext && task.shareRefs.some(ref =>
ref.kind === "user" &&
(ref.contextId === personalContext.id || ref.key === personalContext.key)
);
// 0. Context Filter
if (isPersonalView) {
if (hasHandoffBucket) return false;
const isOwner = task.ownerId === currentUserId;
if (!isOwner && !collaborativeUserShare && !inPersonalContext) return false;
} else if (typeof ctx === 'object' && 'bucketId' in ctx) {
const activeBucketContext = store.contexts.find(context => context.id === (ctx as { bucketId: string }).bucketId);
const activeBucketKey = activeBucketContext?.key || normalizeContextKey((ctx as { name: string }).name);
const inCurrentBucket = rawBucketRefs.some(ref =>
ref.contextId === (ctx as { bucketId: string }).bucketId ||
ref.key === activeBucketKey
) || bucketRefs.some(context => context.id === (ctx as { bucketId: string }).bucketId);
if (!inCurrentBucket) return false;
} else {
if (hasHandoffBucket) return false;
if (task.ownerId !== (ctx as { userId: string }).userId) return false;
}
if (isPersonalView && inAnyBucket && !inPinnedBucket && task.ownerId !== currentUserId) 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;
}
// Owned By Me
if (f.ownedByMe && task.ownerId !== currentUserId) {
return false;
}
// Starred
if (f.starred && !task.tags?.includes(getFavoriteTag())) {
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 => {
const legacyTags: string[] = r.tags || [];
const parsedTags = parseTags(legacyTags).filter(tag => tag.raw !== "__template__");
const shareRefs: TaskShareRef[] = Array.isArray(r.shareRefs)
? r.shareRefs
: parsedTags
.filter(tag => tag.kind === "share")
.map(tag => {
const context = findContextForShareTag(tag);
return {
contextId: context?.id || tag.key,
key: tag.key,
kind: context?.kind || "bucket"
};
});
const noteRefs: NoteRef[] = Array.isArray(r.noteRefs)
? r.noteRefs
: parsedTags
.filter(tag => tag.kind === "note")
.map(tag => {
const note = store.notes.find(existing => existing.key === tag.key);
return {
noteId: note?.id || tag.key,
key: tag.key
};
});
const labelTags: string[] = Array.isArray(r.labelTags)
? r.labelTags
: parsedTags.filter(tag => tag.kind === "label").map(tag => tag.display);
const favoriteTag = legacyTags.find((tag: string) => tag.endsWith("_favorite__"));
const tags = buildTaskTags(labelTags, shareRefs, noteRefs, favoriteTag);
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,
labelTags,
shareRefs,
noteRefs,
ownerId: unwrapRelationId(r.user),
createdBy: unwrapRelationId(r.createdBy) || unwrapRelationId(r.user),
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: [],
attachments: r.attachments || []
};
};
export const uploadTaskAttachment = async (taskId: string, file: File): Promise<{ url: string, filename: string }> => {
try {
const formData = new FormData();
formData.append('attachments+', file);
const record = await pb.collection(TASGRID_COLLECTION).update(taskId, formData);
if (record.attachments && record.attachments.length > 0) {
const latestFilename = record.attachments[record.attachments.length - 1];
return {
url: pb.files.getURL(record, latestFilename),
filename: latestFilename
};
}
throw new Error("File uploaded but no attachment returned");
} catch (err: any) {
console.error("Error uploading attachment. Validation details:", JSON.stringify(err.data, null, 2));
toast.error("Failed to upload image.");
throw err;
}
};
export const uploadNoteAttachment = async (noteId: string, file: File): Promise<{ url: string, filename: 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 {
url: pb.files.getURL(record, latestFilename),
filename: latestFilename
};
}
throw new Error("File uploaded but no attachment returned");
} catch (err: any) {
console.error("Error uploading note attachment. Validation details:", JSON.stringify(err.data, null, 2));
toast.error("Failed to upload image to note.");
throw err;
}
};
export const cleanupNoteAttachments = async (noteId: string) => {
if (!pb.authStore.isValid) return;
try {
const record = await pb.collection(NOTES_COLLECTION).getOne(noteId);
if (!record.attachments || record.attachments.length === 0) return;
const content = record.content || "";
const filesToDelete: string[] = [];
// Also check decoded content just in case of encoding mismatches
const decodedContent = decodeURIComponent(content);
for (const filename of record.attachments) {
// Check for literal filename OR the part of the URL that includes the filename
if (!content.includes(filename) && !decodedContent.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 record = await pb.collection(TASGRID_COLLECTION).getOne(taskId);
if (!record.attachments || record.attachments.length === 0) return;
const content = record.content || "";
const filesToDelete: string[] = [];
const decodedContent = decodeURIComponent(content);
for (const filename of record.attachments) {
if (!content.includes(filename) && !decodedContent.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 mapContextToShareRule = (context: ShareContext): ShareRule => ({
id: context.id,
ownerId: pb.authStore.model?.id || "",
targetUserId: context.targetUserId || pb.authStore.model?.id || "",
type: 'tag',
tagName: buildShareTag(context.displayName),
share_mode: context.policy === "handoff" ? "SEND_TASK" : "ADD_USER"
});
const mapRecordToContext = (r: any): ShareContext => ({
id: r.id,
key: r.key || normalizeContextKey(r.displayName || r.name || ""),
displayName: r.displayName || r.name || r.key,
kind: (r.kind || "bucket") as "user" | "bucket",
policy: (r.policy || (r.kind === "user" ? "collaborative" : "handoff")) as "collaborative" | "handoff",
targetUserId: unwrapRelationId(r.targetUserId),
supervisorUserIds: unwrapRelationIds(r.supervisorUserIds),
color: r.color,
description: r.description,
deletedAt: r.deletedAt ? new Date(r.deletedAt).getTime() : null,
deletedBy: unwrapRelationId(r.deletedBy)
});
const mapRecordToNote = (r: any): Note => {
return {
id: r.id,
title: r.title,
key: r.key || normalizeNoteKey(r.title),
content: r.content,
tags: Array.isArray(r.tags) && r.tags.length > 0 ? r.tags : [buildNoteTag(r.key || r.title)],
isPrivate: r.isPrivate || false,
user: unwrapRelationId(r.user) || "",
tasks: r.tasks || [],
created: r.created,
updated: r.updated,
deletedAt: r.deletedAt ? new Date(r.deletedAt).getTime() : undefined
};
};
export const fetchNoteById = async (id: string): Promise<Note | null> => {
try {
const record = await pb.collection(NOTES_COLLECTION).getOne(id);
if (!record) return null;
return mapRecordToNote(record);
} catch (err) {
console.error("Failed to fetch note by ID:", err);
return null;
}
};
export const updateStoreWithNote = (note: Note) => {
setStore("notes", (currentNotes) => {
const index = currentNotes.findIndex(n => n.id === note.id);
if (index !== -1) {
const newNotes = [...currentNotes];
newNotes[index] = note;
return newNotes;
}
return [...currentNotes, note];
});
};
// Helper to check if a task should be visible based on ownership, shares, or rules
const shouldTaskBeVisible = (task: any, currentUserId: string): boolean => {
const mappedTask = "shareRefs" in task ? task as Task : mapRecordToTask(task);
if (mappedTask.ownerId === currentUserId) return true;
if (store.supervisedContexts.some(context => context.ownerUserId === mappedTask.ownerId)) return true;
if (mappedTask.shareRefs.some(ref => ref.kind === "bucket")) return true;
const contexts = mappedTask.shareRefs
.map(ref => getContextByRef(ref))
.filter((context): context is ShareContext => !!context && !context.deletedAt);
return contexts.some(context =>
context.kind === "user" &&
context.policy === "collaborative" &&
context.targetUserId === currentUserId
);
};
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 = unwrapRelationId(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 = unwrapRelationId(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 || unwrapRelationId(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));
}
});
await pb.collection(CONTEXTS_COLLECTION).unsubscribe('*');
pb.collection(CONTEXTS_COLLECTION).subscribe('*', (e) => {
if (e.action === 'delete') {
const remaining = store.contexts.filter(context => context.id !== e.record.id);
setContexts(remaining);
setStore("shareRules", remaining.map(mapContextToShareRule));
setStore("tasks", tasks => tasks.filter(task => shouldTaskBeVisible(task, pb.authStore.model?.id || "")));
void syncSupervisedContexts();
return;
}
const context = mapRecordToContext(e.record);
const nextContexts = store.contexts.some(existing => existing.id === context.id)
? store.contexts.map(existing => existing.id === context.id ? context : existing)
: [...store.contexts, context];
setContexts(nextContexts);
setStore("shareRules", nextContexts.map(mapContextToShareRule));
if (context.targetUserId === pb.authStore.model?.id) {
setStore("personalContextSupervisorIds", context.supervisorUserIds || []);
}
void syncSupervisedContexts();
});
};
const runLegacyTagMigration = async () => {
const currentUserId = pb.authStore.model?.id;
if (!currentUserId) return;
try {
const userRec = await pb.collection('users').getOne(currentUserId, { requestKey: null });
const rawPrefs = userRec.Taskgrid_pref || {};
const prefs = readScopedPrefs(rawPrefs);
if (prefs.migratedStructuredTagsV2) {
return; // Already migrated
}
console.log("[MIGRATION] Starting structured tag migration...");
await syncUserContexts();
const tasks = await pb.collection(TASGRID_COLLECTION).getFullList({
requestKey: null
});
for (const taskRecord of tasks) {
if (taskRecord.tags?.includes("__template__")) continue;
const task = mapRecordToTask(taskRecord);
await pb.collection(TASGRID_COLLECTION).update(task.id, {
tags: task.tags,
labelTags: task.labelTags,
shareRefs: task.shareRefs,
noteRefs: task.noteRefs,
createdBy: task.createdBy || task.ownerId || currentUserId
}, { requestKey: null });
}
const notes = await pb.collection(NOTES_COLLECTION).getFullList({
requestKey: null
});
for (const noteRecord of notes) {
const note = mapRecordToNote(noteRecord);
await pb.collection(NOTES_COLLECTION).update(note.id, {
key: note.key,
tags: note.tags
}, { requestKey: null });
}
const nextPrefs = { ...prefs, migratedStructuredTagsV2: true };
await pb.collection('users').update(currentUserId, {
Taskgrid_pref: writeScopedPrefs(rawPrefs, nextPrefs)
}, { requestKey: null });
} catch (err) {
console.error("[MIGRATION] Failed:", err);
}
};
const syncUserContexts = async () => {
const allUsers = await pb.collection('users').getFullList({
filter: 'verified = true',
fields: 'id,name,email',
requestKey: null
});
const existingContexts = await pb.collection(CONTEXTS_COLLECTION).getFullList({
requestKey: null
}).catch(() => []);
let createdAny = false;
for (const user of allUsers) {
const displayName = user.name || user.email;
const existingUserContext = existingContexts.find((context: any) =>
(context.kind || "bucket") === "user" &&
unwrapRelationId(context.targetUserId) === user.id
);
if (!displayName || existingUserContext) continue;
const created = await pb.collection(CONTEXTS_COLLECTION).create({
key: buildUserContextKey(user.id),
displayName,
kind: "user",
policy: "collaborative",
targetUserId: user.id
}, { requestKey: null }).catch(() => null);
if (created) {
createdAny = true;
}
}
if (createdAny || store.contexts.length === 0) {
const nextContextRecords = await pb.collection(CONTEXTS_COLLECTION).getFullList({
sort: 'displayName,key',
requestKey: null
}).catch(() => []);
const nextContexts = nextContextRecords.map(mapRecordToContext);
setContexts(nextContexts);
setStore("shareRules", nextContexts.map(mapContextToShareRule));
}
};
const syncSupervisedContexts = async () => {
const currentUserId = pb.authStore.model?.id;
if (!currentUserId) {
setStore("supervisedContexts", []);
return;
}
try {
const contextRecords = await pb.collection(CONTEXTS_COLLECTION).getFullList({
sort: 'displayName,key',
requestKey: null
}).catch(() => []);
const contexts = contextRecords.length > 0 ? contextRecords.map(mapRecordToContext) : store.contexts;
if (contextRecords.length > 0) {
setContexts(contexts);
setStore("shareRules", contexts.map(mapContextToShareRule));
}
const nextSupervisedContexts = contexts
.filter(context =>
!context.deletedAt &&
context.kind === "user" &&
!!context.targetUserId &&
context.targetUserId !== currentUserId &&
(context.supervisorUserIds || []).includes(currentUserId)
)
.map((context) => ({
ownerUserId: context.targetUserId as string,
ownerName: context.displayName,
contextId: context.id,
contextName: context.displayName
}))
.sort((a, b) => a.ownerName.localeCompare(b.ownerName));
setStore("supervisedContexts", nextSupervisedContexts);
} catch (err) {
console.error("Failed to load supervised contexts:", err);
}
};
const runPersonalContextMigration = async () => {
const currentUserId = pb.authStore.model?.id;
if (!currentUserId) return;
try {
const userRec = await pb.collection('users').getOne(currentUserId, { requestKey: null });
const rawPrefs = userRec.Taskgrid_pref || {};
const prefs = readScopedPrefs(rawPrefs);
if (prefs.migratedPersonalContextRefsV1) {
return;
}
const contexts = store.contexts.length > 0
? store.contexts
: (await pb.collection(CONTEXTS_COLLECTION).getFullList({ requestKey: null })).map(mapRecordToContext);
const ownerContextByUserId = new Map(
contexts
.filter(context => context.kind === "user" && !!context.targetUserId && !context.deletedAt)
.map(context => [context.targetUserId as string, context])
);
const taskRecords = await pb.collection(TASGRID_COLLECTION).getFullList({
fields: LIST_FIELDS,
requestKey: null
});
for (const taskRecord of taskRecords) {
if (taskRecord.tags?.includes("__template__")) continue;
const task = mapRecordToTask(taskRecord);
if (!task.ownerId) continue;
const ownerContext = ownerContextByUserId.get(task.ownerId);
if (!ownerContext) continue;
const alreadyHasOwnerContext = task.shareRefs.some(ref =>
ref.kind === "user" &&
(ref.contextId === ownerContext.id || ref.key === ownerContext.key)
);
if (alreadyHasOwnerContext) continue;
const favoriteTag = task.tags.find(tag => tag.endsWith("_favorite__"));
const nextShareRefs = [
{
contextId: ownerContext.id,
key: ownerContext.key,
kind: "user" as const
},
...task.shareRefs
];
await pb.collection(TASGRID_COLLECTION).update(task.id, {
shareRefs: nextShareRefs,
tags: buildTaskTags(task.labelTags, nextShareRefs, task.noteRefs, favoriteTag)
}, { requestKey: null });
}
await pb.collection('users').update(currentUserId, {
Taskgrid_pref: writeScopedPrefs(rawPrefs, {
...prefs,
migratedPersonalContextRefsV1: true
})
}, { requestKey: null });
} catch (err) {
console.error("[MIGRATION] Failed personal context backfill:", 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 = readScopedPrefs(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: `${relationFilter("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 = readScopedPrefs(user.Taskgrid_pref || {});
const personalContext = getPersonalContext(userId);
setStore({
pWeight: prefs.pWeight || 1.0,
uWeight: prefs.uWeight || 1.0,
matrixScaleDays: prefs.matrixScaleDays || 30,
prefId: userId,
subscribedBuckets: prefs.subscribedBuckets || user.subscribedBuckets || [],
personalContextSupervisorIds: personalContext?.supervisorUserIds || prefs.supervisorUserIds || [],
tagDefinitions: (prefs.tagDefinitions || []).filter((def: TagDefinition) => !def.name.startsWith("@")),
filterTemplates: prefs.filterTemplates || [],
quickloadTasks: prefs.quickloadTasks || [],
noteFilter: prefs.noteFilter || {
query: "",
tags: [],
priorityMin: 1,
priorityMax: 10,
urgencyMin: 1,
urgencyMax: 10,
editedToday: false,
ownedByMe: false,
starred: false,
jobStatus: [],
jobDivision: []
},
filter: prefs.filter || store.filter
});
}
} catch (prefErr) {
console.warn("Failed to load preferences:", prefErr);
}
// 1.2 Fetch Contexts
try {
const contextRecords = await pb.collection(CONTEXTS_COLLECTION).getFullList({
sort: 'displayName,key',
requestKey: null
});
const contexts = contextRecords.map(mapRecordToContext);
setContexts(contexts);
setStore("shareRules", contexts.map(mapContextToShareRule));
await syncUserContexts();
await syncSupervisedContexts();
const personalContext = store.contexts.find(context =>
!context.deletedAt &&
context.kind === "user" &&
context.targetUserId === pb.authStore.model?.id
);
if (personalContext) {
setStore("personalContextSupervisorIds", personalContext.supervisorUserIds || []);
}
if (personalContext && currentTaskContext() === "mine") {
setCurrentTaskContext({ bucketId: personalContext.id, name: "My Bucket", isPersonal: true });
}
} catch (contextErr) {
console.warn("Failed to load contexts (collection might not exist yet):", contextErr);
}
// 1.3 Fetch Notes
try {
const currentUserId = pb.authStore.model?.id;
const notesRecords = await pb.collection(NOTES_COLLECTION).getFullList({
filter: `isPrivate = false || ${relationFilter("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);
}
setStore("tagDefinitions", defs => {
const plainDefs = defs.filter(def => !def.name.startsWith("@"));
return [...plainDefs, ...deriveSystemTagDefinitions(store.contexts)];
});
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 subscribedBucketTags = getSubscribedBucketContexts()
.map(context => buildShareTag(context.displayName));
const supervisedUserIds = store.supervisedContexts.map(context => context.ownerUserId);
const collaborativeKeys = store.contexts
.filter(context =>
!context.deletedAt &&
context.kind === "user" &&
context.policy === "collaborative" &&
context.targetUserId === currentUserId
)
.map(context => buildShareTag(context.displayName));
// 2a. Fetch ALL Incomplete Tasks (Metadata Only) to ensure availability
const incompletePromises = [
pb.collection(TASGRID_COLLECTION).getFullList({
filter: `${relationFilter("user", currentUserId)} && completed = false`,
sort: '-created',
fields: LIST_FIELDS,
requestKey: null
}),
(subscribedBucketTags.length > 0) ? pb.collection(TASGRID_COLLECTION).getFullList({
filter: `(${subscribedBucketTags.map(tag => `tags ~ "${tag}"`).join(' || ')}) && completed = false`,
sort: '-created',
fields: LIST_FIELDS,
requestKey: null
}).catch(() => []) : Promise.resolve([]),
(collaborativeKeys.length > 0) ? pb.collection(TASGRID_COLLECTION).getFullList({
filter: `(${collaborativeKeys.map(tag => `tags ~ "${tag}"`).join(' || ')}) && completed = false`,
sort: '-created',
fields: LIST_FIELDS,
requestKey: null
}).catch(() => []) : Promise.resolve([]),
(supervisedUserIds.length > 0) ? pb.collection(TASGRID_COLLECTION).getFullList({
filter: `(${supervisedUserIds.map(id => relationFilter("user", id)).join(' || ')}) && completed = false`,
sort: '-created',
fields: LIST_FIELDS,
requestKey: null
}).catch(() => []) : Promise.resolve([])
];
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: `${relationFilter("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();
await runPersonalContextMigration();
// Separate Templates load (less critical)
try {
const templates = await pb.collection(TASGRID_COLLECTION).getFullList({
filter: `${relationFilter("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 {
const user = await pb.collection('users').getOne(userId, { requestKey: null });
const rawPrefs = user.Taskgrid_pref || {};
const scopedPrefs = readScopedPrefs(rawPrefs);
await pb.collection('users').update(userId, {
Taskgrid_pref: writeScopedPrefs(rawPrefs, {
...scopedPrefs,
subscribedBuckets: newSubscribed
})
});
// If we just subscribed, we should fetch the tasks for this bucket immediately
if (!isSubscribed) {
const bucket = store.buckets.find(b => b.id === bucketId);
if (bucket) {
const records = await pb.collection(TASGRID_COLLECTION).getFullList({
filter: `tags ~ "${buildShareTag(bucket.name)}"`,
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 {
const key = normalizeContextKey(name);
await pb.collection(CONTEXTS_COLLECTION).create({
key,
displayName: name,
kind: "bucket",
policy: "handoff",
color,
description
});
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, policy?: "collaborative" | "handoff" }) => {
try {
const payload: any = { ...data };
if (data.name) payload.key = normalizeContextKey(data.name);
if (data.name) payload.displayName = data.name;
await pb.collection(CONTEXTS_COLLECTION).update(id, payload);
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(CONTEXTS_COLLECTION).update(id, {
deletedAt: new Date().toISOString(),
deletedBy: pb.authStore.model?.id || null
});
toast.success("Bucket moved to trash");
} catch (err) {
console.error("Failed to delete bucket:", err);
toast.error("Failed to delete bucket");
}
};
export const updateUserContextPolicy = async (contextId: string, policy: "collaborative" | "handoff") => {
try {
await pb.collection(CONTEXTS_COLLECTION).update(contextId, { policy }, { requestKey: null });
toast.success("User context updated");
} catch (err) {
console.error("Failed to update user context:", err);
toast.error("Failed to update user context");
}
};
export const savePersonalContextSupervisors = async (supervisorUserIds: string[]) => {
const userId = pb.authStore.model?.id;
if (!userId) return;
const uniqueSupervisorIds = [...new Set(supervisorUserIds.filter(Boolean))];
setStore("personalContextSupervisorIds", uniqueSupervisorIds);
try {
await syncUserContexts();
const personalContext = getPersonalContext(userId);
if (!personalContext) {
throw new Error("Missing personal context record");
}
await pb.collection(CONTEXTS_COLLECTION).update(personalContext.id, {
supervisorUserIds: uniqueSupervisorIds
}, { requestKey: null });
const user = await pb.collection('users').getOne(userId, { requestKey: null });
const rawPrefs = user.Taskgrid_pref || {};
const scopedPrefs = readScopedPrefs(rawPrefs);
await pb.collection('users').update(userId, {
Taskgrid_pref: writeScopedPrefs(rawPrefs, {
...scopedPrefs,
supervisorUserIds: uniqueSupervisorIds
})
}, { requestKey: null });
await syncSupervisedContexts();
toast.success("Personal supervision updated");
} catch (err) {
console.error("Failed to update personal supervision:", err);
toast.error("Failed to update personal supervision");
}
};
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 = withActiveContextTags(options?.tags ?? ["work"]);
const content = options?.content ?? "";
const size = options?.size ?? 3;
const parsedTags = parseTags(tags);
const labelTags = parsedTags.filter(tag => tag.kind === "label").map(tag => tag.display);
const shareRefs = parsedTags
.filter(tag => tag.kind === "share")
.map(tag => {
const context = findContextForShareTag(tag);
return {
contextId: context?.id || tag.key,
key: tag.key,
kind: context?.kind || "bucket"
} satisfies TaskShareRef;
});
const noteRefs = parsedTags
.filter(tag => tag.kind === "note")
.map(tag => {
const note = store.notes.find(existing => existing.key === tag.key);
return {
noteId: note?.id || tag.key,
key: tag.key
} satisfies NoteRef;
});
const tempId = "temp-" + Date.now();
const initialTask: Task = {
id: tempId,
title,
startDate,
dueDate,
priority,
status: 0,
completed: false,
tags: buildTaskTags(labelTags, shareRefs, noteRefs),
labelTags,
shareRefs,
noteRefs,
ownerId: pb.authStore.model?.id || "",
createdBy: 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
createdBy: newTask.createdBy,
title,
startDate,
dueDate,
priority,
status: 0,
completed: false,
tags: newTask.tags,
labelTags: newTask.labelTags,
shareRefs: newTask.shareRefs,
noteRefs: newTask.noteRefs,
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,
ownerId: unwrapRelationId(record.user),
createdBy: unwrapRelationId(record.createdBy) || unwrapRelationId(record.user)
} : 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> => {
const currentUserId = pb.authStore.model?.id;
if (!currentUserId || task.ownerId !== currentUserId) return updates;
const refs = updates.shareRefs || task.shareRefs;
for (const ref of refs) {
const context = getContextByRef(ref);
if (!context || context.policy !== "handoff") continue;
if (context.kind === "user" && context.targetUserId) {
const senderPersonalContext = getPersonalContext(currentUserId);
const nextShareRefs = senderPersonalContext
? refs.filter(existingRef => !(
existingRef.kind === "user" &&
(existingRef.contextId === senderPersonalContext.id || existingRef.key === senderPersonalContext.key)
))
: refs;
const labelTags = updates.labelTags || task.labelTags;
const noteRefs = updates.noteRefs || task.noteRefs;
const favoriteTag = (updates.tags || task.tags || []).find(tag => tag.endsWith("_favorite__"));
return {
...updates,
ownerId: context.targetUserId,
shareRefs: nextShareRefs,
tags: buildTaskTags(labelTags, nextShareRefs, noteRefs, favoriteTag)
};
}
}
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) {
if (finalUpdates.tags) {
const parsedTags = parseTags(finalUpdates.tags);
finalUpdates.labelTags = parsedTags.filter(tag => tag.kind === "label").map(tag => tag.display);
finalUpdates.shareRefs = parsedTags
.filter(tag => tag.kind === "share")
.map(tag => {
const context = findContextForShareTag(tag);
return {
contextId: context?.id || tag.key,
key: tag.key,
kind: context?.kind || "bucket"
} satisfies TaskShareRef;
});
finalUpdates.noteRefs = parsedTags
.filter(tag => tag.kind === "note")
.map(tag => {
const note = store.notes.find(existing => existing.key === tag.key);
return {
noteId: note?.id || tag.key,
key: tag.key
} satisfies NoteRef;
});
}
if (finalUpdates.labelTags || finalUpdates.shareRefs || finalUpdates.noteRefs) {
const favoriteTag = currentTask.tags.find(tag => tag.endsWith("_favorite__"));
finalUpdates.tags = buildTaskTags(
finalUpdates.labelTags || currentTask.labelTags,
finalUpdates.shareRefs || currentTask.shareRefs,
finalUpdates.noteRefs || currentTask.noteRefs,
favoriteTag
);
}
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 (finalUpdates.createdBy !== undefined) {
pbUpdates.createdBy = finalUpdates.createdBy;
}
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);
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;
if (name.startsWith("@")) {
toast.error("Shared @tags are managed from contexts.");
return;
}
const existing = store.tagDefinitions.find(d => d.name === name);
const finalTheme = theme || (document.documentElement.classList.contains("dark") ? "dark" : "light");
try {
if (existing) {
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 = {
id: `pref-${normalizeContextKey(name) || Date.now().toString()}`,
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]);
}
await syncPreferences();
} 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 {
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 });
}
await syncPreferences();
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,
key: updates.key || (updates.title ? normalizeNoteKey(updates.title) : undefined),
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;
}
}
if (!pbUpdates.key && updates.title) {
pbUpdates.key = normalizeNoteKey(updates.title);
}
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;
setStore("shareRules", store.contexts.map(mapContextToShareRule));
setStore("tagDefinitions", defs => {
const plainDefs = defs.filter(def => !def.name.startsWith("@"));
return [...plainDefs, ...deriveSystemTagDefinitions(store.contexts)];
});
};
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 {
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 });
}
await syncPreferences();
} 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)
const bucketTags = getSubscribedBucketContexts()
.map(context => buildShareTag(context.displayName));
const supervisedUserIds = store.supervisedContexts.map(context => context.ownerUserId);
const collaborativeTags = store.contexts
.filter(context =>
!context.deletedAt &&
context.kind === "user" &&
context.policy === "collaborative" &&
context.targetUserId === userId
)
.map(context => buildShareTag(context.displayName));
const promises = [
pb.collection(TASGRID_COLLECTION).getFullList({ filter: relationFilter("user", userId), sort: '-created', fields: LIST_FIELDS, requestKey: null })
];
if (bucketTags.length > 0) {
promises.push(pb.collection(TASGRID_COLLECTION).getFullList({
filter: bucketTags.map(tag => `tags ~ "${tag}"`).join(' || '),
sort: '-created',
fields: LIST_FIELDS,
requestKey: null
}).catch(() => []));
}
if (collaborativeTags.length > 0) {
promises.push(pb.collection(TASGRID_COLLECTION).getFullList({
filter: collaborativeTags.map(tag => `tags ~ "${tag}"`).join(' || '),
sort: '-created',
fields: LIST_FIELDS,
requestKey: null
}).catch(() => []));
}
if (supervisedUserIds.length > 0) {
promises.push(pb.collection(TASGRID_COLLECTION).getFullList({
filter: supervisedUserIds.map(id => relationFilter("user", id)).join(' || '),
sort: '-created',
fields: LIST_FIELDS,
requestKey: null
}).catch(() => []));
}
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 user = await pb.collection('users').getOne(userId, { requestKey: null });
const rawPrefs = user.Taskgrid_pref || {};
const prefs = {
pWeight: store.pWeight,
uWeight: store.uWeight,
matrixScaleDays: store.matrixScaleDays,
tagDefinitions: store.tagDefinitions.filter(def => !def.name.startsWith("@")),
filter: store.filter,
filterTemplates: store.filterTemplates,
quickloadTasks: store.quickloadTasks,
noteFilter: store.noteFilter,
subscribedBuckets: store.subscribedBuckets,
supervisorUserIds: store.personalContextSupervisorIds
};
await pb.collection('users').update(userId, {
Taskgrid_pref: writeScopedPrefs(rawPrefs, {
...readScopedPrefs(rawPrefs),
...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;
toast.info("Direct task sharing has been replaced by @user tags.");
};
export const loadTasksForOwner = async (userId: string) => {
if (!pb.authStore.isValid || !userId) return;
try {
const records = await pb.collection(TASGRID_COLLECTION).getFullList({
filter: relationFilter("user", userId),
sort: '-created',
fields: LIST_FIELDS,
requestKey: `owner-context-${userId}`
});
setStore("tasks", (currentTasks) => {
const taskMap = new Map(currentTasks.map(task => [task.id, task]));
records.forEach((record: any) => {
if (record.tags?.includes("__template__")) return;
const mapped = mapRecordToTask(record);
const existing = taskMap.get(mapped.id);
if (existing && existing.content) {
mapped.content = existing.content;
}
taskMap.set(mapped.id, mapped);
});
return Array.from(taskMap.values());
});
} catch (err) {
console.error("Failed to load owner context tasks:", err);
}
};
export const revokeShare = async (_taskId: string, _userId: string) => {
if (!pb.authStore.isValid) return;
toast.info("Remove the matching @user tag from the task instead.");
};
export const splitTask = async (_taskId: string, _userId: string) => {
if (!pb.authStore.isValid) return;
toast.info("Task split is no longer supported. Duplicate the task and retag it.");
};
// -- Share Rules (for tag-based and all-tasks sharing) --
export const loadShareRules = async () => {
if (!pb.authStore.isValid) return;
setStore("shareRules", reconcile(store.contexts.map(mapContextToShareRule)));
};
export const addShareRule = async (type: 'tag' | 'all', targetUserId: string, tagName?: string, share_mode: 'ADD_USER' | 'SEND_TASK' = 'ADD_USER') => {
if (!pb.authStore.isValid) return;
if (type !== 'tag' || !tagName?.startsWith("@")) {
toast.error("Only @tag contexts are supported now.");
return;
}
const existing = store.contexts.find(context => context.targetUserId === targetUserId && buildShareTag(context.displayName) === tagName);
if (existing) return;
try {
await pb.collection(CONTEXTS_COLLECTION).create({
key: normalizeContextKey(tagName),
displayName: tagName.replace(/^@/, ""),
kind: "user",
policy: share_mode === "SEND_TASK" ? "handoff" : "collaborative",
targetUserId
}, { requestKey: null });
toast.success(`Shared context created`);
} catch (err) {
console.error("Failed to create shared context:", err);
toast.error("Failed to create shared context.");
}
};
export const updateShareRule = async (ruleId: string, updates: Partial<ShareRule>) => {
if (!pb.authStore.isValid) return;
try {
const context = store.contexts.find(existing => existing.id === ruleId);
if (!context) return;
await pb.collection(CONTEXTS_COLLECTION).update(ruleId, {
policy: updates.share_mode === "SEND_TASK" ? "handoff" : "collaborative"
}, { requestKey: null });
toast.success("Context 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(CONTEXTS_COLLECTION).delete(ruleId);
toast.success("Context removed");
} catch (err) {
console.error("Failed to delete share rule:", err);
toast.error("Failed to delete share rule.");
}
};
export const loadFilterTemplates = async () => {
if (!pb.authStore.isValid) return;
const userId = pb.authStore.model?.id;
if (!userId) return;
const user = await pb.collection('users').getOne(userId, { requestKey: null });
const prefs = readScopedPrefs(user.Taskgrid_pref || {});
setStore("filterTemplates", prefs.filterTemplates || []);
};
export const saveFilterTemplate = async (name: string) => {
if (!pb.authStore.isValid) return;
try {
const newTemplate: FilterTemplate = {
id: `filter-${Date.now()}`,
name,
filter: store.filter
};
setStore("filterTemplates", prev => [...prev, newTemplate]);
await syncPreferences();
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 {
setStore("filterTemplates", prev => prev.filter(t => t.id !== id));
await syncPreferences();
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,
ownedByMe: false,
starred: false,
jobStatus: [],
jobDivision: []
});
};
export const setNoteFilter = (update: Partial<Filter>) => {
setStore("noteFilter", (f) => ({ ...f, ...update }));
syncPreferences();
};
export const loadNoteFilterTemplates = async () => {
if (!pb.authStore.isValid) return;
const userId = pb.authStore.model?.id;
if (!userId) return;
const user = await pb.collection('users').getOne(userId, { requestKey: null });
const prefs = readScopedPrefs(user.Taskgrid_pref || {});
setStore("filterTemplates", prefs.noteFilterTemplates || []);
};
export const saveNoteFilterTemplate = async (name: string) => {
if (!pb.authStore.isValid) return;
try {
const newTemplate: FilterTemplate = {
id: `note-filter-${Date.now()}`,
name,
filter: store.noteFilter
};
setStore("filterTemplates", prev => [...prev, newTemplate]);
const userId = pb.authStore.model?.id;
if (userId) {
const user = await pb.collection('users').getOne(userId, { requestKey: null });
const rawPrefs = user.Taskgrid_pref || {};
const prefs = readScopedPrefs(rawPrefs);
await pb.collection('users').update(userId, {
Taskgrid_pref: writeScopedPrefs(rawPrefs, {
...prefs,
noteFilterTemplates: [...(prefs.noteFilterTemplates || []), newTemplate]
})
}, { requestKey: null });
}
toast.success(`Note filter template "${name}" saved`);
} catch (err) {
console.error("Failed to save note filter template:", err);
toast.error("Failed to save note filter template.");
}
};
export const applyNoteFilterTemplate = (id: string) => {
const template = store.filterTemplates.find(t => t.id === id);
if (template) {
setNoteFilter(template.filter);
toast.success(`Applied note filter: ${template.name}`);
}
};
export const removeNoteFilterTemplate = async (id: string) => {
if (!pb.authStore.isValid) return;
try {
setStore("filterTemplates", prev => prev.filter(t => t.id !== id));
const userId = pb.authStore.model?.id;
if (userId) {
const user = await pb.collection('users').getOne(userId, { requestKey: null });
const rawPrefs = user.Taskgrid_pref || {};
const prefs = readScopedPrefs(rawPrefs);
await pb.collection('users').update(userId, {
Taskgrid_pref: writeScopedPrefs(rawPrefs, {
...prefs,
noteFilterTemplates: (prefs.noteFilterTemplates || []).filter((template: FilterTemplate) => template.id !== id)
})
}, { requestKey: null });
}
toast.success("Note filter template deleted");
} catch (err) {
console.error("Failed to delete note filter template:", err);
toast.error("Failed to delete note filter template.");
}
};
export const clearNoteFilter = () => {
setStore("noteFilter", {
query: "",
tags: [],
priorityMin: 1,
priorityMax: 10,
urgencyMin: 1,
urgencyMax: 10,
editedToday: false,
ownedByMe: false,
starred: false,
jobStatus: [],
jobDivision: []
});
syncPreferences();
};
// 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();
};