diff --git a/src/lib/format.ts b/src/lib/format.ts new file mode 100644 index 0000000..e232304 --- /dev/null +++ b/src/lib/format.ts @@ -0,0 +1,17 @@ +/** + * Shared formatting utilities for Mission Control. + * + * Centralizes formatting logic to avoid duplication across components. + */ + +import type { FocusItemType } from "./types"; + +/** + * Format an IID with the appropriate prefix for its type. + * + * MRs use ! prefix, issues use # prefix. + */ +export function formatIid(type: FocusItemType, iid: number): string { + if (type === "mr_review" || type === "mr_authored") return `!${iid}`; + return `#${iid}`; +} diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts new file mode 100644 index 0000000..963c4b7 --- /dev/null +++ b/src/lib/tauri.ts @@ -0,0 +1,29 @@ +/** + * Tauri IPC wrapper. + * + * Thin layer over @tauri-apps/api invoke that provides typed + * function signatures for each Rust command. + */ + +import { invoke } from "@tauri-apps/api/core"; +import type { BridgeStatus, CaptureResult, LoreStatus, SyncResult } from "./types"; + +export async function getLoreStatus(): Promise { + return invoke("get_lore_status"); +} + +export async function getBridgeStatus(): Promise { + return invoke("get_bridge_status"); +} + +export async function syncNow(): Promise { + return invoke("sync_now"); +} + +export async function reconcile(): Promise { + return invoke("reconcile"); +} + +export async function quickCapture(title: string): Promise { + return invoke("quick_capture", { title }); +} diff --git a/src/lib/transform.ts b/src/lib/transform.ts new file mode 100644 index 0000000..28ca60a --- /dev/null +++ b/src/lib/transform.ts @@ -0,0 +1,87 @@ +/** + * Transform lore backend data into FocusItem[] for the UI. + * + * Converts LoreStatus (issues, MRs) into a flat list of FocusItems + * sorted by priority: reviews first (blocking others), then issues, + * then authored MRs. + */ + +import type { FocusItem } from "./types"; + +/** Shape of lore issue from the backend */ +interface LoreIssue { + iid: number; + title: string; + project: string; + web_url: string; + updated_at_iso?: string | null; + attention_state?: string | null; +} + +/** Shape of lore MR from the backend */ +interface LoreMr { + iid: number; + title: string; + project: string; + web_url: string; + updated_at_iso?: string | null; + attention_state?: string | null; + author_username?: string | null; +} + +interface LoreMeData { + open_issues: LoreIssue[]; + open_mrs_authored: LoreMr[]; + reviewing_mrs: LoreMr[]; +} + +export function transformLoreData(data: LoreMeData): FocusItem[] { + const items: FocusItem[] = []; + + // Reviews first (you're blocking someone) + for (const mr of data.reviewing_mrs) { + items.push({ + id: `mr_review:${mr.project}:${mr.iid}`, + title: mr.title, + type: "mr_review", + project: mr.project, + url: mr.web_url, + iid: mr.iid, + updatedAt: mr.updated_at_iso ?? null, + contextQuote: null, + requestedBy: mr.author_username ?? null, + }); + } + + // Assigned issues + for (const issue of data.open_issues) { + items.push({ + id: `issue:${issue.project}:${issue.iid}`, + title: issue.title, + type: "issue", + project: issue.project, + url: issue.web_url, + iid: issue.iid, + updatedAt: issue.updated_at_iso ?? null, + contextQuote: null, + requestedBy: null, + }); + } + + // Authored MRs last (your own work, less urgent) + for (const mr of data.open_mrs_authored) { + items.push({ + id: `mr_authored:${mr.project}:${mr.iid}`, + title: mr.title, + type: "mr_authored", + project: mr.project, + url: mr.web_url, + iid: mr.iid, + updatedAt: mr.updated_at_iso ?? null, + contextQuote: null, + requestedBy: null, + }); + } + + return items; +} diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 0000000..ec2072a --- /dev/null +++ b/src/lib/types.ts @@ -0,0 +1,132 @@ +/** + * TypeScript types mirroring the Rust backend data structures. + * + * These are used by the IPC layer and components to maintain + * type safety across the Tauri boundary. + */ + +// -- Backend response types (match Rust structs in commands/mod.rs) -- + +export interface LoreStatus { + last_sync: string | null; + is_healthy: boolean; + message: string; + summary: LoreSummaryStatus | null; +} + +export interface LoreSummaryStatus { + open_issues: number; + authored_mrs: number; + reviewing_mrs: number; +} + +export interface BridgeStatus { + mapping_count: number; + pending_count: number; + suspect_count: number; + last_sync: string | null; + last_reconciliation: string | null; +} + +export interface SyncResult { + created: number; + closed: number; + skipped: number; + /** Number of suspect_orphan flags cleared (item reappeared) */ + healed: number; + /** Error messages from non-fatal errors during sync */ + errors: string[]; +} + +// -- Structured error types (match Rust error.rs) -- + +/** Error codes for programmatic handling */ +export type McErrorCode = + | "LORE_UNAVAILABLE" + | "LORE_UNHEALTHY" + | "LORE_FETCH_FAILED" + | "BRIDGE_LOCKED" + | "BRIDGE_MAP_CORRUPTED" + | "BRIDGE_SYNC_FAILED" + | "BEADS_UNAVAILABLE" + | "BEADS_CREATE_FAILED" + | "BEADS_CLOSE_FAILED" + | "IO_ERROR" + | "INTERNAL_ERROR"; + +/** Structured error from Tauri IPC commands */ +export interface McError { + code: McErrorCode; + message: string; + recoverable: boolean; +} + +/** Type guard to check if an error is a structured McError */ +export function isMcError(err: unknown): err is McError { + return ( + typeof err === "object" && + err !== null && + "code" in err && + "message" in err && + "recoverable" in err + ); +} + +/** Result from the quick_capture command */ +export interface CaptureResult { + bead_id: string; +} + +// -- Frontend-only types -- + +/** The type of work item surfaced in the Focus View */ +export type FocusItemType = "mr_review" | "issue" | "mr_authored" | "manual"; + +/** A single work item that can be THE ONE THING */ +export interface FocusItem { + /** Unique key matching bridge mapping (e.g., "mr_review:g/p:847") */ + id: string; + /** Human-readable title */ + title: string; + /** Type badge to display */ + type: FocusItemType; + /** Project path (e.g., "platform/core") */ + project: string; + /** URL to open in browser (GitLab link) */ + url: string; + /** Entity IID (e.g., MR !847, Issue #42) */ + iid: number; + /** ISO timestamp of last update */ + updatedAt: string | null; + /** Optional context quote (e.g., reviewer comment) */ + contextQuote: string | null; + /** Who is requesting attention */ + requestedBy: string | null; +} + +/** Action the user takes on a focused item */ +export type FocusAction = "start" | "defer_1h" | "defer_tomorrow" | "skip"; + +/** An entry in the decision log */ +export interface DecisionEntry { + timestamp: string; + action: FocusAction; + itemId: string; + reason: string | null; +} + +/** Staleness level derived from item age */ +export type Staleness = "fresh" | "normal" | "amber" | "urgent"; + +/** Compute staleness from an ISO timestamp */ +export function computeStaleness(updatedAt: string | null): Staleness { + if (!updatedAt) return "normal"; + + const ageMs = Date.now() - new Date(updatedAt).getTime(); + const ageDays = ageMs / (1000 * 60 * 60 * 24); + + if (ageDays < 1) return "fresh"; + if (ageDays < 3) return "normal"; + if (ageDays < 7) return "amber"; + return "urgent"; +}