+
Profit Forecast
+
+ {cards.map((c) => (
+
+
{c.label}
+
{c.value}
+
{c.sub}
+
+ ))}
+
+ {forecast.itemsMissingMarketData > 0 && (
+
+ {forecast.itemsMissingMarketData} item(s) had no scraped market price yet — purchase or asking price was used
+ as a placeholder.
+
+ )}
+
+ );
+}
diff --git a/app/blocks/analytics/StaleInventoryCard.module.css b/app/blocks/analytics/StaleInventoryCard.module.css
new file mode 100644
index 0000000..eb64a74
--- /dev/null
+++ b/app/blocks/analytics/StaleInventoryCard.module.css
@@ -0,0 +1,109 @@
+.card {
+ background: var(--color-bg-card);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ padding: var(--space-4);
+}
+
+.headerRow {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: var(--space-1);
+}
+
+.title {
+ font-size: 1.1rem;
+ color: var(--color-text);
+}
+
+.badge {
+ font-size: 11px;
+ font-family: var(--family-mono);
+ color: var(--color-warning);
+ background: var(--color-warning-bg);
+ border-radius: var(--radius-full);
+ padding: var(--space-1) var(--space-3);
+}
+
+.sub {
+ font-size: 12px;
+ color: var(--color-text-subtle);
+ margin-bottom: var(--space-4);
+}
+
+.empty {
+ font-size: 13px;
+ color: var(--color-text-muted);
+ padding: var(--space-6) 0;
+ text-align: center;
+}
+
+.list {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+}
+
+.row {
+ display: grid;
+ grid-template-columns: 1fr 120px 80px;
+ align-items: center;
+ gap: var(--space-3);
+ padding: var(--space-2) 0;
+ border-bottom: 1px solid var(--color-border);
+}
+
+.row:last-child {
+ border-bottom: none;
+}
+
+.rowInfo {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+}
+
+.itemName {
+ font-size: 13px;
+ color: var(--color-text);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.itemMeta {
+ font-size: 11px;
+ color: var(--color-text-subtle);
+}
+
+.progressTrack {
+ height: 6px;
+ background: var(--color-bg-elevated);
+ border-radius: var(--radius-full);
+ overflow: hidden;
+}
+
+.progressFill {
+ height: 100%;
+ background: var(--color-warning);
+ border-radius: var(--radius-full);
+}
+
+.itemValue {
+ font-family: var(--family-mono);
+ font-size: 13px;
+ text-align: right;
+ color: var(--color-text);
+}
+
+@media (max-width: 768px) {
+ .row {
+ grid-template-columns: 1fr;
+ gap: var(--space-2);
+ }
+ .itemValue {
+ text-align: left;
+ }
+}
diff --git a/app/blocks/analytics/StaleInventoryCard.tsx b/app/blocks/analytics/StaleInventoryCard.tsx
new file mode 100644
index 0000000..199d223
--- /dev/null
+++ b/app/blocks/analytics/StaleInventoryCard.tsx
@@ -0,0 +1,47 @@
+import styles from "./StaleInventoryCard.module.css";
+
+interface Props {
+ className?: string;
+ items?: any[];
+}
+
+export function StaleInventoryCard({ className, items = [] }: Props) {
+ const totalTiedUpCapital = items.reduce((acc, i) => acc + Number(i.purchasePrice), 0);
+ const maxDays = Math.max(...items.map((i) => i.daysInStock), 1);
+
+ return (
+
+
+
Stale Inventory
+ {items.length} items • 60+ days
+
+
${totalTiedUpCapital.toFixed(2)} in capital tied up
+
+ {items.length === 0 ? (
+
Nothing stale — everything is moving.
+ ) : (
+
+ {items.slice(0, 8).map((item) => (
+
+
+
+ {item.name} {item.size ? `(${item.size})` : ""}
+
+
+ {item.sku} • {item.daysInStock} days in stock
+
+
+
+
${Number(item.purchasePrice).toFixed(2)}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/app/routes.ts b/app/routes.ts
index da0437d..d8468cf 100644
--- a/app/routes.ts
+++ b/app/routes.ts
@@ -12,7 +12,7 @@ export default [
route("/changelog", "routes/changelog-page.tsx"),
route("/privacy", "routes/privacy-policy.tsx"),
route("/terms", "routes/terms-of-service.tsx"),
-
+
...prefix("/app", [
layout("routes/app-layout.tsx", [
route("dashboard", "routes/dashboard.tsx"),
@@ -20,6 +20,7 @@ export default [
route("inventory/:id", "routes/inventory-item-detail.tsx"),
route("market-prices", "routes/market-prices.tsx"),
route("sales", "routes/sales-log.tsx"),
+ route("analytics", "routes/analytics.tsx"),
route("expenses", "routes/expenses-tracker.tsx"),
route("income-statement", "routes/income-statement.tsx"),
route("alerts", "routes/price-alerts.tsx"),
@@ -43,11 +44,11 @@ export default [
route("/api/ai/ocr", "routes/api.ai.ocr.ts"),
route("/api/insights", "routes/api.insights.ts"),
route("/api/export/tax", "routes/api.export.tax.ts"),
-
+
// 🌟 Kept from main branch merge (Placed safely above dynamic route)
route("/api/inventory/search", "routes/api.inventory.search.ts"),
route("/api/integrations", "routes/api.integrations.ts"),
-
+
// 🌟 Your dynamic showroom route (Must stay at the very end as a catch-all)
route("/:username", "routes/$username.tsx"),
-] satisfies RouteConfig;
\ No newline at end of file
+] satisfies RouteConfig;
diff --git a/app/routes/analytics.module.css b/app/routes/analytics.module.css
new file mode 100644
index 0000000..7a09d4e
--- /dev/null
+++ b/app/routes/analytics.module.css
@@ -0,0 +1,5 @@
+.page {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-6);
+}
diff --git a/app/routes/analytics.tsx b/app/routes/analytics.tsx
new file mode 100644
index 0000000..6da2aa0
--- /dev/null
+++ b/app/routes/analytics.tsx
@@ -0,0 +1,110 @@
+import { useLoaderData } from "react-router";
+import type { Route } from "./+types/analytics";
+import { getSupabaseServerClient } from "~/utils/supabase.server";
+import { PrismaClient } from "@prisma/client";
+import styles from "./analytics.module.css";
+import { StaleInventoryCard } from "~/blocks/analytics/StaleInventoryCard";
+import { ProfitForecast } from "~/blocks/analytics/ProfitForecast";
+import { CACHE_PRIVATE_NO_STORE } from "~/utils/cache-headers";
+
+export function headers(_: Route.HeadersArgs) {
+ return {
+ "Cache-Control": CACHE_PRIVATE_NO_STORE,
+ };
+}
+
+const prisma = new PrismaClient();
+const STALE_THRESHOLD_DAYS = 60;
+
+export async function loader({ request }: Route.LoaderArgs) {
+ const { supabase } = getSupabaseServerClient(request);
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+
+ if (!user) {
+ return {
+ staleItems: [],
+ forecast: {
+ totalInventoryCost: 0,
+ totalEstimatedMarketValue: 0,
+ projectedProfit: 0,
+ projectedMarginPct: 0,
+ itemsMissingMarketData: 0,
+ },
+ };
+ }
+
+ const cutoffDate = new Date();
+ cutoffDate.setDate(cutoffDate.getDate() - STALE_THRESHOLD_DAYS);
+
+ // Pull every in-stock item once, with its most recent MarketPrice snapshot,
+ // then derive both the stale-inventory list and the profit forecast from
+ // the same result set instead of hitting the DB twice.
+ const inStockItems = await prisma.inventoryItem.findMany({
+ where: { userId: user.id, status: "IN_STOCK" },
+ include: {
+ priceHistory: {
+ orderBy: { fetchedAt: "desc" },
+ take: 1,
+ },
+ },
+ orderBy: { purchaseDate: "asc" },
+ });
+
+ const now = Date.now();
+
+ const normalized = inStockItems.map((item) => {
+ const snapshot = item.priceHistory[0];
+ const estimatedMarketValue = snapshot ? Number(snapshot.lastSold ?? snapshot.askPrice ?? 0) || null : null;
+
+ return {
+ ...item,
+ purchasePrice: Number(item.purchasePrice),
+ askingPrice: item.askingPrice != null ? Number(item.askingPrice) : null,
+ estimatedMarketValue,
+ daysInStock: Math.floor((now - item.purchaseDate.getTime()) / (1000 * 60 * 60 * 24)),
+ };
+ });
+
+ const staleItems = normalized.filter((item) => item.purchaseDate < cutoffDate);
+
+ let totalInventoryCost = 0;
+ let totalEstimatedMarketValue = 0;
+ let itemsMissingMarketData = 0;
+
+ for (const item of normalized) {
+ totalInventoryCost += item.purchasePrice;
+ if (item.estimatedMarketValue != null) {
+ totalEstimatedMarketValue += item.estimatedMarketValue;
+ } else {
+ totalEstimatedMarketValue += item.askingPrice ?? item.purchasePrice;
+ itemsMissingMarketData += 1;
+ }
+ }
+
+ const projectedProfit = totalEstimatedMarketValue - totalInventoryCost;
+ const projectedMarginPct = totalInventoryCost > 0 ? (projectedProfit / totalInventoryCost) * 100 : 0;
+
+ return {
+ staleItems,
+ forecast: {
+ totalInventoryCost,
+ totalEstimatedMarketValue,
+ projectedProfit,
+ projectedMarginPct,
+ itemsMissingMarketData,
+ },
+ };
+}
+
+export default function AnalyticsPage() {
+ const { staleItems, forecast } = useLoaderData