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 @@ -41,6 +41,9 @@ gem "sentry-rails"

gem "good_job"

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

# Slack client
gem "slack-ruby-client"

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 @@ -659,6 +661,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 @@ -148,7 +148,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
52 changes: 44 additions & 8 deletions app/javascript/pages/Home/signedIn/IntervalSelect.svelte
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
<script lang="ts">
import { Popover, 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;
};
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,13 +21,16 @@
{ key: "last_30_days", label: "Last 30 Days" },
{ key: "this_year", label: "This Year" },
{ key: "last_12_months", label: "Last 12 Months" },
{ 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" },
];
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;
];

let {
selected,
Expand All @@ -31,6 +44,29 @@
onchange: (interval: string, from: string, to: string) => void;
} = $props();

const currentUser = page.props.layout.nav.current_user!;
const userCreatedAt = Date.parse(currentUser.created_at!);
// 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;

const visibleIntervals = $derived(
INTERVALS.filter((interval) => {
const range = EVENT_RANGES[interval.key];
if (!range) return true;
if (interval.key === selected) return true;
const endsAt = Date.parse(range.ends_at);
// 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;
Comment on lines +59 to +66
}),
);

let open = $state(false);
let customFrom = $state("");
let customTo = $state("");
Expand Down Expand Up @@ -132,7 +168,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
85 changes: 32 additions & 53 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,63 +33,42 @@ module TimeRangeFilterable
last_12_months: {
human_name: "Last 12 Months",
calculate: -> { (Time.current - 12.months).beginning_of_day..Time.current.end_of_day }
},
flavortown: {
human_name: "Flavortown",
calculate: -> {
timezone = "America/New_York"
Time.use_zone(timezone) do
from = Time.parse("2025-12-15").beginning_of_day
to = Time.parse("2026-04-30").end_of_day
from.beginning_of_day..to.end_of_day
end
}
},
summer_of_making: {
human_name: "Summer of Making",
calculate: -> {
timezone = "America/New_York"
Time.use_zone(timezone) do
from = Time.parse("2025-06-16").beginning_of_day
to = Time.parse("2025-09-30").end_of_day
from.beginning_of_day..to.end_of_day
end
}
},
high_seas: {
human_name: "High Seas",
calculate: -> {
timezone = "America/New_York"
Time.use_zone(timezone) do
from = Time.parse("2024-10-30").beginning_of_day
to = Time.parse("2025-01-31").end_of_day
from.beginning_of_day..to.end_of_day
end
}
},
low_skies: {
human_name: "Low Skies",
calculate: -> {
timezone = "America/New_York"
Time.use_zone(timezone) do
from = Time.parse("2024-10-3").beginning_of_day
to = Time.parse("2025-01-12").end_of_day
from.beginning_of_day..to.end_of_day
end
}
},
scrapyard: {
human_name: "Scrapyard Global",
}
}.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|
timezone = cfg["timezone"]
starts_at = cfg["starts_at"]
ends_at = cfg["ends_at"]
memo[key.to_sym] = {
human_name: cfg["human_name"],
calculate: -> {
timezone = "America/New_York"
Time.use_zone(timezone) do
from = Time.parse("2025-03-14").beginning_of_day
to = Time.parse("2025-03-17").end_of_day
from.beginning_of_day..to.end_of_day
Time.zone.parse(starts_at).beginning_of_day..Time.zone.parse(ends_at).end_of_day
end
}
}
}.freeze
end.freeze

RANGES = STANDARD_RANGES.merge(EVENT_RANGES).freeze

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

has_subscriptions

# Tracks which Hack Club events the user has coded during. EVENT_KEYS is
# sorted alphabetically so bit positions don't shift with events.json edits.
flag :event_participation, TimeRangeFilterable::EVENT_KEYS

USERNAME_MAX_LENGTH = 21 # going over 21 overflows the navbar

has_paper_trail
Expand Down
30 changes: 27 additions & 3 deletions app/services/heartbeat_ingest.rb
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,10 @@ def persist_direct_heartbeat(attrs)
@user.heartbeats.find_by!(fields_hash: fields_hash)
end

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 @@ -194,8 +197,29 @@ def flush_import_batch(seen_hashes)
record.merge(created_at: timestamp, updated_at: timestamp)
end

ActiveRecord::Base.logger.silence do
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 }

User.where(id: @user.id).event_participations.set_all!(key)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Wrong method name — NoMethodError at runtime

active_flag exposes a class/relation method named after the column verbatim: event_participation (singular). Calling event_participations (plural) on the ActiveRecord::Relation will raise NoMethodError: undefined method 'event_participations' on every heartbeat ingested during an active event window, silently failing to record participation. The correct call is User.where(id: @user.id).event_participation.set_all!(key).

Per the gem's README: Profile.languages.set_all!(:chinese) — the receiver matches the flag column name exactly.

Suggested change
User.where(id: @user.id).event_participations.set_all!(key)
User.where(id: @user.id).event_participation.set_all!(key)
Prompt To Fix With AI
This is a comment left during a code review.
Path: app/services/heartbeat_ingest.rb
Line: 221

Comment:
**Wrong method name — `NoMethodError` at runtime**

`active_flag` exposes a class/relation method named after the column verbatim: `event_participation` (singular). Calling `event_participations` (plural) on the `ActiveRecord::Relation` will raise `NoMethodError: undefined method 'event_participations'` on every heartbeat ingested during an active event window, silently failing to record participation. The correct call is `User.where(id: @user.id).event_participation.set_all!(key)`.

Per the gem's README: `Profile.languages.set_all!(:chinese)` — the receiver matches the flag column name exactly.

```suggestion
      User.where(id: @user.id).event_participation.set_all!(key)
```

How can I resolve this? If you propose a fix, please make it concise.

@user.event_participation.set(key) # keep in-memory copy in sync for subsequent calls
end
end

Expand Down
37 changes: 37 additions & 0 deletions config/events.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"flavortown": {
"human_name": "Flavortown",
"starts_at": "2025-12-15",
"ends_at": "2026-04-30",
"timezone": "America/New_York",
"bit": 0
},
"summer_of_making": {
"human_name": "Summer of Making",
"starts_at": "2025-06-16",
"ends_at": "2025-09-30",
"timezone": "America/New_York",
"bit": 1
},
"high_seas": {
"human_name": "High Seas",
"starts_at": "2024-10-30",
"ends_at": "2025-01-31",
"timezone": "America/New_York",
"bit": 2
},
"low_skies": {
"human_name": "Low Skies",
"starts_at": "2024-10-03",
"ends_at": "2025-01-12",
"timezone": "America/New_York",
"bit": 3
},
"scrapyard": {
"human_name": "Scrapyard Global",
"starts_at": "2025-03-14",
"ends_at": "2025-03-17",
"timezone": "America/New_York",
"bit": 4
}
}
5 changes: 5 additions & 0 deletions db/migrate/20260521131313_add_event_participation_to_users.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class AddEventParticipationToUsers < ActiveRecord::Migration[8.1]
def change
add_column :users, :event_participation, :integer, default: 0, null: false
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
class AddEventParticipationBackfilledToUsers < ActiveRecord::Migration[8.1]
def up
# Existing users start false (need backfill), future inserts default true
# (new users have no history to backfill)
add_column :users, :event_participation_backfilled, :boolean, default: false, null: false
change_column_default :users, :event_participation_backfilled, true
end

def down
remove_column :users, :event_participation_backfilled
end
end
Comment thread
skyfallwastaken marked this conversation as resolved.
Loading