Replace threshold placeholder link with a real settings panel
Adds a "Loss thresholds" panel to the popup backed by the backend's new GET/PATCH /me/settings endpoints, converting between the wire's fractional percentages and whole-number form inputs only at that boundary. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01RTorFQXMR9ZQtCKcMTXLHp
This commit is contained in:
@@ -1,7 +1,14 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { browser } from 'wxt/browser';
|
||||
import { DEFAULT_CONNECTION_STATUS, DEFAULT_LOCK_STATE, type ConnectionStatus, type LockState } from '@/shared/types';
|
||||
import type { NexaMessage } from '@/shared/messaging';
|
||||
import {
|
||||
DEFAULT_CONNECTION_STATUS,
|
||||
DEFAULT_LOCK_STATE,
|
||||
DEFAULT_THRESHOLD_SETTINGS,
|
||||
type ConnectionStatus,
|
||||
type LockState,
|
||||
type ThresholdSettings,
|
||||
} from '@/shared/types';
|
||||
import type { NexaMessage, SettingsResult } from '@/shared/messaging';
|
||||
|
||||
const CONNECTION_LABELS: Record<ConnectionStatus, string> = {
|
||||
connecting: 'Connecting…',
|
||||
@@ -78,13 +85,193 @@ export function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<a
|
||||
className="thresholds-link"
|
||||
href="#"
|
||||
title="Threshold settings are not part of this milestone"
|
||||
>
|
||||
Adjust loss thresholds →
|
||||
</a>
|
||||
<ThresholdsPanel />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Percent-shaped fields (`near_full_exit_fraction`, both loss thresholds) are
|
||||
* edited here as whole numbers (e.g. "30") and converted to/from the
|
||||
* backend's fractional wire representation only at this boundary — same
|
||||
* "convert only at the seam" rule already applied to LockScope's kebab/snake
|
||||
* case split (see ../../CLAUDE.md).
|
||||
*/
|
||||
type ThresholdForm = {
|
||||
exitPercent: string;
|
||||
windowHours: string;
|
||||
streakLength: string;
|
||||
singleLossPercent: string;
|
||||
streakLossPercent: string;
|
||||
cooldownMinutes: string;
|
||||
};
|
||||
|
||||
function toForm(settings: ThresholdSettings): ThresholdForm {
|
||||
return {
|
||||
exitPercent: String(Math.round(settings.near_full_exit_fraction * 100)),
|
||||
windowHours: String(settings.loss_window_hours),
|
||||
streakLength: String(settings.streak_length),
|
||||
singleLossPercent: String(Math.round(-settings.single_trade_loss_threshold * 100)),
|
||||
streakLossPercent: String(Math.round(-settings.streak_sum_loss_threshold * 100)),
|
||||
cooldownMinutes: String(settings.cooldown_minutes),
|
||||
};
|
||||
}
|
||||
|
||||
function fromForm(form: ThresholdForm): Partial<ThresholdSettings> {
|
||||
return {
|
||||
near_full_exit_fraction: Number(form.exitPercent) / 100,
|
||||
loss_window_hours: Number(form.windowHours),
|
||||
streak_length: Number(form.streakLength),
|
||||
single_trade_loss_threshold: -Number(form.singleLossPercent) / 100,
|
||||
streak_sum_loss_threshold: -Number(form.streakLossPercent) / 100,
|
||||
cooldown_minutes: Number(form.cooldownMinutes),
|
||||
};
|
||||
}
|
||||
|
||||
function ThresholdsPanel() {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [form, setForm] = useState<ThresholdForm>(() => toForm(DEFAULT_THRESHOLD_SETTINGS));
|
||||
const [status, setStatus] = useState<'idle' | 'loading' | 'saving'>('idle');
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
function expand(): void {
|
||||
setExpanded(true);
|
||||
if (status !== 'idle') return;
|
||||
setStatus('loading');
|
||||
setError(undefined);
|
||||
browser.runtime
|
||||
.sendMessage({ type: 'nexa:get-settings' } satisfies NexaMessage)
|
||||
.then((result: SettingsResult | undefined) => {
|
||||
if (result && 'settings' in result) {
|
||||
setForm(toForm(result.settings));
|
||||
setStatus('idle');
|
||||
} else {
|
||||
setError(result?.error ?? 'Failed to load settings.');
|
||||
setStatus('idle');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function field(key: keyof ThresholdForm, value: string): void {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
}
|
||||
|
||||
function save(): void {
|
||||
setStatus('saving');
|
||||
setError(undefined);
|
||||
browser.runtime
|
||||
.sendMessage({ type: 'nexa:update-settings', settings: fromForm(form) } satisfies NexaMessage)
|
||||
.then((result: SettingsResult | undefined) => {
|
||||
setStatus('idle');
|
||||
if (result && 'settings' in result) {
|
||||
setForm(toForm(result.settings));
|
||||
} else {
|
||||
setError(result?.error ?? 'Failed to save settings.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!expanded) {
|
||||
return (
|
||||
<button type="button" className="thresholds-toggle" onClick={expand}>
|
||||
Adjust loss thresholds →
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="thresholds-panel">
|
||||
<h2>Loss thresholds</h2>
|
||||
|
||||
<label>
|
||||
Exit counts as "full" at
|
||||
<div className="threshold-input">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={form.exitPercent}
|
||||
onChange={(e) => field('exitPercent', e.target.value)}
|
||||
/>
|
||||
%
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Streak window
|
||||
<div className="threshold-input">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={168}
|
||||
value={form.windowHours}
|
||||
onChange={(e) => field('windowHours', e.target.value)}
|
||||
/>
|
||||
hours
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Streak length
|
||||
<div className="threshold-input">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
value={form.streakLength}
|
||||
onChange={(e) => field('streakLength', e.target.value)}
|
||||
/>
|
||||
trades
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Single-trade loss locks at
|
||||
<div className="threshold-input">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={form.singleLossPercent}
|
||||
onChange={(e) => field('singleLossPercent', e.target.value)}
|
||||
/>
|
||||
%
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Streak cumulative loss locks at
|
||||
<div className="threshold-input">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={form.streakLossPercent}
|
||||
onChange={(e) => field('streakLossPercent', e.target.value)}
|
||||
/>
|
||||
%
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Auto-unlock after
|
||||
<div className="threshold-input">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={1440}
|
||||
value={form.cooldownMinutes}
|
||||
onChange={(e) => field('cooldownMinutes', e.target.value)}
|
||||
/>
|
||||
minutes
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{error && <p className="thresholds-error">{error}</p>}
|
||||
|
||||
<button type="button" className="sign-out-button" onClick={save} disabled={status === 'saving'}>
|
||||
{status === 'saving' ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user