From 9509227ee278cefaced59da1241ce632997951d4 Mon Sep 17 00:00:00 2001 From: Silma Thoron Date: Fri, 17 Jul 2026 23:13:33 +0200 Subject: [PATCH 1/6] Add simplified per-API status tracking with inline dual-API status UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace XState state machine with a lightweight useApiSync hook that tracks YNAB and SettleUp status independently with 6 states each: idle → fetching → fetch_error / ready → entering → success / enter_error - src/hooks/useApiSync.js: custom hook with parallel concurrent execution, independent per-API retry (retryFetch / retryEnter), and all API logic extracted from ReviewPage - src/components/ApiSyncStatus.jsx: compact inline status summary with API-specific brand colors (YNAB green / SettleUp blue), animated SVG spinner for in-flight states, and per-API retry actions - src/ReviewPage.jsx: simplified to use the hook; calls onSubmitted only when all targeted APIs succeed --- api-sync-status-preview.html | 12 ++ src/ReviewPage.jsx | 213 +++------------------ src/api-sync-status-preview.jsx | 112 +++++++++++ src/components/ApiSyncStatus.jsx | 176 ++++++++++++++++++ src/hooks/useApiSync.js | 306 +++++++++++++++++++++++++++++++ 5 files changed, 631 insertions(+), 188 deletions(-) create mode 100644 api-sync-status-preview.html create mode 100644 src/api-sync-status-preview.jsx create mode 100644 src/components/ApiSyncStatus.jsx create mode 100644 src/hooks/useApiSync.js diff --git a/api-sync-status-preview.html b/api-sync-status-preview.html new file mode 100644 index 0000000..914a7e2 --- /dev/null +++ b/api-sync-status-preview.html @@ -0,0 +1,12 @@ + + + + + + ApiSyncStatus Preview + + + +
+ + diff --git a/src/ReviewPage.jsx b/src/ReviewPage.jsx index 1107eaa..3012d6a 100644 --- a/src/ReviewPage.jsx +++ b/src/ReviewPage.jsx @@ -1,201 +1,40 @@ import PropTypes from "prop-types"; -import { useState } from "react"; import { useNavigate } from "react-router-dom"; -import { - addSettleUpTransaction, - fetchSettleUpPermissions, -} from "./api/settleup"; import { useAppContext } from "./AppContext.jsx"; import { useAuth } from "./AuthProvider.jsx"; +import ApiSyncStatus from "./components/ApiSyncStatus.jsx"; import CenteredCardLayout from "./components/CenteredCardLayout.jsx"; import ReviewSection from "./components/ReviewSection.jsx"; -import { BOURSO_TRANSFER_PAYEE_ID } from "./constants.js"; +import { API_STATUS, useApiSync } from "./hooks/useApiSync"; import { formStatePropType } from "./propTypes.js"; -import { formatYYYYMMDDLocal } from "./utils/dateUtils"; -import { getAccountIdByName } from "./utils/ynabUtils"; export default function ReviewPage({ formState, onBack, onSubmitted }) { const navigate = useNavigate(); - const [loading, setLoading] = useState(false); - const [result, setResult] = useState(""); const { ynabAPI, budgetId, accounts } = useAppContext(); const { token, user } = useAuth(); - // Helper function to create base transaction object - function createBaseTransaction(accountId, amount) { - return { - account_id: accountId, - date: formatYYYYMMDDLocal(formState.date), - amount: amount, - payee_id: formState.payeeId || null, - payee_name: !formState.payeeId ? formState.payee : undefined, - category_id: formState.categoryId, - memo: formState.description, - approved: true, - }; - } - - // Helper function to execute YNAB API call - async function executeYnabTransaction(transaction, successMessage) { - try { - setLoading(true); - const res = await ynabAPI.transactions.createTransaction(budgetId, { - transaction, - }); - setResult(successMessage + "\n" + JSON.stringify(res, null, 2)); - } catch (err) { - setResult("YNAB API error: " + (err?.message || err)); - } finally { - setLoading(false); - } - } - - async function handleYnabSubmit() { - if (!ynabAPI || !budgetId) { - setResult("YNAB not configured."); - return; - } - - // Split transaction logic - if (formState.account.swile && formState.account.bourso) { - const swileAccountId = getAccountIdByName(accounts, "Swile"); - const boursoAccountId = getAccountIdByName(accounts, "Boursorama"); - - if (!swileAccountId || !boursoAccountId) { - setResult("No matching YNAB account found for Swile or Bourso."); - return; - } + const { ynab, settleup, anyInFlight, startSync, retryFetch, retryEnter } = + useApiSync({ ynabAPI, budgetId, accounts, token, user }); - const transferInflowMilliunits = - formState.swileMilliunits - formState.amountMilliunits; + async function handleConfirmSubmit() { + const results = await startSync(formState); - // If Swile covers the full amount, create a simple transaction - if (transferInflowMilliunits === 0) { - const transaction = createBaseTransaction( - swileAccountId, - formState.amountMilliunits, - ); - await executeYnabTransaction(transaction, "✅ YNAB transaction sent!"); - return; - } + const allTargetedSucceeded = + (!formState.target.ynab || results.ynab === API_STATUS.SUCCESS) && + (!formState.target.settleup || results.settleup === API_STATUS.SUCCESS); - // Create split transaction - const transaction = { - ...createBaseTransaction(swileAccountId, formState.swileMilliunits), - category_id: null, // Override for split transactions - subtransactions: [ - { - amount: formState.amountMilliunits, - category_id: formState.categoryId, - memo: formState.description, - payee_id: formState.payeeId || null, - }, - { - amount: transferInflowMilliunits, - payee_id: BOURSO_TRANSFER_PAYEE_ID, - transfer_account_id: boursoAccountId, - memo: "Bourso completion", - }, - ], - }; - - await executeYnabTransaction( - transaction, - "✅ YNAB split transaction sent!", - ); - return; - } - - // Single-account transaction - const accountId = formState.account.bourso - ? getAccountIdByName(accounts, "Boursorama") - : formState.account.swile - ? getAccountIdByName(accounts, "Swile") - : getAccountIdByName(accounts, "Boursorama"); // Default fallback - - if (!accountId) { - setResult("No matching YNAB account found for the selected button."); - return; - } - - const transaction = createBaseTransaction( - accountId, - formState.amountMilliunits, - ); - await executeYnabTransaction(transaction, "✅ YNAB transaction sent!"); + if (allTargetedSucceeded && onSubmitted) onSubmitted(); } - async function handleSettleUpSubmit() { - setResult(""); - if ( - !token || - !formState.settleUpGroup?.groupId || - formState.amountMilliunits === 0 || - !formState.settleUpPayerId || - !formState.settleUpMembers?.length - ) { - console.warn("[ReviewPage] Missing required fields for SettleUp submit"); - setResult("❌ Please fill all required fields before submitting."); - return; - } - const permissions = await fetchSettleUpPermissions( - token, - formState.settleUpGroup.groupId, - ); - if (!permissions[user.uid] || permissions[user.uid].level < 20) { - console.warn("[ReviewPage] Insufficient permissions for user:", user.uid); - setResult("❌ You do not have permission to submit this transaction."); - return; - } - const amount = (-formState.amountMilliunits / 1000).toFixed(2); - const tx = { - category: - formState.settleUpCategory === "∅" - ? undefined - : formState.settleUpCategory, - currencyCode: formState.settleUpCurrency || "EUR", - dateTime: formState.date.getTime(), - items: [ - { - amount: amount, - forWhom: formState.settleUpMembers.map((member) => ({ - memberId: member.id, - weight: (member.defaultWeight || "1").toString(), - })), - }, - ], - purpose: - formState.payee + - (formState.description ? ` - ${formState.description}` : ""), - type: "expense", - whoPaid: [{ memberId: formState.settleUpPayerId, weight: "1" }], - exchangeRates: [], - fixedExchangeRate: true, - }; - try { - setLoading(true); - const data = await addSettleUpTransaction( - token, - formState.settleUpGroup.groupId, - tx, - ); - if (data && data.name) { - setResult("✅ SettleUp transaction sent!"); - } else { - setResult("Error adding transaction: " + JSON.stringify(data)); - } - } catch (e) { - setResult("Error adding transaction: " + e.message); - } finally { - setLoading(false); - } + function handleRetryYnab() { + if (ynab.status === API_STATUS.FETCH_ERROR) retryFetch("ynab"); + else retryEnter("ynab"); } - async function handleSubmit() { - if (formState.target.ynab) await handleYnabSubmit(); - if (formState.target.settleup) await handleSettleUpSubmit(); - if (onSubmitted) onSubmitted(); + function handleRetrySettleup() { + if (settleup.status === API_STATUS.FETCH_ERROR) retryFetch("settleup"); + else retryEnter("settleup"); } return ( @@ -212,20 +51,18 @@ export default function ReviewPage({ formState, onBack, onSubmitted }) { - {result && ( -
- {result} -
- )} + ); diff --git a/src/api-sync-status-preview.jsx b/src/api-sync-status-preview.jsx new file mode 100644 index 0000000..bc7de2c --- /dev/null +++ b/src/api-sync-status-preview.jsx @@ -0,0 +1,112 @@ +import { StrictMode } from "react"; +import ReactDOM from "react-dom/client"; + +import ApiSyncStatus from "./components/ApiSyncStatus.jsx"; +import { API_STATUS } from "./hooks/useApiSync"; +import "./index.css"; + +const SAMPLE_STATES = [ + { + title: "Idle", + ynab: { status: API_STATUS.IDLE, error: null }, + settleup: { status: API_STATUS.IDLE, error: null }, + }, + { + title: "Fetching", + ynab: { status: API_STATUS.FETCHING, error: null }, + settleup: { status: API_STATUS.FETCHING, error: null }, + }, + { + title: "Mixed", + ynab: { status: API_STATUS.READY, error: null }, + settleup: { status: API_STATUS.FETCHING, error: null }, + }, + { + title: "Enter error", + ynab: { status: API_STATUS.SUCCESS, error: null }, + settleup: { status: API_STATUS.ENTER_ERROR, error: "Temporary network issue" }, + }, + { + title: "Fetch error", + ynab: { status: API_STATUS.FETCH_ERROR, error: "YNAB auth expired" }, + settleup: { status: API_STATUS.SUCCESS, error: null }, + }, + { + title: "Success", + ynab: { status: API_STATUS.SUCCESS, error: null }, + settleup: { status: API_STATUS.SUCCESS, error: null }, + }, +]; + +function PreviewCard({ title, ynab, settleup }) { + return ( +
+
+ {title} +
+ {}} + onRetrySettleup={() => {}} + /> +
+ ); +} + +function App() { + return ( +
+
+
+
+ Preview +
+

+ ApiSyncStatus +

+

+ Static preview of the inline status component with the current brand colors, + spinner, success, and error states. +

+
+ +
+ {SAMPLE_STATES.map((sample) => ( + + ))} +
+
+
+ ); +} + +ReactDOM.createRoot(document.getElementById("root")).render( + + + , +); diff --git a/src/components/ApiSyncStatus.jsx b/src/components/ApiSyncStatus.jsx new file mode 100644 index 0000000..5b88266 --- /dev/null +++ b/src/components/ApiSyncStatus.jsx @@ -0,0 +1,176 @@ +import PropTypes from "prop-types"; +import { MdCheck, MdClose, MdHourglassEmpty } from "react-icons/md"; + +import { API_STATUS } from "../hooks/useApiSync"; + +// ─── Brand colors ────────────────────────────────────────────────────────────── + +const YNAB_COLOR = "#5C6CFA"; +const SETTLEUP_COLOR = "#f2774a"; + +// ─── Inline spinner SVG ──────────────────────────────────────────────────────── + +function Spinner({ color }) { + return ( + + ); +} + +Spinner.propTypes = { + color: PropTypes.string.isRequired, +}; + +// ─── Status icon ─────────────────────────────────────────────────────────────── + +function StatusIcon({ status, color }) { + const style = { verticalAlign: "middle", color }; + if (status === API_STATUS.FETCHING || status === API_STATUS.ENTERING) { + return ; + } + if (status === API_STATUS.SUCCESS) return ; + if (status === API_STATUS.FETCH_ERROR || status === API_STATUS.ENTER_ERROR) { + return ; + } + if (status === API_STATUS.READY) return ; + return null; +} + +StatusIcon.propTypes = { + status: PropTypes.string.isRequired, + color: PropTypes.string.isRequired, +}; + +// ─── Status labels ───────────────────────────────────────────────────────────── + +const LABELS = { + [API_STATUS.FETCHING]: "fetching data…", + [API_STATUS.FETCH_ERROR]: "failed to fetch", + [API_STATUS.READY]: "ready", + [API_STATUS.ENTERING]: "submitting…", + [API_STATUS.SUCCESS]: "done", + [API_STATUS.ENTER_ERROR]: "submission failed", +}; + +// ─── Single API chip ─────────────────────────────────────────────────────────── + +function ApiChip({ name, syncState, color, onRetry }) { + const { status, error } = syncState; + const label = LABELS[status]; + if (!label) return null; // idle or skipped → hidden + + const canRetry = + status === API_STATUS.FETCH_ERROR || status === API_STATUS.ENTER_ERROR; + + return ( + + + + {name} {label} + + {canRetry && ( + + )} + + ); +} + +ApiChip.propTypes = { + name: PropTypes.string.isRequired, + syncState: PropTypes.shape({ + status: PropTypes.string.isRequired, + error: PropTypes.string, + }).isRequired, + color: PropTypes.string.isRequired, + onRetry: PropTypes.func.isRequired, +}; + +// ─── Main component ──────────────────────────────────────────────────────────── + +/** + * Compact inline dual-API status summary. + * Renders nothing when both APIs are idle/skipped. + * + * Example: [spinner] YNAB submitting… | [check] SettleUp done + */ +export default function ApiSyncStatus({ + ynab, + settleup, + onRetryYnab, + onRetrySettleup, +}) { + const showYnab = + ynab.status !== API_STATUS.IDLE && ynab.status !== API_STATUS.SKIPPED; + const showSettleup = + settleup.status !== API_STATUS.IDLE && + settleup.status !== API_STATUS.SKIPPED; + + if (!showYnab && !showSettleup) return null; + + return ( +
+ {showYnab && ( + + )} + {showYnab && showSettleup && ( + + )} + {showSettleup && ( + + )} +
+ ); +} + +ApiSyncStatus.propTypes = { + ynab: PropTypes.shape({ + status: PropTypes.string.isRequired, + error: PropTypes.string, + }).isRequired, + settleup: PropTypes.shape({ + status: PropTypes.string.isRequired, + error: PropTypes.string, + }).isRequired, + onRetryYnab: PropTypes.func.isRequired, + onRetrySettleup: PropTypes.func.isRequired, +}; diff --git a/src/hooks/useApiSync.js b/src/hooks/useApiSync.js new file mode 100644 index 0000000..c1fda72 --- /dev/null +++ b/src/hooks/useApiSync.js @@ -0,0 +1,306 @@ +import { useState, useCallback, useRef } from "react"; + +import { + addSettleUpTransaction, + fetchSettleUpPermissions, +} from "../api/settleup"; +import { BOURSO_TRANSFER_PAYEE_ID } from "../constants"; +import { formatYYYYMMDDLocal } from "../utils/dateUtils"; +import { getAccountIdByName } from "../utils/ynabUtils"; + +// ─── Status constants ────────────────────────────────────────────────────────── + +export const API_STATUS = { + IDLE: "idle", + SKIPPED: "skipped", + FETCHING: "fetching", + FETCH_ERROR: "fetch_error", + READY: "ready", + ENTERING: "entering", + SUCCESS: "success", + ENTER_ERROR: "enter_error", +}; + +const INITIAL = { status: API_STATUS.IDLE, error: null }; + +// ─── YNAB helpers ────────────────────────────────────────────────────────────── + +function buildYnabTransaction(formState, accounts) { + const { swileMilliunits, amountMilliunits, account } = formState; + + function base(accountId, amount) { + return { + account_id: accountId, + date: formatYYYYMMDDLocal(formState.date), + amount, + payee_id: formState.payeeId || null, + payee_name: !formState.payeeId ? formState.payee : undefined, + category_id: formState.categoryId, + memo: formState.description, + approved: true, + }; + } + + if (account.swile && account.bourso) { + const swileId = getAccountIdByName(accounts, "Swile"); + const boursoId = getAccountIdByName(accounts, "Boursorama"); + if (!swileId || !boursoId) + throw new Error("No matching YNAB account for Swile or Bourso."); + + const transferInflow = swileMilliunits - amountMilliunits; + if (transferInflow === 0) { + return base(swileId, amountMilliunits); + } + + return { + ...base(swileId, swileMilliunits), + category_id: null, + subtransactions: [ + { + amount: amountMilliunits, + category_id: formState.categoryId, + memo: formState.description, + payee_id: formState.payeeId || null, + }, + { + amount: transferInflow, + payee_id: BOURSO_TRANSFER_PAYEE_ID, + transfer_account_id: boursoId, + memo: "Bourso completion", + }, + ], + }; + } + + const accountId = account.bourso + ? getAccountIdByName(accounts, "Boursorama") + : account.swile + ? getAccountIdByName(accounts, "Swile") + : getAccountIdByName(accounts, "Boursorama"); + + if (!accountId) + throw new Error("No matching YNAB account for the selected button."); + return base(accountId, amountMilliunits); +} + +async function runYnabEnter(formState, { ynabAPI, budgetId, accounts }) { + if (!ynabAPI || !budgetId) throw new Error("YNAB not configured."); + const transaction = buildYnabTransaction(formState, accounts); + await ynabAPI.transactions.createTransaction(budgetId, { transaction }); +} + +// ─── SettleUp helpers ────────────────────────────────────────────────────────── + +async function fetchAndValidateSettleup(formState, { token, user }) { + const { settleUpGroup } = formState; + if ( + !token || + !settleUpGroup?.groupId || + formState.amountMilliunits === 0 || + !formState.settleUpPayerId || + !formState.settleUpMembers?.length + ) { + throw new Error("Missing required fields for SettleUp."); + } + const permissions = await fetchSettleUpPermissions( + token, + settleUpGroup.groupId, + ); + if (!permissions[user.uid] || permissions[user.uid].level < 20) { + throw new Error("Insufficient SettleUp permissions."); + } + return permissions; +} + +async function runSettleupEnter(formState, { token }) { + const amount = (-formState.amountMilliunits / 1000).toFixed(2); + const tx = { + category: + formState.settleUpCategory === "∅" + ? undefined + : formState.settleUpCategory, + currencyCode: formState.settleUpCurrency || "EUR", + dateTime: formState.date.getTime(), + items: [ + { + amount, + forWhom: formState.settleUpMembers.map((m) => ({ + memberId: m.id, + weight: (m.defaultWeight || "1").toString(), + })), + }, + ], + purpose: + formState.payee + + (formState.description ? ` - ${formState.description}` : ""), + type: "expense", + whoPaid: [{ memberId: formState.settleUpPayerId, weight: "1" }], + exchangeRates: [], + fixedExchangeRate: true, + }; + const data = await addSettleUpTransaction( + token, + formState.settleUpGroup.groupId, + tx, + ); + if (!data?.name) throw new Error("Unexpected response: " + JSON.stringify(data)); +} + +// ─── Hook ────────────────────────────────────────────────────────────────────── + +/** + * Tracks and orchestrates API sync status for YNAB and SettleUp independently. + * + * Execution model: both APIs run concurrently. YNAB has no fetch phase and goes + * straight to entering. SettleUp fetches+validates permissions first. + * + * @param {{ ynabAPI, budgetId, accounts, token, user }} deps - Live context deps + */ +export function useApiSync({ ynabAPI, budgetId, accounts, token, user }) { + const [ynab, setYnab] = useState(INITIAL); + const [settleup, setSettleup] = useState(INITIAL); + + // Persisted across retries without triggering re-renders + const lastFormStateRef = useRef(null); + const settleupPermissionsRef = useRef(null); + + const anyInFlight = + ynab.status === API_STATUS.FETCHING || + ynab.status === API_STATUS.ENTERING || + settleup.status === API_STATUS.FETCHING || + settleup.status === API_STATUS.ENTERING; + + // ── Internal runners ────────────────────────────────────────────────────── + + const _runYnabEnter = useCallback( + async (formState) => { + setYnab({ status: API_STATUS.ENTERING, error: null }); + try { + await runYnabEnter(formState, { ynabAPI, budgetId, accounts }); + setYnab({ status: API_STATUS.SUCCESS, error: null }); + return API_STATUS.SUCCESS; + } catch (err) { + const msg = err?.message || String(err); + setYnab({ status: API_STATUS.ENTER_ERROR, error: msg }); + return API_STATUS.ENTER_ERROR; + } + }, + [ynabAPI, budgetId, accounts], + ); + + const _runSettleupFetchThenEnter = useCallback( + async (formState) => { + setSettleup({ status: API_STATUS.FETCHING, error: null }); + let permissions; + try { + permissions = await fetchAndValidateSettleup(formState, { token, user }); + settleupPermissionsRef.current = permissions; + setSettleup({ status: API_STATUS.READY, error: null }); + } catch (err) { + const msg = err?.message || String(err); + setSettleup({ status: API_STATUS.FETCH_ERROR, error: msg }); + return API_STATUS.FETCH_ERROR; + } + // Enter phase + setSettleup({ status: API_STATUS.ENTERING, error: null }); + try { + await runSettleupEnter(formState, { token }); + setSettleup({ status: API_STATUS.SUCCESS, error: null }); + return API_STATUS.SUCCESS; + } catch (err) { + const msg = err?.message || String(err); + setSettleup({ status: API_STATUS.ENTER_ERROR, error: msg }); + return API_STATUS.ENTER_ERROR; + } + }, + [token, user], + ); + + const _runSettleupEnterOnly = useCallback( + async (formState) => { + const permissions = settleupPermissionsRef.current; + if (!permissions) { + // No cached permissions — fall back to full fetch+enter + return _runSettleupFetchThenEnter(formState); + } + setSettleup({ status: API_STATUS.ENTERING, error: null }); + try { + await runSettleupEnter(formState, { token }); + setSettleup({ status: API_STATUS.SUCCESS, error: null }); + return API_STATUS.SUCCESS; + } catch (err) { + const msg = err?.message || String(err); + setSettleup({ status: API_STATUS.ENTER_ERROR, error: msg }); + return API_STATUS.ENTER_ERROR; + } + }, + [token, _runSettleupFetchThenEnter], + ); + + // ── Public API ───────────────────────────────────────────────────────────── + + /** + * Start a full sync for all targeted APIs concurrently. + * Returns { ynab: finalStatus, settleup: finalStatus }. + */ + const startSync = useCallback( + async (formState) => { + lastFormStateRef.current = formState; + settleupPermissionsRef.current = null; + + const targets = formState.target; + setYnab(targets.ynab ? INITIAL : { status: API_STATUS.SKIPPED, error: null }); + setSettleup(targets.settleup ? INITIAL : { status: API_STATUS.SKIPPED, error: null }); + + const [ynabResult, settleupResult] = await Promise.allSettled([ + targets.ynab ? _runYnabEnter(formState) : Promise.resolve(API_STATUS.SKIPPED), + targets.settleup + ? _runSettleupFetchThenEnter(formState) + : Promise.resolve(API_STATUS.SKIPPED), + ]); + + return { + ynab: ynabResult.value ?? API_STATUS.ENTER_ERROR, + settleup: settleupResult.value ?? API_STATUS.ENTER_ERROR, + }; + }, + [_runYnabEnter, _runSettleupFetchThenEnter], + ); + + /** + * Retry from the fetch phase (for FETCH_ERROR). + * For YNAB (no fetch phase), this is equivalent to retryEnter. + */ + const retryFetch = useCallback( + async (api) => { + const formState = lastFormStateRef.current; + if (!formState) return; + if (api === "ynab") return _runYnabEnter(formState); + if (api === "settleup") return _runSettleupFetchThenEnter(formState); + }, + [_runYnabEnter, _runSettleupFetchThenEnter], + ); + + /** + * Retry from the enter phase only (for ENTER_ERROR). + * Reuses cached permissions for SettleUp to avoid re-fetching. + */ + const retryEnter = useCallback( + async (api) => { + const formState = lastFormStateRef.current; + if (!formState) return; + if (api === "ynab") return _runYnabEnter(formState); + if (api === "settleup") return _runSettleupEnterOnly(formState); + }, + [_runYnabEnter, _runSettleupEnterOnly], + ); + + return { + ynab, + settleup, + anyInFlight, + startSync, + retryFetch, + retryEnter, + }; +} From 66de84279a33c1471558ca4a75965c28a1611426 Mon Sep 17 00:00:00 2001 From: Silma Thoron Date: Sat, 18 Jul 2026 00:02:06 +0200 Subject: [PATCH 2/6] Add simplified per-API status tracking with inline dual-API status UI This updates the API sync flow to track YNAB and SettleUp independently with a simpler status model and an inline summary UI. Included: - lightweight per-API status tracking with independent retries - inline ApiSyncStatus component with API-specific colors and spinner/check/error states - local static preview page for quick visual checks Notes: - the existing branch work has been amended into one commit - generated version and unrelated planning files are left out of the PR --- src/api-sync-status-preview.jsx | 13 +++++++++++++ src/version.js | 12 ++++++------ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/api-sync-status-preview.jsx b/src/api-sync-status-preview.jsx index bc7de2c..eea1f36 100644 --- a/src/api-sync-status-preview.jsx +++ b/src/api-sync-status-preview.jsx @@ -1,3 +1,4 @@ +import PropTypes from "prop-types"; import { StrictMode } from "react"; import ReactDOM from "react-dom/client"; @@ -62,6 +63,18 @@ function PreviewCard({ title, ynab, settleup }) { ); } +PreviewCard.propTypes = { + title: PropTypes.string.isRequired, + ynab: PropTypes.shape({ + status: PropTypes.string.isRequired, + error: PropTypes.string, + }).isRequired, + settleup: PropTypes.shape({ + status: PropTypes.string.isRequired, + error: PropTypes.string, + }).isRequired, +}; + function App() { return (
{ From ea87a556ca746658e4851ed0ee390f9219dbe3ed Mon Sep 17 00:00:00 2001 From: Silma Thoron Date: Sat, 18 Jul 2026 00:12:28 +0200 Subject: [PATCH 3/6] Address Copilot review feedback for API sync --- src/components/ApiSyncStatus.jsx | 6 ++- src/components/ApiSyncStatus.test.jsx | 70 ++++++++++++++++++++++++ src/hooks/useApiSync.js | 12 ++++- src/hooks/useApiSync.test.js | 76 +++++++++++++++++++++++++++ 4 files changed, 161 insertions(+), 3 deletions(-) create mode 100644 src/components/ApiSyncStatus.test.jsx create mode 100644 src/hooks/useApiSync.test.js diff --git a/src/components/ApiSyncStatus.jsx b/src/components/ApiSyncStatus.jsx index 5b88266..a88ed26 100644 --- a/src/components/ApiSyncStatus.jsx +++ b/src/components/ApiSyncStatus.jsx @@ -91,7 +91,11 @@ function ApiChip({ name, syncState, color, onRetry }) { {canRetry && (