From 3dd13e509ee003592ceb952f5d2c1dc3333b650d Mon Sep 17 00:00:00 2001 From: zzstoatzz Date: Wed, 27 May 2026 22:36:19 -0500 Subject: [PATCH] landing: add activity timescale --- src/http/landing/assets.zig | 17 +++-- src/http/landing/entropy.zig | 117 +++++++++++++++++++++++++++++++++-- 2 files changed, 123 insertions(+), 11 deletions(-) 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} + \\"); + 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 => "?", + }; +} -- 2.51.2