diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..188561d --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,3 @@ +#!/usr/bin/env sh + +npm run precommit 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/package-lock.json b/package-lock.json index 2ac7e17..3cfb0ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.2.0", "globals": "^16.3.0", + "husky": "^9.1.7", "jsdom": "^26.1.0", "postcss": "^8.5.6", "prettier": "^3.5.3", @@ -7621,6 +7622,22 @@ "node": ">= 14" } }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", diff --git a/package.json b/package.json index 7916110..cd69975 100644 --- a/package.json +++ b/package.json @@ -20,8 +20,12 @@ "test:run": "vitest run", "test:watch": "vitest --watch", "test:coverage": "vitest run --coverage", + "format:check": "prettier --check .", + "format:check:staged": "node scripts/check-staged-format.js", "lint": "eslint .", - "lint:fix": "eslint . --fix" + "lint:fix": "eslint . --fix", + "precommit": "npm run lint && npm run format:check:staged", + "prepare": "husky" }, "dependencies": { "@emoji-mart/data": "^1.2.1", @@ -51,6 +55,7 @@ "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.2.0", "globals": "^16.3.0", + "husky": "^9.1.7", "jsdom": "^26.1.0", "postcss": "^8.5.6", "prettier": "^3.5.3", diff --git a/scripts/check-staged-format.js b/scripts/check-staged-format.js new file mode 100644 index 0000000..a954a98 --- /dev/null +++ b/scripts/check-staged-format.js @@ -0,0 +1,37 @@ +/* eslint-disable import/no-nodejs-modules */ +import { execFileSync } from "child_process"; +import { resolve } from "path"; + +const prettierCliPath = resolve( + "node_modules", + "prettier", + "bin", + "prettier.cjs", +); + +function getStagedFiles() { + const output = execFileSync( + "git", + ["diff", "--cached", "--name-only", "--diff-filter=ACMR"], + { encoding: "utf8" }, + ).trim(); + + if (!output) { + return []; + } + + return output.split(/\r?\n/).filter(Boolean); +} + +const stagedFiles = getStagedFiles(); + +if (stagedFiles.length === 0) { + console.log("No staged files to check with Prettier."); + globalThis.process.exit(0); +} + +execFileSync( + globalThis.process.execPath, + [prettierCliPath, "--check", "--ignore-unknown", ...stagedFiles], + { stdio: "inherit" }, +); 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..c26774e --- /dev/null +++ b/src/api-sync-status-preview.jsx @@ -0,0 +1,151 @@ +import PropTypes from "prop-types"; +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={() => {}} + /> +
+ ); +} + +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 ( +
+
+
+
+ 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..d685f72 --- /dev/null +++ b/src/components/ApiSyncStatus.jsx @@ -0,0 +1,187 @@ +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/components/ApiSyncStatus.test.jsx b/src/components/ApiSyncStatus.test.jsx new file mode 100644 index 0000000..e75b9c5 --- /dev/null +++ b/src/components/ApiSyncStatus.test.jsx @@ -0,0 +1,70 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { API_STATUS } from "../hooks/useApiSync"; + +import ApiSyncStatus from "./ApiSyncStatus.jsx"; + +function renderStatus(overrides = {}) { + const onRetryYnab = vi.fn(); + const onRetrySettleup = vi.fn(); + + render( + , + ); + + return { onRetryYnab, onRetrySettleup }; +} + +describe("ApiSyncStatus", () => { + it("renders nothing when both APIs are idle", () => { + const { container } = render( + , + ); + + expect(container).toBeEmptyDOMElement(); + }); + + it("renders the current status labels with the API names", () => { + render( + , + ); + + expect(screen.getByText(/YNAB fetching data/i)).toBeInTheDocument(); + expect(screen.getByText(/SettleUp ready/i)).toBeInTheDocument(); + }); + + it("renders retry buttons for error states and disables them immediately on click", () => { + const { onRetrySettleup } = renderStatus({ + ynab: { status: API_STATUS.SUCCESS, error: null }, + settleup: { + status: API_STATUS.ENTER_ERROR, + error: "Temporary network issue", + }, + }); + + const retryButton = screen.getByRole("button", { name: /retry/i }); + expect(retryButton).toHaveAttribute("type", "button"); + + fireEvent.click(retryButton); + + expect(retryButton).toBeDisabled(); + expect(onRetrySettleup).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/hooks/useApiSync.js b/src/hooks/useApiSync.js new file mode 100644 index 0000000..92142d3 --- /dev/null +++ b/src/hooks/useApiSync.js @@ -0,0 +1,320 @@ +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 + ? { status: API_STATUS.ENTERING, error: null } + : { status: API_STATUS.SKIPPED, error: null }, + ); + setSettleup( + targets.settleup + ? { status: API_STATUS.FETCHING, error: null } + : { 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, + }; +} diff --git a/src/hooks/useApiSync.test.js b/src/hooks/useApiSync.test.js new file mode 100644 index 0000000..efab04c --- /dev/null +++ b/src/hooks/useApiSync.test.js @@ -0,0 +1,70 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { API_STATUS, useApiSync } from "./useApiSync"; + +const { createTransaction, fetchSettleUpPermissions, addSettleUpTransaction } = + vi.hoisted(() => ({ + createTransaction: vi.fn(), + fetchSettleUpPermissions: vi.fn(), + addSettleUpTransaction: vi.fn(), + })); + +vi.mock("../api/settleup", () => ({ + fetchSettleUpPermissions: (...args) => fetchSettleUpPermissions(...args), + addSettleUpTransaction: (...args) => addSettleUpTransaction(...args), +})); + +describe("useApiSync", () => { + const formState = { + target: { ynab: true, settleup: true }, + date: new Date("2026-07-18T12:00:00.000Z"), + payeeId: "", + payee: "Lunch", + categoryId: "cat-1", + description: "Team lunch", + account: { swile: false, bourso: true }, + swileMilliunits: 20000, + amountMilliunits: 10000, + settleUpGroup: { groupId: "group-1" }, + settleUpPayerId: "member-1", + settleUpMembers: [{ id: "member-1", defaultWeight: "1" }], + settleUpCategory: "∅", + settleUpCurrency: "EUR", + }; + + const ynabAPI = { + transactions: { + createTransaction, + }, + }; + + beforeEach(() => { + createTransaction.mockImplementation(() => new Promise(() => {})); + fetchSettleUpPermissions.mockImplementation(() => new Promise(() => {})); + addSettleUpTransaction.mockImplementation(() => new Promise(() => {})); + }); + + it("marks both APIs in-flight immediately when syncing starts", async () => { + const { result } = renderHook(() => + useApiSync({ + ynabAPI, + budgetId: "budget-1", + accounts: [ + { id: "acc-1", name: "Boursorama", closed: false }, + { id: "acc-2", name: "Swile", closed: false }, + ], + token: "token-1", + user: { uid: "user-1" }, + }), + ); + + await act(async () => { + void result.current.startSync(formState); + }); + + expect(result.current.ynab.status).toBe(API_STATUS.ENTERING); + expect(result.current.settleup.status).toBe(API_STATUS.FETCHING); + expect(result.current.anyInFlight).toBe(true); + }); +}); diff --git a/src/version.js b/src/version.js index c12dbe1..4d4682e 100644 --- a/src/version.js +++ b/src/version.js @@ -2,13 +2,13 @@ // Do not edit manually export const VERSION_INFO = { - "tag": null, - "branch": "copilot/update-icons-to-loading-svg", - "commit": "293c8f3", + "tag": "1.2.0", + "branch": "feature/simplified-api-status-tracking", + "commit": "db79089", "isDirty": true, - "buildTime": "2025-10-26T11:09:42.757Z", - "version": "copilot/update-icons-to-loading-svg-293c8f3-dirty", - "shortVersion": "293c8f3" + "buildTime": "2026-07-17T21:57:22.329Z", + "version": "1.2.0", + "shortVersion": "1.2.0" }; export const getVersionString = () => {