Skip to main content
Build a dashboard that connects to a local Oxygen backend to display monthly metrics in real-time. Goal: Demonstrate the simplest possible Oxygen SDK integration pattern - querying time-series data (month + value).

Design System

Use this clean, professional design system inspired by modern SaaS dashboards:

Colors

/* Backgrounds */
--background: #f9fafb; /* Light gray page background */
--card-bg: #ffffff; /* White card background */

/* Text */
--text-primary: #1f2937; /* Dark gray for headings and primary text */
--text-secondary: #6b7280; /* Medium gray for secondary text */
--text-muted: #9ca3af; /* Light gray for labels and muted text */

/* Accents */
--accent-primary: #ff7a59; /* Coral/salmon orange for primary actions and highlights */
--accent-success: #10b981; /* Green for positive metrics */
--accent-danger: #ef4444; /* Red for negative metrics */

/* Borders & Dividers */
--border-color: #e5e7eb; /* Light gray borders */

/* Chart Colors */
--chart-primary: #ff7a59; /* Coral for main data line */
--chart-secondary: #9ca3af; /* Gray for secondary lines */
--chart-grid: #f3f4f6; /* Very light gray for grid lines */

Typography

/* Font Family */
font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", sans-serif;

/* Headings */
.page-title {
  font-size: 1.875rem; /* 30px */
  font-weight: 700;
  color: var(--text-primary);
}

.section-title {
  font-size: 1.125rem; /* 18px */
  font-weight: 600;
  color: var(--text-primary);
}

/* Metric Values */
.metric-value {
  font-size: 2rem; /* 32px */
  font-weight: 700;
  color: var(--text-primary);
}

/* Labels */
.label {
  font-size: 0.75rem; /* 12px */
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.025em;
  color: var(--text-muted);
}

/* Body Text */
.body-text {
  font-size: 0.875rem; /* 14px */
  color: var(--text-secondary);
}

Components

/* Cards */
.card {
  background: var(--card-bg);
  border: 1px solid var(--border-color);
  border-radius: 8px;
  padding: 1.5rem;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}

/* Buttons */
.button {
  padding: 0.5rem 1rem;
  border-radius: 6px;
  font-size: 0.875rem;
  font-weight: 500;
  transition: all 150ms ease;
}

.button-primary {
  background: var(--accent-primary);
  color: white;
}

/* Icons */
.icon {
  color: var(--text-muted);
  width: 1rem;
  height: 1rem;
}

Layout

/* Grid for metric cards */
.metrics-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
  gap: 1.5rem;
}

/* Spacing */
.section-spacing {
  margin-bottom: 2rem;
}

/* Container */
.container {
  max-width: 1280px;
  margin: 0 auto;
  padding: 2rem;
}

