agent-trajectories 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/.beads/.local_version +1 -0
  2. package/.beads/README.md +81 -0
  3. package/.beads/config.yaml +62 -0
  4. package/.beads/issues.jsonl +0 -0
  5. package/.beads/metadata.json +4 -0
  6. package/.gitattributes +3 -0
  7. package/IMPLEMENTATION-PROPOSAL.md +1598 -0
  8. package/PROPOSAL-trajectories.md +1582 -0
  9. package/README.md +275 -0
  10. package/biome.json +20 -0
  11. package/docs/architecture/core.md +477 -0
  12. package/docs/architecture/memory-integration.md +186 -0
  13. package/docs/architecture/web-viewer.md +213 -0
  14. package/package.json +47 -0
  15. package/src/cli/commands/abandon.ts +32 -0
  16. package/src/cli/commands/complete.ts +51 -0
  17. package/src/cli/commands/decision.ts +49 -0
  18. package/src/cli/commands/export.ts +100 -0
  19. package/src/cli/commands/index.ts +39 -0
  20. package/src/cli/commands/list.ts +90 -0
  21. package/src/cli/commands/show.ts +98 -0
  22. package/src/cli/commands/start.ts +53 -0
  23. package/src/cli/commands/status.ts +68 -0
  24. package/src/cli/index.ts +25 -0
  25. package/src/cli/runner.ts +67 -0
  26. package/src/core/id.ts +62 -0
  27. package/src/core/index.ts +54 -0
  28. package/src/core/schema.ts +272 -0
  29. package/src/core/trajectory.ts +283 -0
  30. package/src/core/types.ts +272 -0
  31. package/src/export/index.ts +10 -0
  32. package/src/export/json.ts +26 -0
  33. package/src/export/markdown.ts +216 -0
  34. package/src/export/pr-summary.ts +57 -0
  35. package/src/export/timeline.ts +69 -0
  36. package/src/index.ts +69 -0
  37. package/src/storage/file.ts +394 -0
  38. package/src/storage/index.ts +6 -0
  39. package/src/storage/interface.ts +92 -0
  40. package/src/web/generator.ts +347 -0
  41. package/src/web/index.ts +6 -0
  42. package/src/web/styles.ts +355 -0
  43. package/src/workspace/index.ts +6 -0
  44. package/src/workspace/storage.ts +231 -0
  45. package/src/workspace/types.ts +88 -0
  46. package/tests/cli/commands.test.ts +476 -0
  47. package/tests/core/trajectory.test.ts +426 -0
  48. package/tests/export/export.test.ts +376 -0
  49. package/tests/storage/storage.test.ts +410 -0
  50. package/tests/web/generator.test.ts +197 -0
  51. package/tests/workspace/storage.test.ts +275 -0
  52. package/tsconfig.json +26 -0
  53. package/tsup.config.ts +14 -0
  54. package/vitest.config.ts +15 -0
