Implement session outcomes, step timing, and live timer fixes

This commit is contained in:
Michael Chihlas
2026-02-11 17:52:12 -05:00
parent 2a1ed4d250
commit ca4ce7cad6
15 changed files with 574 additions and 59 deletions

View File

@@ -237,6 +237,31 @@ export function SessionDetailPage() {
return new Date(dateString).toLocaleString()
}
const formatDuration = (durationSeconds: number | null | undefined) => {
if (durationSeconds == null || durationSeconds < 0) return null
if (durationSeconds < 60) return `${durationSeconds}s`
const hours = Math.floor(durationSeconds / 3600)
const minutes = Math.floor((durationSeconds % 3600) / 60)
const seconds = durationSeconds % 60
if (hours > 0) return seconds > 0 ? `${hours}h ${minutes}m ${seconds}s` : `${hours}h ${minutes}m`
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`
}
const getTotalDuration = () => {
if (!session?.completed_at) return 'In progress'
const startedAtMs = new Date(session.started_at).getTime()
const completedAtMs = new Date(session.completed_at).getTime()
if (Number.isNaN(startedAtMs) || Number.isNaN(completedAtMs)) return 'Unknown'
const seconds = Math.max(0, Math.floor((completedAtMs - startedAtMs) / 1000))
return formatDuration(seconds) || '0s'
}
const outcomeLabel = session?.outcome
? session.outcome === 'workaround'
? 'Workaround'
: session.outcome.charAt(0).toUpperCase() + session.outcome.slice(1)
: null
if (isLoading) {
return (
<div className="flex h-64 items-center justify-center">
@@ -292,7 +317,20 @@ export function SessionDetailPage() {
{session.completed_at ? 'Completed' : 'In Progress'}
</span>
{session.client_name && <span>Client: {session.client_name}</span>}
{session.completed_at && (
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs text-white">
Duration: {getTotalDuration()}
</span>
)}
{outcomeLabel && (
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs text-white">
Outcome: {outcomeLabel}
</span>
)}
</div>
{session.outcome_notes && (
<p className="mt-2 text-sm text-white/60">Outcome Notes: {session.outcome_notes}</p>
)}
</div>
{/* Actions */}
@@ -401,6 +439,11 @@ export function SessionDetailPage() {
Notes: {decision.notes}
</p>
)}
{decision.duration_seconds != null && (
<p className="mt-2 text-xs text-white/50">
Duration: {formatDuration(decision.duration_seconds)}
</p>
)}
<p className="mt-2 text-xs text-white/40">
{formatDate(decision.timestamp)}
</p>

View File

@@ -140,6 +140,13 @@ export function SessionHistoryPage() {
return session.tree_snapshot?.name || 'Unknown Tree'
}
const formatOutcomeLabel = (outcome: Session['outcome']): string => {
if (!outcome) return 'Not set'
return outcome === 'workaround'
? 'Workaround'
: outcome.charAt(0).toUpperCase() + outcome.slice(1)
}
return (
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
<div className="mb-8">
@@ -226,6 +233,20 @@ export function SessionHistoryPage() {
{session.client_name}
</span>
)}
{session.completed_at && (
<span
className={cn(
'rounded-full px-2.5 py-0.5 text-xs font-medium',
session.outcome === 'resolved' && 'bg-emerald-500/20 text-emerald-300',
session.outcome === 'workaround' && 'bg-amber-500/20 text-amber-300',
session.outcome === 'escalated' && 'bg-blue-500/20 text-blue-300',
session.outcome === 'unresolved' && 'bg-rose-500/20 text-rose-300',
!session.outcome && 'bg-white/10 text-white/70'
)}
>
{formatOutcomeLabel(session.outcome)}
</span>
)}
</div>
{/* Tree Name */}

View File

@@ -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>