Add settings page redesign implementation plan

This commit is contained in:
2026-05-16 20:40:04 +02:00
parent 14cee9c16a
commit 2f1fe5b39a
@@ -0,0 +1,960 @@
# 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:
```typescript
// 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**
```bash
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**
```typescript
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**
```bash
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**
```typescript
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**
```bash
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**
```typescript
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**
```bash
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**
```typescript
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**
```bash
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**
```typescript
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**
```bash
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`:
```typescript
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**
```bash
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:
```typescript
// 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**
```bash
git add app/lib/settings.server.ts
git commit -m "fix: use shared db instance in settingsService to resolve Prisma type errors"
```