diff --git a/src/http/landing/assets.zig b/src/http/landing/assets.zig
index 62f1de1..70f2eb5 100644
--- a/src/http/landing/assets.zig
+++ b/src/http/landing/assets.zig
@@ -81,9 +81,8 @@ pub const css =
\\h1 { margin: 0; color: var(--text); font-size: clamp(36px, 11vw, 74px); line-height: 0.95; letter-spacing: 0; }
\\.lede { margin: 14px 0 0; max-width: 47ch; color: var(--muted); font-size: 15px; }
\\.lede a, .lede code { color: var(--green); }
- \\.entropy { width: min(100%, 300px); min-height: 64px; margin-top: 19px; display: grid; gap: 8px; }
- \\.entropy-label { color: var(--dim); font-size: 10px; opacity: 0; transform: translateY(3px); transition: opacity .14s ease, transform .14s ease; }
- \\.entropy:hover .entropy-label, .entropy:focus-within .entropy-label { opacity: 1; transform: translateY(0); }
+ \\.entropy { width: min(100%, 330px); min-height: 76px; margin-top: 19px; display: grid; gap: 6px; }
+ \\.entropy-label { color: var(--muted); font-size: 10px; }
\\.entropy-bars {
\\ position: relative; display: flex; align-items: end; gap: 4px; min-height: 43px; padding-bottom: 5px;
\\ border-bottom: 1px solid color-mix(in srgb, var(--line) 78%, transparent);
@@ -102,6 +101,14 @@ pub const css =
\\ transition: opacity .16s ease, transform .16s ease, filter .16s ease;
\\}
\\.entropy-bars i:hover { opacity: 1; transform: translateY(-3px); filter: saturate(1.18) brightness(1.05); }
+ \\.entropy-bars i:hover::after {
+ \\ content: attr(data-label); position: absolute; left: 50%; bottom: calc(100% + 8px); z-index: 2;
+ \\ width: max-content; max-width: min(72vw, 260px); padding: 5px 7px; border: 1px solid var(--line);
+ \\ border-radius: 8px; background: color-mix(in srgb, var(--panel) 94%, transparent); color: var(--text);
+ \\ font-size: 10px; line-height: 1.25; white-space: normal; transform: translateX(-50%);
+ \\ box-shadow: 0 12px 28px rgba(0,0,0,.22);
+ \\}
+ \\.entropy-axis { display: flex; justify-content: space-between; color: var(--dim); font-size: 9px; }
\\.signal-empty { color: var(--dim); font-size: 11px; }
\\@media (prefers-reduced-motion: reduce) { .entropy-label, .entropy-bars i { transition: none; } }
\\.pulse {
@@ -210,9 +217,9 @@ pub const css =
\\ .icon-link, .settings { width: 32px; height: 32px; border-radius: 10px; }
\\ .logo-link::after { inset: 5px; border-radius: 8px; }
\\ .icon-link img { width: 20px; height: 20px; }
- \\ .entropy { width: min(100%, 230px); min-height: 54px; }
- \\ .entropy-label { opacity: 1; transform: none; }
+ \\ .entropy { width: min(100%, 250px); min-height: 68px; }
\\ .entropy-bars { gap: 3px; min-height: 36px; }
+ \\ .entropy-bars i:hover::after { display: none; }
\\}
;
diff --git a/src/http/landing/entropy.zig b/src/http/landing/entropy.zig
index 8a22bee..2507008 100644
--- a/src/http/landing/entropy.zig
+++ b/src/http/landing/entropy.zig
@@ -51,19 +51,124 @@ pub fn fromState(
max_count = @max(max_count, bucket.count);
total_count += bucket.count;
}
- try html.writer.print("{d} writes over time", .{total_count});
- try html.writer.writeAll("");
+ 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} writes · {s}
+ \\
+ , .{ total_count, range_label });
var i: usize = 0;
while (i < activity.len) : (i += 1) {
const bucket = activity[i];
- const height = 6 + (bucket.count * 34 / max_count);
+ 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.count });
+ \\
+ , .{ height, offset, bucket_label, bucket_label });
}
- try html.writer.writeAll("");
+ 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} writes · {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} writes · {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 => "?",
+ };
+}