Ctrl K
ComunidadGuías…

src/services/chartTooltip.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import type { Chart, TooltipItem as ChartTooltipItem, TooltipLabelStyle, TooltipModel } from 'chart.js'
import { useDark } from '@vueuse/core'
import { formatLocalDateLong, utcCalendarDayAsLocalDate } from '~/services/date'
import { formatNumberValue } from '~/services/formatLocale'

interface TooltipContext {
  chart: Chart
  tooltip: TooltipModel<'bar' | 'line'>
}

interface ProcessedTooltipItem {
  body: string[]
  value: number
  count?: number | null
  colors: TooltipLabelStyle
  appId?: string
  label?: string
}

export interface TooltipClickHandler {
  onAppClick?: (appId: string, clickContext?: { date: Date, dataIndex: number }) => void
  datasetIdKey?: 'appId' | 'breakdownId'
  appIdByLabel?: Record<string, string> // Maps app label/name to app ID
}

function getCanvasContext(ctx: unknown): CanvasRenderingContext2D | null {
  if (ctx && typeof (ctx as CanvasRenderingContext2D).save === 'function')
    return ctx as CanvasRenderingContext2D

  return null
}

function hasConnectedCanvas(chart: Pick<Chart, 'canvas' | 'ctx'>): boolean {
  const ctx = getCanvasContext(chart.ctx)
  const canvas = chart.canvas ?? ctx?.canvas
  return canvas?.isConnected !== false
}

function canSafelyUpdateChart(chart: Chart): boolean {
  return !!getCanvasContext(chart.ctx) && hasConnectedCanvas(chart)
}

function clearTooltipSelection(chart: Chart) {
  if (!canSafelyUpdateChart(chart))
    return

  chart.setActiveElements([])
  chart.update('none')
}

