src/lib/dive-activity.ts
Rama main · réplica de lectura del repositorio original.
// How much the diver has actually been diving, bucketed for Home's
// activity chart. The counting itself happens in the API (`/user/dive-activity`,
// one entry per day with diving in it); everything here is about turning that
// sparse series into the fixed grid a bar chart needs.
//
// The three scopes are the same three the gas card offers, and deliberately so -
// the two cards sit one above the other, and a control that reads the same in
// both places has to mean the same thing in both. What differs is only what a bar
// *is*: a day, a month or a year of the logbook, against the gas chart's
// individual dives.
//
// The calendar arithmetic behind those scopes - which period is on screen, what
// it's called, which ones are worth offering, how to step between them - lives in
// `lib/chart-period.ts` and is shared with the gas card rather than reimplemented
// here.
import type { DiveActivityPoint } from "@/lib/api/dive-stats";
import {
type ChartScope,
monthName,
periodLabel,
periodRange,
stepPeriod,
} from "@/lib/chart-period";
export interface ActivityBar {
// Identity within the view - a calendar year, a month 1-12, or a day of the
// month. Unique per view, so it's also the React key.
key: number;
// Under the axis. Kept short enough that twelve of them - or thirty-one - fit
// across the plot.
label: string;
// The same bucket said in full ("August 12, 2026"), for the tooltip and the
// chart's description, where there is room and no neighbouring label to infer
// the month or year from.
name: string;
dives: number;
}
// The UTC instant a bucket starts at - the form every function in
// `chart-period.ts` speaks. Built with `Date.UTC` and read back with `getUTC*`
// throughout, for the reason that module documents: these are wall-clock buckets
// the API already resolved in the dives' own local time, and re-reading them
// through the viewer's timezone would slide a New Year's Eve dive into the wrong
// year.
function dayStart(point: DiveActivityPoint): number {
return Date.UTC(point.year, point.month - 1, point.day);
}
// How many days a month has, without a leap-year table: day 0 of the *next*
// month is the last day of this one.
function daysInMonth(year: number, month: number): number {
return new Date(Date.UTC(year, month, 0)).getUTCDate();
}
// Every day the diver logged at least one dive on, oldest first - what the period
// controls step through and pick from.
//
// The anchor the card carries is always one of these, which is the invariant that
// makes switching scope land somewhere useful: going from Year to Month shows the
// month *containing* the day you were reading, not an arbitrary month that may be
// empty. Left in the API's order, which `bucket_by_day` sorts on the buckets
// themselves rather than on the instants they came from.
export function activityDays(points: DiveActivityPoint[]): number[] {
return points.map(dayStart);
}
// Dives per calendar year, keyed by year.
function yearTotals(points: DiveActivityPoint[]): Map<number, number> {
const totals = new Map<number, number>();
for (const point of points) {
totals.set(point.year, (totals.get(point.year) ?? 0) + point.dives);
}
return totals;
}
// Dives per calendar month across the whole logbook, keyed by the month's own UTC
// start - the same key `periodRange` produces, so nothing here invents a second
// identity for a month.
function monthTotals(points: DiveActivityPoint[]): Map<number, number> {
const totals = new Map<number, number>();
for (const point of points) {
const start = Date.UTC(point.year, point.month - 1, 1);
totals.set(start, (totals.get(start) ?? 0) + point.dives);
}
return totals;
}
// The bars to draw, in calendar order, including the empty ones.
//
// Empty buckets are the point of this function. A chart of only the months that
// had diving would space three trips evenly across the plot and quietly say the
// year was busy throughout; the gaps are what make a season legible as a season.
// It's the same call the gas chart makes by plotting a period's *calendar* bounds
// rather than the extent of what's in it.
//
// The "all" scope spans the first year with diving to the last, so a year taken
// off appears as the gap it was - but nothing is drawn before someone started
// diving or after their most recent dive, where there is no data rather than no
// diving. The bounded scopes have their own calendar bounds and use them: twelve
// months whatever the year held, and every day of the month whatever the trip
// covered.
export function activityBars(
points: DiveActivityPoint[],
scope: ChartScope,
anchor: number,
): ActivityBar[] {
if (scope === "month") {
const date = new Date(anchor);
const year = date.getUTCFullYear();
const month = date.getUTCMonth() + 1;
const dives = new Map(
points
.filter((point) => point.year === year && point.month === month)
.map((point) => [point.day, point.dives]),
);
return Array.from(
{ length: daysInMonth(year, month) },
(_, index) => index + 1,
).map((day) => ({
key: day,
label: String(day),
// "August 12, 2026" - the platform's own long date, so the hover card reads
// the way every other date in the app does.
name: new Date(Date.UTC(year, month - 1, day)).toLocaleDateString(
"en-US",
{ month: "long", day: "numeric", year: "numeric", timeZone: "UTC" },
),
dives: dives.get(day) ?? 0,
}));
}
if (scope === "year") {
const year = new Date(anchor).getUTCFullYear();
const dives = new Map<number, number>();
for (const point of points) {
if (point.year !== year) continue;
dives.set(point.month, (dives.get(point.month) ?? 0) + point.dives);
}
return Array.from({ length: 12 }, (_, index) => index + 1).map((month) => ({
key: month,
label: monthName(month, "short"),
name: `${monthName(month, "long")} ${year}`,
dives: dives.get(month) ?? 0,
}));
}
const totals = yearTotals(points);
const years = [...totals.keys()];
if (years.length === 0) return [];
const first = Math.min(...years);
const last = Math.max(...years);
return Array.from(
{ length: last - first + 1 },
(_, index) => first + index,
).map((candidate) => ({
key: candidate,
label: String(candidate),
name: String(candidate),
dives: totals.get(candidate) ?? 0,
}));
}
// The tallest bar the scope can ever produce, across the whole logbook - what the
// y axis is scaled to.
//
// Deliberately not the tallest bar in *this* period, which is the same call the
// gas chart makes for its own domain and matters more here: bar height is the
// quantity, so a per-year axis would draw a four-dive August exactly as tall as a
// forty-dive one, and paging between periods - the whole point of the arrows -
// would compare nothing.
export function barCeiling(
points: DiveActivityPoint[],
scope: ChartScope,
): number {
const counts =
scope === "month"
? points.map((point) => point.dives)
: scope === "year"
? [...monthTotals(points).values()]
: [...yearTotals(points).values()];
return counts.length === 0 ? 0 : Math.max(...counts);
}
// Dives logged inside a half-open `[start, end)` window of days.
function divesIn(
points: DiveActivityPoint[],
range: { start: number; end: number },
): number {
return points.reduce((total, point) => {
const day = dayStart(point);
return day >= range.start && day < range.end ? total + point.dives : total;
}, 0);
}
export interface DiveActivitySummary {
// Dives in the view: the whole logbook at the "all" scope, one year or one
// month at the bounded ones.
dives: number;
// The fullest bar in the view, and what to call it.
busiestLabel: string;
busiestDives: number;
// Change against the previous period *with diving*, in dives rather than
// percent: these are small whole numbers a diver can hold in their head, and
// "12 more than 2025" says something "+150%" doesn't, from 8 dives to 20. Null
// when there is nothing to compare against - the "all" scope, which spans
// everything, or the first period of diving.
change: number | null;
// What that previous period is called. Null exactly when `change` is.
previousLabel: string | null;
}
// The figures above the chart: how much diving is in the view, when the best of
// it happened, and whether there is more of it than last time.
//
// The bars show the shape; this answers "am I diving more", which is the question
// the card exists for. Returns null when the view holds no dives at all, which
// the "all" scope can only hit on an empty logbook.
export function summarizeActivity(
points: DiveActivityPoint[],
scope: ChartScope,
anchor: number,
): DiveActivitySummary | null {
const bars = activityBars(points, scope, anchor);
const dives = bars.reduce((total, bar) => total + bar.dives, 0);
if (dives === 0) return null;
// Scanned in calendar order and kept on a strict `>`, so a tie resolves to the
// earlier bucket every time rather than to whichever the sort happened to move.
// The label drops whatever the period control above it already states: the year
// at the month scope, the year *and* month at the day scope.
const busiest = bars.reduce((best, bar) =>
bar.dives > best.dives ? bar : best,
);
const summary: DiveActivitySummary = {
dives,
busiestLabel: busiestName(busiest, scope, anchor),
busiestDives: busiest.dives,
change: null,
previousLabel: null,
};
if (scope === "all") return summary;
// The previous period *with diving*, not the previous calendar one - the same
// rule the arrows step by, and for the same reason: diving happens in bursts,
// and "vs last month" is meaningless when last month was the off-season.
const previousAnchor = stepPeriod(anchor, scope, -1, activityDays(points));
if (previousAnchor === null) return summary;
const previousRange = periodRange(previousAnchor, scope);
return {
...summary,
change: dives - divesIn(points, previousRange),
previousLabel: periodLabel(previousRange.start, scope),
};
}
// What to call the busiest bar, with whatever the period control already says
// left off it.
function busiestName(
bar: ActivityBar,
scope: ChartScope,
anchor: number,
): string {
if (scope === "all") return bar.name;
if (scope === "year") return monthName(bar.key, "long");
const date = new Date(anchor);
return new Date(
Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), bar.key),
).toLocaleDateString("en-US", {
month: "long",
day: "numeric",
timeZone: "UTC",
});
}