deepspace 0.3.9 → 0.3.11

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 (120) hide show
  1. package/dist/cli.js +96 -18
  2. package/dist/cli.js.map +1 -1
  3. package/dist/index.d.ts +160 -1
  4. package/dist/index.js +283 -126
  5. package/dist/index.js.map +1 -1
  6. package/dist/server.d.ts +172 -2
  7. package/dist/server.js +581 -1
  8. package/dist/server.js.map +1 -1
  9. package/dist/worker.d.ts +252 -2
  10. package/dist/worker.js +588 -1
  11. package/dist/worker.js.map +1 -1
  12. package/features/admin-page/feature.json +29 -0
  13. package/features/admin-page/src/AdminPage.tsx +292 -0
  14. package/features/admin-page/src/admin-schema.ts +20 -0
  15. package/features/ai-chat/feature.json +41 -0
  16. package/features/ai-chat/src/AiChatPage.tsx +692 -0
  17. package/features/ai-chat/src/ChatPanel.tsx +1098 -0
  18. package/features/ai-chat/src/ai-chat-schema.ts +11 -0
  19. package/features/canvas/feature.json +45 -0
  20. package/features/canvas/src/CanvasEditorPage.tsx +87 -0
  21. package/features/canvas/src/CanvasListPage.tsx +202 -0
  22. package/features/canvas/src/canvas-schema.ts +33 -0
  23. package/features/canvas/src/components/CanvasToolbar.tsx +170 -0
  24. package/features/canvas/src/components/CanvasView.tsx +369 -0
  25. package/features/canvas/src/components/ShapeRenderer.tsx +114 -0
  26. package/features/cron/feature.json +33 -0
  27. package/features/cron/src/CronLogPage.tsx +100 -0
  28. package/features/cron/src/cron.ts +25 -0
  29. package/features/docs/feature.json +96 -0
  30. package/features/docs/src/DocsEditorPage.tsx +1012 -0
  31. package/features/docs/src/DocsListPage.tsx +951 -0
  32. package/features/docs/src/DocsOutlinePanel.tsx +68 -0
  33. package/features/docs/src/DocsPresence.tsx +138 -0
  34. package/features/docs/src/InviteDialog.tsx +336 -0
  35. package/features/docs/src/LibrarySidebar.tsx +464 -0
  36. package/features/docs/src/docs-favorites.ts +18 -0
  37. package/features/docs/src/docs-library-types.ts +15 -0
  38. package/features/docs/src/docs-schema.ts +80 -0
  39. package/features/docs/src/docs-ui.css +309 -0
  40. package/features/docs/src/editor/DocEditorSurface.tsx +257 -0
  41. package/features/docs/src/editor/DocsTiptapToolbar.tsx +560 -0
  42. package/features/docs/src/editor/useDocEditor.ts +206 -0
  43. package/features/docs/src/use-yjs-room-with-awareness.ts +285 -0
  44. package/features/file-attachments/feature.json +42 -0
  45. package/features/file-attachments/src/AttachmentsPage.tsx +864 -0
  46. package/features/file-attachments/src/attachments-constants.ts +9 -0
  47. package/features/file-attachments/src/attachments-schema.ts +45 -0
  48. package/features/file-attachments/src/useFilePreview.ts +471 -0
  49. package/features/file-attachments/src/useMimeTypeDetection.ts +138 -0
  50. package/features/file-manager/feature.json +24 -0
  51. package/features/file-manager/src/FileManagerPage.tsx +330 -0
  52. package/features/integration-test/feature.json +18 -0
  53. package/features/integration-test/src/IntegrationTestPage.tsx +423 -0
  54. package/features/integration-test/src/integration.spec.ts +42 -0
  55. package/features/items/feature.json +33 -0
  56. package/features/items/src/ItemsPage.tsx +325 -0
  57. package/features/items/src/items-constants.ts +10 -0
  58. package/features/items/src/items-schema.ts +27 -0
  59. package/features/kanban/feature.json +18 -0
  60. package/features/kanban/src/Kanban.tsx +283 -0
  61. package/features/landing/feature.json +62 -0
  62. package/features/landing/src/LandingPage.tsx +627 -0
  63. package/features/landing/src/primitives.tsx +389 -0
  64. package/features/landing/src/sections/FAQSection.tsx +110 -0
  65. package/features/landing/src/sections/FeaturesGridSection.tsx +311 -0
  66. package/features/landing/src/sections/HowItWorksSection.tsx +62 -0
  67. package/features/landing/src/sections/LogoCloudSection.tsx +58 -0
  68. package/features/landing/src/sections/ShowcaseSection.tsx +63 -0
  69. package/features/landing/src/sections/StatsSection.tsx +31 -0
  70. package/features/landing/src/sections/TeamSection.tsx +86 -0
  71. package/features/landing/src/sections/TestimonialsSection.tsx +68 -0
  72. package/features/landing/src/sections/VideoSection.tsx +71 -0
  73. package/features/leaderboard/feature.json +33 -0
  74. package/features/leaderboard/src/LeaderboardPage.tsx +372 -0
  75. package/features/leaderboard/src/leaderboard-constants.ts +21 -0
  76. package/features/leaderboard/src/leaderboard-schema.ts +36 -0
  77. package/features/messaging/feature.json +111 -0
  78. package/features/messaging/src/ChatMultiPage.tsx +300 -0
  79. package/features/messaging/src/ChatPage.tsx +75 -0
  80. package/features/messaging/src/components/chat/ChatHeader.tsx +76 -0
  81. package/features/messaging/src/components/chat/MessageActionSheet.tsx +168 -0
  82. package/features/messaging/src/components/chat/MessageInput.tsx +65 -0
  83. package/features/messaging/src/components/chat/MessageItem.tsx +352 -0
  84. package/features/messaging/src/components/chat/MessageList.tsx +248 -0
  85. package/features/messaging/src/components/chat/ReactionPicker.tsx +2 -0
  86. package/features/messaging/src/components/chat/ThreadPanel.tsx +107 -0
  87. package/features/messaging/src/components/chat/UserProfilePopover.tsx +158 -0
  88. package/features/messaging/src/components/chat-multi/AddMemberModal.tsx +188 -0
  89. package/features/messaging/src/components/chat-multi/BrowseGroupsModal.tsx +163 -0
  90. package/features/messaging/src/components/chat-multi/ChannelListItem.tsx +147 -0
  91. package/features/messaging/src/components/chat-multi/ChannelSettingsPanel.tsx +344 -0
  92. package/features/messaging/src/components/chat-multi/ChannelSidebar.tsx +460 -0
  93. package/features/messaging/src/components/chat-multi/CreateGroupModal.tsx +190 -0
  94. package/features/messaging/src/components/chat-multi/InvitationList.tsx +149 -0
  95. package/features/messaging/src/components/chat-multi/NewMessageModal.tsx +159 -0
  96. package/features/messaging/src/hooks/useChatChannel.ts +64 -0
  97. package/features/messaging/src/hooks/useLongPress.ts +74 -0
  98. package/features/messaging/src/hooks/useMultiChannel.ts +142 -0
  99. package/features/messaging/src/messaging-schema.ts +25 -0
  100. package/features/presence-test/feature.json +21 -0
  101. package/features/presence-test/src/PresenceTestPage.tsx +120 -0
  102. package/features/sidebar/feature.json +19 -0
  103. package/features/sidebar/src/AppSidebar.tsx +558 -0
  104. package/features/tasks/feature.json +33 -0
  105. package/features/tasks/src/TasksPage.tsx +598 -0
  106. package/features/tasks/src/tasks-constants.ts +43 -0
  107. package/features/tasks/src/tasks-schema.ts +56 -0
  108. package/features/testing/feature.json +49 -0
  109. package/features/testing/src/TestPage.tsx +145 -0
  110. package/features/testing/src/cpu-burn-action.ts +51 -0
  111. package/features/testing/src/subrequest-burn-action.ts +53 -0
  112. package/features/testing/src/test-schema.ts +23 -0
  113. package/features/topbar/feature.json +18 -0
  114. package/features/topbar/src/Topbar.tsx +353 -0
  115. package/features/tree/feature.json +26 -0
  116. package/features/tree/src/TreeSidebar.tsx +607 -0
  117. package/features/tree/src/tree-constants.ts +68 -0
  118. package/features/tree/src/tree-hooks.ts +258 -0
  119. package/package.json +4 -2
  120. package/scripts/add-feature.cjs +654 -0
