---
name: aircall-blocks/migrate-dashboard/tile
description: >
  Migrate @dashboard/library Tile, TileHeader, TileValue (+ TileBody, TileActions,
  TileExtra, TileNumber, TileDuration, TileText) to @aircall/blocks KpiCard
  compositions (KpiCard, KpiValue, KpiDescription, KpiValueSkeleton,
  KpiDescriptionSkeleton) plus DS CardHeader / CardTitle / CardContent / CardAction.
  Load when a file imports Tile, TileHeader, or TileValue from @dashboard/library.
type: sub-skill
library: aircall-blocks
requires:
  - aircall-blocks/setup
  - aircall-blocks/migrate-dashboard
sources:
  - "aircall/hydra:packages/blocks/src/components/kpi.tsx"
  - "aircall/hydra:packages/blocks/src/helpers/format-kpi.ts"
  - "aircall/hydra:packages/ds/src/components/card.tsx"
---

This skill builds on aircall-blocks/migrate-dashboard.

## 1. Component mapping

| @dashboard/library | Target |
| --- | --- |
| `Tile` (root container) | `KpiCard` (`@aircall/blocks`) — DS `Card` wrapper, `size="sm"` by default |
| `Tile` `disabled` prop | `KpiCard disabled` — applies `opacity-50` + `cursor-not-allowed` and cascades to nested `KpiValue`s |
| `TileHeader` `label` prop | DS `CardTitle` inside DS `CardHeader` |
| `TileHeader` `tooltipTexts` prop | inline DS `Tooltip` / `TooltipTrigger` / `TooltipContent` next to `CardTitle` |
| `TileBody` (metric row wrapper) | DS `CardContent` |
| `TileActions` (top-right action overlay) | DS `CardAction` inside `CardHeader` |
| `TileValue` (number / duration / text / percent) | `KpiValue` (`@aircall/blocks`) — use `kind`, `durationStyle`, `unit`, `onClick`, `active` |
| `TileValue loading` | `KpiValueSkeleton` (and `KpiDescriptionSkeleton` when a comparison line loads) |
| `TileExtra` `secondaryText` prop | `KpiDescription` children |
| `TileExtra` `trend` prop | `KpiDescription` `trend` + `tone` (`up` / `down` / `neutral` × `positive` / `negative` / `neutral`) |
| `TileProvider` (internal context) | removed — `KpiCard` provides disabled context |

`TILE_VALUE` enum and `TileValueSizes` type are internal to `@dashboard/library`; delete them from the import when migrating. Map former types via `KpiValue` `kind`:

| Former `TILE_VALUE` / type | `KpiValue` props |
| --- | --- |
| number | `kind="number"` (default for numeric `value`) |
| percent | `kind="percent"` |
| duration (long / verbose) | `kind="duration"` (`durationStyle="long"` default) |
| duration clock HH:MM | `kind="duration" durationStyle="clock" durationScale="hours"` |
| duration clock MM:SS | `kind="duration" durationStyle="clock" durationScale="minutes"` |
| opaque string | string `value` (or `kind="text"`) |

## 2. Imports

```tsx
// Blocks — KPI composition
import {
  KpiCard,
  KpiValue,
  KpiDescription,
  KpiValueSkeleton,
  KpiDescriptionSkeleton
} from '@aircall/blocks';

// DS — card slots + optional tooltip / actions
import {
  Button,
  CardAction,
  CardContent,
  CardHeader,
  CardTitle,
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger
} from '@aircall/ds';
```

## 3. Before / After

### 3a. Basic tile — number value

**Before (`@dashboard/library`):**
```tsx
import { Tile, TileBody, TileHeader, TileValue } from '@dashboard/library';

function AnsweredTile() {
  return (
    <Tile>
      <TileHeader label="Answered" />
      <TileBody>
        <TileValue value={123} />
      </TileBody>
    </Tile>
  );
}
```

**After (`@aircall/blocks` + `@aircall/ds`):**
```tsx
import { CardContent, CardHeader, CardTitle } from '@aircall/ds';
import { KpiCard, KpiValue } from '@aircall/blocks';

function AnsweredTile() {
  return (
    <KpiCard className="min-w-36">
      <CardHeader>
        <CardTitle>Answered</CardTitle>
      </CardHeader>
      <CardContent>
        <KpiValue value={123} kind="number" />
      </CardContent>
    </KpiCard>
  );
}
```

Key changes:
- `Tile` → `KpiCard`. Optional `minWidth` (default 144 px) becomes `className="min-w-36"`.
- `TileHeader label` → `CardTitle` inside `CardHeader`.
- `TileBody` → `CardContent`.
- `TileValue` → `KpiValue` (formatting + typography owned by the block).

### 3b. Tile with loading state

**Before (`@dashboard/library`):**
```tsx
import { Tile, TileBody, TileHeader, TileValue } from '@dashboard/library';

function LoadingTile() {
  return (
    <Tile>
      <TileHeader label="Answered" />
      <TileBody>
        <TileValue loading />
      </TileBody>
    </Tile>
  );
}
```

