mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
fix(coach): make a deleted conversation stay deleted
Deleting a conversation hides the row and sends the DELETE only after the six-second undo window. The pending delete lived in a timer alone, so a reload, a closed tab or an installed app sent to the background inside the window dropped it without a request, and the conversation came back. When the window did run out, the hide filter was released while the paginated history still held the row, so it showed again until the refetch landed. Every surface that deletes a conversation (the Coach panel, the conversations page, the settings memory card) shares one hook, and the fix lives there: - a scheduled delete commits early when the page is hidden, on pagehide and on unmount, and the request is sent with keepalive - a committed row leaves every list cache, head list and every history search variant, before anything else changes, and stays hidden - deletes not yet confirmed are kept in sessionStorage; the next page hides them and sends them again, so its list read cannot bring the row back before the old page's request lands; a 404 counts as gone - a failed delete restores the lists and says so instead of the row quietly returning - Undo after the commit reports false, so the rail does not reopen a thread that is gone
This commit is contained in:
1 parent
8425e61a0d
commit
359f11fe4d
11 files changed
+683
-94
No files matched your search
@@ -0,0 +1,188 @@
|
||||
import { expect, test } from "./setup/test";
|
||||
import type { Route } from "@playwright/test";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { hash } from "@node-rs/argon2";
|
||||
import pg from "pg";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
|
||||
|
||||
const KEEP_ID = "conversation-delete-keep";
|
||||
const DROP_ID = "conversation-delete-drop";
|
||||
|
||||
function row(id: string, title: string) {
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
createdAt: "2026-07-20T10:00:00.000Z",
|
||||
updatedAt: "2026-07-20T10:00:00.000Z",
|
||||
messageCount: 2,
|
||||
fenced: false,
|
||||
attachments: [],
|
||||
documentTitle: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A deleted conversation must stay deleted. The delete waits out an undo
|
||||
* window before it is sent; a reload inside that window used to drop it, and
|
||||
* once the window ran out the row showed again until the refetch landed.
|
||||
*/
|
||||
test.describe("Coach conversation delete", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await serveAiBlock(page, aiBlockAvailable());
|
||||
});
|
||||
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
async function serveConversations(
|
||||
page: import("@playwright/test").Page,
|
||||
deleteDelayMs: number,
|
||||
) {
|
||||
const state = {
|
||||
rows: [row(KEEP_ID, "Kept thread"), row(DROP_ID, "Dropped thread")],
|
||||
deletes: [] as string[],
|
||||
};
|
||||
// Context-level, so a `keepalive` request sent while the page unloads is
|
||||
// still answered here.
|
||||
await page
|
||||
.context()
|
||||
.route(
|
||||
/\/api\/insights\/chat(?:\/[^?]+)?(?:\?|$)/,
|
||||
async (route: Route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
if (request.method() === "DELETE") {
|
||||
const id = decodeURIComponent(url.pathname.split("/").pop() ?? "");
|
||||
state.deletes.push(id);
|
||||
state.rows = state.rows.filter((r) => r.id !== id);
|
||||
if (deleteDelayMs > 0) await delay(deleteDelayMs);
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ data: { deleted: true }, error: null }),
|
||||
});
|
||||
}
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: { conversations: state.rows, nextCursor: null },
|
||||
error: null,
|
||||
}),
|
||||
});
|
||||
},
|
||||
);
|
||||
return state;
|
||||
}
|
||||
|
||||
test("the row does not come back when the window runs out", async ({
|
||||
page,
|
||||
}) => {
|
||||
// A slow DELETE holds the refetch back, which is when the row used to
|
||||
// reappear.
|
||||
const state = await serveConversations(page, 2_000);
|
||||
await page.goto("/coach/conversations", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const items = page.locator('[data-slot="coach-conversations-item"]');
|
||||
await expect(items).toHaveCount(2, { timeout: 10_000 });
|
||||
await items
|
||||
.filter({ hasText: "Dropped thread" })
|
||||
.locator('[data-slot="coach-conversations-delete"]')
|
||||
.click();
|
||||
await expect(items).toHaveCount(1);
|
||||
|
||||
await expect
|
||||
.poll(() => state.deletes, { timeout: 15_000 })
|
||||
.toEqual([DROP_ID]);
|
||||
// Sampled while the DELETE is still in flight.
|
||||
for (let i = 0; i < 6; i += 1) {
|
||||
expect(await items.count()).toBe(1);
|
||||
await delay(250);
|
||||
}
|
||||
await expect(items).toContainText("Kept thread");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The real server and database, not a mock: the request that matters here is
|
||||
* sent with `keepalive` while the page unloads, which Playwright's routing
|
||||
* never sees. The account is the spec's own, so the conversation it seeds
|
||||
* cannot surface in another spec's Coach.
|
||||
*/
|
||||
test.describe("Coach conversation delete against the database", () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test("a reload inside the undo window still deletes", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const dbUrl = process.env.DATABASE_URL;
|
||||
test.skip(!dbUrl, "DATABASE_URL is required to seed the conversation");
|
||||
|
||||
const suffix = `${testInfo.project.name}-${randomBytes(4).toString("hex")}`;
|
||||
const username = `e2e-coach-delete-${suffix}`;
|
||||
const password = "Cd5!Vq8nTz3wLp7K";
|
||||
const userId = `e2e-coach-delete-user-${suffix}`;
|
||||
const conversationId = `e2e-coach-delete-${suffix}`;
|
||||
const pool = new pg.Pool({ connectionString: dbUrl });
|
||||
try {
|
||||
await pool.query(
|
||||
`INSERT INTO users
|
||||
(id, username, email, password_hash, role, created_at, updated_at,
|
||||
onboarding_completed_at, onboarding_tour_completed)
|
||||
VALUES ($1, $2, $3, $4, 'USER', now(), now(), now(), true)`,
|
||||
[
|
||||
userId,
|
||||
username,
|
||||
`${username}@healthlog.test`,
|
||||
await hash(password, {
|
||||
memoryCost: 19456,
|
||||
timeCost: 2,
|
||||
outputLen: 32,
|
||||
parallelism: 1,
|
||||
}),
|
||||
],
|
||||
);
|
||||
// A legacy readable title keeps the seed free of the encryption key.
|
||||
await pool.query(
|
||||
`INSERT INTO coach_conversations (id, user_id, title, updated_at)
|
||||
VALUES ($1, $2, 'Dropped thread', now())`,
|
||||
[conversationId, userId],
|
||||
);
|
||||
|
||||
const login = await page.request.post("/api/auth/login", {
|
||||
data: { email: username, password },
|
||||
});
|
||||
expect(login.status()).toBe(200);
|
||||
|
||||
await page.goto("/coach/conversations", {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
const items = page.locator('[data-slot="coach-conversations-item"]');
|
||||
await expect(items).toHaveCount(1, { timeout: 15_000 });
|
||||
await items.locator('[data-slot="coach-conversations-delete"]').click();
|
||||
await expect(items).toHaveCount(0);
|
||||
|
||||
// Well inside the undo window.
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const { rowCount } = await pool.query(
|
||||
"SELECT 1 FROM coach_conversations WHERE id = $1",
|
||||
[conversationId],
|
||||
);
|
||||
return rowCount;
|
||||
})
|
||||
.toBe(0);
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-conversations-empty"]'),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
await expect(items).toHaveCount(0);
|
||||
} finally {
|
||||
await pool.query("DELETE FROM users WHERE id = $1", [userId]);
|
||||
await pool.end();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2909,6 +2909,7 @@
|
||||
"historyEmpty": "Deine Unterhaltungen erscheinen hier. Stell eine Frage, um zu starten.",
|
||||
"historyDeleteAria": "Unterhaltung löschen",
|
||||
"historyDeleted": "Unterhaltung gelöscht",
|
||||
"historyDeleteFailed": "Die Unterhaltung konnte nicht gelöscht werden.",
|
||||
"rename": {
|
||||
"action": "Unterhaltung umbenennen",
|
||||
"input": "Titel der Unterhaltung",
|
||||
|
||||
@@ -2909,6 +2909,7 @@
|
||||
"historyEmpty": "Your conversations will appear here. Ask anything to start.",
|
||||
"historyDeleteAria": "Delete conversation",
|
||||
"historyDeleted": "Conversation deleted",
|
||||
"historyDeleteFailed": "The conversation could not be deleted.",
|
||||
"rename": {
|
||||
"action": "Rename conversation",
|
||||
"input": "Conversation title",
|
||||
|
||||
@@ -2909,6 +2909,7 @@
|
||||
"historyEmpty": "Tus conversaciones aparecen aquí. Lanza una pregunta para empezar.",
|
||||
"historyDeleteAria": "Eliminar conversación",
|
||||
"historyDeleted": "Conversación eliminada",
|
||||
"historyDeleteFailed": "No se pudo eliminar la conversación.",
|
||||
"rename": {
|
||||
"action": "Renombrar conversación",
|
||||
"input": "Título de la conversación",
|
||||
|
||||
@@ -2909,6 +2909,7 @@
|
||||
"historyEmpty": "Tes conversations s’affichent ici. Lance une question pour commencer.",
|
||||
"historyDeleteAria": "Supprimer la conversation",
|
||||
"historyDeleted": "Conversation supprimée",
|
||||
"historyDeleteFailed": "La conversation n’a pas pu être supprimée.",
|
||||
"rename": {
|
||||
"action": "Renommer la conversation",
|
||||
"input": "Titre de la conversation",
|
||||
|
||||
@@ -2909,6 +2909,7 @@
|
||||
"historyEmpty": "Le tue conversazioni compaiono qui. Fai una domanda per iniziare.",
|
||||
"historyDeleteAria": "Elimina conversazione",
|
||||
"historyDeleted": "Conversazione eliminata",
|
||||
"historyDeleteFailed": "Non è stato possibile eliminare la conversazione.",
|
||||
"rename": {
|
||||
"action": "Rinomina conversazione",
|
||||
"input": "Titolo della conversazione",
|
||||
|
||||
@@ -2909,6 +2909,7 @@
|
||||
"historyEmpty": "대화가 여기에 표시돼요. 무엇이든 물어보며 시작하세요.",
|
||||
"historyDeleteAria": "대화 삭제",
|
||||
"historyDeleted": "대화를 삭제했어요",
|
||||
"historyDeleteFailed": "대화를 삭제하지 못했어요.",
|
||||
"rename": {
|
||||
"action": "대화 이름 바꾸기",
|
||||
"input": "대화 제목",
|
||||
|
||||
@@ -2909,6 +2909,7 @@
|
||||
"historyEmpty": "Twoje rozmowy pojawią się tutaj. Zadaj pytanie, aby zacząć.",
|
||||
"historyDeleteAria": "Usuń rozmowę",
|
||||
"historyDeleted": "Rozmowa usunięta",
|
||||
"historyDeleteFailed": "Nie udało się usunąć rozmowy.",
|
||||
"rename": {
|
||||
"action": "Zmień nazwę rozmowy",
|
||||
"input": "Tytuł rozmowy",
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type {
|
||||
CoachConversationDTO,
|
||||
CoachConversationsPage,
|
||||
} from "@/lib/ai/coach/types";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
CONVERSATION_DELETE_UNDO_MS,
|
||||
commitCoachConversationDelete,
|
||||
createCoachConversationDeleteQueue,
|
||||
deleteCoachConversationRequest,
|
||||
removeCoachConversationFromCaches,
|
||||
} from "../use-coach";
|
||||
|
||||
function conversation(id: string): CoachConversationDTO {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
createdAt: "2026-07-20T10:00:00.000Z",
|
||||
updatedAt: "2026-07-20T10:00:00.000Z",
|
||||
messageCount: 2,
|
||||
fenced: false,
|
||||
};
|
||||
}
|
||||
|
||||
function page(
|
||||
...conversations: CoachConversationDTO[]
|
||||
): CoachConversationsPage {
|
||||
return { conversations, nextCursor: null };
|
||||
}
|
||||
|
||||
describe("Coach conversation delete queue", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("commits when the undo window runs out", () => {
|
||||
const commit = vi.fn();
|
||||
const queue = createCoachConversationDeleteQueue(commit);
|
||||
queue.request("c1");
|
||||
vi.advanceTimersByTime(CONVERSATION_DELETE_UNDO_MS - 1);
|
||||
expect(commit).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(commit).toHaveBeenCalledExactlyOnceWith("c1");
|
||||
});
|
||||
|
||||
it("commits a scheduled delete when the page goes away inside the window", () => {
|
||||
// The defect: a delete followed by a reload, a closed tab or a
|
||||
// backgrounded app inside the window lived only in a timer that never
|
||||
// fired, so no request was sent and the conversation came back.
|
||||
const commit = vi.fn();
|
||||
const queue = createCoachConversationDeleteQueue(commit);
|
||||
queue.request("c1");
|
||||
queue.request("c2");
|
||||
queue.flush();
|
||||
expect(commit.mock.calls).toEqual([["c1"], ["c2"]]);
|
||||
|
||||
// Committed once: neither the timer nor a second flush repeats it.
|
||||
vi.advanceTimersByTime(CONVERSATION_DELETE_UNDO_MS);
|
||||
queue.flush();
|
||||
expect(commit).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("undo cancels inside the window and reports false once committed", () => {
|
||||
const commit = vi.fn();
|
||||
const queue = createCoachConversationDeleteQueue(commit);
|
||||
queue.request("c1");
|
||||
expect(queue.undo("c1")).toBe(true);
|
||||
queue.flush();
|
||||
vi.advanceTimersByTime(CONVERSATION_DELETE_UNDO_MS);
|
||||
expect(commit).not.toHaveBeenCalled();
|
||||
|
||||
queue.request("c2");
|
||||
queue.flush();
|
||||
expect(queue.undo("c2")).toBe(false);
|
||||
expect(commit).toHaveBeenCalledExactlyOnceWith("c2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Coach conversation delete caches and request", () => {
|
||||
const originalFetch = global.fetch;
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("removes the row from the head list and every history variant at once", () => {
|
||||
// The hide filter is released only after this, so no cached list may
|
||||
// still hold the row: the paginated history used to keep it until the
|
||||
// refetch, and the row reappeared for that long.
|
||||
const client = new QueryClient();
|
||||
client.setQueryData(
|
||||
queryKeys.coachConversations(),
|
||||
page(conversation("c1"), conversation("c2")),
|
||||
);
|
||||
client.setQueryData(queryKeys.coachConversationHistory(""), {
|
||||
pages: [page(conversation("c2")), page(conversation("c1"))],
|
||||
pageParams: [null, "cursor-1"],
|
||||
});
|
||||
client.setQueryData(queryKeys.coachConversationHistory("c1"), {
|
||||
pages: [page(conversation("c1"))],
|
||||
pageParams: [null],
|
||||
});
|
||||
|
||||
const snapshot = removeCoachConversationFromCaches(client, "c1");
|
||||
|
||||
expect(
|
||||
client
|
||||
.getQueryData<CoachConversationsPage>(queryKeys.coachConversations())
|
||||
?.conversations.map((c) => c.id),
|
||||
).toEqual(["c2"]);
|
||||
for (const search of ["", "c1"]) {
|
||||
const data = client.getQueryData<{ pages: CoachConversationsPage[] }>(
|
||||
queryKeys.coachConversationHistory(search),
|
||||
);
|
||||
expect(
|
||||
data?.pages.flatMap((p) => p.conversations.map((c) => c.id)),
|
||||
).not.toContain("c1");
|
||||
}
|
||||
expect(snapshot).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("sends the DELETE with keepalive so it outlives the page", async () => {
|
||||
const fetchMock = vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ data: { deleted: true }, error: null }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
global.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
await deleteCoachConversationRequest("c 1");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url, init] = fetchMock.mock.calls[0] as unknown as [
|
||||
string,
|
||||
RequestInit,
|
||||
];
|
||||
expect(url).toBe("/api/insights/chat/c%201");
|
||||
expect(init.method).toBe("DELETE");
|
||||
expect(init.keepalive).toBe(true);
|
||||
});
|
||||
|
||||
function seeded(): QueryClient {
|
||||
const client = new QueryClient();
|
||||
client.setQueryData(
|
||||
queryKeys.coachConversations(),
|
||||
page(conversation("c1"), conversation("c2")),
|
||||
);
|
||||
return client;
|
||||
}
|
||||
|
||||
function respond(status: number) {
|
||||
global.fetch = vi.fn(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify(
|
||||
status === 200
|
||||
? { data: { deleted: true }, error: null }
|
||||
: { data: null, error: "coach.conversation.notFound" },
|
||||
),
|
||||
{ status, headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
const headIds = (client: QueryClient) =>
|
||||
client
|
||||
.getQueryData<CoachConversationsPage>(queryKeys.coachConversations())
|
||||
?.conversations.map((c) => c.id);
|
||||
|
||||
it("treats a 404 as already deleted", async () => {
|
||||
// A delete re-sent after a reload meets a row the first request already
|
||||
// removed.
|
||||
respond(404);
|
||||
const client = seeded();
|
||||
const onFailed = vi.fn();
|
||||
commitCoachConversationDelete(client, "c1", onFailed);
|
||||
await vi.waitFor(() => expect(global.fetch).toHaveBeenCalledOnce());
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(onFailed).not.toHaveBeenCalled();
|
||||
expect(headIds(client)).toEqual(["c2"]);
|
||||
});
|
||||
|
||||
it("restores the lists and reports a failed delete", async () => {
|
||||
respond(500);
|
||||
const client = seeded();
|
||||
const onFailed = vi.fn();
|
||||
commitCoachConversationDelete(client, "c1", onFailed);
|
||||
expect(headIds(client)).toEqual(["c2"]);
|
||||
await vi.waitFor(() => expect(onFailed).toHaveBeenCalledOnce());
|
||||
expect(headIds(client)).toEqual(["c1", "c2"]);
|
||||
});
|
||||
});
|
||||
@@ -121,8 +121,9 @@ export function HistoryRail({
|
||||
action: {
|
||||
label: t("common.undo"),
|
||||
onClick: () => {
|
||||
undoDelete(id);
|
||||
if (wasActive) onUndoDeleteActive?.(id);
|
||||
// A delete already committed (window over, or the page was
|
||||
// hidden meanwhile) has nothing to reopen.
|
||||
if (undoDelete(id) && wasActive) onUndoDeleteActive?.(id);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import {
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
@@ -31,6 +38,7 @@ import type {
|
||||
import type { CoachSuggestedAction } from "@/lib/ai/coach/suggest-action";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
ApiError,
|
||||
apiDelete,
|
||||
apiFetchRaw,
|
||||
apiGet,
|
||||
@@ -285,121 +293,307 @@ export function useCoachConversation(id: string | null) {
|
||||
});
|
||||
}
|
||||
|
||||
function removeConversationFromCache(data: unknown, id: string): unknown {
|
||||
if (!data || typeof data !== "object") return data;
|
||||
|
||||
if ("pages" in data && Array.isArray(data.pages)) {
|
||||
let changed = false;
|
||||
const pages = data.pages.map((page) => {
|
||||
const next = removeConversationFromCache(page, id);
|
||||
changed ||= next !== page;
|
||||
return next;
|
||||
});
|
||||
return changed ? { ...data, pages } : data;
|
||||
}
|
||||
|
||||
if ("conversations" in data && Array.isArray(data.conversations)) {
|
||||
const conversations = data.conversations.filter(
|
||||
(conversation) =>
|
||||
!(
|
||||
conversation &&
|
||||
typeof conversation === "object" &&
|
||||
"id" in conversation &&
|
||||
conversation.id === id
|
||||
),
|
||||
);
|
||||
return conversations.length === data.conversations.length
|
||||
? data
|
||||
: { ...data, conversations };
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a conversation with optimistic removal from the rail cache.
|
||||
* Rolls back if the server responds with a non-2xx.
|
||||
* Drop one conversation from every materialised list cache: the head list
|
||||
* the auto-open reads and every search variant of the paginated history
|
||||
* below the same prefix. Synchronous on purpose, so the row is gone from the
|
||||
* caches before any hide filter that covered it is released. Returns the
|
||||
* previous data for a rollback.
|
||||
*/
|
||||
export function useDeleteCoachConversation() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
await apiDelete(`/api/insights/chat/${id}`);
|
||||
return id;
|
||||
},
|
||||
onMutate: async (id: string) => {
|
||||
await queryClient.cancelQueries({ queryKey: QUERY_KEYS.list() });
|
||||
const previous = queryClient.getQueryData<CoachConversationsPage>(
|
||||
QUERY_KEYS.list(),
|
||||
);
|
||||
if (previous) {
|
||||
queryClient.setQueryData<CoachConversationsPage>(QUERY_KEYS.list(), {
|
||||
...previous,
|
||||
conversations: previous.conversations.filter((c) => c.id !== id),
|
||||
});
|
||||
}
|
||||
return { previous };
|
||||
},
|
||||
onError: (_err, _id, ctx) => {
|
||||
if (ctx?.previous) {
|
||||
queryClient.setQueryData(QUERY_KEYS.list(), ctx.previous);
|
||||
}
|
||||
},
|
||||
onSettled: (_data, _err, id) => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.list() });
|
||||
if (typeof id === "string") {
|
||||
queryClient.removeQueries({ queryKey: QUERY_KEYS.one(id) });
|
||||
}
|
||||
},
|
||||
export function removeCoachConversationFromCaches(
|
||||
queryClient: QueryClient,
|
||||
id: string,
|
||||
): CoachConversationRenameSnapshot {
|
||||
void queryClient.cancelQueries({ queryKey: QUERY_KEYS.list() });
|
||||
const snapshot = queryClient.getQueriesData({ queryKey: QUERY_KEYS.list() });
|
||||
for (const [key, previous] of snapshot) {
|
||||
queryClient.setQueryData(key, removeConversationFromCache(previous, id));
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* The DELETE itself. `keepalive` lets the request outlive the page, which is
|
||||
* what a delete committed from `pagehide` or a backgrounded app needs.
|
||||
*/
|
||||
export function deleteCoachConversationRequest(id: string): Promise<unknown> {
|
||||
return apiDelete(`/api/insights/chat/${encodeURIComponent(id)}`, undefined, {
|
||||
keepalive: true,
|
||||
});
|
||||
}
|
||||
|
||||
// v1.30.1 M5 — delayed-commit delete window, in milliseconds. Long
|
||||
// enough to read + tap Undo, short enough that the eventual DELETE
|
||||
// isn't a surprise days later.
|
||||
const CONVERSATION_DELETE_UNDO_MS = 6000;
|
||||
export const CONVERSATION_DELETE_UNDO_MS = 6000;
|
||||
|
||||
export interface CoachConversationDeleteQueue {
|
||||
/** Schedule the delete; it commits when the window runs out. */
|
||||
request(id: string): void;
|
||||
/** Cancel a scheduled delete. False once it has been committed. */
|
||||
undo(id: string): boolean;
|
||||
/** Commit every scheduled delete now. */
|
||||
flush(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.30.1 M5 — replaces the old "tap once to arm, tap the same row
|
||||
* again to delete" confirm, which never disarmed (a row armed minutes
|
||||
* earlier deleted on a later stray tap) and had no way back once fired.
|
||||
* Mirrors the Documents delete-with-undo pattern instead: a single tap
|
||||
* hides the row immediately and schedules the real
|
||||
* The undo window as plain state, apart from React. A delete that is still
|
||||
* waiting when the page goes away is committed by `flush`, never dropped:
|
||||
* the window exists to offer an Undo, not to make the delete conditional on
|
||||
* the tab staying open for another six seconds.
|
||||
*/
|
||||
export function createCoachConversationDeleteQueue(
|
||||
commit: (id: string) => void,
|
||||
delayMs: number = CONVERSATION_DELETE_UNDO_MS,
|
||||
): CoachConversationDeleteQueue {
|
||||
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const fire = (id: string) => {
|
||||
const timer = timers.get(id);
|
||||
if (timer === undefined) return;
|
||||
clearTimeout(timer);
|
||||
timers.delete(id);
|
||||
commit(id);
|
||||
};
|
||||
return {
|
||||
request(id) {
|
||||
if (timers.has(id)) return;
|
||||
timers.set(
|
||||
id,
|
||||
setTimeout(() => fire(id), delayMs),
|
||||
);
|
||||
},
|
||||
undo(id) {
|
||||
const timer = timers.get(id);
|
||||
if (timer === undefined) return false;
|
||||
clearTimeout(timer);
|
||||
timers.delete(id);
|
||||
return true;
|
||||
},
|
||||
flush() {
|
||||
for (const id of [...timers.keys()]) fire(id);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Conversations this page has deleted or is deleting, shared by every
|
||||
* surface: a row hidden in the Coach panel is hidden in the settings card
|
||||
* too. An id stays here once its delete went through; the server no longer
|
||||
* has the row, so hiding it costs nothing and closes the gap before the
|
||||
* refetch lands.
|
||||
*/
|
||||
let hiddenConversationIds: ReadonlySet<string> = new Set();
|
||||
const NO_HIDDEN_CONVERSATIONS: ReadonlySet<string> = new Set();
|
||||
const hiddenConversationListeners = new Set<() => void>();
|
||||
|
||||
function setConversationHidden(id: string, hidden: boolean): void {
|
||||
if (hiddenConversationIds.has(id) === hidden) return;
|
||||
const next = new Set(hiddenConversationIds);
|
||||
if (hidden) next.add(id);
|
||||
else next.delete(id);
|
||||
hiddenConversationIds = next;
|
||||
for (const listener of hiddenConversationListeners) listener();
|
||||
}
|
||||
|
||||
function subscribeHiddenConversations(listener: () => void): () => void {
|
||||
hiddenConversationListeners.add(listener);
|
||||
return () => hiddenConversationListeners.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes sent but not yet confirmed, kept in `sessionStorage` so they
|
||||
* outlive a reload. A reload inside the undo window sends the DELETE with
|
||||
* `keepalive` from the old page, and the new page's list read can reach the
|
||||
* server before it: without the journal that read brings the row back. The
|
||||
* new page hides every journaled id and sends its DELETE again; a 404 then
|
||||
* means it is already gone.
|
||||
*/
|
||||
const DELETE_JOURNAL_KEY = "healthlog:coach-conversation-deletes";
|
||||
|
||||
function readDeleteJournal(): string[] {
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(DELETE_JOURNAL_KEY);
|
||||
const parsed: unknown = raw ? JSON.parse(raw) : [];
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((id): id is string => typeof id === "string")
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeDeleteJournal(id: string, present: boolean): void {
|
||||
try {
|
||||
const ids = new Set(readDeleteJournal());
|
||||
if (present) ids.add(id);
|
||||
else ids.delete(id);
|
||||
if (ids.size === 0) {
|
||||
window.sessionStorage.removeItem(DELETE_JOURNAL_KEY);
|
||||
} else {
|
||||
window.sessionStorage.setItem(
|
||||
DELETE_JOURNAL_KEY,
|
||||
JSON.stringify([...ids]),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Storage unavailable: the keepalive request still carries the delete.
|
||||
}
|
||||
}
|
||||
|
||||
const deletesInFlight = new Set<string>();
|
||||
|
||||
/**
|
||||
* Send one delete. Synchronous up to the request, so a commit made from
|
||||
* `pagehide` is on the wire before the page is torn down. Every list cache
|
||||
* drops the row first; a failure restores them, shows the row again and
|
||||
* calls `onFailed`. A 404 is success: the row is gone, which is the point.
|
||||
*/
|
||||
export function commitCoachConversationDelete(
|
||||
queryClient: QueryClient,
|
||||
id: string,
|
||||
onFailed: () => void,
|
||||
): void {
|
||||
if (deletesInFlight.has(id)) return;
|
||||
deletesInFlight.add(id);
|
||||
setConversationHidden(id, true);
|
||||
writeDeleteJournal(id, true);
|
||||
const request = deleteCoachConversationRequest(id);
|
||||
const snapshot = removeCoachConversationFromCaches(queryClient, id);
|
||||
request
|
||||
.catch((error: unknown) => {
|
||||
if (error instanceof ApiError && error.status === 404) return;
|
||||
throw error;
|
||||
})
|
||||
.then(
|
||||
() => {
|
||||
queryClient.removeQueries({ queryKey: QUERY_KEYS.one(id) });
|
||||
},
|
||||
() => {
|
||||
restoreCoachConversationRename(queryClient, snapshot);
|
||||
setConversationHidden(id, false);
|
||||
onFailed();
|
||||
},
|
||||
)
|
||||
.finally(() => {
|
||||
deletesInFlight.delete(id);
|
||||
writeDeleteJournal(id, false);
|
||||
void queryClient.invalidateQueries({ queryKey: QUERY_KEYS.list() });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.30.1 M5 — one tap hides the row and schedules the real
|
||||
* `DELETE /api/insights/chat/[id]` after `CONVERSATION_DELETE_UNDO_MS`;
|
||||
* calling `undoDelete` within that window cancels the network call and
|
||||
* un-hides the row. `pendingDeleteIds` is a client-only hide filter —
|
||||
* consumers subtract it from whatever list they render.
|
||||
* `undoDelete` within that window cancels it. `pendingDeleteIds` is the
|
||||
* hide filter every surface subtracts from what it renders.
|
||||
*
|
||||
* Centralised here (rather than duplicated in the rail + the standalone
|
||||
* `/coach/conversations` page, which previously carried byte-identical
|
||||
* arm/confirm logic) so both surfaces share one implementation.
|
||||
* The delete is never conditional on the page surviving the window. A
|
||||
* scheduled delete commits early when the page is hidden (an installed app
|
||||
* sent to the background, a tab switched away), on `pagehide` (reload,
|
||||
* close, navigation away) and when the owning component unmounts; the
|
||||
* request goes out with `keepalive`, and a delete the page could not confirm
|
||||
* is sent again by the next one (see the journal above). Before that, a
|
||||
* delete followed by a reload inside the window was never sent, and the
|
||||
* conversation came back.
|
||||
*
|
||||
* Shared by every surface that deletes a Coach conversation: the Coach
|
||||
* panel's rail, `/coach/conversations` and the settings memory card.
|
||||
*/
|
||||
export function useDeleteCoachConversationWithUndo() {
|
||||
const deleteMutation = useDeleteCoachConversation();
|
||||
const [pendingDeleteIds, setPendingDeleteIds] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslations();
|
||||
const pendingDeleteIds = useSyncExternalStore(
|
||||
subscribeHiddenConversations,
|
||||
() => hiddenConversationIds,
|
||||
() => NO_HIDDEN_CONVERSATIONS,
|
||||
);
|
||||
|
||||
// Kept current by an effect, so the queue below is created once and still
|
||||
// commits with the latest translator.
|
||||
const commitRef = useRef<(id: string) => void>(() => {});
|
||||
useEffect(() => {
|
||||
const failedMessage = t("insights.coach.historyDeleteFailed");
|
||||
commitRef.current = (id) =>
|
||||
commitCoachConversationDelete(queryClient, id, () =>
|
||||
toast.error(failedMessage),
|
||||
);
|
||||
});
|
||||
|
||||
// Created on first use, from a handler or an effect, never during render.
|
||||
const queueRef = useRef<CoachConversationDeleteQueue | null>(null);
|
||||
const getQueue = useCallback(
|
||||
() =>
|
||||
(queueRef.current ??= createCoachConversationDeleteQueue((id) =>
|
||||
commitRef.current(id),
|
||||
)),
|
||||
[],
|
||||
);
|
||||
const timers = useRef(new Map<string, ReturnType<typeof setTimeout>>());
|
||||
|
||||
useEffect(() => {
|
||||
// A closed drawer / unmounted page must not silently lose a
|
||||
// scheduled delete: fire any still-pending ones immediately rather
|
||||
// than leaking the timer (or worse, never deleting at all).
|
||||
const timerMap = timers.current;
|
||||
return () => {
|
||||
for (const [id, timer] of timerMap) {
|
||||
clearTimeout(timer);
|
||||
deleteMutation.mutate(id);
|
||||
}
|
||||
timerMap.clear();
|
||||
// Finish what a previous page of this tab could not confirm.
|
||||
for (const id of readDeleteJournal()) commitRef.current(id);
|
||||
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === "hidden") getQueue().flush();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- run once on unmount only
|
||||
}, []);
|
||||
const onPageHide = () => getQueue().flush();
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
window.addEventListener("pagehide", onPageHide);
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
window.removeEventListener("pagehide", onPageHide);
|
||||
// A closed drawer or a left page commits what it scheduled.
|
||||
getQueue().flush();
|
||||
};
|
||||
}, [getQueue]);
|
||||
|
||||
const requestDelete = useCallback(
|
||||
(id: string) => {
|
||||
setPendingDeleteIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(id);
|
||||
return next;
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
timers.current.delete(id);
|
||||
setPendingDeleteIds((prev) => {
|
||||
if (!prev.has(id)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
deleteMutation.mutate(id);
|
||||
}, CONVERSATION_DELETE_UNDO_MS);
|
||||
timers.current.set(id, timer);
|
||||
setConversationHidden(id, true);
|
||||
getQueue().request(id);
|
||||
},
|
||||
[deleteMutation],
|
||||
[getQueue],
|
||||
);
|
||||
|
||||
const undoDelete = useCallback((id: string) => {
|
||||
const timer = timers.current.get(id);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timers.current.delete(id);
|
||||
}
|
||||
setPendingDeleteIds((prev) => {
|
||||
if (!prev.has(id)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const undoDelete = useCallback(
|
||||
(id: string): boolean => {
|
||||
// Too late once committed: the row stays hidden, as the server has it.
|
||||
const undone = getQueue().undo(id);
|
||||
if (undone) setConversationHidden(id, false);
|
||||
return undone;
|
||||
},
|
||||
[getQueue],
|
||||
);
|
||||
|
||||
return { pendingDeleteIds, requestDelete, undoDelete };
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user