Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 73 additions & 1 deletion client/src/components/NetworkGraphVisualization.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,77 @@ export default function NetworkGraphVisualization({
}
}

// Draw date axis at the bottom of the graph
// Commits are evenly spaced (not proportional to time), so we sample
// a subset of commit positions and label them with their actual dates.
{
// Build array of {x, date} sorted by x (same as chronological)
const dateTicks = layout.nodes
.map((n) => ({ x: n.x, ts: new Date(n.committedDate).getTime() }))
.filter((d) => d.ts > 0)
.sort((a, b) => a.x - b.x);

if (dateTicks.length >= 2) {
const axisY = layout.totalHeight + 8;
const axisGroup = g.append('g')
.attr('class', 'date-axis')
.attr('transform', `translate(0,${axisY})`);

// Draw the baseline
const minX = dateTicks[0].x;
const maxX = dateTicks[dateTicks.length - 1].x;
axisGroup.append('line')
.attr('x1', minX)
.attr('x2', maxX)
.attr('y1', 0)
.attr('y2', 0)
.style('stroke', '#30363d');

// Pick evenly-spaced samples from the commit positions
const maxTicks = Math.min(10, Math.max(3, Math.floor(viewWidth / 100)));
const step = Math.max(1, Math.floor((dateTicks.length - 1) / (maxTicks - 1)));
const samples: Array<{ x: number; ts: number }> = [];
for (let i = 0; i < dateTicks.length; i += step) {
samples.push(dateTicks[i]);
}
// Always include the last tick
if (samples[samples.length - 1] !== dateTicks[dateTicks.length - 1]) {
samples.push(dateTicks[dateTicks.length - 1]);
}

const spanDays = (dateTicks[dateTicks.length - 1].ts - dateTicks[0].ts) / 86400000;
const fmtStr = spanDays > 365 ? '%b %Y' : '%b %d';
const fmt = d3.timeFormat(fmtStr);

// Filter out samples whose labels would overlap (min 60px apart)
const filtered: typeof samples = [samples[0]];
for (let i = 1; i < samples.length; i++) {
if (samples[i].x - filtered[filtered.length - 1].x >= 60) {
filtered.push(samples[i]);
}
}

for (const sample of filtered) {
// Tick mark
axisGroup.append('line')
.attr('x1', sample.x)
.attr('x2', sample.x)
.attr('y1', 0)
.attr('y2', 4)
.style('stroke', '#30363d');

// Label
axisGroup.append('text')
.attr('x', sample.x)
.attr('y', 18)
.attr('text-anchor', 'middle')
.style('fill', '#8b949e')
.style('font-size', '0.75rem')
.text(fmt(new Date(sample.ts)));
}
}
}

// Draw nodes
const nodeGroup = g.append('g').attr('class', 'nodes');

