From 224e2e0157a5a509bec6cf20239b53aadfe3d26d Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 6 Oct 2026 22:07:42 +0000
Subject: [PATCH] D606: the Eadwyn Tickets canvas, built (#1107, #1108)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The owner's Eadwyn Tickets canvas, its four screens built:
- T1, T1b: /hq/review, the Super Admin's review queue. Two tabs counting
what waits (withheld, never 0, while a list is loading or unreadable),
a title search, Priority and Date filters, "1-25 of [N]", the most
pressing first, and Approve and Decline on the item they decide, each
with a typed reason (D544's rule). A task has the audience picker and
"Approve · +N credits", N from the credit rules.
- T2, T3: the Eadwyn chat's panel. Conversation, Tasks (community tasks
with Start, my suggestions with what each grant paid, Suggest a task)
and My tickets (the balance, each ticket's bounty or review, what a
fixed bug earns by tier).
Worker first: the review lists return the number waiting (`total`) and a
task's issue number; each approved suggestion carries the credits its
grant paid; GET /api/community-tasks/rewards gives members the amounts the
panels state. The chat's ticket form loses its preset type (D544), except
from "Suggest a task". H7 links a feature request awaiting review to the
queue, for the Super Admin.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_018jkNYLq29UXAvcqYxGCSB3
---
.../src/routes/admin_ticket_reviews.ts | 25 +-
.../src/routes/community_tasks.ts | 75 +++
.../test/eadwyn_tickets_d606.test.ts | 220 +++++++++
.../test/ticket_type_required_d544.test.ts | 11 +-
design/canvases/README.md | 12 +-
.../integrated/Eadwyn Tickets Screen.dc.html | 140 ++++++
.../integrated/Eadwyn Tickets.dc.html | 39 ++
design/incoming/README.md | 7 +-
documentation/architecture/PAGE_INVENTORY.md | 2 +-
documentation/architecture/PROFILE_ROUTING.md | 14 +-
documentation/architecture/ROUTE_MAP.md | 2 +
documentation/architecture/decisions/D544.md | 3 +-
documentation/architecture/decisions/D545.md | 3 +-
documentation/architecture/decisions/D604.md | 4 +-
documentation/architecture/decisions/D606.md | 171 +++++++
frontend/src/App.jsx | 3 +
.../src/components/advisor/EadwynPanels.jsx | 265 ++++++++++
.../components/advisor/PersonalAdvisor.jsx | 123 +++--
frontend/src/lib/api.js | 1 +
frontend/src/lib/api/communityTasks.js | 4 +
frontend/src/lib/api/ticketReviews.js | 18 +
frontend/src/lib/eadwynTickets.js | 269 +++++++++++
frontend/src/pages/TicketsPage.jsx | 12 +-
frontend/src/pages/hq/HqSupportPage.jsx | 6 +
frontend/src/pages/hq/ReviewQueuePage.jsx | 457 ++++++++++++++++++
frontend/src/sidebarConfig.js | 3 +-
frontend/test/eadwyn_tickets_d606.test.mjs | 382 +++++++++++++++
frontend/test/profile_routing_fresh.test.mjs | 4 +-
28 files changed, 2218 insertions(+), 57 deletions(-)
create mode 100644 cloudflare-worker/test/eadwyn_tickets_d606.test.ts
create mode 100644 design/canvases/integrated/Eadwyn Tickets Screen.dc.html
create mode 100644 design/canvases/integrated/Eadwyn Tickets.dc.html
create mode 100644 documentation/architecture/decisions/D606.md
create mode 100644 frontend/src/components/advisor/EadwynPanels.jsx
create mode 100644 frontend/src/lib/api/ticketReviews.js
create mode 100644 frontend/src/lib/eadwynTickets.js
create mode 100644 frontend/src/pages/hq/ReviewQueuePage.jsx
create mode 100644 frontend/test/eadwyn_tickets_d606.test.mjs
diff --git a/cloudflare-worker/src/routes/admin_ticket_reviews.ts b/cloudflare-worker/src/routes/admin_ticket_reviews.ts
index bea0b65de6..2c3d826e39 100644
--- a/cloudflare-worker/src/routes/admin_ticket_reviews.ts
+++ b/cloudflare-worker/src/routes/admin_ticket_reviews.ts
@@ -3,9 +3,11 @@
* tasks. Mounted at /api/admin/ticket-reviews.
*
* GET /features feature requests not yet decided
+ * { items, count, total }
* POST /features/:ticketId/approve { reason }
* POST /features/:ticketId/decline { reason } the user is sent it
* GET /tasks suggested tasks still pending
+ * { items, count, total, audiences }
* POST /tasks/:id/approve { audience, reason }
* POST /tasks/:id/decline { reason } the user is sent it
*
@@ -26,7 +28,11 @@
* through D540's `grantCredits`, keyed `task:` so it is paid once.
* Declining sends the reason.
*
- * The page that drives these waits on its design (#1107 is `needs-design`).
+ * D606 — the page is `/hq/review` (`pages/hq/ReviewQueuePage.jsx`), built to
+ * the owner's Eadwyn Tickets canvas. A list reads the oldest 200 waiting;
+ * `count` is how many came back and `total` how many are waiting, so a page
+ * past the 200th says how many it cannot show rather than a short number.
+ * A task carries its GitHub issue's number beside its link, as a feature does.
*/
import { Hono, type Context } from 'hono';
import type { Env } from '../types';
@@ -113,7 +119,12 @@ r.get('/features', async (c) => {
ticket_id: x.id, title: x.title, body: x.description, priority: x.priority, created_at: x.created_at,
asked_by: asker(x), github_issue_number: x.github_issue_number ?? null, github_issue_url: x.github_issue_url ?? null,
}));
- return c.json({ items, count: items.length });
+ const waiting = await c.env.DB.prepare(
+ `SELECT COUNT(*) AS n FROM tickets t
+ WHERE t.type = 'feature'
+ AND NOT EXISTS (SELECT 1 FROM feature_request_reviews f WHERE f.ticket_id = t.id)`,
+ ).first<{ n: number }>();
+ return c.json({ items, count: items.length, total: Math.max(Number(waiting?.n ?? 0), items.length) });
} catch (e) { return mapError(c, e); }
});
@@ -163,7 +174,7 @@ r.get('/tasks', async (c) => {
await requireSuperAdmin(c);
const rows = await c.env.DB.prepare(
`SELECT ct.id, ct.ticket_id, ct.title, ct.body, ct.created_at, t.priority, t.user_id, t.submitted_by,
- t.github_issue_url, u.name AS user_name, u.email AS user_email
+ t.github_issue_number, t.github_issue_url, u.name AS user_name, u.email AS user_email
FROM community_tasks ct
JOIN tickets t ON t.id = ct.ticket_id
LEFT JOIN users u ON u.id = t.user_id
@@ -172,9 +183,13 @@ r.get('/tasks', async (c) => {
).all();
const items = (rows.results || []).map((x: any) => ({
id: x.id, ticket_id: x.ticket_id, title: x.title, body: x.body, priority: x.priority, created_at: x.created_at,
- asked_by: asker(x), github_issue_url: x.github_issue_url ?? null,
+ asked_by: asker(x), github_issue_number: x.github_issue_number ?? null, github_issue_url: x.github_issue_url ?? null,
}));
- return c.json({ items, count: items.length, audiences: AUDIENCES });
+ const waiting = await c.env.DB.prepare(
+ `SELECT COUNT(*) AS n FROM community_tasks ct JOIN tickets t ON t.id = ct.ticket_id
+ WHERE ct.status = 'pending' AND t.type = 'task'`,
+ ).first<{ n: number }>();
+ return c.json({ items, count: items.length, total: Math.max(Number(waiting?.n ?? 0), items.length), audiences: AUDIENCES });
} catch (e) { return mapError(c, e); }
});
diff --git a/cloudflare-worker/src/routes/community_tasks.ts b/cloudflare-worker/src/routes/community_tasks.ts
index 97f129ef11..726968aeb8 100644
--- a/cloudflare-worker/src/routes/community_tasks.ts
+++ b/cloudflare-worker/src/routes/community_tasks.ts
@@ -17,6 +17,23 @@
* null when this page is the last. Older suggestions are
* one request away, never silently cut off.
*
+ * GET /rewards { rewards } (D606)
+ *
+ * rewards what an approved suggestion and a fixed bug earn today,
+ * from D540's `credit_reward_rules`, for the Eadwyn panels'
+ * notes: `task_approved`, `bug_high`, `bug_medium` and
+ * `bug_low`, each `{ credits, active }`, or null when the
+ * table has no such rule. A failed read is a refusal,
+ * never zeros.
+ *
+ * D606 — each approved suggestion also carries `reward`: the credits its
+ * grant actually paid (the ledger row D544's approval wrote, keyed
+ * `task:`), what the suggester got rather than what the rule says now.
+ * `credits` is null when no grant was paid (refused, or the rule was off);
+ * `revoked` says a grant was taken back; a ledger that cannot be read says
+ * `credits_unreadable` and the list is still served. A pending or declined
+ * suggestion carries null.
+ *
* The store is D544's `community_tasks` (migration 383). A read that fails is
* a 500 refusal, never an empty list: "No tasks yet" and "could not read the
* tasks" are different sentences, and the panel prints each.
@@ -35,6 +52,41 @@ const r = new Hono<{ Bindings: Env }>();
const SUGGESTIONS_PAGE = 100;
+/** The rules the Eadwyn panels state, in the order the notes name them. */
+export const PANEL_REWARD_KEYS = ['task_approved', 'bug_high', 'bug_medium', 'bug_low'] as const;
+
+export type SuggestionReward = { credits: number | null; revoked: boolean } | { credits: null; credits_unreadable: true } | null;
+
+/**
+ * The grant each approved suggestion paid, from the ledger. Same reading as
+ * D542's `bountySummaries`: the grant's own delta, revoked when an
+ * `admin_adjust` row names it.
+ */
+async function suggestionRewards(env: Env, userId: number, rows: Array<{ id: number; status: string }>): Promise> {
+ const out = new Map();
+ const approved = rows.filter((x) => x.status === 'approved');
+ for (const x of rows) out.set(Number(x.id), null);
+ if (!approved.length) return out;
+ try {
+ const { results } = await env.DB.prepare(
+ `SELECT g.source_ref, g.delta,
+ EXISTS (SELECT 1 FROM perk_credit_ledger r
+ WHERE r.user_id = g.user_id AND r.kind = 'admin_adjust' AND r.source_ref = 'revoke:' || g.id) AS revoked
+ FROM perk_credit_ledger g
+ WHERE g.user_id = ? AND g.kind = 'grant' AND g.source_ref IN (SELECT value FROM json_each(?))`,
+ ).bind(userId, JSON.stringify(approved.map((x) => `task:${x.id}`))).all<{ source_ref: string; delta: number; revoked: number }>();
+ const byRef = new Map((results || []).map((g) => [g.source_ref, g]));
+ for (const x of approved) {
+ const g = byRef.get(`task:${x.id}`);
+ out.set(Number(x.id), { credits: g ? Number(g.delta) : null, revoked: !!(g && Number(g.revoked)) });
+ }
+ } catch (e) {
+ console.warn('[community-tasks] ledger unreadable for the suggestion rewards', (e as Error).message);
+ for (const x of approved) out.set(Number(x.id), { credits: null, credits_unreadable: true });
+ }
+ return out;
+}
+
/** The audience a role reads, or null for "only the tasks for everyone". */
export function audienceFor(role: unknown): string | null {
const v = String(role ?? '').toLowerCase();
@@ -75,6 +127,7 @@ r.get('/', async (c) => {
const mineRows = mine.results || [];
const page = mineRows.slice(0, SUGGESTIONS_PAGE);
const next = mineRows.length > SUGGESTIONS_PAGE ? page[page.length - 1].id : null;
+ const rewards = await suggestionRewards(c.env, Number(user.id), page);
return c.json({
audience: seesAll ? 'admin' : (audience ?? 'all'),
tasks: (tasks.results || []).map((x: any) => ({
@@ -85,6 +138,7 @@ r.get('/', async (c) => {
// The reason belongs to a decline only; a pending or approved row has none.
decline_reason: x.status === 'declined' ? (x.decline_reason ?? null) : null,
created_at: x.created_at, decided_at: x.decided_at ?? null,
+ reward: rewards.get(Number(x.id)) ?? null,
})),
my_suggestions_next: next,
});
@@ -97,4 +151,25 @@ r.get('/', async (c) => {
}
});
+r.get('/rewards', async (c) => {
+ try { await requireAuth(c); } catch (e) { return mapError(c, e); }
+ try {
+ const { results } = await c.env.DB.prepare(
+ `SELECT key, credits, active FROM credit_reward_rules WHERE key IN (SELECT value FROM json_each(?))`,
+ ).bind(JSON.stringify(PANEL_REWARD_KEYS)).all<{ key: string; credits: number; active: number }>();
+ const byKey = new Map((results || []).map((x) => [x.key, x]));
+ const rewards = Object.fromEntries(PANEL_REWARD_KEYS.map((k) => {
+ const x = byKey.get(k);
+ return [k, x ? { credits: Number(x.credits), active: Number(x.active) === 1 } : null];
+ }));
+ return c.json({ rewards });
+ } catch (e) {
+ return refuse(c, 500, {
+ code: 'rewards_unreadable',
+ message: 'What a task or a bug report earns could not be read. This is not a sign it earns nothing; try again.',
+ raw: e,
+ });
+ }
+});
+
export default r;
diff --git a/cloudflare-worker/test/eadwyn_tickets_d606.test.ts b/cloudflare-worker/test/eadwyn_tickets_d606.test.ts
new file mode 100644
index 0000000000..03ee75506a
--- /dev/null
+++ b/cloudflare-worker/test/eadwyn_tickets_d606.test.ts
@@ -0,0 +1,220 @@
+/**
+ * D606 (#1107, #1108) — what the Eadwyn Tickets screens read from the Worker.
+ *
+ * - The review queue's lists say how many are waiting (`total`), not only
+ * how many came back: a list reads the oldest 200, so a queue past the
+ * 200th would otherwise show a short number. A task carries its GitHub
+ * issue's number, as a feature does.
+ * - Each approved suggestion carries `reward`, the credits its grant paid
+ * (the ledger row D544's approval writes, keyed `task:`), and says
+ * when the grant was taken back or the ledger could not be read.
+ * - `GET /api/community-tasks/rewards` gives every signed-in member the
+ * amounts the panels state, from D540's rules; a failed read is a
+ * refusal, never zeros.
+ *
+ * The real routers run over node:sqlite with `users`, `tickets`,
+ * `activity_logs` and the ledger from `schema_baseline.sql`, plus migrations
+ * 381 (the rules) and 383 (the review store, its triggers included).
+ *
+ * Run:
+ * node --experimental-strip-types --no-warnings --import ./cloudflare-worker/test/_ts-loader.mjs \
+ * --test cloudflare-worker/test/eadwyn_tickets_d606.test.ts
+ */
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { DatabaseSync } from 'node:sqlite';
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+import { SignJWT } from 'jose';
+
+import reviews from '../src/routes/admin_ticket_reviews.ts';
+import communityTasks, { PANEL_REWARD_KEYS } from '../src/routes/community_tasks.ts';
+
+const JWT_SECRET = 'unit-test-jwt-secret-0123456789-abcdef';
+const SUPER = 1, FOUNDER = 10, OTHER = 11;
+const ROLE: Record = { [SUPER]: 'admin', [FOUNDER]: 'founder', [OTHER]: 'founder' };
+const read = (p: string) => readFileSync(resolve(process.cwd(), p), 'utf8');
+const BASELINE = read('cloudflare-worker/sql/schema_baseline.sql');
+function ddl(name: string): string {
+ const at = `\n${BASELINE}`.indexOf(`\nCREATE TABLE ${name} (`);
+ assert.ok(at >= 0, `${name} is no longer defined in schema_baseline.sql`);
+ return BASELINE.slice(at, BASELINE.indexOf(');', at) + 2);
+}
+
+function freshDb() {
+ const db = new DatabaseSync(':memory:', { enableForeignKeyConstraints: false, enableDoubleQuotedStringLiterals: true });
+ for (const t of ['users', 'tickets', 'activity_logs', 'perk_credit_ledger']) db.exec(ddl(t));
+ db.exec(`CREATE UNIQUE INDEX idx_perk_ledger_idem ON perk_credit_ledger(user_id, kind, source_ref);
+ CREATE TABLE super_admins (user_id INTEGER PRIMARY KEY, granted_by_user_id INTEGER, granted_at TEXT);`);
+ db.exec(read('cloudflare-worker/sql/migrations/381_credit_reward_rules.sql'));
+ db.exec(read('cloudflare-worker/sql/migrations/383_community_tasks.sql'));
+ const u = db.prepare(`INSERT INTO users (id, role, name, email, is_active) VALUES (?, ?, ?, ?, 1)`);
+ for (const [id, role] of Object.entries(ROLE)) u.run(Number(id), role, `U${id}`, `u${id}@example.test`);
+ db.prepare('INSERT INTO super_admins (user_id) VALUES (?)').run(SUPER);
+ return db;
+}
+
+const ticket = (db: any, type: string, o: { user?: number; issue?: number | null; title?: string } = {}) =>
+ Number(db.prepare(`INSERT INTO tickets (title, description, priority, type, user_id, submitted_by, github_issue_number, github_issue_url)
+ VALUES (?, 'Details', 'high', ?, ?, 'U', ?, ?)`)
+ .run(o.title ?? `A ${type}`, type, o.user ?? FOUNDER, o.issue ?? null,
+ o.issue ? `https://github.com/acme/tickets/issues/${o.issue}` : null).lastInsertRowid);
+const taskRowFor = (db: any, ticketId: number) => Number((db.prepare('SELECT id FROM community_tasks WHERE ticket_id = ?').get(ticketId) as any).id);
+const decide = (db: any, taskId: number, status: 'approved' | 'declined', reason: string | null = null) =>
+ db.prepare(`UPDATE community_tasks SET status = ?, decline_reason = ?, decided_by = ?, decided_at = '2026-10-05T10:00:00.000Z' WHERE id = ?`)
+ .run(status, reason, SUPER, taskId);
+const grant = (db: any, userId: number, ref: string, delta: number) =>
+ Number(db.prepare(`INSERT INTO perk_credit_ledger (user_id, delta, kind, source_ref, note, rule_key) VALUES (?, ?, 'grant', ?, 'x', 'task_approved')`)
+ .run(userId, delta, ref).lastInsertRowid);
+const revoke = (db: any, userId: number, grantId: number, delta: number) =>
+ db.prepare(`INSERT INTO perk_credit_ledger (user_id, delta, kind, source_ref, note) VALUES (?, ?, 'admin_adjust', ?, 'taken back')`)
+ .run(userId, -delta, `revoke:${grantId}`);
+
+function makeD1(db: InstanceType, failOn?: RegExp) {
+ return {
+ prepare(sql: string) {
+ let b: any[] = [];
+ const boom = () => { if (failOn && failOn.test(sql)) throw new Error('D1_ERROR: disk I/O error'); };
+ const api: any = {
+ bind: (...x: any[]) => { b = x.map((v) => (v === undefined ? null : v)); return api; },
+ async first() { boom(); return db.prepare(sql).get(...b) ?? null; },
+ async all() { boom(); return { results: db.prepare(sql).all(...b) }; },
+ async run() {
+ boom();
+ const r = db.prepare(sql).run(...b);
+ return { meta: { last_row_id: Number(r.lastInsertRowid), changes: Number(r.changes) } };
+ },
+ };
+ return api;
+ },
+ };
+}
+
+async function call(router: any, db: any, path: string, who: number | null, failOn?: RegExp) {
+ const headers: Record = {};
+ if (who !== null) {
+ headers.Authorization = `Bearer ${await new SignJWT({ user_id: who, role: ROLE[who] }).setProtectedHeader({ alg: 'HS256' })
+ .setIssuedAt().setExpirationTime('1h').sign(new TextEncoder().encode(JWT_SECRET))}`;
+ }
+ const res = await router.request(path, { headers }, { JWT_SECRET, ENVIRONMENT: 'development', DB: makeD1(db, failOn) } as any);
+ return { status: res.status, body: (await res.json().catch(() => null)) as any };
+}
+const quiet = async (fn: () => Promise) => {
+ const w = console.warn, e = console.error; console.warn = () => {}; console.error = () => {};
+ try { return await fn(); } finally { console.warn = w; console.error = e; }
+};
+
+// ---- the review queue's lists ------------------------------------------------------
+
+test('the feature list says how many are waiting, past the 200 it returns, and leaves decided ones out', async () => {
+ const db = freshDb();
+ const ins = db.prepare(`INSERT INTO tickets (title, priority, type, user_id) VALUES (?, 'low', 'feature', ?)`);
+ for (let i = 0; i < 203; i++) ins.run(`Feature ${i}`, FOUNDER);
+ const decided = Number((db.prepare(`SELECT id FROM tickets ORDER BY id LIMIT 1`).get() as any).id);
+ db.prepare(`INSERT INTO feature_request_reviews (ticket_id, status, decided_by, decided_at) VALUES (?, 'approved', ?, '2026-10-05')`).run(decided, SUPER);
+ ticket(db, 'bug');
+ const r = await call(reviews, db, '/features', SUPER);
+ assert.equal(r.status, 200, JSON.stringify(r.body));
+ assert.equal(r.body.items.length, 200);
+ assert.equal(r.body.count, 200);
+ assert.equal(r.body.total, 202, 'the decided feature or the bug was counted, or the page capped the total');
+});
+
+test('the task list carries each issue number and a total of pending suggestions only', async () => {
+ const db = freshDb();
+ const a = ticket(db, 'task', { issue: 1301, title: 'Share a cap-table template' });
+ const b = ticket(db, 'task', { title: 'Post your first update' });
+ const c = ticket(db, 'task', { title: 'Declined idea' });
+ decide(db, taskRowFor(db, c), 'declined', 'Not useful to others');
+ ticket(db, 'feature');
+ const r = await call(reviews, db, '/tasks', SUPER);
+ assert.equal(r.status, 200, JSON.stringify(r.body));
+ assert.equal(r.body.total, 2);
+ assert.equal(r.body.count, 2);
+ const by = Object.fromEntries(r.body.items.map((x: any) => [x.ticket_id, x]));
+ assert.equal(by[a].github_issue_number, 1301);
+ assert.equal(by[a].github_issue_url, 'https://github.com/acme/tickets/issues/1301');
+ assert.equal(by[b].github_issue_number, null);
+ assert.deepEqual(r.body.audiences, ['all', 'founder', 'investor', 'advisor', 'partner', 'explorer']);
+});
+
+test('the lists stay the Super Admin\'s', async () => {
+ const db = freshDb();
+ for (const path of ['/features', '/tasks']) {
+ const r = await quiet(() => call(reviews, db, path, FOUNDER));
+ assert.equal(r.status, 403, path);
+ assert.equal('total' in (r.body || {}), false);
+ }
+});
+
+// ---- what each approved suggestion was paid ------------------------------------------
+
+test('an approved suggestion carries the credits its grant paid; pending and declined carry none', async () => {
+ const db = freshDb();
+ const paid = taskRowFor(db, ticket(db, 'task', { title: 'Paid' }));
+ const back = taskRowFor(db, ticket(db, 'task', { title: 'Taken back' }));
+ const unpaid = taskRowFor(db, ticket(db, 'task', { title: 'Approved, nothing paid' }));
+ const pending = taskRowFor(db, ticket(db, 'task', { title: 'Pending' }));
+ const declined = taskRowFor(db, ticket(db, 'task', { title: 'Declined' }));
+ for (const id of [paid, back, unpaid]) decide(db, id, 'approved');
+ decide(db, declined, 'declined', 'Not useful to others');
+ grant(db, FOUNDER, `task:${paid}`, 10);
+ const g = grant(db, FOUNDER, `task:${back}`, 12);
+ revoke(db, FOUNDER, g, 12);
+ // Someone else's grant under the same key shape is not this member's.
+ grant(db, OTHER, `task:${unpaid}`, 10);
+ const r = await call(communityTasks, db, '/', FOUNDER);
+ assert.equal(r.status, 200, JSON.stringify(r.body));
+ const by = Object.fromEntries(r.body.my_suggestions.map((s: any) => [s.id, s.reward]));
+ assert.deepEqual(by[paid], { credits: 10, revoked: false });
+ assert.deepEqual(by[back], { credits: 12, revoked: true });
+ assert.deepEqual(by[unpaid], { credits: null, revoked: false }, 'another member\'s grant was read as this one\'s');
+ assert.equal(by[pending], null);
+ assert.equal(by[declined], null);
+});
+
+test('a ledger that cannot be read says so on each approved suggestion, and the list is still served', async () => {
+ const db = freshDb();
+ const ok = taskRowFor(db, ticket(db, 'task', { title: 'Approved' }));
+ const pending = taskRowFor(db, ticket(db, 'task', { title: 'Pending' }));
+ decide(db, ok, 'approved');
+ grant(db, FOUNDER, `task:${ok}`, 10);
+ const r = await quiet(() => call(communityTasks, db, '/', FOUNDER, /FROM perk_credit_ledger g/));
+ assert.equal(r.status, 200, JSON.stringify(r.body));
+ const by = Object.fromEntries(r.body.my_suggestions.map((s: any) => [s.id, s.reward]));
+ assert.deepEqual(by[ok], { credits: null, credits_unreadable: true });
+ assert.equal(by[pending], null);
+});
+
+// ---- what the panels state ---------------------------------------------------------
+
+test('the rewards read gives the four amounts the panels state, as the rules hold them today', async () => {
+ const db = freshDb();
+ db.prepare(`UPDATE credit_reward_rules SET credits = 175 WHERE key = 'bug_high'`).run();
+ db.prepare(`UPDATE credit_reward_rules SET active = 0 WHERE key = 'bug_low'`).run();
+ const r = await call(communityTasks, db, '/rewards', FOUNDER);
+ assert.equal(r.status, 200, JSON.stringify(r.body));
+ assert.deepEqual(Object.keys(r.body.rewards), [...PANEL_REWARD_KEYS]);
+ assert.deepEqual(r.body.rewards, {
+ task_approved: { credits: 10, active: true },
+ bug_high: { credits: 175, active: true },
+ bug_medium: { credits: 60, active: true },
+ bug_low: { credits: 20, active: false },
+ });
+ assert.doesNotMatch(JSON.stringify(r.body), /feature_shipped|updated_by/);
+});
+
+test('a rule the table lacks is null, never zero; a failed read is a refusal; signed out is refused', async () => {
+ const db = freshDb();
+ db.prepare(`DELETE FROM credit_reward_rules WHERE key = 'task_approved'`).run();
+ const r = await call(communityTasks, db, '/rewards', FOUNDER);
+ assert.equal(r.status, 200);
+ assert.equal(r.body.rewards.task_approved, null);
+ const bad = await quiet(() => call(communityTasks, db, '/rewards', FOUNDER, /FROM credit_reward_rules/));
+ assert.equal(bad.status, 500);
+ assert.equal(bad.body.error, 'rewards_unreadable');
+ assert.equal('rewards' in bad.body, false);
+ assert.doesNotMatch(JSON.stringify(bad.body), /disk I\/O/);
+ const out = await call(communityTasks, db, '/rewards', null);
+ assert.equal(out.status, 401);
+});
diff --git a/cloudflare-worker/test/ticket_type_required_d544.test.ts b/cloudflare-worker/test/ticket_type_required_d544.test.ts
index f1c91cdb23..e53a931f00 100644
--- a/cloudflare-worker/test/ticket_type_required_d544.test.ts
+++ b/cloudflare-worker/test/ticket_type_required_d544.test.ts
@@ -82,6 +82,15 @@ test('both callers that create tickets send a type', () => {
/const found = formErrors\(form\);[\s\S]{0,200}if \(found\.type\) \{ typeRef\.current\?\.focus\(\); return; \}[\s\S]*const res = await api\.createTicket\(form\)/,
'the Tickets page form sends a ticket without a type',
);
+ // D606 (#1107, #1108) — the chat's form follows the same rule: no default
+ // type, except from the Tasks panel's "Suggest a task", which opens it on
+ // Task; the submit stops before the request while none is picked.
const pa = readFileSync('frontend/src/components/advisor/PersonalAdvisor.jsx', 'utf8');
- assert.match(pa, /const \[type, setType\] = useState\('task'\);/);
+ assert.match(pa, /const \[type, setType\] = useState\(initialType \|\| ''\);/, 'the chat form presets a type again');
+ assert.match(
+ pa,
+ /if \(!TICKET_TYPES\.some\(\(t\) => t\.id === type\)\) \{[\s\S]{0,120}return;\s*\}[\s\S]{0,400}api\.createTicket\(/,
+ 'the chat form sends a ticket without a type',
+ );
+ assert.match(pa, /const suggestTask = useCallback\(\(\) => \{ setTicketPreset\('task'\); setTicketOpen\(true\); \}, \[\]\);/);
});
diff --git a/design/canvases/README.md b/design/canvases/README.md
index 8b8cb03b0f..21b54d432f 100644
--- a/design/canvases/README.md
+++ b/design/canvases/README.md
@@ -5,7 +5,7 @@ The corpus the first integration pass worked from. Sorted by one question:
| Folder | Count | Meaning |
| --- | --- | --- |
-| `integrated/` | 79 | A route is running on main. Graded `CURRENT`, `UPGRADE` or `RESKIN` — the surface exists, and for `UPGRADE`/`RESKIN` the canvas is a *diff against* something already live. |
+| `integrated/` | 81 | A route is running on main. Graded `CURRENT`, `UPGRADE` or `RESKIN` — the surface exists, and for `UPGRADE`/`RESKIN` the canvas is a *diff against* something already live. |
| `backlog/` | 28 | Graded `NEW` or `DEFERRED`. No route yet. |
| `out-of-scope/` | 26 | Deliberately not being built. The `ROUTE_MAP.md` row says why. |
| `shared/` | 6 | The dc-runtime bundle every canvas loads, plus two standalone pitch-deck exports. Generated — both code scanners ignore this tree. Every canvas asks for it as `src="./support.js"`, which resolves next to the canvas and **not** to this folder, so opening one straight from `integrated/` in a browser gets a blank page: copy `shared/support.js` beside it first. Nothing runs these at runtime, so the reference is nominal — it is a convention marking "the shared runtime, not an inlined 69KB copy", and `scripts/read-canvas.mjs` reproduces it when it decodes a new export. |
@@ -88,9 +88,17 @@ They came from the intake queue, not `backlog/`, so `backlog/` did not move.
`Ops Page` is the owner's newer export of the same day, whose one change is
the Account center's section list on a phone.
+**2026-10-06 (D606), two canvases arrived with their routes.** The owner's
+Eadwyn Tickets canvas (#1107, #1108) is a board, `Eadwyn Tickets`, and the
+component every artboard imports, `Eadwyn Tickets Screen`. Its four screens
+are built in the same change: the Super Admin's review queue at `/hq/review`
+(T1, T1b) and the Eadwyn chat's Tasks and My tickets panels (T2, T3). So both
+files went straight to `integrated/`, as `Help Ticket Screen` did:
+`integrated/` went **79 → 81**. Nothing left another folder.
+
**The `107` in the title is the first audit's corpus, not today's total.** It
is `54 + 26 + 27` — the sum as it stood when `integrated/` read 54. The three
-folders hold `79 + 28 + 26 = 133` as of 2026-10-06, counted by listing them,
+folders hold `81 + 28 + 26 = 135` as of 2026-10-06, counted by listing them,
and the title keeps 107 because the paragraph below uses it for the audit it
names.
diff --git a/design/canvases/integrated/Eadwyn Tickets Screen.dc.html b/design/canvases/integrated/Eadwyn Tickets Screen.dc.html
new file mode 100644
index 0000000000..cf5ff28285
--- /dev/null
+++ b/design/canvases/integrated/Eadwyn Tickets Screen.dc.html
@@ -0,0 +1,140 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Axal VC
+ AXAL VC HQ
+ {{ s.t }}
+
+
+
+
+
{{ area }} {{ route }}
+
+
+
+
+
+
+ Review queue Feature requests and suggested tasks filed from the Eadwyn chat. Approve sends a feature to the build queue; approving a task publishes it and rewards the suggester.
+ {{ t.t }} {{ t.n }}
+ Search titles… {{ f }} ▾ 1–25 of [N]
+
+
+
+
{{ i.title }} {{ i.prio }} {{ i.type }}
+
{{ i.text }}
+
+
+
+
Publish to {{ i.audience }} ▾
+
{{ approveLabel }} Decline…
+
+
+
+
+
Approve · confirm
{{ approveCopy }} Audience: {{ confirmAudience }} · suggester [User name] receives +10 credits Cancel {{ approveLabel }}
+
Decline · reason required
The user sees this reason on their ticket. Type the reason… Cancel Decline
Decline stays disabled until a reason is typed.
+
+
+
+
Empty {{ emptyCopy }}
+
Unreadable The queue could not be read. The count on the tab is withheld, not zero. Try again
+
+
+
+
+
+
+
+
+
E Eadwyn
Drafts, explains and summarises. You decide.
+
+
[Eadwyn message]
+
[Your message]
+
[Eadwyn message]
+
+
Message Eadwyn, or type "open a ticket"… Send
+
+
+ {{ t.t }}
+
+
+
+
Community tasks Suggest a task
+
+
My suggestions
+
{{ m.title }} {{ m.st }}
{{ m.reason }}
+
"Suggest a task" opens the ticket form with Type set to Task.
+
+
+
+
+
+
[n] credits US$1 each · spent on Perks
Perks →
+
+
Unreadable. Your tickets could not be read. The credit balance is withheld, not zero. Try again
+
+
+
+
+
+
+
+
+
+
+
diff --git a/design/canvases/integrated/Eadwyn Tickets.dc.html b/design/canvases/integrated/Eadwyn Tickets.dc.html
new file mode 100644
index 0000000000..f3f0810139
--- /dev/null
+++ b/design/canvases/integrated/Eadwyn Tickets.dc.html
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ b.id }} {{ b.title }} {{ b.route }}
+
+
{{ b.cap }}
+
+
+
+
+
+
diff --git a/design/incoming/README.md b/design/incoming/README.md
index 8cc1625396..2c48580154 100644
--- a/design/incoming/README.md
+++ b/design/incoming/README.md
@@ -14,7 +14,7 @@ exists for each — not by how far each has been built. Counts as of 2026-10-06:
| Folder | Meaning |
| --- | --- |
-| `canvases/integrated/` | 79 — a live route exists for this canvas. **Not "it is finished"** — most are `UPGRADE`, meaning the route runs and the canvas asks for more than it does. `design/canvases/README.md` has a section on exactly this misreading; the gloss here read "the canvas is built" until 2026-09-07, which is what caused it. |
+| `canvases/integrated/` | 81 — a live route exists for this canvas. **Not "it is finished"** — most are `UPGRADE`, meaning the route runs and the canvas asks for more than it does. `design/canvases/README.md` has a section on exactly this misreading; the gloss here read "the canvas is built" until 2026-09-07, which is what caused it. |
| `canvases/backlog/` | 28 — graded `NEW` or `DEFERRED`: no route yet. |
| `canvases/out-of-scope/` | 26 — deliberately not being built. |
@@ -36,8 +36,9 @@ and 74 / 30 / 26 until 2026-10-06, when `Pages · Company analysis` moved from
75 / 29 / 26 until `Founder Studio` moved the same way the same day, when the
founder's `/studio` was built to it (D564), and 76 / 28 / 26 until
`Help Ticket Screen` arrived beside `Help Center`, whose new export imports it
-(D599), and 77 / 28 / 26 until `Ops · Boards` and `Ops Page` graduated with
-all five of their areas built (D607).
+(D599), 77 / 28 / 26 until `Ops · Boards` and `Ops Page` graduated with
+all five of their areas built (D607), and 79 / 28 / 26 until the Eadwyn
+Tickets canvas's two files arrived with their routes built (D606).
**If you move a file, move the number.**)
**A canvas whose bodies are built does not belong in this folder.** Move it to
diff --git a/documentation/architecture/PAGE_INVENTORY.md b/documentation/architecture/PAGE_INVENTORY.md
index 074dd15b7a..394ae74e4e 100644
--- a/documentation/architecture/PAGE_INVENTORY.md
+++ b/documentation/architecture/PAGE_INVENTORY.md
@@ -150,4 +150,4 @@ rather than merely absent.
- branch_admin · Branch · Insights (`/branch/insights`)
- branch_admin · Branch · Settings (`/branch/settings`)
-**Canvas corpus:** 135. **Workspace assignments:** 177.
+**Canvas corpus:** 137. **Workspace assignments:** 179.
diff --git a/documentation/architecture/PROFILE_ROUTING.md b/documentation/architecture/PROFILE_ROUTING.md
index 9fa3124daa..c11bd2a989 100644
--- a/documentation/architecture/PROFILE_ROUTING.md
+++ b/documentation/architecture/PROFILE_ROUTING.md
@@ -13,8 +13,8 @@ Nothing here is typed by hand, so nothing here can disagree with them.
each canvas and why. This document answers a different question: *who sees
it, where in their nav, and how do they get there.*
-**Corpus:** 135 canonical canvases at `design/canvases/`, listed
-177 times below — a canvas serving two workspaces appears under both.
+**Corpus:** 137 canonical canvases at `design/canvases/`, listed
+179 times below — a canvas serving two workspaces appears under both.
## Column meanings
@@ -181,12 +181,13 @@ it, where in their nav, and how do they get there.*
| Support · Subsidiary | `/help` | — (no nav entry) | Full page | Deep link / in-page action | UPGRADE | Medium |
| Team · Authority | `/admin/team` · `/ic` · `/ic/:uid` | — (no nav entry) | Full page | Deep link / in-page action | NEW | Medium |
-## Super Admin — 7 canvases
+## Super Admin — 8 canvases
| Canvas | Route | Nav section | Surface | Entry point | Status | Confidence |
| --- | --- | --- | --- | --- | --- | --- |
| Admin · Super | `/hq` · `/admin/licences` · `/admin/accounts` · `/admin/contracts` · `/admin/funds` · `/admin` · `/admin/*` | HQ | Full page | Sidebar → HQ → Home | UPGRADE | High |
| Contracts · Super | `/admin/contracts` · `/admin/contracts/registry` | HQ | Full page | Sidebar → HQ → Contracts | UPGRADE | High |
+| Eadwyn Tickets · Review queue (T1, T1b, D606) | `/hq/review` | HQ | Full page | Sidebar → HQ → Support | CURRENT | High |
| Founder Journey Map | — | — (not routed) | Full page (proposed) | Not reachable yet | NEW | Low |
| Fund Administration | `/funds/performance` · `/funds/lp-workspace` · `/spinout-lab/investor-workspace` · `/lp-reports` · `/portfolio/waterfall` | — (no nav entry) | Full page | Deep link / in-page action | UPGRADE | Medium |
| Funds · Fabric | `/admin/fabric` · `/funds` | — (not routed) | Full page (proposed) | Not reachable yet | NEW | Low |
@@ -210,13 +211,14 @@ it, where in their nav, and how do they get there.*
| PartnerRail | — | — (not routed) | Embedded rail | Not reachable yet | UPGRADE | Low |
| System Sheet | — | — (not routed) | Reference (no surface) | Not reachable yet | NEW | Low |
-## Shared (multi-workspace) — 18 canvases
+## Shared (multi-workspace) — 19 canvases
| Canvas | Route | Nav section | Surface | Entry point | Status | Confidence |
| --- | --- | --- | --- | --- | --- | --- |
| Account | `/account` · `/account/:section` · `/settings` | — (no nav entry) | Full page | Deep link / in-page action | UPGRADE | Medium |
| Auth and Onboarding | `/login` · `/register` · `/onboarding/chat` · `/onboarding/persona` · `/onboarding/founder\|investor\|partner` | — (no nav entry) | Full page | Deep link / in-page action | UPGRADE | Medium |
| Calendar | `/calendar` | — (no nav entry) | Full page | Deep link / in-page action | LIVE (built 2026-09-10) | Medium |
+| Eadwyn Tickets · Eadwyn chat panels (T2, T3, D606) | `/studio` | — (not routed) | Full page (proposed) | Not reachable yet | CURRENT | Low |
| Emails | — | — (not routed) | Templates (no surface) | Not reachable yet | UPGRADE | Low |
| Events | `/events` · `/events/:slug` · `/my/events` · `/events/new` · `/events/:id/edit` · `/events/:id/manage` · `/invite/:token` | — (no nav entry) | Full page | Deep link / in-page action | UPGRADE | Medium |
| Help Center | `/help` · `/help/:area/:guide` · `/help/tickets` · `/help/tickets/:id` · `/help/admin/*` · `/help/:id` · `/docs` · `/docs/admin/*` · `/support` | — (no nav entry) | Full page | Deep link / in-page action | CURRENT | Medium |
@@ -258,9 +260,9 @@ it, where in their nav, and how do they get there.*
| Confidence | Rows | Meaning |
| --- | ---: | --- |
-| High | 97 | Live route, reachable from that workspace’s sidebar. |
+| High | 98 | Live route, reachable from that workspace’s sidebar. |
| Medium | 45 | Live route, but no sidebar row for that role — reached by deep link or an in-page action. |
-| Low | 35 | Not routed. Proposed route only. |
+| Low | 36 | Not routed. Proposed route only. |
A Medium row is not a defect. Detail pages (`/deals/:id`), print artefacts
and wizards are reached from the surface above them by design; the brief’s
diff --git a/documentation/architecture/ROUTE_MAP.md b/documentation/architecture/ROUTE_MAP.md
index 3f113954d7..6236992263 100644
--- a/documentation/architecture/ROUTE_MAP.md
+++ b/documentation/architecture/ROUTE_MAP.md
@@ -175,6 +175,8 @@ does not fill it.
| Canvas | Persona | Proposed route | Live route today | Backend it wires to | STATUS | Notes |
| --- | --- | --- | --- | --- | --- | --- |
| Ops Page · Advisor practice (A1–A5, D573) | advisor, founder, public | `/practice/deliverables`, `/practice/session-types`, `/practice/booking-links`, `/practice/access-log`, `/advisors/:uid`, `/b/*` | Practice Delivery/Sessions/Engagements sections, `AdvisorProfilePage`, `PublicAdvisorBookingPage` | `advisors.ts`, `advisor_grants.ts`, migration 394 | CURRENT | D573 wires the existing Ops A1–A5 screens to nullable due/work status, explicit included pricing, attributed booking counts, link toggles/opens, own access receipts and client-only acceptance. Public booking reuses A5 and the existing directory dialog; older facts remain Not recorded. |
+| Eadwyn Tickets · Review queue (T1, T1b, D606) | super-admin | `/hq/review`, `/hq/review?tab=tasks` | `/hq/review` → `pages/hq/ReviewQueuePage.jsx` (`hqOnly`; the HQ sidebar's Support row lights, and HQ Support links to it) | `admin_ticket_reviews.ts` (D544: lists with `total`, tasks with their issue number), `community_tasks.ts` `GET /rewards` | CURRENT | Built to the owner's Eadwyn Tickets canvas (#1107, D606): the two tabs with their counts (the number waiting, withheld while a list is loading or unreadable), title search, Priority and Date filters, "1–25 of [N]" with a note when only the oldest 200 are loaded, the most pressing first, and Approve and Decline on the item they decide. A task carries the "Publish to" audience and "Approve · +N credits", N read from the credit rules. **Departures:** Approve asks for a reason too (the Worker records one of at least ten characters with every decision, D544); "Asked by" is a name and an email, because no page exists for one account; the page carries HQ's Worker AI rail like every HQ page, where the canvas draws none. H7's "Feature review queue →" (D604's departure) now links here, for the Super Admin. |
+| Eadwyn Tickets · Eadwyn chat panels (T2, T3, D606) | shared | `/studio` (the Eadwyn chat) | every Studio home's `StudioInterview` → `PersonalAdvisor`: the panel's tabs Conversation, Tasks and My tickets (`components/advisor/EadwynPanels.jsx`) | `community_tasks.ts` (D545; D606 adds each approved suggestion's `reward` and `GET /rewards`), `tickets.ts`, `perks.ts` `GET /mine` | CURRENT | Built to the canvas (#1108, D606). **Tasks:** the approved community tasks with Start, the member's suggestions with Pending review, Approved · +N credits (what the grant paid, from the ledger) or Declined with the reason, older ones a page away, and "Suggest a task", which opens the ticket form on Task. **My tickets:** the balance (withheld, never 0, when unreadable), the five most recent tickets with type, priority, bounty or review, and what a fixed bug earns by tier, from the rules. On a desktop the tabs head the rail and Conversation keeps the interview widget; on a phone they sit above the card and Conversation is the chat. The chat's ticket form has no default type now. **Departures:** Start puts "Help me with …" in the composer, because no task page exists; a duplicate's original and a not-a-bug reason are in the ticket's replies, so the row links to the ticket; the amounts are by the tier a fix is graded at, not by priority. |
| Partner Operator Canvas | partner | `/partner/operations/*` | `/studio`, `/spinout-lab`, `/needs`, `/partner/insights`, `/partner/operations/*`, `/services`, `/perks`, `/comarketing`, `/partner/office-hours`, `/network`, `/market-intel` | `partner_portal.ts`, `partners.ts`, `partnernet.ts`, `needs.ts`, `services.ts`, `comarketing.ts`, `market_intel.ts` | UPGRADE | Seven-row Partner shell is live: Home, Spin-Out Lab, Pipeline, Delivery, Offers, Network, Research. The headerless sidebar keeps existing deep links under their owning workspace. Pipeline, Delivery, Offers, Network, and Research share Partner-only amber identity, cyan source/grant seams, responsive dark-mode framing, and honest access guidance while retaining their existing live API data, actions, guards, and role branches. Home and Spin-Out Lab remain intentionally unchanged. D394: P2 Home is built as `pages/partner/PartnerHomeP2.jsx` over the existing Delivery and Pipeline reads, with the operating brief as the `home/brief` draft surface — NOT mounted; `/studio` stays `PartnerStudioHome` until the owner signs P2 off. D395: the Firm Settings row's successor is live — `PartnerFirmProfileCard` is mounted on `/company-settings` for partner sign-ins, above the no-company gate, and `/partner/operations/*` redirects to the canvas-built pages. **UPDATE 2026-10-06 (D564, #1168):** `/studio` for a partner is Partner Studio. `PartnerStudioHome` keeps its masthead, Eadwyn and the profile band, and mounts P2 (`PartnerHomeP2`, D394) as its body, signed off by the owner on #981. The old module grid is retired, and nothing on the page says Home. |
| PartnerRail | chrome | — (component, not a route) | Partner workspace side rail | `assistant.ts`, `monitoring.ts` | UPGRADE | The Partner shell now carries the honest AI-assist, consent/screening, founder-grant, and source-provenance guidance rail. It does not invent spend, model, or action controls: those remain unavailable until a scoped Partner rail API exists. |
| Perks & Products | shared (founder + partner) | `/perks` (new) | `/perks` → `PerksPage` (Wave 4) | — (none) for perks/credits; adjacent `products.ts`, `catalog.ts`, `admin_promos.ts`, `billing.ts` | NEW | Credit-priced partner perk marketplace: claim modal with credit debit + redemption code, My Perks + credit ledger, partner submit with live preview + review queue, listing analytics. Distinct from `/marketplace` (services) and `/plans-and-pricing` (Stripe storefront). No perk or credit-ledger table exists. **Wave 4 shipped** it: migration 186 (`perks`, `perk_claims`, `perk_credit_ledger`, `perk_views`), `routes/perks.ts`, and `/perks` role-branched inside one route — founders browse and claim, partners submit and see their own listing stats, admins review. A perk is priced one of three ways: `credits` debits the ledger, `tier` is gated by the SAME `userMeetsTier` the rest of the product uses rather than a second notion of entitlement, and `money` quotes `price_cents` the partner invoices offline. Balance is `SUM(delta)` and is never stored; a claim and its debit go in one `DB.batch`, and two unique indexes (`perk_claims(perk_id, user_id)`, `perk_credit_ledger(user_id, kind, source_ref)`) make a double-spend unreachable rather than unlikely. **A second credit ledger, deliberately:** `intro_credit_ledger` (migration 150) is a different unit — one intro credit buys one warm introduction, where the canvas prices perks at 150–3,200 — so sharing the pool would impose an exchange rate nobody set; and its `bucket`/`kind` CHECK clauses cannot be extended without rebuilding a live table, which an additive migration must not do. **Not built, and why:** no monthly plan allowance. How many credits Growth or Studio includes is a commercial term nobody has set, and inventing one would put a made-up price in the product — the same reason Pricing is blocked in Wave 3. Credits enter only through an admin grant today, and the response carries `allowance_configured` so the page can say "no allowance is set up" instead of "not enough credits", which are very different things to tell someone with a zero balance. The catalogue also starts empty: the partners named in the canvas (Northlake, Sablefin, Oakbridge…) are placeholders, and listing them would invent commercial relationships. |
diff --git a/documentation/architecture/decisions/D544.md b/documentation/architecture/decisions/D544.md
index 656922cdee..5b1a7426bd 100644
--- a/documentation/architecture/decisions/D544.md
+++ b/documentation/architecture/decisions/D544.md
@@ -6,7 +6,8 @@ that, once approved for an audience, is published to the community task list
and pays its suggester. Every decision is recorded once, with a typed reason,
whatever GitHub answers.** Issue #1107, slot S08, part of #1101. Migration
383. This entry covers the store and the API; the HQ page waits on its design
-(`needs-design`).
+(`needs-design`). **D606 builds the page** at `/hq/review`, to the owner's
+Eadwyn Tickets canvas.
**What was true on main.**
- `POST /api/tickets` (`routes/tickets.ts`) filed a ticket with a type and
diff --git a/documentation/architecture/decisions/D545.md b/documentation/architecture/decisions/D545.md
index cb9d04bd9d..43b2047020 100644
--- a/documentation/architecture/decisions/D545.md
+++ b/documentation/architecture/decisions/D545.md
@@ -5,7 +5,8 @@ tasks for their persona and for everyone, and their own suggestions with
their review state; a read that fails is a refusal, never an empty list.**
Issue #1108, slot S08, part of #1101. No migration: the store is D544's
`community_tasks` (migration 383). This entry covers the API; the Tasks panel
-in `PersonalAdvisor.jsx` waits on its design (`needs-design`).
+in `PersonalAdvisor.jsx` waits on its design (`needs-design`). **D606 builds the
+panel** to the owner's Eadwyn Tickets canvas.
**What changed.**
- **`routes/community_tasks.ts`**, mounted at `/api/community-tasks`. It
diff --git a/documentation/architecture/decisions/D604.md b/documentation/architecture/decisions/D604.md
index ecf6001d6d..2cdd1f7be7 100644
--- a/documentation/architecture/decisions/D604.md
+++ b/documentation/architecture/decisions/D604.md
@@ -128,7 +128,9 @@ them, H3 to H7 of `design/canvases/integrated/Help Ticket Screen.dc.html`.
mapped to a GitHub login through `ADMIN_GITHUB_LOGINS`. No roster of support
staff exists to fill a picker.
- **No "Feature review queue →" link on H7.** The queue's page waits on its
- design (#1107, `needs-design`). The review itself is shown.
+ design (#1107, `needs-design`). The review itself is shown. D606 builds the
+ page and adds the link, for the Super Admin, on a request still awaiting
+ review.
- **No logo bar.** The app shell carries the brand, as on H1.
- **The "After submit" legend is not drawn.** The outcome it describes is shown
on the ticket after submitting.
diff --git a/documentation/architecture/decisions/D606.md b/documentation/architecture/decisions/D606.md
new file mode 100644
index 0000000000..2a2d122bdc
--- /dev/null
+++ b/documentation/architecture/decisions/D606.md
@@ -0,0 +1,171 @@
+## D606 — The Eadwyn Tickets canvas: the Super Admin's review queue, and the Eadwyn chat's Tasks and My tickets panels
+
+**Issues #1107 and #1108, slot S01, 2026-10-06.** Worker first. There is no
+migration and one new route, `GET /api/community-tasks/rewards`.
+
+The owner approved the Eadwyn Tickets canvas on 2026-10-06
+(`https://claude.ai/artifact/7sLCsbcdBAqK8XuaYVtzcA`). It was drawn from the
+prompt on #1107. It is a board, `Eadwyn Tickets.dc.html`, and the component
+its artboards import, `Eadwyn Tickets Screen.dc.html`. Both are committed in
+`design/canvases/integrated/`, since all four of its screens are built here:
+
+| Board | Screen | Built at |
+| --- | --- | --- |
+| T1 | HQ review queue · Feature requests | `/hq/review` |
+| T1b | HQ review queue · Suggested tasks | `/hq/review?tab=tasks` |
+| T2 | Eadwyn chat · Tasks panel | the chat on every Studio home |
+| T3 | Eadwyn chat · My tickets | the chat on every Studio home |
+
+### What was true on main
+
+- **D544** (#1107) built the review store and its Super Admin routes. **D545**
+ (#1108) built the members' task list. Both pages waited on this design.
+- **A list that could undercount.** The review lists returned the oldest 200
+ waiting and a `count` of what came back, so a queue past the 200th would
+ show a short number.
+- **No issue number on tasks.** A suggested task carried its GitHub link but
+ not its issue number, though a feature request carried both.
+- **No paid amount on suggestions.** A member's suggestions said "approved"
+ without what the grant paid.
+- **No member-readable rule amounts.** No member could read the reward
+ amounts the canvas states (`credit_reward_rules` is the Super Admin's).
+- **A preset type in the chat.** The chat's ticket form preset the type to
+ `task`. D544 made the Worker refuse a missing type, and D604 gave the Help
+ Center's form no default; the chat's form still sent `task` for anyone who
+ did not change it.
+
+### The change: the Worker
+
+- **The review lists say how many are waiting.**
+ - `GET /api/admin/ticket-reviews/features` and `/tasks` return `total`, a
+ count of everything waiting, beside `count`, what the page holds.
+ - A task now carries `github_issue_number`.
+- **Each approved suggestion carries `reward`.** This is the credits its grant
+ actually paid: the ledger row D544's approval writes, keyed `task:`,
+ read the way D542 reads a bounty.
+ - `revoked` says the grant was taken back.
+ - `credits: null` says nothing was paid (the rule was off, or the grant was
+ refused).
+ - `credits_unreadable` says the ledger could not be read, and the list is
+ still served.
+ - A pending or declined suggestion carries `null`.
+- **`GET /api/community-tasks/rewards`** gives every signed-in member the four
+ amounts the panels state: `task_approved`, `bug_high`, `bug_medium` and
+ `bug_low`. Each is `{ credits, active }`, or `null` when the table has no
+ such rule.
+ - The feature rules are left out, since feature requests earn nothing
+ (#1102 Q2 A).
+ - A failed read is a 500 refusal (`rewards_unreadable`), never zeros.
+
+### The change: T1 and T1b, `/hq/review` (`pages/hq/ReviewQueuePage.jsx`)
+
+- **Who and where.** The page is the Super Admin's (`hqOnly`, as D544's routes
+ are). The sidebar's Support row lights on it, and HQ Support links to it.
+- **The tabs:**
+ - Feature requests, and Suggested tasks (`?tab=tasks`);
+ - each tab counts the number waiting;
+ - the count is withheld while its list loads or cannot be read, never 0.
+- **The toolbar:**
+ - a title search, and Priority and Date filters;
+ - "1–25 of [N]", where N is the number waiting, or the number matching once
+ a filter is on;
+ - when more wait than the 200 loaded, a line says so.
+- **Order.** The most pressing come first, oldest first within a priority, as
+ the canvas draws them.
+- **An item shows:**
+ - its title, priority and type, and its text;
+ - who asked, with the date;
+ - its GitHub issue, or "Not on the GitHub tracker".
+- **A task also has:**
+ - the "Publish to" audience (D544's six, in the Worker's order);
+ - "Approve · +N credits", with N read from the credit rules. When the rule is
+ off, absent or unreadable, the button says only "Approve", and a line under
+ it says why.
+- **Approve and Decline open the canvas's confirm panels on the item they
+ decide.**
+ - Decline says the user sees the reason.
+ - Neither panel sends until the reason is long enough.
+- **After a decision,** the card shows what the route did, and the tab's
+ count drops:
+ - the GitHub label, or why there is none;
+ - whether the requester was told;
+ - for a task, the credits paid, or the refusal in the grant service's own
+ words.
+- **A 409** says someone decided it first, and asks for a reload.
+- **The rail.** The page carries HQ's Worker AI rail, like every HQ page.
+
+### The change: T2 and T3, the Eadwyn chat (`components/advisor/EadwynPanels.jsx`)
+
+- **Three tabs head the chat's panel:** Conversation, Tasks and My tickets.
+ - **Beside the chat,** on a desktop and in fullscreen, they head the rail.
+ Conversation keeps the rail it always had: the interview's sections and
+ queue.
+ - **On a phone,** they sit above the card. Conversation is the chat itself,
+ as the canvas hides the chat column there.
+- **Tasks (T2):**
+ - the approved community tasks, with Start;
+ - the member's suggestions, each with its state: Pending review,
+ "Approved · +N credits" (what the grant paid), "credits taken back", or
+ Declined with the reason;
+ - older suggestions, one request away;
+ - "Suggest a task", which opens the ticket form on Task;
+ - "No tasks yet" only for an empty list; a list that could not be read says
+ so.
+- **My tickets (T3):**
+ - the balance, from `/perks/mine`, linking to Perks. One that could not be
+ read is withheld, never 0.
+ - the five most recently active tickets, each with type, priority, and
+ bounty (D542's words) or review (D604's badge);
+ - a link to all tickets;
+ - what a fixed bug earns by tier, from the rules.
+- **The chat's ticket form has no default type.** It opens on Task only from
+ "Suggest a task". Its submit stops, naming the field, while no type is
+ picked.
+
+### Where the build departs from the canvas, and why
+
+- **Approve asks for a reason.** The Worker records a typed reason of at least
+ ten characters with every decision, approvals included (D544). The approve
+ panel says the reason is recorded and not sent to the requester.
+- **"Asked by" is a name and an email, not a link.** No page exists for one
+ account; the canvas's `/admin/accounts/[id]` does not exist.
+- **Start puts the task to Eadwyn.** It writes "Help me with …" into the
+ composer, which the chat reads as a request for an explanation, and the
+ member sends it. The canvas's `/tasks/[id]` does not exist, and no store
+ records who started a task.
+- **A duplicate's original and a not-a-bug reason are in the ticket's
+ replies.** The ticket list carries neither, so the row says so and links to
+ the ticket. Reading every ticket's GitHub thread for the list was not worth
+ one call per row.
+- **The bounty note is by tier, not by priority.** The canvas says "Urgent and
+ High bugs earn +150"; the tier is set by whoever grades the fix (D542), not
+ by the ticket's priority. The amounts are the rules' today, never code's.
+- **The review page carries the Worker AI rail,** which the canvas does not
+ draw, as every HQ page does.
+
+### Tests
+
+- **`cloudflare-worker/test/eadwyn_tickets_d606.test.ts` (7):**
+ - the lists' `total` past the 200 they return, with decided requests and
+ other types left out;
+ - each task's issue number;
+ - the lists stay the Super Admin's;
+ - each suggestion's reward: paid, taken back, unpaid, another member's grant
+ never read as the caller's, and an unreadable ledger;
+ - the rewards read, an edited and a switched-off rule, a missing rule as
+ null, a failed read refused, and signed out refused.
+- **`frontend/test/eadwyn_tickets_d606.test.mjs` (24):**
+ - the reason floor against the Worker's constant, and the audiences against
+ its list;
+ - the filters, order and count line;
+ - the withheld counts;
+ - the reward states;
+ - the outcome sentences;
+ - every rendered state of the queue, the confirm panels and both panels;
+ - the route, the sidebar and the client;
+ - H7's link for the Super Admin only;
+ - the committed canvas.
+- **`cloudflare-worker/test/ticket_type_required_d544.test.ts`:** now pins the
+ chat form's rule.
+- **`frontend/test/profile_routing_fresh.test.mjs`:** counts the two new
+ ROUTE_MAP rows (137).
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index e4c7a8bd01..f20d8a640a 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -250,6 +250,8 @@ const HqPlatformTopologyPage = lazy(() => import('./pages/hq/PlatformTopologyPag
const HqAnalyticsPage = lazy(() => import('./pages/hq/HqAnalyticsPage'));
const HqSecurityPage = lazy(() => import('./pages/hq/SecurityPage'));
const HqSupportPage = lazy(() => import('./pages/hq/HqSupportPage'));
+// D606 (#1107) — the Super Admin's review queue, the Eadwyn Tickets canvas's T1 and T1b.
+const ReviewQueuePage = lazy(() => import('./pages/hq/ReviewQueuePage'));
const HqFundsPage = lazy(() => import('./pages/hq/HqFundsPage'));
const KYCPage = lazy(() => import('./pages/KYCPage'));
const TrustCenterPage = lazy(() => import('./pages/TrustCenterPage'));
@@ -2647,6 +2649,7 @@ function AppInner() {
))} />
))} />
))} />
+ ))} />
))} />
)} />
{/* Task #102 — standalone Spin-Out Lab admin dashboard (same component
diff --git a/frontend/src/components/advisor/EadwynPanels.jsx b/frontend/src/components/advisor/EadwynPanels.jsx
new file mode 100644
index 0000000000..99f9861638
--- /dev/null
+++ b/frontend/src/components/advisor/EadwynPanels.jsx
@@ -0,0 +1,265 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import { Link } from 'react-router-dom';
+import { RefreshCw } from 'lucide-react';
+import { api } from '../../lib/api';
+import { communityTasksApi } from '../../lib/api/communityTasks.js';
+import { reportError } from '../../lib/log';
+import { PILL, TONE, dayOf, priorityChip, typeChip } from '../../lib/ticketScreens';
+import { bountyLine } from '../tickets/BountyBadge';
+import ReviewBadge from '../tickets/ReviewBadge';
+import {
+ balanceLine, bountyRatesNote, panelTickets, suggestNote, suggestionLine, taskLine,
+} from '../../lib/eadwynTickets';
+
+/**
+ * D606 (#1108) — the Eadwyn chat's panel, the Eadwyn Tickets canvas's T2 and
+ * T3 (`design/canvases/integrated/Eadwyn Tickets Screen.dc.html`, screens
+ * `chat-tasks` and `chat-tickets`).
+ *
+ * Conversation the chat itself on a phone; beside the chat, the rail it
+ * always had (the interview's sections and queue)
+ * Tasks the approved community tasks for the member's persona and
+ * their own suggestions, with each one's state (D545)
+ * My tickets the credit balance, and each ticket's bounty or review
+ *
+ * Each panel owns its reads, and a failed read says so: never "No tasks yet"
+ * for a list that could not be read, never 0 for a balance that could not be.
+ */
+
+export const PANEL_TABS = [
+ { id: 'conversation', label: 'Conversation' },
+ { id: 'tasks', label: 'Tasks' },
+ { id: 'tickets', label: 'My tickets' },
+];
+
+/** The canvas's tab strip above the panel. */
+export function PanelTabs({ tab, onTab, className = '' }) {
+ return (
+
+ {PANEL_TABS.map((t) => {
+ const on = t.id === tab;
+ return (
+ onTab(t.id)}
+ data-testid={`eadwyn-tab-${t.id}`}
+ className={`-mb-px whitespace-nowrap border-b-2 px-3 py-2 text-[13px] font-semibold transition-colors ${on
+ ? 'border-axal-violet text-axal-violet-deep dark:text-violet-300'
+ : 'border-transparent text-axal-muted hover:text-axal-ink dark:text-gray-400 dark:hover:text-gray-100'}`}
+ >
+ {t.label}
+
+ );
+ })}
+
+ );
+}
+
+const SECTION = 'text-[11px] font-semibold uppercase tracking-[.08em] text-axal-muted dark:text-gray-400';
+const CARD = 'flex flex-col gap-1 rounded-[10px] border border-axal-hairline px-3 py-2.5 dark:border-gray-800';
+
+function Chip({ text, tone }) {
+ return {text} ;
+}
+
+function PanelUnreadable({ text, onRetry, testId }) {
+ return (
+
+ Unreadable. {text}{' '}
+ {onRetry && (
+
+ Try again
+
+ )}
+
+ );
+}
+
+/** A read in its three states, re-runnable. */
+function useRead(reader) {
+ const [read, setRead] = useState({ status: 'loading', data: null });
+ const [n, setN] = useState(0);
+ useEffect(() => {
+ let live = true;
+ setRead((r) => ({ ...r, status: 'loading' }));
+ Promise.resolve().then(reader).then(
+ (data) => { if (live) setRead({ status: 'ready', data }); },
+ (e) => { reportError('EadwynPanels:read', e); if (live) setRead({ status: 'unreadable', data: null }); },
+ );
+ return () => { live = false; };
+ }, [n]); // eslint-disable-line react-hooks/exhaustive-deps
+ return [read, useCallback(() => setN((x) => x + 1), [])];
+}
+
+// ---------------------------------------------------------------------------
+// T2 — Tasks
+// ---------------------------------------------------------------------------
+
+/** The Tasks panel as it renders from its reads: pure, so each state is testable. */
+export function TasksPanelView({ list, rewards, older = [], olderNext = null, onSuggest, onStart, onRetry, onOlder }) {
+ const tasks = list.status === 'ready' ? (list.data?.tasks || []) : [];
+ const mine = list.status === 'ready' ? [...(list.data?.my_suggestions || []), ...older] : [];
+ const next = older.length ? olderNext : (list.data?.my_suggestions_next ?? null);
+ return (
+
+
+ Community tasks
+
+ Suggest a task
+
+
+
+ {list.status === 'loading' &&
Reading the task list…
}
+ {list.status === 'unreadable' && (
+
+ )}
+ {list.status === 'ready' && !tasks.length && (
+
No tasks yet. Approved suggestions appear here.
+ )}
+ {tasks.map((t) => {
+ const line = taskLine(t.body);
+ return (
+
+ {t.title}
+ {line && {line} }
+ onStart(t)} className="w-fit text-[12.5px] font-semibold text-axal-violet-deep hover:underline dark:text-violet-300" data-testid="eadwyn-task-start">
+ Start →
+
+
+ );
+ })}
+
+ {list.status === 'ready' && (
+ <>
+
My suggestions
+ {!mine.length &&
You have not suggested a task yet.
}
+ {mine.map((s) => {
+ const st = suggestionLine(s);
+ return (
+
+
+ {s.title}
+ {st && }
+
+ {st?.reason &&
{st.reason} }
+ {s.status === 'declined' && !st?.reason && (
+
No reason was recorded.
+ )}
+
+ );
+ })}
+ {next != null && (
+
onOlder(next)} className="w-fit text-[12px] font-semibold text-axal-violet-deep hover:underline dark:text-violet-300" data-testid="eadwyn-suggestions-older">
+ Show older suggestions
+
+ )}
+ >
+ )}
+
+
{suggestNote(rewards)}
+
+ );
+}
+
+export function TasksPanel({ onSuggest, onStart }) {
+ const [list, reload] = useRead(() => communityTasksApi.list());
+ const [rewards] = useRead(() => communityTasksApi.rewards());
+ const [older, setOlder] = useState({ rows: [], next: null });
+ useEffect(() => { if (list.status === 'loading') setOlder({ rows: [], next: null }); }, [list.status]);
+ const onOlder = (before) => {
+ communityTasksApi.list(before).then(
+ (data) => setOlder((o) => ({ rows: [...o.rows, ...(data?.my_suggestions || [])], next: data?.my_suggestions_next ?? null })),
+ (e) => reportError('EadwynPanels:older', e),
+ );
+ };
+ return (
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// T3 — My tickets
+// ---------------------------------------------------------------------------
+
+// D542's bounty wording, in the canvas's palette (Under review grey,
+// Confirmed violet, Fixed green, Not a bug and Duplicate grey).
+const BOUNTY_TONE = { neutral: 'grey', info: 'violet', ok: 'green', warn: 'amber', muted: 'grey' };
+
+/** The My tickets panel as it renders from its reads: pure, so each state is testable. */
+export function TicketsPanelView({ tickets, balance, rewards, onRetry }) {
+ const bal = balanceLine(balance);
+ const rows = tickets.status === 'ready' ? panelTickets(tickets.data) : [];
+ const rates = bountyRatesNote(rewards);
+ return (
+
+
+
+ {bal.state === 'ready'
+ ? {bal.text}
+ : {bal.text} }
+ US$1 each · spent on Perks
+
+
Perks →
+
+
+ {tickets.status === 'loading' &&
Reading your tickets…
}
+ {tickets.status === 'unreadable' && (
+
+ )}
+ {tickets.status === 'ready' && !rows.length && (
+
You have not opened a ticket.
+ )}
+ {rows.map((t) => {
+ const type = typeChip(t.type);
+ const prio = priorityChip(t.priority);
+ const bounty = bountyLine(t.bounty);
+ const updated = dayOf(t.updated_at || t.created_at);
+ return (
+
+
+ {t.title}
+ {type && {type.text} }
+
+
+ {prio && {prio.text} }
+ {bounty && }
+
+
+ {bounty?.hint && (
+
+ {bounty.hint}{' '}
+ Open the ticket →
+
+ )}
+
#{t.id}{updated ? ` · updated ${updated}` : ''}
+
+ );
+ })}
+ {tickets.status === 'ready' && rows.length > 0 && (
+
+ All tickets →
+
+ )}
+ {rates &&
{rates}
}
+
+ );
+}
+
+export function TicketsPanel() {
+ const [tickets, reload] = useRead(() => api.listTickets());
+ const [balance] = useRead(() => api.perksMine());
+ const [rewards] = useRead(() => communityTasksApi.rewards());
+ return ;
+}
diff --git a/frontend/src/components/advisor/PersonalAdvisor.jsx b/frontend/src/components/advisor/PersonalAdvisor.jsx
index ce37661211..e1c240678a 100644
--- a/frontend/src/components/advisor/PersonalAdvisor.jsx
+++ b/frontend/src/components/advisor/PersonalAdvisor.jsx
@@ -61,6 +61,9 @@ import { predictTarget } from '../../lib/advisor/router';
import { useWebSocket } from '../../hooks/useWebSocket';
import AdvisorProgressWidget from './AdvisorProgressWidget';
import MyBugReports from '../tickets/MyBugReports';
+import { PanelTabs, TasksPanel, TicketsPanel } from './EadwynPanels';
+import { startPrompt } from '../../lib/eadwynTickets';
+import { TICKET_TYPES } from '../../lib/ticketScreens';
const STORAGE_KEY = 'advisor:state';
@@ -162,6 +165,11 @@ export default function PersonalAdvisor({ disablePersistedFullscreen = false, on
// D324 — a Studio home's collapsed interview row reopens the chat with
// the ticket form already open when its "Open a ticket" is pressed.
const [ticketOpen, setTicketOpen] = useState(initialTicketOpen);
+ // D606 (#1108) — the Eadwyn Tickets canvas's panel: Conversation, Tasks and
+ // My tickets. "Suggest a task" opens the ticket form with Type set to Task;
+ // every other way in opens it with no type chosen (D544).
+ const [panelTab, setPanelTab] = useState('conversation');
+ const [ticketPreset, setTicketPreset] = useState(null);
// Task #2 (AR) — server-driven per-page / per-section progress
// and Spin-Out week banner state. Refreshed after every answer
@@ -744,6 +752,7 @@ export default function PersonalAdvisor({ disablePersistedFullscreen = false, on
*/
const handleTicketFiled = useCallback((t) => {
setTicketOpen(false);
+ setTicketPreset(null);
const title = t?.title ? `"${t.title}"` : 'Your ticket';
const status = t?.github_sync_status || null;
const reason = String(t?.github_sync_error || '').trim();
@@ -770,6 +779,17 @@ export default function PersonalAdvisor({ disablePersistedFullscreen = false, on
}]);
}, [user?.role]);
+ // D606 — the panel's two actions. "Start" puts the task to Eadwyn as a
+ // "help me with" line in the composer, which opens an explanation when
+ // sent; nothing is sent until the member sends it.
+ const toggleTicket = useCallback(() => { setTicketPreset(null); setTicketOpen((v) => !v); }, []);
+ const closeTicket = useCallback(() => { setTicketOpen(false); setTicketPreset(null); }, []);
+ const suggestTask = useCallback(() => { setTicketPreset('task'); setTicketOpen(true); }, []);
+ const startTask = useCallback((task) => {
+ setInput(startPrompt(task));
+ if (!isDesktop) setPanelTab('conversation');
+ }, [isDesktop]);
+
// ---------- Render ------------------------------------------------------
if (!user) return null; // anonymous: nothing to advise on yet
// Endpoint missing in this environment — render nothing so the
@@ -811,9 +831,14 @@ export default function PersonalAdvisor({ disablePersistedFullscreen = false, on
progressBumpToken={progressBumpToken}
onPickQuestion={handlePickQuestion}
ticketOpen={ticketOpen}
- onToggleTicket={() => setTicketOpen((v) => !v)}
+ ticketPreset={ticketPreset}
+ onToggleTicket={toggleTicket}
onTicketFiled={handleTicketFiled}
- onCloseTicket={() => setTicketOpen(false)}
+ onCloseTicket={closeTicket}
+ panelTab={panelTab}
+ onPanelTab={setPanelTab}
+ onSuggestTask={suggestTask}
+ onStartTask={startTask}
/>
);
}
@@ -831,13 +856,17 @@ export default function PersonalAdvisor({ disablePersistedFullscreen = false, on
persona={persona}
progress={progress}
onMaximize={() => setViewMode('fullscreen')}
- onOpenTicket={() => setTicketOpen((v) => !v)}
+ onOpenTicket={toggleTicket}
ticketOpen={ticketOpen}
/>
{weekBanner && }
{ticketOpen && (
- setTicketOpen(false)} />
+
)}
+ {/* D606 — on a phone the panel's tabs sit above the card's body, and
+ Conversation is the chat itself (the canvas hides the chat column
+ below 760px and shows the panel). */}
+ {!isDesktop && }
{/* Chat column. On mobile we cap the height to ~60vh so the chat
@@ -848,7 +877,7 @@ export default function PersonalAdvisor({ disablePersistedFullscreen = false, on
min-h-[260px] lets it shrink) scrolls internally while the
current-question line and composer stay pinned at the bottom,
and no blank gap opens beneath the task panel as the chat grows. */}
-
+
-
-
+ Completed) stay visible as the chat transcript grows.
+ D606 — it is the panel's Conversation tab now, beside Tasks and
+ My tickets. */}
+ {isDesktop ? (
+
+
+ {panelTab === 'conversation' ? (
+
+ ) : panelTab === 'tasks'
+ ?
+ : }
+ ) : panelTab !== 'conversation' && (
+
+ {panelTab === 'tasks' ? : }
+
)}
@@ -955,7 +997,8 @@ function FullscreenView({
input, setInput, onSend, onSkip, busy, onPickOption,
sectionStats, focusSection, pickFocus,
pendingEvidence, labState, progressBumpToken, onPickQuestion,
- ticketOpen, onToggleTicket, onTicketFiled, onCloseTicket,
+ ticketOpen, ticketPreset, onToggleTicket, onTicketFiled, onCloseTicket,
+ panelTab = 'conversation', onPanelTab, onSuggestTask, onStartTask,
}) {
useEscapeClose(onExit);
const vvStyle = useVisualViewportStyle();
@@ -969,7 +1012,7 @@ function FullscreenView({
>
{ticketOpen && (
-
+
)}
{/* Responsive: single column on small screens (chat over a capped,
scrollable progress section), two panes on large screens. */}
@@ -1006,16 +1049,25 @@ function FullscreenView({
fullscreen so the progress widget is present at every width:
a capped, independently-scrollable section stacked BELOW the
chat on small screens, and a full-height side rail on large. */}
-
-
-
+
+ {/* D606 — the same three tabs as the card's rail. The chat stays in
+ view here at every width, so Conversation is its rail. */}
+ {onPanelTab && }
+ {panelTab === 'conversation' || !onPanelTab ? (
+
+ ) : panelTab === 'tasks'
+ ?
+ : }
@@ -1256,10 +1308,12 @@ function CtaButtons({ cta, onCtaClick }) {
// POST /api/tickets endpoint via api.createTicket (no backend changes).
// On success it calls onFiled(ticket) so the advisor can confirm in the
// transcript with a link back to the Help Center.
-function AdvisorTicketPanel({ onFiled, onClose }) {
+function AdvisorTicketPanel({ onFiled, onClose, initialType = null }) {
const [title, setTitle] = useState('');
const [priority, setPriority] = useState('medium');
- const [type, setType] = useState('task');
+ // D606 — the type is a required choice with no default (D544, the H4 form's
+ // rule), except from "Suggest a task", which opens the form on Task.
+ const [type, setType] = useState(initialType || '');
const [description, setDescription] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(null);
@@ -1270,6 +1324,10 @@ function AdvisorTicketPanel({ onFiled, onClose }) {
setError('Please enter a short summary for your ticket.');
return;
}
+ if (!TICKET_TYPES.some((t) => t.id === type)) {
+ setError('Choose a type: Task, Bug or Feature.');
+ return;
+ }
setSubmitting(true);
setError(null);
try {
@@ -1295,7 +1353,7 @@ function AdvisorTicketPanel({ onFiled, onClose }) {
- Open a support ticket
+ {initialType === 'task' ? 'Suggest a task' : 'Open a support ticket'}
+ Choose a type
Task
Bug
Feature
diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js
index 1741025e6e..5c0ffabd4f 100644
--- a/frontend/src/lib/api.js
+++ b/frontend/src/lib/api.js
@@ -21,6 +21,7 @@ export * from './api/researchOverview.js';
export * from './api/researchSectors.js';
export * from './api/safeIssuances.js';
export * from './api/section83bReview.js';
+export * from './api/ticketReviews.js';
// api-modules:end
const BASE = '/api';
diff --git a/frontend/src/lib/api/communityTasks.js b/frontend/src/lib/api/communityTasks.js
index f4ef79a22b..de62e46593 100644
--- a/frontend/src/lib/api/communityTasks.js
+++ b/frontend/src/lib/api/communityTasks.js
@@ -8,4 +8,8 @@ export const communityTasksApi = {
list: (suggestionsBefore) => request(
suggestionsBefore ? `/community-tasks?suggestions_before=${encodeURIComponent(suggestionsBefore)}` : '/community-tasks',
),
+ // D606 — what an approved suggestion and a fixed bug earn today, for the
+ // panels' notes: { rewards: { task_approved, bug_high, bug_medium, bug_low } },
+ // each { credits, active } or null.
+ rewards: () => request('/community-tasks/rewards'),
};
diff --git a/frontend/src/lib/api/ticketReviews.js b/frontend/src/lib/api/ticketReviews.js
new file mode 100644
index 0000000000..49ae8b3bed
--- /dev/null
+++ b/frontend/src/lib/api/ticketReviews.js
@@ -0,0 +1,18 @@
+// D606 — the Super Admin's review queue (D544): feature requests and
+// suggested tasks, each approved or declined with a typed reason of at least
+// ten characters. Worker: routes/admin_ticket_reviews.ts, at
+// /api/admin/ticket-reviews. Every route is the Super Admin's.
+import { request } from '../api.js';
+
+const post = (path, body) => request(path, { method: 'POST', body: JSON.stringify(body) });
+
+export const ticketReviewsApi = {
+ // { items, count, total }: the oldest 200 waiting, and how many wait in all.
+ features: () => request('/admin/ticket-reviews/features'),
+ // { items, count, total, audiences }
+ tasks: () => request('/admin/ticket-reviews/tasks'),
+ approveFeature: (ticketId, reason) => post(`/admin/ticket-reviews/features/${encodeURIComponent(ticketId)}/approve`, { reason }),
+ declineFeature: (ticketId, reason) => post(`/admin/ticket-reviews/features/${encodeURIComponent(ticketId)}/decline`, { reason }),
+ approveTask: (id, { audience, reason }) => post(`/admin/ticket-reviews/tasks/${encodeURIComponent(id)}/approve`, { audience, reason }),
+ declineTask: (id, reason) => post(`/admin/ticket-reviews/tasks/${encodeURIComponent(id)}/decline`, { reason }),
+};
diff --git a/frontend/src/lib/eadwynTickets.js b/frontend/src/lib/eadwynTickets.js
new file mode 100644
index 0000000000..7375f5072e
--- /dev/null
+++ b/frontend/src/lib/eadwynTickets.js
@@ -0,0 +1,269 @@
+/**
+ * D606 (#1107, #1108) — the screens of the owner's Eadwyn Tickets canvas
+ * (`design/canvases/integrated/Eadwyn Tickets.dc.html`):
+ *
+ * T1, T1b /hq/review — the Super Admin's review queue: feature requests,
+ * and suggested tasks with an audience picker (D544's routes).
+ * T2 the Eadwyn chat's Tasks panel: the approved community tasks and
+ * the member's own suggestions with their state (D545).
+ * T3 the Eadwyn chat's My tickets panel: each ticket's bounty or
+ * review, and the credit balance (D542, D540).
+ *
+ * Pure helpers, so every state renders in a test. The chips are D604's
+ * (`ticketScreens.js`): a priority or a type reads the same on every ticket
+ * surface.
+ */
+import { toUtcInstant } from './notices';
+
+// ---------------------------------------------------------------------------
+// T1, T1b — the review queue
+// ---------------------------------------------------------------------------
+
+/** The Worker's floor for a decision's reason (`REVIEW_REASON_MIN`, D544). */
+export const REASON_MIN = 10;
+/** Rows a queue page shows: the canvas's "1–25 of [N]". */
+export const QUEUE_PAGE = 25;
+
+export const QUEUE_TABS = [
+ { id: 'features', label: 'Feature requests' },
+ { id: 'tasks', label: 'Suggested tasks' },
+];
+
+/** `?tab=tasks` opens T1b; anything else opens T1. */
+export const tabFromSearch = (search) => (new URLSearchParams(search || '').get('tab') === 'tasks' ? 'tasks' : 'features');
+
+/** Who an approved task is published to, in the Worker's order (`AUDIENCES`, D544). */
+export const AUDIENCES = [
+ { id: 'all', label: 'All users' },
+ { id: 'founder', label: 'Founders' },
+ { id: 'investor', label: 'Investors' },
+ { id: 'advisor', label: 'Advisors' },
+ { id: 'partner', label: 'Partners' },
+ { id: 'explorer', label: 'Explorers' },
+];
+export const audienceLabel = (id) => AUDIENCES.find((a) => a.id === id)?.label || String(id || '');
+
+export const DATE_WINDOWS = [
+ { id: '', label: 'Any date' },
+ { id: '7', label: 'Last 7 days' },
+ { id: '30', label: 'Last 30 days' },
+ { id: 'older', label: 'Older than 30 days' },
+];
+
+export const NO_QUEUE_FILTERS = Object.freeze({ query: '', priority: '', window: '' });
+export const hasQueueFilters = (f) => Object.values(f || {}).some((v) => String(v || '').trim() !== '');
+
+const RANK = { urgent: 0, high: 1, medium: 2, low: 3 };
+
+function ageDays(value, now) {
+ const iso = toUtcInstant(value);
+ const t = iso ? Date.parse(iso) : NaN;
+ return Number.isFinite(t) ? (now - t) / 86_400_000 : null;
+}
+
+/** Title search, priority and date window. A row with no readable date matches no window. */
+export function matchesQueue(item, f = NO_QUEUE_FILTERS, now = Date.now()) {
+ const q = String(f.query || '').trim().toLowerCase();
+ if (q && !String(item?.title || '').toLowerCase().includes(q)) return false;
+ if (f.priority && item?.priority !== f.priority) return false;
+ if (f.window) {
+ const age = ageDays(item?.created_at, now);
+ if (age === null) return false;
+ if (f.window === 'older' ? age <= 30 : age > Number(f.window)) return false;
+ }
+ return true;
+}
+
+/** Most pressing first, as the canvas draws them; the oldest first within a priority. */
+export function sortQueue(items) {
+ return [...(items || [])].sort((a, b) => {
+ const r = (RANK[a?.priority] ?? 4) - (RANK[b?.priority] ?? 4);
+ if (r) return r;
+ return String(toUtcInstant(a?.created_at) || '').localeCompare(String(toUtcInstant(b?.created_at) || ''));
+ });
+}
+
+/**
+ * One page of a filtered queue, and its count line. `total` is the Worker's
+ * number waiting; the page reads the oldest 200, so past that the line still
+ * names the true number and `partial` says only some are loaded.
+ */
+export function queueView(items, { filters = NO_QUEUE_FILTERS, page = 1, total = null, now = Date.now() } = {}) {
+ const loaded = Array.isArray(items) ? items : [];
+ const matched = sortQueue(loaded.filter((x) => matchesQueue(x, filters, now)));
+ const pages = Math.max(1, Math.ceil(matched.length / QUEUE_PAGE));
+ const p = Math.min(Math.max(1, Number(page) || 1), pages);
+ const rows = matched.slice((p - 1) * QUEUE_PAGE, p * QUEUE_PAGE);
+ const filtered = hasQueueFilters(filters);
+ const of = filtered ? matched.length : Math.max(Number(total) || 0, loaded.length);
+ const label = rows.length ? `${(p - 1) * QUEUE_PAGE + 1}–${(p - 1) * QUEUE_PAGE + rows.length} of ${of}` : `0 of ${of}`;
+ const partial = Number(total) > loaded.length
+ ? `The oldest ${loaded.length} of ${total} waiting are loaded. Decide some to reach the rest.`
+ : null;
+ return { rows, page: p, pages, label, partial, matched: matched.length };
+}
+
+/** A tab's count: the number waiting once read, and nothing while loading or unreadable. */
+export const tabCount = (read) => (read?.status === 'ready' ? Number(read.data?.total ?? read.data?.count ?? 0) : null);
+
+/**
+ * What approving a task pays, from the rules (`GET /api/community-tasks/rewards`).
+ * on the rule is on and pays credits
+ * off the rule is switched off: approving pays nothing
+ * none no rule, or one that pays nothing
+ * unreadable the rules could not be read
+ */
+export function taskReward(read) {
+ if (!read || read.status === 'loading') return { state: 'loading' };
+ if (read.status !== 'ready') return { state: 'unreadable' };
+ const rule = read.data?.rewards?.task_approved;
+ if (!rule) return { state: 'none' };
+ if (rule.active === false) return { state: 'off' };
+ const n = Number(rule.credits);
+ return Number.isFinite(n) && n > 0 ? { state: 'on', credits: n } : { state: 'none' };
+}
+
+/** The canvas's button: "Approve · +10 credits" on a task when the rule pays. */
+export function approveLabel(tab, reward) {
+ return tab === 'tasks' && reward?.state === 'on' ? `Approve · +${reward.credits} credits` : 'Approve';
+}
+
+/** The line under a task's Approve when the button cannot name a reward. */
+export function rewardNote(reward) {
+ switch (reward?.state) {
+ case 'off': return 'The task reward is switched off in the credit rules, so approving pays nothing.';
+ case 'none': return 'No task reward is set in the credit rules, so approving pays nothing.';
+ case 'unreadable': return 'What approving pays could not be read. The grant follows the credit rules either way.';
+ default: return null;
+ }
+}
+
+/** The confirm panel's sentence (the canvas's "Approve · confirm"). */
+export function approveCopy(tab, item, { audience = 'all', reward } = {}) {
+ const title = item?.title ? `“${item.title}”` : 'this request';
+ if (tab !== 'tasks') return `Sends ${title} to the build queue. The requester sees “Approved” on their ticket.`;
+ const pays = reward?.state === 'on'
+ ? ` and rewards the suggester ${reward.credits} credits`
+ : '';
+ return `Publishes ${title} to the Task list of ${audienceLabel(audience).toLowerCase()}${pays}.`;
+}
+
+/** A typed reason is long enough to send. */
+export const reasonReady = (text) => String(text || '').trim().length >= REASON_MIN;
+export const reasonHint = (text) => {
+ const n = String(text || '').trim().length;
+ return n >= REASON_MIN ? null : `At least ${REASON_MIN} characters (${n} so far). It is recorded with the decision.`;
+};
+
+const LABEL_LINES = {
+ added: (decision) => `The GitHub issue is labelled feature:${decision}.`,
+ no_issue: () => 'It has no GitHub issue to label.',
+ not_configured: () => 'GitHub is not configured here, so no label was added.',
+ failed: () => 'The GitHub label could not be added; the decision is recorded all the same.',
+};
+
+/** What a decision did, in the route's own facts: the label, the notice, the reward. */
+export function decisionOutcome(tab, decision, res) {
+ const told = (who) => (res?.notified ? `The ${who} was told in their inbox.` : `The ${who} could not be told in their inbox.`);
+ if (tab !== 'tasks') {
+ const head = decision === 'approved' ? 'Approved. It goes to the build queue.' : 'Declined, with your reason.';
+ const label = LABEL_LINES[res?.label_status]?.(decision) || null;
+ return [head, label, told('requester')].filter(Boolean).join(' ');
+ }
+ if (decision === 'declined') return `Declined, with your reason. ${told('suggester')}`;
+ const r = res?.reward;
+ const pay = r?.status === 'granted'
+ ? `The suggester was paid ${r.credits} credit${Number(r.credits) === 1 ? '' : 's'}.`
+ : r?.status === 'already_granted'
+ ? 'The suggester had already been paid for it.'
+ : `No credits were paid: ${String(r?.message || 'the grant was refused.').replace(/\.?$/, '.')}`;
+ return `Approved and published to ${audienceLabel(res?.audience).toLowerCase()}. ${pay}`;
+}
+
+/** A refusal's own message; 409 says someone decided it first. */
+export function decisionRefusal(err) {
+ if (err?.status === 409) return `${err.message || 'This was already decided.'} Reload the queue to see where it stands.`;
+ return err?.message || 'The decision could not be saved. Nothing was changed.';
+}
+
+// ---------------------------------------------------------------------------
+// T2 — the Tasks panel
+// ---------------------------------------------------------------------------
+
+/**
+ * A suggestion's state chip. An approved one names what its grant paid (the
+ * ledger, D606), never what the rule says today.
+ */
+export function suggestionLine(s) {
+ switch (s?.status) {
+ case 'pending': return { text: 'Pending review', tone: 'amber' };
+ case 'declined': return { text: 'Declined', tone: 'red', reason: s.decline_reason || null };
+ case 'approved': {
+ const r = s.reward;
+ if (r?.credits_unreadable) return { text: 'Approved · credits could not be read', tone: 'green' };
+ if (r?.revoked) return { text: 'Approved · credits taken back', tone: 'amber' };
+ const n = Number(r?.credits);
+ if (r && r.credits != null && Number.isFinite(n) && n > 0) return { text: `Approved · +${n} credits`, tone: 'green' };
+ return { text: 'Approved · no credits paid', tone: 'green' };
+ }
+ default: return null;
+ }
+}
+
+/** One line from a task's text, for the card. */
+export function taskLine(body, max = 140) {
+ const line = String(body || '').replace(/\s+/g, ' ').trim();
+ if (!line) return null;
+ return line.length > max ? `${line.slice(0, max - 1).trimEnd()}…` : line;
+}
+
+/** "Start" puts the task to Eadwyn: the chat's "help me with" opens an explanation. */
+export const startPrompt = (task) => `Help me with ${String(task?.title || '').trim()}`;
+
+/** The note under "Suggest a task", with the reward when the rule pays one. */
+export function suggestNote(rewardsRead) {
+ const r = taskReward(rewardsRead);
+ const base = '“Suggest a task” opens the ticket form with Type set to Task.';
+ return r.state === 'on' ? `${base} An approved suggestion earns ${r.credits} credits.` : base;
+}
+
+// ---------------------------------------------------------------------------
+// T3 — My tickets
+// ---------------------------------------------------------------------------
+
+/** Rows the panel lists before "All tickets". */
+export const PANEL_TICKETS = 5;
+
+/** The member's tickets, most recently active first. */
+export function panelTickets(rows, limit = PANEL_TICKETS) {
+ const at = (t) => String(toUtcInstant(t?.updated_at || t?.created_at) || '');
+ return [...(Array.isArray(rows) ? rows : [])].sort((a, b) => at(b).localeCompare(at(a))).slice(0, limit);
+}
+
+/** The balance card: a number once read, withheld (never 0) when it could not be. */
+export function balanceLine(read) {
+ if (!read || read.status === 'loading') return { state: 'loading', text: 'Reading your balance…' };
+ if (read.status !== 'ready') return { state: 'unreadable', text: 'Your balance could not be read. It is withheld, not zero.' };
+ const n = Number(read.data?.balance);
+ if (!Number.isFinite(n)) return { state: 'unreadable', text: 'Your balance could not be read. It is withheld, not zero.' };
+ return { state: 'ready', text: `${n} credit${n === 1 ? '' : 's'}` };
+}
+
+const TIERS = [['bug_high', 'High'], ['bug_medium', 'Medium'], ['bug_low', 'Low']];
+
+/**
+ * What a fixed bug earns, by the tier it is graded at, from today's rules.
+ * The canvas tied the amounts to the ticket's priority; the tier is the
+ * grader's, set when the fix is verified (D542), so the note says that.
+ */
+export function bountyRatesNote(rewardsRead) {
+ if (!rewardsRead || rewardsRead.status === 'loading') return null;
+ if (rewardsRead.status !== 'ready') return 'What a fixed bug earns could not be read just now.';
+ const parts = TIERS.map(([key, label]) => {
+ const rule = rewardsRead.data?.rewards?.[key];
+ if (!rule) return `${label}: not set`;
+ if (rule.active === false) return `${label}: not paid now`;
+ return `${label} +${Number(rule.credits)}`;
+ });
+ return `A confirmed bug earns credits when the fix ships, by the tier it is graded at: ${parts.join(', ')}.`;
+}
diff --git a/frontend/src/pages/TicketsPage.jsx b/frontend/src/pages/TicketsPage.jsx
index 90714ecbf6..e1736a4151 100644
--- a/frontend/src/pages/TicketsPage.jsx
+++ b/frontend/src/pages/TicketsPage.jsx
@@ -3,6 +3,7 @@ import { Link, useLocation, useNavigate, useParams } from 'react-router-dom';
import { Search, RefreshCw } from 'lucide-react';
import { safeReadJSON } from '../lib/storage';
import { api } from '../lib/api';
+import { isSuperAdminUser } from '../lib/shellRole';
import { TICKET_CATEGORIES } from '../lib/ticketCategories';
import { createDocsFuse } from '../lib/docs/search';
import { SECTIONS, filterSectionsForRole } from './docs/sections';
@@ -805,7 +806,7 @@ function TrackerCard({ ticket, isAdmin, onChanged }) {
);
}
-function OutcomePanel({ ticket }) {
+function OutcomePanel({ ticket, reviewer = false }) {
if (ticket.type === 'bug') {
const line = bountyLine(ticket.bounty);
const panel = bountyPanel(ticket.bounty);
@@ -826,6 +827,13 @@ function OutcomePanel({ ticket }) {
Review
{line.text}
{line.note}
+ {/* D606 — H7's link, now that the queue has its page. The Super
+ Admin decides; everyone else sees the review only. */}
+ {reviewer && ticket.review?.state === 'awaiting' && (
+
+ Feature review queue →
+
+ )}
);
}
@@ -943,7 +951,7 @@ export function TicketView({ ticket: t, isAdmin, user, outcome, onRefresh }) {
-
+
diff --git a/frontend/src/pages/hq/HqSupportPage.jsx b/frontend/src/pages/hq/HqSupportPage.jsx
index d77c9a9463..e674f3e29b 100644
--- a/frontend/src/pages/hq/HqSupportPage.jsx
+++ b/frontend/src/pages/hq/HqSupportPage.jsx
@@ -1139,6 +1139,12 @@ export default function HqSupportPage() {
stands now, so the two ticket queues and the tickets outside them add up to HQ Home's Queue backlog.
Open an escalation to answer it.
+ {/* D606 — the Super Admin's review queue sits under Support (the Eadwyn Tickets canvas lights this row). */}
+
+
+ Review queue: feature requests and suggested tasks →
+
+
diff --git a/frontend/src/pages/hq/ReviewQueuePage.jsx b/frontend/src/pages/hq/ReviewQueuePage.jsx
new file mode 100644
index 0000000000..fb2bd93a23
--- /dev/null
+++ b/frontend/src/pages/hq/ReviewQueuePage.jsx
@@ -0,0 +1,457 @@
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import { Link, useLocation, useNavigate } from 'react-router-dom';
+import { ExternalLink, Inbox, RefreshCw } from 'lucide-react';
+import { communityTasksApi } from '../../lib/api/communityTasks.js';
+import { ticketReviewsApi } from '../../lib/api/ticketReviews.js';
+import { reportError } from '../../lib/log';
+import { RailFrame, WorkerRail } from '../../ui';
+import { dayOf, priorityChip, typeChip } from '../../lib/ticketScreens';
+import {
+ AUDIENCES, DATE_WINDOWS, NO_QUEUE_FILTERS, QUEUE_TABS, approveCopy, approveLabel, audienceLabel,
+ decisionOutcome, decisionRefusal, hasQueueFilters, queueView, reasonHint, reasonReady, rewardNote,
+ tabCount, tabFromSearch, taskReward,
+} from '../../lib/eadwynTickets';
+
+/**
+ * D606 (#1107) — HQ · Review queue, the Eadwyn Tickets canvas's T1 and T1b
+ * (`design/canvases/integrated/Eadwyn Tickets Screen.dc.html`, screens
+ * `queue-features` and `queue-tasks`). Super Admin only, as D544's routes are.
+ *
+ * /hq/review feature requests (T1)
+ * /hq/review?tab=tasks suggested tasks, with the audience picker (T1b)
+ *
+ * WHAT IT READS. Both lists at once, so each tab carries its count: the
+ * number waiting (`total`), withheld while loading and when a list cannot be
+ * read, never shown as 0. The reward on a task's Approve comes from the
+ * credit rules (`GET /api/community-tasks/rewards`), never from code: the
+ * Super Admin edits that amount.
+ *
+ * WHERE IT DEPARTS FROM THE CANVAS, and why (D606):
+ * - Approve asks for a reason too. The Worker records a typed reason of at
+ * least ten characters with every decision, approvals included (D544).
+ * - "Asked by" is a name and an email, not a link: there is no page for
+ * one account to link to. The Team directory is one row away in the
+ * sidebar.
+ * - The confirm panels open on the item they decide, rather than below
+ * the list, so a decision is never made on the wrong row.
+ */
+
+function Chip({ chip }) {
+ if (!chip) return null;
+ return
{chip.text} ;
+}
+
+const BTN = {
+ approve: 'flex-1 rounded-[9px] bg-axal-violet px-3 py-2 text-[12.5px] font-semibold text-white transition-colors hover:bg-axal-violet-deep disabled:cursor-not-allowed disabled:opacity-50',
+ decline: 'flex-1 rounded-[9px] border border-red-200 bg-white px-3 py-2 text-[12.5px] font-semibold text-red-700 transition-colors hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-red-900 dark:bg-gray-900 dark:text-red-300 dark:hover:bg-red-950/40',
+ quiet: 'rounded-[9px] border border-axal-hairline bg-white px-3 py-2 text-[12.5px] font-semibold text-axal-ink transition-colors hover:border-axal-violet/40 disabled:opacity-50 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100',
+ declineSolid: 'rounded-[9px] bg-red-700 px-3 py-2 text-[12.5px] font-semibold text-white transition-colors hover:bg-red-800 disabled:cursor-not-allowed disabled:opacity-50',
+ approveSolid: 'rounded-[9px] bg-axal-violet px-3 py-2 text-[12.5px] font-semibold text-white transition-colors hover:bg-axal-violet-deep disabled:cursor-not-allowed disabled:opacity-50',
+};
+const FIELD = 'w-full rounded-[9px] border border-axal-hairline bg-white px-3 py-2 text-[13px] text-axal-ink placeholder:text-axal-faint focus:outline-none focus:ring-2 focus:ring-axal-violet/40 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100';
+const SELECT = 'rounded-full border border-axal-hairline bg-white px-3 py-1.5 text-[12px] font-medium text-axal-ink focus:outline-none focus:ring-2 focus:ring-axal-violet/40 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100';
+
+/** The canvas's two confirm panels, on the item they decide. */
+export function DecisionPanel({ kind, tab, item, audience, reward, busy, error, onCancel, onConfirm }) {
+ const [reason, setReason] = useState('');
+ const approving = kind === 'approve';
+ const hint = reasonHint(reason);
+ const ready = reasonReady(reason) && !busy;
+ const id = `review-reason-${tab}-${item.id ?? item.ticket_id}`;
+ return (
+
+
+ {approving ? 'Approve · confirm' : 'Decline · reason required'}
+
+
+ {approving ? (
+ <>
+
{approveCopy(tab, item, { audience, reward })}
+ {tab === 'tasks' && (
+
+ Audience: {audienceLabel(audience)}
+ {reward?.state === 'on' ? ` · suggester ${item.asked_by?.name || 'with no name on record'} receives +${reward.credits} credits` : ''}
+
+ )}
+ >
+ ) : (
+
The user sees this reason on their ticket.
+ )}
+
+ {approving ? 'Why approve? Recorded with the decision; the requester is not sent it.' : 'Reason'}
+
+
+
+ );
+}
+
+/** One waiting request: its text, who asked, and the two decisions. */
+export function QueueItem({ tab, item, reward, outcome, onDecide }) {
+ const [open, setOpen] = useState(null); // 'approve' | 'decline' | null
+ const [audience, setAudience] = useState('all');
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+ const key = item.id ?? item.ticket_id;
+ const note = tab === 'tasks' ? rewardNote(reward) : null;
+ const decide = async (kind, reason) => {
+ setBusy(true);
+ setError(null);
+ try {
+ await onDecide({ kind, item, reason, audience });
+ setOpen(null);
+ } catch (e) {
+ setError(decisionRefusal(e));
+ } finally {
+ setBusy(false);
+ }
+ };
+ const asker = item.asked_by || {};
+ return (
+
+
+
+
+
{item.title || 'Untitled request'}
+
+
+
+ {item.body
+ ?
{item.body}
+ :
No text was filed with it.
}
+
+
+
+
+ {outcome ? (
+
+ {outcome.text}
+
+ ) : (
+ <>
+ {tab === 'tasks' && (
+
+ Publish to
+ setAudience(e.target.value)}
+ disabled={busy}
+ className={FIELD}
+ data-testid="review-audience"
+ >
+ {AUDIENCES.map((a) => {a.label} )}
+
+
+ )}
+
+ { setOpen('approve'); setError(null); }} disabled={busy} aria-expanded={open === 'approve'} className={BTN.approve} data-testid="review-approve">
+ {approveLabel(tab, reward)}
+
+ { setOpen('decline'); setError(null); }} disabled={busy} aria-expanded={open === 'decline'} className={BTN.decline} data-testid="review-decline">
+ Decline…
+
+
+ {note &&
{note}
}
+ >
+ )}
+
+
+ {open && !outcome && (
+ { setOpen(null); setError(null); }}
+ onConfirm={(reason) => decide(open, reason)}
+ />
+ )}
+
+ );
+}
+
+function SkeletonItems() {
+ return (
+
+ {[0, 1, 2].map((i) => (
+
+ ))}
+
+ );
+}
+
+/** The list for one tab, in every state the canvas draws. */
+export function QueueList({ tab, read, reward, filters, page, onPage, onClearFilters, outcomes, onDecide, onRetry, now }) {
+ if (!read || read.status === 'loading') return
;
+ if (read.status !== 'ready') {
+ return (
+
+ Unreadable
+
+ The queue could not be read. The count on the tab is withheld, not zero.
+
+
+ Try again
+
+
+ );
+ }
+ const items = read.data?.items || [];
+ const view = queueView(items, { filters, page, total: read.data?.total, now });
+ if (!items.length) {
+ return (
+
+ {tab === 'tasks' ? 'No suggested tasks are waiting for review.' : 'No feature requests are waiting for review.'}
+
+ );
+ }
+ return (
+
+
+ {view.label}
+ {view.partial && {view.partial} }
+
+ {!view.rows.length ? (
+
+ Nothing waiting matches these filters.{' '}
+ Clear the filters
+
+ ) : view.rows.map((item) => {
+ const key = item.id ?? item.ticket_id;
+ return
;
+ })}
+ {view.pages > 1 && (
+
+ onPage(view.page - 1)} className={BTN.quiet}>Previous
+ Page {view.page} of {view.pages}
+ = view.pages} onClick={() => onPage(view.page + 1)} className={BTN.quiet}>Next
+
+ )}
+
+ );
+}
+
+const READERS = { features: () => ticketReviewsApi.features(), tasks: () => ticketReviewsApi.tasks() };
+const LOADING = { status: 'loading' };
+
+export default function ReviewQueuePage() {
+ const location = useLocation();
+ const navigate = useNavigate();
+ const tab = tabFromSearch(location.search);
+ const [reads, setReads] = useState({ features: LOADING, tasks: LOADING });
+ const [rewards, setRewards] = useState(LOADING);
+ const [filters, setFilters] = useState(NO_QUEUE_FILTERS);
+ const [page, setPage] = useState(1);
+ // A decision stays on its card until the next read, so its outcome is seen.
+ const [outcomes, setOutcomes] = useState({ features: {}, tasks: {} });
+
+ const load = useCallback(() => {
+ setReads({ features: LOADING, tasks: LOADING });
+ setOutcomes({ features: {}, tasks: {} });
+ for (const key of Object.keys(READERS)) {
+ READERS[key]().then(
+ (data) => setReads((r) => ({ ...r, [key]: { status: 'ready', data } })),
+ (e) => { reportError(`ReviewQueue:${key}`, e); setReads((r) => ({ ...r, [key]: { status: 'unreadable', message: e?.message || '' } })); },
+ );
+ }
+ setRewards(LOADING);
+ communityTasksApi.rewards().then(
+ (data) => setRewards({ status: 'ready', data }),
+ (e) => { reportError('ReviewQueue:rewards', e); setRewards({ status: 'unreadable' }); },
+ );
+ }, []);
+ useEffect(() => { load(); }, [load]);
+
+ const selectTab = (id) => {
+ setPage(1);
+ navigate(id === 'tasks' ? '/hq/review?tab=tasks' : '/hq/review');
+ };
+ const setFilter = (k, v) => { setFilters((f) => ({ ...f, [k]: v })); setPage(1); };
+
+ const reward = useMemo(() => taskReward(rewards), [rewards]);
+
+ const onDecide = useCallback(async ({ kind, item, reason, audience }) => {
+ const decision = kind === 'approve' ? 'approved' : 'declined';
+ let res;
+ if (tab === 'tasks') {
+ res = kind === 'approve'
+ ? await ticketReviewsApi.approveTask(item.id, { audience, reason })
+ : await ticketReviewsApi.declineTask(item.id, reason);
+ } else {
+ res = kind === 'approve'
+ ? await ticketReviewsApi.approveFeature(item.ticket_id, reason)
+ : await ticketReviewsApi.declineFeature(item.ticket_id, reason);
+ }
+ const key = item.id ?? item.ticket_id;
+ setOutcomes((o) => ({ ...o, [tab]: { ...o[tab], [key]: { decision, text: decisionOutcome(tab, decision, res) } } }));
+ }, [tab]);
+
+ // A tab's count is the number still waiting: decided rows leave it at once.
+ const count = (id) => {
+ const n = tabCount(reads[id]);
+ return n === null ? null : Math.max(0, n - Object.keys(outcomes[id] || {}).length);
+ };
+ const coverage = [
+ ...QUEUE_TABS.map((t) => {
+ const n = count(t.id);
+ return n === null ? null : `${t.label}: ${n} waiting`;
+ }),
+ reward.state === 'on' ? `An approved task pays ${reward.credits} credits` : null,
+ ].filter(Boolean);
+
+ return (
+
+ )}
+ data-testid="review-queue-page"
+ >
+
+
+
+
+ {QUEUE_TABS.map((t) => {
+ const on = t.id === tab;
+ const n = count(t.id);
+ return (
+ selectTab(t.id)}
+ data-testid={`review-tab-${t.id}`}
+ className={`-mb-px whitespace-nowrap border-b-2 px-3 py-2.5 text-[13px] font-semibold transition-colors ${on
+ ? 'border-axal-violet text-axal-violet-deep dark:text-violet-300'
+ : 'border-transparent text-axal-muted hover:text-axal-ink dark:text-gray-400 dark:hover:text-gray-100'}`}
+ >
+ {t.label}
+ {n !== null && {n} }
+
+ );
+ })}
+
+
+
+ setFilter('query', e.target.value)}
+ placeholder="Search titles…"
+ aria-label="Search titles"
+ className={`${FIELD} min-w-[200px] flex-1`}
+ data-testid="review-search"
+ />
+ setFilter('priority', e.target.value)} aria-label="Priority" className={SELECT} data-testid="review-priority">
+ All priorities
+ Urgent
+ High
+ Medium
+ Low
+
+ setFilter('window', e.target.value)} aria-label="Date" className={SELECT} data-testid="review-date">
+ {DATE_WINDOWS.map((w) => {w.label} )}
+
+ {hasQueueFilters(filters) && (
+ { setFilters(NO_QUEUE_FILTERS); setPage(1); }} className="px-2 text-[12px] font-semibold text-axal-violet-deep hover:underline dark:text-violet-300">
+ Clear
+
+ )}
+
+
+
+ { setFilters(NO_QUEUE_FILTERS); setPage(1); }}
+ outcomes={outcomes[tab] || {}}
+ onDecide={onDecide}
+ onRetry={load}
+ />
+
+
+
+ A decision is recorded once. A second decision on the same request is refused, never applied twice.
+
+
+
+ );
+}
diff --git a/frontend/src/sidebarConfig.js b/frontend/src/sidebarConfig.js
index abce61be15..2d2fca0d8e 100644
--- a/frontend/src/sidebarConfig.js
+++ b/frontend/src/sidebarConfig.js
@@ -122,7 +122,8 @@ export const SIDEBAR_GROUPS = {
{ to: '/admin/content', icon: FileStack, label: 'Content' },
{ to: '/admin/platform', icon: SlidersHorizontal, label: 'Platform' },
// Y1 — three queues, not the shared Help Center. /help stays for every role.
- { to: '/admin/hq-support', icon: Inbox, label: 'Support' },
+ // D606 — the review queue (/hq/review) is Support's: the canvas lights this row.
+ { to: '/admin/hq-support', icon: Inbox, label: 'Support', match: ['/admin/hq-support', '/hq/review'] },
// "Security", not "Governance" (ASSUMPTIONS_LOG A4): the audit log is
// what someone finds inside, not what they come for.
{ to: '/admin/security', icon: ShieldCheck, label: 'Security' },
diff --git a/frontend/test/eadwyn_tickets_d606.test.mjs b/frontend/test/eadwyn_tickets_d606.test.mjs
new file mode 100644
index 0000000000..2dc8af3a4a
--- /dev/null
+++ b/frontend/test/eadwyn_tickets_d606.test.mjs
@@ -0,0 +1,382 @@
+/**
+ * D606 (#1107, #1108) — the owner's Eadwyn Tickets canvas
+ * (`design/canvases/integrated/Eadwyn Tickets Screen.dc.html`): the Super
+ * Admin's review queue at /hq/review (T1, T1b) and the Eadwyn chat's Tasks and
+ * My tickets panels (T2, T3).
+ *
+ * What is pinned, and why:
+ * - a tab's count is the number waiting, and it is withheld (never 0) while
+ * a list is loading or could not be read;
+ * - the queue's filters, order and "1–25 of [N]" agree, and a queue past the
+ * 200 the Worker returns says so;
+ * - a decision needs a typed reason of the Worker's length, approvals too;
+ * - the reward on a task's Approve comes from the credit rules, never code;
+ * - a suggestion shows what its grant paid; a balance that could not be
+ * read is withheld, never 0;
+ * - the chat's ticket form has no default type except from "Suggest a task".
+ *
+ * Run:
+ * node --import ./frontend/test/_deck-loader.mjs --test frontend/test/eadwyn_tickets_d606.test.mjs
+ */
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync, existsSync } from 'node:fs';
+import { resolve, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { createElement as h } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { MemoryRouter } from 'react-router-dom';
+
+import {
+ AUDIENCES, NO_QUEUE_FILTERS, QUEUE_PAGE, REASON_MIN, approveCopy, approveLabel, balanceLine, bountyRatesNote,
+ decisionOutcome, decisionRefusal, matchesQueue, panelTickets, queueView, reasonHint, reasonReady, rewardNote,
+ sortQueue, startPrompt, suggestNote, suggestionLine, tabCount, tabFromSearch, taskLine, taskReward,
+} from '../src/lib/eadwynTickets.js';
+import { QueueList, DecisionPanel } from '../src/pages/hq/ReviewQueuePage.jsx';
+import { TasksPanelView, TicketsPanelView, PANEL_TABS } from '../src/components/advisor/EadwynPanels.jsx';
+import { TicketView } from '../src/pages/TicketsPage.jsx';
+import { ticketReviewsApi } from '../src/lib/api/ticketReviews.js';
+
+const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
+const read = (p) => readFileSync(resolve(ROOT, p), 'utf8');
+const html = (el) => renderToStaticMarkup(h(MemoryRouter, null, el));
+const text = (markup) => markup.replace(/<[^>]+>/g, ' ').replace(/'/g, "'").replace(/"/g, '"').replace(/&/g, '&')
+ .replace(/\s+/g, ' ').trim();
+
+const NOW = Date.parse('2026-10-06T12:00:00Z');
+const daysAgo = (n) => new Date(NOW - n * 86_400_000).toISOString();
+const ready = (data) => ({ status: 'ready', data });
+const RULES = (o = {}) => ready({ rewards: {
+ task_approved: { credits: 10, active: true },
+ bug_high: { credits: 150, active: true },
+ bug_medium: { credits: 60, active: true },
+ bug_low: { credits: 20, active: true },
+ ...o,
+} });
+
+// ---------------------------------------------------------------------------
+// T1, T1b — the queue's logic
+// ---------------------------------------------------------------------------
+
+test('the reason floor is the Worker\'s, and a short reason cannot be sent', () => {
+ const worker = read('cloudflare-worker/src/routes/admin_ticket_reviews.ts');
+ assert.match(worker, new RegExp(`export const REVIEW_REASON_MIN = ${REASON_MIN};`));
+ assert.equal(reasonReady('Too short'), false);
+ assert.equal(reasonReady(' ten chars! '), true);
+ assert.equal(reasonHint(''), `At least ${REASON_MIN} characters (0 so far). It is recorded with the decision.`);
+ assert.equal(reasonHint('Clear enough now'), null);
+});
+
+test('?tab=tasks opens T1b and anything else T1; the audiences are the Worker\'s, in its order', () => {
+ assert.equal(tabFromSearch('?tab=tasks'), 'tasks');
+ assert.equal(tabFromSearch('?tab=features'), 'features');
+ assert.equal(tabFromSearch(''), 'features');
+ const worker = read('cloudflare-worker/src/routes/admin_ticket_reviews.ts');
+ const listed = JSON.parse(worker.match(/export const AUDIENCES = (\[[^\]]+\])/)[1].replace(/'/g, '"'));
+ assert.deepEqual(AUDIENCES.map((a) => a.id), listed);
+});
+
+test('search, priority and date filter the queue; the most pressing come first', () => {
+ const items = [
+ { ticket_id: 1, title: 'Export to CSV', priority: 'medium', created_at: daysAgo(40) },
+ { ticket_id: 2, title: 'Dark charts', priority: 'urgent', created_at: daysAgo(2) },
+ { ticket_id: 3, title: 'Export to PDF', priority: 'urgent', created_at: daysAgo(10) },
+ { ticket_id: 4, title: 'No date on record', priority: 'low', created_at: null },
+ ];
+ assert.deepEqual(sortQueue(items).map((x) => x.ticket_id), [3, 2, 1, 4], 'urgent first, oldest first within it');
+ const pick = (f) => items.filter((x) => matchesQueue(x, { ...NO_QUEUE_FILTERS, ...f }, NOW)).map((x) => x.ticket_id);
+ assert.deepEqual(pick({ query: 'export' }), [1, 3]);
+ assert.deepEqual(pick({ priority: 'urgent' }), [2, 3]);
+ assert.deepEqual(pick({ window: '7' }), [2]);
+ assert.deepEqual(pick({ window: '30' }), [2, 3]);
+ assert.deepEqual(pick({ window: 'older' }), [1], 'a row with no date matches no window');
+});
+
+test('"1–25 of [N]" names the number waiting, and past the loaded 200 says so', () => {
+ const items = Array.from({ length: 30 }, (_, i) => ({ ticket_id: i + 1, title: `Ask ${i}`, priority: 'low', created_at: daysAgo(i) }));
+ const first = queueView(items, { total: 30, now: NOW });
+ assert.equal(first.rows.length, QUEUE_PAGE);
+ assert.equal(first.label, '1–25 of 30');
+ assert.equal(first.partial, null);
+ const second = queueView(items, { total: 30, page: 2, now: NOW });
+ assert.equal(second.label, '26–30 of 30');
+ const capped = queueView(items, { total: 412, now: NOW });
+ assert.equal(capped.label, '1–25 of 412');
+ assert.equal(capped.partial, 'The oldest 30 of 412 waiting are loaded. Decide some to reach the rest.');
+ const filtered = queueView(items, { filters: { ...NO_QUEUE_FILTERS, query: 'Ask 1' }, total: 412, now: NOW });
+ assert.equal(filtered.label.endsWith(` of ${filtered.matched}`), true, 'a filtered view counts what matched, not what waits');
+});
+
+test('a tab\'s count is withheld while loading or unreadable, never 0', () => {
+ assert.equal(tabCount({ status: 'loading' }), null);
+ assert.equal(tabCount({ status: 'unreadable' }), null);
+ assert.equal(tabCount(ready({ items: [], count: 0, total: 0 })), 0);
+ assert.equal(tabCount(ready({ items: [], count: 200, total: 236 })), 236);
+});
+
+test('a task\'s reward is read from the rules: on, off, absent or unreadable', () => {
+ assert.deepEqual(taskReward(RULES()), { state: 'on', credits: 10 });
+ assert.equal(approveLabel('tasks', taskReward(RULES({ task_approved: { credits: 25, active: true } }))), 'Approve · +25 credits');
+ assert.equal(approveLabel('features', taskReward(RULES())), 'Approve', 'a feature earns nothing (#1102 Q2 A)');
+ const off = taskReward(RULES({ task_approved: { credits: 10, active: false } }));
+ assert.equal(off.state, 'off');
+ assert.equal(approveLabel('tasks', off), 'Approve');
+ assert.match(rewardNote(off), /switched off/);
+ assert.equal(taskReward(RULES({ task_approved: null })).state, 'none');
+ const unread = taskReward({ status: 'unreadable' });
+ assert.equal(unread.state, 'unreadable');
+ assert.match(rewardNote(unread), /could not be read/);
+ assert.equal(rewardNote(taskReward(RULES())), null);
+});
+
+test('the confirm sentence and the outcome say what the route did', () => {
+ assert.equal(approveCopy('features', { title: 'Dark charts' }), 'Sends “Dark charts” to the build queue. The requester sees “Approved” on their ticket.');
+ assert.equal(
+ approveCopy('tasks', { title: 'Share a template' }, { audience: 'founder', reward: { state: 'on', credits: 10 } }),
+ 'Publishes “Share a template” to the Task list of founders and rewards the suggester 10 credits.',
+ );
+ assert.equal(
+ decisionOutcome('features', 'approved', { label_status: 'added', notified: true }),
+ 'Approved. It goes to the build queue. The GitHub issue is labelled feature:approved. The requester was told in their inbox.',
+ );
+ assert.match(decisionOutcome('features', 'declined', { label_status: 'not_configured', notified: false }), /no label was added\. The requester could not be told/);
+ assert.equal(
+ decisionOutcome('tasks', 'approved', { audience: 'all', reward: { status: 'granted', credits: 10 } }),
+ 'Approved and published to all users. The suggester was paid 10 credits.',
+ );
+ assert.equal(
+ decisionOutcome('tasks', 'approved', { audience: 'investor', reward: { status: 'refused', code: 'rule_inactive', message: 'The task_approved reward is switched off, so nothing was granted.' } }),
+ 'Approved and published to investors. No credits were paid: The task_approved reward is switched off, so nothing was granted.',
+ );
+ assert.match(decisionRefusal({ status: 409, message: 'This request was already approved; nothing was changed.' }), /already approved; nothing was changed\. Reload the queue/);
+});
+
+// ---------------------------------------------------------------------------
+// T1, T1b — rendered
+// ---------------------------------------------------------------------------
+
+const FEATURE = {
+ ticket_id: 41, title: 'Dark charts on the dashboard', body: 'The charts are hard to read at night.', priority: 'high',
+ created_at: daysAgo(3), asked_by: { user_id: 7, name: 'Fay Founder', email: 'fay@example.test' },
+ github_issue_number: 1301, github_issue_url: 'https://github.com/AxalNetwork/StudioOS/issues/1301',
+};
+const TASK = { ...FEATURE, id: 5, ticket_id: 42, title: 'Share a cap-table template', github_issue_number: null, github_issue_url: null };
+const list = (tab, read, extra = {}) => html(h(QueueList, {
+ tab, read, reward: taskReward(RULES()), filters: NO_QUEUE_FILTERS, page: 1, onPage() {}, onClearFilters() {},
+ outcomes: {}, onDecide: async () => {}, onRetry() {}, now: NOW, ...extra,
+}));
+
+test('the queue renders its loading, unreadable and empty states, each in its own words', () => {
+ assert.match(list('features', { status: 'loading' }), /data-testid="review-loading"/);
+ const bad = text(list('features', { status: 'unreadable' }));
+ assert.match(bad, /Unreadable The queue could not be read\. The count on the tab is withheld, not zero\. Try again/);
+ assert.match(text(list('features', ready({ items: [], count: 0, total: 0 }))), /No feature requests are waiting for review\./);
+ assert.match(text(list('tasks', ready({ items: [], count: 0, total: 0 }))), /No suggested tasks are waiting for review\./);
+});
+
+test('a feature request shows its text, who asked, the date and its GitHub issue', () => {
+ const markup = list('features', ready({ items: [FEATURE], count: 1, total: 1 }));
+ const t = text(markup);
+ assert.match(t, /Dark charts on the dashboard High Feature/);
+ assert.match(t, /The charts are hard to read at night\./);
+ assert.match(t, /Asked by Fay Founder · fay@example\.test/);
+ assert.match(markup, /href="https:\/\/github\.com\/AxalNetwork\/StudioOS\/issues\/1301"[^>]*>GitHub #1301/);
+ assert.match(t, /1–1 of 1/);
+ assert.doesNotMatch(markup, /review-audience/, 'a feature has no audience picker');
+ assert.match(markup, /data-testid="review-approve"[^>]*>Approve);
+});
+
+test('a suggested task has the audience picker and the reward on its button; one off the tracker says so', () => {
+ const markup = list('tasks', ready({ items: [TASK], count: 1, total: 1 }));
+ const opts = [...markup.matchAll(/
m[1]);
+ assert.deepEqual(opts, AUDIENCES.map((a) => a.id));
+ assert.match(markup, /data-testid="review-approve"[^>]*>Approve · \+10 credits);
+ assert.match(text(markup), /Not on the GitHub tracker/);
+ const off = list('tasks', ready({ items: [TASK], count: 1, total: 1 }), { reward: taskReward(RULES({ task_approved: { credits: 10, active: false } })) });
+ assert.match(off, /data-testid="review-approve"[^>]*>Approve);
+ assert.match(text(off), /The task reward is switched off in the credit rules, so approving pays nothing\./);
+});
+
+test('a decided item shows its outcome instead of its buttons', () => {
+ const markup = list('features', ready({ items: [FEATURE], count: 1, total: 1 }), {
+ outcomes: { 41: { decision: 'declined', text: 'Declined, with your reason. The requester was told in their inbox.' } },
+ });
+ assert.match(text(markup), /Declined, with your reason\. The requester was told in their inbox\./);
+ assert.doesNotMatch(markup, /data-testid="review-approve"/);
+});
+
+test('both confirm panels ask for the reason, and stay closed to a click until it is long enough', () => {
+ const approve = html(h(DecisionPanel, { kind: 'approve', tab: 'tasks', item: TASK, audience: 'partner', reward: { state: 'on', credits: 10 }, busy: false, error: null, onCancel() {}, onConfirm() {} }));
+ const a = text(approve);
+ assert.match(a, /Approve · confirm Publishes “Share a cap-table template” to the Task list of partners and rewards the suggester 10 credits\./);
+ assert.match(a, /Audience: Partners · suggester Fay Founder receives \+10 credits/);
+ assert.match(a, /Why approve\? Recorded with the decision; the requester is not sent it\./);
+ assert.match(approve, /]*data-testid="review-approve-confirm"/);
+ const decline = html(h(DecisionPanel, { kind: 'decline', tab: 'features', item: FEATURE, audience: 'all', reward: null, busy: false, error: 'This request was already approved; nothing was changed.', onCancel() {}, onConfirm() {} }));
+ const d = text(decline);
+ assert.match(d, /Decline · reason required The user sees this reason on their ticket\./);
+ assert.match(d, /At least 10 characters \(0 so far\)\./);
+ assert.match(decline, /role="alert"[^>]*>This request was already approved; nothing was changed\./);
+ assert.match(decline, /]*data-testid="review-decline-confirm"/);
+});
+
+// ---------------------------------------------------------------------------
+// T2 — Tasks
+// ---------------------------------------------------------------------------
+
+test('a suggestion\'s state names what its grant paid, never the rule', () => {
+ assert.deepEqual(suggestionLine({ status: 'pending' }), { text: 'Pending review', tone: 'amber' });
+ assert.deepEqual(suggestionLine({ status: 'approved', reward: { credits: 12, revoked: false } }), { text: 'Approved · +12 credits', tone: 'green' });
+ assert.equal(suggestionLine({ status: 'approved', reward: { credits: 12, revoked: true } }).text, 'Approved · credits taken back');
+ assert.equal(suggestionLine({ status: 'approved', reward: { credits: null, revoked: false } }).text, 'Approved · no credits paid');
+ assert.equal(suggestionLine({ status: 'approved', reward: { credits: null, credits_unreadable: true } }).text, 'Approved · credits could not be read');
+ assert.deepEqual(suggestionLine({ status: 'declined', decline_reason: 'Not useful to others' }), { text: 'Declined', tone: 'red', reason: 'Not useful to others' });
+ assert.equal(taskLine(' Share it\n\nwith the cohort '), 'Share it with the cohort');
+ assert.equal(taskLine(''), null);
+ assert.equal(startPrompt({ title: 'Post your first update' }), 'Help me with Post your first update');
+ assert.match(read('frontend/src/components/advisor/PersonalAdvisor.jsx'), /const m = raw\.match\(\/\^\\s\*\(\?:explain\|what is\|what's\|tell me about\|help me with\|/,
+ '"Start" relies on the chat reading "help me with" as an explanation, not an answer');
+});
+
+test('the Tasks panel: community tasks with Start, my suggestions with their states, and the note', () => {
+ const data = {
+ tasks: [{ id: 1, title: 'Share a cap-table template', body: 'Upload the one you use.', audience: 'all' }],
+ my_suggestions: [
+ { id: 3, title: 'Post a weekly update', status: 'approved', decline_reason: null, reward: { credits: 10, revoked: false } },
+ { id: 2, title: 'Record a demo', status: 'declined', decline_reason: 'Decks are private by default here' },
+ { id: 4, title: 'Join a cohort call', status: 'pending', decline_reason: null, reward: null },
+ ],
+ my_suggestions_next: 2,
+ };
+ const t = text(html(h(TasksPanelView, { list: ready(data), rewards: RULES(), onSuggest() {}, onStart() {}, onRetry() {}, onOlder() {} })));
+ assert.match(t, /Community tasks Suggest a task Share a cap-table template Upload the one you use\. Start →/);
+ assert.match(t, /My suggestions Post a weekly update Approved · \+10 credits Record a demo Declined Decks are private by default here Join a cohort call Pending review/);
+ assert.match(t, /Show older suggestions/);
+ assert.match(t, /“Suggest a task” opens the ticket form with Type set to Task\. An approved suggestion earns 10 credits\./);
+ assert.equal(suggestNote({ status: 'unreadable' }), '“Suggest a task” opens the ticket form with Type set to Task.');
+});
+
+test('the Tasks panel says "No tasks yet" only for an empty list, and unreadable for a failed one', () => {
+ const empty = text(html(h(TasksPanelView, { list: ready({ tasks: [], my_suggestions: [], my_suggestions_next: null }), rewards: RULES(), onSuggest() {}, onStart() {}, onRetry() {}, onOlder() {} })));
+ assert.match(empty, /No tasks yet\./);
+ assert.match(empty, /You have not suggested a task yet\./);
+ const bad = text(html(h(TasksPanelView, { list: { status: 'unreadable' }, rewards: RULES(), onSuggest() {}, onStart() {}, onRetry() {}, onOlder() {} })));
+ assert.match(bad, /Unreadable\. The task list could not be read\. This is not a sign there are none\. Try again/);
+ assert.doesNotMatch(bad, /No tasks yet/);
+});
+
+// ---------------------------------------------------------------------------
+// T3 — My tickets
+// ---------------------------------------------------------------------------
+
+test('the balance is a number once read, and withheld, never 0, when it could not be', () => {
+ assert.deepEqual(balanceLine(ready({ balance: 12 })), { state: 'ready', text: '12 credits' });
+ assert.equal(balanceLine(ready({ balance: 1 })).text, '1 credit');
+ assert.equal(balanceLine(ready({ balance: 0 })).text, '0 credits', 'a read zero is a real zero');
+ for (const r of [{ status: 'unreadable' }, ready({})]) {
+ const b = balanceLine(r);
+ assert.equal(b.state, 'unreadable');
+ assert.doesNotMatch(b.text, /\b0\b/);
+ }
+});
+
+test('what a fixed bug earns is by graded tier, from the rules; an off or missing rule says so', () => {
+ assert.equal(bountyRatesNote(RULES()), 'A confirmed bug earns credits when the fix ships, by the tier it is graded at: High +150, Medium +60, Low +20.');
+ assert.equal(
+ bountyRatesNote(RULES({ bug_high: { credits: 175, active: true }, bug_low: { credits: 20, active: false }, bug_medium: null })),
+ 'A confirmed bug earns credits when the fix ships, by the tier it is graded at: High +175, Medium: not set, Low: not paid now.',
+ );
+ assert.equal(bountyRatesNote({ status: 'unreadable' }), 'What a fixed bug earns could not be read just now.');
+ assert.equal(bountyRatesNote({ status: 'loading' }), null);
+});
+
+test('My tickets lists the five most recent with type, priority and bounty or review', () => {
+ const rows = [
+ { id: 1, title: 'Old one', type: 'task', priority: 'low', updated_at: '2026-09-01 10:00:00', bounty: null, review: null },
+ { id: 2, title: 'Export is empty', type: 'bug', priority: 'high', updated_at: '2026-10-05 10:00:00', bounty: { state: 'fixed', credits: 150, revoked: false }, review: null },
+ { id: 3, title: 'Not reproducible', type: 'bug', priority: 'low', updated_at: '2026-10-04 10:00:00', bounty: { state: 'not_a_bug' }, review: null },
+ { id: 4, title: 'Dark charts', type: 'feature', priority: 'medium', updated_at: '2026-10-03 10:00:00', bounty: null, review: { state: 'declined', reason: 'Not now' } },
+ { id: 5, title: 'Same as #2', type: 'bug', priority: 'medium', updated_at: '2026-10-02 10:00:00', bounty: { state: 'duplicate' }, review: null },
+ { id: 6, title: 'Mid', type: 'task', priority: 'medium', updated_at: '2026-09-20 10:00:00', bounty: null, review: null },
+ ];
+ assert.deepEqual(panelTickets(rows).map((t) => t.id), [2, 3, 4, 5, 6]);
+ const markup = html(h(TicketsPanelView, { tickets: ready(rows), balance: ready({ balance: 170 }), rewards: RULES(), onRetry() {} }));
+ const t = text(markup);
+ assert.match(t, /170 credits US\$1 each · spent on Perks Perks →/);
+ assert.match(t, /Export is empty Bug High Fixed: 150 credits #2/);
+ assert.match(t, /Not reproducible Bug Low Not a bug The reason is in the ticket’s comments\. Open the ticket →/);
+ assert.match(t, /Dark charts Feature Medium Declined/);
+ assert.match(t, /Same as #2 Bug Medium Duplicate The original is named in the ticket’s comments\. Open the ticket →/);
+ assert.doesNotMatch(t, /Old one/);
+ assert.match(markup, /href="\/help\/tickets"[^>]*>All tickets →/);
+ assert.match(markup, /href="\/perks"/);
+});
+
+test('My tickets: a failed read is said as one, and the balance stays withheld on its own', () => {
+ const t = text(html(h(TicketsPanelView, { tickets: { status: 'unreadable' }, balance: { status: 'unreadable' }, rewards: RULES(), onRetry() {} })));
+ assert.match(t, /Your balance could not be read\. It is withheld, not zero\./);
+ assert.match(t, /Unreadable\. Your tickets could not be read\. This is not a sign that you have none\. Try again/);
+ assert.doesNotMatch(t, /\b0 credits\b/);
+ const none = text(html(h(TicketsPanelView, { tickets: ready([]), balance: ready({ balance: 0 }), rewards: RULES(), onRetry() {} })));
+ assert.match(none, /You have not opened a ticket\./);
+});
+
+// ---------------------------------------------------------------------------
+// Where it is wired
+// ---------------------------------------------------------------------------
+
+test('/hq/review is a route, the Super Admin\'s, under Support', () => {
+ const app = read('frontend/src/App.jsx');
+ assert.match(app, / \)\)\} \/>/);
+ const sidebar = read('frontend/src/sidebarConfig.js');
+ assert.match(sidebar, /\{ to: '\/admin\/hq-support', icon: Inbox, label: 'Support', match: \['\/admin\/hq-support', '\/hq\/review'\] \}/);
+ assert.match(read('frontend/src/pages/hq/HqSupportPage.jsx'), / {
+ const index = read('cloudflare-worker/src/index.ts');
+ assert.match(index, /['"]\/api\/admin\/ticket-reviews['"]/);
+ const src = read('frontend/src/lib/api/ticketReviews.js');
+ for (const path of ['/admin/ticket-reviews/features', '/admin/ticket-reviews/tasks']) assert.ok(src.includes(`'${path}'`), path);
+ for (const m of ['features', 'tasks', 'approveFeature', 'declineFeature', 'approveTask', 'declineTask']) assert.equal(typeof ticketReviewsApi[m], 'function', m);
+ assert.match(read('frontend/src/lib/api.js'), /export \* from '\.\/api\/ticketReviews\.js';/);
+});
+
+test('the chat carries the panel\'s three tabs, and "Suggest a task" opens the form on Task', () => {
+ assert.deepEqual(PANEL_TABS.map((t) => t.label), ['Conversation', 'Tasks', 'My tickets']);
+ const pa = read('frontend/src/components/advisor/PersonalAdvisor.jsx');
+ assert.match(pa, /const suggestTask = useCallback\(\(\) => \{ setTicketPreset\('task'\); setTicketOpen\(true\); \}, \[\]\);/);
+ assert.match(pa, /setInput\(startPrompt\(task\)\);/);
+ assert.match(pa, / \}/, 'the phone has no tabs');
+ assert.ok((pa.match(/= 3, 'a view lost its Tasks panel');
+ assert.match(pa, /Choose a type<\/option>/);
+});
+
+test('H7 links an awaiting feature request to the queue for the Super Admin only', () => {
+ const feature = {
+ id: 9, title: 'Dark charts', description: 'Please.', type: 'feature', category: null, priority: 'medium', status: 'open',
+ user_id: 2, submitted_by: 'Member Two', created_at: '2026-10-04 09:00:00', updated_at: '2026-10-05 11:00:00',
+ github_issue_number: 77, github_issue_url: 'https://github.com/AxalNetwork/StudioOS/issues/77', github_sync_status: 'synced',
+ bounty: null, review: { state: 'awaiting' }, thread: [], thread_unreadable: false,
+ };
+ const render = (user) => html(h(TicketView, { ticket: feature, isAdmin: user.role === 'admin', user, outcome: null, onRefresh: async () => {} }));
+ assert.match(render({ id: 1, role: 'admin', is_super_admin: 1 }), /href="\/hq\/review"[^>]*>Feature review queue →/);
+ assert.doesNotMatch(render({ id: 3, role: 'admin', is_super_admin: 0 }), /Feature review queue/);
+ assert.doesNotMatch(render({ id: 2, role: 'founder' }), /Feature review queue/);
+ const decided = html(h(TicketView, { ticket: { ...feature, review: { state: 'approved' } }, isAdmin: true, user: { id: 1, role: 'admin', is_super_admin: 1 }, outcome: null, onRefresh: async () => {} }));
+ assert.doesNotMatch(decided, /Feature review queue/, 'a decided request has nothing left to review');
+});
+
+test('the canvas is committed beside its routes, and draws the four screens built here', () => {
+ const shell = 'design/canvases/integrated/Eadwyn Tickets.dc.html';
+ const screen = 'design/canvases/integrated/Eadwyn Tickets Screen.dc.html';
+ assert.ok(existsSync(resolve(ROOT, shell)) && existsSync(resolve(ROOT, screen)));
+ const s = read(shell);
+ for (const id of ['T1', 'T1b', 'T2', 'T3']) assert.match(s, new RegExp(`id: '${id}'`));
+ assert.match(s, / {
// `Research · Company`, approved on 2026-10-05 and built from `design/incoming/`.
// 134 on 2026-10-06: Ops Page event organiser E1–E4 (#1173, D567).
// 135: Advisor practice A1–A5 and working public booking links (D573).
- assert.equal(recs.length, 135);
+ // 137 on 2026-10-06 (D606): the Eadwyn Tickets canvas, two rows — the
+ // review queue (T1, T1b) and the Eadwyn chat's panels (T2, T3).
+ assert.equal(recs.length, 137);
for (const r of recs) {
assert.ok(r.canvas.length > 0, 'every row names a canvas');
assert.ok(r.status.length > 0, `row "${r.canvas}" has no status`);