feat(ui): contribution graph with daily activity heatmap
Add /v1/activity/daily endpoint returning per-day event counts via generate_series + LEFT JOIN. Frontend renders an SVG contribution graph with circles colored by quantile-based thresholds. Clicking a day navigates to /activity/YYYY-MM-DD showing that day's events. New /activity/:timespan route parses single dates (YYYY-MM-DD) and ranges (YYYY-MM-DD..YYYY-MM-DD) from the URL to initialize the activity timeline filter. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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}`);
|
||||||
|
|||||||
163
ui/src/components/ContributionGraph.tsx
Normal file
163
ui/src/components/ContributionGraph.tsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { fetchDailyCounts } from '../api/client';
|
||||||
|
|
||||||
|
const CELL_SIZE = 12;
|
||||||
|
const GAP = 3;
|
||||||
|
const RADIUS = CELL_SIZE / 2;
|
||||||
|
const ROWS = 7; // days per week
|
||||||
|
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)', // 0: empty
|
||||||
|
'#0e4429', // 1: low
|
||||||
|
'#006d32', // 2: medium-low
|
||||||
|
'#26a641', // 3: medium
|
||||||
|
'#39d353', // 4: high
|
||||||
|
];
|
||||||
|
|
||||||
|
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]));
|
||||||
|
|
||||||
|
// Start from the Sunday before `from`
|
||||||
|
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 });
|
||||||
|
|
||||||
|
// Track month transitions (on the first day of each week)
|
||||||
|
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++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute quantile thresholds from non-zero counts
|
||||||
|
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-4">
|
||||||
|
<p style={{ fontSize: '0.8rem', opacity: 0.6 }}>
|
||||||
|
{totalCount} contributions in the last year
|
||||||
|
</p>
|
||||||
|
<div style={{ overflowX: 'auto' }}>
|
||||||
|
<svg width={svgWidth} height={svgHeight} className="d-block">
|
||||||
|
{/* Day-of-week labels */}
|
||||||
|
{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,
|
||||||
|
)}
|
||||||
|
{/* Month labels */}
|
||||||
|
{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>
|
||||||
|
))}
|
||||||
|
{/* Circles */}
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 } from '../components/ContributionGraph';
|
||||||
|
|
||||||
export function DashPage() {
|
export function DashPage() {
|
||||||
const projectsQ = useQuery({
|
const projectsQ = useQuery({
|
||||||
@@ -25,6 +26,7 @@ export function DashPage() {
|
|||||||
</p>
|
</p>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
<ContributionGraph />
|
||||||
{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,24 @@ 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 parseTimespan(timespan?: string): [number, number] | null {
|
||||||
|
if (!timespan) return null;
|
||||||
|
if (timespan.includes('..')) {
|
||||||
|
const [a, b] = timespan.split('..');
|
||||||
|
const from = new Date(a + 'T00:00:00Z').getTime();
|
||||||
|
const to = new Date(b + 'T23:59:59Z').getTime();
|
||||||
|
if (!isNaN(from) && !isNaN(to)) return [from, to];
|
||||||
|
} else {
|
||||||
|
const from = new Date(timespan + 'T00:00:00Z').getTime();
|
||||||
|
const to = new Date(timespan + 'T23:59:59Z').getTime();
|
||||||
|
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 +37,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