**After:**
```tsx
import { CardContent, CardHeader, CardTitle } from '@aircall/ds';
import { KpiCard, KpiValueSkeleton } from '@aircall/blocks';

function LoadingTile() {
  return (
    <KpiCard className="min-w-36">
      <CardHeader>
        <CardTitle>Answered</CardTitle>
      </CardHeader>
      <CardContent>
        <KpiValueSkeleton />
      </CardContent>
    </KpiCard>
  );
}
```

When a comparison line also loads, pair with `KpiDescriptionSkeleton` inside
`CardContent className="flex flex-col gap-1"`.

### 3c. Tile with tooltip on header

**Before (`@dashboard/library`):**
```tsx
import { Tile, TileBody, TileHeader, TileValue } from '@dashboard/library';

function InfoTile() {
  return (
    <Tile>
      <TileHeader label="Answered" tooltipTexts={{ info: 'Calls answered in the period' }} />
      <TileBody>
        <TileValue value={123} />
      </TileBody>
    </Tile>
  );
}
```

**After:**
```tsx
import {
  CardContent,
  CardHeader,
  CardTitle,
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger
} from '@aircall/ds';
import { InformationCircleIcon } from '@aircall/react-icons';
import { KpiCard, KpiValue } from '@aircall/blocks';

function InfoTile() {
  return (
    <KpiCard className="min-w-36">
      <CardHeader>
        <CardTitle className="flex items-center gap-1">
          Answered
          <TooltipProvider>
            <Tooltip>
              <TooltipTrigger
                render={<button type="button" className="inline-flex" aria-label="More info" />}
              >
                <InformationCircleIcon className="h-3 w-3 cursor-default" aria-hidden />
              </TooltipTrigger>
              <TooltipContent>Calls answered in the period</TooltipContent>
            </Tooltip>
          </TooltipProvider>
        </CardTitle>
      </CardHeader>
      <CardContent>
        <KpiValue value={123} />
      </CardContent>
    </KpiCard>
  );
}
```

### 3d. Tile with actions + clickable value

**Before (`@dashboard/library`):**
```tsx
import { Gap, Tile, TileActions, TileBody, TileHeader, TileValue } from '@dashboard/library';
import { IconButton } from '@aircall/tractor';
import { SettingsFilled, MenuVerticalFilled } from '@aircall/icons';

function ActionTile() {
  return (
    <Tile>
      <TileActions>
        <Gap gap="xxxs">
          <IconButton component={SettingsFilled} size={16} onClick={() => null} type="button" />
          <IconButton component={MenuVerticalFilled} size={16} onClick={() => null} type="button" />
        </Gap>
      </TileActions>
      <TileHeader label="Answered" />
      <TileBody>
        <TileValue onClick={() => null} value={123} />
      </TileBody>
    </Tile>
  );
}
```

**After:**
```tsx
import { Button, CardAction, CardContent, CardHeader, CardTitle } from '@aircall/ds';
import { EllipsisVertical, Settings } from '@aircall/react-icons';
import { KpiCard, KpiValue } from '@aircall/blocks';

function ActionTile() {
  return (
    <KpiCard className="min-w-36">
      <CardHeader>
        <CardTitle>Answered</CardTitle>
        <CardAction className="flex items-center gap-1">
          <Button variant="ghost" size="icon-sm" aria-label="Settings" onClick={() => null}>
            <Settings />
          </Button>
          <Button variant="ghost" size="icon-sm" aria-label="More options" onClick={() => null}>
            <EllipsisVertical />
          </Button>
        </CardAction>
      </CardHeader>
      <CardContent>
        <KpiValue value={123} onClick={() => null} />
      </CardContent>
    </KpiCard>
  );
}
```

Key changes:
- `TileActions` → `CardAction` inside `CardHeader`.
- `TileValue onClick` → `KpiValue onClick` (dotted underline; `active` for solid underline).

### 3e. Disabled tile

**Before (`@dashboard/library`):**
```tsx
import { Tile, TileBody, TileHeader, TileValue } from '@dashboard/library';

function DisabledTile() {
  return (
    <Tile disabled>
      <TileHeader label="Answered" />
      <TileBody>
        <TileValue onClick={() => null} value={123} />
      </TileBody>
    </Tile>
  );
}
```

**After:**
```tsx
import { CardContent, CardHeader, CardTitle } from '@aircall/ds';
import { KpiCard, KpiValue } from '@aircall/blocks';

function DisabledTile() {
  return (
    <KpiCard disabled className="min-w-36">
      <CardHeader>
        <CardTitle>Answered</CardTitle>
      </CardHeader>
      <CardContent>
        <KpiValue value={123} onClick={() => null} />
      </CardContent>
    </KpiCard>
  );
}
```

