/** Task-detail sub-view: header, tab bar, overview tab (KPIs, meta, charts, * analysis cards), history tab, and the documents section. * * Extracted from the panel (renderers/ pattern, like history.ts). State the * panel owns — the active tab, collapsed sections, feature flags — plus the * ~20 action callbacks (complete/skip/archive/QR/edit/…) are passed in via * TaskDetailContext. Dialog ownership deliberately STAYS in the panel: every * dialog-opening callback runs panel-side against the panel's shadow root, so * this module renders into the panel's root and never needs its own dialogs. */ import { html, nothing } from "lit"; import { t, formatDate, formatDateTime, formatRecurrence } from "../styles"; import type { AdvancedFeatures, HomeAssistant, MaintenanceTask } from "../types"; import { renderTriggerSection, type SparklineContext } from "./sparkline"; import { renderPredictionSection } from "./prediction"; import { renderWeibullSection } from "./weibull"; import { renderRecommendationBars } from "./recommendation"; import { renderSeasonalCardCompact, renderSeasonalCardExpanded } from "./seasonal"; import { renderCostDurationCard } from "./charts"; import { renderDaysProgress } from "./progress"; import { renderHistoryFilters, renderHistoryList, type HistoryContext } from "./history"; import "../components/task-documents"; export interface TaskDetailContext { lang: string; hass: HomeAssistant; entryId: string; taskId: string; /** Parent object's display name (breadcrumb + object-manual label). */ objectName: string; /** Parent object's documentation_url (raw; sanitised here). */ objectDocUrl: string | null | undefined; isOperator: boolean; actionLoading: boolean; moreMenuOpen: boolean; activeTab: "overview" | "history"; features: AdvancedFeatures; currencySymbol: string; collapsedSections: Set; costDurationToggle: "cost" | "duration" | "both"; /** Whether the interval suggestion for THIS task was dismissed this session. */ suggestionDismissed: boolean; /** Sub-contexts for the chart + history renderers. */ sparkline: SparklineContext; history: HistoryContext; getUserName: (userId: string) => string | null; // ── Panel-owned state mutations ──────────────────────────────────────── setActiveTab: (tab: "overview" | "history") => void; toggleSection: (key: string) => void; setCostDurationToggle: (v: "cost" | "duration" | "both") => void; showTaskView: () => void; showObject: () => void; toggleMoreMenu: () => void; closeMoreMenu: () => void; // ── Panel-owned actions (dialogs live in the panel's shadow root) ────── openEdit: (task: MaintenanceTask) => void; openComplete: (task: MaintenanceTask) => void; promptSkip: () => void; toggleArchive: (archived: boolean) => void; openQr: (taskName: string) => void; duplicateTask: () => void; promptReset: () => void; promptPostpone: () => void; snoozeTask: () => void; /** v2.21: open the printable one-pager for this task. */ printWorksheet: () => void; deleteTask: () => void; applySuggestion: (interval: number) => void; reanalyze: () => void; dismissSuggestion: () => void; openSeasonalOverrides: (task: MaintenanceTask) => void; } /** User badge for a task (if a responsible user is assigned). Also used by * the object-detail task list, so it takes the lookup directly. */ export function renderUserBadge( task: MaintenanceTask, getUserName: (userId: string) => string | null, ) { if (!task.responsible_user_id) return nothing; const userName = getUserName(task.responsible_user_id); if (!userName) return nothing; return html` ${userName} `; } function renderTaskHeader(task: MaintenanceTask, ctx: TaskDetailContext) { const L = ctx.lang; const isOperator = ctx.isOperator; // Determine status chip — use the backend-computed status. A completed // one-time task is shown as archived ("done") rather than its raw "ok". const statusClass = task.archived ? "archived" : (task.is_done ? "done" : (task.status === "due_soon" ? "warning" : (task.status || "ok"))); const statusText = task.archived ? t("archived", L) : (task.is_done ? t("completed", L) : t(task.status || "ok", L)); return html`
ctx.showTaskView()}>${task.name} · ctx.showObject()}>${ctx.objectName} ${statusText} ${task.due_override ? html` ${formatDate(task.due_override, L)} ` : nothing} ${renderUserBadge(task, ctx.getUserName)} ${task.nfc_tag_id ? html` NFC` : !isOperator ? html` ctx.openEdit(task)}> ` : nothing }
ctx.openComplete(task)}>${t("complete", L)} ctx.promptSkip()}>${t("skip", L)} ${!isOperator ? html` ctx.toggleArchive(!!task.archived)}> ${task.archived ? t("unarchive", L) : t("archive", L)} ` : nothing} ctx.openQr(task.name)}> ${t("qr_code", L)} ${!isOperator ? html`
ctx.toggleMoreMenu()}> ${ctx.moreMenuOpen ? html` ` : nothing}
` : nothing}
`; } function renderTabBar(ctx: TaskDetailContext) { const L = ctx.lang; return html`
ctx.setActiveTab("overview")}> ${t("overview", L)}
ctx.setActiveTab("history")}> ${t("history", L)}
`; } /** Wrap a task-detail analysis card in a collapsible section. The section * header owns the title (the wrapped card's own title is hidden via CSS — * `.collapsible-body` — so it isn't shown twice). Remembered per key. */ function collapsible(key: string, titleKey: string, body: unknown, ctx: TaskDetailContext) { const collapsed = ctx.collapsedSections.has(key); return html`
${collapsed ? nothing : html`
${body}
`}
`; } /** Read-only preview of the configured checklist steps so users can see * the steps without having to open the Edit or Complete dialog. Only * rendered when the Checklists feature is enabled and steps are set. */ function renderChecklistCard(task: MaintenanceTask, ctx: TaskDetailContext) { if (!ctx.features.checklists) return nothing; const items = task.checklist || []; if (items.length === 0) return nothing; const L = ctx.lang; return html`
${t("checklist", L)} (${items.length})
    ${items.map((item) => html`
  1. ${item}
  2. `)}
