3301 lines
121 KiB
TypeScript
3301 lines
121 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, TASGRID_IS_DEV_DATA, PROD_MIGRATION_ADMIN_EMAILS, PROD_MIGRATION_ENABLED } 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 NOTE_LIST_FIELDS = 'id,created,updated,title,key,tags,isPrivate,user,tasks,deletedAt';
|
|
|
|
const getStorageKey = () => {
|
|
const userId = pb.authStore.model?.id;
|
|
return userId ? `${STORAGE_KEY_PREFIX}_data_${userId}` : null;
|
|
};
|
|
|
|
const getPersistedStoreSnapshot = () => {
|
|
const { isInitializing, quickloadFadeTaskIds, isNotesLoading, hasLoadedNotesMetadata, loadingNoteContentIds, ...persisted } = store;
|
|
return persisted;
|
|
};
|
|
|
|
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;
|
|
quickloadFadeTaskIds: string[];
|
|
isNotesLoading: boolean;
|
|
hasLoadedNotesMetadata: boolean;
|
|
loadingNoteContentIds: string[];
|
|
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,
|
|
quickloadFadeTaskIds: [],
|
|
isNotesLoading: false,
|
|
hasLoadedNotesMetadata: false,
|
|
loadingNoteContentIds: [],
|
|
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);
|
|
if (context?.displayName) {
|
|
return buildShareTag(context.displayName);
|
|
}
|
|
if (ref.kind === "user" && ref.key.startsWith("user-")) {
|
|
return null;
|
|
}
|
|
return buildShareTag(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((tag): tag is string => !!tag))];
|
|
};
|
|
|
|
const dedupeShareRefs = (refs: TaskShareRef[]) => {
|
|
const seen = new Set<string>();
|
|
return refs.filter(ref => {
|
|
const key = `${ref.kind}:${ref.contextId}:${ref.key}`;
|
|
if (seen.has(key)) return false;
|
|
seen.add(key);
|
|
return true;
|
|
});
|
|
};
|
|
|
|
const dedupeNoteRefs = (refs: NoteRef[]) => {
|
|
const seen = new Set<string>();
|
|
return refs.filter(ref => {
|
|
const key = `${ref.noteId}:${ref.key}`;
|
|
if (seen.has(key)) return false;
|
|
seen.add(key);
|
|
return true;
|
|
});
|
|
};
|
|
|
|
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)));
|
|
setStore("tasks", tasks => tasks.map(task => {
|
|
const favoriteTag = task.tags.find(tag => tag.endsWith("_favorite__"));
|
|
return {
|
|
...task,
|
|
tags: buildTaskTags(task.labelTags, task.shareRefs, task.noteRefs, favoriteTag)
|
|
};
|
|
}));
|
|
|
|
const labelDefinitions = store.tagDefinitions.filter(def => !def.name.startsWith("@"));
|
|
const mergedDefs = [...labelDefinitions, ...deriveSystemTagDefinitions(contexts)];
|
|
setStore("tagDefinitions", reconcile(mergedDefs));
|
|
};
|
|
|
|
const loadContextsIntoStore = async () => {
|
|
const contextRecords = await pb.collection(CONTEXTS_COLLECTION).getFullList({
|
|
sort: 'displayName,key',
|
|
requestKey: null
|
|
}).catch(() => []);
|
|
const nextContexts = contextRecords.map(mapRecordToContext);
|
|
setContexts(nextContexts);
|
|
setStore("shareRules", nextContexts.map(mapContextToShareRule));
|
|
return nextContexts;
|
|
};
|
|
|
|
const getPersonalContext = (userId = pb.authStore.model?.id) =>
|
|
userId
|
|
? store.contexts.find(context =>
|
|
!context.deletedAt &&
|
|
context.kind === "user" &&
|
|
context.targetUserId === userId
|
|
) || null
|
|
: null;
|
|
|
|
export const isCurrentUserContextTag = (tag: string) => {
|
|
const normalizedTag = tag.trim().toLowerCase();
|
|
if (!normalizedTag.startsWith("@")) return false;
|
|
|
|
const personalContext = getPersonalContext();
|
|
const currentUserDisplayName = (pb.authStore.model?.name || pb.authStore.model?.email || "").trim();
|
|
const hiddenTags = new Set<string>();
|
|
|
|
if (personalContext?.displayName) {
|
|
hiddenTags.add(buildShareTag(personalContext.displayName).toLowerCase());
|
|
}
|
|
|
|
if (currentUserDisplayName) {
|
|
hiddenTags.add(buildShareTag(currentUserDisplayName).toLowerCase());
|
|
}
|
|
|
|
return hiddenTags.has(normalizedTag);
|
|
};
|
|
|
|
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 ||
|
|
normalizeContextKey(personalContext.displayName) === ref.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 {
|
|
const targetUserId = (ctx as { userId: string }).userId;
|
|
const targetPersonalContext = getPersonalContext(targetUserId);
|
|
const inTargetPersonalContext = !!targetPersonalContext && task.shareRefs.some(ref =>
|
|
ref.kind === "user" &&
|
|
(
|
|
ref.contextId === targetPersonalContext.id ||
|
|
ref.key === targetPersonalContext.key ||
|
|
normalizeContextKey(targetPersonalContext.displayName) === ref.key
|
|
)
|
|
);
|
|
|
|
if (targetPersonalContext) {
|
|
if (!inTargetPersonalContext) return false;
|
|
} else if (task.ownerId !== targetUserId) {
|
|
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(getPersistedStoreSnapshot()));
|
|
}
|
|
});
|
|
|
|
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;
|
|
let quickloadFadeTimer: number | undefined;
|
|
let notesLoadPromise: Promise<void> | null = null;
|
|
let deferredNotesLoadTimer: number | undefined;
|
|
const loadedNoteDetailIds = new Set<string>();
|
|
const failedNoteDetailIds = new Set<string>();
|
|
|
|
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 || []
|
|
};
|
|
};
|
|
|
|
const buildNoteLinkMap = (notes: Note[]) => {
|
|
const links = new Map<string, NoteRef[]>();
|
|
for (const note of notes) {
|
|
for (const taskId of note.tasks || []) {
|
|
const existing = links.get(taskId) || [];
|
|
existing.push({ noteId: note.id, key: note.key });
|
|
links.set(taskId, existing);
|
|
}
|
|
}
|
|
return links;
|
|
};
|
|
|
|
const buildTaskMigrationPayload = (
|
|
task: Task,
|
|
ownerContextByUserId: Map<string, ShareContext>,
|
|
linkedNoteRefs: NoteRef[] = [],
|
|
fallbackUserId?: string
|
|
) => {
|
|
const favoriteTag = task.tags.find(tag => tag.endsWith("_favorite__"));
|
|
const ownerContext = task.ownerId ? ownerContextByUserId.get(task.ownerId) : undefined;
|
|
let nextShareRefs = dedupeShareRefs(task.shareRefs);
|
|
let nextNoteRefs = dedupeNoteRefs([...task.noteRefs, ...linkedNoteRefs]);
|
|
const hasHandoffBucket = nextShareRefs.some(ref => {
|
|
const context = getContextByRef(ref);
|
|
return context?.kind === "bucket" && context.policy === "handoff";
|
|
});
|
|
|
|
if (ownerContext) {
|
|
const matchesOwnerContext = (ref: TaskShareRef) =>
|
|
ref.kind === "user" && (
|
|
ref.contextId === ownerContext.id ||
|
|
ref.key === ownerContext.key ||
|
|
normalizeContextKey(ownerContext.displayName) === ref.key
|
|
);
|
|
|
|
if (hasHandoffBucket) {
|
|
nextShareRefs = nextShareRefs.filter(ref => !matchesOwnerContext(ref));
|
|
} else if (!nextShareRefs.some(matchesOwnerContext)) {
|
|
nextShareRefs = [
|
|
{
|
|
contextId: ownerContext.id,
|
|
key: ownerContext.key,
|
|
kind: "user" as const
|
|
},
|
|
...nextShareRefs
|
|
];
|
|
}
|
|
}
|
|
|
|
nextShareRefs = dedupeShareRefs(nextShareRefs);
|
|
|
|
return {
|
|
tags: buildTaskTags(task.labelTags, nextShareRefs, nextNoteRefs, favoriteTag),
|
|
labelTags: task.labelTags,
|
|
shareRefs: nextShareRefs,
|
|
noteRefs: nextNoteRefs,
|
|
createdBy: task.createdBy || task.ownerId || fallbackUserId || pb.authStore.model?.id || ""
|
|
};
|
|
};
|
|
|
|
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) {
|
|
if ((err as any)?.status === 404) return;
|
|
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, includeContent = true): Note => {
|
|
return {
|
|
id: r.id,
|
|
title: r.title,
|
|
key: r.key || normalizeNoteKey(r.title),
|
|
content: includeContent ? r.content : undefined,
|
|
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
|
|
};
|
|
};
|
|
|
|
const reconcileFetchedNotes = (records: any[], includeContent = false) => {
|
|
const existingById = new Map(store.notes.map(note => [note.id, note]));
|
|
const nextNotes = records.map((record: any) => {
|
|
const mapped = mapRecordToNote(record, includeContent);
|
|
const existing = existingById.get(mapped.id);
|
|
|
|
if (!includeContent && existing?.content !== undefined) {
|
|
mapped.content = existing.content;
|
|
}
|
|
|
|
return mapped;
|
|
});
|
|
|
|
setStore("notes", nextNotes);
|
|
};
|
|
|
|
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) => {
|
|
failedNoteDetailIds.delete(note.id);
|
|
if (note.content !== undefined) {
|
|
loadedNoteDetailIds.add(note.id);
|
|
}
|
|
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];
|
|
});
|
|
};
|
|
|
|
export const loadNotesMetadata = async () => {
|
|
if (!pb.authStore.isValid) return;
|
|
if (store.hasLoadedNotesMetadata) return;
|
|
if (notesLoadPromise) return notesLoadPromise;
|
|
|
|
const currentUserId = pb.authStore.model?.id;
|
|
setStore("isNotesLoading", true);
|
|
|
|
notesLoadPromise = (async () => {
|
|
try {
|
|
const notesRecords = await pb.collection(NOTES_COLLECTION).getFullList({
|
|
filter: `isPrivate = false || ${relationFilter("user", currentUserId)}`,
|
|
sort: '-created',
|
|
fields: NOTE_LIST_FIELDS,
|
|
requestKey: null
|
|
});
|
|
|
|
reconcileFetchedNotes(notesRecords, false);
|
|
setStore("hasLoadedNotesMetadata", true);
|
|
} catch (notesErr) {
|
|
console.warn("Failed to load notes (collection might not exist yet):", notesErr);
|
|
} finally {
|
|
setStore("isNotesLoading", false);
|
|
notesLoadPromise = null;
|
|
}
|
|
})();
|
|
|
|
return notesLoadPromise;
|
|
};
|
|
|
|
export const scheduleDeferredNotesMetadataLoad = () => {
|
|
if (!pb.authStore.isValid || store.hasLoadedNotesMetadata || notesLoadPromise || deferredNotesLoadTimer) return;
|
|
|
|
deferredNotesLoadTimer = window.setTimeout(() => {
|
|
deferredNotesLoadTimer = undefined;
|
|
void loadNotesMetadata();
|
|
}, 0);
|
|
};
|
|
|
|
export const requestImmediateNotesMetadataLoad = async () => {
|
|
if (deferredNotesLoadTimer) {
|
|
clearTimeout(deferredNotesLoadTimer);
|
|
deferredNotesLoadTimer = undefined;
|
|
}
|
|
|
|
await loadNotesMetadata();
|
|
};
|
|
|
|
export const loadNoteContent = async (id: string) => {
|
|
if (!id) return;
|
|
if (loadedNoteDetailIds.has(id)) return;
|
|
if (failedNoteDetailIds.has(id)) return;
|
|
if (store.loadingNoteContentIds.includes(id)) return;
|
|
|
|
setStore("loadingNoteContentIds", ids => ids.includes(id) ? ids : [...ids, id]);
|
|
|
|
try {
|
|
const currentUserId = pb.authStore.model?.id;
|
|
const visibilityFilter = `id = "${id}" && (isPrivate = false || ${relationFilter("user", currentUserId)})`;
|
|
const record = await pb.collection(NOTES_COLLECTION).getFirstListItem(visibilityFilter, {
|
|
requestKey: `note-content-${id}`
|
|
});
|
|
const note = mapRecordToNote(record, true);
|
|
updateStoreWithNote(note);
|
|
loadedNoteDetailIds.add(id);
|
|
} catch (err) {
|
|
const status = (err as any)?.status;
|
|
if (status === 404) {
|
|
failedNoteDetailIds.add(id);
|
|
return;
|
|
}
|
|
console.error("Failed to load note content:", err);
|
|
} finally {
|
|
setStore("loadingNoteContentIds", ids => ids.filter(existingId => existingId !== id));
|
|
}
|
|
};
|
|
|
|
|
|
// 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 shouldSyncContent = loadedNoteDetailIds.has(e.record.id) || activeNoteId() === e.record.id;
|
|
const updatedNote = mapRecordToNote(e.record, shouldSyncContent);
|
|
setStore("notes", prev => {
|
|
const exists = prev.find(n => n.id === updatedNote.id);
|
|
if (exists) {
|
|
const mergedNote = shouldSyncContent || exists.content === undefined
|
|
? updatedNote
|
|
: { ...updatedNote, content: exists.content };
|
|
return prev.map(n => n.id === updatedNote.id ? mergedNote : 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 (force = false) => {
|
|
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 (!force && prefs.migratedStructuredTagsV2) {
|
|
return; // Already migrated
|
|
}
|
|
|
|
console.log("[MIGRATION] Starting structured tag migration...");
|
|
|
|
await syncUserContexts();
|
|
const contexts = await loadContextsIntoStore();
|
|
const ownerContextByUserId = new Map(
|
|
contexts
|
|
.filter(context => context.kind === "user" && !!context.targetUserId && !context.deletedAt)
|
|
.map(context => [context.targetUserId as string, context])
|
|
);
|
|
|
|
const notes = await pb.collection(NOTES_COLLECTION).getFullList({
|
|
requestKey: null
|
|
});
|
|
const migratedNotes: Note[] = [];
|
|
for (const noteRecord of notes) {
|
|
const key = normalizeNoteKey(noteRecord.key || noteRecord.title || noteRecord.id);
|
|
const existingTags = Array.isArray(noteRecord.tags) ? noteRecord.tags.filter(Boolean) : [];
|
|
const nextTags = [...new Set([buildNoteTag(key), ...existingTags])];
|
|
const nextTasks = [...new Set((Array.isArray(noteRecord.tasks) ? noteRecord.tasks : []).filter(Boolean))];
|
|
|
|
await pb.collection(NOTES_COLLECTION).update(noteRecord.id, {
|
|
key,
|
|
tags: nextTags,
|
|
tasks: nextTasks
|
|
}, { requestKey: null });
|
|
|
|
migratedNotes.push({
|
|
...mapRecordToNote(noteRecord),
|
|
key,
|
|
tags: nextTags,
|
|
tasks: nextTasks
|
|
});
|
|
}
|
|
setStore("notes", migratedNotes);
|
|
const noteLinksByTaskId = buildNoteLinkMap(migratedNotes);
|
|
|
|
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);
|
|
const payload = buildTaskMigrationPayload(
|
|
task,
|
|
ownerContextByUserId,
|
|
noteLinksByTaskId.get(task.id) || [],
|
|
currentUserId
|
|
);
|
|
await pb.collection(TASGRID_COLLECTION).update(task.id, payload, { 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) {
|
|
await loadContextsIntoStore();
|
|
}
|
|
};
|
|
|
|
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 (force = false) => {
|
|
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 (!force && prefs.migratedPersonalContextRefsV1) {
|
|
return;
|
|
}
|
|
|
|
const contexts = store.contexts.length > 0
|
|
? store.contexts
|
|
: await loadContextsIntoStore();
|
|
const ownerContextByUserId = new Map(
|
|
contexts
|
|
.filter(context => context.kind === "user" && !!context.targetUserId && !context.deletedAt)
|
|
.map(context => [context.targetUserId as string, context])
|
|
);
|
|
const noteLinksByTaskId = buildNoteLinkMap(store.notes);
|
|
|
|
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 payload = buildTaskMigrationPayload(
|
|
task,
|
|
ownerContextByUserId,
|
|
noteLinksByTaskId.get(task.id) || [],
|
|
currentUserId
|
|
);
|
|
|
|
const sameShareRefs = JSON.stringify(payload.shareRefs) === JSON.stringify(task.shareRefs);
|
|
const sameNoteRefs = JSON.stringify(payload.noteRefs) === JSON.stringify(task.noteRefs);
|
|
const sameTags = JSON.stringify(payload.tags) === JSON.stringify(task.tags);
|
|
if (sameShareRefs && sameNoteRefs && sameTags && payload.createdBy === task.createdBy) continue;
|
|
|
|
await pb.collection(TASGRID_COLLECTION).update(task.id, payload, { 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 runDevDataMigration = async () => {
|
|
if (!pb.authStore.isValid) return;
|
|
if (!TASGRID_IS_DEV_DATA) {
|
|
toast.error("Dev migration is only available in dev data mode.");
|
|
return;
|
|
}
|
|
|
|
await toast.promise((async () => {
|
|
await runLegacyTagMigration(true);
|
|
await runPersonalContextMigration(true);
|
|
await loadContextsIntoStore();
|
|
await syncSupervisedContexts();
|
|
return "Dev data migration completed.";
|
|
})(), {
|
|
loading: "Running dev data migration...",
|
|
success: (message) => message,
|
|
error: "Dev data migration failed."
|
|
});
|
|
};
|
|
|
|
export const canRunProdMigration = () => {
|
|
if (TASGRID_IS_DEV_DATA || !PROD_MIGRATION_ENABLED) return false;
|
|
const email = (pb.authStore.model?.email || "").trim().toLowerCase();
|
|
return !!email && PROD_MIGRATION_ADMIN_EMAILS.includes(email);
|
|
};
|
|
|
|
export const runProdDataMigration = async () => {
|
|
if (!pb.authStore.isValid) return;
|
|
if (!canRunProdMigration()) {
|
|
toast.error("Prod migration is not enabled for this account.");
|
|
return;
|
|
}
|
|
|
|
await toast.promise((async () => {
|
|
await runLegacyTagMigration(true);
|
|
await runPersonalContextMigration(true);
|
|
await loadContextsIntoStore();
|
|
await syncSupervisedContexts();
|
|
return "Production data migration completed.";
|
|
})(), {
|
|
loading: "Running production data migration...",
|
|
success: (message) => message,
|
|
error: "Production data migration failed."
|
|
});
|
|
};
|
|
|
|
|
|
export const initStore = async () => {
|
|
if (!pb.authStore.isValid || store.isInitializing) return;
|
|
setStore("isInitializing", true);
|
|
|
|
const currentUserId = pb.authStore.model?.id;
|
|
const refreshedTaskIds = new Set<string>();
|
|
|
|
// 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);
|
|
setStore("quickloadFadeTaskIds", focusTasks.map(task => task.id));
|
|
focusTasks.forEach(task => refreshedTaskIds.add(task.id));
|
|
if (quickloadFadeTimer) clearTimeout(quickloadFadeTimer);
|
|
quickloadFadeTimer = window.setTimeout(() => {
|
|
setStore("quickloadFadeTaskIds", []);
|
|
quickloadFadeTimer = undefined;
|
|
}, 200);
|
|
} 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 {
|
|
const parsed = JSON.parse(saved);
|
|
const cachedTasks = Array.isArray(parsed?.tasks) ? parsed.tasks : [];
|
|
const mergedCachedTasks = [
|
|
...store.tasks,
|
|
...cachedTasks.filter((task: Task) => !refreshedTaskIds.has(task.id))
|
|
];
|
|
setStore({
|
|
...parsed,
|
|
tasks: mergedCachedTasks,
|
|
isInitializing: true
|
|
});
|
|
} 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);
|
|
}
|
|
|
|
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;
|
|
if (refreshedTaskIds.has(r.id)) 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);
|
|
refreshedTaskIds.add(r.id);
|
|
});
|
|
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 && !refreshedTaskIds.has(t.id))
|
|
.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__" || tagName.startsWith("@") || tagName.startsWith("#")) 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 automatically only in dev data mode.
|
|
if (TASGRID_IS_DEV_DATA) {
|
|
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();
|
|
|
|
// Defer note metadata sync until task loading is fully settled.
|
|
scheduleDeferredNotesMetadataLoad();
|
|
} 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;
|
|
const senderPersonalContext = getPersonalContext(currentUserId);
|
|
const stripSenderPersonalContext = (inputRefs: TaskShareRef[]) =>
|
|
senderPersonalContext
|
|
? inputRefs.filter(existingRef => !(
|
|
existingRef.kind === "user" &&
|
|
(existingRef.contextId === senderPersonalContext.id || existingRef.key === senderPersonalContext.key)
|
|
))
|
|
: inputRefs;
|
|
|
|
for (const ref of refs) {
|
|
const context = getContextByRef(ref);
|
|
if (!context || context.policy !== "handoff") continue;
|
|
const nextShareRefs = stripSenderPersonalContext(refs);
|
|
const labelTags = updates.labelTags || task.labelTags;
|
|
const noteRefs = updates.noteRefs || task.noteRefs;
|
|
const favoriteTag = (updates.tags || task.tags || []).find(tag => tag.endsWith("_favorite__"));
|
|
|
|
if (context.kind === "user" && context.targetUserId) {
|
|
return {
|
|
...updates,
|
|
ownerId: context.targetUserId,
|
|
shareRefs: nextShareRefs,
|
|
tags: buildTaskTags(labelTags, nextShareRefs, noteRefs, favoriteTag)
|
|
};
|
|
}
|
|
|
|
if (context.kind === "bucket") {
|
|
return {
|
|
...updates,
|
|
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;
|
|
|
|
if (updates.content !== undefined) {
|
|
loadedNoteDetailIds.add(id);
|
|
}
|
|
|
|
// 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 targetPersonalContext = getPersonalContext(userId);
|
|
const filter = targetPersonalContext
|
|
? `tags ~ "${buildShareTag(targetPersonalContext.displayName)}"`
|
|
: relationFilter("user", userId);
|
|
const records = await pb.collection(TASGRID_COLLECTION).getFullList({
|
|
filter,
|
|
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();
|
|
};
|
|
|
|
|