Add settings page redesign implementation plan
This commit is contained in:
@@ -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"
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user