Skip to content

Commit 049a587

Browse files
committed
feat: Add analytics view and backend for displaying conversion statistics.
1 parent 0fc0897 commit 049a587

9 files changed

Lines changed: 606 additions & 5 deletions

File tree

dist-electron/main.js

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,96 @@ function closeDatabase() {
124124
console.log("[database] Database closed.");
125125
}
126126
}
127+
function getAnalytics() {
128+
const db2 = getDatabase();
129+
const totalResult = db2.prepare("SELECT COUNT(*) as count FROM conversions").get();
130+
const totalConversions = totalResult.count;
131+
if (totalConversions === 0) {
132+
return {
133+
totalConversions: 0,
134+
successfulConversions: 0,
135+
failedConversions: 0,
136+
totalFilesSize: 0,
137+
totalDuration: 0,
138+
averageDuration: 0,
139+
topSourceFormats: [],
140+
topTargetFormats: [],
141+
conversionsByCategory: [],
142+
recentTrend: [],
143+
fastestConversion: null,
144+
slowestConversion: null
145+
};
146+
}
147+
const successResult = db2.prepare("SELECT COUNT(*) as count FROM conversions WHERE status = ?").get("completed");
148+
const failedResult = db2.prepare("SELECT COUNT(*) as count FROM conversions WHERE status = ?").get("failed");
149+
const aggregateResult = db2.prepare("SELECT SUM(source_size) as total_size, SUM(duration_ms) as total_duration, AVG(duration_ms) as avg_duration FROM conversions").get();
150+
const topSourceFormats = db2.prepare(`
151+
SELECT source_ext as format, COUNT(*) as count
152+
FROM conversions
153+
GROUP BY source_ext
154+
ORDER BY count DESC
155+
LIMIT 10
156+
`).all();
157+
const topTargetFormats = db2.prepare(`
158+
SELECT target_format as format, COUNT(*) as count
159+
FROM conversions
160+
GROUP BY target_format
161+
ORDER BY count DESC
162+
LIMIT 10
163+
`).all();
164+
const allExts = db2.prepare("SELECT source_ext, COUNT(*) as count FROM conversions GROUP BY source_ext").all();
165+
const categoryCount = { image: 0, document: 0, video: 0, audio: 0 };
166+
for (const ext of allExts) {
167+
const lower = ext.source_ext.toLowerCase();
168+
if (["png", "jpg", "jpeg", "gif", "webp", "bmp", "avif", "tiff", "tif", "svg", "ico", "jxl"].includes(lower)) {
169+
categoryCount.image += ext.count;
170+
} else if (["pdf", "epub", "docx", "txt", "rtf", "odt", "xps", "cbz", "mobi", "fb2"].includes(lower)) {
171+
categoryCount.document += ext.count;
172+
} else if (["mp4", "mkv", "avi", "mov", "webm", "3gp", "flv", "wmv"].includes(lower)) {
173+
categoryCount.video += ext.count;
174+
} else if (["mp3", "wav", "aac", "ogg", "flac", "wma", "m4a"].includes(lower)) {
175+
categoryCount.audio += ext.count;
176+
}
177+
}
178+
const conversionsByCategory = Object.entries(categoryCount).filter(([_, count]) => count > 0).map(([category, count]) => ({ category, count })).sort((a, b) => b.count - a.count);
179+
const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1e3;
180+
const recentTrend = db2.prepare(`
181+
SELECT DATE(created_at / 1000, 'unixepoch') as date, COUNT(*) as count
182+
FROM conversions
183+
WHERE created_at > ?
184+
GROUP BY date
185+
ORDER BY date DESC
186+
LIMIT 7
187+
`).all(sevenDaysAgo);
188+
const fastestConversion = db2.prepare(`
189+
SELECT source_name as name, duration_ms as duration
190+
FROM conversions
191+
WHERE status = 'completed' AND duration_ms > 0
192+
ORDER BY duration_ms ASC
193+
LIMIT 1
194+
`).get();
195+
const slowestConversion = db2.prepare(`
196+
SELECT source_name as name, duration_ms as duration
197+
FROM conversions
198+
WHERE status = 'completed' AND duration_ms > 0
199+
ORDER BY duration_ms DESC
200+
LIMIT 1
201+
`).get();
202+
return {
203+
totalConversions,
204+
successfulConversions: successResult.count,
205+
failedConversions: failedResult.count,
206+
totalFilesSize: aggregateResult.total_size ?? 0,
207+
totalDuration: aggregateResult.total_duration ?? 0,
208+
averageDuration: aggregateResult.avg_duration ?? 0,
209+
topSourceFormats,
210+
topTargetFormats,
211+
conversionsByCategory,
212+
recentTrend,
213+
fastestConversion: fastestConversion ?? null,
214+
slowestConversion: slowestConversion ?? null
215+
};
216+
}
127217
const SHARP_FORMATS = ["png", "jpg", "jpeg", "webp", "gif", "avif", "tiff", "tif", "jxl"];
128218
const SPECIAL_INPUT_FORMATS = ["svg", "ico", "bmp"];
129219
function isImageFormat(ext) {
@@ -835,6 +925,9 @@ ipcMain.handle("show-in-folder", async (_event, filePath) => {
835925
shell.showItemInFolder(filePath);
836926
return true;
837927
});
928+
ipcMain.handle("get-analytics", async () => {
929+
return getAnalytics();
930+
});
838931
ipcMain.handle("get-settings", async () => {
839932
return getAllSettings();
840933
});

dist-electron/preload.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ contextBridge.exposeInMainWorld("electronAPI", {
3232
deleteHistoryItem: (id) => ipcRenderer.invoke("delete-history-item", id),
3333
clearHistory: () => ipcRenderer.invoke("clear-history"),
3434
showInFolder: (filePath) => ipcRenderer.invoke("show-in-folder", filePath),
35+
// Analytics
36+
getAnalytics: () => ipcRenderer.invoke("get-analytics"),
3537
// Settings
3638
getSettings: () => ipcRenderer.invoke("get-settings"),
3739
getSetting: (key) => ipcRenderer.invoke("get-setting", key),

electron/database.ts

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,3 +177,148 @@ export function closeDatabase(): void {
177177
console.log('[database] Database closed.')
178178
}
179179
}
180+
181+
// --- Analytics ---
182+
183+
export interface AnalyticsData {
184+
totalConversions: number
185+
successfulConversions: number
186+
failedConversions: number
187+
totalFilesSize: number
188+
totalDuration: number
189+
averageDuration: number
190+
topSourceFormats: Array<{ format: string; count: number }>
191+
topTargetFormats: Array<{ format: string; count: number }>
192+
conversionsByCategory: Array<{ category: string; count: number }>
193+
recentTrend: Array<{ date: string; count: number }>
194+
fastestConversion: { name: string; duration: number } | null
195+
slowestConversion: { name: string; duration: number } | null
196+
}
197+
198+
export function getAnalytics(): AnalyticsData {
199+
const db = getDatabase()
200+
201+
// Total conversions
202+
const totalResult = db.prepare('SELECT COUNT(*) as count FROM conversions').get() as { count: number }
203+
const totalConversions = totalResult.count
204+
205+
if (totalConversions === 0) {
206+
return {
207+
totalConversions: 0,
208+
successfulConversions: 0,
209+
failedConversions: 0,
210+
totalFilesSize: 0,
211+
totalDuration: 0,
212+
averageDuration: 0,
213+
topSourceFormats: [],
214+
topTargetFormats: [],
215+
conversionsByCategory: [],
216+
recentTrend: [],
217+
fastestConversion: null,
218+
slowestConversion: null,
219+
}
220+
}
221+
222+
// Success/failure counts
223+
const successResult = db.prepare('SELECT COUNT(*) as count FROM conversions WHERE status = ?').get('completed') as { count: number }
224+
const failedResult = db.prepare('SELECT COUNT(*) as count FROM conversions WHERE status = ?').get('failed') as { count: number }
225+
226+
// Total file size and duration
227+
const aggregateResult = db.prepare('SELECT SUM(source_size) as total_size, SUM(duration_ms) as total_duration, AVG(duration_ms) as avg_duration FROM conversions').get() as {
228+
total_size: number | null
229+
total_duration: number | null
230+
avg_duration: number | null
231+
}
232+
233+
// Top source formats
234+
const topSourceFormats = db.prepare(`
235+
SELECT source_ext as format, COUNT(*) as count
236+
FROM conversions
237+
GROUP BY source_ext
238+
ORDER BY count DESC
239+
LIMIT 10
240+
`).all() as Array<{ format: string; count: number }>
241+
242+
// Top target formats
243+
const topTargetFormats = db.prepare(`
244+
SELECT target_format as format, COUNT(*) as count
245+
FROM conversions
246+
GROUP BY target_format
247+
ORDER BY count DESC
248+
LIMIT 10
249+
`).all() as Array<{ format: string; count: number }>
250+
251+
// Conversions by category (need to infer from extension)
252+
// Simplified: group by first letter of extension (not ideal but works without FORMAT_MAP in Node)
253+
const categoryMap: Record<string, string> = {
254+
'p': 'image', 'j': 'image', 'g': 'image', 'w': 'image', 'b': 'image', 'a': 'image', 't': 'image', 's': 'image', 'i': 'image',
255+
'pdf': 'document', 'e': 'document', 'd': 'document', 'r': 'document', 'o': 'document', 'x': 'document', 'c': 'document', 'm': 'document', 'f': 'document',
256+
'v': 'video',
257+
'audio': 'audio',
258+
}
259+
260+
// Get all extensions and manually categorize
261+
const allExts = db.prepare('SELECT source_ext, COUNT(*) as count FROM conversions GROUP BY source_ext').all() as Array<{ source_ext: string; count: number }>
262+
const categoryCount: Record<string, number> = { image: 0, document: 0, video: 0, audio: 0 }
263+
264+
for (const ext of allExts) {
265+
const lower = ext.source_ext.toLowerCase()
266+
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'avif', 'tiff', 'tif', 'svg', 'ico', 'jxl'].includes(lower)) {
267+
categoryCount.image += ext.count
268+
} else if (['pdf', 'epub', 'docx', 'txt', 'rtf', 'odt', 'xps', 'cbz', 'mobi', 'fb2'].includes(lower)) {
269+
categoryCount.document += ext.count
270+
} else if (['mp4', 'mkv', 'avi', 'mov', 'webm', '3gp', 'flv', 'wmv'].includes(lower)) {
271+
categoryCount.video += ext.count
272+
} else if (['mp3', 'wav', 'aac', 'ogg', 'flac', 'wma', 'm4a'].includes(lower)) {
273+
categoryCount.audio += ext.count
274+
}
275+
}
276+
277+
const conversionsByCategory = Object.entries(categoryCount)
278+
.filter(([_, count]) => count > 0)
279+
.map(([category, count]) => ({ category, count }))
280+
.sort((a, b) => b.count - a.count)
281+
282+
// Recent trend (last 7 days)
283+
const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000
284+
const recentTrend = db.prepare(`
285+
SELECT DATE(created_at / 1000, 'unixepoch') as date, COUNT(*) as count
286+
FROM conversions
287+
WHERE created_at > ?
288+
GROUP BY date
289+
ORDER BY date DESC
290+
LIMIT 7
291+
`).all(sevenDaysAgo) as Array<{ date: string; count: number }>
292+
293+
// Fastest and slowest conversions
294+
const fastestConversion = db.prepare(`
295+
SELECT source_name as name, duration_ms as duration
296+
FROM conversions
297+
WHERE status = 'completed' AND duration_ms > 0
298+
ORDER BY duration_ms ASC
299+
LIMIT 1
300+
`).get() as { name: string; duration: number } | undefined
301+
302+
const slowestConversion = db.prepare(`
303+
SELECT source_name as name, duration_ms as duration
304+
FROM conversions
305+
WHERE status = 'completed' AND duration_ms > 0
306+
ORDER BY duration_ms DESC
307+
LIMIT 1
308+
`).get() as { name: string; duration: number } | undefined
309+
310+
return {
311+
totalConversions,
312+
successfulConversions: successResult.count,
313+
failedConversions: failedResult.count,
314+
totalFilesSize: aggregateResult.total_size ?? 0,
315+
totalDuration: aggregateResult.total_duration ?? 0,
316+
averageDuration: aggregateResult.avg_duration ?? 0,
317+
topSourceFormats,
318+
topTargetFormats,
319+
conversionsByCategory,
320+
recentTrend,
321+
fastestConversion: fastestConversion ?? null,
322+
slowestConversion: slowestConversion ?? null,
323+
}
324+
}

