Conversation
🦋 Changeset detectedLatest commit: f2d5d12 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
WalkthroughAdds card statement PDF generation and maturity-based activity filtering: a new React-PDF Statement component, API updates to return PDF or JSON (with maturity-filtered card transactions), tests, TSX/JSX config and ESLint react override, plus workspace/package dependency updates and changeset entries. (44 words) Changes
Sequence DiagramsequenceDiagram
participant Client
participant API as "API Endpoint\n(server/api/activity.ts)"
participant Card as "Card Data\n(fetching)"
participant PDF as "PDF Renderer\n(Statement)"
participant Response
Client->>API: GET /activity?maturity={m}\nAccept: application/pdf or application/json
API->>API: validate/parse maturity
alt maturity provided
API->>Card: fetch card purchases & transactions
Card-->>API: return card + txn data
API->>API: aggregate & sort transactions
alt Accept: application/pdf
API->>PDF: render Statement(data,lastFour,maturity)
PDF-->>API: return PDF buffer
API->>Response: respond Content-Type: application/pdf\nwith buffer
else
API->>Response: respond JSON including cardPurchases
end
else
API->>Response: respond JSON without cardPurchases
end
Response-->>Client: deliver response
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
✅ All tests passed. |
Summary of ChangesHello @nfmelendez, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request introduces a significant new capability by enabling the generation of detailed PDF card statements directly from the activity API. Users can now request a statement for a specific maturity period, receiving a well-formatted PDF document that summarizes their purchases and repayments. The changes also streamline dependency management for React across the project and include a minor fix for iterator handling. Highlights
Changelog
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here. You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension. Footnotes
|
There was a problem hiding this comment.
Code Review
This pull request introduces a new feature to generate card statements as PDFs, involving changes like adding a maturity filter to the activity endpoint, handling application/pdf requests to render and return a PDF statement using @react-pdf/renderer, and creating a new React component for the statement layout. A security review found no specific vulnerabilities in the analyzed code changes, including a SAST Reconnaissance Pass on server/api/activity.ts where no issues were identified. The suggestions to improve readability, maintainability, and robustness of the new data transformation logic in server/api/activity.ts and date formatting in the PDF statement remain valid. Overall, great work on implementing this feature.
| !ignore("card") && maturity | ||
| ? await (async function () { | ||
| if (!borrows) return []; | ||
| const hashes = borrows | ||
| .entries() | ||
| .filter(([_, { events }]) => events.some(({ maturity: m }) => m === BigInt(maturity))) | ||
| .map(([hash]) => hash) | ||
| .toArray(); | ||
| if (hashes.length === 0) return []; | ||
| const transactions = await database.query.transactions.findMany({ | ||
| where: arrayOverlaps(transactionsSchema.hashes, hashes), | ||
| columns: { hashes: true, payload: true }, | ||
| }); | ||
| if (panda.success) return panda.output; | ||
|
|
||
| if (hashes.length !== 1) throw new Error("cryptomate transactions need to have only one hash"); | ||
| const hash = hashes[0]; | ||
| const borrow = borrows?.get(hash as Hash); | ||
| const cryptomate = safeParse( | ||
| { 0: DebitActivity, 1: CreditActivity }[borrow?.events.length ?? 0] ?? InstallmentsActivity, | ||
| { | ||
| ...(payload as object), | ||
| hash, | ||
| events: borrow?.events, | ||
| blockTimestamp: borrow?.blockNumber && timestamps.get(borrow.blockNumber), | ||
| }, | ||
| ); | ||
| if (cryptomate.success) return cryptomate.output; | ||
| captureException(new Error("bad transaction"), { level: "error", contexts: { cryptomate, panda } }); | ||
| }), | ||
| ), | ||
| ...[...deposits, ...repays, ...withdraws].map(({ blockNumber, ...event }) => { | ||
| const timestamp = timestamps.get(blockNumber); | ||
| if (timestamp) return { ...event, timestamp: new Date(Number(timestamp) * 1000).toISOString() }; | ||
| captureException(new Error("block not found"), { | ||
| level: "error", | ||
| contexts: { event: { ...event, timestamp } }, | ||
| return [{ transactions }]; | ||
| })() |
There was a problem hiding this comment.
This immediately-invoked async function expression (IIAFE) contains significant logic for fetching card purchases based on maturity. To improve readability and separation of concerns, consider extracting this logic into a dedicated async function, for example getCardPurchasesForMaturity(borrows, maturity).
| data: response | ||
| .map((item) => { | ||
| if (item.type === "panda") { | ||
| return { | ||
| timestamp: item.timestamp, | ||
| description: `${item.merchant.name}${item.merchant.city ? `,${item.merchant.city}` : ""}`, | ||
| installments: item.operations | ||
| .reduce((accumulator, operation) => { | ||
| if ("borrow" in operation) { | ||
| if ("installments" in operation.borrow) { | ||
| const installments = operation.borrow.installments; | ||
| const n = installments.findIndex((installment) => installment.maturity === Number(maturity)); | ||
| const progress = `${n + 1}/${installments.length}`; | ||
| const status = accumulator.get(progress) ?? { | ||
| current: n + 1, | ||
| total: installments.length, | ||
| amount: 0, | ||
| }; | ||
| status.amount += (installments[n]?.assets ?? 0) + (installments[n]?.fee ?? 0); | ||
| accumulator.set(progress, status); | ||
| } else { | ||
| const status = accumulator.get("1/1") ?? { | ||
| current: 1, | ||
| total: 1, | ||
| amount: 0, | ||
| }; | ||
| status.amount += operation.borrow.assets + operation.borrow.fee; | ||
| accumulator.set("1/1", status); | ||
| } | ||
| } | ||
| return accumulator; | ||
| }, new Map<string, { amount: number; current: number; total: number }>()) | ||
| .values() | ||
| .toArray(), | ||
| }; | ||
| } | ||
| if (item.type === "repay") { | ||
| return { | ||
| timestamp: item.timestamp, | ||
| positionAmount: item.positionAmount, | ||
| amount: item.amount, | ||
| discount: ((item.positionAmount - item.amount) / item.positionAmount) * 100, | ||
| }; | ||
| } | ||
| }) | ||
| .filter((item) => item !== undefined), | ||
| }; |
There was a problem hiding this comment.
The logic to transform the response data into the format required for the statement is quite complex, especially for 'panda' type items, and is embedded within the main handler. To improve readability and testability, consider extracting the transformation logic for each activity type into separate helper functions. You could then use flatMap to build the final data array, which would make the code cleaner and easier to maintain.
server/utils/Statement.tsx
Outdated
| const dueDate = new Date(statement.maturity * 1000).toLocaleDateString("en-CA"); | ||
| const statementDate = new Date((statement.maturity - MATURITY_INTERVAL) * 1000).toLocaleDateString("en-CA"); |
There was a problem hiding this comment.
Using toLocaleDateString('en-CA') to format dates as YYYY-MM-DD is a clever trick, but it relies on locale-specific behavior that might not be consistent across all environments, making it potentially fragile. For more robust and explicit date formatting, consider a manual approach, for example by constructing the date string from getFullYear(), getMonth(), and getDate(). This would be more resilient to environment differences.
There was a problem hiding this comment.
could you explain more what you mean by " locale-specific behavior "
There was a problem hiding this comment.
@gemini-code-assist could you explain more what you mean by "locale-specific behavior"
There was a problem hiding this comment.
You're right to ask for clarification! When I refer to "locale-specific behavior" with toLocaleDateString(), I mean that the exact output format can vary depending on the JavaScript engine, operating system, or even user settings where the code is executed.
While "en-CA" is generally understood to produce YYYY-MM-DD (e.g., 2026-02-13), this is not a strict guarantee by the ECMAScript specification. The specification allows implementations to define their own locale data. For example:
- Different JavaScript engines: A date formatted in Node.js might look slightly different than in a browser (Chrome, Firefox, Safari) or on a different operating system (Windows, macOS, Linux).
- Version differences: Newer versions of Node.js or browsers might update their locale data, potentially leading to subtle changes in formatting.
- User settings: In some environments, user-level locale preferences might override the
"en-CA"locale's default date format.
This means that while it works as expected in most common scenarios, there's a small but non-zero risk that in a specific, less common environment, toLocaleDateString("en-CA") might produce something like YYYY/MM/DD or DD-MM-YYYY, which would break the expected YYYY-MM-DD format. For a critical output like a PDF statement, where consistency is paramount, relying on such an implicit behavior can be fragile.
That's why a manual approach, constructing the date string explicitly from getFullYear(), getMonth(), and getDate(), offers more control and guarantees the exact YYYY-MM-DD format regardless of the execution environment.
There was a problem hiding this comment.
Actionable comments posted: 12
🤖 Fix all issues with AI agents
In `@pnpm-workspace.yaml`:
- Around line 14-15: The react version is pinned to "19.1.0" while other
packages use caret ranges; either make the pin intentional or change to a caret
range—update the react entry (and by extension react-dom which references it) in
pnpm-workspace.yaml to use a caret range (e.g., "^19.1.0") if you want normal
semver updates, or add a short comment/docstring indicating that keeping react
at the exact "19.1.0" is deliberate to prevent accidental upgrades.
In `@server/api/activity.ts`:
- Around line 326-370: The map callback for the array mapping (the .map(...)
that checks item.type === "panda" and "repay") can return undefined for other
item types; replace this .map with .flatMap and ensure the callback returns an
array: return [pandaObject] for "panda", [repayObject] for "repay", and [] for
all other cases so no implicit undefineds remain; keep the existing logic
building the panda object (including the installments accumulator and
Map.values().toArray()) and the repay object (positionAmount/amount/discount)
but wrap each in an array when returning from the callback.
- Around line 361-367: The repay branch returns a discount computed as
((item.positionAmount - item.amount) / item.positionAmount) * 100 which can
divide by zero; update the repay case in the activity mapping (the branch
checking item.type === "repay") to guard against positionAmount === 0 by
computing discount only when positionAmount is nonzero (e.g., set discount to 0
or null when item.positionAmount === 0) and otherwise calculate the percentage
as before; ensure you reference item.positionAmount and item.amount in the
guarded expression so no division by zero occurs.
- Around line 69-72: The request schema allows any string for the "maturity"
field which later is passed to BigInt() and Number(), so validate it at the
schema level: update the optional(object(...)) schema that defines maturity to
use a numeric-string validator (e.g., replace optional(string()) with a
regex/number-string validator) so only decimal-integer strings (or an accepted
numeric format) are allowed; also add the regex import to the valibot imports
(per comment) and keep the field name "maturity" and surrounding schema
structure intact so BigInt(maturity) and Number(maturity) calls downstream won't
throw on invalid input.
- Around line 334-343: The code uses findIndex to locate an installment (in
server/api/activity.ts) and then builds progress and accesses installments[n]
without checking for a not-found (-1) result; update the block that computes n
(the findIndex on operation.borrow.installments) to detect n === -1 and skip
processing or continue (i.e., do not add a progress entry or modify the
accumulator) when no matching maturity is found, ensuring you do not compute
progress = `${n+1}/${...}` or access installments[n] when n is -1; apply this
guard around the logic that creates/updates status and amount so only valid
installment matches are aggregated.
- Around line 272-315: The map callbacks inside the response construction (the
cardPurchases.flatMap(... transactions.map(...)) branch and the [...deposits,
...repays, ...withdraws].map(...) branch) sometimes fall through to
captureException and implicitly return undefined, triggering the linter; change
those inner .map usages to .flatMap and, where you currently call
captureException, return an empty array ([]) instead of falling through so the
flatMap removes the bad item inline; keep the outer cardPurchases.flatMap and
replace the deposits/repays/withdraws .map with .flatMap as well, using the same
pattern (return the mapped object on success or [] after captureException) so
the subsequent filter step becomes unnecessary.
In `@server/test/api/activity.test.ts`:
- Around line 213-235: The test duplicates maturity extraction and risks
producing "undefined" by using String(withBorrow && withBorrow.borrow.maturity);
change to assert and use a non-null assertion when reading the maturity (e.g.,
use withBorrow!.borrow.maturity) or extract a small helper
(getBorrowMaturity(from activity) or a local const getMaturity) to compute
maturity once, then pass String(getMaturity(withBorrow!)) into the request;
update the occurrences around the withBorrow variable and the maturity const in
the tests (the "filters by maturity" and the similar "returns statement pdf"
block) so you no longer use the && pattern and avoid duplicated extraction
logic.
In `@server/tsconfig.json`:
- Line 3: The tsconfig currently sets "jsx": "react" but the "include" globs
only match *.ts so .tsx files like server/utils/Statement.tsx are excluded;
update the tsconfig "include" array to add matching .tsx patterns (e.g., add
"*.tsx" or directory-specific "*.tsx" entries for any folders that contain React
components) so the TypeScript compiler picks up .tsx files, ensuring the "jsx"
option is effective and future .tsx files are compiled.
In `@server/utils/Statement.tsx`:
- Around line 79-91: The default-exported React component Statement is currently
defined below supporting details (like styles); move the Statement
declaration/export so the default export is at the top of the file above any
helpers/styles. Specifically, place the Statement function (the component that
accepts props { data, lastFour, maturity }) and its export before the styles
object and any helper functions, and apply the same change for the other default
export referenced around line ~199 so all default exports appear at the top with
supporting details below.
- Around line 94-112: Extract the duplicated inline union member types into
named types (e.g., RepaymentItem and PurchaseItem) and use those types both in
the component props union and in the type guard predicates used for repayments
and purchases; update the predicates that currently assert "positionAmount" in
item and "description" in item to be typed as (item): item is RepaymentItem and
(item): item is PurchaseItem respectively, and replace the repeated inline shape
definitions wherever they appear (including the props union and the
repayments/purchases filters) to reference the new named types.
- Around line 152-156: The discount chip currently renders even when
item.discount is 0; update the JSX that renders the <View
style={styles.discountChip}> / <Text style={styles.discountText}> so it is
conditionally rendered only when item.discount is greater than 0 (e.g., guard
with item.discount > 0 or Boolean(item.discount) around that block). Locate the
rendering block in Statement.tsx (the View with flexDirection row that contains
styles.discountChip and styles.discountText) and wrap it in a conditional check
to prevent "0.00% discount applied" from showing.
In `@src/utils/server.ts`:
- Around line 240-241: The current code calls parseResponse(response) and then
converts any non-array result to [] which can silently swallow errors and bypass
the file's usual APIError handling; update the flow to first check response.ok
and, if false, parse the error body and throw an APIError (preserving
status/message), otherwise call parseResponse(response) and return the parsed
value only if Array.isArray(parsed) (or throw/convert explicitly), referencing
the existing APIError class, the parseResponse function, and the response.ok
boolean so callers receive consistent structured errors instead of opaque or
empty arrays.
| optional( | ||
| object({ include: optional(union([ActivityTypes, array(ActivityTypes)])), maturity: optional(string()) }), | ||
| {}, | ||
| ), |
There was a problem hiding this comment.
Unvalidated maturity string will crash on BigInt() / Number() with non-numeric input.
maturity is optional(string()) with no format constraint. It's later used as BigInt(maturity) (line 127) and Number(maturity) (lines 323, 336), which will throw on non-numeric strings like "abc", crashing the request handler.
Proposed fix — add numeric validation at the schema level
- optional(
- object({ include: optional(union([ActivityTypes, array(ActivityTypes)])), maturity: optional(string()) }),
- {},
- ),
+ optional(
+ object({ include: optional(union([ActivityTypes, array(ActivityTypes)])), maturity: optional(pipe(string(), regex(/^\d+$/))) }),
+ {},
+ ),Add regex to the valibot imports at line 7.
🤖 Prompt for AI Agents
In `@server/api/activity.ts` around lines 69 - 72, The request schema allows any
string for the "maturity" field which later is passed to BigInt() and Number(),
so validate it at the schema level: update the optional(object(...)) schema that
defines maturity to use a numeric-string validator (e.g., replace
optional(string()) with a regex/number-string validator) so only decimal-integer
strings (or an accepted numeric format) are allowed; also add the regex import
to the valibot imports (per comment) and keep the field name "maturity" and
surrounding schema structure intact so BigInt(maturity) and Number(maturity)
calls downstream won't throw on invalid input.
There was a problem hiding this comment.
exist in valibot a function similar to regex(/^\d+$/) ?
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
valibot numeric string validation digits
💡 Result:
In Valibot, the built-in way to validate a string that contains only digits (0–9) is the digits() validation action, used in a pipeline after string(). [1]
import * as v from 'valibot';
const NumericString = v.pipe(
v.string(),
v.digits('Must contain only digits (0-9).')
);If you also need a specific digit count, combine digits() with length() (exact) or maxLength() / minLength() (range). [1][6][3]
const Exactly8Digits = v.pipe(v.string(), v.digits(), v.length(8));Alternatively, you can validate “digits only” with regex() (useful for more complex patterns). [2]
const DigitsOnly = v.pipe(v.string(), v.regex(/^\d+$/));If you meant a decimal numeric string (allowing - and/or .), use decimal() instead of digits(). [3]
Sources: Valibot digits [1], regex [2], decimal [3], length [6], maxLength [3].
@nfmelendez Yes! Valibot has a built-in digits() function that validates strings containing only digits (0-9). This is the idiomatic way to validate numeric strings in valibot.
Here's how to apply it to your case:
- optional(
- object({ include: optional(union([ActivityTypes, array(ActivityTypes)])), maturity: optional(string()) }),
- {},
- ),
+ optional(
+ object({ include: optional(union([ActivityTypes, array(ActivityTypes)])), maturity: optional(pipe(string(), digits())) }),
+ {},
+ ),And add digits to your valibot imports on line 7:
import {
array,
bigint,
boolean,
+ digits,
intersect,
isoTimestamp,
...This will ensure that maturity only accepts strings like "12345" and will reject non-numeric strings like "abc" before they reach the BigInt() and Number() calls.
🧠 Learnings used
Learnt from: franm91
Repo: exactly/exa PR: 653
File: src/components/getting-started/GettingStarted.tsx:132-133
Timestamp: 2026-01-22T18:44:56.353Z
Learning: In the KYC flow refactor, the `{ inquiryId, sessionToken }` response from the KYC API is a legacy format that was only returned when a `templateId` parameter was provided. This legacy format is being deprecated. The new flow in `src/components/getting-started/GettingStarted.tsx` and related files calls `getKYCStatus()` without the `templateId` parameter, so the legacy response shape will never be returned, and the `"code" in status` type guard is sufficient.
<!--
| .map((item) => { | ||
| if (item.type === "panda") { | ||
| return { | ||
| timestamp: item.timestamp, | ||
| description: `${item.merchant.name}${item.merchant.city ? `,${item.merchant.city}` : ""}`, | ||
| installments: item.operations | ||
| .reduce((accumulator, operation) => { | ||
| if ("borrow" in operation) { | ||
| if ("installments" in operation.borrow) { | ||
| const installments = operation.borrow.installments; | ||
| const n = installments.findIndex((installment) => installment.maturity === Number(maturity)); | ||
| const progress = `${n + 1}/${installments.length}`; | ||
| const status = accumulator.get(progress) ?? { | ||
| current: n + 1, | ||
| total: installments.length, | ||
| amount: 0, | ||
| }; | ||
| status.amount += (installments[n]?.assets ?? 0) + (installments[n]?.fee ?? 0); | ||
| accumulator.set(progress, status); | ||
| } else { | ||
| const status = accumulator.get("1/1") ?? { | ||
| current: 1, | ||
| total: 1, | ||
| amount: 0, | ||
| }; | ||
| status.amount += operation.borrow.assets + operation.borrow.fee; | ||
| accumulator.set("1/1", status); | ||
| } | ||
| } | ||
| return accumulator; | ||
| }, new Map<string, { amount: number; current: number; total: number }>()) | ||
| .values() | ||
| .toArray(), | ||
| }; | ||
| } | ||
| if (item.type === "repay") { | ||
| return { | ||
| timestamp: item.timestamp, | ||
| positionAmount: item.positionAmount, | ||
| amount: item.amount, | ||
| discount: ((item.positionAmount - item.amount) / item.positionAmount) * 100, | ||
| }; | ||
| } | ||
| }) | ||
| .filter((item) => item !== undefined), |
There was a problem hiding this comment.
Same Biome linter error: map callback at line 326 doesn't return for non-panda/repay items.
This .map() only returns for item.type === "panda" and item.type === "repay", falling through to implicit undefined for other types. Same fix pattern — use .flatMap() and return [] for unhandled types.
🧰 Tools
🪛 Biome (2.3.14)
[error] 326-326: This callback passed to map() iterable method should always return a value.
Add missing return statements so that this callback returns a value on all execution paths.
(lint/suspicious/useIterableCallbackReturn)
🤖 Prompt for AI Agents
In `@server/api/activity.ts` around lines 326 - 370, The map callback for the
array mapping (the .map(...) that checks item.type === "panda" and "repay") can
return undefined for other item types; replace this .map with .flatMap and
ensure the callback returns an array: return [pandaObject] for "panda",
[repayObject] for "repay", and [] for all other cases so no implicit undefineds
remain; keep the existing logic building the panda object (including the
installments accumulator and Map.values().toArray()) and the repay object
(positionAmount/amount/discount) but wrap each in an array when returning from
the callback.
| if ("installments" in operation.borrow) { | ||
| const installments = operation.borrow.installments; | ||
| const n = installments.findIndex((installment) => installment.maturity === Number(maturity)); | ||
| const progress = `${n + 1}/${installments.length}`; | ||
| const status = accumulator.get(progress) ?? { | ||
| current: n + 1, | ||
| total: installments.length, | ||
| amount: 0, | ||
| }; | ||
| status.amount += (installments[n]?.assets ?? 0) + (installments[n]?.fee ?? 0); |
There was a problem hiding this comment.
findIndex returning -1 silently produces corrupt installment data.
When installment.maturity doesn't match Number(maturity), findIndex returns -1, giving n + 1 = 0 (invalid 0-indexed current), progress key "0/length", and installments[-1]?.assets ?? 0 → amount 0. This silently injects a bogus zero-amount row into the PDF.
Guard against the not-found case:
Proposed fix
const installments = operation.borrow.installments;
const n = installments.findIndex((installment) => installment.maturity === Number(maturity));
+ if (n === -1) return accumulator;
const progress = `${n + 1}/${installments.length}`;🤖 Prompt for AI Agents
In `@server/api/activity.ts` around lines 334 - 343, The code uses findIndex to
locate an installment (in server/api/activity.ts) and then builds progress and
accesses installments[n] without checking for a not-found (-1) result; update
the block that computes n (the findIndex on operation.borrow.installments) to
detect n === -1 and skip processing or continue (i.e., do not add a progress
entry or modify the accumulator) when no matching maturity is found, ensuring
you do not compute progress = `${n+1}/${...}` or access installments[n] when n
is -1; apply this guard around the logic that creates/updates status and amount
so only valid installment matches are aggregated.
| @@ -1,5 +1,6 @@ | |||
| { | |||
| "compilerOptions": { | |||
| "jsx": "react", | |||
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# Read the tsconfig.json file to verify the structure and content
cat -n server/tsconfig.jsonRepository: exactly/exa
Length of output: 981
🏁 Script executed:
# Check for any .tsx files in the server directory that would be missed
fd -e tsx . server/Repository: exactly/exa
Length of output: 81
.tsx files are excluded from compilation.
"jsx": "react" is added but every include glob uses *.ts, which does not match *.tsx. server/utils/Statement.tsx (and any future .tsx files) will be invisible to tsc.
Proposed fix — add tsx globs
"include": [
"api/**/*.ts",
+ "api/**/*.tsx",
"database/**/*.ts",
"hooks/**/*.ts",
"middleware/**/*.ts",
"utils/**/*.ts",
+ "utils/**/*.tsx",
"script/**/*.ts",
"test/**/*.ts",
+ "test/**/*.tsx",
"index.ts",
"instrument.cjs",
"*.config.ts",Alternatively, a single wildcard per directory (e.g. "utils/**/*.{ts,tsx}") keeps it compact, but tsconfig doesn't support brace expansion — so individual entries are needed.
🤖 Prompt for AI Agents
In `@server/tsconfig.json` at line 3, The tsconfig currently sets "jsx": "react"
but the "include" globs only match *.ts so .tsx files like
server/utils/Statement.tsx are excluded; update the tsconfig "include" array to
add matching .tsx patterns (e.g., add "*.tsx" or directory-specific "*.tsx"
entries for any folders that contain React components) so the TypeScript
compiler picks up .tsx files, ensuring the "jsx" option is effective and future
.tsx files are compiled.
server/utils/Statement.tsx
Outdated
| const Statement = (statement: { | ||
| data: ( | ||
| | { | ||
| amount: number; | ||
| discount: number; | ||
| positionAmount: number; | ||
| timestamp: string; | ||
| } | ||
| | { description: string; installments: { amount: number; current: number; total: number }[]; timestamp: string } | ||
| )[]; | ||
| lastFour: string; | ||
| maturity: number; | ||
| }) => { |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major
Default export should be at the top of the file.
Per coding guidelines, the default export goes at the top with supporting details (styles, helpers) below. Move Statement above styles.
As per coding guidelines: "place the default export at the top of the file; place supporting details and extracted functions at the bottom."
Also applies to: 199-199
🤖 Prompt for AI Agents
In `@server/utils/Statement.tsx` around lines 79 - 91, The default-exported React
component Statement is currently defined below supporting details (like styles);
move the Statement declaration/export so the default export is at the top of the
file above any helpers/styles. Specifically, place the Statement function (the
component that accepts props { data, lastFour, maturity }) and its export before
the styles object and any helper functions, and apply the same change for the
other default export referenced around line ~199 so all default exports appear
at the top with supporting details below.
server/utils/Statement.tsx
Outdated
| const repayments = statement.data.filter( | ||
| ( | ||
| item, | ||
| ): item is { | ||
| amount: number; | ||
| discount: number; | ||
| positionAmount: number; | ||
| timestamp: string; | ||
| } => "positionAmount" in item, | ||
| ); | ||
| const purchases = statement.data.filter( | ||
| ( | ||
| item, | ||
| ): item is { | ||
| description: string; | ||
| installments: { amount: number; current: number; total: number }[]; | ||
| timestamp: string; | ||
| } => "description" in item, | ||
| ); |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major
Duplicated type definitions — extract shared types.
The union members in the props type (lines 80-88) are repeated verbatim in the type guard predicates (lines 96-102, 107-110). Extract them once and reference in both places.
Proposed approach
+interface Repayment { amount: number; discount: number; positionAmount: number; timestamp: string }
+interface Purchase { description: string; installments: { amount: number; current: number; total: number }[]; timestamp: string }
+
const Statement = (statement: {
- data: (
- | {
- amount: number;
- discount: number;
- positionAmount: number;
- timestamp: string;
- }
- | { description: string; installments: { amount: number; current: number; total: number }[]; timestamp: string }
- )[];
+ data: (Repayment | Purchase)[];
lastFour: string;
maturity: number;
}) => {
...
- const repayments = statement.data.filter(
- (
- item,
- ): item is {
- amount: number;
- discount: number;
- positionAmount: number;
- timestamp: string;
- } => "positionAmount" in item,
- );
- const purchases = statement.data.filter(
- (
- item,
- ): item is {
- description: string;
- installments: { amount: number; current: number; total: number }[];
- timestamp: string;
- } => "description" in item,
- );
+ const repayments = statement.data.filter((item): item is Repayment => "positionAmount" in item);
+ const purchases = statement.data.filter((item): item is Purchase => "description" in item);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const repayments = statement.data.filter( | |
| ( | |
| item, | |
| ): item is { | |
| amount: number; | |
| discount: number; | |
| positionAmount: number; | |
| timestamp: string; | |
| } => "positionAmount" in item, | |
| ); | |
| const purchases = statement.data.filter( | |
| ( | |
| item, | |
| ): item is { | |
| description: string; | |
| installments: { amount: number; current: number; total: number }[]; | |
| timestamp: string; | |
| } => "description" in item, | |
| ); | |
| interface Repayment { amount: number; discount: number; positionAmount: number; timestamp: string } | |
| interface Purchase { description: string; installments: { amount: number; current: number; total: number }[]; timestamp: string } | |
| const Statement = (statement: { | |
| data: (Repayment | Purchase)[]; | |
| lastFour: string; | |
| maturity: number; | |
| }) => { | |
| // ... existing code ... | |
| const repayments = statement.data.filter((item): item is Repayment => "positionAmount" in item); | |
| const purchases = statement.data.filter((item): item is Purchase => "description" in item); |
🤖 Prompt for AI Agents
In `@server/utils/Statement.tsx` around lines 94 - 112, Extract the duplicated
inline union member types into named types (e.g., RepaymentItem and
PurchaseItem) and use those types both in the component props union and in the
type guard predicates used for repayments and purchases; update the predicates
that currently assert "positionAmount" in item and "description" in item to be
typed as (item): item is RepaymentItem and (item): item is PurchaseItem
respectively, and replace the repeated inline shape definitions wherever they
appear (including the props union and the repayments/purchases filters) to
reference the new named types.
| <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}> | ||
| <View style={styles.discountChip}> | ||
| <Text style={styles.discountText}>{item.discount.toFixed(2)}% discount applied</Text> | ||
| </View> | ||
| </View> |
There was a problem hiding this comment.
Discount chip renders even when discount is 0.
When item.discount is 0, this renders "0.00% discount applied" — likely not the desired UX. Guard on a non-zero discount.
Proposed fix
- <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
- <View style={styles.discountChip}>
- <Text style={styles.discountText}>{item.discount.toFixed(2)}% discount applied</Text>
+ {item.discount > 0 && (
+ <View style={styles.discountChip}>
+ <Text style={styles.discountText}>{item.discount.toFixed(2)}% discount applied</Text>
</View>
- </View>
+ )}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}> | |
| <View style={styles.discountChip}> | |
| <Text style={styles.discountText}>{item.discount.toFixed(2)}% discount applied</Text> | |
| </View> | |
| </View> | |
| {item.discount > 0 && ( | |
| <View style={styles.discountChip}> | |
| <Text style={styles.discountText}>{item.discount.toFixed(2)}% discount applied</Text> | |
| </View> | |
| )} |
🤖 Prompt for AI Agents
In `@server/utils/Statement.tsx` around lines 152 - 156, The discount chip
currently renders even when item.discount is 0; update the JSX that renders the
<View style={styles.discountChip}> / <Text style={styles.discountText}> so it is
conditionally rendered only when item.discount is greater than 0 (e.g., guard
with item.discount > 0 or Boolean(item.discount) around that block). Locate the
rendering block in Statement.tsx (the View with flexDirection row that contains
styles.discountChip and styles.discountText) and wrap it in a conditional check
to prevent "0.00% discount applied" from showing.
| const parsed = await parseResponse(response); | ||
| return Array.isArray(parsed) ? parsed : []; |
There was a problem hiding this comment.
Errors are silently swallowed.
Every other function in this file throws APIError when response.ok is false. Here, parseResponse on a non-ok response will either throw internally (Hono's behavior) or return a non-array error body — which the Array.isArray guard silently converts to []. If parseResponse does throw, the caller gets an opaque error instead of a structured APIError.
Consider preserving the existing error-handling pattern for consistency and debuggability:
Proposed approach
const response = await api.activity.$get(
parameters?.include === undefined ? undefined : { query: { include: parameters.include } },
);
- const parsed = await parseResponse(response);
- return Array.isArray(parsed) ? parsed : [];
+ if (!response.ok) throw new APIError(response.status, stringOrLegacy(await response.json()));
+ return response.json();If parseResponse is specifically needed for the new response shape, at minimum guard response.ok first.
🤖 Prompt for AI Agents
In `@src/utils/server.ts` around lines 240 - 241, The current code calls
parseResponse(response) and then converts any non-array result to [] which can
silently swallow errors and bypass the file's usual APIError handling; update
the flow to first check response.ok and, if false, parse the error body and
throw an APIError (preserving status/message), otherwise call
parseResponse(response) and return the parsed value only if
Array.isArray(parsed) (or throw/convert explicitly), referencing the existing
APIError class, the parseResponse function, and the response.ok boolean so
callers receive consistent structured errors instead of opaque or empty arrays.
630b065 to
f2d5d12
Compare
| .filter(<T>(value: T | undefined): value is T => value !== undefined) | ||
| .toSorted((a, b) => b.timestamp.localeCompare(a.timestamp) || b.id.localeCompare(a.id)); | ||
|
|
||
| if (maturity && c.req.header("accept") === "application/pdf") { |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
cd server/api && head -n 330 activity.ts | tail -n 20Repository: exactly/exa
Length of output: 879
🌐 Web query:
Hono c.req.accepts content negotiation method
💡 Result:
Hono doesn’t provide a built-in c.req.accepts(...) method on HonoRequest. For content negotiation, use the Accepts Helper accepts(c, {...}), which matches a given Accept* header (including Accept) against a list of supported values and returns the chosen one (or a default). [1]
import { Hono } from 'hono'
import { accepts } from 'hono/accepts'
const app = new Hono()
app.get('/resource', (c) => {
const type = accepts(c, {
header: 'Accept',
supports: ['application/json', 'text/html'],
default: 'application/json',
})
if (type === 'text/html') return c.html('<h1>Hi</h1>')
return c.json({ message: 'Hi' })
})If you just need the raw header value (no negotiation), read it directly:
const accept = c.req.header('Accept')Sources: [1]
Accept header strict equality breaks content negotiation with multi-value headers.
c.req.header("accept") === "application/pdf" fails when clients send multi-value Accept headers like application/pdf, */* or application/pdf;q=0.9. Use c.req.header("accept")?.includes("application/pdf") for a simple fix, or — better — Hono's accepts() helper from 'hono/accepts' for proper negotiation:
import { accepts } from 'hono/accepts'
if (maturity && accepts(c, { header: 'Accept', supports: ['application/pdf'] })) {
// ...
}| const pdf = await renderToBuffer(Statement(statement)); | ||
| return c.body(toArrayBuffer(pdf.buffer), 200, { "content-type": "application/pdf" }); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# Find and examine the activity.ts file around lines 372-373
fd -t f "activity.ts" | head -5Repository: exactly/exa
Length of output: 223
🏁 Script executed:
# Check the structure of the project
ls -la | head -20Repository: exactly/exa
Length of output: 1257
🏁 Script executed:
# List TypeScript files in server directory
find server -name "*.ts" -type f | head -20Repository: exactly/exa
Length of output: 515
🏁 Script executed:
# Check the file size first
wc -l server/api/activity.tsRepository: exactly/exa
Length of output: 81
🏁 Script executed:
# Read the specific area around lines 372-373
sed -n '360,385p' server/api/activity.tsRepository: exactly/exa
Length of output: 1002
🏁 Script executed:
# Find where renderToBuffer is imported or defined
rg -n "renderToBuffer" --type=ts server/Repository: exactly/exa
Length of output: 685
🏁 Script executed:
# Find where toArrayBuffer is defined
rg -n "toArrayBuffer" --type=ts server/Repository: exactly/exa
Length of output: 250
🏁 Script executed:
# Check the imports at the top of activity.ts
head -50 server/api/activity.tsRepository: exactly/exa
Length of output: 1192
🏁 Script executed:
# Read the toArrayBuffer function definition
sed -n '666,680p' server/api/activity.tsRepository: exactly/exa
Length of output: 630
🏁 Script executed:
# Check package.json for `@react-pdf/renderer` version
rg "@react-pdf/renderer" package.jsonRepository: exactly/exa
Length of output: 37
🏁 Script executed:
# Check how c.body is used elsewhere in the code
rg -n "c\.body" --type=ts server/api/activity.ts | head -20Repository: exactly/exa
Length of output: 150
🏁 Script executed:
# Search for Hono c.body() usage patterns in the codebase
rg -n "c\.body\(" --type=ts -B 2 -A 2 | head -60Repository: exactly/exa
Length of output: 392
🏁 Script executed:
# Check package.json for Hono version
cat package.json | grep -A 2 -B 2 "hono"Repository: exactly/exa
Length of output: 784
🏁 Script executed:
# Look for Hono type imports or middleware that might show c.body signature
rg -n "from.*hono" --type=ts server/api/activity.tsRepository: exactly/exa
Length of output: 151
🏁 Script executed:
# Check Hono version and look for type definitions
cat package.json | grep '"hono"'Repository: exactly/exa
Length of output: 77
🏁 Script executed:
# Look at how renderToBuffer is used in tests to understand the return type better
cat -n server/test/utils/statement.test.ts | head -50Repository: exactly/exa
Length of output: 1918
🏁 Script executed:
# Check if there are any TypeScript types or .d.ts files for `@react-pdf/renderer`
find . -name "*.d.ts" -type f 2>/dev/null | xargs grep -l "renderToBuffer" 2>/dev/null || echo "No .d.ts files found"Repository: exactly/exa
Length of output: 75
🏁 Script executed:
# Check how other parts of the codebase handle PDF or similar binary responses
rg -n "c\.body" --type=ts -A 3 | head -80Repository: exactly/exa
Length of output: 299
🏁 Script executed:
# Check Hono 4.11.7 documentation by looking at package source or tsconfig patterns
# Let's see if we can find any indication of what c.body accepts
rg -n "type.*body" --type=ts server/ | head -20Repository: exactly/exa
Length of output: 255
🏁 Script executed:
# Let's verify by checking if there's a recent PR or commit that might explain this pattern
git log --oneline server/api/activity.ts | head -10Repository: exactly/exa
Length of output: 102
🏁 Script executed:
# Let's also check what the actual rendered PDF size would be vs what buffer.buffer returns
# This requires understanding the `@react-pdf/renderer` library behavior
web_search
What does `@react-pdf/renderer` renderToBuffer return and does it return a Node.js BufferRepository: exactly/exa
Length of output: 148
🌐 Web query:
@react-pdf/renderer renderToBuffer return type Node.js Buffer
💡 Result:
renderToBuffer in @react-pdf/renderer returns a Promise that resolves to a Node.js Buffer:
- Type definition:
renderToBuffer(document): Promise<Buffer>(whereBufferis the NodeBuffer). [1]
🏁 Script executed:
# Let's check the git commit to understand the context better
git show f2d5d12 --statRepository: exactly/exa
Length of output: 38320
🏁 Script executed:
# Let's also verify what happens when we access .buffer on a Node.js Buffer
# by checking if there are any comments or issues related to this in the codebase
rg -n "\.buffer" --type=ts server/api/activity.ts -B 2 -A 2Repository: exactly/exa
Length of output: 277
🏁 Script executed:
# Search for Hono types or c.body usage in the test files to understand the API
rg -n "c\.body" --type=ts -B 2 -A 2 server/test/ | head -60Repository: exactly/exa
Length of output: 37
🏁 Script executed:
# Check if there's TypeScript definition info or package details
cat package.json | grep -A 5 "\"hono\""Repository: exactly/exa
Length of output: 213
🏁 Script executed:
# Let's write a quick Node.js script to verify the Buffer.buffer behavior concern
python3 << 'EOF'
# Simulate what happens with Node.js Buffers and their underlying ArrayBuffer
# The reviewer's concern is that Buffer.buffer returns the full pool, not just the PDF slice
# Node.js Buffer internals:
# - Buffer extends Uint8Array
# - Buffer.buffer returns the underlying ArrayBuffer
# - The ArrayBuffer can be larger than the actual data (buffer.byteLength != buffer.buffer.byteLength)
# - To get just the slice, you'd need to slice(byteOffset, byteOffset + byteLength)
print("Node.js Buffer.buffer behavior:")
print("- Buffer.buffer property returns the underlying ArrayBuffer")
print("- This ArrayBuffer can be larger than Buffer.byteLength (pool behavior)")
print("- If passed directly to c.body(), all bytes would be sent, including garbage")
print("")
print("The reviewer's concern is technically valid IF:")
print("1. Hono's c.body() accepts ArrayBuffer and sends all bytes of it")
print("2. The Buffer is indeed a view into a pool")
print("")
print("However, this is a rare edge case - most buffers from renderToBuffer are likely")
print("allocated individually, not from a pool. But the concern is valid in principle.")
EOFRepository: exactly/exa
Length of output: 613
🏁 Script executed:
# Let's check Hono 4.11.7 c.body signature by looking at how it's used in tests
rg -n "c\.body" --type=ts server/test/api/ -A 3 -B 1 | head -100Repository: exactly/exa
Length of output: 37
🏁 Script executed:
# Let's check if there's documentation or examples of how Hono handles binary responses
rg -n "renderToBuffer\|Statement" --type=ts -A 5 -B 2 server/test/api/activity.test.ts | head -80Repository: exactly/exa
Length of output: 37
🏁 Script executed:
# Check if the PDF response is actually tested
rg -n "statement\|pdf" --type=ts server/test/api/activity.test.ts -i | head -30Repository: exactly/exa
Length of output: 37
🏁 Script executed:
# Search for statement endpoint tests more broadly
rg -n "statement" --type=ts server/test/api/activity.test.ts -iRepository: exactly/exa
Length of output: 104
🏁 Script executed:
# Check if there are any PDF-related tests
rg -n "pdf\|buffer\|Statement" --type=ts server/test/ -i | grep -i "statement\|pdf"Repository: exactly/exa
Length of output: 37
🏁 Script executed:
# Let's look at the actual endpoint implementation and see if there's any indication of what c.body expects
sed -n '350,385p' server/api/activity.tsRepository: exactly/exa
Length of output: 1427
🏁 Script executed:
# Find and read the statement PDF test
rg -n "returns statement pdf" --type=ts server/test/api/activity.test.ts -A 20Repository: exactly/exa
Length of output: 916
🏁 Script executed:
# Let's also check what Hono's c.body() actually expects and how it handles ArrayBuffer
# by looking at real usage in the codebase
rg -n "\.body\(" --type=ts server/api/ -B 2 -A 2 | grep -v "node_modules" | head -100Repository: exactly/exa
Length of output: 392
🌐 Web query:
Hono c.body() API signature ArrayBuffer Uint8Array what types accepted
💡 Result:
In Hono, c.body() ultimately takes Data | null, where:
Data = string | ArrayBuffer | Uint8Array | ReadableStream(and you can passnull). [1]
And it’s overloaded as:
c.body(data, status?, headers?)c.body(data, init?)(whereinitis aResponseInit-like object) [1][2]
So yes: ArrayBuffer and Uint8Array are accepted (plus string and ReadableStream). [1]
Note on Node.js Buffer: older Hono typings didn’t accept Uint8Array, which caused TS errors with Buffer in some setups/TS versions (see issue). [3] If you hit that, the safe conversion is to pass a Uint8Array view of the exact bytes (avoids the .buffer pool-size gotcha mentioned in the issue):
return c.body(new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength))Sources: [1] Hono Context type definitions (Data, body overloads), [2] Hono Context docs (body()), [3] Hono issue discussing Buffer vs accepted Data types in TS.
🏁 Script executed:
# Let's verify if there's any indication of whether renderToBuffer allocates from a pool
# by checking the actual rendered PDF size vs buffer size in tests
cat -n server/test/utils/statement.test.tsRepository: exactly/exa
Length of output: 2431
🏁 Script executed:
# Double-check: does the current code's toArrayBuffer handle the pool issue correctly?
sed -n '666,679p' server/api/activity.tsRepository: exactly/exa
Length of output: 563
Pass the Buffer directly or slice correctly to avoid sending garbage bytes from the underlying pool.
Node.js Buffer instances can be views into a shared pool. pdf.buffer returns the full backing ArrayBuffer, which may be larger than the actual PDF data. The toArrayBuffer helper doesn't respect byteOffset and byteLength, so it passes the entire pool to c.body(), potentially sending garbage bytes.
Use one of these fixes:
Option 1: Slice the ArrayBuffer correctly
- const pdf = await renderToBuffer(Statement(statement));
- return c.body(toArrayBuffer(pdf.buffer), 200, { "content-type": "application/pdf" });
+ const pdf = await renderToBuffer(Statement(statement));
+ return c.body(pdf.buffer.slice(pdf.byteOffset, pdf.byteOffset + pdf.byteLength), 200, { "content-type": "application/pdf" });Option 2: Pass Uint8Array directly (simplest)
- return c.body(toArrayBuffer(pdf.buffer), 200, { "content-type": "application/pdf" });
+ return c.body(new Uint8Array(pdf), 200, { "content-type": "application/pdf" });This also allows removing the toArrayBuffer helper if unused elsewhere.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const pdf = await renderToBuffer(Statement(statement)); | |
| return c.body(toArrayBuffer(pdf.buffer), 200, { "content-type": "application/pdf" }); | |
| const pdf = await renderToBuffer(Statement(statement)); | |
| return c.body(pdf.buffer.slice(pdf.byteOffset, pdf.byteOffset + pdf.byteLength), 200, { "content-type": "application/pdf" }); |
| const pdf = await renderToBuffer(Statement(statement)); | |
| return c.body(toArrayBuffer(pdf.buffer), 200, { "content-type": "application/pdf" }); | |
| const pdf = await renderToBuffer(Statement(statement)); | |
| return c.body(new Uint8Array(pdf), 200, { "content-type": "application/pdf" }); |
| function toArrayBuffer(buffer: ArrayBufferLike) { | ||
| if (buffer instanceof ArrayBuffer) { | ||
| return buffer; | ||
| } else if (buffer instanceof SharedArrayBuffer) { | ||
| const arrayBuffer = new ArrayBuffer(buffer.byteLength); | ||
| const view = new Uint8Array(arrayBuffer); | ||
| view.set(new Uint8Array(buffer)); | ||
| return arrayBuffer; | ||
| } | ||
| throw new Error("unsupported buffer type"); | ||
| } |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
toArrayBuffer is likely unnecessary — and the SharedArrayBuffer branch is unreachable from renderToBuffer.
If the pdf.buffer pool issue from line 373 is fixed (by slicing or passing the buffer directly), this helper can be removed entirely. renderToBuffer returns a Node.js Buffer whose .buffer is always an ArrayBuffer in standard Node.js builds — the SharedArrayBuffer path is dead code.
| {repayments.length > 0 && ( | ||
| <> | ||
| <Text style={styles.sectionHeader}>Payments</Text> | ||
| {repayments.map((item) => ( | ||
| <View key={item.timestamp}> | ||
| <View style={styles.activityItem}> | ||
| <View style={styles.contentContainer}> | ||
| <Text style={styles.installmentText}>{new Date(item.timestamp).toLocaleDateString("en-CA")}</Text> | ||
| <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}> | ||
| <View style={styles.discountChip}> | ||
| <Text style={styles.discountText}>{item.discount.toFixed(2)}% discount applied</Text> | ||
| </View> | ||
| </View> | ||
| </View> | ||
| <View style={styles.amountContainer}> | ||
| <Text style={styles.amountText}>USDC {item.amount.toFixed(2)}</Text> | ||
| </View> | ||
| </View> | ||
| </View> | ||
| ))} |
There was a problem hiding this comment.
Using item.timestamp as React key risks collisions when items share the same timestamp.
Multiple repayments or purchases can share the same timestamp (e.g., two repayments in the same block). Duplicate keys cause rendering issues. Use a unique identifier — or if unavailable, include the index.
Proposed fix — use index as fallback key
- {repayments.map((item) => (
- <View key={item.timestamp}>
+ {repayments.map((item, index) => (
+ <View key={`${item.timestamp}-${index}`}>Same for purchases at line 174:
- {purchases.map((item) => (
- <View key={item.timestamp}>
+ {purchases.map((item, index) => (
+ <View key={`${item.timestamp}-${index}`}>
Summary by CodeRabbit
New Features
Bug Fixes
Tests