fix(inbox): corrigir agrupamento de data por fuso de Brasilia

O Companion envia hora local com 'Z' literal (nao converte para UTC),
entao o timestamp no DB ja carrega a data correta de Brasilia. Usava-se
+3h no frontend, que deslocava a virada de dia para as 21h locais e
fazia compras da tarde aparecerem como 'Ontem'.

- getItemDateKey: remove offset (data UTC ja e a data de Brasilia)
- getBrasiliaDateKey: usa UTC-3 apenas para calcular hoje/ontem
- Paraleliza insercoes no batch endpoint com Promise.allSettled
- Usa selectDistinct no fetchInboxSourceApps
- Envolve InboxCard em memo e callbacks em useCallback

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-03-22 00:55:46 +00:00
parent 60a52b9873
commit 50477fb1be
4 changed files with 62 additions and 47 deletions

View File

@@ -86,12 +86,10 @@ export async function POST(request: Request) {
const body = await request.json(); const body = await request.json();
const { items } = inboxBatchSchema.parse(body); const { items } = inboxBatchSchema.parse(body);
// Processar cada item // Processar todos os itens em paralelo
const results: BatchResult[] = []; const settled = await Promise.allSettled(
items.map((item) =>
for (const item of items) { db
try {
const [inserted] = await db
.insert(inboxItems) .insert(inboxItems)
.values({ .values({
userId: tokenRecord.userId, userId: tokenRecord.userId,
@@ -104,22 +102,26 @@ export async function POST(request: Request) {
parsedAmount: item.parsedAmount?.toString(), parsedAmount: item.parsedAmount?.toString(),
status: "pending", status: "pending",
}) })
.returning({ id: inboxItems.id }); .returning({ id: inboxItems.id }),
),
);
results.push({ const results: BatchResult[] = settled.map((result, i) => {
clientId: item.clientId, const item = items[i];
serverId: inserted.id, if (result.status === "fulfilled") {
return {
clientId: item?.clientId,
serverId: result.value[0]?.id,
success: true, success: true,
}); };
} catch (error) { }
console.error("[API] Error processing batch item:", error); console.error("[API] Error processing batch item:", result.reason);
results.push({ return {
clientId: item.clientId, clientId: item?.clientId,
success: false, success: false,
error: "Erro ao processar notificação", error: "Erro ao processar notificação",
};
}); });
}
}
// Atualizar último uso do token // Atualizar último uso do token
const clientIp = const clientIp =

View File

@@ -9,6 +9,7 @@ import {
import { format, formatDistanceToNow } from "date-fns"; import { format, formatDistanceToNow } from "date-fns";
import { ptBR } from "date-fns/locale"; import { ptBR } from "date-fns/locale";
import Image from "next/image"; import Image from "next/image";
import { memo } from "react";
import MoneyValues from "@/shared/components/money-values"; import MoneyValues from "@/shared/components/money-values";
import { Badge } from "@/shared/components/ui/badge"; import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
@@ -62,7 +63,7 @@ interface InboxCardProps {
onSelectToggle?: (id: string) => void; onSelectToggle?: (id: string) => void;
} }
export function InboxCard({ export const InboxCard = memo(function InboxCard({
item, item,
readonly, readonly,
appLogoMap, appLogoMap,
@@ -222,4 +223,4 @@ export function InboxCard({
)} )}
</Card> </Card>
); );
} });

View File