`KpiCard disabled` applies `opacity-50` + `cursor-not-allowed` and cascades so nested
`KpiValue` buttons cannot be activated — no need to strip `onClick` or style the value by hand.

### 3f. Comparison / trend line (TileExtra)

**Before:** `TileExtra` with `secondaryText` + `trend`.

**After:**
```tsx
import { CardContent, CardHeader, CardTitle } from '@aircall/ds';
import { KpiCard, KpiDescription, KpiValue } from '@aircall/blocks';

function OutboundCallsTile() {
  return (
    <KpiCard className="min-w-36">
      <CardHeader>
        <CardTitle>Outbound calls</CardTitle>
      </CardHeader>
      <CardContent className="flex flex-col gap-1">
        <KpiValue value={87} onClick={() => null} />
        <KpiDescription trend="up" tone="positive">
          +16.3% vs previous period
        </KpiDescription>
      </CardContent>
    </KpiCard>
  );
}
```

`trend` is icon direction only; `tone` is color only — an upward change that is bad uses
`trend="up" tone="negative"`.

### 3g. Duration values

```tsx
{/* Long: "1h 2min 3s" */}
<KpiValue value={3723} kind="duration" />

{/* Clock hours: "01:38 hr/min" */}
<KpiValue value={5880} kind="duration" durationStyle="clock" durationScale="hours" />

{/* Clock minutes: "05:04 min/sec" */}
<KpiValue value={304} kind="duration" durationStyle="clock" durationScale="minutes" />

{/* Duration + unit label */}
<KpiValue value={112} kind="duration" unit="avg" />
```

Do not reimplement `formatSecondsToDuration` in the app — `KpiValue` owns formatting.

---

## 4. Common mistakes

### Mistake 1 — Using raw DS `Card` instead of `KpiCard`

```tsx
// ❌ Wrong — loses disabled cascade and KPI defaults (size="sm", gap)
<Card size="sm">
  <CardHeader>
    <CardTitle>Answered</CardTitle>
  </CardHeader>
  <CardContent>
    <KpiValue value={123} />
  </CardContent>
</Card>

// ✅ Correct
<KpiCard>
  <CardHeader>
    <CardTitle>Answered</CardTitle>
  </CardHeader>
  <CardContent>
    <KpiValue value={123} />
  </CardContent>
</KpiCard>
```

### Mistake 2 — Hand-formatting values or using raw `Skeleton`

```tsx
// ❌ Wrong
<span className="text-3xl font-bold">{formatDuration(123)}</span>
<Skeleton className="h-6 w-18" />

// ✅ Correct
<KpiValue value={123} kind="duration" />
<KpiValueSkeleton />
```

### Mistake 3 — Passing Tile/TileBody flex props to KpiCard / CardContent

```tsx
// ❌ Wrong — tractor FlexProps on the card
<KpiCard minWidth="228px" px="xxs" py="xxs">
  <CardContent alignItems="center" gap="xxxs">…</CardContent>
</KpiCard>

// ✅ Correct — Tailwind via className
<KpiCard className="min-w-[228px]">
  <CardContent className="flex flex-col gap-1">…</CardContent>
</KpiCard>
```

### Mistake 4 — Placing CardAction outside CardHeader

```tsx
// ❌ Wrong
<KpiCard>
  <CardAction>…</CardAction>
  <CardHeader>
    <CardTitle>Answered</CardTitle>
  </CardHeader>
  <CardContent>…</CardContent>
</KpiCard>

// ✅ Correct — CardAction must be a child of CardHeader
<KpiCard>
  <CardHeader>
    <CardTitle>Answered</CardTitle>
    <CardAction>…</CardAction>
  </CardHeader>
  <CardContent>…</CardContent>
</KpiCard>
```

### Mistake 5 — Keeping TILE_VALUE / TileValue imports after migration

```tsx
// ❌ Wrong
import { TILE_VALUE, Tile, TileHeader, TileValue } from '@dashboard/library';
<TileValue type={TILE_VALUE.DURATION} value={123} />

// ✅ Correct
import { KpiCard, KpiValue } from '@aircall/blocks';
import { CardContent, CardHeader, CardTitle } from '@aircall/ds';

<KpiCard>
  <CardHeader>
    <CardTitle>AHT</CardTitle>
  </CardHeader>
  <CardContent>
    <KpiValue value={123} kind="duration" />
  </CardContent>
</KpiCard>
```

### Mistake 6 — Using CardTitle for the metric value instead of the label

```tsx
// ❌ Wrong — CardTitle used for the big number
<KpiCard>
  <CardHeader>
    <CardTitle>123</CardTitle>
  </CardHeader>
</KpiCard>

// ✅ Correct — CardTitle = label; KpiValue = metric
<KpiCard>
  <CardHeader>
    <CardTitle>Answered</CardTitle>
  </CardHeader>
  <CardContent>
    <KpiValue value={123} />
  </CardContent>
</KpiCard>
```
