perf(logos): pré-resolver mapeamentos Logo.dev no servidor

Cada EstablishmentLogo dispara um GET para /api/logo/mapping por
nome único (deduplicado pelo React Query, mas ainda N requests por
página). Em /dashboard, /transactions e /payers/[payerId] agora
fazemos uma única query SQL em batch (fetchEstablishmentLogoMap) e
semeamos o cache do React Query antes do primeiro render via novo
LogoPrefetchProvider — eliminando os requests da rede.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-04-25 14:45:54 +00:00
parent 7f05d2a681
commit b453b432ed
8 changed files with 257 additions and 125 deletions

View File

@@ -2,10 +2,13 @@ import { connection } from "next/server";
import { DashboardGridEditable } from "@/features/dashboard/components/dashboard-grid-editable"; import { DashboardGridEditable } from "@/features/dashboard/components/dashboard-grid-editable";
import { DashboardMetricsCards } from "@/features/dashboard/components/dashboard-metrics-cards"; import { DashboardMetricsCards } from "@/features/dashboard/components/dashboard-metrics-cards";
import { DashboardWelcome } from "@/features/dashboard/components/dashboard-welcome"; import { DashboardWelcome } from "@/features/dashboard/components/dashboard-welcome";
import { extractDashboardLogoNames } from "@/features/dashboard/extract-logo-names";
import { fetchDashboardPageData } from "@/features/dashboard/page-data-queries"; import { fetchDashboardPageData } from "@/features/dashboard/page-data-queries";
import { getSingleParam } from "@/features/transactions/page-helpers"; import { getSingleParam } from "@/features/transactions/page-helpers";
import { LogoPrefetchProvider } from "@/shared/components/entity-avatar";
import MonthNavigation from "@/shared/components/month-picker/month-navigation"; import MonthNavigation from "@/shared/components/month-picker/month-navigation";
import { getUser } from "@/shared/lib/auth/server"; import { getUser } from "@/shared/lib/auth/server";
import { prefetchLogoMappings } from "@/shared/lib/logo/prefetch-server";
import { parsePeriodParam } from "@/shared/utils/period"; import { parsePeriodParam } from "@/shared/utils/period";
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>; type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;
@@ -25,17 +28,24 @@ export default async function Page({ searchParams }: PageProps) {
await fetchDashboardPageData(user.id, selectedPeriod); await fetchDashboardPageData(user.id, selectedPeriod);
const { dashboardWidgets } = preferences; const { dashboardWidgets } = preferences;
const logoMappings = await prefetchLogoMappings(
user.id,
extractDashboardLogoNames(dashboardData),
);
return ( return (
<main className="flex flex-col gap-4"> <main className="flex flex-col gap-4">
<DashboardWelcome name={user.name} /> <DashboardWelcome name={user.name} />
<MonthNavigation /> <MonthNavigation />
<DashboardMetricsCards metrics={dashboardData.metrics} /> <DashboardMetricsCards metrics={dashboardData.metrics} />
<DashboardGridEditable <LogoPrefetchProvider mappings={logoMappings}>
data={dashboardData} <DashboardGridEditable
period={selectedPeriod} data={dashboardData}
initialPreferences={dashboardWidgets} period={selectedPeriod}
quickActionOptions={quickActionOptions} initialPreferences={dashboardWidgets}
/> quickActionOptions={quickActionOptions}
/>
</LogoPrefetchProvider>
</main> </main>
); );
} }

View File

