Files
JARVIS/frontend/src/pages/chat/composables/useSidebarPlan.ts

421 lines
15 KiB
TypeScript
Raw Normal View History

import { computed, onMounted, onUnmounted, ref, watch, type Ref } from 'vue'
import { CornerDownLeft, Database, Sparkles, Sun, ListTodo } from 'lucide-vue-next'
import {
scheduleCenterApi,
type ScheduleCenterCommanderSummary,
type ScheduleCenterDateResponse,
type ScheduleCenterDaySummary,
} from '@/api/scheduleCenter'
import { taskApi, type Task, type TaskDispatchStatus, type TaskQuadrant } from '@/api/task'
import type { Conversation } from '@/api/conversation'
export interface SidebarFocusItem {
id: string
label: string
title: string
meta: string
tone: 'done' | 'doing' | 'pending'
}
export interface SidebarNewsItem {
id: string
title: string
meta: string
}
export interface TodayStatusQuadrantViewTask {
id: string
title: string
completed: boolean
status: string
dispatchStatus: string
assigneeLabel: string
}
export interface TodayStatusQuadrantView {
id: string
title: string
subtitle: string
color: string
glowColor: string
icon: string
tasks: TodayStatusQuadrantViewTask[]
}
export const sidebarCollapsedModules = [
{ id: 'calendar', label: 'Calendar', icon: Sun },
{ id: 'status', label: 'Status', icon: Database },
{ id: 'focus', label: 'Focus', icon: Sparkles },
{ id: 'kanban', label: 'Issues', icon: ListTodo },
{ id: 'review', label: 'Review', icon: CornerDownLeft },
]
const ISSUE_QUADRANT_META: Record<TaskQuadrant, Omit<TodayStatusQuadrantView, 'tasks'>> = {
'urgent-important': {
id: 'urgent-important',
title: 'Important & Urgent',
subtitle: 'CRITICAL',
color: '#ff4757',
glowColor: 'rgba(255, 71, 87, 0.4)',
icon: '!',
},
'not-urgent-important': {
id: 'not-urgent-important',
title: 'Important & Planned',
subtitle: 'PLANNED',
color: '#ffd93d',
glowColor: 'rgba(255, 217, 61, 0.4)',
icon: 'P',
},
'urgent-not-important': {
id: 'urgent-not-important',
title: 'Urgent & Delegated',
subtitle: 'DELEGATE',
color: '#00d4ff',
glowColor: 'rgba(0, 212, 255, 0.4)',
icon: 'D',
},
'not-urgent-not-important': {
id: 'not-urgent-not-important',
title: 'Backlog / Low Priority',
subtitle: 'BACKLOG',
color: '#6bcf7f',
glowColor: 'rgba(107, 207, 127, 0.4)',
icon: 'B',
},
}
export function formatDateKey(date: Date) {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
function formatMonthKey(date: Date) {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
return `${year}-${month}`
}
function toneFromStatus(status: string) {
if (status === 'done' || status === 'completed') return 'done' as const
if (status === 'in_progress' || status === 'running' || status === 'queued') return 'doing' as const
return 'pending' as const
}
function focusLabelByQuadrant(quadrant?: string | null) {
if (!quadrant) return 'Task'
if (quadrant === 'urgent-important') return 'Critical'
if (quadrant === 'not-urgent-important') return 'Planned'
if (quadrant === 'urgent-not-important') return 'Delegated'
return 'Backlog'
}
function focusMeta(status: string, dispatchStatus: string, assigneeType?: string | null, assigneeId?: string | null) {
const statusMap: Record<string, string> = {
todo: 'Pending',
in_progress: 'In progress',
done: 'Done',
cancelled: 'Cancelled',
}
const dispatchMap: Record<string, string> = {
idle: 'Not dispatched',
queued: 'Queued',
running: 'Running',
completed: 'Completed',
failed: 'Failed',
}
const statusLabel = dispatchStatus !== 'idle' ? dispatchMap[dispatchStatus] : (statusMap[status] || 'Pending')
if (assigneeType && assigneeId) return `${statusLabel} / ${assigneeType}:${assigneeId}`
if (assigneeType) return `${statusLabel} / ${assigneeType}`
return statusLabel
}
function deriveIssueQuadrant(task: Task): TaskQuadrant {
if (task.quadrant) return task.quadrant
if (task.priority === 'high' || task.priority === 'urgent') return 'urgent-important'
if (task.status === 'in_progress') return 'not-urgent-important'
if (task.priority === 'medium') return 'urgent-not-important'
return 'not-urgent-not-important'
}
function buildIssueCommanderSummary(tasks: Task[]): ScheduleCenterCommanderSummary {
const summary: ScheduleCenterCommanderSummary = {
total: 0,
queued: 0,
running: 0,
completed: 0,
failed: 0,
overall_status: 'idle',
}
tasks.forEach((task) => {
const state = task.dispatch_status as TaskDispatchStatus
if (state === 'idle') return
summary.total += 1
if (state === 'queued') summary.queued += 1
if (state === 'running') summary.running += 1
if (state === 'completed') summary.completed += 1
if (state === 'failed') summary.failed += 1
})
if (summary.running > 0) {
summary.overall_status = 'running'
} else if (summary.queued > 0) {
summary.overall_status = 'queued'
} else if (summary.failed > 0 && summary.completed === 0) {
summary.overall_status = 'failed'
}
return summary
}
export function useSidebarPlan(
clientTimeRef: { value: Date },
loadDailyDigestFn: () => void,
conversationsRef: Ref<Conversation[]> | Conversation[] = [],
) {
const todayPlanDetail = ref<ScheduleCenterDateResponse | null>(null)
const monthPlanDays = ref<ScheduleCenterDaySummary[]>([])
const issueTasks = ref<Task[]>([])
const selectedDate = ref<string | null>(null)
const conversationDateMap = computed(() => {
const map = new Map<string, boolean>()
const conversations = Array.isArray(conversationsRef) ? conversationsRef : (conversationsRef.value ?? [])
conversations.forEach((conv) => {
map.set(formatDateKey(new Date(conv.updated_at)), true)
})
return map
})
const todayDateKey = computed(() => formatDateKey(clientTimeRef.value))
const monthPlanSummaryMap = computed(() => new Map(monthPlanDays.value.map((item) => [item.date, item])))
const issueStatusQuadrants = computed<TodayStatusQuadrantView[]>(() => (
Object.values(ISSUE_QUADRANT_META).map((meta) => ({
...meta,
tasks: issueTasks.value
.filter((task) => deriveIssueQuadrant(task) === meta.id)
.map((task) => ({
id: task.id,
title: task.title,
completed: task.status === 'done',
status: task.status,
dispatchStatus: task.dispatch_status,
assigneeLabel: task.assignee_id ? `${task.assignee_type ?? 'owner'}:${task.assignee_id}` : (task.assignee_type ?? 'unassigned'),
})),
}))
))
const issueCommanderSummary = computed<ScheduleCenterCommanderSummary>(() => buildIssueCommanderSummary(issueTasks.value))
const calendarCells = computed(() => {
const year = clientTimeRef.value.getFullYear()
const month = clientTimeRef.value.getMonth()
const daysInMonth = new Date(year, month + 1, 0).getDate()
const firstDayOffset = (new Date(year, month, 1).getDay() + 6) % 7
const todayKey = todayDateKey.value
const cells: Array<{ key: string; value: number | null; active: boolean; busy: boolean; selected: boolean; hasConversation: boolean }> = []
for (let index = 0; index < firstDayOffset; index += 1) {
cells.push({ key: `blank-start-${index}`, value: null, active: false, busy: false, selected: false, hasConversation: false })
}
for (let day = 1; day <= daysInMonth; day += 1) {
const monthDate = new Date(year, month, day)
const dateKey = formatDateKey(monthDate)
const summary = monthPlanSummaryMap.value.get(dateKey)
const busy = Boolean(summary && (summary.todo_total + summary.task_due_total + summary.goal_total + summary.reminder_total) > 0)
const hasConv = conversationDateMap.value.get(dateKey) || false
cells.push({ key: dateKey, value: day, active: dateKey === todayKey, busy, selected: dateKey === selectedDate.value, hasConversation: hasConv })
}
while (cells.length % 7 !== 0) {
cells.push({ key: `blank-end-${cells.length}`, value: null, active: false, busy: false, selected: false, hasConversation: false })
}
return cells
})
const calendarYear = computed(() => clientTimeRef.value.getFullYear())
const calendarMonth = computed(() => clientTimeRef.value.getMonth() + 1)
const issueStatusCounters = computed(() => {
const done = issueTasks.value.filter((item) => item.status === 'done').length
const doing = issueTasks.value.filter((item) => item.status === 'in_progress').length
const pending = issueTasks.value.filter((item) => item.status === 'todo').length
const total = done + doing + pending
return { done, doing, pending, total, completion: total > 0 ? Math.round((done / total) * 100) : 0 }
})
const monthReviewStats = computed(() => monthPlanDays.value.reduce(
(acc, item) => {
acc.todoTotal += item.todo_total
acc.todoCompleted += item.todo_completed
acc.taskTotal += item.task_due_total
acc.reminderTotal += item.reminder_total
acc.goalTotal += item.goal_total
acc.highPriorityTotal += item.high_priority_total
if (item.todo_total + item.task_due_total + item.reminder_total + item.goal_total > 0) acc.activeDays += 1
return acc
},
{ todoTotal: 0, todoCompleted: 0, taskTotal: 0, reminderTotal: 0, goalTotal: 0, highPriorityTotal: 0, activeDays: 0 },
))
const sidebarWeekLabels = [
{ label: 'M', isWeekend: false },
{ label: 'T', isWeekend: false },
{ label: 'W', isWeekend: false },
{ label: 'T', isWeekend: false },
{ label: 'F', isWeekend: false },
{ label: 'S', isWeekend: true },
{ label: 'S', isWeekend: true },
]
const sidebarStatusHeadline = computed(() => '')
const sidebarStatusBreakdown = computed(() => [
{ key: 'done', label: 'Completed', value: issueStatusCounters.value.done, tone: 'done' },
{ key: 'doing', label: 'In Progress', value: issueStatusCounters.value.doing, tone: 'doing' },
{ key: 'pending', label: 'Pending', value: issueStatusCounters.value.pending, tone: 'pending' },
{ key: 'total', label: 'Total', value: issueStatusCounters.value.total, tone: 'total' },
])
const sidebarFocusItems = computed<SidebarFocusItem[]>(() => (
(todayPlanDetail.value?.focus_tasks ?? []).map((task) => ({
id: task.id,
label: focusLabelByQuadrant(task.quadrant),
title: task.title,
meta: focusMeta(task.status, task.dispatch_status, task.assignee_type, task.assignee_id),
tone: toneFromStatus(task.dispatch_status !== 'idle' ? task.dispatch_status : task.status),
}))
))
const sidebarReviewAchievements = computed(() => {
const stats = monthReviewStats.value
const items = [
stats.todoCompleted > 0 ? `Completed ${stats.todoCompleted} todos this month.` : '',
stats.activeDays > 0 ? `${stats.activeDays} active planning days recorded this month.` : '',
stats.highPriorityTotal > 0 ? `${stats.highPriorityTotal} high-priority items were tracked.` : '',
].filter(Boolean)
if (items.length > 0) return items.slice(0, 3)
return ['Monthly review data is still accumulating.']
})
const sidebarReviewReflections = computed(() => {
const stats = monthReviewStats.value
const pendingTodoCount = Math.max(stats.todoTotal - stats.todoCompleted, 0)
const items = [
pendingTodoCount > 0 ? `${pendingTodoCount} todos are still open and may need to be broken down further.` : '',
stats.highPriorityTotal >= 8 ? 'High-priority load is dense. Consider narrowing the active mainline.' : '',
stats.reminderTotal >= Math.max(6, stats.activeDays) ? 'Reminder volume is high. A fixed review block may help reduce interruption cost.' : '',
].filter(Boolean)
if (items.length > 0) return items.slice(0, 3)
return ['Execution rhythm looks stable.']
})
const sidebarFeedItems = computed<SidebarNewsItem[]>(() => [
{ id: 'fallback-1', title: 'Task orchestration is replacing simple one-shot assistants in modern AI workflows.', meta: 'Industry' },
{ id: 'fallback-2', title: 'Shared planning state across chat and execution surfaces is becoming a core product boundary.', meta: 'Product' },
{ id: 'fallback-3', title: 'This feed is still placeholder content and can be replaced by a real RSS source later.', meta: 'System' },
])
const topbarFeedItems = computed(() => (
sidebarFeedItems.value.length > 0 ? [...sidebarFeedItems.value, ...sidebarFeedItems.value] : []
))
async function loadIssueStatusSnapshot() {
try {
const response = await taskApi.list()
issueTasks.value = response.data.filter((task) => task.status !== 'cancelled')
} catch (err) {
console.warn('Failed to load issue status snapshot:', err)
issueTasks.value = []
}
}
async function loadDailyPlanSnapshot(date = new Date()) {
const dateKey = formatDateKey(date)
const monthKey = formatMonthKey(date)
try {
const [todayResponse, monthResponse] = await Promise.all([
scheduleCenterApi.date(dateKey),
scheduleCenterApi.month(monthKey),
])
todayPlanDetail.value = todayResponse.data
monthPlanDays.value = monthResponse.data.days
} catch (err) {
console.warn('Failed to load sidebar plan snapshot:', err)
todayPlanDetail.value = null
monthPlanDays.value = []
}
}
async function loadSidebarPlanSnapshot(date = new Date()) {
await Promise.all([
loadDailyPlanSnapshot(date),
loadIssueStatusSnapshot(),
])
}
watch(todayDateKey, (next, previous) => {
if (next === previous) return
void loadDailyDigestFn()
void loadSidebarPlanSnapshot(clientTimeRef.value)
})
function selectCalendarDate(dateKey: string) {
selectedDate.value = dateKey
}
onMounted(() => {
void loadDailyDigestFn()
void loadSidebarPlanSnapshot(clientTimeRef.value)
if (typeof window !== 'undefined') {
window.addEventListener('jarvis:today-status-refresh', handleExternalRefresh)
}
})
function handleExternalRefresh() {
void loadSidebarPlanSnapshot(clientTimeRef.value)
}
onUnmounted(() => {
if (typeof window !== 'undefined') {
window.removeEventListener('jarvis:today-status-refresh', handleExternalRefresh)
}
})
return {
todayPlanDetail,
monthPlanDays,
todayDateKey,
monthPlanSummaryMap,
calendarCells,
calendarYear,
calendarMonth,
issueStatusCounters,
monthReviewStats,
sidebarWeekLabels,
sidebarStatusHeadline,
sidebarStatusBreakdown,
sidebarFocusItems,
issueStatusQuadrants,
issueCommanderSummary,
sidebarReviewAchievements,
sidebarReviewReflections,
sidebarFeedItems,
topbarFeedItems,
loadSidebarPlanSnapshot,
sidebarCollapsedModules,
selectedDate,
selectCalendarDate,
conversationDateMap,
}
}