Ctrl K
ComunidadGuías…

src/components/dives/dive-activity-card.tsx

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

Archivo original ↗Preguntar a KiBot
"use client";

import { useMemo } from "react";
import { BarChart3, Minus, TrendingDown, TrendingUp } from "lucide-react";
import {
  CARD_TITLE_ROW,
  Card,
  CardContent,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { ChartPeriodControls } from "@/components/dives/chart-period-controls";
import { ChartSkeleton } from "@/components/dives/chart-skeleton";
import { ChartStat, ChartStatRow } from "@/components/dives/chart-stat";
import { DiveActivityChart } from "@/components/dives/dive-activity-chart";
import { diveStatsAPI, DiveActivityPoint } from "@/lib/api/dive-stats";
import {
  type DiveActivitySummary,
  activityBars,
  activityDays,
  barCeiling,
  summarizeActivity,
} from "@/lib/dive-activity";
import type { ChartScope } from "@/lib/chart-period";
import { DIVE_ACTIVITY_VIEW_KEY } from "@/lib/chart-period-view";
import {
  type ChartPeriodSource,
  useChartPeriodView,
} from "@/hooks/useChartPeriodView";
import { cn } from "@/lib/utils";

const ACTIVITY_SOURCE: ChartPeriodSource<DiveActivityPoint> = {
  load: () => diveStatsAPI.getDiveActivity(),
  failure: "Failed to fetch dive activity:",
  // A bar is a calendar bucket, so the anchor is the start of a day that has
  // diving in it.
  anchors: activityDays,
  storageKey: DIVE_ACTIVITY_VIEW_KEY,
  // Opens on the whole career, not on a year of it - the opposite default to the
  // gas card's, and deliberate. "How has my diving gone" is the question this
  // card exists for, and a bar per year answers it at any career length, where a
  // dot per dive over the same span is a smear. A single season is one click in.
  defaultScope: "all",
};

// The Home page's how-much-am-I-diving card, and the counterpart to
// `GasUseCard`: that one is about the quality of the diving, this one about the
// quantity. They share their shape deliberately - the same header, the same stat
// row, the same period controls in the same corner, the same All/Year/Month
// scopes over the same shared `chart-period.ts` - because two charts on one page
// that work differently cost more to read than either does alone.
//
// Where they differ is what a mark *is*. The gas chart plots dives, so its anchor
// is one of their timestamps; here a bar is a calendar bucket, so the anchor is
// the start of a day that has diving in it. Everything downstream of that -
// stepping to the next period with dives, the dropdown of periods worth offering,
// restoring the remembered one - is `useChartPeriodView`, shared by both cards.
export function DiveActivityCard() {
  const view = useChartPeriodView(ACTIVITY_SOURCE);
  const { points, scope, anchor } = view;

  const bars = useMemo(
    () => activityBars(points ?? [], scope, anchor),
    [points, scope, anchor],
  );
  const ceiling = useMemo(
    () => barCeiling(points ?? [], scope),
    [points, scope],
  );
  const summary = useMemo(
    () => summarizeActivity(points ?? [], scope, anchor),
    [points, scope, anchor],
  );

  return (
    <Card>
      <CardHeader>
        <div className={cn(CARD_TITLE_ROW, "@container")}>
          <CardTitle
            as="h2"
            className="flex min-w-0 grow basis-0 items-center gap-2"
          >
            <BarChart3 className="h-5 w-5" />
            Dive Activity
          </CardTitle>
          <ChartPeriodControls name="Dive activity" view={view} />
        </div>
      </CardHeader>
      <CardContent>
        {points === null ? (
          <ChartSkeleton stats={2} />
        ) : (
          <>
            {summary && (
              <DiveActivitySummaryRow summary={summary} scope={scope} />
            )}
            <DiveActivityChart bars={bars} ceiling={ceiling} scope={scope} />
          </>
        )}
      </CardContent>
    </Card>
  );
}

// What one bar counts, for the stat that names the fullest of them.
const BUSIEST_LABELS: Record<ChartScope, string> = {
  all: "Busiest year",
  year: "Busiest month",
  month: "Busiest day",
};

// The headline figures for what's on screen. The bars show the shape; these
// answer "how much, and is it more than before", which a row of columns is
// worst at answering precisely.
function DiveActivitySummaryRow({
  summary,
  scope,
}: {
  summary: DiveActivitySummary;
  scope: ChartScope;
}) {
  return (
    <ChartStatRow className="mb-5">
      <ChartStat label="Dives" detail={<Change summary={summary} />}>
        <span className="text-base font-semibold tabular-nums">
          {summary.dives}
        </span>
      </ChartStat>
      <ChartStat label={BUSIEST_LABELS[scope]}>
        <span className="text-base font-semibold">{summary.busiestLabel}</span>
        <span className="text-sm text-muted-foreground tabular-nums">
          {summary.busiestDives} {summary.busiestDives === 1 ? "dive" : "dives"}
        </span>
      </ChartStat>
    </ChartStatRow>
  );
}

// Change against the previous period with diving.
//
// Deliberately uncolored, the same call the gas card's `Change` makes and for
// the same reason: the app has no good/bad status tokens, and teal is already
// spoken for as "a dive" on the plot below. It also isn't obviously a good or
// bad thing - a light year can be a house move rather than a slump - so the
// arrow and the "vs 2025" carry the direction and nothing editorializes about it.
function Change({ summary }: { summary: DiveActivitySummary }) {
  if (summary.change === null) return null;

  const Icon =
    summary.change === 0
      ? Minus
      : summary.change < 0
        ? TrendingDown
        : TrendingUp;

  return (
    <span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
      <Icon className="h-3.5 w-3.5 shrink-0" aria-hidden />
      {summary.change === 0
        ? `level with ${summary.previousLabel}`
        : `${summary.change > 0 ? "+" : "−"}${Math.abs(summary.change)} vs ${summary.previousLabel}`}
    </span>
  );
}