@@ -41,6 +41,7 @@ import {
fetchRecentEstablishments, fetchRecentEstablishments,
fetchTransactionFilterSources, fetchTransactionFilterSources,
} from "@/features/transactions/queries"; } from "@/features/transactions/queries";
import { LogoPrefetchProvider } from "@/shared/components/entity-avatar";
import { ExpandableWidgetCard } from "@/shared/components/expandable-widget-card"; import { ExpandableWidgetCard } from "@/shared/components/expandable-widget-card";
import MonthNavigation from "@/shared/components/month-picker/month-navigation"; import MonthNavigation from "@/shared/components/month-picker/month-navigation";
import { import {
@@ -50,6 +51,7 @@ import {
TabsTrigger, TabsTrigger,
} from "@/shared/components/ui/tabs"; } from "@/shared/components/ui/tabs";
import { getUserId } from "@/shared/lib/auth/server"; import { getUserId } from "@/shared/lib/auth/server";
import { prefetchLogoMappings } from "@/shared/lib/logo/prefetch-server";
import { getPayerAccess } from "@/shared/lib/payers/access"; import { getPayerAccess } from "@/shared/lib/payers/access";
import { import {
fetchPagadorBoletoItems, fetchPagadorBoletoItems,
@@ -82,6 +84,7 @@ const EMPTY_FILTERS: TransactionSearchFilters = {
searchFilter: null, searchFilter: null,
settledFilter: null, settledFilter: null,
attachmentFilter: null, attachmentFilter: null,
dividedFilter: null,
}; };
const createEmptySlugMaps = (): SlugMaps => ({ const createEmptySlugMaps = (): SlugMaps => ({
@@ -307,104 +310,113 @@ export default async function Page({ params, searchParams }: PageProps) {
lancamentoCount: transactionData.length, lancamentoCount: transactionData.length,
}; };
const logoMappings = await prefetchLogoMappings(dataOwnerId, [
...transactionData.map((t) => t.name),
...boletoItems.map((b) => b.name),
]);
return ( return (
<main className="flex flex-col gap-6"> <main className="flex flex-col gap-6">
<MonthNavigation /> <MonthNavigation />
<Tabs defaultValue="profile" className="w-full"> <LogoPrefetchProvider mappings={logoMappings}>
<TabsList className="mb-2"> <Tabs defaultValue="profile" className="w-full">
<TabsTrigger value="profile">Perfil</TabsTrigger> <TabsList className="mb-2">
<TabsTrigger value="painel">Painel</TabsTrigger> <TabsTrigger value="profile">Perfil</TabsTrigger>
<TabsTrigger value="lancamentos">Lançamentos</TabsTrigger> <TabsTrigger value="painel">Painel</TabsTrigger>
</TabsList> <TabsTrigger value="lancamentos">Lançamentos</TabsTrigger>
<PayerHeaderCard </TabsList>
payer={payerData} <PayerHeaderCard
selectedPeriod={selectedPeriod} payer={payerData}
summary={summaryPreview} selectedPeriod={selectedPeriod}
/> summary={summaryPreview}
/>
<TabsContent value="profile" className="space-y-4"> <TabsContent value="profile" className="space-y-4">
<PagadorInfoCard payer={payerData} /> <PagadorInfoCard payer={payerData} />
{canEdit && payerData.shareCode ? ( {canEdit && payerData.shareCode ? (
<PayerSharingCard <PayerSharingCard
payerId={pagador.id} payerId={pagador.id}
shareCode={payerData.shareCode} shareCode={payerData.shareCode}
shares={payerSharesData} shares={payerSharesData}
/> />
) : null} ) : null}
{!canEdit && currentUserShare ? ( {!canEdit && currentUserShare ? (
<PayerLeaveShareCard <PayerLeaveShareCard
shareId={currentUserShare.id} shareId={currentUserShare.id}
pagadorName={payerData.name} pagadorName={payerData.name}
createdAt={currentUserShare.createdAt} createdAt={currentUserShare.createdAt}
/> />
) : null} ) : null}
</TabsContent> </TabsContent>
<TabsContent value="painel" className="space-y-4"> <TabsContent value="painel" className="space-y-4">
<section className="grid gap-3 lg:grid-cols-2"> <section className="grid gap-3 lg:grid-cols-2">
<PayerMonthlySummaryCard <PayerMonthlySummaryCard
periodLabel={periodLabel} periodLabel={periodLabel}
breakdown={monthlyBreakdown} breakdown={monthlyBreakdown}
/> />
<PayerHistoryCard data={historyData} /> <PayerHistoryCard data={historyData} />
</section> </section>
<section className="grid gap-3 lg:grid-cols-3"> <section className="grid gap-3 lg:grid-cols-3">
<ExpandableWidgetCard <ExpandableWidgetCard
title="Minhas Faturas" title="Minhas Faturas"
subtitle="Valores por cartão neste período" subtitle="Valores por cartão neste período"
icon={<RiBankCard2Line className="size-4" />} icon={<RiBankCard2Line className="size-4" />}
> >
<PayerCardUsageCard items={cardUsage} /> <PayerCardUsageCard items={cardUsage} />
</ExpandableWidgetCard> </ExpandableWidgetCard>
<ExpandableWidgetCard <ExpandableWidgetCard
title="Boletos" title="Boletos"
subtitle="Boletos registrados neste período" subtitle="Boletos registrados neste período"
icon={<RiBarcodeLine className="size-4" />} icon={<RiBarcodeLine className="size-4" />}
> >
<PayerBoletoCard items={boletoItems} /> <PayerBoletoCard items={boletoItems} />
</ExpandableWidgetCard> </ExpandableWidgetCard>
<ExpandableWidgetCard <ExpandableWidgetCard
title="Status de Pagamento" title="Status de Pagamento"
subtitle="Situação das despesas no período" subtitle="Situação das despesas no período"
icon={<RiWallet3Line className="size-4" />} icon={<RiWallet3Line className="size-4" />}
> >
<PayerPaymentStatusCard data={paymentStatus} /> <PayerPaymentStatusCard data={paymentStatus} />
</ExpandableWidgetCard> </ExpandableWidgetCard>
</section> </section>
</TabsContent> </TabsContent>
<TabsContent value="lancamentos"> <TabsContent value="lancamentos">
<section className="flex flex-col gap-4"> <section className="flex flex-col gap-4">
<LancamentosSection <LancamentosSection
currentUserId={userId} currentUserId={userId}
transactions={transactionData} transactions={transactionData}
payerOptions={optionSets.payerOptions} payerOptions={optionSets.payerOptions}
splitPayerOptions={optionSets.splitPayerOptions} splitPayerOptions={optionSets.splitPayerOptions}
defaultPayerId={pagador.id} defaultPayerId={pagador.id}
accountOptions={optionSets.accountOptions} accountOptions={optionSets.accountOptions}
cardOptions={optionSets.cardOptions} cardOptions={optionSets.cardOptions}
categoryOptions={optionSets.categoryOptions} categoryOptions={optionSets.categoryOptions}
payerFilterOptions={payerFilterOptions} payerFilterOptions={payerFilterOptions}
categoryFilterOptions={optionSets.categoryFilterOptions} categoryFilterOptions={optionSets.categoryFilterOptions}
accountCardFilterOptions={optionSets.accountCardFilterOptions} accountCardFilterOptions={optionSets.accountCardFilterOptions}
selectedPeriod={selectedPeriod} selectedPeriod={selectedPeriod}
estabelecimentos={estabelecimentos} estabelecimentos={estabelecimentos}
allowCreate={canEdit} allowCreate={canEdit}
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false} noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
columnOrder={userPreferences?.transactionsColumnOrder ?? null} columnOrder={userPreferences?.transactionsColumnOrder ?? null}
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50} attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
importPayerOptions={loggedUserOptionSets?.payerOptions} importPayerOptions={loggedUserOptionSets?.payerOptions}
importSplitPayerOptions={loggedUserOptionSets?.splitPayerOptions} importSplitPayerOptions={
importDefaultPayerId={loggedUserOptionSets?.defaultPayerId} loggedUserOptionSets?.splitPayerOptions
importAccountOptions={loggedUserOptionSets?.accountOptions} }
importCardOptions={loggedUserOptionSets?.cardOptions} importDefaultPayerId={loggedUserOptionSets?.defaultPayerId}
importCategoryOptions={loggedUserOptionSets?.categoryOptions} importAccountOptions={loggedUserOptionSets?.accountOptions}
/> importCardOptions={loggedUserOptionSets?.cardOptions}
</section> importCategoryOptions={loggedUserOptionSets?.categoryOptions}
</TabsContent> />
</Tabs> </section>
</TabsContent>
</Tabs>
</LogoPrefetchProvider>
</main> </main>
); );
} }

View File

@@ -17,8 +17,10 @@ import {
fetchTransactionFilterSources, fetchTransactionFilterSources,
fetchTransactionsPage, fetchTransactionsPage,
} from "@/features/transactions/queries"; } from "@/features/transactions/queries";
import { LogoPrefetchProvider } from "@/shared/components/entity-avatar";
import MonthNavigation from "@/shared/components/month-picker/month-navigation"; import MonthNavigation from "@/shared/components/month-picker/month-navigation";
import { getUserId } from "@/shared/lib/auth/server"; import { getUserId } from "@/shared/lib/auth/server";
import { prefetchLogoMappings } from "@/shared/lib/logo/prefetch-server";
import { parsePeriodParam } from "@/shared/utils/period"; import { parsePeriodParam } from "@/shared/utils/period";
type PageSearchParams = Promise<ResolvedSearchParams>; type PageSearchParams = Promise<ResolvedSearchParams>;
@@ -74,38 +76,45 @@ export default async function Page({ searchParams }: PageProps) {
payerRows: filterSources.payerRows, payerRows: filterSources.payerRows,
}); });
const logoMappings = await prefetchLogoMappings(
userId,
transactionData.map((t) => t.name),
);
return ( return (
<main className="flex flex-col gap-6"> <main className="flex flex-col gap-6">
<MonthNavigation /> <MonthNavigation />
<TransactionsPage <LogoPrefetchProvider mappings={logoMappings}>
currentUserId={userId} <TransactionsPage
transactions={transactionData} currentUserId={userId}
payerOptions={payerOptions} transactions={transactionData}
splitPayerOptions={splitPayerOptions} payerOptions={payerOptions}
defaultPayerId={defaultPayerId} splitPayerOptions={splitPayerOptions}
accountOptions={accountOptions} defaultPayerId={defaultPayerId}
cardOptions={cardOptions} accountOptions={accountOptions}
categoryOptions={categoryOptions} cardOptions={cardOptions}
payerFilterOptions={payerFilterOptions} categoryOptions={categoryOptions}
categoryFilterOptions={categoryFilterOptions} payerFilterOptions={payerFilterOptions}
accountCardFilterOptions={accountCardFilterOptions} categoryFilterOptions={categoryFilterOptions}
selectedPeriod={selectedPeriod} accountCardFilterOptions={accountCardFilterOptions}
estabelecimentos={estabelecimentos} selectedPeriod={selectedPeriod}
pagination={{ estabelecimentos={estabelecimentos}
page: transactionsPage.page, pagination={{
pageSize: transactionsPage.pageSize, page: transactionsPage.page,
totalItems: transactionsPage.totalItems, pageSize: transactionsPage.pageSize,
totalPages: transactionsPage.totalPages, totalItems: transactionsPage.totalItems,
}} totalPages: transactionsPage.totalPages,
exportContext={{ }}
source: "transactions", exportContext={{
period: selectedPeriod, source: "transactions",
filters: searchFilters, period: selectedPeriod,
}} filters: searchFilters,
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false} }}
columnOrder={userPreferences?.transactionsColumnOrder ?? null} noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50} columnOrder={userPreferences?.transactionsColumnOrder ?? null}
/> attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
/>
</LogoPrefetchProvider>
</main> </main>
); );
} }

