feat(changelog): implementar funcionalidades de leitura de atualizações

- Adiciona funções para marcar atualizações como lidas
- Implementa a lógica para marcar todas as atualizações como lidas
- Adiciona suporte a logs de atualizações lidas no banco de dados
- Cria funções utilitárias para manipulação de changelog
- Gera changelog a partir de commits do Git
- Salva changelog em formato JSON na pasta pública
perf: adicionar índices de banco de dados para otimização de queries
- Cria 14 índices compostos em tabelas principais (lancamentos, contas, etc)
- Adiciona índice user_id + period em lancamentos, faturas e orçamentos
- Adiciona índices para séries de parcelas e transferências
This commit is contained in:
Felipe Coutinho
2025-12-08 14:56:50 +00:00
parent 7a4a947e3f
commit b7fcba77b7
21 changed files with 5250 additions and 161 deletions

View File

@@ -0,0 +1,141 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { Button, buttonVariants } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { markAllUpdatesAsRead } from "@/lib/changelog/actions";
import type { ChangelogEntry } from "@/lib/changelog/data";
import {
getCategoryLabel,
groupEntriesByCategory,
} from "@/lib/changelog/utils";
import { cn } from "@/lib/utils";
import { RiMegaphoneLine } from "@remixicon/react";
import { formatDistanceToNow } from "date-fns";
import { ptBR } from "date-fns/locale";
import { useState } from "react";
interface ChangelogNotificationProps {
unreadCount: number;
entries: ChangelogEntry[];
}
export function ChangelogNotification({
unreadCount: initialUnreadCount,
entries,
}: ChangelogNotificationProps) {
const [unreadCount, setUnreadCount] = useState(initialUnreadCount);
const [isOpen, setIsOpen] = useState(false);
const handleMarkAllAsRead = async () => {
const updateIds = entries.map((e) => e.id);
await markAllUpdatesAsRead(updateIds);
setUnreadCount(0);
};
const grouped = groupEntriesByCategory(entries);
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
buttonVariants({ variant: "ghost", size: "icon-sm" }),
"group relative text-muted-foreground transition-all duration-200",
"hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary/40",
"data-[state=open]:bg-accent/60 data-[state=open]:text-foreground border"
)}
>
<RiMegaphoneLine className="h-5 w-5" />
{unreadCount > 0 && (
<Badge
className="absolute -top-1 -right-1 h-5 w-5 rounded-full p-0 flex items-center justify-center text-xs"
variant="info"
>
{unreadCount > 9 ? "9+" : unreadCount}
</Badge>
)}
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>Novidades</TooltipContent>
</Tooltip>
<PopoverContent className="w-96 p-0" align="end">
<div className="flex items-center justify-between p-4 pb-2">
<div className="flex items-center gap-2">
<RiMegaphoneLine className="h-5 w-5" />
<h3 className="font-semibold">Novidades</h3>
</div>
{unreadCount > 0 && (
<Button
variant="ghost"
size="sm"
onClick={handleMarkAllAsRead}
className="h-7 text-xs"
>
Marcar todas como lida
</Button>
)}
</div>
<Separator />
<ScrollArea className="h-[400px]">
<div className="p-4 space-y-4">
{Object.entries(grouped).map(([category, categoryEntries]) => (
<div key={category} className="space-y-2">
<h4 className="text-sm font-medium text-muted-foreground">
{getCategoryLabel(category)}
</h4>
<div className="space-y-2">
{categoryEntries.map((entry) => (
<div key={entry.id} className="space-y-1">
<div className="flex items-start gap-2 border-b pb-2 border-dashed">
<span className="text-lg mt-0.5">{entry.icon}</span>
<div className="flex-1 space-y-1">
<code className="text-xs font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
#{entry.id.substring(0, 7)}
</code>
<p className="text-sm leading-tight flex-1 first-letter:capitalize">
{entry.title}
</p>
<p className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(entry.date), {
addSuffix: true,
locale: ptBR,
})}
</p>
</div>
</div>
</div>
))}
</div>
</div>
))}
{entries.length === 0 && (
<div className="text-center py-8 text-sm text-muted-foreground">
Nenhuma atualização recente
</div>
)}
</div>
</ScrollArea>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,154 @@
"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button, buttonVariants } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import {
RiMessageLine,
RiBugLine,
RiLightbulbLine,
RiQuestionLine,
RiStarLine,
RiExternalLinkLine,
} from "@remixicon/react";
const GITHUB_REPO_BASE = "https://github.com/felipegcoutinho/opensheets-app";
const GITHUB_DISCUSSIONS_BASE = `${GITHUB_REPO_BASE}/discussions/new`;
const GITHUB_ISSUES_URL = `${GITHUB_REPO_BASE}/issues/new`;
const feedbackCategories = [
{
id: "bug",
title: "Reportar Bug",
icon: RiBugLine,
description: "Encontrou algo que não está funcionando?",
color: "text-red-500 dark:text-red-400",
url: GITHUB_ISSUES_URL,
},
{
id: "idea",
title: "Sugerir Feature",
icon: RiLightbulbLine,
description: "Tem uma ideia para melhorar o app?",
color: "text-yellow-500 dark:text-yellow-400",
url: `${GITHUB_DISCUSSIONS_BASE}?category=ideias`,
},
{
id: "question",
title: "Dúvidas/Suporte",
icon: RiQuestionLine,
description: "Precisa de ajuda com alguma coisa?",
color: "text-blue-500 dark:text-blue-400",
url: `${GITHUB_DISCUSSIONS_BASE}?category=q-a`,
},
{
id: "experience",
title: "Compartilhar Experiência",
icon: RiStarLine,
description: "Como o OpenSheets tem ajudado você?",
color: "text-purple-500 dark:text-purple-400",
url: `${GITHUB_DISCUSSIONS_BASE}?category=sua-experiencia`,
},
];
export function FeedbackDialog() {
const [open, setOpen] = useState(false);
const handleCategoryClick = (url: string) => {
window.open(url, "_blank", "noopener,noreferrer");
setOpen(false);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
buttonVariants({ variant: "ghost", size: "icon-sm" }),
"group relative text-muted-foreground transition-all duration-200",
"hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary/40",
"data-[state=open]:bg-accent/60 data-[state=open]:text-foreground border"
)}
>
<RiMessageLine className="h-5 w-5" />
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent>Enviar Feedback</TooltipContent>
</Tooltip>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<RiMessageLine className="h-5 w-5" />
Enviar Feedback
</DialogTitle>
<DialogDescription>
Sua opinião é muito importante! Escolha o tipo de feedback que
deseja compartilhar.
</DialogDescription>
</DialogHeader>
<div className="grid gap-3 py-4">
{feedbackCategories.map((item) => {
const Icon = item.icon;
return (
<button
key={item.id}
onClick={() => handleCategoryClick(item.url)}
className={cn(
"flex items-start gap-3 p-4 rounded-lg border transition-all",
"hover:border-primary hover:bg-accent/50",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
)}
>
<div
className={cn(
"flex h-10 w-10 shrink-0 items-center justify-center rounded-full",
"bg-muted"
)}
>
<Icon className={cn("h-5 w-5", item.color)} />
</div>
<div className="flex-1 text-left space-y-1">
<h3 className="font-semibold text-sm flex items-center gap-2">
{item.title}
<RiExternalLinkLine className="h-3.5 w-3.5 text-muted-foreground" />
</h3>
<p className="text-sm text-muted-foreground">
{item.description}
</p>
</div>
</button>
);
})}
</div>
<div className="flex items-start gap-2 p-3 bg-muted/50 rounded-lg text-xs text-muted-foreground">
<RiExternalLinkLine className="h-4 w-4 shrink-0 mt-0.5" />
<p>
Você será redirecionado para o GitHub Discussions onde poderá
escrever seu feedback. É necessário ter uma conta no GitHub.
</p>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,29 +1,41 @@
import { ChangelogNotification } from "@/components/changelog/changelog-notification";
import { FeedbackDialog } from "@/components/feedback/feedback-dialog";
import { NotificationBell } from "@/components/notifications/notification-bell";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { getUser } from "@/lib/auth/server";
import { getUnreadUpdates } from "@/lib/changelog/data";
import type { DashboardNotificationsSnapshot } from "@/lib/dashboard/notifications";
import LogoutButton from "./auth/logout-button";
import { AnimatedThemeToggler } from "./animated-theme-toggler";
import { PrivacyModeToggle } from "./privacy-mode-toggle";
import LogoutButton from "./auth/logout-button";
import { CalculatorDialogButton } from "./calculadora/calculator-dialog";
import { PrivacyModeToggle } from "./privacy-mode-toggle";
type SiteHeaderProps = {
notificationsSnapshot: DashboardNotificationsSnapshot;
};
export function SiteHeader({ notificationsSnapshot }: SiteHeaderProps) {
export async function SiteHeader({ notificationsSnapshot }: SiteHeaderProps) {
const user = await getUser();
const { unreadCount, allEntries } = await getUnreadUpdates(user.id);
return (
<header className="border-b flex h-12 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
<SidebarTrigger className="-ml-1" />
<div className="ml-auto flex items-center gap-2">
<CalculatorDialogButton withTooltip />
<span className="text-muted-foreground">|</span>
<NotificationBell
notifications={notificationsSnapshot.notifications}
totalCount={notificationsSnapshot.totalCount}
/>
<CalculatorDialogButton withTooltip />
<PrivacyModeToggle />
<AnimatedThemeToggler />
<span className="text-muted-foreground">|</span>
<ChangelogNotification
unreadCount={unreadCount}
entries={allEntries}
/>
<FeedbackDialog />
<LogoutButton />
</div>
</div>

View File

@@ -103,7 +103,7 @@ export function NotificationBell({
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={8}>
Notificações
Pagamentos para os próximos 5 dias.
</TooltipContent>
</Tooltip>
<DropdownMenuContent
@@ -112,7 +112,7 @@ export function NotificationBell({
className="w-80 max-h-[500px] overflow-hidden rounded-lg border border-border/60 bg-popover/95 p-0 shadow-lg backdrop-blur-lg supports-backdrop-filter:backdrop-blur-md"
>
<DropdownMenuLabel className="sticky top-0 z-10 flex items-center justify-between gap-2 border-b border-border/60 bg-linear-to-b from-background/95 to-background/80 px-4 py-3 text-sm font-semibold">
<span>Notificações</span>
<span>Notificações | Próximos 5 dias.</span>
{hasNotifications && (
<Badge variant="outline" className="text-[10px] font-semibold">
{totalCount} {totalCount === 1 ? "item" : "itens"}

193
components/ui/item.tsx Normal file
View File

@@ -0,0 +1,193 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Separator } from "@/components/ui/separator"
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
role="list"
data-slot="item-group"
className={cn("group/item-group flex flex-col", className)}
{...props}
/>
)
}
function ItemSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="item-separator"
orientation="horizontal"
className={cn("my-0", className)}
{...props}
/>
)
}
const itemVariants = cva(
"group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border-border",
muted: "bg-muted/50",
},
size: {
default: "p-4 gap-4 ",
sm: "py-3 px-4 gap-2.5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Item({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof itemVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div"
return (
<Comp
data-slot="item"
data-variant={variant}
data-size={size}
className={cn(itemVariants({ variant, size, className }))}
{...props}
/>
)
}
const itemMediaVariants = cva(
"flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5",
{
variants: {
variant: {
default: "bg-transparent",
icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4",
image:
"size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover",
},
},
defaultVariants: {
variant: "default",
},
}
)
function ItemMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
return (
<div
data-slot="item-media"
data-variant={variant}
className={cn(itemMediaVariants({ variant, className }))}
{...props}
/>
)
}
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-content"
className={cn(
"flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none",
className
)}
{...props}
/>
)
}
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-title"
className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium",
className
)}
{...props}
/>
)
}
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="item-description"
className={cn(
"text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...props}
/>
)
}
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-actions"
className={cn("flex items-center gap-2", className)}
{...props}
/>
)
}
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-header"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
{...props}
/>
)
}
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-footer"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
{...props}
/>
)
}
export {
Item,
ItemMedia,
ItemContent,
ItemActions,
ItemGroup,
ItemSeparator,
ItemTitle,
ItemDescription,
ItemHeader,
ItemFooter,
}

