1
0
mirror of https://github.com/avinal/avinal.github.io.git synced 2026-07-03 23:30:09 +05:30
Files
avinal.github.io/src/components/ActivityRow.astro
T
2026-03-17 21:29:53 +05:30

446 lines
12 KiB
Plaintext

---
/**
* Combined activity card: graph (left) + stats (right) in one row.
* Mirrors jay.fish's "Commit Carnage" + "Kill Count" layout.
*/
import type { ActivityData } from "@/lib/activity";
import type { GitHubUser } from "@/lib/github";
interface Props {
activity: ActivityData;
user: GitHubUser | null;
}
const { activity, user } = Astro.props;
const hasWaka = activity.wakatime.available;
---
<div class="activity-card card">
<div class="activity-header">
<h3 class="widget-title">
<span class="icon-trigger" id="gol-trigger">
<svg class="icon-heart" width="24" height="24" viewBox="0 0 24 24" fill="#e11d48" xmlns="http://www.w3.org/2000/svg">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
</svg>
<svg class="icon-signal" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
</span>
Activity
</h3>
<span class="text-muted text-xs" id="activity-subtitle">past year</span>
</div>
{activity.weeks.length > 0 ? (
<div class="graph-scroll">
<div class="graph-grid" role="img" aria-label="Activity graph">
{activity.weeks.map((week) => (
<div class="graph-col">
{week.map((day) => {
const ghLevel = day.githubLevel;
const wakaLvl = hasWaka && day.wakaSeconds > 0
? Math.min(Math.max(1, Math.ceil(day.wakaSeconds / 1800)), 4)
: 0;
const d = new Date(day.date);
const fmtDate = `${String(d.getDate()).padStart(2, "0")} ${d.toLocaleString("en-US", { month: "short" })} ${d.getFullYear()}`;
const tipParts = [fmtDate, `${day.githubCount} contribution${day.githubCount !== 1 ? "s" : ""}`];
if (hasWaka && day.wakaSeconds > 0) tipParts.push(day.wakaText);
const tip = tipParts.join(" · ");
const hasGh = ghLevel > 0;
const hasWk = wakaLvl > 0;
let color: string;
if (hasGh && hasWk) {
const ghPct = Math.round((ghLevel / (ghLevel + wakaLvl)) * 100);
color = `color-mix(in oklch, var(--graph-${ghLevel}) ${ghPct}%, var(--waka-${wakaLvl}))`;
} else if (hasWk) {
color = `var(--waka-${wakaLvl})`;
} else {
color = `var(--graph-${ghLevel})`;
}
const isAlive = hasGh || hasWk;
return <div class="graph-cell" style={`background-color: ${color}`} title={tip} data-alive={isAlive ? "1" : "0"}></div>;
})}
</div>
))}
</div>
</div>
<div class="graph-legend">
<span class="legend-group">
<span class="text-xs text-muted">GitHub</span>
<div class="legend-cell" style="background-color: var(--graph-1)"></div>
<div class="legend-cell" style="background-color: var(--graph-2)"></div>
<div class="legend-cell" style="background-color: var(--graph-3)"></div>
<div class="legend-cell" style="background-color: var(--graph-4)"></div>
</span>
{hasWaka && (
<span class="legend-group">
<span class="text-xs text-muted">Both</span>
<div class="legend-cell" style="background-color: color-mix(in oklch, var(--graph-2) 50%, var(--waka-2))"></div>
<div class="legend-cell" style="background-color: color-mix(in oklch, var(--graph-3) 50%, var(--waka-3))"></div>
</span>
)}
{hasWaka && (
<span class="legend-group">
<div class="legend-cell" style="background-color: var(--waka-1)"></div>
<div class="legend-cell" style="background-color: var(--waka-2)"></div>
<div class="legend-cell" style="background-color: var(--waka-3)"></div>
<div class="legend-cell" style="background-color: var(--waka-4)"></div>
<span class="text-xs text-muted">WakaTime</span>
</span>
)}
</div>
) : (
<p class="text-muted text-sm">Activity data unavailable.</p>
)}
<!-- Stats below the graph -->
<div class="activity-stats">
<dl class="stats-row">
<div class="stat-item gh">
<dt>Contributions</dt>
<dd>{activity.github.total.toLocaleString()}</dd>
</div>
<div class="stat-item gh">
<dt>Public Repos</dt>
<dd>{user?.public_repos ?? "—"}</dd>
</div>
<div class="stat-item gh">
<dt>Followers</dt>
<dd>{user?.followers ?? "—"}</dd>
</div>
{hasWaka && (
<Fragment>
<div class="stat-item waka">
<dt>Tracked (year)</dt>
<dd>{activity.wakatime.totalText}</dd>
</div>
<div class="stat-item waka">
<dt>Daily Avg</dt>
<dd>{activity.wakatime.dailyAvgText}</dd>
</div>
<div class="stat-item waka">
<dt>Best Day</dt>
<dd>{activity.wakatime.bestDayText}</dd>
</div>
</Fragment>
)}
</dl>
</div>
</div>
<style>
.activity-card {
padding: var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.activity-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.graph-scroll {
overflow: hidden;
display: flex;
justify-content: center;
}
@media (max-width: 900px) {
.graph-scroll {
justify-content: flex-end;
}
}
.graph-grid {
display: flex;
gap: 3px;
flex-shrink: 0;
}
.graph-col {
display: flex;
flex-direction: column;
gap: 3px;
}
.graph-cell {
width: 11px;
height: 11px;
border-radius: 2px;
cursor: default;
}
.graph-legend {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-2);
}
.legend-group {
display: flex;
align-items: center;
gap: 3px;
}
.legend-cell {
width: 10px;
height: 10px;
border-radius: 2px;
}
/* Stats — horizontal row below the graph */
.activity-stats {
padding-top: var(--space-4);
border-top: 1px solid var(--border);
}
.stats-row {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: var(--space-4) var(--space-6);
}
.stat-item {
display: flex;
flex-direction: column;
gap: 2px;
}
.stat-item dt {
font-size: var(--text-xs);
color: var(--text-muted);
}
.stat-item dd {
font-size: var(--text-sm);
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--text);
}
.stat-item.gh dd {
color: var(--graph-3);
}
.stat-item.waka dd {
color: var(--waka-3);
}
/* Easter egg: heart icon trigger */
.icon-trigger {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
width: 26px;
height: 26px;
}
.icon-heart {
position: absolute;
opacity: 0;
transform: scale(0.6);
transition: opacity 0.3s var(--ease-out), transform 0.3s var(--ease-out);
pointer-events: none;
}
.icon-signal {
position: relative;
z-index: 1;
transition: opacity 0.3s var(--ease-out);
}
.icon-trigger:hover .icon-heart {
opacity: 0.4;
transform: scale(1);
animation: heart-pulse 1.2s ease-in-out infinite;
}
.icon-trigger:hover .icon-signal {
opacity: 0.6;
}
:global(.gol-active) .icon-heart {
opacity: 0.5;
transform: scale(1);
animation: heart-pulse 1.2s ease-in-out infinite;
}
:global(.gol-active) .icon-signal {
opacity: 0.5;
}
@keyframes heart-pulse {
0%, 100% { opacity: 0.3; transform: scale(0.88); }
50% { opacity: 0.6; transform: scale(1.12); }
}
:global(.gol-active) .graph-cell {
cursor: crosshair;
}
</style>
<script>
const trigger = document.getElementById("gol-trigger");
const card = document.querySelector(".activity-card");
const graphGrid = document.querySelector(".graph-grid") as HTMLElement | null;
const subtitle = document.getElementById("activity-subtitle");
if (trigger && graphGrid && card) {
let active = false;
let tickId: number | null = null;
let grid: number[][] = [];
let saved: { bg: string; title: string }[][] = [];
const columns = () => graphGrid.querySelectorAll(".graph-col");
function readGrid(): number[][] {
return Array.from(columns()).map((col) =>
Array.from(col.querySelectorAll(".graph-cell")).map((c) => {
if (c.getAttribute("data-alive") !== "1") return 0;
return Math.random() < 0.45 ? 1 : 0;
}),
);
}
function saveState() {
saved = Array.from(columns()).map((col) =>
Array.from(col.querySelectorAll(".graph-cell")).map((c) => ({
bg: (c as HTMLElement).style.backgroundColor,
title: c.getAttribute("title") || "",
})),
);
}
function restoreState() {
const cols = columns();
cols.forEach((col, ci) => {
col.querySelectorAll(".graph-cell").forEach((c, ri) => {
const el = c as HTMLElement;
el.style.backgroundColor = saved[ci]?.[ri]?.bg || "";
el.setAttribute("title", saved[ci]?.[ri]?.title || "");
});
});
}
function countAlive(): number {
return grid.reduce((s, col) => s + col.reduce((a, v) => a + v, 0), 0);
}
function step() {
const numC = grid.length;
const numR = grid[0]?.length || 0;
const out = grid.map((col) => col.map(() => 0));
for (let c = 0; c < numC; c++) {
for (let r = 0; r < numR; r++) {
let n = 0;
for (let dc = -1; dc <= 1; dc++) {
for (let dr = -1; dr <= 1; dr++) {
if (dc === 0 && dr === 0) continue;
n += grid[(c + dc + numC) % numC][(r + dr + numR) % numR];
}
}
const a = grid[c][r];
out[c][r] = a ? (n === 2 || n === 3 ? 1 : 0) : n === 3 ? 1 : 0;
}
}
grid = out;
}
function render() {
const style = getComputedStyle(document.documentElement);
const deadColor = style.getPropertyValue("--graph-0").trim();
const fallbackAlive = style.getPropertyValue("--accent").trim();
const cols = columns();
cols.forEach((col, ci) => {
col.querySelectorAll(".graph-cell").forEach((c, ri) => {
const el = c as HTMLElement;
const alive = grid[ci]?.[ri];
const origColor = saved[ci]?.[ri]?.bg;
const isOrigAlive = origColor && origColor !== deadColor;
el.style.backgroundColor = alive
? (isOrigAlive ? origColor : fallbackAlive)
: deadColor;
el.setAttribute("title", "");
});
});
}
function tick() {
step();
if (countAlive() === 0) {
stop();
return;
}
render();
tickId = window.setTimeout(tick, 280);
}
function start() {
saveState();
grid = readGrid();
if (countAlive() < 10) {
for (let c = 0; c < grid.length; c++) {
for (let r = 0; r < (grid[0]?.length || 0); r++) {
if (Math.random() < 0.3) grid[c][r] = 1;
}
}
}
active = true;
card.classList.add("gol-active");
if (subtitle) subtitle.textContent = "hover to bring cells alive";
render();
tickId = window.setTimeout(tick, 280);
}
function stop() {
if (tickId !== null) clearTimeout(tickId);
tickId = null;
active = false;
card.classList.remove("gol-active");
if (subtitle) subtitle.textContent = "past year";
restoreState();
}
trigger.addEventListener("click", () => {
active ? stop() : start();
});
graphGrid.addEventListener("mousemove", (e) => {
if (!active) return;
const target = e.target as HTMLElement;
if (!target.classList.contains("graph-cell")) return;
const col = target.parentElement;
if (!col) return;
const allCols = Array.from(columns());
const ci = allCols.indexOf(col);
const cells = Array.from(col.querySelectorAll(".graph-cell"));
const ri = cells.indexOf(target);
if (ci < 0 || ri < 0) return;
for (let dc = -1; dc <= 1; dc++) {
for (let dr = -1; dr <= 1; dr++) {
const nc = ci + dc;
const nr = ri + dr;
if (nc >= 0 && nc < grid.length && nr >= 0 && nr < (grid[0]?.length || 0)) {
grid[nc][nr] = 1;
}
}
}
});
}
</script>