feat: add quiet reading with rare confirmations

Part of the quiet-user-feedback change; docs and specs land in their own repositories.
This commit is contained in:
2026-10-06 07:01:58 +03:00
parent c096fc70ef
commit 5b30bb802a
77 changed files with 1352 additions and 1259 deletions
+2 -2
View File
@@ -147,7 +147,7 @@ describe("banking API", () => {
expect(url.searchParams.get("from")).toBe("2026-09-01");
expect(url.searchParams.get("to")).toBe("2026-09-30");
if (isFail && page === 2)
return HttpResponse.json({ code: "FAILED" }, { status: 500 });
return HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 });
return HttpResponse.json({
hasNext: page === 1,
@@ -299,7 +299,7 @@ describe("banking API", () => {
expect(result.current.query.data?.balance.value).toBe("100");
server.use(
http.get("*/api/v1/bank-cards/a", () =>
HttpResponse.json({ code: "INTERNAL_ERROR" }, { status: 500 }),
HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 }),
),
);
await act(() => result.current.query.refetch());
@@ -1,4 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { notifications } from "@mantine/notifications";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
@@ -71,7 +72,7 @@ function useBankCardFormVm(props: BankCardFormProps) {
openingDate: values.openingDate,
};
if (props.card)
if (props.card) {
await updateCard.mutateAsync({
body: {
...metadata,
@@ -80,12 +81,19 @@ function useBankCardFormVm(props: BankCardFormProps) {
},
id: props.card.id,
});
else
}
else {
await cardCreation.mutateAsync({
id: intent,
...metadata,
...financial,
});
notifications.show({
id: "bank-card-created-" + intent,
message: t("bank.cardSaved"),
});
}
setOpened(false);
}
catch (error_) {
@@ -47,7 +47,6 @@ const meta
error: false,
fetching: false,
loading: false,
retry: () => {},
},
component: BankSummaryView,
parameters: {
@@ -1,6 +1,6 @@
import type { CSSProperties, FC } from "react";
import { Button, Group, Skeleton, Stack, Text, Title } from "@mantine/core";
import { Skeleton, Stack, Text, Title } from "@mantine/core";
import { OperationIcon } from "@wealthledger/icons";
import { LoadingSkeleton } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
@@ -20,7 +20,6 @@ type BankSummaryViewProps = {
error: boolean;
fetching: boolean;
loading: boolean;
retry: () => void;
};
const BankSummaryViewCategories: FC<BankSummaryViewProps> = (props) => {
@@ -101,17 +100,7 @@ const BankSummaryView: FC<BankSummaryViewProps> = (props) => {
<BankSummaryViewCategories {...props} />
{props.error && (
<Group role="alert">
<Text>{t(props.data ? "bank.refreshError" : "bank.error")}</Text>
<Button onClick={props.retry} variant="default">
{t("bank.retry")}
</Button>
</Group>
)}
{props.data && props.fetching && <Text role="status">{t("bank.refreshing")}</Text>}
{props.error && !props.data && <Text role="alert">{t("bank.error")}</Text>}
</Stack>
);
};
@@ -1,5 +1,5 @@
import { hashKey, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useEffect, useState } from "react";
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
@@ -29,15 +29,28 @@ function useBrokerageOperationHistory(
cursor: cursors.at(-1),
});
const isInvalidCursor = isApiErrorCode(
apiErrorCode(operations.error),
ApiErrorCode.InvalidOperationsCursor,
);
const isRecoveringInvalidCursor = cursors.length > 0 && isInvalidCursor;
const reset = () => setPagination({ cursors: [], scope });
useEffect(() => {
if (!isRecoveringInvalidCursor) return;
const timeout = setTimeout(() => {
setPagination({ cursors: [], scope });
void client.invalidateQueries({ exact: true, queryKey });
}, 0);
return () => clearTimeout(timeout);
}, [client, isRecoveringInvalidCursor, queryKey, scope]);
const retry = (retryScreen: () => unknown) => {
if (
isApiErrorCode(
apiErrorCode(operations.error),
ApiErrorCode.InvalidOperationsCursor,
)
) {
if (isInvalidCursor) {
reset();
void client.invalidateQueries({
exact: true,
@@ -49,6 +62,7 @@ function useBrokerageOperationHistory(
return {
hasPrevious: cursors.length > 0,
isRecoveringInvalidCursor,
onNext: () => {
if (operations.data?.nextCursor)
setPagination({
@@ -1,12 +1,10 @@
import type { FC } from "react";
import { Alert, Button, Skeleton, Stack, Text } from "@mantine/core";
import { Button, Skeleton, Stack, Text } from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { SourceSyncStatus } from "@/shared/ui";
import type { BrokerageEvent } from "../../api/useBrokerageEvents";
import { brokerageEventKey } from "../../model/event-key";
@@ -19,9 +17,6 @@ type BrokerageEventsPreviewProps = {
type BrokerageEventsPreviewState
= | {
items: BrokerageEvent[];
lastSuccessfulAt?: null | string;
refreshError?: boolean;
stale?: boolean;
state: "loaded" | "partial";
to: string;
}
@@ -33,28 +28,15 @@ const BrokerageEventsPreview: FC<BrokerageEventsPreviewProps> = (props) => {
const { events } = props;
const { t } = useTranslation();
const errorText
= events.state === "loaded" && events.refreshError
? t("accounts.refreshError")
: null;
return (
<Stack gap="sm">
{events.state === "loaded" && (
<SourceSyncStatus
errorText={errorText}
lastSuccessfulAt={events.lastSuccessfulAt}
stale={events.stale}
/>
)}
{events.state === "loading" && (
<LoadingSkeleton label={t("events.recentLoading")}>
{["first", "second", "third"].map(placeholder => <Skeleton height={48} key={placeholder} radius="sm" />)}
</LoadingSkeleton>
)}
{events.state === "error" && <Alert color="red">{t("events.recentError")}</Alert>}
{events.state === "error" && <Text c="dimmed">{t("states.unavailableText")}</Text>}
{events.state === "empty" && <Text c="dimmed">{t("events.recentEmpty")}</Text>}