# Agenda Card

> A day-at-a-glance widget: the date lit by a blue glow, events sliding in, and a bar that fills for the one under way.

- Category: Data
- Version: 1.0.0
- Platforms: iOS, Android, web
- Dependencies: `react-native-reanimated`, `react-native-svg`, `react-native-worklets`
- Requires: React Native New Architecture (Reanimated 4)

## Install

```bash
npx kinetik-ui add agenda-card
```

With the shadcn CLI:

```bash
npx shadcn@latest add https://kinetik-ui.dev/r/agenda-card.json
```

## Usage

```tsx
import { AgendaCard } from '@/components/kinetik/agenda-card';

<AgendaCard
  date="Oct 30"
  weekday="Thursday"
  events={[
    { id: '1', title: 'Team standup', start: '09:30', end: '09:45' },
    { id: '2', title: 'Deep work', start: '10:00', end: '12:00', progress: 0.4 },
    { id: '3', title: 'Design review', start: '13:30', end: '14:30' },
  ]}
  onAdd={openNewEvent}
  onEventPress={(e) => router.push(`/events/${e.id}`)}
/>;
```

## When to use

- A home-screen style glance at today: the date, a count and the next few events.

## When not to use

- Full calendars or long agendas: use Month Calendar or Timeline.

## Notes

- The first `limit` events are listed (3 by default). `total` sets the count on the date panel when the day has more.
- Give the event under way a `progress` (0–1): its bar dims and fills to match, and screen readers hear "now".
- Events added later slide in at once; the ones there from the start stagger in.
- The card is night-blue on both themes so the glow reads. The add button only shows with `onAdd`.

## AgendaCardProps

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `date` | `string` |  | The big date, e.g. "Oct 30". |
| `weekday` | `string` |  | The day under it, e.g. "Thursday". |
| `events` | `readonly AgendaEvent[]` |  | Events in order. The first `limit` are listed. |
| `total`? | `number` |  | How many events the day has, if more than are listed. Defaults to `events.length`. |
| `limit`? | `number` | `3` | Most events listed at once. Defaults to 3. |
| `heading`? | `string` | `'Upcoming'` | Heading over the list. Defaults to "Upcoming". |
| `onAdd`? | `() => void` |  | Shows the round add button. |
| `onEventPress`? | `(event: AgendaEvent) => void` |  |  |
| `haptics`? | `boolean` | `true` | A tap on add. Defaults to true. |
| `style`? | `StyleProp<ViewStyle>` |  |  |

## Accessibility

- Reduced motion: The glow, rows and fills appear in place; the add button does not turn.
- Screen readers: The date reads as one line ("Thursday, Oct 30, 6 events"), each event as one line ("Focus Time, 11:00 to 13:00, now"), and the add button as "Add event".
- Touch target: Event rows are at least 44 pt tall; the add button is 48 pt.

## Performance

The glow, rows and fill run short UI-thread springs and timings on arrival and on change. Nothing animates at rest.

- Measured: Pixel 9 emulator, API 35, release build · interaction median 1.00× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 150 MB app memory
- Real-device measurement pending.

## Source

`components/kinetik/agenda-card.tsx`