View File

@@ -0,0 +1,28 @@
import type { DashboardData } from "./fetch-dashboard-data";
/**
* Coleta todos os nomes de estabelecimentos exibidos nos widgets do
* dashboard que renderizam `<EstablishmentLogo />`. Usado para
* pré-resolver os mapeamentos Logo.dev no servidor.
*/
export function extractDashboardLogoNames(data: DashboardData): string[] {
const names: string[] = [];
for (const bill of data.billsSnapshot.bills) names.push(bill.name);
for (const expense of data.recurringExpensesData.expenses)
names.push(expense.name);
for (const expense of data.installmentExpensesData.expenses)
names.push(expense.name);
for (const establishment of data.topEstablishmentsData.establishments)
names.push(establishment.name);
for (const expense of data.topExpensesAll.expenses) names.push(expense.name);
for (const expense of data.topExpensesCardOnly.expenses)
names.push(expense.name);
for (const transactions of Object.values(
data.purchasesByCategoryData.transactionsByCategory,
)) {
for (const transaction of transactions) names.push(transaction.name);
}
return names;
}

View File

@@ -5,3 +5,4 @@ export type {
export { CategoryIconBadge } from "./category-icon-badge"; export { CategoryIconBadge } from "./category-icon-badge";
export { EstablishmentLogo } from "./establishment-logo"; export { EstablishmentLogo } from "./establishment-logo";
export { EstablishmentLogoPicker } from "./establishment-logo-picker"; export { EstablishmentLogoPicker } from "./establishment-logo-picker";
export { LogoPrefetchProvider } from "./logo-prefetch-provider";

View File

@@ -0,0 +1,36 @@
"use client";
import { useQueryClient } from "@tanstack/react-query";
import { type ReactNode, useRef } from "react";
import { logoQueryKeys } from "@/shared/lib/logo";
import type { LogoPrefetchEntry } from "@/shared/lib/logo/types";
type LogoPrefetchProviderProps = {
mappings: LogoPrefetchEntry[];
children: ReactNode;
};
/**
* Semeia o cache do React Query com mapeamentos de logo já resolvidos
* no servidor. Evita que cada `EstablishmentLogo` dispare seu próprio
* GET para `/api/logo/mapping` no primeiro render.
*/
export function LogoPrefetchProvider({
mappings,
children,
}: LogoPrefetchProviderProps) {
const queryClient = useQueryClient();
const seeded = useRef(false);
if (!seeded.current) {
for (const { nameKey, domain, logoUrl } of mappings) {
queryClient.setQueryData(logoQueryKeys.mapping(nameKey), {
domain,
logoUrl,
});
}
seeded.current = true;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,31 @@
import "server-only";
import { fetchEstablishmentLogoMap } from "./establishment-logo-queries";
import { toNameKey } from "./index";
import { buildLogoDevUrl } from "./server";
import type { LogoPrefetchEntry } from "./types";
export async function prefetchLogoMappings(
userId: string,
names: string[],
): Promise<LogoPrefetchEntry[]> {
const uniqueNames = [
...new Set(
names.filter((n) => typeof n === "string" && n.trim().length > 0),
),
];
if (uniqueNames.length === 0) return [];
const map = await fetchEstablishmentLogoMap(userId, uniqueNames);
const seen = new Set<string>();
const entries: LogoPrefetchEntry[] = [];
for (const name of uniqueNames) {
const nameKey = toNameKey(name);
if (seen.has(nameKey)) continue;
seen.add(nameKey);
const domain = map.get(nameKey) ?? null;
entries.push({ nameKey, domain, logoUrl: buildLogoDevUrl(domain) });
}
return entries;
}

View File

@@ -0,0 +1,5 @@
export type LogoPrefetchEntry = {
nameKey: string;
domain: string | null;
logoUrl: string | null;
};