`; } /** Task notes, task documentation URL, and (since v1.4.1) the parent object's * documentation_url for quick access to the device manual without having to * navigate back to the object detail. */ function renderTaskMeta(task: MaintenanceTask, ctx: TaskDetailContext) { const safeTaskUrl = task.documentation_url && /^https?:\/\//i.test(task.documentation_url) ? task.documentation_url : null; const safeObjUrl = ctx.objectDocUrl && /^https?:\/\//i.test(ctx.objectDocUrl) ? ctx.objectDocUrl : null; if (!task.notes && !safeTaskUrl && !safeObjUrl) return nothing; const L = ctx.lang; return html`
${task.notes ? html`
${task.notes}
` : nothing} ${safeTaskUrl ? html` ` : nothing} ${safeObjUrl ? html` ` : nothing}
`; } /** KPI bar with 7 cards. */ function renderKPIBar(task: MaintenanceTask, ctx: TaskDetailContext) { const L = ctx.lang; const avgCost = task.times_performed > 0 ? task.total_cost / task.times_performed : 0; const daysClass = task.days_until_due !== null && task.days_until_due !== undefined ? (task.days_until_due < 0 ? "overdue" : (task.days_until_due <= task.warning_days ? "warning" : "")) : ""; return html`
${t("next_due", L)}
${task.next_due ? formatDate(task.next_due, L) : "—"}
${ctx.features.schedule_time && task.schedule_time ? html`
${t("at_time", L)} ${task.schedule_time}
` : nothing}
${t("days_until_due", L)}
${task.days_until_due !== null && task.days_until_due !== undefined ? task.days_until_due : "—"}
${t("interval", L)}
${formatRecurrence(task, L)}
${ctx.features.adaptive && task.suggested_interval && task.suggested_interval !== task.interval_days ? html`
${t("recommended", L)}: ${task.suggested_interval}${task.interval_analysis?.confidence_interval_low != null ? ` (${task.interval_analysis.confidence_interval_low}–${task.interval_analysis.confidence_interval_high})` : ""}
` : nothing}
${t("warning", L)}
${task.warning_days} ${t("days", L)}
${t("last_performed", L)}
${task.last_performed ? formatDate(task.last_performed, L) : "—"}
${t("avg_cost", L)}
${avgCost.toFixed(0)} ${ctx.currencySymbol}
${t("avg_duration", L)}
${task.average_duration ? task.average_duration.toFixed(0) : "—"} min
`; } function renderRecommendationCard(task: MaintenanceTask, ctx: TaskDetailContext) { const L = ctx.lang; if (!ctx.features.adaptive || !task.suggested_interval || task.suggested_interval === task.interval_days) { return nothing; } if (ctx.suggestionDismissed) return nothing; const suggested = task.suggested_interval; return html`

