Compare commits

...

11 Commits

Author SHA1 Message Date
293d112c18 fix: fall back to _repo in commit reshape for github-repo events
The commit presentation layer only checked repository.full_name,
missing commits ingested by github_repo which store the repo name
in _repo instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-05 18:44:31 +03:00
ef1e84a41b feat(ui): link forge icon to repo on project page
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-05 18:42:04 +03:00
f8c13b5e21 fix: icon colors for dark backgrounds 2026-05-05 18:40:29 +03:00
abc90c8da0 feat(ui): forge icon on project page header
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-05 18:30:13 +03:00
d46a0e3777 fix: add _repo fallback to events repo filter for github-repo commits
The events query's COALESCE for github source was missing _repo,
so per-repo commit events from github_repo had no repo match and
project pages showed 0 activities.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-05 18:28:32 +03:00
642209068a feat(ui): forge icons on repo cards (github, gitea, mozilla)
Add SVG icons for each forge before the repo name on dashboard cards.
Icons sourced from user-provided SVGs in ui/public/.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-05 18:24:47 +03:00
c1e964de06 feat(ui): show all repos on dashboard instead of top 24
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-05 18:09:34 +03:00
45fd45f5da fix: stamp _repo into github-repo commit payloads for project attribution
The /repos/{owner}/{repo}/commits endpoint doesn't include repo info
in its response. Without _repo in the payload, these commits were
invisible to the projects query. Add _repo to parse_commit and include
it in the COALESCE chain for github source repo extraction.

After deploy, reset github-repo poller state to re-ingest with _repo:
  DELETE FROM poller_state WHERE source LIKE 'github-repo%';

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-05 17:59:31 +03:00
03c816d2d3 feat(ui): show repo count in contribution graph summaries
Add "in N repositories" to both the year and all-time graph summary
lines. Year graph counts repos with overlapping activity; all-time
graph uses total project count. OG image includes repo count too.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-05 17:50:15 +03:00
13db392273 fix(nginx): exclude /api/ paths from static asset location block
The static asset regex matched .png in /api/v1/og/contributions.png
before the /api/ proxy block, returning 404. Add negative lookahead
to skip /api/ paths.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-05 17:40:31 +03:00
e63583877c feat(api): server-rendered OG image of all-time contribution graph
Add /v1/og/contributions.png endpoint that builds an SVG of the
all-time weekly contribution graph (one row per year) from daily
counts, then rasterizes to PNG via resvg. Served with 1h cache.

Add og:image and twitter:card meta tags to index.html pointing at
the endpoint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-05 17:37:19 +03:00
16 changed files with 711 additions and 16 deletions

388
Cargo.lock generated
View File

