diff --git a/Gemfile b/Gemfile index d4e70b1d8..dec41812c 100644 --- a/Gemfile +++ b/Gemfile @@ -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 diff --git a/Gemfile.lock b/Gemfile.lock index 8b3092e8d..51943c8dc 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) @@ -597,6 +599,7 @@ PLATFORMS x86_64-linux-musl DEPENDENCIES + active_flag activerecord-import autotuner (~> 1.0) aws-sdk-s3 diff --git a/app/controllers/inertia_controller.rb b/app/controllers/inertia_controller.rb index 56d602f6b..52211f140 100644 --- a/app/controllers/inertia_controller.rb +++ b/app/controllers/inertia_controller.rb @@ -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 diff --git a/app/javascript/pages/Home/signedIn/IntervalSelect.svelte b/app/javascript/pages/Home/signedIn/IntervalSelect.svelte index 432333948..cc1c471f7 100644 --- a/app/javascript/pages/Home/signedIn/IntervalSelect.svelte +++ b/app/javascript/pages/Home/signedIn/IntervalSelect.svelte @@ -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 = { + const STANDARD_INTERVAL_LABELS: Record = { today: "Today", yesterday: "Yesterday", this_week: "This Week", @@ -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).map( + ([key, cfg]) => [key, cfg.human_name], + ), + ); + + const INTERVAL_LABELS: Record = { + ...STANDARD_INTERVAL_LABELS, + ...EVENT_INTERVAL_LABELS, }; let { diff --git a/app/javascript/pages/Home/signedIn/IntervalSelectBody.svelte b/app/javascript/pages/Home/signedIn/IntervalSelectBody.svelte index 0b254b78b..2bfe9eb74 100644 --- a/app/javascript/pages/Home/signedIn/IntervalSelectBody.svelte +++ b/app/javascript/pages/Home/signedIn/IntervalSelectBody.svelte @@ -1,8 +1,20 @@
@@ -62,7 +142,7 @@ onValueChange={selectInterval} class="flex flex-col gap-1 overflow-hidden" > - {#each INTERVALS as interval} + {#each visibleIntervals as interval (interval.key)} { Time.current.beginning_of_day..Time.current.end_of_day } @@ -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 @@ -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| diff --git a/app/models/user.rb b/app/models/user.rb index 0052fd21c..49f547588 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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 diff --git a/app/services/event_participation_backfill.rb b/app/services/event_participation_backfill.rb new file mode 100644 index 000000000..bd9e04800 --- /dev/null +++ b/app/services/event_participation_backfill.rb @@ -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 diff --git a/app/services/heartbeat_ingest.rb b/app/services/heartbeat_ingest.rb index c76cd0b39..10ee0b0fe 100644 --- a/app/services/heartbeat_ingest.rb +++ b/app/services/heartbeat_ingest.rb @@ -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 @@ -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) diff --git a/config/events.json b/config/events.json new file mode 100644 index 000000000..ed58a9fed --- /dev/null +++ b/config/events.json @@ -0,0 +1,45 @@ +{ + "stardance": { + "human_name": "Stardance", + "starts_at": "2026-05-30 09:00:00", + "ends_at": "2026-08-30 23:59:59", + "timezone": "America/New_York", + "all_day": false, + "bit": 5 + }, + "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 + } +} diff --git a/db/migrate/20260521131313_add_event_participation_to_users.rb b/db/migrate/20260521131313_add_event_participation_to_users.rb new file mode 100644 index 000000000..70003c145 --- /dev/null +++ b/db/migrate/20260521131313_add_event_participation_to_users.rb @@ -0,0 +1,5 @@ +class AddEventParticipationToUsers < ActiveRecord::Migration[8.1] + def change + add_column :users, :event_participation, :integer, default: 0, null: false + end +end diff --git a/db/migrate/20260521132654_add_event_participation_backfilled_to_users.rb b/db/migrate/20260521132654_add_event_participation_backfilled_to_users.rb new file mode 100644 index 000000000..8faa50d7a --- /dev/null +++ b/db/migrate/20260521132654_add_event_participation_backfilled_to_users.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index b59591e7e..f6ecafad5 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -656,6 +656,8 @@ t.boolean "default_timezone_leaderboard", default: true, null: false t.string "deprecated_name" t.string "display_name_override" + t.integer "event_participation", default: 0, null: false + t.boolean "event_participation_backfilled", default: true, null: false t.text "github_access_token" t.string "github_avatar_url" t.string "github_uid" diff --git a/lib/tasks/event_participation.rake b/lib/tasks/event_participation.rake new file mode 100644 index 000000000..c0be95d52 --- /dev/null +++ b/lib/tasks/event_participation.rake @@ -0,0 +1,7 @@ +namespace :event_participation do + desc "Backfill users.event_participation from historical heartbeats" + task backfill: :environment do + count = EventParticipationBackfill.call + puts "Backfilled event participation for #{count} users" + end +end diff --git a/test/services/event_participation_backfill_test.rb b/test/services/event_participation_backfill_test.rb new file mode 100644 index 000000000..a2607511b --- /dev/null +++ b/test/services/event_participation_backfill_test.rb @@ -0,0 +1,23 @@ +require "test_helper" + +class EventParticipationBackfillTest < ActiveSupport::TestCase + test "backfills event participation from historical heartbeats" do + user = User.create!(timezone: "UTC") + user.update_columns(event_participation: 0, event_participation_backfilled: false) + user.heartbeats.create!( + entity: "src/high_seas.rb", + type: "file", + category: "coding", + time: Time.zone.parse("2024-12-15 12:00:00").to_f, + project: "hackatime", + source_type: :test_entry + ) + + assert_equal 1, EventParticipationBackfill.call(scope: User.where(id: user.id)) + + user.reload + assert user.event_participation_backfilled? + assert user.event_participation.set?(:high_seas) + assert_not user.event_participation.set?(:scrapyard) + end +end diff --git a/test/services/heartbeat_ingest_test.rb b/test/services/heartbeat_ingest_test.rb index c5e005474..a9f7a432a 100644 --- a/test/services/heartbeat_ingest_test.rb +++ b/test/services/heartbeat_ingest_test.rb @@ -118,6 +118,19 @@ class HeartbeatIngestTest < ActiveSupport::TestCase assert_no_enqueued_jobs only: DashboardRollupRefreshJob end + test "direct heartbeat ingest records event participation for inserted heartbeats" do + user = User.create!(timezone: "UTC") + high_seas_time = Time.zone.parse("2024-12-15 12:00:00").to_f + + HeartbeatIngest.call( + user: user, + mode: :direct, + heartbeats: [ { entity: "src/event.rb", time: high_seas_time, type: "file" } ] + ) + + assert user.reload.event_participation.set?(:high_seas) + end + test "direct heartbeat ingest resolves last language within the batch" do user = User.create!(timezone: "UTC") now = Time.current.to_f @@ -188,4 +201,34 @@ class HeartbeatIngestTest < ActiveSupport::TestCase heartbeat = user.heartbeats.order(:id).last assert_equal "wakapi_import", heartbeat.source_type end + + test "import heartbeat ingest records event participation only for inserted heartbeats" do + user = User.create!(timezone: "UTC") + high_seas_time = Time.zone.parse("2024-12-15 12:00:00").to_f + heartbeat = { + entity: "/tmp/event.rb", + type: "file", + time: high_seas_time, + project: "hackatime", + language: "Ruby" + } + + HeartbeatIngest.call( + user: user, + mode: :import, + heartbeats: [ heartbeat ] + ) + + assert user.reload.event_participation.set?(:high_seas) + + user.update_column(:event_participation, 0) + + HeartbeatIngest.call( + user: user, + mode: :import, + heartbeats: [ heartbeat ] + ) + + assert_not user.reload.event_participation.set?(:high_seas) + end end