Ctrl K
ComunidadGuías…

src/services/buildCharts.ts

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

Archivo original ↗Preguntar a KiBot
// Shared helpers for the build charts (Builds by status + Build time) and the
// org-wide native build summary.

import { addUtcDays, normalizeToUtcStartOfDay } from '~/services/date'

const DAY_IN_MS = 1000 * 60 * 60 * 24
export const WINDOW_DAYS = 30

export const BUILD_SERIES_KEYS = ['ios_succeeded', 'android_succeeded', 'ios_failed', 'android_failed'] as const
export type BuildSeriesKey = typeof BUILD_SERIES_KEYS[number]
export type BuildSeriesData = Record<BuildSeriesKey, number[]>

export function emptyBuildSeries(length: number = WINDOW_DAYS): BuildSeriesData {
  return {
    ios_succeeded: Array.from({ length }).fill(0) as number[],
    android_succeeded: Array.from({ length }).fill(0) as number[],
    ios_failed: Array.from({ length }).fill(0) as number[],
    android_failed: Array.from({ length }).fill(0) as number[],
  }
}

// Map a build_requests.status into a terminal outcome. Returns null for builds
// that are still in flight (pending/ready/starting/running) or unknown — they
// are not counted in the succeeded/failed breakdown. Both cancel spellings are
// handled since the backend uses each in different places.
export function bucketBuildStatus(status: string | null | undefined): 'succeeded' | 'failed' | null {
  switch (status) {
    case 'succeeded':
    case 'completed':
      return 'succeeded'
    case 'failed':
    case 'cancelled':
    case 'canceled':
    case 'expired':
      return 'failed'
    default:
      return null
  }
}

// Resolve the {platform}_{outcome} series key, or null for unsupported platforms.
export function buildSeriesKey(platform: string | null | undefined, outcome: 'succeeded' | 'failed'): BuildSeriesKey | null {
  if (platform === 'ios')
    return outcome === 'succeeded' ? 'ios_succeeded' : 'ios_failed'
  if (platform === 'android')
    return outcome === 'succeeded' ? 'android_succeeded' : 'android_failed'
  return null
}

export interface BuildChartWindow {
  windowStart: Date // UTC midnight of the first rendered day
  startISO: string // inclusive lower bound for created_at queries
  endISO: string // inclusive upper bound (end of today)
  dayCount: number // number of days from windowStart..today inclusive
}

// Resolve the fetch/render window. In billing-period mode it starts at the
// org's current cycle anchor (subscription_start) so the full cycle is covered
// — including a 31st day — instead of a fixed 30-day buffer. In last-30-days
// mode it is the trailing 30 days. Either way data is bucketed by day from
// windowStart, so there is no separate billing remapping step.
export function getBuildChartWindow(useBillingPeriod: boolean, subscriptionStart?: string | null): BuildChartWindow {
  const todayStart = normalizeToUtcStartOfDay()
  const endOfToday = new Date()

  let windowStart = new Date(todayStart)

  const cycleStart = subscriptionStart ? normalizeToUtcStartOfDay(new Date(subscriptionStart)) : null
  if (useBillingPeriod && cycleStart && !Number.isNaN(cycleStart.getTime())) {
    const elapsedDays = Math.floor((todayStart.getTime() - cycleStart.getTime()) / DAY_IN_MS)
    // Guard against a future or implausibly old anchor; fall back to 30 days.
    if (elapsedDays >= 0 && elapsedDays <= 366)
      windowStart = cycleStart
    else
      windowStart = addUtcDays(windowStart, -(WINDOW_DAYS - 1))
  }
  else {
    windowStart = addUtcDays(windowStart, -(WINDOW_DAYS - 1))
  }

  const dayCount = Math.max(Math.floor((todayStart.getTime() - windowStart.getTime()) / DAY_IN_MS) + 1, 1)
  return { windowStart, startISO: windowStart.toISOString(), endISO: endOfToday.toISOString(), dayCount }
}

export function dayIndexInWindow(date: Date, windowStart: Date): number {
  return Math.floor((date.getTime() - windowStart.getTime()) / DAY_IN_MS)
}

// Paginate a range query so apps above the configured max_rows cap are
// not silently undercounted. `runRange` must apply `.range(from, to)` and return
// the capgo result.
export async function fetchAllRows<T>(runRange: (from: number, to: number) => PromiseLike<{ data: T[] | null, error: unknown }>): Promise<T[]> {
  const PAGE_SIZE = 1000
  const rows: T[] = []
  for (let offset = 0; ; offset += PAGE_SIZE) {
    const { data, error } = await runRange(offset, offset + PAGE_SIZE - 1)
    if (error)
      throw error
    if (!data || data.length === 0)
      break
    rows.push(...data)
    if (data.length < PAGE_SIZE)
      break
  }
  return rows
}

// Percentage change between the last two non-zero days of a series.
export function computeLastDayEvolution(series: number[]): number {
  const nonZeroDays = series.filter(count => count > 0)
  if (nonZeroDays.length < 2)
    return 0
  const lastDayCount = nonZeroDays[nonZeroDays.length - 1]
  const previousDayCount = nonZeroDays[nonZeroDays.length - 2]
  return previousDayCount > 0 ? ((lastDayCount - previousDayCount) / previousDayCount) * 100 : 0
}

