Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ gem "sentry-rails"

gem "good_job"

# Bitmask flag column on AR models — used for User#event_participation.
gem "active_flag"

# Reduces boot times through caching; required in config/boot.rb
gem "bootsnap", require: false

Expand Down
3 changes: 3 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ GEM
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
active_flag (2.1.1)
activerecord (>= 5)
activejob (8.1.3)
activesupport (= 8.1.3)
globalid (>= 0.3.6)
Expand Down Expand Up @@ -597,6 +599,7 @@ PLATFORMS
x86_64-linux-musl

DEPENDENCIES
active_flag
activerecord-import
autotuner (~> 1.0)
aws-sdk-s3
Expand Down
4 changes: 3 additions & 1 deletion app/controllers/inertia_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,9 @@ def inertia_nav_current_user
country_code: current_user.country_code,
country_name: country&.common_name,
streak_days: current_user.streak_days,
admin_level: current_user.admin_level
admin_level: current_user.admin_level,
created_at: current_user.created_at&.iso8601,
event_participation: current_user.event_participation_backfilled? ? current_user.event_participation.to_a.map(&:to_s) : nil
}
end

Expand Down
24 changes: 17 additions & 7 deletions app/javascript/pages/Home/signedIn/IntervalSelect.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
import { onMount } from "svelte";
import type { Component } from "svelte";
import FilterShell from "./FilterShell.svelte";
import eventsConfig from "../../../../../config/events.json";