@@ -88,6 +88,18 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arrayref"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
[[package]]
name = "arrayvec"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]] [[package]]
name = "async-compression" name = "async-compression"
version = "0.4.42" version = "0.4.42"
@@ -196,6 +208,12 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.11.1" version = "2.11.1"
@@ -220,12 +238,24 @@ version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
name = "bytemuck"
version = "1.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
[[package]] [[package]]
name = "byteorder" name = "byteorder"
version = "1.5.0" version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.11.1" version = "1.11.1"
@@ -306,6 +336,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "color_quant"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]] [[package]]
name = "colorchoice" name = "colorchoice"
version = "1.0.5" version = "1.0.5"
@@ -350,6 +386,15 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "core_maths"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30"
dependencies = [
"libm",
]
[[package]] [[package]]
name = "cpufeatures" name = "cpufeatures"
version = "0.2.17" version = "0.2.17"
@@ -408,6 +453,12 @@ dependencies = [
"typenum", "typenum",
] ]
[[package]]
name = "data-url"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
[[package]] [[package]]
name = "der" name = "der"
version = "0.7.10" version = "0.7.10"
@@ -484,6 +535,15 @@ dependencies = [
"windows-sys 0.48.0", "windows-sys 0.48.0",
] ]
[[package]]
name = "euclid"
version = "0.22.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06"
dependencies = [
"num-traits",
]
[[package]] [[package]]
name = "event-listener" name = "event-listener"
version = "5.4.1" version = "5.4.1"
@@ -495,6 +555,15 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.9" version = "0.1.9"
@@ -511,6 +580,12 @@ dependencies = [
"miniz_oxide", "miniz_oxide",
] ]
[[package]]
name = "float-cmp"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4"
[[package]] [[package]]
name = "flume" name = "flume"
version = "0.11.1" version = "0.11.1"
@@ -528,6 +603,29 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "fontconfig-parser"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646"
dependencies = [
"roxmltree",
]
[[package]]
name = "fontdb"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905"
dependencies = [
"fontconfig-parser",
"log",
"memmap2",
"slotmap",
"tinyvec",
"ttf-parser",
]
[[package]] [[package]]
name = "form_urlencoded" name = "form_urlencoded"
version = "1.2.2" version = "1.2.2"
@@ -645,6 +743,16 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "gif"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b"
dependencies = [
"color_quant",
"weezl",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.15.5" version = "0.15.5"
@@ -941,6 +1049,22 @@ dependencies = [
"icu_properties", "icu_properties",
] ]
[[package]]
name = "image-webp"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error",
]
[[package]]
name = "imagesize"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285"
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.14.0" version = "2.14.0"
@@ -991,6 +1115,17 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "kurbo"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62"
dependencies = [
"arrayvec",
"euclid",
"smallvec",
]
[[package]] [[package]]
name = "lazy_static" name = "lazy_static"
version = "1.5.0" version = "1.5.0"
@@ -1018,7 +1153,7 @@ version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.11.1",
"libc", "libc",
"plain", "plain",
"redox_syscall 0.7.4", "redox_syscall 0.7.4",
@@ -1092,6 +1227,15 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "memmap2"
version = "0.9.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "mime" name = "mime"
version = "0.3.17" version = "0.3.17"
@@ -1131,6 +1275,7 @@ dependencies = [
"moments-data", "moments-data",
"moments-entities", "moments-entities",
"reqwest", "reqwest",
"resvg",
"serde", "serde",
"serde_json", "serde_json",
"tokio", "tokio",
@@ -1308,6 +1453,12 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pico-args"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
[[package]] [[package]]
name = "pin-project-lite" name = "pin-project-lite"
version = "0.2.17" version = "0.2.17"
@@ -1347,6 +1498,19 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "png"
version = "0.17.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
dependencies = [
"bitflags 1.3.2",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]] [[package]]
name = "potential_utf" name = "potential_utf"
version = "0.1.5" version = "0.1.5"
@@ -1374,6 +1538,12 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]] [[package]]
name = "quinn" name = "quinn"
version = "0.11.9" version = "0.11.9"
@@ -1509,7 +1679,7 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.11.1",
] ]
[[package]] [[package]]
@@ -1518,7 +1688,7 @@ version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.11.1",
] ]
[[package]] [[package]]
@@ -1576,6 +1746,32 @@ dependencies = [
"webpki-roots 1.0.7", "webpki-roots 1.0.7",
] ]
[[package]]
name = "resvg"
version = "0.45.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43"
dependencies = [
"gif",
"image-webp",
"log",
"pico-args",
"rgb",
"svgtypes",
"tiny-skia",
"usvg",
"zune-jpeg",
]
[[package]]
name = "rgb"
version = "0.8.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4"
dependencies = [
"bytemuck",
]
[[package]] [[package]]
name = "ring" name = "ring"
version = "0.17.14" version = "0.17.14"
@@ -1590,6 +1786,12 @@ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]]
name = "roxmltree"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
[[package]] [[package]]
name = "rsa" name = "rsa"
version = "0.9.10" version = "0.9.10"
@@ -1657,6 +1859,24 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "rustybuzz"
version = "0.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702"
dependencies = [
"bitflags 2.11.1",
"bytemuck",
"core_maths",
"log",
"smallvec",
"ttf-parser",
"unicode-bidi-mirroring",
"unicode-ccc",
"unicode-properties",
"unicode-script",
]
[[package]] [[package]]
name = "ryu" name = "ryu"
version = "1.0.23" version = "1.0.23"
@@ -1798,12 +2018,36 @@ version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "simplecss"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c"
dependencies = [
"log",
]
[[package]]
name = "siphasher"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
[[package]] [[package]]
name = "slab" name = "slab"
version = "0.4.12" version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "slotmap"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038"
dependencies = [
"version_check",
]
[[package]] [[package]]
name = "smallvec" name = "smallvec"
version = "1.15.1" version = "1.15.1"
@@ -1938,7 +2182,7 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
dependencies = [ dependencies = [
"atoi", "atoi",
"base64", "base64",
"bitflags", "bitflags 2.11.1",
"byteorder", "byteorder",
"bytes", "bytes",
"chrono", "chrono",
@@ -1981,7 +2225,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
dependencies = [ dependencies = [
"atoi", "atoi",
"base64", "base64",
"bitflags", "bitflags 2.11.1",
"byteorder", "byteorder",
"chrono", "chrono",
"crc", "crc",
@@ -2042,6 +2286,15 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "strict-num"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
dependencies = [
"float-cmp",
]
[[package]] [[package]]
name = "stringprep" name = "stringprep"
version = "0.1.5" version = "0.1.5"
@@ -2065,6 +2318,16 @@ version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "svgtypes"
version = "0.15.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc"
dependencies = [
"kurbo",
"siphasher",
]
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.117" version = "2.0.117"
@@ -2125,6 +2388,32 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "tiny-skia"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
dependencies = [
"arrayref",
"arrayvec",
"bytemuck",
"cfg-if",
"log",
"png",
"tiny-skia-path",
]
[[package]]
name = "tiny-skia-path"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"
dependencies = [
"arrayref",
"bytemuck",
"strict-num",
]
[[package]] [[package]]
name = "tinystr" name = "tinystr"
version = "0.8.3" version = "0.8.3"
@@ -2234,7 +2523,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
dependencies = [ dependencies = [
"async-compression", "async-compression",
"bitflags", "bitflags 2.11.1",
"bytes", "bytes",
"futures-core", "futures-core",
"futures-util", "futures-util",
@@ -2344,6 +2633,15 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "ttf-parser"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
dependencies = [
"core_maths",
]
[[package]] [[package]]
name = "typenum" name = "typenum"
version = "1.20.0" version = "1.20.0"
@@ -2356,6 +2654,18 @@ version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
[[package]]
name = "unicode-bidi-mirroring"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe"
[[package]]
name = "unicode-ccc"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.24" version = "1.0.24"
@@ -2377,6 +2687,18 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
[[package]]
name = "unicode-script"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee"
[[package]]
name = "unicode-vo"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94"
[[package]] [[package]]
name = "untrusted" name = "untrusted"
version = "0.9.0" version = "0.9.0"
@@ -2395,6 +2717,33 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "usvg"
version = "0.45.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef"
dependencies = [
"base64",
"data-url",
"flate2",
"fontdb",
"imagesize",
"kurbo",
"log",
"pico-args",
"roxmltree",
"rustybuzz",
"simplecss",
"siphasher",
"strict-num",
"svgtypes",
"tiny-skia-path",
"unicode-bidi",
"unicode-script",
"unicode-vo",
"xmlwriter",
]
[[package]] [[package]]
name = "utf8_iter" name = "utf8_iter"
version = "1.0.4" version = "1.0.4"
@@ -2548,6 +2897,12 @@ dependencies = [
"rustls-pki-types", "rustls-pki-types",
] ]
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]] [[package]]
name = "whoami" name = "whoami"
version = "1.6.1" version = "1.6.1"
@@ -2860,6 +3215,12 @@ version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]]
name = "xmlwriter"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
[[package]] [[package]]
name = "yoke" name = "yoke"
version = "0.8.2" version = "0.8.2"
@@ -2968,3 +3329,18 @@ name = "zmij"
version = "1.0.21" version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zune-core"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a"
[[package]]
name = "zune-jpeg"
version = "0.4.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713"
dependencies = [
"zune-core",
]

View File

@@ -30,6 +30,7 @@ anyhow = "1"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "gzip"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "gzip"] }
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"
# internal # internal
moments-entities = { path = "crates/moments-entities", version = "=0.1.0" } moments-entities = { path = "crates/moments-entities", version = "=0.1.0" }