@@ -0,0 +1,369 @@
1
+ /**
2
+ * CanvasView — SVG canvas with shape rendering, mouse interaction, zoom/pan, and multi-user cursors.
3
+ */
4
+
5
+ import { useState, useRef, useCallback, useEffect } from 'react'
6
+ import { useCanvas, type CanvasShapeClient } from 'deepspace'
7
+ import { CanvasToolbar, type CanvasTool } from './CanvasToolbar'
8
+ import { ShapeRenderer } from './ShapeRenderer'
9
+ import { Badge } from 'deepspace'
10
+
11
+ interface CanvasViewProps {
12
+ docId: string
13
+ className?: string
14
+ }
15
+
16
+ interface DragState {
17
+ type: 'create' | 'move'
18
+ startX: number
19
+ startY: number
20
+ shapeId?: string
21
+ originalX?: number
22
+ originalY?: number
23
+ }
24
+
25
+ // User colors for multi-user cursors
26
+ const CURSOR_COLORS = ['#ef4444', '#3b82f6', '#22c55e', '#f59e0b', '#8b5cf6', '#ec4899', '#14b8a6']
27
+
28
+ export function CanvasView({ docId, className }: CanvasViewProps) {
29
+ const {
30
+ shapes,
31
+ viewports,
32
+ connected,
33
+ addShape,
34
+ moveShape,
35
+ deleteShape,
36
+ setViewport,
37
+ undo,
38
+ redo,
39
+ } = useCanvas(docId)
40
+
41
+ const [activeTool, setActiveTool] = useState<CanvasTool>('select')
42
+ const [activeColor, setActiveColor] = useState('#6366f1')
43
+ const [selectedShapeId, setSelectedShapeId] = useState<string | null>(null)
44
+ const [dragState, setDragState] = useState<DragState | null>(null)
45
+ const [pan, setPan] = useState({ x: 0, y: 0 })
46
+ const [zoom, setZoom] = useState(1)
47
+ const [isPanning, setIsPanning] = useState(false)
48
+ const [panStart, setPanStart] = useState({ x: 0, y: 0, panX: 0, panY: 0 })
49
+
50
+ const svgRef = useRef<SVGSVGElement>(null)
51
+
52
+ // Convert screen coordinates to canvas coordinates
53
+ const screenToCanvas = useCallback(
54
+ (clientX: number, clientY: number) => {
55
+ const svg = svgRef.current
56
+ if (!svg) return { x: 0, y: 0 }
57
+ const rect = svg.getBoundingClientRect()
58
+ return {
59
+ x: (clientX - rect.left - pan.x) / zoom,
60
+ y: (clientY - rect.top - pan.y) / zoom,
61
+ }
62
+ },
63
+ [pan, zoom],
64
+ )
65
+
66
+ // Report viewport on pan/zoom change
67
+ useEffect(() => {
68
+ const svg = svgRef.current
69
+ if (!svg) return
70
+ const rect = svg.getBoundingClientRect()
71
+ setViewport({
72
+ x: -pan.x / zoom,
73
+ y: -pan.y / zoom,
74
+ width: rect.width / zoom,
75
+ height: rect.height / zoom,
76
+ zoom,
77
+ })
78
+ }, [pan, zoom, setViewport])
79
+
80
+ // Keyboard shortcuts
81
+ useEffect(() => {
82
+ const handleKeyDown = (e: KeyboardEvent) => {
83
+ if (e.key === 'Delete' || e.key === 'Backspace') {
84
+ if (selectedShapeId && document.activeElement === document.body) {
85
+ e.preventDefault()
86
+ deleteShape(selectedShapeId)
87
+ setSelectedShapeId(null)
88
+ }
89
+ }
90
+ if ((e.metaKey || e.ctrlKey) && e.key === 'z') {
91
+ e.preventDefault()
92
+ if (e.shiftKey) {
93
+ redo()
94
+ } else {
95
+ undo()
96
+ }
97
+ }
98
+ }
99
+ window.addEventListener('keydown', handleKeyDown)
100
+ return () => window.removeEventListener('keydown', handleKeyDown)
101
+ }, [selectedShapeId, deleteShape, undo, redo])
102
+
103
+ // Mouse down on SVG background
104
+ const handleSvgMouseDown = useCallback(
105
+ (e: React.MouseEvent) => {
106
+ // Middle mouse button or space+click for panning
107
+ if (e.button === 1) {
108
+ e.preventDefault()
109
+ setIsPanning(true)
110
+ setPanStart({ x: e.clientX, y: e.clientY, panX: pan.x, panY: pan.y })
111
+ return
112
+ }
113
+
114
+ if (e.button !== 0) return
115
+
116
+ const pos = screenToCanvas(e.clientX, e.clientY)
117
+
118
+ if (activeTool === 'select') {
119
+ // Deselect when clicking background
120
+ setSelectedShapeId(null)
121
+ // Start panning
122
+ setIsPanning(true)
123
+ setPanStart({ x: e.clientX, y: e.clientY, panX: pan.x, panY: pan.y })
124
+ return
125
+ }
126
+
127
+ // Start creating a shape
128
+ setDragState({
129
+ type: 'create',
130
+ startX: pos.x,
131
+ startY: pos.y,
132
+ })
133
+ },
134
+ [activeTool, screenToCanvas, pan],
135
+ )
136
+
137
+ // Mouse down on a shape
138
+ const handleShapeMouseDown = useCallback(
139
+ (e: React.MouseEvent, shapeId: string) => {
140
+ if (activeTool !== 'select') return
141
+
142
+ const shape = shapes.find((s) => s.id === shapeId)
143
+ if (!shape) return
144
+
145
+ setSelectedShapeId(shapeId)
146
+ setDragState({
147
+ type: 'move',
148
+ startX: e.clientX,
149
+ startY: e.clientY,
150
+ shapeId,
151
+ originalX: shape.x,
152
+ originalY: shape.y,
153
+ })
154
+ },
155
+ [activeTool, shapes],
156
+ )
157
+
158
+ // Mouse move
159
+ const handleMouseMove = useCallback(
160
+ (e: React.MouseEvent) => {
161
+ if (isPanning) {
162
+ setPan({
163
+ x: panStart.panX + (e.clientX - panStart.x),
164
+ y: panStart.panY + (e.clientY - panStart.y),
165
+ })
166
+ return
167
+ }
168
+
169
+ if (!dragState) return
170
+
171
+ if (dragState.type === 'move' && dragState.shapeId) {
172
+ const dx = (e.clientX - dragState.startX) / zoom
173
+ const dy = (e.clientY - dragState.startY) / zoom
174
+ moveShape(dragState.shapeId, dragState.originalX! + dx, dragState.originalY! + dy)
175
+ }
176
+ },
177
+ [dragState, isPanning, panStart, zoom, moveShape],
178
+ )
179
+
180
+ // Mouse up
181
+ const handleMouseUp = useCallback(
182
+ (e: React.MouseEvent) => {
183
+ if (isPanning) {
184
+ setIsPanning(false)
185
+ return
186
+ }
187
+
188
+ if (!dragState) return
189
+
190
+ if (dragState.type === 'create') {
191
+ const pos = screenToCanvas(e.clientX, e.clientY)
192
+ const x = Math.min(dragState.startX, pos.x)
193
+ const y = Math.min(dragState.startY, pos.y)
194
+ const width = Math.max(Math.abs(pos.x - dragState.startX), 20)
195
+ const height = Math.max(Math.abs(pos.y - dragState.startY), 20)
196
+
197
+ addShape({
198
+ type: activeTool,
199
+ x,
200
+ y,
201
+ width,
202
+ height,
203
+ props: {
204
+ stroke: activeColor,
205
+ fill: 'transparent',
206
+ ...(activeTool === 'text' ? { text: 'Text' } : {}),
207
+ },
208
+ })
209
+
210
+ // Switch back to select after creating
211
+ setActiveTool('select')
212
+ }
213
+
214
+ setDragState(null)
215
+ },
216
+ [dragState, isPanning, screenToCanvas, activeTool, activeColor, addShape],
217
+ )
218
+
219
+ // Wheel for zoom
220
+ const handleWheel = useCallback(
221
+ (e: React.WheelEvent) => {
222
+ e.preventDefault()
223
+ const svg = svgRef.current
224
+ if (!svg) return
225
+
226
+ const rect = svg.getBoundingClientRect()
227
+ const mouseX = e.clientX - rect.left
228
+ const mouseY = e.clientY - rect.top
229
+
230
+ const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1
231
+ const newZoom = Math.min(Math.max(zoom * zoomFactor, 0.1), 5)
232
+
233
+ // Zoom towards mouse position
234
+ setPan({
235
+ x: mouseX - (mouseX - pan.x) * (newZoom / zoom),
236
+ y: mouseY - (mouseY - pan.y) * (newZoom / zoom),
237
+ })
238
+ setZoom(newZoom)
239
+ },
240
+ [zoom, pan],
241
+ )
242
+
243
+ const handleDelete = useCallback(() => {
244
+ if (selectedShapeId) {
245
+ deleteShape(selectedShapeId)
246
+ setSelectedShapeId(null)
247
+ }
248
+ }, [selectedShapeId, deleteShape])
249
+
250
+ return (
251
+ <div data-testid="canvas-view" className={`flex flex-col h-full ${className ?? ''}`}>
252
+ <CanvasToolbar
253
+ activeTool={activeTool}
254
+ activeColor={activeColor}
255
+ hasSelection={selectedShapeId !== null}
256
+ onToolChange={setActiveTool}
257
+ onColorChange={setActiveColor}
258
+ onDelete={handleDelete}
259
+ onUndo={undo}
260
+ onRedo={redo}
261
+ />
262
+
263
+ <div className="relative flex-1 overflow-hidden bg-background">
264
+ {/* Connection status */}
265
+ <div className="absolute top-3 right-3 z-10">
266
+ {connected ? (
267
+ <Badge variant="success" className="text-xs">
268
+ <span className="w-1.5 h-1.5 bg-success rounded-full mr-1" />
269
+ Connected
270
+ </Badge>
271
+ ) : (
272
+ <Badge variant="secondary" className="text-xs">
273
+ <span className="w-1.5 h-1.5 bg-warning rounded-full animate-pulse mr-1" />
274
+ Connecting...
275
+ </Badge>
276
+ )}
277
+ </div>
278
+
279
+ {/* Zoom indicator */}
280
+ <div className="absolute bottom-3 right-3 z-10">
281
+ <span className="text-xs text-muted-foreground bg-card/80 px-2 py-1 rounded border border-border">
282
+ {Math.round(zoom * 100)}%
283
+ </span>
284
+ </div>
285
+
286
+ <svg
287
+ ref={svgRef}
288
+ data-testid="canvas-svg"
289
+ className="w-full h-full"
290
+ style={{
291
+ cursor: activeTool === 'select' ? (isPanning ? 'grabbing' : 'default') : 'crosshair',
292
+ }}
293
+ onMouseDown={handleSvgMouseDown}
294
+ onMouseMove={handleMouseMove}
295
+ onMouseUp={handleMouseUp}
296
+ onMouseLeave={handleMouseUp}
297
+ onWheel={handleWheel}
298
+ >
299
+ {/* Grid background */}
300
+ <defs>
301
+ <pattern
302
+ id="grid"
303
+ width={20 * zoom}
304
+ height={20 * zoom}
305
+ patternUnits="userSpaceOnUse"
306
+ x={pan.x % (20 * zoom)}
307
+ y={pan.y % (20 * zoom)}
308
+ >
309
+ <circle cx={1} cy={1} r={0.5} fill="currentColor" className="text-border" />
310
+ </pattern>
311
+ </defs>
312
+ <rect width="100%" height="100%" fill="url(#grid)" />
313
+
314
+ {/* Canvas content with pan/zoom transform */}
315
+ <g transform={`translate(${pan.x}, ${pan.y}) scale(${zoom})`}>
316
+ {/* Shapes */}
317
+ {shapes.map((shape) => (
318
+ <ShapeRenderer
319
+ key={shape.id}
320
+ shape={shape}
321
+ isSelected={shape.id === selectedShapeId}
322
+ onMouseDown={handleShapeMouseDown}
323
+ />
324
+ ))}
325
+
326
+ {/* Other users' cursors */}
327
+ {viewports.map((vp, i) => (
328
+ <g key={vp.userId} pointerEvents="none">
329
+ {/* Cursor dot */}
330
+ <circle
331
+ cx={vp.x + vp.width / 2}
332
+ cy={vp.y + 20}
333
+ r={4 / zoom}
334
+ fill={CURSOR_COLORS[i % CURSOR_COLORS.length]}
335
+ />
336
+ {/* User label */}
337
+ <text
338
+ x={vp.x + vp.width / 2 + 8 / zoom}
339
+ y={vp.y + 24 / zoom}
340
+ fontSize={11 / zoom}
341
+ fill={CURSOR_COLORS[i % CURSOR_COLORS.length]}
342
+ fontFamily="system-ui, sans-serif"
343
+ >
344
+ User {vp.userId.slice(0, 6)}
345
+ </text>
346
+ </g>
347
+ ))}
348
+ </g>
349
+
350
+ {/* Drag preview for shape creation */}
351
+ {dragState?.type === 'create' && (
352
+ <rect
353
+ x={0}
354
+ y={0}
355
+ width={0}
356
+ height={0}
357
+ fill="transparent"
358
+ stroke={activeColor}
359
+ strokeWidth={1}
360
+ strokeDasharray="4 3"
361
+ pointerEvents="none"
362
+ style={{ display: 'none' }}
363
+ />
364
+ )}
365
+ </svg>
366
+ </div>
367
+ </div>
368
+ )
369
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * ShapeRenderer — Renders individual shapes as SVG elements.
3
+ *
4
+ * Supports: rect, ellipse, line, text
5
+ */
6
+
7
+ import type { CanvasShapeClient } from 'deepspace'
8
+
9
+ interface ShapeRendererProps {
10
+ shape: CanvasShapeClient
11
+ isSelected: boolean
12
+ onMouseDown: (e: React.MouseEvent, shapeId: string) => void
13
+ }
14
+
15
+ const SELECTION_PADDING = 4
16
+
17
+ export function ShapeRenderer({ shape, isSelected, onMouseDown }: ShapeRendererProps) {
18
+ const fill = (shape.props.fill as string) ?? 'transparent'
19
+ const stroke = (shape.props.stroke as string) ?? '#6366f1'
20
+ const strokeWidth = (shape.props.strokeWidth as number) ?? 2
21
+
22
+ const handleMouseDown = (e: React.MouseEvent) => {
23
+ e.stopPropagation()
24
+ onMouseDown(e, shape.id)
25
+ }
26
+
27
+ return (
28
+ <g
29
+ data-testid={`shape-${shape.id}`}
30
+ onMouseDown={handleMouseDown}
31
+ style={{ cursor: 'move' }}
32
+ >
33
+ {/* Selection outline */}
34
+ {isSelected && (
35
+ <rect
36
+ x={shape.x - SELECTION_PADDING}
37
+ y={shape.y - SELECTION_PADDING}
38
+ width={shape.width + SELECTION_PADDING * 2}
39
+ height={shape.height + SELECTION_PADDING * 2}
40
+ fill="none"
41
+ style={{ stroke: 'var(--color-primary)' }}
42
+ strokeWidth={1.5}
43
+ strokeDasharray="4 3"
44
+ rx={2}
45
+ pointerEvents="none"
46
+ />
47
+ )}
48
+
49
+ {/* Shape */}
50
+ {shape.type === 'rect' && (
51
+ <rect
52
+ x={shape.x}
53
+ y={shape.y}
54
+ width={shape.width}
55
+ height={shape.height}
56
+ fill={fill}
57
+ stroke={stroke}
58
+ strokeWidth={strokeWidth}
59
+ rx={4}
60
+ />
61
+ )}
62
+
63
+ {shape.type === 'ellipse' && (
64
+ <ellipse
65
+ cx={shape.x + shape.width / 2}
66
+ cy={shape.y + shape.height / 2}
67
+ rx={shape.width / 2}
68
+ ry={shape.height / 2}
69
+ fill={fill}
70
+ stroke={stroke}
71
+ strokeWidth={strokeWidth}
72
+ />
73
+ )}
74
+
75
+ {shape.type === 'line' && (
76
+ <line
77
+ x1={shape.x}
78
+ y1={shape.y}
79
+ x2={shape.x + shape.width}
80
+ y2={shape.y + shape.height}
81
+ stroke={stroke}
82
+ strokeWidth={strokeWidth}
83
+ strokeLinecap="round"
84
+ />
85
+ )}
86
+
87
+ {shape.type === 'text' && (
88
+ <>
89
+ <rect
90
+ x={shape.x}
91
+ y={shape.y}
92
+ width={shape.width}
93
+ height={shape.height}
94
+ fill={fill === 'transparent' ? 'transparent' : fill}
95
+ stroke={isSelected ? stroke : 'transparent'}
96
+ strokeWidth={isSelected ? strokeWidth : 0}
97
+ rx={2}
98
+ />
99
+ <text
100
+ x={shape.x + 8}
101
+ y={shape.y + shape.height / 2}
102
+ dominantBaseline="central"
103
+ fill={stroke}
104
+ fontSize={14}
105
+ fontFamily="system-ui, sans-serif"
106
+ pointerEvents="none"
107
+ >
108
+ {(shape.props.text as string) ?? 'Text'}
109
+ </text>
110
+ </>
111
+ )}
112
+ </g>
113
+ )
114
+ }
@@ -0,0 +1,33 @@
1
+ {
2
+ "id": "cron",
3
+ "name": "Cron Heartbeat",
4
+ "category": "data",
5
+ "description": "Scheduled background tasks via the per-app CronRoom DO",
6
+ "details": "Reference implementation of a working cron job. Registers a 1-minute 'heartbeat' task in src/cron.ts (consumed by AppCronRoom at construction time). Each fire is logged to the DO's cron_history table; /cron-log subscribes via useCronMonitor and renders live history. Useful both as a working example and as the basis for the cron e2e spec.",
7
+ "files": [
8
+ {
9
+ "src": "src/cron.ts",
10
+ "dest": "src/cron.ts",
11
+ "overwrite": true
12
+ },
13
+ {
14
+ "src": "src/CronLogPage.tsx",
15
+ "dest": "src/pages/cron-log.tsx"
16
+ }
17
+ ],
18
+ "route": {
19
+ "path": "/cron-log",
20
+ "protected": false
21
+ },
22
+ "requires": [],
23
+ "instructions": [
24
+ "Deploy the app: npm run deploy",
25
+ "Within ~90s, visit /cron-log on the deployed app — you should see one new row per minute as the AppCronRoom alarm fires the heartbeat task.",
26
+ "Open the page on a desktop browser and look at devtools → Network → WS → /ws/cron/app:<APP_NAME> to see live cron.* protocol frames."
27
+ ],
28
+ "patterns": [
29
+ "Task config lives in code (src/cron.ts), not JSON. AppCronRoom validates at construction time.",
30
+ "Per-app CronRoom DO shards work; no global KV bottleneck. CPU is correctly attributed to the app owner via the tail-worker.",
31
+ "Heartbeat tasks (intervalMinutes: 1) are useful as low-cost liveness probes for cron infrastructure."
32
+ ]
33
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Cron Log Page
3
+ *
4
+ * Subscribes to the app's CronRoom via `useCronMonitor` and renders live
5
+ * task state + execution history. The DO is keyed by `app:${APP_NAME}` so
6
+ * a single shared CronRoom DO instance backs the whole app — same pattern
7
+ * as RecordRoom.
8
+ *
9
+ * Used both as a UI surface for verifying that scheduled tasks are firing
10
+ * in production, and as the data source for the cron e2e spec at
11
+ * tests/feature-tests/tests/cron.spec.ts.
12
+ */
13
+
14
+ import { useCronMonitor } from 'deepspace'
15
+ import { SCOPE_ID } from '../constants'
16
+
17
+ export default function CronLogPage() {
18
+ const { tasks, history, connected } = useCronMonitor(SCOPE_ID)
19
+
20
+ // Newest first.
21
+ const sorted = [...history].sort((a, b) => {
22
+ return new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()
23
+ })
24
+
25
+ return (
26
+ <div data-testid="cron-log-page" style={{ padding: 24 }}>
27
+ <h1>Cron Log</h1>
28
+ <p>
29
+ Each row is one tick of a scheduled task fired by the AppCronRoom DO.
30
+ Heartbeat ticks every minute once the DO alarm picks up the registered
31
+ config.
32
+ </p>
33
+
34
+ <div data-testid="cron-log-status" style={{ marginBottom: 12 }}>
35
+ Connection: {connected ? 'live' : 'connecting…'}
36
+ </div>
37
+
38
+ <h2>Tasks</h2>
39
+ <table data-testid="cron-tasks" style={{ borderCollapse: 'collapse', width: '100%' }}>
40
+ <thead>
41
+ <tr>
42
+ <th style={{ textAlign: 'left', borderBottom: '1px solid var(--color-border)', padding: 8 }}>Name</th>
43
+ <th style={{ textAlign: 'left', borderBottom: '1px solid var(--color-border)', padding: 8 }}>Schedule</th>
44
+ <th style={{ textAlign: 'left', borderBottom: '1px solid var(--color-border)', padding: 8 }}>Last run</th>
45
+ <th style={{ textAlign: 'left', borderBottom: '1px solid var(--color-border)', padding: 8 }}>Next run</th>
46
+ </tr>
47
+ </thead>
48
+ <tbody>
49
+ {tasks.map((task) => (
50
+ <tr key={task.name} data-testid="cron-task-row" data-task={task.name}>
51
+ <td style={{ padding: 8 }}>{task.name}</td>
52
+ <td style={{ padding: 8 }}>
53
+ {task.schedule
54
+ ? `${task.schedule} (${task.timezone ?? 'UTC'})`
55
+ : `every ${task.intervalMinutes ?? '?'} min`}
56
+ </td>
57
+ <td style={{ padding: 8 }}>{task.lastRunAt ?? '—'}</td>
58
+ <td style={{ padding: 8 }}>{task.nextRunAt ?? '—'}</td>
59
+ </tr>
60
+ ))}
61
+ </tbody>
62
+ </table>
63
+
64
+ <h2 style={{ marginTop: 24 }}>History</h2>
65
+ <div data-testid="cron-log-count">{sorted.length} entries</div>
66
+
67
+ <table style={{ marginTop: 16, borderCollapse: 'collapse', width: '100%' }}>
68
+ <thead>
69
+ <tr>
70
+ <th style={{ textAlign: 'left', borderBottom: '1px solid var(--color-border)', padding: 8 }}>Task</th>
71
+ <th style={{ textAlign: 'left', borderBottom: '1px solid var(--color-border)', padding: 8 }}>Started (UTC)</th>
72
+ <th style={{ textAlign: 'left', borderBottom: '1px solid var(--color-border)', padding: 8 }}>Duration</th>
73
+ <th style={{ textAlign: 'left', borderBottom: '1px solid var(--color-border)', padding: 8 }}>Outcome</th>
74
+ </tr>
75
+ </thead>
76
+ <tbody data-testid="cron-log-rows">
77
+ {sorted.map((entry, idx) => (
78
+ <tr
79
+ key={`${entry.taskName}-${entry.startedAt}-${idx}`}
80
+ data-testid="cron-log-row"
81
+ data-task={entry.taskName}
82
+ data-success={entry.success ? '1' : '0'}
83
+ >
84
+ <td style={{ padding: 8 }}>{entry.taskName}</td>
85
+ <td style={{ padding: 8 }}>{entry.startedAt}</td>
86
+ <td style={{ padding: 8 }}>{entry.durationMs} ms</td>
87
+ <td style={{ padding: 8 }}>{entry.success ? 'ok' : `error: ${entry.error ?? '?'}`}</td>
88
+ </tr>
89
+ ))}
90
+ </tbody>
91
+ </table>
92
+
93
+ {sorted.length === 0 && connected && (
94
+ <p data-testid="cron-log-empty" style={{ marginTop: 24, opacity: 0.6 }}>
95
+ No cron ticks recorded yet. The first one should appear within ~90s of deploy.
96
+ </p>
97
+ )}
98
+ </div>
99
+ )
100
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Cron task definitions for the heartbeat reference feature.
3
+ *
4
+ * AppCronRoom (declared in worker.ts) reads `tasks` at construction time
5
+ * and validates them. The DO alarm fires `runTask(name, env)` on the
6
+ * configured schedule; each fire is recorded in the DO's `cron_history`
7
+ * table and pushed to subscribers over `/ws/cron/:roomId`.
8
+ *
9
+ * The heartbeat task here is the low-cost liveness probe used by both
10
+ * the cron e2e spec and as a quick sanity check after a deploy. To add
11
+ * your own task, append a {name, intervalMinutes} or {name, schedule,
12
+ * timezone} entry to `tasks` and dispatch on `name` inside `runTask`.
13
+ */
14
+
15
+ import type { CronTask } from 'deepspace/worker'
16
+
17
+ export const tasks: CronTask[] = [
18
+ { name: 'heartbeat', intervalMinutes: 1 },
19
+ ]
20
+
21
+ export async function runTask(_name: string, _env: unknown): Promise<void> {
22
+ // No-op work — the DO records every invocation in cron_history regardless,
23
+ // which is what the e2e spec asserts against. Real apps would dispatch on
24
+ // `_name` here and call into integrations / records via buildCronContext.
25
+ }