Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
b008ce6
fix: roll back optimistic updates when a save fails
xiaomi7732 Sep 30, 2026
6407a7b
fix: keep top bar actions reachable on narrow screens
xiaomi7732 Sep 30, 2026
84df4e5
fix: give the task overlays real dialog semantics and focus management
xiaomi7732 Sep 30, 2026
8328851
fix: use one date format across the app
xiaomi7732 Sep 30, 2026
91193b3
fix: surface auto-bumped dates and stop mislabelling them
xiaomi7732 Sep 30, 2026
5c21c09
fix: stop announcing the search result count twice
xiaomi7732 Sep 30, 2026
a47e590
fix: let users dismiss the error banner
xiaomi7732 Sep 30, 2026
85fb76c
fix: explain and reveal items hidden by the default status filters
xiaomi7732 Sep 30, 2026
37632a2
fix: stop printing raw backend IDs on book cards
xiaomi7732 Sep 30, 2026
3774081
fix: order scrum lanes by workflow and show the switcher on Cancelled
xiaomi7732 Sep 30, 2026
dee02c8
fix: address review findings on the UX audit branch
xiaomi7732 Sep 30, 2026
758f0f4
fix: report when a rollback could not be reconciled
xiaomi7732 Sep 30, 2026
4f214eb
fix: only compose error messages when reconciling a failed write
xiaomi7732 Sep 30, 2026
7898b55
fix: keep the unverified-write warning until a read proves the board
xiaomi7732 Sep 30, 2026
4630ab8
fix: keep a failed write on screen until it is dismissed
xiaomi7732 Sep 30, 2026
8d877f8
fix: tie an unverified write to the book it was made in
xiaomi7732 Sep 30, 2026
98ed261
fix: clear the banner of a write failure left behind by a book switch
xiaomi7732 Sep 30, 2026
4800cf5
fix: recognise every shape of a banner built from a write failure
xiaomi7732 Sep 30, 2026
1c27a8a
fix: address Copilot review of #93
xiaomi7732 Sep 30, 2026
1525723
fix: drop the pre-bump original in the Sheets backend too
xiaomi7732 Sep 30, 2026
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
13 changes: 0 additions & 13 deletions src/arrange-v4/app/cancelled/page.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -105,19 +105,6 @@
}

/* Error / loading / empty */
.error {
background-color: #fef2f2;
border: 1px solid #fecaca;
color: #991b1b;
padding: 12px 16px;
border-radius: 4px;
margin-bottom: 16px;
}

.errorTitle {
font-weight: bold;
}

