added UPDATED indicators and task collapsing
This commit is contained in:
+160
-18
@@ -12,8 +12,12 @@ export const getFavoriteTag = () => {
|
||||
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 BASE_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';
|
||||
let updatedByFieldSupport: boolean | null = null;
|
||||
|
||||
const getTaskListFields = () =>
|
||||
updatedByFieldSupport ? `${BASE_LIST_FIELDS},updatedBy` : BASE_LIST_FIELDS;
|
||||
|
||||
const getStorageKey = () => {
|
||||
const userId = pb.authStore.model?.id;
|
||||
@@ -48,6 +52,7 @@ export interface Task {
|
||||
noteRefs: NoteRef[];
|
||||
ownerId: string | null; // Add ownerId to interface
|
||||
createdBy: string | null;
|
||||
updatedBy?: 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
|
||||
@@ -215,7 +220,7 @@ export const startContextPolling = () => {
|
||||
const records = await pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
filter: `${relationFilter("user", personalOrUserId)} && updated >= "${timeStr}"`,
|
||||
sort: '-updated',
|
||||
fields: LIST_FIELDS,
|
||||
fields: getTaskListFields(),
|
||||
requestKey: 'context-poll'
|
||||
});
|
||||
|
||||
@@ -277,6 +282,8 @@ interface ScopedTaskgridPrefs {
|
||||
noteFilter?: Filter;
|
||||
subscribedBuckets?: string[];
|
||||
supervisorUserIds?: string[];
|
||||
dismissedTaskUpdateIndicators?: Record<string, string>;
|
||||
collapsedUntilUpdatedTasks?: Record<string, string>;
|
||||
migratedStructuredTagsV2?: boolean;
|
||||
migratedPersonalContextRefsV1?: boolean;
|
||||
}
|
||||
@@ -402,6 +409,8 @@ interface TaskStore {
|
||||
tasks: Task[];
|
||||
isInitializing: boolean;
|
||||
quickloadFadeTaskIds: string[];
|
||||
dismissedTaskUpdateIndicators: Record<string, string>;
|
||||
collapsedUntilUpdatedTasks: Record<string, string>;
|
||||
isNotesLoading: boolean;
|
||||
hasLoadedNotesMetadata: boolean;
|
||||
loadingNoteContentIds: string[];
|
||||
@@ -430,6 +439,8 @@ export const [store, setStore] = createStore<TaskStore>({
|
||||
tasks: [],
|
||||
isInitializing: false,
|
||||
quickloadFadeTaskIds: [],
|
||||
dismissedTaskUpdateIndicators: {},
|
||||
collapsedUntilUpdatedTasks: {},
|
||||
isNotesLoading: false,
|
||||
hasLoadedNotesMetadata: false,
|
||||
loadingNoteContentIds: [],
|
||||
@@ -576,6 +587,80 @@ const dedupeNoteRefs = (refs: NoteRef[]) => {
|
||||
});
|
||||
};
|
||||
|
||||
const recordHasField = (record: any, field: string) =>
|
||||
!!record && Object.prototype.hasOwnProperty.call(record, field);
|
||||
|
||||
const detectUpdatedBySupportFromRecord = (record: any) => {
|
||||
if (updatedByFieldSupport !== true && recordHasField(record, "updatedBy")) {
|
||||
updatedByFieldSupport = true;
|
||||
}
|
||||
};
|
||||
|
||||
const refreshLoadedTaskUpdatedBy = async () => {
|
||||
if (updatedByFieldSupport !== true || !pb.authStore.isValid) return;
|
||||
|
||||
const taskIds = store.tasks
|
||||
.map(task => task.id)
|
||||
.filter(id => !!id && !id.startsWith("temp-"));
|
||||
|
||||
if (taskIds.length === 0) return;
|
||||
|
||||
const chunkSize = 50;
|
||||
for (let i = 0; i < taskIds.length; i += chunkSize) {
|
||||
const ids = taskIds.slice(i, i + chunkSize);
|
||||
try {
|
||||
const records = await pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
filter: ids.map(id => `id="${id}"`).join(' || '),
|
||||
fields: 'id,updated,updatedBy',
|
||||
requestKey: null
|
||||
});
|
||||
|
||||
const updatesById = new Map(
|
||||
records.map((record: any) => {
|
||||
detectUpdatedBySupportFromRecord(record);
|
||||
return [
|
||||
record.id,
|
||||
{
|
||||
updated: record.updated,
|
||||
updatedBy: unwrapRelationId(record.updatedBy)
|
||||
}
|
||||
] as const;
|
||||
})
|
||||
);
|
||||
|
||||
setStore("tasks", tasks => tasks.map(task => {
|
||||
const update = updatesById.get(task.id);
|
||||
return update ? { ...task, ...update } : task;
|
||||
}));
|
||||
} catch (err) {
|
||||
console.warn("Failed to refresh task update actors:", err);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const ensureUpdatedByFieldSupport = async () => {
|
||||
if (updatedByFieldSupport !== null || !pb.authStore.isValid) return updatedByFieldSupport;
|
||||
|
||||
const sampleTaskId = store.tasks.find(task => !task.id.startsWith("temp-"))?.id;
|
||||
if (!sampleTaskId) return updatedByFieldSupport;
|
||||
|
||||
try {
|
||||
const record = await pb.collection(TASGRID_COLLECTION).getOne(sampleTaskId, { requestKey: null });
|
||||
updatedByFieldSupport = recordHasField(record, "updatedBy");
|
||||
detectUpdatedBySupportFromRecord(record);
|
||||
|
||||
if (updatedByFieldSupport) {
|
||||
await refreshLoadedTaskUpdatedBy();
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Failed to detect task update actor support:", err);
|
||||
updatedByFieldSupport = false;
|
||||
}
|
||||
|
||||
return updatedByFieldSupport;
|
||||
};
|
||||
|
||||
const withActiveContextTags = (rawTags: string[]) => {
|
||||
const nextTags = [...rawTags];
|
||||
const ctx = currentTaskContext();
|
||||
@@ -807,6 +892,37 @@ export const matchesFilter = (task: Task) => {
|
||||
return true;
|
||||
};
|
||||
|
||||
export const isTaskUpdatedByAnotherUser = (task: Task) => {
|
||||
const currentUserId = pb.authStore.model?.id;
|
||||
if (!currentUserId || !task.updatedBy || task.updatedBy === currentUserId) return false;
|
||||
return store.dismissedTaskUpdateIndicators[task.id] !== task.updated;
|
||||
};
|
||||
|
||||
export const dismissTaskUpdateIndicator = (taskId: string, updatedAt: string) => {
|
||||
setStore("dismissedTaskUpdateIndicators", taskId, updatedAt);
|
||||
void syncPreferences();
|
||||
};
|
||||
|
||||
export const isTaskCollaborativelyShared = (task: Task) =>
|
||||
task.shareRefs.some(ref => {
|
||||
const context = getContextByRef(ref);
|
||||
const policy = context?.policy || (ref.kind === "user" ? "collaborative" : "handoff");
|
||||
return policy === "collaborative";
|
||||
});
|
||||
|
||||
export const isTaskCollapsedUntilUpdated = (task: Task) =>
|
||||
store.collapsedUntilUpdatedTasks[task.id] === task.updated;
|
||||
|
||||
export const collapseTaskUntilUpdated = (taskId: string, updatedAt: string) => {
|
||||
setStore("collapsedUntilUpdatedTasks", taskId, updatedAt);
|
||||
void syncPreferences();
|
||||
};
|
||||
|
||||
export const expandCollapsedTask = (taskId: string) => {
|
||||
setStore("collapsedUntilUpdatedTasks", taskId, "");
|
||||
void syncPreferences();
|
||||
};
|
||||
|
||||
// Auto-persist changes to localStorage (wrapped in root to avoid console warning)
|
||||
createRoot(() => {
|
||||
createEffect(() => {
|
||||
@@ -936,6 +1052,7 @@ const loadedNoteDetailIds = new Set<string>();
|
||||
const failedNoteDetailIds = new Set<string>();
|
||||
|
||||
const mapRecordToTask = (r: any): Task => {
|
||||
detectUpdatedBySupportFromRecord(r);
|
||||
const legacyTags: string[] = r.tags || [];
|
||||
const parsedTags = parseTags(legacyTags).filter(tag => tag.raw !== "__template__");
|
||||
const shareRefs: TaskShareRef[] = Array.isArray(r.shareRefs)
|
||||
@@ -980,6 +1097,9 @@ const mapRecordToTask = (r: any): Task => {
|
||||
noteRefs,
|
||||
ownerId: unwrapRelationId(r.user),
|
||||
createdBy: unwrapRelationId(r.createdBy) || unwrapRelationId(r.user),
|
||||
updatedBy: recordHasField(r, "updatedBy")
|
||||
? (unwrapRelationId(r.updatedBy) || (r.created === r.updated ? (unwrapRelationId(r.createdBy) || unwrapRelationId(r.user)) : null))
|
||||
: null,
|
||||
content: r.content,
|
||||
deletedAt: r.deletedAt ? new Date(r.deletedAt).getTime() : undefined,
|
||||
created: r.created,
|
||||
@@ -1670,7 +1790,7 @@ const runPersonalContextMigration = async (force = false) => {
|
||||
const noteLinksByTaskId = buildNoteLinkMap(store.notes);
|
||||
|
||||
const taskRecords = await pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
fields: LIST_FIELDS,
|
||||
fields: getTaskListFields(),
|
||||
requestKey: null
|
||||
});
|
||||
|
||||
@@ -1852,6 +1972,8 @@ export const initStore = async () => {
|
||||
tagDefinitions: (prefs.tagDefinitions || []).filter((def: TagDefinition) => !def.name.startsWith("@")),
|
||||
filterTemplates: prefs.filterTemplates || [],
|
||||
quickloadTasks: prefs.quickloadTasks || [],
|
||||
dismissedTaskUpdateIndicators: prefs.dismissedTaskUpdateIndicators || {},
|
||||
collapsedUntilUpdatedTasks: prefs.collapsedUntilUpdatedTasks || {},
|
||||
noteFilter: prefs.noteFilter || {
|
||||
query: "",
|
||||
tags: [],
|
||||
@@ -1930,25 +2052,25 @@ export const initStore = async () => {
|
||||
pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
filter: `${relationFilter("user", currentUserId)} && completed = false`,
|
||||
sort: '-created',
|
||||
fields: LIST_FIELDS,
|
||||
fields: getTaskListFields(),
|
||||
requestKey: null
|
||||
}),
|
||||
(subscribedBucketTags.length > 0) ? pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
filter: `(${subscribedBucketTags.map(tag => `tags ~ "${tag}"`).join(' || ')}) && completed = false`,
|
||||
sort: '-created',
|
||||
fields: LIST_FIELDS,
|
||||
fields: getTaskListFields(),
|
||||
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,
|
||||
fields: getTaskListFields(),
|
||||
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,
|
||||
fields: getTaskListFields(),
|
||||
requestKey: null
|
||||
}).catch(() => []) : Promise.resolve([])
|
||||
];
|
||||
@@ -2016,7 +2138,7 @@ export const initStore = async () => {
|
||||
const recentCompleted = await pb.collection(TASGRID_COLLECTION).getList(1, remainingSlots, {
|
||||
filter: `${relationFilter("user", currentUserId)} && completed = true`,
|
||||
sort: '-updated',
|
||||
fields: LIST_FIELDS,
|
||||
fields: getTaskListFields(),
|
||||
requestKey: null
|
||||
});
|
||||
|
||||
@@ -2052,6 +2174,7 @@ export const initStore = async () => {
|
||||
|
||||
// Fire off background sync
|
||||
await backgroundSync();
|
||||
await ensureUpdatedByFieldSupport();
|
||||
|
||||
// Run one-time migration automatically only in dev data mode.
|
||||
if (TASGRID_IS_DEV_DATA) {
|
||||
@@ -2135,7 +2258,7 @@ export const toggleBucketSubscription = async (bucketId: string) => {
|
||||
const records = await pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
filter: `tags ~ "${buildShareTag(bucket.name)}"`,
|
||||
sort: '-created',
|
||||
fields: LIST_FIELDS
|
||||
fields: getTaskListFields()
|
||||
});
|
||||
|
||||
const newTasks: Task[] = [];
|
||||
@@ -2299,6 +2422,7 @@ export const addTask = async (title: string, options?: { dueDate?: Date | string
|
||||
noteRefs,
|
||||
ownerId: pb.authStore.model?.id || "",
|
||||
createdBy: pb.authStore.model?.id || "",
|
||||
updatedBy: pb.authStore.model?.id || "",
|
||||
content,
|
||||
size,
|
||||
created: new Date().toISOString(),
|
||||
@@ -2314,7 +2438,8 @@ export const addTask = async (title: string, options?: { dueDate?: Date | string
|
||||
setStore("tasks", (t) => [newTask, ...t]);
|
||||
|
||||
try {
|
||||
const record = await pb.collection(TASGRID_COLLECTION).create({
|
||||
const updatedBySupported = await ensureUpdatedByFieldSupport();
|
||||
const createPayload: any = {
|
||||
user: newTask.ownerId, // Use the (potentially updated) ownerId
|
||||
createdBy: newTask.createdBy,
|
||||
title,
|
||||
@@ -2329,7 +2454,13 @@ export const addTask = async (title: string, options?: { dueDate?: Date | string
|
||||
noteRefs: newTask.noteRefs,
|
||||
content,
|
||||
size
|
||||
});
|
||||
};
|
||||
|
||||
if (updatedBySupported) {
|
||||
createPayload.updatedBy = pb.authStore.model?.id || null;
|
||||
}
|
||||
|
||||
const record = await pb.collection(TASGRID_COLLECTION).create(createPayload);
|
||||
|
||||
// Replace temp task with real record (merging server fields like id, created, updated)
|
||||
setStore("tasks", (t) => {
|
||||
@@ -2346,7 +2477,10 @@ export const addTask = async (title: string, options?: { dueDate?: Date | string
|
||||
created: record.created,
|
||||
updated: record.updated,
|
||||
ownerId: unwrapRelationId(record.user),
|
||||
createdBy: unwrapRelationId(record.createdBy) || unwrapRelationId(record.user)
|
||||
createdBy: unwrapRelationId(record.createdBy) || unwrapRelationId(record.user),
|
||||
updatedBy: recordHasField(record, "updatedBy")
|
||||
? (unwrapRelationId(record.updatedBy) || unwrapRelationId(record.createdBy) || unwrapRelationId(record.user))
|
||||
: task.updatedBy
|
||||
} : task);
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -2481,7 +2615,8 @@ export const updateTask = async (id: string, updates: Partial<Task>) => {
|
||||
// Optimistic update with timestamp to prevent stale realtime events
|
||||
const optimisticUpdates = {
|
||||
...finalUpdates,
|
||||
updated: new Date().toISOString()
|
||||
updated: new Date().toISOString(),
|
||||
updatedBy: pb.authStore.model?.id || null
|
||||
};
|
||||
setStore("tasks", (t) => t.id === id, optimisticUpdates);
|
||||
|
||||
@@ -2489,6 +2624,7 @@ export const updateTask = async (id: string, updates: Partial<Task>) => {
|
||||
// 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 };
|
||||
const updatedBySupported = await ensureUpdatedByFieldSupport();
|
||||
|
||||
if (finalUpdates.ownerId !== undefined) {
|
||||
pbUpdates.user = finalUpdates.ownerId || null; // Map ownerId to 'user' field in PB
|
||||
@@ -2508,6 +2644,10 @@ export const updateTask = async (id: string, updates: Partial<Task>) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (updatedBySupported) {
|
||||
pbUpdates.updatedBy = pb.authStore.model?.id || null;
|
||||
}
|
||||
|
||||
// Disable auto-cancellation for updates to prevent aborted errors during rapid typing
|
||||
await pb.collection(TASGRID_COLLECTION).update(id, pbUpdates, { requestKey: null });
|
||||
} catch (err) {
|
||||
@@ -2820,14 +2960,14 @@ export const loadAllHistory = async () => {
|
||||
.map(context => buildShareTag(context.displayName));
|
||||
|
||||
const promises = [
|
||||
pb.collection(TASGRID_COLLECTION).getFullList({ filter: relationFilter("user", userId), sort: '-created', fields: LIST_FIELDS, requestKey: null })
|
||||
pb.collection(TASGRID_COLLECTION).getFullList({ filter: relationFilter("user", userId), sort: '-created', fields: getTaskListFields(), 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,
|
||||
fields: getTaskListFields(),
|
||||
requestKey: null
|
||||
}).catch(() => []));
|
||||
}
|
||||
@@ -2836,7 +2976,7 @@ export const loadAllHistory = async () => {
|
||||
promises.push(pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
filter: collaborativeTags.map(tag => `tags ~ "${tag}"`).join(' || '),
|
||||
sort: '-created',
|
||||
fields: LIST_FIELDS,
|
||||
fields: getTaskListFields(),
|
||||
requestKey: null
|
||||
}).catch(() => []));
|
||||
}
|
||||
@@ -2845,7 +2985,7 @@ export const loadAllHistory = async () => {
|
||||
promises.push(pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
filter: supervisedUserIds.map(id => relationFilter("user", id)).join(' || '),
|
||||
sort: '-created',
|
||||
fields: LIST_FIELDS,
|
||||
fields: getTaskListFields(),
|
||||
requestKey: null
|
||||
}).catch(() => []));
|
||||
}
|
||||
@@ -2908,6 +3048,8 @@ export const syncPreferences = async () => {
|
||||
filter: store.filter,
|
||||
filterTemplates: store.filterTemplates,
|
||||
quickloadTasks: store.quickloadTasks,
|
||||
dismissedTaskUpdateIndicators: store.dismissedTaskUpdateIndicators,
|
||||
collapsedUntilUpdatedTasks: store.collapsedUntilUpdatedTasks,
|
||||
noteFilter: store.noteFilter,
|
||||
subscribedBuckets: store.subscribedBuckets,
|
||||
supervisorUserIds: store.personalContextSupervisorIds
|
||||
@@ -3040,7 +3182,7 @@ export const loadTasksForOwner = async (userId: string) => {
|
||||
const records = await pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
filter,
|
||||
sort: '-created',
|
||||
fields: LIST_FIELDS,
|
||||
fields: getTaskListFields(),
|
||||
requestKey: `owner-context-${userId}`
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user