function formatTooltipValue(value: unknown) {
  if (typeof value !== 'number' || Number.isNaN(value))
    return '0'

  return formatNumberValue(value, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
}

/**
 * Calculate the actual date from the chart data index
 * @param dataIndex The index in the chart data array
 * @param dateStartOrUseBillingPeriod Either a Date for billing start, or boolean (false = last 30 days mode)
 */
function getDateFromIndex(dataIndex: number, dateStartOrUseBillingPeriod?: Date | boolean): Date {
  const today = new Date()
  today.setUTCHours(0, 0, 0, 0)

  if (dateStartOrUseBillingPeriod instanceof Date) {
    // Billing period mode: start from billing start date (UTC day)
    const date = new Date(dateStartOrUseBillingPeriod)
    date.setUTCHours(0, 0, 0, 0)
    date.setUTCDate(date.getUTCDate() + dataIndex)
    return date
  }

  // Last 30 days mode (dateStartOrUseBillingPeriod is false or undefined)
  const date = new Date(today)
  date.setUTCDate(date.getUTCDate() - 29 + dataIndex) // 29 days ago + index
  return date
}

/**
 * Format a date for tooltip display using the app's locale (e.g., "December 10" in English, "10 décembre" in French)
 */
function formatDateForTooltip(date: Date): string {
  return formatLocalDateLong(utcCalendarDayAsLocalDate(date))
}

function getDatasetBaseValue(
  chart: Chart | undefined,
  dataset: any,
  datasetIndex: number,
  dataIndex: number,
  parsedY: unknown,
  isAccumulated: boolean = false,
) {
  const metaValues = Array.isArray(dataset?.metaBaseValues) ? dataset.metaBaseValues as Array<number | null> : null
  if (metaValues) {
    const candidate = metaValues[dataIndex]
    if (typeof candidate === 'number' && Number.isFinite(candidate))
      return candidate
    return null
  }

  if (typeof parsedY !== 'number' || Number.isNaN(parsedY))
    return null

  // In non-accumulated charts, parsedY is already the real value.
  if (!isAccumulated)
    return parsedY

  if (!chart || datasetIndex <= 0)
    return parsedY

  const previousDataset: any = chart.data?.datasets?.[datasetIndex - 1]
  const previousRaw = Array.isArray(previousDataset?.data) ? previousDataset.data[dataIndex] : undefined
  const previousValue = typeof previousRaw === 'number' && Number.isFinite(previousRaw) ? previousRaw : 0
  return parsedY - previousValue
}

/**
 * Creates a custom Chart.js tooltip with smart positioning and scrollable content
 * @param context Chart.js tooltip context
 * @param isAccumulated Whether the chart is in accumulated mode
 * @param hasMultipleDatasets Whether the chart has multiple datasets (apps)
 * @param dateStartOrUseBillingPeriod Either a Date for billing start, or boolean for mode
 * @param clickHandler Optional click handler for interactive tooltip items
 */
export function createCustomTooltip(context: TooltipContext, isAccumulated: boolean = false, hasMultipleDatasets: boolean = true, dateStartOrUseBillingPeriod?: Date | boolean, clickHandler?: TooltipClickHandler) {
  const { chart, tooltip } = context
  const { canvas } = chart
  const isDark = useDark()
  const isMobile = 'ontouchstart' in window || navigator.maxTouchPoints > 0

  // Get or create tooltip element
  let tooltipEl = chart.canvas.parentNode?.querySelector('.chartjs-tooltip') as HTMLElement | null
  if (!tooltipEl) {
    tooltipEl = document.createElement('div')
    tooltipEl.className = 'chartjs-tooltip'
    tooltipEl.style.opacity = '0'
    tooltipEl.style.position = 'absolute'
    tooltipEl.style.background = isDark.value ? 'rgba(17, 24, 39, 0.97)' : 'rgba(255, 255, 255, 1)'
    tooltipEl.style.backdropFilter = 'blur(12px)'
    tooltipEl.style.borderRadius = '8px'
    tooltipEl.style.color = isDark.value ? 'white' : 'black'
    tooltipEl.style.border = isDark.value ? '1px solid rgba(55, 65, 81, 0.6)' : '1px solid rgba(209, 213, 219, 0.8)'
    // Default to non-interactive; we'll enable pointer events when needed
    tooltipEl.style.pointerEvents = 'none'
    tooltipEl.style.transform = 'translate(-50%, 0)'
    tooltipEl.style.transition = 'all .1s ease'
    tooltipEl.style.boxShadow = '0 10px 30px rgba(0, 0, 0, 0.15), 0 4px 10px rgba(0, 0, 0, 0.1)'
    tooltipEl.style.zIndex = '1000'
    tooltipEl.style.fontSize = '12px'
    tooltipEl.style.maxHeight = '60vh'
    tooltipEl.style.overflow = 'hidden'
    tooltipEl.style.minWidth = '200px'
    tooltipEl.style.maxWidth = '300px'
    chart.canvas.parentNode?.appendChild(tooltipEl)

    // Hover state listeners are attached later when we know we need pointer events

    // Add touch event listener for mobile to dismiss tooltip
    if (isMobile) {
      const dismissTooltip = (e: TouchEvent) => {
        // Check if the touch is not on the chart canvas
        if (!canvas.contains(e.target as Node)) {
          tooltipEl!.style.opacity = '0'
          clearTooltipSelection(chart)
        }
      }

      // Store the listener reference to remove it later if needed
      (tooltipEl as any).dismissListener = dismissTooltip
      document.addEventListener('touchstart', dismissTooltip, { passive: true })
    }
  }

  // Clear any existing auto-hide timer
  if ((tooltipEl as any).hideTimer) {
    clearTimeout((tooltipEl as any).hideTimer)
  }

  // Hide if no tooltip (but not if user is hovering over the tooltip for interactive mode)
  if (tooltip.opacity === 0) {
    // If tooltip is being hovered and we have click handlers, don't hide it
    if ((tooltipEl as any).isHovered && clickHandler?.onAppClick) {
      return
    }
    tooltipEl.style.opacity = '0'
    return
  }

  // Auto-hide on mobile after 3 seconds
  if (isMobile) {
    (tooltipEl as any).hideTimer = setTimeout(() => {
      tooltipEl.style.opacity = '0'
      // Trigger chart update to clear tooltip
      clearTooltipSelection(chart)
    }, 3000)
  }

  // Set content
  if (tooltip.body) {
    const dataPoints = tooltip.dataPoints || []

    const dataIndex = dataPoints[0]?.dataIndex ?? 0
    const tooltipDate = getDateFromIndex(dataIndex, dateStartOrUseBillingPeriod)
    const formattedTitle = formatDateForTooltip(tooltipDate)

    // Create an array of items with their values, colors, and labels
    const items: ProcessedTooltipItem[] = dataPoints.map((dataPoint, index) => {
      const datasetIndex = dataPoint.datasetIndex ?? 0
      const dataIndex = dataPoint.dataIndex ?? 0
      const dataset = dataPoint.dataset as any
      const baseValue = getDatasetBaseValue(chart, dataset, datasetIndex, dataIndex, dataPoint.parsed?.y, isAccumulated)
      const numericValue = typeof baseValue === 'number' && Number.isFinite(baseValue) ? baseValue : 0
      const countValues = Array.isArray(dataset?.metaCountValues) ? dataset.metaCountValues as Array<number | null | undefined> : null
      const countCandidate = countValues ? countValues[dataIndex] : null
      const numericCount = typeof countCandidate === 'number' && Number.isFinite(countCandidate)
        ? Math.max(0, Math.round(countCandidate))
        : null
      const label = dataset?.label ?? ''
      const candidateId: unknown = clickHandler?.datasetIdKey
        ? dataset?.[clickHandler.datasetIdKey]
        : clickHandler?.appIdByLabel?.[label]
      const appId = typeof candidateId === 'string' ? candidateId : undefined
      const formattedValue = numericCount !== null
        ? `${formatTooltipValue(numericValue)}% (${formatNumberValue(numericCount)})`
        : formatTooltipValue(numericValue)

      return {
        body: [`${formattedValue} - ${label}`],
        value: numericValue,
        count: numericCount,
        colors: tooltip.labelColors[index],
        appId,
        label,
      }
    }).filter(item => item.value !== 0 || (item.count ?? 0) !== 0)

    // Sort by value in descending order (highest to lowest)
    items.sort((a, b) => b.value - a.value)

    // Calculate total value based on mode - matching UsageCard logic
    let totalValue: number
    const isSingleApp = !hasMultipleDatasets

    if (isSingleApp) {
      if (isAccumulated) {
        // Single app cumulative mode: for the tooltip at a specific point,
        // we show the accumulated value at that point (which is already calculated in the data)
        const cumulativeValues = tooltip.dataPoints?.map((point: any) => point.parsed.y) || []
        totalValue = cumulativeValues.length > 0 ? cumulativeValues[0] : 0
      }
      else {
        // Single app daily mode: show the raw daily value
        totalValue = items.reduce((sum, item) => sum + item.value, 0)
      }
    }
    else {
      // Multi-app view
      totalValue = items.reduce((sum, item) => sum + item.value, 0)
    }
    const totalCount = items.reduce((sum, item) => sum + (typeof item.count === 'number' ? item.count : 0), 0)
    const hasCountValues = items.some(item => typeof item.count === 'number')

    const titleColor = isDark.value ? '#e5e7eb' : '#374151'
    const totalColor = isDark.value ? '#60a5fa' : '#2563eb'
    const container = document.createElement('div')
    container.style.padding = '12px'

    // Add title with formatted date
    const titleEl = document.createElement('div')
    titleEl.style.fontWeight = '600'
    titleEl.style.marginBottom = '4px'
    titleEl.style.color = titleColor
    titleEl.textContent = formattedTitle
    container.appendChild(titleEl)

    // Add total value
    if (items.length > 1) {
      const totalEl = document.createElement('div')
      totalEl.style.fontWeight = '600'
      totalEl.style.marginBottom = '8px'
      totalEl.style.paddingBottom = '8px'
      totalEl.style.borderBottom = `1px solid ${isDark.value ? 'rgba(75, 85, 99, 0.3)' : 'rgba(209, 213, 219, 0.3)'}`
      totalEl.style.color = totalColor
      totalEl.textContent = hasCountValues
        ? `Total devices: ${formatNumberValue(Math.round(totalCount))}`
        : `Total: ${formatTooltipValue(totalValue)}`
      container.appendChild(totalEl)
    }

    // Add body with scrollable content (now sorted)
    const bodyEl = document.createElement('div')
    bodyEl.className = 'tooltip-body'
    bodyEl.style.maxHeight = 'var(--tooltip-body-max-height, 40vh)'
    bodyEl.style.overflowY = 'auto'
    const hasClickHandler = !!clickHandler?.onAppClick
    items.forEach((item) => {
      // Convert color to string if it's not already
      const bgColor = typeof item.colors.backgroundColor === 'string' ? item.colors.backgroundColor : '#666'
      const borderColor = typeof item.colors.borderColor === 'string' ? item.colors.borderColor : '#999'

      const colorIndicator = document.createElement('div')
      colorIndicator.style.width = '12px'
      colorIndicator.style.height = '12px'
      colorIndicator.style.backgroundColor = bgColor
      colorIndicator.style.border = `1px solid ${borderColor}`
      colorIndicator.style.borderRadius = '2px'
      colorIndicator.style.marginRight = '8px'
      colorIndicator.style.flexShrink = '0'

      // Make item clickable if we have a click handler and app ID
      const isClickable = hasClickHandler && item.appId
      const rowEl = document.createElement('div')
      rowEl.style.display = 'flex'
      rowEl.style.alignItems = 'center'
      rowEl.style.marginBottom = '4px'
      rowEl.style.padding = '4px 6px'
      rowEl.style.marginLeft = '-6px'
      rowEl.style.marginRight = '-6px'
      rowEl.style.borderRadius = '4px'

      if (isClickable) {
        rowEl.classList.add('tooltip-app-item')
        rowEl.style.cursor = 'pointer'
        rowEl.style.transition = 'background-color 0.15s ease'
        rowEl.dataset.appId = String(item.appId)
      }

      const textContent = document.createElement('span')
      textContent.style.fontSize = '11px'
      textContent.textContent = item.body.join(' ')

      rowEl.appendChild(colorIndicator)
      rowEl.appendChild(textContent)
      bodyEl.appendChild(rowEl)
    })

    container.appendChild(bodyEl)
    tooltipEl.replaceChildren(container)

    // Add click handlers to clickable items
    if (hasClickHandler) {
      const clickableItems = tooltipEl.querySelectorAll('.tooltip-app-item[data-app-id]')
      clickableItems.forEach((el) => {
        const element = el as HTMLElement
        const appId = element.dataset.appId
        if (appId) {
          // Add hover effect
          element.addEventListener('mouseenter', () => {
            element.style.backgroundColor = isDark.value ? 'rgba(55, 65, 81, 0.5)' : 'rgba(229, 231, 235, 0.8)'
          })
          element.addEventListener('mouseleave', () => {
            element.style.backgroundColor = 'transparent'
          })
          // Add click handler with date context
          element.addEventListener('click', (e) => {
            e.stopPropagation()
            clickHandler.onAppClick!(appId, { date: tooltipDate, dataIndex })
            // Hide tooltip after click
            tooltipEl.style.opacity = '0'
          })
        }
      })
    }
  }

  // Position tooltip with smart viewport bounds checking
  positionTooltip(tooltipEl, canvas, tooltip)

  // Enable pointer events only when needed (clicks or scrolling)
  const bodyEl = tooltipEl.querySelector('.tooltip-body') as HTMLElement | null
  const bodyScrollable = !!bodyEl && bodyEl.scrollHeight > bodyEl.clientHeight
  const needsInteraction = !!clickHandler?.onAppClick || bodyScrollable
  tooltipEl.style.pointerEvents = needsInteraction ? 'auto' : 'none'

  if (needsInteraction && !(tooltipEl as any).hoverHandlersAttached) {
    tooltipEl.addEventListener('mouseenter', () => {
      (tooltipEl as any).isHovered = true
      if ((tooltipEl as any).hideTimer) {
        clearTimeout((tooltipEl as any).hideTimer)
      }
    })
    tooltipEl.addEventListener('mouseleave', () => {
      ;(tooltipEl as any).isHovered = false
      ;(tooltipEl as any).hideTimer = setTimeout(() => {
        if (!(tooltipEl as any).isHovered) {
          tooltipEl!.style.opacity = '0'
          clearTooltipSelection(chart)
        }
      }, 100)
    })
    ;(tooltipEl as any).hoverHandlersAttached = true
  }
}

/**
 * Positions the tooltip element with intelligent viewport bounds checking
 * @param tooltipEl The tooltip DOM element
 * @param canvas The chart canvas element
 * @param tooltip Chart.js tooltip object
 */
function positionTooltip(tooltipEl: HTMLElement, canvas: HTMLCanvasElement, tooltip: any) {
  // Position relative to the canvas parent (which is where tooltip is appended)
  // Use tooltip.caretX/Y directly since they're relative to the canvas
  let left = tooltip.caretX
  let top = tooltip.caretY

  // Get canvas dimensions for bounds checking
  const canvasWidth = canvas.offsetWidth
  const canvasHeight = canvas.offsetHeight

  // Constrain tooltip size to the canvas to avoid clipping in dense charts
  const maxTooltipWidth = Math.max(180, canvasWidth - 20)
  const maxTooltipHeight = Math.max(160, canvasHeight - 20)
  tooltipEl.style.maxWidth = `${Math.min(320, maxTooltipWidth)}px`
  tooltipEl.style.minWidth = `${Math.min(200, maxTooltipWidth)}px`
  tooltipEl.style.maxHeight = `${maxTooltipHeight}px`
  // Reserve space for title/total padding so body can scroll without nested scrollbars
  const bodyMaxHeight = Math.max(120, maxTooltipHeight - 80)
  tooltipEl.style.setProperty('--tooltip-body-max-height', `${bodyMaxHeight}px`)

  // Get tooltip dimensions
  const tooltipRect = tooltipEl.getBoundingClientRect()
  const tooltipWidth = tooltipRect.width
  const tooltipHeight = tooltipRect.height

  // Horizontal positioning - keep tooltip within canvas bounds
  // Center the tooltip on the caret position, then clamp within bounds
  const halfWidth = tooltipWidth / 2
  const minLeft = halfWidth + 10
  const maxLeft = canvasWidth - halfWidth - 10
  if (minLeft > maxLeft) {
    left = canvasWidth / 2
  }
  else {
    left = Math.min(Math.max(left, minLeft), maxLeft)
  }

  // Always use centered transform
  tooltipEl.style.transform = 'translate(-50%, 0)'

  // Vertical positioning - prefer below caret, then above; clamp to canvas
  const minTop = 10
  const maxTop = canvasHeight - tooltipHeight - 10
  if (tooltipHeight + 20 > canvasHeight) {
    top = minTop
  }
  else if (top + tooltipHeight > canvasHeight - 10) {
    top = tooltip.caretY - tooltipHeight - 10
  }
  if (top < minTop)
    top = minTop
  if (maxTop >= minTop && top > maxTop)
    top = maxTop

  // Apply position
  tooltipEl.style.left = `${left}px`
  tooltipEl.style.top = `${top}px`
  tooltipEl.style.opacity = '1'
}

/**
 * Plugin to draw vertical line at tooltip position
 */
interface VerticalLinePluginOptions {
  color?: string
  glowColor?: string
  lineWidth?: number
  glowWidth?: number
  dash?: number[]
  glowDash?: number[]
}

function isDarkMode() {
  if (typeof document !== 'undefined' && document.documentElement.classList.contains('dark'))
    return true

  if (typeof window !== 'undefined' && typeof window.matchMedia === 'function')
    return window.matchMedia('(prefers-color-scheme: dark)').matches

  return false
}

export const verticalLinePlugin = {
  id: 'verticalLine',
  afterDatasetsDraw(chart: Chart) {
    if (!hasConnectedCanvas(chart))
      return

    const active = chart.tooltip?.getActiveElements()
    if (chart.tooltip && active && active.length > 0) {
      const activePoint = active[0]
      const ctx = getCanvasContext(chart.ctx)
      if (!ctx)
        return
      const x = activePoint.element.x
      const topY = chart.scales.y.top
      const bottomY = chart.scales.y.bottom
      const pluginOptions = (chart.options?.plugins as any)?.verticalLine as VerticalLinePluginOptions | undefined
      const dark = isDarkMode()

      const lineColor = pluginOptions?.color ?? (dark ? 'rgba(255, 255, 255, 0.7)' : 'rgba(49, 70, 87, 0.6)')
      const glowColor = pluginOptions?.glowColor ?? (dark ? 'rgba(255, 255, 255, 0.25)' : 'rgba(129, 140, 248, 0.25)')
      const lineWidth = pluginOptions?.lineWidth ?? 2
      const glowWidth = pluginOptions?.glowWidth ?? 4
      const dash = pluginOptions?.dash ?? [8, 4]
      const glowDash = pluginOptions?.glowDash ?? dash

      // Save context state
      ctx.save()

      // Set higher z-index by drawing last
      ctx.globalCompositeOperation = 'source-over'

      // Draw vertical line with more visibility
      ctx.beginPath()
      ctx.moveTo(x, topY)
      ctx.lineTo(x, bottomY)
      ctx.lineWidth = lineWidth
      ctx.strokeStyle = lineColor
      ctx.setLineDash(dash)
      ctx.stroke()

      if (glowWidth > 0 && glowColor) {
        // Draw a subtle glow effect for better visibility
        ctx.beginPath()
        ctx.moveTo(x, topY)
        ctx.lineTo(x, bottomY)
        ctx.lineWidth = glowWidth
        ctx.strokeStyle = glowColor
        ctx.setLineDash(glowDash)
        ctx.stroke()
      }

      // Restore context state
      ctx.restore()
    }
  },
}

interface TodayLinePluginOptions {
  enabled?: boolean
  xIndex?: number
  label?: string
  color?: string
  glowColor?: string
  badgeFill?: string
  textColor?: string
}

function drawRoundedRect(ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, radius: number) {
  const r = Math.min(radius, width / 2, height / 2)

  ctx.beginPath()
  ctx.moveTo(x + r, y)
  ctx.lineTo(x + width - r, y)
  ctx.quadraticCurveTo(x + width, y, x + width, y + r)
  ctx.lineTo(x + width, y + height - r)
  ctx.quadraticCurveTo(x + width, y + height, x + width - r, y + height)
  ctx.lineTo(x + r, y + height)
  ctx.quadraticCurveTo(x, y + height, x, y + height - r)
  ctx.lineTo(x, y + r)
  ctx.quadraticCurveTo(x, y, x + r, y)
  ctx.closePath()
}

function resolveScale(chart: Chart, axisId: 'x' | 'y') {
  const scale = (chart.scales as Record<string, any>)[axisId]
  if (scale)
    return scale

  // Fallback to first matching axis for legacy ids
  const fallbackKey = Object.keys(chart.scales).find(key => key.toLowerCase().startsWith(axisId))
  return fallbackKey ? (chart.scales as Record<string, any>)[fallbackKey] : undefined
}

export const todayLinePlugin = {
  id: 'todayLine',
  afterDatasetsDraw(chart: Chart, _args: unknown, pluginOptions?: TodayLinePluginOptions) {
    const options = pluginOptions ?? {}
    if (!options.enabled)
      return

    if (!hasConnectedCanvas(chart))
      return

    const xScale = resolveScale(chart, 'x')
    const yScale = resolveScale(chart, 'y')
    if (!xScale || !yScale)
      return

    const index = typeof options.xIndex === 'number' ? options.xIndex : -1
    if (index < 0)
      return

    const x = typeof xScale.getPixelForValue === 'function'
      ? xScale.getPixelForValue(index)
      : undefined

    if (typeof x !== 'number' || Number.isNaN(x))
      return

    const ctx = getCanvasContext(chart.ctx)
    if (!ctx)
      return
    const topY = yScale.top ?? chart.chartArea?.top
    const bottomY = yScale.bottom ?? chart.chartArea?.bottom
    if (typeof topY !== 'number' || typeof bottomY !== 'number')
      return

    const strokeColor = options.color ?? 'rgba(99, 102, 241, 0.6)'
    const glowColor = options.glowColor ?? 'rgba(159, 168, 255, 0.25)'

    ctx.save()
    ctx.globalCompositeOperation = 'source-over'

    ctx.beginPath()
    ctx.moveTo(x, topY)
    ctx.lineTo(x, bottomY)
    ctx.lineWidth = 2
    ctx.strokeStyle = strokeColor
    ctx.setLineDash([8, 4])
    ctx.stroke()

    ctx.beginPath()
    ctx.moveTo(x, topY)
    ctx.lineTo(x, bottomY)
    ctx.lineWidth = 4
    ctx.strokeStyle = glowColor
    ctx.setLineDash([8, 4])
    ctx.stroke()

    ctx.setLineDash([])

    if (options.label) {
      const badgeFill = options.badgeFill ?? 'rgba(199, 210, 254, 0.85)'
      const textColor = options.textColor ?? '#312e81'
      const fontSize = 12
      const paddingX = 10
      const paddingY = 6
      const badgeY = (chart.chartArea?.top ?? topY) + 8

      ctx.font = `${fontSize}px sans-serif`
      const labelWidth = ctx.measureText(options.label).width
      const badgeWidth = labelWidth + paddingX * 2
      const badgeHeight = fontSize + paddingY

      let badgeX = x - badgeWidth / 2
      const chartLeft = chart.chartArea?.left ?? 0
      const chartRight = chart.chartArea?.right ?? chart.width

      if (badgeX < chartLeft + 4)
        badgeX = chartLeft + 4
      if (badgeX + badgeWidth > chartRight - 4)
        badgeX = chartRight - badgeWidth - 4

      drawRoundedRect(ctx, badgeX, badgeY, badgeWidth, badgeHeight, 6)
      ctx.fillStyle = badgeFill
      ctx.fill()

      ctx.lineWidth = 1
      ctx.strokeStyle = strokeColor
      ctx.stroke()

      ctx.fillStyle = textColor
      ctx.fillText(options.label, badgeX + paddingX, badgeY + badgeHeight - paddingY / 2)
    }

    ctx.restore()
  },
}

/**
 * Creates tooltip configuration for Chart.js options
 * @param hasMultipleDatasets Whether the chart has multiple datasets (apps)
 * @param isAccumulated Whether the chart is in accumulated/cumulative mode
 * @param dateStartOrUseBillingPeriod Either a Date for billing start, or boolean for mode
 * @param clickHandler Optional click handler for interactive tooltip items
 * @returns Chart.js tooltip configuration object
 */
export function createTooltipConfig(hasMultipleDatasets: boolean, isAccumulated: boolean = false, dateStartOrUseBillingPeriod?: Date | boolean, clickHandler?: TooltipClickHandler) {
  return {
    mode: 'index' as const,
    intersect: false,
    position: 'nearest' as const,
    // Always use custom tooltip for consistent design across single and multi-app views
    external: (context: TooltipContext) => createCustomTooltip(context, isAccumulated, hasMultipleDatasets, dateStartOrUseBillingPeriod, clickHandler),
    enabled: false, // Always disable default tooltip since we use custom
    callbacks: {
      title(tooltipItems: ChartTooltipItem<any>[]) {
        // Format the title to show full date like "December 10"
        const dataIndex = tooltipItems[0].dataIndex
        const date = getDateFromIndex(dataIndex, dateStartOrUseBillingPeriod)
        return formatDateForTooltip(date)
      },
      label(context: ChartTooltipItem<any>) {
        if (isAccumulated && !hasMultipleDatasets) {
          // For single dataset in accumulated mode, show total
          return `Total: ${formatNumberValue(context.parsed.y)}`
        }
        else if (hasMultipleDatasets) {
          const datasetIndex = context.datasetIndex ?? 0
          const dataIndex = context.dataIndex ?? 0
          const baseValue = getDatasetBaseValue(context.chart, context.dataset, datasetIndex, dataIndex, context.parsed?.y, isAccumulated)
          const numericValue = typeof baseValue === 'number' && Number.isFinite(baseValue) ? baseValue : 0
          const countValues = Array.isArray((context.dataset as any)?.metaCountValues)
            ? ((context.dataset as any).metaCountValues as Array<number | null | undefined>)
            : null
          const countCandidate = countValues ? countValues[dataIndex] : null
          const numericCount = typeof countCandidate === 'number' && Number.isFinite(countCandidate)
            ? Math.max(0, Math.round(countCandidate))
            : null
          if (numericCount !== null)
            return `${formatTooltipValue(numericValue)}% (${formatNumberValue(numericCount)}) - ${context.dataset.label}`
          return `${formatTooltipValue(numericValue)} - ${context.dataset.label}`
        }
        // For single dataset in daily mode, use default formatting
        return undefined
      },
      afterLabel(context: ChartTooltipItem<any>) {
        // In accumulated mode, show the daily value in parentheses
        if (isAccumulated && !hasMultipleDatasets && context.parsed.y > 0) {
          const dataIndex = context.dataIndex
          const currentValue = context.parsed.y
          const previousValue = dataIndex > 0 ? context.dataset.data[dataIndex - 1] : 0
          const dailyValue = currentValue - previousValue
          return dailyValue > 0 ? `(+${formatNumberValue(dailyValue)} today)` : undefined
        }
        return undefined
      },
    },
  }
}