Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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;
}),
);

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
75 changes: 22 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,32 @@ 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

# mahad says: NEVER remove entries from the events JSON
# if you need to get rid of an event, add a retired flag or something
EVENT_KEYS = EVENT_DEFINITIONS.keys.sort.map(&:to_sym).freeze

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
28 changes: 26 additions & 2 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,9 +197,30 @@ def flush_import_batch(seen_hashes)
record.merge(created_at: timestamp, updated_at: timestamp)
end

ActiveRecord::Base.logger.silence do
inserted = ActiveRecord::Base.logger.silence do
Heartbeat.insert_all(records, unique_by: [ :fields_hash ]).length
end
record_event_participation(records.map { |r| r[:time] })
inserted
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

def parse_user_agent(user_agent)
Expand Down
32 changes: 32 additions & 0 deletions config/events.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"flavortown": {
"human_name": "Flavortown",
"starts_at": "2025-12-15",
"ends_at": "2026-04-30",
"timezone": "America/New_York"
},
"summer_of_making": {
"human_name": "Summer of Making",
"starts_at": "2025-06-16",
"ends_at": "2025-09-30",
"timezone": "America/New_York"
},
"high_seas": {
"human_name": "High Seas",
"starts_at": "2024-10-30",
"ends_at": "2025-01-31",
"timezone": "America/New_York"
},
"low_skies": {
"human_name": "Low Skies",
"starts_at": "2024-10-03",
"ends_at": "2025-01-12",
"timezone": "America/New_York"
},
"scrapyard": {
"human_name": "Scrapyard Global",
"starts_at": "2025-03-14",
"ends_at": "2025-03-17",
"timezone": "America/New_York"
}
}
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