Files
AITrader/docs/superpowers/plans/2026-05-16-settings-page-redesign.md
T

32 KiB

Settings Page Redesign Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Replace the bare-bones settings page with a structured dashboard featuring sidebar navigation, typed LLM/trading settings, and an editable stock database table.

Architecture: Client-side SPA using useEffect to fetch settings and stocks on mount. Settings stored as structured keys in existing AppSetting table. Saves via existing PUT /api/admin/settings/:key endpoint with optimistic UI updates. Stock notes saved via POST /api/stocks.

Tech Stack: React Router 7, TypeScript, TailwindCSS, Prisma (SQLite)


Task 1: Extend stocks API to support notes field

Files:

  • Modify: app/routes/api/stocks/index.ts

  • Test: tests/stock-db.spec.ts

  • Step 1: Add notes to the stocks API action

The stocks API currently does not save or return the notes field. Add it to the upsert:

// In app/routes/api/stocks/index.ts, add notes extraction after lastJobId:
const notes = formData.get("notes")?.toString();

// Update the upsert to include notes in both update and create:
const stock = await db.stock.upsert({
  where: { ticker },
  update: {
    lastDecision: lastDecision ?? undefined,
    lastExplanation: lastExplanation ?? undefined,
    lastExecutionPlan: lastExecutionPlan ?? undefined,
    lastJobId: lastJobId ?? undefined,
    notes: notes ?? undefined,
  },
  create: {
    ticker,
    lastDecision: lastDecision ?? undefined,
    lastExplanation: lastExplanation ?? undefined,
    lastExecutionPlan: lastExecutionPlan ?? undefined,
    lastJobId: lastJobId ?? undefined,
    notes: notes ?? undefined,
  },
});
  • Step 2: Run Playwright test to verify stock DB still works