Charts

  • Use line charts with 2px stroke width
  • Primary line: coral color (#FF7A59)
  • Secondary/average lines: gray (#9CA3AF), dashed
  • Grid lines: very light gray (#F3F4F6)
  • Axis labels: small, gray text (0.75rem, #6B7280)
  • Chart dots: filled circles matching line color

Key Principles

  1. Clean & Professional: Minimal decoration, focus on data
  2. Generous Whitespace: Don’t crowd elements
  3. Subtle Shadows: Use sparingly (0 1px 3px rgba(0,0,0,0.05))
  4. Consistent Spacing: Use 0.5rem increments (8px, 16px, 24px, 32px)
  5. Clear Hierarchy: Bold headings, medium labels, regular body text
  6. Accessible Colors: Ensure sufficient contrast for text

1. Install Dependencies

npm install @oxy-hq/sdk recharts date-fns
Packages:
  • @oxy-hq/sdk (version 0.3.0+) - Oxygen SDK for data access
  • recharts - For charts
  • date-fns - For date formatting (optional)

2. Configure Vite

Update vite.config.ts:
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import path from "path";
import { componentTagger } from "lovable-tagger";

export default defineConfig(({ mode }) => ({
  server: {
    host: "::",
    port: 8080,
  },
  plugins: [react(), mode === "development" && componentTagger()].filter(
    Boolean,
  ),
  resolve: {
    alias: {
      "@": path.resolve(__dirname, "./src"),
    },
  },
}));

3. Configure OxyProvider in App.tsx

Wrap your app with OxyProvider to enable SDK access:
import { OxyProvider } from "@oxy-hq/sdk";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { ErrorBoundary } from "@/components/ErrorBoundary";
import { useEffect, type ReactNode } from "react";
import { toast } from "sonner";
import Index from "./pages/Index";

const queryClient = new QueryClient();

const oxyConfig = {
  baseUrl: import.meta.env.VITE_OXY_URL || "http://localhost:3000/api",
  projectId: import.meta.env.VITE_OXY_PROJECT_ID || "00000000-0000-0000-0000-000000000000",
};

const GlobalErrorHandler = ({ children }: { children: ReactNode }) => {
  useEffect(() => {
    const handler = (event: PromiseRejectionEvent) => {
      console.error("Unhandled rejection:", event.reason);
      toast.error("An unexpected error occurred");
      event.preventDefault();
    };
    window.addEventListener("unhandledrejection", handler);
    return () => window.removeEventListener("unhandledrejection", handler);
  }, []);
  return <>{children}</>;
};

const App = () => (
  <ErrorBoundary>
    <GlobalErrorHandler>
      <OxyProvider
        config={oxyConfig}
        loadingFallback={<div>Loading SDK...</div>}
        errorFallback={(error) => <div>Failed to initialize: {error.message}</div>}
      >
        <QueryClientProvider client={queryClient}>
          <BrowserRouter>
            <Routes>
              <Route path="/" element={<Index />} />
            </Routes>
          </BrowserRouter>
        </QueryClientProvider>
      </OxyProvider>
    </GlobalErrorHandler>
  </ErrorBoundary>
);

export default App;
Key features:
  • Uses loadingFallback and errorFallback props for graceful SDK loading
  • Global error handler for unhandled promise rejections
  • Error boundary for React errors

4. Add Error Boundary Component

Create src/components/ErrorBoundary.tsx:
import React from "react";

export class ErrorBoundary extends React.Component<
  { children: React.ReactNode },
  { hasError: boolean; error?: Error }
> {
  constructor(props: { children: React.ReactNode }) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error: Error) {
    return { hasError: true, error };
  }

  componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
    console.error("ErrorBoundary caught:", error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      return (
        <div className="p-8 text-center">
          <h1 className="text-2xl font-bold mb-4">Something went wrong</h1>
          <p className="text-muted-foreground">{this.state.error?.message}</p>
        </div>
      );
    }
    return this.props.children;
  }
}

5. Add Error Handling to main.tsx

Update src/main.tsx to catch mount errors:
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
import "./index.css";

console.log("[main] Mounting app...");

try {
  createRoot(document.getElementById("root")!).render(<App />);
  console.log("[main] App mounted");
} catch (err) {
  console.error("[main] Failed to mount:", err);
  document.getElementById("root")!.innerHTML = `<pre style="padding:2rem;color:red">${err}</pre>`;
}

6. Create Oxygen Integration File

Create src/integrations/oxy/oxy-integration.ts:
import type { OxySDK } from "@oxy-hq/sdk";

export interface MonthlyMetric {
  period: string; // Date string (e.g., "2024-11-01")
  value: number; // Metric value
}

// BigInt-safe JSON serializer
function safeStringify(obj: any): string {
  return JSON.stringify(
    obj,
    (_key, value) =>
      typeof value === "bigint" ? `BigInt(${value.toString()})` : value,
    2,
  );
}

export async function fetchMonthlyMetrics(
  sdk: OxySDK,
  appPath: string = "apps/monthly_metrics_starter.app.yml",
): Promise<MonthlyMetric[]> {
  try {
    console.log("[Oxy] Loading app data...");
    await sdk.loadAppData(appPath);

    console.log("[Oxy] Querying table...");
    const result = await sdk.query("SELECT * FROM monthly_values");

    console.log("[Oxy] Columns:", result.columns);
    console.log("[Oxy] Number of rows:", result.rows?.length);

    // Log every row safely (BigInt-safe)
    result.rows.forEach((row: any[], i: number) => {
      const cellInfo = row.map((cell: any, j: number) => {
        const t = typeof cell;
        let val: string;
        if (cell === null || cell === undefined) val = "NULL";
        else if (t === "bigint") val = `BigInt(${cell.toString()})`;
        else if (t === "object") {
          try {
            val = safeStringify(cell);
          } catch {
            val = "[unserializable object]";
          }
        } else val = String(cell);
        return `col[${j}](${t}): ${val}`;
      });
      console.log(`[Oxy] Row ${i}: ${cellInfo.join(" | ")}`);
    });

    const metrics: MonthlyMetric[] = result.rows.map((row: any[]) => {
      // Handle epoch date objects from DuckDB
      let period: string;
      const rawDate = row[0];
      if (rawDate && typeof rawDate === "object" && rawDate.epoch) {
        const epochValue =
          typeof rawDate.epoch === "bigint"
            ? Number(rawDate.epoch)
            : typeof rawDate.epoch === "object" &&
                rawDate.epoch._type === "BigInt"
              ? Number(rawDate.epoch.value)
              : Number(rawDate.epoch);
        const ms = epochValue < 1e12 ? epochValue * 1000 : epochValue / 1000;
        period = new Date(ms).toISOString().split("T")[0];
      } else {
        period = String(rawDate);
      }

      // Try all columns for a numeric value (skip the date column)
      let value = 0;
      for (let i = 1; i < row.length; i++) {
        const rawValue = row[i];
        if (rawValue == null) continue;
        if (typeof rawValue === "bigint") {
          value = Number(rawValue);
          break;
        } else if (typeof rawValue === "number") {
          value = rawValue;
          break;
        } else if (typeof rawValue === "string") {
          const parsed = Number(rawValue.replace(/[$,]/g, ""));
          if (!isNaN(parsed) && parsed !== 0) {
            value = parsed;
            break;
          }
        } else if (typeof rawValue === "object") {
          // Could be a DuckDB decimal or other structured type
          console.log(
            `  [Oxy] col[${i}] object keys:`,
            Object.keys(rawValue),
            safeStringify(rawValue),
          );
          if (rawValue.value !== undefined) value = Number(rawValue.value);
          else if (rawValue.epoch !== undefined)
            value = Number(
              typeof rawValue.epoch === "bigint"
                ? rawValue.epoch
                : rawValue.epoch,
            );
          if (value !== 0) break;
        }
      }

      return { period, value };
    });

    metrics.sort((a, b) => a.period.localeCompare(b.period));

    return metrics;
  } catch (error) {
    console.error("[Oxy] Error:", error);
    throw error;
  }
}
Key features:
  • BigInt-safe JSON serializer for logging
  • Handles epoch date objects with BigInt support
  • Checks multiple columns for numeric values
  • Handles various data type formats (BigInt, objects, strings)

7. Create Connection Error Component

Create src/components/ConnectionError.tsx:
import { AlertCircle, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription } from "@/components/ui/alert";

interface ConnectionErrorProps {
  error: Error;
  onRetry?: () => void;
}

export function ConnectionError({ error, onRetry }: ConnectionErrorProps) {
  return (
    <div className="flex min-h-screen items-center justify-center bg-background p-8">
      <div className="w-full max-w-lg space-y-6">
        <div className="flex items-start gap-4">
          <div className="rounded-full bg-destructive/10 p-3">
            <AlertCircle className="h-6 w-6 text-destructive" />
          </div>
          <div>
            <h2 className="text-xl font-semibold text-foreground">Connection Error</h2>
            <p className="text-muted-foreground">Unable to connect to Oxy backend</p>
          </div>
        </div>

        <Alert variant="destructive">
          <AlertDescription className="font-mono text-sm">{error.message}</AlertDescription>
        </Alert>

        <div className="rounded-lg border border-border bg-card p-4">
          <p className="mb-3 font-medium text-card-foreground">Troubleshooting:</p>
          <ul className="list-inside list-disc space-y-1.5 text-sm text-muted-foreground">
            <li>
              Ensure{" "}
              <code className="rounded bg-muted px-1 py-0.5 text-xs">oxy start --enterprise</code> is
              running
            </li>
            <li>Check that port 3000 is not blocked</li>
            <li>Verify database connection in config.yml</li>
            <li>Check browser console (F12) for details</li>
          </ul>
        </div>

        {onRetry && (
          <Button onClick={onRetry} className="w-full gap-2">
            <RefreshCw className="h-4 w-4" />
            Retry Connection
          </Button>
        )}
      </div>
    </div>
  );
}

8. Create Main Page Component

Create src/pages/Index.tsx:
import { useOxy } from "@oxy-hq/sdk";
import { useEffect, useState } from "react";
import { fetchMonthlyMetrics, type MonthlyMetric } from "@/integrations/oxy/oxy-integration";
import { ConnectionError } from "@/components/ConnectionError";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";

const Index = () => {
  const { sdk } = useOxy();
  const [metrics, setMetrics] = useState<MonthlyMetric[]>([]);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  const loadData = async () => {
    if (!sdk) {
      setError(new Error("SDK not initialized"));
      setIsLoading(false);
      return;
    }

    try {
      setIsLoading(true);
      setError(null);
      const data = await fetchMonthlyMetrics(sdk);
      setMetrics(data);
    } catch (err) {
      console.error("Failed to fetch data:", err);
      setError(err as Error);
    } finally {
      setIsLoading(false);
    }
  };

  useEffect(() => {
    loadData();
  }, [sdk]);

  // Show error page
  if (error) {
    return <ConnectionError error={error} onRetry={loadData} />;
  }

  // Show loading
  if (isLoading) {
    return <div className="p-8 text-center">Loading...</div>;
  }

  // Show data
  return (
    <div className="container mx-auto py-8">
      <div className="mb-8">
        <h1 className="text-3xl font-bold">Company Revenue Targets</h1>
        <p className="text-muted-foreground">Company-wide monthly revenue targets</p>
      </div>

      <Card>
        <CardHeader>
          <CardTitle>Monthly Target Performance</CardTitle>
        </CardHeader>
        <CardContent>
          <table className="w-full">
            <thead>
              <tr>
                <th className="text-left p-2">Target Month</th>
                <th className="text-right p-2">Target Amount</th>
              </tr>
            </thead>
            <tbody>
              {metrics.map((m, i) => (
                <tr key={i}>
                  <td className="p-2">{m.period}</td>
                  <td className="text-right p-2">${m.value.toLocaleString()}</td>
                </tr>
              ))}
            </tbody>
          </table>
        </CardContent>
      </Card>
    </div>
  );
};

export default Index;

9. Optional: Add Line Chart

Enhance with a chart visualization:
import {
  LineChart,
  Line,
  XAxis,
  YAxis,
  CartesianGrid,
  Tooltip,
  ResponsiveContainer,
} from "recharts";

// Add this before the table in your Card component:
<ResponsiveContainer width="100%" height={300}>
  <LineChart data={metrics}>
    <CartesianGrid strokeDasharray="3 3" />
    <XAxis
      dataKey="period"
      tickFormatter={(value) => {
        const date = new Date(value);
        return date.toLocaleDateString("en-US", { month: "short", year: "2-digit" });
      }}
    />
    <YAxis tickFormatter={(value) => `$${(value / 1000000).toFixed(1)}M`} />
    <Tooltip
      formatter={(value: number) => [`$${value.toLocaleString()}`, "Target Amount"]}
      labelFormatter={(label) => {
        const date = new Date(label);
        return date.toLocaleDateString("en-US", { month: "long", year: "numeric" });
      }}
    />
    <Line
      type="monotone"
      dataKey="value"
      stroke="#2563eb"
      strokeWidth={2}
      dot={{ fill: "#2563eb" }}
    />
  </LineChart>
</ResponsiveContainer>

10. Optional: Add Summary Metrics

Show current month target, total targets, and period:
const latestMetric = metrics[metrics.length - 1];
const previousMetric = metrics[metrics.length - 2];
const percentChange = previousMetric
  ? ((latestMetric.value - previousMetric.value) / previousMetric.value) * 100
  : 0;

const totalTargets = metrics.reduce((sum, m) => sum + m.value, 0);
const firstPeriod = metrics[0]?.period;
const lastPeriod = metrics[metrics.length - 1]?.period;

// Add these cards before the main Card component:
<div className="grid gap-4 md:grid-cols-3 mb-8">
  <Card>
    <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
      <CardTitle className="text-sm font-medium">Current Month Target</CardTitle>
      <DollarSign className="h-4 w-4 text-muted-foreground" />
    </CardHeader>
    <CardContent>
      <div className="text-2xl font-bold">${(latestMetric.value / 1000000).toFixed(1)}M</div>
      <p className="text-xs text-muted-foreground">
        <span className={percentChange >= 0 ? "text-green-600" : "text-red-600"}>
          {percentChange >= 0 ? "↑" : "↓"} {Math.abs(percentChange).toFixed(1)}%
        </span>{" "}
        vs. previous month
      </p>
    </CardContent>
  </Card>

  <Card>
    <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
      <CardTitle className="text-sm font-medium">Total Targets</CardTitle>
      <TrendingUp className="h-4 w-4 text-muted-foreground" />
    </CardHeader>
    <CardContent>
      <div className="text-2xl font-bold">${(totalTargets / 1000000).toFixed(1)}M</div>
      <p className="text-xs text-muted-foreground">{metrics.length} months tracked</p>
    </CardContent>
  </Card>

  <Card>
    <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
      <CardTitle className="text-sm font-medium">Target Period</CardTitle>
      <Calendar className="h-4 w-4 text-muted-foreground" />
    </CardHeader>
    <CardContent>
      <div className="text-2xl font-bold">
        {new Date(firstPeriod).toLocaleDateString("en-US", { month: "short", year: "2-digit" })} →{" "}
        {new Date(lastPeriod).toLocaleDateString("en-US", { month: "short", year: "2-digit" })}
      </div>
      <p className="text-xs text-muted-foreground">Date range</p>
    </CardContent>
  </Card>
</div>;
Note: Don’t forget to import the icons at the top of your file:
import { DollarSign, TrendingUp, Calendar } from "lucide-react";

Key Takeaways

  1. OxyProvider with Fallbacks: Use loadingFallback and errorFallback props for graceful loading
  2. BigInt-Safe Logging: Handle BigInt values from DuckDB properly
  3. Epoch Date Handling: Convert DuckDB epoch objects to readable dates
  4. Robust Value Parsing: Check multiple columns and data types for values
  5. Error Boundaries: Catch and display errors gracefully
  6. Global Error Handler: Catch unhandled promise rejections
  7. Show Real Errors: Don’t hide connection problems with fake data
This is the foundation - once you understand this pattern, you can build more complex dashboards!