Compare commits
1 Commits
72eeb547af
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
2821548e6e
|
@@ -11,7 +11,7 @@ use chrono::{DateTime, Datelike, NaiveDate, Utc};
|
||||
use clap::Parser;
|
||||
use moments_core::{EventReader, reshape};
|
||||
use moments_data::PgStore;
|
||||
use moments_entities::{DailyCount, EventQuery, LanguageDailyCount, ProjectSummary, RepoLanguage, Source, SourceSummary, TimelineItem};
|
||||
use moments_entities::{DailyCount, EventQuery, HourlyAvg, LanguageDailyCount, ProjectSummary, RepoLanguage, Source, SourceSummary, TimelineItem};
|
||||
use serde::Deserialize;
|
||||
use tower_http::{cors::CorsLayer, trace::TraceLayer};
|
||||
use tracing::info;
|
||||
@@ -57,6 +57,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
.route("/v1/sources", get(list_sources))
|
||||
.route("/v1/projects", get(list_projects))
|
||||
.route("/v1/activity/daily", get(daily_counts))
|
||||
.route("/v1/activity/hourly", get(hourly_avgs))
|
||||
.route("/v1/languages/daily", get(language_daily_counts))
|
||||
.route("/v1/languages/repos", get(repo_languages))
|
||||
.route("/v1/forge/{source}/{*rest}", get(forge_proxy))
|
||||
@@ -169,6 +170,38 @@ async fn language_daily_counts(
|
||||
Ok(Json(counts))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HourlyAvgsParams {
|
||||
from: Option<NaiveDate>,
|
||||
to: Option<NaiveDate>,
|
||||
/// IANA timezone name (e.g. "Europe/Helsinki"). Defaults to UTC.
|
||||
/// Hour buckets are computed in this zone so the chart matches the
|
||||
/// clock the user sees.
|
||||
tz: Option<String>,
|
||||
}
|
||||
|
||||
async fn hourly_avgs(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<HourlyAvgsParams>,
|
||||
) -> Result<Json<Vec<HourlyAvg>>, 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 tz = params.tz.as_deref().unwrap_or("UTC");
|
||||
// Validate the tz string before handing it to postgres — a bad name
|
||||
// here would surface as an opaque 500 from the DB. chrono-tz would do
|
||||
// it for free but we don't depend on it; instead reject obvious shell
|
||||
// injection vectors (the value is bound, not interpolated, so this is
|
||||
// belt-and-braces).
|
||||
if tz.len() > 64 || tz.chars().any(|c| !(c.is_ascii_alphanumeric() || matches!(c, '/' | '_' | '+' | '-'))) {
|
||||
return Err(ApiError {
|
||||
status: StatusCode::BAD_REQUEST,
|
||||
message: "invalid tz".into(),
|
||||
});
|
||||
}
|
||||
let avgs = state.store.hourly_avgs(from, to, tz, /* include_private */ true).await.map_err(internal)?;
|
||||
Ok(Json(avgs))
|
||||
}
|
||||
|
||||
async fn repo_languages(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Vec<RepoLanguage>>, ApiError> {
|
||||
|
||||
@@ -6,7 +6,7 @@ pub use sources::{EventSource, PollerState, PollerStateStore, SourceError, run_p
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::NaiveDate;
|
||||
use moments_entities::{DailyCount, Event, EventQuery, LanguageDailyCount, ProjectSummary, RepoLanguage, SourceSummary};
|
||||
use moments_entities::{DailyCount, Event, EventQuery, HourlyAvg, LanguageDailyCount, ProjectSummary, RepoLanguage, SourceSummary};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum StoreError {
|
||||
@@ -22,6 +22,7 @@ pub trait EventReader: Send + Sync {
|
||||
async fn list_projects(&self) -> Result<Vec<ProjectSummary>, StoreError>;
|
||||
async fn daily_counts(&self, from: NaiveDate, to: NaiveDate, include_private: bool) -> Result<Vec<DailyCount>, StoreError>;
|
||||
async fn language_daily_counts(&self, from: NaiveDate, to: NaiveDate, include_private: bool) -> Result<Vec<LanguageDailyCount>, StoreError>;
|
||||
async fn hourly_avgs(&self, from: NaiveDate, to: NaiveDate, tz: &str, include_private: bool) -> Result<Vec<HourlyAvg>, StoreError>;
|
||||
async fn repo_languages(&self) -> Result<Vec<RepoLanguage>, StoreError>;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use moments_core::{EventReader, EventWriter, PollerState, PollerStateStore, StoreError};
|
||||
use chrono::NaiveDate;
|
||||
use moments_entities::{DailyCount, Event, EventQuery, LanguageDailyCount, ProjectSummary, RepoLanguage, Source, SourceSummary};
|
||||
use moments_entities::{DailyCount, Event, EventQuery, HourlyAvg, LanguageDailyCount, ProjectSummary, RepoLanguage, Source, SourceSummary};
|
||||
use sqlx::Row;
|
||||
use sqlx::postgres::{PgPool, PgPoolOptions};
|
||||
use std::str::FromStr;
|
||||
@@ -291,6 +291,55 @@ impl EventReader for PgStore {
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn hourly_avgs(
|
||||
&self,
|
||||
from: NaiveDate,
|
||||
to: NaiveDate,
|
||||
tz: &str,
|
||||
include_private: bool,
|
||||
) -> Result<Vec<HourlyAvg>, StoreError> {
|
||||
// GREATEST guards against from > to (returns NaN-via-div-by-zero
|
||||
// otherwise). EXTRACT(hour FROM tz-shifted timestamp) buckets each
|
||||
// event into the user's local hour rather than UTC, so the chart
|
||||
// matches the labels they'd see on a clock.
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
WITH params AS (
|
||||
SELECT GREATEST(($2::date - $1::date + 1), 1)::float8 AS day_count
|
||||
),
|
||||
bucketed AS (
|
||||
SELECT EXTRACT(hour FROM (occurred_at AT TIME ZONE $3))::int AS hour
|
||||
FROM events
|
||||
WHERE occurred_at >= ($1::date::timestamp AT TIME ZONE 'UTC')
|
||||
AND occurred_at < (($2::date + 1)::timestamp AT TIME ZONE 'UTC')
|
||||
AND ($4::bool OR public = true)
|
||||
)
|
||||
SELECT g.h::int AS hour,
|
||||
(COUNT(b.hour)::float8 / (SELECT day_count FROM params)) AS avg
|
||||
FROM generate_series(0, 23) AS g(h)
|
||||
LEFT JOIN bucketed b ON b.hour = g.h
|
||||
GROUP BY g.h
|
||||
ORDER BY g.h
|
||||
"#,
|
||||
)
|
||||
.bind(from)
|
||||
.bind(to)
|
||||
.bind(tz)
|
||||
.bind(include_private)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(|r| {
|
||||
Ok(HourlyAvg {
|
||||
hour: r.try_get("hour").map_err(map_err)?,
|
||||
avg: r.try_get("avg").map_err(map_err)?,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn repo_languages(&self) -> Result<Vec<RepoLanguage>, StoreError> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
|
||||
@@ -91,6 +91,14 @@ pub struct DailyCount {
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
/// Average events per day at a given hour of the day, computed in a
|
||||
/// caller-supplied IANA timezone. 24 entries (0..=23).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HourlyAvg {
|
||||
pub hour: i32,
|
||||
pub avg: f64,
|
||||
}
|
||||
|
||||
/// Per-repo activity rollup for the dashboard.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProjectSummary {
|
||||
|
||||
@@ -70,6 +70,11 @@ export interface DailyCount {
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface HourlyAvg {
|
||||
hour: number;
|
||||
avg: number;
|
||||
}
|
||||
|
||||
export interface LanguageDailyCount {
|
||||
date: string;
|
||||
language: string;
|
||||
@@ -120,6 +125,13 @@ export async function fetchDailyCounts(from: string, to: string): Promise<DailyC
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
export async function fetchHourlyAvgs(from: string, to: string, tz: string): Promise<HourlyAvg[]> {
|
||||
const qs = new URLSearchParams({ from, to, tz });
|
||||
const resp = await fetch(`${API_BASE}/activity/hourly?${qs}`);
|
||||
if (!resp.ok) throw new Error(`hourly-avgs: HTTP ${resp.status}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
export async function fetchLanguageDailyCounts(from: string, to: string): Promise<LanguageDailyCount[]> {
|
||||
const resp = await fetch(`${API_BASE}/languages/daily?from=${from}&to=${to}`);
|
||||
if (!resp.ok) throw new Error(`language-daily-counts: HTTP ${resp.status}`);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { fetchDailyCounts, fetchSources } from '../api/client';
|
||||
import { fetchDailyCounts, fetchHourlyAvgs, fetchSources } from '../api/client';
|
||||
|
||||
export function ContributionStats() {
|
||||
const sourcesQ = useQuery({
|
||||
@@ -31,6 +31,21 @@ export function ContributionStats() {
|
||||
staleTime: 10 * 60_000,
|
||||
});
|
||||
|
||||
// Bucket hour-of-day in the user's local timezone so the chart matches
|
||||
// the clock they see. Browser may report e.g. "Europe/Helsinki"; fall
|
||||
// back to UTC if the resolver returns something the server won't
|
||||
// accept (it validates the string before binding).
|
||||
const tz = useMemo(
|
||||
() => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
|
||||
[],
|
||||
);
|
||||
const hourlyQ = useQuery({
|
||||
queryKey: ['hourly-avgs-alltime', fromStr, toStr, tz],
|
||||
queryFn: () => fetchHourlyAvgs(fromStr, toStr, tz),
|
||||
enabled: !!earliest,
|
||||
staleTime: 10 * 60_000,
|
||||
});
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const counts = dailyQ.data ?? [];
|
||||
if (counts.length === 0) return null;
|
||||
@@ -96,6 +111,17 @@ export function ContributionStats() {
|
||||
return { currentStreak, longestStreak, busiest, dayAvgs, maxAvg, activeDays };
|
||||
}, [dailyQ.data]);
|
||||
|
||||
const hourly = useMemo(() => {
|
||||
const data = hourlyQ.data ?? [];
|
||||
if (data.length === 0) return null;
|
||||
const byHour = new Array(24).fill(0);
|
||||
for (const { hour, avg } of data) {
|
||||
if (hour >= 0 && hour < 24) byHour[hour] = avg;
|
||||
}
|
||||
const max = Math.max(...byHour);
|
||||
return { hours: byHour, max };
|
||||
}, [hourlyQ.data]);
|
||||
|
||||
if (!stats) return null;
|
||||
|
||||
return (
|
||||
@@ -139,6 +165,31 @@ export function ContributionStats() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{hourly && (
|
||||
<div className="mt-3">
|
||||
<span style={{ opacity: 0.7, fontSize: '0.75rem' }}>avg by hour ({tz})</span>
|
||||
<div className="d-flex align-items-end gap-1 mt-1" style={{ height: 64 }}>
|
||||
{hourly.hours.map((avg, h) => (
|
||||
<div key={h} className="d-flex flex-column align-items-center" style={{ flex: 1, height: '100%', justifyContent: 'flex-end' }}>
|
||||
<div style={{ width: '100%', borderRadius: 2, background: 'rgba(255,255,255,0.05)', flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'flex-end' }}>
|
||||
<div
|
||||
style={{
|
||||
height: hourly.max > 0 ? `${(avg / hourly.max) * 100}%` : '0%',
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#39d353',
|
||||
opacity: 0.7,
|
||||
}}
|
||||
title={`${h.toString().padStart(2, '0')}:00 — ${avg.toFixed(2)}/day`}
|
||||
/>
|
||||
</div>
|
||||
<span style={{ fontSize: '0.6rem', opacity: 0.7, marginTop: 2, minHeight: '0.7rem' }}>
|
||||
{h % 4 === 0 ? h.toString().padStart(2, '0') : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user