Repository: l2beat/l2beat
Pull request #7502: Implement throughput by project chart
Author: login: torztomasz
Created: 2025-04-16T06:51:54Z | Merged: 2025-04-28T07:52:29Z



Diff:
diff --git a/packages/frontend/src/app/(top-nav)/data-availability/projects/[layer]/_utils/get-da-throughput-section.ts b/packages/frontend/src/app/(top-nav)/data-availability/projects/[layer]/_utils/get-da-throughput-section.ts
index 0072c1b64063..059662802747 100644
--- a/packages/frontend/src/app/(top-nav)/data-availability/projects/[layer]/_utils/get-da-throughput-section.ts
+++ b/packages/frontend/src/app/(top-nav)/data-availability/projects/[layer]/_utils/get-da-throughput-section.ts
@@ -2,18 +2,23 @@ import type { Project } from '@l2beat/config'
 import { UnixTime } from '@l2beat/shared-pure'
 import { getDaThroughputTable } from '~/server/features/data-availability/throughput/get-da-throughput-table'
 import { getThroughputSyncWarning } from '~/server/features/data-availability/throughput/is-throughput-synced'
+import { ps } from '~/server/projects'
 import { api } from '~/trpc/server'
 
 export async function getDaThroughputSection(
   project: Project<'daLayer' | 'statuses' | 'display', 'milestones'>,
 ) {
-  const throughputChart = await api.da.projectChart({
-    range: 'max',
-    projectId: project.id,
-  })
+  const [throughputChart, throughputData, projectsWithColors] =
+    await Promise.all([
+      api.da.projectChart({
+        range: 'max',
+        projectId: project.id,
+      }),
+      getDaThroughputTable([project.id]),
+      ps.getProjects({ select: ['colors'] }),
+    ])
   if (throughputChart.chart.length === 0) return undefined
 
-  const throughputData = await getDaThroughputTable([project.id])
   const projectData = throughputData.data[project.id]
 
   if (!projectData) return undefined
@@ -34,6 +39,9 @@ export async function getDaThroughputSection(
       warning: notSyncedStatus,
       isSynced: !notSyncedStatus,
     },
+    customColors: Object.fromEntries(
+      projectsWithColors.map((p) => [p.name, p.colors.primary]),
+    ),
     milestones: project.milestones ?? [],
   }
 }
diff --git a/packages/frontend/src/components/chart/data-availability/da-absolute-throughput-chart.tsx b/packages/frontend/src/components/chart/data-availability/da-absolute-throughput-chart.tsx
index 7a4f2c6ee3a8..93d95038be93 100644
--- a/packages/frontend/src/components/chart/data-availability/da-absolute-throughput-chart.tsx
+++ b/packages/frontend/src/components/chart/data-availability/da-absolute-throughput-chart.tsx
@@ -1,9 +1,8 @@
 'use client'
 
-import { assert } from '@l2beat/shared-pure'
 import { useMemo } from 'react'
-import { AreaChart } from 'recharts'
 import type { TooltipProps } from 'recharts'
