From 90a2411960043fd1906248ebc26a48c2ec260c65 Mon Sep 17 00:00:00 2001 From: Vasily Zubarev Date: Mon, 19 May 2025 16:29:04 +0200 Subject: [PATCH] feat: add progress indication for long downloads --- app/(app)/export/transactions/route.ts | 18 ++++ app/(app)/settings/backups/data/route.ts | 28 +++++- app/(app)/settings/backups/page.tsx | 45 ++++++++-- app/api/progress/[progressId]/route.ts | 64 ++++++++++++++ components/export/transactions.tsx | 51 ++++++----- hooks/use-download.tsx | 48 +++++++++++ hooks/use-progress.tsx | 85 +++++++++++++++++++ models/progress.ts | 62 ++++++++++++++ .../20250519130610_progress/migration.sql | 18 ++++ prisma/schema.prisma | 15 ++++ 10 files changed, 404 insertions(+), 30 deletions(-) create mode 100644 app/api/progress/[progressId]/route.ts create mode 100644 hooks/use-download.tsx create mode 100644 hooks/use-progress.tsx create mode 100644 models/progress.ts create mode 100644 prisma/migrations/20250519130610_progress/migration.sql diff --git a/app/(app)/export/transactions/route.ts b/app/(app)/export/transactions/route.ts index 7899daa..07106fd 100644 --- a/app/(app)/export/transactions/route.ts +++ b/app/(app)/export/transactions/route.ts @@ -3,6 +3,7 @@ import { fileExists, fullPathForFile } from "@/lib/files" import { EXPORT_AND_IMPORT_FIELD_MAP, ExportFields, ExportFilters } from "@/models/export_and_import" import { getFields } from "@/models/fields" import { getFilesByTransactionId } from "@/models/files" +import { incrementProgress, updateProgress } from "@/models/progress" import { getTransactions } from "@/models/transactions" import { format } from "@fast-csv/format" import { formatDate } from "date-fns" @@ -14,12 +15,14 @@ import { Readable } from "stream" const TRANSACTIONS_CHUNK_SIZE = 300 const FILES_CHUNK_SIZE = 50 +const PROGRESS_UPDATE_INTERVAL = 10 // files export async function GET(request: Request) { const url = new URL(request.url) const filters = Object.fromEntries(url.searchParams.entries()) as ExportFilters const fields = (url.searchParams.get("fields")?.split(",") ?? []) as ExportFields const includeAttachments = url.searchParams.get("includeAttachments") === "true" + const progressId = url.searchParams.get("progressId") const user = await getCurrentUser() const { transactions } = await getTransactions(user.id, filters) @@ -102,6 +105,11 @@ export async function GET(request: Request) { totalFilesToProcess += transactionFiles.length } + // Update progress with total files if progressId is provided + if (progressId) { + await updateProgress(user.id, progressId, { total: totalFilesToProcess }) + } + console.log(`Starting to process ${totalFilesToProcess} files in total`) for (let i = 0; i < transactions.length; i += FILES_CHUNK_SIZE) { @@ -136,6 +144,11 @@ export async function GET(request: Request) { }${fileExtension}`, fileData ) + + // Update progress every PROGRESS_UPDATE_INTERVAL files + if (progressId && totalFilesProcessed % PROGRESS_UPDATE_INTERVAL === 0) { + await incrementProgress(user.id, progressId) + } } else { console.log(`Skipping missing file: ${file.filename} for transaction ${transaction.id}`) } @@ -143,6 +156,11 @@ export async function GET(request: Request) { } } + // Final progress update + if (progressId) { + await updateProgress(user.id, progressId, { current: totalFilesToProcess }) + } + console.log(`Finished processing all ${totalFilesProcessed} files`) // Generate zip with progress tracking diff --git a/app/(app)/settings/backups/data/route.ts b/app/(app)/settings/backups/data/route.ts index e1bbfd3..e055bf0 100644 --- a/app/(app)/settings/backups/data/route.ts +++ b/app/(app)/settings/backups/data/route.ts @@ -1,6 +1,7 @@ import { getCurrentUser } from "@/lib/auth" import { fileExists, getUserUploadsDirectory } from "@/lib/files" import { MODEL_BACKUP, modelToJSON } from "@/models/backups" +import { incrementProgress, updateProgress } from "@/models/progress" import fs from "fs/promises" import JSZip from "jszip" import { NextResponse } from "next/server" @@ -8,10 +9,13 @@ import path from "path" const MAX_FILE_SIZE = 64 * 1024 * 1024 // 64MB const BACKUP_VERSION = "1.0" +const PROGRESS_UPDATE_INTERVAL = 10 // files -export async function GET() { +export async function GET(request: Request) { const user = await getCurrentUser() const userUploadsDirectory = getUserUploadsDirectory(user) + const url = new URL(request.url) + const progressId = url.searchParams.get("progressId") try { const zip = new JSZip() @@ -52,6 +56,13 @@ export async function GET() { } const uploadedFiles = await getAllFilePaths(userUploadsDirectory) + + // Update progress with total files if progressId is provided + if (progressId) { + await updateProgress(user.id, progressId, { total: uploadedFiles.length }) + } + + let processedFiles = 0 for (const file of uploadedFiles) { try { // Check file size before reading @@ -62,22 +73,33 @@ export async function GET() { MAX_FILE_SIZE / 1024 / 1024 }MB limit)` ) - return + continue } const fileContent = await fs.readFile(file) uploadsFolder.file(file.replace(userUploadsDirectory, ""), fileContent) + + processedFiles++ + // Update progress every PROGRESS_UPDATE_INTERVAL files + if (progressId && processedFiles % PROGRESS_UPDATE_INTERVAL === 0) { + await incrementProgress(user.id, progressId) + } } catch (error) { console.error(`Error reading file ${file}:`, error) } } + // Final progress update + if (progressId) { + await updateProgress(user.id, progressId, { current: uploadedFiles.length }) + } + const archive = await zip.generateAsync({ type: "blob" }) return new NextResponse(archive, { headers: { "Content-Type": "application/octet-stream", - "Content-Disposition": `attachment; filename="data.zip"`, + "Content-Disposition": `attachment; filename="taxhacker-backup.zip"`, }, }) } catch (error) { diff --git a/app/(app)/settings/backups/page.tsx b/app/(app)/settings/backups/page.tsx index 63b73e3..b5b26de 100644 --- a/app/(app)/settings/backups/page.tsx +++ b/app/(app)/settings/backups/page.tsx @@ -3,24 +3,57 @@ import { FormError } from "@/components/forms/error" import { Button } from "@/components/ui/button" import { Card } from "@/components/ui/card" +import { useDownload } from "@/hooks/use-download" +import { useProgress } from "@/hooks/use-progress" import { Download, Loader2 } from "lucide-react" -import Link from "next/link" import { useActionState } from "react" import { restoreBackupAction } from "./actions" export default function BackupSettingsPage() { const [restoreState, restoreBackup, restorePending] = useActionState(restoreBackupAction, null) + const { isLoading, startProgress, progress } = useProgress({ + onError: (error) => { + console.error("Backup progress error:", error) + }, + }) + + const { download, isDownloading } = useDownload({ + onError: (error) => { + console.error("Download error:", error) + }, + }) + + const handleDownload = async () => { + try { + const progressId = await startProgress("backup") + const downloadUrl = `/settings/backups/data?progressId=${progressId || ""}` + await download(downloadUrl, "taxhacker-backup.zip") + } catch (error) { + console.error("Failed to start backup:", error) + } + } + return (

