113 lines
3.6 KiB
TypeScript
113 lines
3.6 KiB
TypeScript
import { useQuery } from '@tanstack/react-query';
|
|
import { Link } from 'react-router-dom';
|
|
import Col from 'react-bootstrap/Col';
|
|
import Row from 'react-bootstrap/Row';
|
|
|
|
import { fetchProjects, fetchLanguages, type ProjectSummary, type Source } from '../api/client';
|
|
import { ContributionGraph, AllTimeGraph } from '../components/ContributionGraph';
|
|
|
|
export function DashPage() {
|
|
const projectsQ = useQuery({
|
|
queryKey: ['projects'],
|
|
queryFn: fetchProjects,
|
|
refetchInterval: 60_000,
|
|
});
|
|
|
|
const projects = projectsQ.data ?? [];
|
|
const ranked = rankProjects(projects);
|
|
|
|
return (
|
|
<>
|
|
<Row className="mb-3">
|
|
<Col>
|
|
<p>
|
|
i rarely say anything that warrants capital letters. a peek into the
|
|
projects i'm working on is below.
|
|
</p>
|
|
</Col>
|
|
</Row>
|
|
<ContributionGraph />
|
|
<AllTimeGraph />
|
|
{projectsQ.isLoading && <p>loading...</p>}
|
|
{projectsQ.isError && (
|
|
<p>error: {(projectsQ.error as Error).message}</p>
|
|
)}
|
|
<Row xs={1} md={2} lg={3} className="g-3">
|
|
{ranked.map((p) => (
|
|
<Col key={`${p.source}:${p.repo}`}>
|
|
<ProjectCard project={p} />
|
|
</Col>
|
|
))}
|
|
</Row>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function ProjectCard({ project: p }: { project: ProjectSummary }) {
|
|
const langsQ = useQuery({
|
|
queryKey: ['languages', p.source, p.host, p.repo],
|
|
queryFn: () => fetchLanguages(p.source as Source, p.host, p.repo),
|
|
enabled: p.source === 'github' || p.source === 'gitea',
|
|
staleTime: 10 * 60_000,
|
|
});
|
|
|
|
const langs = langsQ.data;
|
|
const topLangs = langs ? topLanguages(langs, 3) : null;
|
|
|
|
return (
|
|
<Link to={`/project/${p.source}/${p.repo}`} className="text-decoration-none">
|
|
<div className="project-card p-3">
|
|
<h5 className="mb-1">{p.repo}</h5>
|
|
<small className="text-muted d-block mb-2">
|
|
{p.source}
|
|
{topLangs && ` · ${topLangs}`}
|
|
</small>
|
|
<div className="d-flex gap-3" style={{ fontSize: '0.8rem' }}>
|
|
{p.commit_count > 0 && <span>{p.commit_count} commits</span>}
|
|
{p.issue_count > 0 && <span>{p.issue_count} issues</span>}
|
|
{p.pr_count > 0 && <span>{p.pr_count} prs</span>}
|
|
</div>
|
|
<div style={{ fontSize: '0.75rem', opacity: 0.6, marginTop: '0.25rem' }}>
|
|
{formatRange(p.first_activity, p.last_activity)}
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
function topLanguages(langs: Record<string, number>, n: number): string {
|
|
return Object.entries(langs)
|
|
.sort(([, a], [, b]) => b - a)
|
|
.slice(0, n)
|
|
.map(([lang]) => lang.toLowerCase())
|
|
.join(', ');
|
|
}
|
|
|
|
function formatRange(first: string | null, last: string | null): string {
|
|
const fmt = (iso: string) =>
|
|
new Date(iso).toLocaleDateString('en-GB', { month: 'short', year: 'numeric' }).toLowerCase();
|
|
if (first && last) return `${fmt(first)} — ${fmt(last)}`;
|
|
if (last) return fmt(last);
|
|
return '';
|
|
}
|
|
|
|
function rankProjects(projects: ProjectSummary[]): ProjectSummary[] {
|
|
if (projects.length === 0) return [];
|
|
const now = Date.now();
|
|
const maxVolume = Math.max(...projects.map((p) => p.commit_count + p.issue_count + p.pr_count));
|
|
const oldest = Math.min(
|
|
...projects.map((p) => (p.last_activity ? new Date(p.last_activity).getTime() : 0)),
|
|
);
|
|
const range = now - oldest || 1;
|
|
|
|
return [...projects].sort((a, b) => score(b) - score(a));
|
|
|
|
function score(p: ProjectSummary): number {
|
|
const volume = (p.commit_count + p.issue_count + p.pr_count) / (maxVolume || 1);
|
|
const recency = p.last_activity
|
|
? (new Date(p.last_activity).getTime() - oldest) / range
|
|
: 0;
|
|
return 0.6 * recency + 0.4 * volume;
|
|
}
|
|
}
|