const INTERVAL_LABELS: Record<string, string> = {
const STANDARD_INTERVAL_LABELS: Record<string, string> = {
today: "Today",
yesterday: "Yesterday",
this_week: "This Week",
Expand All @@ -12,12 +13,21 @@
last_30_days: "Last 30 Days",
this_year: "This Year",
last_12_months: "Last 12 Months",
stardance: "Stardance",
flavortown: "Flavortown",
summer_of_making: "Summer of Making",
high_seas: "High Seas",
low_skies: "Low Skies",
scrapyard: "Scrapyard Global",
};

type EventConfig = {
human_name: string;
};

const EVENT_INTERVAL_LABELS = Object.fromEntries(
Object.entries(eventsConfig as Record<string, EventConfig>).map(
([key, cfg]) => [key, cfg.human_name],
),
);

const INTERVAL_LABELS: Record<string, string> = {
...STANDARD_INTERVAL_LABELS,
...EVENT_INTERVAL_LABELS,
};

let {
Expand Down
96 changes: 88 additions & 8 deletions app/javascript/pages/Home/signedIn/IntervalSelectBody.svelte
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
<script lang="ts">
import { RadioGroup } from "bits-ui";
import { page } from "@inertiajs/svelte";
import Button from "../../../components/Button.svelte";
import eventsConfig from "../../../../../config/events.json";

const INTERVALS = [
type EventConfig = {
human_name: string;
starts_at: string;
ends_at: string;
timezone: string;
all_day?: boolean;
};

const EVENT_RANGES = eventsConfig as Record<string, EventConfig>;

const STANDARD_INTERVALS = [
{ key: "today", label: "Today" },
{ key: "yesterday", label: "Yesterday" },
{ key: "this_week", label: "This Week" },
Expand All @@ -11,12 +23,14 @@
{ key: "last_30_days", label: "Last 30 Days" },
{ key: "this_year", label: "This Year" },
{ key: "last_12_months", label: "Last 12 Months" },
{ key: "stardance", label: "Stardance" },
{ key: "flavortown", label: "Flavortown" },
{ key: "summer_of_making", label: "Summer of Making" },
{ key: "high_seas", label: "High Seas" },
{ key: "low_skies", label: "Low Skies" },
{ key: "scrapyard", label: "Scrapyard Global" },
] as const;
const EVENT_INTERVALS = Object.entries(EVENT_RANGES).map(([key, cfg]) => ({
key,
label: cfg.human_name,
}));
const INTERVALS = [
...STANDARD_INTERVALS,
...EVENT_INTERVALS,
{ key: "", label: "All Time" },
] as const;

Expand All @@ -38,6 +52,15 @@
let customFrom = $state(from);
let customTo = $state(to);

const currentUser = page.props.layout.nav.current_user!;
const userCreatedAt = currentUser.created_at
? Date.parse(currentUser.created_at)
: 0;
// null = user hasn't been backfilled yet, so we can't trust the bitmap
const participated = currentUser.event_participation
? new Set(currentUser.event_participation)
: null;

$effect(() => {
customFrom = from;
customTo = to;
Expand All @@ -47,13 +70,70 @@
selected && !from && !to ? selected : "",
);

const visibleIntervals = $derived(
INTERVALS.filter((interval) => {
const range = EVENT_RANGES[interval.key];
if (!range) return true;
if (interval.key === selected) return true;

const endsAt = eventEndsAt(range);
// Ended event + backfilled: show only if the user actually participated.
// Otherwise (active/future event, or not-yet-backfilled user) fall back
// to the cheap "did the user exist before the event ended" check.
if (endsAt < Date.now() && participated) {
return participated.has(interval.key);
}
return userCreatedAt <= endsAt;
}),
);

function selectInterval(key: string) {
onapply(key, "", "");
}

function applyCustomRange() {
onapply("", customFrom, customTo);
}

function eventEndsAt(range: EventConfig): number {
const time =
range.all_day === false ? range.ends_at : `${range.ends_at} 23:59:59`;
return zonedTimeToUtcMs(time, range.timezone);
}

function zonedTimeToUtcMs(value: string, timeZone: string): number {
const [datePart, timePart = "00:00:00"] = value.split(" ");
const [year, month, day] = datePart.split("-").map(Number);
const [hour, minute, second] = timePart.split(":").map(Number);
const localAsUtc = Date.UTC(year, month - 1, day, hour, minute, second);
return localAsUtc - timezoneOffsetMs(timeZone, localAsUtc);
}

function timezoneOffsetMs(timeZone: string, timestamp: number): number {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
hour12: false,
hourCycle: "h23",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}).formatToParts(new Date(timestamp));
const values = Object.fromEntries(
parts.map((part) => [part.type, part.value]),
);
const zonedAsUtc = Date.UTC(
Number(values.year),
Number(values.month) - 1,
Number(values.day),
Number(values.hour),
Number(values.minute),
Number(values.second),
);
return zonedAsUtc - timestamp;
}
</script>

<div class="m-0 max-h-56 overflow-y-auto">
Expand All @@ -62,7 +142,7 @@
onValueChange={selectInterval}
class="flex flex-col gap-1 overflow-hidden"
>
{#each INTERVALS as interval}
{#each visibleIntervals as interval (interval.key)}
<RadioGroup.Item
value={interval.key}
class="flex w-full items-center rounded-md px-3 py-2 text-left text-sm text-muted outline-none transition-all duration-150 hover:bg-surface-100/60 hover:text-surface-content data-[highlighted]:bg-surface-100/70 data-[state=checked]:bg-primary/12 data-[state=checked]:text-surface-content"
Expand Down
7 changes: 6 additions & 1 deletion app/javascript/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ export type FlashData = {
alert?: string;
};

export type SharedProps = {};
export type SharedProps = {
layout: LayoutProps;
};

export type NavLink = {
label: string;
Expand All @@ -29,6 +31,9 @@ export type NavCurrentUser = {
country_name?: string | null;
streak_days?: number | null;
admin_level: AdminLevel;
created_at?: string | null;
// null until the user has been backfilled — fall back to created_at then.
event_participation: string[] | null;
};

export type LayoutNav = {
Expand Down
45 changes: 37 additions & 8 deletions app/models/concerns/time_range_filterable.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
module TimeRangeFilterable
extend ActiveSupport::Concern

RANGES = {
STANDARD_RANGES = {
today: {
human_name: "Today",
calculate: -> { Time.current.beginning_of_day..Time.current.end_of_day }
Expand Down Expand Up @@ -33,15 +33,36 @@ module TimeRangeFilterable
last_12_months: {
human_name: "Last 12 Months",
calculate: -> { (Time.current - 12.months).beginning_of_day..Time.current.end_of_day }
},
stardance: { human_name: "Stardance", calculate: -> { TimeRangeFilterable.datetime_range("2026-05-30 09:00:00", "2026-08-30 23:59:59") } },
flavortown: { human_name: "Flavortown", calculate: -> { TimeRangeFilterable.event_range("2025-12-15", "2026-04-30") } },
summer_of_making: { human_name: "Summer of Making", calculate: -> { TimeRangeFilterable.event_range("2025-06-16", "2025-09-30") } },
high_seas: { human_name: "High Seas", calculate: -> { TimeRangeFilterable.event_range("2024-10-30", "2025-01-31") } },
low_skies: { human_name: "Low Skies", calculate: -> { TimeRangeFilterable.event_range("2024-10-3", "2025-01-12") } },
scrapyard: { human_name: "Scrapyard Global", calculate: -> { TimeRangeFilterable.event_range("2025-03-14", "2025-03-17") } }
}
}.freeze

EVENTS_CONFIG_PATH = Rails.root.join("config", "events.json").freeze
EVENT_DEFINITIONS = JSON.parse(File.read(EVENTS_CONFIG_PATH)).freeze

EVENT_KEYS = begin
pairs = EVENT_DEFINITIONS.map do |key, cfg|
bit = cfg["bit"]
raise "events.json: #{key} missing 'bit'" unless bit.is_a?(Integer) && bit >= 0

[ bit, key.to_sym ]
end.sort_by(&:first)

expected = (0...pairs.length).to_a
actual = pairs.map(&:first)
raise "events.json: bits must be contiguous 0..N (got #{actual.inspect})" unless actual == expected

pairs.map(&:last).freeze
end

EVENT_RANGES = EVENT_DEFINITIONS.each_with_object({}) do |(key, cfg), memo|
memo[key.to_sym] = {
human_name: cfg["human_name"],
calculate: -> { TimeRangeFilterable.event_range_from_config(cfg) }
}
end.freeze

RANGES = STANDARD_RANGES.merge(EVENT_RANGES).freeze

def self.event_range(from_date, to_date, timezone: "America/New_York")
Time.use_zone(timezone) do
Time.zone.parse(from_date).beginning_of_day..Time.zone.parse(to_date).end_of_day
Expand All @@ -54,6 +75,14 @@ def self.datetime_range(from_datetime, to_datetime, timezone: "America/New_York"
end
end

def self.event_range_from_config(config)
if config["all_day"] == false
datetime_range(config.fetch("starts_at"), config.fetch("ends_at"), timezone: config.fetch("timezone"))
else
event_range(config.fetch("starts_at"), config.fetch("ends_at"), timezone: config.fetch("timezone"))
end
end

class_methods do
def time_range_filterable_field(field_name)
RANGES.each do |name, config|
Expand Down
5 changes: 5 additions & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ class User < ApplicationRecord

has_subscriptions

# Tracks which Hack Club events the user has coded during. EVENT_KEYS is
# ordered by each event's explicit bit value so positions stay stable across
# events.json edits.
flag :event_participation, TimeRangeFilterable::EVENT_KEYS

USERNAME_MAX_LENGTH = 21 # going over 21 overflows the navbar
DISPLAY_NAME_MAX_LENGTH = 80

Expand Down
30 changes: 30 additions & 0 deletions app/services/event_participation_backfill.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
class EventParticipationBackfill
def self.call(scope: User.where(event_participation_backfilled: false)) = new(scope: scope).call

def initialize(scope:)
@scope = scope
end

def call
count = 0
@scope.find_each do |user|
user.update_columns(
event_participation: event_participation_mask(user),
event_participation_backfilled: true,
updated_at: Time.current
)
count += 1
end
count
end

private

def event_participation_mask(user)
TimeRangeFilterable::EVENT_KEYS.each_with_index.reduce(0) do |mask, (key, index)|
range = TimeRangeFilterable::EVENT_RANGES.fetch(key).fetch(:calculate).call
participated = user.heartbeats.where(time: range.begin.to_f..range.end.to_f).exists?
participated ? mask | (1 << index) : mask
end
end
end
32 changes: 30 additions & 2 deletions app/services/heartbeat_ingest.rb
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,10 @@ def persist_direct_heartbeat(attrs)
)

persisted = result.any? ? Heartbeat.new(result.first) : @user.heartbeats.find_by!(fields_hash:)
self.class.schedule_rollup_refresh(user: @user) if result.any? && @schedule_rollup_refresh
if result.any?
record_event_participation([ attrs[:time] ])
self.class.schedule_rollup_refresh(user: @user) if @schedule_rollup_refresh
end
[ persisted, !result.any? ]
end

Expand Down Expand Up @@ -165,7 +168,32 @@ def flush_import_batch(seen_hashes)
return 0 if seen_hashes.empty?
timestamp = Time.current
records = seen_hashes.values.map { |r| r.merge(created_at: timestamp, updated_at: timestamp) }
ActiveRecord::Base.logger.silence { Heartbeat.insert_all(records, unique_by: [ :fields_hash ]).length }

result = ActiveRecord::Base.logger.silence do
Heartbeat.insert_all(records, unique_by: [ :fields_hash ], returning: [ "time" ])
end
record_event_participation(result.rows.flatten)
result.length
end

# OR each touched event's bit into the user's event_participation. The
# in-memory `unset?` check short-circuits before issuing any SQL once the
# bit is set, which is the common case after the first heartbeat per event.
def record_event_participation(times)
return if times.blank?

TimeRangeFilterable::EVENT_RANGES.each do |key, cfg|
next if @user.event_participation.set?(key)

range = cfg[:calculate].call
from_i = range.begin.to_i
to_i = range.end.to_i
next unless times.any? { |t| t >= from_i && t <= to_i }

mask = User.active_flags[:event_participation].to_i(key)
User.where(id: @user.id).update_all("event_participation = COALESCE(event_participation, 0) | #{mask}")
@user.event_participation.set(key) # keep in-memory copy in sync for subsequent calls
end
end

def parse_user_agent(user_agent)
Expand Down
Loading