Download backup

- - - +
Inside the archive you will find all the uploaded files, as well as JSON files for transactions, categories, diff --git a/app/api/progress/[progressId]/route.ts b/app/api/progress/[progressId]/route.ts new file mode 100644 index 0000000..7dc248d --- /dev/null +++ b/app/api/progress/[progressId]/route.ts @@ -0,0 +1,64 @@ +import { getSession } from "@/lib/auth" +import { getOrCreateProgress, getProgressById } from "@/models/progress" +import { NextRequest, NextResponse } from "next/server" + +const POLL_INTERVAL_MS = 2000 // 2 seconds + +export async function GET(req: NextRequest, { params }: { params: Promise<{ progressId: string }> }) { + const session = await getSession() + if (!session || !session.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const userId = session.user.id + const { progressId } = await params + const url = new URL(req.url) + const type = url.searchParams.get("type") || "unknown" + + await getOrCreateProgress(userId, progressId, type) + + const encoder = new TextEncoder() + const stream = new ReadableStream({ + async start(controller) { + let lastSent: any = null + let stopped = false + + req.signal.addEventListener("abort", () => { + stopped = true + controller.close() + }) + + while (!stopped) { + const progress = await getProgressById(userId, progressId) + if (!progress) { + controller.enqueue(encoder.encode(`event: error\ndata: {"error":"Not found"}\n\n`)) + controller.close() + break + } + + // Only send if progress has changed + if (JSON.stringify(progress) !== JSON.stringify(lastSent)) { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(progress)}\n\n`)) + lastSent = progress + + // If progress is complete, close the connection + if (progress.current === progress.total && progress.total > 0) { + controller.close() + break + } + } + + await new Promise((res) => setTimeout(res, POLL_INTERVAL_MS)) + } + }, + }) + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + "Access-Control-Allow-Origin": "*", + }, + }) +} diff --git a/components/export/transactions.tsx b/components/export/transactions.tsx index 2ce78ea..b2b9059 100644 --- a/components/export/transactions.tsx +++ b/components/export/transactions.tsx @@ -14,6 +14,8 @@ import { DialogTrigger, } from "@/components/ui/dialog" import { Separator } from "@/components/ui/separator" +import { useDownload } from "@/hooks/use-download" +import { useProgress } from "@/hooks/use-progress" import { useTransactionFilters } from "@/hooks/use-transaction-filters" import { Category, Field, Project } from "@/prisma/client" import { formatDate } from "date-fns" @@ -34,17 +36,28 @@ export function ExportTransactionsDialog({ total: number children: React.ReactNode }) { - const [isLoading, setIsLoading] = useState(false) const [exportFilters, setExportFilters] = useTransactionFilters() const [exportFields, setExportFields] = useState( fields.map((field) => (deselectedFields.includes(field.code) ? "" : field.code)) ) const [includeAttachments, setIncludeAttachments] = useState(true) + const { isLoading, startProgress, progress } = useProgress({ + onError: (error) => { + console.error("Export progress error:", error) + }, + }) - const handleSubmit = () => { - setIsLoading(true) - const exportWindow = window.open( - `/export/transactions?${new URLSearchParams({ + const { download, isDownloading } = useDownload({ + onError: (error) => { + console.error("Download error:", error) + }, + }) + + const handleSubmit = async () => { + try { + const progressId = await startProgress("transactions-export") + + const exportUrl = `/export/transactions?${new URLSearchParams({ search: exportFilters?.search || "", dateFrom: exportFilters?.dateFrom || "", dateTo: exportFilters?.dateTo || "", @@ -53,22 +66,12 @@ export function ExportTransactionsDialog({ projectCode: exportFilters?.projectCode || "", fields: exportFields.join(","), includeAttachments: includeAttachments.toString(), + progressId: progressId || "", }).toString()}` - ) - - // Check if window was opened successfully - if (!exportWindow) { - setIsLoading(false) - return + await download(exportUrl, "transactions.zip") + } catch (error) { + console.error("Failed to start export:", error) } - - // Monitor the export window - const checkWindow = setInterval(() => { - if (exportWindow.closed) { - clearInterval(checkWindow) - setIsLoading(false) - } - }, 1000) } return ( @@ -171,8 +174,14 @@ export function ExportTransactionsDialog({
- diff --git a/hooks/use-download.tsx b/hooks/use-download.tsx new file mode 100644 index 0000000..c83ed38 --- /dev/null +++ b/hooks/use-download.tsx @@ -0,0 +1,48 @@ +import { useState } from "react" + +interface UseDownloadOptions { + onSuccess?: () => void + onError?: (error: Error) => void +} + +export function useDownload(options: UseDownloadOptions = {}) { + const [isDownloading, setIsDownloading] = useState(false) + + const download = async (url: string, defaultName: string) => { + try { + setIsDownloading(true) + + const response = await fetch(url) + if (!response.ok) throw new Error("Download failed") + + // Get the filename from the Content-Disposition header + const contentDisposition = response.headers.get("Content-Disposition") + const filename = contentDisposition ? contentDisposition.split("filename=")[1].replace(/"/g, "") : defaultName + + // Create a blob from the response + const blob = await response.blob() + + // Create a download link and trigger it + const downloadLink = window.URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = downloadLink + a.download = filename + document.body.appendChild(a) + a.click() + window.URL.revokeObjectURL(downloadLink) + document.body.removeChild(a) + + options.onSuccess?.() + } catch (error) { + console.error("Download error:", error) + options.onError?.(error instanceof Error ? error : new Error("Download failed")) + } finally { + setIsDownloading(false) + } + } + + return { + download, + isDownloading, + } +} diff --git a/hooks/use-progress.tsx b/hooks/use-progress.tsx new file mode 100644 index 0000000..9243708 --- /dev/null +++ b/hooks/use-progress.tsx @@ -0,0 +1,85 @@ +import { useEffect, useState } from "react" + +interface Progress { + id: string + current: number + total: number + type: string + data: any + createdAt: string +} + +interface UseProgressOptions { + onSuccess?: (progress: Progress) => void + onError?: (error: Error) => void +} + +export function useProgress(options: UseProgressOptions = {}) { + const [isLoading, setIsLoading] = useState(false) + const [eventSource, setEventSource] = useState(null) + const [progress, setProgress] = useState(null) + + // Cleanup on unmount + useEffect(() => { + return () => { + if (eventSource) { + eventSource.close() + } + } + }, [eventSource]) + + const startProgress = async (type: string) => { + setIsLoading(true) + setProgress(null) + + // Close any existing connection + if (eventSource) { + eventSource.close() + } + + try { + const progressId = crypto.randomUUID() + const source = new EventSource(`/api/progress/${progressId}?type=${type}`) + setEventSource(source) + + source.onmessage = (event) => { + try { + const progress = JSON.parse(event.data) + setProgress(progress) + options.onSuccess?.(progress) + + if (progress.current === progress.total && progress.total > 0) { + source.close() + setIsLoading(false) + } + } catch (error) { + console.error("Failed to parse progress data:", error) + source.close() + setIsLoading(false) + } + } + + source.onerror = (error) => { + source.close() + setIsLoading(false) + const err = new Error("Progress tracking failed") + console.error("Progress tracking error:", err) + options.onError?.(err) + } + + return progressId + } catch (error) { + setIsLoading(false) + const err = error instanceof Error ? error : new Error("Failed to start progress") + console.error("Failed to start progress:", err) + options.onError?.(err) + return null + } + } + + return { + isLoading, + startProgress, + progress, + } +} diff --git a/models/progress.ts b/models/progress.ts new file mode 100644 index 0000000..dede24f --- /dev/null +++ b/models/progress.ts @@ -0,0 +1,62 @@ +import { prisma } from "@/lib/db" + +export const getOrCreateProgress = async ( + userId: string, + id: string, + type: string | null = null, + data: any = null, + total: number = 0 +) => { + return await prisma.progress.upsert({ + where: { id }, + create: { + id, + user: { connect: { id: userId } }, + type: type || "unknown", + data, + total, + }, + update: { + // Don't update existing progress + }, + }) +} + +export const getProgressById = async (userId: string, id: string) => { + return await prisma.progress.findFirst({ + where: { id, userId }, + }) +} + +export const updateProgress = async ( + userId: string, + id: string, + fields: { current?: number; total?: number; data?: any } +) => { + return await prisma.progress.updateMany({ + where: { id, userId }, + data: fields, + }) +} + +export const incrementProgress = async (userId: string, id: string, amount: number = 1) => { + return await prisma.progress.updateMany({ + where: { id, userId }, + data: { + current: { increment: amount }, + }, + }) +} + +export const getAllProgressByUser = async (userId: string) => { + return await prisma.progress.findMany({ + where: { userId }, + orderBy: { createdAt: "desc" }, + }) +} + +export const deleteProgress = async (userId: string, id: string) => { + return await prisma.progress.deleteMany({ + where: { id, userId }, + }) +} diff --git a/prisma/migrations/20250519130610_progress/migration.sql b/prisma/migrations/20250519130610_progress/migration.sql new file mode 100644 index 0000000..671c8f5 --- /dev/null +++ b/prisma/migrations/20250519130610_progress/migration.sql @@ -0,0 +1,18 @@ +-- CreateTable +CREATE TABLE "progress" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "type" TEXT NOT NULL, + "data" JSONB, + "current" INTEGER NOT NULL DEFAULT 0, + "total" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "progress_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "progress_user_id_idx" ON "progress"("user_id"); + +-- AddForeignKey +ALTER TABLE "progress" ADD CONSTRAINT "progress_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 38c1eb1..f813f1c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -39,6 +39,7 @@ model User { accounts Account[] sessions Session[] appData AppData[] + progress Progress[] @@map("users") } @@ -219,3 +220,17 @@ model AppData { @@unique([userId, app]) @@map("app_data") } + +model Progress { + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + type String + data Json? + current Int @default(0) + total Int @default(0) + createdAt DateTime @default(now()) @map("created_at") + + @@index([userId]) + @@map("progress") +}