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:
141
components/changelog/changelog-notification.tsx
Normal file
141
components/changelog/changelog-notification.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
154
components/feedback/feedback-dialog.tsx
Normal file
154
components/feedback/feedback-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
193
components/ui/item.tsx
Normal 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,
|
||||
}
|
||||
58
components/ui/scroll-area.tsx
Normal file
58
components/ui/scroll-area.tsx
Normal 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 }
|
||||
@@ -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,
|
||||
|
||||
144
db/schema.ts
144
db/schema.ts
@@ -102,7 +102,9 @@ export const verification = pgTable("verification", {
|
||||
|
||||
// ===================== PUBLIC TABLES =====================
|
||||
|
||||
export const contas = pgTable("contas", {
|
||||
export const contas = pgTable(
|
||||
"contas",
|
||||
{
|
||||
id: uuid("id")
|
||||
.primaryKey()
|
||||
.default(sql`gen_random_uuid()`),
|
||||
@@ -126,9 +128,18 @@ export const contas = pgTable("contas", {
|
||||
})
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
});
|
||||
},
|
||||
(table) => ({
|
||||
userIdStatusIdx: index("contas_user_id_status_idx").on(
|
||||
table.userId,
|
||||
table.status
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
export const categorias = pgTable("categorias", {
|
||||
export const categorias = pgTable(
|
||||
"categorias",
|
||||
{
|
||||
id: uuid("id")
|
||||
.primaryKey()
|
||||
.default(sql`gen_random_uuid()`),
|
||||
@@ -144,7 +155,14 @@ export const categorias = pgTable("categorias", {
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
});
|
||||
},
|
||||
(table) => ({
|
||||
userIdTypeIdx: index("categorias_user_id_type_idx").on(
|
||||
table.userId,
|
||||
table.type
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
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,7 +240,9 @@ export const pagadorShares = pgTable(
|
||||
})
|
||||
);
|
||||
|
||||
export const cartoes = pgTable("cartoes", {
|
||||
export const cartoes = pgTable(
|
||||
"cartoes",
|
||||
{
|
||||
id: uuid("id")
|
||||
.primaryKey()
|
||||
.default(sql`gen_random_uuid()`),
|
||||
@@ -238,9 +266,18 @@ export const cartoes = pgTable("cartoes", {
|
||||
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
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
export const faturas = pgTable("faturas", {
|
||||
export const faturas = pgTable(
|
||||
"faturas",
|
||||
{
|
||||
id: uuid("id")
|
||||
.primaryKey()
|
||||
.default(sql`gen_random_uuid()`),
|
||||
@@ -259,9 +296,22 @@ export const faturas = pgTable("faturas", {
|
||||
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
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
export const orcamentos = pgTable("orcamentos", {
|
||||
export const orcamentos = pgTable(
|
||||
"orcamentos",
|
||||
{
|
||||
id: uuid("id")
|
||||
.primaryKey()
|
||||
.default(sql`gen_random_uuid()`),
|
||||
@@ -280,7 +330,14 @@ export const orcamentos = pgTable("orcamentos", {
|
||||
onDelete: "cascade",
|
||||
onUpdate: "cascade",
|
||||
}),
|
||||
});
|
||||
},
|
||||
(table) => ({
|
||||
userIdPeriodIdx: index("orcamentos_user_id_period_idx").on(
|
||||
table.userId,
|
||||
table.period
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
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,7 +461,9 @@ export const installmentAnticipations = pgTable(
|
||||
})
|
||||
);
|
||||
|
||||
export const lancamentos = pgTable("lancamentos", {
|
||||
export const lancamentos = pgTable(
|
||||
"lancamentos",
|
||||
{
|
||||
id: uuid("id")
|
||||
.primaryKey()
|
||||
.default(sql`gen_random_uuid()`),
|
||||
@@ -430,7 +514,34 @@ export const lancamentos = pgTable("lancamentos", {
|
||||
}),
|
||||
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
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
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;
|
||||
|
||||
14
drizzle/0001_young_mister_fear.sql
Normal file
14
drizzle/0001_young_mister_fear.sql
Normal 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");
|
||||
9
drizzle/0002_slimy_flatman.sql
Normal file
9
drizzle/0002_slimy_flatman.sql
Normal 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");
|
||||
1869
drizzle/meta/0001_snapshot.json
Normal file
1869
drizzle/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1944
drizzle/meta/0002_snapshot.json
Normal file
1944
drizzle/meta/0002_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
76
lib/changelog/actions.ts
Normal 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
75
lib/changelog/data.ts
Normal 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
29
lib/changelog/utils.ts
Normal 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
2
lib/utils.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Re-export from lib/utils/ui.ts for shadcn compatibility
|
||||
export { cn } from "./utils/ui";
|
||||
@@ -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
33
pnpm-lock.yaml
generated
@@ -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
166
public/changelog.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
178
scripts/generate-changelog.ts
Normal file
178
scripts/generate-changelog.ts
Normal 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();
|
||||
Reference in New Issue
Block a user