Compare commits
4 Commits
7de23303bd
...
2284a886d0
| Author | SHA1 | Date | |
|---|---|---|---|
|
2284a886d0
|
|||
|
1ca85fe632
|
|||
|
822def3227
|
|||
|
27ce16e630
|
@@ -7,11 +7,11 @@ use axum::{
|
|||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
routing::get,
|
routing::get,
|
||||||
};
|
};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, NaiveDate, Utc};
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use moments_core::{EventReader, reshape};
|
use moments_core::{EventReader, reshape};
|
||||||
use moments_data::PgStore;
|
use moments_data::PgStore;
|
||||||
use moments_entities::{EventQuery, ProjectSummary, Source, SourceSummary, TimelineItem};
|
use moments_entities::{DailyCount, EventQuery, ProjectSummary, Source, SourceSummary, TimelineItem};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tower_http::{cors::CorsLayer, trace::TraceLayer};
|
use tower_http::{cors::CorsLayer, trace::TraceLayer};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
@@ -56,6 +56,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.route("/v1/events", get(list_events))
|
.route("/v1/events", get(list_events))
|
||||||
.route("/v1/sources", get(list_sources))
|
.route("/v1/sources", get(list_sources))
|
||||||
.route("/v1/projects", get(list_projects))
|
.route("/v1/projects", get(list_projects))
|
||||||
|
.route("/v1/activity/daily", get(daily_counts))
|
||||||
.route("/v1/forge/{source}/{*rest}", get(forge_proxy))
|
.route("/v1/forge/{source}/{*rest}", get(forge_proxy))
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
@@ -139,6 +140,22 @@ async fn list_projects(
|
|||||||
Ok(Json(projects))
|
Ok(Json(projects))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct DailyCountsParams {
|
||||||
|
from: Option<NaiveDate>,
|
||||||
|
to: Option<NaiveDate>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn daily_counts(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(params): Query<DailyCountsParams>,
|
||||||
|
) -> Result<Json<Vec<DailyCount>>, ApiError> {
|
||||||
|
let to = params.to.unwrap_or_else(|| Utc::now().date_naive());
|
||||||
|
let from = params.from.unwrap_or_else(|| to - chrono::Duration::days(365));
|
||||||
|
let counts = state.store.daily_counts(from, to).await.map_err(internal)?;
|
||||||
|
Ok(Json(counts))
|
||||||
|
}
|
||||||
|
|
||||||
/// Allowlisted forge hosts that the proxy may contact.
|
/// Allowlisted forge hosts that the proxy may contact.
|
||||||
const ALLOWED_HOSTS: &[&str] = &["api.github.com", "git.lair.cafe"];
|
const ALLOWED_HOSTS: &[&str] = &["api.github.com", "git.lair.cafe"];
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ pub use presentation::reshape;
|
|||||||
pub use sources::{EventSource, PollerState, PollerStateStore, SourceError, run_poller};
|
pub use sources::{EventSource, PollerState, PollerStateStore, SourceError, run_poller};
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use moments_entities::{Event, EventQuery, ProjectSummary, SourceSummary};
|
use chrono::NaiveDate;
|
||||||
|
use moments_entities::{DailyCount, Event, EventQuery, ProjectSummary, SourceSummary};
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum StoreError {
|
pub enum StoreError {
|
||||||
@@ -19,6 +20,7 @@ pub trait EventReader: Send + Sync {
|
|||||||
async fn list_events(&self, query: &EventQuery) -> Result<Vec<Event>, StoreError>;
|
async fn list_events(&self, query: &EventQuery) -> Result<Vec<Event>, StoreError>;
|
||||||
async fn source_summaries(&self, include_private: bool) -> Result<Vec<SourceSummary>, StoreError>;
|
async fn source_summaries(&self, include_private: bool) -> Result<Vec<SourceSummary>, StoreError>;
|
||||||
async fn list_projects(&self) -> Result<Vec<ProjectSummary>, StoreError>;
|
async fn list_projects(&self) -> Result<Vec<ProjectSummary>, StoreError>;
|
||||||
|
async fn daily_counts(&self, from: NaiveDate, to: NaiveDate) -> Result<Vec<DailyCount>, StoreError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write-side port consumed by `moments-worker`. Idempotent upserts on `id`.
|
/// Write-side port consumed by `moments-worker`. Idempotent upserts on `id`.
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ pub mod hg;
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use moments_core::{EventReader, EventWriter, PollerState, PollerStateStore, StoreError};
|
use moments_core::{EventReader, EventWriter, PollerState, PollerStateStore, StoreError};
|
||||||
use moments_entities::{Event, EventQuery, ProjectSummary, Source, SourceSummary};
|
use chrono::NaiveDate;
|
||||||
|
use moments_entities::{DailyCount, Event, EventQuery, ProjectSummary, Source, SourceSummary};
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
use sqlx::postgres::{PgPool, PgPoolOptions};
|
use sqlx::postgres::{PgPool, PgPoolOptions};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
@@ -192,6 +193,35 @@ impl EventReader for PgStore {
|
|||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn daily_counts(&self, from: NaiveDate, to: NaiveDate) -> Result<Vec<DailyCount>, StoreError> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT d::date AS date,
|
||||||
|
COUNT(e.id)::bigint AS count
|
||||||
|
FROM generate_series($1::date, $2::date, '1 day') d
|
||||||
|
LEFT JOIN events e
|
||||||
|
ON e.occurred_at >= d AND e.occurred_at < d + interval '1 day'
|
||||||
|
AND e.public = true
|
||||||
|
GROUP BY d::date
|
||||||
|
ORDER BY d::date
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(from)
|
||||||
|
.bind(to)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(map_err)?;
|
||||||
|
|
||||||
|
rows.into_iter()
|
||||||
|
.map(|r| {
|
||||||
|
Ok(DailyCount {
|
||||||
|
date: r.try_get("date").map_err(map_err)?,
|
||||||
|
count: r.try_get("count").map_err(map_err)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|||||||
@@ -84,6 +84,13 @@ pub struct SourceSummary {
|
|||||||
pub latest: Option<DateTime<Utc>>,
|
pub latest: Option<DateTime<Utc>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Per-day event count for the contribution graph.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DailyCount {
|
||||||
|
pub date: chrono::NaiveDate,
|
||||||
|
pub count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
/// Per-repo activity rollup for the dashboard.
|
/// Per-repo activity rollup for the dashboard.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ProjectSummary {
|
pub struct ProjectSummary {
|
||||||
|
|||||||
@@ -77,6 +77,23 @@ a.hot-pink {
|
|||||||
opacity: 0.3;
|
opacity: 0.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.graph-label {
|
||||||
|
fill: #ecf0f1;
|
||||||
|
font-size: 9px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-cell {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-cell:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
stroke: #ecf0f1;
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.project-card {
|
.project-card {
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export default function App() {
|
|||||||
<Route index element={<DashPage />} />
|
<Route index element={<DashPage />} />
|
||||||
<Route path="/dash" element={<DashPage />} />
|
<Route path="/dash" element={<DashPage />} />
|
||||||
<Route path="/activity" element={<TimelineHome />} />
|
<Route path="/activity" element={<TimelineHome />} />
|
||||||
|
<Route path="/activity/:timespan" element={<TimelineHome />} />
|
||||||
<Route path="/project/:source/*" element={<ProjectPage />} />
|
<Route path="/project/:source/*" element={<ProjectPage />} />
|
||||||
<Route path="/cv" element={<CvPage />} />
|
<Route path="/cv" element={<CvPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -65,6 +65,11 @@ export interface ProjectSummary {
|
|||||||
last_activity: string | null;
|
last_activity: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DailyCount {
|
||||||
|
date: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface EventQuery {
|
export interface EventQuery {
|
||||||
from?: Date;
|
from?: Date;
|
||||||
to?: Date;
|
to?: Date;
|
||||||
@@ -102,6 +107,12 @@ export async function fetchSources(): Promise<SourceSummary[]> {
|
|||||||
return resp.json();
|
return resp.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchDailyCounts(from: string, to: string): Promise<DailyCount[]> {
|
||||||
|
const resp = await fetch(`${API_BASE}/activity/daily?from=${from}&to=${to}`);
|
||||||
|
if (!resp.ok) throw new Error(`daily-counts: HTTP ${resp.status}`);
|
||||||
|
return resp.json();
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchProjects(): Promise<ProjectSummary[]> {
|
export async function fetchProjects(): Promise<ProjectSummary[]> {
|
||||||
const resp = await fetch(`${API_BASE}/projects`);
|
const resp = await fetch(`${API_BASE}/projects`);
|
||||||
if (!resp.ok) throw new Error(`projects: HTTP ${resp.status}`);
|
if (!resp.ok) throw new Error(`projects: HTTP ${resp.status}`);
|
||||||
|
|||||||
281
ui/src/components/ContributionGraph.tsx
Normal file
281
ui/src/components/ContributionGraph.tsx
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { fetchDailyCounts, fetchSources } from '../api/client';
|
||||||
|
|
||||||
|
const CELL_SIZE = 12;
|
||||||
|
const GAP = 3;
|
||||||
|
const RADIUS = CELL_SIZE / 2;
|
||||||
|
const ROWS = 7;
|
||||||
|
const LEFT_LABEL_WIDTH = 28;
|
||||||
|
const TOP_LABEL_HEIGHT = 16;
|
||||||
|
|
||||||
|
const DAY_LABELS = ['', 'mon', '', 'wed', '', 'fri', ''];
|
||||||
|
const MONTH_LABELS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
|
||||||
|
|
||||||
|
const COLORS = [
|
||||||
|
'rgba(255,255,255,0.05)',
|
||||||
|
'#0e4429',
|
||||||
|
'#006d32',
|
||||||
|
'#26a641',
|
||||||
|
'#39d353',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Daily contribution graph — last 1 year, one circle per day. */
|
||||||
|
export function ContributionGraph() {
|
||||||
|
const to = new Date();
|
||||||
|
const from = new Date(to);
|
||||||
|
from.setFullYear(from.getFullYear() - 1);
|
||||||
|
|
||||||
|
const fromStr = fmt(from);
|
||||||
|
const toStr = fmt(to);
|
||||||
|
|
||||||
|
const dailyQ = useQuery({
|
||||||
|
queryKey: ['daily-counts', fromStr, toStr],
|
||||||
|
queryFn: () => fetchDailyCounts(fromStr, toStr),
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const { weeks, monthMarkers, thresholds, totalCount } = useMemo(() => {
|
||||||
|
const counts = dailyQ.data ?? [];
|
||||||
|
const countMap = new Map(counts.map((d) => [d.date, d.count]));
|
||||||
|
|
||||||
|
const start = new Date(from);
|
||||||
|
start.setDate(start.getDate() - start.getDay());
|
||||||
|
|
||||||
|
const weeks: { date: string; count: number; col: number; row: number }[][] = [];
|
||||||
|
const monthMarkers: { col: number; label: string }[] = [];
|
||||||
|
let col = 0;
|
||||||
|
let prevMonth = -1;
|
||||||
|
const cursor = new Date(start);
|
||||||
|
|
||||||
|
while (cursor <= to) {
|
||||||
|
const week: typeof weeks[0] = [];
|
||||||
|
for (let row = 0; row < ROWS; row++) {
|
||||||
|
const dateStr = fmt(cursor);
|
||||||
|
const count = countMap.get(dateStr) ?? 0;
|
||||||
|
week.push({ date: dateStr, count, col, row });
|
||||||
|
if (row === 0) {
|
||||||
|
const m = cursor.getMonth();
|
||||||
|
if (m !== prevMonth) {
|
||||||
|
monthMarkers.push({ col, label: MONTH_LABELS[m] });
|
||||||
|
prevMonth = m;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cursor.setDate(cursor.getDate() + 1);
|
||||||
|
}
|
||||||
|
weeks.push(week);
|
||||||
|
col++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nonZero = counts.map((d) => d.count).filter((c) => c > 0).sort((a, b) => a - b);
|
||||||
|
const thresholds = computeThresholds(nonZero);
|
||||||
|
const totalCount = counts.reduce((sum, d) => sum + d.count, 0);
|
||||||
|
|
||||||
|
return { weeks, monthMarkers, thresholds, totalCount };
|
||||||
|
}, [dailyQ.data]);
|
||||||
|
|
||||||
|
const cols = weeks.length;
|
||||||
|
const svgWidth = LEFT_LABEL_WIDTH + cols * (CELL_SIZE + GAP);
|
||||||
|
const svgHeight = TOP_LABEL_HEIGHT + ROWS * (CELL_SIZE + GAP);
|
||||||
|
|
||||||
|
if (dailyQ.isLoading) return <p style={{ fontSize: '0.8rem' }}>loading contribution graph...</p>;
|
||||||
|
if (dailyQ.isError) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="contribution-graph mb-3">
|
||||||
|
<p style={{ fontSize: '0.8rem', opacity: 0.6 }}>
|
||||||
|
{totalCount} contributions in the last year
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<svg viewBox={`0 0 ${svgWidth} ${svgHeight}`} width="100%" className="d-block">
|
||||||
|
{DAY_LABELS.map((label, i) =>
|
||||||
|
label ? (
|
||||||
|
<text
|
||||||
|
key={i}
|
||||||
|
x={LEFT_LABEL_WIDTH - 6}
|
||||||
|
y={TOP_LABEL_HEIGHT + i * (CELL_SIZE + GAP) + CELL_SIZE / 2}
|
||||||
|
textAnchor="end"
|
||||||
|
dominantBaseline="central"
|
||||||
|
className="graph-label"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</text>
|
||||||
|
) : null,
|
||||||
|
)}
|
||||||
|
{monthMarkers.map(({ col, label }, i) => (
|
||||||
|
<text
|
||||||
|
key={i}
|
||||||
|
x={LEFT_LABEL_WIDTH + col * (CELL_SIZE + GAP) + RADIUS}
|
||||||
|
y={10}
|
||||||
|
textAnchor="middle"
|
||||||
|
className="graph-label"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
{weeks.flatMap((week) =>
|
||||||
|
week.map(({ date, count, col, row }) => (
|
||||||
|
<circle
|
||||||
|
key={date}
|
||||||
|
cx={LEFT_LABEL_WIDTH + col * (CELL_SIZE + GAP) + RADIUS}
|
||||||
|
cy={TOP_LABEL_HEIGHT + row * (CELL_SIZE + GAP) + RADIUS}
|
||||||
|
r={RADIUS - 1}
|
||||||
|
fill={colorFor(count, thresholds)}
|
||||||
|
className="graph-cell"
|
||||||
|
onClick={() => navigate(`/activity/${date}`)}
|
||||||
|
>
|
||||||
|
<title>{`${date}: ${count} ${count === 1 ? 'contribution' : 'contributions'}`}</title>
|
||||||
|
</circle>
|
||||||
|
)),
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All-time weekly contribution graph — one circle per week. */
|
||||||
|
export function AllTimeGraph() {
|
||||||
|
const sourcesQ = useQuery({
|
||||||
|
queryKey: ['sources'],
|
||||||
|
queryFn: fetchSources,
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const earliest = useMemo(() => {
|
||||||
|
if (!sourcesQ.data) return null;
|
||||||
|
const dates = sourcesQ.data
|
||||||
|
.map((s) => s.earliest)
|
||||||
|
.filter((d): d is string => d != null)
|
||||||
|
.map((d) => new Date(d));
|
||||||
|
return dates.length > 0 ? new Date(Math.min(...dates.map((d) => d.getTime()))) : null;
|
||||||
|
}, [sourcesQ.data]);
|
||||||
|
|
||||||
|
const to = new Date();
|
||||||
|
const from = earliest ?? new Date(to.getFullYear() - 5, 0, 1);
|
||||||
|
const fromStr = fmt(from);
|
||||||
|
const toStr = fmt(to);
|
||||||
|
|
||||||
|
const dailyQ = useQuery({
|
||||||
|
queryKey: ['daily-counts-alltime', fromStr, toStr],
|
||||||
|
queryFn: () => fetchDailyCounts(fromStr, toStr),
|
||||||
|
enabled: !!earliest,
|
||||||
|
staleTime: 10 * 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const { yearRows, thresholds, totalCount } = useMemo(() => {
|
||||||
|
const counts = dailyQ.data ?? [];
|
||||||
|
if (counts.length === 0) return { yearRows: [], thresholds: [1, 2, 3], totalCount: 0 };
|
||||||
|
|
||||||
|
const countMap = new Map(counts.map((d) => [d.date, d.count]));
|
||||||
|
|
||||||
|
// Group into year rows, each with ~52 weekly columns
|
||||||
|
// Start each year from Jan 1, aligned to its preceding Sunday
|
||||||
|
const startYear = from.getFullYear();
|
||||||
|
const endYear = to.getFullYear();
|
||||||
|
const yearRows: { year: number; weeks: { weekStart: string; weekEnd: string; count: number; col: number }[] }[] = [];
|
||||||
|
|
||||||
|
for (let yr = startYear; yr <= endYear; yr++) {
|
||||||
|
const yearStart = new Date(yr, 0, 1);
|
||||||
|
const yearEnd = yr === endYear ? to : new Date(yr, 11, 31);
|
||||||
|
// Align to preceding Sunday
|
||||||
|
const cursor = new Date(yearStart);
|
||||||
|
cursor.setDate(cursor.getDate() - cursor.getDay());
|
||||||
|
|
||||||
|
const weeks: typeof yearRows[0]['weeks'] = [];
|
||||||
|
let col = 0;
|
||||||
|
while (cursor <= yearEnd) {
|
||||||
|
const weekStart = fmt(cursor);
|
||||||
|
let weekCount = 0;
|
||||||
|
for (let d = 0; d < 7; d++) {
|
||||||
|
weekCount += countMap.get(fmt(cursor)) ?? 0;
|
||||||
|
if (d < 6) cursor.setDate(cursor.getDate() + 1);
|
||||||
|
}
|
||||||
|
const weekEnd = fmt(cursor);
|
||||||
|
cursor.setDate(cursor.getDate() + 1);
|
||||||
|
weeks.push({ weekStart, weekEnd, count: weekCount, col });
|
||||||
|
col++;
|
||||||
|
}
|
||||||
|
yearRows.push({ year: yr, weeks });
|
||||||
|
}
|
||||||
|
|
||||||
|
const allWeekCounts = yearRows.flatMap((r) => r.weeks.map((w) => w.count));
|
||||||
|
const nonZero = allWeekCounts.filter((c) => c > 0).sort((a, b) => a - b);
|
||||||
|
const thresholds = computeThresholds(nonZero);
|
||||||
|
const totalCount = counts.reduce((sum, d) => sum + d.count, 0);
|
||||||
|
|
||||||
|
return { yearRows, thresholds, totalCount };
|
||||||
|
}, [dailyQ.data]);
|
||||||
|
|
||||||
|
if (!earliest || dailyQ.isLoading) return null;
|
||||||
|
if (dailyQ.isError) return null;
|
||||||
|
if (yearRows.length === 0) return null;
|
||||||
|
|
||||||
|
const maxCols = 53; // max weeks in a year
|
||||||
|
const yearLabelWidth = 32;
|
||||||
|
const svgWidth = yearLabelWidth + maxCols * (CELL_SIZE + GAP);
|
||||||
|
const rows = yearRows.length;
|
||||||
|
const svgHeight = rows * (CELL_SIZE + GAP);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="contribution-graph mb-4">
|
||||||
|
<p style={{ fontSize: '0.8rem', opacity: 0.6 }}>
|
||||||
|
{totalCount} contributions since {fmt(from)}
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<svg viewBox={`0 0 ${svgWidth} ${svgHeight}`} width="100%" className="d-block">
|
||||||
|
{yearRows.map(({ year, weeks }, rowIdx) => (
|
||||||
|
<g key={year}>
|
||||||
|
<text
|
||||||
|
x={yearLabelWidth - 4}
|
||||||
|
y={rowIdx * (CELL_SIZE + GAP) + CELL_SIZE / 2}
|
||||||
|
textAnchor="end"
|
||||||
|
dominantBaseline="central"
|
||||||
|
className="graph-label"
|
||||||
|
>
|
||||||
|
{year}
|
||||||
|
</text>
|
||||||
|
{weeks.map(({ weekStart, weekEnd, count, col }) => (
|
||||||
|
<circle
|
||||||
|
key={weekStart}
|
||||||
|
cx={yearLabelWidth + col * (CELL_SIZE + GAP) + RADIUS}
|
||||||
|
cy={rowIdx * (CELL_SIZE + GAP) + RADIUS}
|
||||||
|
r={RADIUS - 1}
|
||||||
|
fill={colorFor(count, thresholds)}
|
||||||
|
className="graph-cell"
|
||||||
|
onClick={() => navigate(`/activity/${weekStart}..${weekEnd}`)}
|
||||||
|
>
|
||||||
|
<title>{`${weekStart} — ${weekEnd}: ${count} ${count === 1 ? 'contribution' : 'contributions'}`}</title>
|
||||||
|
</circle>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(d: Date): string {
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorFor(count: number, thresholds: number[]): string {
|
||||||
|
if (count === 0) return COLORS[0];
|
||||||
|
if (count <= thresholds[0]) return COLORS[1];
|
||||||
|
if (count <= thresholds[1]) return COLORS[2];
|
||||||
|
if (count <= thresholds[2]) return COLORS[3];
|
||||||
|
return COLORS[4];
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeThresholds(sorted: number[]): number[] {
|
||||||
|
if (sorted.length === 0) return [1, 2, 3];
|
||||||
|
const p = (pct: number) => sorted[Math.min(Math.floor(pct * sorted.length), sorted.length - 1)];
|
||||||
|
return [p(0.25), p(0.5), p(0.75)];
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import Col from 'react-bootstrap/Col';
|
|||||||
import Row from 'react-bootstrap/Row';
|
import Row from 'react-bootstrap/Row';
|
||||||
|
|
||||||
import { fetchProjects, fetchLanguages, type ProjectSummary, type Source } from '../api/client';
|
import { fetchProjects, fetchLanguages, type ProjectSummary, type Source } from '../api/client';
|
||||||
|
import { ContributionGraph, AllTimeGraph } from '../components/ContributionGraph';
|
||||||
|
|
||||||
export function DashPage() {
|
export function DashPage() {
|
||||||
const projectsQ = useQuery({
|
const projectsQ = useQuery({
|
||||||
@@ -25,6 +26,8 @@ export function DashPage() {
|
|||||||
</p>
|
</p>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
<ContributionGraph />
|
||||||
|
<AllTimeGraph />
|
||||||
{projectsQ.isLoading && <p>loading...</p>}
|
{projectsQ.isLoading && <p>loading...</p>}
|
||||||
{projectsQ.isError && (
|
{projectsQ.isError && (
|
||||||
<p>error: {(projectsQ.error as Error).message}</p>
|
<p>error: {(projectsQ.error as Error).message}</p>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import Col from 'react-bootstrap/Col';
|
import Col from 'react-bootstrap/Col';
|
||||||
import Row from 'react-bootstrap/Row';
|
import Row from 'react-bootstrap/Row';
|
||||||
@@ -11,7 +12,35 @@ import { TimelineEntry } from '../components/TimelineEntry';
|
|||||||
const RANGE_MIN = new Date('2010-01-01T00:00:00Z').getTime();
|
const RANGE_MIN = new Date('2010-01-01T00:00:00Z').getTime();
|
||||||
const RANGE_MAX = Date.now();
|
const RANGE_MAX = Date.now();
|
||||||
|
|
||||||
|
function parseDate(s: string): number {
|
||||||
|
// Accept YYYY-MM-DD or full ISO datetime
|
||||||
|
const t = new Date(s.includes('T') ? s : s + 'T00:00:00Z').getTime();
|
||||||
|
return isNaN(t) ? NaN : t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function endOfDay(s: string): number {
|
||||||
|
const t = new Date(s.includes('T') ? s : s + 'T23:59:59Z').getTime();
|
||||||
|
return isNaN(t) ? NaN : t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTimespan(timespan?: string): [number, number] | null {
|
||||||
|
if (!timespan) return null;
|
||||||
|
if (timespan.includes('..')) {
|
||||||
|
const [a, b] = timespan.split('..');
|
||||||
|
const from = parseDate(a);
|
||||||
|
const to = endOfDay(b);
|
||||||
|
if (!isNaN(from) && !isNaN(to)) return [from, to];
|
||||||
|
} else {
|
||||||
|
const from = parseDate(timespan);
|
||||||
|
const to = endOfDay(timespan);
|
||||||
|
if (!isNaN(from)) return [from, to];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function TimelineHome() {
|
export function TimelineHome() {
|
||||||
|
const { timespan } = useParams();
|
||||||
|
|
||||||
const [enabledSources, setEnabledSources] = useState<Record<Source, boolean>>({
|
const [enabledSources, setEnabledSources] = useState<Record<Source, boolean>>({
|
||||||
github: true,
|
github: true,
|
||||||
gitea: true,
|
gitea: true,
|
||||||
@@ -19,6 +48,8 @@ export function TimelineHome() {
|
|||||||
bugzilla: true,
|
bugzilla: true,
|
||||||
});
|
});
|
||||||
const [rangeValue, setRangeValue] = useState<[number, number]>(() => {
|
const [rangeValue, setRangeValue] = useState<[number, number]>(() => {
|
||||||
|
const parsed = parseTimespan(timespan);
|
||||||
|
if (parsed) return parsed;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const thirtyDaysAgo = now - 30 * 24 * 60 * 60 * 1000;
|
const thirtyDaysAgo = now - 30 * 24 * 60 * 60 * 1000;
|
||||||
return [thirtyDaysAgo, now];
|
return [thirtyDaysAgo, now];
|
||||||
|
|||||||
Reference in New Issue
Block a user