+import { AreaChart } from 'recharts'
 
 import {
   ChartContainer,
@@ -38,7 +37,16 @@ interface Props {
 
 export function DaAbsoluteThroughputChart({ data, isLoading }: Props) {
   const chartMeta = getDaChartMeta({ shape: 'line' })
-  const { denominator, unit } = getDaDataParams(data)
+  const max = useMemo(() => {
+    return data
+      ? Math.max(
+          ...data.map(([_, ...rest]) =>
+            Math.max(...rest.filter((x) => x !== null)),
+          ),
+        )
+      : undefined
+  }, [data])
+  const { denominator, unit } = getDaDataParams(max)
   const chartData = useMemo(() => {
     return data?.map(([timestamp, ethereum, celestia, avail]) => {
       return {
@@ -116,7 +124,7 @@ function CustomTooltip({
         {payload.map((entry, index) => {
           if (entry.type === 'none') return null
           const configEntry = entry.name ? config[entry.name] : undefined
-          assert(configEntry, 'Config entry not found')
+          if (!configEntry) return null
           return (
             <div
               key={index}
diff --git a/packages/frontend/src/components/chart/data-availability/da-percentage-throughput-chart.tsx b/packages/frontend/src/components/chart/data-availability/da-percentage-throughput-chart.tsx
index 70d30f22f77e..64d318967e12 100644
--- a/packages/frontend/src/components/chart/data-availability/da-percentage-throughput-chart.tsx
+++ b/packages/frontend/src/components/chart/data-availability/da-percentage-throughput-chart.tsx
@@ -1,10 +1,9 @@
 'use client'
 
-import { assert } from '@l2beat/shared-pure'
 import { round } from 'lodash'
 import { useMemo } from 'react'
-import { Bar, BarChart } from 'recharts'
 import type { TooltipProps } from 'recharts'
+import { Bar, BarChart } from 'recharts'
 
 import {
   ChartContainer,
@@ -108,7 +107,7 @@ function CustomTooltip({
       <div className="flex flex-col gap-2">
         {payload.map((entry, index) => {
           const configEntry = entry.name ? meta[entry.name] : undefined
-          assert(configEntry, 'Config entry not found')
+          if (!configEntry) return null
 
           return (
             <div
diff --git a/packages/frontend/src/components/chart/data-availability/da-throughput-by-project-chart.tsx b/packages/frontend/src/components/chart/data-availability/da-throughput-by-project-chart.tsx
new file mode 100644
index 000000000000..23d91006b0f4
--- /dev/null
+++ b/packages/frontend/src/components/chart/data-availability/da-throughput-by-project-chart.tsx
@@ -0,0 +1,185 @@
+'use client'
+import { sum } from 'lodash'
+import { useMemo } from 'react'
+import type { TooltipProps } from 'recharts'
+import { Bar, BarChart } from 'recharts'
+import type { ChartMeta } from '~/components/core/chart/chart'
+import {
+  ChartContainer,
+  ChartLegend,
+  ChartLegendContent,
+  ChartTooltip,
+  ChartTooltipWrapper,
+  useChart,
+} from '~/components/core/chart/chart'
+import { ChartDataIndicator } from '~/components/core/chart/chart-data-indicator'
+import { getCommonChartComponents } from '~/components/core/chart/utils/get-common-chart-components'
+import { HorizontalSeparator } from '~/components/core/horizontal-separator'
+import type { DaThroughputChartDataByChart } from '~/server/features/data-availability/throughput/get-da-throughput-chart-by-project'
+import { formatTimestamp } from '~/utils/dates'
+import { generateAccessibleColors } from '~/utils/generate-colors'
+import { getDaDataParams } from './get-da-data-params'
+
+interface Props {
+  data: DaThroughputChartDataByChart | undefined
+  isLoading: boolean
+  projectsToShow: string[]
+  customColors: Record<string, string>
+}
+
+export function DaThroughputByProjectChart({
+  data,
+  isLoading,
+  projectsToShow,
+  customColors,
+}: Props) {
+  const colors = useMemo(
+    () => generateAccessibleColors(projectsToShow.length),
+    [projectsToShow],
+  )
+
+  const max = useMemo(() => {
+    return data
+      ? Math.max(
+          ...data.map(([_, values]) =>
+            sum(
+              Object.entries(values).map(([project, value]) => {
+                if (!projectsToShow.includes(project)) return 0
+                return value
+              }),
+            ),
+          ),
+        )
+      : undefined
+  }, [data, projectsToShow])
+
+  const { denominator, unit } = getDaDataParams(max)
+
+  const chartMeta = useMemo(() => {
+    let colorIndex = 0
+    return projectsToShow?.reduce((acc, project) => {
+      if (!acc[project]) {
+        acc[project] = {
+          label: project,
+          color:
+            project === 'Unknown'
+              ? 'hsl(var(--secondary))'
+              : // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+                (customColors[project] ?? colors[colorIndex++]!),
+          indicatorType: { shape: 'square' },
+        }
+      }
+
+      return acc
+    }, {} as ChartMeta)
+  }, [colors, customColors, projectsToShow])
+
+  const chartData = useMemo(() => {
+    if (projectsToShow.length === 0) {
+      return []
+    }
+
+    return (
+      data?.map(([timestamp, values]) => {
+        return {
+          timestamp: timestamp,
+          ...Object.fromEntries(
+            Object.entries(values)
+              .map(([key, value]) => {
+                if (!projectsToShow.includes(key)) return
+                return [key, value / denominator] as const
+              })
+              .filter((v) => v !== undefined),
+          ),
+        }
+      }) ?? []
+    )
+  }, [data, projectsToShow, denominator])
+
+  return (
+    <ChartContainer data={chartData} meta={chartMeta} isLoading={isLoading}>
+      <BarChart
+        accessibilityLayer
+        data={chartData}
+        margin={{ top: 20 }}
+        barCategoryGap={0}
+      >
+        <ChartLegend content={<ChartLegendContent />} />
+        {projectsToShow?.map((project) => (
+          <Bar
+            key={project}
+            dataKey={project}
+            stackId="a"
+            fill={chartMeta?.[project]?.color}
+            isAnimationActive={false}
+          />
+        ))}
+
+        {getCommonChartComponents({
+          data: chartData,
+          isLoading,
+          yAxis: {
+            unit: ` ${unit}`,
+            tickCount: 3,
+            tick: {
+              width: 100,
+            },
+          },
+        })}
+        <ChartTooltip content={<CustomTooltip denominator={denominator} />} />
+      </BarChart>
+    </ChartContainer>
+  )
+}
+
+function CustomTooltip({
+  active,
+  payload,
+  label,
+  denominator: mainDenominator,
+}: TooltipProps<number, string> & { denominator: number }) {
+  const { meta: config } = useChart()
+  if (!active || !payload || typeof label !== 'number') return null
+  payload.sort((a, b) => {
+    if (a.name === 'Unknown') return 1
+    if (b.name === 'Unknown') return -1
+    return (b.value ?? 0) - (a.value ?? 0)
+  })
+
+  return (
+    <ChartTooltipWrapper>
+      <div className="text-secondary">
+        {formatTimestamp(label, { longMonthName: true })}
+      </div>
+      <HorizontalSeparator className="my-1" />
+      <div>
+        {payload.map((entry, index) => {
+          if (entry.type === 'none') return null
+          const configEntry = entry.name ? config[entry.name] : undefined
+          if (!configEntry) return null
+          const value = entry.value ?? 0
+          const valueInBytes = value * mainDenominator
+          const { unit, denominator } = getDaDataParams(valueInBytes)
+          const formattedValue = (valueInBytes / denominator).toFixed(2)
+          return (
+            <div
+              key={index}
+              className="flex items-center justify-between gap-x-6"
+            >
+              <div className="flex items-center gap-1">
+                <ChartDataIndicator
+                  backgroundColor={configEntry.color}
+                  type={configEntry.indicatorType}
+                />
+                <span className="text-secondary">{configEntry.label}</span>
+              </div>
+              <span className="font-medium tabular-nums text-primary">
+                {formattedValue} {unit}
+              </span>
+            </div>
+          )
+        })}
+      </div>
+    </ChartTooltipWrapper>
+  )
+}
diff --git a/packages/frontend/src/components/chart/data-availability/get-da-data-params.ts b/packages/frontend/src/components/chart/data-availability/get-da-data-params.ts
index 504992251f67..404c984c8a57 100644
--- a/packages/frontend/src/components/chart/data-availability/get-da-data-params.ts
+++ b/packages/frontend/src/components/chart/data-availability/get-da-data-params.ts
@@ -1,12 +1,10 @@
-export function getDaDataParams(data: (number | null)[][] | undefined) {
-  if (!data)
+export function getDaDataParams(max: number | undefined) {
+  if (!max)
     return {
       denominator: 1,
       unit: 'B',
     }
-  const max = Math.max(
-    ...data.map(([_, ...rest]) => Math.max(...rest.filter((x) => x !== null))),
-  )
+
   if (max < 1024)
     return {
       denominator: 1,
diff --git a/packages/frontend/src/components/chart/data-availability/project-da-absolute-throughput-chart.tsx b/packages/frontend/src/components/chart/data-availability/project-da-absolute-throughput-chart.tsx
index 21bf4e61c5f1..16845c14e8cd 100644
--- a/packages/frontend/src/components/chart/data-availability/project-da-absolute-throughput-chart.tsx
+++ b/packages/frontend/src/components/chart/data-availability/project-da-absolute-throughput-chart.tsx
@@ -1,10 +1,9 @@
 'use client'
 
 import type { ProjectId } from '@l2beat/shared-pure'
-import { assert } from '@l2beat/shared-pure'
 import { useMemo } from 'react'
-import { Area, AreaChart } from 'recharts'
 import type { TooltipProps } from 'recharts'
+import { Area, AreaChart } from 'recharts'
 
 import type { Milestone } from '@l2beat/config'
 import type { ChartMeta } from '~/components/core/chart/chart'
@@ -54,7 +53,16 @@ export function ProjectDaAbsoluteThroughputChart({
   showMax,
   milestones,
 }: Props) {
-  const { denominator, unit } = getDaDataParams(dataWithConfiguredThroughputs)
+  const max = useMemo(() => {
+    return dataWithConfiguredThroughputs
+      ? Math.max(
+          ...dataWithConfiguredThroughputs.map(([_, ...rest]) =>
+            Math.max(...rest.filter((x) => x !== null)),
+          ),
+        )
+      : undefined
+  }, [dataWithConfiguredThroughputs])
+  const { denominator, unit } = getDaDataParams(max)
 
   const chartData = useMemo(() => {
     return dataWithConfiguredThroughputs?.map(
@@ -171,7 +179,7 @@ function CustomTooltip({
       <div className="flex flex-col gap-2">
         {payload.map((entry, index) => {
           const configEntry = entry.name ? config[entry.name] : undefined
-          assert(configEntry, 'Config entry not found')
+          if (!configEntry) return null
 
           return (
             <div
diff --git a/packages/frontend/src/components/chart/data-availability/project-da-throughput-chart.tsx b/packages/frontend/src/components/chart/data-availability/throughput-section-absolute-chart.tsx
similarity index 90%
rename from packages/frontend/src/components/chart/data-availability/project-da-throughput-chart.tsx
rename to packages/frontend/src/components/chart/data-availability/throughput-section-absolute-chart.tsx
index b1f5b1ca2996..cddc73990be6 100644
--- a/packages/frontend/src/components/chart/data-availability/project-da-throughput-chart.tsx
+++ b/packages/frontend/src/components/chart/data-availability/throughput-section-absolute-chart.tsx
@@ -2,7 +2,7 @@
 
 import type { DaLayerThroughput, Milestone } from '@l2beat/config'
 import { type ProjectId, UnixTime } from '@l2beat/shared-pure'
-import { useMemo, useState } from 'react'
+import { useMemo } from 'react'
 import { ProjectChartTimeRange } from '~/components/core/chart/chart-time-range'
 import { ChartTimeRangeControls } from '~/components/core/chart/chart-time-range-controls'
 import { getChartRange } from '~/components/core/chart/utils/get-chart-range-from-columns'
@@ -14,22 +14,27 @@ import type { ProjectChartDataWithConfiguredThroughput } from './project-da-abso
 import { ProjectDaAbsoluteThroughputChart } from './project-da-absolute-throughput-chart'
 
 interface Props {
-  projectId: ProjectId
+  daLayer: ProjectId
   configuredThroughputs: DaLayerThroughput[]
   milestones: Milestone[]
+  range: DaThroughputTimeRange
+  setRange: (range: DaThroughputTimeRange) => void
+  showMax: boolean
+  setShowMax: (showMax: boolean) => void
 }
 
-export function ProjectDaThroughputChart({
-  projectId,
+export function ThroughputSectionAbsoluteChart({
+  daLayer,
   configuredThroughputs,
   milestones,
+  range,
+  setRange,
+  showMax,
+  setShowMax,
 }: Props) {
-  const [range, setRange] = useState<DaThroughputTimeRange>('1y')
-  const [showMax, setShowMax] = useState(true)
-
   const { data, isLoading } = api.da.projectChart.useQuery({
     range,
-    projectId,
+    projectId: daLayer,
   })
 
   const chartRange = useMemo(
@@ -66,7 +71,7 @@ export function ProjectDaThroughputChart({
         </div>
       </div>
       <ProjectDaAbsoluteThroughputChart
-        projectId={projectId}
+        projectId={daLayer}
         dataWithConfiguredThroughputs={dataWithConfiguredThroughputs}
         isLoading={isLoading}
         showMax={showMax}
diff --git a/packages/frontend/src/components/chart/data-availability/throughput-section-by-project-chart.tsx b/packages/frontend/src/components/chart/data-availability/throughput-section-by-project-chart.tsx
new file mode 100644
index 000000000000..573195018800
--- /dev/null
+++ b/packages/frontend/src/components/chart/data-availability/throughput-section-by-project-chart.tsx
@@ -0,0 +1,100 @@
+'use client'
+import { uniq } from 'lodash'
+import { useMemo } from 'react'
+import { ProjectChartTimeRange } from '~/components/core/chart/chart-time-range'
+import { ChartTimeRangeControls } from '~/components/core/chart/chart-time-range-controls'
+import { getChartRange } from '~/components/core/chart/utils/get-chart-range-from-columns'
+import { Skeleton } from '~/components/core/skeleton'
+import { ProjectCombobox } from '~/components/project-combobox'
+import { DaThroughputTimeRange } from '~/server/features/data-availability/throughput/utils/range'
+import { api } from '~/trpc/react'
+import { DaThroughputByProjectChart } from './da-throughput-by-project-chart'
+
+const DEFAULT_PROJECTS_TO_SHOW = 5
+
+interface Props {
+  daLayer: string
+  range: DaThroughputTimeRange
+  setRange: (range: DaThroughputTimeRange) => void
+  selectedProjects: string[] | undefined
+  setSelectedProjects: (projects: string[] | undefined) => void
+  customColors: Record<string, string>
+}
+
+export function ThroughputSectionByProjectChart({
+  daLayer,
+  range,
+  setRange,
+  selectedProjects,
+  setSelectedProjects,
+  customColors,
+}: Props) {
+  const { data, isLoading } = api.da.projectChartByProject.useQuery({
+    range,
+    daLayer,
+  })
+
+  const allProjects = useMemo(() => {
+    // We want to get latest top projects.
+    return data
+      ? uniq(
+          [...data]
+            .reverse()
+            .flatMap(([_, values]) => Object.keys(values))
+            .sort((a, b) => {
+              if (a === 'Unknown') return 1
+              if (b === 'Unknown') return -1
+              return 0
+            }),
+        )
+      : []
+  }, [data])
+
+  const chartRange = useMemo(
+    () => getChartRange(data?.map(([timestamp]) => ({ timestamp }))),
+    [data],
+  )
+
+  const projectsToShow = useMemo(
+    () =>
+      selectedProjects ??
+      allProjects
+        .filter((p) => p !== 'Unknown')
+        .slice(0, DEFAULT_PROJECTS_TO_SHOW),
+    [allProjects, selectedProjects],
+  )
+
+  return (
+    <div>
+      <div className="mb-3 mt-4 flex flex-col justify-between gap-1">
+        <ProjectChartTimeRange range={chartRange} />
+        <div className="flex justify-between gap-1">
+          {!data && isLoading ? (
+            <Skeleton className="h-8 w-44" />
+          ) : (
+            <ProjectCombobox
+              allProjects={allProjects}
+              projects={projectsToShow}
+              setProjects={setSelectedProjects}
+            />
+          )}
+          <ChartTimeRangeControls
+            name="Range"
+            value={range}
+            setValue={setRange}
+            options={Object.values(DaThroughputTimeRange.Enum).map((v) => ({
+              value: v,
+              label: v.toUpperCase(),
+            }))}
+          />
+        </div>
+      </div>
+      <DaThroughputByProjectChart
+        data={data}
+        isLoading={isLoading}
+        projectsToShow={projectsToShow}
+        customColors={customColors}
+      />
+    </div>
+  )
+}
diff --git a/packages/frontend/src/components/chart/data-availability/throughput-section-chart.tsx b/packages/frontend/src/components/chart/data-availability/throughput-section-chart.tsx
new file mode 100644
index 000000000000..af2c7594ab26
--- /dev/null
+++ b/packages/frontend/src/components/chart/data-availability/throughput-section-chart.tsx
@@ -0,0 +1,65 @@
+'use client'
+import type { DaLayerThroughput, Milestone } from '@l2beat/config'
+import type { ProjectId } from '@l2beat/shared-pure'
+import { useState } from 'react'
+import { RadioGroup, RadioGroupItem } from '~/components/core/radio-group'
+import type { DaThroughputTimeRange } from '~/server/features/data-availability/throughput/utils/range'
+import { ThroughputSectionAbsoluteChart } from './throughput-section-absolute-chart'
+import { ThroughputSectionByProjectChart } from './throughput-section-by-project-chart'
+
+interface Props {
+  daLayer: ProjectId
+  configuredThroughputs: DaLayerThroughput[]
+  customColors: Record<string, string>
+  milestones: Milestone[]
+}
+
+export function ThroughputSectionChart({
+  daLayer,
+  configuredThroughputs,
+  customColors,
+  milestones,
+}: Props) {
+  const [type, setType] = useState<'combined' | 'by-project'>('combined')
+  const [range, setRange] = useState<DaThroughputTimeRange>('1y')
+  const [selectedProjects, setSelectedProjects] = useState<string[]>()
+  const [showMax, setShowMax] = useState(true)
+
+  return (
+    <div>
+      <RadioGroup
+        name="type"
+        value={type}
+        onValueChange={(value) => setType(value as 'combined' | 'by-project')}
+        className="w-full"
+      >
+        <RadioGroupItem value="combined" className="w-full">
+          Combined
+        </RadioGroupItem>
+        <RadioGroupItem value="by-project" className="w-full">
+          By Project
+        </RadioGroupItem>
+      </RadioGroup>
+      {type === 'combined' ? (
+        <ThroughputSectionAbsoluteChart
+          daLayer={daLayer}
+          configuredThroughputs={configuredThroughputs}
+          milestones={milestones}
+          range={range}
+          setRange={setRange}
+          showMax={showMax}
+          setShowMax={setShowMax}
+        />
+      ) : (
+        <ThroughputSectionByProjectChart
+          daLayer={daLayer}
+          range={range}
+          setRange={setRange}
+          selectedProjects={selectedProjects}
+          setSelectedProjects={setSelectedProjects}
+          customColors={customColors}
+        />
+      )}
+    </div>
+  )
+}
diff --git a/packages/frontend/src/components/project-combobox.tsx b/packages/frontend/src/components/project-combobox.tsx
new file mode 100644
index 000000000000..a470c8dc83d3
--- /dev/null
+++ b/packages/frontend/src/components/project-combobox.tsx
@@ -0,0 +1,84 @@
+'use client'
+import React, { useCallback } from 'react'
+import { CheckIcon } from '~/icons/check'
+import { ChevronIcon } from '~/icons/chevron'
+import { cn } from '~/utils/cn'
+import {
+  Command,
+  CommandEmpty,
+  CommandGroup,
+  CommandInput,
+  CommandInputActionButton,
+  CommandItem,
+  CommandList,
+} from './core/command'
+import { Popover, PopoverContent, PopoverTrigger } from './core/popover'
+
+interface Props {
+  projects: string[]
+  setProjects: (values: string[]) => void
+  allProjects: string[]
+  className?: string
+}
+
+export function ProjectCombobox({
+  allProjects,
+  projects,
+  setProjects,
+  className,
+}: Props) {
+  const [open, setOpen] = React.useState(false)
+
+  const onSelect = useCallback(
+    (project: string) => {
+      if (projects.includes(project)) {
+        setProjects(projects.filter((p) => p !== project))
+      } else {
+        setProjects([...projects, project])
+      }
+    },
+    [projects, setProjects],
+  )
+
+  return (
+    <Popover open={open} onOpenChange={setOpen}>
+      <PopoverTrigger
+        className={cn('group/popover-trigger h-8 justify-between', className)}
+      >
+        {projects.length > 1
+          ? `${projects.length} projects selected`
+          : projects.length === 1
+            ? projects[0]
+            : 'Select projects'}
+        <ChevronIcon className="size-3 shrink-0 transition-transform group-data-[state=open]/popover-trigger:rotate-180" />
+      </PopoverTrigger>
+      <PopoverContent className="p-0" align="start">
+        <Command>
+          <CommandInput placeholder="Search for project...">
+            <CommandInputActionButton
+              onClick={projects.length > 0 ? () => setProjects([]) : undefined}
+            >
+              {projects.length > 0 ? 'Clear' : undefined}
+            </CommandInputActionButton>
+          </CommandInput>
+          <CommandList>
+            <CommandEmpty>No projects found</CommandEmpty>
+            <CommandGroup>
+              {allProjects.map((project) => (
+                <CommandItem key={project} value={project} onSelect={onSelect}>
+                  <CheckIcon
+                    className={cn(
+                      'mr-2 size-5 shrink-0',
+                      projects.includes(project) ? 'opacity-100' : 'opacity-0',
+                    )}
+                  />
+                  <span className="text-sm font-bold">{project}</span>
+                </CommandItem>
+              ))}
+            </CommandGroup>
+          </CommandList>
+        </Command>
+      </PopoverContent>
+    </Popover>
+  )
+}
diff --git a/packages/frontend/src/components/projects/sections/throughput/throughput-section.tsx b/packages/frontend/src/components/projects/sections/throughput/throughput-section.tsx
index d4431443a5f0..f3888e9997fe 100644
--- a/packages/frontend/src/components/projects/sections/throughput/throughput-section.tsx
+++ b/packages/frontend/src/components/projects/sections/throughput/throughput-section.tsx
@@ -1,7 +1,7 @@
 import type { DaLayerThroughput, Milestone } from '@l2beat/config'
 import type { ProjectId } from '@l2beat/shared-pure'
 import Link from 'next/link'
-import { ProjectDaThroughputChart } from '~/components/chart/data-availability/project-da-throughput-chart'
+import { ThroughputSectionChart } from '~/components/chart/data-availability/throughput-section-chart'
 import { ChartStats, ChartStatsItem } from '~/components/core/chart/chart-stats'
 import { HorizontalSeparator } from '~/components/core/horizontal-separator'
 import { ClockIcon } from '~/icons/clock'
@@ -26,6 +26,7 @@ export interface ThroughputSectionProps extends ProjectSectionProps {
       }
     | undefined
   totalPosted: number
+  customColors: Record<string, string>
   syncStatus: {
     warning: string | undefined
     isSynced: boolean
@@ -40,6 +41,7 @@ export function ThroughputSection({
   pastDayAvgThroughputPerSecond,
   largestPoster,
   totalPosted,
+  customColors,
   syncStatus,
   milestones,
   ...sectionProps
@@ -60,9 +62,10 @@ export function ThroughputSection({
         per day.
       </p>
       <HorizontalSeparator className="my-4" />
-      <ProjectDaThroughputChart
-        projectId={projectId}
+      <ThroughputSectionChart
+        daLayer={projectId}
         configuredThroughputs={throughput}
+        customColors={customColors}
         milestones={milestones}
       />
       <HorizontalSeparator className="my-4" />
diff --git a/packages/frontend/src/server/api/routers/da.ts b/packages/frontend/src/server/api/routers/da.ts
index 307f9c4c26ec..94e8ac2f265c 100644
--- a/packages/frontend/src/server/api/routers/da.ts
+++ b/packages/frontend/src/server/api/routers/da.ts
@@ -2,6 +2,10 @@ import {
   DaThroughputChartParams,
   getDaThroughputChart,
 } from '~/server/features/data-availability/throughput/get-da-throughput-chart'
+import {
+  DaThroughputChartByProjectParams,
+  getDaThroughputChartByProject,
+} from '~/server/features/data-availability/throughput/get-da-throughput-chart-by-project'
 import {
   ProjectDaThroughputChartParams,
   getProjectDaThroughputChart,
@@ -15,4 +19,7 @@ export const daRouter = router({
   projectChart: procedure
     .input(ProjectDaThroughputChartParams)
     .query(async ({ input }) => getProjectDaThroughputChart(input)),
+  projectChartByProject: procedure
+    .input(DaThroughputChartByProjectParams)
+    .query(async ({ input }) => getDaThroughputChartByProject(input)),
 })
diff --git a/packages/frontend/src/server/features/data-availability/project/get-da-project-entry.ts b/packages/frontend/src/server/features/data-availability/project/get-da-project-entry.ts
index fc10cd0da4e4..0b547bc52c38 100644
--- a/packages/frontend/src/server/features/data-availability/project/get-da-project-entry.ts
+++ b/packages/frontend/src/server/features/data-availability/project/get-da-project-entry.ts
@@ -117,6 +117,10 @@ export async function getDaProjectEntry(
         range: 'max',
         projectId: layer.id,
       }),
+      api.da.projectChartByProject.prefetch({
+        range: '30d',
+        daLayer: layer.id,
+      }),
     ])
 
   const layerTvs = tvsPerProject.reduce((acc, value) => acc + value.tvs, 0)
diff --git a/packages/frontend/src/server/features/data-availability/throughput/get-da-throughput-chart-by-project.ts b/packages/frontend/src/server/features/data-availability/throughput/get-da-throughput-chart-by-project.ts
new file mode 100644
index 000000000000..fda58b6c33b6
--- /dev/null
+++ b/packages/frontend/src/server/features/data-availability/throughput/get-da-throughput-chart-by-project.ts
@@ -0,0 +1,144 @@
+import type { Project } from '@l2beat/config'
+import type { DataAvailabilityRecord } from '@l2beat/database'
+import { assert, UnixTime } from '@l2beat/shared-pure'
+import partition from 'lodash/partition'
+import { unstable_cache as cache } from 'next/cache'
+import { z } from 'zod'
+import { env } from '~/env'
+import { getDb } from '~/server/database'
+import { ps } from '~/server/projects'
+import { getRangeWithMax } from '~/utils/range/range'
+import { rangeToDays } from '~/utils/range/range-to-days'
+import { generateTimestamps } from '../../utils/generate-timestamps'
+import { DaThroughputTimeRange } from './utils/range'
+
+export type DaThroughputChartDataByChart = [
+  timestamp: number,
+  values: Record<string, number>,
+][]
+
+export const DaThroughputChartByProjectParams = z.object({
+  range: DaThroughputTimeRange,
+  daLayer: z.string(),
+})
+export type DaThroughputChartByProjectParams = z.infer<
+  typeof DaThroughputChartByProjectParams
+>
+
+export function getDaThroughputChartByProject(
+  params: DaThroughputChartByProjectParams,
+) {
+  if (env.MOCK) {
+    return getMockDaThroughputChartByProjectData(params)
+  }
+
+  return getCachedDaThroughputChartByProjectData(params)
+}
+
+const getCachedDaThroughputChartByProjectData = cache(
+  async ({
+    range,
+    daLayer,
+  }: DaThroughputChartByProjectParams): Promise<DaThroughputChartDataByChart> => {
+    const db = getDb()
+    const [from, to] = getRangeWithMax(range, 'daily', {
+      offset: -1 * UnixTime.DAY,
+    })
+    const throughput = await db.dataAvailability.getByDaLayersAndTimeRange(
+      [daLayer],
+      [from, to],
+    )
+    if (throughput.length === 0) {
+      return []
+    }
+    const allProjects = await ps.getProjects({})
+    const { grouped, minTimestamp, maxTimestamp } =
+      groupByTimestampAndProjectId(throughput, allProjects)
+
+    const timestamps = generateTimestamps([minTimestamp, maxTimestamp], 'daily')
+    return timestamps.map((timestamp) => [timestamp, grouped[timestamp] ?? {}])
+  },
+  ['da-throughput-chart-by-project-data'],
+  { tags: ['hourly-data'], revalidate: UnixTime.HOUR },
+)
+
+function groupByTimestampAndProjectId(
+  records: DataAvailabilityRecord[],
+  allProjects: Project[],
+) {
+  let minTimestamp = Infinity
+  let maxTimestamp = -Infinity
+  const result: Record<number, Record<string, number>> = {}
+  const [daLayerRecords, projectRecords] = partition(
+    records,
+    (r) => r.daLayer === r.projectId,
+  )
+
+  for (const record of projectRecords) {
+    const timestamp = record.timestamp
+    const value = record.totalSize
+    const project = allProjects.find((p) => p.id === record.projectId)
+    assert(project, `Project ${record.projectId} not found`)
+    result[timestamp] = {
+      ...result[timestamp],
+      [project.name]: Number(value),
+    }
+    minTimestamp = Math.min(minTimestamp, timestamp)
+    maxTimestamp = Math.max(maxTimestamp, timestamp)
+  }
+
+  // Add the difference between the total size and the sum of the other projects as 'Unknown'
+  for (const record of daLayerRecords) {
+    const timestamp = record.timestamp
+    const value = record.totalSize
+    const restSummed = Object.values(result[timestamp] ?? {}).reduce(
+      (acc, curr) => acc + curr,
+      0,
+    )
+    result[timestamp] = {
+      ...result[timestamp],
+      ['Unknown']: Number(value) - restSummed,
+    }
+    minTimestamp = Math.min(minTimestamp, timestamp)
+    maxTimestamp = Math.max(maxTimestamp, timestamp)
+  }
+
+  return {
+    grouped: Object.fromEntries(
+      Object.entries(result).map(([timestamp, projects]) => [
+        timestamp,
+        Object.fromEntries(
+          Object.entries(projects).sort(
+            ([, valueA], [, valueB]) => valueB - valueA,
+          ),
+        ),
+      ]),
+    ),
+    minTimestamp: UnixTime(minTimestamp),
+    maxTimestamp: UnixTime(maxTimestamp),
+  }
+}
+
+function getMockDaThroughputChartByProjectData({
+  range,
+}: DaThroughputChartByProjectParams): DaThroughputChartDataByChart {
+  const days = rangeToDays(range) ?? 730
+  const to = UnixTime.toStartOf(UnixTime.now(), 'day')
+  const from = to - days * UnixTime.DAY
+
+  const timestamps = generateTimestamps([from, to], 'daily')
+  return timestamps.map((timestamp) => {
+    const values = {
+      base: Math.random() * 900_000_000 + 90_000_000,
+      optimism: Math.random() * 900_000_000 + 90_000_000,
+      arbitrum: Math.random() * 900_000_000 + 90_000_000,
+      polygon: Math.random() * 900_000_000 + 90_000_000,
+      zkSync: Math.random() * 900_000_000 + 90_000_000,
+      zkSyncEra: Math.random() * 900_000_000 + 90_000_000,
+      gnosis: Math.random() * 900_000_000 + 90_000_000,
+      linea: Math.random() * 900_000_000 + 90_000_000,
+    }
+
+    return [timestamp, values]
+  })
+}
diff --git a/packages/frontend/src/server/features/data-availability/throughput/get-project-da-throughput-chart.ts b/packages/frontend/src/server/features/data-availability/throughput/get-project-da-throughput-chart.ts
index eafef83f7e7a..a66f418f7d44 100644
--- a/packages/frontend/src/server/features/data-availability/throughput/get-project-da-throughput-chart.ts
+++ b/packages/frontend/src/server/features/data-availability/throughput/get-project-da-throughput-chart.ts
@@ -4,6 +4,7 @@ import { unstable_cache as cache } from 'next/cache'
 import { z } from 'zod'
 import { env } from '~/env'
 import { getDb } from '~/server/database'
+import { getRangeWithMax } from '~/utils/range/range'
 import { rangeToDays } from '~/utils/range/range-to-days'
 import { CostsTimeRange } from '../../scaling/costs/utils/range'
 import { generateTimestamps } from '../../utils/generate-timestamps'
@@ -39,9 +40,9 @@ const getCachedProjectDaThroughputChartData = cache(
     projectId,
   }: ProjectDaThroughputChartParams): Promise<ProjectDaThroughputChartData> => {
     const db = getDb()
-    const days = rangeToDays(range)
-    const to = UnixTime.toStartOf(UnixTime.now(), 'day') - 1 * UnixTime.DAY
-    const from = days ? to - days * UnixTime.DAY : null
+    const [from, to] = getRangeWithMax(range, 'daily', {
+      offset: -1 * UnixTime.DAY,
+    })
     const throughput = await db.dataAvailability.getByProjectIdsAndTimeRange(
       [projectId],
       [from, to],
diff --git a/packages/frontend/src/utils/generate-colors.ts b/packages/frontend/src/utils/generate-colors.ts
new file mode 100644
index 000000000000..c4d39bf50129
--- /dev/null
+++ b/packages/frontend/src/utils/generate-colors.ts
@@ -0,0 +1,150 @@
+/* eslint-disable @typescript-eslint/no-non-null-assertion */
+/**
+ * Colorblind friendly color generation strategy:
+ * - 11 colors is predefined in the base palette (Okabe-Ito + CARTO Safe)
+ * - If more than 11 colors are needed, we use the CIELab space to generate new colors, however it does not guarantee that the colors are colorblind friendly
+ */
+
+type Color = { L: number; a: number; b: number } // Lab color object
+
+const MIN_LAB_DISTANCE = 20
+
+// Predefined base palette (12 colorblind-safe colors)
+const basePaletteHex = [
+  // 7-color Okabe-Ito (without black and with slight tweak for yellow for better contrast):
+  '#E69F00',
+  '#56B4E9',
+  '#009E73',
+  '#F5C710', // orange, sky blue, bluish green, amber
+  '#0072B2',
+  '#D55E00',
+  '#CC79A7', // dark blue, vermillion, purple
+  // Additional from CARTO Safe to extend to 11:
+  '#88CCEE',
+  '#DDCC77',
+  '#117733',
+  '#661100', // light blue, light yellow, dark green, dark brown
+]
+const basePaletteLab: Color[] = basePaletteHex.map(hexToLab)
+
+/**
+ * Generate an array of distinct, colorblind-friendly HEX colors.
+ * @param n - Number of colors desired.
+ */
+export function generateAccessibleColors(n: number): string[] {
+  if (n <= basePaletteHex.length) {
+    return basePaletteHex.slice(0, n)
+  }
+  // Start with base palette
+  const selected: Color[] = basePaletteLab.slice()
+  // Sample candidate colors in HCL space
+  const candidates: Color[] = []
+  const targetL = [30, 55, 80] // example lightness levels to sample
+  for (const L of targetL) {
+    for (let H = 0; H < 360; H += 10) {
+      const C = 60 // fixed chroma (saturation)
+      // Convert HCL -> Lab (approximate)
+      const rad = (H * Math.PI) / 180
+      const a = Math.cos(rad) * C
+      const b = Math.sin(rad) * C
+      candidates.push({ L, a, b })
+    }
+  }
+  // Filter out any candidate too close to an existing color
+  const filtered = candidates.filter((c) =>
+    selected.every((sel) => labDistance(c, sel) > MIN_LAB_DISTANCE),
+  )
+  // Greedily pick new colors until we have n
+  while (selected.length < n && filtered.length) {
+    // find the candidate with max distance to the nearest selected color
+    let bestIndex = 0
+    let bestDist = -Infinity
+    filtered.forEach((c, idx) => {
+      // distance to closest selected color
+      const d = Math.min(...selected.map((sel) => labDistance(c, sel)))
+      if (d > bestDist) {
+        bestDist = d
+        bestIndex = idx
+      }
+    })
+    const newColor = filtered.splice(bestIndex, 1)[0]
+    if (!newColor) {
+      throw new Error('No new color found')
+    }
+    selected.push(newColor)
+    // Remove any remaining candidates that are too close to the new color
+    for (const [i, color] of filtered.entries()) {
+      if (labDistance(color, newColor) < MIN_LAB_DISTANCE) {
+        filtered.splice(i, 1)
+      }
+    }
+  }
+  // Convert selected colors to HEX for output
+  return selected.map(labToHex).slice(0, n)
+}
+
+// Utility: Convert HEX to Lab
+function hexToLab(hex: string): Color {
+  // Parse hex to RGB [0,255]
+  const bigint = parseInt(hex.slice(1), 16)
+  const r = (bigint >> 16) & 255
+  const g = (bigint >> 8) & 255
+  const b = bigint & 255
+  // Convert sRGB to XYZ (D65) then to CIELAB
+  // (For brevity, using a simplified conversion)
+  const [R, G, B] = [r, g, b].map((v) => {
+    const fv = v / 255
+    return fv <= 0.04045 ? fv / 12.92 : Math.pow((fv + 0.055) / 1.055, 2.4)
+  })
+  const X = R! * 0.4124 + G! * 0.3576 + B! * 0.1805
+  const Y = R! * 0.2126 + G! * 0.7152 + B! * 0.0722
+  const Z = R! * 0.0193 + G! * 0.1192 + B! * 0.9505
+  // Normalize for D65 white point
+  const [Xn, Yn, Zn] = [0.95047, 1.0, 1.08883]
+  const fx = X / Xn > 0.008856 ? Math.cbrt(X / Xn) : (7.787 * X) / Xn + 16 / 116
+  const fy = Y / Yn > 0.008856 ? Math.cbrt(Y / Yn) : (7.787 * Y) / Yn + 16 / 116
+  const fz = Z / Zn > 0.008856 ? Math.cbrt(Z / Zn) : (7.787 * Z) / Zn + 16 / 116
+  return {
+    L: 116 * fy - 16,
+    a: 500 * (fx - fy),
+    b: 200 * (fy - fz),
+  }
+}
+
+// Utility: Convert Lab to HEX (for final output)
+function labToHex(color: Color): string {
+  // Inverse of hexToLab: Lab -> XYZ -> linear RGB -> sRGB -> hex
+  const { L, a, b: labB } = color
+  // Lab to XYZ
+  const fy = (L + 16) / 116
+  const fx = fy + a / 500
+  const fz = fy - labB / 200
+  const [Xn, Yn, Zn] = [0.95047, 1.0, 1.08883]
+  const [fx3, fy3, fz3] = [fx ** 3, fy ** 3, fz ** 3]
+  const X = Xn * (fx3 > 0.008856 ? fx3 : (fx - 16 / 116) / 7.787)
+  const Y = Yn * (fy3 > 0.008856 ? fy3 : (fy - 16 / 116) / 7.787)
+  const Z = Zn * (fz3 > 0.008856 ? fz3 : (fz - 16 / 116) / 7.787)
+  // XYZ to linear RGB
+  let R = 3.2406 * X + -1.5372 * Y + -0.4986 * Z
+  let G = -0.9689 * X + 1.8758 * Y + 0.0415 * Z
+  let B = 0.0557 * X + -0.204 * Y + 1.057 * Z
+  // linear RGB to sRGB
+  const toSRGB = (u: number) =>
+    u <= 0.0031308 ? 12.92 * u : 1.055 * Math.pow(u, 1 / 2.4) - 0.055
+  ;[R, G, B] = [toSRGB(R), toSRGB(G), toSRGB(B)]
+  // Clamp and convert to [0,255]
+  const [r, g, b] = [R, G, B].map((u) => {
+    const c = Math.min(Math.max(u, 0), 1)
+    return Math.round(c * 255)
+  })
+  const hex = (x: number) => x.toString(16).padStart(2, '0')
+  return `#${hex(r!)}${hex(g!)}${hex(b!)}`
+}
+
+// Calculate CIEDE2000-like distance (using simple Euclidean in Lab as approximation)
+function labDistance(c1: Color, c2: Color): number {
+  const dL = c1.L - c2.L
+  const da = c1.a - c2.a
+  const db = c1.b - c2.b
+  return Math.sqrt(dL * dL + da * da + db * db)
+}
diff --git a/packages/frontend/src/utils/range/range.ts b/packages/frontend/src/utils/range/range.ts
index c814e73e0fe7..37154643e5c7 100644
--- a/packages/frontend/src/utils/range/range.ts
+++ b/packages/frontend/src/utils/range/range.ts
@@ -30,17 +30,18 @@ export function getRangeWithMax(
   resolution: Resolution,
   opts?: {
     now?: UnixTime
+    offset?: UnixTime
   },
 ): [UnixTime | null, UnixTime] {
   const days = rangeToDays(range)
-
+  const offset = opts?.offset ?? 0
   const roundedNow = UnixTime.toStartOf(
     opts?.now ?? UnixTime.now(),
     resolution === 'hourly' ? 'hour' : 'day',
   )
 
-  const start = days !== null ? roundedNow - days * UnixTime.DAY : null
-  const end = roundedNow
+  const start = days !== null ? roundedNow - days * UnixTime.DAY + offset : null
+  const end = roundedNow + offset
 
   return [start, end]
 }


Reviews:
user: login: maciekzygmunt
body: We have now 2 very similar file names, eighter merge them or rename one of them, as now this is confusing
<img width="635" alt="18187" src="https://github.com/user-attachments/assets/39430173-8948-45a6-85d4-70afbc357662" />

state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-16T07:50:40Z
user: login: torztomasz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T10:08:27Z
user: login: maciekzygmunt
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-16T10:33:58Z
user: login: maciekzygmunt
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-16T10:45:21Z

Review comments:
diff_hunk: @@ -0,0 +1,126 @@
+import type { Project } from '@l2beat/config'
+import type { DataAvailabilityRecord } from '@l2beat/database'
+import { assert, UnixTime } from '@l2beat/shared-pure'
+import { unstable_cache as cache } from 'next/cache'
+import { z } from 'zod'
+import { env } from '~/env'
+import { getDb } from '~/server/database'
+import { ps } from '~/server/projects'
+import { rangeToDays } from '~/utils/range/range-to-days'
+import { CostsTimeRange } from '../../scaling/costs/utils/range'
+import { generateTimestamps } from '../../utils/generate-timestamps'
+import { DaThroughputTimeRange } from './utils/range'
+
+export type DaThroughputChartDataByChart = [
+  timestamp: number,
+  values: Record<string, number>,
+][]
+
+export const DaThroughputChartByProjectParams = z.object({
+  range: DaThroughputTimeRange.or(CostsTimeRange),
path: packages/frontend/src/server/features/data-availability/throughput/get-da-throughput-chart-by-project.ts
user: login: maciekzygmunt
body: Why `CostsTimeRange`?
created_at: 2025-04-16T07:33:34Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,126 @@
+import type { Project } from '@l2beat/config'
+import type { DataAvailabilityRecord } from '@l2beat/database'
+import { assert, UnixTime } from '@l2beat/shared-pure'
+import { unstable_cache as cache } from 'next/cache'
+import { z } from 'zod'
+import { env } from '~/env'
+import { getDb } from '~/server/database'
+import { ps } from '~/server/projects'
+import { rangeToDays } from '~/utils/range/range-to-days'
+import { CostsTimeRange } from '../../scaling/costs/utils/range'
+import { generateTimestamps } from '../../utils/generate-timestamps'
+import { DaThroughputTimeRange } from './utils/range'
+
+export type DaThroughputChartDataByChart = [
+  timestamp: number,
+  values: Record<string, number>,
+][]
+
+export const DaThroughputChartByProjectParams = z.object({
+  range: DaThroughputTimeRange.or(CostsTimeRange),
+  daLayer: z.string(),
+})
+export type DaThroughputChartByProjectParams = z.infer<
+  typeof DaThroughputChartByProjectParams
+>
+
+export function getDaThroughputChartByProject(
+  params: DaThroughputChartByProjectParams,
+) {
+  if (env.MOCK) {
+    return getMockDaThroughputChartByProjectData(params)
+  }
+
+  return getCachedDaThroughputChartByProjectData(params)
+}
+
+const getCachedDaThroughputChartByProjectData = cache(
+  async ({
+    range,
+    daLayer,
+  }: DaThroughputChartByProjectParams): Promise<DaThroughputChartDataByChart> => {
+    const db = getDb()
+    const days = rangeToDays(range)
+    const to = UnixTime.toStartOf(UnixTime.now(), 'day') - 1 * UnixTime.DAY
+    const from = days ? to - days * UnixTime.DAY : null
path: packages/frontend/src/server/features/data-availability/throughput/get-da-throughput-chart-by-project.ts
user: login: maciekzygmunt
body: Use `getRangeWithMax`
created_at: 2025-04-16T07:35:19Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,84 @@
+'use client'
+import React, { useCallback } from 'react'
+import { CheckIcon } from '~/icons/check'
+import { ChevronIcon } from '~/icons/chevron'
+import { cn } from '~/utils/cn'
+import {
+  Command,
+  CommandEmpty,
+  CommandGroup,
+  CommandInput,
+  CommandInputActionButton,
+  CommandItem,
+  CommandList,
+} from './core/command'
+import { Popover, PopoverContent, PopoverTrigger } from './core/popover'
+
+interface Props {
+  projects: string[]
+  setProjects: (values: string[]) => void
+  allProjects: string[]
+  className?: string
+}
+
+export function ProjectCombobox({
+  allProjects,
+  projects,
+  setProjects,
+  className,
+}: Props) {
path: packages/frontend/src/components/project-combobox.tsx
user: login: maciekzygmunt
body: I dont like scroll bar color on dark mode here
created_at: 2025-04-16T07:39:19Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,84 @@
+'use client'
+import React, { useCallback } from 'react'
+import { CheckIcon } from '~/icons/check'
+import { ChevronIcon } from '~/icons/chevron'
+import { cn } from '~/utils/cn'
+import {
+  Command,
+  CommandEmpty,
+  CommandGroup,
+  CommandInput,
+  CommandInputActionButton,
+  CommandItem,
+  CommandList,
+} from './core/command'
+import { Popover, PopoverContent, PopoverTrigger } from './core/popover'
+
+interface Props {
+  projects: string[]
+  setProjects: (values: string[]) => void
+  allProjects: string[]
+  className?: string
+}
+
+export function ProjectCombobox({
+  allProjects,
+  projects,
+  setProjects,
+  className,
+}: Props) {
path: packages/frontend/src/components/project-combobox.tsx
user: login: torztomasz
body: Its not a matter of this PR as its using scrollbar color just as everywhere else on the page
created_at: 2025-04-16T10:08:27Z
author_association: MEMBER
diff_hunk: @@ -57,7 +58,7 @@ const getCachedDaThroughputChartByProjectData = cache(
     const timestamps = generateTimestamps([minTimestamp, maxTimestamp], 'daily')
     return timestamps.map((timestamp) => [timestamp, grouped[timestamp] ?? {}])
   },
-  ['da-throughput-chart-by-project-data'],
+  ['da-throughput-chart-by-project-data3'],
path: packages/frontend/src/server/features/data-availability/throughput/get-da-throughput-chart-by-project.ts
user: login: maciekzygmunt
body: fix cache key
created_at: 2025-04-16T10:31:33Z
author_association: MEMBER
diff_hunk: @@ -58,7 +57,7 @@ const getCachedDaThroughputChartByProjectData = cache(
     const timestamps = generateTimestamps([minTimestamp, maxTimestamp], 'daily')
     return timestamps.map((timestamp) => [timestamp, grouped[timestamp] ?? {}])
   },
-  ['da-throughput-chart-by-project-data3'],
+  ['da-throughput-chart-by-project-dataxyx'],
path: packages/frontend/src/server/features/data-availability/throughput/get-da-throughput-chart-by-project.ts
user: login: maciekzygmunt
body: same
created_at: 2025-04-16T10:32:41Z
author_association: MEMBER

PR comments:
user: login: linear[bot]
created_at: 2025-04-16T06:51:58Z
body: <p><a href="https://linear.app/l2beat/issue/L2B-9519/add-da-throughput-chart-for-breakdown-per-project">L2B-9519 Add DA throughput chart for breakdown per project</a></p>
author_association: NONE
user: login: vercel[bot]
created_at: 2025-04-16T06:52:02Z
body: [vc]: #4FaK0dwgl+iC4d3B93iXhXfPqeovxCBvbOCI3TkQlxM=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJsMmJlYXQtc3RhZ2luZyIsInJvb3REaXJlY3RvcnkiOiJwYWNrYWdlcy9mcm9udGVuZCIsImluc3BlY3RvclVybCI6Imh0dHBzOi8vdmVyY2VsLmNvbS9sMmJlYXQvbDJiZWF0LXN0YWdpbmcvM2ZqZFdIWW1rRERXM2pFZnM4aUZZUVlCblVKayIsInByZXZpZXdVcmwiOiJsMmJlYXQtc3RhZ2luZy1naXQtdG9tYXN6dG9yei1sMmItOTUxOS1hZGQtZGEtdGhyLTE1ZTRjNC1sMmJlYXQudmVyY2VsLmFwcCIsIm5leHRDb21taXRTdGF0dXMiOiJERVBMT1lFRCIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiJsMmJlYXQtc3RhZ2luZy1naXQtdG9tYXN6dG9yei1sMmItOTUxOS1hZGQtZGEtdGhyLTE1ZTRjNC1sMmJlYXQudmVyY2VsLmFwcCJ9fSx7Im5hbWUiOiJsMmJlYXQtcHJvZHVjdGlvbiIsInJvb3REaXJlY3RvcnkiOiJwYWNrYWdlcy9mcm9udGVuZCIsImluc3BlY3RvclVybCI6Imh0dHBzOi8vdmVyY2VsLmNvbS9sMmJlYXQvbDJiZWF0LXByb2R1Y3Rpb24vOXJRQlNIQ25kTXplZTliQjdjSGhLOEZINThzaCIsInByZXZpZXdVcmwiOiJsMmJlYXQtcHJvZHVjdGlvbi1naXQtdG9tYXN6dG9yei1sMmItOTUxOS1hZGQtZGEtZmE0NmJhLWwyYmVhdC52ZXJjZWwuYXBwIiwibmV4dENvbW1pdFN0YXR1cyI6IkRFUExPWUVEIiwibGl2ZUZlZWRiYWNrIjp7InJlc29sdmVkIjowLCJ1bnJlc29sdmVkIjowLCJ0b3RhbCI6MCwibGluayI6ImwyYmVhdC1wcm9kdWN0aW9uLWdpdC10b21hc3p0b3J6LWwyYi05NTE5LWFkZC1kYS1mYTQ2YmEtbDJiZWF0LnZlcmNlbC5hcHAifX0seyJuYW1lIjoicHJvdG9jb2xiZWF0Iiwicm9vdERpcmVjdG9yeSI6InBhY2thZ2VzL3Byb3RvY29sYmVhdCIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiIifSwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL2wyYmVhdC9wcm90b2NvbGJlYXQvOU1XZG1qUnlVOGc1aGdkRVNlRnlWNDY5TWl5ciIsIm5leHRDb21taXRTdGF0dXMiOiJJR05PUkVEIiwicHJldmlld1VybCI6InByb3RvY29sYmVhdC1naXQtdG9tYXN6dG9yei1sMmItOTUxOS1hZGQtZGEtdGhyb3UtMWU3ZTk4LWwyYmVhdC52ZXJjZWwuYXBwIn0seyJuYW1lIjoidG9vbHMiLCJyb290RGlyZWN0b3J5IjoicGFja2FnZXMvdG9vbHMiLCJsaXZlRmVlZGJhY2siOnsicmVzb2x2ZWQiOjAsInVucmVzb2x2ZWQiOjAsInRvdGFsIjowLCJsaW5rIjoiIn0sImluc3BlY3RvclVybCI6Imh0dHBzOi8vdmVyY2VsLmNvbS9sMmJlYXQvdG9vbHMvQUFaeUtCVWVOUHQ3QXNlV1hTR29yVDhGYWpNYyIsIm5leHRDb21taXRTdGF0dXMiOiJERVBMT1lFRCIsInByZXZpZXdVcmwiOiJ0b29scy1naXQtdG9tYXN6dG9yei1sMmItOTUxOS1hZGQtZGEtdGhyb3VnaHB1dC1jLWNlZDEzNS1sMmJlYXQudmVyY2VsLmFwcCJ9LHsibmFtZSI6InVvcHMtZGFzaGJvYXJkIiwicm9vdERpcmVjdG9yeSI6InBhY2thZ2VzL3VvcHMtZGFzaGJvYXJkIiwibGl2ZUZlZWRiYWNrIjp7InJlc29sdmVkIjowLCJ1bnJlc29sdmVkIjowLCJ0b3RhbCI6MCwibGluayI6IiJ9LCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vbDJiZWF0L3VvcHMtZGFzaGJvYXJkLzdwM0JVNmJ0NHlmM1N3RzVWcEQySGgyU2t0OVIiLCJwcmV2aWV3VXJsIjoiIiwibmV4dENvbW1pdFN0YXR1cyI6IlNLSVBQRUQifSx7Im5hbWUiOiJkb2NzIiwicm9vdERpcmVjdG9yeSI6ImRvY3MvbWRib29rIiwibGl2ZUZlZWRiYWNrIjp7InJlc29sdmVkIjowLCJ1bnJlc29sdmVkIjowLCJ0b3RhbCI6MCwibGluayI6ImRvY3MtZ2l0LXRvbWFzenRvcnotbDJiLTk1MTktYWRkLWRhLXRocm91Z2hwdXQtY2gtMjkxYzllLWwyYmVhdC52ZXJjZWwuYXBwIn0sImluc3BlY3RvclVybCI6Imh0dHBzOi8vdmVyY2VsLmNvbS9sMmJlYXQvZG9jcy84bzRzeHVhTktIRUppcHpKTXZSYkRta3NpWmNhIiwicHJldmlld1VybCI6ImRvY3MtZ2l0LXRvbWFzenRvcnotbDJiLTk1MTktYWRkLWRhLXRocm91Z2hwdXQtY2gtMjkxYzllLWwyYmVhdC52ZXJjZWwuYXBwIiwibmV4dENvbW1pdFN0YXR1cyI6IkRFUExPWUVEIn1dfQ==
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **docs** | ✅ Ready ([Inspect](https://vercel.com/l2beat/docs/8o4sxuaNKHEJipzJMvRbDmksiZca)) | [Visit Preview](https://docs-git-tomasztorz-l2b-9519-add-da-throughput-ch-291c9e-l2beat.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/docs-git-tomasztorz-l2b-9519-add-da-throughput-ch-291c9e-l2beat.vercel.app?via=pr-comment-feedback-link) | Apr 28, 2025 7:11am |
| **l2beat-production** | ✅ Ready ([Inspect](https://vercel.com/l2beat/l2beat-production/9rQBSHCndMzee9bB7cHhK8FH58sh)) | [Visit Preview](https://l2beat-production-git-tomasztorz-l2b-9519-add-da-fa46ba-l2beat.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/l2beat-production-git-tomasztorz-l2b-9519-add-da-fa46ba-l2beat.vercel.app?via=pr-comment-feedback-link) | Apr 28, 2025 7:11am |
| **l2beat-staging** | ✅ Ready ([Inspect](https://vercel.com/l2beat/l2beat-staging/3fjdWHYmkDDW3jEfs8iFYQYBnUJk)) | [Visit Preview](https://l2beat-staging-git-tomasztorz-l2b-9519-add-da-thr-15e4c4-l2beat.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/l2beat-staging-git-tomasztorz-l2b-9519-add-da-thr-15e4c4-l2beat.vercel.app?via=pr-comment-feedback-link) | Apr 28, 2025 7:11am |
| **tools** | ✅ Ready ([Inspect](https://vercel.com/l2beat/tools/AAZyKBUeNPt7AseWXSGorT8FajMc)) | [Visit Preview](https://tools-git-tomasztorz-l2b-9519-add-da-throughput-c-ced135-l2beat.vercel.app) |  | Apr 28, 2025 7:11am |

<details><summary>2 Skipped Deployments</summary>

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **protocolbeat** | ⬜️ Ignored ([Inspect](https://vercel.com/l2beat/protocolbeat/9MWdmjRyU8g5hgdESeFyV469Miyr)) | [Visit Preview](https://protocolbeat-git-tomasztorz-l2b-9519-add-da-throu-1e7e98-l2beat.vercel.app) |  | Apr 28, 2025 7:11am |
| **uops-dashboard** | ⬜️ Skipped ([Inspect](https://vercel.com/l2beat/uops-dashboard/7p3BU6bt4yf3SwG5VpD2Hh2Skt9R)) |  |  | Apr 28, 2025 7:11am |
</details>







author_association: NONE
user: login: github-actions[bot]
created_at: 2025-04-16T06:52:45Z
body: ## Config Changes Report

No schema changes detected between main and this PR.

*Last updated: 2025-04-28 07:09:10 UTC*
author_association: CONTRIBUTOR