<script lang="ts">
import type { Action } from "svelte/action";
import type { Component } from "svelte";
import type { SvelteHTMLElements } from "svelte/elements";
import { SvelteDate } from "svelte/reactivity";
import ArrowDownRight from "$icon/arrow-down-right";
import ArrowUpRight from "$icon/arrow-up-right";
import Minus from "$icon/minus";
import BookPlus from "$icon/book-plus";
import CircleDot from "$icon/circle-dot";
import GitCommit from "$icon/git-commit-horizontal";
import GitPullRequest from "$icon/git-pull-request";
import EmptyState from "#lib/components/ui/EmptyState.svelte";
import { formatCount, plural } from "#lib/format.ts";
import {
ACTIVITY_METRICS,
layoutActivityChart,
type ActivityMetric
} from "#lib/components/profile/profileActivityChart.ts";
import type {
ProfileActivityData,
ProfileActivityMonth
} from "#lib/components/profile/types.ts";
type Icon = Component<SvelteHTMLElements["svg"]>;
let { activity }: { activity: ProfileActivityData } = $props();
const emptyMonth = (monthsAgo: number): ProfileActivityMonth => ({
monthsAgo,
commits: 0,
repos: [],
issues: [],
pulls: []
});
const months = $derived(
Array.from({ length: 6 }, (_, index) => {
const monthsAgo = 5 - index;
return (
activity.months.find((month) => month.monthsAgo === monthsAgo) ??
emptyMonth(monthsAgo)
);
})
);
const monthName = (monthsAgo: number) => {
const date = new SvelteDate();
date.setUTCDate(1);
date.setUTCMonth(date.getUTCMonth() - monthsAgo);
return new Intl.DateTimeFormat("en", { month: "long", timeZone: "UTC" }).format(date);
};
const metrics: { key: ActivityMetric; label: string; singular: string; icon: Icon }[] = [
{ key: "pulls", label: "pull requests", singular: "pull request", icon: GitPullRequest },
{ key: "commits", label: "commits", singular: "commit", icon: GitCommit },
{ key: "repos", label: "new repositories", singular: "new repository", icon: BookPlus },
{ key: "issues", label: "issues", singular: "issue", icon: CircleDot }
];
const valueOf = (month: ProfileActivityMonth, key: ActivityMetric) =>
key === "commits" ? month.commits : month[key].length;
const nameOf = (value: number, metric: (typeof metrics)[number]) =>
plural(value, metric.singular, metric.label);
const values = $derived(
Object.fromEntries(
ACTIVITY_METRICS.map((metric) => [
metric,
months.map((month) => valueOf(month, metric))
])
) as Record<ActivityMetric, number[]>
);
let availableWidth = $state(982);
let focused = $state<number | null>(null);
let measured = $state(false);
const layout = $derived(layoutActivityChart(values, availableWidth));
const viewport: Action<HTMLDivElement> = (node) => {
let followsCurrent = true;
let frame = 0;
const alignCurrent = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
if (followsCurrent) node.scrollLeft = node.scrollWidth - node.clientWidth;
findFocused();
});
};
const findFocused = () => {
const scrollable = node.scrollWidth - node.clientWidth;
if (scrollable <= 1) {
focused = null;
return;
}
if (node.scrollLeft <= 1) {
focused = 0;
return;
}
const middle = node.scrollLeft + node.clientWidth / 2;
const index = layout.columns.findIndex(
(column) => middle >= column.left && middle < column.left + column.width
);
focused = index < 0 ? null : Math.min(index, layout.columns.length - 2);
};
const onScroll = () => {
followsCurrent = node.scrollWidth - node.clientWidth - node.scrollLeft <= 1;
findFocused();
};
const observer = new ResizeObserver(([entry]) => {
availableWidth = Math.floor(entry.contentRect.width);
measured = true;
alignCurrent();
});
observer.observe(node);
node.addEventListener("scroll", onScroll, { passive: true });
alignCurrent();
return {
destroy() {
cancelAnimationFrame(frame);
observer.disconnect();
node.removeEventListener("scroll", onScroll);
}
};
};
let pointerInside = $state(false);
let shiftHeld = $state(false);
let hoveredMetric = $state<ActivityMetric | null>(null);
const trackPointer = (event: PointerEvent) => {
pointerInside = true;
shiftHeld = event.shiftKey;
};
const trackShift = (event: KeyboardEvent) => {
shiftHeld = event.shiftKey;
};
</script>
<svelte:window onkeydown={trackShift} onkeyup={trackShift} onblur={() => (shiftHeld = false)} />
<section class="space-y-xs">
<h2 class="px-xs typography-paragraph-regular font-medium">Activity</h2>
{#if activity.months.length === 0}
<EmptyState message="This user does not have any activity yet." />
{:else}
<div
use:viewport
class="rounded-md border border-border-default {measured
? 'overflow-x-auto'
: 'overflow-hidden'}"
>
<div
class="relative bg-background-default"
style={`width:${layout.width}px;height:${layout.height}px`}
role="presentation"
onpointermove={trackPointer}
onpointerleave={() => (pointerInside = false)}
>
<div
class="absolute top-0 right-0 h-full bg-background-subtle"
style={`width:${layout.columns.at(-1)!.width}px`}
></div>
<svg
class="pointer-events-none absolute inset-0 size-full"
viewBox={`0 0 ${layout.width} ${layout.height}`}
fill="none"
aria-hidden="true"
>
{#each metrics as metric (metric.key)}
{#each layout.segments[metric.key] as segment, index (index)}
<path
d={segment.d}
class="transition-[stroke-opacity] duration-200 ease-out {segment.rising
? 'stroke-foreground-success'
: 'stroke-foreground-subtle'}"
stroke-width="1"
stroke-opacity={hoveredMetric === null
? 0.25
: hoveredMetric === metric.key
? 0.9
: 0.08}
/>
{/each}
{/each}
</svg>
{#each months as month, monthIndex (month.monthsAgo)}
{@const column = layout.columns[monthIndex]}
<div
class="absolute top-0 h-full {column.divider
? 'border-l border-border-default'
: ''}"
style={`left:${column.left}px;width:${column.width}px`}
>
<span
class="absolute bottom-2 left-2 cursor-default rounded-md px-xs py-3xs typography-paragraph-regular {monthIndex ===
5
? 'border border-border-subtle bg-background-default text-foreground-default'
: 'text-foreground-muted'}"
>
{monthName(month.monthsAgo)}
</span>
</div>
{#each metrics as metric (metric.key)}
{@const Icon = metric.icon}
{@const point = layout.points[metric.key][monthIndex]}
{@const previous =
monthIndex > 0 ? layout.points[metric.key][monthIndex - 1] : null}
{@const trend =
point.value === (previous?.value ?? 0)
? "flat"
: point.value > (previous?.value ?? 0)
? "up"
: "down"}
{@const current = monthIndex === 5}
{@const revealed =
current ||
focused === monthIndex ||
hoveredMetric === metric.key ||
(pointerInside && shiftHeld)}
<div
class="absolute z-10 flex h-7 -translate-y-1/2 cursor-default items-center rounded-md px-2xs py-3xs typography-paragraph-regular transition-colors duration-150 ease-out hover:z-20 {current
? 'bg-background-subtle text-foreground-default shadow-[0_0_var(--spacing-xs)_var(--spacing-2xs)_var(--color-background-subtle)]'
: `bg-background-default shadow-[0_0_var(--spacing-xs)_var(--spacing-2xs)_var(--color-background-default)] select-none ${revealed ? 'text-foreground-default' : 'text-foreground-muted'}`}"
style={`left:${point.x}px;top:${point.y}px`}
data-metric={metric.key}
data-month={monthIndex}
role="presentation"
onpointerenter={() => (hoveredMetric = metric.key)}
onpointerleave={() => (hoveredMetric = null)}
>
<Icon class="size-3.5 shrink-0" aria-hidden="true" />
<span
class="grid transition-[grid-template-columns,opacity] duration-200 ease-out {revealed
? 'grid-cols-[1fr] opacity-100'
: 'grid-cols-[0fr] opacity-0'}"
>
<span class="min-w-0 overflow-hidden">
<span class="flex items-center pl-xs whitespace-nowrap">
{formatCount(point.value)}
{#if current}
<span class="whitespace-pre">
{` ${nameOf(point.value, metric)}`}
</span>
{/if}
</span>
</span>
</span>
{#if trend === "flat"}
<Minus class="ml-xs size-3 shrink-0" aria-hidden="true" />
{:else if trend === "up"}
<ArrowUpRight
class="ml-xs size-3 shrink-0 transition-colors duration-150 ease-out {revealed
? 'text-foreground-success'
: ''}"
aria-hidden="true"
/>
{:else}
<ArrowDownRight class="ml-xs size-3 shrink-0" aria-hidden="true" />
{/if}
</div>
{/each}
{/each}
</div>
</div>
{#if activity.truncated}
<p class="px-xs typography-paragraph-small text-foreground-subtle">
Some older activity is not shown.
</p>
{/if}
{/if}
</section>