View File

@@ -0,0 +1,58 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@@ -3,7 +3,7 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils/ui"
import { cn } from "@/lib/utils"
function Separator({
className,

View File

@@ -102,49 +102,67 @@ export const verification = pgTable("verification", {
// ===================== PUBLIC TABLES =====================
export const contas = pgTable("contas", {
id: uuid("id")
.primaryKey()
.default(sql`gen_random_uuid()`),
name: text("nome").notNull(),
accountType: text("tipo_conta").notNull(),
note: text("anotacao"),
status: text("status").notNull(),
logo: text("logo").notNull(),
initialBalance: numeric("saldo_inicial", { precision: 12, scale: 2 })
.notNull()
.default("0"),
excludeFromBalance: boolean("excluir_do_saldo")
.notNull()
.default(false),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at", {
mode: "date",
withTimezone: true,
export const contas = pgTable(
"contas",
{
id: uuid("id")
.primaryKey()
.default(sql`gen_random_uuid()`),
name: text("nome").notNull(),
accountType: text("tipo_conta").notNull(),
note: text("anotacao"),
status: text("status").notNull(),
logo: text("logo").notNull(),
initialBalance: numeric("saldo_inicial", { precision: 12, scale: 2 })
.notNull()
.default("0"),
excludeFromBalance: boolean("excluir_do_saldo")
.notNull()
.default(false),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at", {
mode: "date",
withTimezone: true,
})
.notNull()
.defaultNow(),
},
(table) => ({
userIdStatusIdx: index("contas_user_id_status_idx").on(
table.userId,
table.status
),
})
.notNull()
.defaultNow(),
});
);
export const categorias = pgTable("categorias", {
id: uuid("id")
.primaryKey()
.default(sql`gen_random_uuid()`),
name: text("nome").notNull(),
type: text("tipo").notNull(),
icon: text("icone"),
createdAt: timestamp("created_at", {
mode: "date",
withTimezone: true,
export const categorias = pgTable(
"categorias",
{
id: uuid("id")
.primaryKey()
.default(sql`gen_random_uuid()`),
name: text("nome").notNull(),
type: text("tipo").notNull(),
icon: text("icone"),
createdAt: timestamp("created_at", {
mode: "date",
withTimezone: true,
})
.notNull()
.defaultNow(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
},
(table) => ({
userIdTypeIdx: index("categorias_user_id_type_idx").on(
table.userId,
table.type
),
})
.notNull()
.defaultNow(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
});
);
export const pagadores = pgTable(
"pagadores",
@@ -180,6 +198,14 @@ export const pagadores = pgTable(
uniqueShareCode: uniqueIndex("pagadores_share_code_key").on(
table.shareCode
),
userIdStatusIdx: index("pagadores_user_id_status_idx").on(
table.userId,
table.status
),
userIdRoleIdx: index("pagadores_user_id_role_idx").on(
table.userId,
table.role
),
})
);
@@ -214,73 +240,104 @@ export const pagadorShares = pgTable(
})
);
export const cartoes = pgTable("cartoes", {
id: uuid("id")
.primaryKey()
.default(sql`gen_random_uuid()`),
name: text("nome").notNull(),
closingDay: text("dt_fechamento").notNull(),
dueDay: text("dt_vencimento").notNull(),
note: text("anotacao"),
limit: numeric("limite", { precision: 10, scale: 2 }),
brand: text("bandeira"),
logo: text("logo"),
status: text("status").notNull(),
createdAt: timestamp("created_at", {
mode: "date",
withTimezone: true,
export const cartoes = pgTable(
"cartoes",
{
id: uuid("id")
.primaryKey()
.default(sql`gen_random_uuid()`),
name: text("nome").notNull(),
closingDay: text("dt_fechamento").notNull(),
dueDay: text("dt_vencimento").notNull(),
note: text("anotacao"),
limit: numeric("limite", { precision: 10, scale: 2 }),
brand: text("bandeira"),
logo: text("logo"),
status: text("status").notNull(),
createdAt: timestamp("created_at", {
mode: "date",
withTimezone: true,
})
.notNull()
.defaultNow(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
contaId: uuid("conta_id")
.notNull()
.references(() => contas.id, { onDelete: "cascade", onUpdate: "cascade" }),
},
(table) => ({
userIdStatusIdx: index("cartoes_user_id_status_idx").on(
table.userId,
table.status
),
})
.notNull()
.defaultNow(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
contaId: uuid("conta_id")
.notNull()
.references(() => contas.id, { onDelete: "cascade", onUpdate: "cascade" }),
});
);
export const faturas = pgTable("faturas", {
id: uuid("id")
.primaryKey()
.default(sql`gen_random_uuid()`),
paymentStatus: text("status_pagamento"),
period: text("periodo"),
createdAt: timestamp("created_at", {
mode: "date",
withTimezone: true,
export const faturas = pgTable(
"faturas",
{
id: uuid("id")
.primaryKey()
.default(sql`gen_random_uuid()`),
paymentStatus: text("status_pagamento"),
period: text("periodo"),
createdAt: timestamp("created_at", {
mode: "date",
withTimezone: true,
})
.notNull()
.defaultNow(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
cartaoId: uuid("cartao_id").references(() => cartoes.id, {
onDelete: "cascade",
onUpdate: "cascade",
}),
},
(table) => ({
userIdPeriodIdx: index("faturas_user_id_period_idx").on(
table.userId,
table.period
),
cartaoIdPeriodIdx: index("faturas_cartao_id_period_idx").on(
table.cartaoId,
table.period
),
})
.notNull()
.defaultNow(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
cartaoId: uuid("cartao_id").references(() => cartoes.id, {
onDelete: "cascade",
onUpdate: "cascade",
}),
});
);
export const orcamentos = pgTable("orcamentos", {
id: uuid("id")
.primaryKey()
.default(sql`gen_random_uuid()`),
amount: numeric("valor", { precision: 10, scale: 2 }).notNull(),
period: text("periodo").notNull(),
createdAt: timestamp("created_at", {
mode: "date",
withTimezone: true,
export const orcamentos = pgTable(
"orcamentos",
{
id: uuid("id")
.primaryKey()
.default(sql`gen_random_uuid()`),
amount: numeric("valor", { precision: 10, scale: 2 }).notNull(),
period: text("periodo").notNull(),
createdAt: timestamp("created_at", {
mode: "date",
withTimezone: true,
})
.notNull()
.defaultNow(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
categoriaId: uuid("categoria_id").references(() => categorias.id, {
onDelete: "cascade",
onUpdate: "cascade",
}),
},
(table) => ({
userIdPeriodIdx: index("orcamentos_user_id_period_idx").on(
table.userId,
table.period
),
})
.notNull()
.defaultNow(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
categoriaId: uuid("categoria_id").references(() => categorias.id, {
onDelete: "cascade",
onUpdate: "cascade",
}),
});
);
export const anotacoes = pgTable("anotacoes", {
id: uuid("id")
@@ -301,6 +358,31 @@ export const anotacoes = pgTable("anotacoes", {
.references(() => user.id, { onDelete: "cascade" }),
});
export const userUpdateLog = pgTable(
"user_update_log",
{
id: uuid("id")
.primaryKey()
.default(sql`gen_random_uuid()`),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
updateId: text("update_id").notNull(), // commit hash
readAt: timestamp("read_at", {
mode: "date",
withTimezone: true,
})
.notNull()
.defaultNow(),
},
(table) => ({
userIdUpdateIdIdx: uniqueIndex("user_update_log_user_update_idx").on(
table.userId,
table.updateId
),
})
);
export const savedInsights = pgTable(
"saved_insights",
{
@@ -379,58 +461,87 @@ export const installmentAnticipations = pgTable(
})
);
export const lancamentos = pgTable("lancamentos", {
id: uuid("id")
.primaryKey()
.default(sql`gen_random_uuid()`),
condition: text("condicao").notNull(),
name: text("nome").notNull(),
paymentMethod: text("forma_pagamento").notNull(),
note: text("anotacao"),
amount: numeric("valor", { precision: 12, scale: 2 }).notNull(),
purchaseDate: date("data_compra", { mode: "date" }).notNull(),
transactionType: text("tipo_transacao").notNull(),
installmentCount: smallint("qtde_parcela"),
period: text("periodo").notNull(),
currentInstallment: smallint("parcela_atual"),
recurrenceCount: integer("qtde_recorrencia"),
dueDate: date("data_vencimento", { mode: "date" }),
boletoPaymentDate: date("dt_pagamento_boleto", { mode: "date" }),
isSettled: boolean("realizado").default(false),
isDivided: boolean("dividido").default(false),
isAnticipated: boolean("antecipado").default(false),
anticipationId: uuid("antecipacao_id").references(
() => installmentAnticipations.id,
{ onDelete: "set null" }
),
createdAt: timestamp("created_at", {
mode: "date",
withTimezone: true,
export const lancamentos = pgTable(
"lancamentos",
{
id: uuid("id")
.primaryKey()
.default(sql`gen_random_uuid()`),
condition: text("condicao").notNull(),
name: text("nome").notNull(),
paymentMethod: text("forma_pagamento").notNull(),
note: text("anotacao"),
amount: numeric("valor", { precision: 12, scale: 2 }).notNull(),
purchaseDate: date("data_compra", { mode: "date" }).notNull(),
transactionType: text("tipo_transacao").notNull(),
installmentCount: smallint("qtde_parcela"),
period: text("periodo").notNull(),
currentInstallment: smallint("parcela_atual"),
recurrenceCount: integer("qtde_recorrencia"),
dueDate: date("data_vencimento", { mode: "date" }),
boletoPaymentDate: date("dt_pagamento_boleto", { mode: "date" }),
isSettled: boolean("realizado").default(false),
isDivided: boolean("dividido").default(false),
isAnticipated: boolean("antecipado").default(false),
anticipationId: uuid("antecipacao_id").references(
() => installmentAnticipations.id,
{ onDelete: "set null" }
),
createdAt: timestamp("created_at", {
mode: "date",
withTimezone: true,
})
.notNull()
.defaultNow(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
cartaoId: uuid("cartao_id").references(() => cartoes.id, {
onDelete: "cascade",
onUpdate: "cascade",
}),
contaId: uuid("conta_id").references(() => contas.id, {
onDelete: "cascade",
onUpdate: "cascade",
}),
categoriaId: uuid("categoria_id").references(() => categorias.id, {
onDelete: "cascade",
onUpdate: "cascade",
}),
pagadorId: uuid("pagador_id").references(() => pagadores.id, {
onDelete: "cascade",
onUpdate: "cascade",
}),
seriesId: uuid("series_id"),
transferId: uuid("transfer_id"),
},
(table) => ({
// Índice composto mais importante: userId + period (usado em quase todas as queries do dashboard)
userIdPeriodIdx: index("lancamentos_user_id_period_idx").on(
table.userId,
table.period
),
// Índice para queries ordenadas por data de compra
userIdPurchaseDateIdx: index("lancamentos_user_id_purchase_date_idx").on(
table.userId,
table.purchaseDate
),
// Índice para buscar parcelas de uma série
seriesIdIdx: index("lancamentos_series_id_idx").on(table.seriesId),
// Índice para buscar transferências relacionadas
transferIdIdx: index("lancamentos_transfer_id_idx").on(table.transferId),
// Índice para filtrar por condição (aberto, realizado, cancelado)
userIdConditionIdx: index("lancamentos_user_id_condition_idx").on(
table.userId,
table.condition
),
// Índice para queries de cartão específico
cartaoIdPeriodIdx: index("lancamentos_cartao_id_period_idx").on(
table.cartaoId,
table.period
),
})
.notNull()
.defaultNow(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
cartaoId: uuid("cartao_id").references(() => cartoes.id, {
onDelete: "cascade",
onUpdate: "cascade",
}),
contaId: uuid("conta_id").references(() => contas.id, {
onDelete: "cascade",
onUpdate: "cascade",
}),
categoriaId: uuid("categoria_id").references(() => categorias.id, {
onDelete: "cascade",
onUpdate: "cascade",
}),
pagadorId: uuid("pagador_id").references(() => pagadores.id, {
onDelete: "cascade",
onUpdate: "cascade",
}),
seriesId: uuid("series_id"),
transferId: uuid("transfer_id"),
});
);
export const userRelations = relations(user, ({ many, one }) => ({
accounts: many(account),
@@ -444,6 +555,7 @@ export const userRelations = relations(user, ({ many, one }) => ({
orcamentos: many(orcamentos),
pagadores: many(pagadores),
installmentAnticipations: many(installmentAnticipations),
updateLogs: many(userUpdateLog),
}));
export const accountRelations = relations(account, ({ one }) => ({
@@ -551,6 +663,13 @@ export const savedInsightsRelations = relations(savedInsights, ({ one }) => ({
}),
}));
export const userUpdateLogRelations = relations(userUpdateLog, ({ one }) => ({
user: one(user, {
fields: [userUpdateLog.userId],
references: [user.id],
}),
}));
export const lancamentosRelations = relations(lancamentos, ({ one }) => ({
user: one(user, {
fields: [lancamentos.userId],
@@ -616,3 +735,4 @@ export type SavedInsight = typeof savedInsights.$inferSelect;
export type Lancamento = typeof lancamentos.$inferSelect;
export type InstallmentAnticipation =
typeof installmentAnticipations.$inferSelect;
export type UserUpdateLog = typeof userUpdateLog.$inferSelect;

View File

@@ -0,0 +1,14 @@
CREATE INDEX "cartoes_user_id_status_idx" ON "cartoes" USING btree ("user_id","status");--> statement-breakpoint
CREATE INDEX "categorias_user_id_type_idx" ON "categorias" USING btree ("user_id","tipo");--> statement-breakpoint
CREATE INDEX "contas_user_id_status_idx" ON "contas" USING btree ("user_id","status");--> statement-breakpoint
CREATE INDEX "faturas_user_id_period_idx" ON "faturas" USING btree ("user_id","periodo");--> statement-breakpoint
CREATE INDEX "faturas_cartao_id_period_idx" ON "faturas" USING btree ("cartao_id","periodo");--> statement-breakpoint
CREATE INDEX "lancamentos_user_id_period_idx" ON "lancamentos" USING btree ("user_id","periodo");--> statement-breakpoint
CREATE INDEX "lancamentos_user_id_purchase_date_idx" ON "lancamentos" USING btree ("user_id","data_compra");--> statement-breakpoint
CREATE INDEX "lancamentos_series_id_idx" ON "lancamentos" USING btree ("series_id");--> statement-breakpoint
CREATE INDEX "lancamentos_transfer_id_idx" ON "lancamentos" USING btree ("transfer_id");--> statement-breakpoint
CREATE INDEX "lancamentos_user_id_condition_idx" ON "lancamentos" USING btree ("user_id","condicao");--> statement-breakpoint
CREATE INDEX "lancamentos_cartao_id_period_idx" ON "lancamentos" USING btree ("cartao_id","periodo");--> statement-breakpoint
CREATE INDEX "orcamentos_user_id_period_idx" ON "orcamentos" USING btree ("user_id","periodo");--> statement-breakpoint
CREATE INDEX "pagadores_user_id_status_idx" ON "pagadores" USING btree ("user_id","status");--> statement-breakpoint
CREATE INDEX "pagadores_user_id_role_idx" ON "pagadores" USING btree ("user_id","role");

View File

@@ -0,0 +1,9 @@
CREATE TABLE "user_update_log" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"update_id" text NOT NULL,
"read_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "user_update_log" ADD CONSTRAINT "user_update_log_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "user_update_log_user_update_idx" ON "user_update_log" USING btree ("user_id","update_id");

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,20 @@
"when": 1762993507299,
"tag": "0000_flashy_manta",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1765199006435,
"tag": "0001_young_mister_fear",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1765200545692,
"tag": "0002_slimy_flatman",
"breakpoints": true
}
]
}

76
lib/changelog/actions.ts Normal file
View File

@@ -0,0 +1,76 @@
"use server";
import { userUpdateLog } from "@/db/schema";
import { successResult, type ActionResult } from "@/lib/actions/types";
import { getUser } from "@/lib/auth/server";
import { db } from "@/lib/db";
import { and, eq } from "drizzle-orm";
import { handleActionError } from "../actions/helpers";
export async function markUpdateAsRead(
updateId: string
): Promise<ActionResult> {
try {
const user = await getUser();
// Check if already marked as read
const existing = await db
.select()
.from(userUpdateLog)
.where(
and(
eq(userUpdateLog.userId, user.id),
eq(userUpdateLog.updateId, updateId)
)
)
.limit(1);
if (existing.length > 0) {
return successResult("Já marcado como lido");
}
await db.insert(userUpdateLog).values({
userId: user.id,
updateId,
});
return successResult("Marcado como lido");
} catch (error) {
return handleActionError(error);
}
}
export async function markAllUpdatesAsRead(
updateIds: string[]
): Promise<ActionResult> {
try {
const user = await getUser();
// Get existing read updates
const existing = await db
.select()
.from(userUpdateLog)
.where(eq(userUpdateLog.userId, user.id));
const existingIds = new Set(existing.map((log) => log.updateId));
// Filter out already read updates
const newUpdateIds = updateIds.filter((id) => !existingIds.has(id));
if (newUpdateIds.length === 0) {
return successResult("Todos já marcados como lidos");
}
// Insert new read logs
await db.insert(userUpdateLog).values(
newUpdateIds.map((updateId) => ({
userId: user.id,
updateId,
}))
);
return successResult("Todas as atualizações marcadas como lidas");
} catch (error) {
return handleActionError(error);
}
}

75
lib/changelog/data.ts Normal file
View File

@@ -0,0 +1,75 @@
import { db } from "@/lib/db";
import { userUpdateLog } from "@/db/schema";
import { eq } from "drizzle-orm";
import fs from "fs";
import path from "path";
export interface ChangelogEntry {
id: string;
type: string;
title: string;
date: string;
icon: string;
category: string;
}
export interface Changelog {
version: string;
generatedAt: string;
entries: ChangelogEntry[];
}
export function getChangelog(): Changelog {
try {
const changelogPath = path.join(process.cwd(), "public", "changelog.json");
if (!fs.existsSync(changelogPath)) {
return {
version: "1.0.0",
generatedAt: new Date().toISOString(),
entries: [],
};
}
const content = fs.readFileSync(changelogPath, "utf-8");
return JSON.parse(content);
} catch (error) {
console.error("Error reading changelog:", error);
return {
version: "1.0.0",
generatedAt: new Date().toISOString(),
entries: [],
};
}
}
export async function getUnreadUpdates(userId: string) {
const changelog = getChangelog();
if (changelog.entries.length === 0) {
return {
unreadCount: 0,
unreadEntries: [],
allEntries: [],
};
}
// Get read updates from database
const readLogs = await db
.select()
.from(userUpdateLog)
.where(eq(userUpdateLog.userId, userId));
const readUpdateIds = new Set(readLogs.map((log) => log.updateId));
// Filter unread entries
const unreadEntries = changelog.entries.filter(
(entry) => !readUpdateIds.has(entry.id)
);
return {
unreadCount: unreadEntries.length,
unreadEntries,
allEntries: changelog.entries,
};
}

29
lib/changelog/utils.ts Normal file
View File

@@ -0,0 +1,29 @@
import type { ChangelogEntry } from "./data";
export function getCategoryLabel(category: string): string {
const labels: Record<string, string> = {
feature: "Novidades",
bugfix: "Correções",
performance: "Performance",
documentation: "Documentação",
style: "Interface",
refactor: "Melhorias",
test: "Testes",
chore: "Manutenção",
other: "Outros",
};
return labels[category] || "Outros";
}
export function groupEntriesByCategory(entries: ChangelogEntry[]) {
return entries.reduce(
(acc, entry) => {
if (!acc[entry.category]) {
acc[entry.category] = [];
}
acc[entry.category].push(entry);
return acc;
},
{} as Record<string, ChangelogEntry[]>
);
}

2
lib/utils.ts Normal file
View File

@@ -0,0 +1,2 @@
// Re-export from lib/utils/ui.ts for shadcn compatibility
export { cn } from "./utils/ui";

View File

@@ -5,6 +5,7 @@
"scripts": {
"dev": "next dev --turbopack",
"dev-env": "tsx scripts/dev.ts",
"prebuild": "tsx scripts/generate-changelog.ts",
"build": "next build",
"start": "next start",
"lint": "eslint .",
@@ -42,6 +43,7 @@
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "2.2.6",
"@radix-ui/react-separator": "1.1.8",
"@radix-ui/react-slot": "1.2.4",

33
pnpm-lock.yaml generated
View File

@@ -53,6 +53,9 @@ importers:
'@radix-ui/react-radio-group':
specifier: ^1.3.8
version: 1.3.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
'@radix-ui/react-scroll-area':
specifier: ^1.2.10
version: 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
'@radix-ui/react-select':
specifier: 2.2.6
version: 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
@@ -1457,6 +1460,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-scroll-area@1.2.10':
resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-select@2.2.6':
resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==}
peerDependencies:
@@ -5238,6 +5254,23 @@ snapshots:
'@types/react': 19.2.7
'@types/react-dom': 19.2.3(@types/react@19.2.7)
'@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)':
dependencies:
'@radix-ui/number': 1.1.1
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.1)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.1)
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.2.1)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.2.1)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.2.1)
react: 19.2.1
react-dom: 19.2.1(react@19.2.1)
optionalDependencies:
'@types/react': 19.2.7
'@types/react-dom': 19.2.3(@types/react@19.2.7)
'@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)':
dependencies:
'@radix-ui/number': 1.1.1

166
public/changelog.json Normal file
View File

@@ -0,0 +1,166 @@
{
"version": "1.0.0",
"generatedAt": "2025-12-08T14:32:17.605Z",
"entries": [
{
"id": "7a4a947e3fa4f78f174d1042906828045cbf6eaf",
"type": "fix",
"title": "atualizar dependências do projeto",
"date": "2025-12-07 18:50:00 +0000",
"icon": "🐛",
"category": "bugfix"
},
{
"id": "244534921b9b10fbff79777a024da17a45722bce",
"type": "fix",
"title": "replace session cookie validation with actual session check in proxy middleware",
"date": "2025-12-07 09:50:55 -0300",
"icon": "🐛",
"category": "bugfix"
},
{
"id": "de3d99a3b1a398ae01eec0f65f03309648cbe24d",
"type": "fix",
"title": "add error handling for internal server error in login form",
"date": "2025-12-06 07:35:25 -0300",
"icon": "🐛",
"category": "bugfix"
},
{
"id": "9d03387079d9ff867d0309522d5cb8989075bc2f",
"type": "fix",
"title": "adjust padding and layout in various dashboard widgets for improved UI consistency",
"date": "2025-12-02 13:54:13 +0000",
"icon": "🐛",
"category": "bugfix"
},
{
"id": "c834648d395e58a6fb62c620a0c5e2ee4d1b8a4f",
"type": "fix",
"title": "corrige condição de análise de gastos parcelados",
"date": "2025-12-01 00:16:50 +0000",
"icon": "🐛",
"category": "bugfix"
},
{
"id": "47038ae687e5c6d611009171a5730f3c1477aa78",
"type": "fix",
"title": "corrige timezone e seleção de parcelas na análise de parcelas",
"date": "2025-11-29 18:26:28 +0000",
"icon": "🐛",
"category": "bugfix"
},
{
"id": "cf5a0b7745bf2ade4970e7e15c29bdb643955878",
"type": "feat",
"title": "implement category history widget and loading state for category history page",
"date": "2025-11-28 13:42:21 +0000",
"icon": "✨",
"category": "feature"
},
{
"id": "bf1a310c286e39664908ca989ffda0d3cea4ef3c",
"type": "feat",
"title": "add AI coding assistant instructions and update Node.js version requirement in README",
"date": "2025-11-28 01:30:09 -0300",
"icon": "✨",
"category": "feature"
},
{
"id": "2d8d677bcc85d863b2aee58b0c9144a62588173a",
"type": "fix",
"title": "update dependencies to latest versions",
"date": "2025-11-25 14:17:58 +0000",
"icon": "🐛",
"category": "bugfix"
},
{
"id": "a34d92f3bd7ceb96285bc32f1f2ff2eb79052170",
"type": "feat",
"title": "aprimora a exibição do cartão de parcelas e ajusta a lógica de busca",
"date": "2025-11-23 14:52:22 -0300",
"icon": "✨",
"category": "feature"
},
{
"id": "e8a343a6dd1f2426d484afe2902b05cfc65ea32d",
"type": "feat",
"title": "adiciona integração com Speed Insights",
"date": "2025-11-23 12:32:38 -0300",
"icon": "✨",
"category": "feature"
},
{
"id": "9fbe722d00aa0105fc3a37e0d19555e1aaf27928",
"type": "feat",
"title": "adicionar estrutura para gerenciamento de mudanças de código",
"date": "2025-11-23 12:26:05 -0300",
"icon": "✨",
"category": "feature"
},
{
"id": "3ce8541a5699317c747c629e1c0e07d579458633",
"type": "fix",
"title": "corrige a grafia de \"OpenSheets\" para \"Opensheets\"",
"date": "2025-11-22 20:29:25 -0300",
"icon": "🐛",
"category": "bugfix"
},
{
"id": "ac24961e4b97bfb58a52e1b95f3d9696fe1e5d86",
"type": "refactor",
"title": "substitui '•' por '-' em textos de exibição",
"date": "2025-11-22 12:58:57 -0300",
"icon": "♻️",
"category": "refactor"
},
{
"id": "8c5313119dafaf3a33ab4bffeeb40d7f0278eb08",
"type": "feat",
"title": "atualiza fontes e altera avatar SVG",
"date": "2025-11-22 12:49:56 -0300",
"icon": "✨",
"category": "feature"
},
{
"id": "4d076772e623cc3cb1a51f94551125ad9b791841",
"type": "refactor",
"title": "Relocate `PrivacyProvider` to the dashboard layout and update `tsconfig` `jsx` compiler option.",
"date": "2025-11-21 09:40:41 -0300",
"icon": "♻️",
"category": "refactor"
},
{
"id": "3d8772e55f2d25b757b0b3fe398f7db2fafcb745",
"type": "feat",
"title": "adiciona tipos para d3-array e ajusta configurações do TypeScript",
"date": "2025-11-17 20:58:05 -0300",
"icon": "✨",
"category": "feature"
},
{
"id": "a7736b7ab9249dd0e82b30f71ca74530dad0fdb0",
"type": "feat",
"title": "adicionar babel-plugin-react-compiler como dependência",
"date": "2025-11-17 19:55:21 -0300",
"icon": "✨",
"category": "feature"
},
{
"id": "835d94f140670888df920834ab2b77eb365362ce",
"type": "chore",
"title": "add package-lock.json for dependency version locking",
"date": "2025-11-17 19:45:01 +0000",
"icon": "🔧",
"category": "chore"
},
{
"id": "fcd4ebc608e7d0e9f6f0eb106ba7f53177d28d05",
"type": "feat",
"title": "melhorar UX/UI e segurança do módulo de ajustes de usuário",
"date": "2025-11-17 19:43:50 +0000",
"icon": "✨",
"category": "feature"
}
]
}

View File

@@ -0,0 +1,178 @@
import { execSync } from "child_process";
import fs from "fs";
import path from "path";
interface ChangelogEntry {
id: string;
type: string;
title: string;
date: string;
icon: string;
category: string;
}
function getIcon(type: string): string {
const icons: Record<string, string> = {
feat: "✨",
fix: "🐛",
perf: "🚀",
docs: "📝",
style: "🎨",
refactor: "♻️",
test: "🧪",
chore: "🔧",
};
return icons[type] || "📦";
}
function getCategory(type: string): string {
const categories: Record<string, string> = {
feat: "feature",
fix: "bugfix",
perf: "performance",
docs: "documentation",
style: "style",
refactor: "refactor",
test: "test",
chore: "chore",
};
return categories[type] || "other";
}
function getCategoryLabel(category: string): string {
const labels: Record<string, string> = {
feature: "Novidades",
bugfix: "Correções",
performance: "Performance",
documentation: "Documentação",
style: "Interface",
refactor: "Melhorias",
test: "Testes",
chore: "Manutenção",
other: "Outros",
};
return labels[category] || "Outros";
}
function generateChangelog() {
try {
console.log("🔍 Gerando changelog dos últimos commits...\n");
// Pega commits dos últimos 30 dias
const gitCommand =
'git log --since="30 days ago" --pretty=format:"%H|%s|%ai" --no-merges';
let output: string;
try {
output = execSync(gitCommand, { encoding: "utf-8" });
} catch (error) {
console.warn("⚠️ Não foi possível acessar o Git. Gerando changelog vazio.");
output = "";
}
if (!output.trim()) {
console.log(" Nenhum commit encontrado nos últimos 30 dias.");
const emptyChangelog = {
version: "1.0.0",
generatedAt: new Date().toISOString(),
entries: [],
};
const publicDir = path.join(process.cwd(), "public");
if (!fs.existsSync(publicDir)) {
fs.mkdirSync(publicDir, { recursive: true });
}
fs.writeFileSync(
path.join(publicDir, "changelog.json"),
JSON.stringify(emptyChangelog, null, 2)
);
return;
}
const commits = output
.split("\n")
.filter((line) => line.trim())
.map((line) => {
const [hash, message, date] = line.split("|");
return { hash, message, date };
});
console.log(`📝 Processando ${commits.length} commits...\n`);
// Parseia conventional commits
const entries: ChangelogEntry[] = commits
.map((commit) => {
// Match conventional commit format: type: message
const match = commit.message.match(
/^(feat|fix|perf|docs|style|refactor|test|chore):\s*(.+)$/
);
if (!match) {
// Ignora commits que não seguem o padrão
return null;
}
const [, type, title] = match;
return {
id: commit.hash,
type,
title: title.trim(),
date: commit.date,
icon: getIcon(type),
category: getCategory(type),
};
})
.filter((entry): entry is ChangelogEntry => entry !== null);
console.log(`${entries.length} commits válidos encontrados\n`);
// Agrupa por categoria
const grouped = entries.reduce(
(acc, entry) => {
if (!acc[entry.category]) {
acc[entry.category] = [];
}
acc[entry.category].push(entry);
return acc;
},
{} as Record<string, ChangelogEntry[]>
);
// Mostra resumo
Object.entries(grouped).forEach(([category, items]) => {
console.log(
`${getIcon(items[0].type)} ${getCategoryLabel(category)}: ${items.length}`
);
});
// Pega versão do package.json
const packageJson = JSON.parse(
fs.readFileSync(path.join(process.cwd(), "package.json"), "utf-8")
);
const changelog = {
version: packageJson.version || "1.0.0",
generatedAt: new Date().toISOString(),
entries: entries.slice(0, 20), // Limita a 20 mais recentes
};
// Salva em public/changelog.json
const publicDir = path.join(process.cwd(), "public");
if (!fs.existsSync(publicDir)) {
fs.mkdirSync(publicDir, { recursive: true });
}
const changelogPath = path.join(publicDir, "changelog.json");
fs.writeFileSync(changelogPath, JSON.stringify(changelog, null, 2));
console.log(`\n✅ Changelog gerado com sucesso em: ${changelogPath}`);
} catch (error) {
console.error("❌ Erro ao gerar changelog:", error);
// Não falha o build, apenas avisa
process.exit(0);
}
}
generateChangelog();