const std = @import("std");
const store = @import("../../storage/store.zig");
pub const Signal = struct {
style: []const u8,
html: []const u8,
};
pub fn fromState(
allocator: std.mem.Allocator,
residents: []const store.Resident,
collections: []const store.CollectionSummary,
activity: []const store.ActivityBucket,
) !Signal {
var hash = std.hash.Wyhash.init(0x7a_64_73);
for (residents) |resident| {
hash.update(resident.did);
hash.update(resident.handle);
var buf: [16]u8 = undefined;
hash.update(try std.fmt.bufPrint(&buf, "{d}", .{resident.record_count}));
}
for (collections) |collection| {
hash.update(collection.collection);
var buf: [16]u8 = undefined;
hash.update(try std.fmt.bufPrint(&buf, "{d}", .{collection.count}));
}
for (activity) |bucket| {
var buf: [32]u8 = undefined;
hash.update(try std.fmt.bufPrint(&buf, "{d}", .{bucket.count}));
}
const seed = hash.final();
const hue_a: u16 = @intCast(seed % 360);
const hue_b: u16 = @intCast((seed >> 11) % 360);
const hue_c: u16 = @intCast((seed >> 23) % 360);
const style = try std.fmt.allocPrint(
allocator,
"--signal-a:hsl({d} 76% 62%);--signal-b:hsl({d} 84% 66%);--signal-c:hsl({d} 78% 65%);",
.{ hue_a, hue_b, hue_c },
);
var html: std.Io.Writer.Allocating = .init(allocator);
defer html.deinit();
try html.writer.writeAll("
");
if (activity.len == 0) {
try html.writer.writeAll("waiting for writes");
} else {
var max_count: u64 = 1;
var total_count: u64 = 0;
for (activity) |bucket| {
max_count = @max(max_count, bucket.count);
total_count += bucket.count;
}
const first_label = try formatDate(allocator, activity[0].start_us);
const last_label = try formatDate(allocator, activity[activity.len - 1].end_us);
const range_label = try formatRange(allocator, activity[0].start_us, activity[activity.len - 1].end_us);
try html.writer.print(
\\{d} repo commits · {s}
\\
, .{ total_count, range_label });
var i: usize = 0;
while (i < activity.len) : (i += 1) {
const bucket = activity[i];
const height = scaledHeight(bucket.count, max_count);
const offset = i % 3;
const bucket_label = try formatBucketLabel(allocator, bucket);
try html.writer.print(
\\
, .{ height, offset, bucket_label });
}
try html.writer.print(
\\
\\{s}{s}
, .{ first_label, last_label });
}
try html.writer.writeAll("
");
return .{ .style = style, .html = try html.toOwnedSlice() };
}
fn scaledHeight(count: u64, max_count: u64) u64 {
if (count == 0) return 5;
const count_root = std.math.sqrt(@as(f64, @floatFromInt(count)));
const max_root = std.math.sqrt(@as(f64, @floatFromInt(@max(max_count, 1))));
return 7 + @as(u64, @intFromFloat(@round(count_root * 34.0 / max_root)));
}
fn formatRange(allocator: std.mem.Allocator, first_us: u64, last_us: u64) ![]const u8 {
const first = civilFromMicros(first_us);
const last = civilFromMicros(last_us);
if (first.year == last.year and first.month == last.month and first.day == last.day) {
return std.fmt.allocPrint(allocator, "{s} {d}", .{ monthName(first.month), first.day });
}
if (first.year == last.year) {
return std.fmt.allocPrint(allocator, "{s} {d}-{s} {d}", .{ monthName(first.month), first.day, monthName(last.month), last.day });
}
return std.fmt.allocPrint(allocator, "{s} {d} {d}-{s} {d} {d}", .{ monthName(first.month), first.day, first.year, monthName(last.month), last.day, last.year });
}
fn formatDate(allocator: std.mem.Allocator, timestamp_us: u64) ![]const u8 {
const civil = civilFromMicros(timestamp_us);
return std.fmt.allocPrint(allocator, "{s} {d}", .{ monthName(civil.month), civil.day });
}
fn formatBucketLabel(allocator: std.mem.Allocator, bucket: store.ActivityBucket) ![]const u8 {
const start = civilFromMicros(bucket.start_us);
const end = civilFromMicros(bucket.end_us);
if (start.year == end.year and start.month == end.month and start.day == end.day) {
return std.fmt.allocPrint(
allocator,
"{d} commits · {s} {d}, {d:0>2}:{d:0>2}-{d:0>2}:{d:0>2} UTC",
.{ bucket.count, monthName(start.month), start.day, start.hour, start.minute, end.hour, end.minute },
);
}
return std.fmt.allocPrint(
allocator,
"{d} commits · {s} {d}-{s} {d}",
.{ bucket.count, monthName(start.month), start.day, monthName(end.month), end.day },
);
}
const Civil = struct {
year: i64,
month: u8,
day: u8,
hour: u8,
minute: u8,
};
fn civilFromMicros(timestamp_us: u64) Civil {
const timestamp_s: i64 = @intCast(timestamp_us / std.time.us_per_s);
const days = @divFloor(timestamp_s, std.time.s_per_day);
const seconds_of_day = @mod(timestamp_s, std.time.s_per_day);
const date = civilFromDays(days);
return .{
.year = date.year,
.month = date.month,
.day = date.day,
.hour = @intCast(@divFloor(seconds_of_day, std.time.s_per_hour)),
.minute = @intCast(@divFloor(@mod(seconds_of_day, std.time.s_per_hour), std.time.s_per_min)),
};
}
fn civilFromDays(days_since_epoch: i64) struct { year: i64, month: u8, day: u8 } {
const z = days_since_epoch + 719468;
const era = if (z >= 0) @divFloor(z, 146097) else @divFloor(z - 146096, 146097);
const doe: u64 = @intCast(z - era * 146097);
const yoe = @divFloor(doe - @divFloor(doe, 1460) + @divFloor(doe, 36524) - @divFloor(doe, 146096), 365);
var year: i64 = @intCast(yoe);
year += era * 400;
const doy = doe - (365 * yoe + @divFloor(yoe, 4) - @divFloor(yoe, 100));
const mp = @divFloor(5 * doy + 2, 153);
const day = doy - @divFloor(153 * mp + 2, 5) + 1;
const month = if (mp < 10) mp + 3 else mp - 9;
year += if (month <= 2) 1 else 0;
return .{ .year = year, .month = @intCast(month), .day = @intCast(day) };
}
fn monthName(month: u8) []const u8 {
return switch (month) {
1 => "Jan",
2 => "Feb",
3 => "Mar",
4 => "Apr",
5 => "May",
6 => "Jun",
7 => "Jul",
8 => "Aug",
9 => "Sep",
10 => "Oct",
11 => "Nov",
12 => "Dec",
else => "?",
};
}