View File

@@ -20,7 +20,7 @@ server {
add_header Cache-Control "no-cache" always; add_header Cache-Control "no-cache" always;
} }
location ~* \.(js|css|woff2?|ttf|eot|svg|png|jpg|jpeg|gif|ico|webp|avif)$ { location ~* ^(?!/api/)\S+\.(js|css|woff2?|ttf|eot|svg|png|jpg|jpeg|gif|ico|webp|avif)$ {
expires 30d; expires 30d;
add_header Cache-Control "public, max-age=2592000, immutable"; add_header Cache-Control "public, max-age=2592000, immutable";
try_files $uri =404; try_files $uri =404;

View File

@@ -21,3 +21,4 @@ serde_json.workspace = true
chrono.workspace = true chrono.workspace = true
clap.workspace = true clap.workspace = true
reqwest.workspace = true reqwest.workspace = true
resvg.workspace = true

View File

@@ -7,7 +7,7 @@ use axum::{
response::IntoResponse, response::IntoResponse,
routing::get, routing::get,
}; };
use chrono::{DateTime, NaiveDate, Utc}; 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;
@@ -58,6 +58,7 @@ async fn main() -> anyhow::Result<()> {
.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/forge/{source}/{*rest}", get(forge_proxy)) .route("/v1/forge/{source}/{*rest}", get(forge_proxy))
.route("/v1/og/contributions.png", get(og_contributions))
.with_state(state) .with_state(state)
.layer(TraceLayer::new_for_http()) .layer(TraceLayer::new_for_http())
.layer(CorsLayer::permissive()); .layer(CorsLayer::permissive());
@@ -156,6 +157,181 @@ async fn daily_counts(
Ok(Json(counts)) Ok(Json(counts))
} }
async fn og_contributions(
State(state): State<AppState>,
) -> Result<impl IntoResponse, ApiError> {
// Get date range from source summaries
let summaries = state
.store
.source_summaries(false)
.await
.map_err(internal)?;
let earliest = summaries
.iter()
.filter_map(|s| s.earliest)
.min()
.unwrap_or_else(|| Utc::now())
.date_naive();
let today = Utc::now().date_naive();
let counts = state
.store
.daily_counts(earliest, today)
.await
.map_err(internal)?;
let projects = state.store.list_projects().await.map_err(internal)?;
let repo_count = projects.len();
let png = render_contributions_png(&counts, earliest, today, repo_count).map_err(|e| ApiError {
status: StatusCode::INTERNAL_SERVER_ERROR,
message: e,
})?;
Ok((
StatusCode::OK,
[
(axum::http::header::CONTENT_TYPE, "image/png"),
(axum::http::header::CACHE_CONTROL, "public, max-age=3600"),
],
png,
))
}
fn render_contributions_png(
counts: &[DailyCount],
from: NaiveDate,
to: NaiveDate,
repo_count: usize,
) -> Result<Vec<u8>, String> {
use std::collections::HashMap;
let count_map: HashMap<NaiveDate, i64> = counts.iter().map(|d| (d.date, d.count)).collect();
let cell = 10_f64;
let gap = 2_f64;
let step = cell + gap;
let radius = cell / 2.0;
let year_label_w = 40_f64;
let max_cols = 53;
let bg = "#2c3e50";
let colors = ["rgba(255,255,255,0.05)", "#0e4429", "#006d32", "#26a641", "#39d353"];
// Build weekly data per year
struct YearRow {
year: i32,
weeks: Vec<(NaiveDate, NaiveDate, i64)>, // start, end, count
}
let start_year = from.year();
let end_year = to.year();
let mut rows: Vec<YearRow> = Vec::new();
for yr in start_year..=end_year {
let year_start = NaiveDate::from_ymd_opt(yr, 1, 1).unwrap();
let year_end = if yr == end_year {
to
} else {
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 mut cursor = year_start - chrono::Duration::days(offset as i64);
let mut weeks = Vec::new();
while cursor <= year_end {
let week_start = cursor;
let mut week_count = 0i64;
for _ in 0..7 {
week_count += count_map.get(&cursor).copied().unwrap_or(0);
cursor += chrono::Duration::days(1);
}
let week_end = cursor - chrono::Duration::days(1);
weeks.push((week_start, week_end, week_count));
}
rows.push(YearRow { year: yr, weeks });
}
// Quantile thresholds
let mut non_zero: Vec<i64> = rows
.iter()
.flat_map(|r| r.weeks.iter().map(|w| w.2))
.filter(|&c| c > 0)
.collect();
non_zero.sort();
let thresholds = if non_zero.is_empty() {
[1i64, 2, 3]
} else {
let p = |pct: f64| non_zero[(pct * non_zero.len() as f64).min(non_zero.len() as f64 - 1.0) as usize];
[p(0.25), p(0.5), p(0.75)]
};
let color_for = |count: i64| -> &str {
if count == 0 { colors[0] }
else if count <= thresholds[0] { colors[1] }
else if count <= thresholds[1] { colors[2] }
else if count <= thresholds[2] { colors[3] }
else { colors[4] }
};
let n_rows = rows.len();
let title_h = 28_f64;
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 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 {
format!(" in {repo_count} repositories")
} else {
String::new()
};
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>"##,
x = year_label_w,
));
for (row_idx, row) in rows.iter().enumerate() {
let y_base = title_h + (row_idx as f64) * step;
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>"##,
x = year_label_w - 4.0,
y = y_base + radius,
yr = row.year,
));
for (col, (_, _, count)) in row.weeks.iter().enumerate() {
let cx = year_label_w + (col as f64) * step + radius;
let cy = y_base + radius;
let fill = color_for(*count);
svg.push_str(&format!(
r#"<circle cx="{cx}" cy="{cy}" r="{r}" fill="{fill}"/>"#,
r = radius - 1.0,
));
}
}
svg.push_str("</svg>");
// Rasterize with resvg
let tree = resvg::usvg::Tree::from_str(&svg, &resvg::usvg::Options::default())
.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 =
resvg::tiny_skia::Pixmap::new(w, h).ok_or_else(|| "pixmap alloc failed".to_string())?;
resvg::render(&tree, resvg::tiny_skia::Transform::default(), &mut pixmap.as_mut());
pixmap
.encode_png()
.map_err(|e| format!("png encode: {e}"))
}
/// Allowlisted forge hosts that the proxy may contact. /// Allowlisted forge hosts that the proxy may contact.
const ALLOWED_HOSTS: &[&str] = &["api.github.com", "git.lair.cafe"]; const ALLOWED_HOSTS: &[&str] = &["api.github.com", "git.lair.cafe"];

