diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 7775f1b78..eb29ffb78 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -82,6 +82,7 @@ @use "pages/certification/ships/index" as certification_ships_index; @use "pages/certification/ships/show" as certification_ships_show; @use "pages/certification/ships/monitor" as certification_ships_monitor; +@use "pages/certification/ships/spot_checks" as certification_ships_spot_checks; @use "pages/certification/mystats" as certification_mystats; @use "pages/certification/payouts" as certification_payouts; @use "pages/raffle"; diff --git a/app/assets/stylesheets/pages/certification/ships/_spot_checks.scss b/app/assets/stylesheets/pages/certification/ships/_spot_checks.scss new file mode 100644 index 000000000..345a14526 --- /dev/null +++ b/app/assets/stylesheets/pages/certification/ships/_spot_checks.scss @@ -0,0 +1,245 @@ + +.spot-checks { + &__stats { + display: flex; + flex-wrap: wrap; + gap: var(--space-m); + padding: var(--space-m) var(--space-l); + margin-bottom: var(--space-l); + background: var(--card-bg, rgba(255, 255, 255, 0.04)); + border: 1px solid var(--card-border, rgba(255, 255, 255, 0.1)); + border-radius: 12px; + } + + &__stat { + display: flex; + flex-direction: column; + gap: 2px; + padding-right: var(--space-l); + border-right: 1px solid rgba(255, 255, 255, 0.1); + + &:last-child { + border-right: none; + padding-right: 0; + } + } + + &__stat-value { + font-size: 1.5rem; + font-weight: 700; + line-height: 1; + font-variant-numeric: tabular-nums; + + small { + font-size: 0.8rem; + font-weight: 400; + color: var(--muted, rgba(255, 255, 255, 0.7)); + } + + &--good { + color: #4ade80; + } + + &--bad { + color: #f87171; + } + + &--muted { + color: var(--muted, rgba(255, 255, 255, 0.7)); + } + } + + &__stat-label { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted, rgba(255, 255, 255, 0.7)); + } + + &__cell-spotcheck { + min-width: 260px; + vertical-align: top !important; + padding-top: 12px !important; + padding-bottom: 12px !important; + } + + &__form { + display: flex; + flex-direction: column; + gap: var(--space-xs); + } + + &__form-radios { + display: flex; + gap: var(--space-s); + align-items: center; + } + + &__radio-label { + display: inline-flex; + align-items: center; + gap: var(--space-xxs); + padding: 4px 10px; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.15); + cursor: pointer; + font-size: 0.85rem; + font-weight: 600; + transition: + border-color 150ms ease, + background 150ms ease; + + &--good { + &:has(input:checked), + &:hover { + border-color: #4ade80; + background: rgba(74, 222, 128, 0.12); + color: #4ade80; + } + } + + &--bad { + &:has(input:checked), + &:hover { + border-color: #f87171; + background: rgba(248, 113, 113, 0.12); + color: #f87171; + } + } + } + + &__radio { + position: absolute; + opacity: 0; + width: 0; + height: 0; + pointer-events: none; + } + + &__radio-text { + user-select: none; + } + + &__justification-wrap { + display: flex; + flex-direction: column; + gap: var(--space-xxs); + } + + &__field-label { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted, rgba(255, 255, 255, 0.7)); + } + + &__textarea { + width: 100%; + padding: var(--space-xs) var(--space-s); + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.15); + background: rgba(0, 0, 0, 0.3); + color: var(--color-space-text); + font-family: inherit; + font-size: 0.85rem; + resize: vertical; + color-scheme: dark; + + &:focus { + outline: none; + border-color: var(--color-brand-mint); + } + } + + &__form-actions { + display: flex; + align-items: center; + gap: var(--space-s); + } + + &__result { + display: flex; + flex-direction: column; + gap: 4px; + padding: var(--space-xs) var(--space-s); + border-radius: 8px; + margin-bottom: var(--space-xs); + + &--good { + background: rgba(74, 222, 128, 0.08); + border: 1px solid rgba(74, 222, 128, 0.25); + } + + &--bad { + background: rgba(248, 113, 113, 0.08); + border: 1px solid rgba(248, 113, 113, 0.25); + } + } + + &__result-badge { + font-size: 0.8rem; + font-weight: 700; + + .spot-checks__result--good & { + color: #4ade80; + } + + .spot-checks__result--bad & { + color: #f87171; + } + } + + &__result-justification { + margin: 0; + font-size: 0.8rem; + color: var(--muted, rgba(255, 255, 255, 0.7)); + font-style: italic; + } + + &__result-meta { + font-size: 0.72rem; + color: var(--muted, rgba(255, 255, 255, 0.5)); + } + + // --- Edit override disclosure -------------------------------------------- + &__override { + margin-top: var(--space-xxs); + } + + &__override-toggle { + font-size: 0.75rem; + color: var(--muted, rgba(255, 255, 255, 0.6)); + cursor: pointer; + user-select: none; + + &:hover { + color: var(--color-brand-mint); + } + + &::marker { + content: ""; + } + + &::-webkit-details-marker { + display: none; + } + } + + &__feedback-hint { + color: var(--color-brand-peach); + font-size: 0.78rem; + } + + &__mobile-card { + display: flex; + flex-direction: column; + gap: var(--space-xs); + padding: var(--space-m); + } + + &__mobile-form { + margin-top: var(--space-xs); + border-top: 1px solid rgba(255, 255, 255, 0.08); + padding-top: var(--space-xs); + } +} diff --git a/app/controllers/admin/certification/ship_spot_checks_controller.rb b/app/controllers/admin/certification/ship_spot_checks_controller.rb new file mode 100644 index 000000000..449d6cc37 --- /dev/null +++ b/app/controllers/admin/certification/ship_spot_checks_controller.rb @@ -0,0 +1,101 @@ +class Admin::Certification::ShipSpotChecksController < Admin::Certification::ApplicationController + before_action :set_body_class + + def index + authorize ::Certification::ShipSpotCheck, policy_class: Admin::Certification::ShipSpotCheckPolicy + + @reviewer_id = params[:reviewer_id].presence + @status = params[:status].presence_in(%w[approved returned all]) || "all" + @sort = params[:sort] == "oldest" ? "oldest" : "newest" + @rating = params[:rating].presence_in(%w[good bad unchecked]) || "unchecked" + + @reviewers = User.joins( + "INNER JOIN certification_ship_reviews ON certification_ship_reviews.reviewer_id = users.id" + ).where.not("certification_ship_reviews.status" => 0) + .distinct + .order(:display_name) + + scope = ::Certification::Ship + .joins(:project, :reviewer) + .where.not(status: :pending) + .where(projects: { deleted_at: nil }) + .includes(:reviewer, :spot_check, project: { memberships: :user }) + + scope = scope.where(reviewer_id: @reviewer_id) if @reviewer_id.present? + scope = scope.where(status: @status) unless @status == "all" + + scope = case @rating + when "unchecked" + scope.where.missing(:spot_check) + when "good" + scope.joins(:spot_check).where(certification_ship_spot_checks: { rating: :good }) + when "bad" + scope.joins(:spot_check).where(certification_ship_spot_checks: { rating: :bad }) + else + scope + end + + scope = scope.order(decided_at: @sort == "oldest" ? :asc : :desc) + + @pagy, @ships = pagy(:offset, scope, limit: 25) + + @spot_checks_by_ship = ::Certification::ShipSpotCheck + .where(ship_id: @ships.map(&:id)) + .index_by(&:ship_id) + + @stats = spot_check_stats + + @lb_period = params[:lb].presence_in(%w[daily weekly alltime]) || "daily" + @leaderboards = { + "daily" => ::Certification::ShipSpotCheck.leaderboard(:daily), + "weekly" => ::Certification::ShipSpotCheck.leaderboard(:weekly), + "alltime" => ::Certification::ShipSpotCheck.leaderboard(:alltime) + } + end + + def create + authorize ::Certification::ShipSpotCheck, policy_class: Admin::Certification::ShipSpotCheckPolicy + + @ship = ::Certification::Ship.find(params[:ship_id]) + @spot_check = ::Certification::ShipSpotCheck.find_or_initialize_by(ship_id: @ship.id) + @spot_check.assign_attributes(spot_check_params.merge(checker: current_user)) + + if @spot_check.save + redirect_back_or_to spot_checks_admin_certification_ships_path(return_params), + notice: "Spot check recorded for \"#{@ship.project.title}\"." + else + redirect_back_or_to spot_checks_admin_certification_ships_path(return_params), + alert: "Couldn't save spot check: #{@spot_check.errors.full_messages.to_sentence}" + end + end + + private + + def set_body_class + @body_class = "app-layout-page" + end + + def spot_check_params + params.require(:certification_ship_spot_check).permit(:rating, :justification) + end + + def return_params + params.permit(:reviewer_id, :status, :sort, :rating, :page) + end + + def spot_check_stats + all_decided = ::Certification::Ship.where.not(status: :pending).count + checked = ::Certification::ShipSpotCheck.count + good_count = ::Certification::ShipSpotCheck.good.count + bad_count = ::Certification::ShipSpotCheck.bad.count + + { + total_decided: all_decided, + checked: checked, + unchecked: all_decided - checked, + good: good_count, + bad: bad_count, + coverage_pct: all_decided.zero? ? nil : (checked * 100.0 / all_decided).round(1) + } + end +end diff --git a/app/controllers/admin/certification/ships_controller.rb b/app/controllers/admin/certification/ships_controller.rb index 2e9b148f5..b0acc248d 100644 --- a/app/controllers/admin/certification/ships_controller.rb +++ b/app/controllers/admin/certification/ships_controller.rb @@ -71,6 +71,7 @@ def logs def show authorize @ship @reviewed_today = ::Certification::Ship.reviewed_today(current_user) + @spot_check = ::Certification::ShipSpotCheck.find_by(ship_id: @ship.id) if current_user.admin? || current_user.super_admin? end def set_project_type diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js index 40742bf1a..194a8f8cd 100644 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -301,6 +301,9 @@ application.register("slack-doodle", SlackDoodleController); import SortableController from "./sortable_controller"; application.register("sortable", SortableController); +import SpotCheckFormController from "./spot_check_form_controller"; +application.register("spot-check-form", SpotCheckFormController); + import StarImageInputController from "./star_image_input_controller"; application.register("star-image-input", StarImageInputController); diff --git a/app/javascript/controllers/spot_check_form_controller.js b/app/javascript/controllers/spot_check_form_controller.js new file mode 100644 index 000000000..6f1e46631 --- /dev/null +++ b/app/javascript/controllers/spot_check_form_controller.js @@ -0,0 +1,12 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["justification"] + + toggleJustification(event) { + const isBad = event.target.value === "bad" + this.justificationTargets.forEach(el => { + el.style.display = isBad ? "" : "none" + }) + } +} diff --git a/app/models/certification/ship.rb b/app/models/certification/ship.rb index f1531b9fa..d1c07ad61 100644 --- a/app/models/certification/ship.rb +++ b/app/models/certification/ship.rb @@ -44,6 +44,8 @@ class Ship < ApplicationRecord belongs_to :reviewer, class_name: "User", optional: true belongs_to :returned_by, class_name: "User", optional: true + has_one :spot_check, class_name: "Certification::ShipSpotCheck", foreign_key: :ship_id, dependent: :destroy + has_paper_trail # The reviewer records a walkthrough and passes it along with the verdict. diff --git a/app/models/certification/ship_spot_check.rb b/app/models/certification/ship_spot_check.rb new file mode 100644 index 000000000..b25b94a9e --- /dev/null +++ b/app/models/certification/ship_spot_check.rb @@ -0,0 +1,56 @@ +# == Schema Information +# +# Table name: certification_ship_spot_checks +# +# id :bigint not null, primary key +# justification :text +# rating :integer default("good"), not null +# created_at :datetime not null +# updated_at :datetime not null +# checker_id :bigint not null +# ship_id :bigint not null +# +# Indexes +# +# index_certification_ship_spot_checks_on_checker_id (checker_id) +# index_certification_ship_spot_checks_on_ship_id (ship_id) +# index_ship_spot_checks_unique_per_ship (ship_id) UNIQUE +# +# Foreign Keys +# +# fk_rails_... (checker_id => users.id) +# fk_rails_... (ship_id => certification_ship_reviews.id) +# +module Certification + class ShipSpotCheck < ApplicationRecord + self.table_name = "certification_ship_spot_checks" + + belongs_to :ship, class_name: "Certification::Ship", foreign_key: :ship_id + belongs_to :checker, class_name: "User" + + has_paper_trail + + enum :rating, { good: 0, bad: 1 }, default: :good + + validates :rating, presence: true + validates :justification, presence: true, if: :bad? + validates :justification, length: { maximum: 5_000 }, allow_blank: true + validates :ship_id, uniqueness: { message: "has already been spot-checked" } + + def self.leaderboard(period) + scope = joins(:checker) + scope = case period + when :daily then scope.where("certification_ship_spot_checks.created_at >= ?", Time.current.beginning_of_day) + when :weekly then scope.where("certification_ship_spot_checks.created_at >= ?", Time.current.beginning_of_week) + else scope + end + + scope + .group("users.id", "users.display_name") + .order(Arel.sql("count_all DESC")) + .limit(10) + .count + .map { |(id, name), count| { id: id, name: name, count: count } } + end + end +end diff --git a/app/models/reviewer_payout_request.rb b/app/models/reviewer_payout_request.rb index 52ebc7e87..bc7874410 100644 --- a/app/models/reviewer_payout_request.rb +++ b/app/models/reviewer_payout_request.rb @@ -12,9 +12,6 @@ # paid_amount :integer # paid_at :datetime # created_at :datetime not null -# updated_at :datetime not null -# admin_id :bigint -# user_id :bigint not null # # Indexes # diff --git a/app/policies/admin/certification/ship_spot_check_policy.rb b/app/policies/admin/certification/ship_spot_check_policy.rb new file mode 100644 index 000000000..2c9f2b8be --- /dev/null +++ b/app/policies/admin/certification/ship_spot_check_policy.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class Admin::Certification::ShipSpotCheckPolicy < ApplicationPolicy + def index? = user&.admin? + def create? = user&.admin? +end diff --git a/app/views/admin/certification/ship_spot_checks/_form.html.erb b/app/views/admin/certification/ship_spot_checks/_form.html.erb new file mode 100644 index 000000000..51c2f92c8 --- /dev/null +++ b/app/views/admin/certification/ship_spot_checks/_form.html.erb @@ -0,0 +1,40 @@ +<% filter_params = request.query_parameters.slice(:reviewer_id, :status, :sort, :rating, :page) %> +<%= form_with model: existing || Certification::ShipSpotCheck.new, + url: admin_certification_ship_spot_checks_path(ship_id: ship.id, **filter_params), + class: "spot-checks__form", + data: { controller: "spot-check-form" } do |f| %> + +
+ + +
+ +
"> + <%= f.label :justification, "Justification (required for bad reviews)", class: "spot-checks__field-label" %> + <%= f.text_area :justification, + rows: 3, + placeholder: "Explain why this review was poor quality…", + class: "spot-checks__textarea" %> +
+ +
+ <%= f.submit existing ? "Update" : "Record", + class: "action-btn action-btn--small action-btn--primary" %> +
+<% end %> diff --git a/app/views/admin/certification/ship_spot_checks/index.html.erb b/app/views/admin/certification/ship_spot_checks/index.html.erb new file mode 100644 index 000000000..d5854d94b --- /dev/null +++ b/app/views/admin/certification/ship_spot_checks/index.html.erb @@ -0,0 +1,249 @@ +<% content_for :title, "Spot checks" %> + +
+
+ <%= link_to "← Back to queue", admin_certification_ships_path, class: "ship-queue__back" %> + +
+
+

Spot checks

+
+
+ + <%# Stats overview %> + <%# Stats & Leaderboard overview %> +
+
+
+
+ Total reviewed + <%= @stats[:total_decided] %> +
+
+ Spot-checked + <%= @stats[:checked] %> +
+
+ Unchecked + <%= @stats[:unchecked] %> +
+
+ Good reviews + + <%= @stats[:good] %> + + + <% if @stats[:checked] > 0 %><%= (@stats[:good] * 100.0 / @stats[:checked]).round %>%<% end %> + +
+
+ Bad reviews + + <%= @stats[:bad] %> + + + <% if @stats[:checked] > 0 %><%= (@stats[:bad] * 100.0 / @stats[:checked]).round %>%<% end %> + +
+ <% if @stats[:coverage_pct] %> +
+ Coverage + <%= @stats[:coverage_pct] %>% +
+ <% end %> +
+
+ +
+
+

Leaderboard

+
+ <% %w[daily weekly alltime].each do |period| %> + + <% end %> +
+
+ + <% %w[daily weekly alltime].each do |period| %> +
    " + data-certification--queue-target="panel" + data-period="<%= period %>"> + <% rows = @leaderboards[period] %> + <% if rows.empty? %> +
  1. No spot checks yet.
  2. + <% else %> + <% rows.each_with_index do |row, i| %> +
  3. + <%= i + 1 %> + <%= row[:name] %> + <%= row[:count] %> +
  4. + <% end %> + <% end %> +
+ <% end %> +
+
+ + <%# Filters %> + <%= form_with url: spot_checks_admin_certification_ships_path, method: :get, + data: { certification__queue_target: "filters" }, class: "ship-queue__filters" do %> +
+ + <%= select_tag :reviewer_id, + options_from_collection_for_select(@reviewers, :id, :display_name, @reviewer_id&.to_i), + id: "filter-reviewer", class: "ship-queue__select", include_blank: "All reviewers", + data: { action: "change->certification--queue#submit" } %> +
+
+ + <%= select_tag :status, + options_for_select([["All decided", "all"], ["Approved", "approved"], ["Returned", "returned"]], @status), + id: "filter-status", class: "ship-queue__select", + data: { action: "change->certification--queue#submit" } %> +
+
+ + <%= select_tag :rating, + options_for_select([["Unchecked", "unchecked"], ["Good", "good"], ["Bad", "bad"], ["All", "all"]], @rating), + id: "filter-rating", class: "ship-queue__select", + data: { action: "change->certification--queue#submit" } %> +
+
+ + <%= select_tag :sort, + options_for_select([["Newest first", "newest"], ["Oldest first", "oldest"]], @sort), + id: "filter-sort", class: "ship-queue__select", + data: { action: "change->certification--queue#submit" } %> +
+
+ + <%= link_to "Reset", spot_checks_admin_certification_ships_path, class: "ship-queue__reset" %> +
+ <% end %> + + <% if @ships.empty? %> +
+

No reviews match these filters.

+
+ <% else %> +
+ + + + + + + + + + + <% @ships.each do |ship| %> + <% owner = ship.project.memberships.find(&:owner?)&.user %> + <% existing = @spot_checks_by_ship[ship.id] %> + + + + + + + <% end %> + +
ProjectFeedbackStatusSpot check
+
+ <%= link_to ship.project.title, admin_certification_ship_path(ship), class: "ship-queue__project-title", target: "_blank" %> + #<%= ship.id %> +
+
+ by <%= owner&.display_name || "—" %> + + <% if ship.reviewer %> + reviewed by <%= ship.reviewer.display_name %> + + <% end %> + <%= time_ago_in_words(ship.decided_at || ship.updated_at) %> ago +
+
+ <%= ship.status.capitalize %> + + <% if existing %> +
+ + <%= existing.rating == "good" ? "✓ Good" : "✗ Bad" %> + + <% if existing.justification.present? %> +

<%= truncate(existing.justification, length: 80) %>

+ <% end %> + by <%= existing.checker&.display_name %> +
+ +
+ Edit + <%= render "form", ship: ship, existing: existing %> +
+ <% else %> + <%= render "form", ship: ship, existing: nil %> + <% end %> +
+
+ + <%# Mobile for sure %> +
+ <% @ships.each do |ship| %> + <% owner = ship.project.memberships.find(&:owner?)&.user %> + <% existing = @spot_checks_by_ship[ship.id] %> +
+
+ <%= link_to ship.project.title, admin_certification_ship_path(ship), class: "ship-queue__project-title", target: "_blank" %> + <%= ship.status.capitalize %> +
+
+ #<%= ship.id %> + + by <%= owner&.display_name || "—" %> + + reviewed by <%= ship.reviewer&.display_name || "—" %> +
+
+ <% if existing %> +
+ <%= existing.rating == "good" ? "✓ Good" : "✗ Bad" %> + <% if existing.justification.present? %> +

<%= truncate(existing.justification, length: 60) %>

+ <% end %> +
+
+ Edit + <%= render "form", ship: ship, existing: existing %> +
+ <% else %> + <%= render "form", ship: ship, existing: nil %> + <% end %> +
+
+ <% end %> +
+ <% end %> + + <% if @pagy.pages > 1 %> +
+ <%== @pagy.series_nav %> +
+ <% end %> +
+
diff --git a/app/views/admin/certification/ships/index.html.erb b/app/views/admin/certification/ships/index.html.erb index fbd651625..4ea6f7aaf 100644 --- a/app/views/admin/certification/ships/index.html.erb +++ b/app/views/admin/certification/ships/index.html.erb @@ -23,6 +23,8 @@ <% if current_user.admin? || current_user.super_admin? %> <%= link_to "Monitor", monitor_admin_certification_ships_path, class: "action-btn action-btn--large action-btn--secondary" %> + <%= link_to "Spot checks", spot_checks_admin_certification_ships_path, + class: "action-btn action-btn--large action-btn--secondary" %> <% end %> <%= link_to "Logs", logs_admin_certification_ships_path, class: "action-btn action-btn--large action-btn--secondary" %> @@ -308,7 +310,7 @@ <% end %> <%= wait_days %>d - <% if ship_event&.hours_at_ship.present? && ship_event.hours_ai_ship > 0 %> + <% if ship_event&.hours_at_ship.present? && ship_event.hours_at_ship > 0 %> <%= number_with_precision(ship_event.hours_at_ship, precision: 1) %>h <% end %> diff --git a/app/views/admin/certification/ships/show.html.erb b/app/views/admin/certification/ships/show.html.erb index ee8338d78..53cd1f315 100644 --- a/app/views/admin/certification/ships/show.html.erb +++ b/app/views/admin/certification/ships/show.html.erb @@ -95,6 +95,29 @@ <% end %> + <% if !@ship.pending? && (current_user.admin? || current_user.super_admin?) %> +
+

Spot Check

+ <% if @spot_check %> +
+ + <%= @spot_check.rating == "good" ? "✓ Good" : "✗ Bad" %> + + <% if @spot_check.justification.present? %> +

<%= @spot_check.justification %>

+ <% end %> + by <%= @spot_check.checker&.display_name %> +
+
+ Edit spot check + <%= render "admin/certification/ship_spot_checks/form", ship: @ship, existing: @spot_check %> +
+ <% else %> + <%= render "admin/certification/ship_spot_checks/form", ship: @ship, existing: nil %> + <% end %> +
+ <% end %> +