Implement session outcomes, step timing, and live timer fixes
This commit is contained in:
@@ -5,11 +5,11 @@ import { sessionsApi } from '@/api/sessions'
|
||||
import { useTreeNavigationShortcuts } from '@/hooks/useKeyboardShortcuts'
|
||||
import { useCustomStepFlow } from '@/hooks/useCustomStepFlow'
|
||||
import { useSessionTimer } from '@/hooks/useSessionTimer'
|
||||
import type { Tree, Session, DecisionRecord, TreeStructure } from '@/types'
|
||||
import type { Tree, Session, DecisionRecord, TreeStructure, SessionOutcome } from '@/types'
|
||||
import { cn, safeGetItem, safeSetItem } from '@/lib/utils'
|
||||
import { MarkdownContent } from '@/components/ui/MarkdownContent'
|
||||
import { CustomStepModal } from '@/components/step-library/CustomStepModal'
|
||||
import { PostStepActionModal, ContinuationModal, ForkTreeModal, ScratchpadSidebar } from '@/components/session'
|
||||
import { PostStepActionModal, ContinuationModal, ForkTreeModal, ScratchpadSidebar, SessionOutcomeModal } from '@/components/session'
|
||||
import { Plus, CheckCircle, ArrowRight, Clock } from 'lucide-react'
|
||||
|
||||
interface LocationState {
|
||||
@@ -18,6 +18,8 @@ interface LocationState {
|
||||
prefillTicketNumber?: string
|
||||
}
|
||||
|
||||
type CompletionSource = 'standard' | 'custom'
|
||||
|
||||
export function TreeNavigationPage() {
|
||||
const { id: treeId } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
@@ -29,10 +31,14 @@ export function TreeNavigationPage() {
|
||||
const [currentNodeId, setCurrentNodeId] = useState<string>('root')
|
||||
const [pathTaken, setPathTaken] = useState<string[]>(['root'])
|
||||
const [decisions, setDecisions] = useState<DecisionRecord[]>([])
|
||||
const [currentStepEnteredAt, setCurrentStepEnteredAt] = useState<string>(new Date().toISOString())
|
||||
const [notes, setNotes] = useState<string>('')
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isCompleting, setIsCompleting] = useState(false)
|
||||
const [showOutcomeModal, setShowOutcomeModal] = useState(false)
|
||||
const [pendingCompletionDecision, setPendingCompletionDecision] = useState<DecisionRecord | null>(null)
|
||||
const [completionSource, setCompletionSource] = useState<CompletionSource>('standard')
|
||||
|
||||
// Session metadata (prefill from Repeat Last Session)
|
||||
const [ticketNumber, setTicketNumber] = useState<string>(locationState?.prefillTicketNumber || '')
|
||||
@@ -59,6 +65,42 @@ export function TreeNavigationPage() {
|
||||
return null
|
||||
}
|
||||
|
||||
const calculateDurationSeconds = (enteredAtIso: string, exitedAtIso: string): number => {
|
||||
const enteredAtMs = new Date(enteredAtIso).getTime()
|
||||
const exitedAtMs = new Date(exitedAtIso).getTime()
|
||||
if (Number.isNaN(enteredAtMs) || Number.isNaN(exitedAtMs)) return 0
|
||||
return Math.max(0, Math.floor((exitedAtMs - enteredAtMs) / 1000))
|
||||
}
|
||||
|
||||
const deriveCurrentStepEnteredAt = (sessionData: Session): string => {
|
||||
if (!sessionData.decisions || sessionData.decisions.length === 0) {
|
||||
return sessionData.started_at
|
||||
}
|
||||
const lastDecision = sessionData.decisions[sessionData.decisions.length - 1]
|
||||
return lastDecision.exited_at || lastDecision.timestamp || sessionData.started_at
|
||||
}
|
||||
|
||||
const openCompletionModal = (completionDecision: DecisionRecord, source: CompletionSource) => {
|
||||
const exitedAt = new Date().toISOString()
|
||||
const enteredAt = currentStepEnteredAt || session?.started_at || exitedAt
|
||||
setPendingCompletionDecision({
|
||||
...completionDecision,
|
||||
timestamp: exitedAt,
|
||||
entered_at: enteredAt,
|
||||
exited_at: exitedAt,
|
||||
duration_seconds: calculateDurationSeconds(enteredAt, exitedAt),
|
||||
})
|
||||
setCompletionSource(source)
|
||||
setShowOutcomeModal(true)
|
||||
}
|
||||
|
||||
const closeOutcomeModal = () => {
|
||||
if (isCompleting) return
|
||||
setShowOutcomeModal(false)
|
||||
setPendingCompletionDecision(null)
|
||||
setCompletionSource('standard')
|
||||
}
|
||||
|
||||
// Custom step flow (creation, post-step actions, continuation, branching, forking)
|
||||
const customStepFlow = useCustomStepFlow({
|
||||
tree,
|
||||
@@ -72,9 +114,12 @@ export function TreeNavigationPage() {
|
||||
setPathTaken,
|
||||
setDecisions,
|
||||
setNotes,
|
||||
setIsCompleting,
|
||||
setError,
|
||||
onEnterNode: setCurrentStepEnteredAt,
|
||||
isCompleting,
|
||||
onRequestCompletion: (completionDecision) => {
|
||||
openCompletionModal(completionDecision, 'custom')
|
||||
},
|
||||
})
|
||||
|
||||
const handleScratchpadSave = async (content: string) => {
|
||||
@@ -102,6 +147,7 @@ export function TreeNavigationPage() {
|
||||
setPathTaken(sessionData.path_taken)
|
||||
setCurrentNodeId(sessionData.path_taken[sessionData.path_taken.length - 1] || 'root')
|
||||
setDecisions(sessionData.decisions as DecisionRecord[])
|
||||
setCurrentStepEnteredAt(deriveCurrentStepEnteredAt(sessionData))
|
||||
customStepFlow.initCustomSteps(sessionData.custom_steps || [])
|
||||
setTicketNumber(sessionData.ticket_number || '')
|
||||
setClientName(sessionData.client_name || '')
|
||||
@@ -125,6 +171,7 @@ export function TreeNavigationPage() {
|
||||
client_name: clientName || undefined,
|
||||
})
|
||||
setSession(newSession)
|
||||
setCurrentStepEnteredAt(newSession.started_at)
|
||||
setShowMetadataForm(false)
|
||||
// Save for "Repeat Last Session"
|
||||
safeSetItem('last-session', JSON.stringify({
|
||||
@@ -147,6 +194,8 @@ export function TreeNavigationPage() {
|
||||
const node = findNode(currentNodeId, tree.tree_structure)
|
||||
if (!node) return
|
||||
|
||||
const exitedAt = new Date().toISOString()
|
||||
const enteredAt = currentStepEnteredAt || session.started_at || exitedAt
|
||||
const newDecision: DecisionRecord = {
|
||||
node_id: currentNodeId,
|
||||
question: node.question || null,
|
||||
@@ -154,7 +203,10 @@ export function TreeNavigationPage() {
|
||||
action_performed: null,
|
||||
notes: notes || null,
|
||||
automation_used: false,
|
||||
timestamp: new Date().toISOString(),
|
||||
timestamp: exitedAt,
|
||||
entered_at: enteredAt,
|
||||
exited_at: exitedAt,
|
||||
duration_seconds: calculateDurationSeconds(enteredAt, exitedAt),
|
||||
attachments: [],
|
||||
}
|
||||
|
||||
@@ -164,6 +216,7 @@ export function TreeNavigationPage() {
|
||||
setPathTaken(newPath)
|
||||
setDecisions(newDecisions)
|
||||
setCurrentNodeId(nextNodeId)
|
||||
setCurrentStepEnteredAt(exitedAt)
|
||||
setNotes('')
|
||||
|
||||
try {
|
||||
@@ -182,6 +235,8 @@ export function TreeNavigationPage() {
|
||||
const node = findNode(currentNodeId, tree.tree_structure)
|
||||
if (!node || !node.next_node_id) return
|
||||
|
||||
const exitedAt = new Date().toISOString()
|
||||
const enteredAt = currentStepEnteredAt || session.started_at || exitedAt
|
||||
const newDecision: DecisionRecord = {
|
||||
node_id: currentNodeId,
|
||||
question: null,
|
||||
@@ -189,7 +244,10 @@ export function TreeNavigationPage() {
|
||||
action_performed: actionPerformed || node.title || 'Action completed',
|
||||
notes: notes || null,
|
||||
automation_used: false,
|
||||
timestamp: new Date().toISOString(),
|
||||
timestamp: exitedAt,
|
||||
entered_at: enteredAt,
|
||||
exited_at: exitedAt,
|
||||
duration_seconds: calculateDurationSeconds(enteredAt, exitedAt),
|
||||
attachments: [],
|
||||
}
|
||||
|
||||
@@ -199,6 +257,7 @@ export function TreeNavigationPage() {
|
||||
setPathTaken(newPath)
|
||||
setDecisions(newDecisions)
|
||||
setCurrentNodeId(node.next_node_id)
|
||||
setCurrentStepEnteredAt(exitedAt)
|
||||
setNotes('')
|
||||
|
||||
try {
|
||||
@@ -213,28 +272,44 @@ export function TreeNavigationPage() {
|
||||
|
||||
const handleComplete = async () => {
|
||||
if (!session || !tree) return
|
||||
const node = findNode(currentNodeId, tree.tree_structure)
|
||||
if (!node) return
|
||||
const completionDecision: DecisionRecord = {
|
||||
node_id: currentNodeId,
|
||||
question: null,
|
||||
answer: null,
|
||||
action_performed: node.title || 'Session completed',
|
||||
notes: notes || null,
|
||||
automation_used: false,
|
||||
timestamp: new Date().toISOString(),
|
||||
attachments: [],
|
||||
}
|
||||
openCompletionModal(completionDecision, 'standard')
|
||||
}
|
||||
|
||||
const handleSubmitOutcome = async (data: { outcome: SessionOutcome; outcome_notes?: string }) => {
|
||||
if (!session) return
|
||||
setIsCompleting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const node = findNode(currentNodeId, tree.tree_structure)
|
||||
if (node) {
|
||||
const finalDecision: DecisionRecord = {
|
||||
node_id: currentNodeId,
|
||||
question: null,
|
||||
answer: null,
|
||||
action_performed: node.title || 'Session completed',
|
||||
notes: notes || null,
|
||||
automation_used: false,
|
||||
timestamp: new Date().toISOString(),
|
||||
attachments: [],
|
||||
}
|
||||
let finalDecisions = decisions
|
||||
if (pendingCompletionDecision) {
|
||||
finalDecisions = [...decisions, pendingCompletionDecision]
|
||||
setDecisions(finalDecisions)
|
||||
await sessionsApi.update(session.id, {
|
||||
decisions: [...decisions, finalDecision],
|
||||
decisions: finalDecisions,
|
||||
})
|
||||
}
|
||||
|
||||
await sessionsApi.complete(session.id)
|
||||
navigate(`/sessions/${session.id}`)
|
||||
await sessionsApi.complete(session.id, data)
|
||||
|
||||
setShowOutcomeModal(false)
|
||||
setPendingCompletionDecision(null)
|
||||
if (completionSource === 'custom' && customStepFlow.customSteps.length > 0) {
|
||||
customStepFlow.setShowForkModal(true)
|
||||
} else {
|
||||
navigate(`/sessions/${session.id}`)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to complete session:', err)
|
||||
setError('Failed to complete session. Check console for details.')
|
||||
@@ -250,6 +325,7 @@ export function TreeNavigationPage() {
|
||||
setPathTaken(newPath)
|
||||
setDecisions(newDecisions)
|
||||
setCurrentNodeId(newPath[newPath.length - 1])
|
||||
setCurrentStepEnteredAt(new Date().toISOString())
|
||||
}
|
||||
|
||||
// Compute current node for keyboard shortcuts (must be before any returns for hooks rules)
|
||||
@@ -763,6 +839,13 @@ export function TreeNavigationPage() {
|
||||
onFork={customStepFlow.handleForkTree}
|
||||
onSkip={customStepFlow.handleSkipFork}
|
||||
/>
|
||||
|
||||
<SessionOutcomeModal
|
||||
isOpen={showOutcomeModal}
|
||||
onClose={closeOutcomeModal}
|
||||
onSubmit={handleSubmitOutcome}
|
||||
isSubmitting={isCompleting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user