jettypod 4.4.118 → 4.4.121
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.
- package/.env +4 -3
- package/Cargo.lock +6450 -0
- package/Cargo.toml +35 -0
- package/README.md +5 -1
- package/TAURI-MIGRATION-PLAN.md +840 -0
- package/apps/dashboard/app/connect-claude/page.tsx +5 -6
- package/apps/dashboard/app/decision/[id]/page.tsx +63 -58
- package/apps/dashboard/app/demo/gates/page.tsx +43 -45
- package/apps/dashboard/app/design-system/page.tsx +868 -0
- package/apps/dashboard/app/globals.css +80 -4
- package/apps/dashboard/app/install-claude/page.tsx +4 -6
- package/apps/dashboard/app/login/page.tsx +72 -54
- package/apps/dashboard/app/page.tsx +101 -48
- package/apps/dashboard/app/settings/page.tsx +61 -13
- package/apps/dashboard/app/signup/page.tsx +242 -0
- package/apps/dashboard/app/subscribe/page.tsx +0 -2
- package/apps/dashboard/app/tests/page.tsx +37 -4
- package/apps/dashboard/app/welcome/page.tsx +13 -16
- package/apps/dashboard/app/work/[id]/page.tsx +117 -118
- package/apps/dashboard/app/work/[id]/proof/page.tsx +1489 -0
- package/apps/dashboard/components/AppShell.tsx +92 -85
- package/apps/dashboard/components/CardMenu.tsx +45 -12
- package/apps/dashboard/components/ClaudePanel.tsx +771 -850
- package/apps/dashboard/components/ClaudePanelInput.tsx +43 -15
- package/apps/dashboard/components/ConnectClaudeScreen.tsx +17 -34
- package/apps/dashboard/components/CopyableId.tsx +3 -4
- package/apps/dashboard/components/DetailReviewActions.tsx +100 -0
- package/apps/dashboard/components/DragContext.tsx +134 -63
- package/apps/dashboard/components/DraggableCard.tsx +3 -5
- package/apps/dashboard/components/DropZone.tsx +6 -7
- package/apps/dashboard/components/EditableDetailDescription.tsx +7 -13
- package/apps/dashboard/components/EditableDetailTitle.tsx +6 -13
- package/apps/dashboard/components/EditableTitle.tsx +26 -7
- package/apps/dashboard/components/ElapsedTimer.tsx +66 -0
- package/apps/dashboard/components/EpicGroup.tsx +359 -0
- package/apps/dashboard/components/GateCard.tsx +79 -17
- package/apps/dashboard/components/GateChoiceCard.tsx +15 -18
- package/apps/dashboard/components/InstallClaudeScreen.tsx +15 -32
- package/apps/dashboard/components/JettyLoader.tsx +37 -0
- package/apps/dashboard/components/KanbanBoard.tsx +368 -958
- package/apps/dashboard/components/KanbanCard.tsx +740 -0
- package/apps/dashboard/components/LazyCard.tsx +62 -0
- package/apps/dashboard/components/LazyMarkdown.tsx +11 -0
- package/apps/dashboard/components/MainNav.tsx +38 -73
- package/apps/dashboard/components/MessageBlock.tsx +468 -0
- package/apps/dashboard/components/ModeStartCard.tsx +15 -16
- package/apps/dashboard/components/OnboardingWelcome.tsx +213 -0
- package/apps/dashboard/components/PlaceholderCard.tsx +3 -4
- package/apps/dashboard/components/ProjectSwitcher.tsx +30 -30
- package/apps/dashboard/components/PrototypeTimeline.tsx +72 -51
- package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +406 -388
- package/apps/dashboard/components/RealTimeTestsWrapper.tsx +373 -235
- package/apps/dashboard/components/ReviewFooter.tsx +139 -0
- package/apps/dashboard/components/SessionList.tsx +19 -19
- package/apps/dashboard/components/SubscribeContent.tsx +91 -47
- package/apps/dashboard/components/TestTree.tsx +16 -16
- package/apps/dashboard/components/TipCard.tsx +16 -17
- package/apps/dashboard/components/Toast.tsx +5 -6
- package/apps/dashboard/components/TypeIcon.tsx +55 -0
- package/apps/dashboard/components/ViewModeToolbar.tsx +104 -0
- package/apps/dashboard/components/WaveCompletionAnimation.tsx +52 -65
- package/apps/dashboard/components/WelcomeScreen.tsx +19 -35
- package/apps/dashboard/components/WorkItemHeader.tsx +4 -5
- package/apps/dashboard/components/WorkItemTree.tsx +11 -32
- package/apps/dashboard/components/settings/AccountSection.tsx +55 -35
- package/apps/dashboard/components/settings/AiContextSection.tsx +89 -0
- package/apps/dashboard/components/settings/ContextDocumentsSection.tsx +317 -0
- package/apps/dashboard/components/settings/EnvVarsSection.tsx +74 -152
- package/apps/dashboard/components/settings/GeneralSection.tsx +162 -56
- package/apps/dashboard/components/settings/ProjectStackSection.tsx +948 -0
- package/apps/dashboard/components/settings/SettingsLayout.tsx +4 -5
- package/apps/dashboard/components/ui/Button.tsx +104 -0
- package/apps/dashboard/components/ui/Input.tsx +78 -0
- package/apps/dashboard/components.json +1 -1
- package/apps/dashboard/contexts/ClaudeSessionContext.tsx +711 -418
- package/apps/dashboard/contexts/ConnectionStatusContext.tsx +25 -5
- package/apps/dashboard/contexts/UsageContext.tsx +87 -32
- package/apps/dashboard/dev.sh +35 -0
- package/apps/dashboard/eslint.config.mjs +9 -9
- package/apps/dashboard/hooks/useKanbanAnimation.ts +29 -0
- package/apps/dashboard/hooks/useKanbanUndo.ts +83 -0
- package/apps/dashboard/hooks/useWebSocket.ts +138 -83
- package/apps/dashboard/index.html +73 -0
- package/apps/dashboard/lib/constants.ts +43 -0
- package/apps/dashboard/lib/data-bridge.ts +722 -0
- package/apps/dashboard/lib/db.ts +69 -1265
- package/apps/dashboard/lib/environment-config.ts +173 -0
- package/apps/dashboard/lib/environment-verification.ts +119 -0
- package/apps/dashboard/lib/kanban-utils.ts +270 -0
- package/apps/dashboard/lib/proof-run.ts +495 -0
- package/apps/dashboard/lib/proof-scenario-runner.ts +346 -0
- package/apps/dashboard/lib/run-migrations.js +27 -2
- package/apps/dashboard/lib/service-recovery.ts +326 -0
- package/apps/dashboard/lib/session-state-machine.ts +1 -0
- package/apps/dashboard/lib/session-state-utils.ts +0 -164
- package/apps/dashboard/lib/session-stream-manager.ts +308 -134
- package/apps/dashboard/lib/shadows.ts +7 -0
- package/apps/dashboard/lib/stream-manager-registry.ts +46 -6
- package/apps/dashboard/lib/tauri-bridge.ts +102 -0
- package/apps/dashboard/lib/tauri.ts +106 -0
- package/apps/dashboard/lib/utils.ts +6 -0
- package/apps/dashboard/next-env.d.ts +1 -1
- package/apps/dashboard/package.json +21 -32
- package/apps/dashboard/public/bug-icon.png +0 -0
- package/apps/dashboard/public/buoy-icon.png +0 -0
- package/apps/dashboard/public/fonts/Satoshi-Variable.woff2 +0 -0
- package/apps/dashboard/public/fonts/Satoshi-VariableItalic.woff2 +0 -0
- package/apps/dashboard/public/in-flight-seagull.png +0 -0
- package/apps/dashboard/public/jetty-icon-loading-alt.svg +11 -0
- package/apps/dashboard/public/jetty-icon-loading.svg +11 -0
- package/apps/dashboard/public/jettypod_logo.png +0 -0
- package/apps/dashboard/public/pier-icon.png +0 -0
- package/apps/dashboard/public/star-icon.png +0 -0
- package/apps/dashboard/public/wrench-icon.png +0 -0
- package/apps/dashboard/scripts/tauri-build.js +228 -0
- package/apps/dashboard/scripts/upload-tauri-to-r2.js +125 -0
- package/apps/dashboard/scripts/ws-server.js +191 -0
- package/apps/dashboard/src/main.tsx +12 -0
- package/apps/dashboard/src/router.tsx +107 -0
- package/apps/dashboard/src/vite-env.d.ts +1 -0
- package/apps/dashboard/tsconfig.json +7 -12
- package/apps/dashboard/tsconfig.tsbuildinfo +1 -1
- package/apps/dashboard/vite.config.ts +33 -0
- package/apps/update-server/src/index.ts +228 -80
- package/claude-hooks/global-guardrails.js +14 -13
- package/crates/jettypod-cli/Cargo.toml +19 -0
- package/crates/jettypod-cli/src/commands.rs +1249 -0
- package/crates/jettypod-cli/src/main.rs +595 -0
- package/crates/jettypod-core/Cargo.toml +26 -0
- package/crates/jettypod-core/build.rs +98 -0
- package/crates/jettypod-core/migrations/V1__baseline.sql +197 -0
- package/crates/jettypod-core/migrations/V2__work_items_indexes.sql +6 -0
- package/crates/jettypod-core/migrations/V3__qa_steps.sql +2 -0
- package/crates/jettypod-core/src/auth.rs +294 -0
- package/crates/jettypod-core/src/config.rs +397 -0
- package/crates/jettypod-core/src/db/mod.rs +507 -0
- package/crates/jettypod-core/src/db/recovery.rs +114 -0
- package/crates/jettypod-core/src/db/startup.rs +101 -0
- package/crates/jettypod-core/src/db/validate.rs +149 -0
- package/crates/jettypod-core/src/error.rs +76 -0
- package/crates/jettypod-core/src/git.rs +458 -0
- package/crates/jettypod-core/src/lib.rs +20 -0
- package/crates/jettypod-core/src/sessions.rs +625 -0
- package/crates/jettypod-core/src/skills.rs +556 -0
- package/crates/jettypod-core/src/work.rs +1086 -0
- package/crates/jettypod-core/src/worktree.rs +628 -0
- package/crates/jettypod-core/src/ws.rs +767 -0
- package/cucumber-test.cjs +6 -0
- package/cucumber.js +9 -3
- package/docs/COMMAND_REFERENCE.md +34 -0
- package/hooks/post-checkout +32 -75
- package/hooks/post-merge +111 -10
- package/jest.setup.js +1 -0
- package/jettypod.js +145 -116
- package/lib/bdd-preflight.js +96 -0
- package/lib/chore-taxonomy.js +33 -10
- package/lib/database.js +36 -16
- package/lib/db-watcher.js +1 -1
- package/lib/git-hooks/pre-commit +1 -1
- package/lib/jettypod-backup.js +27 -4
- package/lib/merge-lock.js +111 -253
- package/lib/migrations/027-plan-at-creation-column.js +3 -1
- package/lib/migrations/029-remove-autoincrement.js +307 -0
- package/lib/migrations/029-rename-corrupted-to-cleaned.js +149 -0
- package/lib/migrations/030-rejection-round-columns.js +54 -0
- package/lib/migrations/031-session-isolation-index.js +17 -0
- package/lib/migrations/index.js +47 -4
- package/lib/schema.js +10 -5
- package/lib/seed-onboarding.js +1 -1
- package/lib/update-command/index.js +9 -175
- package/lib/work-commands/index.js +144 -19
- package/lib/work-tracking/index.js +148 -27
- package/lib/worktree-diagnostics.js +16 -16
- package/lib/worktree-facade.js +1 -1
- package/lib/worktree-manager.js +8 -8
- package/lib/worktree-reconciler.js +5 -5
- package/package.json +9 -2
- package/scripts/ndjson-to-cucumber-json.js +152 -0
- package/scripts/postinstall.js +25 -0
- package/skills-templates/bug-mode/SKILL.md +79 -20
- package/skills-templates/bug-planning/SKILL.md +25 -29
- package/skills-templates/chore-mode/SKILL.md +171 -69
- package/skills-templates/chore-mode/verification.js +51 -10
- package/skills-templates/chore-planning/SKILL.md +47 -18
- package/skills-templates/design-system-selection/SKILL.md +273 -0
- package/skills-templates/epic-planning/SKILL.md +82 -48
- package/skills-templates/external-transition/SKILL.md +47 -47
- package/skills-templates/feature-planning/SKILL.md +173 -74
- package/skills-templates/production-mode/SKILL.md +69 -49
- package/skills-templates/request-routing/SKILL.md +4 -4
- package/skills-templates/simple-improvement/SKILL.md +74 -29
- package/skills-templates/speed-mode/SKILL.md +217 -141
- package/skills-templates/stable-mode/SKILL.md +148 -89
- package/apps/dashboard/README.md +0 -36
- package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +0 -386
- package/apps/dashboard/app/api/claude/[workItemId]/pin/route.ts +0 -24
- package/apps/dashboard/app/api/claude/[workItemId]/route.ts +0 -167
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/content/route.ts +0 -52
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +0 -378
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/pin/route.ts +0 -24
- package/apps/dashboard/app/api/claude/sessions/cleanup/route.ts +0 -34
- package/apps/dashboard/app/api/claude/sessions/route.ts +0 -184
- package/apps/dashboard/app/api/decisions/[id]/route.ts +0 -25
- package/apps/dashboard/app/api/internal/set-project/route.ts +0 -17
- package/apps/dashboard/app/api/kanban/route.ts +0 -15
- package/apps/dashboard/app/api/settings/env-vars/route.ts +0 -125
- package/apps/dashboard/app/api/settings/general/route.ts +0 -21
- package/apps/dashboard/app/api/tests/route.ts +0 -9
- package/apps/dashboard/app/api/tests/run/route.ts +0 -82
- package/apps/dashboard/app/api/tests/run/stream/route.ts +0 -71
- package/apps/dashboard/app/api/tests/undefined/route.ts +0 -9
- package/apps/dashboard/app/api/usage/route.ts +0 -17
- package/apps/dashboard/app/api/work/[id]/description/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/epic/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/order/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/status/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/title/route.ts +0 -21
- package/apps/dashboard/app/layout.tsx +0 -43
- package/apps/dashboard/components/UpgradeBanner.tsx +0 -29
- package/apps/dashboard/electron/ipc-handlers.js +0 -1028
- package/apps/dashboard/electron/main.js +0 -2124
- package/apps/dashboard/electron/preload.js +0 -123
- package/apps/dashboard/electron/session-manager.js +0 -141
- package/apps/dashboard/electron-builder.config.js +0 -357
- package/apps/dashboard/hooks/useClaudeSessions.ts +0 -299
- package/apps/dashboard/lib/claude-process-manager.ts +0 -492
- package/apps/dashboard/lib/db-bridge.ts +0 -282
- package/apps/dashboard/lib/prototypes.ts +0 -202
- package/apps/dashboard/lib/test-results-db.ts +0 -307
- package/apps/dashboard/lib/tests.ts +0 -282
- package/apps/dashboard/next.config.js +0 -50
- package/apps/dashboard/postcss.config.mjs +0 -7
- package/apps/dashboard/public/file.svg +0 -1
- package/apps/dashboard/public/globe.svg +0 -1
- package/apps/dashboard/public/next.svg +0 -1
- package/apps/dashboard/public/vercel.svg +0 -1
- package/apps/dashboard/public/window.svg +0 -1
- package/apps/dashboard/scripts/download-node.js +0 -104
- package/apps/dashboard/scripts/upload-to-r2.js +0 -89
- package/docs/bdd-guidance.md +0 -390
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
'use client';
|
|
2
1
|
|
|
3
2
|
import { createContext, useContext, useState, useCallback, useRef, useEffect, ReactNode } from 'react';
|
|
4
3
|
import {
|
|
@@ -17,6 +16,7 @@ import {
|
|
|
17
16
|
} from '@dnd-kit/core';
|
|
18
17
|
import { sortableKeyboardCoordinates } from '@dnd-kit/sortable';
|
|
19
18
|
import type { WorkItem } from '@/lib/db';
|
|
19
|
+
import { shadow } from '@/lib/shadows';
|
|
20
20
|
|
|
21
21
|
type DropHandler = (itemId: number, newStatus: string) => Promise<void>;
|
|
22
22
|
type ReorderHandler = (itemId: number, pointerY: number) => Promise<void>;
|
|
@@ -53,6 +53,8 @@ interface DragContextType {
|
|
|
53
53
|
registerEpicDropZone: (id: string, info: EpicDropZoneInfo) => void;
|
|
54
54
|
unregisterEpicDropZone: (id: string) => void;
|
|
55
55
|
getCardPositions: () => CardPosition[];
|
|
56
|
+
/** Ref to current pointer position — read by EpicGroups for insertion preview without per-group listeners */
|
|
57
|
+
pointerPositionRef: React.RefObject<{ x: number; y: number }>;
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
const DragContext = createContext<DragContextType>({
|
|
@@ -66,6 +68,7 @@ const DragContext = createContext<DragContextType>({
|
|
|
66
68
|
registerEpicDropZone: () => {},
|
|
67
69
|
unregisterEpicDropZone: () => {},
|
|
68
70
|
getCardPositions: () => [],
|
|
71
|
+
pointerPositionRef: { current: { x: 0, y: 0 } },
|
|
69
72
|
});
|
|
70
73
|
|
|
71
74
|
interface DragProviderProps {
|
|
@@ -114,6 +117,13 @@ export function DragProvider({ children, renderDragOverlay, onRemoveFromEpic, on
|
|
|
114
117
|
const epicDropZonesRef = useRef<Map<string, EpicDropZoneInfo>>(new Map());
|
|
115
118
|
const draggedItemRef = useRef<WorkItem | null>(null);
|
|
116
119
|
const pointerPositionRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
|
|
120
|
+
// Generation counter — prevents stale async handleDragEnd finally blocks
|
|
121
|
+
// from clearing state that belongs to a newer drag operation.
|
|
122
|
+
const dragGenRef = useRef(0);
|
|
123
|
+
|
|
124
|
+
// Ref mirrors for change detection — prevents re-renders when zone hasn't changed
|
|
125
|
+
const activeDropZoneRef = useRef<string | null>(null);
|
|
126
|
+
const activeEpicZoneRef = useRef<string | null>(null);
|
|
117
127
|
|
|
118
128
|
const sensors = useSensors(
|
|
119
129
|
useSensor(PointerSensor, {
|
|
@@ -147,9 +157,18 @@ export function DragProvider({ children, renderDragOverlay, onRemoveFromEpic, on
|
|
|
147
157
|
epicDropZonesRef.current.delete(id);
|
|
148
158
|
}, []);
|
|
149
159
|
|
|
150
|
-
// Read fresh card positions from DOM
|
|
151
|
-
//
|
|
160
|
+
// Read fresh card positions from DOM — cached for 150ms so multiple
|
|
161
|
+
// EpicGroups reading positions across renders reuse the same measurements.
|
|
162
|
+
const positionCacheRef = useRef<{ frame: number; positions: CardPosition[] } | null>(null);
|
|
152
163
|
const getCardPositions = useCallback((): CardPosition[] => {
|
|
164
|
+
const frame = performance.now();
|
|
165
|
+
// Reuse cached positions within 150ms — card positions don't change
|
|
166
|
+
// meaningfully during a drag gesture. Matches EpicGroup's rAF throttle
|
|
167
|
+
// interval so querySelectorAll + getBoundingClientRect only runs once
|
|
168
|
+
// per visible update, avoiding layout thrashing on slower hardware.
|
|
169
|
+
if (positionCacheRef.current && frame - positionCacheRef.current.frame < 150) {
|
|
170
|
+
return positionCacheRef.current.positions;
|
|
171
|
+
}
|
|
153
172
|
const positions: CardPosition[] = [];
|
|
154
173
|
const elements = document.querySelectorAll<HTMLElement>('[data-item-id]');
|
|
155
174
|
elements.forEach((el) => {
|
|
@@ -158,6 +177,7 @@ export function DragProvider({ children, renderDragOverlay, onRemoveFromEpic, on
|
|
|
158
177
|
positions.push({ id, rect: el.getBoundingClientRect() });
|
|
159
178
|
}
|
|
160
179
|
});
|
|
180
|
+
positionCacheRef.current = { frame, positions };
|
|
161
181
|
return positions;
|
|
162
182
|
}, []);
|
|
163
183
|
|
|
@@ -169,6 +189,7 @@ export function DragProvider({ children, renderDragOverlay, onRemoveFromEpic, on
|
|
|
169
189
|
const handleDragStart = useCallback((event: DragStartEvent) => {
|
|
170
190
|
const item = event.active.data.current?.item as WorkItem | undefined;
|
|
171
191
|
if (item) {
|
|
192
|
+
dragGenRef.current += 1;
|
|
172
193
|
setDraggedItem(item);
|
|
173
194
|
draggedItemRef.current = item;
|
|
174
195
|
}
|
|
@@ -203,44 +224,53 @@ export function DragProvider({ children, renderDragOverlay, onRemoveFromEpic, on
|
|
|
203
224
|
}
|
|
204
225
|
|
|
205
226
|
const overId = String(over.id);
|
|
227
|
+
let newDropZone: string | null = null;
|
|
228
|
+
let newEpicZone: string | null = null;
|
|
229
|
+
const px = pointerPositionRef.current.x;
|
|
230
|
+
const py = pointerPositionRef.current.y;
|
|
206
231
|
|
|
207
|
-
// Check if it's an epic zone
|
|
208
232
|
if (overId.startsWith('epic-')) {
|
|
209
|
-
|
|
210
|
-
//
|
|
211
|
-
|
|
212
|
-
dropZonesRef.current.forEach((info, id) => {
|
|
233
|
+
newEpicZone = overId;
|
|
234
|
+
// Check which nested status zone the pointer is in (live rects)
|
|
235
|
+
for (const [id, info] of dropZonesRef.current) {
|
|
213
236
|
const rect = info.element.getBoundingClientRect();
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
foundStatusZone = id;
|
|
237
|
+
if (px >= rect.left && px <= rect.right && py >= rect.top && py <= rect.bottom) {
|
|
238
|
+
newDropZone = id;
|
|
239
|
+
break;
|
|
218
240
|
}
|
|
219
|
-
}
|
|
220
|
-
setActiveDropZone(foundStatusZone);
|
|
241
|
+
}
|
|
221
242
|
} else if (dropZonesRef.current.has(overId)) {
|
|
222
|
-
|
|
223
|
-
//
|
|
224
|
-
|
|
225
|
-
// also within an epic zone (mirror of status zone check above).
|
|
226
|
-
let foundEpicZone: string | null = null;
|
|
227
|
-
epicDropZonesRef.current.forEach((info, id) => {
|
|
243
|
+
newDropZone = overId;
|
|
244
|
+
// Check which nested epic zone the pointer is in (live rects)
|
|
245
|
+
for (const [id, info] of epicDropZonesRef.current) {
|
|
228
246
|
const rect = info.element.getBoundingClientRect();
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
foundEpicZone = id;
|
|
247
|
+
if (px >= rect.left && px <= rect.right && py >= rect.top && py <= rect.bottom) {
|
|
248
|
+
newEpicZone = id;
|
|
249
|
+
break;
|
|
233
250
|
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Only update state when zone actually changed — prevents redundant re-renders
|
|
255
|
+
// when pointer stays within the same zone across collision events.
|
|
256
|
+
if (newDropZone !== activeDropZoneRef.current) {
|
|
257
|
+
activeDropZoneRef.current = newDropZone;
|
|
258
|
+
setActiveDropZone(newDropZone);
|
|
259
|
+
}
|
|
260
|
+
if (newEpicZone !== activeEpicZoneRef.current) {
|
|
261
|
+
activeEpicZoneRef.current = newEpicZone;
|
|
262
|
+
setActiveEpicZone(newEpicZone);
|
|
239
263
|
}
|
|
240
264
|
}, []);
|
|
241
265
|
|
|
242
266
|
const handleDragEnd = useCallback(async (event: DragEndEvent) => {
|
|
243
267
|
const { over, activatorEvent, delta } = event;
|
|
268
|
+
// Snapshot the current drag generation so the finally block can detect
|
|
269
|
+
// whether a new drag started while we were awaiting the drop handler.
|
|
270
|
+
const endGen = dragGenRef.current;
|
|
271
|
+
|
|
272
|
+
// Clear position cache so drop handlers get fresh DOM measurements
|
|
273
|
+
positionCacheRef.current = null;
|
|
244
274
|
|
|
245
275
|
// Get final pointer position (both x and y needed for epic zone bounds check)
|
|
246
276
|
const pointerEvent = activatorEvent as PointerEvent;
|
|
@@ -268,44 +298,47 @@ export function DragProvider({ children, renderDragOverlay, onRemoveFromEpic, on
|
|
|
268
298
|
const overId = over ? String(over.id) : null;
|
|
269
299
|
const isEpicZoneDrop = overId?.startsWith('epic-') ?? false;
|
|
270
300
|
|
|
271
|
-
//
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
} else if (overId) {
|
|
284
|
-
// Collision detection returned a status zone, but epic zones are nested
|
|
285
|
-
// inside status zones. Check if pointer is actually within an epic zone.
|
|
286
|
-
let resolvedEpicZone: string | null = null;
|
|
301
|
+
// Helper: resolve drop target from pointer position against live DOM rects.
|
|
302
|
+
// Used both when dnd-kit returns a status zone (epic zones are nested) and
|
|
303
|
+
// as a fallback when collision detection misses (over === null). This
|
|
304
|
+
// prevents dropped reorders from being silently swallowed when the pointer
|
|
305
|
+
// is near zone boundaries — especially common when dragging upward.
|
|
306
|
+
const resolveFromPointer = (): { epicZone: string | null; dropZone: string | null } => {
|
|
307
|
+
const px = pointerPositionRef.current.x;
|
|
308
|
+
const py = pointerPositionRef.current.y;
|
|
309
|
+
let epicZone: string | null = null;
|
|
310
|
+
let dropZone: string | null = null;
|
|
287
311
|
epicDropZonesRef.current.forEach((info, id) => {
|
|
288
312
|
const rect = info.element.getBoundingClientRect();
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {
|
|
292
|
-
resolvedEpicZone = id;
|
|
313
|
+
if (px >= rect.left && px <= rect.right && py >= rect.top && py <= rect.bottom) {
|
|
314
|
+
epicZone = id;
|
|
293
315
|
}
|
|
294
316
|
});
|
|
317
|
+
for (const [id, info] of dropZonesRef.current) {
|
|
318
|
+
const rect = info.element.getBoundingClientRect();
|
|
319
|
+
if (px >= rect.left && px <= rect.right && py >= rect.top && py <= rect.bottom) {
|
|
320
|
+
dropZone = id;
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return { epicZone, dropZone };
|
|
325
|
+
};
|
|
295
326
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
327
|
+
// Helper: route a resolved drop to the appropriate handler
|
|
328
|
+
const routeDrop = async (epicZone: string | null, dropZone: string | null) => {
|
|
329
|
+
if (epicZone) {
|
|
330
|
+
const epicZoneInfo = epicDropZonesRef.current.get(epicZone);
|
|
299
331
|
if (epicZoneInfo) {
|
|
300
332
|
if (currentEpicId === epicZoneInfo.epicId && epicZoneInfo.onReorder) {
|
|
301
333
|
await epicZoneInfo.onReorder(item.id, pointerPositionRef.current.y);
|
|
302
334
|
} else if (currentEpicId !== epicZoneInfo.epicId) {
|
|
303
335
|
await epicZoneInfo.onEpicAssign(item.id, epicZoneInfo.epicId);
|
|
304
336
|
}
|
|
337
|
+
return;
|
|
305
338
|
}
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
const zoneInfo = dropZonesRef.current.get(
|
|
339
|
+
}
|
|
340
|
+
if (dropZone) {
|
|
341
|
+
const zoneInfo = dropZonesRef.current.get(dropZone);
|
|
309
342
|
if (zoneInfo) {
|
|
310
343
|
if (item.status !== zoneInfo.targetStatus) {
|
|
311
344
|
await zoneInfo.onDrop(item.id, zoneInfo.targetStatus);
|
|
@@ -314,18 +347,50 @@ export function DragProvider({ children, renderDragOverlay, onRemoveFromEpic, on
|
|
|
314
347
|
}
|
|
315
348
|
}
|
|
316
349
|
}
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
// Check for epic zone drop first (higher precedence)
|
|
353
|
+
if (isEpicZoneDrop && overId) {
|
|
354
|
+
const epicZoneInfo = epicDropZonesRef.current.get(overId);
|
|
355
|
+
if (epicZoneInfo) {
|
|
356
|
+
// Same epic - reorder within epic
|
|
357
|
+
if (currentEpicId === epicZoneInfo.epicId && epicZoneInfo.onReorder) {
|
|
358
|
+
await epicZoneInfo.onReorder(item.id, pointerPositionRef.current.y);
|
|
359
|
+
} else if (currentEpicId !== epicZoneInfo.epicId) {
|
|
360
|
+
// Different epic - assign to new epic
|
|
361
|
+
await epicZoneInfo.onEpicAssign(item.id, epicZoneInfo.epicId);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
} else if (overId) {
|
|
365
|
+
// Collision detection returned a status zone, but epic zones are nested
|
|
366
|
+
// inside status zones. Check pointer against live rects to find the real target.
|
|
367
|
+
const { epicZone, dropZone } = resolveFromPointer();
|
|
368
|
+
await routeDrop(epicZone, dropZone || overId);
|
|
369
|
+
} else {
|
|
370
|
+
// Collision detection missed (over === null) — common when pointer is
|
|
371
|
+
// near zone boundaries during upward drags. Fall back to pointer
|
|
372
|
+
// position against live DOM rects instead of silently dropping the op.
|
|
373
|
+
const { epicZone, dropZone } = resolveFromPointer();
|
|
374
|
+
if (epicZone || dropZone) {
|
|
375
|
+
await routeDrop(epicZone, dropZone);
|
|
376
|
+
}
|
|
317
377
|
}
|
|
318
|
-
// over: null means collision detection missed - treat as no-op.
|
|
319
|
-
// Same gap issue we handle in handleDragOver. Don't remove from epic
|
|
320
|
-
// just because the collision detection had a gap at the moment of drop.
|
|
321
378
|
} catch (error) {
|
|
322
379
|
const errorMessage = error instanceof Error ? error.message : 'Failed to complete drop operation';
|
|
323
380
|
onError?.(errorMessage);
|
|
324
381
|
} finally {
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
382
|
+
// Only clean up if no new drag has started while we were awaiting.
|
|
383
|
+
// A newer handleDragStart bumps dragGenRef, so if they differ,
|
|
384
|
+
// this finally belongs to a stale drag — skip cleanup.
|
|
385
|
+
if (dragGenRef.current === endGen) {
|
|
386
|
+
setDraggedItem(null);
|
|
387
|
+
draggedItemRef.current = null;
|
|
388
|
+
setActiveDropZone(null);
|
|
389
|
+
setActiveEpicZone(null);
|
|
390
|
+
activeDropZoneRef.current = null;
|
|
391
|
+
activeEpicZoneRef.current = null;
|
|
392
|
+
positionCacheRef.current = null;
|
|
393
|
+
}
|
|
329
394
|
}
|
|
330
395
|
}, [onRemoveFromEpic, onError]);
|
|
331
396
|
|
|
@@ -334,6 +399,9 @@ export function DragProvider({ children, renderDragOverlay, onRemoveFromEpic, on
|
|
|
334
399
|
draggedItemRef.current = null;
|
|
335
400
|
setActiveDropZone(null);
|
|
336
401
|
setActiveEpicZone(null);
|
|
402
|
+
activeDropZoneRef.current = null;
|
|
403
|
+
activeEpicZoneRef.current = null;
|
|
404
|
+
positionCacheRef.current = null;
|
|
337
405
|
}, []);
|
|
338
406
|
|
|
339
407
|
// Cancel drag on Escape key
|
|
@@ -363,6 +431,7 @@ export function DragProvider({ children, renderDragOverlay, onRemoveFromEpic, on
|
|
|
363
431
|
registerEpicDropZone,
|
|
364
432
|
unregisterEpicDropZone,
|
|
365
433
|
getCardPositions,
|
|
434
|
+
pointerPositionRef,
|
|
366
435
|
}}
|
|
367
436
|
>
|
|
368
437
|
<DndContext
|
|
@@ -388,9 +457,11 @@ export function DragProvider({ children, renderDragOverlay, onRemoveFromEpic, on
|
|
|
388
457
|
<div
|
|
389
458
|
style={{
|
|
390
459
|
transform: 'scale(1.02)',
|
|
391
|
-
boxShadow:
|
|
460
|
+
boxShadow: shadow.overlay,
|
|
392
461
|
borderRadius: 8,
|
|
393
462
|
overflow: 'hidden',
|
|
463
|
+
WebkitBackfaceVisibility: 'hidden',
|
|
464
|
+
backfaceVisibility: 'hidden',
|
|
394
465
|
}}
|
|
395
466
|
>
|
|
396
467
|
{renderDragOverlay(draggedItem)}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
'use client';
|
|
2
1
|
|
|
3
|
-
import { useRef, useEffect } from 'react';
|
|
2
|
+
import { memo, useRef, useEffect } from 'react';
|
|
4
3
|
import { useDraggable } from '@dnd-kit/core';
|
|
5
4
|
import type { WorkItem } from '@/lib/db';
|
|
6
5
|
import { useDragContext } from './DragContext';
|
|
@@ -11,7 +10,7 @@ interface DraggableCardProps {
|
|
|
11
10
|
disabled?: boolean;
|
|
12
11
|
}
|
|
13
12
|
|
|
14
|
-
export function DraggableCard({ item, children, disabled = false }: DraggableCardProps) {
|
|
13
|
+
export const DraggableCard = memo(function DraggableCard({ item, children, disabled = false }: DraggableCardProps) {
|
|
15
14
|
const { draggedItem, setDraggedItem } = useDragContext();
|
|
16
15
|
const prevDisabledRef = useRef(disabled);
|
|
17
16
|
|
|
@@ -46,7 +45,6 @@ export function DraggableCard({ item, children, disabled = false }: DraggableCar
|
|
|
46
45
|
const style = {
|
|
47
46
|
opacity: isDragging ? 0.2 : 1,
|
|
48
47
|
transition: 'opacity 150ms ease',
|
|
49
|
-
touchAction: 'none' as const,
|
|
50
48
|
};
|
|
51
49
|
|
|
52
50
|
return (
|
|
@@ -62,4 +60,4 @@ export function DraggableCard({ item, children, disabled = false }: DraggableCar
|
|
|
62
60
|
{children}
|
|
63
61
|
</div>
|
|
64
62
|
);
|
|
65
|
-
}
|
|
63
|
+
});
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
'use client';
|
|
2
1
|
|
|
3
|
-
import { useRef, useEffect, useId } from 'react';
|
|
2
|
+
import { memo, useRef, useEffect, useId } from 'react';
|
|
4
3
|
import { useDroppable } from '@dnd-kit/core';
|
|
5
4
|
import { useDragContext } from './DragContext';
|
|
6
5
|
|
|
@@ -16,15 +15,15 @@ interface DropZoneProps {
|
|
|
16
15
|
'data-testid'?: string;
|
|
17
16
|
}
|
|
18
17
|
|
|
19
|
-
export function DropZone({
|
|
18
|
+
export const DropZone = memo(function DropZone({
|
|
20
19
|
targetStatus,
|
|
21
20
|
onDrop,
|
|
22
21
|
onReorder,
|
|
23
22
|
allowReorder = false,
|
|
24
23
|
children,
|
|
25
24
|
className = '',
|
|
26
|
-
highlightClassName = 'ring-2 ring-
|
|
27
|
-
reorderHighlightClassName = 'ring-2 ring-
|
|
25
|
+
highlightClassName = 'ring-2 ring-[#819D9F] bg-[#819D9F]/10 dark:bg-[#819D9F]/20',
|
|
26
|
+
reorderHighlightClassName = 'ring-2 ring-[#E3D985] bg-[#F9F7E8]/50 dark:bg-[#E3D985]/20',
|
|
28
27
|
'data-testid': testId,
|
|
29
28
|
}: DropZoneProps) {
|
|
30
29
|
const { isDragging, draggedItem, activeDropZone, activeEpicZone, registerDropZone, unregisterDropZone } = useDragContext();
|
|
@@ -72,7 +71,7 @@ export function DropZone({
|
|
|
72
71
|
return (
|
|
73
72
|
<div
|
|
74
73
|
ref={setRefs}
|
|
75
|
-
className={`${className} ${isValidTarget && isActive ? activeHighlight : ''} transition-
|
|
74
|
+
className={`${className} ${isValidTarget && isActive ? activeHighlight : ''} transition-[color,background-color] duration-200 ease-out`}
|
|
76
75
|
data-testid={testId}
|
|
77
76
|
data-drop-zone={targetStatus}
|
|
78
77
|
data-is-active={isActive}
|
|
@@ -81,4 +80,4 @@ export function DropZone({
|
|
|
81
80
|
{children}
|
|
82
81
|
</div>
|
|
83
82
|
);
|
|
84
|
-
}
|
|
83
|
+
});
|
|
@@ -1,15 +1,13 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
1
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
|
4
|
-
import {
|
|
2
|
+
import { dataBridge } from '@/lib/data-bridge';
|
|
5
3
|
|
|
6
4
|
interface EditableDetailDescriptionProps {
|
|
7
5
|
description: string | null;
|
|
8
6
|
itemId: number;
|
|
7
|
+
onDescriptionChange?: (newDescription: string) => void;
|
|
9
8
|
}
|
|
10
9
|
|
|
11
|
-
export function EditableDetailDescription({ description, itemId }: EditableDetailDescriptionProps) {
|
|
12
|
-
const router = useRouter();
|
|
10
|
+
export function EditableDetailDescription({ description, itemId, onDescriptionChange }: EditableDetailDescriptionProps) {
|
|
13
11
|
const [isEditing, setIsEditing] = useState(false);
|
|
14
12
|
const [editValue, setEditValue] = useState(description ?? '');
|
|
15
13
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -30,12 +28,8 @@ export function EditableDetailDescription({ description, itemId }: EditableDetai
|
|
|
30
28
|
const newDescription = editValue.trim();
|
|
31
29
|
if (newDescription !== (description ?? '')) {
|
|
32
30
|
try {
|
|
33
|
-
await
|
|
34
|
-
|
|
35
|
-
headers: { 'Content-Type': 'application/json' },
|
|
36
|
-
body: JSON.stringify({ description: newDescription }),
|
|
37
|
-
});
|
|
38
|
-
router.refresh();
|
|
31
|
+
await dataBridge.updateDescription(itemId, newDescription);
|
|
32
|
+
onDescriptionChange?.(newDescription);
|
|
39
33
|
} catch (err) {
|
|
40
34
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
41
35
|
setError(`Failed to save: ${message}`);
|
|
@@ -43,7 +37,7 @@ export function EditableDetailDescription({ description, itemId }: EditableDetai
|
|
|
43
37
|
}
|
|
44
38
|
}
|
|
45
39
|
setIsEditing(false);
|
|
46
|
-
}, [editValue, description, itemId
|
|
40
|
+
}, [editValue, description, itemId]);
|
|
47
41
|
|
|
48
42
|
const handleClick = () => {
|
|
49
43
|
setEditValue(description ?? '');
|
|
@@ -81,7 +75,7 @@ export function EditableDetailDescription({ description, itemId }: EditableDetai
|
|
|
81
75
|
className={`w-full text-zinc-700 dark:text-zinc-300 bg-white dark:bg-zinc-700 border rounded px-2 py-1.5 focus:outline-none focus:ring-2 resize-y ${
|
|
82
76
|
error
|
|
83
77
|
? 'border-red-500 focus:ring-red-500'
|
|
84
|
-
: 'border-zinc-300 dark:border-zinc-600 focus:ring-
|
|
78
|
+
: 'border-zinc-300 dark:border-zinc-600 focus:ring-[#819D9F]'
|
|
85
79
|
}`}
|
|
86
80
|
/>
|
|
87
81
|
{error && (
|
|
@@ -1,25 +1,18 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
1
|
import { useCallback } from 'react';
|
|
4
|
-
import { useRouter } from 'next/navigation';
|
|
5
2
|
import { EditableTitle } from './EditableTitle';
|
|
3
|
+
import { dataBridge } from '@/lib/data-bridge';
|
|
6
4
|
|
|
7
5
|
interface EditableDetailTitleProps {
|
|
8
6
|
title: string;
|
|
9
7
|
itemId: number;
|
|
8
|
+
onTitleChange?: (newTitle: string) => void;
|
|
10
9
|
}
|
|
11
10
|
|
|
12
|
-
export function EditableDetailTitle({ title, itemId }: EditableDetailTitleProps) {
|
|
13
|
-
const router = useRouter();
|
|
14
|
-
|
|
11
|
+
export function EditableDetailTitle({ title, itemId, onTitleChange }: EditableDetailTitleProps) {
|
|
15
12
|
const handleSave = useCallback(async (id: number, newTitle: string) => {
|
|
16
|
-
await
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
body: JSON.stringify({ title: newTitle }),
|
|
20
|
-
});
|
|
21
|
-
router.refresh();
|
|
22
|
-
}, [router]);
|
|
13
|
+
await dataBridge.updateTitle(id, newTitle);
|
|
14
|
+
onTitleChange?.(newTitle);
|
|
15
|
+
}, [onTitleChange]);
|
|
23
16
|
|
|
24
17
|
return <EditableTitle title={title} itemId={itemId} onSave={handleSave} variant="page" />;
|
|
25
18
|
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
'use client';
|
|
2
1
|
|
|
3
2
|
import { useState, useRef, useEffect } from 'react';
|
|
4
3
|
|
|
@@ -7,12 +6,15 @@ interface EditableTitleProps {
|
|
|
7
6
|
itemId: number;
|
|
8
7
|
onSave: (id: number, newTitle: string) => Promise<void>;
|
|
9
8
|
variant?: 'card' | 'page';
|
|
9
|
+
isEditing?: boolean;
|
|
10
|
+
onEditingChange?: (editing: boolean) => void;
|
|
11
|
+
clickToEdit?: boolean;
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
const variantStyles = {
|
|
13
15
|
card: {
|
|
14
|
-
display: 'text-
|
|
15
|
-
input: 'text-
|
|
16
|
+
display: 'text-base font-medium text-zinc-900 dark:text-zinc-100 leading-snug cursor-pointer hover:bg-zinc-100 dark:hover:bg-zinc-700 rounded px-1 py-0.5 -mx-1 -my-0.5',
|
|
17
|
+
input: 'text-base font-medium text-zinc-900 dark:text-zinc-100 leading-snug w-full bg-white dark:bg-zinc-700 border rounded px-1 py-0.5 focus:outline-none focus:ring-2',
|
|
16
18
|
},
|
|
17
19
|
page: {
|
|
18
20
|
display: 'text-2xl font-bold text-zinc-900 dark:text-zinc-100 cursor-pointer hover:bg-zinc-100 dark:hover:bg-zinc-700 rounded px-1 py-0.5 -mx-1 -my-0.5',
|
|
@@ -20,8 +22,13 @@ const variantStyles = {
|
|
|
20
22
|
},
|
|
21
23
|
};
|
|
22
24
|
|
|
23
|
-
export function EditableTitle({ title, itemId, onSave, variant = 'card' }: EditableTitleProps) {
|
|
24
|
-
const [
|
|
25
|
+
export function EditableTitle({ title, itemId, onSave, variant = 'card', isEditing: externalIsEditing, onEditingChange, clickToEdit = true }: EditableTitleProps) {
|
|
26
|
+
const [internalIsEditing, setInternalIsEditing] = useState(false);
|
|
27
|
+
const isEditing = externalIsEditing ?? internalIsEditing;
|
|
28
|
+
const setIsEditing = (value: boolean) => {
|
|
29
|
+
setInternalIsEditing(value);
|
|
30
|
+
onEditingChange?.(value);
|
|
31
|
+
};
|
|
25
32
|
const [editValue, setEditValue] = useState(title);
|
|
26
33
|
const [error, setError] = useState<string | null>(null);
|
|
27
34
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
@@ -33,7 +40,15 @@ export function EditableTitle({ title, itemId, onSave, variant = 'card' }: Edita
|
|
|
33
40
|
}
|
|
34
41
|
}, [isEditing]);
|
|
35
42
|
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (isEditing) {
|
|
45
|
+
setEditValue(title);
|
|
46
|
+
setError(null);
|
|
47
|
+
}
|
|
48
|
+
}, [isEditing, title]);
|
|
49
|
+
|
|
36
50
|
const handleClick = (e: React.MouseEvent) => {
|
|
51
|
+
if (!clickToEdit) return;
|
|
37
52
|
e.preventDefault();
|
|
38
53
|
e.stopPropagation();
|
|
39
54
|
setEditValue(title);
|
|
@@ -93,7 +108,7 @@ export function EditableTitle({ title, itemId, onSave, variant = 'card' }: Edita
|
|
|
93
108
|
className={`${variantStyles[variant].input} ${
|
|
94
109
|
error
|
|
95
110
|
? 'border-red-500 focus:ring-red-500'
|
|
96
|
-
: 'border-zinc-300 dark:border-zinc-600 focus:ring-
|
|
111
|
+
: 'border-zinc-300 dark:border-zinc-600 focus:ring-[#819D9F]'
|
|
97
112
|
}`}
|
|
98
113
|
/>
|
|
99
114
|
{error && (
|
|
@@ -103,10 +118,14 @@ export function EditableTitle({ title, itemId, onSave, variant = 'card' }: Edita
|
|
|
103
118
|
);
|
|
104
119
|
}
|
|
105
120
|
|
|
121
|
+
const displayClass = clickToEdit
|
|
122
|
+
? variantStyles[variant].display
|
|
123
|
+
: variantStyles[variant].display.replace('cursor-pointer hover:bg-zinc-100 dark:hover:bg-zinc-700 rounded px-1 py-0.5 -mx-1 -my-0.5', '');
|
|
124
|
+
|
|
106
125
|
return (
|
|
107
126
|
<p
|
|
108
127
|
onClick={handleClick}
|
|
109
|
-
className={
|
|
128
|
+
className={displayClass}
|
|
110
129
|
>
|
|
111
130
|
{title}
|
|
112
131
|
</p>
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
|
|
2
|
+
import { useState, useEffect } from 'react';
|
|
3
|
+
|
|
4
|
+
// Persist timer start timestamps outside the component so they survive remounts (e.g., tab switches).
|
|
5
|
+
// Capped at MAX_ENTRIES to prevent unbounded growth — evicts oldest entry on overflow.
|
|
6
|
+
const MAX_TIMER_ENTRIES = 50;
|
|
7
|
+
const timerStartTimes = new Map<string, number>();
|
|
8
|
+
|
|
9
|
+
export function ElapsedTimer({ isStreaming, timerKey }: { isStreaming: boolean; timerKey: string }) {
|
|
10
|
+
const [elapsed, setElapsed] = useState(() => {
|
|
11
|
+
const existing = timerStartTimes.get(timerKey);
|
|
12
|
+
return existing ? Math.floor((Date.now() - existing) / 1000) : 0;
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (isStreaming) {
|
|
17
|
+
// Start or continue timing — reuse persisted start time if available
|
|
18
|
+
if (!timerStartTimes.has(timerKey)) {
|
|
19
|
+
timerStartTimes.set(timerKey, Date.now());
|
|
20
|
+
// Evict oldest entry if Map exceeds cap
|
|
21
|
+
if (timerStartTimes.size > MAX_TIMER_ENTRIES) {
|
|
22
|
+
const oldest = timerStartTimes.keys().next().value;
|
|
23
|
+
if (oldest != null) timerStartTimes.delete(oldest);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
// Immediately sync elapsed value for this timerKey (prevents stale value on tab switch)
|
|
27
|
+
const startTime = timerStartTimes.get(timerKey);
|
|
28
|
+
if (startTime != null) {
|
|
29
|
+
setElapsed(Math.floor((Date.now() - startTime) / 1000));
|
|
30
|
+
}
|
|
31
|
+
const interval = setInterval(() => {
|
|
32
|
+
const startTime = timerStartTimes.get(timerKey);
|
|
33
|
+
if (startTime != null) {
|
|
34
|
+
setElapsed(Math.floor((Date.now() - startTime) / 1000));
|
|
35
|
+
}
|
|
36
|
+
}, 1000);
|
|
37
|
+
return () => {
|
|
38
|
+
clearInterval(interval);
|
|
39
|
+
// Do NOT delete timerStartTimes here — component may unmount due to
|
|
40
|
+
// navigation while session is still streaming. The start time must
|
|
41
|
+
// persist so the timer resumes correctly when the user navigates back.
|
|
42
|
+
// Cleanup happens in the else branch when streaming actually stops.
|
|
43
|
+
};
|
|
44
|
+
} else {
|
|
45
|
+
// Reset when not streaming
|
|
46
|
+
timerStartTimes.delete(timerKey);
|
|
47
|
+
setElapsed(0);
|
|
48
|
+
}
|
|
49
|
+
}, [isStreaming, timerKey]);
|
|
50
|
+
|
|
51
|
+
if (!isStreaming) return null;
|
|
52
|
+
|
|
53
|
+
const minutes = Math.floor(elapsed / 60);
|
|
54
|
+
const seconds = elapsed % 60;
|
|
55
|
+
const display = `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div
|
|
59
|
+
className="text-xs text-zinc-500"
|
|
60
|
+
style={{ width: '3rem', flexShrink: 0, textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}
|
|
61
|
+
data-testid="elapsed-timer"
|
|
62
|
+
>
|
|
63
|
+
{display}
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|