feat: add threaded comments, activity feeds, and task dependencies

This commit is contained in:
2026-08-10 21:59:10 +00:00
parent 1059512888
commit a3e4d3c868
35 changed files with 10283 additions and 578 deletions
@@ -0,0 +1,65 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { api } from "@/lib/api";
interface PatchVariables {
id: string;
data: Record<string, unknown>;
}
interface UseOptimisticPatchOptions<T extends { id: string }> {
/** Detail query key, e.g. ["task", id]. */
entityKey: string[];
/** List query keys to prefix-invalidate after settle, e.g. [["tasks"]]. */
listKeys?: string[][];
/** Builds the PATCH url from the entity id, e.g. (id) => `/tasks/${id}`. */
patchUrl: (id: string) => string;
/** Merges the partial update into the cached entity for the optimistic UI. */
applyPatch: (current: T, data: Record<string, unknown>) => T;
}
/**
* Optimistic PATCH with rollback, shared by all entity detail pages.
*
* Returns a `patch({ id, data })` callable: the mutation updates the detail
* query cache immediately via `applyPatch`, restores the previous snapshot on
* error (with an error toast), and prefix-invalidates the entity key plus all
* list keys once the request settles.
*/
export function useOptimisticPatch<T extends { id: string }>(
opts: UseOptimisticPatchOptions<T>
) {
const queryClient = useQueryClient();
const { entityKey, listKeys = [], patchUrl, applyPatch } = opts;
const mutation = useMutation({
mutationFn: ({ id, data }: PatchVariables) =>
api.patch<unknown>(patchUrl(id), data),
onMutate: async ({ id, data }) => {
await queryClient.cancelQueries({ queryKey: entityKey });
const previous = queryClient.getQueryData<T>(entityKey);
queryClient.setQueryData<T>(entityKey, (old) =>
old ? applyPatch(old, data) : old
);
return { previous };
},
onError: (err, _vars, context) => {
if (context?.previous !== undefined) {
queryClient.setQueryData<T>(entityKey, context.previous);
}
const message = err instanceof Error ? err.message : "Failed to save";
toast.error(message || "Failed to save");
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: entityKey });
for (const key of listKeys) {
queryClient.invalidateQueries({ queryKey: key });
}
},
});
return {
...mutation,
patch: mutation.mutate,
};
}
+5 -4
View File
@@ -28,16 +28,17 @@ export function useRealtime(options: UseRealtimeOptions = {}) {
switch (entityType) {
case "task":
queryKeys.push(["tasks"], ["tasks-due"], ["stats"], ["productivity-chart"], ["analytics-daily"], ["analytics-projects"]);
// Singular keys (e.g. ["task", id]) refresh open detail pages live.
queryKeys.push(["tasks"], ["task"], ["tasks-due"], ["stats"], ["productivity-chart"], ["analytics-daily"], ["analytics-projects"]);
break;
case "habit":
queryKeys.push(["habits"], ["habits-today"], ["streaks"], ["analytics-habits"]);
queryKeys.push(["habits"], ["habit"], ["habits-today"], ["streaks"], ["analytics-habits"]);
break;
case "project":
queryKeys.push(["projects"], ["active-projects"], ["analytics-projects"]);
queryKeys.push(["projects"], ["project"], ["active-projects"], ["analytics-projects"]);
break;
case "note":
queryKeys.push(["notes"], ["recent-notes"]);
queryKeys.push(["notes"], ["note"], ["recent-notes"]);
break;
case "calendar_event":
queryKeys.push(["calendar-events"], ["upcoming-events"]);