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()}> - - value={(props.task.status ?? 0).toString()} - onChange={(val: any) => { - const value = typeof val === 'object' ? val?.value : val; - if (value) { + +
e.stopPropagation()}> + + value={(props.task.status ?? 0).toString()} + onChange={(val: any) => { + const value = typeof val === 'object' ? val?.value : val; + if (!value) return; + if (value === "__collapse_until_updated__") { + collapseTaskUntilUpdated(props.task.id, props.task.updated); + return; + } + const s = parseInt(value); if (!isNaN(s)) updateTask(props.task.id, { status: s }); - } - }} - options={statusOptions()} - optionValue="value" - optionTextValue="label" - placeholder="Status" - itemComponent={(props: any) => {props.item.rawValue.label}} - > - { - e.preventDefault(); - e.stopPropagation(); - updateTask(props.task.id, { status: 10 }); }} + options={statusOptions()} + optionValue="value" + optionTextValue="label" + placeholder="Status" + itemComponent={(props: any) => {props.item.rawValue.label}} > - - - - -
+ { + e.preventDefault(); + e.stopPropagation(); + updateTask(props.task.id, { status: 10 }); + }} + > + + + + +
+ -
+
+

= (props)

+ + } + > +

+ {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}` });