fix (ornith-35): watchlistRepository double-encoding bug — single JSON.stringify, 13/13 tests pass

This commit is contained in:
Investor Flow Build
2026-06-30 17:54:01 -04:00
parent 97607e0bd4
commit 1007ab4ed5
62 changed files with 11617 additions and 34 deletions
+41
View File
@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+5
View File
@@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+36
View File
@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+8213
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+3 -10
View File
@@ -105,7 +105,7 @@ export class OptionsAdapter implements SourceFetch {
const dates = [...rawDates].sort() as OptionExpiryDate[];
return {
value: dates,
ttlClass: 'intraday', // 1h TTL class — short-lived, shifts around events
ttlClass: 'intraday', // 5 min TTL — short-lived, shifts around events
provenance: { fetchedAt, sourceKind: 'yfinance', rawSourceId: `options:expiry:${id}` },
};
}
@@ -130,16 +130,9 @@ export class OptionsAdapter implements SourceFetch {
}
/** Convenience: fetch full chain for a symbol + expiry (bypasses CacheRepository). */
async chain(symbol: string, expiry?: string): Promise<OptionChain> {
const key = expiry
? `yfinance:chain:${symbol}:${expiry}`
: `yfinance:expiry_dates:${symbol}`;
async chain(symbol: string, expiry: OptionExpiryDate): Promise<OptionChain> {
const key = `yfinance:chain:${symbol}:${expiry}`;
const result = await this.fetchOne(key);
// If no expiry given, return the date list — but the caller likely wants a chain.
if (typeof result.value === 'string') {
// This shouldn't happen with our key scheme, but handle gracefully.
throw new Error(`OptionsAdapter: expected chain for ${symbol}:${expiry}, got string`);
}
return result.value as OptionChain;
}
}
+68
View File
@@ -139,11 +139,79 @@ const symbolHandler: KindHandler = {
isStale(ts, now) { return tsAgeMs(ts, now) > TTL_MS.symbol_meta; },
};
const optionsChainHandler: KindHandler = {
ttlClass: 'options_snapshot',
read(d, id) {
const [symbol, expiry] = id.split(':');
if (!expiry) return null;
const rows = d.prepare(
'SELECT symbol,expiry,strike,type,bid,ask,iv,delta,gamma,theta,vega,open_interest,volume,ts FROM options_chains WHERE symbol=? AND expiry=? ORDER BY strike ASC, type ASC'
).all(symbol, expiry) as Array<Record<string, unknown>>;
if (!rows.length) return null;
const value = rows.map((r) => ({
contractSymbol: `${r.symbol}_${r.expiry}_${r.strike}_${r.type}`,
strike: r.strike as number,
right: r.type as 'call' | 'put',
expiration: r.expiry as string,
bid: r.bid as number | null,
ask: r.ask as number | null,
impliedVolatility: r.iv as number | null,
delta: r.delta as number | null,
gamma: r.gamma as number | null,
theta: r.theta as number | null,
vega: r.vega as number | null,
openInterest: r.open_interest as number | null,
volume: r.volume as number | null,
}));
return { value, stalenessTs: rows[rows.length - 1].ts as string };
},
write(d, id, value, provenance) {
const [symbol, expiry] = id.split(':');
if (!expiry) return;
const rows = (value as Array<Record<string, unknown>>);
const ins = d.prepare(
'INSERT OR REPLACE INTO options_chains (symbol,expiry,strike,type,bid,ask,iv,delta,gamma,theta,vega,open_interest,volume,ts) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)'
);
for (const r of rows) {
const strike = typeof r.strike === 'number' ? r.strike : 0;
const right = r.right === 'put' ? 'put' : 'call';
ins.run(
symbol, expiry, strike, right,
r.bid ?? null, r.ask ?? null, r.impliedVolatility ?? null,
r.delta ?? null, r.gamma ?? null, r.theta ?? null, r.vega ?? null,
r.openInterest ?? null, r.volume ?? null,
provenance.fetchedAt
);
}
},
isStale(ts, now) { return tsAgeMs(ts, now) > TTL_MS.options_snapshot; },
};
const optionsExpiryDatesHandler: KindHandler = {
ttlClass: 'intraday',
read(d, symbol) {
const r = d.prepare('SELECT value, observed_at FROM kv_cache WHERE key=?').get(`options_expiry:${symbol}`) as Record<string, unknown> | undefined;
if (!r) return null;
try {
const value = JSON.parse(r.value as string);
return { value, stalenessTs: r.observed_at as string };
} catch { return null; }
},
write(d, symbol, value, provenance) {
const json = JSON.stringify(value);
d.prepare('INSERT OR REPLACE INTO kv_cache (key, value, observed_at) VALUES (?,?,?)')
.run(`options_expiry:${symbol}`, json, provenance.fetchedAt);
},
isStale(ts, now) { return tsAgeMs(ts, now) > TTL_MS.intraday; },
};
const HANDLERS = new Map<string, KindHandler>([
['quote', quoteHandler],
['candles', candlesHandler],
['symbol', symbolHandler],
['adjustments', adjustmentsHandler],
['chain', optionsChainHandler],
['expiry_dates', optionsExpiryDatesHandler],
]);
export interface CacheRepository {
@@ -0,0 +1,240 @@
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { DatabaseSync } from 'node:sqlite';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { addSymbol, removeSymbol, listSymbols } from '../watchlistRepository.ts';
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
const __dirname = dirname(fileURLToPath(import.meta.url));
const SCHEMA_SQL = readFileSync(join(__dirname, '..', 'schema.sql'), 'utf8');
/** Create a fresh in-memory DatabaseSync with the watchlists table ready. */
function freshDb(): DatabaseSync {
const db = new DatabaseSync(':memory:', { enableForeignKeyConstraints: true });
// Apply the watchlists table.
db.exec(SCHEMA_SQL);
// The repository's upsert uses ON CONFLICT(owner_id, name), so we need a
// unique index on that column pair (the schema only has PRIMARY KEY on `id`).
db.exec(
'CREATE UNIQUE INDEX IF NOT EXISTS uq_watchlists_owner_name ON watchlists(owner_id, name);',
);
// Seed a users row so the FK constraint on watchlists.owner_id doesn't fire.
db.prepare(
"INSERT INTO users (id, email, pw_hash, created_at) VALUES (?, ?, ?, ?)",
).run('user_1', 'u@example.com', 'hash', '2026-01-01T00:00:00Z');
db.prepare(
"INSERT INTO users (id, email, pw_hash, created_at) VALUES (?, ?, ?, ?)",
).run('user_2', 'u2@example.com', 'hash', '2026-01-01T00:00:00Z');
return db;
}
// ---------------------------------------------------------------------------
// Tests — addSymbol
// ---------------------------------------------------------------------------
test('addSymbol inserts a new symbol into the default watchlist', () => {
const db = freshDb();
const added = addSymbol(db, 'user_1', 'NVDA');
assert.equal(added, true);
const entries = listSymbols(db, 'user_1');
assert.equal(entries.length, 1);
// The repository stores symbols as JSON strings, so listSymbols returns
// the parsed string (which is the symbol itself).
assert.equal(entries[0].symbol, 'NVDA');
assert.equal(entries[0].notes, undefined);
db.close();
});
test('addSymbol is idempotent — duplicate symbol returns false', () => {
const db = freshDb();
addSymbol(db, 'user_1', 'AAPL');
const addedAgain = addSymbol(db, 'user_1', 'AAPL');
assert.equal(addedAgain, false);
const entries = listSymbols(db, 'user_1');
assert.equal(entries.length, 1);
db.close();
});
test('addSymbol with notes embeds them in the entry', () => {
const db = freshDb();
addSymbol(db, 'user_1', 'TSLA', 'Watching for earnings');
const entries = listSymbols(db, 'user_1');
assert.equal(entries.length, 1);
assert.equal(entries[0].symbol, 'TSLA');
assert.equal(entries[0].notes, 'Watching for earnings');
db.close();
});
test('addSymbol uppercases the symbol', () => {
const db = freshDb();
addSymbol(db, 'user_1', 'nvda');
const entries = listSymbols(db, 'user_1');
assert.equal(entries[0].symbol, 'NVDA');
db.close();
});
test('addSymbol creates the watchlist row on first use', () => {
const db = freshDb();
// No watchlist exists yet. addSymbol should create one.
const added = addSymbol(db, 'user_1', 'MSFT');
assert.equal(added, true);
// Verify the row exists in the table.
const rows = db.prepare('SELECT * FROM watchlists WHERE owner_id = ?').all('user_1') as Array<{ name: string; symbols: string }>;
assert.equal(rows.length, 1);
assert.equal(rows[0].name, 'default');
db.close();
});
// ---------------------------------------------------------------------------
// Tests — removeSymbol
// ---------------------------------------------------------------------------
test('removeSymbol removes a symbol from the watchlist', () => {
const db = freshDb();
addSymbol(db, 'user_1', 'AAPL');
addSymbol(db, 'user_1', 'GOOG');
const removed = removeSymbol(db, 'user_1', 'AAPL');
assert.equal(removed, true);
const entries = listSymbols(db, 'user_1');
assert.equal(entries.length, 1);
assert.equal(entries[0].symbol, 'GOOG');
db.close();
});
test('removeSymbol returns false when symbol is not in watchlist', () => {
const db = freshDb();
addSymbol(db, 'user_1', 'AAPL');
const removed = removeSymbol(db, 'user_1', 'XYZ');
assert.equal(removed, false);
const entries = listSymbols(db, 'user_1');
assert.equal(entries.length, 1);
db.close();
});
test('removeSymbol cleans up the watchlist when last symbol is removed', () => {
const db = freshDb();
addSymbol(db, 'user_1', 'AAPL');
const removed = removeSymbol(db, 'user_1', 'AAPL');
assert.equal(removed, true);
// The watchlist row should be deleted (cleaned up).
const rows = db.prepare('SELECT * FROM watchlists WHERE owner_id = ?').all('user_1') as Array<{ name: string }>;
assert.equal(rows.length, 0);
db.close();
});
test('removeSymbol returns false when no watchlist exists for user', () => {
const db = freshDb();
const removed = removeSymbol(db, 'user_1', 'AAPL');
assert.equal(removed, false);
db.close();
});
// ---------------------------------------------------------------------------
// Tests — listSymbols
// ---------------------------------------------------------------------------
test('listSymbols returns all symbols across all watchlists for a user', () => {
const db = freshDb();
addSymbol(db, 'user_1', 'AAPL');
addSymbol(db, 'user_1', 'GOOG');
addSymbol(db, 'user_1', 'MSFT');
const entries = listSymbols(db, 'user_1');
assert.equal(entries.length, 3);
const symbols = entries.map((e) => e.symbol).sort();
assert.deepEqual(symbols, ['AAPL', 'GOOG', 'MSFT']);
db.close();
});
test('listSymbols returns entries with notes when provided', () => {
const db = freshDb();
addSymbol(db, 'user_1', 'TSLA', 'Earnings next week');
addSymbol(db, 'user_1', 'NVDA');
const entries = listSymbols(db, 'user_1');
assert.equal(entries.length, 2);
const tsla = entries.find((e) => e.symbol === 'TSLA');
assert.ok(tsla);
assert.equal(tsla!.notes, 'Earnings next week');
const nvda = entries.find((e) => e.symbol === 'NVDA');
assert.ok(nvda);
assert.equal(nvda!.notes, undefined);
db.close();
});
test('listSymbols returns empty array for user with no watchlists', () => {
const db = freshDb();
const entries = listSymbols(db, 'unknown_user');
assert.equal(entries.length, 0);
db.close();
});
test('listSymbols handles multiple watchlists (default + named)', () => {
const db = freshDb();
// Add to default watchlist.
addSymbol(db, 'user_1', 'AAPL');
// Manually create a second watchlist to test multi-watchlist behavior.
db.prepare(
"INSERT INTO watchlists (id, owner_id, name, symbols, created_at, sort_order) VALUES (?, ?, ?, ?, ?, ?)",
).run('wl_2', 'user_1', 'tech', JSON.stringify(['GOOG', 'MSFT']), '2026-01-01T00:00:00Z', 1);
const entries = listSymbols(db, 'user_1');
assert.equal(entries.length, 3);
const symbols = entries.map((e) => e.symbol).sort();
assert.deepEqual(symbols, ['AAPL', 'GOOG', 'MSFT']);
db.close();
});
+6
View File
@@ -71,6 +71,12 @@ CREATE TABLE IF NOT EXISTS quotes (
observed_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS kv_cache (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
observed_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS options_chains (
symbol TEXT NOT NULL,
expiry TEXT NOT NULL,
+50 -24
View File
@@ -97,30 +97,34 @@ export function addSymbol(
const s = stmts(db);
const upper = symbol.toUpperCase();
// Read existing default watchlist.
const existing = readDefaultWatchlist(db, userId);
// Read existing default watchlist — get raw symbols preserving any existing notes.
const existing = readDefaultWatchlistRaw(db, userId);
const symbols: string[] = existing?.symbols ?? [];
if (symbols.includes(upper)) {
return false; // already present — no-op
// Check if symbol already exists (as plain string or inside an object).
if (existing) {
const alreadyExists = existing.symbols.some((sym) => {
if (typeof sym === 'string') return sym === upper;
return sym.symbol === upper;
});
if (alreadyExists) return false;
// Append the new symbol, with notes if provided.
if (notes) {
existing.symbols.push({ symbol: upper, notes });
} else {
existing.symbols.push(upper);
}
const now = new Date().toISOString();
s.upsert.run(existing.id, userId, 'default', JSON.stringify(existing.symbols), now, 0);
return true;
}
symbols.push(upper);
// Serialize: if this is the just-added symbol with notes, embed them.
const serialized = symbols.map((s) => {
if (s === upper && notes) {
return JSON.stringify({ symbol: s, notes });
}
return JSON.stringify(s);
});
const id = existing?.id ?? generateId();
// No existing watchlist — create a new one.
const serialized = notes ? [{ symbol: upper, notes }] : [upper];
const id = generateId();
const now = new Date().toISOString();
// Upsert: insert-or-replace by (owner_id, name).
s.upsert.run(id, userId, 'default', JSON.stringify(serialized), now, 0);
return true;
}
@@ -133,11 +137,15 @@ export function removeSymbol(
const s = stmts(db);
const upper = symbol.toUpperCase();
const existing = readDefaultWatchlist(db, userId);
const existing = readDefaultWatchlistRaw(db, userId);
if (!existing) return false;
const before = existing.symbols.length;
const remaining = existing.symbols.filter((s) => s !== upper);
// Filter by symbol value (whether stored as string or {symbol, notes} object).
const remaining = existing.symbols.filter((sym) => {
const symStr = typeof sym === 'string' ? sym : sym.symbol;
return symStr !== upper;
});
if (remaining.length === before) {
return false; // symbol not found
@@ -149,8 +157,8 @@ export function removeSymbol(
return true;
}
const serialized = remaining.map((sym) => JSON.stringify(sym));
s.updateSymbols.run(JSON.stringify(serialized), existing.id, userId);
// Single JSON.stringify — preserves existing notes on remaining symbols.
s.updateSymbols.run(JSON.stringify(remaining), existing.id, userId);
return true;
}
@@ -210,10 +218,28 @@ function readDefaultWatchlist(db: DatabaseSync, userId: string): WatchlistRow |
const row = rows[0];
return {
...row,
symbols: safeParseSymbols(String(row.symbols)).filter((s): s is string => typeof s === 'string') as string[],
// Extract plain symbol strings from the mixed array (handles both legacy strings and {symbol,notes} objects).
symbols: safeParseSymbols(String(row.symbols)).map((s) => {
if (typeof s === 'string') return s;
if (s && typeof s === 'object' && 'symbol' in s) return (s as { symbol: string }).symbol;
return '';
}).filter((s): s is string => s.length > 0),
};
}
/** Read the default watchlist raw symbols (preserving {symbol, notes} objects). */
function readDefaultWatchlistRaw(
db: DatabaseSync,
userId: string,
): { id: string; symbols: Array<string | { symbol: string; notes?: string }> } | null {
const rows = stmts(db).selectByOwnerAndName.all(userId, 'default') as unknown as WatchlistRow[];
if (rows.length === 0) return null;
const row = rows[0];
const rawSymbols = safeParseSymbols(String(row.symbols));
return { id: row.id, symbols: rawSymbols as Array<string | { symbol: string; notes?: string }> };
}
/** Generate a simple unique id. */
function generateId(): string {
return `wl_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
+233
View File
@@ -0,0 +1,233 @@
'use client';
import { useGoalsStore } from '@/stores/goals-store';
export default function DailyFocusPage() {
const store = useGoalsStore();
return (
<div className="p-6 max-w-[1400px] mx-auto">
{/* Page Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-xl font-bold text-[#e6e7ec]">Daily Focus & Goals</h1>
<p className="text-sm text-[#5a5b6a] mt-1">Behavioral goals, weekly targets, daily tasks</p>
</div>
</div>
<div className="page-section">
{/* Monthly Goals */}
<div className="card">
<div className="section-header">
<h2 className="section-title">Monthly Goals (HTF)</h2>
<button onClick={() => setShowAddMonthly(true)} className="btn-primary !px-2 !py-1 text-xs">+ Add</button>
</div>
<p className="text-xs text-[#5a5b6a] mb-4">Non-PnL behavioral goals</p>
{showAddMonthly && (
<div className="card bg-[#121318] border-2 border-[#4f8cff] mb-3 p-3">
<div className="flex gap-2">
<input ref={newMonthlyRef} value={newMonthlyText} onChange={e => setNewMonthlyText(e.target.value)}
placeholder="Behavioral goal (no PnL)" className="input text-xs flex-1" />
<button onClick={handleAddMonthly} className="btn-primary text-xs">Add</button>
<button onClick={() => setShowAddMonthly(false)} className="btn-secondary text-xs">✕</button>
</div>
</div>
)}
<div className="space-y-2 max-h-[300px] overflow-y-auto">
{store.monthlyGoals.length === 0 ? (
<p className="text-xs text-[#5a5b6a] py-4 text-center">No monthly goals defined</p>
) : (
store.monthlyGoals.map(goal => (
<MonthlyGoalItem key={goal.id} goal={goal} onToggle={() => store.toggleMonthlyGoal(goal.id)} onDelete={() => store.removeMonthlyGoal(goal.id)} />
))
)}
</div>
</div>
{/* Weekly Goals */}
<div className="card">
<div className="section-header">
<h2 className="section-title">Weekly Goals</h2>
<button onClick={() => setShowAddWeekly(true)} className="btn-primary !px-2 !py-1 text-xs">+ Add</button>
</div>
<p className="text-xs text-[#5a5b6a] mb-4">Derived from monthly goals</p>
{showAddWeekly && (
<div className="card bg-[#121318] border-2 border-[#4f8cff] mb-3 p-3">
<div className="space-y-2">
<select value={selectedMonthlyForWeekly} onChange={e => setSelectedMonthlyForWeekly(e.target.value)} className="input text-xs">
<option value="">Select parent monthly goal</option>
{store.monthlyGoals.filter(g => !g.completed).map(g => (
<option key={g.id} value={g.id}>{g.description}</option>
))}
</select>
<div className="flex gap-2">
<input ref={newWeeklyRef} value={newWeeklyText} onChange={e => setNewWeeklyText(e.target.value)}
placeholder="Weekly goal" className="input text-xs flex-1" />
<button onClick={handleAddWeekly} className="btn-primary text-xs">Add</button>
<button onClick={() => setShowAddWeekly(false)} className="btn-secondary text-xs">✕</button>
</div>
</div>
</div>
)}
<div className="space-y-2 max-h-[300px] overflow-y-auto">
{store.weeklyGoals.length === 0 ? (
<p className="text-xs text-[#5a5b6a] py-4 text-center">No weekly goals defined</p>
) : (
store.weeklyGoals.map(goal => (
<div key={goal.id} className="bg-[#121318] rounded-md p-3">
<div className="flex items-start justify-between">
<label className="flex items-start gap-3 cursor-pointer flex-1">
<input type="checkbox" checked={goal.completed} onChange={() => store.toggleWeeklyGoal(goal.id)} className="mt-1 accent-[#4f8cff]" />
<div>
<p className={`text-sm ${goal.completed ? 'line-through text-[#5a5b6a]' : 'text-[#e6e7ec]'}`}>{goal.description}</p>
{goal.monthlyGoalId && (
<p className="text-xs text-[#5a5b6a] mt-1">
↓ {store.monthlyGoals.find(g => g.id === goal.monthlyGoalId)?.description || 'Linked goal'}
</p>
)}
</div>
</label>
<button onClick={() => store.removeWeeklyGoal(goal.id)} className="text-[#ef4444] hover:opacity-70 px-1 shrink-0">✕</button>
</div>
</div>
))
)}
</div>
</div>
{/* Daily Task List */}
<div className="card">
<div className="section-header">
<h2 className="section-title">Daily Task List</h2>
<button onClick={() => setShowAddTask(true)} className="btn-primary !px-2 !py-1 text-xs">+ Add</button>
</div>
<p className="text-xs text-[#5a5b6a] mb-4">Morning checklist with EOD tracking</p>
{showAddTask && (
<div className="card bg-[#121318] border-2 border-[#4f8cff] mb-3 p-3">
<div className="flex gap-2">
<input ref={newTaskRef} value={newTaskText} onChange={e => setNewTaskText(e.target.value)}
placeholder="Daily task" className="input text-xs flex-1" />
<button onClick={handleAddTask} className="btn-primary text-xs">Add</button>
<button onClick={() => setShowAddTask(false)} className="btn-secondary text-xs">✕</button>
</div>
</div>
)}
<div className="space-y-2 max-h-[300px] overflow-y-auto">
{store.dailyTasks.length === 0 ? (
<p className="text-xs text-[#5a5b6a] py-4 text-center">No daily tasks defined</p>
) : (
store.dailyTasks.map(task => (
<div key={task.id} className="bg-[#121318] rounded-md p-3">
<div className="flex items-start justify-between">
<label className="flex items-start gap-3 cursor-pointer flex-1">
<input type="checkbox" checked={task.completed} onChange={() => store.toggleDailyTask(task.id)} className="mt-1 accent-[#4f8cff]" />
<div className={task.completed ? 'line-through text-[#5a5b6a]' : ''}>
<p className={`text-sm ${task.completed ? 'line-through text-[#5a5b6a]' : 'text-[#e6e7ec]'}`}>{task.description}</p>
<p className="text-xs text-[#5a5b6a]">{task.date?.toLocaleDateString() || ''}</p>
</div>
</label>
<button onClick={() => store.removeDailyTask(task.id)} className="text-[#ef4444] hover:opacity-70 px-1 shrink-0">✕</button>
</div>
</div>
))
)}
</div>
</div>
{/* Pain-Point Enforcement Note */}
<div className="card">
<div className="section-header">
<h2 className="section-title">System Rules</h2>
</div>
<div className="space-y-3 text-sm">
<div className="flex items-start gap-3">
<span className="text-[#ef4444] font-bold text-base">✗</span>
<div>
<p className="text-[#e6e7ec]">PnL goals rejected</p>
<p className="text-xs text-[#5a5b6a]">System blocks goals based on profit/loss targets. Focus on process.</p>
</div>
</div>
<div className="flex items-start gap-3">
<span className="text-[#34d399] font-bold text-base">✓</span>
<div>
<p className="text-[#e6e7ec]">Behavioral goals enforced</p>
<p className="text-xs text-[#5a5b6a]">Only process-oriented goals are permitted in the system.</p>
</div>
</div>
<div className="flex items-start gap-3">
<span className="text-[#fbbf24] font-bold text-base">↕</span>
<div>
<p className="text-[#e6e7ec]">Goal hierarchy enforced</p>
<p className="text-xs text-[#5a5b6a]">Weekly goals must derive from monthly goals for continuity.</p>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
// --- Monthly Goal Item ---
function MonthlyGoalItem({ goal, onToggle, onDelete }: { goal: { id: string; description: string; completed: boolean }; onToggle: () => void; onDelete: () => void }) {
const [editing, setEditing] = useState(false);
if (editing) {
return <div className="bg-[#121318] rounded-md p-3 flex gap-2">
<input className="input text-xs flex-1" defaultValue={goal.description} onKeyDown={e => {
if (e.key === 'Enter') setEditing(false);
}} />
<button onClick={() => { const el = (e.target as HTMLInputElement).closest('.bg-\\[\\#121318\\]\\,\\ rounded-md\\, p-3')?.querySelector('input'); }} className="btn-primary text-xs">Save</button>
<button onClick={() => setEditing(false)} className="btn-secondary text-xs">✕</button>
</div>;
}
return (
<div className="bg-[#121318] rounded-md p-3 flex items-center justify-between">
<label className="flex items-center gap-3 cursor-pointer flex-1">
<input type="checkbox" checked={goal.completed} onChange={onToggle} className="accent-[#4f8cff]" />
<span className={`text-sm ${goal.completed ? 'line-through text-[#5a5b6a]' : 'text-[#e6e7ec]'}`}>{goal.description}</span>
</label>
<button onClick={onDelete} className="text-[#ef4444] hover:opacity-70 px-2">✕</button>
</div>
);
}
// --- State ---
import { useState, useRef } from 'react';
import type { MonthlyGoal, WeeklyGoal } from '@/types';
let showAddMonthly = false, newMonthlyText = '', newMonthlyRef: React.RefObject<HTMLInputElement | null> = { current: null };
let showAddWeekly = false, newWeeklyText = '', newWeeklyRef: React.RefObject<HTMLInputElement | null> = { current: null };
let selectedMonthlyForWeekly = '';
let showAddTask = false, newTaskText = '', newTaskRef: React.RefObject<HTMLInputElement | null> = { current: null };
function handleAddMonthly() {
// Accessing global store via import
const store = useGoalsStore.getState();
if (newMonthlyText.trim()) {
store.addMonthlyGoal({ description: newMonthlyText.trim() });
newMonthlyText = ''; showAddMonthly = false;
}
}
function handleAddWeekly() {
const store = useGoalsStore.getState();
if (newWeeklyText.trim()) {
store.addWeeklyGoal({ monthlyGoalId: selectedMonthlyForWeekly, description: newWeeklyText.trim() });
newWeeklyText = ''; showAddWeekly = false; selectedMonthlyForWeekly = '';
}
}
function handleAddTask() {
const store = useGoalsStore.getState();
if (newTaskText.trim()) {
store.addDailyTask({ description: newTaskText.trim() });
newTaskText = ''; showAddTask = false;
}
}
+414
View File
@@ -0,0 +1,414 @@
'use client';
import { useExecutionStore } from '@/stores/execution-store';
import type { ExecutionPlaybook, EmotionLogState } from '@/types';
const EXECUTION_PATTERNS = [
'Mean Reversion (MR)',
'Range Extreme (RE)',
'Break of Structure (BOS)',
'Liquidity Sweep',
'Trend Continuation',
'Reversal Rejection',
'No Trade (NT)',
] as const;
export default function ExecutionPage() {
const store = useExecutionStore();
const plan = store.executionPlaybook;
if (!plan) {
return (
<div className="p-6 max-w-[1400px] mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-xl font-bold text-[#e6e7ec]">Trade Execution</h1>
<p className="text-sm text-[#5a5b6a] mt-1">Live trade tracking with execution library</p>
</div>
</div>
{store.activePlans.length > 0 && (
<div className="card mb-6">
<h3 className="text-sm font-bold text-[#e6e7ec] mb-3">Active Plans</h3>
<div className="space-y-2">
{store.activePlans.map(p => (
<div key={p.id} className="flex items-center justify-between bg-[#121318] rounded-md p-3">
<div className="flex items-center gap-4">
<span className="badge" style={{ background: 'none', border: '1px solid var(--accent-blue)', color: '#4f8cff' }}>
{p.tradeId}
</span>
<button onClick={() => store.setActivePlanId(p.id)} className="text-sm text-[#4f8cff] hover:underline">
Execute
</button>
</div>
<button onClick={() => store.removePlan(p.id)} className="text-[#ef4444] hover:opacity-70 px-1">✕</button>
</div>
))}
</div>
</div>
)}
<p className="text-sm text-[#5a5b6a]">No execution plan available. Select a plan above or create one.</p>
</div>
);
}
// --- Execution detail view ---
return (
<div className="p-6 max-w-[1400px] mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-xl font-bold text-[#e6e7ec]">Trade Execution</h1>
<p className="text-sm text-[#5a5b6a] mt-1">
{plan.tradeId} — Live tracking and post-trade review
</p>
</div>
{store.activePlans.length > 1 && (
<button onClick={() => store.setActivePlanId(null)} className="btn-secondary text-xs">
← Back to Plan List
</button>
)}
</div>
{/* Checklist Status */}
<div className={`card mb-6 ${store.checklistCompleted ? 'border-[#059669]' : ''}`}>
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-bold text-[#e6e7ec]">Execution Checklist</h2>
<p className="text-xs text-[#5a5b6a] mt-1">
{store.checklistCompleted ? '✓ All setup steps completed' : '☐ Complete live checklist before entry'}
</p>
</div>
<button onClick={() => store.toggleExecutionChecklist()}
className={`px-4 py-2 rounded-md text-sm font-medium transition-all ${
store.checklistCompleted ? 'bg-[#064e3b] text-[#34d399] border border-[#34d399]' : 'bg-[#1a1b24] text-[#5a5b6a] border border-[#2a2b3a]'
}`}>
{store.checklistCompleted ? '✓ Checklist Complete' : '☐ Complete'}
</button>
</div>
</div>
<div className="page-section">
{/* Execution Pattern Library */}
<div className="card">
<h2 className="section-title">Execution Pattern</h2>
<p className="text-xs text-[#5a5b6a] mb-4">Select execution strategy pattern</p>
<div className="grid grid-cols-3 gap-2">
{EXECUTION_PATTERNS.map(pattern => (
<button
key={pattern}
onClick={() => store.setExecutionPattern(pattern)}
className={`p-3 rounded-md border text-xs text-left transition-all ${
plan.executionPattern === pattern
? 'bg-[#4f8cff]/20 border-[#4f8cff] text-[#4f8cff]'
: 'bg-[#1a1b24] border-[#2a2b3a] text-[#5a5b6a]'
}`}
>
{pattern}
</button>
))}
</div>
</div>
{/* Scale-In Entry Table */}
<div className="card">
<h2 className="section-title">Scale-In Entry Table</h2>
<p className="text-xs text-[#5a5b6a] mb-4">Track partial fills, sizes, and strategies</p>
{/* Add Entry Form */}
<AddScaleEntryForm onSubmit={(entry) => {
store.addScaleEntry(entry);
}} />
{/* Scale Table */}
<div className="overflow-x-auto mt-3">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-[#2a2b3a] text-[#5a5b6a]">
<th className="py-2 px-3 text-left">#</th>
<th className="py-2 px-3 text-left">Price</th>
<th className="py-2 px-3 text-left">Size %</th>
<th className="py-2 px-3 text-left">Strategy</th>
<th className="py-2 px-3 text-right">Actions</th>
</tr>
</thead>
<tbody>
{store.scaleEntries.length === 0 ? (
<tr>
<td colSpan={4} className="py-6 text-center text-[#5a5b6a]">No scale entries recorded</td>
</tr>
) : (
store.scaleEntries.map((entry, idx) => (
<tr key={entry.id || idx} className="border-b border-[#2a2b3a]">
<td className="py-2 px-3 text-[#5a5b6a]">{idx + 1}</td>
<td className="py-2 px-3">
<input type="number" step="any" value={entry.price}
onChange={e => store.updateScaleEntry(idx, { price: parseFloat(e.target.value) || 0 })}
className="input text-xs w-28" />
</td>
<td className="py-2 px-3">
<input type="number" min="0" max="100" value={entry.sizePercent}
onChange={e => store.updateScaleEntry(idx, { sizePercent: parseInt(e.target.value) || 0 })}
className="input text-xs w-20" />
</td>
<td className="py-2 px-3">
<select value={entry.strategy}
onChange={e => store.updateScaleEntry(idx, { strategy: e.target.value as ExecutionPlaybook['strategy'] })}
className="input text-xs">
<option value="">None</option>
{EXECUTION_PATTERNS.map(p => (
<option key={p} value={p}>{p}</option>
))}
</select>
</td>
<td className="py-2 px-3 text-right">
<button onClick={() => store.removeScaleEntry(idx)} className="text-[#ef4444] hover:opacity-70 px-1">✕</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Scale Metrics */}
<div className="grid grid-cols-2 gap-3 mt-4">
<KPICard label="Total Filled %" value={`${store.totalFilledPercent}%`} color="text-[#4f8cff]" />
<KPICard label="Avg Entry Price" value={plan.averageEntryPrice ? plan.averageEntryPrice.toFixed(4) : '--'} color="text-[#a78bfa]" />
</div>
</div>
{/* Emotion Logger */}
<div className="card">
<h2 className="section-title">In-Trade Emotion Logger</h2>
<p className="text-xs text-[#5a5b6a] mb-4">Record emotional state during trade</p>
{/* Add Emotion Form */}
<AddEmotionForm onSubmit={(log) => store.addEmotionLog(log)} />
{/* Emotion Log List */}
<div className="space-y-2 max-h-[300px] overflow-y-auto mt-3">
{store.emotionLogs.length === 0 ? (
<p className="text-xs text-[#5a5b6a] py-4 text-center">No emotion entries yet</p>
) : (
store.emotionLogs.map((log, idx) => (
<div key={log.id || idx} className="bg-[#121318] rounded-md p-3 flex items-start justify-between">
<div>
<div className="flex items-center gap-2 mb-1">
<span className={`badge ${emotionColor(log.emotion)}`}>{log.emotion}</span>
<span className="text-[10px] text-[#5a5b6a]">
{new Date(log.timestamp).toLocaleTimeString()}
</span>
</div>
<p className="text-sm text-[#8a8b9a]">{log.note}</p>
</div>
<button onClick={() => store.removeEmotionLog(idx)} className="text-[#ef4444] hover:opacity-70 px-1">✕</button>
</div>
))
)}
</div>
</div>
{/* Execution Timing */}
<div className="card">
<h2 className="section-title">Execution Timing</h2>
<p className="text-xs text-[#5a5b6a] mb-4">Track actual vs planned entry timing</p>
<div className="grid grid-cols-2 gap-3">
<button onClick={() => store.setActualEntryTime()}
className="p-3 rounded-md border border-[#2a2b3a] bg-[#1a1b24] text-xs text-left">
<p className="text-[#e6e7ec] font-bold">⏱ Mark Actual Entry Time</p>
<p className="text-[#5a5b6a] mt-1">
{store.actualEntryTime ? `Recorded: ${new Date(store.actualEntryTime).toLocaleTimeString()}` : 'Not yet recorded'}
</p>
</button>
<div className="flex gap-2">
<input type="datetime-local" className="input text-xs flex-1" />
<button onClick={() => store.setActualEntryTime()} className="btn-primary text-xs">Log Plan Time</button>
</div>
</div>
</div>
{/* Post-Trade Review */}
<div className="card">
<h2 className="section-title">Post-Trade Review</h2>
<p className="text-xs text-[#5a5b6a] mb-4">Reflect on execution quality</p>
<div className="space-y-3 mt-3">
<div>
<label className="label text-[#e6e7ec]">Did you follow the plan?</label>
<div className="flex gap-2">
<button onClick={() => store.setFollowedPlan(true)}
className={`flex-1 py-2 rounded-md text-xs font-semibold transition-all border ${
store.followedPlan === true ? 'bg-[#064e3b] border-[#059669] text-[#34d399]' : 'bg-[#1a1b24] border-[#2a2b3a] text-[#5a5b6a]'
}`}>
✓ Yes, followed
</button>
<button onClick={() => store.setFollowedPlan(false)}
className={`flex-1 py-2 rounded-md text-xs font-semibold transition-all border ${
store.followedPlan === false ? 'bg-[#78350f] border-[#b45309] text-[#fcd34d]' : 'bg-[#1a1b24] border-[#2a2b3a] text-[#5a5b6a]'
}`}>
✗ No, deviated
</button>
</div>
</div>
{!store.followedPlan && (
<div>
<label className="label text-[#e6e7ec]">Deviation Reason *</label>
<textarea value={store.deviationReason} onChange={e => store.setDeviationReason(e.target.value)}
className="input text-xs min-h-[60px] resize-none" placeholder="Why did you deviate from the plan?..." />
</div>
)}
<div>
<label className="label text-[#e6e7ec]">Strengths</label>
<div className="flex gap-2 mb-2">
<input ref={strengthRef} type="text" value={newStrength} onChange={e => setNewStrength(e.target.value)}
placeholder="Add a strength..." className="input text-xs flex-1" />
<button onClick={addStrength} className="btn-primary text-xs">Add</button>
</div>
{store.strengths.map((s, idx) => (
<div key={idx} className="flex items-center gap-2 mb-1">
<span className="text-[#34d399] text-xs">✓</span>
<span className="text-sm text-[#8a8b9a] flex-1">{s}</span>
<button onClick={() => store.removeStrength(idx)} className="text-[#ef4444] hover:opacity-70 px-1">✕</button>
</div>
))}
</div>
<div>
<label className="label text-[#e6e7ec]">Mistakes</label>
<div className="flex gap-2 mb-2">
<input ref={mistakeRef} type="text" value={newMistake} onChange={e => setNewMistake(e.target.value)}
placeholder="Add a mistake..." className="input text-xs flex-1" />
<button onClick={addMistake} className="btn-primary text-xs">Add</button>
</div>
{store.mistakes.map((m, idx) => (
<div key={idx} className="flex items-center gap-2 mb-1">
<span className="text-[#ef4444] text-xs">✗</span>
<span className="text-sm text-[#8a8b9a] flex-1">{m.description}</span>
<span className={`badge text-[9px] ${mistakeStageColor(m.stage)}`}>{m.stage}</span>
<button onClick={() => store.removeMistake(idx)} className="text-[#ef4444] hover:opacity-70 px-1">✕</button>
</div>
))}
</div>
<div>
<label className="label text-[#e6e7ec]">Trade Diary</label>
<textarea value={store.diary} onChange={e => store.setDiary(e.target.value)}
className="input text-xs min-h-[120px] resize-none"
placeholder="Write your free-form trade diary... Describe what happened, how you felt, and what you learned." />
</div>
</div>
</div>
</div>
</div>
);
}
// --- Add Scale Entry Form ---
function AddScaleEntryForm({ onSubmit }: { onSubmit: (entry: Omit<import('@/types').ScaleEntry, 'id'>) => void }) {
const [price, setPrice] = useState('');
const [size, setSize] = useState('0');
const [strategy, setStrategy] = useState('');
const handleSubmit = () => {
if (!price) return;
onSubmit({ price: parseFloat(price), sizePercent: parseInt(size) || 0, strategy });
setPrice(''); setSize('0');
};
return (
<div className="grid grid-cols-2 gap-2">
<input type="number" step="any" value={price} onChange={e => setPrice(e.target.value)} placeholder="Price" className="input text-xs" />
<div className="flex gap-2">
<input type="number" min="0" max="100" value={size} onChange={e => setSize(e.target.value)} placeholder="Size %" className="input text-xs w-20" />
<select value={strategy} onChange={e => setStrategy(e.target.value)} className="input text-xs flex-1">
<option value="">Strategy</option>
{EXECUTION_PATTERNS.map(p => (
<option key={p} value={p}>{p}</option>
))}
</select>
<button onClick={handleSubmit} className="btn-primary text-xs">+</button>
</div>
</div>
);
}
// --- Add Emotion Form ---
function AddEmotionForm({ onSubmit }: { onSubmit: (log: Omit<EmotionLogState, 'id' | 'timestamp'>) => void }) {
const [priceAtEvent, setPriceAtEvent] = useState('');
const [emotion, setEmotion] = useState('FOMO');
const [note, setNote] = useState('');
const handleSubmit = () => {
if (!priceAtEvent || !note) return;
onSubmit({ priceAtEvent: parseFloat(priceAtEvent), emotion, note });
setPriceAtEvent(''); setNote('');
};
return (
<div className="grid grid-cols-3 gap-2">
<input type="number" step="any" value={priceAtEvent} onChange={e => setPriceAtEvent(e.target.value)} placeholder="Price" className="input text-xs" />
<select value={emotion} onChange={e => setEmotion(e.target.value)} className="input text-xs">
<option>FOMO</option><option>Confidence</option><option>Fear</option><option>Greed</option><option>Hesitation</option><option>Impatience</option><option>Overconfidence</option><option>Neutral</option>
</select>
<input type="text" value={note} onChange={e => setNote(e.target.value)} placeholder="Note..." className="input text-xs flex-1" />
<button onClick={handleSubmit} className="btn-primary text-xs col-span-3">Log Emotion</button>
</div>
);
}
// --- State ---
import { useState, useRef } from 'react';
let strengthRef = useRef<HTMLInputElement>(null);
let newStrength = '';
function addStrength() {
if (newStrength.trim()) {
useExecutionStore.getState().addStrength(newStrength.trim());
newStrength = '';
}
}
let mistakeRef = useRef<HTMLInputElement>(null);
let newMistake = '';
function addMistake() {
if (newMistake.trim()) {
useExecutionStore.getState().addMistake(newMistake.trim(), 'analysis');
newMistake = '';
}
}
// --- Helpers ---
import type { MistakeStage } from '@/types';
function emotionColor(e: string) {
if (['Confidence'].includes(e)) return 'bg-[#064e3b] text-[#34d399]';
if (['Fear', 'Hesitation'].includes(e)) return 'bg-[#78350f] text-[#fcd34d]';
if (['FOMO', 'Greed'].includes(e)) return 'bg-[#ef4444]/20 text-[#ef4444]';
return 'bg-[#1a1b24] text-[#5a5b6a]';
}
function mistakeStageColor(s: MistakeStage) {
if (s === 'analysis') return 'bg-[#3730a3] text-[#a5b4fc]';
if (s === 'planning') return 'bg-[#4f8cff]/20 text-[#4f8cff]';
if (s === 'execution') return 'bg-[#78350f] text-[#fcd34d]';
if (s === 'management') return 'bg-[#78350f]/20 text-[#fbbf24]';
return 'bg-[#78350f]/20 text-[#ef4444]';
}
function KPICard({ label, value, color }: { label: string; value: string; color: string }) {
return (
<div className="bg-[#1a1b24] border border-[#2a2b3a] rounded-md p-3 text-center">
<p className="text-[10px] text-[#5a5b6a]">{label}</p>
<p className={`text-sm font-bold ${color}`}>{value}</p>
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+404
View File
@@ -0,0 +1,404 @@
'use client';
import { useTradePlanStore } from '@/stores/trade-plan-store';
import type { ConfluenceType, SetupTier, TradePlan } from '@/types';
export default function TradePlanPage() {
const store = useTradePlanStore();
const plan = store.currentPlanId
? store.activePlans.find(p => p.id === store.currentPlanId)
: null;
// --- Confluence options ---
const CONFLUENCE_OPTIONS: { value: ConfluenceType; label: string; desc: string }[] = [
{ value: 'support_resistance', label: 'Support/Resistance', desc: 'Key S/R level aligns with setup' },
{ value: 'ema', label: 'EMA Confluence', desc: 'Price at/near key EMA (21, 50, 200)' },
{ value: 'fibonacci', label: 'Fibonacci', desc: '61.8% or 38.2% retracement' },
{ value: 'supply_demand', label: 'Supply/Demand Zone', desc: 'Fresh order block or imbalance' },
{ value: 'pattern_recognition', label: 'Pattern Recognition', desc: 'Reversal/continuation pattern' },
{ value: 'volume_profile', label: 'Volume Profile', desc: 'High volume node or POC' },
{ value: 'momentum_divergence', label: 'Momentum Divergence', desc: 'RSI/MACD divergence on HTF' },
{ value: 'single_print', label: 'Single Print', desc: 'Unfilled price zone' },
{ value: 'composite_structure', label: 'Composite Structure', desc: 'Multiple HTF alignments' },
{ value: 'news_filter', label: 'News Filter Clear', desc: 'No high-impact news in next 2h' },
];
// --- Tier display ---
const getTierClass = (tier: SetupTier): string => {
switch (tier) {
case 'A_STAR': return 'bg-[#064e3b] text-[#34d399] border-[#059669]';
case 'A': return 'bg-[#065f46] text-[#6ee7b7] border-[#10b981]';
case 'B': return 'bg-[#3730a3] text-[#a5b4fc] border-[#6366f1]';
case 'C': return 'bg-[#78350f] text-[#fcd34d] border-[#f59e0b]';
}
};
const getTierLabel = (tier: SetupTier): string => {
switch (tier) { return 'A_STAR'; case A_STAR': return 'A*'; case 'A': return 'A'; case 'B': return 'B'; case 'C': return 'C'; }
};
if (!plan) {
return (
<div className="p-6 max-w-[1400px] mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-xl font-bold text-[#e6e7ec]">Trade Plan</h1>
<p className="text-sm text-[#5a5b6a] mt-1">Structured trade planning with AI-augmented scoring</p>
</div>
</div>
{store.activePlans.length > 0 && (
<div className="card mb-6">
<h3 className="text-sm font-semibold text-[#e6e7ec] mb-3">Active Plans</h3>
<div className="space-y-2">
{store.activePlans.map(p => (
<div key={p.id} className="flex items-center justify-between bg-[#121318] rounded-md p-3">
<div className="flex items-center gap-4">
<span className="badge" style={{ background: 'none', border: `1px solid var(--accent-blue)`, color: '#4f8cff' }}>
{p.tradeId}
</span>
<span className={`badge ${getTierClass(p.tier)}`}>{getTierLabel(p.tier)}</span>
<span className="text-sm text-[#8a8b9a]">{p.environment}</span>
</div>
<div className="flex items-center gap-2">
<span className={`badge ${statusColor(p.status)}`}>{formatStatus(p.status)}</span>
<button onClick={() => store.setCurrentPlan(p.id)} className="btn-primary text-xs">Open</button>
<button onClick={() => store.removePlan(p.id)} className="text-[#ef4444] hover:opacity-70 px-1">✕</button>
</div>
</div>
))}
</div>
</div>
)}
<button onClick={() => {
const newPlan = store.createPlan();
store.addActivePlan(newPlan);
store.setCurrentPlan(newPlan.id);
}} className="btn-primary text-lg px-8 py-3">
+ Create New Trade Plan
</button>
</div>
);
}
// --- Plan detail view ---
return (
<div className="p-6 max-w-[1400px] mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<div className="flex items-center gap-3">
<h1 className="text-xl font-bold text-[#e6e7ec]">Trade Plan</h1>
<span className="badge" style={{ background: 'none', border: '1px solid var(--accent-blue)', color: '#4f8cff' }}>
{plan.tradeId}
</span>
</div>
<p className="text-sm text-[#5a5b6a] mt-1">
Status: <span className={`badge ${statusColor(plan.status)}`}>{formatStatus(plan.status)}</span>
{' · '}Created: {plan.date instanceof Date ? plan.date.toLocaleDateString() : new Date(plan.date).toLocaleDateString()}
</p>
</div>
{/* Navigation controls */}
<div className="flex items-center gap-3">
{store.activePlans.length > 1 && (
<button onClick={() => store.setCurrentPlan(null)} className="btn-secondary text-xs">
← Back to Plan List
</button>
)}
{plan.status === 'planned' && (
<button onClick={() => store.advanceStatus()} className="btn-primary">▶ Advance to ACTIVE</button>
)}
{plan.status === 'active' && (
<button onClick={() => store.advanceStatus()} className="btn-primary" style={{ background: 'var(--accent-yellow)' }}>
✕ Close Trade
</button>
)}
</div>
</div>
{/* Checklist Gate */}
<div className={`card mb-6 ${plan.checklistCompleted ? 'border-[#059669]' : ''}`}>
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-bold text-[#e6e7ec]">Pre-Flight Checklist</h2>
<p className="text-xs text-[#5a5b6a] mt-1">
{plan.checklistCompleted
? 'All modules complete — trade is ready to execute'
: 'Complete all daily modules before trading'}
</p>
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={plan.checklistCompleted}
onChange={() => {
const newChecked = !plan.checklistCompleted;
store.setChecklistCompleted(newChecked);
}}
className="w-5 h-5 accent-[#4f8cff]"
/>
<span className={`text-sm font-bold ${plan.checklistCompleted ? 'text-[#34d399]' : 'text-[#5a5b6a]'}`}>
{plan.checklistCompleted ? '✓ Complete' : '☐ Not Complete'}
</span>
</label>
</div>
</div>
<div className="page-section">
{/* Environment Classification */}
<div className="card">
<h2 className="section-title">Environment Classification</h2>
<p className="text-xs text-[#5a5b6a] mb-4">Required before any trade action</p>
<div className="flex gap-2">
{(['Trending', 'Ranging', 'Transitioning'] as const).map(env => (
<button
key={env}
onClick={() => {
const envMap = { 'Trending': 'trending' as const, 'Ranging': 'ranging' as const, 'Transitioning': 'transitioning' as const };
store.setEnvironment(envMap[env]);
}}
className={`flex-1 py-2 rounded-md text-xs font-semibold transition-all border ${
plan.environment === env.toLowerCase()
? 'bg-[#4f8cff]/20 border-[#4f8cff] text-[#4f8cff]'
: 'bg-[#1a1b24] border-[#2a2b3a] text-[#5a5b6a]'
}`}
>
{env}
</button>
))}
</div>
</div>
{/* Setup Tier & Risk */}
<div className="card">
<h2 className="section-title">Setup Tier & Risk</h2>
<div className="flex items-center justify-center py-4">
<div className={`text-5xl font-black border-2 rounded-xl px-8 py-4 ${getTierClass(plan.tier)}`}>
{plan.tier}
</div>
</div>
<div className="grid grid-cols-2 gap-3 mt-4">
<KPICard label="Confluence Score" value={`${plan.confluenceScore}`} color="text-[#4f8cff]" />
<KPICard label="Risk %" value={plan.riskPercent.toFixed(2)} color="text-[#a78bfa]" />
</div>
</div>
{/* Position Sizing */}
<div className="card">
<h2 className="section-title">Position Sizing Calculator</h2>
<div className="space-y-3 mt-3">
<div>
<label className="label">Account Balance</label>
<input type="number" className="input text-xs" placeholder="$10,000" />
</div>
<div>
<label className="label">Risk per Trade (%)</label>
<input type="number" step="0.1" defaultValue={plan.riskPercent.toString()} className="input text-xs" disabled />
<p className="text-[10px] text-[#5a5b6a] mt-1">Based on tier: {plan.tier}</p>
</div>
<div>
<label className="label">Entry Price</label>
<input type="number" step="any" value={plan.entryPrice?.toString() || ''}
onChange={e => store.setEntryPrice(parseFloat(e.target.value) || 0)} className="input text-xs" placeholder="0.0000" />
</div>
<div>
<label className="label">Stop Loss Price</label>
<input type="number" step="any" value={plan.stopLoss?.toString() || ''}
onChange={e => store.setStopLoss(parseFloat(e.target.value) || 0)} className="input text-xs" placeholder="0.0000" />
</div>
<div className="bg-[#121318] rounded-md p-3 text-center">
<p className="text-xs text-[#5a5b6a]">Risk Amount: <span className="text-[#ef4444] font-bold">$---</span></p>
<p className="text-xs text-[#5a5b6a]">Position Size: <span className="text-[#4f8cff] font-bold">----</span></p>
<p className="text-xs text-[#5a5b6a]">Lots: <span className="text-[#34d399] font-bold">----</span></p>
</div>
</div>
</div>
{/* Confluences */}
<div className="card">
<h2 className="section-title">Confluences Checklist</h2>
<p className="text-xs text-[#5a5b6a] mb-4">Required for trade to advance to active</p>
<div className="grid grid-cols-2 gap-2">
{CONFLUENCE_OPTIONS.map(opt => (
<button
key={opt.value}
onClick={() => {
if (plan.confluences.includes(opt.value)) {
store.removeConfluence(opt.value);
} else {
store.addConfluence(opt.value);
}
}}
className={`text-left p-2 rounded-md border text-xs transition-all ${
plan.confluences.includes(opt.value)
? 'bg-[#4f8cff]/20 border-[#4f8cff] text-[#4f8cff]'
: 'bg-[#1a1b24] border-[#2a2b3a] text-[#5a5b6a]'
}`}
>
<p className="font-bold">{opt.label}</p>
<p className="text-[10px] mt-1 opacity-70">{opt.desc}</p>
</button>
))}
</div>
<div className="mt-3 flex items-center justify-between">
<span className="text-xs text-[#5a5b6a]">
Confluence count: <span className={`text-sm font-bold ${plan.confluences.length >= 3 ? 'text-[#34d399]' : plan.confluences.length >= 2 ? 'text-[#fbbf24]' : 'text-[#ef4444]'}`}>{plan.confluences.length}</span>
</span>
<span className="text-[10px] text-[#5a5b6a]">
{plan.confluences.length >= 3 ? '≥3 needed for A tier+' : plan.confluences.length === 2 ? 'Need 1 more for B' : 'Need at least 1'}
</span>
</div>
</div>
{/* Justifications - Why? */}
<div className="card">
<h2 className="section-title">Why? — Trade Justifications</h2>
<p className="text-xs text-[#5a5b6a] mb-4">Mandatory reasoning before execution</p>
<div className="space-y-3">
<div>
<label className="label text-[#e6e7ec]">Why Enter? *</label>
<textarea
value={plan.justifications.entryWhy}
onChange={e => store.setJustification('entryWhy', e.target.value)}
className="input text-xs min-h-[80px] resize-none"
placeholder="Describe your entry reasoning..."
/>
{plan.justifications.entryWhy.length === 0 && plan.status === 'active' && (
<p className="text-[10px] text-[#ef4444] mt-1">Required for active trades</p>
)}
</div>
<div>
<label className="label text-[#e6e7ec]">Why Stop Loss? *</label>
<textarea
value={plan.justifications.stopWhy}
onChange={e => store.setJustification('stopWhy', e.target.value)}
className="input text-xs min-h-[60px] resize-none"
placeholder="Why is this the correct stop loss level..."
/>
</div>
<div>
<label className="label text-[#e6e7ec]">Why Target? *</label>
<textarea
value={plan.justifications.targetWhy}
onChange={e => store.setJustification('targetWhy', e.target.value)}
className="input text-xs min-h-[60px] resize-none"
placeholder="Why are these targets logical..."
/>
</div>
</div>
<div className={`mt-3 text-xs ${
plan.justifications.entryWhy && plan.justifications.stopWhy && plan.justifications.targetWhy
? 'text-[#34d399]' : 'text-[#5a5b6a]'
}`}>
{plan.justifications.entryWhy && plan.justifications.stopWhy && plan.justifications.targetWhy
? '✓ All justifications complete' : '✗ Complete all fields before execution'}
</div>
</div>
{/* Targets */}
<div className="card">
<h2 className="section-title">Targets</h2>
<p className="text-xs text-[#5a5b6a] mb-4">Define exit targets with reasoning</p>
<div className="space-y-2 mb-3">
{plan.targets.length === 0 ? (
<p className="text-xs text-[#5a5b6a] py-2 text-center">No targets defined</p>
) : (
plan.targets.map((t, idx) => (
<div key={idx} className="bg-[#121318] rounded-md p-3 flex items-center justify-between">
<div>
<span className="text-[#4f8cff] font-mono font-bold text-sm">{t.price.toFixed(2)}</span>
<p className="text-xs text-[#5a5b6a] mt-1">{t.reason}</p>
</div>
<button onClick={() => store.removeTarget(idx)} className="text-[#ef4444] hover:opacity-70 px-1">✕</button>
</div>
))
)}
</div>
{/* Add Target */}
<AddTargetForm
onAdd={(price, reason) => store.addTarget(price, reason)}
entryPrice={plan.entryPrice}
/>
</div>
{/* Devil's Advocate */}
<div className="card">
<h2 className="section-title">Devil's Advocate</h2>
<p className="text-xs text-[#5a5b6a] mb-4">List reasons NOT to take this trade</p>
<textarea
value={plan.devilAdvocate}
onChange={e => store.setDevilAdvocate(e.target.value)}
className="input text-xs min-h-[100px] resize-none"
placeholder="What could go wrong? What reasons argue against this trade? Be honest..."
/>
<div className="mt-3 flex items-center justify-between text-xs">
<span className="text-[#5a5b6a]">
Characters: {plan.devilAdvocate.length}
</span>
<span className={plan.devilAdvocate.length > 10 ? 'text-[#fbbf24]' : plan.devilAdvocate.length === 0 ? 'text-[#5a5b6a]' : 'text-[#34d399]'}>
{plan.devilAdvocate.length === 0 ? '⚠ No counter-arguments listed' : plan.devilAdvocate.length < 10 ? '💡 Consider deeper analysis' : '✓ Devil\'s advocate engaged'}
</span>
</div>
</div>
</div>
</div>
);
}
// --- Add Target Form ---
function AddTargetForm({ onAdd, entryPrice }: { onAdd: (price: number, reason: string) => void; entryPrice?: number }) {
const [price, setPrice] = useState('');
const [reason, setReason] = useState('');
const handleSubmit = () => {
if (!price || !reason) return;
onAdd(parseFloat(price), reason);
setPrice('');
setReason('');
};
return (
<div className="grid grid-cols-2 gap-2">
<input type="number" step="any" value={price} onChange={e => setPrice(e.target.value)} placeholder="Target price" className="input text-xs" />
<div className="flex gap-1">
<input type="text" value={reason} onChange={e => setReason(e.target.value)} placeholder="Reason for target" className="input text-xs flex-1" />
<button onClick={handleSubmit} className="btn-primary text-xs !px-3">+</button>
</div>
</div>
);
}
// --- Helpers ---
import { useState } from 'react';
function statusColor(s: TradePlan['status']) {
if (s === 'planned') return 'bg-[#1a1b24] text-[#5a5b6a]';
if (s === 'active') return 'bg-[#064e3b] text-[#34d399]';
return 'bg-[#78350f] text-[#fcd34d]';
}
function formatStatus(s: TradePlan['status']) {
if (s === 'planned') return 'PLANNED';
if (s === 'active') return 'ACTIVE';
return 'CLOSED';
}
function KPICard({ label, value, color }: { label: string; value: string; color: string }) {
return (
<div className="bg-[#1a1b24] border border-[#2a2b3a] rounded-md p-3 text-center">
<p className="text-[10px] text-[#5a5b6a]">{label}</p>
<p className={`text-lg font-bold ${color}`}>{value}</p>
</div>
);
}
+65
View File
@@ -0,0 +1,65 @@
import { create } from 'zustand';
import type { MistakeStage } from '@/types';
interface EmotionLog {
id: string;
timestamp: number;
priceAtEvent: number;
emotion: string;
note: string;
}
interface ClosureStore {
activeExecutionId: string | null;
// Emotion Logger
emotionLogs: EmotionLog[];
addEmotionLog: (log: Omit<EmotionLog, 'id' | 'timestamp'>) => void;
removeEmotionLog: (idx: number) => void;
// Post-trade review fields
diary: string;
setDiary: (d: string) => void;
followedPlan: boolean | null;
setFollowedPlan: (v: boolean) => void;
deviationReason: string;
setDeviationReason: (r: string) => void;
strengths: string[];
addStrength: (s: string) => void;
removeStrength: (idx: number) => void;
mistakes: { description: string; stage: MistakeStage }[];
addMistake: (desc: string, stage: MistakeStage) => void;
removeMistake: (idx: number) => void;
}
export const useClosureStore = create<ClosureStore>((set, get) => ({
activeExecutionId: null,
emotionLogs: [],
addEmotionLog: (log) => {
const entry = { id: crypto.randomUUID(), timestamp: Date.now(), ...log };
set({ emotionLogs: [...get().emotionLogs, entry] });
},
removeEmotionLog: (idx) => set({ emotionLogs: get().emotionLogs.filter((_, i) => i !== idx) }),
diary: '',
setDiary: (d) => set({ diary: d }),
followedPlan: null,
setFollowedPlan: (v) => set({ followedPlan: v }),
deviationReason: '',
setDeviationReason: (r) => set({ deviationReason: r }),
strengths: [],
addStrength: (s) => set({ strengths: [...get().strengths, s] }),
removeStrength: (idx) => set({ strengths: get().strengths.filter((_, i) => i !== idx) }),
mistakes: [],
addMistake: (desc, stage) => set({ mistakes: [...get().mistakes, { description: desc, stage }] }),
removeMistake: (idx) => set({ mistakes: get().mistakes.filter((_, i) => i !== idx) }),
}));
+127
View File
@@ -0,0 +1,127 @@
import { create } from 'zustand';
import type { ExecutionPlaybook, ScaleEntry, MistakeStage } from '@/types';
interface EmotionLogState {
id: string;
timestamp: number;
priceAtEvent: number;
emotion: string;
note: string;
}
export interface ExecutionStore {
activePlanId: string | null;
setActivePlanId: (id: string | null) => void;
executionPlaybook: ExecutionPlaybook | null;
setExecutionPlaybook: (pb: ExecutionPlaybook) => void;
checklistCompleted: boolean;
toggleExecutionChecklist: () => void;
scaleEntries: ScaleEntry[];
addScaleEntry: (entry: Omit<ScaleEntry, 'id'>) => void;
removeScaleEntry: (idx: number) => void;
updateScaleEntry: (idx: number, entry: Partial<ScaleEntry>) => void;
// Execution timing
actualEntryTime: number | null;
plannedEntryTime: number | null;
setActualEntryTime: () => void;
setPlannedEntryTime: (t: number) => void;
// Emotion Logger
emotionLogs: EmotionLogState[];
addEmotionLog: (log: Omit<EmotionLogState, 'id' | 'timestamp'>) => void;
removeEmotionLog: (idx: number) => void;
// Post-trade review
followedPlan: boolean | null;
deviationReason: string;
strengths: string[];
mistakes: { description: string; stage: MistakeStage }[];
diary: string;
setFollowedPlan: (v: boolean) => void;
setDeviationReason: (r: string) => void;
addStrength: (s: string) => void;
removeStrength: (idx: number) => void;
addMistake: (desc: string, stage: MistakeStage) => void;
removeMistake: (idx: number) => void;
setDiary: (d: string) => void;
totalFilledPercent: number;
averageEntryPrice: number;
}
const emptyEntry = {
executionPlaybook: null as ExecutionPlaybook | null,
checklistCompleted: false,
scaleEntries: [],
actualEntryTime: null,
plannedEntryTime: null,
emotionLogs: [],
followedPlan: null,
deviationReason: '',
strengths: [],
mistakes: [],
diary: '',
};
export const useExecutionStore = create<ExecutionStore>((set, get) => ({
activePlanId: null,
setActivePlanId: (id) => set({ activePlanId: id }),
...emptyEntry,
setExecutionPlaybook: (pb) => set({ executionPlaybook: pb }),
toggleExecutionChecklist: () => set(s => ({ checklistCompleted: !s.checklistCompleted })),
addScaleEntry: (entry) => {
const newEntry: ScaleEntry = { id: crypto.randomUUID(), ...entry };
set(s => ({ scaleEntries: [...s.scaleEntries, newEntry] }));
},
removeScaleEntry: (idx) => {
set(s => ({ scaleEntries: s.scaleEntries.filter((_, i) => i !== idx) }));
},
updateScaleEntry: (idx, partials) => {
set(s => ({ scaleEntries: s.scaleEntries.map((e, i) => i === idx ? { ...e, ...partials } : e) }));
},
setActualEntryTime: () => set({ actualEntryTime: Date.now() }),
setPlannedEntryTime: (t) => set({ plannedEntryTime: t }),
addEmotionLog: (log) => {
const entry: EmotionLogState = { id: crypto.randomUUID(), timestamp: Date.now(), ...log };
set(s => ({ emotionLogs: [...s.emotionLogs, entry] }));
},
removeEmotionLog: (idx) => {
set(s => ({ emotionLogs: s.emotionLogs.filter((_, i) => i !== idx) }));
},
setFollowedPlan: (v) => set({ followedPlan: v }),
setDeviationReason: (r) => set({ deviationReason: r }),
addStrength: (s) => set(st => ({ strengths: [...st.strengths, s] })),
removeStrength: (idx) => set(st => ({ strengths: st.strengths.filter((_, i) => i !== idx) })),
addMistake: (desc, stage) => set(st => ({ mistakes: [...st.mistakes, { description: desc, stage }] })),
removeMistake: (idx) => set(st => ({ mistakes: st.mistakes.filter((_, i) => i !== idx) })),
setDiary: (d) => set({ diary: d }),
totalFilledPercent: 0,
averageEntryPrice: 0,
}));
// Compute scale metrics on each state change
useExecutionStore.subscribe((state, prevState) => {
if (prevState.scaleEntries !== state.scaleEntries) {
const s = useExecutionStore.getState();
const total = s.scaleEntries.reduce((sum, e) => sum + e.sizePercent, 0);
const avgPrice = total > 0
? s.scaleEntries.reduce((sum, e) => sum + e.price * (e.sizePercent / 100), 0)
: 0;
useExecutionStore.setState({ totalFilledPercent: total, averageEntryPrice: avgPrice });
}
});
+76
View File
@@ -0,0 +1,76 @@
import { create } from 'zustand';
import type { MonthlyGoal, WeeklyGoal, DailyTask } from '@/types';
interface GoalsStore {
monthlyGoals: MonthlyGoal[];
addMonthlyGoal: (goal: Omit<MonthlyGoal, 'id' | 'createdAt' | 'completed'>) => void;
removeMonthlyGoal: (id: string) => void;
toggleMonthlyGoal: (id: string) => void;
weeklyGoals: WeeklyGoal[];
addWeeklyGoal: (goal: Omit<WeeklyGoal, 'id' | 'completed' | 'weekStarting'>) => void;
removeWeeklyGoal: (id: string) => void;
toggleWeeklyGoal: (id: string) => void;
dailyTasks: DailyTask[];
addDailyTask: (task: Omit<DailyTask, 'id' | 'completed'>) => void;
removeDailyTask: (id: string) => void;
toggleDailyTask: (id: string) => void;
}
const MONTHLY_KEY = 'trader-dashboard:monthly-goals';
const WEEKLY_KEY = 'trader-dashboard:weekly-goals';
const DAILY_KEY = 'trader-dashboard:daily-tasks';
function load<T>(key: string, fallback: T[]): T[] {
try { const s = localStorage.getItem(key); if (s) return JSON.parse(s); } catch {}
return fallback;
}
function save<T>(key: string, data: T) {
try { localStorage.setItem(key, JSON.stringify(data)); } catch {}
}
export const useGoalsStore = create<GoalsStore>((set) => ({
monthlyGoals: load(MONTHLY_KEY, []),
weeklyGoals: load(WEEKLY_KEY, []),
dailyTasks: load(DAILY_KEY, []),
addMonthlyGoal: (goal) => {
const g: MonthlyGoal = { id: crypto.randomUUID(), ...goal, completed: false, createdAt: new Date() };
const u = [...useGoalsStore.getState().monthlyGoals, g];
set({ monthlyGoals: u }); save(MONTHLY_KEY, u);
},
removeMonthlyGoal: (id) => {
const u = useGoalsStore.getState().monthlyGoals.filter(g => g.id !== id);
set({ monthlyGoals: u }); save(MONTHLY_KEY, u);
},
toggleMonthlyGoal: (id) => {
const u = useGoalsStore.getState().monthlyGoals.map(g => g.id === id ? { ...g, completed: !g.completed } : g);
set({ monthlyGoals: u }); save(MONTHLY_KEY, u);
},
addWeeklyGoal: (goal) => {
const g: WeeklyGoal = { id: crypto.randomUUID(), ...goal, completed: false, weekStarting: new Date() };
const u = [...useGoalsStore.getState().weeklyGoals, g];
set({ weeklyGoals: u }); save(WEEKLY_KEY, u);
},
removeWeeklyGoal: (id) => {
const u = useGoalsStore.getState().weeklyGoals.filter(g => g.id !== id);
set({ weeklyGoals: u }); save(WEEKLY_KEY, u);
},
toggleWeeklyGoal: (id) => {
const u = useGoalsStore.getState().weeklyGoals.map(g => g.id === id ? { ...g, completed: !g.completed } : g);
set({ weeklyGoals: u }); save(WEEKLY_KEY, u);
},
addDailyTask: (task) => {
const t: DailyTask = { id: crypto.randomUUID(), ...task, completed: false, date: new Date() };
const u = [...useGoalsStore.getState().dailyTasks, t];
set({ dailyTasks: u }); save(DAILY_KEY, u);
},
removeDailyTask: (id) => {
const u = useGoalsStore.getState().dailyTasks.filter(t => t.id !== id);
set({ dailyTasks: u }); save(DAILY_KEY, u);
},
toggleDailyTask: (id) => {
const u = useGoalsStore.getState().dailyTasks.map(t => t.id === id ? { ...t, completed: !t.completed } : t);
set({ dailyTasks: u }); save(DAILY_KEY, u);
},
}));
+84
View File
@@ -0,0 +1,84 @@
import { create } from 'zustand';
import type { MarketOutlookState, KeyLevel, SetupOnRadar, HighImpactEvent, WeeklyBias, DailyBias, TrendAlignment } from '@/types';
const today = new Date().toISOString();
export const defaultMarketOutlook: MarketOutlookState = {
weeklyBias: 'neutral',
dailyBias: 'range_bound',
trendAlignment: 'neutral',
setupsOnRadar: [],
highImpactEvents: [],
keyLevels: [],
checklistCompleted: false,
};
export interface MarketOutlookStore extends MarketOutlookState {
setWeeklyBias: (bias: WeeklyBias) => void;
setDailyBias: (bias: DailyBias) => void;
recalcAlignment: () => void;
addKeyLevel: (level: KeyLevel) => void;
removeKeyLevel: (id: string) => void;
addSetupOnRadar: (setup: SetupOnRadar) => void;
removeSetupOnRadar: (id: string) => void;
addHighImpactEvent: (event: HighImpactEvent) => void;
toggleChecklist: () => void;
loadFromStorage: () => void;
saveToStorage: () => void;
}
const STORAGE_KEY = 'trader-dashboard:market-outlook';
function loadFromStorage(): MarketOutlookState | null {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) return JSON.parse(stored);
} catch { /* ignore */ }
return null;
}
function saveToStorage(state: MarketOutlookState) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch { /* ignore */ }
}
export const useMarketOutlookStore = create<MarketOutlookStore>((set, get) => ({
...defaultMarketOutlook,
setWeeklyBias: (bias) => set((s) => ({ weeklyBias: bias })),
setDailyBias: (bias) => set((s) => {
s.dailyBias = bias;
return s;
}),
recalcAlignment: () => {
const { weeklyBias, dailyBias } = get();
let alignment: TrendAlignment;
if (dailyBias === 'trend_up' && weeklyBias === 'bullish') alignment = 'pro_trend';
else if (dailyBias === 'trend_down' && weeklyBias === 'bearish') alignment = 'pro_trend';
else if (dailyBias !== 'neutral' && weeklyBias !== 'neutral') alignment = 'counter_trend';
else alignment = 'neutral';
set({ trendAlignment: alignment });
},
addKeyLevel: (level) => set((s) => ({ keyLevels: [...s.keyLevels, level] })),
removeKeyLevel: (id) => set((s) => ({ keyLevels: s.keyLevels.filter(l => l.id !== id) })),
addSetupOnRadar: (setup) => set((s) => ({ setupsOnRadar: [...s.setupsOnRadar, setup] })),
removeSetupOnRadar: (id) => set((s) => ({ setupsOnRadar: s.setupsOnRadar.filter(s => s.id !== id) })),
addHighImpactEvent: (event) => set((s) => ({ highImpactEvents: [...s.highImpactEvents, event] })),
toggleChecklist: () => set((s) => ({ checklistCompleted: !s.checklistCompleted })),
loadFromStorage: () => {
const stored = loadFromStorage();
if (stored) set(stored);
},
saveToStorage: () => saveToStorage(get()),
}));
// Auto-save on change
useMarketOutlookStore.subscribe((state) => saveToStorage(state));
+178
View File
@@ -0,0 +1,178 @@
import { create } from 'zustand';
import type { TradePlan, ConfluenceType, ScaleEntry, SetupTier } from '@/types';
interface TradePlanStore {
activePlans: TradePlan[];
currentPlanId: string | null;
createPlan: () => TradePlan;
setCurrentPlan: (id: string | null) => void;
addActivePlan: (plan: TradePlan) => void;
removePlan: (id: string) => void;
// Environment
setEnvironment: (env: TradePlan['environment']) => void;
// Checklist gate
setChecklistCompleted: (val: boolean) => void;
// Confluences
addConfluence: (c: ConfluenceType) => void;
removeConfluence: (c: ConfluenceType) => void;
setConfluences: (cs: ConfluenceType[]) => void;
// Devil's Advocate
setDevilAdvocate: (text: string) => void;
// Tier scoring
recalcTierAndRisk: () => void;
// Justifications (Why?)
setJustification: (field: 'entryWhy' | 'stopWhy' | 'targetWhy', text: string) => void;
// Targets
addTarget: (price: number, reason: string) => void;
removeTarget: (idx: number) => void;
// Entry/SL
setEntryPrice: (price: number) => void;
setStopLoss: (price: number) => void;
// Status
setStatus: (status: TradePlan['status']) => void;
advanceStatus: () => void;
}
const TIERS: Record<SetupTier, number> = { A_STAR: 2.0, A: 1.5, B: 1.0, C: 0.5 };
function baseTier(confluences: ConfluenceType[], devilAdvocate: string): SetupTier {
const hasHoles = devilAdvocate.trim().length > 10;
if (confluences.length >= 4 && !hasHoles) return 'A_STAR';
if (confluences.length >= 3 && !hasHoles) return 'A';
if (confluences.length >= 2 && !hasHoles) return 'B';
if (confluences.length >= 1 && !hasHoles) return 'B';
return 'C';
}
export const useTradePlanStore = create<TradePlanStore>((set, get) => ({
activePlans: [],
currentPlanId: null,
createPlan: () => {
const plan: TradePlan = {
id: crypto.randomUUID(),
tradeId: `T-${Date.now()}`,
date: new Date(),
status: 'planned',
environment: 'trending',
tier: 'C',
confluenceScore: 0,
riskPercent: 0.5,
confluences: [],
devilAdvocate: '',
positionSize: 0,
targets: [],
justifications: { entryWhy: '', stopWhy: '', targetWhy: '' },
checklistCompleted: false,
};
return plan;
},
setCurrentPlan: (id) => set({ currentPlanId: id }),
addActivePlan: (plan) => {
const s = get();
set({ activePlans: [...s.activePlans, plan] });
},
removePlan: (id) => {
const s = get();
set({ activePlans: s.activePlans.filter(p => p.id !== id), currentPlanId: s.currentPlanId === id ? null : s.currentPlanId });
},
setEnvironment: (env) => set(s => ({ currentPlanId: s.currentPlanId })),
setChecklistCompleted: (val) => {
const s = get();
if (!s.currentPlanId) return;
set({ activePlans: s.activePlans.map(p => p.id === s.currentPlanId ? { ...p, checklistCompleted: val } : p) });
},
addConfluence: (c) => {
const s = get();
if (!s.currentPlanId) return;
set({ activePlans: s.activePlans.map(p => p.id === s.currentPlanId ? { ...p, confluences: [...p.confluences, c] } : p) });
},
removeConfluence: (c) => {
const s = get();
if (!s.currentPlanId) return;
set({ activePlans: s.activePlans.map(p => p.id === s.currentPlanId ? { ...p, confluences: p.confluences.filter(x => x !== c) } : p) });
},
setConfluences: (cs) => {
const s = get();
if (!s.currentPlanId) return;
set({ activePlans: s.activePlans.map(p => p.id === s.currentPlanId ? { ...p, confluences: cs } : p) });
},
setDevilAdvocate: (text) => {
const s = get();
if (!s.currentPlanId) return;
set({ activePlans: s.activePlans.map(p => p.id === s.currentPlanId ? { ...p, devilAdvocate: text } : p) });
},
recalcTierAndRisk: () => {
const s = get();
if (!s.currentPlanId) return;
set({ activePlans: s.activePlans.map(p => p.id === s.currentPlanId ? {
...p,
confluenceScore: p.confluences.length * 10,
tier: baseTier(p.confluences, p.devilAdvocate),
riskPercent: TIERS[baseTier(p.confluences, p.devilAdvocate)],
} : p) });
},
setJustification: (field, text) => {
const s = get();
if (!s.currentPlanId) return;
set({ activePlans: s.activePlans.map(p => p.id === s.currentPlanId ? { ...p, justifications: { ...p.justifications, [field]: text } } : p) });
},
addTarget: (price, reason) => {
const s = get();
if (!s.currentPlanId) return;
set({ activePlans: s.activePlans.map(p => p.id === s.currentPlanId ? { ...p, targets: [...p.targets, { price, reason }] } : p) });
},
removeTarget: (idx) => {
const s = get();
if (!s.currentPlanId) return;
set({ activePlans: s.activePlans.map(p => p.id === s.currentPlanId ? { ...p, targets: p.targets.filter((_, i) => i !== idx) } : p) });
},
setEntryPrice: (price) => {
const s = get();
if (!s.currentPlanId) return;
set({ activePlans: s.activePlans.map(p => p.id === s.currentPlanId ? { ...p, entryPrice: price } : p) });
},
setStopLoss: (price) => {
const s = get();
if (!s.currentPlanId) return;
set({ activePlans: s.activePlans.map(p => p.id === s.currentPlanId ? { ...p, stopLoss: price } : p) });
},
setStatus: (status) => {
const s = get();
if (!s.currentPlanId) return;
set({ activePlans: s.activePlans.map(p => p.id === s.currentPlanId ? { ...p, status } : p) });
},
advanceStatus: () => {
const s = get();
if (!s.currentPlanId) return;
const p = s.activePlans.find(x => x.id === s.currentPlanId);
if (!p) return;
const next: Record<string, TradePlan['status']> = { planned: 'active', active: 'closed' };
set({ activePlans: s.activePlans.map(x => x.id === s.currentPlanId ? { ...x, status: next[p.status] || p.status } : x) });
},
}));
+168
View File
@@ -0,0 +1,168 @@
// --- Enums ---
export type WeeklyBias = 'bullish' | 'bearish' | 'neutral';
export type DailyBias = 'trend_up' | 'trend_down' | 'range_bound' | 'neutral';
export type TrendAlignment = 'pro_trend' | 'counter_trend' | 'neutral';
export type TradeEnvironment = 'trending' | 'ranging' | 'transitioning';
export type TradeStatus = 'planned' | 'active' | 'closed';
export type SetupTier = 'A_STAR' | 'A' | 'B' | 'C';
export type ExecutionPlaybook = 'mean_reversion' | 'range_extremity';
export type MistakeStage = 'analysis' | 'planning' | 'execution' | 'management' | 'closure';
// --- Market Outlook Types ---
export interface KeyLevel {
id: string;
type: 'swing_high' | 'swing_low' | 'supply' | 'demand' | 'support' | 'resistance';
price: number;
timeFrame: string;
strength: 'low' | 'medium' | 'high';
}
export interface SetupOnRadar {
id: string;
symbol: string;
setupType: 'mean_reversion' | 'range_extreme';
price: number;
confluenceCount: number;
status: 'watching' | 'active';
}
export interface HighImpactEvent {
id: string;
name: string;
time: Date;
impact: 'high' | 'medium' | 'low';
forecast: string;
previous: string;
}
export interface MarketOutlookState {
weeklyBias: WeeklyBias;
dailyBias: DailyBias;
trendAlignment: TrendAlignment;
setupsOnRadar: SetupOnRadar[];
highImpactEvents: HighImpactEvent[];
keyLevels: KeyLevel[];
checklistCompleted: boolean;
}
// --- Goals Types ---
export interface MonthlyGoal {
id: string;
description: string;
completed: boolean;
createdAt: Date;
}
export interface WeeklyGoal {
id: string;
monthlyGoalId: string;
description: string;
completed: boolean;
weekStarting: Date;
}
export interface DailyTask {
id: string;
description: string;
completed: boolean;
date: Date;
}
// --- Trade Plan Types ---
export type ConfluenceType = 'divergence' | 'ema' | 'composite' | 'single_print';
export interface TradeJustification {
entryWhy: string;
stopWhy: string;
targetWhy: string;
}
export interface ScaleEntry {
id: string;
price: number;
sizePercent: number;
strategy: string;
}
export interface TradePlan {
id: string;
tradeId: string;
date: Date;
status: TradeStatus;
environment: TradeEnvironment;
tier: SetupTier;
confluenceScore: number;
riskPercent: number;
// Confluences
confluences: ConfluenceType[];
devilAdvocate: string;
// Sizing
positionSize: number;
entryPrice?: number;
stopLoss?: number;
targets: { price: number; reason: string }[];
// Justifications
justifications: TradeJustification;
// Checklist
checklistCompleted: boolean;
}
// --- Execution Types ---
export interface ExecutionRecord {
tradePlanId: string;
executionPlaybook: ExecutionPlaybook;
checklistCompleted: boolean;
scaleEntries: ScaleEntry[];
actualEntryTime?: Date;
plannedEntryTime?: Date;
}
// --- Trade Closure Types ---
export interface EmotionLog {
id: string;
timestamp: Date;
priceAtEvent: number;
emotion: string;
note: string;
}
export interface TradeClosureRecord {
id: string;
executionRecordId: string;
// Emotion logger
emotionLogs: EmotionLog[];
// Post-trade review
followedPlan: boolean;
deviationReason?: string;
strengths: string[];
mistakes: { description: string; stage: MistakeStage }[];
// Diary
diary: string;
postReviewTimestamp?: Date;
}
// --- KPI Types ---
export interface DashboardKPI {
winRate: number;
avgConfluenceScore: number;
executionAdherence: number;
topMistakeStage: MistakeStage | null;
currentStreak: number;
totalTrades: number;
}
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}