electron/main.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
setSetting,
1717
getAllSettings,
1818
resetAllSettings,
19+
getAnalytics,
1920
} from './database'
2021
import {
2122
convertImage,
@@ -411,6 +412,10 @@ ipcMain.handle('show-in-folder', async (_event, filePath: string) => {
411412
return true
412413
})
413414

415+
ipcMain.handle('get-analytics', async () => {
416+
return getAnalytics()
417+
})
418+
414419
// ========================================
415420
// Settings
416421
// ========================================

electron/preload.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
4040
clearHistory: () => ipcRenderer.invoke('clear-history'),
4141
showInFolder: (filePath: string) => ipcRenderer.invoke('show-in-folder', filePath),
4242

43+
// Analytics
44+
getAnalytics: () => ipcRenderer.invoke('get-analytics'),
45+
4346
// Settings
4447
getSettings: () => ipcRenderer.invoke('get-settings'),
4548
getSetting: (key: string) => ipcRenderer.invoke('get-setting', key),

src/App.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { Tab } from '@/components/Sidebar'
77
import ConvertView from '@/views/ConvertView'
88
import PluginsView from '@/views/PluginsView'
99
import HistoryView from '@/views/HistoryView'
10+
import AnalyticsView from '@/views/AnalyticsView'
1011
import SettingsView from '@/views/SettingsView'
1112
import type { ConvertFile } from '@/components/FileList'
1213

@@ -27,10 +28,12 @@ function renderView(
2728
setOutputDir={setOutputDir}
2829
/>
2930
)
30-
case 'plugins':
31-
return <PluginsView />
3231
case 'history':
3332
return <HistoryView />
33+
case 'analytics':
34+
return <AnalyticsView />
35+
case 'plugins':
36+
return <PluginsView />
3437
case 'settings':
3538
return <SettingsView />
3639
}

src/components/Sidebar.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { ArrowLeftRight, Puzzle, Clock, Settings } from 'lucide-react'
1+
import { ArrowLeftRight, Puzzle, Clock, Settings, BarChart3 } from 'lucide-react'
22

3-
export type Tab = 'convert' | 'plugins' | 'history' | 'settings'
3+
export type Tab = 'convert' | 'plugins' | 'history' | 'analytics' | 'settings'
44

55
interface AppSidebarProps {
66
activeTab: Tab
@@ -10,8 +10,9 @@ interface AppSidebarProps {
1010

1111
const mainNavItems: { id: Tab; label: string; icon: typeof ArrowLeftRight }[] = [
1212
{ id: 'convert', label: 'Convert', icon: ArrowLeftRight },
13-
{ id: 'plugins', label: 'Plugins', icon: Puzzle },
1413
{ id: 'history', label: 'History', icon: Clock },
14+
{ id: 'analytics', label: 'Analytics', icon: BarChart3 },
15+
{ id: 'plugins', label: 'Plugins', icon: Puzzle },
1516
]
1617

1718
const settingsNavItems: { id: Tab; label: string; icon: typeof Settings }[] = [

0 commit comments

Comments
 (0)