Something went wrong. Try again.
Monorepo for Tangled forked from tangled.org/core
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268<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>