```tsx
import { useEffect, useState } from 'react';
import { Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
import Animated, {
  useAnimatedStyle,
  useSharedValue,
  withDelay,
  withSequence,
  withSpring,
  withTiming,
} from 'react-native-reanimated';
import Svg, { Path } from 'react-native-svg';

import { useHaptic } from '../../lib/kinetik/hooks/use-haptic';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { spring, timing } from '../../lib/kinetik/motion/motion';
import { gradient } from '../../lib/kinetik/tokens/gradient';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { glow, minTouchTarget, palettes, withAlpha } from '../../lib/kinetik/tokens/tokens';

export type AgendaEvent = {
  id: string;
  title: string;
  /** Start and end times as shown, e.g. "10:00" and "10:30". */
  start: string;
  end: string;
  /** Colour of the event's bar. Defaults to a blue, violet and teal rotation. */
  color?: string;
  /** For the event happening now: how far through it is, 0–1. Its bar fills to match. */
  progress?: number;
};

export type AgendaCardProps = {
  /** The big date, e.g. "Oct 30". */
  date: string;
  /** The day under it, e.g. "Thursday". */
  weekday: string;
  /** Events in order. The first `limit` are listed. */
  events: readonly AgendaEvent[];
  /** How many events the day has, if more than are listed. Defaults to `events.length`. */
  total?: number;
  /** Most events listed at once. Defaults to 3. */
  limit?: number;
  /** Heading over the list. Defaults to "Upcoming". */
  heading?: string;
  /** Shows the round add button. */
  onAdd?: () => void;
  onEventPress?: (event: AgendaEvent) => void;
  /** A tap on add. Defaults to true. */
  haptics?: boolean;
  style?: StyleProp<ViewStyle>;
};

// Plus from Tabler Icons (MIT, https://tabler.io/icons), via iconstack.io.
const PLUS = 'M12 5l0 14 M5 12l14 0';
const TONES = ['hsl(224, 90%, 62%)', 'hsl(272, 72%, 62%)', 'hsl(188, 80%, 50%)'];

/**
 * A day-at-a-glance widget: the date on a panel lit from below by a blue
 * glow, and the next events sliding in one after another, each with a colour
 * bar that fills for the event under way. The add button turns as it is
 * pressed, and new events slide in at their place in the list.
 */
export function AgendaCard({
  date,
  weekday,
  events,
  total,
  limit = 3,
  heading = 'Upcoming',
  onAdd,
  onEventPress,
  haptics = true,
  style,
}: AgendaCardProps) {
  const { radius, space, font } = useKinetikTheme();
  // A night-blue slab on either theme, so the glow reads.
  const colors = palettes.dark;
  const reduced = useReduceMotion();
  const haptic = useHaptic(haptics);
  const count = total ?? events.length;
  const shown = events.slice(0, limit);
  // Events there from the start stagger in; ones added later slide in at once.
  const [first] = useState(() => new Set(events.map((e) => e.id)));

  const light = useSharedValue(reduced ? 1 : 0);
  useEffect(() => {
    if (!reduced) light.set(withTiming(1, { duration: 900 }));
  }, [reduced, light]);
  const bump = useSharedValue(1);
  useEffect(() => {
    if (!reduced)
      bump.set(withSequence(withTiming(0.7, { duration: 0 }), withSpring(1, spring('bouncy'))));
  }, [count, reduced, bump]);
  const turn = useSharedValue(0);

  const glowStyle = useAnimatedStyle(() => ({
    opacity: light.get(),
    transform: [{ translateY: (1 - light.get()) * 30 }],
  }));
  const countStyle = useAnimatedStyle(() => ({ transform: [{ scale: bump.get() }] }));
  const plus = useAnimatedStyle(() => ({ transform: [{ rotate: `${turn.get()}deg` }] }));

  return (
    <View
      style={[
        styles.card,
        {
          backgroundColor: 'hsl(224, 20%, 14%)',
          borderColor: withAlpha(colors.highlight, 0.08),
          borderRadius: radius.xl + 4,
          padding: space[3],
          gap: space[4],
        },
        style,
      ]}
    >
      <View
        accessible
        accessibilityLabel={`${weekday}, ${date}, ${count} ${count === 1 ? 'event' : 'events'}`}
        style={[styles.panel, { borderRadius: radius.xl - 4, padding: space[4] }]}
      >
        <Animated.View
          pointerEvents="none"
          style={[
            StyleSheet.absoluteFill,
            gradient(
              'linear-gradient(180deg, hsl(226, 32%, 6%) 0%, hsl(226, 34%, 8%) 42%, hsl(222, 78%, 42%) 82%, hsl(214, 95%, 72%) 100%)',
            ),
            glowStyle,
          ]}
        />
        <Text
          numberOfLines={1}
          adjustsFontSizeToFit
          style={{
            color: colors.highlight,
            fontSize: font.size['3xl'],
            fontWeight: font.weight.semibold,
            letterSpacing: font.tracking.tight,
          }}
        >
          {date}
        </Text>
        <View style={styles.push} />
        <Text
          style={{
            color: colors.highlight,
            fontSize: font.size.lg,
            fontWeight: font.weight.semibold,
          }}
        >
          {weekday}
        </Text>
        <Animated.View style={[styles.countRow, countStyle]}>
          <Text
            style={{
              color: colors.highlight,
              fontSize: font.size.lg,
              fontWeight: font.weight.semibold,
            }}
          >
            {count} {count === 1 ? 'event' : 'events'}
          </Text>
        </Animated.View>
      </View>

      <View style={[styles.list, { gap: space[2], paddingTop: space[2] }]}>
        <Text
          accessibilityRole="header"
          style={{ color: colors.textMuted, fontSize: font.size.sm, letterSpacing: 0.6 }}
        >
          {heading.toUpperCase()}
        </Text>
        {shown.map((event, i) => (
          <Row
            key={event.id}
            event={event}
            tone={event.color ?? TONES[i % TONES.length]!}
            delay={first.has(event.id) ? 160 + i * 110 : 0}
            onPress={onEventPress ? () => onEventPress(event) : undefined}
          />
        ))}
      </View>

      {onAdd ? (
        <Pressable
          accessibilityRole="button"
          accessibilityLabel="Add event"
          onPress={() => {
            haptic('tap');
            if (!reduced)
              turn.set(
                withSequence(withSpring(90, spring('snappy')), withTiming(0, { duration: 0 })),
              );
            onAdd();
          }}
          style={({ pressed }) => [
            styles.fab,
            {
              backgroundColor: colors.surface,
              borderColor: withAlpha(colors.sky, 0.55),
              boxShadow: glow(colors.sky),
              transform: [{ scale: pressed ? 0.92 : 1 }],
            },
          ]}
        >
          <Animated.View style={plus}>
            <Svg width={22} height={22} viewBox="0 0 24 24" fill="none">
              <Path d={PLUS} stroke={colors.highlight} strokeWidth={2.2} strokeLinecap="round" />
            </Svg>
          </Animated.View>
        </Pressable>
      ) : null}
    </View>
  );
}

function Row({
  event,
  tone,
  delay,
  onPress,
}: {
  event: AgendaEvent;
  tone: string;
  delay: number;
  onPress?: () => void;
}) {
  const { font } = useKinetikTheme();
  const colors = palettes.dark;
  const reduced = useReduceMotion();
  const enter = useSharedValue(reduced ? 1 : 0);
  const share = Math.max(0, Math.min(1, event.progress ?? 0));
  const fillTo = useSharedValue(reduced ? share : 0);
  // The delay only matters on mount.
  const [wait] = useState(delay);
  useEffect(() => {
    if (reduced) return;
    enter.set(withDelay(wait, withSpring(1, spring('smooth'))));
  }, [wait, reduced, enter]);
  useEffect(() => {
    fillTo.set(reduced ? share : withDelay(400, withTiming(share, timing('slow'))));
  }, [share, reduced, fillTo]);

  const style = useAnimatedStyle(() => ({
    opacity: enter.get(),
    transform: [{ translateX: (1 - enter.get()) * 16 }],
  }));
  const fill = useAnimatedStyle(() => ({ height: `${fillTo.get() * 100}%` }));
  const now = event.progress !== undefined;

  const label = `${event.title}, ${event.start} to ${event.end}${now ? ', now' : ''}`;
  const content = (
    <>
      <View
        style={[styles.bar, { backgroundColor: now ? withAlpha(colors.highlight, 0.16) : tone }]}
      >
        {now ? <Animated.View style={[styles.barFill, { backgroundColor: tone }, fill]} /> : null}
      </View>
      <View style={styles.grow}>
        <Text numberOfLines={1} style={{ color: colors.text, fontSize: font.size.md + 1 }}>
          {event.title}
        </Text>
        <Text
          style={{
            color: colors.textSubtle,
            fontSize: font.size.sm,
            fontVariant: ['tabular-nums'],
          }}
        >
          {event.start} - {event.end}
        </Text>
      </View>
    </>
  );

  return (
    <Animated.View style={style}>
      {onPress ? (
        <Pressable
          accessibilityRole="button"
          accessibilityLabel={label}
          onPress={onPress}
          style={styles.row}
        >
          {content}
        </Pressable>
      ) : (
        <View accessible accessibilityLabel={label} style={styles.row}>
          {content}
        </View>
      )}
    </Animated.View>
  );
}

const styles = StyleSheet.create({
  card: { flexDirection: 'row', borderWidth: 1 },
  panel: {
    width: '46%',
    minHeight: 200,
    overflow: 'hidden',
    backgroundColor: 'hsl(226, 32%, 6%)',
  },
  push: { flex: 1 },
  countRow: { alignSelf: 'flex-start', transformOrigin: 'left center' },
  list: { flex: 1, minWidth: 0 },
  row: { flexDirection: 'row', gap: 12, minHeight: minTouchTarget, alignItems: 'stretch' },
  bar: { width: 3, borderRadius: 2, overflow: 'hidden' },
  barFill: { width: 3, borderRadius: 2 },
  grow: { flex: 1, minWidth: 0, justifyContent: 'center' },
  fab: {
    position: 'absolute',
    right: 14,
    bottom: 14,
    width: 48,
    height: 48,
    borderRadius: 24,
    borderWidth: 1.5,
    alignItems: 'center',
    justifyContent: 'center',
  },
});
```
