Compare commits
18 Commits
283b2126c0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
2821548e6e
|
|||
|
72eeb547af
|
|||
|
86411bb88e
|
|||
|
acb061baca
|
|||
|
8a7177a54a
|
|||
|
818a535903
|
|||
|
9a8c0955b5
|
|||
|
25eab2d795
|
|||
|
2130032d46
|
|||
|
92a66422ab
|
|||
|
94b6fbe42d
|
|||
|
048646a7c1
|
|||
|
1f2fea3427
|
|||
|
d539892b70
|
|||
|
a57682e610
|
|||
|
22c80fd7af
|
|||
|
8b5656ef26
|
|||
|
dd1de38b2f
|
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -1271,6 +1271,7 @@ dependencies = [
|
|||||||
"axum",
|
"axum",
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
|
"fontdb",
|
||||||
"moments-core",
|
"moments-core",
|
||||||
"moments-data",
|
"moments-data",
|
||||||
"moments-entities",
|
"moments-entities",
|
||||||
@@ -1306,6 +1307,7 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"moments-core",
|
"moments-core",
|
||||||
"moments-entities",
|
"moments-entities",
|
||||||
|
"percent-encoding",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"
|
|||||||
figment = { version = "0.10", features = ["toml", "env"] }
|
figment = { version = "0.10", features = ["toml", "env"] }
|
||||||
clap = { version = "4", features = ["derive", "env"] }
|
clap = { version = "4", features = ["derive", "env"] }
|
||||||
resvg = "0.45"
|
resvg = "0.45"
|
||||||
|
fontdb = "0.23"
|
||||||
|
|
||||||
# internal
|
# internal
|
||||||
moments-entities = { path = "crates/moments-entities", version = "=0.1.0" }
|
moments-entities = { path = "crates/moments-entities", version = "=0.1.0" }
|
||||||
|
|||||||
@@ -22,3 +22,4 @@ chrono.workspace = true
|
|||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
resvg.workspace = true
|
resvg.workspace = true
|
||||||
|
fontdb.workspace = true
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use chrono::{DateTime, Datelike, 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::{DailyCount, EventQuery, LanguageDailyCount, ProjectSummary, RepoLanguage, Source, SourceSummary, TimelineItem};
|
use moments_entities::{DailyCount, EventQuery, HourlyAvg, LanguageDailyCount, ProjectSummary, RepoLanguage, 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;
|
||||||
@@ -57,6 +57,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.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/activity/daily", get(daily_counts))
|
||||||
|
.route("/v1/activity/hourly", get(hourly_avgs))
|
||||||
.route("/v1/languages/daily", get(language_daily_counts))
|
.route("/v1/languages/daily", get(language_daily_counts))
|
||||||
.route("/v1/languages/repos", get(repo_languages))
|
.route("/v1/languages/repos", get(repo_languages))
|
||||||
.route("/v1/forge/{source}/{*rest}", get(forge_proxy))
|
.route("/v1/forge/{source}/{*rest}", get(forge_proxy))
|
||||||
@@ -169,6 +170,38 @@ async fn language_daily_counts(
|
|||||||
Ok(Json(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(
|
async fn repo_languages(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
) -> Result<Json<Vec<RepoLanguage>>, ApiError> {
|
) -> Result<Json<Vec<RepoLanguage>>, ApiError> {
|
||||||
@@ -227,14 +260,21 @@ fn render_contributions_png(
|
|||||||
|
|
||||||
let count_map: HashMap<NaiveDate, i64> = counts.iter().map(|d| (d.date, d.count)).collect();
|
let count_map: HashMap<NaiveDate, i64> = counts.iter().map(|d| (d.date, d.count)).collect();
|
||||||
|
|
||||||
let cell = 10_f64;
|
// OG image canvas: 1200x630
|
||||||
let gap = 2_f64;
|
let og_w = 1200_f64;
|
||||||
let step = cell + gap;
|
let og_h = 630_f64;
|
||||||
let radius = cell / 2.0;
|
let padding = 40_f64;
|
||||||
let year_label_w = 40_f64;
|
|
||||||
let max_cols = 53;
|
|
||||||
let bg = "#2c3e50";
|
let bg = "#2c3e50";
|
||||||
|
|
||||||
|
let year_label_w = 50_f64;
|
||||||
|
let max_cols = 53;
|
||||||
|
// Scale cell size to fill available width
|
||||||
|
let avail_w = og_w - 2.0 * padding - year_label_w;
|
||||||
|
let step = (avail_w / max_cols as f64).floor();
|
||||||
|
let gap = (step * 0.17).round();
|
||||||
|
let cell = step - gap;
|
||||||
|
let radius = cell / 2.0;
|
||||||
|
|
||||||
let colors = ["rgba(255,255,255,0.05)", "#0e4429", "#006d32", "#26a641", "#39d353"];
|
let colors = ["rgba(255,255,255,0.05)", "#0e4429", "#006d32", "#26a641", "#39d353"];
|
||||||
|
|
||||||
// Build weekly data per year
|
// Build weekly data per year
|
||||||
@@ -253,7 +293,6 @@ fn render_contributions_png(
|
|||||||
} else {
|
} else {
|
||||||
NaiveDate::from_ymd_opt(yr, 12, 31).unwrap()
|
NaiveDate::from_ymd_opt(yr, 12, 31).unwrap()
|
||||||
};
|
};
|
||||||
// Align to preceding Sunday (weekday 6 = Sunday in chrono's Mon=0 scheme)
|
|
||||||
let offset = year_start.weekday().num_days_from_sunday();
|
let offset = year_start.weekday().num_days_from_sunday();
|
||||||
let mut cursor = year_start - chrono::Duration::days(offset as i64);
|
let mut cursor = year_start - chrono::Duration::days(offset as i64);
|
||||||
|
|
||||||
@@ -294,35 +333,54 @@ fn render_contributions_png(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let n_rows = rows.len();
|
let n_rows = rows.len();
|
||||||
let title_h = 28_f64;
|
let graph_h = (n_rows as f64) * step;
|
||||||
let svg_w = year_label_w + (max_cols as f64) * step;
|
|
||||||
let svg_h = title_h + (n_rows as f64) * step + 8.0;
|
|
||||||
|
|
||||||
let total: i64 = counts.iter().map(|d| d.count).sum();
|
let total: i64 = counts.iter().map(|d| d.count).sum();
|
||||||
|
|
||||||
let mut svg = format!(
|
|
||||||
r#"<svg xmlns="http://www.w3.org/2000/svg" width="{svg_w}" height="{svg_h}" viewBox="0 0 {svg_w} {svg_h}"><rect width="100%" height="100%" fill="{bg}"/>"#,
|
|
||||||
);
|
|
||||||
let repo_text = if repo_count > 0 {
|
let repo_text = if repo_count > 0 {
|
||||||
format!(" in {repo_count} repositories")
|
format!(" in {repo_count} repositories")
|
||||||
} else {
|
} else {
|
||||||
String::new()
|
String::new()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Layout: headline at top, graph vertically centered in remaining space
|
||||||
|
let offset_x = padding;
|
||||||
|
let headline_y = padding + 36.0;
|
||||||
|
let subtitle_y = headline_y + 28.0;
|
||||||
|
let graph_top = subtitle_y + 16.0;
|
||||||
|
let avail_graph_h = og_h - graph_top - padding;
|
||||||
|
let graph_y = graph_top + (avail_graph_h - graph_h).max(0.0) / 2.0;
|
||||||
|
|
||||||
|
let mut svg = format!(
|
||||||
|
r#"<svg xmlns="http://www.w3.org/2000/svg" width="{og_w}" height="{og_h}" viewBox="0 0 {og_w} {og_h}"><rect width="100%" height="100%" fill="{bg}"/>"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Headline
|
||||||
svg.push_str(&format!(
|
svg.push_str(&format!(
|
||||||
r##"<text x="{x}" y="18" fill="#ecf0f1" font-size="12" opacity="0.6">{total} contributions since {from}{repo_text}</text>"##,
|
r##"<text x="{x}" y="{y}" fill="#ecf0f1" font-family="sans-serif" font-size="36" font-weight="bold">rob thijssen</text>"##,
|
||||||
x = year_label_w,
|
x = offset_x + year_label_w,
|
||||||
|
y = headline_y,
|
||||||
));
|
));
|
||||||
|
|
||||||
|
// Subtitle
|
||||||
|
svg.push_str(&format!(
|
||||||
|
r##"<text x="{x}" y="{y}" fill="#ecf0f1" font-family="sans-serif" font-size="16" opacity="0.6">{total} contributions since {from}{repo_text}</text>"##,
|
||||||
|
x = offset_x + year_label_w,
|
||||||
|
y = subtitle_y,
|
||||||
|
));
|
||||||
|
|
||||||
|
let label_font_size = (step * 0.7).round().max(8.0).min(14.0);
|
||||||
|
|
||||||
for (row_idx, row) in rows.iter().enumerate() {
|
for (row_idx, row) in rows.iter().enumerate() {
|
||||||
let y_base = title_h + (row_idx as f64) * step;
|
let y_base = graph_y + (row_idx as f64) * step;
|
||||||
svg.push_str(&format!(
|
svg.push_str(&format!(
|
||||||
r##"<text x="{x}" y="{y}" text-anchor="end" dominant-baseline="central" fill="#ecf0f1" font-size="9" opacity="0.6">{yr}</text>"##,
|
r##"<text x="{x}" y="{y}" text-anchor="end" dominant-baseline="central" fill="#ecf0f1" font-family="sans-serif" font-size="{fs}" opacity="0.6">{yr}</text>"##,
|
||||||
x = year_label_w - 4.0,
|
x = offset_x + year_label_w - 6.0,
|
||||||
y = y_base + radius,
|
y = y_base + radius,
|
||||||
|
fs = label_font_size,
|
||||||
yr = row.year,
|
yr = row.year,
|
||||||
));
|
));
|
||||||
for (col, (_, _, count)) in row.weeks.iter().enumerate() {
|
for (col, (_, _, count)) in row.weeks.iter().enumerate() {
|
||||||
let cx = year_label_w + (col as f64) * step + radius;
|
let cx = offset_x + year_label_w + (col as f64) * step + radius;
|
||||||
let cy = y_base + radius;
|
let cy = y_base + radius;
|
||||||
let fill = color_for(*count);
|
let fill = color_for(*count);
|
||||||
svg.push_str(&format!(
|
svg.push_str(&format!(
|
||||||
@@ -334,15 +392,17 @@ fn render_contributions_png(
|
|||||||
|
|
||||||
svg.push_str("</svg>");
|
svg.push_str("</svg>");
|
||||||
|
|
||||||
// Rasterize with resvg
|
// Rasterize at 1200x630
|
||||||
let tree = resvg::usvg::Tree::from_str(&svg, &resvg::usvg::Options::default())
|
let mut fontdb = fontdb::Database::new();
|
||||||
|
fontdb.load_system_fonts();
|
||||||
|
let mut opts = resvg::usvg::Options::default();
|
||||||
|
opts.fontdb = std::sync::Arc::new(fontdb);
|
||||||
|
opts.font_family = "Noto Sans".to_owned();
|
||||||
|
let tree = resvg::usvg::Tree::from_str(&svg, &opts)
|
||||||
.map_err(|e| format!("svg parse: {e}"))?;
|
.map_err(|e| format!("svg parse: {e}"))?;
|
||||||
|
|
||||||
let size = tree.size();
|
|
||||||
let w = size.width().ceil() as u32;
|
|
||||||
let h = size.height().ceil() as u32;
|
|
||||||
let mut pixmap =
|
let mut pixmap =
|
||||||
resvg::tiny_skia::Pixmap::new(w, h).ok_or_else(|| "pixmap alloc failed".to_string())?;
|
resvg::tiny_skia::Pixmap::new(og_w as u32, og_h as u32).ok_or_else(|| "pixmap alloc failed".to_string())?;
|
||||||
|
|
||||||
resvg::render(&tree, resvg::tiny_skia::Transform::default(), &mut pixmap.as_mut());
|
resvg::render(&tree, resvg::tiny_skia::Transform::default(), &mut pixmap.as_mut());
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ pub use sources::{EventSource, PollerState, PollerStateStore, SourceError, run_p
|
|||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use chrono::NaiveDate;
|
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)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum StoreError {
|
pub enum StoreError {
|
||||||
@@ -22,6 +22,7 @@ pub trait EventReader: Send + Sync {
|
|||||||
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, include_private: bool) -> Result<Vec<DailyCount>, 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 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>;
|
async fn repo_languages(&self) -> Result<Vec<RepoLanguage>, StoreError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,3 +17,4 @@ tracing.workspace = true
|
|||||||
async-trait.workspace = true
|
async-trait.workspace = true
|
||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
percent-encoding = "2"
|
||||||
|
|||||||
55
crates/moments-data/migrations/0005_dedup_gitea_events.sql
Normal file
55
crates/moments-data/migrations/0005_dedup_gitea_events.sql
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
-- Collapse duplicate Gitea events introduced by polling both the user
|
||||||
|
-- activity feed and per-org activity feeds.
|
||||||
|
--
|
||||||
|
-- Gitea writes one Action row per interested user-context: a push to
|
||||||
|
-- helexa/cortex by user grenade produces two rows, one with
|
||||||
|
-- user_id=grenade and one with user_id=helexa. Everything else (op_type,
|
||||||
|
-- act_user_id, repo_id, ref_name, comment_id, created) is identical.
|
||||||
|
-- Our prior id scheme (gitea:{action_row_id}) gave them different ids,
|
||||||
|
-- so the upsert-on-id dedup never fired and the timeline rendered each
|
||||||
|
-- push twice.
|
||||||
|
--
|
||||||
|
-- This migration re-keys every existing gitea row to the same canonical
|
||||||
|
-- formula `parse_gitea_event` now emits, deleting duplicates encountered
|
||||||
|
-- along the way. Idempotent: running it again is a no-op because the
|
||||||
|
-- canonical id of a canonical id is itself.
|
||||||
|
|
||||||
|
-- Snapshot the canonical id for every gitea row.
|
||||||
|
CREATE TEMP TABLE _gitea_canonical AS
|
||||||
|
SELECT
|
||||||
|
id AS old_id,
|
||||||
|
'gitea:'
|
||||||
|
|| coalesce(payload->>'op_type', '') || ':'
|
||||||
|
|| coalesce(payload->>'act_user_id', payload->'act_user'->>'id', '0') || ':'
|
||||||
|
|| coalesce(payload->>'repo_id', payload->'repo'->>'id', '0') || ':'
|
||||||
|
|| coalesce(payload->>'ref_name', '') || ':'
|
||||||
|
|| coalesce(payload->>'comment_id', '0') || ':'
|
||||||
|
|| coalesce(payload->>'created', '')
|
||||||
|
AS new_id
|
||||||
|
FROM events
|
||||||
|
WHERE source = 'gitea';
|
||||||
|
|
||||||
|
-- For each canonical id, keep the row whose current id is lexicographically
|
||||||
|
-- smallest (stable, arbitrary tie-break) and delete the rest. The "old id
|
||||||
|
-- already matches the new id" case lands here too — DELETE skips it because
|
||||||
|
-- rn = 1 for any singleton group.
|
||||||
|
DELETE FROM events
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT old_id FROM (
|
||||||
|
SELECT old_id, new_id,
|
||||||
|
row_number() OVER (PARTITION BY new_id ORDER BY old_id) AS rn
|
||||||
|
FROM _gitea_canonical
|
||||||
|
) ranked
|
||||||
|
WHERE rn > 1
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Rename remaining rows to the canonical id. Postgres defers PK uniqueness
|
||||||
|
-- to statement end, so swapping ids across rows in one UPDATE is fine
|
||||||
|
-- provided the final set is unique (dedup above guarantees that).
|
||||||
|
UPDATE events e
|
||||||
|
SET id = c.new_id
|
||||||
|
FROM _gitea_canonical c
|
||||||
|
WHERE e.id = c.old_id
|
||||||
|
AND e.id <> c.new_id;
|
||||||
|
|
||||||
|
DROP TABLE _gitea_canonical;
|
||||||
@@ -276,8 +276,14 @@ impl EventSource for GiteaSource {
|
|||||||
/// Convert a Gitea activity feed item into our Event row. The host gets
|
/// Convert a Gitea activity feed item into our Event row. The host gets
|
||||||
/// stamped into the payload as `_host` so the reshape layer can build
|
/// stamped into the payload as `_host` so the reshape layer can build
|
||||||
/// web URLs without needing global config.
|
/// web URLs without needing global config.
|
||||||
|
///
|
||||||
|
/// The id is content-derived rather than using Gitea's `id` field directly:
|
||||||
|
/// Gitea creates one Action row per interested user-context, so a push to
|
||||||
|
/// an org repo by user U produces two rows (one under U's context, one
|
||||||
|
/// under the org's), distinguished only by `id` and `user_id`. Keying on
|
||||||
|
/// `(op_type, act_user_id, repo_id, ref_name, comment_id, created)` makes
|
||||||
|
/// those two rows collapse to the same event on upsert.
|
||||||
fn parse_gitea_event(item: &Value, host: &str) -> Option<Event> {
|
fn parse_gitea_event(item: &Value, host: &str) -> Option<Event> {
|
||||||
let id = item.get("id").and_then(Value::as_i64)?;
|
|
||||||
let op_type = item.get("op_type").and_then(Value::as_str)?.to_string();
|
let op_type = item.get("op_type").and_then(Value::as_str)?.to_string();
|
||||||
let created_str = item.get("created").and_then(Value::as_str)?;
|
let created_str = item.get("created").and_then(Value::as_str)?;
|
||||||
let occurred_at = DateTime::parse_from_rfc3339(created_str)
|
let occurred_at = DateTime::parse_from_rfc3339(created_str)
|
||||||
@@ -285,13 +291,15 @@ fn parse_gitea_event(item: &Value, host: &str) -> Option<Event> {
|
|||||||
.with_timezone(&Utc);
|
.with_timezone(&Utc);
|
||||||
let private = item.get("is_private").and_then(Value::as_bool).unwrap_or(false);
|
let private = item.get("is_private").and_then(Value::as_bool).unwrap_or(false);
|
||||||
|
|
||||||
|
let id = gitea_canonical_id(item, &op_type, created_str);
|
||||||
|
|
||||||
let mut payload = item.clone();
|
let mut payload = item.clone();
|
||||||
if let Some(obj) = payload.as_object_mut() {
|
if let Some(obj) = payload.as_object_mut() {
|
||||||
obj.insert("_host".into(), Value::String(host.into()));
|
obj.insert("_host".into(), Value::String(host.into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
Some(Event {
|
Some(Event {
|
||||||
id: format!("gitea:{id}"),
|
id,
|
||||||
source: Source::Gitea,
|
source: Source::Gitea,
|
||||||
action: op_type,
|
action: op_type,
|
||||||
occurred_at,
|
occurred_at,
|
||||||
@@ -300,6 +308,25 @@ fn parse_gitea_event(item: &Value, host: &str) -> Option<Event> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build the canonical, content-derived id for a Gitea action. Must stay
|
||||||
|
/// in lockstep with the SQL formula in migration 0005 so back-fill and
|
||||||
|
/// new writes share the same id space.
|
||||||
|
fn gitea_canonical_id(item: &Value, op_type: &str, created: &str) -> String {
|
||||||
|
let act_user_id = item
|
||||||
|
.get("act_user_id")
|
||||||
|
.and_then(Value::as_i64)
|
||||||
|
.or_else(|| item.get("act_user").and_then(|u| u.get("id")).and_then(Value::as_i64))
|
||||||
|
.unwrap_or(0);
|
||||||
|
let repo_id = item
|
||||||
|
.get("repo_id")
|
||||||
|
.and_then(Value::as_i64)
|
||||||
|
.or_else(|| item.get("repo").and_then(|r| r.get("id")).and_then(Value::as_i64))
|
||||||
|
.unwrap_or(0);
|
||||||
|
let ref_name = item.get("ref_name").and_then(Value::as_str).unwrap_or("");
|
||||||
|
let comment_id = item.get("comment_id").and_then(Value::as_i64).unwrap_or(0);
|
||||||
|
format!("gitea:{op_type}:{act_user_id}:{repo_id}:{ref_name}:{comment_id}:{created}")
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -310,14 +337,16 @@ mod tests {
|
|||||||
let raw = json!({
|
let raw = json!({
|
||||||
"id": 973,
|
"id": 973,
|
||||||
"op_type": "commit_repo",
|
"op_type": "commit_repo",
|
||||||
|
"act_user_id": 42,
|
||||||
|
"repo_id": 7,
|
||||||
"ref_name": "refs/heads/main",
|
"ref_name": "refs/heads/main",
|
||||||
"is_private": false,
|
"is_private": false,
|
||||||
"content": "{\"Commits\":[{\"Sha1\":\"abc123\"}],\"Len\":1}",
|
"content": "{\"Commits\":[{\"Sha1\":\"abc123\"}],\"Len\":1}",
|
||||||
"created": "2026-05-03T16:37:45Z",
|
"created": "2026-05-03T16:37:45Z",
|
||||||
"repo": { "full_name": "grenade/moments" }
|
"repo": { "id": 7, "full_name": "grenade/moments" }
|
||||||
});
|
});
|
||||||
let ev = parse_gitea_event(&raw, "git.lair.cafe").expect("parses");
|
let ev = parse_gitea_event(&raw, "git.lair.cafe").expect("parses");
|
||||||
assert_eq!(ev.id, "gitea:973");
|
assert_eq!(ev.id, "gitea:commit_repo:42:7:refs/heads/main:0:2026-05-03T16:37:45Z");
|
||||||
assert_eq!(ev.source, Source::Gitea);
|
assert_eq!(ev.source, Source::Gitea);
|
||||||
assert_eq!(ev.action, "commit_repo");
|
assert_eq!(ev.action, "commit_repo");
|
||||||
assert!(ev.public);
|
assert!(ev.public);
|
||||||
@@ -328,6 +357,43 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dup_action_rows_for_user_and_org_contexts_collapse_to_same_id() {
|
||||||
|
// Gitea creates two Action rows when grenade pushes to helexa/cortex:
|
||||||
|
// one with user_id=grenade (surfaced by the user feed), one with
|
||||||
|
// user_id=helexa (surfaced by the org feed). Everything except `id`
|
||||||
|
// and `user_id` is identical. The canonical id ignores both.
|
||||||
|
let user_ctx = json!({
|
||||||
|
"id": 1322,
|
||||||
|
"user_id": 42,
|
||||||
|
"op_type": "commit_repo",
|
||||||
|
"act_user_id": 42,
|
||||||
|
"act_user": { "login": "grenade", "id": 42 },
|
||||||
|
"repo_id": 99,
|
||||||
|
"repo": { "id": 99, "full_name": "helexa/cortex" },
|
||||||
|
"ref_name": "refs/heads/main",
|
||||||
|
"comment_id": 0,
|
||||||
|
"is_private": false,
|
||||||
|
"created": "2026-05-20T04:32:50Z"
|
||||||
|
});
|
||||||
|
let org_ctx = json!({
|
||||||
|
"id": 1323,
|
||||||
|
"user_id": 7,
|
||||||
|
"op_type": "commit_repo",
|
||||||
|
"act_user_id": 42,
|
||||||
|
"act_user": { "login": "grenade", "id": 42 },
|
||||||
|
"repo_id": 99,
|
||||||
|
"repo": { "id": 99, "full_name": "helexa/cortex" },
|
||||||
|
"ref_name": "refs/heads/main",
|
||||||
|
"comment_id": 0,
|
||||||
|
"is_private": false,
|
||||||
|
"created": "2026-05-20T04:32:50Z"
|
||||||
|
});
|
||||||
|
let a = parse_gitea_event(&user_ctx, "git.lair.cafe").expect("parses");
|
||||||
|
let b = parse_gitea_event(&org_ctx, "git.lair.cafe").expect("parses");
|
||||||
|
assert_eq!(a.id, b.id, "duplicate action rows must collide on id");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn org_event_user_filter_predicate() {
|
fn org_event_user_filter_predicate() {
|
||||||
let by_user = json!({
|
let by_user = json!({
|
||||||
|
|||||||
@@ -7,12 +7,17 @@
|
|||||||
//! to, opened issues/PRs on, or reviewed, even without collaborator
|
//! to, opened issues/PRs on, or reviewed, even without collaborator
|
||||||
//! status. No result cap (cursor-paginated).
|
//! status. No result cap (cursor-paginated).
|
||||||
//!
|
//!
|
||||||
//! Then walks each repo's commit history via
|
//! Then walks each branch's commit history via
|
||||||
//! `/repos/{owner}/{repo}/commits?author={user}` with a `since` cursor
|
//! `/repos/{owner}/{repo}/commits?author={user}&sha={branch}` with a
|
||||||
//! to avoid re-fetching known commits.
|
//! per-branch `since` cursor to avoid re-fetching known commits. Walking
|
||||||
|
//! every branch (not just the default) is what catches work-in-progress
|
||||||
|
//! on feature branches and pushes to fork branches that never get merged
|
||||||
|
//! upstream — neither the user events feed nor /search/commits surface
|
||||||
|
//! those reliably.
|
||||||
//!
|
//!
|
||||||
//! Events use `github-commit:{sha}` as their ID, matching the scheme in
|
//! Events use `github-commit:{sha}` as their ID, matching the scheme in
|
||||||
//! `github_search`, so duplicates are resolved via idempotent upsert.
|
//! `github_search`, so duplicates are resolved via idempotent upsert
|
||||||
|
//! (the same commit reached via two branches just upserts twice).
|
||||||
|
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -21,10 +26,30 @@ use async_trait::async_trait;
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use moments_core::{EventSource, EventWriter, PollerStateStore, SourceError};
|
use moments_core::{EventSource, EventWriter, PollerStateStore, SourceError};
|
||||||
use moments_entities::{Event, RepoLanguage, Source};
|
use moments_entities::{Event, RepoLanguage, Source};
|
||||||
|
use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode};
|
||||||
use reqwest::{Client, header};
|
use reqwest::{Client, header};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use tracing::{debug, warn};
|
use tracing::{debug, warn};
|
||||||
|
|
||||||
|
/// Encode characters that have meaning in a URL query — branch names can
|
||||||
|
/// contain `/`, `#`, `?`, etc. Whitelisting is too fragile; encode anything
|
||||||
|
/// outside the unreserved set plus a few safe characters.
|
||||||
|
const BRANCH_ENCODE_SET: &AsciiSet = &CONTROLS
|
||||||
|
.add(b' ')
|
||||||
|
.add(b'"')
|
||||||
|
.add(b'#')
|
||||||
|
.add(b'<')
|
||||||
|
.add(b'>')
|
||||||
|
.add(b'?')
|
||||||
|
.add(b'`')
|
||||||
|
.add(b'{')
|
||||||
|
.add(b'}')
|
||||||
|
.add(b'/')
|
||||||
|
.add(b'&')
|
||||||
|
.add(b'=')
|
||||||
|
.add(b'+')
|
||||||
|
.add(b'%');
|
||||||
|
|
||||||
const SOURCE_NAME: &str = "github-repo";
|
const SOURCE_NAME: &str = "github-repo";
|
||||||
const USER_AGENT: &str = concat!(
|
const USER_AGENT: &str = concat!(
|
||||||
"moments/",
|
"moments/",
|
||||||
@@ -227,19 +252,217 @@ impl GithubRepoSource {
|
|||||||
Ok(repos)
|
Ok(repos)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch commits for a single repo, paginating fully on first run
|
/// Branch discovery via GraphQL, filtered to branches whose HEAD
|
||||||
/// and using `since` on subsequent runs to catch everything new.
|
/// commit was authored by the user. Skips the long tail of
|
||||||
|
/// upstream-contributor branches in large forks (e.g. azure-docs).
|
||||||
|
///
|
||||||
|
/// Why HEAD author and not `history(author:).totalCount`: the latter
|
||||||
|
/// forces GraphQL to walk full commit history per branch looking for
|
||||||
|
/// matches, which times out (502) on forks with thousands of branches.
|
||||||
|
/// Checking the HEAD commit's author is O(1) per branch. The blind
|
||||||
|
/// spot — branches with the user's older commits but a different
|
||||||
|
/// HEAD author — is rare in practice for forks/feature branches.
|
||||||
|
///
|
||||||
|
/// On any GraphQL failure, callers should fall back to `list_branches`
|
||||||
|
/// (REST, walks everything; 500s from empty branches are silenced
|
||||||
|
/// inside `scan_repo_branch`).
|
||||||
|
async fn list_branches_with_commits(
|
||||||
|
&self,
|
||||||
|
repo: &Repo,
|
||||||
|
user_login: &str,
|
||||||
|
) -> Result<Vec<String>, SourceError> {
|
||||||
|
let token = match &self.config.token {
|
||||||
|
Some(t) => t,
|
||||||
|
None => return Err(SourceError::Http("no token; graphql unavailable".into())),
|
||||||
|
};
|
||||||
|
let parts: Vec<&str> = repo.full_name.splitn(2, '/').collect();
|
||||||
|
if parts.len() != 2 {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let (owner, name) = (parts[0], parts[1]);
|
||||||
|
|
||||||
|
let mut branches = Vec::new();
|
||||||
|
let mut cursor: Option<String> = None;
|
||||||
|
// Cap pages to bound cost on pathological repos. 50 pages × 100
|
||||||
|
// branches = 5000; well past anything plausible for a human user.
|
||||||
|
for _ in 0..50u32 {
|
||||||
|
let after = match &cursor {
|
||||||
|
Some(c) => format!(", after: \"{}\"", c),
|
||||||
|
None => String::new(),
|
||||||
|
};
|
||||||
|
// `author.user.login` resolves the commit's GitHub user (may
|
||||||
|
// differ from the raw commit author name); falling back to
|
||||||
|
// `author.email` is intentionally omitted to keep the query
|
||||||
|
// shape minimal — false negatives there are caught by the
|
||||||
|
// REST fallback on the next poll cycle.
|
||||||
|
let query = format!(
|
||||||
|
r#"{{ repository(owner: "{owner}", name: "{name}") {{ refs(refPrefix: "refs/heads/", first: 100{after}) {{ pageInfo {{ hasNextPage endCursor }} nodes {{ name target {{ ... on Commit {{ author {{ user {{ login }} }} }} }} }} }} }} }}"#,
|
||||||
|
);
|
||||||
|
let body = serde_json::json!({ "query": query });
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.post("https://api.github.com/graphql")
|
||||||
|
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||||
|
.header(header::USER_AGENT, USER_AGENT)
|
||||||
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| SourceError::Http(e.to_string()))?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(SourceError::Http(format!(
|
||||||
|
"{} POST graphql (branches {}/{})",
|
||||||
|
resp.status(),
|
||||||
|
owner,
|
||||||
|
name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let data: Value = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| SourceError::Parse(e.to_string()))?;
|
||||||
|
if let Some(errors) = data.get("errors").and_then(Value::as_array) {
|
||||||
|
if let Some(msg) = errors.first().and_then(|e| e.get("message")).and_then(Value::as_str) {
|
||||||
|
return Err(SourceError::Http(format!("GraphQL error listing branches: {msg}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let refs = &data["data"]["repository"]["refs"];
|
||||||
|
if refs.is_null() {
|
||||||
|
// Repo may be deleted or inaccessible — treat as empty.
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
if let Some(nodes) = refs["nodes"].as_array() {
|
||||||
|
for node in nodes {
|
||||||
|
let branch = node["name"].as_str();
|
||||||
|
let head_login = node["target"]["author"]["user"]["login"].as_str();
|
||||||
|
if let (Some(b), Some(login)) = (branch, head_login) {
|
||||||
|
if login.eq_ignore_ascii_case(user_login) {
|
||||||
|
branches.push(b.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let has_next = refs["pageInfo"]["hasNextPage"].as_bool().unwrap_or(false);
|
||||||
|
if !has_next {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
cursor = refs["pageInfo"]["endCursor"].as_str().map(String::from);
|
||||||
|
}
|
||||||
|
Ok(branches)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List every branch in a repo. Returns an empty vec for empty (409)
|
||||||
|
/// or missing (404) repos; surfaces rate-limit / transport errors so the
|
||||||
|
/// caller can decide whether to bail.
|
||||||
|
async fn list_branches(&self, repo: &Repo) -> Result<Vec<String>, SourceError> {
|
||||||
|
let mut branches = Vec::new();
|
||||||
|
for page in 1..=10u32 {
|
||||||
|
let url = format!(
|
||||||
|
"https://api.github.com/repos/{}/branches?per_page={}&page={}",
|
||||||
|
repo.full_name, self.config.per_page, page
|
||||||
|
);
|
||||||
|
let req = self.apply_headers(self.client.get(&url));
|
||||||
|
let resp = req
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| SourceError::Http(e.to_string()))?;
|
||||||
|
|
||||||
|
let status = resp.status();
|
||||||
|
if status.as_u16() == 404 || status.as_u16() == 409 {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
if status.as_u16() == 403 || status.as_u16() == 429 {
|
||||||
|
return Err(SourceError::Http(format!("{} GET {}", status, url)));
|
||||||
|
}
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(SourceError::Http(format!("{} GET {}", status, url)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let items: Vec<Value> = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| SourceError::Parse(e.to_string()))?;
|
||||||
|
if items.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
for item in &items {
|
||||||
|
if let Some(name) = item.get("name").and_then(Value::as_str) {
|
||||||
|
branches.push(name.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if items.len() < self.config.per_page as usize {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(branches)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch commits for a single repo across all branches the user has
|
||||||
|
/// touched. Per-branch state keys (`github-repo:{full_name}@{branch}`)
|
||||||
|
/// hold the newest seen commit timestamp so each branch can be
|
||||||
|
/// incremented independently — important because a brand new branch's
|
||||||
|
/// `since` cursor must start unset even when the default branch has
|
||||||
|
/// been polled many times already.
|
||||||
|
///
|
||||||
|
/// When `user_id` is supplied, branches are pre-filtered via GraphQL
|
||||||
|
/// to those with at least one commit by the user — vastly cheaper for
|
||||||
|
/// large upstream forks where most branches were never touched. On
|
||||||
|
/// GraphQL failure (or no token), falls back to the REST branch list
|
||||||
|
/// and relies on the per-branch 500-as-empty handling to discard the
|
||||||
|
/// noise.
|
||||||
async fn scan_repo(&self, repo: &Repo) -> Result<usize, SourceError> {
|
async fn scan_repo(&self, repo: &Repo) -> Result<usize, SourceError> {
|
||||||
let state_key = format!("github-repo:{}", repo.full_name);
|
let branches = if self.config.token.is_some() {
|
||||||
|
match self.list_branches_with_commits(repo, &self.config.user).await {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(e) => {
|
||||||
|
warn!(repo = %repo.full_name, error = %e, "graphql branch filter failed; falling back to REST");
|
||||||
|
self.list_branches(repo).await?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.list_branches(repo).await?
|
||||||
|
};
|
||||||
|
if branches.is_empty() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut total = 0usize;
|
||||||
|
// Dedup commits seen via multiple branches in one tick. Without this
|
||||||
|
// the same SHA appears in the upsert batch twice (postgres rejects
|
||||||
|
// duplicate conflict targets in a single INSERT).
|
||||||
|
let mut seen_in_tick: HashSet<String> = HashSet::new();
|
||||||
|
for branch in &branches {
|
||||||
|
match self.scan_repo_branch(repo, branch, &mut seen_in_tick).await {
|
||||||
|
Ok(n) => total += n,
|
||||||
|
Err(SourceError::Http(ref msg)) if msg.starts_with("403") || msg.starts_with("429") => {
|
||||||
|
return Err(SourceError::Http(msg.clone()));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(repo = %repo.full_name, branch = %branch, error = %e, "branch scan failed; continuing");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(total)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scan_repo_branch(
|
||||||
|
&self,
|
||||||
|
repo: &Repo,
|
||||||
|
branch: &str,
|
||||||
|
seen_in_tick: &mut HashSet<String>,
|
||||||
|
) -> Result<usize, SourceError> {
|
||||||
|
let state_key = format!("github-repo:{}@{}", repo.full_name, branch);
|
||||||
let prior = self.state.load(&state_key).await?;
|
let prior = self.state.load(&state_key).await?;
|
||||||
let since = prior.as_ref().and_then(|s| s.last_modified);
|
let since = prior.as_ref().and_then(|s| s.last_modified);
|
||||||
|
|
||||||
|
let encoded_branch = utf8_percent_encode(branch, BRANCH_ENCODE_SET).to_string();
|
||||||
|
|
||||||
let mut total = 0usize;
|
let mut total = 0usize;
|
||||||
let mut newest: Option<DateTime<Utc>> = since;
|
let mut newest: Option<DateTime<Utc>> = since;
|
||||||
for page in 1..=MAX_BACKFILL_PAGES {
|
for page in 1..=MAX_BACKFILL_PAGES {
|
||||||
let mut url = format!(
|
let mut url = format!(
|
||||||
"https://api.github.com/repos/{}/commits?author={}&per_page={}&page={}",
|
"https://api.github.com/repos/{}/commits?author={}&sha={}&per_page={}&page={}",
|
||||||
repo.full_name, self.config.user, self.config.per_page, page
|
repo.full_name, self.config.user, encoded_branch, self.config.per_page, page
|
||||||
);
|
);
|
||||||
if let Some(since_dt) = since {
|
if let Some(since_dt) = since {
|
||||||
url.push_str(&format!("&since={}", since_dt.to_rfc3339()));
|
url.push_str(&format!("&since={}", since_dt.to_rfc3339()));
|
||||||
@@ -256,11 +479,20 @@ impl GithubRepoSource {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if status.as_u16() == 403 || status.as_u16() == 429 {
|
if status.as_u16() == 403 || status.as_u16() == 429 {
|
||||||
warn!(repo = %repo.full_name, status = %status, "rate limited; stopping early");
|
warn!(repo = %repo.full_name, branch = %branch, status = %status, "rate limited; stopping early");
|
||||||
return Err(SourceError::Http(format!("{} GET {}", status, url)));
|
return Err(SourceError::Http(format!("{} GET {}", status, url)));
|
||||||
}
|
}
|
||||||
if status.as_u16() == 404 {
|
if status.as_u16() == 404 {
|
||||||
warn!(repo = %repo.full_name, "repo not found; skipping");
|
warn!(repo = %repo.full_name, branch = %branch, "repo or branch not found; skipping");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// GitHub's `/repos/.../commits?author=X&sha=branch` returns 500
|
||||||
|
// (not an empty array) when the user has zero commits on the
|
||||||
|
// specified branch. Treat it as "no commits on this branch"
|
||||||
|
// rather than a server error — surfacing it as a warning floods
|
||||||
|
// logs on forks whose branches were all authored by upstream.
|
||||||
|
if status.as_u16() == 500 {
|
||||||
|
debug!(repo = %repo.full_name, branch = %branch, "no commits by author on branch (500)");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
@@ -275,16 +507,32 @@ impl GithubRepoSource {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
let events: Vec<Event> = items
|
let mut events = Vec::with_capacity(items.len());
|
||||||
.iter()
|
for item in &items {
|
||||||
.filter_map(|item| parse_commit(item, repo))
|
if let Some(ev) = parse_commit(item, repo) {
|
||||||
.collect();
|
if seen_in_tick.insert(ev.id.clone()) {
|
||||||
for ev in &events {
|
if let Some(n) = newest {
|
||||||
newest = Some(match newest {
|
if ev.occurred_at > n {
|
||||||
Some(n) if ev.occurred_at > n => ev.occurred_at,
|
newest = Some(ev.occurred_at);
|
||||||
Some(n) => n,
|
}
|
||||||
None => ev.occurred_at,
|
} else {
|
||||||
});
|
newest = Some(ev.occurred_at);
|
||||||
|
}
|
||||||
|
events.push(ev);
|
||||||
|
} else {
|
||||||
|
// Already ingested via another branch this tick;
|
||||||
|
// still advance `newest` so the per-branch cursor
|
||||||
|
// doesn't get stuck behind shared history.
|
||||||
|
let occurred = parse_commit_date(item);
|
||||||
|
if let Some(t) = occurred {
|
||||||
|
newest = Some(match newest {
|
||||||
|
Some(n) if t > n => t,
|
||||||
|
Some(n) => n,
|
||||||
|
None => t,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
total += self.writer.upsert_events(&events).await?;
|
total += self.writer.upsert_events(&events).await?;
|
||||||
|
|
||||||
@@ -451,8 +699,7 @@ fn parse_repo(item: &Value) -> Option<Repo> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_commit(item: &Value, repo: &Repo) -> Option<Event> {
|
fn parse_commit_date(item: &Value) -> Option<DateTime<Utc>> {
|
||||||
let sha = item.get("sha").and_then(Value::as_str)?;
|
|
||||||
let date_str = item
|
let date_str = item
|
||||||
.get("commit")
|
.get("commit")
|
||||||
.and_then(|c| c.get("author"))
|
.and_then(|c| c.get("author"))
|
||||||
@@ -464,9 +711,16 @@ fn parse_commit(item: &Value, repo: &Repo) -> Option<Event> {
|
|||||||
.and_then(|c| c.get("date"))
|
.and_then(|c| c.get("date"))
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
})?;
|
})?;
|
||||||
let occurred_at = DateTime::parse_from_rfc3339(date_str)
|
Some(
|
||||||
.ok()?
|
DateTime::parse_from_rfc3339(date_str)
|
||||||
.with_timezone(&Utc);
|
.ok()?
|
||||||
|
.with_timezone(&Utc),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_commit(item: &Value, repo: &Repo) -> Option<Event> {
|
||||||
|
let sha = item.get("sha").and_then(Value::as_str)?;
|
||||||
|
let occurred_at = parse_commit_date(item)?;
|
||||||
|
|
||||||
let mut payload = item.clone();
|
let mut payload = item.clone();
|
||||||
if let Some(obj) = payload.as_object_mut() {
|
if let Some(obj) = payload.as_object_mut() {
|
||||||
|
|||||||
@@ -113,8 +113,11 @@ impl GithubSearchSource {
|
|||||||
) -> Result<usize, SourceError> {
|
) -> Result<usize, SourceError> {
|
||||||
let mut total = 0usize;
|
let mut total = 0usize;
|
||||||
for page in 1..=self.config.max_pages {
|
for page in 1..=self.config.max_pages {
|
||||||
|
// `fork:true` opts forks into the search — by default GitHub's
|
||||||
|
// search API excludes them entirely, which means commits on a
|
||||||
|
// user's fork (regardless of branch) never surface here.
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"https://api.github.com/search/commits?q=author:{}&sort=author-date&order=desc&per_page={}&page={}",
|
"https://api.github.com/search/commits?q=author:{}+fork:true&sort=author-date&order=desc&per_page={}&page={}",
|
||||||
self.config.user, self.config.per_page, page
|
self.config.user, self.config.per_page, page
|
||||||
);
|
);
|
||||||
let req = self.apply_headers(self.client.get(&url));
|
let req = self.apply_headers(self.client.get(&url));
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ 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 chrono::NaiveDate;
|
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::Row;
|
||||||
use sqlx::postgres::{PgPool, PgPoolOptions};
|
use sqlx::postgres::{PgPool, PgPoolOptions};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
@@ -291,6 +291,55 @@ impl EventReader for PgStore {
|
|||||||
.collect()
|
.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> {
|
async fn repo_languages(&self) -> Result<Vec<RepoLanguage>, StoreError> {
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
|
|||||||
@@ -91,6 +91,14 @@ pub struct DailyCount {
|
|||||||
pub count: i64,
|
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.
|
/// Per-repo activity rollup for the dashboard.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ProjectSummary {
|
pub struct ProjectSummary {
|
||||||
|
|||||||
@@ -48,6 +48,31 @@ ssh_run() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Ensure /tmp on the remote is world-writable + sticky (mode 1777). Some
|
||||||
|
# hosts in this fleet have had /tmp reset to root-owned 0755 by an
|
||||||
|
# unrelated configuration step, which silently breaks the rsync of the
|
||||||
|
# deploy stage dir under our unprivileged user. Check the mode first so a
|
||||||
|
# correctly-configured host doesn't incur a needless sudo call.
|
||||||
|
ensure_tmp_writable() {
|
||||||
|
local host="$1"
|
||||||
|
if (( dry_run )); then
|
||||||
|
printf '\033[2m[dry-run]\033[0m ssh %s -- stat /tmp; chmod 1777 if needed\n' "$host" >&2
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
local mode
|
||||||
|
mode="$(ssh -o BatchMode=yes "$host" 'stat -c %a /tmp')" || {
|
||||||
|
warn "could not stat /tmp on $host"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if [[ "$mode" != "1777" ]]; then
|
||||||
|
warn "/tmp on $host is mode $mode; fixing to 1777"
|
||||||
|
ssh -o BatchMode=yes "$host" 'sudo chmod 1777 /tmp' || {
|
||||||
|
warn "failed to chmod /tmp on $host"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
[[ $# -ge 1 ]] || usage
|
[[ $# -ge 1 ]] || usage
|
||||||
environment="$1"; shift
|
environment="$1"; shift
|
||||||
components=()
|
components=()
|
||||||
@@ -60,10 +85,24 @@ while [[ $# -gt 0 ]]; do
|
|||||||
done
|
done
|
||||||
|
|
||||||
[[ -f "$manifest" ]] || die "manifest not found: $manifest"
|
[[ -f "$manifest" ]] || die "manifest not found: $manifest"
|
||||||
command -v yq >/dev/null 2>&1 || die "yq is required"
|
command -v yq >/dev/null 2>&1 || die "yq is required"
|
||||||
command -v pass >/dev/null 2>&1 || die "pass is required"
|
command -v pass >/dev/null 2>&1 || die "pass is required"
|
||||||
command -v rsync >/dev/null 2>&1 || die "rsync is required"
|
command -v rsync >/dev/null 2>&1 || die "rsync is required"
|
||||||
command -v cargo >/dev/null 2>&1 || die "cargo is required"
|
command -v cargo >/dev/null 2>&1 || die "cargo is required"
|
||||||
|
command -v podman >/dev/null 2>&1 || die "podman is required (used for the deploy build container)"
|
||||||
|
|
||||||
|
# Rust binaries are built inside a Debian container so the resulting ELF
|
||||||
|
# links against an older glibc than this workstation's. Building natively
|
||||||
|
# on f44 (glibc 2.43) produces binaries that won't load on f42 / f43
|
||||||
|
# servers — the dynamic loader refuses them outright. Debian bookworm's
|
||||||
|
# glibc 2.36 is older than every Fedora release we deploy to, so its
|
||||||
|
# binaries are forward-compatible.
|
||||||
|
#
|
||||||
|
# The artifacts land in target/deploy/release/ so a native `cargo build`
|
||||||
|
# in this checkout (for tests, clippy, dev runs) doesn't compete with
|
||||||
|
# the container for incremental state, and vice-versa.
|
||||||
|
rust_build_image="docker.io/library/rust:1-bookworm"
|
||||||
|
rust_target_dir="${repo_root}/target/deploy"
|
||||||
|
|
||||||
# Resolve component list ----------------------------------------------------
|
# Resolve component list ----------------------------------------------------
|
||||||
|
|
||||||
@@ -93,8 +132,20 @@ for c in "${components[@]}"; do
|
|||||||
done
|
done
|
||||||
|
|
||||||
if (( needs_rust )); then
|
if (( needs_rust )); then
|
||||||
log "cargo build --release (api, worker)"
|
log "cargo build --release in ${rust_build_image} (api, worker)"
|
||||||
run cargo build --release --bin moments-api --bin moments-worker --manifest-path "${repo_root}/Cargo.toml"
|
install --directory "$rust_target_dir"
|
||||||
|
# Named volumes cache the cargo registry and git index across runs so
|
||||||
|
# subsequent builds don't re-fetch every crate. CARGO_TARGET_DIR
|
||||||
|
# redirects build output into the host-mounted target/deploy.
|
||||||
|
# :Z relabels the bind mount for SELinux on Fedora hosts.
|
||||||
|
run podman run --rm \
|
||||||
|
--volume "${repo_root}:/workspace:Z" \
|
||||||
|
--volume moments-deploy-cargo-registry:/usr/local/cargo/registry \
|
||||||
|
--volume moments-deploy-cargo-git:/usr/local/cargo/git \
|
||||||
|
--workdir /workspace \
|
||||||
|
--env CARGO_TARGET_DIR=/workspace/target/deploy \
|
||||||
|
"$rust_build_image" \
|
||||||
|
cargo build --release --bin moments-api --bin moments-worker
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if (( needs_web )); then
|
if (( needs_web )); then
|
||||||
@@ -156,7 +207,7 @@ deploy_api() {
|
|||||||
install --mode=0644 "${repo_root}/asset/systemd/moments-api.service" "$stage/etc/systemd/system/"
|
install --mode=0644 "${repo_root}/asset/systemd/moments-api.service" "$stage/etc/systemd/system/"
|
||||||
install --mode=0644 "${repo_root}/asset/systemd/moments-api-cert-reload.service" "$stage/etc/systemd/system/"
|
install --mode=0644 "${repo_root}/asset/systemd/moments-api-cert-reload.service" "$stage/etc/systemd/system/"
|
||||||
install --mode=0644 "${repo_root}/asset/systemd/moments.sysusers.conf" "$stage/etc/sysusers.d/moments.conf"
|
install --mode=0644 "${repo_root}/asset/systemd/moments.sysusers.conf" "$stage/etc/sysusers.d/moments.conf"
|
||||||
install --mode=0755 "${repo_root}/target/release/moments-api" "$stage/usr/local/bin/moments-api"
|
install --mode=0755 "${rust_target_dir}/release/moments-api" "$stage/usr/local/bin/moments-api"
|
||||||
|
|
||||||
chmod 0640 "$stage/etc/moments/api.env"
|
chmod 0640 "$stage/etc/moments/api.env"
|
||||||
|
|
||||||
@@ -166,6 +217,8 @@ deploy_api() {
|
|||||||
# live system dirs.
|
# live system dirs.
|
||||||
local remote_stage="/tmp/moments-deploy.api.${$}.${RANDOM}"
|
local remote_stage="/tmp/moments-deploy.api.${$}.${RANDOM}"
|
||||||
|
|
||||||
|
ensure_tmp_writable "$host" || return 1
|
||||||
|
|
||||||
rsync \
|
rsync \
|
||||||
--archive \
|
--archive \
|
||||||
--hard-links \
|
--hard-links \
|
||||||
@@ -310,7 +363,7 @@ deploy_worker() {
|
|||||||
install --mode=0644 "${repo_root}/asset/systemd/moments-worker.service" "$stage/etc/systemd/system/"
|
install --mode=0644 "${repo_root}/asset/systemd/moments-worker.service" "$stage/etc/systemd/system/"
|
||||||
install --mode=0644 "${repo_root}/asset/systemd/moments-worker-cert-reload.service" "$stage/etc/systemd/system/"
|
install --mode=0644 "${repo_root}/asset/systemd/moments-worker-cert-reload.service" "$stage/etc/systemd/system/"
|
||||||
install --mode=0644 "${repo_root}/asset/systemd/moments.sysusers.conf" "$stage/etc/sysusers.d/moments.conf"
|
install --mode=0644 "${repo_root}/asset/systemd/moments.sysusers.conf" "$stage/etc/sysusers.d/moments.conf"
|
||||||
install --mode=0755 "${repo_root}/target/release/moments-worker" "$stage/usr/local/bin/moments-worker"
|
install --mode=0755 "${rust_target_dir}/release/moments-worker" "$stage/usr/local/bin/moments-worker"
|
||||||
|
|
||||||
chmod 0640 "$stage/etc/moments/worker.env"
|
chmod 0640 "$stage/etc/moments/worker.env"
|
||||||
|
|
||||||
@@ -318,6 +371,8 @@ deploy_worker() {
|
|||||||
# path via the heredoc. Never rsync into /.
|
# path via the heredoc. Never rsync into /.
|
||||||
local remote_stage="/tmp/moments-deploy.worker.${$}.${RANDOM}"
|
local remote_stage="/tmp/moments-deploy.worker.${$}.${RANDOM}"
|
||||||
|
|
||||||
|
ensure_tmp_writable "$host" || return 1
|
||||||
|
|
||||||
rsync \
|
rsync \
|
||||||
--archive \
|
--archive \
|
||||||
--hard-links \
|
--hard-links \
|
||||||
|
|||||||
@@ -1,25 +1,74 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>rob.tn</title>
|
<title>rob.tn</title>
|
||||||
<meta property="og:title" content="rob thijssen" />
|
<meta
|
||||||
<meta property="og:description" content="contribution history across github, gitea, and mozilla hg" />
|
name="description"
|
||||||
<meta property="og:image" content="https://rob.tn/api/v1/og/contributions.png" />
|
content="a timeline of open source contributions across github, gitea, and mozilla hg. ranked projects, language trends, and commit activity since 2012."
|
||||||
<meta property="og:type" content="website" />
|
/>
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
<meta
|
||||||
<meta name="twitter:image" content="https://rob.tn/api/v1/og/contributions.png" />
|
property="og:title"
|
||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
content="rob thijssen: developer activity and contribution history"
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png" />
|
/>
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
|
<meta
|
||||||
<link rel="icon" type="image/png" sizes="48x48" href="/favicon-48.png" />
|
property="og:description"
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
content="a timeline of open source contributions across github, gitea, and mozilla hg. ranked projects, language trends, and commit activity since 2012."
|
||||||
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png" />
|
/>
|
||||||
<link rel="icon" type="image/png" sizes="512x512" href="/icon-512.png" />
|
<meta
|
||||||
</head>
|
property="og:image"
|
||||||
<body>
|
content="https://rob.tn/api/v1/og/contributions.png"
|
||||||
<div id="root"></div>
|
width="1200"
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
height="630"
|
||||||
</body>
|
/>
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:url" content="https://rob.tn/" />
|
||||||
|
<meta property="og:site_name" content="rob.tn" />
|
||||||
|
<meta property="og:locale" content="en_US" />
|
||||||
|
<meta property="og:logo" content="https://rob.tn/icon-512.png" />
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta
|
||||||
|
name="twitter:image"
|
||||||
|
content="https://rob.tn/api/v1/og/contributions.png"
|
||||||
|
width="1200"
|
||||||
|
height="630"
|
||||||
|
/>
|
||||||
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="16x16"
|
||||||
|
href="/favicon-16.png"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="32x32"
|
||||||
|
href="/favicon-32.png"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="48x48"
|
||||||
|
href="/favicon-48.png"
|
||||||
|
/>
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="192x192"
|
||||||
|
href="/icon-192.png"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="512x512"
|
||||||
|
href="/icon-512.png"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -19,7 +19,10 @@
|
|||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-markdown": "^9.0.1",
|
"react-markdown": "^9.0.1",
|
||||||
"react-router-dom": "^7.14.2",
|
"react-router-dom": "^7.14.2",
|
||||||
"react-vertical-timeline-component": "^3.6.0"
|
"react-vertical-timeline-component": "^3.6.0",
|
||||||
|
"rehype-raw": "^7.0.0",
|
||||||
|
"rehype-sanitize": "^6.0.0",
|
||||||
|
"remark-gfm": "^4.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
|
|||||||
328
ui/pnpm-lock.yaml
generated
328
ui/pnpm-lock.yaml
generated
@@ -38,6 +38,15 @@ importers:
|
|||||||
react-vertical-timeline-component:
|
react-vertical-timeline-component:
|
||||||
specifier: ^3.6.0
|
specifier: ^3.6.0
|
||||||
version: 3.6.0(react@19.2.5)
|
version: 3.6.0(react@19.2.5)
|
||||||
|
rehype-raw:
|
||||||
|
specifier: ^7.0.0
|
||||||
|
version: 7.0.0
|
||||||
|
rehype-sanitize:
|
||||||
|
specifier: ^6.0.0
|
||||||
|
version: 6.0.0
|
||||||
|
remark-gfm:
|
||||||
|
specifier: ^4.0.1
|
||||||
|
version: 4.0.1
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: ^19.0.0
|
specifier: ^19.0.0
|
||||||
@@ -625,11 +634,19 @@ packages:
|
|||||||
dom-helpers@5.2.1:
|
dom-helpers@5.2.1:
|
||||||
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
|
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
|
||||||
|
|
||||||
|
entities@6.0.1:
|
||||||
|
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
||||||
|
engines: {node: '>=0.12'}
|
||||||
|
|
||||||
esbuild@0.25.12:
|
esbuild@0.25.12:
|
||||||
resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
|
resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
escape-string-regexp@5.0.0:
|
||||||
|
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
estree-util-is-identifier-name@3.0.0:
|
estree-util-is-identifier-name@3.0.0:
|
||||||
resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==}
|
resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==}
|
||||||
|
|
||||||
@@ -650,15 +667,36 @@ packages:
|
|||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
|
hast-util-from-parse5@8.0.3:
|
||||||
|
resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==}
|
||||||
|
|
||||||
|
hast-util-parse-selector@4.0.0:
|
||||||
|
resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==}
|
||||||
|
|
||||||
|
hast-util-raw@9.1.0:
|
||||||
|
resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==}
|
||||||
|
|
||||||
|
hast-util-sanitize@5.0.2:
|
||||||
|
resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==}
|
||||||
|
|
||||||
hast-util-to-jsx-runtime@2.3.6:
|
hast-util-to-jsx-runtime@2.3.6:
|
||||||
resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
|
resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
|
||||||
|
|
||||||
|
hast-util-to-parse5@8.0.1:
|
||||||
|
resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==}
|
||||||
|
|
||||||
hast-util-whitespace@3.0.0:
|
hast-util-whitespace@3.0.0:
|
||||||
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
|
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
|
||||||
|
|
||||||
|
hastscript@9.0.1:
|
||||||
|
resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==}
|
||||||
|
|
||||||
html-url-attributes@3.0.1:
|
html-url-attributes@3.0.1:
|
||||||
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
|
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
|
||||||
|
|
||||||
|
html-void-elements@3.0.0:
|
||||||
|
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
|
||||||
|
|
||||||
inline-style-parser@0.2.7:
|
inline-style-parser@0.2.7:
|
||||||
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
|
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
|
||||||
|
|
||||||
@@ -691,9 +729,33 @@ packages:
|
|||||||
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
markdown-table@3.0.4:
|
||||||
|
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
|
||||||
|
|
||||||
|
mdast-util-find-and-replace@3.0.2:
|
||||||
|
resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==}
|
||||||
|
|
||||||
mdast-util-from-markdown@2.0.3:
|
mdast-util-from-markdown@2.0.3:
|
||||||
resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==}
|
resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==}
|
||||||
|
|
||||||
|
mdast-util-gfm-autolink-literal@2.0.1:
|
||||||
|
resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==}
|
||||||
|
|
||||||
|
mdast-util-gfm-footnote@2.1.0:
|
||||||
|
resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==}
|
||||||
|
|
||||||
|
mdast-util-gfm-strikethrough@2.0.0:
|
||||||
|
resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==}
|
||||||
|
|
||||||
|
mdast-util-gfm-table@2.0.0:
|
||||||
|
resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==}
|
||||||
|
|
||||||
|
mdast-util-gfm-task-list-item@2.0.0:
|
||||||
|
resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==}
|
||||||
|
|
||||||
|
mdast-util-gfm@3.1.0:
|
||||||
|
resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==}
|
||||||
|
|
||||||
mdast-util-mdx-expression@2.0.1:
|
mdast-util-mdx-expression@2.0.1:
|
||||||
resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==}
|
resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==}
|
||||||
|
|
||||||
@@ -718,6 +780,27 @@ packages:
|
|||||||
micromark-core-commonmark@2.0.3:
|
micromark-core-commonmark@2.0.3:
|
||||||
resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
|
resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
|
||||||
|
|
||||||
|
micromark-extension-gfm-autolink-literal@2.1.0:
|
||||||
|
resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==}
|
||||||
|
|
||||||
|
micromark-extension-gfm-footnote@2.1.0:
|
||||||
|
resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==}
|
||||||
|
|
||||||
|
micromark-extension-gfm-strikethrough@2.1.0:
|
||||||
|
resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==}
|
||||||
|
|
||||||
|
micromark-extension-gfm-table@2.1.1:
|
||||||
|
resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==}
|
||||||
|
|
||||||
|
micromark-extension-gfm-tagfilter@2.0.0:
|
||||||
|
resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==}
|
||||||
|
|
||||||
|
micromark-extension-gfm-task-list-item@2.1.0:
|
||||||
|
resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==}
|
||||||
|
|
||||||
|
micromark-extension-gfm@3.0.0:
|
||||||
|
resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==}
|
||||||
|
|
||||||
micromark-factory-destination@2.0.1:
|
micromark-factory-destination@2.0.1:
|
||||||
resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==}
|
resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==}
|
||||||
|
|
||||||
@@ -793,6 +876,9 @@ packages:
|
|||||||
parse-entities@4.0.2:
|
parse-entities@4.0.2:
|
||||||
resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
|
resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
|
||||||
|
|
||||||
|
parse5@7.3.0:
|
||||||
|
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
|
||||||
|
|
||||||
picocolors@1.1.1:
|
picocolors@1.1.1:
|
||||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||||
|
|
||||||
@@ -909,12 +995,24 @@ packages:
|
|||||||
resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==}
|
resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
rehype-raw@7.0.0:
|
||||||
|
resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==}
|
||||||
|
|
||||||
|
rehype-sanitize@6.0.0:
|
||||||
|
resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==}
|
||||||
|
|
||||||
|
remark-gfm@4.0.1:
|
||||||
|
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
|
||||||
|
|
||||||
remark-parse@11.0.0:
|
remark-parse@11.0.0:
|
||||||
resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==}
|
resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==}
|
||||||
|
|
||||||
remark-rehype@11.1.2:
|
remark-rehype@11.1.2:
|
||||||
resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==}
|
resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==}
|
||||||
|
|
||||||
|
remark-stringify@11.0.0:
|
||||||
|
resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
|
||||||
|
|
||||||
rollup@4.60.2:
|
rollup@4.60.2:
|
||||||
resolution: {integrity: sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==}
|
resolution: {integrity: sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==}
|
||||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||||
@@ -993,6 +1091,9 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
|
vfile-location@5.0.3:
|
||||||
|
resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==}
|
||||||
|
|
||||||
vfile-message@4.0.3:
|
vfile-message@4.0.3:
|
||||||
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
|
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
|
||||||
|
|
||||||
@@ -1042,6 +1143,9 @@ packages:
|
|||||||
warning@4.0.3:
|
warning@4.0.3:
|
||||||
resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==}
|
resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==}
|
||||||
|
|
||||||
|
web-namespaces@2.0.1:
|
||||||
|
resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
|
||||||
|
|
||||||
zwitch@2.0.4:
|
zwitch@2.0.4:
|
||||||
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
|
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
|
||||||
|
|
||||||
@@ -1428,6 +1532,8 @@ snapshots:
|
|||||||
'@babel/runtime': 7.29.2
|
'@babel/runtime': 7.29.2
|
||||||
csstype: 3.2.3
|
csstype: 3.2.3
|
||||||
|
|
||||||
|
entities@6.0.1: {}
|
||||||
|
|
||||||
esbuild@0.25.12:
|
esbuild@0.25.12:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@esbuild/aix-ppc64': 0.25.12
|
'@esbuild/aix-ppc64': 0.25.12
|
||||||
@@ -1457,6 +1563,8 @@ snapshots:
|
|||||||
'@esbuild/win32-ia32': 0.25.12
|
'@esbuild/win32-ia32': 0.25.12
|
||||||
'@esbuild/win32-x64': 0.25.12
|
'@esbuild/win32-x64': 0.25.12
|
||||||
|
|
||||||
|
escape-string-regexp@5.0.0: {}
|
||||||
|
|
||||||
estree-util-is-identifier-name@3.0.0: {}
|
estree-util-is-identifier-name@3.0.0: {}
|
||||||
|
|
||||||
extend@3.0.2: {}
|
extend@3.0.2: {}
|
||||||
@@ -1468,6 +1576,43 @@ snapshots:
|
|||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
hast-util-from-parse5@8.0.3:
|
||||||
|
dependencies:
|
||||||
|
'@types/hast': 3.0.4
|
||||||
|
'@types/unist': 3.0.3
|
||||||
|
devlop: 1.1.0
|
||||||
|
hastscript: 9.0.1
|
||||||
|
property-information: 7.1.0
|
||||||
|
vfile: 6.0.3
|
||||||
|
vfile-location: 5.0.3
|
||||||
|
web-namespaces: 2.0.1
|
||||||
|
|
||||||
|
hast-util-parse-selector@4.0.0:
|
||||||
|
dependencies:
|
||||||
|
'@types/hast': 3.0.4
|
||||||
|
|
||||||
|
hast-util-raw@9.1.0:
|
||||||
|
dependencies:
|
||||||
|
'@types/hast': 3.0.4
|
||||||
|
'@types/unist': 3.0.3
|
||||||
|
'@ungap/structured-clone': 1.3.0
|
||||||
|
hast-util-from-parse5: 8.0.3
|
||||||
|
hast-util-to-parse5: 8.0.1
|
||||||
|
html-void-elements: 3.0.0
|
||||||
|
mdast-util-to-hast: 13.2.1
|
||||||
|
parse5: 7.3.0
|
||||||
|
unist-util-position: 5.0.0
|
||||||
|
unist-util-visit: 5.1.0
|
||||||
|
vfile: 6.0.3
|
||||||
|
web-namespaces: 2.0.1
|
||||||
|
zwitch: 2.0.4
|
||||||
|
|
||||||
|
hast-util-sanitize@5.0.2:
|
||||||
|
dependencies:
|
||||||
|
'@types/hast': 3.0.4
|
||||||
|
'@ungap/structured-clone': 1.3.0
|
||||||
|
unist-util-position: 5.0.0
|
||||||
|
|
||||||
hast-util-to-jsx-runtime@2.3.6:
|
hast-util-to-jsx-runtime@2.3.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree': 1.0.8
|
'@types/estree': 1.0.8
|
||||||
@@ -1488,12 +1633,32 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
hast-util-to-parse5@8.0.1:
|
||||||
|
dependencies:
|
||||||
|
'@types/hast': 3.0.4
|
||||||
|
comma-separated-tokens: 2.0.3
|
||||||
|
devlop: 1.1.0
|
||||||
|
property-information: 7.1.0
|
||||||
|
space-separated-tokens: 2.0.2
|
||||||
|
web-namespaces: 2.0.1
|
||||||
|
zwitch: 2.0.4
|
||||||
|
|
||||||
hast-util-whitespace@3.0.0:
|
hast-util-whitespace@3.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/hast': 3.0.4
|
'@types/hast': 3.0.4
|
||||||
|
|
||||||
|
hastscript@9.0.1:
|
||||||
|
dependencies:
|
||||||
|
'@types/hast': 3.0.4
|
||||||
|
comma-separated-tokens: 2.0.3
|
||||||
|
hast-util-parse-selector: 4.0.0
|
||||||
|
property-information: 7.1.0
|
||||||
|
space-separated-tokens: 2.0.2
|
||||||
|
|
||||||
html-url-attributes@3.0.1: {}
|
html-url-attributes@3.0.1: {}
|
||||||
|
|
||||||
|
html-void-elements@3.0.0: {}
|
||||||
|
|
||||||
inline-style-parser@0.2.7: {}
|
inline-style-parser@0.2.7: {}
|
||||||
|
|
||||||
invariant@2.2.4:
|
invariant@2.2.4:
|
||||||
@@ -1521,6 +1686,15 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
js-tokens: 4.0.0
|
js-tokens: 4.0.0
|
||||||
|
|
||||||
|
markdown-table@3.0.4: {}
|
||||||
|
|
||||||
|
mdast-util-find-and-replace@3.0.2:
|
||||||
|
dependencies:
|
||||||
|
'@types/mdast': 4.0.4
|
||||||
|
escape-string-regexp: 5.0.0
|
||||||
|
unist-util-is: 6.0.1
|
||||||
|
unist-util-visit-parents: 6.0.2
|
||||||
|
|
||||||
mdast-util-from-markdown@2.0.3:
|
mdast-util-from-markdown@2.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/mdast': 4.0.4
|
'@types/mdast': 4.0.4
|
||||||
@@ -1538,6 +1712,63 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
mdast-util-gfm-autolink-literal@2.0.1:
|
||||||
|
dependencies:
|
||||||
|
'@types/mdast': 4.0.4
|
||||||
|
ccount: 2.0.1
|
||||||
|
devlop: 1.1.0
|
||||||
|
mdast-util-find-and-replace: 3.0.2
|
||||||
|
micromark-util-character: 2.1.1
|
||||||
|
|
||||||
|
mdast-util-gfm-footnote@2.1.0:
|
||||||
|
dependencies:
|
||||||
|
'@types/mdast': 4.0.4
|
||||||
|
devlop: 1.1.0
|
||||||
|
mdast-util-from-markdown: 2.0.3
|
||||||
|
mdast-util-to-markdown: 2.1.2
|
||||||
|
micromark-util-normalize-identifier: 2.0.1
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
|
mdast-util-gfm-strikethrough@2.0.0:
|
||||||
|
dependencies:
|
||||||
|
'@types/mdast': 4.0.4
|
||||||
|
mdast-util-from-markdown: 2.0.3
|
||||||
|
mdast-util-to-markdown: 2.1.2
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
|
mdast-util-gfm-table@2.0.0:
|
||||||
|
dependencies:
|
||||||
|
'@types/mdast': 4.0.4
|
||||||
|
devlop: 1.1.0
|
||||||
|
markdown-table: 3.0.4
|
||||||
|
mdast-util-from-markdown: 2.0.3
|
||||||
|
mdast-util-to-markdown: 2.1.2
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
|
mdast-util-gfm-task-list-item@2.0.0:
|
||||||
|
dependencies:
|
||||||
|
'@types/mdast': 4.0.4
|
||||||
|
devlop: 1.1.0
|
||||||
|
mdast-util-from-markdown: 2.0.3
|
||||||
|
mdast-util-to-markdown: 2.1.2
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
|
mdast-util-gfm@3.1.0:
|
||||||
|
dependencies:
|
||||||
|
mdast-util-from-markdown: 2.0.3
|
||||||
|
mdast-util-gfm-autolink-literal: 2.0.1
|
||||||
|
mdast-util-gfm-footnote: 2.1.0
|
||||||
|
mdast-util-gfm-strikethrough: 2.0.0
|
||||||
|
mdast-util-gfm-table: 2.0.0
|
||||||
|
mdast-util-gfm-task-list-item: 2.0.0
|
||||||
|
mdast-util-to-markdown: 2.1.2
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
mdast-util-mdx-expression@2.0.1:
|
mdast-util-mdx-expression@2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree-jsx': 1.0.5
|
'@types/estree-jsx': 1.0.5
|
||||||
@@ -1629,6 +1860,64 @@ snapshots:
|
|||||||
micromark-util-symbol: 2.0.1
|
micromark-util-symbol: 2.0.1
|
||||||
micromark-util-types: 2.0.2
|
micromark-util-types: 2.0.2
|
||||||
|
|
||||||
|
micromark-extension-gfm-autolink-literal@2.1.0:
|
||||||
|
dependencies:
|
||||||
|
micromark-util-character: 2.1.1
|
||||||
|
micromark-util-sanitize-uri: 2.0.1
|
||||||
|
micromark-util-symbol: 2.0.1
|
||||||
|
micromark-util-types: 2.0.2
|
||||||
|
|
||||||
|
micromark-extension-gfm-footnote@2.1.0:
|
||||||
|
dependencies:
|
||||||
|
devlop: 1.1.0
|
||||||
|
micromark-core-commonmark: 2.0.3
|
||||||
|
micromark-factory-space: 2.0.1
|
||||||
|
micromark-util-character: 2.1.1
|
||||||
|
micromark-util-normalize-identifier: 2.0.1
|
||||||
|
micromark-util-sanitize-uri: 2.0.1
|
||||||
|
micromark-util-symbol: 2.0.1
|
||||||
|
micromark-util-types: 2.0.2
|
||||||
|
|
||||||
|
micromark-extension-gfm-strikethrough@2.1.0:
|
||||||
|
dependencies:
|
||||||
|
devlop: 1.1.0
|
||||||
|
micromark-util-chunked: 2.0.1
|
||||||
|
micromark-util-classify-character: 2.0.1
|
||||||
|
micromark-util-resolve-all: 2.0.1
|
||||||
|
micromark-util-symbol: 2.0.1
|
||||||
|
micromark-util-types: 2.0.2
|
||||||
|
|
||||||
|
micromark-extension-gfm-table@2.1.1:
|
||||||
|
dependencies:
|
||||||
|
devlop: 1.1.0
|
||||||
|
micromark-factory-space: 2.0.1
|
||||||
|
micromark-util-character: 2.1.1
|
||||||
|
micromark-util-symbol: 2.0.1
|
||||||
|
micromark-util-types: 2.0.2
|
||||||
|
|
||||||
|
micromark-extension-gfm-tagfilter@2.0.0:
|
||||||
|
dependencies:
|
||||||
|
micromark-util-types: 2.0.2
|
||||||
|
|
||||||
|
micromark-extension-gfm-task-list-item@2.1.0:
|
||||||
|
dependencies:
|
||||||
|
devlop: 1.1.0
|
||||||
|
micromark-factory-space: 2.0.1
|
||||||
|
micromark-util-character: 2.1.1
|
||||||
|
micromark-util-symbol: 2.0.1
|
||||||
|
micromark-util-types: 2.0.2
|
||||||
|
|
||||||
|
micromark-extension-gfm@3.0.0:
|
||||||
|
dependencies:
|
||||||
|
micromark-extension-gfm-autolink-literal: 2.1.0
|
||||||
|
micromark-extension-gfm-footnote: 2.1.0
|
||||||
|
micromark-extension-gfm-strikethrough: 2.1.0
|
||||||
|
micromark-extension-gfm-table: 2.1.1
|
||||||
|
micromark-extension-gfm-tagfilter: 2.0.0
|
||||||
|
micromark-extension-gfm-task-list-item: 2.1.0
|
||||||
|
micromark-util-combine-extensions: 2.0.1
|
||||||
|
micromark-util-types: 2.0.2
|
||||||
|
|
||||||
micromark-factory-destination@2.0.1:
|
micromark-factory-destination@2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
micromark-util-character: 2.1.1
|
micromark-util-character: 2.1.1
|
||||||
@@ -1759,6 +2048,10 @@ snapshots:
|
|||||||
is-decimal: 2.0.1
|
is-decimal: 2.0.1
|
||||||
is-hexadecimal: 2.0.1
|
is-hexadecimal: 2.0.1
|
||||||
|
|
||||||
|
parse5@7.3.0:
|
||||||
|
dependencies:
|
||||||
|
entities: 6.0.1
|
||||||
|
|
||||||
picocolors@1.1.1: {}
|
picocolors@1.1.1: {}
|
||||||
|
|
||||||
picomatch@4.0.4: {}
|
picomatch@4.0.4: {}
|
||||||
@@ -1913,6 +2206,28 @@ snapshots:
|
|||||||
|
|
||||||
react@19.2.5: {}
|
react@19.2.5: {}
|
||||||
|
|
||||||
|
rehype-raw@7.0.0:
|
||||||
|
dependencies:
|
||||||
|
'@types/hast': 3.0.4
|
||||||
|
hast-util-raw: 9.1.0
|
||||||
|
vfile: 6.0.3
|
||||||
|
|
||||||
|
rehype-sanitize@6.0.0:
|
||||||
|
dependencies:
|
||||||
|
'@types/hast': 3.0.4
|
||||||
|
hast-util-sanitize: 5.0.2
|
||||||
|
|
||||||
|
remark-gfm@4.0.1:
|
||||||
|
dependencies:
|
||||||
|
'@types/mdast': 4.0.4
|
||||||
|
mdast-util-gfm: 3.1.0
|
||||||
|
micromark-extension-gfm: 3.0.0
|
||||||
|
remark-parse: 11.0.0
|
||||||
|
remark-stringify: 11.0.0
|
||||||
|
unified: 11.0.5
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
remark-parse@11.0.0:
|
remark-parse@11.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/mdast': 4.0.4
|
'@types/mdast': 4.0.4
|
||||||
@@ -1930,6 +2245,12 @@ snapshots:
|
|||||||
unified: 11.0.5
|
unified: 11.0.5
|
||||||
vfile: 6.0.3
|
vfile: 6.0.3
|
||||||
|
|
||||||
|
remark-stringify@11.0.0:
|
||||||
|
dependencies:
|
||||||
|
'@types/mdast': 4.0.4
|
||||||
|
mdast-util-to-markdown: 2.1.2
|
||||||
|
unified: 11.0.5
|
||||||
|
|
||||||
rollup@4.60.2:
|
rollup@4.60.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree': 1.0.8
|
'@types/estree': 1.0.8
|
||||||
@@ -2044,6 +2365,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.5
|
react: 19.2.5
|
||||||
|
|
||||||
|
vfile-location@5.0.3:
|
||||||
|
dependencies:
|
||||||
|
'@types/unist': 3.0.3
|
||||||
|
vfile: 6.0.3
|
||||||
|
|
||||||
vfile-message@4.0.3:
|
vfile-message@4.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/unist': 3.0.3
|
'@types/unist': 3.0.3
|
||||||
@@ -2069,4 +2395,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
loose-envify: 1.4.0
|
loose-envify: 1.4.0
|
||||||
|
|
||||||
|
web-namespaces@2.0.1: {}
|
||||||
|
|
||||||
zwitch@2.0.4: {}
|
zwitch@2.0.4: {}
|
||||||
|
|||||||
14
ui/public/robots.txt
Normal file
14
ui/public/robots.txt
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: facebookexternalhit
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: Twitterbot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: LinkedInBot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: WhatsApp
|
||||||
|
Allow: /
|
||||||
@@ -70,6 +70,11 @@ export interface DailyCount {
|
|||||||
count: number;
|
count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HourlyAvg {
|
||||||
|
hour: number;
|
||||||
|
avg: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface LanguageDailyCount {
|
export interface LanguageDailyCount {
|
||||||
date: string;
|
date: string;
|
||||||
language: string;
|
language: string;
|
||||||
@@ -120,6 +125,13 @@ export async function fetchDailyCounts(from: string, to: string): Promise<DailyC
|
|||||||
return resp.json();
|
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[]> {
|
export async function fetchLanguageDailyCounts(from: string, to: string): Promise<LanguageDailyCount[]> {
|
||||||
const resp = await fetch(`${API_BASE}/languages/daily?from=${from}&to=${to}`);
|
const resp = await fetch(`${API_BASE}/languages/daily?from=${from}&to=${to}`);
|
||||||
if (!resp.ok) throw new Error(`language-daily-counts: HTTP ${resp.status}`);
|
if (!resp.ok) throw new Error(`language-daily-counts: HTTP ${resp.status}`);
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from "react";
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { fetchDailyCounts, fetchLanguageDailyCounts, fetchProjects, fetchSources } from '../api/client';
|
import {
|
||||||
|
fetchDailyCounts,
|
||||||
|
fetchLanguageDailyCounts,
|
||||||
|
fetchProjects,
|
||||||
|
fetchSources,
|
||||||
|
} from "../api/client";
|
||||||
|
|
||||||
const CELL_SIZE = 12;
|
const CELL_SIZE = 12;
|
||||||
const GAP = 3;
|
const GAP = 3;
|
||||||
@@ -11,11 +16,24 @@ const ROWS = 7;
|
|||||||
const LEFT_LABEL_WIDTH = 28;
|
const LEFT_LABEL_WIDTH = 28;
|
||||||
const TOP_LABEL_HEIGHT = 16;
|
const TOP_LABEL_HEIGHT = 16;
|
||||||
|
|
||||||
const DAY_LABELS = ['', 'mon', '', 'wed', '', 'fri', ''];
|
const DAY_LABELS = ["", "mon", "", "wed", "", "fri", ""];
|
||||||
const MONTH_LABELS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
|
const MONTH_LABELS = [
|
||||||
|
"jan",
|
||||||
|
"feb",
|
||||||
|
"mar",
|
||||||
|
"apr",
|
||||||
|
"may",
|
||||||
|
"jun",
|
||||||
|
"jul",
|
||||||
|
"aug",
|
||||||
|
"sep",
|
||||||
|
"oct",
|
||||||
|
"nov",
|
||||||
|
"dec",
|
||||||
|
];
|
||||||
|
|
||||||
const EMPTY_COLOR = 'rgba(255,255,255,0.05)';
|
const EMPTY_COLOR = "rgba(255,255,255,0.05)";
|
||||||
const FALLBACK_COLOR = '#39d353';
|
const FALLBACK_COLOR = "#39d353";
|
||||||
|
|
||||||
/** Daily contribution graph — last 1 year, one circle per day. */
|
/** Daily contribution graph — last 1 year, one circle per day. */
|
||||||
export function ContributionGraph() {
|
export function ContributionGraph() {
|
||||||
@@ -27,19 +45,19 @@ export function ContributionGraph() {
|
|||||||
const toStr = fmt(to);
|
const toStr = fmt(to);
|
||||||
|
|
||||||
const dailyQ = useQuery({
|
const dailyQ = useQuery({
|
||||||
queryKey: ['daily-counts', fromStr, toStr],
|
queryKey: ["daily-counts", fromStr, toStr],
|
||||||
queryFn: () => fetchDailyCounts(fromStr, toStr),
|
queryFn: () => fetchDailyCounts(fromStr, toStr),
|
||||||
staleTime: 5 * 60_000,
|
staleTime: 5 * 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const langQ = useQuery({
|
const langQ = useQuery({
|
||||||
queryKey: ['language-daily', fromStr, toStr],
|
queryKey: ["language-daily", fromStr, toStr],
|
||||||
queryFn: () => fetchLanguageDailyCounts(fromStr, toStr),
|
queryFn: () => fetchLanguageDailyCounts(fromStr, toStr),
|
||||||
staleTime: 5 * 60_000,
|
staleTime: 5 * 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const projectsQ = useQuery({
|
const projectsQ = useQuery({
|
||||||
queryKey: ['projects'],
|
queryKey: ["projects"],
|
||||||
queryFn: fetchProjects,
|
queryFn: fetchProjects,
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
});
|
});
|
||||||
@@ -49,7 +67,9 @@ export function ContributionGraph() {
|
|||||||
const fromMs = from.getTime();
|
const fromMs = from.getTime();
|
||||||
const toMs = to.getTime();
|
const toMs = to.getTime();
|
||||||
return projectsQ.data.filter((p) => {
|
return projectsQ.data.filter((p) => {
|
||||||
const first = p.first_activity ? new Date(p.first_activity).getTime() : Infinity;
|
const first = p.first_activity
|
||||||
|
? new Date(p.first_activity).getTime()
|
||||||
|
: Infinity;
|
||||||
const last = p.last_activity ? new Date(p.last_activity).getTime() : 0;
|
const last = p.last_activity ? new Date(p.last_activity).getTime() : 0;
|
||||||
return last >= fromMs && first <= toMs;
|
return last >= fromMs && first <= toMs;
|
||||||
}).length;
|
}).length;
|
||||||
@@ -69,14 +89,15 @@ export function ContributionGraph() {
|
|||||||
const start = new Date(from);
|
const start = new Date(from);
|
||||||
start.setDate(start.getDate() - start.getDay());
|
start.setDate(start.getDate() - start.getDay());
|
||||||
|
|
||||||
const weeks: { date: string; count: number; col: number; row: number }[][] = [];
|
const weeks: { date: string; count: number; col: number; row: number }[][] =
|
||||||
|
[];
|
||||||
const monthMarkers: { col: number; label: string }[] = [];
|
const monthMarkers: { col: number; label: string }[] = [];
|
||||||
let col = 0;
|
let col = 0;
|
||||||
let prevMonth = -1;
|
let prevMonth = -1;
|
||||||
const cursor = new Date(start);
|
const cursor = new Date(start);
|
||||||
|
|
||||||
while (cursor <= to) {
|
while (cursor <= to) {
|
||||||
const week: typeof weeks[0] = [];
|
const week: (typeof weeks)[0] = [];
|
||||||
for (let row = 0; row < ROWS; row++) {
|
for (let row = 0; row < ROWS; row++) {
|
||||||
const dateStr = fmt(cursor);
|
const dateStr = fmt(cursor);
|
||||||
const count = countMap.get(dateStr) ?? 0;
|
const count = countMap.get(dateStr) ?? 0;
|
||||||
@@ -94,7 +115,10 @@ export function ContributionGraph() {
|
|||||||
col++;
|
col++;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nonZero = counts.map((d) => d.count).filter((c) => c > 0).sort((a, b) => a - b);
|
const nonZero = counts
|
||||||
|
.map((d) => d.count)
|
||||||
|
.filter((c) => c > 0)
|
||||||
|
.sort((a, b) => a - b);
|
||||||
const thresholds = computeThresholds(nonZero);
|
const thresholds = computeThresholds(nonZero);
|
||||||
const totalCount = counts.reduce((sum, d) => sum + d.count, 0);
|
const totalCount = counts.reduce((sum, d) => sum + d.count, 0);
|
||||||
|
|
||||||
@@ -105,17 +129,23 @@ export function ContributionGraph() {
|
|||||||
const svgWidth = LEFT_LABEL_WIDTH + cols * (CELL_SIZE + GAP);
|
const svgWidth = LEFT_LABEL_WIDTH + cols * (CELL_SIZE + GAP);
|
||||||
const svgHeight = TOP_LABEL_HEIGHT + ROWS * (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.isLoading)
|
||||||
|
return <p style={{ fontSize: "0.8rem" }}>loading contribution graph...</p>;
|
||||||
if (dailyQ.isError) return null;
|
if (dailyQ.isError) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="contribution-graph mb-3">
|
<div className="contribution-graph mb-3">
|
||||||
<p style={{ fontSize: '0.8rem', opacity: 0.6 }}>
|
<p style={{ fontSize: "0.8rem", opacity: 0.6 }}>
|
||||||
{totalCount} contributions in the last year
|
{new Intl.NumberFormat().format(totalCount)} contributions
|
||||||
{repoCount > 0 && ` in ${repoCount} repositories`}
|
{repoCount > 0 && `, across ${repoCount} repositories, `}
|
||||||
|
in the last year
|
||||||
</p>
|
</p>
|
||||||
<div>
|
<div>
|
||||||
<svg viewBox={`0 0 ${svgWidth} ${svgHeight}`} width="100%" className="d-block">
|
<svg
|
||||||
|
viewBox={`0 0 ${svgWidth} ${svgHeight}`}
|
||||||
|
width="100%"
|
||||||
|
className="d-block"
|
||||||
|
>
|
||||||
{DAY_LABELS.map((label, i) =>
|
{DAY_LABELS.map((label, i) =>
|
||||||
label ? (
|
label ? (
|
||||||
<text
|
<text
|
||||||
@@ -148,12 +178,16 @@ export function ContributionGraph() {
|
|||||||
cx={LEFT_LABEL_WIDTH + col * (CELL_SIZE + GAP) + RADIUS}
|
cx={LEFT_LABEL_WIDTH + col * (CELL_SIZE + GAP) + RADIUS}
|
||||||
cy={TOP_LABEL_HEIGHT + row * (CELL_SIZE + GAP) + RADIUS}
|
cy={TOP_LABEL_HEIGHT + row * (CELL_SIZE + GAP) + RADIUS}
|
||||||
r={RADIUS - 1}
|
r={RADIUS - 1}
|
||||||
fill={count === 0 ? EMPTY_COLOR : (dayColorMap.get(date) ?? FALLBACK_COLOR)}
|
fill={
|
||||||
|
count === 0
|
||||||
|
? EMPTY_COLOR
|
||||||
|
: (dayColorMap.get(date) ?? FALLBACK_COLOR)
|
||||||
|
}
|
||||||
opacity={count === 0 ? 1 : opacityFor(count, thresholds)}
|
opacity={count === 0 ? 1 : opacityFor(count, thresholds)}
|
||||||
className="graph-cell"
|
className="graph-cell"
|
||||||
onClick={() => navigate(`/activity/${date}`)}
|
onClick={() => navigate(`/activity/${date}`)}
|
||||||
>
|
>
|
||||||
<title>{`${date}: ${count} ${count === 1 ? 'contribution' : 'contributions'}`}</title>
|
<title>{`${date}: ${count} ${count === 1 ? "contribution" : "contributions"}`}</title>
|
||||||
</circle>
|
</circle>
|
||||||
)),
|
)),
|
||||||
)}
|
)}
|
||||||
@@ -166,7 +200,7 @@ export function ContributionGraph() {
|
|||||||
/** All-time monthly contribution graph — years on X axis, months on Y axis. */
|
/** All-time monthly contribution graph — years on X axis, months on Y axis. */
|
||||||
export function AllTimeGraph() {
|
export function AllTimeGraph() {
|
||||||
const sourcesQ = useQuery({
|
const sourcesQ = useQuery({
|
||||||
queryKey: ['sources'],
|
queryKey: ["sources"],
|
||||||
queryFn: fetchSources,
|
queryFn: fetchSources,
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
});
|
});
|
||||||
@@ -177,11 +211,13 @@ export function AllTimeGraph() {
|
|||||||
.map((s) => s.earliest)
|
.map((s) => s.earliest)
|
||||||
.filter((d): d is string => d != null)
|
.filter((d): d is string => d != null)
|
||||||
.map((d) => new Date(d));
|
.map((d) => new Date(d));
|
||||||
return dates.length > 0 ? new Date(Math.min(...dates.map((d) => d.getTime()))) : null;
|
return dates.length > 0
|
||||||
|
? new Date(Math.min(...dates.map((d) => d.getTime())))
|
||||||
|
: null;
|
||||||
}, [sourcesQ.data]);
|
}, [sourcesQ.data]);
|
||||||
|
|
||||||
const projectsQ = useQuery({
|
const projectsQ = useQuery({
|
||||||
queryKey: ['projects'],
|
queryKey: ["projects"],
|
||||||
queryFn: fetchProjects,
|
queryFn: fetchProjects,
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
});
|
});
|
||||||
@@ -193,14 +229,14 @@ export function AllTimeGraph() {
|
|||||||
const toStr = fmt(to);
|
const toStr = fmt(to);
|
||||||
|
|
||||||
const dailyQ = useQuery({
|
const dailyQ = useQuery({
|
||||||
queryKey: ['daily-counts-alltime', fromStr, toStr],
|
queryKey: ["daily-counts-alltime", fromStr, toStr],
|
||||||
queryFn: () => fetchDailyCounts(fromStr, toStr),
|
queryFn: () => fetchDailyCounts(fromStr, toStr),
|
||||||
enabled: !!earliest,
|
enabled: !!earliest,
|
||||||
staleTime: 10 * 60_000,
|
staleTime: 10 * 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const langQ = useQuery({
|
const langQ = useQuery({
|
||||||
queryKey: ['language-daily-alltime', fromStr, toStr],
|
queryKey: ["language-daily-alltime", fromStr, toStr],
|
||||||
queryFn: () => fetchLanguageDailyCounts(fromStr, toStr),
|
queryFn: () => fetchLanguageDailyCounts(fromStr, toStr),
|
||||||
enabled: !!earliest,
|
enabled: !!earliest,
|
||||||
staleTime: 10 * 60_000,
|
staleTime: 10 * 60_000,
|
||||||
@@ -212,7 +248,10 @@ export function AllTimeGraph() {
|
|||||||
const monthColorMap = useMemo(() => {
|
const monthColorMap = useMemo(() => {
|
||||||
const entries = langQ.data ?? [];
|
const entries = langQ.data ?? [];
|
||||||
if (entries.length === 0) return new Map<string, string>();
|
if (entries.length === 0) return new Map<string, string>();
|
||||||
const map = new Map<string, Map<string, { commits: number; color: string }>>();
|
const map = new Map<
|
||||||
|
string,
|
||||||
|
Map<string, { commits: number; color: string }>
|
||||||
|
>();
|
||||||
for (const e of entries) {
|
for (const e of entries) {
|
||||||
const key = e.date.slice(0, 7); // YYYY-MM
|
const key = e.date.slice(0, 7); // YYYY-MM
|
||||||
if (!map.has(key)) map.set(key, new Map());
|
if (!map.has(key)) map.set(key, new Map());
|
||||||
@@ -221,7 +260,10 @@ export function AllTimeGraph() {
|
|||||||
if (cur) {
|
if (cur) {
|
||||||
cur.commits += e.commits;
|
cur.commits += e.commits;
|
||||||
} else {
|
} else {
|
||||||
langMap.set(e.language, { commits: e.commits, color: e.color ?? FALLBACK_COLOR });
|
langMap.set(e.language, {
|
||||||
|
commits: e.commits,
|
||||||
|
color: e.color ?? FALLBACK_COLOR,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const result = new Map<string, string>();
|
const result = new Map<string, string>();
|
||||||
@@ -237,7 +279,8 @@ export function AllTimeGraph() {
|
|||||||
|
|
||||||
const { years, monthGrid, thresholds, totalCount } = useMemo(() => {
|
const { years, monthGrid, thresholds, totalCount } = useMemo(() => {
|
||||||
const counts = dailyQ.data ?? [];
|
const counts = dailyQ.data ?? [];
|
||||||
if (counts.length === 0) return { years: [], monthGrid: [], thresholds: [1, 2, 3], totalCount: 0 };
|
if (counts.length === 0)
|
||||||
|
return { years: [], monthGrid: [], thresholds: [1, 2, 3], totalCount: 0 };
|
||||||
|
|
||||||
const countMap = new Map(counts.map((d) => [d.date, d.count]));
|
const countMap = new Map(counts.map((d) => [d.date, d.count]));
|
||||||
|
|
||||||
@@ -247,16 +290,30 @@ export function AllTimeGraph() {
|
|||||||
for (let yr = startYear; yr <= endYear; yr++) years.push(yr);
|
for (let yr = startYear; yr <= endYear; yr++) years.push(yr);
|
||||||
|
|
||||||
// Build a 12 x years grid of monthly totals
|
// Build a 12 x years grid of monthly totals
|
||||||
const monthGrid: { year: number; month: number; count: number; monthStart: string; monthEnd: string; monthKey: string }[][] = [];
|
const monthGrid: {
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
count: number;
|
||||||
|
monthStart: string;
|
||||||
|
monthEnd: string;
|
||||||
|
monthKey: string;
|
||||||
|
}[][] = [];
|
||||||
for (let m = 0; m < 12; m++) {
|
for (let m = 0; m < 12; m++) {
|
||||||
const row: typeof monthGrid[0] = [];
|
const row: (typeof monthGrid)[0] = [];
|
||||||
for (const yr of years) {
|
for (const yr of years) {
|
||||||
const monthStart = new Date(yr, m, 1);
|
const monthStart = new Date(yr, m, 1);
|
||||||
const monthEnd = new Date(yr, m + 1, 0); // last day of month
|
const monthEnd = new Date(yr, m + 1, 0); // last day of month
|
||||||
const monthKey = `${yr}-${String(m + 1).padStart(2, '0')}`;
|
const monthKey = `${yr}-${String(m + 1).padStart(2, "0")}`;
|
||||||
// Don't include months entirely outside our data range
|
// Don't include months entirely outside our data range
|
||||||
if (monthStart > to || monthEnd < from) {
|
if (monthStart > to || monthEnd < from) {
|
||||||
row.push({ year: yr, month: m, count: 0, monthStart: fmt(monthStart), monthEnd: fmt(monthEnd), monthKey });
|
row.push({
|
||||||
|
year: yr,
|
||||||
|
month: m,
|
||||||
|
count: 0,
|
||||||
|
monthStart: fmt(monthStart),
|
||||||
|
monthEnd: fmt(monthEnd),
|
||||||
|
monthKey,
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let total = 0;
|
let total = 0;
|
||||||
@@ -265,7 +322,14 @@ export function AllTimeGraph() {
|
|||||||
total += countMap.get(fmt(cursor)) ?? 0;
|
total += countMap.get(fmt(cursor)) ?? 0;
|
||||||
cursor.setDate(cursor.getDate() + 1);
|
cursor.setDate(cursor.getDate() + 1);
|
||||||
}
|
}
|
||||||
row.push({ year: yr, month: m, count: total, monthStart: fmt(monthStart), monthEnd: fmt(monthEnd), monthKey });
|
row.push({
|
||||||
|
year: yr,
|
||||||
|
month: m,
|
||||||
|
count: total,
|
||||||
|
monthStart: fmt(monthStart),
|
||||||
|
monthEnd: fmt(monthEnd),
|
||||||
|
monthKey,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
monthGrid.push(row);
|
monthGrid.push(row);
|
||||||
}
|
}
|
||||||
@@ -290,12 +354,17 @@ export function AllTimeGraph() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="contribution-graph mb-4">
|
<div className="contribution-graph mb-4">
|
||||||
<p style={{ fontSize: '0.8rem', opacity: 0.6 }}>
|
<p style={{ fontSize: "0.8rem", opacity: 0.6 }}>
|
||||||
{totalCount} contributions since {fmt(from)}
|
{new Intl.NumberFormat().format(totalCount)} contributions
|
||||||
{repoCount > 0 && ` in ${repoCount} repositories`}
|
{repoCount > 0 && `, across ${repoCount} repos, `}
|
||||||
|
since {fmt(from).split("-")[0]}
|
||||||
</p>
|
</p>
|
||||||
<div>
|
<div>
|
||||||
<svg viewBox={`0 0 ${svgWidth} ${svgHeight}`} width="100%" className="d-block">
|
<svg
|
||||||
|
viewBox={`0 0 ${svgWidth} ${svgHeight}`}
|
||||||
|
width="100%"
|
||||||
|
className="d-block"
|
||||||
|
>
|
||||||
{/* Year labels along the top */}
|
{/* Year labels along the top */}
|
||||||
{years.map((year, colIdx) => (
|
{years.map((year, colIdx) => (
|
||||||
<text
|
<text
|
||||||
@@ -323,20 +392,28 @@ export function AllTimeGraph() {
|
|||||||
))}
|
))}
|
||||||
{/* Monthly contribution circles */}
|
{/* Monthly contribution circles */}
|
||||||
{monthGrid.map((row, rowIdx) =>
|
{monthGrid.map((row, rowIdx) =>
|
||||||
row.map(({ year, count, monthStart, monthEnd, monthKey }, colIdx) => (
|
row.map(
|
||||||
<circle
|
({ year, count, monthStart, monthEnd, monthKey }, colIdx) => (
|
||||||
key={`${year}-${rowIdx}`}
|
<circle
|
||||||
cx={monthLabelWidth + colIdx * (CELL_SIZE + GAP) + RADIUS}
|
key={`${year}-${rowIdx}`}
|
||||||
cy={topLabelHeight + rowIdx * (CELL_SIZE + GAP) + RADIUS}
|
cx={monthLabelWidth + colIdx * (CELL_SIZE + GAP) + RADIUS}
|
||||||
r={RADIUS - 1}
|
cy={topLabelHeight + rowIdx * (CELL_SIZE + GAP) + RADIUS}
|
||||||
fill={count === 0 ? EMPTY_COLOR : (monthColorMap.get(monthKey) ?? FALLBACK_COLOR)}
|
r={RADIUS - 1}
|
||||||
opacity={count === 0 ? 1 : opacityFor(count, thresholds)}
|
fill={
|
||||||
className="graph-cell"
|
count === 0
|
||||||
onClick={() => navigate(`/activity/${monthStart}..${monthEnd}`)}
|
? EMPTY_COLOR
|
||||||
>
|
: (monthColorMap.get(monthKey) ?? FALLBACK_COLOR)
|
||||||
<title>{`${MONTH_LABELS[rowIdx]} ${year}: ${count} ${count === 1 ? 'contribution' : 'contributions'}`}</title>
|
}
|
||||||
</circle>
|
opacity={count === 0 ? 1 : opacityFor(count, thresholds)}
|
||||||
)),
|
className="graph-cell"
|
||||||
|
onClick={() =>
|
||||||
|
navigate(`/activity/${monthStart}..${monthEnd}`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<title>{`${MONTH_LABELS[rowIdx]} ${year}: ${count} ${count === 1 ? "contribution" : "contributions"}`}</title>
|
||||||
|
</circle>
|
||||||
|
),
|
||||||
|
),
|
||||||
)}
|
)}
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
@@ -349,7 +426,14 @@ function fmt(d: Date): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Build a map of date → dominant (highest commit count) language color. */
|
/** Build a map of date → dominant (highest commit count) language color. */
|
||||||
function buildDominantColorMap(entries: { date: string; language: string; color: string | null; commits: number }[]): Map<string, string> {
|
function buildDominantColorMap(
|
||||||
|
entries: {
|
||||||
|
date: string;
|
||||||
|
language: string;
|
||||||
|
color: string | null;
|
||||||
|
commits: number;
|
||||||
|
}[],
|
||||||
|
): Map<string, string> {
|
||||||
const map = new Map<string, { commits: number; color: string }>();
|
const map = new Map<string, { commits: number; color: string }>();
|
||||||
for (const e of entries) {
|
for (const e of entries) {
|
||||||
const cur = map.get(e.date);
|
const cur = map.get(e.date);
|
||||||
@@ -374,6 +458,7 @@ function opacityFor(count: number, thresholds: number[]): number {
|
|||||||
|
|
||||||
function computeThresholds(sorted: number[]): number[] {
|
function computeThresholds(sorted: number[]): number[] {
|
||||||
if (sorted.length === 0) return [1, 2, 3];
|
if (sorted.length === 0) return [1, 2, 3];
|
||||||
const p = (pct: number) => sorted[Math.min(Math.floor(pct * sorted.length), sorted.length - 1)];
|
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)];
|
return [p(0.25), p(0.5), p(0.75)];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { fetchDailyCounts, fetchSources } from '../api/client';
|
import { fetchDailyCounts, fetchHourlyAvgs, fetchSources } from '../api/client';
|
||||||
|
|
||||||
export function ContributionStats() {
|
export function ContributionStats() {
|
||||||
const sourcesQ = useQuery({
|
const sourcesQ = useQuery({
|
||||||
@@ -31,6 +31,21 @@ export function ContributionStats() {
|
|||||||
staleTime: 10 * 60_000,
|
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 stats = useMemo(() => {
|
||||||
const counts = dailyQ.data ?? [];
|
const counts = dailyQ.data ?? [];
|
||||||
if (counts.length === 0) return null;
|
if (counts.length === 0) return null;
|
||||||
@@ -96,6 +111,17 @@ export function ContributionStats() {
|
|||||||
return { currentStreak, longestStreak, busiest, dayAvgs, maxAvg, activeDays };
|
return { currentStreak, longestStreak, busiest, dayAvgs, maxAvg, activeDays };
|
||||||
}, [dailyQ.data]);
|
}, [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;
|
if (!stats) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -120,26 +146,50 @@ export function ContributionStats() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<span style={{ opacity: 0.7, fontSize: '0.75rem' }}>avg by weekday</span>
|
<span style={{ opacity: 0.7, fontSize: '0.75rem' }}>avg by weekday</span>
|
||||||
<div className="d-flex flex-column gap-1 mt-1">
|
<div className="d-flex align-items-end gap-1 mt-1" style={{ height: 64 }}>
|
||||||
{stats.dayAvgs.map(({ name, avg }) => (
|
{stats.dayAvgs.map(({ name, avg }) => (
|
||||||
<div key={name} className="d-flex align-items-center gap-2" style={{ fontSize: '0.75rem' }}>
|
<div key={name} className="d-flex flex-column align-items-center" style={{ flex: 1, height: '100%', justifyContent: 'flex-end' }}>
|
||||||
<span style={{ width: 24, textAlign: 'right', opacity: 0.7 }}>{name}</span>
|
<span style={{ fontSize: '0.65rem', opacity: 0.6, marginBottom: 2 }}>{avg.toFixed(1)}</span>
|
||||||
<div style={{ flex: 1, height: 8, borderRadius: 3, background: 'rgba(255,255,255,0.05)' }}>
|
<div style={{ width: '100%', maxWidth: 20, borderRadius: 3, background: 'rgba(255,255,255,0.05)', flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'flex-end' }}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: stats.maxAvg > 0 ? `${(avg / stats.maxAvg) * 100}%` : '0%',
|
height: stats.maxAvg > 0 ? `${(avg / stats.maxAvg) * 100}%` : '0%',
|
||||||
height: '100%',
|
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
backgroundColor: '#39d353',
|
backgroundColor: '#39d353',
|
||||||
opacity: 0.7,
|
opacity: 0.7,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span style={{ width: 28, textAlign: 'right', opacity: 0.6 }}>{avg.toFixed(1)}</span>
|
<span style={{ fontSize: '0.65rem', opacity: 0.7, marginTop: 2 }}>{name}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
|
|
||||||
import { fetchRepoLanguages } from '../api/client';
|
import { fetchRepoLanguages } from '../api/client';
|
||||||
|
|
||||||
const MAX_LANGS = 10;
|
const MAX_LANGS = 14;
|
||||||
|
|
||||||
export function TopLanguages() {
|
export function TopLanguages() {
|
||||||
const langsQ = useQuery({
|
const langsQ = useQuery({
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ 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';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import rehypeRaw from 'rehype-raw';
|
||||||
|
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
import { VerticalTimeline } from 'react-vertical-timeline-component';
|
import { VerticalTimeline } from 'react-vertical-timeline-component';
|
||||||
|
|
||||||
import { fetchEvents, fetchReadme, fetchRepoLanguages, fetchProjects, type Source } from '../api/client';
|
import { fetchEvents, fetchReadme, fetchRepoLanguages, fetchProjects, type Source } from '../api/client';
|
||||||
@@ -82,7 +85,12 @@ export function ProjectPage() {
|
|||||||
<Row className="mb-4">
|
<Row className="mb-4">
|
||||||
<Col>
|
<Col>
|
||||||
<div className="project-readme">
|
<div className="project-readme">
|
||||||
<ReactMarkdown>{readmeQ.data}</ReactMarkdown>
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
rehypePlugins={[rehypeRaw, [rehypeSanitize, readmeSanitizeSchema]]}
|
||||||
|
>
|
||||||
|
{readmeQ.data}
|
||||||
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
@@ -126,3 +134,49 @@ function forgeIcon(source: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rehype-sanitize defaults are conservative — README authors lean on raw
|
||||||
|
// HTML for layout (centered headers, collapsible sections, image
|
||||||
|
// dimensions). Extend the schema to permit those tags/attributes while
|
||||||
|
// still blocking script-y or interactive content (iframe, object, etc.).
|
||||||
|
const readmeSanitizeSchema = {
|
||||||
|
...defaultSchema,
|
||||||
|
tagNames: [
|
||||||
|
...(defaultSchema.tagNames ?? []),
|
||||||
|
'details',
|
||||||
|
'summary',
|
||||||
|
'picture',
|
||||||
|
'source',
|
||||||
|
'kbd',
|
||||||
|
'sub',
|
||||||
|
'sup',
|
||||||
|
'mark',
|
||||||
|
'abbr',
|
||||||
|
'cite',
|
||||||
|
'figure',
|
||||||
|
'figcaption',
|
||||||
|
'center',
|
||||||
|
],
|
||||||
|
attributes: {
|
||||||
|
...defaultSchema.attributes,
|
||||||
|
'*': [
|
||||||
|
...((defaultSchema.attributes && defaultSchema.attributes['*']) || []),
|
||||||
|
'align',
|
||||||
|
'style',
|
||||||
|
],
|
||||||
|
a: [
|
||||||
|
...((defaultSchema.attributes && defaultSchema.attributes.a) || []),
|
||||||
|
'target',
|
||||||
|
'rel',
|
||||||
|
],
|
||||||
|
img: [
|
||||||
|
...((defaultSchema.attributes && defaultSchema.attributes.img) || []),
|
||||||
|
'width',
|
||||||
|
'height',
|
||||||
|
'align',
|
||||||
|
'srcset',
|
||||||
|
],
|
||||||
|
source: ['srcset', 'media', 'type'],
|
||||||
|
details: ['open'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user