Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 172 additions & 0 deletions src/components/braindump/BrainDumpEditor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1157,3 +1157,175 @@ describe('BrainDumpEditor clear-on-complete (deferred linger)', () => {
)
})
})

describe('BrainDumpEditor completion toast — close button + display duration (#109)', () => {
beforeEach(() => {
vi.clearAllMocks()
completedMutateAsync.mockResolvedValue({ id: 1 })
// Reset the active floating category — the clamp spec leaves it on 1, but be
// explicit so a future cross-category spec here can't bleed state.
selectedCategoryRef.current = 1
})

it('shows the completion toast with a close button and the configured display duration', async () => {
// Arrange — clear-on-complete OFF (the always-shown toast path), with an
// 8 s display duration saved.
installBrainDumpAPI({
getVisibleOnAllWorkspaces: vi.fn().mockResolvedValue(false),
setVisibleOnAllWorkspaces: vi.fn().mockResolvedValue(true),
})
renderEditor({ braindumpToastDurationMs: 8000 })
const noteField = await screen.findByRole<HTMLTextAreaElement>('textbox')

// Act — complete a plain line.
fireCompleteCommandOnFirstLine(noteField, 'buy milk')

// Assert — the success toast carries the ✕ (closeButton) and stays for the
// saved 8000 ms, not the old fixed 5 s.
await waitFor(() => {
expect(toast.success).toHaveBeenCalled()
})
const toastOptions = vi.mocked(toast.success).mock.calls.at(-1)?.[1]
expect(toastOptions?.closeButton).toBe(true)
expect(toastOptions?.duration).toBe(8000)
})

it('phrases the Undo-window copy for the configured display duration', async () => {
// Arrange — an 8 s duration must read "8 s", not a hardcoded "5 s".
installBrainDumpAPI({
getVisibleOnAllWorkspaces: vi.fn().mockResolvedValue(false),
setVisibleOnAllWorkspaces: vi.fn().mockResolvedValue(true),
})
renderEditor({ braindumpToastDurationMs: 8000 })
const noteField = await screen.findByRole<HTMLTextAreaElement>('textbox')

// Act
fireCompleteCommandOnFirstLine(noteField, 'buy milk')

// Assert — the description names the actual undo window in seconds.
await waitFor(() => {
expect(toast.success).toHaveBeenCalled()
})
const toastOptions = vi.mocked(toast.success).mock.calls.at(-1)?.[1]
expect(toastOptions?.description).toBe(
'Undo stays here for 8 s if you need it.',
)
})

it('floors the Undo-window copy at a half-step duration so it never over-promises the Undo time', async () => {
// Arrange — a half-step 2500 ms duration (reachable via the slider's 500 ms
// step) must read "2 s" (floor), never "3 s" (round): the copy must never
// claim more Undo time than actually remains (FINDING-001 regret-safe floor).
installBrainDumpAPI({
getVisibleOnAllWorkspaces: vi.fn().mockResolvedValue(false),
setVisibleOnAllWorkspaces: vi.fn().mockResolvedValue(true),
})
renderEditor({ braindumpToastDurationMs: 2500 })
const noteField = await screen.findByRole<HTMLTextAreaElement>('textbox')

// Act
fireCompleteCommandOnFirstLine(noteField, 'buy milk')

// Assert — 2500 ms floors to "2 s", never the rounded-up "3 s".
await waitFor(() => {
expect(toast.success).toHaveBeenCalled()
})
const toastOptions = vi.mocked(toast.success).mock.calls.at(-1)?.[1]
expect(toastOptions?.description).toBe(
'Undo stays here for 2 s if you need it.',
)
})

it('keeps the close button and configured duration on the clear-on-complete toast', async () => {
// Arrange — clear-on-complete ON with instant clear and a 6 s duration: the
// SAME helper must wire the ✕ + duration on this second completion path too.
installBrainDumpAPI({
getVisibleOnAllWorkspaces: vi.fn().mockResolvedValue(false),
setVisibleOnAllWorkspaces: vi.fn().mockResolvedValue(true),
})
renderEditor({
braindumpClearOnComplete: true,
braindumpClearDelayMs: 0,
braindumpToastDurationMs: 6000,
})
const noteField = await screen.findByRole<HTMLTextAreaElement>('textbox')

// Act — complete the only line (it clears instantly).
fireCompleteCommandOnFirstLine(noteField, 'buy milk')
await waitFor(() => {
expect(noteField).toHaveValue('')
})

// Assert — the clear toast also has the ✕ and the saved 6000 ms duration.
const toastOptions = vi.mocked(toast.success).mock.calls.at(-1)?.[1]
expect(toastOptions?.closeButton).toBe(true)
expect(toastOptions?.duration).toBe(6000)
})

it('still restores the cleared line on Undo even though the toast now fires onDismiss on close', async () => {
// Arrange — clear-on-complete ON; the ✕ adds an onDismiss that BOTH a ✕ and an
// Undo trigger. Undo must still revert, and the trailing onDismiss must NOT
// confirm the win away (the call-site wasUndoCalled guard — CEO-D4).
installBrainDumpAPI({
getVisibleOnAllWorkspaces: vi.fn().mockResolvedValue(false),
setVisibleOnAllWorkspaces: vi.fn().mockResolvedValue(true),
})
renderEditor({
braindumpClearOnComplete: true,
braindumpClearDelayMs: 0,
braindumpToastDurationMs: 6000,
})
const noteField = await screen.findByRole<HTMLTextAreaElement>('textbox')
const value = 'keep me\n- [ ] buy milk'
fireEvent.change(noteField, { target: { value } })
const caret = value.length // caret at end of the second line
noteField.selectionStart = caret
noteField.selectionEnd = caret
fireEvent.keyDown(noteField, { key: 'Enter', metaKey: true })
await waitFor(() => {
expect(noteField).toHaveValue('keep me')
})

// Act — tap Undo, THEN let sonner fire onDismiss (it dismisses after the
// action runs); the guard must keep the restored line in place.
const toastOptions = vi.mocked(toast.success).mock.calls.at(-1)?.[1]
const undoAction = toastOptions?.action as
| { onClick: () => void }
| undefined
await act(async () => {
undoAction?.onClick()
})
act(() => {
toastOptions?.onDismiss?.({} as ToastT)
})

// Assert — the verbatim line returns at index 1 and stays there.
expect(noteField).toHaveValue('keep me\n- [ ] buy milk')
})

it('clamps the clear linger down to the shorter toast duration so a line never outlasts its Undo', async () => {
// Arrange — a clear delay (300 ms) LONGER than the toast duration (100 ms).
// The runtime min() must remove the line when the toast (and its Undo) closes
// at 100 ms, never letting it linger the full 300 ms (#109 replaces #108's
// fixed ceiling). In production this is the clearDelay-5000 vs toast-2000 case.
installBrainDumpAPI({
getVisibleOnAllWorkspaces: vi.fn().mockResolvedValue(false),
setVisibleOnAllWorkspaces: vi.fn().mockResolvedValue(true),
})
renderEditor({
braindumpClearOnComplete: true,
braindumpClearDelayMs: 300,
braindumpToastDurationMs: 100,
})
const noteField = await screen.findByRole<HTMLTextAreaElement>('textbox')

// Act — complete line 0; the deferred path leaves it on screen for now.
fireCompleteCommandOnFirstLine(noteField, 'buy milk\nkeep me')
expect(noteField).toHaveValue('buy milk\nkeep me')

// Assert — after 150 ms (past the 100 ms toast, before the 300 ms delay) the
// line is already gone: the clamp picked the shorter toast duration.
await new Promise((resolve) => setTimeout(resolve, 150))
expect(noteField).toHaveValue('keep me')
})
})
156 changes: 117 additions & 39 deletions src/components/braindump/BrainDumpEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ import {
BRAINDUMP_OPACITY_MAX,
BRAINDUMP_OPACITY_MIN,
BRAINDUMP_OPACITY_STEP,
BRAINDUMP_TOAST_UNDO_MS,
} from '@/lib/constants/braindump'
import { log } from '@/lib/logger'
import { orpc } from '@/lib/orpc/client-query'
Expand All @@ -38,6 +37,7 @@ import {
selectBraindumpFontFamily,
selectBraindumpFontSize,
selectBraindumpTextColor,
selectBraindumpToastDurationMs,
} from '@/lib/redux/slices/preferencesSlice'
import { broadcastTodoSync } from '@/lib/todo-sync-channel'
import type { Category, CategoryWithCount } from '@/server/schemas/category'
Expand Down Expand Up @@ -178,6 +178,65 @@ function findCheckedLineIndexByTitle(
return null
}

