feat SVG commit frequency chart on repo summary page

Introduce lib/charts.ml for non-interactive SVG chart generation. The repo summary page now displays a bar chart of daily commit frequency over the past 30 days, replacing the previous commit list. The chart auto-scales vertically, shows gridlines, and labels every 7th day on the x-axis. Add Resolvers.Commit.since to fetch all commits after a timestamp cutoff, stopping traversal once commits are too old.

Commit
ea11b5c7b6869e90ba955432e9358cf6b932af1b
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
lib/charts.ml
index 00000000..fd16cedb 000000..100644
@@ -0,0 +1,131 @@
1 Added: (* -*- mode: tuareg; -*- *)
2 Added:
3 Added: (** Non-interactive SVG chart generation for embedding in HTML pages. *)
4 Added:
5 Added: open Dream_html
6 Added:
7 Added: let rect = std_tag "rect"
8 Added: let text_ = std_tag "text"
9 Added: let g = std_tag "g"
10 Added: let line = std_tag "line"
11 Added: let x = string_attr "x"
12 Added: let y = string_attr "y"
13 Added: let width_ = string_attr "width"
14 Added: let height_ = string_attr "height"
15 Added: let rx = string_attr "rx"
16 Added: let x1 = string_attr "x1"
17 Added: let y1 = string_attr "y1"
18 Added: let x2 = string_attr "x2"
19 Added: let y2 = string_attr "y2"
20 Added: let text_anchor = string_attr "text-anchor"
21 Added: let font_size = string_attr "font-size"
22 Added: let int_s = string_of_int
23 Added: let float_s f = Printf.sprintf "%.1f" f
24 Added:
25 Added: (** Render a bar chart of daily commit frequency.
26 Added:
27 Added: [values] is a list of [(label, count)] pairs representing consecutive days.
28 Added: The chart auto-scales vertically to the maximum count. *)
29 Added: let commit_frequency ~chart_width ~chart_height values =
30 Added: let n = List.length values in
31 Added: if n = 0 then HTML.null []
32 Added: else
33 Added: let max_count =
34 Added: List.fold_left (fun acc (_, count) -> max acc count) 0 values
35 Added: in
36 Added: let max_count = max max_count 1 in
37 Added: let padding_top = 20 in
38 Added: let padding_bottom = 30 in
39 Added: let padding_left = 30 in
40 Added: let padding_right = 10 in
41 Added: let plot_width = chart_width - padding_left - padding_right in
42 Added: let plot_height = chart_height - padding_top - padding_bottom in
43 Added: let bar_width = float_of_int plot_width /. float_of_int n in
44 Added: let gap = bar_width *. 0.2 in
45 Added: let bar_actual = bar_width -. gap in
46 Added: (* Y-axis gridlines *)
47 Added: let grid_lines =
48 Added: let steps = min 4 max_count in
49 Added: List.init (steps + 1) (fun i ->
50 Added: let value = max_count * i / steps in
51 Added: let y_pos =
52 Added: padding_top + plot_height - (plot_height * value / max_count)
53 Added: in
54 Added: [
55 Added: line
56 Added: [
57 Added: x1 "%s" (int_s padding_left);
58 Added: y1 "%s" (int_s y_pos);
59 Added: x2 "%s" (int_s (chart_width - padding_right));
60 Added: y2 "%s" (int_s y_pos);
61 Added: SVG.stroke "#333";
62 Added: SVG.stroke_width "1";
63 Added: ]
64 Added: [];
65 Added: text_
66 Added: [
67 Added: x "%s" (int_s (padding_left - 5));
68 Added: y "%s" (int_s (y_pos + 4));
69 Added: text_anchor "end";
70 Added: font_size "10";
71 Added: SVG.fill "#888";
72 Added: ]
73 Added: [ txt "%d" value ];
74 Added: ])
75 Added: |> List.concat
76 Added: in
77 Added: (* Bars *)
78 Added: let bars =
79 Added: List.mapi
80 Added: (fun i (_, count) ->
81 Added: let bar_height =
82 Added: if max_count = 0 then 0 else plot_height * count / max_count
83 Added: in
84 Added: let bx =
85 Added: float_of_int padding_left
86 Added: +. (float_of_int i *. bar_width)
87 Added: +. (gap /. 2.0)
88 Added: in
89 Added: let by = padding_top + plot_height - bar_height in
90 Added: rect
91 Added: [
92 Added: x "%s" (float_s bx);
93 Added: y "%s" (int_s by);
94 Added: width_ "%s" (float_s bar_actual);
95 Added: height_ "%s" (int_s bar_height);
96 Added: rx "2";
97 Added: SVG.fill "rgb(194, 79, 30)";
98 Added: ]
99 Added: [])
100 Added: values
101 Added: in
102 Added: (* X-axis labels — show every 7th day *)
103 Added: let labels =
104 Added: List.mapi
105 Added: (fun i (label, _) ->
106 Added: if i mod 7 = 0 || i = n - 1 then
107 Added: let lx =
108 Added: float_of_int padding_left
109 Added: +. (float_of_int i *. bar_width)
110 Added: +. (bar_width /. 2.0)
111 Added: in
112 Added: text_
113 Added: [
114 Added: x "%s" (float_s lx);
115 Added: y "%s" (int_s (chart_height - 5));
116 Added: text_anchor "middle";
117 Added: font_size "10";
118 Added: SVG.fill "#888";
119 Added: ]
120 Added: [ txt "%s" label ]
121 Added: else HTML.null [])
122 Added: values
123 Added: in
124 Added: SVG.(
125 Added: svg
126 Added: [
127 Added: viewbox ~min_x:0 ~min_y:0 ~width:chart_width ~height:chart_height;
128 Added: xmlns;
129 Added: HTML.class_ "chart-commit-frequency";
130 Added: ]
131 Added: [ g [] grid_lines; g [] bars; g [] labels ])
lib/handlers.ml
index fa943d98..46ad12ed 100644..100644
@@ -123,12 +123,38 @@
123 123 with_repository config name (fun repository context ->
124 124 handler repository context id)
125 125
126 Removed: let summary config repository context =
127 Removed: let* commits =
128 Removed: Resolvers.Commit.recent repository config.Config.commits_max_displayed
126 Added: let summary _config repository context =
127 Added: let now = Unix.time () in
128 Added: let days = 30 in
129 Added: let seconds_per_day = 86400.0 in
130 Added: let cutoff =
131 Added: Int64.of_float (now -. (float_of_int days *. seconds_per_day))
129 132 in
133 Added: let* commits = Resolvers.Commit.since repository cutoff in
130 134 let* readme = Resolvers.Repo.readme repository in
131 Removed: Views.Repo.summary context ?readme commits
135 Added: (* Group commits by day — build a count for each of the past 30 days *)
136 Added: let day_of_timestamp ts =
137 Added: let t = Int64.to_float ts in
138 Added: let tm = Unix.localtime t in
139 Added: (tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday)
140 Added: in
141 Added: let frequency =
142 Added: List.init days (fun i ->
143 Added: let t = now -. (float_of_int (days - 1 - i) *. seconds_per_day) in
144 Added: let tm = Unix.localtime t in
145 Added: let key = (tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday) in
146 Added: let label = Printf.sprintf "%02d/%02d" (tm.tm_mon + 1) tm.tm_mday in
147 Added: let count =
148 Added: List.length
149 Added: (List.filter
150 Added: (fun (c : Resolvers.Commit.t) ->
151 Added: let ts, _ = c.author.Git.User.date in
152 Added: day_of_timestamp ts = key)
153 Added: commits)
154 Added: in
155 Added: (label, count))
156 Added: in
157 Added: Views.Repo.summary context ?readme frequency
132 158
133 159 let commit_matches ?filter_type ?author ?committer
134 160 (commit : Resolvers.Commit.t) =
lib/resolvers.ml
index c7f4cbd2..5854c548 100644..100644
@@ -349,6 +349,46 @@
349 349
350 350 let recent repository count =
351 351 recent_matching repository count (Fun.const true)
352 Added:
353 Added: let since repository cutoff =
354 Added: let predicate (commit : t) =
355 Added: let ts, _ = commit.author.Git.User.date in
356 Added: Int64.compare ts cutoff >= 0
357 Added: in
358 Added: (* Walk up to 1000 commits; stop collecting once we pass the cutoff *)
359 Added: let* head_hash = resolve_head_hash repository in
360 Added: let module S = Set.Make (String) in
361 Added: let rec walk collected seen queue =
362 Added: match queue with
363 Added: | [] -> Lwt_result.return (List.rev collected)
364 Added: | (_, h) :: rest ->
365 Added: if S.mem h seen then walk collected seen rest
366 Added: else
367 Added: let seen = S.add h seen in
368 Added: let* commit = of_id repository h in
369 Added: let ts, _ = commit.author.Git.User.date in
370 Added: if Int64.compare ts cutoff < 0 then
371 Added: (* This commit is too old; don't enqueue its parents *)
372 Added: walk collected seen rest
373 Added: else
374 Added: let new_queue =
375 Added: List.filter_map
376 Added: (fun p ->
377 Added: if S.mem p seen then None
378 Added: else Some (commit.author.Git.User.date, p))
379 Added: commit.parents
380 Added: in
381 Added: let queue =
382 Added: List.merge
383 Added: (fun ((a_ts, _), _) ((b_ts, _), _) -> Int64.compare b_ts a_ts)
384 Added: rest new_queue
385 Added: in
386 Added: let collected =
387 Added: if predicate commit then commit :: collected else collected
388 Added: in
389 Added: walk collected seen queue
390 Added: in
391 Added: walk [] S.empty [ ((Int64.max_int, None), Store.Hash.to_hex head_hash) ]
352 392 end
353 393
354 394 module Reference = struct
lib/resolvers.mli
index 05353528..e3a6790c 100644..100644
@@ -67,6 +67,7 @@
67 67 repository -> int -> (t -> bool) -> (t list, error) Lwt_result.t
68 68
69 69 val recent : repository -> int -> (t list, error) Lwt_result.t
70 Added: val since : repository -> int64 -> (t list, error) Lwt_result.t
70 71 end
71 72
72 73 (** {1 References} *)
lib/static/styles.css
index d082da74..9e2b265f 100644..100644
@@ -440,6 +440,18 @@
440 440 display: none;
441 441 }
442 442
443 Added: /* Chart container */
444 Added: .chart-container {
445 Added: width: 100%;
446 Added: overflow-x: auto;
447 Added: }
448 Added:
449 Added: .chart-commit-frequency {
450 Added: width: 100%;
451 Added: height: auto;
452 Added: display: block;
453 Added: }
454 Added:
443 455 @media (min-width: 960px) {
444 456 .summary-layout {
445 457 display: grid;
lib/views/repo.ml
index 2ebc434b..4388fbc2 100644..100644
@@ -485,17 +485,19 @@
485 485 ];
486 486 ])
487 487
488 Removed: let summary context ?readme commits =
489 Removed: let commits_section =
488 Added: let summary context ?readme frequency =
489 Added: let chart_section =
490 490 HTML.
491 491 [
492 Removed: h3 [] [ txt "Latest commits" ];
493 Removed: ul [ id "commit-list" ] (List.map (li_of_commit context.repo) commits);
494 Removed: p
495 Removed: [ class_ "summary-more" ]
492 Added: h3 [] [ txt "Commit activity (past 30 days)" ];
493 Added: div
494 Added: [ class_ "chart-container" ]
496 495 [
497 Removed: Routes.link_to (Commits context.repo) (txt "Show previous commits");
496 Added: Charts.commit_frequency ~chart_width:600 ~chart_height:200 frequency;
498 497 ];
498 Added: p
499 Added: [ class_ "summary-more" ]
500 Added: [ Routes.link_to (Commits context.repo) (txt "View all commits") ];
499 501 ]
500 502 in
501 503 let readme_section =
@@ -531,7 +533,7 @@
531 533 div
532 534 [ class_ "summary-layout" ]
533 535 [
534 Removed: div [ class_ "summary-commits" ] commits_section;
536 Added: div [ class_ "summary-commits" ] chart_section;
535 537 div [ class_ "summary-readme" ] [ readme_section ];
536 538 ];
537 539 ]