feat: add rTime integration — time entries, sync bridge, and CLI commands
CI/CD / deploy (push) Successful in 35s Details

Add TimeEntry tracking to task files with `backlog task log` and `backlog task time`
commands. Introduce rTime bridge layer with offline queue for pushing time entries
to rSpace commitment pools. New `backlog rtime` command group for sync, pull,
link, and import operations.

Phase 1: TimeEntry types, parser/serializer, Core.updateTaskFromInput
Phase 2: rTime API (handled in rspace-online repo)
Phase 3: rtime-sync.ts bridge with offline queue
Phase 4: Pull sync (rTime → backlog status updates)
Phase 6: Import rTime tasks as local backlog tasks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-03 17:37:46 -07:00
parent 65bcb8b98f
commit 9648ec1a16
6 changed files with 807 additions and 1 deletions

View File

@ -1974,6 +1974,144 @@ taskCmd
await viewTaskEnhanced(task, { startWithDetailFocus: true, core });
});
taskCmd
.command("log <taskId>")
.description("log time spent on a task")
.option("--hours <number>", "hours spent (decimal, e.g. 1.5)")
.option("--skill <skill>", "skill category (e.g. tech, design, facilitation, outreach, logistics)")
.option("--note <text>", "description of work done")
.option("--date <date>", "date of work (YYYY-MM-DD, defaults to today)")
.option("--push", "push time entry to rTime after logging")
.action(async (taskId: string, options) => {
const cwd = process.cwd();
const core = new Core(cwd);
const canonicalId = normalizeTaskId(taskId);
const task = await core.loadTaskById(canonicalId);
if (!task) {
console.error(`Task ${taskId} not found.`);
process.exitCode = 1;
return;
}
if (!options.hours) {
console.error("--hours is required.");
process.exitCode = 1;
return;
}
const hours = Number(options.hours);
if (Number.isNaN(hours) || hours <= 0) {
console.error("Hours must be a positive number.");
process.exitCode = 1;
return;
}
const date = options.date ?? new Date().toISOString().slice(0, 10);
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
console.error("Date must be in YYYY-MM-DD format.");
process.exitCode = 1;
return;
}
const updatedTask = await core.editTask(canonicalId, {
addTimeEntry: {
date,
hours,
...(options.skill && { skill: String(options.skill) }),
...(options.note && { note: String(options.note) }),
},
});
const totalHours = (updatedTask.timeEntries ?? []).reduce((sum, e) => sum + e.hours, 0);
const skillSuffix = options.skill ? ` ${options.skill}` : "";
const noteSuffix = options.note ? ` "${options.note}"` : "";
console.log(`Logged ${hours}h on ${canonicalId}${skillSuffix}${noteSuffix} — total: ${totalHours}h`);
// Push to rTime if --push flag or autoPush config
const config = await core.filesystem.loadConfig();
const rtimeConfig = config?.rtime;
if (options.push || rtimeConfig?.autoPush) {
if (!rtimeConfig?.baseUrl) {
console.log(" (rTime push skipped — no rtime.baseUrl configured)");
} else {
const { pushTimeEntry } = await import("./utils/rtime-sync.ts");
const entry = updatedTask.timeEntries?.[updatedTask.timeEntries.length - 1];
if (entry) {
const result = await pushTimeEntry(updatedTask, entry, cwd, rtimeConfig);
if (result.success) {
console.log(` Pushed to rTime (commitment: ${result.commitmentId})`);
} else {
console.log(` Queued for rTime sync (${result.error})`);
}
}
}
}
});
taskCmd
.command("time [taskId]")
.description("show time summary for a task or all tasks")
.option("--by-skill", "group time entries by skill")
.option("--plain", "plain text output")
.action(async (taskId: string | undefined, options) => {
const cwd = process.cwd();
const core = new Core(cwd);
let tasks: Task[];
if (taskId) {
const canonicalId = normalizeTaskId(taskId);
const task = await core.loadTaskById(canonicalId);
if (!task) {
console.error(`Task ${taskId} not found.`);
process.exitCode = 1;
return;
}
tasks = [task];
} else {
tasks = await core.queryTasks();
tasks = tasks.filter((t) => t.timeEntries && t.timeEntries.length > 0);
}
if (tasks.length === 0) {
console.log("No time entries found.");
return;
}
if (options.bySkill) {
const skillTotals = new Map<string, number>();
for (const task of tasks) {
for (const entry of task.timeEntries ?? []) {
const skill = entry.skill ?? "(none)";
skillTotals.set(skill, (skillTotals.get(skill) ?? 0) + entry.hours);
}
}
console.log("Time by skill:");
let grandTotal = 0;
for (const [skill, hours] of [...skillTotals.entries()].sort((a, b) => b[1] - a[1])) {
console.log(` ${skill.padEnd(16)} ${hours.toFixed(1)}h`);
grandTotal += hours;
}
console.log(` ${"Total".padEnd(16)} ${grandTotal.toFixed(1)}h`);
return;
}
for (const task of tasks) {
const entries = task.timeEntries ?? [];
if (entries.length === 0) continue;
const totalHours = entries.reduce((sum, e) => sum + e.hours, 0);
const estimated = task.estimatedHours !== undefined ? `${task.estimatedHours}h` : "n/a";
console.log(`${task.id}: ${task.title}`);
for (const entry of entries) {
const skill = entry.skill ? ` ${entry.skill}` : "";
const note = entry.note ? ` "${entry.note}"` : "";
const synced = entry.rtimeCommitmentId ? " [synced]" : "";
console.log(` ${entry.date} ${entry.hours.toFixed(1)}h${skill}${note}${synced}`);
}
console.log(` Total: ${totalHours.toFixed(1)}h (estimated: ${estimated})`);
if (tasks.length > 1) console.log();
}
});
taskCmd
.command("archive <taskId>")
.description("archive a task")
@ -2010,7 +2148,7 @@ taskCmd
const core = new Core(cwd);
// Don't handle commands that should be handled by specific command handlers
const reservedCommands = ["create", "list", "edit", "view", "archive", "demote"];
const reservedCommands = ["create", "list", "edit", "view", "archive", "demote", "log", "time"];
if (taskId && reservedCommands.includes(taskId)) {
console.error(`Unknown command: ${taskId}`);
taskCmd.help();
@ -2223,6 +2361,269 @@ draftCmd
await viewTaskEnhanced(draft, { startWithDetailFocus: true, core });
});
// ── rTime integration commands ──
const rtimeCmd = program.command("rtime").description("rTime integration for time tracking and commitment pools");
rtimeCmd
.command("sync")
.description("flush offline queue of time entries to rTime")
.action(async () => {
const cwd = process.cwd();
const core = new Core(cwd);
const config = await core.filesystem.loadConfig();
const rtimeConfig = config?.rtime;
if (!rtimeConfig?.baseUrl) {
console.error("rTime not configured. Add rtime.baseUrl to .backlog/config.yml");
process.exitCode = 1;
return;
}
const { flushQueue } = await import("./utils/rtime-sync.ts");
const result = await flushQueue(cwd, rtimeConfig);
if (result.flushed === 0 && result.remaining === 0) {
console.log("Queue empty — nothing to sync.");
} else {
console.log(`Synced: ${result.flushed} pushed, ${result.failed} failed, ${result.remaining} remaining`);
}
});
rtimeCmd
.command("status")
.description("show rTime integration config and queue status")
.action(async () => {
const cwd = process.cwd();
const core = new Core(cwd);
const config = await core.filesystem.loadConfig();
const rtimeConfig = config?.rtime;
console.log("rTime Integration Status");
console.log("========================");
if (!rtimeConfig) {
console.log("Not configured. Add rtime section to .backlog/config.yml:");
console.log(" rtime:");
console.log(" baseUrl: https://rspace.online");
console.log(" space: your-space");
console.log(" memberName: Your Name");
return;
}
console.log(` Base URL: ${rtimeConfig.baseUrl || "(not set)"}`);
console.log(` Space: ${rtimeConfig.space || "demo"}`);
console.log(` Member: ${rtimeConfig.memberName || "(not set)"}`);
console.log(` Auto-push: ${rtimeConfig.autoPush ? "yes" : "no"}`);
console.log(` Default skill: ${rtimeConfig.defaultSkill || "tech"}`);
const { loadQueue } = await import("./utils/rtime-sync.ts");
const queue = await loadQueue(cwd);
console.log(` Queue: ${queue.length} entries pending`);
if (rtimeConfig.baseUrl) {
const token = rtimeConfig.token || process.env.RSPACE_TOKEN;
if (token) {
console.log(" Auth: token configured");
} else {
console.log(" Auth: no token (set RSPACE_TOKEN env var or rtime.token in config)");
}
}
});
rtimeCmd
.command("pull [taskId]")
.description("pull commitment statuses from rTime and update local task files")
.action(async (taskId: string | undefined) => {
const cwd = process.cwd();
const core = new Core(cwd);
const config = await core.filesystem.loadConfig();
const rtimeConfig = config?.rtime;
if (!rtimeConfig?.baseUrl) {
console.error("rTime not configured. Add rtime.baseUrl to .backlog/config.yml");
process.exitCode = 1;
return;
}
const { pullStatus } = await import("./utils/rtime-sync.ts");
const resolvedTaskId = taskId ? normalizeTaskId(taskId) : undefined;
const result = await pullStatus(cwd, rtimeConfig, resolvedTaskId);
if (result.error) {
console.error(`Pull failed: ${result.error}`);
process.exitCode = 1;
return;
}
if (result.logs.length === 0) {
console.log("No external time logs found in rTime.");
return;
}
// Group logs by backlog task ID
const byTask = new Map<string, typeof result.logs>();
for (const log of result.logs) {
const existing = byTask.get(log.backlogTaskId) ?? [];
existing.push(log);
byTask.set(log.backlogTaskId, existing);
}
let updated = 0;
for (const [btid, logs] of byTask) {
const task = await core.loadTaskById(btid);
if (!task) continue;
let mutated = false;
for (const log of logs) {
if (!log.commitmentId) continue;
// Find matching time entry and update its syncedAt / rtimeCommitmentId
const entry = (task.timeEntries ?? []).find(
(e) => Math.abs(e.hours - log.hours) < 0.01 && !e.rtimeCommitmentId,
);
if (entry) {
entry.rtimeCommitmentId = log.commitmentId;
entry.syncedAt = new Date().toISOString();
mutated = true;
}
}
if (mutated) {
await core.updateTask(task);
updated++;
console.log(` ${btid}: updated ${logs.length} entries (${logs.map((l) => l.status).join(", ")})`);
}
}
console.log(`Pulled ${result.logs.length} logs, updated ${updated} tasks.`);
});
rtimeCmd
.command("link <taskId>")
.description("link a backlog task to an rTime task")
.option("--rtime-task <id>", "rTime task ID to link to")
.action(async (taskId: string, options) => {
const cwd = process.cwd();
const core = new Core(cwd);
const canonicalId = normalizeTaskId(taskId);
const task = await core.loadTaskById(canonicalId);
if (!task) {
console.error(`Task ${taskId} not found.`);
process.exitCode = 1;
return;
}
const config = await core.filesystem.loadConfig();
const rtimeConfig = config?.rtime;
if (!rtimeConfig?.baseUrl) {
console.error("rTime not configured.");
process.exitCode = 1;
return;
}
if (options.rtimeTask) {
// Manual link — update local task with rTime ref
task.rtime = {
...task.rtime,
taskId: options.rtimeTask,
space: rtimeConfig.space || "demo",
};
await core.updateTask(task);
console.log(`Linked ${canonicalId} → rTime task ${options.rtimeTask}`);
} else {
// Auto-link — find or create rTime task
const { ensureRtimeTask } = await import("./utils/rtime-sync.ts");
const result = await ensureRtimeTask(task, rtimeConfig);
if (result.error) {
console.error(`Failed to link: ${result.error}`);
process.exitCode = 1;
return;
}
task.rtime = {
...task.rtime,
taskId: result.taskId,
space: rtimeConfig.space || "demo",
};
await core.updateTask(task);
console.log(`Linked ${canonicalId} → rTime task ${result.taskId}`);
}
});
rtimeCmd
.command("import <rtimeTaskId>")
.description("import an rTime task as a local backlog task")
.action(async (rtimeTaskId: string) => {
const cwd = process.cwd();
const core = new Core(cwd);
const config = await core.filesystem.loadConfig();
const rtimeConfig = config?.rtime;
if (!rtimeConfig?.baseUrl) {
console.error("rTime not configured. Add rtime.baseUrl to .backlog/config.yml");
process.exitCode = 1;
return;
}
const base = rtimeConfig.baseUrl || "https://rspace.online";
const space = rtimeConfig.space || "demo";
const token = rtimeConfig.token || process.env.RSPACE_TOKEN || "";
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (token) headers.Authorization = `Bearer ${token}`;
try {
const resp = await fetch(`${base}/${space}/rtime/api/tasks/${rtimeTaskId}/export-to-backlog`, {
method: "POST",
headers,
signal: AbortSignal.timeout(10_000),
});
if (!resp.ok) {
const body = await resp.text();
console.error(`Failed to export from rTime: HTTP ${resp.status}${body}`);
process.exitCode = 1;
return;
}
const data = (await resp.json()) as {
title: string;
description?: string;
estimatedHours?: number;
labels?: string[];
notes?: string;
acceptanceCriteria?: string[];
rtime?: { taskId: string; space: string };
};
await core.ensureConfigLoaded();
const id = await core.generateNextId();
const task: Task = {
id,
title: data.title,
status: config?.defaultStatus || "To Do",
assignee: config?.defaultAssignee ? [config.defaultAssignee] : [],
createdDate: new Date().toISOString().slice(0, 16).replace("T", " "),
labels: data.labels ?? [],
dependencies: [],
description: data.description,
estimatedHours: data.estimatedHours,
implementationNotes: data.notes || undefined,
rtime: data.rtime,
};
if (data.acceptanceCriteria && data.acceptanceCriteria.length > 0) {
task.acceptanceCriteriaItems = data.acceptanceCriteria.map((text, i) => ({
index: i + 1,
text,
checked: false,
}));
}
const filepath = await core.createTask(task);
console.log(`Imported rTime task as ${id}: ${data.title}`);
console.log(`File: ${filepath}`);
if (data.rtime) {
console.log(`Linked to rTime task: ${data.rtime.taskId} (space: ${data.rtime.space})`);
}
} catch (err) {
console.error(`Import failed: ${err instanceof Error ? err.message : String(err)}`);
process.exitCode = 1;
}
});
const boardCmd = program.command("board");
function addBoardOptions(cmd: Command) {

View File

@ -14,6 +14,7 @@ import type {
TaskCreateInput,
TaskListFilter,
TaskUpdateInput,
TimeEntry,
} from "../types/index.ts";
import { isLocalEditableTask } from "../types/index.ts";
import { normalizeAssignee } from "../utils/assignee.ts";
@ -904,6 +905,17 @@ export class Core {
task.acceptanceCriteriaItems = acceptanceCriteria;
if (input.addTimeEntry) {
const entry: TimeEntry = {
date: input.addTimeEntry.date,
hours: input.addTimeEntry.hours,
...(input.addTimeEntry.skill && { skill: input.addTimeEntry.skill }),
...(input.addTimeEntry.note && { note: input.addTimeEntry.note }),
};
task.timeEntries = [...(task.timeEntries ?? []), entry];
mutated = true;
}
if (!mutated) {
return task;
}

View File

@ -4,8 +4,10 @@ import type {
Decision,
Document,
ParsedMarkdown,
RtimeRef,
StatusHistoryEntry,
Task,
TimeEntry,
} from "../types/index.ts";
import { AcceptanceCriteriaManager, extractStructuredSection, STRUCTURED_SECTION_KEYS } from "./structured-sections.ts";
@ -107,6 +109,39 @@ function parseStatusHistory(value: unknown): StatusHistoryEntry[] | undefined {
return entries.length > 0 ? entries : undefined;
}
function parseTimeEntries(value: unknown): TimeEntry[] | undefined {
if (!value || !Array.isArray(value)) return undefined;
const entries: TimeEntry[] = [];
for (const item of value) {
if (item && typeof item === "object" && "date" in item && "hours" in item) {
const entry: TimeEntry = {
date: String(item.date).slice(0, 10), // ensure YYYY-MM-DD
hours: Number(item.hours),
};
if ("skill" in item && item.skill) entry.skill = String(item.skill);
if ("note" in item && item.note) entry.note = String(item.note);
if ("rtimeCommitmentId" in item && item.rtimeCommitmentId) {
entry.rtimeCommitmentId = String(item.rtimeCommitmentId);
}
if ("syncedAt" in item && item.syncedAt) entry.syncedAt = String(item.syncedAt);
entries.push(entry);
}
}
return entries.length > 0 ? entries : undefined;
}
function parseRtimeRef(value: unknown): RtimeRef | undefined {
if (!value || typeof value !== "object") return undefined;
const ref: RtimeRef = {};
const obj = value as Record<string, unknown>;
if (obj.taskId) ref.taskId = String(obj.taskId);
if (obj.space) ref.space = String(obj.space);
if (Array.isArray(obj.commitmentIds)) {
ref.commitmentIds = obj.commitmentIds.map(String);
}
return ref.taskId || ref.space || ref.commitmentIds ? ref : undefined;
}
export function parseMarkdown(content: string): ParsedMarkdown {
// Updated regex to handle both Windows (\r\n) and Unix (\n) line endings
const fmRegex = /^---\r?\n([\s\S]*?)\r?\n---/;
@ -183,6 +218,8 @@ export function parseTask(content: string): Task {
: frontmatter.deadline
? normalizeDate(frontmatter.deadline)
: undefined,
timeEntries: parseTimeEntries(frontmatter.time_entries),
rtime: parseRtimeRef(frontmatter.rtime),
};
}

View File

@ -24,6 +24,8 @@ export function serializeTask(task: Task): string {
...(task.statusHistory && task.statusHistory.length > 0 && { status_history: task.statusHistory }),
...(task.doToday && { do_today: task.doToday }),
...(task.estimatedHours !== undefined && { estimated_hours: task.estimatedHours }),
...(task.timeEntries && task.timeEntries.length > 0 && { time_entries: task.timeEntries }),
...(task.rtime && { rtime: task.rtime }),
};
let contentBody = task.rawContent ?? "";

View File

@ -6,6 +6,23 @@ export interface StatusHistoryEntry {
timestamp: string; // ISO format: "YYYY-MM-DD HH:mm"
}
/** A time log entry recorded against a task */
export interface TimeEntry {
date: string; // "YYYY-MM-DD"
hours: number; // decimal hours
skill?: string; // maps to rTime skill (e.g. "tech", "design", "facilitation")
note?: string;
rtimeCommitmentId?: string; // set after push to rTime
syncedAt?: string; // ISO timestamp of last sync
}
/** Cross-references between a backlog task and an rTime task */
export interface RtimeRef {
taskId?: string; // rTime task ID
space?: string; // rTime space slug
commitmentIds?: string[]; // rTime commitment IDs created from time entries
}
// Structured Acceptance Criterion (domain-level)
export interface AcceptanceCriterion {
index: number; // 1-based
@ -54,6 +71,10 @@ export interface Task {
estimatedHours?: number;
/** Due date / deadline for the task (YYYY-MM-DD or YYYY-MM-DD HH:mm) */
dueDate?: string;
/** Time log entries recorded against this task */
timeEntries?: TimeEntry[];
/** Cross-references to rTime (commitment pool integration) */
rtime?: RtimeRef;
}
/**
@ -106,6 +127,8 @@ export interface TaskUpdateInput {
rawContent?: string;
doToday?: boolean;
estimatedHours?: number;
/** Append a time entry to the task's time log */
addTimeEntry?: Omit<TimeEntry, "rtimeCommitmentId" | "syncedAt">;
}
export interface TaskListFilter {
@ -238,6 +261,16 @@ export interface BacklogConfig {
allowedOrigins?: string[];
};
};
/** rTime integration config for pushing time entries to rSpace commitment pools */
rtime?: {
baseUrl?: string;
space?: string;
memberName?: string;
token?: string;
defaultSkill?: string;
autoPush?: boolean;
skillMap?: Record<string, string>;
};
}
export interface ParsedMarkdown {

321
src/utils/rtime-sync.ts Normal file
View File

@ -0,0 +1,321 @@
/**
* rTime bridge push/pull time entries between backlog-md and rTime.
*
* Offline-first: entries queue in .backlog/rtime-queue.json when rTime
* is unreachable, and replay on `backlog rtime sync`.
*/
import { join } from "node:path";
import type { BacklogConfig, Task, TimeEntry } from "../types/index.ts";
// ── Config types ──
export interface RtimeConfig {
baseUrl?: string;
space?: string;
memberName?: string;
token?: string;
defaultSkill?: string;
autoPush?: boolean;
skillMap?: Record<string, string>;
}
export interface QueueEntry {
taskId: string;
taskTitle: string;
entry: TimeEntry;
queuedAt: string;
}
// ── Skill resolution ──
const SKILL_ALIASES: Record<string, string> = {
dev: "tech",
development: "tech",
engineering: "tech",
code: "tech",
coding: "tech",
programming: "tech",
ux: "design",
ui: "design",
graphics: "design",
art: "design",
comms: "outreach",
marketing: "outreach",
community: "outreach",
ops: "logistics",
admin: "logistics",
coordination: "logistics",
planning: "logistics",
meeting: "facilitation",
workshop: "facilitation",
hosting: "facilitation",
};
const VALID_SKILLS = ["facilitation", "design", "tech", "outreach", "logistics"];
/**
* 3-tier skill resolution: exact match config map alias table.
*/
export function resolveSkill(skill: string | undefined, skillMap?: Record<string, string>): string | undefined {
if (!skill) return undefined;
const lower = skill.toLowerCase().trim();
// Tier 1: exact match
if (VALID_SKILLS.includes(lower)) return lower;
// Tier 2: config skill map
if (skillMap) {
const mapped = skillMap[lower] ?? skillMap[skill];
if (mapped && VALID_SKILLS.includes(mapped.toLowerCase())) return mapped.toLowerCase();
}
// Tier 3: built-in alias table
const alias = SKILL_ALIASES[lower];
if (alias) return alias;
// Fallback: return as-is (rTime will validate)
return lower;
}
// ── Queue management ──
function queuePath(cwd: string): string {
return join(cwd, ".backlog", "rtime-queue.json");
}
export async function loadQueue(cwd: string): Promise<QueueEntry[]> {
try {
const file = Bun.file(queuePath(cwd));
if (!(await file.exists())) return [];
return await file.json();
} catch {
return [];
}
}
async function saveQueue(cwd: string, queue: QueueEntry[]): Promise<void> {
const path = queuePath(cwd);
const dir = join(cwd, ".backlog");
const { mkdirSync, existsSync } = await import("node:fs");
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
await Bun.write(path, JSON.stringify(queue, null, "\t"));
}
async function enqueue(cwd: string, entry: QueueEntry): Promise<void> {
const queue = await loadQueue(cwd);
queue.push(entry);
await saveQueue(cwd, queue);
}
// ── API helpers ──
function getAuthHeaders(config: RtimeConfig): Record<string, string> {
const token = config.token || process.env.RSPACE_TOKEN;
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (token) headers.Authorization = `Bearer ${token}`;
return headers;
}
function getApiUrl(config: RtimeConfig, path: string): string {
const base = config.baseUrl || "https://rspace.online";
const space = config.space || "demo";
return `${base}/${space}/rtime${path}`;
}
// ── Push ──
export async function pushTimeEntry(
task: Task,
entry: TimeEntry,
cwd: string,
config: RtimeConfig,
): Promise<{ success: boolean; commitmentId?: string; error?: string }> {
const url = getApiUrl(config, "/api/external-time-logs");
const skill = resolveSkill(entry.skill, config.skillMap) || config.defaultSkill || "tech";
const body = {
backlogTaskId: task.id,
backlogTaskTitle: task.title,
memberName: config.memberName || "Unknown",
hours: entry.hours,
skill,
note: entry.note,
loggedAt: new Date(entry.date).getTime(),
};
try {
const resp = await fetch(url, {
method: "POST",
headers: getAuthHeaders(config),
body: JSON.stringify(body),
signal: AbortSignal.timeout(10_000),
});
if (!resp.ok) {
const errBody = await resp.text();
throw new Error(`HTTP ${resp.status}: ${errBody}`);
}
const result = (await resp.json()) as { log: { id: string }; commitmentId: string };
return { success: true, commitmentId: result.commitmentId };
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
// Queue for offline retry
await enqueue(cwd, {
taskId: task.id,
taskTitle: task.title,
entry,
queuedAt: new Date().toISOString(),
});
return { success: false, error };
}
}
// ── Flush queue ──
export async function flushQueue(
cwd: string,
config: RtimeConfig,
): Promise<{ flushed: number; failed: number; remaining: number }> {
const queue = await loadQueue(cwd);
if (queue.length === 0) return { flushed: 0, failed: 0, remaining: 0 };
const remaining: QueueEntry[] = [];
let flushed = 0;
let failed = 0;
for (const item of queue) {
const url = getApiUrl(config, "/api/external-time-logs");
const skill = resolveSkill(item.entry.skill, config.skillMap) || config.defaultSkill || "tech";
try {
const resp = await fetch(url, {
method: "POST",
headers: getAuthHeaders(config),
body: JSON.stringify({
backlogTaskId: item.taskId,
backlogTaskTitle: item.taskTitle,
memberName: config.memberName || "Unknown",
hours: item.entry.hours,
skill,
note: item.entry.note,
loggedAt: new Date(item.entry.date).getTime(),
}),
signal: AbortSignal.timeout(10_000),
});
if (resp.ok) {
flushed++;
} else {
remaining.push(item);
failed++;
}
} catch {
remaining.push(item);
failed++;
}
}
await saveQueue(cwd, remaining);
return { flushed, failed, remaining: remaining.length };
}
// ── Pull status ──
export async function pullStatus(
_cwd: string,
config: RtimeConfig,
taskId?: string,
): Promise<{
logs: Array<{
backlogTaskId: string;
hours: number;
skill: string;
status: string;
commitmentId?: string;
}>;
error?: string;
}> {
const params = new URLSearchParams();
if (config.memberName) params.set("memberName", config.memberName);
if (taskId) params.set("backlogTaskId", taskId);
const url = getApiUrl(config, `/api/external-time-logs?${params.toString()}`);
try {
const resp = await fetch(url, {
headers: getAuthHeaders(config),
signal: AbortSignal.timeout(10_000),
});
if (!resp.ok) {
return { logs: [], error: `HTTP ${resp.status}` };
}
const data = (await resp.json()) as {
logs: Array<{
backlogTaskId: string;
hours: number;
skill: string;
status: string;
commitmentId?: string;
}>;
};
return { logs: data.logs };
} catch (err) {
return { logs: [], error: err instanceof Error ? err.message : String(err) };
}
}
// ── Ensure rTime task ──
export async function ensureRtimeTask(task: Task, config: RtimeConfig): Promise<{ taskId?: string; error?: string }> {
const url = getApiUrl(config, "/api/tasks");
try {
// Check if a task already references this backlog task
const listResp = await fetch(url, {
headers: getAuthHeaders(config),
signal: AbortSignal.timeout(10_000),
});
if (listResp.ok) {
const data = (await listResp.json()) as { tasks: Array<{ id: string; description: string; name: string }> };
const existing = data.tasks.find(
(t) => t.description?.includes(`[backlog:${task.id}]`) || t.name?.includes(task.id),
);
if (existing) return { taskId: existing.id };
}
// Create new task
const createResp = await fetch(url, {
method: "POST",
headers: getAuthHeaders(config),
body: JSON.stringify({
name: task.title,
description: `[backlog:${task.id}]`,
needs: {},
}),
signal: AbortSignal.timeout(10_000),
});
if (!createResp.ok) {
return { error: `Failed to create rTime task: HTTP ${createResp.status}` };
}
const result = (await createResp.json()) as { id: string };
return { taskId: result.id };
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
}
}
// ── Config loader ──
export function getRtimeConfig(backlogConfig: BacklogConfig | null): RtimeConfig | undefined {
const config = backlogConfig as any;
return config?.rtime;
}