@@ -13,7 +13,13 @@ import { format } from "date-fns";
import { ptBR } from "date-fns/locale"; import { ptBR } from "date-fns/locale";
import Image from "next/image"; import Image from "next/image";
import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState, useTransition } from "react"; import {
useCallback,
useEffect,
useMemo,
useState,
useTransition,
} from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
bulkDeleteInboxItemsAction, bulkDeleteInboxItemsAction,
@@ -57,18 +63,25 @@ import type {
SelectOption, SelectOption,
} from "./types"; } from "./types";
const BRASILIA_OFFSET_MS = 3 * 60 * 60 * 1000;
const DEFAULT_INBOX_APP_LOGO = "/avatars/default_icon.png"; const DEFAULT_INBOX_APP_LOGO = "/avatars/default_icon.png";
function getDateKey(date: Date): string { // O Companion envia hora local de Brasília com 'Z' literal (não converte para UTC).
const adjusted = new Date(date.getTime() + BRASILIA_OFFSET_MS); // Por isso, o timestamp armazenado no DB já tem a data correta de Brasília como componente UTC.
return adjusted.toISOString().slice(0, 10); // Basta fatiar o ISO string sem nenhum ajuste para obter a data de Brasília do item.
function getItemDateKey(date: Date): string {
return date.toISOString().slice(0, 10);
}
// Para "hoje" e "ontem", precisamos da data real de Brasília (UTC-3).
function getBrasiliaDateKey(date: Date): string {
const BRASILIA_OFFSET_MS = 3 * 60 * 60 * 1000;
return new Date(date.getTime() - BRASILIA_OFFSET_MS).toISOString().slice(0, 10);
} }
function getGroupLabel(dateKey: string): string { function getGroupLabel(dateKey: string): string {
const now = new Date(); const now = new Date();
const todayKey = getDateKey(now); const todayKey = getBrasiliaDateKey(now);
const yesterdayKey = getDateKey( const yesterdayKey = getBrasiliaDateKey(
new Date(now.getTime() - 24 * 60 * 60 * 1000), new Date(now.getTime() - 24 * 60 * 60 * 1000),
); );
if (dateKey === todayKey) return "Hoje"; if (dateKey === todayKey) return "Hoje";
@@ -84,7 +97,7 @@ function groupItemsByDay(
): { label: string; items: InboxItem[] }[] { ): { label: string; items: InboxItem[] }[] {
const groups = new Map<string, InboxItem[]>(); const groups = new Map<string, InboxItem[]>();
for (const item of items) { for (const item of items) {
const key = getDateKey(new Date(item.notificationTimestamp)); const key = getItemDateKey(new Date(item.notificationTimestamp));
const group = groups.get(key); const group = groups.get(key);
if (group) { if (group) {
group.push(item); group.push(item);
@@ -234,20 +247,20 @@ export function InboxPage({
} }
}; };
const handleProcessRequest = (item: InboxItem) => { const handleProcessRequest = useCallback((item: InboxItem) => {
setItemToProcess(item); setItemToProcess(item);
setProcessOpen(true); setProcessOpen(true);
}; }, []);
const handleDetailsRequest = (item: InboxItem) => { const handleDetailsRequest = useCallback((item: InboxItem) => {
setItemDetails(item); setItemDetails(item);
setDetailsOpen(true); setDetailsOpen(true);
}; }, []);
const handleDiscardRequest = (item: InboxItem) => { const handleDiscardRequest = useCallback((item: InboxItem) => {
setItemToDiscard(item); setItemToDiscard(item);
setDiscardOpen(true); setDiscardOpen(true);
}; }, []);
const handleDiscardConfirm = async () => { const handleDiscardConfirm = async () => {
if (!itemToDiscard) return; if (!itemToDiscard) return;
@@ -272,10 +285,10 @@ export function InboxPage({
} }
}; };
const handleDeleteRequest = (item: InboxItem) => { const handleDeleteRequest = useCallback((item: InboxItem) => {
setItemToDelete(item); setItemToDelete(item);
setDeleteOpen(true); setDeleteOpen(true);
}; }, []);
const handleDeleteConfirm = async () => { const handleDeleteConfirm = async () => {
if (!itemToDelete) return; if (!itemToDelete) return;
@@ -300,10 +313,10 @@ export function InboxPage({
} }
}; };
const handleRestoreRequest = (item: InboxItem) => { const handleRestoreRequest = useCallback((item: InboxItem) => {
setItemToRestore(item); setItemToRestore(item);
setRestoreOpen(true); setRestoreOpen(true);
}; }, []);
const handleRestoreToPendingConfirm = async () => { const handleRestoreToPendingConfirm = async () => {
if (!itemToRestore) return; if (!itemToRestore) return;
@@ -326,13 +339,13 @@ export function InboxPage({
setSelectedIds((current) => current.filter((id) => visibleIds.has(id))); setSelectedIds((current) => current.filter((id) => visibleIds.has(id)));
}, [items]); }, [items]);
const toggleSelection = (id: string) => { const toggleSelection = useCallback((id: string) => {
setSelectedIds((current) => setSelectedIds((current) =>
current.includes(id) current.includes(id)
? current.filter((value) => value !== id) ? current.filter((value) => value !== id)
: [...current, id], : [...current, id],
); );
}; }, []);
const allSelected = items.length > 0 && selectedIds.length === items.length; const allSelected = items.length > 0 && selectedIds.length === items.length;

View File

@@ -89,15 +89,14 @@ export async function fetchInboxSourceApps(
status: InboxStatus, status: InboxStatus,
): Promise<string[]> { ): Promise<string[]> {
const rows = await db const rows = await db
.select({ name: inboxItems.sourceAppName }) .selectDistinct({ name: inboxItems.sourceAppName })
.from(inboxItems) .from(inboxItems)
.where(and(eq(inboxItems.userId, userId), eq(inboxItems.status, status))); .where(and(eq(inboxItems.userId, userId), eq(inboxItems.status, status)));
const seen = new Set<string>(); return rows
for (const row of rows) { .map((row) => row.name)
if (row.name) seen.add(row.name); .filter((name): name is string => name !== null)
} .sort();
return [...seen].sort();
} }
export async function fetchInboxStatusCounts( export async function fetchInboxStatusCounts(