/**
* Build the BrainDump completion toast — the `Completed: <title>` success toast
* with an Undo action AND a close (✕) button. Both completion paths
* (`promoteLineToCompleted`, `completeAndClearLine`) call this so the ✕, the
* configurable display duration, the dynamic Undo-window copy, and the Undo
* wiring live in ONE place and can't drift between the two sites (#109).
*
* The two sites differ only in their dismiss bookkeeping, so `onAutoClose` /
* `onDismiss` are passed in: site A ties its line-clear to `onAutoClose` and has
* no `onDismiss`; site B's clear is timer-independent and runs the same cleanup
* on BOTH auto-close and a ✕-close. Sonner can't distinguish a ✕-close from an
* Undo-close (both fire `onDismiss`), so that disambiguation is owned by the call
* site (which captures the Undo handler), never by this helper.
*
* @param params - Toast inputs.
* @param params.title - Already-normalised completed title (rendered `Completed: <title>`).
* @param params.durationMs - How long the toast stays before auto-dismiss (the user pref).
* @param params.onUndo - Runs when the user clicks Undo; the toast is dismissed right after.
* @param params.onAutoClose - Optional: runs when the toast times out (NOT on ✕/Undo).
* @param params.onDismiss - Optional: runs on a manual close (✕ OR Undo — caller guards).
* @returns The sonner toast id, so the caller can dismiss it later (e.g. on a late create failure).
* @example
* const id = showCompletionToast({ title, durationMs: 5000, onUndo: () => revert() })
*/
function showCompletionToast({
title,
durationMs,
onUndo,
onAutoClose,
onDismiss,
}: {
title: BrainDumpCompletedTitle
durationMs: number
onUndo: () => void
onAutoClose?: () => void
onDismiss?: () => void
}): string | number {
const toastId = toast.success(`Completed: ${title}`, {
// Dynamic Undo-window copy in the quiet-companion voice — once the duration
// is user-configurable a fixed "5 s" would be wrong at every non-default
// setting (#109). floor (not round) keeps the promise regret-safe: at a
// half-step like 2500ms it reads "2 s" (under), never "3 s" (over), so the
// Undo never expires earlier than the copy says.
description: `Undo stays here for ${Math.floor(durationMs / 1000)} s if you need it.`,
duration: durationMs,
closeButton: true,
action: {
label: 'Undo',
onClick: () => {
onUndo()
toast.dismiss(toastId)
},
},
onAutoClose,
onDismiss,
})
return toastId
}

