diff --git a/src/http/landing/assets.zig b/src/http/landing/assets.zig index 0b78eb6..62f1de1 100644 --- a/src/http/landing/assets.zig +++ b/src/http/landing/assets.zig @@ -81,32 +81,29 @@ 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 { display: flex; align-items: end; gap: 5px; width: min(100%, 260px); min-height: 54px; margin-top: 18px; padding-top: 18px; } - \\.entropy a { - \\ position: relative; flex: 1 1 0; min-width: 10px; height: var(--h); min-height: 12px; - \\ border-radius: 999px 999px 3px 3px; text-decoration: none; background: - \\ linear-gradient(180deg, var(--signal-a, var(--green)), var(--signal-b, var(--accent)) 58%, var(--signal-c, var(--pink))); - \\ box-shadow: inset 0 1px 0 rgba(255,255,255,.28), 0 5px 16px color-mix(in srgb, var(--signal-a, var(--green)) 18%, transparent); - \\ transform: translateY(var(--y)); opacity: .84; - \\ transition: height .16s ease, opacity .16s ease, transform .16s ease, filter .16s ease; + \\.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-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); \\} - \\.entropy a::after { content: ""; position: absolute; left: 0; right: 0; bottom: -5px; height: 2px; border-radius: 999px; background: color-mix(in srgb, var(--line) 80%, transparent); } - \\.entropy a span { - \\ position: absolute; left: 50%; bottom: calc(100% + 8px); z-index: 2; max-width: min(68vw, 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.2; white-space: nowrap; transform: translate(-50%, 4px); - \\ opacity: 0; pointer-events: none; box-shadow: 0 12px 28px rgba(0,0,0,.22); - \\ transition: opacity .12s ease, transform .12s ease; + \\.entropy-bars::before { + \\ content: ""; position: absolute; left: 0; right: 0; bottom: 14px; height: 1px; + \\ background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--line) 65%, transparent), transparent); + \\ opacity: .8; \\} - \\.entropy a em { - \\ position: absolute; left: 50%; top: calc(100% + 9px); color: var(--dim); font-size: 9px; font-style: normal; - \\ transform: translateX(-50%); opacity: 0; transition: opacity .12s ease; + \\.entropy-bars i { + \\ position: relative; flex: 1 1 0; min-width: 5px; height: var(--h); min-height: 6px; + \\ border-radius: 999px 999px 2px 2px; background: + \\ linear-gradient(180deg, color-mix(in srgb, var(--signal-a, var(--green)) 92%, white 8%), var(--signal-b, var(--accent)) 58%, var(--signal-c, var(--pink))); + \\ box-shadow: inset 0 1px 0 rgba(255,255,255,.35), 0 5px 18px color-mix(in srgb, var(--signal-b, var(--accent)) 18%, transparent); + \\ transform: translateY(var(--y)); opacity: .78; + \\ transition: opacity .16s ease, transform .16s ease, filter .16s ease; \\} - \\.entropy a:hover, .entropy a:focus-visible { opacity: 1; transform: translateY(-3px); filter: saturate(1.12); } - \\.entropy a:hover span, .entropy a:focus-visible span, .entropy a:hover em, .entropy a:focus-visible em { opacity: 1; transform: translate(-50%, 0); } - \\.entropy a:hover em, .entropy a:focus-visible em { transform: translateX(-50%); } + \\.entropy-bars i:hover { opacity: 1; transform: translateY(-3px); filter: saturate(1.18) brightness(1.05); } \\.signal-empty { color: var(--dim); font-size: 11px; } - \\@media (prefers-reduced-motion: reduce) { .entropy a, .entropy a span, .entropy a em { transition: none; } } + \\@media (prefers-reduced-motion: reduce) { .entropy-label, .entropy-bars i { transition: none; } } \\.pulse { \\ margin: 0 0 30px; padding: 14px; \\ border: 1px solid var(--line); border-radius: 8px; background: @@ -213,9 +210,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%, 220px); gap: 4px; min-height: 48px; } - \\ .entropy a span { display: none; } - \\ .entropy a em { top: calc(100% + 7px); } + \\ .entropy { width: min(100%, 230px); min-height: 54px; } + \\ .entropy-label { opacity: 1; transform: none; } + \\ .entropy-bars { gap: 3px; min-height: 36px; } \\} ; diff --git a/src/http/landing/entropy.zig b/src/http/landing/entropy.zig index cc9cdd4..8a22bee 100644 --- a/src/http/landing/entropy.zig +++ b/src/http/landing/entropy.zig @@ -10,6 +10,7 @@ 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| { @@ -23,6 +24,10 @@ pub fn fromState( 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); @@ -36,39 +41,29 @@ pub fn fromState( var html: std.Io.Writer.Allocating = .init(allocator); defer html.deinit(); - try html.writer.writeAll("
"); - if (collections.len == 0) { - try html.writer.writeAll("waiting for records"); + try html.writer.writeAll("
"); + if (activity.len == 0) { + try html.writer.writeAll("waiting for writes"); } else { - const max_count = collections[0].count; - const limit = @min(collections.len, 14); + var max_count: u64 = 1; + var total_count: u64 = 0; + for (activity) |bucket| { + 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(""); var i: usize = 0; - while (i < limit) : (i += 1) { - const collection = collections[i]; - const height = if (max_count == 0) 12 else 12 + (collection.count * 34 / max_count); + while (i < activity.len) : (i += 1) { + const bucket = activity[i]; + const height = 6 + (bucket.count * 34 / max_count); const offset = i % 3; - const nsid = try escapeHtml(allocator, collection.collection); try html.writer.print( - \\ - \\ {s}{d} - \\ - , .{ nsid, height, offset, nsid, collection.count, nsid, collection.count }); + \\ + , .{ height, offset, bucket.count }); } + try html.writer.writeAll(""); } try html.writer.writeAll("
"); return .{ .style = style, .html = try html.toOwnedSlice() }; } - -fn escapeHtml(allocator: std.mem.Allocator, text: []const u8) ![]const u8 { - var out: std.Io.Writer.Allocating = .init(allocator); - defer out.deinit(); - for (text) |c| switch (c) { - '&' => try out.writer.writeAll("&"), - '<' => try out.writer.writeAll("<"), - '>' => try out.writer.writeAll(">"), - '"' => try out.writer.writeAll("""), - '\'' => try out.writer.writeAll("'"), - else => try out.writer.writeByte(c), - }; - return out.toOwnedSlice(); -} diff --git a/src/http/landing/mod.zig b/src/http/landing/mod.zig index 118ccf7..f66c35e 100644 --- a/src/http/landing/mod.zig +++ b/src/http/landing/mod.zig @@ -61,7 +61,8 @@ fn render(allocator: std.mem.Allocator) ![]const u8 { } const collections = try store.listCollectionSummaries(allocator, 10); - const signal = try entropy.fromState(allocator, residents, collections); + const activity = try store.listLandingActivity(allocator, 18); + const signal = try entropy.fromState(allocator, residents, collections, activity); var collection_items: std.Io.Writer.Allocating = .init(allocator); defer collection_items.deinit(); if (collections.len == 0) { diff --git a/src/storage/store.zig b/src/storage/store.zig index ab150b3..d622396 100644 --- a/src/storage/store.zig +++ b/src/storage/store.zig @@ -154,6 +154,12 @@ pub const CollectionSummary = struct { count: u64, }; +pub const ActivityBucket = struct { + start_us: u64, + end_us: u64, + count: u64, +}; + pub const RecentRecord = struct { handle: []const u8, did: []const u8, @@ -418,6 +424,54 @@ pub fn listCollectionSummaries(allocator: std.mem.Allocator, limit: usize) ![]Co return summaries.toOwnedSlice(allocator); } +pub fn listLandingActivity(allocator: std.mem.Allocator, bucket_count: usize) ![]ActivityBucket { + db_mutex.lockUncancelable(store_io); + defer db_mutex.unlock(store_io); + try requireInitialized(); + + const actual_count = if (bucket_count == 0) 18 else @min(bucket_count, 48); + var rows = try conn.rows( + \\SELECT r.rev + \\FROM records r + \\JOIN accounts a ON a.did = r.did + \\WHERE a.handle NOT LIKE '%.test' + \\ORDER BY r.seq ASC + , .{}); + defer rows.deinit(); + + var timestamps: std.ArrayList(u64) = .empty; + defer timestamps.deinit(allocator); + while (rows.next()) |row| { + const timestamp = atid.timestampMicros(row.text(0)) catch continue; + try timestamps.append(allocator, timestamp); + } + if (rows.err) |err| return err; + if (timestamps.items.len == 0) return &.{}; + + const first = timestamps.items[0]; + const last = timestamps.items[timestamps.items.len - 1]; + const span = if (last > first) last - first else 1; + const width = @max(@as(u64, 1), (span + actual_count - 1) / actual_count); + + const buckets = try allocator.alloc(ActivityBucket, actual_count); + for (buckets, 0..) |*bucket, i| { + const start = first + width * i; + bucket.* = .{ + .start_us = start, + .end_us = start + width, + .count = 0, + }; + } + + for (timestamps.items) |timestamp| { + const offset = if (timestamp <= first) 0 else timestamp - first; + const index = @min(actual_count - 1, @as(usize, @intCast(offset / width))); + buckets[index].count += 1; + } + buckets[actual_count - 1].end_us = last; + return buckets; +} + pub fn listLandingRecentRecords(allocator: std.mem.Allocator, limit: usize) ![]RecentRecord { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io);