added UPDATED indicators and task collapsing
This commit is contained in:
@@ -32,7 +32,6 @@ const App: Component = () => {
|
|||||||
|
|
||||||
let isFirstRun = true;
|
let isFirstRun = true;
|
||||||
const removeListener = pb.authStore.onChange((token) => {
|
const removeListener = pb.authStore.onChange((token) => {
|
||||||
console.log('[DEBUG] pb.authStore.onChange fired, token present:', !!token);
|
|
||||||
const wasAuthenticated = isAuthenticated();
|
const wasAuthenticated = isAuthenticated();
|
||||||
setIsAuthenticated(!!token);
|
setIsAuthenticated(!!token);
|
||||||
|
|
||||||
|
|||||||
+102
-33
@@ -1,7 +1,7 @@
|
|||||||
import { type Component, createMemo, For, Show } from "solid-js";
|
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 { 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 { Badge } from "@/components/ui/badge";
|
||||||
import { useTheme } from "./ThemeProvider";
|
import { useTheme } from "./ThemeProvider";
|
||||||
import { getThemeAdjustedColor } from "@/lib/colors";
|
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(() => {
|
const recurrenceProgress = createMemo(() => {
|
||||||
if (props.task.status !== 10) return null;
|
if (props.task.status !== 10) return null;
|
||||||
return getRecurrenceProgress(props.task.recurrence, props.task.updated, now());
|
return getRecurrenceProgress(props.task.recurrence, props.task.updated, now());
|
||||||
});
|
});
|
||||||
const shouldQuickloadFade = createMemo(() => store.quickloadFadeTaskIds.includes(props.task.id));
|
const shouldQuickloadFade = createMemo(() => store.quickloadFadeTaskIds.includes(props.task.id));
|
||||||
|
const showUpdateIndicator = createMemo(() => isTaskUpdatedByAnotherUser(props.task));
|
||||||
|
const isCollapsed = createMemo(() => isTaskCollapsedUntilUpdated(props.task));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class={cn(
|
class={cn(
|
||||||
"group relative flex items-center p-3 sm:p-4 bg-card border border-border rounded-2xl transition-[background-color,border-color,box-shadow,opacity] duration-500 hover:duration-100 hover:shadow-xl hover:shadow-primary/5 hover:-translate-y-0.5 cursor-pointer w-full min-w-0 overflow-hidden",
|
"group relative flex items-center bg-card border border-border rounded-2xl transition-[background-color,border-color,box-shadow,opacity] duration-500 hover:duration-100 hover:shadow-xl hover:shadow-primary/5 hover:-translate-y-0.5 cursor-pointer w-full min-w-0 overflow-hidden",
|
||||||
|
isCollapsed()
|
||||||
|
? "py-1.5 pl-5 pr-5 sm:py-2 sm:pl-6 sm:pr-6"
|
||||||
|
: "py-3 pl-6 pr-6 sm:py-4 sm:pl-7 sm:pr-7",
|
||||||
props.task.completed && "opacity-60",
|
props.task.completed && "opacity-60",
|
||||||
props.isShaking && "animate-task-shake bg-accent/20",
|
props.isShaking && "animate-task-shake bg-accent/20",
|
||||||
shouldQuickloadFade() && "animate-task-quickload-fade"
|
shouldQuickloadFade() && "animate-task-quickload-fade"
|
||||||
@@ -73,40 +86,73 @@ export const TaskCard: Component<{ task: Task, isShaking?: boolean }> = (props)
|
|||||||
>
|
>
|
||||||
{/* Today indicator */}
|
{/* Today indicator */}
|
||||||
<Show when={isDueWithin12Hours() && !props.task.completed}>
|
<Show when={isDueWithin12Hours() && !props.task.completed}>
|
||||||
<div class="absolute left-0 top-3 bottom-3 w-1 bg-primary rounded-r-md" />
|
<div class="absolute left-0 top-3 bottom-3 w-3 sm:w-[0.875rem] rounded-r-md bg-primary/65 text-primary-foreground/90">
|
||||||
|
<span
|
||||||
|
class="absolute inset-0 flex items-center justify-center text-[0.4375rem] sm:text-[0.5rem] font-semibold uppercase tracking-[0.08em] [writing-mode:vertical-rl] [text-orientation:mixed]"
|
||||||
|
>
|
||||||
|
Today
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={showUpdateIndicator()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="absolute right-0 top-3 bottom-3 w-3 sm:w-[0.875rem] rounded-l-md bg-primary/65 text-primary-foreground/90 transition-colors hover:bg-primary/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
|
||||||
|
title="Hide updates until the next change from another user"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
dismissTaskUpdateIndicator(props.task.id, props.task.updated);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute inset-0 flex items-center justify-center text-[0.4375rem] sm:text-[0.5rem] font-semibold uppercase tracking-[0.08em] [writing-mode:vertical-rl] [text-orientation:mixed]"
|
||||||
|
>
|
||||||
|
Updates
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
{/* Status Dropdown */}
|
{/* Status Dropdown */}
|
||||||
<div class="mr-2 sm:mr-3 shrink-0" onClick={(e) => e.stopPropagation()}>
|
<Show when={!isCollapsed()}>
|
||||||
<Select<any>
|
<div class="mr-2 sm:mr-3 shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||||
value={(props.task.status ?? 0).toString()}
|
<Select<any>
|
||||||
onChange={(val: any) => {
|
value={(props.task.status ?? 0).toString()}
|
||||||
const value = typeof val === 'object' ? val?.value : val;
|
onChange={(val: any) => {
|
||||||
if (value) {
|
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);
|
const s = parseInt(value);
|
||||||
if (!isNaN(s)) updateTask(props.task.id, { status: s });
|
if (!isNaN(s)) updateTask(props.task.id, { status: s });
|
||||||
}
|
|
||||||
}}
|
|
||||||
options={statusOptions()}
|
|
||||||
optionValue="value"
|
|
||||||
optionTextValue="label"
|
|
||||||
placeholder="Status"
|
|
||||||
itemComponent={(props: any) => <SelectItem item={props.item}>{props.item.rawValue.label}</SelectItem>}
|
|
||||||
>
|
|
||||||
<SelectTrigger
|
|
||||||
class="h-9 w-9 p-0 bg-transparent border-transparent hover:bg-muted/50 data-[expanded]:bg-muted/50 shadow-none focus:ring-0 shrink-0 overflow-hidden flex items-center justify-center [&>svg]:hidden"
|
|
||||||
onDoubleClick={(e: MouseEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
updateTask(props.task.id, { status: 10 });
|
|
||||||
}}
|
}}
|
||||||
|
options={statusOptions()}
|
||||||
|
optionValue="value"
|
||||||
|
optionTextValue="label"
|
||||||
|
placeholder="Status"
|
||||||
|
itemComponent={(props: any) => <SelectItem item={props.item}>{props.item.rawValue.label}</SelectItem>}
|
||||||
>
|
>
|
||||||
<StatusCircle status={props.task.status ?? 0} size={26} recurrenceProgress={recurrenceProgress()} />
|
<SelectTrigger
|
||||||
</SelectTrigger>
|
class="h-9 w-9 p-0 bg-transparent border-transparent hover:bg-muted/50 data-[expanded]:bg-muted/50 shadow-none focus:ring-0 shrink-0 overflow-hidden flex items-center justify-center [&>svg]:hidden"
|
||||||
<SelectContent />
|
onDoubleClick={(e: MouseEvent) => {
|
||||||
</Select>
|
e.preventDefault();
|
||||||
</div>
|
e.stopPropagation();
|
||||||
|
updateTask(props.task.id, { status: 10 });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StatusCircle status={props.task.status ?? 0} size={26} recurrenceProgress={recurrenceProgress()} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent />
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<div class="flex-1 min-w-0 flex flex-col">
|
<div class={cn("flex-1 min-w-0", isCollapsed() ? "flex items-center justify-between gap-2" : "flex flex-col")}>
|
||||||
|
<Show
|
||||||
|
when={isCollapsed()}
|
||||||
|
fallback={
|
||||||
|
<>
|
||||||
<h3 class={cn(
|
<h3 class={cn(
|
||||||
"text-sm sm:text-base font-semibold truncate transition-all",
|
"text-sm sm:text-base font-semibold truncate transition-all",
|
||||||
props.task.completed && "line-through"
|
props.task.completed && "line-through"
|
||||||
@@ -213,9 +259,31 @@ export const TaskCard: Component<{ task: Task, isShaking?: boolean }> = (props)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<h3 class={cn(
|
||||||
|
"text-[0.6875rem] sm:text-xs font-semibold truncate text-muted-foreground/90",
|
||||||
|
props.task.completed && "line-through"
|
||||||
|
)}>
|
||||||
|
{props.task.title}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="shrink-0 rounded-full p-1 text-muted-foreground/70 transition-colors hover:bg-muted hover:text-foreground"
|
||||||
|
title="Expand task"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
expandCollapsedTask(props.task.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ml-2 sm:ml-4 shrink-0 flex items-center gap-1">
|
<Show when={!isCollapsed()}>
|
||||||
|
<div class="ml-2 sm:ml-4 shrink-0 flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
class="p-1.5 hover:bg-muted rounded-full transition-all text-muted-foreground hover:text-primary opacity-0 group-hover:opacity-100"
|
class="p-1.5 hover:bg-muted rounded-full transition-all text-muted-foreground hover:text-primary opacity-0 group-hover:opacity-100"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -245,7 +313,8 @@ export const TaskCard: Component<{ task: Task, isShaking?: boolean }> = (props)
|
|||||||
>
|
>
|
||||||
<Star size={16} class={cn(props.task.tags?.includes(getFavoriteTag()) && "fill-amber-500")} />
|
<Star size={16} class={cn(props.task.tags?.includes(getFavoriteTag()) && "fill-amber-500")} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+160
-18
@@ -12,8 +12,12 @@ export const getFavoriteTag = () => {
|
|||||||
return userId ? `__${userId}_favorite__` : "__favorite__";
|
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';
|
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 getStorageKey = () => {
|
||||||
const userId = pb.authStore.model?.id;
|
const userId = pb.authStore.model?.id;
|
||||||
@@ -48,6 +52,7 @@ export interface Task {
|
|||||||
noteRefs: NoteRef[];
|
noteRefs: NoteRef[];
|
||||||
ownerId: string | null; // Add ownerId to interface
|
ownerId: string | null; // Add ownerId to interface
|
||||||
createdBy: string | null;
|
createdBy: string | null;
|
||||||
|
updatedBy?: string | null;
|
||||||
content?: string; // HTML content from Tiptap
|
content?: string; // HTML content from Tiptap
|
||||||
deletedAt?: number | null; // Timestamp of soft delete or null if restored
|
deletedAt?: number | null; // Timestamp of soft delete or null if restored
|
||||||
created: string; // ISO string from PB
|
created: string; // ISO string from PB
|
||||||
@@ -215,7 +220,7 @@ export const startContextPolling = () => {
|
|||||||
const records = await pb.collection(TASGRID_COLLECTION).getFullList({
|
const records = await pb.collection(TASGRID_COLLECTION).getFullList({
|
||||||
filter: `${relationFilter("user", personalOrUserId)} && updated >= "${timeStr}"`,
|
filter: `${relationFilter("user", personalOrUserId)} && updated >= "${timeStr}"`,
|
||||||
sort: '-updated',
|
sort: '-updated',
|
||||||
fields: LIST_FIELDS,
|
fields: getTaskListFields(),
|
||||||
requestKey: 'context-poll'
|
requestKey: 'context-poll'
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -277,6 +282,8 @@ interface ScopedTaskgridPrefs {
|
|||||||
noteFilter?: Filter;
|
noteFilter?: Filter;
|
||||||
subscribedBuckets?: string[];
|
subscribedBuckets?: string[];
|
||||||
supervisorUserIds?: string[];
|
supervisorUserIds?: string[];
|
||||||
|
dismissedTaskUpdateIndicators?: Record<string, string>;
|
||||||
|
collapsedUntilUpdatedTasks?: Record<string, string>;
|
||||||
migratedStructuredTagsV2?: boolean;
|
migratedStructuredTagsV2?: boolean;
|
||||||
migratedPersonalContextRefsV1?: boolean;
|
migratedPersonalContextRefsV1?: boolean;
|
||||||
}
|
}
|
||||||
@@ -402,6 +409,8 @@ interface TaskStore {
|
|||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
isInitializing: boolean;
|
isInitializing: boolean;
|
||||||
quickloadFadeTaskIds: string[];
|
quickloadFadeTaskIds: string[];
|
||||||
|
dismissedTaskUpdateIndicators: Record<string, string>;
|
||||||
|
collapsedUntilUpdatedTasks: Record<string, string>;
|
||||||
isNotesLoading: boolean;
|
isNotesLoading: boolean;
|
||||||
hasLoadedNotesMetadata: boolean;
|
hasLoadedNotesMetadata: boolean;
|
||||||
loadingNoteContentIds: string[];
|
loadingNoteContentIds: string[];
|
||||||
@@ -430,6 +439,8 @@ export const [store, setStore] = createStore<TaskStore>({
|
|||||||
tasks: [],
|
tasks: [],
|
||||||
isInitializing: false,
|
isInitializing: false,
|
||||||
quickloadFadeTaskIds: [],
|
quickloadFadeTaskIds: [],
|
||||||
|
dismissedTaskUpdateIndicators: {},
|
||||||
|
collapsedUntilUpdatedTasks: {},
|
||||||
isNotesLoading: false,
|
isNotesLoading: false,
|
||||||
hasLoadedNotesMetadata: false,
|
hasLoadedNotesMetadata: false,
|
||||||
loadingNoteContentIds: [],
|
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 withActiveContextTags = (rawTags: string[]) => {
|
||||||
const nextTags = [...rawTags];
|
const nextTags = [...rawTags];
|
||||||
const ctx = currentTaskContext();
|
const ctx = currentTaskContext();
|
||||||
@@ -807,6 +892,37 @@ export const matchesFilter = (task: Task) => {
|
|||||||
return true;
|
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)
|
// Auto-persist changes to localStorage (wrapped in root to avoid console warning)
|
||||||
createRoot(() => {
|
createRoot(() => {
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -936,6 +1052,7 @@ const loadedNoteDetailIds = new Set<string>();
|
|||||||
const failedNoteDetailIds = new Set<string>();
|
const failedNoteDetailIds = new Set<string>();
|
||||||
|
|
||||||
const mapRecordToTask = (r: any): Task => {
|
const mapRecordToTask = (r: any): Task => {
|
||||||
|
detectUpdatedBySupportFromRecord(r);
|
||||||
const legacyTags: string[] = r.tags || [];
|
const legacyTags: string[] = r.tags || [];
|
||||||
const parsedTags = parseTags(legacyTags).filter(tag => tag.raw !== "__template__");
|
const parsedTags = parseTags(legacyTags).filter(tag => tag.raw !== "__template__");
|
||||||
const shareRefs: TaskShareRef[] = Array.isArray(r.shareRefs)
|
const shareRefs: TaskShareRef[] = Array.isArray(r.shareRefs)
|
||||||
@@ -980,6 +1097,9 @@ const mapRecordToTask = (r: any): Task => {
|
|||||||
noteRefs,
|
noteRefs,
|
||||||
ownerId: unwrapRelationId(r.user),
|
ownerId: unwrapRelationId(r.user),
|
||||||
createdBy: unwrapRelationId(r.createdBy) || 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,
|
content: r.content,
|
||||||
deletedAt: r.deletedAt ? new Date(r.deletedAt).getTime() : undefined,
|
deletedAt: r.deletedAt ? new Date(r.deletedAt).getTime() : undefined,
|
||||||
created: r.created,
|
created: r.created,
|
||||||
@@ -1670,7 +1790,7 @@ const runPersonalContextMigration = async (force = false) => {
|
|||||||
const noteLinksByTaskId = buildNoteLinkMap(store.notes);
|
const noteLinksByTaskId = buildNoteLinkMap(store.notes);
|
||||||
|
|
||||||
const taskRecords = await pb.collection(TASGRID_COLLECTION).getFullList({
|
const taskRecords = await pb.collection(TASGRID_COLLECTION).getFullList({
|
||||||
fields: LIST_FIELDS,
|
fields: getTaskListFields(),
|
||||||
requestKey: null
|
requestKey: null
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1852,6 +1972,8 @@ export const initStore = async () => {
|
|||||||
tagDefinitions: (prefs.tagDefinitions || []).filter((def: TagDefinition) => !def.name.startsWith("@")),
|
tagDefinitions: (prefs.tagDefinitions || []).filter((def: TagDefinition) => !def.name.startsWith("@")),
|
||||||
filterTemplates: prefs.filterTemplates || [],
|
filterTemplates: prefs.filterTemplates || [],
|
||||||
quickloadTasks: prefs.quickloadTasks || [],
|
quickloadTasks: prefs.quickloadTasks || [],
|
||||||
|
dismissedTaskUpdateIndicators: prefs.dismissedTaskUpdateIndicators || {},
|
||||||
|
collapsedUntilUpdatedTasks: prefs.collapsedUntilUpdatedTasks || {},
|
||||||
noteFilter: prefs.noteFilter || {
|
noteFilter: prefs.noteFilter || {
|
||||||
query: "",
|
query: "",
|
||||||
tags: [],
|
tags: [],
|
||||||
@@ -1930,25 +2052,25 @@ export const initStore = async () => {
|
|||||||
pb.collection(TASGRID_COLLECTION).getFullList({
|
pb.collection(TASGRID_COLLECTION).getFullList({
|
||||||
filter: `${relationFilter("user", currentUserId)} && completed = false`,
|
filter: `${relationFilter("user", currentUserId)} && completed = false`,
|
||||||
sort: '-created',
|
sort: '-created',
|
||||||
fields: LIST_FIELDS,
|
fields: getTaskListFields(),
|
||||||
requestKey: null
|
requestKey: null
|
||||||
}),
|
}),
|
||||||
(subscribedBucketTags.length > 0) ? pb.collection(TASGRID_COLLECTION).getFullList({
|
(subscribedBucketTags.length > 0) ? pb.collection(TASGRID_COLLECTION).getFullList({
|
||||||
filter: `(${subscribedBucketTags.map(tag => `tags ~ "${tag}"`).join(' || ')}) && completed = false`,
|
filter: `(${subscribedBucketTags.map(tag => `tags ~ "${tag}"`).join(' || ')}) && completed = false`,
|
||||||
sort: '-created',
|
sort: '-created',
|
||||||
fields: LIST_FIELDS,
|
fields: getTaskListFields(),
|
||||||
requestKey: null
|
requestKey: null
|
||||||
}).catch(() => []) : Promise.resolve([]),
|
}).catch(() => []) : Promise.resolve([]),
|
||||||
(collaborativeKeys.length > 0) ? pb.collection(TASGRID_COLLECTION).getFullList({
|
(collaborativeKeys.length > 0) ? pb.collection(TASGRID_COLLECTION).getFullList({
|
||||||
filter: `(${collaborativeKeys.map(tag => `tags ~ "${tag}"`).join(' || ')}) && completed = false`,
|
filter: `(${collaborativeKeys.map(tag => `tags ~ "${tag}"`).join(' || ')}) && completed = false`,
|
||||||
sort: '-created',
|
sort: '-created',
|
||||||
fields: LIST_FIELDS,
|
fields: getTaskListFields(),
|
||||||
requestKey: null
|
requestKey: null
|
||||||
}).catch(() => []) : Promise.resolve([]),
|
}).catch(() => []) : Promise.resolve([]),
|
||||||
(supervisedUserIds.length > 0) ? pb.collection(TASGRID_COLLECTION).getFullList({
|
(supervisedUserIds.length > 0) ? pb.collection(TASGRID_COLLECTION).getFullList({
|
||||||
filter: `(${supervisedUserIds.map(id => relationFilter("user", id)).join(' || ')}) && completed = false`,
|
filter: `(${supervisedUserIds.map(id => relationFilter("user", id)).join(' || ')}) && completed = false`,
|
||||||
sort: '-created',
|
sort: '-created',
|
||||||
fields: LIST_FIELDS,
|
fields: getTaskListFields(),
|
||||||
requestKey: null
|
requestKey: null
|
||||||
}).catch(() => []) : Promise.resolve([])
|
}).catch(() => []) : Promise.resolve([])
|
||||||
];
|
];
|
||||||
@@ -2016,7 +2138,7 @@ export const initStore = async () => {
|
|||||||
const recentCompleted = await pb.collection(TASGRID_COLLECTION).getList(1, remainingSlots, {
|
const recentCompleted = await pb.collection(TASGRID_COLLECTION).getList(1, remainingSlots, {
|
||||||
filter: `${relationFilter("user", currentUserId)} && completed = true`,
|
filter: `${relationFilter("user", currentUserId)} && completed = true`,
|
||||||
sort: '-updated',
|
sort: '-updated',
|
||||||
fields: LIST_FIELDS,
|
fields: getTaskListFields(),
|
||||||
requestKey: null
|
requestKey: null
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2052,6 +2174,7 @@ export const initStore = async () => {
|
|||||||
|
|
||||||
// Fire off background sync
|
// Fire off background sync
|
||||||
await backgroundSync();
|
await backgroundSync();
|
||||||
|
await ensureUpdatedByFieldSupport();
|
||||||
|
|
||||||
// Run one-time migration automatically only in dev data mode.
|
// Run one-time migration automatically only in dev data mode.
|
||||||
if (TASGRID_IS_DEV_DATA) {
|
if (TASGRID_IS_DEV_DATA) {
|
||||||
@@ -2135,7 +2258,7 @@ export const toggleBucketSubscription = async (bucketId: string) => {
|
|||||||
const records = await pb.collection(TASGRID_COLLECTION).getFullList({
|
const records = await pb.collection(TASGRID_COLLECTION).getFullList({
|
||||||
filter: `tags ~ "${buildShareTag(bucket.name)}"`,
|
filter: `tags ~ "${buildShareTag(bucket.name)}"`,
|
||||||
sort: '-created',
|
sort: '-created',
|
||||||
fields: LIST_FIELDS
|
fields: getTaskListFields()
|
||||||
});
|
});
|
||||||
|
|
||||||
const newTasks: Task[] = [];
|
const newTasks: Task[] = [];
|
||||||
@@ -2299,6 +2422,7 @@ export const addTask = async (title: string, options?: { dueDate?: Date | string
|
|||||||
noteRefs,
|
noteRefs,
|
||||||
ownerId: pb.authStore.model?.id || "",
|
ownerId: pb.authStore.model?.id || "",
|
||||||
createdBy: pb.authStore.model?.id || "",
|
createdBy: pb.authStore.model?.id || "",
|
||||||
|
updatedBy: pb.authStore.model?.id || "",
|
||||||
content,
|
content,
|
||||||
size,
|
size,
|
||||||
created: new Date().toISOString(),
|
created: new Date().toISOString(),
|
||||||
@@ -2314,7 +2438,8 @@ export const addTask = async (title: string, options?: { dueDate?: Date | string
|
|||||||
setStore("tasks", (t) => [newTask, ...t]);
|
setStore("tasks", (t) => [newTask, ...t]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const record = await pb.collection(TASGRID_COLLECTION).create({
|
const updatedBySupported = await ensureUpdatedByFieldSupport();
|
||||||
|
const createPayload: any = {
|
||||||
user: newTask.ownerId, // Use the (potentially updated) ownerId
|
user: newTask.ownerId, // Use the (potentially updated) ownerId
|
||||||
createdBy: newTask.createdBy,
|
createdBy: newTask.createdBy,
|
||||||
title,
|
title,
|
||||||
@@ -2329,7 +2454,13 @@ export const addTask = async (title: string, options?: { dueDate?: Date | string
|
|||||||
noteRefs: newTask.noteRefs,
|
noteRefs: newTask.noteRefs,
|
||||||
content,
|
content,
|
||||||
size
|
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)
|
// Replace temp task with real record (merging server fields like id, created, updated)
|
||||||
setStore("tasks", (t) => {
|
setStore("tasks", (t) => {
|
||||||
@@ -2346,7 +2477,10 @@ export const addTask = async (title: string, options?: { dueDate?: Date | string
|
|||||||
created: record.created,
|
created: record.created,
|
||||||
updated: record.updated,
|
updated: record.updated,
|
||||||
ownerId: unwrapRelationId(record.user),
|
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);
|
} : task);
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -2481,7 +2615,8 @@ export const updateTask = async (id: string, updates: Partial<Task>) => {
|
|||||||
// Optimistic update with timestamp to prevent stale realtime events
|
// Optimistic update with timestamp to prevent stale realtime events
|
||||||
const optimisticUpdates = {
|
const optimisticUpdates = {
|
||||||
...finalUpdates,
|
...finalUpdates,
|
||||||
updated: new Date().toISOString()
|
updated: new Date().toISOString(),
|
||||||
|
updatedBy: pb.authStore.model?.id || null
|
||||||
};
|
};
|
||||||
setStore("tasks", (t) => t.id === id, optimisticUpdates);
|
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)
|
// Map Task fields to PB fields if needed (dates are ISO strings, so should matches)
|
||||||
// Check for specific fields being updated
|
// Check for specific fields being updated
|
||||||
const pbUpdates: any = { ...finalUpdates };
|
const pbUpdates: any = { ...finalUpdates };
|
||||||
|
const updatedBySupported = await ensureUpdatedByFieldSupport();
|
||||||
|
|
||||||
if (finalUpdates.ownerId !== undefined) {
|
if (finalUpdates.ownerId !== undefined) {
|
||||||
pbUpdates.user = finalUpdates.ownerId || null; // Map ownerId to 'user' field in PB
|
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
|
// Disable auto-cancellation for updates to prevent aborted errors during rapid typing
|
||||||
await pb.collection(TASGRID_COLLECTION).update(id, pbUpdates, { requestKey: null });
|
await pb.collection(TASGRID_COLLECTION).update(id, pbUpdates, { requestKey: null });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -2820,14 +2960,14 @@ export const loadAllHistory = async () => {
|
|||||||
.map(context => buildShareTag(context.displayName));
|
.map(context => buildShareTag(context.displayName));
|
||||||
|
|
||||||
const promises = [
|
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) {
|
if (bucketTags.length > 0) {
|
||||||
promises.push(pb.collection(TASGRID_COLLECTION).getFullList({
|
promises.push(pb.collection(TASGRID_COLLECTION).getFullList({
|
||||||
filter: bucketTags.map(tag => `tags ~ "${tag}"`).join(' || '),
|
filter: bucketTags.map(tag => `tags ~ "${tag}"`).join(' || '),
|
||||||
sort: '-created',
|
sort: '-created',
|
||||||
fields: LIST_FIELDS,
|
fields: getTaskListFields(),
|
||||||
requestKey: null
|
requestKey: null
|
||||||
}).catch(() => []));
|
}).catch(() => []));
|
||||||
}
|
}
|
||||||
@@ -2836,7 +2976,7 @@ export const loadAllHistory = async () => {
|
|||||||
promises.push(pb.collection(TASGRID_COLLECTION).getFullList({
|
promises.push(pb.collection(TASGRID_COLLECTION).getFullList({
|
||||||
filter: collaborativeTags.map(tag => `tags ~ "${tag}"`).join(' || '),
|
filter: collaborativeTags.map(tag => `tags ~ "${tag}"`).join(' || '),
|
||||||
sort: '-created',
|
sort: '-created',
|
||||||
fields: LIST_FIELDS,
|
fields: getTaskListFields(),
|
||||||
requestKey: null
|
requestKey: null
|
||||||
}).catch(() => []));
|
}).catch(() => []));
|
||||||
}
|
}
|
||||||
@@ -2845,7 +2985,7 @@ export const loadAllHistory = async () => {
|
|||||||
promises.push(pb.collection(TASGRID_COLLECTION).getFullList({
|
promises.push(pb.collection(TASGRID_COLLECTION).getFullList({
|
||||||
filter: supervisedUserIds.map(id => relationFilter("user", id)).join(' || '),
|
filter: supervisedUserIds.map(id => relationFilter("user", id)).join(' || '),
|
||||||
sort: '-created',
|
sort: '-created',
|
||||||
fields: LIST_FIELDS,
|
fields: getTaskListFields(),
|
||||||
requestKey: null
|
requestKey: null
|
||||||
}).catch(() => []));
|
}).catch(() => []));
|
||||||
}
|
}
|
||||||
@@ -2908,6 +3048,8 @@ export const syncPreferences = async () => {
|
|||||||
filter: store.filter,
|
filter: store.filter,
|
||||||
filterTemplates: store.filterTemplates,
|
filterTemplates: store.filterTemplates,
|
||||||
quickloadTasks: store.quickloadTasks,
|
quickloadTasks: store.quickloadTasks,
|
||||||
|
dismissedTaskUpdateIndicators: store.dismissedTaskUpdateIndicators,
|
||||||
|
collapsedUntilUpdatedTasks: store.collapsedUntilUpdatedTasks,
|
||||||
noteFilter: store.noteFilter,
|
noteFilter: store.noteFilter,
|
||||||
subscribedBuckets: store.subscribedBuckets,
|
subscribedBuckets: store.subscribedBuckets,
|
||||||
supervisorUserIds: store.personalContextSupervisorIds
|
supervisorUserIds: store.personalContextSupervisorIds
|
||||||
@@ -3040,7 +3182,7 @@ export const loadTasksForOwner = async (userId: string) => {
|
|||||||
const records = await pb.collection(TASGRID_COLLECTION).getFullList({
|
const records = await pb.collection(TASGRID_COLLECTION).getFullList({
|
||||||
filter,
|
filter,
|
||||||
sort: '-created',
|
sort: '-created',
|
||||||
fields: LIST_FIELDS,
|
fields: getTaskListFields(),
|
||||||
requestKey: `owner-context-${userId}`
|
requestKey: `owner-context-${userId}`
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user