Model Update
This commit is contained in:
23
src/app.css
23
src/app.css
@@ -135,3 +135,26 @@ body {
|
|||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 10px 30px -10px rgba(0, 0, 0, 0.5);
|
box-shadow: 0 10px 30px -10px rgba(0, 0, 0, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Date Input Dark Theme Styling */
|
||||||
|
input[type="date"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="date"]::-webkit-calendar-picker-indicator {
|
||||||
|
filter: invert(0.7);
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="date"]::-webkit-calendar-picker-indicator:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Firefox date input styling */
|
||||||
|
input[type="date"]::-moz-calendar-picker-indicator {
|
||||||
|
filter: invert(0.7);
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,6 +47,22 @@
|
|||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleDuplicate() {
|
||||||
|
if (
|
||||||
|
!confirm(
|
||||||
|
"Create a duplicate print log? This will deduct filament from your spool.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
isSubmitting = true;
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("id", print._id);
|
||||||
|
await fetch("?/duplicate", { method: "POST", body: formData });
|
||||||
|
isSubmitting = false;
|
||||||
|
handleClose();
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
// STL Upload
|
// STL Upload
|
||||||
let stlFile = $state<File | null>(null);
|
let stlFile = $state<File | null>(null);
|
||||||
let uploadProgress = $state(0);
|
let uploadProgress = $state(0);
|
||||||
@@ -222,6 +238,28 @@
|
|||||||
|
|
||||||
<Input label="Print Name" name="name" value={print.name} required />
|
<Input label="Print Name" name="name" value={print.name} required />
|
||||||
|
|
||||||
|
<!-- Date Field -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
|
<label
|
||||||
|
class="block text-xs font-medium text-slate-400 uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
Date
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<Icon
|
||||||
|
icon="mdi:calendar"
|
||||||
|
class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500 pointer-events-none"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
name="date"
|
||||||
|
value={new Date(print.date).toISOString().split("T")[0]}
|
||||||
|
class="w-full rounded-lg bg-slate-800/50 border border-slate-700 pl-10 pr-4 py-2.5 text-sm text-slate-100 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500/30 transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- STL Viewer/Upload -->
|
<!-- STL Viewer/Upload -->
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<!-- svelte-ignore a11y_label_has_associated_control -->
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
@@ -494,6 +532,51 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<!-- Completed print fields -->
|
<!-- Completed print fields -->
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
|
<!-- Printer and Spool Selection -->
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
|
<label
|
||||||
|
class="block text-xs font-medium text-slate-400 uppercase tracking-wider"
|
||||||
|
>Printer</label
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
name="printer_id"
|
||||||
|
class="w-full rounded-lg bg-slate-800/50 border border-slate-700 px-4 py-2.5 text-sm text-slate-100 focus:border-blue-500 focus:outline-none"
|
||||||
|
>
|
||||||
|
{#each printers as p}
|
||||||
|
<option
|
||||||
|
value={p._id}
|
||||||
|
selected={print.printer_id?._id ===
|
||||||
|
p._id || print.printer_id === p._id}
|
||||||
|
>{p.name}</option
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
|
<label
|
||||||
|
class="block text-xs font-medium text-slate-400 uppercase tracking-wider"
|
||||||
|
>Spool</label
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
name="spool_id"
|
||||||
|
class="w-full rounded-lg bg-slate-800/50 border border-slate-700 px-4 py-2.5 text-sm text-slate-100 focus:border-blue-500 focus:outline-none"
|
||||||
|
>
|
||||||
|
{#each spools as s}
|
||||||
|
<option
|
||||||
|
value={s._id}
|
||||||
|
selected={print.spool_id?._id ===
|
||||||
|
s._id || print.spool_id === s._id}
|
||||||
|
>{s.brand}
|
||||||
|
{s.material} ({s.weight_remaining_g}g
|
||||||
|
left)</option
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<!-- svelte-ignore a11y_label_has_associated_control -->
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
<label
|
<label
|
||||||
@@ -550,23 +633,30 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="pt-4 flex justify-between">
|
<div class="pt-4 flex justify-between">
|
||||||
|
<div class="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
type="button"
|
type="button"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
onclick={handleDelete}
|
onclick={handleDelete}
|
||||||
>
|
>
|
||||||
|
<Icon icon="mdi:delete" class="w-4 h-4 mr-1" />
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
<div class="flex gap-3">
|
<Button
|
||||||
<Button variant="ghost" onclick={handleClose} type="button"
|
variant="ghost"
|
||||||
>Cancel</Button
|
type="button"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onclick={handleDuplicate}
|
||||||
>
|
>
|
||||||
|
<Icon icon="mdi:content-copy" class="w-4 h-4 mr-1" />
|
||||||
|
Duplicate
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<Button type="submit" disabled={isSubmitting}>
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
{isSubmitting ? "Saving..." : "Save Changes"}
|
{isSubmitting ? "Saving..." : "Save Changes"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -192,6 +192,28 @@
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Date Field -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
|
<label
|
||||||
|
class="block text-xs font-medium text-slate-400 uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
Date
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<Icon
|
||||||
|
icon="mdi:calendar"
|
||||||
|
class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500 pointer-events-none"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
name="date"
|
||||||
|
value={new Date().toISOString().split("T")[0]}
|
||||||
|
class="w-full rounded-lg bg-slate-800/50 border border-slate-700 pl-10 pr-4 py-2.5 text-sm text-slate-100 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500/30 transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 3D Model Upload -->
|
<!-- 3D Model Upload -->
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<!-- svelte-ignore a11y_label_has_associated_control -->
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
@@ -457,10 +479,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="pt-4 flex justify-end gap-3">
|
<div class="pt-4 flex justify-end">
|
||||||
<Button variant="ghost" onclick={handleClose} type="button"
|
|
||||||
>Cancel</Button
|
|
||||||
>
|
|
||||||
<Button type="submit" disabled={isSubmitting}>
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
{isSubmitting
|
{isSubmitting
|
||||||
? "Saving..."
|
? "Saving..."
|
||||||
|
|||||||
@@ -1,65 +1,189 @@
|
|||||||
import { PrintJob } from '$lib/models/PrintJob';
|
import { PrintJob } from '$lib/models/PrintJob';
|
||||||
|
import { Printer } from '$lib/models/Printer';
|
||||||
import { connectDB } from '$lib/server/db';
|
import { connectDB } from '$lib/server/db';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import { statSync, existsSync } from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals }) => {
|
export const load: PageServerLoad = async ({ locals, url }) => {
|
||||||
if (!locals.user) throw redirect(303, '/login');
|
if (!locals.user) throw redirect(303, '/login');
|
||||||
|
|
||||||
await connectDB();
|
await connectDB();
|
||||||
|
|
||||||
// Fetch all prints for aggregation - filtered by user
|
// Get time range from URL param (default: 30 days)
|
||||||
const prints = await PrintJob.find({ user_id: locals.user.id })
|
const range = url.searchParams.get('range') || '30';
|
||||||
.populate('spool_id', 'color_hex material')
|
let dateFilter: Date | null = null;
|
||||||
.populate('printer_id', 'power_consumption_watts')
|
|
||||||
|
if (range !== 'all') {
|
||||||
|
const days = parseInt(range);
|
||||||
|
dateFilter = new Date();
|
||||||
|
dateFilter.setDate(dateFilter.getDate() - days);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build query with optional date filter
|
||||||
|
const query: any = { user_id: locals.user.id };
|
||||||
|
if (dateFilter) {
|
||||||
|
query.date = { $gte: dateFilter };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all prints for aggregation
|
||||||
|
const prints = await PrintJob.find(query)
|
||||||
|
.populate('spool_id', 'color_hex material brand')
|
||||||
|
.populate('printer_id', 'power_consumption_watts name')
|
||||||
.sort({ date: 1 })
|
.sort({ date: 1 })
|
||||||
.lean();
|
.lean();
|
||||||
|
|
||||||
// 1. Success vs Fail
|
// Get all printers for the user
|
||||||
|
const printers = await Printer.find({ user_id: locals.user.id }).lean();
|
||||||
|
|
||||||
|
// 1. Success vs Fail vs Cancelled
|
||||||
let successCount = 0;
|
let successCount = 0;
|
||||||
let failCount = 0;
|
let failCount = 0;
|
||||||
|
let cancelledCount = 0;
|
||||||
|
let inProgressCount = 0;
|
||||||
|
|
||||||
// 2. Material Usage (Map: Material -> Weight)
|
// 2. Material Usage (Map: Material -> Weight)
|
||||||
const materialUsage: Record<string, number> = {};
|
const materialUsage: Record<string, number> = {};
|
||||||
|
|
||||||
// 3. Usage Over Time (Last 30 days)
|
// 3. Usage Over Time
|
||||||
const usageByDate: Record<string, number> = {};
|
const usageByDate: Record<string, number> = {};
|
||||||
|
|
||||||
// 4. Electricity Usage Over Time (Wh)
|
// 4. Electricity Usage Over Time (Wh)
|
||||||
const electricityByDate: Record<string, number> = {};
|
const electricityByDate: Record<string, number> = {};
|
||||||
let totalElectricity = 0;
|
let totalElectricity = 0;
|
||||||
|
|
||||||
|
// 5. Cost tracking
|
||||||
|
const costByDate: Record<string, number> = {};
|
||||||
|
let totalCost = 0;
|
||||||
|
let totalFilamentCost = 0;
|
||||||
|
let totalEnergyCost = 0;
|
||||||
|
|
||||||
|
// 6. Print time tracking
|
||||||
|
let totalPrintTime = 0;
|
||||||
|
|
||||||
|
// 7. Printer usage (how many prints per printer)
|
||||||
|
const printerUsage: Record<string, { name: string; count: number; time: number }> = {};
|
||||||
|
|
||||||
|
// 8. Total filament used
|
||||||
|
let totalFilamentUsed = 0;
|
||||||
|
|
||||||
|
// 9. Prints with 3D models count
|
||||||
|
let printsWithModels = 0;
|
||||||
|
let totalModelSize = 0; // Total file size of all models in bytes
|
||||||
|
|
||||||
|
// 10. Top printed models
|
||||||
|
const modelCounts: Record<string, number> = {};
|
||||||
|
|
||||||
prints.forEach(print => {
|
prints.forEach(print => {
|
||||||
// Status
|
// Status
|
||||||
if (print.status === 'Success') successCount++;
|
if (print.status === 'Success') successCount++;
|
||||||
else if (print.status === 'Fail') failCount++;
|
else if (print.status === 'Fail') failCount++;
|
||||||
|
else if (print.status === 'Cancelled') cancelledCount++;
|
||||||
|
else if (print.status === 'In Progress') inProgressCount++;
|
||||||
|
|
||||||
// Material
|
// Material
|
||||||
if (print.spool_id?.material) {
|
if (print.spool_id?.material) {
|
||||||
const mat = print.spool_id.material;
|
const mat = print.spool_id.material;
|
||||||
materialUsage[mat] = (materialUsage[mat] || 0) + print.filament_used_g;
|
materialUsage[mat] = (materialUsage[mat] || 0) + (print.filament_used_g || 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Timeline - Filament
|
// Timeline - Filament
|
||||||
const dateKey = new Date(print.date).toISOString().split('T')[0];
|
const dateKey = new Date(print.date).toISOString().split('T')[0];
|
||||||
usageByDate[dateKey] = (usageByDate[dateKey] || 0) + print.filament_used_g;
|
usageByDate[dateKey] = (usageByDate[dateKey] || 0) + (print.filament_used_g || 0);
|
||||||
|
|
||||||
// Electricity: Power (W) × Duration (hours) = Wh
|
// Electricity: Power (W) × Duration (hours) = Wh
|
||||||
const powerWatts = print.printer_id?.power_consumption_watts || 0;
|
const powerWatts = (print.printer_id as any)?.power_consumption_watts || 0;
|
||||||
const durationHours = (print.duration_minutes || 0) / 60;
|
const durationHours = (print.duration_minutes || 0) / 60;
|
||||||
const wattHours = powerWatts * durationHours;
|
const wattHours = powerWatts * durationHours;
|
||||||
|
|
||||||
electricityByDate[dateKey] = (electricityByDate[dateKey] || 0) + wattHours;
|
electricityByDate[dateKey] = (electricityByDate[dateKey] || 0) + wattHours;
|
||||||
totalElectricity += wattHours;
|
totalElectricity += wattHours;
|
||||||
|
|
||||||
|
// Cost
|
||||||
|
const printCost = print.calculated_cost_filament || 0;
|
||||||
|
const energyCost = print.calculated_cost_energy || 0;
|
||||||
|
costByDate[dateKey] = (costByDate[dateKey] || 0) + printCost;
|
||||||
|
totalCost += printCost;
|
||||||
|
totalFilamentCost += (printCost - energyCost);
|
||||||
|
totalEnergyCost += energyCost;
|
||||||
|
|
||||||
|
// Print time
|
||||||
|
totalPrintTime += print.duration_minutes || 0;
|
||||||
|
|
||||||
|
// Printer usage
|
||||||
|
const printerId = (print.printer_id as any)?._id?.toString() || 'unknown';
|
||||||
|
const printerName = (print.printer_id as any)?.name || 'Unknown';
|
||||||
|
if (!printerUsage[printerId]) {
|
||||||
|
printerUsage[printerId] = { name: printerName, count: 0, time: 0 };
|
||||||
|
}
|
||||||
|
printerUsage[printerId].count++;
|
||||||
|
printerUsage[printerId].time += print.duration_minutes || 0;
|
||||||
|
|
||||||
|
// Filament total
|
||||||
|
totalFilamentUsed += print.filament_used_g || 0;
|
||||||
|
|
||||||
|
// 3D models
|
||||||
|
if (print.stl_file) {
|
||||||
|
printsWithModels++;
|
||||||
|
modelCounts[print.name] = (modelCounts[print.name] || 0) + 1;
|
||||||
|
|
||||||
|
// Get file size
|
||||||
|
try {
|
||||||
|
const filePath = path.join('static', print.stl_file);
|
||||||
|
if (existsSync(filePath)) {
|
||||||
|
const stats = statSync(filePath);
|
||||||
|
totalModelSize += stats.size;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore file read errors
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Sort top models by count
|
||||||
|
const topModels = Object.entries(modelCounts)
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 5)
|
||||||
|
.map(([name, count]) => ({ name, count }));
|
||||||
|
|
||||||
|
// Calculate averages
|
||||||
|
const completedPrints = successCount + failCount;
|
||||||
|
const avgPrintTime = completedPrints > 0 ? totalPrintTime / completedPrints : 0;
|
||||||
|
const avgCost = completedPrints > 0 ? totalCost / completedPrints : 0;
|
||||||
|
const avgFilament = completedPrints > 0 ? totalFilamentUsed / completedPrints : 0;
|
||||||
|
|
||||||
|
// Convert printer usage to array and sort
|
||||||
|
const printerStats = Object.values(printerUsage)
|
||||||
|
.sort((a, b) => b.count - a.count);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
analytics: {
|
analytics: {
|
||||||
successRate: { success: successCount, fail: failCount },
|
successRate: {
|
||||||
|
success: successCount,
|
||||||
|
fail: failCount,
|
||||||
|
cancelled: cancelledCount,
|
||||||
|
inProgress: inProgressCount
|
||||||
|
},
|
||||||
materialUsage,
|
materialUsage,
|
||||||
usageByDate,
|
usageByDate,
|
||||||
electricityByDate,
|
electricityByDate,
|
||||||
totalElectricity: (totalElectricity / 1000).toFixed(2) // Convert to kWh
|
costByDate,
|
||||||
|
totalElectricity: (totalElectricity / 1000).toFixed(2),
|
||||||
|
totalCost: totalCost.toFixed(2),
|
||||||
|
totalFilamentCost: totalFilamentCost.toFixed(2),
|
||||||
|
totalEnergyCost: totalEnergyCost.toFixed(2),
|
||||||
|
totalPrintTime,
|
||||||
|
totalFilamentUsed: Math.round(totalFilamentUsed),
|
||||||
|
avgPrintTime: Math.round(avgPrintTime),
|
||||||
|
avgCost: avgCost.toFixed(2),
|
||||||
|
avgFilament: Math.round(avgFilament),
|
||||||
|
printerStats,
|
||||||
|
printsWithModels,
|
||||||
|
totalModelSize,
|
||||||
|
topModels,
|
||||||
|
totalPrints: prints.length,
|
||||||
|
range
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,18 +1,64 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
import Chart from "chart.js/auto";
|
import Chart from "chart.js/auto";
|
||||||
import Card from "$lib/components/ui/Card.svelte";
|
import Card from "$lib/components/ui/Card.svelte";
|
||||||
|
import Button from "$lib/components/ui/Button.svelte";
|
||||||
import Icon from "@iconify/svelte";
|
import Icon from "@iconify/svelte";
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
// svelte-ignore non_reactive_update
|
|
||||||
let analytics = $derived(data.analytics);
|
let analytics = $derived(data.analytics);
|
||||||
|
|
||||||
let timelineCanvas: HTMLCanvasElement;
|
let timelineCanvas: HTMLCanvasElement;
|
||||||
let pieCanvas: HTMLCanvasElement;
|
let pieCanvas: HTMLCanvasElement;
|
||||||
let electricityCanvas: HTMLCanvasElement;
|
let electricityCanvas: HTMLCanvasElement;
|
||||||
|
let costCanvas: HTMLCanvasElement;
|
||||||
|
let printerCanvas: HTMLCanvasElement;
|
||||||
|
|
||||||
|
// Time range options
|
||||||
|
const ranges = [
|
||||||
|
{ value: "7", label: "7 Days" },
|
||||||
|
{ value: "30", label: "30 Days" },
|
||||||
|
{ value: "90", label: "90 Days" },
|
||||||
|
{ value: "365", label: "1 Year" },
|
||||||
|
{ value: "all", label: "All Time" },
|
||||||
|
];
|
||||||
|
let selectedRange = $state(analytics.range);
|
||||||
|
|
||||||
|
function changeRange(range: string) {
|
||||||
|
selectedRange = range;
|
||||||
|
goto(`/analytics?range=${range}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format minutes to hours:minutes
|
||||||
|
function formatTime(minutes: number): string {
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const mins = minutes % 60;
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}h ${mins}m`;
|
||||||
|
}
|
||||||
|
return `${mins}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format bytes to human readable
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return "0 B";
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ["B", "KB", "MB", "GB"];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
const chartColors = [
|
||||||
|
"#3b82f6",
|
||||||
|
"#8b5cf6",
|
||||||
|
"#10b981",
|
||||||
|
"#f59e0b",
|
||||||
|
"#ef4444",
|
||||||
|
"#64748b",
|
||||||
|
];
|
||||||
|
|
||||||
// 1. Timeline Chart - Filament Usage
|
// 1. Timeline Chart - Filament Usage
|
||||||
const dates = Object.keys(analytics.usageByDate).slice(-30);
|
const dates = Object.keys(analytics.usageByDate).slice(-30);
|
||||||
const weights = dates.map((d) => analytics.usageByDate[d]);
|
const weights = dates.map((d) => analytics.usageByDate[d]);
|
||||||
@@ -20,7 +66,12 @@
|
|||||||
new Chart(timelineCanvas, {
|
new Chart(timelineCanvas, {
|
||||||
type: "line",
|
type: "line",
|
||||||
data: {
|
data: {
|
||||||
labels: dates,
|
labels: dates.map((d) =>
|
||||||
|
new Date(d).toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
}),
|
||||||
|
),
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
label: "Filament Usage (g)",
|
label: "Filament Usage (g)",
|
||||||
@@ -39,8 +90,14 @@
|
|||||||
legend: { display: false },
|
legend: { display: false },
|
||||||
},
|
},
|
||||||
scales: {
|
scales: {
|
||||||
y: { grid: { color: "rgba(255,255,255,0.1)" } },
|
y: {
|
||||||
x: { grid: { display: false } },
|
grid: { color: "rgba(255,255,255,0.1)" },
|
||||||
|
ticks: { color: "#94a3b8" },
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
grid: { display: false },
|
||||||
|
ticks: { color: "#94a3b8" },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -48,14 +105,6 @@
|
|||||||
// 2. Material Pie Chart
|
// 2. Material Pie Chart
|
||||||
const materials = Object.keys(analytics.materialUsage);
|
const materials = Object.keys(analytics.materialUsage);
|
||||||
const matWeights = materials.map((m) => analytics.materialUsage[m]);
|
const matWeights = materials.map((m) => analytics.materialUsage[m]);
|
||||||
const chartColors = [
|
|
||||||
"#3b82f6",
|
|
||||||
"#8b5cf6",
|
|
||||||
"#10b981",
|
|
||||||
"#f59e0b",
|
|
||||||
"#ef4444",
|
|
||||||
"#64748b",
|
|
||||||
];
|
|
||||||
|
|
||||||
new Chart(pieCanvas, {
|
new Chart(pieCanvas, {
|
||||||
type: "doughnut",
|
type: "doughnut",
|
||||||
@@ -84,12 +133,17 @@
|
|||||||
);
|
);
|
||||||
const electricityWh = electricDates.map(
|
const electricityWh = electricDates.map(
|
||||||
(d) => analytics.electricityByDate[d] / 1000,
|
(d) => analytics.electricityByDate[d] / 1000,
|
||||||
); // Convert to kWh
|
);
|
||||||
|
|
||||||
new Chart(electricityCanvas, {
|
new Chart(electricityCanvas, {
|
||||||
type: "bar",
|
type: "bar",
|
||||||
data: {
|
data: {
|
||||||
labels: electricDates,
|
labels: electricDates.map((d) =>
|
||||||
|
new Date(d).toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
}),
|
||||||
|
),
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
label: "Electricity (kWh)",
|
label: "Electricity (kWh)",
|
||||||
@@ -110,39 +164,189 @@
|
|||||||
scales: {
|
scales: {
|
||||||
y: {
|
y: {
|
||||||
grid: { color: "rgba(255,255,255,0.1)" },
|
grid: { color: "rgba(255,255,255,0.1)" },
|
||||||
|
ticks: { color: "#94a3b8" },
|
||||||
title: { display: true, text: "kWh", color: "#94a3b8" },
|
title: { display: true, text: "kWh", color: "#94a3b8" },
|
||||||
},
|
},
|
||||||
x: { grid: { display: false } },
|
x: {
|
||||||
|
grid: { display: false },
|
||||||
|
ticks: { color: "#94a3b8" },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 4. Cost Chart
|
||||||
|
const costDates = Object.keys(analytics.costByDate).slice(-30);
|
||||||
|
const costs = costDates.map((d) => analytics.costByDate[d]);
|
||||||
|
|
||||||
|
new Chart(costCanvas, {
|
||||||
|
type: "line",
|
||||||
|
data: {
|
||||||
|
labels: costDates.map((d) =>
|
||||||
|
new Date(d).toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: "Cost ($)",
|
||||||
|
data: costs,
|
||||||
|
borderColor: "#10b981",
|
||||||
|
backgroundColor: "rgba(16, 185, 129, 0.2)",
|
||||||
|
tension: 0.4,
|
||||||
|
fill: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
grid: { color: "rgba(255,255,255,0.1)" },
|
||||||
|
ticks: {
|
||||||
|
color: "#94a3b8",
|
||||||
|
callback: (value) => "$" + value,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
grid: { display: false },
|
||||||
|
ticks: { color: "#94a3b8" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. Printer Usage Chart
|
||||||
|
if (analytics.printerStats.length > 0) {
|
||||||
|
new Chart(printerCanvas, {
|
||||||
|
type: "bar",
|
||||||
|
data: {
|
||||||
|
labels: analytics.printerStats.map(
|
||||||
|
(p: { name: string }) => p.name,
|
||||||
|
),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: "Prints",
|
||||||
|
data: analytics.printerStats.map(
|
||||||
|
(p: { count: number }) => p.count,
|
||||||
|
),
|
||||||
|
backgroundColor: chartColors,
|
||||||
|
borderRadius: 6,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
indexAxis: "y",
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
grid: { display: false },
|
||||||
|
ticks: { color: "#94a3b8" },
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
grid: { color: "rgba(255,255,255,0.1)" },
|
||||||
|
ticks: { color: "#94a3b8" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Derived values
|
||||||
let totalPrints = $derived(
|
let totalPrints = $derived(
|
||||||
analytics.successRate.success + analytics.successRate.fail,
|
analytics.successRate.success +
|
||||||
|
analytics.successRate.fail +
|
||||||
|
analytics.successRate.cancelled,
|
||||||
);
|
);
|
||||||
let successRate = $derived(
|
let successRate = $derived(
|
||||||
totalPrints > 0
|
totalPrints > 0
|
||||||
? Math.round((analytics.successRate.success / totalPrints) * 100)
|
? Math.round((analytics.successRate.success / totalPrints) * 100)
|
||||||
: 0,
|
: 0,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Export to CSV
|
||||||
|
function exportToCSV() {
|
||||||
|
const headers = [
|
||||||
|
"Date",
|
||||||
|
"Filament (g)",
|
||||||
|
"Electricity (kWh)",
|
||||||
|
"Cost ($)",
|
||||||
|
];
|
||||||
|
const dates = Object.keys(analytics.usageByDate);
|
||||||
|
const rows = dates.map((d) => [
|
||||||
|
d,
|
||||||
|
analytics.usageByDate[d] || 0,
|
||||||
|
((analytics.electricityByDate[d] || 0) / 1000).toFixed(3),
|
||||||
|
(analytics.costByDate[d] || 0).toFixed(2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const csv = [headers.join(","), ...rows.map((r) => r.join(","))].join(
|
||||||
|
"\n",
|
||||||
|
);
|
||||||
|
const blob = new Blob([csv], { type: "text/csv" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `filaprint-analytics-${new Date().toISOString().split("T")[0]}.csv`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-6 fade-in">
|
<div class="space-y-6 fade-in">
|
||||||
|
<div
|
||||||
|
class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold text-white">Analytics</h1>
|
<h1 class="text-3xl font-bold text-white">Analytics</h1>
|
||||||
<p class="text-slate-400 mt-1">Insights into your printing habits</p>
|
<p class="text-slate-400 mt-1">
|
||||||
|
Insights into your printing habits
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<!-- Time Range Selector -->
|
||||||
|
<div class="flex bg-slate-800/50 rounded-lg p-1">
|
||||||
|
{#each ranges as range}
|
||||||
|
<button
|
||||||
|
class="px-3 py-1.5 text-xs font-medium rounded-md transition-all {selectedRange ===
|
||||||
|
range.value
|
||||||
|
? 'bg-blue-500 text-white'
|
||||||
|
: 'text-slate-400 hover:text-white'}"
|
||||||
|
onclick={() => changeRange(range.value)}
|
||||||
|
>
|
||||||
|
{range.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<!-- Export Button -->
|
||||||
|
<Button variant="ghost" onclick={exportToCSV}>
|
||||||
|
<Icon icon="mdi:download" class="w-4 h-4 mr-1" />
|
||||||
|
Export
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Stats Row -->
|
<!-- Main Stats Row -->
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||||
<Card class="text-center">
|
<Card class="text-center">
|
||||||
<p
|
<p
|
||||||
class="text-xs font-medium text-slate-400 uppercase tracking-wider"
|
class="text-xs font-medium text-slate-400 uppercase tracking-wider"
|
||||||
>
|
>
|
||||||
Total Prints
|
Total Prints
|
||||||
</p>
|
</p>
|
||||||
<p class="text-3xl font-bold text-white mt-1">{totalPrints}</p>
|
<p class="text-2xl font-bold text-white mt-1">
|
||||||
|
{analytics.totalPrints}
|
||||||
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
<Card class="text-center">
|
<Card class="text-center">
|
||||||
<p
|
<p
|
||||||
@@ -150,7 +354,7 @@
|
|||||||
>
|
>
|
||||||
Success Rate
|
Success Rate
|
||||||
</p>
|
</p>
|
||||||
<p class="text-3xl font-bold text-emerald-400 mt-1">
|
<p class="text-2xl font-bold text-emerald-400 mt-1">
|
||||||
{successRate}%
|
{successRate}%
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -158,9 +362,31 @@
|
|||||||
<p
|
<p
|
||||||
class="text-xs font-medium text-slate-400 uppercase tracking-wider"
|
class="text-xs font-medium text-slate-400 uppercase tracking-wider"
|
||||||
>
|
>
|
||||||
Electricity Used
|
Total Spent
|
||||||
</p>
|
</p>
|
||||||
<p class="text-3xl font-bold text-amber-400 mt-1">
|
<p class="text-2xl font-bold text-green-400 mt-1">
|
||||||
|
${analytics.totalCost}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
<Card class="text-center">
|
||||||
|
<p
|
||||||
|
class="text-xs font-medium text-slate-400 uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
Filament Used
|
||||||
|
</p>
|
||||||
|
<p class="text-2xl font-bold text-blue-400 mt-1">
|
||||||
|
{analytics.totalFilamentUsed}<span
|
||||||
|
class="text-sm font-normal text-slate-500 ml-1">g</span
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
<Card class="text-center">
|
||||||
|
<p
|
||||||
|
class="text-xs font-medium text-slate-400 uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
Electricity
|
||||||
|
</p>
|
||||||
|
<p class="text-2xl font-bold text-amber-400 mt-1">
|
||||||
{analytics.totalElectricity}<span
|
{analytics.totalElectricity}<span
|
||||||
class="text-sm font-normal text-slate-500 ml-1">kWh</span
|
class="text-sm font-normal text-slate-500 ml-1">kWh</span
|
||||||
>
|
>
|
||||||
@@ -170,37 +396,118 @@
|
|||||||
<p
|
<p
|
||||||
class="text-xs font-medium text-slate-400 uppercase tracking-wider"
|
class="text-xs font-medium text-slate-400 uppercase tracking-wider"
|
||||||
>
|
>
|
||||||
Materials
|
Print Time
|
||||||
</p>
|
</p>
|
||||||
<p class="text-3xl font-bold text-violet-400 mt-1">
|
<p class="text-2xl font-bold text-violet-400 mt-1">
|
||||||
{Object.keys(analytics.materialUsage).length}
|
{formatTime(analytics.totalPrintTime)}
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<!-- Averages Row -->
|
||||||
|
<div class="grid grid-cols-3 gap-4">
|
||||||
|
<Card class="flex items-center gap-4">
|
||||||
|
<div class="p-3 rounded-lg bg-blue-500/10">
|
||||||
|
<Icon icon="mdi:timer-outline" class="w-8 h-8 text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-slate-400 uppercase">Avg Print Time</p>
|
||||||
|
<p class="text-xl font-bold text-white">
|
||||||
|
{formatTime(analytics.avgPrintTime)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card class="flex items-center gap-4">
|
||||||
|
<div class="p-3 rounded-lg bg-green-500/10">
|
||||||
|
<Icon icon="mdi:currency-usd" class="w-8 h-8 text-green-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-slate-400 uppercase">Avg Cost/Print</p>
|
||||||
|
<p class="text-xl font-bold text-white">${analytics.avgCost}</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card class="flex items-center gap-4">
|
||||||
|
<div class="p-3 rounded-lg bg-violet-500/10">
|
||||||
|
<Icon icon="mdi:scale" class="w-8 h-8 text-violet-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-slate-400 uppercase">
|
||||||
|
Avg Filament/Print
|
||||||
|
</p>
|
||||||
|
<p class="text-xl font-bold text-white">
|
||||||
|
{analytics.avgFilament}g
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cost Breakdown -->
|
||||||
|
<Card>
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Icon icon="mdi:wallet" class="w-5 h-5 text-green-400" />
|
||||||
|
<h3 class="text-lg font-semibold text-white">Cost Breakdown</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-3 gap-4">
|
||||||
|
<div class="p-4 rounded-lg bg-slate-800/50 text-center">
|
||||||
|
<p class="text-xs text-slate-400 uppercase mb-1">
|
||||||
|
Filament Cost
|
||||||
|
</p>
|
||||||
|
<p class="text-2xl font-bold text-blue-400">
|
||||||
|
${analytics.totalFilamentCost}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 rounded-lg bg-slate-800/50 text-center">
|
||||||
|
<p class="text-xs text-slate-400 uppercase mb-1">Energy Cost</p>
|
||||||
|
<p class="text-2xl font-bold text-amber-400">
|
||||||
|
${analytics.totalEnergyCost}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 rounded-lg bg-slate-800/50 text-center">
|
||||||
|
<p class="text-xs text-slate-400 uppercase mb-1">Total Cost</p>
|
||||||
|
<p class="text-2xl font-bold text-green-400">
|
||||||
|
${analytics.totalCost}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<!-- Usage Timeline -->
|
<!-- Usage Timeline -->
|
||||||
<Card class="col-span-1 md:col-span-2 min-h-[350px]">
|
<Card class="min-h-[350px]">
|
||||||
<div class="flex items-center gap-2 mb-4">
|
<div class="flex items-center gap-2 mb-4">
|
||||||
<Icon icon="mdi:scale" class="w-5 h-5 text-blue-400" />
|
<Icon icon="mdi:chart-line" class="w-5 h-5 text-blue-400" />
|
||||||
<h3 class="text-lg font-semibold text-white">
|
<h3 class="text-lg font-semibold text-white">Filament Usage</h3>
|
||||||
Daily Filament Usage (g)
|
|
||||||
</h3>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="h-[280px]">
|
<div class="h-[280px]">
|
||||||
<canvas bind:this={timelineCanvas}></canvas>
|
<canvas bind:this={timelineCanvas}></canvas>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<!-- Cost Timeline -->
|
||||||
|
<Card class="min-h-[350px]">
|
||||||
|
<div class="flex items-center gap-2 mb-4">
|
||||||
|
<Icon
|
||||||
|
icon="mdi:chart-areaspline"
|
||||||
|
class="w-5 h-5 text-green-400"
|
||||||
|
/>
|
||||||
|
<h3 class="text-lg font-semibold text-white">Cost Over Time</h3>
|
||||||
|
</div>
|
||||||
|
<div class="h-[280px]">
|
||||||
|
<canvas bind:this={costCanvas}></canvas>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<!-- Electricity Usage Chart -->
|
<!-- Electricity Usage Chart -->
|
||||||
<Card class="col-span-1 md:col-span-2 min-h-[350px]">
|
<Card class="min-h-[350px]">
|
||||||
<div class="flex items-center gap-2 mb-4">
|
<div class="flex items-center gap-2 mb-4">
|
||||||
<Icon
|
<Icon
|
||||||
icon="mdi:lightning-bolt"
|
icon="mdi:lightning-bolt"
|
||||||
class="w-5 h-5 text-amber-400"
|
class="w-5 h-5 text-amber-400"
|
||||||
/>
|
/>
|
||||||
<h3 class="text-lg font-semibold text-white">
|
<h3 class="text-lg font-semibold text-white">
|
||||||
Daily Electricity Usage (kWh)
|
Electricity Usage
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="h-[280px]">
|
<div class="h-[280px]">
|
||||||
@@ -208,7 +515,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- Success Rate Stat -->
|
<!-- Material Distribution -->
|
||||||
|
<Card class="min-h-[350px]">
|
||||||
|
<div class="flex items-center gap-2 mb-4">
|
||||||
|
<Icon icon="mdi:chart-pie" class="w-5 h-5 text-violet-400" />
|
||||||
|
<h3 class="text-lg font-semibold text-white">
|
||||||
|
Material Distribution
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="h-[280px]">
|
||||||
|
<canvas bind:this={pieCanvas}></canvas>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<!-- Success Rate Ring -->
|
||||||
<Card class="flex flex-col items-center justify-center py-8">
|
<Card class="flex flex-col items-center justify-center py-8">
|
||||||
<div class="relative w-32 h-32">
|
<div class="relative w-32 h-32">
|
||||||
<svg class="w-full h-full transform -rotate-90">
|
<svg class="w-full h-full transform -rotate-90">
|
||||||
@@ -244,23 +566,86 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-slate-400 mt-4 text-sm">
|
<div class="flex items-center gap-4 mt-4 text-sm">
|
||||||
{analytics.successRate.success} Success / {analytics.successRate
|
<span class="flex items-center gap-1 text-emerald-400">
|
||||||
.fail} Fail
|
<Icon icon="mdi:check-circle" class="w-4 h-4" />
|
||||||
</p>
|
{analytics.successRate.success}
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center gap-1 text-red-400">
|
||||||
|
<Icon icon="mdi:close-circle" class="w-4 h-4" />
|
||||||
|
{analytics.successRate.fail}
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center gap-1 text-slate-400">
|
||||||
|
<Icon icon="mdi:cancel" class="w-4 h-4" />
|
||||||
|
{analytics.successRate.cancelled}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- Material Usage Pie -->
|
<!-- Printer Usage -->
|
||||||
|
<Card class="min-h-[280px]">
|
||||||
|
<div class="flex items-center gap-2 mb-4">
|
||||||
|
<Icon icon="mdi:printer-3d" class="w-5 h-5 text-blue-400" />
|
||||||
|
<h3 class="text-lg font-semibold text-white">Printer Usage</h3>
|
||||||
|
</div>
|
||||||
|
{#if analytics.printerStats.length > 0}
|
||||||
|
<div class="h-[200px]">
|
||||||
|
<canvas bind:this={printerCanvas}></canvas>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-center h-[200px] text-slate-500"
|
||||||
|
>
|
||||||
|
No printer data available
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- 3D Models Stats -->
|
||||||
<Card>
|
<Card>
|
||||||
<div class="flex items-center gap-2 mb-4">
|
<div class="flex items-center gap-2 mb-4">
|
||||||
<Icon icon="mdi:chart-pie" class="w-5 h-5 text-violet-400" />
|
<Icon icon="mdi:cube-scan" class="w-5 h-5 text-violet-400" />
|
||||||
<h3 class="text-lg font-semibold text-white">
|
<h3 class="text-lg font-semibold text-white">3D Models</h3>
|
||||||
Material Distribution
|
|
||||||
</h3>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="h-[250px]">
|
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||||
<canvas bind:this={pieCanvas}></canvas>
|
<div class="text-center p-3 bg-slate-800/50 rounded-lg">
|
||||||
|
<p class="text-2xl font-bold text-violet-400">
|
||||||
|
{analytics.printsWithModels}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-slate-400 uppercase">
|
||||||
|
Prints with Models
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="text-center p-3 bg-slate-800/50 rounded-lg">
|
||||||
|
<p class="text-2xl font-bold text-purple-400">
|
||||||
|
{formatBytes(analytics.totalModelSize)}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-slate-400 uppercase">
|
||||||
|
Storage Used
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if analytics.topModels.length > 0}
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p class="text-xs text-slate-400 uppercase mb-2">
|
||||||
|
Most Printed
|
||||||
|
</p>
|
||||||
|
{#each analytics.topModels as model}
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between text-sm py-1"
|
||||||
|
>
|
||||||
|
<span class="text-slate-300 truncate max-w-[150px]"
|
||||||
|
>{model.name}</span
|
||||||
|
>
|
||||||
|
<span class="text-slate-500">{model.count}x</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-slate-500 text-center">
|
||||||
|
No models uploaded yet
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { PrintJob } from '$lib/models/PrintJob';
|
|||||||
import { connectDB } from '$lib/server/db';
|
import { connectDB } from '$lib/server/db';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import { statSync, existsSync } from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals }) => {
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
if (!locals.user) throw redirect(303, '/login');
|
if (!locals.user) throw redirect(303, '/login');
|
||||||
@@ -16,7 +18,31 @@ export const load: PageServerLoad = async ({ locals }) => {
|
|||||||
.sort({ date: -1 })
|
.sort({ date: -1 })
|
||||||
.lean();
|
.lean();
|
||||||
|
|
||||||
|
// Add file sizes to each model
|
||||||
|
const modelsWithSizes = printsWithSTL.map(print => {
|
||||||
|
let fileSize = 0;
|
||||||
|
if (print.stl_file) {
|
||||||
|
try {
|
||||||
|
const filePath = path.join('static', print.stl_file);
|
||||||
|
if (existsSync(filePath)) {
|
||||||
|
const stats = statSync(filePath);
|
||||||
|
fileSize = stats.size;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore file read errors
|
||||||
|
}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
models: JSON.parse(JSON.stringify(printsWithSTL))
|
...print,
|
||||||
|
fileSize
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate total storage
|
||||||
|
const totalStorage = modelsWithSizes.reduce((sum, m) => sum + m.fileSize, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
models: JSON.parse(JSON.stringify(modelsWithSizes)),
|
||||||
|
totalStorage
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
let models = $derived(data.models);
|
let models = $derived(data.models);
|
||||||
|
let totalStorage = $derived(data.totalStorage);
|
||||||
|
|
||||||
let selectedModel = $state<any>(null);
|
let selectedModel = $state<any>(null);
|
||||||
let showViewer = $state(false);
|
let showViewer = $state(false);
|
||||||
@@ -26,15 +27,35 @@
|
|||||||
year: "numeric",
|
year: "numeric",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return "0 B";
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ["B", "KB", "MB", "GB"];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-6 fade-in">
|
<div class="space-y-6 fade-in">
|
||||||
|
<div
|
||||||
|
class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold text-white">Model Library</h1>
|
<h1 class="text-3xl font-bold text-white">Model Library</h1>
|
||||||
<p class="text-slate-400 mt-1">
|
<p class="text-slate-400 mt-1">
|
||||||
Browse your 3D model collection ({models.length} models)
|
Browse your 3D model collection ({models.length} models)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 px-4 py-2 bg-slate-800/50 rounded-lg"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:harddisk" class="w-5 h-5 text-violet-400" />
|
||||||
|
<span class="text-sm text-slate-300"
|
||||||
|
>{formatBytes(totalStorage)} used</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if models.length === 0}
|
{#if models.length === 0}
|
||||||
<Card class="text-center py-12">
|
<Card class="text-center py-12">
|
||||||
@@ -129,6 +150,13 @@
|
|||||||
Failed
|
Failed
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
<!-- File size -->
|
||||||
|
<span
|
||||||
|
class="flex items-center gap-1 text-slate-500 ml-auto"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:file" class="w-3.5 h-3.5" />
|
||||||
|
{formatBytes(model.fileSize)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -205,6 +233,12 @@
|
|||||||
${selectedModel.calculated_cost_filament.toFixed(2)}
|
${selectedModel.calculated_cost_filament.toFixed(2)}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if selectedModel.fileSize}
|
||||||
|
<div class="text-slate-400">
|
||||||
|
<span class="text-slate-500">File Size:</span>
|
||||||
|
{formatBytes(selectedModel.fileSize)}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export const actions: Actions = {
|
|||||||
const manual_cost = formData.get('manual_cost');
|
const manual_cost = formData.get('manual_cost');
|
||||||
const elapsed_minutes = formData.get('elapsed_minutes');
|
const elapsed_minutes = formData.get('elapsed_minutes');
|
||||||
const stl_file = formData.get('stl_file');
|
const stl_file = formData.get('stl_file');
|
||||||
|
const date = formData.get('date');
|
||||||
|
|
||||||
if (!spool_id || !printer_id || !filament_used_g) {
|
if (!spool_id || !printer_id || !filament_used_g) {
|
||||||
return fail(400, { missing: true });
|
return fail(400, { missing: true });
|
||||||
@@ -115,7 +116,7 @@ export const actions: Actions = {
|
|||||||
status,
|
status,
|
||||||
started_at: startedAt,
|
started_at: startedAt,
|
||||||
stl_file: stl_file || null,
|
stl_file: stl_file || null,
|
||||||
date: new Date()
|
date: date ? new Date(date as string) : new Date()
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. Deduct Filament from Spool (only if not In Progress)
|
// 3. Deduct Filament from Spool (only if not In Progress)
|
||||||
@@ -146,6 +147,7 @@ export const actions: Actions = {
|
|||||||
const spool_id = formData.get('spool_id');
|
const spool_id = formData.get('spool_id');
|
||||||
const stl_file = formData.get('stl_file');
|
const stl_file = formData.get('stl_file');
|
||||||
const remove_model = formData.get('remove_model');
|
const remove_model = formData.get('remove_model');
|
||||||
|
const date = formData.get('date');
|
||||||
|
|
||||||
if (!id || !name) {
|
if (!id || !name) {
|
||||||
return fail(400, { missing: true });
|
return fail(400, { missing: true });
|
||||||
@@ -169,13 +171,18 @@ export const actions: Actions = {
|
|||||||
? await Printer.findById(printer_id)
|
? await Printer.findById(printer_id)
|
||||||
: printJob.printer_id;
|
: printJob.printer_id;
|
||||||
|
|
||||||
|
// Get correct spool for cost calculation (use new spool if provided, else existing)
|
||||||
|
const spoolForCalc = spool_id
|
||||||
|
? await Spool.findById(spool_id)
|
||||||
|
: printJob.spool_id;
|
||||||
|
|
||||||
// Calculate Filament Cost: use manual if provided, otherwise calculate
|
// Calculate Filament Cost: use manual if provided, otherwise calculate
|
||||||
let costFilament: number;
|
let costFilament: number;
|
||||||
if (manual_cost && String(manual_cost).trim() !== '') {
|
if (manual_cost && String(manual_cost).trim() !== '') {
|
||||||
// Manual cost is the total, we'll calculate energy separately for tracking
|
// Manual cost is the total, we'll calculate energy separately for tracking
|
||||||
costFilament = Number(manual_cost);
|
costFilament = Number(manual_cost);
|
||||||
} else if (printJob.spool_id?.price && printJob.spool_id?.weight_initial_g) {
|
} else if (spoolForCalc?.price && spoolForCalc?.weight_initial_g) {
|
||||||
costFilament = (printJob.spool_id.price / printJob.spool_id.weight_initial_g) * weightUsed;
|
costFilament = (spoolForCalc.price / spoolForCalc.weight_initial_g) * weightUsed;
|
||||||
} else {
|
} else {
|
||||||
costFilament = 0;
|
costFilament = 0;
|
||||||
}
|
}
|
||||||
@@ -212,10 +219,11 @@ export const actions: Actions = {
|
|||||||
calculated_cost_filament: Number(totalCost.toFixed(2)),
|
calculated_cost_filament: Number(totalCost.toFixed(2)),
|
||||||
calculated_cost_energy: Number(costEnergy.toFixed(2)),
|
calculated_cost_energy: Number(costEnergy.toFixed(2)),
|
||||||
status,
|
status,
|
||||||
started_at: startedAt
|
started_at: startedAt,
|
||||||
|
date: date ? new Date(date as string) : printJob.date
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update printer/spool if provided (for In Progress)
|
// Update printer/spool if provided
|
||||||
if (printer_id) {
|
if (printer_id) {
|
||||||
updateData.printer_id = printer_id;
|
updateData.printer_id = printer_id;
|
||||||
}
|
}
|
||||||
@@ -288,6 +296,73 @@ export const actions: Actions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await PrintJob.findOneAndDelete({ _id: id, user_id: locals.user.id });
|
await PrintJob.findOneAndDelete({ _id: id, user_id: locals.user.id });
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return fail(500, { dbError: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
duplicate: async ({ request, locals }) => {
|
||||||
|
if (!locals.user) return fail(401, { unauthorized: true });
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const id = formData.get('id');
|
||||||
|
|
||||||
|
if (!id) return fail(400, { missing: true });
|
||||||
|
|
||||||
|
await connectDB();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Find the original print with populated spool and printer
|
||||||
|
const original = await PrintJob.findOne({ _id: id, user_id: locals.user.id })
|
||||||
|
.populate('spool_id')
|
||||||
|
.populate('printer_id');
|
||||||
|
if (!original) return fail(404, { notFound: true });
|
||||||
|
|
||||||
|
// Get user's electricity rate
|
||||||
|
const user = await User.findById(locals.user.id);
|
||||||
|
const electricityRate = user?.electricity_rate || 0.12;
|
||||||
|
|
||||||
|
// Recalculate costs based on current spool prices
|
||||||
|
let costFilament = 0;
|
||||||
|
let costEnergy = 0;
|
||||||
|
const spool = original.spool_id as any;
|
||||||
|
const printer = original.printer_id as any;
|
||||||
|
|
||||||
|
if (spool?.price && spool?.weight_initial_g && original.filament_used_g) {
|
||||||
|
costFilament = (spool.price / spool.weight_initial_g) * original.filament_used_g;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (printer?.power_consumption_watts && original.duration_minutes) {
|
||||||
|
const powerKw = printer.power_consumption_watts / 1000;
|
||||||
|
const durationHours = original.duration_minutes / 60;
|
||||||
|
costEnergy = powerKw * durationHours * electricityRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalCost = costFilament + costEnergy;
|
||||||
|
|
||||||
|
// Create a new print with the same details
|
||||||
|
await PrintJob.create({
|
||||||
|
user_id: locals.user.id,
|
||||||
|
name: original.name,
|
||||||
|
printer_id: original.printer_id?._id || original.printer_id,
|
||||||
|
spool_id: original.spool_id?._id || original.spool_id,
|
||||||
|
duration_minutes: original.duration_minutes,
|
||||||
|
filament_used_g: original.filament_used_g,
|
||||||
|
calculated_cost_filament: Number(totalCost.toFixed(2)),
|
||||||
|
calculated_cost_energy: Number(costEnergy.toFixed(2)),
|
||||||
|
status: 'Success',
|
||||||
|
stl_file: original.stl_file,
|
||||||
|
date: new Date()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Deduct filament from spool for the new print
|
||||||
|
if (spool && original.filament_used_g) {
|
||||||
|
spool.weight_remaining_g = Math.max(0, spool.weight_remaining_g - original.filament_used_g);
|
||||||
|
await spool.save();
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|||||||
Reference in New Issue
Block a user