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>
182 lines
5.1 KiB
Rust
182 lines
5.1 KiB
Rust
use chrono::{DateTime, Utc};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum Source {
|
|
Github,
|
|
Gitea,
|
|
Hg,
|
|
Bugzilla,
|
|
}
|
|
|
|
impl Source {
|
|
pub const ALL: &'static [Source] = &[
|
|
Source::Github,
|
|
Source::Gitea,
|
|
Source::Hg,
|
|
Source::Bugzilla,
|
|
];
|
|
|
|
pub fn as_str(&self) -> &'static str {
|
|
match self {
|
|
Source::Github => "github",
|
|
Source::Gitea => "gitea",
|
|
Source::Hg => "hg",
|
|
Source::Bugzilla => "bugzilla",
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::str::FromStr for Source {
|
|
type Err = ParseSourceError;
|
|
|
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
|
match s {
|
|
"github" => Ok(Source::Github),
|
|
"gitea" => Ok(Source::Gitea),
|
|
"hg" => Ok(Source::Hg),
|
|
"bugzilla" => Ok(Source::Bugzilla),
|
|
other => Err(ParseSourceError(other.to_string())),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
#[error("unknown source: {0}")]
|
|
pub struct ParseSourceError(pub String);
|
|
|
|
/// Raw event as stored. The presentation reshape lives in `moments-core`
|
|
/// and runs at API request time.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Event {
|
|
pub id: String,
|
|
pub source: Source,
|
|
pub action: String,
|
|
pub occurred_at: DateTime<Utc>,
|
|
/// True when the upstream marks this event as visible to anyone (e.g.
|
|
/// GitHub's top-level `public` flag). The DB stores everything; the API
|
|
/// uses this to gate what gets surfaced on the public timeline.
|
|
pub public: bool,
|
|
pub payload: serde_json::Value,
|
|
}
|
|
|
|
/// Filters accepted by `GET /v1/events`.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct EventQuery {
|
|
pub from: Option<DateTime<Utc>>,
|
|
pub to: Option<DateTime<Utc>>,
|
|
pub sources: Option<Vec<Source>>,
|
|
/// Filter to events matching a specific repo (matched against payload).
|
|
pub repo: Option<String>,
|
|
/// When false (default), only `public = true` rows are returned. The API
|
|
/// pins this to false today; a future authenticated path can flip it.
|
|
pub include_private: bool,
|
|
pub limit: u32,
|
|
}
|
|
|
|
/// Per-source rollup returned by `GET /v1/sources`.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct SourceSummary {
|
|
pub source: Source,
|
|
pub count: i64,
|
|
pub earliest: 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.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ProjectSummary {
|
|
pub repo: String,
|
|
pub source: Source,
|
|
pub host: String,
|
|
pub commit_count: i64,
|
|
pub issue_count: i64,
|
|
pub pr_count: i64,
|
|
pub first_activity: Option<DateTime<Utc>>,
|
|
pub last_activity: Option<DateTime<Utc>>,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Presentation shape — what `GET /v1/events` actually returns.
|
|
// The API reshapes raw payloads into these so the frontend stays dumb.
|
|
// ---------------------------------------------------------------------
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct TimelineItem {
|
|
pub id: String,
|
|
pub source: Source,
|
|
pub action: String,
|
|
pub occurred_at: DateTime<Utc>,
|
|
pub icon: TimelineIcon,
|
|
/// Primary headline. Mixed plain text + inline links so the UI can
|
|
/// render the right anchors without parsing.
|
|
pub title: Vec<TitleSegment>,
|
|
pub subtitle: Option<Vec<TitleSegment>>,
|
|
pub body: Option<TimelineBody>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
#[serde(tag = "kind", rename_all = "lowercase")]
|
|
pub enum TitleSegment {
|
|
Text { text: String },
|
|
Link { text: String, url: String },
|
|
}
|
|
|
|
impl TitleSegment {
|
|
pub fn text(s: impl Into<String>) -> Self {
|
|
Self::Text { text: s.into() }
|
|
}
|
|
pub fn link(text: impl Into<String>, url: impl Into<String>) -> Self {
|
|
Self::Link {
|
|
text: text.into(),
|
|
url: url.into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(tag = "kind", rename_all = "lowercase")]
|
|
pub enum TimelineBody {
|
|
Markdown { text: String },
|
|
Commits { commits: Vec<CommitSummary> },
|
|
Links { items: Vec<TitleSegment> },
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct CommitSummary {
|
|
pub sha: String,
|
|
pub short_sha: String,
|
|
pub message: String,
|
|
pub url: String,
|
|
pub author: Option<String>,
|
|
}
|
|
|
|
/// UI icon hint. The frontend maps these to its own icon set; new variants
|
|
/// here require a frontend update but never break existing renders (the UI
|
|
/// falls back to the generic icon for unknown values).
|
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
|
#[serde(rename_all = "kebab-case")]
|
|
pub enum TimelineIcon {
|
|
GitPush,
|
|
GitCommit,
|
|
GitMerge,
|
|
GitFork,
|
|
GitBranchCreate,
|
|
GitBranchDelete,
|
|
PullRequest,
|
|
Issue,
|
|
Comment,
|
|
Star,
|
|
Release,
|
|
Bug,
|
|
Generic,
|
|
}
|