/**
* BrainDumpEditor — frameless transparent panel that pairs a category picker
* with a freeform textarea using markdown checkboxes.
Expand Down Expand Up @@ -233,6 +292,9 @@ export const BrainDumpEditor = function BrainDumpEditor({
// When ON, a finished line is dropped once its undo window closes (see the
// toast's onAutoClose in promoteLineToCompleted). Default OFF keeps every line.
const clearOnComplete = useAppSelector(selectBraindumpClearOnComplete)
const braindumpToastDurationMs = useAppSelector(
selectBraindumpToastDurationMs,
)
// How long the finished line lingers before it's removed (clear-on-complete ON
// path). 0 = remove instantly (prior behaviour); >0 defers the removal by this
// many ms so the eye registers the completion first (#108). Clamped ≤ the Undo
Expand Down Expand Up @@ -664,29 +726,26 @@ export const BrainDumpEditor = function BrainDumpEditor({
})
await syncCompletedAcrossViews()

const undoToastId = toast.success(`Completed: ${safeTitle}`, {
description: 'Tap Undo within 5 s to revert.',
duration: BRAINDUMP_TOAST_UNDO_MS,
action: {
label: 'Undo',
onClick: () => {
// Read latest text via ref so the user's keystrokes between
// creation and undo are preserved. Pass the captured title so
// undoCompleted can re-resolve the current line index even if
// lines have drifted.
void undoCompleted(
safeTitle,
completedId,
noteTextRef.current,
lineIndex,
)
toast.dismiss(undoToastId)
},
showCompletionToast({
title: safeTitle,
durationMs: braindumpToastDurationMs,
// Read latest text via ref so the user's keystrokes between creation and
// undo are preserved. Pass the captured title so undoCompleted can
// re-resolve the current line index even if lines have drifted.
onUndo: () => {
void undoCompleted(
safeTitle,
completedId,
noteTextRef.current,
lineIndex,
)
},
// Clear-on-complete: when the toast auto-closes (the undo window elapsed
// without an Undo), drop the finished line so the scratchpad clears as
// you go. Sonner fires onAutoClose ONLY on the timeout — an Undo click
// dismisses (onDismiss) instead, so undone completions are never cleared.
// you go. Sonner fires onAutoClose ONLY on the timeout — an Undo OR a ✕
// fires onDismiss instead (site A wires no onDismiss), so an undone or
// ✕-closed completion is never cleared here; the ✕ just hides the toast
// early (this site's clear is toast-tied, so dismissing simply skips it).
onAutoClose: clearOnComplete
? () => {
// Tie the clear to THIS completion via its completedId entry in
Expand Down Expand Up @@ -870,6 +929,15 @@ export const BrainDumpEditor = function BrainDumpEditor({
* scheduleDeferredClear(entry) // drops entry's line after clearDelayMs, unless undone/edited
*/
const scheduleDeferredClear = (entry: ClearedLineMemory): void => {
// Clamp the linger to the toast duration so a finished line can never outlast
// its own Undo: once the toast (carrying that Undo) auto-closes, the line must
// already be gone. #108 used a FIXED ceiling (BRAINDUMP_CLEAR_DELAY_MAX_MS);
// now that the toast duration is user-configurable (#109) this live `min()`
// does that job — the clear-delay slider keeps its own fixed [0,5000] bounds.
const effectiveClearDelayMs = Math.min(
clearDelayMs,
braindumpToastDurationMs,
)
const removalTimerId = window.setTimeout(() => {
// No longer pending — drop it from tracking before doing anything else.
pendingClearTimersRef.current.delete(entry.token)
Expand Down Expand Up @@ -908,7 +976,7 @@ export const BrainDumpEditor = function BrainDumpEditor({
pendingEntry.originalLineIndex -= 1
}
}
}, clearDelayMs)
}, effectiveClearDelayMs)
entry.removalTimerId = removalTimerId
pendingClearTimersRef.current.set(entry.token, entry)
}
Expand Down Expand Up @@ -1050,24 +1118,34 @@ export const BrainDumpEditor = function BrainDumpEditor({
// 4) Immediate optimistic toast — feedback at keypress, not after the
// round-trip. Undo re-inserts the line; auto-close just closes the
// affordance (the line is already gone).
entry.toastId = toast.success(`Completed: ${safeTitle}`, {
description: 'Tap Undo within 5 s to revert.',
duration: BRAINDUMP_TOAST_UNDO_MS,
action: {
label: 'Undo',
onClick: () => {
void undoClearedCompletion(entry, createPromise)
if (entry.toastId !== undefined) toast.dismiss(entry.toastId)
},
// Confirm-cleanup: the undo window closed WITHOUT an undo (a timeout, OR a
// manual ✕ close) → mark the outcome confirmed and drop the map entry. The
// create promise's closure still holds the reinsert info for a late failure,
// so this is safe. (The deferred-clear timer is independent and still fires.)
const confirmClearedCompletion = (): void => {
if (entry.outcome === 'pending') entry.outcome = 'confirmed'
clearedLinesRef.current.delete(token)
}
// A manual ✕ close and an Undo BOTH fire sonner's onDismiss, but only the ✕
// should run confirmClearedCompletion (Undo already reverts via
// undoClearedCompletion). This call-site flag — set by onUndo, read by
// onDismiss — is what tells the two dismiss paths apart; the helper can't,
// since sonner reports both as a plain dismiss (#109 / CEO-D4). Without it, a
// ✕ would leak a stale clearedLinesRef entry and leave outcome stuck 'pending'.
let wasUndoCalled = false
entry.toastId = showCompletionToast({
title: safeTitle,
durationMs: braindumpToastDurationMs,
onUndo: () => {
wasUndoCalled = true
void undoClearedCompletion(entry, createPromise)
},
// Sonner fires onAutoClose ONLY on the timeout — an Undo click dismisses
// (onDismiss) instead, so this never runs for an undone completion.
onAutoClose: () => {
// Undo window elapsed with no Undo → confirm and drop the map entry. The
// create promise's closure still holds the reinsert info for a late
// failure, so this cleanup is safe.
if (entry.outcome === 'pending') entry.outcome = 'confirmed'
clearedLinesRef.current.delete(token)
// Timeout (no Undo, no ✕) → confirm + cleanup.
onAutoClose: confirmClearedCompletion,
// Manual close: a ✕ runs the SAME cleanup as a timeout; an Undo does not
// (it already reverted — the flag guards the double-run).
onDismiss: () => {
if (!wasUndoCalled) confirmClearedCompletion()
},
})
}
Expand Down
Loading
Loading