Expand Down Expand Up @@ -304,7 +375,8 @@ export default function NetworkGraphVisualization({
zoom.transform(d3svg, zoomTransformRef.current);
} else {
const scaleX = viewWidth / (layout.totalWidth + 60);
const scaleY = viewHeight / (layout.totalHeight + 20);
// +50 to account for the date axis at the bottom
const scaleY = viewHeight / (layout.totalHeight + 50);
const scale = Math.min(scaleX, scaleY, 2);
const contentW = layout.totalWidth * scale;
const contentH = layout.totalHeight * scale;
Expand Down
14 changes: 9 additions & 5 deletions client/src/contexts/DateRangeContext.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { createContext, useContext, useState, useMemo, useEffect } from 'react';
import type { ReactNode } from 'react';

export type TimeRange = '1m' | '3m' | '6m' | '1y' | 'all' | 'custom';
export type TimeRange = '1w' | '2w' | '1m' | '3m' | '6m' | '1y' | 'all' | 'custom';

export const TIME_RANGE_LABELS: Record<TimeRange, string> = {
'1w': 'Last week',
'2w': 'Last 2 weeks',
'1m': 'Last month',
'3m': 'Last 3 months',
'6m': 'Last 6 months',
Expand Down Expand Up @@ -36,7 +38,9 @@ export function getDateRange(range: TimeRange): { since?: string; until?: string
const now = new Date();
const untilDate = localToday();
const since = new Date(now);
if (range === '1m') since.setMonth(since.getMonth() - 1);
if (range === '1w') since.setDate(since.getDate() - 7);
else if (range === '2w') since.setDate(since.getDate() - 14);
else if (range === '1m') since.setMonth(since.getMonth() - 1);
else if (range === '3m') since.setMonth(since.getMonth() - 3);
else if (range === '6m') since.setMonth(since.getMonth() - 6);
else if (range === '1y') since.setFullYear(since.getFullYear() - 1);
Expand All @@ -58,9 +62,9 @@ interface DateRangeContextValue {
const DateRangeContext = createContext<DateRangeContextValue | null>(null);

export function DateRangeProvider({ children }: { children: ReactNode }) {
const [timeRange, setTimeRange] = useState<TimeRange>('1m');
const [customSince, setCustomSince] = useState<string>(() => getDateRange('1m').since!.slice(0, 10));
const [customUntil, setCustomUntil] = useState<string>(() => getDateRange('1m').until!.slice(0, 10));
const [timeRange, setTimeRange] = useState<TimeRange>('1w');
const [customSince, setCustomSince] = useState<string>(() => getDateRange('1w').since!.slice(0, 10));
const [customUntil, setCustomUntil] = useState<string>(() => getDateRange('1w').until!.slice(0, 10));

// Sync date inputs when switching to a preset
useEffect(() => {
Expand Down
29 changes: 29 additions & 0 deletions client/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,33 @@ export interface PullRequest {
baseRefName: string;
commits: { totalCount: number };
reviews: { totalCount: number };
statusCheckRollup: {
state: string;
contexts: {
nodes: Array<
| { name: string; conclusion: string | null; status: string; detailsUrl: string | null }
| { context: string; state: string; targetUrl: string | null }
>;
};
} | null;
reviewRequests: Array<{ login: string; avatarUrl: string }>;
reviewList: Array<{ author: { login: string; avatarUrl: string } | null; state: string }>;
}

export interface TagInfo {
name: string;
message: string | null;
taggerName: string | null;
taggerDate: string | null;
commitOid: string;
commitAbbreviatedOid: string;
committedDate: string;
commitMessage: string;
author: {
name: string | null;
avatarUrl: string;
login: string | null;
};
}

export interface BranchInfo {
Expand Down Expand Up @@ -256,6 +283,8 @@ export const api = {
apiFetch<PullRequest[]>(`/api/repos/${owner}/${repo}/pulls?state=${state}`),
branches: (owner: string, repo: string) =>
apiFetch<BranchInfo[]>(`/api/repos/${owner}/${repo}/branches`),
tags: (owner: string, repo: string) =>
apiFetch<TagInfo[]>(`/api/repos/${owner}/${repo}/tags`),
codeFrequency: (
owner: string,
repo: string,
Expand Down
35 changes: 35 additions & 0 deletions client/src/pages/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,41 @@ export default function AppLayout() {
)}
</div>

{/* GitHub repo link */}
{params.owner && params.repo && (
<a
href={`https://github.com/${params.owner}/${params.repo}`}
target="_blank"
rel="noopener noreferrer"
title={`View ${params.owner}/${params.repo} on GitHub`}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 32,
height: 32,
borderRadius: 6,
color: '#8b949e',
textDecoration: 'none',
flexShrink: 0,
transition: 'color 0.15s, background 0.15s',
background: 'transparent',
}}
onMouseOver={(e) => {
e.currentTarget.style.color = '#dfe2eb';
e.currentTarget.style.background = 'rgba(88,166,255,0.1)';
}}
onMouseOut={(e) => {
e.currentTarget.style.color = '#8b949e';
e.currentTarget.style.background = 'transparent';
}}
>
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/>
</svg>
</a>
)}

{/* Spacer */}
<div style={{ flexGrow: 1 }} />

Expand Down
Loading
Loading