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
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import { resource } from "@/test/resource";
@@ -13,9 +13,7 @@ describe("instrument source and transport presentation", () => {
const state = section({
data,
isError: false,
isFetching: false,
meta: resource(data, { availability }).meta,
refetch: vi.fn(),
});
expect(state.loading).toBe(availability === "missing");
@@ -23,39 +21,40 @@ describe("instrument source and transport presentation", () => {
expect(state.hasData).toBe(availability !== "missing");
},
);
it("preserves a full fresh source and timestamp when force fails, while offering scoped retry", () => {
const retry = vi.fn();
const refetch = vi.fn();
it("preserves empty successful data instead of treating it as missing", () => {
const state = section({
data: [],
isError: false,
meta: resource([]).meta,
});
expect(state).toMatchObject({
error: false,
hasData: true,
loading: false,
});
});
it("preserves a full source when force fails", () => {
const meta = resource([], {
sync: { errorCode: "TBANK_UNAVAILABLE", id: "work", state: "failed" },
}).meta;
const state = section(
{ data: [], isError: false, isFetching: false, meta, refetch },
retry,
);
const state = section({ data: [], isError: false, meta });
expect(state).toMatchObject({
error: true,
hasData: true,
lastSuccessfulAt: meta.lastSuccessfulAt,
loading: false,
refreshing: false,
});
state.retry();
expect(retry).toHaveBeenCalledOnce();
expect(refetch).not.toHaveBeenCalled();
});
it("shows source failure without a false loading or empty result", () => {
const state = section({
data: undefined,
isError: false,
isFetching: false,
meta: resource(null, {
sync: { errorCode: "TBANK_UNAVAILABLE", id: "work", state: "failed" },
}).meta,
refetch: vi.fn(),
});
expect(state).toMatchObject({
@@ -3,43 +3,27 @@ import type { InvestmentResourceMeta } from "@/shared/api";
type SectionState = {
error: boolean;
hasData: boolean;
lastSuccessfulAt?: null | string;
loading: boolean;
partial?: boolean;
refreshing: boolean;
retry: () => void;
sourceError?: boolean;
stale?: boolean;
};
function section(
query: {
data: unknown;
isError: boolean;
isFetching: boolean;
meta?: InvestmentResourceMeta;
refetch: () => unknown;
},
retry?: () => unknown,
): SectionState {
function hasSectionData(data: unknown) {
return data !== undefined;
}
function section(query: {
data: unknown;
isError: boolean;
meta?: InvestmentResourceMeta;
}): SectionState {
const isError = query.isError || query.meta?.sync.state === "failed";
const hasData = hasSectionData(query.data);
return {
error: isError,
hasData: Boolean(query.data),
lastSuccessfulAt: query.meta?.lastSuccessfulAt,
loading: !query.data && !isError,
hasData,
loading: !hasData && !isError,
partial: query.meta?.availability === "partial",
refreshing:
Boolean(query.data)
&& (query.isFetching
|| query.meta?.sync.state === "running"
),
retry: () => {
void (retry ?? query.refetch)();
},
sourceError: query.meta?.sync.state === "failed",
stale: query.meta?.freshness === "stale",
};
}
@@ -86,7 +86,7 @@ const InstrumentEvents: FC<InstrumentEventsProps> = (props) => {
<Group justify="space-between" mt="md">
<Button
disabled={!vm.hasPrevious || vm.operationsState.refreshing}
disabled={!vm.hasPrevious}
onClick={vm.onPrevious}
variant="default"
>
@@ -94,7 +94,7 @@ const InstrumentEvents: FC<InstrumentEventsProps> = (props) => {
</Button>
<Button
disabled={!vm.operations.hasNext || vm.operationsState.refreshing}
disabled={!vm.operations.hasNext}
onClick={vm.onNext}
variant="default"
>
@@ -1,21 +1,17 @@
import type { FC, PropsWithChildren, ReactNode } from "react";
import { Avatar, Button, Group, Paper, Text, Title } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Avatar, Group, Paper, Text, Title } from "@mantine/core";
import classes from "./InstrumentHeader.module.css";
type InstrumentHeaderProps = PropsWithChildren<{
logoSrc: null | string;
name: string;
onRefresh: () => void;
refreshing: boolean;
subtitle: ReactNode;
}>;
const InstrumentHeader: FC<InstrumentHeaderProps> = (props) => {
const { children, logoSrc, name, onRefresh, refreshing, subtitle } = props;
const { t } = useTranslation();
const { children, logoSrc, name, subtitle } = props;
return (
<Paper className={classes.header} component="header" withBorder>
@@ -31,10 +27,6 @@ const InstrumentHeader: FC<InstrumentHeaderProps> = (props) => {
<Text c="dimmed" size="sm">{subtitle}</Text>
</div>
</div>
<Button disabled={refreshing} onClick={onRefresh} variant="subtle">
{t("investment.refresh")}
</Button>
</Group>
{children}
@@ -1,6 +1,6 @@
import type { FC } from "react";
import { Anchor, Button, Skeleton, Stack, Text, Title } from "@mantine/core";
import { Anchor, Skeleton, Stack, Text, Title } from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
@@ -11,11 +11,10 @@ type InstrumentPageStateProps = {
code?: string;
kind: "bond" | "stock";
loading: boolean;
retry: () => void;
};
const InstrumentPageState: FC<InstrumentPageStateProps> = (props) => {
const { code, kind, loading, retry } = props;
const { code, kind, loading } = props;
const { t } = useTranslation();
if (loading) return (
<LoadingSkeleton label={t(`${kind}.loading`)}>
@@ -55,10 +54,6 @@ const InstrumentPageState: FC<InstrumentPageStateProps> = (props) => {
<Anchor component={Link} to="/settings">
{t("accounts.openSettings")}
</Anchor>
<Button onClick={retry} variant="default">
{t("accounts.retry")}
</Button>
</Stack>
);
};
@@ -2,7 +2,6 @@ import type { FC, PropsWithChildren, ReactNode } from "react";
import {
Alert,
Button,
Group,
Paper,
Skeleton,
@@ -13,8 +12,6 @@ import {
import { LoadingSkeleton } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
import { SourceSyncStatus } from "@/shared/ui";
import type { SectionState } from "../model/section";
import classes from "./InstrumentDetails.module.css";
@@ -33,10 +30,6 @@ type InstrumentSectionProps = PropsWithChildren<{
const InstrumentSection: FC<InstrumentSectionProps> = (props) => {
const { t } = useTranslation();
const readError = t(
props.state.hasData ? "accounts.refreshError" : "stock.sectionError",
);
const sectionClassName = props.embedded
? classes.embeddedSection
: classes.section;
@@ -80,35 +73,11 @@ const InstrumentSection: FC<InstrumentSectionProps> = (props) => {
{props.controls}
</Group>
<SourceSyncStatus
lastSuccessfulAt={props.state.lastSuccessfulAt}
stale={props.state.stale}
/>
{!!props.state.partial && <Text role="status">{t("investment.partial")}</Text>}
{!!props.state.refreshing && (
<Text c="dimmed" role="status" size="xs">
{t("accounts.refreshing")}
</Text>
)}
{!!props.state.error && (
{!!(props.state.error && !props.state.hasData) && (
<Alert aria-live="polite" color="orange" mb="sm" role="status">
<Group justify="space-between">
<Text size="sm">
{props.state.sourceError ? t("investment.syncFailed") : readError}
</Text>
<Button
disabled={props.state.refreshing}
onClick={props.state.retry}
size="xs"
variant="default"
>
{t("accounts.retry")}
</Button>
</Group>
<Text size="sm">{t("stock.sectionError")}</Text>
</Alert>
)}