feat: add progress indication for long downloads

This commit is contained in:
Vasily Zubarev
2025-05-19 16:29:04 +02:00
parent d2ef3a088a
commit 90a2411960
10 changed files with 404 additions and 30 deletions

View File

@@ -3,6 +3,7 @@ import { fileExists, fullPathForFile } from "@/lib/files"
import { EXPORT_AND_IMPORT_FIELD_MAP, ExportFields, ExportFilters } from "@/models/export_and_import" import { EXPORT_AND_IMPORT_FIELD_MAP, ExportFields, ExportFilters } from "@/models/export_and_import"
import { getFields } from "@/models/fields" import { getFields } from "@/models/fields"
import { getFilesByTransactionId } from "@/models/files" import { getFilesByTransactionId } from "@/models/files"
import { incrementProgress, updateProgress } from "@/models/progress"
import { getTransactions } from "@/models/transactions" import { getTransactions } from "@/models/transactions"
import { format } from "@fast-csv/format" import { format } from "@fast-csv/format"
import { formatDate } from "date-fns" import { formatDate } from "date-fns"
@@ -14,12 +15,14 @@ import { Readable } from "stream"
const TRANSACTIONS_CHUNK_SIZE = 300 const TRANSACTIONS_CHUNK_SIZE = 300
const FILES_CHUNK_SIZE = 50 const FILES_CHUNK_SIZE = 50
const PROGRESS_UPDATE_INTERVAL = 10 // files
export async function GET(request: Request) { export async function GET(request: Request) {
const url = new URL(request.url) const url = new URL(request.url)
const filters = Object.fromEntries(url.searchParams.entries()) as ExportFilters const filters = Object.fromEntries(url.searchParams.entries()) as ExportFilters
const fields = (url.searchParams.get("fields")?.split(",") ?? []) as ExportFields const fields = (url.searchParams.get("fields")?.split(",") ?? []) as ExportFields
const includeAttachments = url.searchParams.get("includeAttachments") === "true" const includeAttachments = url.searchParams.get("includeAttachments") === "true"
const progressId = url.searchParams.get("progressId")
const user = await getCurrentUser() const user = await getCurrentUser()
const { transactions } = await getTransactions(user.id, filters) const { transactions } = await getTransactions(user.id, filters)
@@ -102,6 +105,11 @@ export async function GET(request: Request) {
totalFilesToProcess += transactionFiles.length 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`) console.log(`Starting to process ${totalFilesToProcess} files in total`)
for (let i = 0; i < transactions.length; i += FILES_CHUNK_SIZE) { for (let i = 0; i < transactions.length; i += FILES_CHUNK_SIZE) {
@@ -136,6 +144,11 @@ export async function GET(request: Request) {
}${fileExtension}`, }${fileExtension}`,
fileData fileData
) )
// Update progress every PROGRESS_UPDATE_INTERVAL files
if (progressId && totalFilesProcessed % PROGRESS_UPDATE_INTERVAL === 0) {
await incrementProgress(user.id, progressId)
}
} else { } else {
console.log(`Skipping missing file: ${file.filename} for transaction ${transaction.id}`) 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`) console.log(`Finished processing all ${totalFilesProcessed} files`)
// Generate zip with progress tracking // Generate zip with progress tracking

View File

@@ -1,6 +1,7 @@
import { getCurrentUser } from "@/lib/auth" import { getCurrentUser } from "@/lib/auth"
import { fileExists, getUserUploadsDirectory } from "@/lib/files" import { fileExists, getUserUploadsDirectory } from "@/lib/files"
import { MODEL_BACKUP, modelToJSON } from "@/models/backups" import { MODEL_BACKUP, modelToJSON } from "@/models/backups"
import { incrementProgress, updateProgress } from "@/models/progress"
import fs from "fs/promises" import fs from "fs/promises"
import JSZip from "jszip" import JSZip from "jszip"
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
@@ -8,10 +9,13 @@ import path from "path"
const MAX_FILE_SIZE = 64 * 1024 * 1024 // 64MB const MAX_FILE_SIZE = 64 * 1024 * 1024 // 64MB
const BACKUP_VERSION = "1.0" 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 user = await getCurrentUser()
const userUploadsDirectory = getUserUploadsDirectory(user) const userUploadsDirectory = getUserUploadsDirectory(user)
const url = new URL(request.url)
const progressId = url.searchParams.get("progressId")
try { try {
const zip = new JSZip() const zip = new JSZip()
@@ -52,6 +56,13 @@ export async function GET() {
} }
const uploadedFiles = await getAllFilePaths(userUploadsDirectory) 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) { for (const file of uploadedFiles) {
try { try {
// Check file size before reading // Check file size before reading
@@ -62,22 +73,33 @@ export async function GET() {
MAX_FILE_SIZE / 1024 / 1024 MAX_FILE_SIZE / 1024 / 1024
}MB limit)` }MB limit)`
) )
return continue
} }
const fileContent = await fs.readFile(file) const fileContent = await fs.readFile(file)
uploadsFolder.file(file.replace(userUploadsDirectory, ""), fileContent) 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) { } catch (error) {
console.error(`Error reading file ${file}:`, 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" }) const archive = await zip.generateAsync({ type: "blob" })
return new NextResponse(archive, { return new NextResponse(archive, {
headers: { headers: {
"Content-Type": "application/octet-stream", "Content-Type": "application/octet-stream",
"Content-Disposition": `attachment; filename="data.zip"`, "Content-Disposition": `attachment; filename="taxhacker-backup.zip"`,
}, },
}) })
} catch (error) { } catch (error) {

View File

@@ -3,24 +3,57 @@
import { FormError } from "@/components/forms/error" import { FormError } from "@/components/forms/error"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card" 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 { Download, Loader2 } from "lucide-react"
import Link from "next/link"
import { useActionState } from "react" import { useActionState } from "react"
import { restoreBackupAction } from "./actions" import { restoreBackupAction } from "./actions"
export default function BackupSettingsPage() { export default function BackupSettingsPage() {
const [restoreState, restoreBackup, restorePending] = useActionState(restoreBackupAction, null) 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 ( return (
<div className="container flex flex-col gap-4"> <div className="container flex flex-col gap-4">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<h1 className="text-2xl font-bold">Download backup</h1> <h1 className="text-2xl font-bold">Download backup</h1>
<div className="flex flex-row gap-4"> <div className="flex flex-row gap-4">
<Link href="/settings/backups/data"> <Button onClick={handleDownload} disabled={isLoading || isDownloading}>
<Button> {isLoading ? (
<Download /> Download Data Archive progress?.current ? (
`Archiving ${progress.current}/${progress.total} files`
) : (
"Preparing backup..."
)
) : isDownloading ? (
"Archive is created. Downloading..."
) : (
<>
<Download className="mr-2" /> Download Data Archive
</>
)}
</Button> </Button>
</Link>
</div> </div>
<div className="text-sm text-muted-foreground max-w-xl"> <div className="text-sm text-muted-foreground max-w-xl">
Inside the archive you will find all the uploaded files, as well as JSON files for transactions, categories, Inside the archive you will find all the uploaded files, as well as JSON files for transactions, categories,

View File

@@ -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": "*",
},
})
}

View File

@@ -14,6 +14,8 @@ import {
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { Separator } from "@/components/ui/separator" 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 { useTransactionFilters } from "@/hooks/use-transaction-filters"
import { Category, Field, Project } from "@/prisma/client" import { Category, Field, Project } from "@/prisma/client"
import { formatDate } from "date-fns" import { formatDate } from "date-fns"
@@ -34,17 +36,28 @@ export function ExportTransactionsDialog({
total: number total: number
children: React.ReactNode children: React.ReactNode
}) { }) {
const [isLoading, setIsLoading] = useState(false)
const [exportFilters, setExportFilters] = useTransactionFilters() const [exportFilters, setExportFilters] = useTransactionFilters()
const [exportFields, setExportFields] = useState<string[]>( const [exportFields, setExportFields] = useState<string[]>(
fields.map((field) => (deselectedFields.includes(field.code) ? "" : field.code)) fields.map((field) => (deselectedFields.includes(field.code) ? "" : field.code))
) )
const [includeAttachments, setIncludeAttachments] = useState(true) const [includeAttachments, setIncludeAttachments] = useState(true)
const { isLoading, startProgress, progress } = useProgress({
onError: (error) => {
console.error("Export progress error:", error)
},
})
const handleSubmit = () => { const { download, isDownloading } = useDownload({
setIsLoading(true) onError: (error) => {
const exportWindow = window.open( console.error("Download error:", error)
`/export/transactions?${new URLSearchParams({ },
})
const handleSubmit = async () => {
try {
const progressId = await startProgress("transactions-export")
const exportUrl = `/export/transactions?${new URLSearchParams({
search: exportFilters?.search || "", search: exportFilters?.search || "",
dateFrom: exportFilters?.dateFrom || "", dateFrom: exportFilters?.dateFrom || "",
dateTo: exportFilters?.dateTo || "", dateTo: exportFilters?.dateTo || "",
@@ -53,22 +66,12 @@ export function ExportTransactionsDialog({
projectCode: exportFilters?.projectCode || "", projectCode: exportFilters?.projectCode || "",
fields: exportFields.join(","), fields: exportFields.join(","),
includeAttachments: includeAttachments.toString(), includeAttachments: includeAttachments.toString(),
progressId: progressId || "",
}).toString()}` }).toString()}`
) await download(exportUrl, "transactions.zip")
} catch (error) {
// Check if window was opened successfully console.error("Failed to start export:", error)
if (!exportWindow) {
setIsLoading(false)
return
} }
// Monitor the export window
const checkWindow = setInterval(() => {
if (exportWindow.closed) {
clearInterval(checkWindow)
setIsLoading(false)
}
}, 1000)
} }
return ( return (
@@ -171,8 +174,14 @@ export function ExportTransactionsDialog({
</div> </div>
</div> </div>
<DialogFooter className="sm:justify-end"> <DialogFooter className="sm:justify-end">
<Button type="button" onClick={handleSubmit} disabled={isLoading}> <Button type="button" onClick={handleSubmit} disabled={isLoading || isDownloading}>
{isLoading ? "Exporting..." : "Export Transactions"} {isLoading
? progress?.current
? `Archiving ${progress.current}/${progress.total} files`
: "Exporting..."
: isDownloading
? "Archive is created. Downloading..."
: "Export Transactions"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

48
hooks/use-download.tsx Normal file
View File

@@ -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,
}
}

85
hooks/use-progress.tsx Normal file
View File

@@ -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<EventSource | null>(null)
const [progress, setProgress] = useState<Progress | null>(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,
}
}

62
models/progress.ts Normal file
View File

@@ -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 },
})
}

View File

@@ -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;

View File

@@ -39,6 +39,7 @@ model User {
accounts Account[] accounts Account[]
sessions Session[] sessions Session[]
appData AppData[] appData AppData[]
progress Progress[]
@@map("users") @@map("users")
} }
@@ -219,3 +220,17 @@ model AppData {
@@unique([userId, app]) @@unique([userId, app])
@@map("app_data") @@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")
}