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; font-size: 0.8rem;
color: var(--text-secondary); 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/meaning.js" type="module"></script>
<script src="js/une.js" type="module"></script> <script src="js/une.js" type="module"></script>
<script src="js/dice.js" type="module"></script> <script src="js/dice.js" type="module"></script>
<script src="js/threads.js" type="module"></script>
</html> </html>
+26
View File
@@ -53,3 +53,29 @@ export function getSystems() {
export function getTable(name) { export function getTable(name) {
return request(`/tables/${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 ( CREATE TABLE IF NOT EXISTS threads (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
campaign_id INTEGER NOT NULL REFERENCES campaigns(id), campaign_id INTEGER NOT NULL REFERENCES campaigns(id),
title TEXT NOT NULL, title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active', status TEXT NOT NULL DEFAULT 'active',
notes TEXT, notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')) 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 ( 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/campaigns', campaignsRouter);
app.use('/api/characters', charactersRouter); 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/npcs', npcsRouter);
app.use('/api/notes', notesRouter); app.use('/api/notes', notesRouter);
app.use('/api/tables', tablesRouter); app.use('/api/tables', tablesRouter);
+175 -1
View File
@@ -1,4 +1,178 @@
const express = require('express'); 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; module.exports = router;