View File

@@ -480,6 +480,7 @@ fn commit_reshape(event: &Event) -> TimelineItem {
.get("repository") .get("repository")
.and_then(|r| r.get("full_name")) .and_then(|r| r.get("full_name"))
.and_then(Value::as_str) .and_then(Value::as_str)
.or_else(|| p.get("_repo").and_then(Value::as_str))
.unwrap_or("(unknown repo)"); .unwrap_or("(unknown repo)");
let author_login = p let author_login = p
.get("author") .get("author")

View File

@@ -244,13 +244,18 @@ fn parse_commit(item: &Value, repo: &Repo) -> Option<Event> {
.ok()? .ok()?
.with_timezone(&Utc); .with_timezone(&Utc);
let mut payload = item.clone();
if let Some(obj) = payload.as_object_mut() {
obj.insert("_repo".into(), Value::String(repo.full_name.clone()));
}
Some(Event { Some(Event {
id: format!("github-commit:{sha}"), id: format!("github-commit:{sha}"),
source: Source::Github, source: Source::Github,
action: "Commit".into(), action: "Commit".into(),
occurred_at, occurred_at,
public: !repo.private, public: !repo.private,
payload: item.clone(), payload,
}) })
} }

View File

@@ -58,7 +58,8 @@ impl EventReader for PgStore {
AND ($6::text IS NULL OR (CASE source AND ($6::text IS NULL OR (CASE source
WHEN 'github' THEN COALESCE( WHEN 'github' THEN COALESCE(
payload->'repo'->>'name', payload->'repo'->>'name',
payload->'repository'->>'full_name' payload->'repository'->>'full_name',
payload->>'_repo'
) )
WHEN 'gitea' THEN COALESCE( WHEN 'gitea' THEN COALESCE(
payload->'repo'->>'full_name', payload->'repo'->>'full_name',
@@ -145,7 +146,8 @@ impl EventReader for PgStore {
CASE source CASE source
WHEN 'github' THEN COALESCE( WHEN 'github' THEN COALESCE(
payload->'repo'->>'name', payload->'repo'->>'name',
payload->'repository'->>'full_name' payload->'repository'->>'full_name',
payload->>'_repo'
) )
WHEN 'gitea' THEN COALESCE( WHEN 'gitea' THEN COALESCE(
payload->'repo'->>'full_name', payload->'repo'->>'full_name',

View File

@@ -4,6 +4,12 @@
<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 property="og:description" content="contribution history across github, gitea, and mozilla hg" />
<meta property="og:image" content="https://rob.tn/api/v1/og/contributions.png" />
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content="https://rob.tn/api/v1/og/contributions.png" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" /> <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="16x16" href="/favicon-16.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" /> <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />

23
ui/public/gitea.svg Normal file
View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8" ?>
<svg
xmlns="http://www.w3.org/2000/svg"
aria-label="Gitea"
role="img"
viewBox="0 0 512 512"
>
<rect rx="15%" height="512" width="512" fill="#ffffff" />
<path
d="M419 150c-98 7-186 2-276-1-27 0-63 19-61 67 3 75 71 82 99 83 3 14 35 62 59 65h104c63-5 109-213 75-214zm-311 67c-3-21 7-42 42-42 3 39 10 61 22 96-32-5-59-15-64-54z"
fill="#592"
/>
<path d="m293 152v70" stroke="#ffffff" stroke-width="9" />
<g transform="rotate(25.7 496 -423)" stroke-width="7" fill="#592">
<path d="M561 246h97" stroke="#592" />
<rect x="561" y="246" width="97" height="97" rx="16" fill="#ffffff" />
<path d="M592 245v75" stroke="#592" />
<path d="M592 273c45 0 38-5 38 48" fill="none" stroke="#592" />
<circle cx="592" cy="320" r="10" />
<circle cx="630" cy="320" r="10" />
<circle cx="592" cy="273" r="10" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 951 B

14
ui/public/github.svg Normal file
View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8" ?>
<svg
fill="#ffffff"
width="800px"
height="800px"
viewBox="0 0 24 24"
role="img"
xmlns="http://www.w3.org/2000/svg"
>
<title>github</title>
<path
d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
/>
</svg>

After

Width:  |  Height:  |  Size: 956 B

30
ui/public/mozilla.svg Normal file
View File

@@ -0,0 +1,30 @@
<?xml version="1.0" standalone="no" ?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg
width="800px"
height="800px"
viewBox="-10 -5 1034 1034"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
version="1.1"
>
<path
fill="#ffffff"
d="M411 237q-31 1 -64 10q-27 8 -54 22q-20 9 -38 20l-14 10q-50 33 -120 94q-67 57 -108 100q8 1 23 -5q12 -4 18 -5l99 -27l-32 26q-47 38 -69 59q-39 36 -52 62q11 -4 42 -19t46 -21q26 -11 40 -12l-18 16q-33 30 -49 46q-28 27 -38 44q1 1 1 4v2l10 -6q33 -20 52 -29
q32 -16 47 -18q-77 83 -89 125q19 -13 55 -32.5t55 -26.5q-3 8 -15 23q-9 12 -12.5 18.5t-15.5 21.5t-17 23q-8 14 -9 25q30 -29 73 -53q-4 10 -15 27q-10 15 -14 23q-7 13 -9 26q9 -10 27 -18q11 -5 33 -12l11 -4l-4 4q-27 21 -42 39l8 -1q2 -1 9 1l6 1l-32 13
q143 188 395 166l-13 -28q10 -18 19 -63t19 -66q16 -34 48 -51l25 -14q24 -14 37 -17q18 -4 49 2q8 1 32 7q35 9 52 12q30 6 46 2l10 -5q36 -22 58 -39q24 5 34.5 1.5t18.5 -16.5l5 -14q18 -50 28 -72l1 -15q-7 -21 -11 -75q-2 -32 -5 -36q-13 -18 -45 -34q-20 -10 -67 -27
q-54 -20 -79 -33q-43 -21 -66 -47q8 -25 -5 -51q-10 -19 -32 -40q-25 -18 -63 -16q-24 1 -64 12q-30 -6 -86 -24q-29 -9 -44 -14q-8 -1 -18 -1h-4zM410 285q22 0 64 12q27 7 41 9q-1 4 -6.5 8t-6.5 7l-4 6q-4 5 -5.5 8.5t0.5 7.5l32 -12q13 -4 38 -12q32 -11 48 -14
q28 -6 51 -3l24 31q-24 -20 -55 -15q-19 3 -56 20q-33 15 -49 18q-11 2 -33 8q-17 5 -25 7q29 22 75 17q3 15 18 34.5t29 22.5q-1 -6 -7 -20.5t-7 -22.5q-3 -14 4 -25l9 -6q17 -10 26 -13q16 -5 27 4q-10 1 -26 11l-8 5q-6 4 -8.5 14t-0.5 14q7 20 39 33q36 14 90 14
q-3 -4 -16 -18.5t-18 -21.5q-9 -11 -8 -17q30 30 81 55q30 14 89 35q35 12 49 18q22 10 31 19q1 5 -13 22q-7 9 -9 13q-4 7 -2 10q-3 7 7 22q5 8 22 29l3 4q-3 -25 -1.5 -36.5t6 -9.5t8.5 14.5t3 28.5q0 19 -7 37l-35 -8q-47 -3 -84 -15q-33 -9 -66 -28q-21 -12 -65 -42
l-30 -20q-27 -18 -79 -60l-14 -11l-50 -19q14 17 36 41q20 22 25 30q7 12 5 24.5t-16 36.5l-17 -31l-7 -55q-11 40 -13 66q-2 36 13 62q17 30 58 49l50 7q-22 -18 -30 -29q-12 -16 -8 -31q15 30 78 55q35 14 105 32l24 6q15 6 24 15q-7 6 -13 9q-4 2 -13 4.5t-15 4.5l-28 -9
q-36 -11 -51 -18q-26 -11 -36 -25q4 6 -27 14q-18 4 -63 13q-29 5 -37 7q-13 3 -3 4q-35 -1 -72 -23q-33 -19 -61 -50l-9 -3q0 17 11 35q6 11 22.5 32t22.5 31l38 17l-10 6q-16 10 -23 16q-13 10 -19 21l-2 6q-5 9 -6 14q-2 8 1 14q21 -23 61 -33l-16 25q2 18 -9 49
q-2 -5 -5 -10q-47 -20 -71 -39q6 20 25 44q16 21 37 38q-5 12 -12 23q-36 -25 -54 -42q5 9 11 27q9 24 15 34q-133 -10 -217 -82l60 -34q-10 2 -47 11l-30 8l-7 -8l24 -12q29 -14 42 -22q23 -14 29 -26v0l-6 2l-61 3q-4 -13 0 -27q2 -9 10.5 -24.5t9.5 -23.5q2 -13 -7 -25
q-35 -46 -49 -80q-20 -46 -15 -90l1 2q7 15 13 20l2 -17q2 -18 5 -26v1q6 20 11 29q8 16 21 24l1 -2q-14 -50 -2 -95q13 -51 56 -78q-6 2 -23 4q-26 4 -41 10q2 -1 5 -10q11 -29 23 -39q6 -4 15 -13q11 -10 19 -15q24 -19 43 -28q26 -14 59 -19q8 -1 16 -1h4zM425 332
q-28 0 -60 12l-13 10q-14 17 -19 26q-10 14 -12 29l3 4q13 -16 37 -28q0 18 9.5 33.5t25.5 24.5h3q-3 -19 -1 -39.5t9 -37.5q7 -6 23 -14q18 -10 25 -17q-14 -4 -30 -3zM625 391v0q8 0 15 3q-6 3 -8 11q-1 4 0 7q-11 -8 -11 -21h4zM462 392q2 7 14 22l9 11q-28 2 -60 11
q5 4 14.5 9t13.5 9l-16 6q-21 7 -31 11q-16 8 -28 19l5 2q31 -6 66 -5.5t67 7.5l3 -2l-25 -46l38 -8q-39 -37 -70 -46zM662 406q8 7 15 16q-11 3 -22 0q5 -3 7 -10v-6zM599 430q13 39 37 41q33 6 62 1q-12 -7 -39 -13q-22 -6 -32 -10q-17 -7 -28 -19zM368 492
q-23 17 -32.5 42t-5.5 53q2 7 1 17q-1 6 -4 17q-4 17 -3 25q0 13 8 22q22 25 63 55l-2 -8q-35 -41 -46 -74l17 6q36 13 55 16q-6 -10 -22 -29q-28 -34 -36 -53q-14 -32 -4 -62q0 -5 6 -12q7 -10 5 -15zM728 621q2 0 5 1q8 2 9 4q-3 2 -8.5 7t-8.5 6l1 -6q0 -9 2 -12z
M768 642v0q4 0 9 4h1l-13 13l1 -17h2zM798 653v0l10 6q-1 0 -6 7t-5.5 6.5t-0.5 -6.5q0 -13 2 -13zM829 667v0l9 2q-1 1 -4 7q-6 11 -11 11l4 -8v-9q0 -3 2 -3zM861 673l12 3l-13 22zM896 679q1 0 8 2l4 2l-15 11q-1 -9 -0.5 -12t3.5 -3zM933 685q5 0 7 2q0 5 -7 12
q-4 4 -5 7v-6q2 -10 0 -15h5z"
/>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -105,6 +105,14 @@ a.hot-pink {
font-size: 0.9rem; font-size: 0.9rem;
} }
.forge-icon {
width: 16px;
height: 16px;
margin-right: 6px;
vertical-align: -2px;
opacity: 0.7;
}
.project-card a { .project-card a {
color: #ff4081; color: #ff4081;
} }

View File

@@ -2,7 +2,7 @@ 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, fetchSources } from '../api/client'; import { fetchDailyCounts, fetchProjects, fetchSources } from '../api/client';
const CELL_SIZE = 12; const CELL_SIZE = 12;
const GAP = 3; const GAP = 3;
@@ -37,6 +37,23 @@ export function ContributionGraph() {
staleTime: 5 * 60_000, staleTime: 5 * 60_000,
}); });
const projectsQ = useQuery({
queryKey: ['projects'],
queryFn: fetchProjects,
staleTime: 60_000,
});
const repoCount = useMemo(() => {
if (!projectsQ.data) return 0;
const fromMs = from.getTime();
const toMs = to.getTime();
return projectsQ.data.filter((p) => {
const first = p.first_activity ? new Date(p.first_activity).getTime() : Infinity;
const last = p.last_activity ? new Date(p.last_activity).getTime() : 0;
return last >= fromMs && first <= toMs;
}).length;
}, [projectsQ.data]);
const navigate = useNavigate(); const navigate = useNavigate();
const { weeks, monthMarkers, thresholds, totalCount } = useMemo(() => { const { weeks, monthMarkers, thresholds, totalCount } = useMemo(() => {
@@ -89,6 +106,7 @@ export function ContributionGraph() {
<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 {totalCount} contributions in the last year
{repoCount > 0 && ` in ${repoCount} repositories`}
</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">
@@ -155,6 +173,13 @@ export function AllTimeGraph() {
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({
queryKey: ['projects'],
queryFn: fetchProjects,
staleTime: 60_000,
});
const repoCount = projectsQ.data?.length ?? 0;
const to = new Date(); const to = new Date();
const from = earliest ?? new Date(to.getFullYear() - 5, 0, 1); const from = earliest ?? new Date(to.getFullYear() - 5, 0, 1);
const fromStr = fmt(from); const fromStr = fmt(from);
@@ -227,6 +252,7 @@ export function AllTimeGraph() {
<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)} {totalCount} contributions since {fmt(from)}
{repoCount > 0 && ` in ${repoCount} repositories`}
</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">

View File

@@ -14,7 +14,7 @@ export function DashPage() {
}); });
const projects = projectsQ.data ?? []; const projects = projectsQ.data ?? [];
const ranked = rankProjects(projects).slice(0, 24); const ranked = rankProjects(projects);
return ( return (
<> <>
@@ -57,7 +57,7 @@ function ProjectCard({ project: p }: { project: ProjectSummary }) {
return ( return (
<Link to={`/project/${p.source}/${p.repo}`} className="text-decoration-none"> <Link to={`/project/${p.source}/${p.repo}`} className="text-decoration-none">
<div className="project-card p-3"> <div className="project-card p-3">
<h5 className="mb-1">{p.repo}</h5> <h5 className="mb-1"><img src={forgeIcon(p.source)} alt={p.source} className="forge-icon" />{p.repo}</h5>
<small className="text-muted d-block mb-2"> <small className="text-muted d-block mb-2">
{p.source} {p.source}
{topLangs && ` · ${topLangs}`} {topLangs && ` · ${topLangs}`}
@@ -75,6 +75,15 @@ function ProjectCard({ project: p }: { project: ProjectSummary }) {
); );
} }
function forgeIcon(source: string): string {
switch (source) {
case 'github': return '/github.svg';
case 'gitea': return '/gitea.svg';
case 'hg': return '/mozilla.svg';
default: return '/github.svg';
}
}
function topLanguages(langs: Record<string, number>, n: number): string { function topLanguages(langs: Record<string, number>, n: number): string {
return Object.entries(langs) return Object.entries(langs)
.sort(([, a], [, b]) => b - a) .sort(([, a], [, b]) => b - a)

View File

@@ -54,8 +54,7 @@ export function ProjectPage() {
<> <>
<Row className="mb-3"> <Row className="mb-3">
<Col> <Col>
<h2>{repo}</h2> <h2><a href={repoUrl(source ?? '', host, repo)} target="_blank" rel="noopener noreferrer"><img src={forgeIcon(source ?? '')} alt={source} className="forge-icon" style={{ width: 24, height: 24 }} /></a>{repo}</h2>
<small className="text-muted">{source}</small>
{langs && <LanguageBar languages={langs} />} {langs && <LanguageBar languages={langs} />}
</Col> </Col>
</Row> </Row>
@@ -90,6 +89,24 @@ export function ProjectPage() {
); );
} }
function repoUrl(source: string, host: string, repo: string): string {
switch (source) {
case 'github': return `https://github.com/${repo}`;
case 'gitea': return `https://${host}/${repo}`;
case 'hg': return `https://${host}/${repo}`;
default: return '#';
}
}
function forgeIcon(source: string): string {
switch (source) {
case 'github': return '/github.svg';
case 'gitea': return '/gitea.svg';
case 'hg': return '/mozilla.svg';
default: return '/github.svg';
}
}
function LanguageBar({ languages }: { languages: Record<string, number> }) { function LanguageBar({ languages }: { languages: Record<string, number> }) {
const total = Object.values(languages).reduce((a, b) => a + b, 0); const total = Object.values(languages).reduce((a, b) => a + b, 0);
if (total === 0) return null; if (total === 0) return null;