${t("suggested_interval", L)}

${renderRecommendationBars( task.interval_days, suggested, task.interval_confidence || "medium", L, )}
ctx.applySuggestion(suggested)}> ${t("apply_suggestion", L)} ctx.reanalyze()}> ${t("reanalyze", L)} ctx.dismissSuggestion()}> ${t("dismiss_suggestion", L)}
`; } function renderRecentActivities(task: MaintenanceTask, ctx: TaskDetailContext) { const L = ctx.lang; const recent = task.history.slice(-3).reverse(); if (recent.length === 0) { return nothing; } const getIcon = (type: string) => { switch (type) { case "completed": return "✓"; case "triggered": return "⊗"; case "skipped": return "↷"; case "reset": return "↺"; default: return "·"; } }; return html`

${t("recent_activities", L)}

${recent.map(entry => html`
${getIcon(entry.type)} ${formatDateTime(entry.timestamp, L)} ${entry.notes || "—"} ${entry.cost ? html`${entry.cost.toFixed(0)}${ctx.currencySymbol}` : nothing} ${entry.duration ? html`${entry.duration}min` : nothing}
`)}
ctx.setActiveTab("history")}>${t("show_all", L)} →
`; } export function renderOverviewTab(task: MaintenanceTask, ctx: TaskDetailContext) { const L = ctx.lang; // Check if we have recommendation / seasonal content const hasRecommendation = ctx.features.adaptive && task.suggested_interval && task.suggested_interval !== task.interval_days; const hasSeasonal = ctx.features.seasonal && task.seasonal_factor && task.seasonal_factor !== 1.0; const hasLeftColumn = hasRecommendation || hasSeasonal; // Analysis content: Weibull/Seasonal expanded (only when data is available) const hasWeibullData = ctx.features.adaptive && task.interval_analysis?.weibull_beta != null && task.interval_analysis?.weibull_eta != null; const hasSeasonalData = ctx.features.seasonal && (task.seasonal_factors?.length === 12 || task.interval_analysis?.seasonal_factors?.length === 12); return html`
${renderKPIBar(task, ctx)} ${renderTaskMeta(task, ctx)} ${renderDaysProgress(task, ctx.lang)} ${renderTriggerSection(task, ctx.sparkline)} ${renderPredictionSection(task, L, ctx.features)}
${hasLeftColumn ? html`
${renderRecommendationCard(task, ctx)} ${renderSeasonalCardCompact(task, L, ctx.features)}
` : nothing}
${renderCostDurationCard(task, L, ctx.costDurationToggle, (v) => ctx.setCostDurationToggle(v))}
${hasWeibullData ? collapsible("weibull", "weibull_reliability_curve", renderWeibullSection(task, L), ctx) : nothing} ${hasSeasonalData ? collapsible("seasonal", "seasonal_chart_title", html` ${renderSeasonalCardExpanded(task, L)}
ctx.openSeasonalOverrides(task)}> ${t("edit_seasonal_overrides", L)}
`, ctx) : nothing} ${renderChecklistCard(task, ctx)} ${renderRecentActivities(task, ctx)}
`; } function renderHistoryTab(task: MaintenanceTask, ctx: TaskDetailContext) { return html`
${renderHistoryFilters(task, ctx.history)} ${renderHistoryList(task, ctx.history)}
`; } function renderTabContent(task: MaintenanceTask, ctx: TaskDetailContext) { switch (ctx.activeTab) { case "overview": return renderOverviewTab(task, ctx); case "history": return renderHistoryTab(task, ctx); default: return nothing; } } /** The complete task-detail view. Renders into the PANEL's shadow root (this * is a render function, not a component) so the panel's dialogs and styles * keep working unchanged. */ export function renderTaskDetail(task: MaintenanceTask, ctx: TaskDetailContext) { return html`
${renderTaskHeader(task, ctx)} ${renderTabBar(ctx)} ${renderTabContent(task, ctx)}
`; }