feat(rtime): add external time logs API and fulfillment dashboard

Add ExternalTimeLog schema and REST endpoints for ingesting time entries
from backlog-md CLI. Auto-creates commitments, links to existing tasks,
and supports solo settlement with reputation/skill curve updates.

New Fulfillment dashboard tab shows time logs, skill totals with progress
bars, and inline settle buttons. Export-to-backlog endpoint enables
importing rTime tasks into local backlog.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-03 17:38:08 -07:00
parent b3d6f2eba8
commit d1a9fc338d
3 changed files with 525 additions and 8 deletions

View File

@ -248,7 +248,7 @@ function svgText(txt: string, x: number, y: number, size: number, color: string,
class FolkTimebankApp extends HTMLElement {
private shadow: ShadowRoot;
private space = 'demo';
private currentView: 'canvas' | 'collaborate' = 'canvas';
private currentView: 'canvas' | 'collaborate' | 'dashboard' = 'canvas';
// Pool panel state
private canvas!: HTMLCanvasElement;
@ -326,7 +326,7 @@ class FolkTimebankApp extends HTMLElement {
attributeChangedCallback(name: string, _old: string, val: string) {
if (name === 'space') this.space = val;
if (name === 'view' && (val === 'canvas' || val === 'collaborate')) this.currentView = val;
if (name === 'view' && (val === 'canvas' || val === 'collaborate' || val === 'dashboard')) this.currentView = val;
}
connectedCallback() {
@ -335,6 +335,7 @@ class FolkTimebankApp extends HTMLElement {
// Map legacy view names
if (rawView === 'pool' || rawView === 'weave' || rawView === 'canvas') this.currentView = 'canvas';
else if (rawView === 'collaborate') this.currentView = 'collaborate';
else if (rawView === 'dashboard') this.currentView = 'dashboard';
else this.currentView = 'canvas';
this.dpr = window.devicePixelRatio || 1;
this._theme = (localStorage.getItem('rtime-theme') as 'dark' | 'light') || 'dark';
@ -442,6 +443,7 @@ class FolkTimebankApp extends HTMLElement {
<div class="tab-bar">
<div class="tab active" data-view="canvas">Canvas</div>
<div class="tab" data-view="collaborate">Collaborate</div>
<div class="tab" data-view="dashboard">Fulfillment</div>
<button class="theme-toggle" id="themeToggle" title="Toggle light/dark theme"></button>
</div>
<div class="stats-bar">
@ -533,6 +535,30 @@ class FolkTimebankApp extends HTMLElement {
</div>
</div>
</div>
<div id="dashboard-view" style="display:none">
<div class="collab-panel">
<div class="collab-section">
<div class="collab-section-header">
<h3>Fulfillment Dashboard</h3>
</div>
<div class="dashboard-summary" id="dashboardSummary">
<div class="collab-empty">Loading fulfillment data...</div>
</div>
</div>
<div class="collab-section">
<div class="collab-section-header">
<h3>Time Logs</h3>
</div>
<div class="dashboard-logs" id="dashboardLogs"></div>
</div>
<div class="collab-section">
<div class="collab-section-header">
<h3>Skill Totals</h3>
</div>
<div class="dashboard-skills" id="dashboardSkills"></div>
</div>
</div>
</div>
</div>
<!-- Create Intent Modal -->
@ -654,19 +680,22 @@ class FolkTimebankApp extends HTMLElement {
this.canvas = this.shadow.getElementById('pool-canvas') as HTMLCanvasElement;
this.ctx = this.canvas.getContext('2d')!;
// Tab switching (2 tabs: canvas, collaborate)
// Tab switching (3 tabs: canvas, collaborate, dashboard)
this.shadow.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
const view = (tab as HTMLElement).dataset.view as 'canvas' | 'collaborate';
const view = (tab as HTMLElement).dataset.view as 'canvas' | 'collaborate' | 'dashboard';
if (view === this.currentView) return;
this.currentView = view;
this.shadow.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', (t as HTMLElement).dataset.view === view));
const canvasView = this.shadow.getElementById('canvas-view')!;
const collabView = this.shadow.getElementById('collaborate-view')!;
const dashView = this.shadow.getElementById('dashboard-view')!;
canvasView.style.display = view === 'canvas' ? 'flex' : 'none';
collabView.style.display = view === 'collaborate' ? 'flex' : 'none';
dashView.style.display = view === 'dashboard' ? 'flex' : 'none';
if (view === 'canvas') { this.resizePoolCanvas(); this.rebuildSidebar(); }
if (view === 'collaborate') this.refreshCollaborate();
if (view === 'dashboard') this.refreshDashboard();
});
});
@ -2544,6 +2573,8 @@ class FolkTimebankApp extends HTMLElement {
this.shadow.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', (t as HTMLElement).dataset.view === 'canvas'));
this.shadow.getElementById('canvas-view')!.style.display = 'flex';
this.shadow.getElementById('collaborate-view')!.style.display = 'none';
const dashView = this.shadow.getElementById('dashboard-view');
if (dashView) dashView.style.display = 'none';
}
private renderSkillPrices() {
@ -2563,6 +2594,153 @@ class FolkTimebankApp extends HTMLElement {
`;
}).join('');
}
// ── Fulfillment Dashboard ──
private async refreshDashboard() {
const base = this.getApiBase();
const summaryEl = this.shadow.getElementById('dashboardSummary');
const logsEl = this.shadow.getElementById('dashboardLogs');
const skillsEl = this.shadow.getElementById('dashboardSkills');
try {
const [logsResp, repResp] = await Promise.all([
fetch(`${base}/api/external-time-logs`),
fetch(`${base}/api/reputation/self`).catch(() => null),
]);
if (!logsResp.ok) {
if (summaryEl) summaryEl.innerHTML = '<div class="collab-empty">No fulfillment data available.</div>';
return;
}
const logsData = await logsResp.json();
const logs: Array<{
id: string;
backlogTaskId: string;
backlogTaskTitle: string;
hours: number;
skill: string;
status: string;
note?: string;
loggedAt: number;
commitmentId?: string;
}> = logsData.logs || [];
// Summary stats
const totalHours = logs.reduce((s, l) => s + l.hours, 0);
const settledHours = logs.filter(l => l.status === 'settled').reduce((s, l) => s + l.hours, 0);
const pendingCount = logs.filter(l => l.status !== 'settled').length;
if (summaryEl) {
summaryEl.innerHTML = `
<div style="display:flex;gap:2rem;padding:0.5rem 0">
<div class="price-card">
<span class="price-skill">Total Hours</span>
<span class="price-value" style="font-size:1.4rem">${totalHours.toFixed(1)}h</span>
</div>
<div class="price-card">
<span class="price-skill">Settled</span>
<span class="price-value" style="font-size:1.4rem;color:#10b981">${settledHours.toFixed(1)}h</span>
</div>
<div class="price-card">
<span class="price-skill">Pending</span>
<span class="price-value" style="font-size:1.4rem;color:#f59e0b">${pendingCount}</span>
</div>
</div>
`;
}
// Logs table
if (logsEl) {
if (logs.length === 0) {
logsEl.innerHTML = '<div class="collab-empty">No time logs imported yet. Use <code>backlog task log --push</code> to send entries.</div>';
} else {
logsEl.innerHTML = `
<table style="width:100%;border-collapse:collapse;font-size:0.85rem">
<thead>
<tr style="border-bottom:1px solid rgba(255,255,255,0.1);text-align:left">
<th style="padding:0.4rem">Task</th>
<th style="padding:0.4rem">Skill</th>
<th style="padding:0.4rem">Hours</th>
<th style="padding:0.4rem">Status</th>
<th style="padding:0.4rem">Action</th>
</tr>
</thead>
<tbody>
${logs.map(log => {
const color = SKILL_COLORS[log.skill] || '#8b5cf6';
const statusColor = log.status === 'settled' ? '#10b981' : log.status === 'commitment_created' ? '#3b82f6' : '#f59e0b';
const settleBtn = log.status !== 'settled'
? `<button class="dashboard-settle-btn" data-log-id="${log.id}" style="padding:2px 8px;font-size:0.75rem;border:1px solid #10b981;background:transparent;color:#10b981;border-radius:4px;cursor:pointer">Settle</button>`
: '<span style="color:#10b981">✓</span>';
return `
<tr style="border-bottom:1px solid rgba(255,255,255,0.05)">
<td style="padding:0.4rem">${log.backlogTaskTitle || log.backlogTaskId}</td>
<td style="padding:0.4rem"><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${color};margin-right:4px"></span>${SKILL_LABELS[log.skill] || log.skill}</td>
<td style="padding:0.4rem">${log.hours.toFixed(1)}h</td>
<td style="padding:0.4rem;color:${statusColor}">${log.status}</td>
<td style="padding:0.4rem">${settleBtn}</td>
</tr>
`;
}).join('')}
</tbody>
</table>
`;
// Bind settle buttons
logsEl.querySelectorAll('.dashboard-settle-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const logId = (btn as HTMLElement).dataset.logId;
if (!logId) return;
const token = localStorage.getItem('rspace_token') || '';
const resp = await fetch(`${base}/api/external-time-logs/${logId}/settle`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
});
if (resp.ok) {
this.refreshDashboard();
}
});
});
}
}
// Skill totals
if (skillsEl) {
const skillTotals = new Map<string, { total: number; settled: number }>();
for (const log of logs) {
const existing = skillTotals.get(log.skill) || { total: 0, settled: 0 };
existing.total += log.hours;
if (log.status === 'settled') existing.settled += log.hours;
skillTotals.set(log.skill, existing);
}
if (skillTotals.size === 0) {
skillsEl.innerHTML = '<div class="collab-empty">No skill data yet.</div>';
} else {
skillsEl.innerHTML = [...skillTotals.entries()]
.sort((a, b) => b[1].total - a[1].total)
.map(([skill, data]) => {
const color = SKILL_COLORS[skill] || '#8b5cf6';
const pct = data.total > 0 ? (data.settled / data.total * 100) : 0;
return `
<div style="display:flex;align-items:center;gap:0.6rem;padding:0.4rem 0">
<div class="price-dot" style="background:${color}"></div>
<span style="width:6rem">${SKILL_LABELS[skill] || skill}</span>
<div style="flex:1;height:8px;background:rgba(255,255,255,0.1);border-radius:4px;overflow:hidden">
<div style="width:${pct}%;height:100%;background:${color};border-radius:4px"></div>
</div>
<span style="font-size:0.8rem;color:#94a3b8;min-width:5rem;text-align:right">${data.settled.toFixed(1)}h / ${data.total.toFixed(1)}h</span>
</div>
`;
}).join('');
}
}
} catch {
if (summaryEl) summaryEl.innerHTML = '<div class="collab-empty">Unable to load fulfillment data (offline?).</div>';
}
}
}
// ── CSS ──

View File

@ -21,12 +21,12 @@ import { renderLanding } from "./landing";
import { notify } from '../../server/notification-service';
import type { SyncServer } from '../../server/local-first/sync-server';
import {
commitmentsSchema, tasksSchema,
commitmentsDocId, tasksDocId,
commitmentsSchema, tasksSchema, externalTimeLogsSchema,
commitmentsDocId, tasksDocId, externalTimeLogsDocId,
} from './schemas';
import type {
CommitmentsDoc, TasksDoc,
Commitment, Task, Connection, ExecState, Skill,
CommitmentsDoc, TasksDoc, ExternalTimeLogsDoc,
Commitment, Task, Connection, ExecState, Skill, ExternalTimeLog,
} from './schemas';
import {
intentsSchema, solverResultsSchema,
@ -77,6 +77,243 @@ function newId(): string {
return crypto.randomUUID();
}
// ── External Time Logs helpers ──
function ensureExternalTimeLogsDoc(space: string): ExternalTimeLogsDoc {
const docId = externalTimeLogsDocId(space);
let doc = _syncServer!.getDoc<ExternalTimeLogsDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<ExternalTimeLogsDoc>(), 'init external-time-logs', (d) => {
const init = externalTimeLogsSchema.init();
Object.assign(d, init);
d.meta.spaceSlug = space;
});
_syncServer!.setDoc(docId, doc);
}
return doc;
}
// ── External Time Logs API (backlog-md integration) ──
routes.post("/api/external-time-logs", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims;
try { claims = await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const space = c.req.param("space") || "demo";
const body = await c.req.json();
const { backlogTaskId, backlogTaskTitle, memberName, hours, skill, note, loggedAt } = body;
if (!backlogTaskId || !memberName || !hours || !skill) {
return c.json({ error: "backlogTaskId, memberName, hours, skill required" }, 400);
}
if (typeof hours !== 'number' || hours <= 0) {
return c.json({ error: "hours must be a positive number" }, 400);
}
const VALID_SKILLS = ['facilitation', 'design', 'tech', 'outreach', 'logistics'];
if (!VALID_SKILLS.includes(skill)) {
return c.json({ error: `skill must be one of: ${VALID_SKILLS.join(', ')}` }, 400);
}
const id = newId();
const now = Date.now();
ensureExternalTimeLogsDoc(space);
ensureCommitmentsDoc(space);
// Create external time log entry
_syncServer!.changeDoc<ExternalTimeLogsDoc>(externalTimeLogsDocId(space), 'import time log', (d) => {
d.logs[id] = {
id,
backlogTaskId,
backlogTaskTitle: backlogTaskTitle || backlogTaskId,
memberName,
memberId: (claims.did as string) || undefined,
hours,
skill: skill as Skill,
note: note || undefined,
loggedAt: loggedAt || now,
importedAt: now,
status: 'pending',
} as any;
});
// Auto-create a commitment from this time log
const commitmentId = newId();
_syncServer!.changeDoc<CommitmentsDoc>(commitmentsDocId(space), 'auto-create commitment from time log', (d) => {
d.items[commitmentId] = {
id: commitmentId,
memberName,
hours: Math.max(1, Math.min(10, hours)),
skill: skill as Skill,
desc: note || `Time logged: ${backlogTaskTitle || backlogTaskId}`,
createdAt: now,
status: 'active',
ownerDid: (claims.did as string) || '',
} as any;
});
// Link commitment to the time log
_syncServer!.changeDoc<ExternalTimeLogsDoc>(externalTimeLogsDocId(space), 'link commitment', (d) => {
d.logs[id].commitmentId = commitmentId as any;
d.logs[id].status = 'commitment_created' as any;
});
// Auto-connect to rTime task if a linked task exists
const tasksDocRef = _syncServer!.getDoc<TasksDoc>(tasksDocId(space));
if (tasksDocRef) {
const linkedTask = Object.values(tasksDocRef.tasks).find(
(t) => t.description?.includes(backlogTaskId) || t.name?.includes(backlogTaskId)
);
if (linkedTask) {
ensureTasksDoc(space);
const connId = newId();
_syncServer!.changeDoc<TasksDoc>(tasksDocId(space), 'auto-connect time log to task', (d) => {
d.connections[connId] = {
id: connId,
fromCommitmentId: commitmentId,
toTaskId: linkedTask.id,
skill,
hours,
status: 'committed',
} as any;
});
}
}
const doc = _syncServer!.getDoc<ExternalTimeLogsDoc>(externalTimeLogsDocId(space))!;
return c.json({ log: doc.logs[id], commitmentId }, 201);
});
routes.get("/api/external-time-logs", (c) => {
const space = c.req.param("space") || "demo";
ensureExternalTimeLogsDoc(space);
const doc = _syncServer!.getDoc<ExternalTimeLogsDoc>(externalTimeLogsDocId(space))!;
let logs = Object.values(doc.logs);
// Filter by query params
const backlogTaskId = c.req.query("backlogTaskId");
const memberName = c.req.query("memberName");
const status = c.req.query("status");
if (backlogTaskId) logs = logs.filter(l => l.backlogTaskId === backlogTaskId);
if (memberName) logs = logs.filter(l => l.memberName === memberName);
if (status) logs = logs.filter(l => l.status === status);
return c.json({ logs });
});
routes.post("/api/external-time-logs/:id/settle", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims;
try { claims = await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const space = c.req.param("space") || "demo";
const logId = c.req.param("id");
ensureExternalTimeLogsDoc(space);
const doc = _syncServer!.getDoc<ExternalTimeLogsDoc>(externalTimeLogsDocId(space))!;
const log = doc.logs[logId];
if (!log) return c.json({ error: "Time log not found" }, 404);
if (log.status === 'settled') return c.json({ error: "Already settled" }, 400);
// Update log status to settled
_syncServer!.changeDoc<ExternalTimeLogsDoc>(externalTimeLogsDocId(space), 'settle time log', (d) => {
d.logs[logId].status = 'settled' as any;
});
// Update commitment status
if (log.commitmentId) {
ensureCommitmentsDoc(space);
const cDoc = _syncServer!.getDoc<CommitmentsDoc>(commitmentsDocId(space))!;
if (cDoc.items[log.commitmentId]) {
_syncServer!.changeDoc<CommitmentsDoc>(commitmentsDocId(space), 'settle commitment', (d) => {
d.items[log.commitmentId!].status = 'settled' as any;
});
}
}
// Update reputation: self-attestation with neutral rating (3/5)
const memberId = log.memberId || log.memberName;
const { reputationDocId } = await import('./schemas-intent');
const repDocId = reputationDocId(space);
const { reputationKey } = await import('./reputation');
const key = reputationKey(memberId, log.skill);
const repDoc = _syncServer!.getDoc<any>(repDocId);
if (repDoc) {
_syncServer!.changeDoc<any>(repDocId, 'update reputation from settled time log', (d: any) => {
if (!d.entries[key]) {
d.entries[key] = {
memberId,
skill: log.skill,
score: 50,
completedHours: 0,
ratings: [],
};
}
d.entries[key].completedHours = (d.entries[key].completedHours || 0) + log.hours;
// Self-attestation: neutral 3/5 rating
d.entries[key].ratings.push({
from: memberId,
score: 3,
timestamp: Date.now(),
});
});
}
// Update skill curves: add supply hours
const { skillCurvesDocId } = await import('./schemas-intent');
const scDocId = skillCurvesDocId(space);
const scDoc = _syncServer!.getDoc<any>(scDocId);
if (scDoc) {
_syncServer!.changeDoc<any>(scDocId, 'update skill curve from settled time log', (d: any) => {
if (!d.curves[log.skill]) {
d.curves[log.skill] = {
skill: log.skill,
supplyHours: 0,
demandHours: 0,
currentPrice: 100,
history: [],
};
}
d.curves[log.skill].supplyHours = (d.curves[log.skill].supplyHours || 0) + log.hours;
});
}
return c.json({ ok: true, logId, status: 'settled' });
});
routes.post("/api/tasks/:id/link-backlog", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const space = c.req.param("space") || "demo";
const taskId = c.req.param("id");
const body = await c.req.json();
const { backlogTaskId } = body;
if (!backlogTaskId) return c.json({ error: "backlogTaskId required" }, 400);
ensureTasksDoc(space);
const doc = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!;
if (!doc.tasks[taskId]) return c.json({ error: "Task not found" }, 404);
// Store backlog task ID in the task description as a cross-reference
_syncServer!.changeDoc<TasksDoc>(tasksDocId(space), 'link backlog task', (d) => {
const t = d.tasks[taskId];
const ref = `[backlog:${backlogTaskId}]`;
if (!t.description.includes(ref)) {
t.description = t.description ? `${t.description}\n${ref}` : ref;
}
});
const updated = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!;
return c.json(updated.tasks[taskId]);
});
// ── Cyclos proxy config ──
const CYCLOS_URL = process.env.CYCLOS_URL || '';
const CYCLOS_API_KEY = process.env.CYCLOS_API_KEY || '';
@ -478,6 +715,56 @@ routes.get("/", (c) => {
}));
});
routes.post("/api/tasks/:id/export-to-backlog", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const space = c.req.param("space") || "demo";
const taskId = c.req.param("id");
ensureTasksDoc(space);
const doc = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!;
const task = doc.tasks[taskId];
if (!task) return c.json({ error: "Task not found" }, 404);
// Calculate total needs as estimated hours
const estimatedHours = Object.values(task.needs).reduce((sum, h) => sum + h, 0);
// Map task needs to acceptance criteria
const acceptanceCriteria = Object.entries(task.needs).map(([skill, hours]) =>
`${SKILL_LABELS[skill as Skill] || skill}: ${hours}h`
);
return c.json({
title: task.name,
description: task.description?.replace(/\[backlog:[^\]]+\]/g, '').trim() || '',
estimatedHours,
labels: Object.keys(task.needs),
notes: task.notes || '',
acceptanceCriteria,
rtime: {
taskId: task.id,
space,
},
});
});
routes.get("/dashboard", (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderShell({
title: `${space} — Fulfillment | rTime | rSpace`,
moduleId: "rtime",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-timebank-app space="${space}" view="dashboard"></folk-timebank-app>`,
scripts: `<script type="module" src="/modules/rtime/folk-timebank-app.js?v=1"></script>`,
styles: `<link rel="stylesheet" href="/modules/rtime/rtime.css">`,
}));
});
// ── Module export ──
export const timeModule: RSpaceModule = {
@ -495,6 +782,7 @@ export const timeModule: RSpaceModule = {
{ pattern: '{space}:rtime:solver-results', description: 'Solver collaboration recommendations', init: solverResultsSchema.init },
{ pattern: '{space}:rtime:skill-curves', description: 'Per-skill demand/supply pricing', init: skillCurvesSchema.init },
{ pattern: '{space}:rtime:reputation', description: 'Per-member per-skill reputation', init: reputationSchema.init },
{ pattern: '{space}:rtime:external-time-logs', description: 'External time logs from backlog-md', init: externalTimeLogsSchema.init },
],
routes,
landingPage: renderLanding,
@ -515,6 +803,7 @@ export const timeModule: RSpaceModule = {
outputPaths: [
{ path: "canvas", name: "Canvas", icon: "🧺", description: "Unified commitment pool & task weaving canvas" },
{ path: "collaborate", name: "Collaborate", icon: "🤝", description: "Intent-routed collaboration matching" },
{ path: "dashboard", name: "Fulfillment", icon: "📊", description: "Personal commitment fulfillment tracking" },
],
onboardingActions: [
{ label: "Pledge Hours", icon: "⏳", description: "Add a commitment to the pool", type: 'create', href: '/rtime' },

View File

@ -97,6 +97,36 @@ export interface TasksDoc {
execStates: Record<string, ExecState>;
}
// ── External Time Log (backlog-md integration) ──
export type ExternalTimeLogStatus = 'pending' | 'commitment_created' | 'settled';
export interface ExternalTimeLog {
id: string;
backlogTaskId: string;
backlogTaskTitle: string;
memberName: string;
memberId?: string; // DID
hours: number;
skill: Skill;
note?: string;
loggedAt: number; // unix ms (when work was done)
importedAt: number; // unix ms (when imported to rTime)
status: ExternalTimeLogStatus;
commitmentId?: string; // auto-created commitment ID
}
export interface ExternalTimeLogsDoc {
meta: {
module: string;
collection: string;
version: number;
spaceSlug: string;
createdAt: number;
};
logs: Record<string, ExternalTimeLog>;
}
// ── DocId helpers ──
export function commitmentsDocId(space: string) {
@ -107,6 +137,10 @@ export function tasksDocId(space: string) {
return `${space}:rtime:tasks` as const;
}
export function externalTimeLogsDocId(space: string) {
return `${space}:rtime:external-time-logs` as const;
}
// ── Schema registrations ──
export const commitmentsSchema: DocSchema<CommitmentsDoc> = {
@ -142,3 +176,19 @@ export const tasksSchema: DocSchema<TasksDoc> = {
execStates: {},
}),
};
export const externalTimeLogsSchema: DocSchema<ExternalTimeLogsDoc> = {
module: 'rtime',
collection: 'external-time-logs',
version: 1,
init: (): ExternalTimeLogsDoc => ({
meta: {
module: 'rtime',
collection: 'external-time-logs',
version: 1,
spaceSlug: '',
createdAt: Date.now(),
},
logs: {},
}),
};