Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199<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 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 } from "$lib/format"; import { ACTIVITY_METRICS, layoutActivityChart, type ActivityMetric } from "$lib/components/profile/profileActivityChart"; import type { ProfileActivityData, ProfileActivityMonth } from "$lib/components/profile/types";
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]) => value === 1 ? 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(988); 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; }); }; const onScroll = () => { followsCurrent = node.scrollWidth - node.clientWidth - node.scrollLeft <= 1; }; const observer = new ResizeObserver(() => { availableWidth = node.clientWidth; alignCurrent(); }); observer.observe(node); node.addEventListener("scroll", onScroll, { passive: true }); alignCurrent(); return { destroy() { cancelAnimationFrame(frame); observer.disconnect(); node.removeEventListener("scroll", onScroll); } }; };</script>
<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="overflow-x-auto rounded-sm border border-border-default"> <div class="relative bg-background-default" style={`width:${layout.width}px;height:${layout.height}px`} > <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 opacity-25" 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={segment.rising ? "stroke-foreground-success" : "stroke-foreground-danger"} stroke-width="1" /> {/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 rounded-sm 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 rising = previous === null || point.value >= previous.value} {@const current = monthIndex === 5} <div class="group/stat absolute z-10 flex h-7 -translate-y-1/2 items-center gap-xs rounded-sm px-2xs py-3xs typography-paragraph-regular transition-colors duration-150 ease-out hover:z-20 {current ? 'bg-background-subtle text-foreground-default' : 'bg-background-default text-foreground-muted hover:text-foreground-default'}" style={`left:${point.x}px;top:${point.y}px`} data-metric={metric.key} data-month={monthIndex} > <Icon class="size-3.5 shrink-0" aria-hidden="true" /> <span class="flex items-center whitespace-nowrap"> {formatCount(point.value)} {#if current} <span class="whitespace-pre"> {` ${nameOf(point.value, metric)}`} </span> {/if} </span> {#if rising} <ArrowUpRight class="size-3 transition-colors duration-150 ease-out {current ? 'text-foreground-success' : 'group-hover/stat:text-foreground-success'}" aria-hidden="true" /> {:else} <ArrowDownRight class="size-3 transition-colors duration-150 ease-out {current ? 'text-foreground-danger' : 'group-hover/stat:text-foreground-danger'}" 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>