diff --git a/src/App.tsx b/src/App.tsx
index 97d9b0b..12da75d 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -32,7 +32,6 @@ const App: Component = () => {
let isFirstRun = true;
const removeListener = pb.authStore.onChange((token) => {
- console.log('[DEBUG] pb.authStore.onChange fired, token present:', !!token);
const wasAuthenticated = isAuthenticated();
setIsAuthenticated(!!token);
diff --git a/src/components/TaskCard.tsx b/src/components/TaskCard.tsx
index 63a8a0a..4415fcc 100644
--- a/src/components/TaskCard.tsx
+++ b/src/components/TaskCard.tsx
@@ -1,7 +1,7 @@
import { type Component, createMemo, For, Show } from "solid-js";
-import { type Task, calculateUrgencyFromDate, setActiveTaskId, store, copyTask, updateTask, currentTaskContext, now, getFavoriteTag, isCurrentUserContextTag } from "@/store";
+import { type Task, calculateUrgencyFromDate, setActiveTaskId, store, copyTask, updateTask, currentTaskContext, now, getFavoriteTag, isCurrentUserContextTag, dismissTaskUpdateIndicator, isTaskUpdatedByAnotherUser, collapseTaskUntilUpdated, expandCollapsedTask, isTaskCollapsedUntilUpdated, isTaskCollaborativelyShared } from "@/store";
import { cn } from "@/lib/utils";
-import { Clock, ArrowUpCircle, Copy, Users2, Star } from "lucide-solid";
+import { Clock, ArrowUpCircle, Copy, Users2, Star, ChevronRight } from "lucide-solid";
import { Badge } from "@/components/ui/badge";
import { useTheme } from "./ThemeProvider";
import { getThemeAdjustedColor } from "@/lib/colors";
@@ -54,17 +54,30 @@ export const TaskCard: Component<{ task: Task, isShaking?: boolean }> = (props)
}
});
- const statusOptions = createMemo(() => getStatusOptionsForTask(props.task.recurrence));
+ const canCollapseUntilUpdated = createMemo(() => isTaskCollaborativelyShared(props.task));
+ const statusOptions = createMemo(() => {
+ const baseOptions = getStatusOptionsForTask(props.task.recurrence);
+ if (!canCollapseUntilUpdated()) return baseOptions;
+ return [
+ ...baseOptions,
+ { value: "__collapse_until_updated__", label: "Collapse until updated" }
+ ];
+ });
const recurrenceProgress = createMemo(() => {
if (props.task.status !== 10) return null;
return getRecurrenceProgress(props.task.recurrence, props.task.updated, now());
});
const shouldQuickloadFade = createMemo(() => store.quickloadFadeTaskIds.includes(props.task.id));
+ const showUpdateIndicator = createMemo(() => isTaskUpdatedByAnotherUser(props.task));
+ const isCollapsed = createMemo(() => isTaskCollapsedUntilUpdated(props.task));
return (
= (props)
>
{/* Today indicator */}
-
+
+
+ Today
+
+
+
+
+
{/* Status Dropdown */}
-
e.stopPropagation()}>
-
+
+
-
+ >
+ }
+ >
+
+ {props.task.title}
+
+
+
-
+
+
-
+
+
);
};
diff --git a/src/store/index.ts b/src/store/index.ts
index 0646391..8c1d6df 100644
--- a/src/store/index.ts
+++ b/src/store/index.ts
@@ -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;
+ collapsedUntilUpdatedTasks?: Record;
migratedStructuredTagsV2?: boolean;
migratedPersonalContextRefsV1?: boolean;
}
@@ -402,6 +409,8 @@ interface TaskStore {
tasks: Task[];
isInitializing: boolean;
quickloadFadeTaskIds: string[];
+ dismissedTaskUpdateIndicators: Record;
+ collapsedUntilUpdatedTasks: Record;
isNotesLoading: boolean;
hasLoadedNotesMetadata: boolean;
loadingNoteContentIds: string[];
@@ -430,6 +439,8 @@ export const [store, setStore] = createStore({
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();
const failedNoteDetailIds = new Set();
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) => {
// 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) => {
// 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) => {
}
}
+ 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}`
});