Add tm_server with SQLite, Svelte UI, and task-master TUI sync.

Introduces the web backend (auth, RBAC, project tokens, revisions),
YouTrack-or-taskmaster backend choice in the TUI, and a first-pass
embedded Svelte UI. The frontend is functional but needs a major rework.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-27 22:23:11 +01:00
co-authored by Cursor
parent 98d2253f44
commit 4adfcdb95a
67 changed files with 7188 additions and 129 deletions
+586
View File
@@ -0,0 +1,586 @@
<script>
import { onMount } from 'svelte'
import { api } from './lib/api.js'
let user = $state(null)
let loading = $state(true)
let error = $state('')
let view = $state('dashboard')
let projects = $state([])
let projectId = $state('')
let projectName = $state('')
let tickets = $state([])
let selected = $state(null)
let revisions = $state([])
let ticketTime = $state([])
let users = $state([])
let members = $state([])
let timer = $state(null)
let msg = $state('')
let report = $state(null)
let reportGroupBy = $state('ticket')
let dash = $state({ projects: [], openTickets: [] })
let tokens = $state([])
let newTokenSecret = $state('')
let renameName = $state('')
let loginUser = $state('')
let loginPass = $state('')
let newProjectName = $state('')
let newTicketTitle = $state('')
let editTitle = $state('')
let editDesc = $state('')
let editStatus = $state('todo')
let newUserName = $state('')
let newUserPass = $state('')
let newUserAdmin = $state(false)
let memberUserId = $state('')
let memberRole = $state('member')
let timeMinutes = $state(30)
let tokenName = $state('tui')
let tokenScope = $state('write')
onMount(async () => {
try {
user = await api.me()
await openDashboard()
await refreshTimer()
} catch {
user = null
} finally {
loading = false
}
})
function formatDuration(ns) {
const totalMin = Math.max(0, Math.round(Number(ns) / 6e10))
const h = Math.floor(totalMin / 60)
const m = totalMin % 60
if (h > 0 && m > 0) return `${h}h ${m}m`
if (h > 0) return `${h}h`
return `${m}m`
}
async function doLogin() {
error = ''
try {
user = await api.login(loginUser, loginPass)
await openDashboard()
} catch (e) {
error = e.message
}
}
async function doLogout() {
await api.logout()
user = null
projects = []
}
async function refreshProjects() {
projects = await api.listProjects()
}
async function refreshTimer() {
const data = await api.getTimer()
timer = data.timer
}
async function openDashboard() {
view = 'dashboard'
projectId = ''
dash = await api.dashboard()
await refreshProjects()
}
async function openProject(id, name = '') {
projectId = id
projectName = name || projects.find((p) => p.id === id)?.name || id
view = 'board'
error = ''
tickets = await api.listTickets(id)
}
async function createProject() {
error = ''
const name = newProjectName.trim()
if (!name) {
error = 'Name required'
return
}
try {
await api.createProject(name)
newProjectName = ''
await openDashboard()
} catch (e) {
error = e.message
}
}
async function createTicket() {
if (!newTicketTitle.trim()) return
await api.createTicket(projectId, { title: newTicketTitle, status: 'todo' })
newTicketTitle = ''
tickets = await api.listTickets(projectId)
}
async function openTicket(t) {
selected = t
editTitle = t.title
editDesc = t.description || ''
editStatus = t.status
revisions = await api.listRevisions(projectId, t.id)
const all = await api.listTime(projectId)
ticketTime = all.filter((e) => e.ticketId === t.id)
await refreshTimer()
view = 'ticket'
}
async function saveTicket() {
selected = await api.patchTicket(projectId, selected.id, {
title: editTitle,
description: editDesc,
status: editStatus,
})
revisions = await api.listRevisions(projectId, selected.id)
tickets = await api.listTickets(projectId)
msg = 'Saved'
}
async function removeTicket() {
if (!confirm('Delete ticket?')) return
await api.deleteTicket(projectId, selected.id)
selected = null
view = 'board'
tickets = await api.listTickets(projectId)
}
async function restoreRev(rid) {
selected = await api.restoreRevision(projectId, selected.id, rid)
editTitle = selected.title
editDesc = selected.description || ''
editStatus = selected.status
revisions = await api.listRevisions(projectId, selected.id)
tickets = await api.listTickets(projectId)
msg = 'Restored'
}
async function bookTime() {
if (!selected) return
await api.createTime(projectId, {
ticketId: selected.id,
durationNs: timeMinutes * 60 * 1e9,
source: 'manual',
})
const all = await api.listTime(projectId)
ticketTime = all.filter((e) => e.ticketId === selected.id)
msg = `Booked ${timeMinutes}m`
}
async function toggleTimer() {
if (!selected) return
if (timer && timer.ticketId === selected.id) {
await api.stopTimer()
} else {
await api.startTimer(selected.id, projectId)
}
await refreshTimer()
const all = await api.listTime(projectId)
ticketTime = all.filter((e) => e.ticketId === selected.id)
}
async function openReport() {
view = 'report'
error = ''
report = await api.report(projectId, reportGroupBy)
}
async function changeReportGroup(g) {
reportGroupBy = g
report = await api.report(projectId, reportGroupBy)
}
async function openSettings() {
view = 'settings'
renameName = projectName
newTokenSecret = ''
tokens = await api.listTokens(projectId)
}
async function saveRename() {
const name = renameName.trim()
if (!name) return
const p = await api.patchProject(projectId, { name })
projectName = p.name
msg = 'Renamed'
await refreshProjects()
}
async function createToken() {
const t = await api.createToken(projectId, { name: tokenName || 'token', scope: tokenScope })
newTokenSecret = t.token
tokens = await api.listTokens(projectId)
msg = 'Token created — copy it now'
}
async function revokeToken(tid) {
await api.revokeToken(projectId, tid)
tokens = await api.listTokens(projectId)
}
async function openAdmin() {
view = 'admin'
users = await api.listUsers()
}
async function createUser() {
await api.createUser({ username: newUserName, password: newUserPass, isAdmin: newUserAdmin })
newUserName = ''
newUserPass = ''
newUserAdmin = false
users = await api.listUsers()
}
async function toggleAdmin(u) {
if (u.id === user.id) {
error = 'Cannot change your own admin flag here'
return
}
await api.patchUser(u.id, { isAdmin: !u.isAdmin })
users = await api.listUsers()
}
async function deleteUser(u) {
if (u.id === user.id) return
if (!confirm(`Delete user ${u.username}?`)) return
await api.deleteUser(u.id)
users = await api.listUsers()
}
async function openMembers() {
view = 'members'
members = await api.listMembers(projectId)
users = await api.listUsers()
}
async function addMember() {
await api.setMember(projectId, memberUserId, memberRole)
members = await api.listMembers(projectId)
}
function byStatus(status) {
return tickets.filter((t) => t.status === status)
}
async function copyText(text) {
try {
await navigator.clipboard.writeText(text)
msg = 'Copied'
} catch {
msg = text
}
}
</script>
{#if loading}
<div class="login-wrap"><p class="muted">Loading…</p></div>
{:else if !user}
<div class="login-wrap">
<div class="panel login-card stack">
<h1>task-master</h1>
<p class="muted">Sign in to manage projects and tickets.</p>
{#if error}<p class="error">{error}</p>{/if}
<label>Username<input bind:value={loginUser} autocomplete="username" /></label>
<label>Password<input type="password" bind:value={loginPass} autocomplete="current-password" /></label>
<button class="primary" onclick={doLogin}>Login</button>
</div>
</div>
{:else}
<div class="app-shell">
<header class="topbar">
<div class="brand">task-master</div>
<nav class="nav">
<button class:active={view === 'dashboard'} onclick={openDashboard}>Dashboard</button>
{#if projectId}
<button class:active={view === 'board'} onclick={() => openProject(projectId, projectName)}>Board</button>
<button class:active={view === 'report'} onclick={openReport}>Report</button>
<button class:active={view === 'members'} onclick={openMembers}>Members</button>
<button class:active={view === 'settings'} onclick={openSettings}>Settings</button>
{/if}
{#if user.isAdmin}
<button class:active={view === 'admin'} onclick={openAdmin}>Users</button>
{/if}
</nav>
<div class="row">
{#if timer}
<span class="muted">⏱ {timer.ticketId.slice(0, 8)}…</span>
{/if}
<span class="muted">{user.username}{user.isAdmin ? ' (admin)' : ''}</span>
<button onclick={doLogout}>Logout</button>
</div>
</header>
{#if error}<p class="error">{error}</p>{/if}
{#if msg}<p class="muted">{msg}</p>{/if}
{#if view === 'dashboard'}
<div class="grid" style="grid-template-columns: 1.2fr 1fr; gap: 1rem;">
<section class="panel stack">
<h2>Projects</h2>
{#each dash.projects as p}
<button class="ticket" onclick={() => openProject(p.id, p.name)}>
<strong>{p.name}</strong>
<div class="muted">{p.openCount} open · {formatDuration(p.durationNs)}</div>
</button>
{:else}
<p class="muted">No projects yet.</p>
{/each}
<h3>New project</h3>
<div class="row">
<input placeholder="Project name" bind:value={newProjectName} />
<button class="primary" onclick={createProject}>Create</button>
</div>
</section>
<div class="stack">
<section class="panel stack">
<h2>Open tickets</h2>
{#each dash.openTickets as t}
<button class="ticket" onclick={async () => { await openProject(t.projectId, t.projectName); const full = (await api.listTickets(t.projectId)).find(x => x.id === t.id); if (full) openTicket(full) }}>
<span class="badge {t.status}">{t.status}</span>
<div>{t.title}</div>
<div class="muted">{t.projectName}</div>
</button>
{:else}
<p class="muted">Nothing open.</p>
{/each}
</section>
<section class="panel stack">
<h2>Time by project</h2>
<table>
<thead><tr><th>Project</th><th>Time</th></tr></thead>
<tbody>
{#each dash.projects as p}
<tr><td>{p.name}</td><td>{formatDuration(p.durationNs)}</td></tr>
{/each}
</tbody>
</table>
</section>
</div>
</div>
{:else if view === 'board'}
<section class="panel stack">
<div class="row" style="justify-content: space-between;">
<h2>{projectName}</h2>
<div class="row">
<input placeholder="New ticket title" bind:value={newTicketTitle} style="width: 240px;" />
<button class="primary" onclick={createTicket}>Add</button>
</div>
</div>
<div class="grid cols-3">
{#each [['todo', 'OPEN'], ['in_progress', 'WIP'], ['done', 'DONE']] as [st, label]}
<div class="stack">
<h3>{label}</h3>
{#each byStatus(st) as t}
<button class="ticket" onclick={() => openTicket(t)}>
<span class="badge {t.status}">{label}</span>
<div>{t.title}</div>
{#if t.externalId}<div class="muted">{t.externalId}</div>{/if}
</button>
{/each}
</div>
{/each}
</div>
</section>
{:else if view === 'ticket' && selected}
<section class="panel stack">
<div class="row">
<button onclick={() => (view = 'board')}>← Board</button>
<button class="danger" onclick={removeTicket}>Delete</button>
</div>
<label>Title<input bind:value={editTitle} /></label>
<label>Status
<select bind:value={editStatus}>
<option value="todo">OPEN</option>
<option value="in_progress">WIP</option>
<option value="done">DONE</option>
</select>
</label>
<label>Description<textarea bind:value={editDesc}></textarea></label>
<button class="primary" onclick={saveTicket}>Save</button>
<h3>Time</h3>
<div class="row">
<button class="primary" onclick={toggleTimer}>
{timer && timer.ticketId === selected.id ? 'Stop timer' : 'Start timer'}
</button>
<label class="row" style="width: auto;">Minutes
<input type="number" min="1" bind:value={timeMinutes} style="width: 5rem;" />
</label>
<button onclick={bookTime}>Book</button>
</div>
<table>
<thead><tr><th>When</th><th>Duration</th><th>Source</th></tr></thead>
<tbody>
{#each ticketTime as e}
<tr>
<td class="muted">{e.start ? new Date(e.start).toLocaleString() : '—'}</td>
<td>{formatDuration(e.duration)}</td>
<td>{e.source}</td>
</tr>
{:else}
<tr><td colspan="3" class="muted">No time yet.</td></tr>
{/each}
</tbody>
</table>
<h3>History</h3>
<table>
<thead><tr><th>Rev</th><th>Op</th><th>At</th><th></th></tr></thead>
<tbody>
{#each revisions as rev}
<tr>
<td>{rev.rev}</td>
<td>{rev.op}</td>
<td class="muted">{new Date(rev.at).toLocaleString()}</td>
<td><button onclick={() => restoreRev(rev.id)}>Restore</button></td>
</tr>
{/each}
</tbody>
</table>
</section>
{:else if view === 'report'}
<section class="panel stack">
<div class="row" style="justify-content: space-between;">
<h2>Report — {projectName}</h2>
<div class="row">
{#each [['ticket', 'Ticket'], ['status', 'Status'], ['day', 'Day']] as [g, label]}
<button class:primary={reportGroupBy === g} onclick={() => changeReportGroup(g)}>{label}</button>
{/each}
</div>
</div>
{#if report}
<p class="muted">{report.entryCount} entries · total {formatDuration(report.totalDurationNs)}</p>
<table>
<thead><tr><th>Group</th><th>Duration</th></tr></thead>
<tbody>
{#each report.rows as row}
<tr><td>{row.label}</td><td>{formatDuration(row.durationNs)}</td></tr>
{:else}
<tr><td colspan="2" class="muted">No time logged.</td></tr>
{/each}
</tbody>
</table>
{/if}
</section>
{:else if view === 'settings'}
<section class="panel stack">
<h2>Settings — {projectName}</h2>
<label>Project ID
<div class="row">
<code style="flex:1">{projectId}</code>
<button onclick={() => copyText(projectId)}>Copy</button>
</div>
</label>
<label>Name
<div class="row">
<input bind:value={renameName} />
<button class="primary" onclick={saveRename}>Rename</button>
</div>
</label>
<h3>API tokens (for TUI)</h3>
<p class="muted">Create a write token, then in `tm`: project edit → backend taskmaster → URL, token, this project id.</p>
{#if newTokenSecret}
<div class="panel stack" style="border-color: var(--accent);">
<strong>New token (shown once)</strong>
<code>{newTokenSecret}</code>
<button onclick={() => copyText(newTokenSecret)}>Copy token</button>
</div>
{/if}
<div class="row">
<input placeholder="name" bind:value={tokenName} style="width: 8rem;" />
<select bind:value={tokenScope} style="width: auto;">
<option value="read">read</option>
<option value="write">write</option>
</select>
<button class="primary" onclick={createToken}>Create token</button>
</div>
<table>
<thead><tr><th>Name</th><th>Scope</th><th>Created</th><th></th></tr></thead>
<tbody>
{#each tokens as t}
<tr>
<td>{t.name}{#if t.revokedAt} <span class="muted">(revoked)</span>{/if}</td>
<td>{t.scope}</td>
<td class="muted">{new Date(t.createdAt).toLocaleString()}</td>
<td>
{#if !t.revokedAt}
<button class="danger" onclick={() => revokeToken(t.id)}>Revoke</button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</section>
{:else if view === 'admin'}
<section class="panel stack">
<h2>Users</h2>
<table>
<thead><tr><th>Username</th><th>Admin</th><th></th></tr></thead>
<tbody>
{#each users as u}
<tr>
<td>{u.username}</td>
<td>
<button onclick={() => toggleAdmin(u)} disabled={u.id === user.id}>
{u.isAdmin ? 'yes' : 'no'}
</button>
</td>
<td>
{#if u.id !== user.id}
<button class="danger" onclick={() => deleteUser(u)}>Delete</button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
<h3>Create user</h3>
<input placeholder="username" bind:value={newUserName} />
<input type="password" placeholder="password" bind:value={newUserPass} />
<label class="row"><input type="checkbox" bind:checked={newUserAdmin} /> Admin</label>
<button class="primary" onclick={createUser}>Create</button>
</section>
{:else if view === 'members'}
<section class="panel stack">
<h2>Members — {projectName}</h2>
<table>
<thead><tr><th>User</th><th>Role</th><th></th></tr></thead>
<tbody>
{#each members as m}
<tr>
<td>{m.username}</td>
<td>{m.role}</td>
<td><button class="danger" onclick={async () => { await api.removeMember(projectId, m.userId); members = await api.listMembers(projectId) }}>Remove</button></td>
</tr>
{/each}
</tbody>
</table>
<div class="row">
<select bind:value={memberUserId} style="width: auto;">
<option value="">user…</option>
{#each users as u}<option value={u.id}>{u.username}</option>{/each}
</select>
<select bind:value={memberRole} style="width: auto;">
<option value="viewer">viewer</option>
<option value="member">member</option>
<option value="admin">admin</option>
<option value="owner">owner</option>
</select>
<button class="primary" onclick={addMember}>Add</button>
</div>
</section>
{/if}
</div>
{/if}