Phase 5 — Thread tracker

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
claudecode
2026-07-01 14:41:45 -04:00
parent 2e8de105b2
commit 06abde1471
7 changed files with 867 additions and 9 deletions
+336
View File
@@ -837,3 +837,339 @@ h1, h2, h3, h4, h5, h6 {
font-size: 0.8rem;
color: var(--text-secondary);
}
/* --- Threads --- */
.threads-empty-state {
max-width: 700px;
margin: 40px auto;
padding: 20px;
text-align: center;
font-style: italic;
color: var(--text-muted);
}
.threads-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
max-width: 700px;
margin: 0 auto 16px;
}
.threads-title {
margin: 0;
font-size: 1.4rem;
}
.thread-create-btn {
flex-shrink: 0;
padding: 10px 18px;
background: var(--bg);
border: 1px solid var(--accent);
border-radius: 3px;
color: var(--accent);
font-family: var(--font-heading);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
cursor: pointer;
transition: background var(--transition-speed) ease, border-color var(--transition-speed) ease;
}
.thread-create-btn:hover {
background: var(--bg-hover);
border-color: var(--accent-hover);
}
.thread-tabs {
display: flex;
gap: 8px;
max-width: 700px;
margin: 0 auto 16px;
}
.thread-tab {
flex: 1;
padding: 10px 4px;
border: 1px solid var(--border);
border-radius: 3px;
background: var(--bg);
color: var(--text-secondary);
font-family: var(--font-heading);
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
cursor: pointer;
transition: border-color var(--transition-speed) ease, color var(--transition-speed) ease, background var(--transition-speed) ease;
}
.thread-tab:hover {
color: var(--text-primary);
border-color: var(--accent-hover);
}
.thread-tab.active {
border-color: var(--accent);
color: var(--accent);
background: color-mix(in srgb, var(--accent) 10%, transparent);
}
.thread-list {
max-width: 700px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 10px;
}
.thread-empty {
padding: 24px;
text-align: center;
font-style: italic;
color: var(--text-muted);
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg-elevated);
}
.thread-card {
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg-elevated);
overflow: hidden;
}
.thread-card.expanded {
border-color: var(--accent);
}
.thread-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
padding: 14px 18px;
border: none;
background: none;
color: var(--text-primary);
font-family: var(--font-body);
font-size: 0.95rem;
text-align: left;
cursor: pointer;
}
.thread-card-header:hover {
background: var(--bg-hover);
}
.thread-card-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.thread-status-badge {
flex-shrink: 0;
padding: 3px 10px;
border-radius: 12px;
font-family: var(--font-mono);
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
border: 1px solid transparent;
}
.thread-status-active {
color: var(--info);
border-color: var(--info);
background: color-mix(in srgb, var(--info) 12%, transparent);
}
.thread-status-resolved {
color: var(--success);
border-color: var(--success);
background: color-mix(in srgb, var(--success) 12%, transparent);
}
.thread-status-suspended {
color: var(--text-muted);
border-color: var(--text-muted);
background: color-mix(in srgb, var(--text-muted) 12%, transparent);
}
.thread-status-complicated {
color: var(--danger);
border-color: var(--danger);
background: color-mix(in srgb, var(--danger) 12%, transparent);
}
.thread-card-detail {
padding: 4px 18px 18px;
border-top: 1px solid var(--border);
max-height: 400px;
overflow-y: auto;
}
.thread-field {
display: flex;
flex-direction: column;
gap: 2px;
padding: 10px 0;
border-bottom: 1px solid var(--bg);
}
.thread-field:last-of-type {
border-bottom: none;
}
.thread-field-label {
font-family: var(--font-heading);
font-size: 0.68rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
}
.thread-field-value {
font-size: 0.9rem;
color: var(--text-primary);
white-space: pre-wrap;
}
.thread-card-actions {
display: flex;
gap: 10px;
margin-top: 12px;
}
.thread-edit-btn,
.thread-delete-btn {
flex: 1;
padding: 10px;
border-radius: 3px;
font-family: var(--font-heading);
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
cursor: pointer;
background: var(--bg);
transition: border-color var(--transition-speed) ease, color var(--transition-speed) ease;
}
.thread-edit-btn {
border: 1px solid var(--accent);
color: var(--accent);
}
.thread-edit-btn:hover {
background: var(--bg-hover);
border-color: var(--accent-hover);
}
.thread-delete-btn {
border: 1px solid var(--danger);
color: var(--danger);
}
.thread-delete-btn:hover {
background: color-mix(in srgb, var(--danger) 10%, var(--bg));
}
/* --- Modal (Threads) --- */
.modal-overlay {
display: none;
position: fixed;
inset: 0;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.6);
z-index: 100;
}
.modal-overlay.open {
display: flex;
}
.modal {
width: 100%;
max-width: 520px;
max-height: 85vh;
margin: 0 16px;
display: flex;
flex-direction: column;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 4px;
padding: 20px;
}
.modal-title {
margin-bottom: 14px;
font-size: 1.2rem;
}
.modal-body {
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
padding-right: 4px;
}
.modal-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.modal-field-label {
font-family: var(--font-heading);
font-size: 0.68rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-secondary);
}
.modal-input,
.modal-select,
.modal-textarea {
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 3px;
background: var(--bg);
color: var(--text-primary);
font-family: var(--font-body);
font-size: 0.9rem;
}
.modal-textarea {
resize: vertical;
min-height: 60px;
}
.modal-input:focus,
.modal-select:focus,
.modal-textarea:focus {
outline: none;
border-color: var(--accent-hover);
}
.modal-actions {
display: flex;
gap: 10px;
margin-top: 16px;
}
.modal-actions .roll-btn,
.modal-actions .clear-btn {
margin-top: 0;
flex: 1;
}
+1
View File
@@ -306,4 +306,5 @@
<script src="js/meaning.js" type="module"></script>
<script src="js/une.js" type="module"></script>
<script src="js/dice.js" type="module"></script>
<script src="js/threads.js" type="module"></script>
</html>
+26
View File
@@ -53,3 +53,29 @@ export function getSystems() {
export function getTable(name) {
return request(`/tables/${name}`);
}
export async function getThreads(campaignId) {
const { data } = await request(`/campaigns/${campaignId}/threads`);
return data;
}
export async function createThread(campaignId, data) {
const result = await request(`/campaigns/${campaignId}/threads`, {
method: 'POST',
body: JSON.stringify(data),
});
return result.data;
}
export async function updateThread(campaignId, id, data) {
const result = await request(`/campaigns/${campaignId}/threads/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
});
return result.data;
}
export async function deleteThread(campaignId, id) {
const result = await request(`/campaigns/${campaignId}/threads/${id}`, { method: 'DELETE' });
return result.data;
}
+315 -1
View File
@@ -1 +1,315 @@
// Mythic Oracle — threads
// Mythic Oracle — thread tracker
import { getActiveCampaign } from './app.js';
import { getThreads, createThread, updateThread, deleteThread } from './api.js';
const NO_CAMPAIGN_MESSAGE = 'Select or create a campaign to use the thread tracker.';
const STATUSES = ['active', 'resolved', 'suspended', 'complicated'];
const FIELD_DEFS = [
{ key: 'title', label: 'Title', type: 'text' },
{ key: 'status', label: 'Status', type: 'select' },
{ key: 'notes', label: 'Notes', type: 'textarea' },
{ key: 'related_npcs', label: 'Related NPCs', type: 'text' },
{ key: 'related_location', label: 'Related Location', type: 'text' },
{ key: 'origin', label: 'Origin', type: 'textarea' },
{ key: 'stakes', label: 'Stakes', type: 'textarea' },
{ key: 'last_development', label: 'Last Development', type: 'textarea' },
{ key: 'next_beat', label: 'Next Beat', type: 'textarea' },
{ key: 'suspected_resolution', label: 'Suspected Resolution', type: 'textarea' },
];
const container = document.getElementById('view-threads');
let threadsCache = [];
let activeTab = 'active';
let expandedId = null;
let editingThreadId = null;
function statusLabel(status) {
return status.charAt(0).toUpperCase() + status.slice(1);
}
function renderNoCampaign() {
container.innerHTML = '';
const msg = document.createElement('div');
msg.className = 'threads-empty-state';
msg.textContent = NO_CAMPAIGN_MESSAGE;
container.appendChild(msg);
container.dataset.skeleton = 'false';
}
function ensureSkeleton() {
if (container.dataset.skeleton === 'true') return;
container.innerHTML = `
<div class="threads-header">
<h2 class="threads-title">Threads</h2>
<button class="thread-create-btn" id="threadCreateBtn" type="button">+ New Thread</button>
</div>
<div class="thread-tabs" id="threadTabs">
<button class="thread-tab active" data-status="active" type="button">Active</button>
<button class="thread-tab" data-status="resolved" type="button">Resolved</button>
<button class="thread-tab" data-status="suspended" type="button">Suspended</button>
<button class="thread-tab" data-status="complicated" type="button">Complicated</button>
</div>
<div class="thread-list" id="threadList"></div>
<div class="modal-overlay" id="threadModalOverlay">
<div class="modal">
<h3 class="modal-title" id="threadModalTitle">New Thread</h3>
<div class="modal-body" id="threadModalBody"></div>
<div class="modal-actions">
<button class="clear-btn" id="threadCancelBtn" type="button">Cancel</button>
<button class="roll-btn" id="threadSaveBtn" type="button">Save Thread</button>
</div>
</div>
</div>
`;
container.dataset.skeleton = 'true';
bindSkeletonEvents();
}
function bindSkeletonEvents() {
container.querySelector('#threadCreateBtn').addEventListener('click', openCreateModal);
container.querySelectorAll('.thread-tab').forEach((tab) => {
tab.addEventListener('click', () => {
if (tab.dataset.status === activeTab) return;
activeTab = tab.dataset.status;
container.querySelectorAll('.thread-tab').forEach((t) => t.classList.toggle('active', t === tab));
expandedId = null;
renderList();
});
});
container.querySelector('#threadCancelBtn').addEventListener('click', closeModal);
container.querySelector('#threadSaveBtn').addEventListener('click', saveModal);
container.querySelector('#threadModalOverlay').addEventListener('click', (event) => {
if (event.target.id === 'threadModalOverlay') closeModal();
});
}
function renderList() {
const listEl = container.querySelector('#threadList');
listEl.innerHTML = '';
const filtered = threadsCache.filter((thread) => thread.status === activeTab);
if (filtered.length === 0) {
const empty = document.createElement('div');
empty.className = 'thread-empty';
empty.textContent = `No ${activeTab} threads.`;
listEl.appendChild(empty);
return;
}
filtered.forEach((thread) => {
listEl.appendChild(buildThreadCard(thread));
});
}
function buildThreadCard(thread) {
const card = document.createElement('div');
card.className = 'thread-card';
const header = document.createElement('button');
header.type = 'button';
header.className = 'thread-card-header';
const titleSpan = document.createElement('span');
titleSpan.className = 'thread-card-title';
titleSpan.textContent = thread.title;
const badge = document.createElement('span');
badge.className = `thread-status-badge thread-status-${thread.status}`;
badge.textContent = statusLabel(thread.status);
header.append(titleSpan, badge);
header.addEventListener('click', () => {
expandedId = expandedId === thread.id ? null : thread.id;
renderList();
});
card.appendChild(header);
if (expandedId === thread.id) {
card.classList.add('expanded');
card.appendChild(buildThreadDetail(thread));
}
return card;
}
function buildThreadDetail(thread) {
const detail = document.createElement('div');
detail.className = 'thread-card-detail';
FIELD_DEFS.forEach(({ key, label }) => {
const row = document.createElement('div');
row.className = 'thread-field';
const labelEl = document.createElement('span');
labelEl.className = 'thread-field-label';
labelEl.textContent = label;
const valueEl = document.createElement('span');
valueEl.className = 'thread-field-value';
valueEl.textContent = key === 'status' ? statusLabel(thread.status) : (thread[key] || '—');
row.append(labelEl, valueEl);
detail.appendChild(row);
});
const actions = document.createElement('div');
actions.className = 'thread-card-actions';
const editBtn = document.createElement('button');
editBtn.type = 'button';
editBtn.className = 'thread-edit-btn';
editBtn.textContent = 'Edit';
editBtn.addEventListener('click', () => openEditModal(thread));
const deleteBtn = document.createElement('button');
deleteBtn.type = 'button';
deleteBtn.className = 'thread-delete-btn';
deleteBtn.textContent = 'Delete';
deleteBtn.addEventListener('click', () => handleDelete(thread));
actions.append(editBtn, deleteBtn);
detail.appendChild(actions);
return detail;
}
function buildModalForm(values) {
const body = container.querySelector('#threadModalBody');
body.innerHTML = '';
FIELD_DEFS.forEach(({ key, label, type }) => {
const group = document.createElement('div');
group.className = 'modal-field';
const labelEl = document.createElement('label');
labelEl.className = 'modal-field-label';
labelEl.textContent = label;
labelEl.setAttribute('for', `thread-field-${key}`);
group.appendChild(labelEl);
let input;
if (type === 'select') {
input = document.createElement('select');
input.className = 'modal-select';
STATUSES.forEach((status) => {
const option = document.createElement('option');
option.value = status;
option.textContent = statusLabel(status);
input.appendChild(option);
});
input.value = values.status || 'active';
} else if (type === 'textarea') {
input = document.createElement('textarea');
input.className = 'modal-textarea';
input.rows = 3;
input.value = values[key] || '';
} else {
input = document.createElement('input');
input.type = 'text';
input.className = 'modal-input';
input.value = values[key] || '';
}
input.id = `thread-field-${key}`;
group.appendChild(input);
body.appendChild(group);
});
}
function collectFormData() {
const data = {};
FIELD_DEFS.forEach(({ key }) => {
const el = container.querySelector(`#thread-field-${key}`);
const value = el.value;
data[key] = value.trim() === '' ? null : value;
});
return data;
}
function openCreateModal() {
editingThreadId = null;
container.querySelector('#threadModalTitle').textContent = 'New Thread';
buildModalForm({});
showModal();
}
function openEditModal(thread) {
editingThreadId = thread.id;
container.querySelector('#threadModalTitle').textContent = 'Edit Thread';
buildModalForm(thread);
showModal();
}
function showModal() {
container.querySelector('#threadModalOverlay').classList.add('open');
}
function closeModal() {
container.querySelector('#threadModalOverlay').classList.remove('open');
}
async function saveModal() {
const campaign = getActiveCampaign();
if (!campaign) return;
const data = collectFormData();
if (!data.title) {
alert('Title is required.');
return;
}
if (!data.status) data.status = 'active';
try {
if (editingThreadId === null) {
await createThread(campaign.id, data);
} else {
await updateThread(campaign.id, editingThreadId, data);
}
closeModal();
await loadAndRender();
} catch (err) {
alert(err.message);
}
}
async function handleDelete(thread) {
const confirmed = confirm(`Delete "${thread.title}"? This cannot be undone.`);
if (!confirmed) return;
const campaign = getActiveCampaign();
if (!campaign) return;
await deleteThread(campaign.id, thread.id);
expandedId = null;
await loadAndRender();
}
async function loadAndRender() {
const campaign = getActiveCampaign();
if (!campaign) {
renderNoCampaign();
return;
}
ensureSkeleton();
threadsCache = await getThreads(campaign.id);
renderList();
}
function init() {
loadAndRender();
const navBtn = document.querySelector('.nav-item[data-view="threads"]');
if (navBtn) {
navBtn.addEventListener('click', () => loadAndRender());
}
}
document.addEventListener('DOMContentLoaded', init);
+13 -6
View File
@@ -22,12 +22,19 @@ CREATE TABLE IF NOT EXISTS campaigns (
);
CREATE TABLE IF NOT EXISTS threads (
id INTEGER PRIMARY KEY,
campaign_id INTEGER NOT NULL REFERENCES campaigns(id),
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
id INTEGER PRIMARY KEY,
campaign_id INTEGER NOT NULL REFERENCES campaigns(id),
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
related_npcs TEXT,
related_location TEXT,
origin TEXT,
stakes TEXT,
last_development TEXT,
next_beat TEXT,
suspected_resolution TEXT
);
CREATE TABLE IF NOT EXISTS npcs (
+1 -1
View File
@@ -22,7 +22,7 @@ app.get('/health', (req, res) => {
app.use('/api/campaigns', campaignsRouter);
app.use('/api/characters', charactersRouter);
app.use('/api/threads', threadsRouter);
app.use('/api/campaigns/:campaignId/threads', threadsRouter);
app.use('/api/npcs', npcsRouter);
app.use('/api/notes', notesRouter);
app.use('/api/tables', tablesRouter);
+175 -1
View File
@@ -1,4 +1,178 @@
const express = require('express');
const router = express.Router();
const db = require('../db');
const router = express.Router({ mergeParams: true });
const STATUSES = ['active', 'resolved', 'suspended', 'complicated'];
const OPTIONAL_TEXT_FIELDS = [
'notes',
'related_npcs',
'related_location',
'origin',
'stakes',
'last_development',
'next_beat',
'suspected_resolution',
];
const getCampaignByIdStmt = db.prepare('SELECT id FROM campaigns WHERE id = ?');
const listThreadsStmt = db.prepare('SELECT * FROM threads WHERE campaign_id = ? ORDER BY created_at DESC');
const getThreadStmt = db.prepare('SELECT * FROM threads WHERE id = ? AND campaign_id = ?');
const insertThreadStmt = db.prepare(`
INSERT INTO threads (
campaign_id, title, status, notes, related_npcs, related_location,
origin, stakes, last_development, next_beat, suspected_resolution
) VALUES (
@campaign_id, @title, @status, @notes, @related_npcs, @related_location,
@origin, @stakes, @last_development, @next_beat, @suspected_resolution
)
`);
const updateThreadStmt = db.prepare(`
UPDATE threads SET
title = @title,
status = @status,
notes = @notes,
related_npcs = @related_npcs,
related_location = @related_location,
origin = @origin,
stakes = @stakes,
last_development = @last_development,
next_beat = @next_beat,
suspected_resolution = @suspected_resolution
WHERE id = @id AND campaign_id = @campaign_id
`);
const deleteThreadStmt = db.prepare('DELETE FROM threads WHERE id = ? AND campaign_id = ?');
function parsePositiveInt(raw, res, label) {
const value = Number(raw);
if (!Number.isInteger(value) || value <= 0) {
res.status(400).json({ error: `${label} must be a positive integer` });
return null;
}
return value;
}
function loadCampaignId(req, res) {
const campaignId = parsePositiveInt(req.params.campaignId, res, 'campaign_id');
if (campaignId === null) return null;
if (!getCampaignByIdStmt.get(campaignId)) {
res.status(404).json({ error: 'campaign not found' });
return null;
}
return campaignId;
}
// Shared by create and update — PUT sends the full form, so both treat
// title/status the same way rather than supporting partial patches.
function parseThreadPayload(body, res) {
const title = typeof body.title === 'string' ? body.title.trim() : '';
if (!title) {
res.status(400).json({ error: 'title is required' });
return null;
}
const status = body.status === undefined ? 'active' : body.status;
if (!STATUSES.includes(status)) {
res.status(400).json({ error: `status must be one of: ${STATUSES.join(', ')}` });
return null;
}
const payload = { title, status };
for (const field of OPTIONAL_TEXT_FIELDS) {
const value = body[field];
if (value === undefined || value === null) {
payload[field] = null;
} else if (typeof value === 'string') {
payload[field] = value;
} else {
res.status(400).json({ error: `${field} must be a string or null` });
return null;
}
}
return payload;
}
function withErrorHandling(handler) {
return (req, res) => {
try {
handler(req, res);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'unexpected server error' });
}
};
}
router.get('/', withErrorHandling((req, res) => {
const campaignId = loadCampaignId(req, res);
if (campaignId === null) return;
res.json({ data: listThreadsStmt.all(campaignId) });
}));
router.get('/:id', withErrorHandling((req, res) => {
const campaignId = loadCampaignId(req, res);
if (campaignId === null) return;
const id = parsePositiveInt(req.params.id, res, 'id');
if (id === null) return;
const thread = getThreadStmt.get(id, campaignId);
if (!thread) {
return res.status(404).json({ error: 'thread not found' });
}
res.json({ data: thread });
}));
router.post('/', withErrorHandling((req, res) => {
const campaignId = loadCampaignId(req, res);
if (campaignId === null) return;
const payload = parseThreadPayload(req.body, res);
if (payload === null) return;
const result = insertThreadStmt.run({ ...payload, campaign_id: campaignId });
res.status(201).json({ data: getThreadStmt.get(result.lastInsertRowid, campaignId) });
}));
router.put('/:id', withErrorHandling((req, res) => {
const campaignId = loadCampaignId(req, res);
if (campaignId === null) return;
const id = parsePositiveInt(req.params.id, res, 'id');
if (id === null) return;
if (!getThreadStmt.get(id, campaignId)) {
return res.status(404).json({ error: 'thread not found' });
}
const payload = parseThreadPayload(req.body, res);
if (payload === null) return;
updateThreadStmt.run({ ...payload, id, campaign_id: campaignId });
res.json({ data: getThreadStmt.get(id, campaignId) });
}));
router.delete('/:id', withErrorHandling((req, res) => {
const campaignId = loadCampaignId(req, res);
if (campaignId === null) return;
const id = parsePositiveInt(req.params.id, res, 'id');
if (id === null) return;
if (!getThreadStmt.get(id, campaignId)) {
return res.status(404).json({ error: 'thread not found' });
}
deleteThreadStmt.run(id, campaignId);
res.json({ data: { id } });
}));
module.exports = router;