.loading {
display: flex;
justify-content: center;
Expand Down
87 changes: 74 additions & 13 deletions src/arrange-v4/app/cancelled/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@ import type { StoreOperationOptions, TodoItem, TodoItemWithId } from '@/lib/stor
import { formatRelativeDate } from '@/lib/dateUtils';
import { retainExistingIds } from '@/lib/selectionUtils';
import { filterTasks, SHOW_ALL_STATUS_FILTERS } from '@/lib/search/taskQuery';
import { describeFailure } from '@/lib/failureMessage';
import { bannerDerivesFrom, composeReconcileFailure } from '@/lib/reconcileMessage';
import { useTaskQuery } from '@/lib/search/useTaskQuery';
import { useAuthClient } from '@/lib/auth/useAuthClient';
import { isInteractiveAuthenticationRequiredError } from '@/lib/auth/errors';
import { useBookId } from '@/lib/hooks/useBookId';
import { useRefreshOnPageActivation } from '@/lib/hooks/useRefreshOnPageActivation';
import { useSetTopBarActions } from '@/components/TopBarProvider';
import AuthRecoveryPanel from '@/components/AuthRecoveryPanel';
import ErrorBanner from '@/components/ErrorBanner';
import ViewTodoItem from '@/components/ViewTodoItem';
import TaskSearchBar from '@/components/TaskSearchBar';
import Link from 'next/link';
Expand All @@ -34,12 +37,20 @@ function CancelledPageContent() {
fetchBooks,
authRecoveryRequired: bookAuthRecoveryRequired,
error: bookError,
setError: setBookError,
} = useBookId('/cancelled');

const [cancelledItems, setCancelledItems] = useState<TodoItemWithId[]>([]);
const [itemsBookId, setItemsBookId] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Set when a delete failed and its rows were put back: the list is then a
// guess until a fetch succeeds, because a rejected bulk delete may still
// have removed some of them.
const unverifiedWriteRef = useRef<{ bookId: string; message: string } | null>(null);
// Reads already in flight when the user dismissed the banner: their failures
// belong to a message that has been closed, so they are not reported.
const dismissedFetchSequenceRef = useRef(0);
const [authRecoveryRequired, setAuthRecoveryRequired] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [showConfirm, setShowConfirm] = useState(false);
Expand Down Expand Up @@ -104,7 +115,21 @@ function CancelledPageContent() {
const fetchSequence = ++fetchSequenceRef.current;

setLoading(true);
if (!preserveError) setError(null);
// A write that is still unverified outlives a manual refresh: only a
// successful read may retract it.
// Switching book drops an unverified failure: it belongs to work the user
// is no longer looking at, and a read of another book verifies nothing.
const strayWrite = unverifiedWriteRef.current?.bookId === requestedBookId
? null
: unverifiedWriteRef.current;
if (strayWrite) unverifiedWriteRef.current = null;
if (!preserveError) {
setError(unverifiedWriteRef.current?.message ?? null);
} else if (strayWrite) {
// Preserved errors are the one case where the dropped failure may still
// be on screen, and nothing left can ever retract it.
setError(previous => (bannerDerivesFrom(previous, strayWrite.message, 'list') ? null : previous));
}

try {
const items = await store.listItems(requestedBookId, {
Expand All @@ -116,6 +141,12 @@ function CancelledPageContent() {
bookIdRef.current !== requestedBookId
) return;
const nextItems = items.filter(t => t.status === 'cancelled');
if (unverifiedWriteRef.current) {
// This read is authoritative, so the board is no longer a guess. The
// write failure itself stays: the user still needs to know it failed.
setError(unverifiedWriteRef.current.message);
unverifiedWriteRef.current = null;
Comment on lines +144 to +148
}
setCancelledItems(nextItems);
setItemsBookId(requestedBookId);
setAuthRecoveryRequired(false);
Expand All @@ -137,8 +168,17 @@ function CancelledPageContent() {
return;
}
console.error('Error fetching events:', err);
const message = err instanceof Error ? err.message : 'Failed to fetch events';
setError(message);
const message = describeFailure('Could not load the list.', err);
if (fetchSequence <= dismissedFetchSequenceRef.current) {
// This read was already running when the user dismissed the banner.
// Reporting it now would reopen something they closed.
return;
}
// Always composed from the write failure, never from the banner, so a
// run of failed refreshes replaces its clause instead of stacking, and a
// rolled-back delete keeps saying it is unverified until a read proves
// otherwise.
setError(composeReconcileFailure(unverifiedWriteRef.current?.message ?? null, message, 'list'));
} finally {
if (
fetchSequenceRef.current === fetchSequence &&
Expand Down Expand Up @@ -287,8 +327,11 @@ function CancelledPageContent() {
const confirmDelete = async () => {
if (!bookId || deletableIds.length === 0) return;

setDeleting(true); const idsToDelete = [...deletableIds];
setDeleting(true);
const idsToDelete = [...deletableIds];
const deleteSet = new Set(idsToDelete);
const snapshot = cancelledItems;
const operationBookId = bookId;
setDeleteProgress({ done: 0, total: idsToDelete.length });

setCancelledItems(items => items.filter(item => !deleteSet.has(item.id)));
Expand All @@ -299,9 +342,19 @@ function CancelledPageContent() {
setSelectedIds(new Set());
} catch (err: unknown) {
console.error('Error during bulk delete:', err);
await fetchEvents();
const message = err instanceof Error ? err.message : 'Failed to delete items';
// Put the rows back before reconciling: the refetch is the authoritative
// answer, but it cannot run offline, and leaving the list empty would
// claim a deletion that never happened.
if (bookIdRef.current === operationBookId) setCancelledItems(snapshot);
const message = describeFailure('Could not delete the selected tasks.', err);
// Held until a read proves the list: a bulk delete can partly succeed
// and still reject, so the restored rows are a guess until then.
unverifiedWriteRef.current = { bookId: operationBookId, message };
setError(message);
// preserveError: a bulk delete can partially succeed, so the refetch is
// what reconciles which rows really went away — but it must not overwrite
// the reason the delete failed.
await fetchEvents({ preserveError: true, preserveSelection: true });
} finally {
setDeleting(false);
setShowConfirm(false);
Expand Down Expand Up @@ -338,10 +391,15 @@ function CancelledPageContent() {
<div className={styles.container}>
<div className={styles.inner}>
{displayError && (
<div className={styles.error} role="alert">
<span className={styles.errorTitle}>Error: </span>
<span>{displayError}</span>
</div>
<ErrorBanner
message={displayError}
onDismiss={() => {
unverifiedWriteRef.current = null;
dismissedFetchSequenceRef.current = fetchSequenceRef.current;
setError(null);
setBookError(null);
}}
/>
)}

{loading && (
Expand Down Expand Up @@ -420,9 +478,12 @@ function CancelledPageContent() {
<div className={styles.taskInfo}>
<div className={styles.taskSubject}>{todo.subject}</div>
<div className={styles.taskMeta}>
{todo.etsDateTime && (
<span>ETS: {new Date(todo.etsDateTime).toLocaleDateString()}</span>
)}
{todo.etsDateTime && (() => {
const ets = formatRelativeDate(todo.etsDateTime);
return (
<span title={`ETS: ${ets.fullDate}`}>ETS: {ets.text}</span>
);
})()}
{todo.etaDateTime && (() => {
const eta = formatRelativeDate(todo.etaDateTime);
return (
Expand Down
40 changes: 27 additions & 13 deletions src/arrange-v4/app/matrix/page.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -144,19 +144,6 @@
text-decoration: underline;
}

.error {
background-color: #fef2f2;
border: 1px solid #fecaca;
color: #991b1b;
padding: 12px 16px;
border-radius: 4px;
margin-bottom: 16px;
}

.errorTitle {
font-weight: bold;
}

.loading {
display: flex;
justify-content: center;
Expand Down Expand Up @@ -609,6 +596,24 @@
flex: 1;
}

/* Carries the card's open action while staying visually plain text. */
.todoTitleButton {
font: inherit;
color: inherit;
text-align: left;
background: none;
border: none;
padding: 0;
cursor: pointer;
width: 100%;
}

.todoTitleButton:focus-visible {
outline: 2px solid #1d4ed8;
outline-offset: 2px;
border-radius: 2px;
}

.todoBadges {
display: flex;
gap: 4px;
Expand Down Expand Up @@ -694,6 +699,15 @@
color: #d1d5db;
}

.todoDateBumped {
color: #b45309;
background: #fef3c7;
border-radius: 4px;
padding: 0 4px;
font-size: 0.7rem;
cursor: help;
}

.todoCategories {
display: flex;
flex-wrap: wrap;
Expand Down
Loading
Loading