diff --git a/app/assets/stylesheets/activity.css b/app/assets/stylesheets/activity.css new file mode 100644 index 00000000..cd59ef0c --- /dev/null +++ b/app/assets/stylesheets/activity.css @@ -0,0 +1,110 @@ +.recent-activity { + margin-top: 1rem; +} + +.recent-activity h3 { + font-size: 1.2rem; + margin-bottom: 0.75rem; + color: var(--muted-color); +} + +.activity-list { + max-height: 600px; + overflow-y: auto; +} + +.activity-item { + padding: 0.75rem 0; + border-bottom: 1px solid rgba(0, 0, 0, 0.05); + font-size: 0.9rem; +} + +.activity-item:last-child { + border-bottom: none; +} + +.activity-content { + margin: 0.5rem 0; +} + +.activity-content p { + margin: 0; +} + +.activity-time { + color: var(--muted-color); + font-size: 0.75rem; +} + +.activity-actions { + display: flex; + margin-top: 0.5rem; +} + +.give-kudos-btn { + display: inline-flex; + align-items: center; + background-color: rgba(var(--primary-color-rgb), 0.1); + color: var(--primary-color); + padding: 0.25rem 0.5rem; + border-radius: 4px; + text-decoration: none; + font-size: 0.75rem; + transition: background-color 0.2s; +} + +.give-kudos-btn:hover { + background-color: rgba(var(--primary-color-rgb), 0.2); +} + +.kudos-icon { + margin-right: 0.25rem; + font-style: normal; +} + +.kudos-count { + margin-left: 0.25rem; + background: rgba(var(--primary-color-rgb), 0.2); + border-radius: 10px; + padding: 0.1rem 0.4rem; + font-size: 0.7rem; +} + +.kudos-given, +.kudos-received { + display: inline-flex; + align-items: center; + color: var(--primary-color); + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + background-color: rgba(var(--primary-color-rgb), 0.1); +} + +/* Dark mode overrides */ +@media (prefers-color-scheme: dark) { + .activity-item { + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + } + + .activity-time { + color: rgba(255, 255, 255, 0.5); + } + + .give-kudos-btn { + background-color: rgba(var(--primary-color-rgb), 0.2); + } + + .give-kudos-btn:hover { + background-color: rgba(var(--primary-color-rgb), 0.3); + } + + .kudos-count { + background: rgba(var(--primary-color-rgb), 0.3); + } + + .kudos-given, + .kudos-received { + background-color: rgba(var(--primary-color-rgb), 0.2); + } +} \ No newline at end of file diff --git a/app/controllers/project_milestones_controller.rb b/app/controllers/project_milestones_controller.rb new file mode 100644 index 00000000..996aee23 --- /dev/null +++ b/app/controllers/project_milestones_controller.rb @@ -0,0 +1,31 @@ +class ProjectMilestonesController < ApplicationController + before_action :authenticate_user! + + def give_kudos + milestone = ProjectMilestone.find(params[:id]) + + # Don't allow users to give kudos to themselves + if milestone.user_id == current_user.id + return render json: { error: "You cannot give kudos to yourself" }, status: :unprocessable_entity + end + + # Check if user already gave kudos + if milestone.kudos_from?(current_user.id) + return render json: { error: "You already gave kudos for this milestone" }, status: :unprocessable_entity + end + + kudos = ProjectMilestoneKudos.new( + project_milestone: milestone, + user_id: current_user.id + ) + + if kudos.save + render json: { + success: true, + kudos_count: milestone.reload.kudos_count + } + else + render json: { error: kudos.errors.full_messages.join(", ") }, status: :unprocessable_entity + end + end +end diff --git a/app/javascript/controllers/kudos_controller.js b/app/javascript/controllers/kudos_controller.js new file mode 100644 index 00000000..bb879e71 --- /dev/null +++ b/app/javascript/controllers/kudos_controller.js @@ -0,0 +1,43 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["button"] + + connect() { + console.log("Kudos controller connected") + } + + giveKudos(event) { + event.preventDefault() + + const button = event.currentTarget + const url = button.getAttribute("href") + + fetch(url, { + method: "POST", + headers: { + "X-CSRF-Token": document.querySelector("meta[name='csrf-token']").content, + "Accept": "application/json" + } + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + // Replace the button with the kudos count + const container = button.closest(".activity-actions") + + const kudosEl = document.createElement("span") + kudosEl.classList.add("kudos-given") + kudosEl.innerHTML = `👏 ${data.kudos_count}` + + container.innerHTML = "" + container.appendChild(kudosEl) + } else { + console.error("Error giving kudos:", data.error) + } + }) + .catch(error => { + console.error("Error giving kudos:", error) + }) + } +} \ No newline at end of file diff --git a/app/jobs/project_milestone_check_job.rb b/app/jobs/project_milestone_check_job.rb new file mode 100644 index 00000000..6457830c --- /dev/null +++ b/app/jobs/project_milestone_check_job.rb @@ -0,0 +1,50 @@ +class ProjectMilestoneCheckJob < ApplicationJob + queue_as :default + + def perform + # Get all users with heartbeats in the last hour + active_users = Heartbeat.where("created_at > ?", 1.hour.ago) + .distinct.pluck(:user_id) + + active_users.each do |user_id| + check_hourly_milestones(user_id) + end + end + + private + + def check_hourly_milestones(user_id) + user = User.find_by(id: user_id) + return unless user + + # Get projects with significant time in the last period + project_durations = user.heartbeats.today.group(:project).duration_seconds + + project_durations.each do |project, duration| + next if project.blank? + + # Convert to hours + hours = (duration / 3600.0).floor + next if hours < 1 + + # Check if we already have a milestone for this hour count + existing = ProjectMilestone.where( + user_id: user_id, + project_name: project, + milestone_type: :hourly, + milestone_value: hours + ).where("created_at > ?", 1.day.ago).exists? + + # If no milestone exists, create one + unless existing + ProjectMilestone.create!( + user_id: user_id, + project_name: project, + milestone_type: :hourly, + milestone_value: hours + ) + Rails.logger.info "Created hourly milestone for user #{user_id} on project #{project}: #{hours} hours" + end + end + end +end diff --git a/app/models/project_milestone.rb b/app/models/project_milestone.rb new file mode 100644 index 00000000..c33faec3 --- /dev/null +++ b/app/models/project_milestone.rb @@ -0,0 +1,38 @@ +class ProjectMilestone < ApplicationRecord + belongs_to :user, foreign_key: :user_id + + has_many :project_milestone_kudos, class_name: "ProjectMilestoneKudos" + + validates :project_name, presence: true + validates :milestone_type, presence: true + validates :milestone_value, presence: true + + # We keep this because I don't want to change the database schema + enum :milestone_type, { + hourly: 0, + daily: 1, + weekly: 2 + } + + # Get milestones for display in the sidebar + def self.recent_for_display(limit = 20) + where(milestone_type: :hourly) + .order(created_at: :desc) + .includes(:user, :project_milestone_kudos) + .limit(limit) + end + + # Check if the current user has given kudos to this milestone + def kudos_from?(user_id) + project_milestone_kudos.where(user_id: user_id).exists? + end + + # Get the kudos count + def kudos_count + project_milestone_kudos.count + end + + def formatted_message + "completed #{milestone_value} hour#{'s' if milestone_value > 1} on #{project_name}" + end +end diff --git a/app/models/project_milestone_kudos.rb b/app/models/project_milestone_kudos.rb new file mode 100644 index 00000000..7f652fbd --- /dev/null +++ b/app/models/project_milestone_kudos.rb @@ -0,0 +1,6 @@ +class ProjectMilestoneKudos < ApplicationRecord + belongs_to :project_milestone + belongs_to :user + + validates :project_milestone_id, uniqueness: { scope: :user_id } +end diff --git a/app/views/shared/_nav.html.erb b/app/views/shared/_nav.html.erb index ea99a10c..60e3b328 100644 --- a/app/views/shared/_nav.html.erb +++ b/app/views/shared/_nav.html.erb @@ -75,4 +75,44 @@ <% end %> <% end %> + + <% if current_user %> +
+ +
+