Run: npx playwright test tests/stock-db.spec.ts Expected: PASS (existing tests should still pass since we're only adding a field)

  • Step 3: Commit
git add app/routes/api/stocks/index.ts
git commit -m "feat: add notes field to stocks API upsert"

Task 2: Create SettingsSidebar component

Files:

  • Create: app/components/SettingsSidebar.tsx

  • Step 1: Create the SettingsSidebar component

import React from "react";

export type SettingsSection = "llm" | "trading" | "stocks" | "system";

interface SettingsSidebarProps {
  activeSection: SettingsSection;
  onSectionChange: (section: SettingsSection) => void;
}

const sections: { id: SettingsSection; label: string; icon: string }[] = [
  { id: "llm", label: "LLM & Agents", icon: "🧠" },
  { id: "trading", label: "Trading Defaults", icon: "📊" },
  { id: "stocks", label: "Stock Database", icon: "📋" },
  { id: "system", label: "System", icon: "⚙️" },
];

export default function SettingsSidebar({ activeSection, onSectionChange }: SettingsSidebarProps) {
  return (
    <aside className="w-60 border-r border-gray-200 bg-white h-full sticky top-0 overflow-y-auto">
      <div className="p-6">
        <h2 className="text-lg font-bold text-gray-900 mb-6">Settings</h2>
        <nav className="space-y-1">
          {sections.map((section) => (
            <button
              key={section.id}
              onClick={() => onSectionChange(section.id)}
              className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors ${
                activeSection === section.id
                  ? "bg-blue-50 text-blue-700 border-l-4 border-blue-600"
                  : "text-gray-700 hover:bg-gray-50"
              }`}
            >
              <span>{section.icon}</span>
              <span>{section.label}</span>
            </button>
          ))}
        </nav>
      </div>
    </aside>
  );
}
  • Step 2: Verify TypeScript compiles

Run: npx tsc --noEmit app/components/SettingsSidebar.tsx Expected: No errors

  • Step 3: Commit
git add app/components/SettingsSidebar.tsx
git commit -m "feat: add SettingsSidebar component with section navigation"

Task 3: Create LlmSettings component

Files:

  • Create: app/components/LlmSettings.tsx

  • Step 1: Create the LlmSettings component

import React, { useState, useEffect } from "react";

interface LlmSettingsProps {
  settings: Record<string, any>;
  onSave: (key: string, value: any) => Promise<void>;
  saveError: string | null;
}

const AVAILABLE_MODELS = [
  "openai/gpt-oss-120b:free",
  "openrouter/free",
  "deepseek/deepseek-chat:free",
  "meta/llama-3.3-70b-instruct:free",
];

export default function LlmSettings({ settings, onSave, saveError }: LlmSettingsProps) {
  const [model, setModel] = useState(settings["llm.model"] ?? "openai/gpt-oss-120b:free");
  const [temperature, setTemperature] = useState(settings["llm.temperature"] ?? 0.7);
  const [maxDebateRounds, setMaxDebateRounds] = useState(settings["llm.maxDebateRounds"] ?? 3);

  useEffect(() => {
    setModel(settings["llm.model"] ?? "openai/gpt-oss-120b:free");
    setTemperature(settings["llm.temperature"] ?? 0.7);
    setMaxDebateRounds(settings["llm.maxDebateRounds"] ?? 3);
  }, [settings]);

  const saveModel = async (value: string) => {
    setModel(value);
    await onSave("llm.model", value);
  };

  const saveTemperature = async (value: number) => {
    setTemperature(value);
    await onSave("llm.temperature", value);
  };

  const saveMaxDebateRounds = async (value: number) => {
    const clamped = Math.min(10, Math.max(1, value));
    setMaxDebateRounds(clamped);
    await onSave("llm.maxDebateRounds", clamped);
  };

  return (
    <div className="space-y-6">
      <div>
        <h2 className="text-xl font-bold text-gray-900">LLM & Agents</h2>
        <p className="text-sm text-gray-600 mt-1">Configure the language model and agent behavior for trading analysis.</p>
      </div>

      {saveError && (
        <div className="bg-red-50 text-red-700 px-4 py-2 rounded-lg text-sm">{saveError}</div>
      )}

      <div className="space-y-4">
        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">Model</label>
          <select
            value={model}
            onChange={(e) => saveModel(e.target.value)}
            className="w-full border border-gray-300 rounded-lg px-4 py-2.5 text-gray-900 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
          >
            {AVAILABLE_MODELS.map((m) => (
              <option key={m} value={m}>{m}</option>
            ))}
          </select>
        </div>

        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">
            Temperature: {temperature.toFixed(1)}
          </label>
          <input
            type="range"
            min="0"
            max="2"
            step="0.1"
            value={temperature}
            onChange={(e) => saveTemperature(parseFloat(e.target.value))}
            className="w-full"
          />
          <div className="flex justify-between text-xs text-gray-500">
            <span>0.0 (deterministic)</span>
            <span>2.0 (creative)</span>
          </div>
        </div>

        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">Max Debate Rounds</label>
          <input
            type="number"
            min="1"
            max="10"
            value={maxDebateRounds}
            onChange={(e) => saveMaxDebateRounds(parseInt(e.target.value) || 1)}
            className="w-32 border border-gray-300 rounded-lg px-4 py-2.5 text-gray-900 focus:ring-2 focus:ring-blue-500"
          />
        </div>
      </div>
    </div>
  );
}
  • Step 2: Commit
git add app/components/LlmSettings.tsx
git commit -m "feat: add LlmSettings component with model, temperature, debate rounds"

Task 4: Create TradingSettings component

Files:

  • Create: app/components/TradingSettings.tsx

  • Step 1: Create the TradingSettings component

import React, { useState, useEffect } from "react";

interface TradingSettingsProps {
  settings: Record<string, any>;
  onSave: (key: string, value: any) => Promise<void>;
  saveError: string | null;
}

export default function TradingSettings({ settings, onSave, saveError }: TradingSettingsProps) {
  const [maxLossPercent, setMaxLossPercent] = useState(settings["trading.maxLossPercent"] ?? 2);
  const [positionSizePercent, setPositionSizePercent] = useState(settings["trading.positionSizePercent"] ?? 10);
  const [takeProfitPercent, setTakeProfitPercent] = useState(settings["trading.takeProfitPercent"] ?? 5);
  const [stopLossPercent, setStopLossPercent] = useState(settings["trading.stopLossPercent"] ?? 3);
  const [riskMethod, setRiskMethod] = useState(settings["trading.riskMethod"] ?? "percentage");

  useEffect(() => {
    setMaxLossPercent(settings["trading.maxLossPercent"] ?? 2);
    setPositionSizePercent(settings["trading.positionSizePercent"] ?? 10);
    setTakeProfitPercent(settings["trading.takeProfitPercent"] ?? 5);
    setStopLossPercent(settings["trading.stopLossPercent"] ?? 3);
    setRiskMethod(settings["trading.riskMethod"] ?? "percentage");
  }, [settings]);

  const save = async (key: string, value: any) => {
    await onSave(key, value);
  };

  return (
    <div className="space-y-6">
      <div>
        <h2 className="text-xl font-bold text-gray-900">Trading Defaults</h2>
        <p className="text-sm text-gray-600 mt-1">Default risk management and position sizing parameters.</p>
      </div>

      {saveError && (
        <div className="bg-red-50 text-red-700 px-4 py-2 rounded-lg text-sm">{saveError}</div>
      )}

      <div className="space-y-4">
        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">Max Loss %</label>
          <input
            type="number"
            min="0.1"
            max="100"
            step="0.1"
            value={maxLossPercent}
            onChange={(e) => {
              const v = parseFloat(e.target.value) || 0;
              setMaxLossPercent(v);
              save("trading.maxLossPercent", v);
            }}
            className="w-32 border border-gray-300 rounded-lg px-4 py-2.5 text-gray-900 focus:ring-2 focus:ring-blue-500"
          />
          <p className="text-xs text-gray-500 mt-1">Maximum portfolio percentage to risk on a single trade.</p>
        </div>

        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">Position Size %</label>
          <input
            type="number"
            min="1"
            max="100"
            step="1"
            value={positionSizePercent}
            onChange={(e) => {
              const v = parseInt(e.target.value) || 1;
              setPositionSizePercent(v);
              save("trading.positionSizePercent", v);
            }}
            className="w-32 border border-gray-300 rounded-lg px-4 py-2.5 text-gray-900 focus:ring-2 focus:ring-blue-500"
          />
          <p className="text-xs text-gray-500 mt-1">Default position size as percentage of available cash.</p>
        </div>

        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">Take Profit %</label>
          <input
            type="number"
            min="0.1"
            max="100"
            step="0.1"
            value={takeProfitPercent}
            onChange={(e) => {
              const v = parseFloat(e.target.value) || 0;
              setTakeProfitPercent(v);
              save("trading.takeProfitPercent", v);
            }}
            className="w-32 border border-gray-300 rounded-lg px-4 py-2.5 text-gray-900 focus:ring-2 focus:ring-blue-500"
          />
          <p className="text-xs text-gray-500 mt-1">Target profit percentage for auto take-profit orders.</p>
        </div>

        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">Stop Loss %</label>
          <input
            type="number"
            min="0.1"
            max="100"
            step="0.1"
            value={stopLossPercent}
            onChange={(e) => {
              const v = parseFloat(e.target.value) || 0;
              setStopLossPercent(v);
              save("trading.stopLossPercent", v);
            }}
            className="w-32 border border-gray-300 rounded-lg px-4 py-2.5 text-gray-900 focus:ring-2 focus:ring-blue-500"
          />
          <p className="text-xs text-gray-500 mt-1">Stop loss percentage below entry price.</p>
        </div>

        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">Risk Method</label>
          <select
            value={riskMethod}
            onChange={(e) => {
              setRiskMethod(e.target.value);
              save("trading.riskMethod", e.target.value);
            }}
            className="w-full border border-gray-300 rounded-lg px-4 py-2.5 text-gray-900 focus:ring-2 focus:ring-blue-500"
          >
            <option value="fixed">Fixed amount</option>
            <option value="percentage">Percentage of portfolio</option>
            <option value="atr">ATR-based (Average True Range)</option>
          </select>
        </div>
      </div>
    </div>
  );
}
  • Step 2: Commit
git add app/components/TradingSettings.tsx
git commit -m "feat: add TradingSettings component with risk management defaults"

Task 5: Create StockTable component

Files:

  • Create: app/components/StockTable.tsx

  • Step 1: Create the StockTable component

import React, { useState, useMemo } from "react";

interface Stock {
  id: string;
  ticker: string;
  notes: string | null;
  lastDecision: string | null;
  lastJobId: string | null;
  createdAt: string;
  updatedAt: string;
}

interface StockTableProps {
  stocks: Stock[];
  onNotesSave: (ticker: string, notes: string) => Promise<void>;
  saveError: string | null;
}

type SortField = "ticker" | "createdAt" | "updatedAt";
type SortDirection = "asc" | "desc";

export default function StockTable({ stocks, onNotesSave, saveError }: StockTableProps) {
  const [search, setSearch] = useState("");
  const [sortField, setSortField] = useState<SortField>("ticker");
  const [sortDirection, setSortDirection] = useState<SortDirection>("asc");
  const [editingTicker, setEditingTicker] = useState<string | null>(null);
  const [editingNotes, setEditingNotes] = useState("");
  const [savingNotes, setSavingNotes] = useState<string | null>(null);
  const [page, setPage] = useState(0);
  const pageSize = 20;

  const filtered = useMemo(() => {
    const q = search.toLowerCase();
    const result = stocks.filter((s) => s.ticker.toLowerCase().includes(q));
    result.sort((a, b) => {
      const aVal = a[sortField] ?? "";
      const bVal = b[sortField] ?? "";
      const cmp = aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
      return sortDirection === "asc" ? cmp : -cmp;
    });
    return result;
  }, [stocks, search, sortField, sortDirection]);

  const totalPages = Math.ceil(filtered.length / pageSize);
  const paged = filtered.slice(page * pageSize, (page + 1) * pageSize);

  const handleSort = (field: SortField) => {
    if (sortField === field) {
      setSortDirection((d) => (d === "asc" ? "desc" : "asc"));
    } else {
      setSortField(field);
      setSortDirection("asc");
    }
  };

  const startEditing = (stock: Stock) => {
    setEditingTicker(stock.ticker);
    setEditingNotes(stock.notes ?? "");
  };

  const saveNotes = async () => {
    if (!editingTicker) return;
    setSavingNotes(editingTicker);
    try {
      await onNotesSave(editingTicker, editingNotes);
    } catch (e) {
      console.error("Failed to save notes:", e);
    } finally {
      setSavingNotes(null);
      setEditingTicker(null);
    }
  };

  const SortHeader = ({ field, children }: { field: SortField; children: React.ReactNode }) => (
    <th
      className="text-left py-2 px-3 font-medium text-gray-700 cursor-pointer hover:text-gray-900 select-none"
      onClick={() => handleSort(field)}
    >
      {children}
      {sortField === field && <span className="ml-1">{sortDirection === "asc" ? "↑" : "↓"}</span>}
    </th>
  );

  if (stocks.length === 0) {
    return (
      <div className="space-y-6">
        <div>
          <h2 className="text-xl font-bold text-gray-900">Stock Database</h2>
          <p className="text-sm text-gray-600 mt-1">Manage tracked stocks and their analysis notes.</p>
        </div>
        <p className="text-gray-500 py-8">No stocks tracked yet. Visit the stocks page to add some.</p>
      </div>
    );
  }

  return (
    <div className="space-y-6">
      <div>
        <h2 className="text-xl font-bold text-gray-900">Stock Database</h2>
        <p className="text-sm text-gray-600 mt-1">Manage tracked stocks and their analysis notes.</p>
      </div>

      {saveError && (
        <div className="bg-red-50 text-red-700 px-4 py-2 rounded-lg text-sm">{saveError}</div>
      )}

      <div className="flex items-center gap-4">
        <input
          type="text"
          placeholder="Search by ticker..."
          value={search}
          onChange={(e) => { setSearch(e.target.value); setPage(0); }}
          className="border border-gray-300 rounded-lg px-4 py-2.5 w-64 focus:ring-2 focus:ring-blue-500"
        />
        <span className="text-sm text-gray-500">{filtered.length} stock{filtered.length !== 1 ? "s" : ""}</span>
      </div>

      <div className="overflow-x-auto">
        <table className="w-full text-sm">
          <thead>
            <tr className="border-b border-gray-200">
              <SortHeader field="ticker">Ticker</SortHeader>
              <th className="text-left py-2 px-3 font-medium text-gray-700">Notes</th>
              <th className="text-left py-2 px-3 font-medium text-gray-700">Last Decision</th>
              <th className="text-left py-2 px-3 font-medium text-gray-700">Last Job</th>
              <SortHeader field="createdAt">Created</SortHeader>
              <SortHeader field="updatedAt">Updated</SortHeader>
            </tr>
          </thead>
          <tbody>
            {paged.map((stock) => (
              <tr key={stock.ticker} className="border-b border-gray-100 hover:bg-gray-50">
                <td className="py-2 px-3 font-medium text-gray-900">{stock.ticker}</td>
                <td className="py-2 px-3">
                  {editingTicker === stock.ticker ? (
                    <input
                      type="text"
                      value={editingNotes}
                      onChange={(e) => setEditingNotes(e.target.value)}
                      onBlur={saveNotes}
                      onKeyDown={(e) => { if (e.key === "Enter") saveNotes(); if (e.key === "Escape") setEditingTicker(null); }}
                      className="w-full border border-blue-300 rounded px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500"
                      autoFocus
                    />
                  ) : (
                    <span
                      className="text-gray-600 cursor-pointer hover:text-gray-900 block py-1"
                      onClick={() => startEditing(stock)}
                    >
                      {stock.notes || <span className="text-gray-400 italic">Click to add notes...</span>}
                    </span>
                  )}
                </td>
                <td className="py-2 px-3">
                  {stock.lastDecision ? (
                    <span className={
                      stock.lastDecision === "buy" ? "text-green-600 font-medium" :
                      stock.lastDecision === "sell" ? "text-red-600 font-medium" : "text-gray-600"
                    }>{stock.lastDecision.toUpperCase()}</span>
                  ) : "-"}
                </td>
                <td className="py-2 px-3 text-gray-600">
                  {stock.lastJobId ? (
                    <span className="text-xs font-mono bg-gray-100 px-2 py-1 rounded">{stock.lastJobId.slice(0, 12)}...</span>
                  ) : "-"}
                </td>
                <td className="py-2 px-3 text-gray-600">{new Date(stock.createdAt).toLocaleDateString()}</td>
                <td className="py-2 px-3 text-gray-600">{new Date(stock.updatedAt).toLocaleDateString()}</td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>

      {filtered.length === 0 && (
        <p className="text-gray-500 text-center py-4">No stocks found matching "{search}"</p>
      )}

      {totalPages > 1 && (
        <div className="flex items-center justify-between">
          <span className="text-sm text-gray-600">
            Showing {page * pageSize + 1}-{Math.min((page + 1) * pageSize, filtered.length)} of {filtered.length}
          </span>
          <div className="flex gap-2">
            <button
              onClick={() => setPage((p) => Math.max(0, p - 1))}
              disabled={page === 0}
              className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm disabled:opacity-50 hover:bg-gray-50"
            >
              Previous
            </button>
            <button
              onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
              disabled={page >= totalPages - 1}
              className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm disabled:opacity-50 hover:bg-gray-50"
            >
              Next
            </button>
          </div>
        </div>
      )}
    </div>
  );
}
  • Step 2: Commit
git add app/components/StockTable.tsx
git commit -m "feat: add StockTable component with search, sort, pagination, inline editing"

Task 6: Create SystemSettings component

Files:

  • Create: app/components/SystemSettings.tsx

  • Step 1: Create the SystemSettings component

import React, { useState, useEffect } from "react";

interface SystemSettingsProps {
  settings: Record<string, any>;
  alpacaMode: string | null;
  onSave: (key: string, value: any) => Promise<void>;
  saveError: string | null;
}

const KNOWN_KEYS = new Set([
  "llm.model", "llm.temperature", "llm.maxDebateRounds",
  "trading.maxLossPercent", "trading.positionSizePercent",
  "trading.takeProfitPercent", "trading.stopLossPercent", "trading.riskMethod",
]);

export default function SystemSettings({ settings, alpacaMode, onSave, saveError }: SystemSettingsProps) {
  const [rawSettings, setRawSettings] = useState<Array<{ key: string; value: string }>>([]);

  useEffect(() => {
    const others = Object.entries(settings)
      .filter(([key]) => !KNOWN_KEYS.has(key))
      .map(([key, value]) => ({
        key,
        value: typeof value === "string" ? value : JSON.stringify(value, null, 2),
      }));
    setRawSettings(others);
  }, [settings]);

  const handleRawSave = async (key: string, newValue: string) => {
    try {
      const parsed = JSON.parse(newValue);
      await onSave(key, parsed);
    } catch {
      await onSave(key, newValue);
    }
  };

  return (
    <div className="space-y-6">
      <div>
        <h2 className="text-xl font-bold text-gray-900">System</h2>
        <p className="text-sm text-gray-600 mt-1">System configuration and environment info.</p>
      </div>

      {saveError && (
        <div className="bg-red-50 text-red-700 px-4 py-2 rounded-lg text-sm">{saveError}</div>
      )}

      <div className="space-y-4">
        <div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
          <h3 className="text-sm font-medium text-gray-700 mb-2">Alpaca Trading API</h3>
          <div className="flex items-center gap-3">
            <span className="text-sm text-gray-600">Mode:</span>
            <span className={`px-3 py-1 rounded-full text-sm font-medium ${
              alpacaMode === "live" ? "bg-red-100 text-red-700" : "bg-green-100 text-green-700"
            }`}>
              {alpacaMode === "live" ? "Live Trading" : "Paper Trading"}
            </span>
          </div>
        </div>

        {rawSettings.length > 0 && (
          <div>
            <h3 className="text-sm font-medium text-gray-700 mb-3">Additional Settings</h3>
            <div className="space-y-3">
              {rawSettings.map((setting) => (
                <div key={setting.key} className="flex items-start gap-4">
                  <div className="font-mono text-sm text-gray-600 w-48 shrink-0 pt-2">{setting.key}</div>
                  <textarea
                    className="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono focus:ring-2 focus:ring-blue-500"
                    rows={2}
                    defaultValue={setting.value}
                    onBlur={(e) => {
                      if (e.target.value !== setting.value) {
                        handleRawSave(setting.key, e.target.value);
                      }
                    }}
                  />
                </div>
              ))}
            </div>
          </div>
        )}

        {rawSettings.length === 0 && (
          <p className="text-sm text-gray-500">No additional settings configured.</p>
        )}
      </div>
    </div>
  );
}
  • Step 2: Commit
git add app/components/SystemSettings.tsx
git commit -m "feat: add SystemSettings component with Alpaca mode and raw settings"

Task 7: Rewrite settings.tsx page to wire everything together

Files:

  • Modify: app/routes/settings.tsx (complete rewrite)

  • Step 1: Rewrite the settings page

Replace the entire contents of app/routes/settings.tsx:

import React, { useEffect, useState } from "react";
import Navbar from "../components/Navbar";
import SettingsSidebar, { SettingsSection } from "../components/SettingsSidebar";
import LlmSettings from "../components/LlmSettings";
import TradingSettings from "../components/TradingSettings";
import StockTable from "../components/StockTable";
import SystemSettings from "../components/SystemSettings";

interface Stock {
  id: string;
  ticker: string;
  notes: string | null;
  lastDecision: string | null;
  lastJobId: string | null;
  createdAt: string;
  updatedAt: string;
}

export default function SettingsPage() {
  const [activeSection, setActiveSection] = useState<SettingsSection>("llm");
  const [settings, setSettings] = useState<Record<string, any>>({});
  const [stocks, setStocks] = useState<Stock[]>([]);
  const [loading, setLoading] = useState(true);
  const [saveError, setSaveError] = useState<string | null>(null);
  const [alpacaMode, setAlpacaMode] = useState<string | null>(null);

  useEffect(() => {
    Promise.all([
      fetch("/api/admin/settings").then((r) => r.json()),
      fetch("/api/stocks").then((r) => r.json()),
      fetch("/api/alpaca/account").then((r) => r.ok ? r.json() : null),
    ]).then(([settingsData, stocksData, accountData]) => {
      const settingsMap: Record<string, any> = {};
      if (Array.isArray(settingsData)) {
        settingsData.forEach((s: { key: string; value: any }) => {
          settingsMap[s.key] = s.value;
        });
      }
      setSettings(settingsMap);
      if (Array.isArray(stocksData)) setStocks(stocksData);
      if (accountData?.trading?.paper !== undefined) {
        setAlpacaMode(accountData.trading.paper ? "paper" : "live");
      }
      setLoading(false);
    }).catch((err) => {
      console.error("Failed to load settings:", err);
      setLoading(false);
    });
  }, []);

  const saveSetting = async (key: string, value: any) => {
    setSaveError(null);
    const prevValue = settings[key];
    setSettings((s) => ({ ...s, [key]: value }));
    try {
      const res = await fetch(`/api/admin/settings/${encodeURIComponent(key)}`, {
        method: "PUT",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ value }),
      });
      if (!res.ok) {
        throw new Error(`Failed to save ${key}`);
      }
    } catch (err) {
      setSettings((s) => ({ ...s, [key]: prevValue }));
      setSaveError(err instanceof Error ? err.message : "Save failed");
    }
  };

  const saveStockNotes = async (ticker: string, notes: string) => {
    setSaveError(null);
    const prevStocks = [...stocks];
    setStocks((s) => s.map((st) => (st.ticker === ticker ? { ...st, notes } : st)));
    try {
      const fd = new FormData();
      fd.append("ticker", ticker);
      fd.append("notes", notes);
      const res = await fetch("/api/stocks", { method: "POST", body: fd });
      if (!res.ok) {
        throw new Error("Failed to save notes");
      }
    } catch (err) {
      setStocks(prevStocks);
      setSaveError(err instanceof Error ? err.message : "Save failed");
    }
  };

  if (loading) {
    return (
      <div className="min-h-screen bg-gradient-to-br from-gray-50 to-blue-50">
        <Navbar />
        <div className="flex items-center justify-center h-64">
          <div className="text-gray-500">Loading settings...</div>
        </div>
      </div>
    );
  }

  const renderSection = () => {
    switch (activeSection) {
      case "llm":
        return <LlmSettings settings={settings} onSave={saveSetting} saveError={saveError} />;
      case "trading":
        return <TradingSettings settings={settings} onSave={saveSetting} saveError={saveError} />;
      case "stocks":
        return <StockTable stocks={stocks} onNotesSave={saveStockNotes} saveError={saveError} />;
      case "system":
        return <SystemSettings settings={settings} alpacaMode={alpacaMode} onSave={saveSetting} saveError={saveError} />;
      default:
        return null;
    }
  };

  return (
    <div className="min-h-screen bg-gradient-to-br from-gray-50 to-blue-50">
      <Navbar />
      <div className="mx-auto max-w-7xl px-6 sm:px-8 lg:px-8 py-8">
        <div className="bg-white rounded-xl shadow-lg border border-gray-200 overflow-hidden">
          <div className="flex min-h-[600px]">
            <SettingsSidebar activeSection={activeSection} onSectionChange={setActiveSection} />
            <main className="flex-1 p-6 overflow-y-auto">
              {renderSection()}
            </main>
          </div>
        </div>
      </div>
    </div>
  );
}
  • Step 2: Run typecheck to verify everything compiles

Run: npm run typecheck Expected: Only pre-existing errors (settings.server.ts admin routes), no new errors from settings.tsx

  • Step 3: Run dev server and verify page loads

Run: npm run dev Open: http://localhost:5173/settings Expected: Page loads with sidebar navigation, LLM & Agents section visible by default

  • Step 4: Commit
git add app/routes/settings.tsx
git commit -m "feat: rewrite settings page with sidebar navigation and structured sections"

Task 8: Fix settings.server.ts Prisma type issue

Files:

  • Modify: app/lib/settings.server.ts

  • Step 1: Fix the PrismaClient singleton pattern

The settings.server.ts creates a new PrismaClient without the singleton pattern used in db.server.ts. This can cause issues. Update to use the shared db instance:

// app/lib/settings.server.ts
import { db } from "./db.server";
import EventEmitter from "events";

type JSONValue = any;

class SettingsService extends EventEmitter {
  private cache: Map<string, JSONValue> = new Map();
  private initialized = false;

  async init() {
    if (this.initialized) return;
    const rows = await db.appSetting.findMany();
    rows.forEach((r) => {
      try {
        this.cache.set(r.key, JSON.parse(r.value));
      } catch (e) {
        this.cache.set(r.key, r.value);
      }
    });
    this.initialized = true;
  }

  async get(key: string) {
    if (!this.initialized) await this.init();
    return this.cache.has(key) ? this.cache.get(key) : null;
  }

  async set(key: string, value: JSONValue, updatedBy?: string) {
    if (!this.initialized) await this.init();
    const valueStr = typeof value === "string" ? value : JSON.stringify(value);
    await db.appSetting.upsert({
      where: { key },
      update: { value: valueStr, updatedBy },
      create: { key, value: valueStr, updatedBy },
    });
    this.cache.set(key, value);
    this.emit("update", { key, value });
    return { key, value };
  }

  subscribe(fn: (payload: { key: string; value: any }) => void) {
    this.on("update", fn);
    return () => this.off("update", fn);
  }
}

export const settingsService = new SettingsService();
  • Step 2: Run typecheck

Run: npm run typecheck Expected: The Property 'appSetting' does not exist errors should be resolved (they were caused by the separate PrismaClient instance not being properly typed)

  • Step 3: Commit
git add app/lib/settings.server.ts
git commit -m "fix: use shared db instance in settingsService to resolve Prisma type errors"