@@ -0,0 +1,213 @@
1
+ # Web Viewer Architecture
2
+
3
+ ## Overview
4
+
5
+ A lightweight local web interface for humans to browse and read trajectories in a Notion-like format.
6
+
7
+ ## Design Goals
8
+
9
+ 1. **Zero external dependencies** - No cloud services, runs entirely local
10
+ 2. **Instant startup** - `traj view` opens browser immediately
11
+ 3. **No build step** - Works without webpack/vite in dev
12
+ 4. **Offline-first** - All data from local `.trajectories/`
13
+
14
+ ## Architecture Options
15
+
16
+ ### Option A: Static HTML Export (Recommended for v1)
17
+
18
+ Generate a self-contained HTML file with embedded data:
19
+
20
+ ```
21
+ traj view # Opens generated HTML in browser
22
+ traj view --serve # Start live server with hot reload
23
+ traj view --export site/ # Generate static site
24
+ ```
25
+
26
+ **Pros:**
27
+ - No server needed for basic viewing
28
+ - Can be committed to repo for sharing
29
+ - Works offline, shareable via email
30
+
31
+ **Cons:**
32
+ - No live updates without regeneration
33
+
34
+ ### Option B: Local Dev Server
35
+
36
+ Run a lightweight HTTP server:
37
+
38
+ ```
39
+ traj serve # Start server at localhost:3847
40
+ ```
41
+
42
+ **Stack:**
43
+ - Node HTTP server (built-in, no Express needed)
44
+ - Preact for UI (3KB, no build step with htm)
45
+ - CSS: Simple stylesheet, no framework
46
+
47
+ **Pros:**
48
+ - Live updates as trajectories change
49
+ - Search and filter in UI
50
+
51
+ **Cons:**
52
+ - Requires running server
53
+
54
+ ### Option C: Hybrid (Recommended)
55
+
56
+ Combine both: static HTML for sharing, local server for active use.
57
+
58
+ ## UI Components
59
+
60
+ ```
61
+ ┌─────────────────────────────────────────────────────────────────┐
62
+ │ 🛤️ Trajectories [Search...] [⚙️] │
63
+ ├─────────────────────────────────────────────────────────────────┤
64
+ │ │
65
+ │ SIDEBAR │ MAIN VIEW │
66
+ │ ───────── │ ───────── │
67
+ │ │ │
68
+ │ ▸ Active (1) │ 📋 Implement User Auth │
69
+ │ └─ traj_abc123 │ ━━━━━━━━━━━━━━━━━━━━━━━━━ │
70
+ │ │ │
71
+ │ ▸ This Week (5) │ Status: ● Active │
72
+ │ │ Started: 2h ago │
73
+ │ ▸ Completed (23) │ Agent: Claude │
74
+ │ │ │
75
+ │ ▸ Abandoned (2) │ ▾ Summary │
76
+ │ │ JWT auth with refresh tokens │
77
+ │ ────────────── │ │
78
+ │ 📁 Workspace │ ▾ Key Decisions (2) │
79
+ │ ├─ Decisions │ ├─ JWT over sessions │
80
+ │ ├─ Patterns │ └─ Bcrypt for passwords │
81
+ │ └─ Knowledge │ │
82
+ │ │ ▾ Timeline │
83
+ │ │ 10:00 Started │
84
+ │ │ 10:15 Research phase │
85
+ │ │ 10:30 Decision: JWT │
86
+ │ │ 11:00 Implementation │
87
+ │ │ │
88
+ └─────────────────────────────────────────────────────────────────┘
89
+ ```
90
+
91
+ ## Implementation Plan
92
+
93
+ ### Phase 1: Static HTML Generator
94
+ - Generate single HTML file with all trajectory data
95
+ - Embedded CSS (light/dark mode)
96
+ - Collapsible sections for chapters, decisions
97
+ - `traj view <id>` opens single trajectory
98
+ - `traj view --all` generates index + all trajectories
99
+
100
+ ### Phase 2: Local Server
101
+ - Add `traj serve` command
102
+ - WebSocket for live updates
103
+ - Search across all trajectories
104
+ - Filtering by status, date, agent
105
+
106
+ ### Phase 3: Workspace Integration
107
+ - Sidebar shows workspace knowledge
108
+ - Link decisions to source trajectories
109
+ - Pattern library view
110
+ - "Related trajectories" for context
111
+
112
+ ## File Structure
113
+
114
+ ```
115
+ src/
116
+ ├── web/
117
+ │ ├── server.ts # HTTP server
118
+ │ ├── templates/
119
+ │ │ ├── index.html # Main shell
120
+ │ │ ├── trajectory.html # Single trajectory view
121
+ │ │ └── styles.css # Embedded styles
122
+ │ ├── generator.ts # Static HTML generator
123
+ │ └── components/ # Preact components (Phase 2)
124
+ │ ├── Sidebar.tsx
125
+ │ ├── Timeline.tsx
126
+ │ └── DecisionCard.tsx
127
+ ```
128
+
129
+ ## CLI Integration
130
+
131
+ ```bash
132
+ # View commands
133
+ traj view # Open active trajectory in browser
134
+ traj view <id> # Open specific trajectory
135
+ traj view --all # Open index of all trajectories
136
+
137
+ # Server commands
138
+ traj serve # Start local server
139
+ traj serve --port 8080 # Custom port
140
+
141
+ # Export for sharing
142
+ traj export <id> --format html --output trajectory.html
143
+ traj export --all --format html --output ./docs/trajectories/
144
+ ```
145
+
146
+ ## Workspace Layer
147
+
148
+ The workspace extracts durable knowledge from trajectories:
149
+
150
+ ```
151
+ .agent-workspace/
152
+ ├── decisions/
153
+ │ ├── index.json # All decisions with links
154
+ │ └── auth-approach.md # Individual decision doc
155
+ ├── patterns/
156
+ │ ├── index.json
157
+ │ └── api-endpoint.md # Reusable pattern
158
+ ├── knowledge/
159
+ │ ├── architecture.md # Codebase knowledge
160
+ │ └── conventions.md # Team conventions
161
+ └── config.json # Workspace settings
162
+ ```
163
+
164
+ ### Decision Extraction
165
+
166
+ When a trajectory completes, decisions can be promoted to workspace:
167
+
168
+ ```bash
169
+ traj workspace promote-decision <trajectory-id> <decision-index>
170
+ # or interactively after completion
171
+ ```
172
+
173
+ ### Query Interface
174
+
175
+ Agents query the workspace:
176
+
177
+ ```typescript
178
+ interface WorkspaceQuery {
179
+ type: 'decision' | 'pattern' | 'trajectory' | 'any';
180
+ query: string;
181
+ context?: string; // Current task context
182
+ limit?: number;
183
+ }
184
+
185
+ // Example: Agent asks about auth
186
+ workspace.query({
187
+ type: 'any',
188
+ query: 'authentication',
189
+ context: 'implementing login flow'
190
+ });
191
+
192
+ // Returns:
193
+ // - Decision: "Use JWT for auth"
194
+ // - Pattern: "Auth middleware template"
195
+ // - Trajectory: traj_abc123 (implemented auth before)
196
+ ```
197
+
198
+ ## Technology Choices
199
+
200
+ | Component | Choice | Rationale |
201
+ |-----------|--------|-----------|
202
+ | Server | Node http | Zero deps, built-in |
203
+ | UI (v1) | Vanilla JS | No build step |
204
+ | UI (v2) | Preact + htm | 3KB, no build needed |
205
+ | CSS | Custom | Simple, themed |
206
+ | Icons | Unicode emoji | No icon deps |
207
+
208
+ ## Questions to Resolve
209
+
210
+ 1. **Workspace location**: `.agent-workspace/` vs inside `.trajectories/workspace/`?
211
+ 2. **Knowledge format**: Markdown files vs JSON?
212
+ 3. **Pattern templates**: How to make patterns executable/reusable?
213
+ 4. **Multi-repo**: How does workspace work across repos?
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "agent-trajectories",
3
+ "version": "0.1.0",
4
+ "description": "Capture the complete train of thought of agent work as first-class artifacts",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "bin": {
9
+ "trail": "dist/cli/index.js"
10
+ },
11
+ "scripts": {
12
+ "build": "tsup",
13
+ "dev": "tsup --watch",
14
+ "test": "vitest",
15
+ "test:run": "vitest run",
16
+ "lint": "biome check .",
17
+ "lint:fix": "biome check --write .",
18
+ "format": "biome format --write .",
19
+ "typecheck": "tsc --noEmit"
20
+ },
21
+ "keywords": [
22
+ "agent",
23
+ "trajectory",
24
+ "ai",
25
+ "claude",
26
+ "llm",
27
+ "debugging",
28
+ "tracing"
29
+ ],
30
+ "author": "",
31
+ "license": "MIT",
32
+ "engines": {
33
+ "node": ">=20.0.0"
34
+ },
35
+ "dependencies": {
36
+ "@clack/prompts": "^0.7.0",
37
+ "commander": "^12.0.0",
38
+ "zod": "^3.23.0"
39
+ },
40
+ "devDependencies": {
41
+ "@biomejs/biome": "^1.8.0",
42
+ "@types/node": "^20.0.0",
43
+ "tsup": "^8.0.0",
44
+ "typescript": "^5.4.0",
45
+ "vitest": "^2.0.0"
46
+ }
47
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * trail abandon command
3
+ */
4
+
5
+ import type { Command } from "commander";
6
+ import { abandonTrajectory } from "../../core/trajectory.js";
7
+ import { FileStorage } from "../../storage/file.js";
8
+
9
+ export function registerAbandonCommand(program: Command): void {
10
+ program
11
+ .command("abandon")
12
+ .description("Abandon the active trajectory")
13
+ .option("-r, --reason <text>", "Reason for abandonment")
14
+ .action(async (options) => {
15
+ const storage = new FileStorage();
16
+ await storage.initialize();
17
+
18
+ const active = await storage.getActive();
19
+ if (!active) {
20
+ console.error("Error: No active trajectory");
21
+ throw new Error("No active trajectory");
22
+ }
23
+
24
+ const abandoned = abandonTrajectory(active, options.reason);
25
+ await storage.save(abandoned);
26
+
27
+ console.log(`✓ Trajectory abandoned: ${abandoned.id}`);
28
+ if (options.reason) {
29
+ console.log(` Reason: ${options.reason}`);
30
+ }
31
+ });
32
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * trail complete command
3
+ */
4
+
5
+ import type { Command } from "commander";
6
+ import { completeTrajectory } from "../../core/trajectory.js";
7
+ import { FileStorage } from "../../storage/file.js";
8
+
9
+ export function registerCompleteCommand(program: Command): void {
10
+ program
11
+ .command("complete")
12
+ .description("Complete the active trajectory with retrospective")
13
+ .option("--summary <text>", "Summary of what was accomplished")
14
+ .option("--approach <text>", "How the work was approached")
15
+ .option("--confidence <number>", "Confidence level 0-1", parseFloat)
16
+ .action(async (options) => {
17
+ const storage = new FileStorage();
18
+ await storage.initialize();
19
+
20
+ const active = await storage.getActive();
21
+ if (!active) {
22
+ console.error("Error: No active trajectory");
23
+ console.error("Start one with: trail start \"Task description\"");
24
+ throw new Error("No active trajectory");
25
+ }
26
+
27
+ // Require summary and confidence
28
+ if (!options.summary) {
29
+ console.error("Error: --summary is required");
30
+ throw new Error("Summary required");
31
+ }
32
+
33
+ const confidence = options.confidence ?? 0.8;
34
+ if (confidence < 0 || confidence > 1) {
35
+ console.error("Error: --confidence must be between 0 and 1");
36
+ throw new Error("Invalid confidence");
37
+ }
38
+
39
+ const completed = completeTrajectory(active, {
40
+ summary: options.summary,
41
+ approach: options.approach || "Standard approach",
42
+ confidence,
43
+ });
44
+
45
+ await storage.save(completed);
46
+
47
+ console.log(`✓ Trajectory completed: ${completed.id}`);
48
+ console.log(` Summary: ${options.summary}`);
49
+ console.log(` Confidence: ${Math.round(confidence * 100)}%`);
50
+ });
51
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * trail decision command
3
+ */
4
+
5
+ import type { Command } from "commander";
6
+ import { addDecision } from "../../core/trajectory.js";
7
+ import { FileStorage } from "../../storage/file.js";
8
+
9
+ export function registerDecisionCommand(program: Command): void {
10
+ program
11
+ .command("decision <choice>")
12
+ .description("Record a decision")
13
+ .option("-r, --reasoning <text>", "Why this choice was made (optional for minor decisions)")
14
+ .option("-a, --alternatives <items>", "Comma-separated alternatives considered")
15
+ .action(async (choice: string, options) => {
16
+ const storage = new FileStorage();
17
+ await storage.initialize();
18
+
19
+ const active = await storage.getActive();
20
+ if (!active) {
21
+ console.error("Error: No active trajectory");
22
+ console.error("Start one with: trail start \"Task description\"");
23
+ throw new Error("No active trajectory");
24
+ }
25
+
26
+ const alternatives = options.alternatives
27
+ ? options.alternatives.split(",").map((s: string) => s.trim())
28
+ : [];
29
+
30
+ const reasoning = options.reasoning || "";
31
+
32
+ const updated = addDecision(active, {
33
+ question: choice,
34
+ chosen: choice,
35
+ alternatives,
36
+ reasoning,
37
+ });
38
+
39
+ await storage.save(updated);
40
+
41
+ console.log(`✓ Decision recorded: ${choice}`);
42
+ if (reasoning) {
43
+ console.log(` Reasoning: ${reasoning}`);
44
+ }
45
+ if (alternatives.length > 0) {
46
+ console.log(` Alternatives: ${alternatives.join(", ")}`);
47
+ }
48
+ });
49
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * trail export command
3
+ */
4
+
5
+ import type { Command } from "commander";
6
+ import { writeFile, mkdir } from "node:fs/promises";
7
+ import { join } from "node:path";
8
+ import { exec } from "node:child_process";
9
+ import { FileStorage } from "../../storage/file.js";
10
+ import { exportToMarkdown } from "../../export/markdown.js";
11
+ import { exportToJSON } from "../../export/json.js";
12
+ import { exportToTimeline } from "../../export/timeline.js";
13
+ import { generateTrajectoryHtml } from "../../web/generator.js";
14
+
15
+ export function registerExportCommand(program: Command): void {
16
+ program
17
+ .command("export [id]")
18
+ .description("Export a trajectory")
19
+ .option("-f, --format <format>", "Export format (md, json, timeline, html)", "md")
20
+ .option("-o, --output <path>", "Output file path")
21
+ .option("--open", "Open in browser (html format only)")
22
+ .action(async (id: string | undefined, options) => {
23
+ const storage = new FileStorage();
24
+ await storage.initialize();
25
+
26
+ // If no ID provided, use active trajectory
27
+ let trajectory;
28
+ if (id) {
29
+ trajectory = await storage.get(id);
30
+ if (!trajectory) {
31
+ console.error(`Error: Trajectory not found: ${id}`);
32
+ throw new Error("Trajectory not found");
33
+ }
34
+ } else {
35
+ trajectory = await storage.getActive();
36
+ if (!trajectory) {
37
+ console.error("Error: No active trajectory and no ID provided");
38
+ console.error("Usage: trail export <id> or trail export (with active trajectory)");
39
+ throw new Error("No trajectory specified");
40
+ }
41
+ }
42
+
43
+ let output: string;
44
+
45
+ switch (options.format) {
46
+ case "json":
47
+ output = exportToJSON(trajectory);
48
+ break;
49
+ case "timeline":
50
+ output = exportToTimeline(trajectory);
51
+ break;
52
+ case "html":
53
+ output = generateTrajectoryHtml(trajectory);
54
+ break;
55
+ case "md":
56
+ case "markdown":
57
+ default:
58
+ output = exportToMarkdown(trajectory);
59
+ break;
60
+ }
61
+
62
+ if (options.output) {
63
+ await writeFile(options.output, output, "utf-8");
64
+ console.log(`✓ Exported to ${options.output}`);
65
+
66
+ if (options.open && options.format === "html") {
67
+ openInBrowser(options.output);
68
+ }
69
+ } else if (options.open && options.format === "html") {
70
+ // Write to temp location and open
71
+ const outputDir = join(process.cwd(), ".trajectories", "html");
72
+ await mkdir(outputDir, { recursive: true });
73
+ const filePath = join(outputDir, `${trajectory.id}.html`);
74
+ await writeFile(filePath, output, "utf-8");
75
+ console.log(`✓ Generated: ${filePath}`);
76
+ openInBrowser(filePath);
77
+ } else {
78
+ console.log(output);
79
+ }
80
+ });
81
+ }
82
+
83
+ function openInBrowser(path: string): void {
84
+ const platform = process.platform;
85
+ let command: string;
86
+
87
+ if (platform === "darwin") {
88
+ command = `open "${path}"`;
89
+ } else if (platform === "win32") {
90
+ command = `start "" "${path}"`;
91
+ } else {
92
+ command = `xdg-open "${path}"`;
93
+ }
94
+
95
+ exec(command, (error) => {
96
+ if (error) {
97
+ console.log(`Open manually: file://${path}`);
98
+ }
99
+ });
100
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * CLI Command Registration
3
+ *
4
+ * Registers all commands with the program.
5
+ *
6
+ * Core commands (8 total):
7
+ * - start: Begin tracking a new task
8
+ * - status: Show current trajectory state
9
+ * - decision: Record a decision point
10
+ * - complete: Finish with retrospective
11
+ * - abandon: Stop without completing
12
+ * - list: Browse trajectories (with --search)
13
+ * - show: View trajectory details
14
+ * - export: Output in various formats (with --open)
15
+ */
16
+
17
+ import type { Command } from "commander";
18
+ import { registerStartCommand } from "./start.js";
19
+ import { registerStatusCommand } from "./status.js";
20
+ import { registerCompleteCommand } from "./complete.js";
21
+ import { registerAbandonCommand } from "./abandon.js";
22
+ import { registerDecisionCommand } from "./decision.js";
23
+ import { registerListCommand } from "./list.js";
24
+ import { registerShowCommand } from "./show.js";
25
+ import { registerExportCommand } from "./export.js";
26
+
27
+ /**
28
+ * Register all CLI commands
29
+ */
30
+ export function registerCommands(program: Command): void {
31
+ registerStartCommand(program);
32
+ registerStatusCommand(program);
33
+ registerCompleteCommand(program);
34
+ registerAbandonCommand(program);
35
+ registerDecisionCommand(program);
36
+ registerListCommand(program);
37
+ registerShowCommand(program);
38
+ registerExportCommand(program);
39
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * trail list command
3
+ */
4
+
5
+ import type { Command } from "commander";
6
+ import { FileStorage } from "../../storage/file.js";
7
+ import type { TrajectoryStatus } from "../../core/types.js";
8
+
9
+ export function registerListCommand(program: Command): void {
10
+ program
11
+ .command("list")
12
+ .description("List and search trajectories")
13
+ .option("-s, --status <status>", "Filter by status (active, completed, abandoned)")
14
+ .option("-l, --limit <number>", "Limit results", parseInt)
15
+ .option("--search <query>", "Search trajectories by title or content")
16
+ .action(async (options) => {
17
+ const storage = new FileStorage();
18
+ await storage.initialize();
19
+
20
+ let trajectories = await storage.list({
21
+ status: options.status as TrajectoryStatus | undefined,
22
+ limit: options.search ? undefined : options.limit, // Apply limit after search
23
+ });
24
+
25
+ // Apply search filter if provided
26
+ if (options.search) {
27
+ const query = options.search.toLowerCase();
28
+ trajectories = trajectories.filter((traj) => {
29
+ // Search in title
30
+ if (traj.title.toLowerCase().includes(query)) return true;
31
+ // Search in ID
32
+ if (traj.id.toLowerCase().includes(query)) return true;
33
+ return false;
34
+ });
35
+
36
+ // Apply limit after search
37
+ if (options.limit) {
38
+ trajectories = trajectories.slice(0, options.limit);
39
+ }
40
+ }
41
+
42
+ if (trajectories.length === 0) {
43
+ if (options.search) {
44
+ console.log(`No trajectories found matching "${options.search}"`);
45
+ } else {
46
+ console.log("No trajectories found");
47
+ }
48
+ return;
49
+ }
50
+
51
+ const searchNote = options.search ? ` matching "${options.search}"` : "";
52
+ console.log(`Found ${trajectories.length} trajectories${searchNote}:\n`);
53
+
54
+ for (const traj of trajectories) {
55
+ const statusIcon = getStatusIcon(traj.status);
56
+ const confidence = traj.confidence
57
+ ? ` (${Math.round(traj.confidence * 100)}%)`
58
+ : "";
59
+
60
+ console.log(`${statusIcon} ${traj.id}`);
61
+ console.log(` ${traj.title}${confidence}`);
62
+ console.log(` Started: ${formatDate(traj.startedAt)}`);
63
+ if (traj.completedAt) {
64
+ console.log(` Completed: ${formatDate(traj.completedAt)}`);
65
+ }
66
+ console.log("");
67
+ }
68
+ });
69
+ }
70
+
71
+ function getStatusIcon(status: string): string {
72
+ switch (status) {
73
+ case "active":
74
+ return "🔄";
75
+ case "completed":
76
+ return "✅";
77
+ case "abandoned":
78
+ return "❌";
79
+ default:
80
+ return "•";
81
+ }
82
+ }
83
+
84
+ function formatDate(isoString: string): string {
85
+ return new Date(isoString).toLocaleDateString("en-US", {
86
+ month: "short",
87
+ day: "numeric",
88
+ year: "numeric",
89
+ });
90
+ }