Recent activity

+ +
+ <% ProjectMilestone.recent_for_display(10).each do |milestone| %> +
+ <%= render "shared/user_mention", user: milestone.user %> +
+

<%= milestone.formatted_message %>

+
+ <% if current_user.id != milestone.user_id %> + <% if milestone.kudos_from?(current_user.id) %> + + 👏 <%= milestone.kudos_count %> + + <% else %> + <%= link_to give_kudos_project_milestone_path(milestone), + data: { turbo_method: :post, controller: "kudos", action: "click->kudos#giveKudos" }, + class: "give-kudos-btn" do %> + 👏 Give kudos + <% if milestone.kudos_count > 0 %><%= milestone.kudos_count %><% end %> + <% end %> + <% end %> + <% elsif milestone.kudos_count > 0 %> + + 👏 <%= milestone.kudos_count %> + + <% end %> +
+
+ <%= time_ago_in_words(milestone.created_at) %> ago +
+ <% end %> +
+
+ <% end %> \ No newline at end of file diff --git a/config/initializers/good_job.rb b/config/initializers/good_job.rb index f36065a2..37a56fb3 100644 --- a/config/initializers/good_job.rb +++ b/config/initializers/good_job.rb @@ -37,6 +37,10 @@ scan_github_repos: { cron: "0 10 * * *", class: "ScanGithubReposJob" + }, + project_milestone_check: { + cron: "*/5 * * * *", + class: "ProjectMilestoneCheckJob" } } end diff --git a/config/routes.rb b/config/routes.rb index d1a788f7..4a68579f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -91,4 +91,10 @@ def self.matches?(request) end resources :scrapyard_leaderboards, only: [ :index, :show ] + + resources :project_milestones, only: [] do + member do + post :give_kudos + end + end end diff --git a/db/migrate/20250320052532_create_project_milestones.rb b/db/migrate/20250320052532_create_project_milestones.rb new file mode 100644 index 00000000..4850db9f --- /dev/null +++ b/db/migrate/20250320052532_create_project_milestones.rb @@ -0,0 +1,17 @@ +class CreateProjectMilestones < ActiveRecord::Migration[8.0] + def change + create_table :project_milestones do |t| + t.bigint :user_id, null: false + t.string :project_name, null: false + t.integer :milestone_type, null: false, default: 0 + t.integer :milestone_value, null: false + t.boolean :notified, default: false + + t.timestamps + end + + add_index :project_milestones, :user_id + add_index :project_milestones, [ :user_id, :project_name, :milestone_type ] + add_index :project_milestones, :created_at + end +end diff --git a/db/migrate/20250320052612_create_project_milestone_kudos.rb b/db/migrate/20250320052612_create_project_milestone_kudos.rb new file mode 100644 index 00000000..08560f19 --- /dev/null +++ b/db/migrate/20250320052612_create_project_milestone_kudos.rb @@ -0,0 +1,12 @@ +class CreateProjectMilestoneKudos < ActiveRecord::Migration[8.0] + def change + create_table :project_milestone_kudos do |t| + t.references :project_milestone, null: false, foreign_key: true + t.bigint :user_id, null: false + + t.timestamps + end + + add_index :project_milestone_kudos, [ :project_milestone_id, :user_id ], unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index db64e5cc..1e839ab8 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_03_19_193636) do +ActiveRecord::Schema[8.0].define(version: 2025_03_20_052612) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -175,6 +175,28 @@ t.integer "period_type", default: 0, null: false end + create_table "project_milestone_kudos", force: :cascade do |t| + t.bigint "project_milestone_id", null: false + t.bigint "user_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["project_milestone_id", "user_id"], name: "idx_on_project_milestone_id_user_id_218c1b857a", unique: true + t.index ["project_milestone_id"], name: "index_project_milestone_kudos_on_project_milestone_id" + end + + create_table "project_milestones", force: :cascade do |t| + t.bigint "user_id", null: false + t.string "project_name", null: false + t.integer "milestone_type", default: 0, null: false + t.integer "milestone_value", null: false + t.boolean "notified", default: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["created_at"], name: "index_project_milestones_on_created_at" + t.index ["user_id", "project_name", "milestone_type"], name: "idx_on_user_id_project_name_milestone_type_06e1e9487d" + t.index ["user_id"], name: "index_project_milestones_on_user_id" + end + create_table "project_repo_mappings", force: :cascade do |t| t.bigint "user_id", null: false t.string "project_name", null: false @@ -269,6 +291,7 @@ add_foreign_key "heartbeats", "users" add_foreign_key "leaderboard_entries", "leaderboards" add_foreign_key "leaderboard_entries", "users" + add_foreign_key "project_milestone_kudos", "project_milestones" add_foreign_key "project_repo_mappings", "users" add_foreign_key "sign_in_tokens", "users" end