// Index of "today" within the rendered labels (so future billing-period days
// stay empty).
export function getTodayLimit(labelCount: number, useBillingPeriod: boolean, cycleStart: Date, cycleEnd: Date): number {
  if (!useBillingPeriod)
    return labelCount - 1

  const today = normalizeToUtcStartOfDay()

  if (cycleEnd <= today)
    return labelCount - 1

  const diff = Math.floor((today.getTime() - cycleStart.getTime()) / DAY_IN_MS)
  if (Number.isNaN(diff) || diff < 0)
    return -1

  return Math.min(diff, labelCount - 1)
}

// Turn a raw per-day series into chart display values (daily or cumulative),
// stopping at `limitIndex` (clamped to the array bounds).
export function transformSeries(source: number[], accumulated: boolean, labelCount: number, limitIndex: number) {
  const display: Array<number | null> = Array.from({ length: labelCount }).fill(null) as Array<number | null>
  const base: Array<number | null> = Array.from({ length: labelCount }).fill(null) as Array<number | null>

  const safeLimit = Math.min(limitIndex, labelCount - 1)
  if (safeLimit < 0)
    return { display, base }

  let runningTotal = 0
  for (let index = 0; index <= safeLimit; index++) {
    const hasValue = index < source.length && typeof source[index] === 'number' && Number.isFinite(source[index])
    const numericValue = hasValue ? source[index] as number : 0

    base[index] = numericValue
    if (accumulated) {
      runningTotal += numericValue
      display[index] = runningTotal
    }
    else {
      display[index] = numericValue
    }
  }

  return { display, base }
}

export interface BuildSummaryRow {
  app_id: string
  status: string | null
  created_at: string | null
  started_at: string | null
  completed_at: string | null
  runner_wait_seconds: number | null
}

export interface BuildTotals {
  total: number
  succeeded: number
  failed: number
  inProgress: number
  // Share of finished builds that succeeded, 0-100, or null when nothing finished.
  successRate: number | null
  // Mean run time (started_at -> completed_at) of finished builds, in seconds.
  avgDurationSeconds: number | null
  // Mean time spent waiting for a runner, in seconds.
  avgWaitSeconds: number | null
}

export interface AppBuildSummary extends BuildTotals {
  appId: string
  lastBuildAt: string | null
}

interface BuildAccumulator {
  total: number
  succeeded: number
  failed: number
  durationSum: number
  durationCount: number
  waitSum: number
  waitCount: number
  lastBuildAt: string | null
}

function emptyAccumulator(): BuildAccumulator {
  return { total: 0, succeeded: 0, failed: 0, durationSum: 0, durationCount: 0, waitSum: 0, waitCount: 0, lastBuildAt: null }
}

function addToAccumulator(acc: BuildAccumulator, row: BuildSummaryRow) {
  acc.total += 1
  const outcome = bucketBuildStatus(row.status)
  if (outcome === 'succeeded')
    acc.succeeded += 1
  else if (outcome === 'failed')
    acc.failed += 1

  if (outcome && row.started_at && row.completed_at) {
    const seconds = (new Date(row.completed_at).getTime() - new Date(row.started_at).getTime()) / 1000
    if (Number.isFinite(seconds) && seconds >= 0) {
      acc.durationSum += seconds
      acc.durationCount += 1
    }
  }

  if (row.started_at && row.runner_wait_seconds != null && row.runner_wait_seconds >= 0) {
    acc.waitSum += row.runner_wait_seconds
    acc.waitCount += 1
  }

  if (row.created_at && (!acc.lastBuildAt || row.created_at > acc.lastBuildAt))
    acc.lastBuildAt = row.created_at
}

function toTotals(acc: BuildAccumulator): BuildTotals {
  const finished = acc.succeeded + acc.failed
  return {
    total: acc.total,
    succeeded: acc.succeeded,
    failed: acc.failed,
    inProgress: acc.total - finished,
    successRate: finished > 0 ? (acc.succeeded / finished) * 100 : null,
    avgDurationSeconds: acc.durationCount > 0 ? acc.durationSum / acc.durationCount : null,
    avgWaitSeconds: acc.waitCount > 0 ? acc.waitSum / acc.waitCount : null,
  }
}

// Aggregate build requests into org-wide totals plus a per-app breakdown
// (sorted by build count, busiest app first).
export function summarizeBuilds(rows: BuildSummaryRow[]): { totals: BuildTotals, apps: AppBuildSummary[] } {
  const overall = emptyAccumulator()
  const byApp = new Map<string, BuildAccumulator>()

  for (const row of rows) {
    addToAccumulator(overall, row)
    let appAcc = byApp.get(row.app_id)
    if (!appAcc) {
      appAcc = emptyAccumulator()
      byApp.set(row.app_id, appAcc)
    }
    addToAccumulator(appAcc, row)
  }

  const apps = [...byApp.entries()]
    .map(([appId, acc]) => ({ appId, lastBuildAt: acc.lastBuildAt, ...toTotals(acc) }))
    .sort((a, b) => b.total - a.total || a.appId.localeCompare(b.appId))

  return { totals: toTotals(overall), apps }
}

// Compact human duration (e.g. "45s", "3m 20s", "1h 05m").
export function formatBuildDuration(seconds: number | null | undefined): string {
  if (seconds == null || !Number.isFinite(seconds))
    return '-'
  const rounded = Math.round(seconds)
  if (rounded < 60)
    return `${rounded}s`
  const minutes = Math.floor(rounded / 60)
  if (minutes < 60)
    return `${minutes}m ${String(rounded % 60).padStart(2, '0')}s`
  const hours = Math.floor(minutes / 60)
  return `${hours}h ${String(minutes % 60).padStart(2, '0')}m`
}