diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 482852c04..0901c82de 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -74,6 +74,7 @@ // Pages @use "pages/onboarding" as onboarding_pages; @use "pages/projects/new"; +@use "pages/ship_funnel"; @use "pages/project_setup"; @use "pages/projects/show"; @use "pages/projects/readme"; diff --git a/app/assets/stylesheets/pages/_ship_funnel.scss b/app/assets/stylesheets/pages/_ship_funnel.scss new file mode 100644 index 000000000..9021078c5 --- /dev/null +++ b/app/assets/stylesheets/pages/_ship_funnel.scss @@ -0,0 +1,245 @@ +.ship-funnel { + --ship-funnel-path: #8dffa0; + --ship-funnel-rejected: var(--color-brand-salmon); + --ship-funnel-team: var(--color-brand-blue); + --ship-funnel-builder: var(--color-brand-yellow); + --ship-funnel-deflated: var(--color-brand-lilac); + --ship-funnel-bug: var(--color-brand-cream); + --ship-funnel-muted: var(--color-space-text-muted); + + display: grid; + gap: var(--space-l); + margin: 0 auto; + max-width: 1400px; + + &__back, + &__meta, + &__footer { + color: var(--ship-funnel-muted); + } + + &__header { + align-items: flex-start; + display: flex; + flex-wrap: wrap; + gap: var(--space-s); + justify-content: space-between; + } + + &__title, + &__meta { + margin: 0; + } + + &__controls { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: var(--space-s); + } + + &__toggle { + background: var(--color-space-bg-2); + border: 1px solid var(--color-space-border); + border-radius: 999px; + display: inline-flex; + gap: 2px; + padding: 3px; + } + + :is(.admin-content, body) & button#{&}__toggle-button { + background: transparent; + border: 0; + border-radius: 999px; + box-shadow: none; + color: var(--ship-funnel-muted); + font-size: var(--font-size-s); + font-weight: 500; + min-height: 0; + padding: 5px 14px; + transition: + background 0.15s, + color 0.15s; + + &:hover { + background: var(--color-space-surface-faint); + box-shadow: none; + color: var(--color-brand-off-white); + } + + &.is-active, + &.is-active:hover { + background: var(--color-brand-mint); + color: var(--color-space-bg); + font-weight: 600; + } + + &:focus-visible { + outline: 2px solid var(--color-brand-mint); + outline-offset: 2px; + } + } + + &__checkbox { + align-items: center; + border: 1px solid var(--color-space-border); + border-radius: 999px; + color: var(--ship-funnel-muted); + cursor: pointer; + display: inline-flex; + font-size: var(--font-size-s); + gap: var(--space-xxs); + padding: 5px 14px 5px 10px; + transition: + background 0.15s, + color 0.15s; + + &:hover { + background: var(--color-space-surface-faint); + color: var(--color-brand-off-white); + } + + &:has(input:checked) { + border-color: var(--color-brand-mint); + color: var(--color-brand-off-white); + } + + input { + accent-color: var(--color-brand-mint); + margin: 0; + } + } + + :is(.admin-content, body) & button#{&}__refresh { + background: transparent; + border: 1px solid var(--color-space-border); + border-radius: 999px; + box-shadow: none; + color: var(--ship-funnel-muted); + font-size: var(--font-size-s); + min-height: 0; + padding: 5px 14px; + + &:hover { + background: var(--color-space-surface-faint); + box-shadow: none; + color: var(--color-brand-off-white); + } + } + + &__computed { + color: var(--ship-funnel-muted); + font-size: var(--font-size-s); + } + + &__chart { + overflow-x: auto; + } + + &__svg { + display: block; + } + + &__column-label { + fill: var(--ship-funnel-muted); + font-size: 11px; + letter-spacing: 0.12em; + text-transform: uppercase; + } + + &__label { + fill: var(--color-brand-off-white); + font-family: var(--font-family-text); + font-size: 12.5px; + paint-order: stroke; + stroke: var(--color-space-bg); + stroke-linejoin: round; + stroke-width: 4px; + + &--stage { + font-size: 13px; + } + } + + &__label-name { + font-weight: 600; + } + + &__label-value { + fill: var(--ship-funnel-muted); + font-variant-numeric: tabular-nums; + } + + &__link { + fill: none; + stroke-opacity: 0.42; + transition: stroke-opacity 0.15s; + + &:hover { + stroke-opacity: 0.85; + } + + &--path { + stroke: var(--ship-funnel-path); + stroke-opacity: 0.26; + } + + &--rejected { + stroke: var(--ship-funnel-rejected); + } + + &--team { + stroke: var(--ship-funnel-team); + } + + &--builder { + stroke: var(--ship-funnel-builder); + } + + &--deflated { + stroke: var(--ship-funnel-deflated); + } + + &--bug { + stroke: var(--ship-funnel-bug); + } + } + + &__node { + &--path { + fill: var(--ship-funnel-path); + } + + &--rejected { + fill: var(--ship-funnel-rejected); + } + + &--team { + fill: var(--ship-funnel-team); + } + + &--builder { + fill: var(--ship-funnel-builder); + } + + &--deflated { + fill: var(--ship-funnel-deflated); + } + + &--bug { + fill: var(--color-space-bg); + stroke: var(--ship-funnel-bug); + stroke-dasharray: 3 2; + } + } + + &__footer { + font-size: var(--font-size-s); + margin: 0; + } +} + +// The public page has no admin padding around it. +.ship-funnel-page { + padding: var(--space-l) var(--space-m); +} diff --git a/app/controllers/stats/ship_funnel_controller.rb b/app/controllers/stats/ship_funnel_controller.rb new file mode 100644 index 000000000..f450fb336 --- /dev/null +++ b/app/controllers/stats/ship_funnel_controller.rb @@ -0,0 +1,17 @@ +module Stats + class ShipFunnelController < ApplicationController + def show + authorize :ship_funnel + + @funnel = policy(:ship_funnel).refresh? ? ShipFunnel.fetch : ShipFunnel.cached + ShipFunnelRefreshJob.perform_later unless @funnel + end + + def refresh + authorize :ship_funnel + + ShipFunnel.refresh + redirect_to stats_ship_funnel_path, notice: "Jeremy Funnel recomputed." + end + end +end diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js index 55bd5da21..05b7fc20c 100644 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -253,6 +253,9 @@ application.register("hcb-chart", HcbChartController); import HourFunnelController from "./hour_funnel_controller"; application.register("hour-funnel", HourFunnelController); +import ShipFunnelController from "./ship_funnel_controller"; +application.register("ship-funnel", ShipFunnelController); + import HeroWarpController from "./hero_warp_controller"; application.register("hero-warp", HeroWarpController); diff --git a/app/javascript/controllers/ship_funnel_controller.js b/app/javascript/controllers/ship_funnel_controller.js new file mode 100644 index 000000000..5a32a466e --- /dev/null +++ b/app/javascript/controllers/ship_funnel_controller.js @@ -0,0 +1,401 @@ +import { Controller } from "@hotwired/stimulus"; +import { groups, select, sum } from "d3"; +import { sankey, sankeyLeft, sankeyLinkHorizontal } from "d3-sankey"; + +export default class extends Controller { + static targets = ["chart", "typeButton", "unitButton", "unshippedToggle"]; + static values = { + links: Array, + unshippedLinks: Array, + unshipped: { type: Boolean, default: false }, + type: { type: String, default: "both" }, + unit: { type: String, default: "hours" }, + }; + + static MIN_COLUMN = 175; + static HEIGHT = 900; + static LABEL_ROOM = 190; + static NODE_PADDING = 30; + + static KIND = { + Devlogged: "path", + Shipped: "path", + "Shipwrights review": "path", + "Build review": "path", + "Reship, skipped review": "path", + "GOI review": "path", + "Fraud check": "path", + "Hardware, skipped": "path", + Airtable: "path", + Unified: "path", + Fraud: "rejected", + "Rejected by ship review": "rejected", + "Rejected by GOI": "rejected", + "Duplicate in Unified": "rejected", + "Rejected by sync": "rejected", + "Waiting on Shipwrights": "team", + "Waiting on build review": "team", + "Waiting on GOI": "team", + "Waiting on fraud team": "team", + "Waiting on final pass": "team", + "Final-pass error, fixable": "team", + "Approved, no GOI review": "bug", + "Returned, <30d ago": "builder", + "Returned, >30d ago": "builder", + "Misfiled, back to design": "builder", + "Deflated by GOI": "deflated", + "Deducted by fraud team": "deflated", + "Whole-minute rounding": "deflated", + "Not reviewed by GOI": "bug", + "Lost to key collision": "bug", + "Cut off by a later ship": "bug", + "Cleared, never synced": "bug", + "Not shipped yet": "builder", + "Devlog deleted": "rejected", + "Fraud before shipping": "rejected", + "Design phase (hardware)": "deflated", + }; + + static LEGEND = [ + ["path", "Reached Unified"], + ["rejected", "Rejected, fraud included"], + ["team", "Waiting on a team"], + ["builder", "Waiting on the builder"], + ["deflated", "Deflated"], + ["bug", "Lost to a bug"], + ]; + + static ORDER = [ + "Devlogged", + "Shipped", + "Not shipped yet", + "Design phase (hardware)", + "Devlog deleted", + "Fraud before shipping", + "Reship, skipped review", + "Shipwrights review", + "Build review", + "Fraud", + "GOI review", + "Waiting on Shipwrights", + "Waiting on build review", + "Returned, <30d ago", + "Returned, >30d ago", + "Cut off by a later ship", + "Misfiled, back to design", + "Rejected by ship review", + "Fraud check", + "Hardware, skipped", + "Deflated by GOI", + "Waiting on GOI", + "Rejected by GOI", + "Approved, no GOI review", + "Whole-minute rounding", + "Not reviewed by GOI", + "Airtable", + "Deducted by fraud team", + "Waiting on fraud team", + "Unified", + "Waiting on final pass", + "Final-pass error, fixable", + "Lost to key collision", + "Duplicate in Unified", + "Rejected by sync", + "Cleared, never synced", + ]; + + static COLUMN = { + Devlogged: "Devlogged", + Shipped: "Shipped", + "Shipwrights review": "Shipwrights", + "Build review": "Build review", + "GOI review": "GOI review", + "Fraud check": "Fraud check", + "Hardware, skipped": "Fraud check (skipped)", + Airtable: "Airtable", + Unified: "Final pass", + }; + + connect() { + this.resize = () => this.render(); + window.addEventListener("resize", this.resize); + this.render(); + } + + disconnect() { + window.removeEventListener("resize", this.resize); + } + + toggleUnshipped(event) { + this.unshippedValue = event.target.checked; + } + + unshippedValueChanged() { + if (this.hasChartTarget) this.render(); + } + + setType(event) { + this.typeValue = event.params.type; + } + + setUnit(event) { + this.unitValue = event.params.unit; + } + + typeValueChanged() { + this.markActive( + this.typeButtonTargets, + "shipFunnelTypeParam", + this.typeValue, + ); + if (this.hasChartTarget) this.render(); + } + + unitValueChanged() { + this.markActive( + this.unitButtonTargets, + "shipFunnelUnitParam", + this.unitValue, + ); + if (this.hasChartTarget) this.render(); + } + + markActive(buttons, param, value) { + buttons.forEach((button) => { + const active = button.dataset[param] === value; + button.classList.toggle("is-active", active); + button.setAttribute("aria-pressed", String(active)); + }); + } + + linksForView() { + const unit = this.unitValue; + const totals = new Map(); + const unshipped = + this.unshippedValue && unit === "hours" ? this.unshippedLinksValue : []; + for (const link of [...unshipped, ...this.linksValue]) { + if (this.typeValue !== "both" && link.kind !== this.typeValue) continue; + const value = link[unit]; + if (!(value > 0)) continue; + const key = `${link.source}\u0000${link.target}`; + totals.set(key, (totals.get(key) || 0) + value); + } + return [...totals].map(([key, value]) => { + const [source, target] = key.split("\u0000"); + return { source, target, value }; + }); + } + + format(value) { + if (this.unitValue === "ships") + return `${Math.round(value).toLocaleString()} ships`; + return value >= 1000 + ? `${(value / 1000).toFixed(1)}k h` + : `${Math.round(value)} h`; + } + + render() { + const { + MIN_COLUMN, + HEIGHT, + LABEL_ROOM, + NODE_PADDING, + KIND, + LEGEND, + ORDER, + COLUMN, + } = this.constructor; + const links = this.linksForView(); + const targets = new Set(links.map((link) => link.target)); + const total = sum( + links.filter((link) => !targets.has(link.source)), + (link) => link.value, + ); + const percent = (value) => `${((100 * value) / (total || 1)).toFixed(1)}%`; + const kindOf = (name) => KIND[name] || "bug"; + const order = (name) => + ORDER.includes(name) ? ORDER.indexOf(name) : ORDER.length; + const unitLabel = this.unitValue === "hours" ? " h" : " ships"; + + const svg = select(this.chartTarget); + svg.selectAll("*").remove(); + if (!links.length) return; + + const depth = new Map(); + const deepest = (name) => { + if (!depth.has(name)) { + depth.set(name, 0); + const into = links.filter((link) => link.target === name); + depth.set( + name, + into.length + ? Math.max(...into.map((link) => deepest(link.source) + 1)) + : 0, + ); + } + return depth.get(name); + }; + const columnCount = + Math.max(...links.map((link) => deepest(link.target))) + 1; + const WIDTH = Math.max( + this.chartTarget.parentElement.clientWidth, + (columnCount - 1) * MIN_COLUMN + LABEL_ROOM + 14, + ); + svg + .attr("width", WIDTH) + .attr("height", HEIGHT) + .attr("viewBox", `0 0 ${WIDTH} ${HEIGHT}`); + + const names = [ + ...new Set(links.flatMap((link) => [link.source, link.target])), + ]; + const TOP = 40; + const layout = sankey() + .nodeId((node) => node.name) + .nodeAlign(sankeyLeft) + .nodeWidth(12) + .nodePadding(NODE_PADDING) + .nodeSort((a, b) => order(a.name) - order(b.name)) + .linkSort( + (a, b) => + order(a.target.name) - order(b.target.name) || + order(a.source.name) - order(b.source.name), + ) + .extent([ + [2, TOP], + [WIDTH - LABEL_ROOM, HEIGHT - 40], + ]); + const graph = layout({ + nodes: names.map((name) => ({ name })), + links: links.map((link) => ({ ...link })), + }); + + const sources = new Set(links.map((link) => link.source)); + const ends = {}; + for (const link of links) { + if (!sources.has(link.target)) + ends[kindOf(link.target)] = + (ends[kindOf(link.target)] || 0) + link.value; + } + + const columns = groups(graph.nodes, (node) => node.depth).map( + ([, nodes]) => { + const labels = [ + ...new Set(nodes.map((node) => COLUMN[node.name]).filter(Boolean)), + ]; + const shown = labels.filter( + (label) => + !( + label === "Fraud check (skipped)" && + labels.includes("Fraud check") + ), + ); + const bothReviews = + shown.includes("Shipwrights") && shown.includes("Build review"); + return { + x: nodes[0].x0, + label: bothReviews ? "Ship review" : shown.join(" / "), + }; + }, + ); + svg + .append("g") + .selectAll("text") + .data(columns) + .join("text") + .attr("class", "ship-funnel__column-label") + .attr("x", (column) => column.x) + .attr("y", 18) + .text((column) => column.label); + + svg + .append("g") + .selectAll("path") + .data(graph.links) + .join("path") + .attr( + "class", + (link) => + `ship-funnel__link ship-funnel__link--${kindOf(link.target.name)}`, + ) + .attr("d", sankeyLinkHorizontal()) + .attr("stroke-width", (link) => Math.max(1, link.width)) + .append("title") + .text( + (link) => + `${link.source.name} → ${link.target.name}: ${Math.round(link.value).toLocaleString()}${unitLabel} (${percent(link.value)})`, + ); + + const node = svg.append("g").selectAll("g").data(graph.nodes).join("g"); + node + .append("rect") + .attr( + "class", + (d) => `ship-funnel__node ship-funnel__node--${kindOf(d.name)}`, + ) + .attr("x", (d) => d.x0) + .attr("y", (d) => d.y0) + .attr("width", (d) => d.x1 - d.x0) + .attr("height", (d) => Math.max(2, d.y1 - d.y0)) + .attr("rx", 2) + .append("title") + .text( + (d) => + `${d.name}: ${Math.round(d.value).toLocaleString()}${unitLabel} (${percent(d.value)})`, + ); + + const label = node + .append("text") + .attr( + "class", + (d) => + `ship-funnel__label${kindOf(d.name) === "path" ? " ship-funnel__label--stage" : ""}`, + ) + .attr("x", (d) => d.x1 + 8) + .attr("y", (d) => (d.y0 + d.y1) / 2 - 8); + label + .append("tspan") + .attr("class", "ship-funnel__label-name") + .attr("dy", "0.35em") + .text((d) => d.name); + label + .append("tspan") + .attr("class", "ship-funnel__label-value") + .attr("x", (d) => d.x1 + 8) + .attr("dy", "1.3em") + .text((d) => `${this.format(d.value)} · ${percent(d.value)}`); + + const shown = LEGEND.filter(([kind]) => ends[kind]); + const legend = svg + .append("g") + .attr("class", "ship-funnel__legend") + .attr( + "transform", + `translate(${WIDTH - 270},${HEIGHT - 12 - shown.length * 21})`, + ); + const row = legend + .selectAll("g") + .data(shown) + .join("g") + .attr("transform", (_, index) => `translate(0,${index * 21})`); + row + .append("rect") + .attr("class", ([kind]) => `ship-funnel__node ship-funnel__node--${kind}`) + .attr("width", 11) + .attr("height", 11) + .attr("y", -9) + .attr("rx", 2.5); + const legendText = row + .append("text") + .attr("class", "ship-funnel__label") + .attr("x", 19); + legendText + .append("tspan") + .attr("class", "ship-funnel__label-name") + .text(([, name]) => `${name} `); + legendText + .append("tspan") + .attr("class", "ship-funnel__label-value") + .text(([kind]) => `${this.format(ends[kind])} · ${percent(ends[kind])}`); + } +} diff --git a/app/jobs/ship_funnel_refresh_job.rb b/app/jobs/ship_funnel_refresh_job.rb new file mode 100644 index 000000000..8db64d725 --- /dev/null +++ b/app/jobs/ship_funnel_refresh_job.rb @@ -0,0 +1,11 @@ +class ShipFunnelRefreshJob < ApplicationJob + queue_as :literally_whenever + limits_concurrency to: 1, key: "ship_funnel_refresh", duration: 15.minutes + + # A cold public page enqueues this too, so skip unless forced or still cold. + def perform(force: false) + return if !force && ShipFunnel.cached + + ShipFunnel.refresh + end +end diff --git a/app/policies/ship_funnel_policy.rb b/app/policies/ship_funnel_policy.rb new file mode 100644 index 000000000..5a2af12f7 --- /dev/null +++ b/app/policies/ship_funnel_policy.rb @@ -0,0 +1,4 @@ +class ShipFunnelPolicy < ApplicationPolicy + def show? = true + def refresh? = user&.admin? +end diff --git a/app/services/ship_funnel.rb b/app/services/ship_funnel.rb new file mode 100644 index 000000000..707bde966 --- /dev/null +++ b/app/services/ship_funnel.rb @@ -0,0 +1,211 @@ +# frozen_string_literal: true + +class ShipFunnel + Link = Struct.new(:kind, :source, :target, :hours, :ships) + + AIRTABLE_FIELDS = [ "review_id", "Automation - YSWS Record ID", "Automation - Error", "rejected_at" ].freeze + CACHE_KEY = "ship_funnel".freeze + CACHE_TTL = 2.hours + + # The public page only ever reads the cache; the refresh job fills it hourly. + def self.cached = Rails.cache.read(CACHE_KEY) + + def self.fetch = Rails.cache.fetch(CACHE_KEY, expires_in: CACHE_TTL) { compute } + + def self.refresh = compute.tap { |funnel| Rails.cache.write(CACHE_KEY, funnel, expires_in: CACHE_TTL) } + + def self.compute = new.to_h.merge(generated_at: Time.current) + + def to_h + @links = Hash.new { |links, key| links[key] = Link.new(*key, 0.0, 0) } + @airtable = airtable_states + ship_rows.each { |row| add_ship(row) } + + { + links: @links.values.select { |link| link.hours.positive? || link.ships.positive? }.map(&:to_h), + unshipped_links: unshipped_links, + loops: loops, + airtable_split: !@airtable.nil? + } + end + + private + + def add_ship(row) + kind = row["hardware"] ? "hardware" : "software" + hours = row["hours_at_ship"].to_f + flow = ->(source, target, flow_hours, ships: 1) { add(kind, source, target, flow_hours, ships) } + + return flow.call("Shipped", "Fraud", hours) if row["fraud"] + + review, reship, waiting = + if kind == "hardware" + [ "Build review", "Reship, skipped review", "Waiting on build review" ] + else + [ "Shipwrights review", "Reship, skipped review", "Waiting on Shipwrights" ] + end + status = row["certification_status"] + first = status != "approved" || row["was_reviewed"] ? review : reship + flow.call("Shipped", first, hours) + + case status + when "pending" then return flow.call(first, waiting, hours) + when "returned" then return flow.call(first, row["returned_bucket"], hours) + when "misfiled" then return flow.call(first, "Misfiled, back to design", hours) + when "approved" then nil + else return flow.call(first, "Rejected by ship review", hours) + end + + # GOI sent it back: it's waiting on ship review again + return flow.call(first, waiting, hours) if row["goi_outcome"] == "Sent back to ship review" + + flow.call(first, "GOI review", hours) + return flow.call("GOI review", row["goi_outcome"], hours) unless row["goi_outcome"] == "Passed GOI" + + add_goi_deflation(row, kind, hours, flow) + approved = row["approved_minutes"].to_f / 60 + + check = kind == "hardware" ? "Hardware, skipped" : "Fraud check" + flow.call("GOI review", check, approved) + # fraud checks are only created by the daily 5am job, so a missing one is still waiting + if kind == "software" && row["fraud_check_outcome"].in?([ "No fraud check created", "Waiting on fraud team" ]) + return flow.call(check, "Waiting on fraud team", approved) + end + + deducted = row["deducted"] ? [ row["deduction_minutes"].to_f / 60, approved ].min : 0.0 + flow.call(check, "Deducted by fraud team", deducted, ships: 0) + net = approved - deducted + flow.call(check, "Airtable", net) + return flow.call("Airtable", "Cleared, never synced", net) unless row["synced"] + + flow.call("Airtable", airtable_state(row["review_id"]), net) if @airtable + end + + def add_goi_deflation(row, kind, hours, flow) + claimed = row["claimed_minutes"].to_f / 60 + approved = row["approved_minutes"].to_f / 60 + flow.call("GOI review", "Deflated by GOI", claimed - approved, ships: 0) + return if kind == "hardware" + + rounding = row["rounding_hours"].to_f + flow.call("GOI review", "Whole-minute rounding", rounding, ships: 0) + flow.call("GOI review", "Not reviewed by GOI", hours - claimed - rounding, ships: 0) + end + + def add(kind, source, target, hours, ships) + link = @links[[ kind, source, target ]] + link.hours += hours + link.ships += ships + end + + def airtable_state(review_id) + record = @airtable[review_id.to_s] + return "Lost to key collision" unless record + return "Unified" if record["Automation - YSWS Record ID"].present? + return "Rejected by sync" if record["rejected_at"].present? + + error = record["Automation - Error"].to_s + return "Waiting on final pass" if error.blank? + + error.match?(/unified|duplicate/i) ? "Duplicate in Unified" : "Final-pass error, fixable" + end + + def airtable_states + ::Certification::YswsAirtable.table.all(fields: AIRTABLE_FIELDS) + .each_with_object({}) { |record, rows| rows[record["review_id"].to_s] ||= record if record["review_id"].present? } + rescue Faraday::Error, Norairrecord::Error => error + Rails.logger.warn("[ShipFunnel] Airtable unavailable: #{error.message}") + nil + end + + def loops + { + returned_twice: ::Certification::Ship.returned.group(:post_ship_event_id).having("count(*) >= 2").count.size, + goi_sent_back: ::Certification::Ship.where.not(returned_by_id: nil).distinct.count(:post_ship_event_id), + goi_twice: ::Certification::Ysws.group(:post_ship_event_id).having("count(*) >= 2").count.size + } + end + + def unshipped_links + shipped = @links.values.select { |link| link.source == "Shipped" }.group_by(&:kind) + links = shipped.map do |kind, from_shipped| + { kind: kind, source: "Devlogged", target: "Shipped", hours: from_shipped.sum(&:hours), ships: from_shipped.sum(&:ships) } + end + rows = ::ActiveRecord::Base.connection.select_all(unshipped_sql).to_a + links + rows.map { |row| { kind: row["hardware"] ? "hardware" : "software", source: "Devlogged", target: row["bucket"], hours: row["hours"].to_f, ships: 0 } } + end + + def unshipped_sql + <<~SQL + SELECT hardware, bucket, sum(hours) AS hours + FROM ( + SELECT pr.hardware_stage IS NOT NULL AS hardware, + coalesce(pd.duration_seconds, 0) / 3600.0 AS hours, + CASE WHEN u.banned OR pr.deleted_at IS NOT NULL THEN + CASE WHEN in_window THEN NULL ELSE 'Fraud before shipping' END + WHEN pd.deleted_at IS NOT NULL THEN 'Devlog deleted' + WHEN NOT in_window THEN 'Not shipped yet' + WHEN pr.hardware_stage IS NOT NULL AND pd.phase = 'design' THEN 'Design phase (hardware)' + END AS bucket + FROM post_devlogs pd + JOIN posts dp ON dp.postable_type = 'Post::Devlog' AND dp.postable_id = pd.id + JOIN projects pr ON pr.id = dp.project_id + JOIN users u ON u.id = dp.user_id + CROSS JOIN LATERAL ( + SELECT EXISTS (SELECT 1 FROM posts sp WHERE sp.project_id = dp.project_id + AND sp.postable_type = 'Post::ShipEvent' AND sp.created_at >= dp.created_at) AS in_window + ) w + ) devlogs + WHERE bucket IS NOT NULL + GROUP BY 1, 2 + SQL + end + + def ship_rows = ::ActiveRecord::Base.connection.select_all(ship_rows_sql).to_a + + def ship_rows_sql + returned = ::Certification::Ship.statuses[:returned] + <<~SQL + SELECT y.id AS review_id, + pr.hardware_stage IS NOT NULL AS hardware, + se.hours_at_ship, + coalesce(u.banned OR pr.deleted_at IS NOT NULL OR i.status = #{integrity(:banned)}, false) AS fraud, + se.certification_status, + EXISTS (SELECT 1 FROM certification_ship_reviews c WHERE c.post_ship_event_id = se.id) AS was_reviewed, + CASE WHEN EXISTS (SELECT 1 FROM posts l WHERE l.project_id = p.project_id AND l.postable_type = 'Post::ShipEvent' + AND l.created_at > p.created_at) THEN 'Cut off by a later ship' + WHEN (SELECT max(coalesce(decided_at, created_at)) FROM certification_ship_reviews c + WHERE c.post_ship_event_id = se.id AND c.status = #{returned}) > now() - interval '30 days' + THEN 'Returned, <30d ago' + ELSE 'Returned, >30d ago' END AS returned_bucket, + CASE WHEN y.id IS NULL THEN 'Approved, no GOI review' + WHEN y.reviewed_at IS NULL AND y.returned_at IS NOT NULL THEN 'Sent back to ship review' + WHEN y.reviewed_at IS NULL THEN 'Waiting on GOI' + WHEN NOT EXISTS (SELECT 1 FROM certification_devlog_reviews d + WHERE d.ysws_review_id = y.id AND d.status <> 'rejected') THEN 'Rejected by GOI' + ELSE 'Passed GOI' END AS goi_outcome, + (SELECT sum(original_minutes) FROM certification_devlog_reviews d WHERE d.ysws_review_id = y.id) AS claimed_minutes, + (SELECT coalesce(sum(approved_minutes), 0) FROM certification_devlog_reviews d WHERE d.ysws_review_id = y.id) AS approved_minutes, + (SELECT sum(pd.duration_seconds / 3600.0 - d.original_minutes / 60.0) + FROM certification_devlog_reviews d + JOIN post_devlogs pd ON pd.id = d.post_devlog_id + WHERE d.ysws_review_id = y.id AND pd.deleted_at IS NULL + AND pd.duration_seconds / 60 = d.original_minutes) AS rounding_hours, + CASE WHEN i.status IS NULL THEN 'No fraud check created' + WHEN i.status = #{integrity(:pending)} THEN 'Waiting on fraud team' + ELSE 'Cleared' END AS fraud_check_outcome, + i.status = #{integrity(:deducted)} AND pr.hardware_stage IS NULL AS deducted, + i.deduction_minutes, + y.airtable_synced_at IS NOT NULL AS synced + FROM post_ship_events se + JOIN posts p ON p.postable_type = 'Post::ShipEvent' AND p.postable_id = se.id + JOIN projects pr ON pr.id = p.project_id + JOIN users u ON u.id = p.user_id + LEFT JOIN certification_integrities i ON i.ship_event_id = se.id + LEFT JOIN certification_ysws_reviews y + ON y.id = (SELECT max(id) FROM certification_ysws_reviews WHERE post_ship_event_id = se.id) + SQL + end + + def integrity(status) = ::Certification::Integrity.statuses.fetch(status) +end diff --git a/app/views/admin/application/index.html.erb b/app/views/admin/application/index.html.erb index ff8b62b75..2e14225ae 100644 --- a/app/views/admin/application/index.html.erb +++ b/app/views/admin/application/index.html.erb @@ -41,6 +41,7 @@ <%= admin_page_button "Raffles", admin_raffles_path, enabled: policy(:admin).access_raffles? %> <%= admin_page_button "Rating Hangtime", admin_rating_dashboard_path, enabled: policy(:rating_dashboard).show? %> <%= admin_page_button "Hour Funnel", admin_hour_funnel_path, enabled: policy(:hour_funnel).show? %> + <%= admin_page_button "Jeremy Funnel", stats_ship_funnel_path, enabled: Pundit.policy!(pundit_user, :ship_funnel).refresh? %> diff --git a/app/views/stats/ship_funnel/show.html.erb b/app/views/stats/ship_funnel/show.html.erb new file mode 100644 index 000000000..4625aac8c --- /dev/null +++ b/app/views/stats/ship_funnel/show.html.erb @@ -0,0 +1,62 @@ +<% content_for :title, "Jeremy Funnel" %> +<% admin = policy(:ship_funnel).refresh? %> + +
+ <% if @funnel %> +
+ <% if admin %> + <%= link_to "← back to admin", admin_root_path, class: "ship-funnel__back" %> + <% end %> + +
+
+

Jeremy Funnel

+
+
+
+ <% { both: "Both", software: "Software", hardware: "Hardware" }.each do |type, label| %> + + <% end %> +
+
+ <% { hours: "Hours", ships: "Ships" }.each do |unit, label| %> + + <% end %> +
+ + <% if admin %> + <%= button_to "Update", refresh_stats_ship_funnel_path, class: "ship-funnel__refresh" %> + <% end %> + updated <%= time_ago_in_words(@funnel[:generated_at]) %> ago +
+
+ +
+ +
+ + +
+ <% else %> +
+

Jeremy Funnel

+ +
+ <% end %> +
diff --git a/config/recurring.yml b/config/recurring.yml index 2b8abc087..2cf4e1433 100644 --- a/config/recurring.yml +++ b/config/recurring.yml @@ -24,6 +24,12 @@ production: schedule: every hour at minute 42 description: "Delete user data exports and attached ZIPs after seven days" + refresh_ship_funnel: + class: ShipFunnelRefreshJob + args: [ { force: true } ] + schedule: every hour at minute 20 + description: "Rebuild the ship funnel's cached numbers for the public /stats/ship_funnel page" + refresh_materialized_all_signups: class: RefreshMaterializedAllSignupsJob schedule: every 2 minutes diff --git a/config/routes.rb b/config/routes.rb index ca2e1fc01..0bfebd6e7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -545,6 +545,12 @@ get "auth/:provider/callback", to: "sessions#create" get "/auth/failure", to: "sessions#failure" delete "logout", to: "sessions#destroy" + namespace :stats do + resource :ship_funnel, only: [ :show ], controller: "ship_funnel" do + post :refresh + end + end + get "dev_login", to: "sessions#dev_login", as: :dev_login_auto if Rails.env.development? || Rails.env.test? get "dev_login/:id", to: "sessions#dev_login", as: :dev_login if Rails.env.development? || Rails.env.test? diff --git a/test/controllers/stats/ship_funnel_controller_test.rb b/test/controllers/stats/ship_funnel_controller_test.rb new file mode 100644 index 000000000..015dad503 --- /dev/null +++ b/test/controllers/stats/ship_funnel_controller_test.rb @@ -0,0 +1,64 @@ +require "test_helper" + +class Stats::ShipFunnelControllerTest < ActionDispatch::IntegrationTest + include ActiveJob::TestHelper + + self.fixture_table_names = [] + + FUNNEL = { links: [], unshipped_links: [], loops: { returned_twice: 1, goi_sent_back: 2, goi_twice: 3 }, + airtable_split: true, generated_at: Time.current }.freeze + + # The test environment's null store drops every write, and these are about the cache + setup do + @cache = Rails.cache + Rails.cache = ActiveSupport::Cache::MemoryStore.new + end + + teardown { Rails.cache = @cache } + + test "a cold cache asks visitors to check back and queues a refresh instead of computing" do + assert_enqueued_with(job: ShipFunnelRefreshJob) { get stats_ship_funnel_path } + + assert_response :success + assert_select ".ship-funnel__footer", /Check back in a minute/ + assert_select "[data-controller='ship-funnel']", count: 0 + end + + test "anyone can see the cached funnel, without the admin controls" do + Rails.cache.write(ShipFunnel::CACHE_KEY, FUNNEL) + + assert_no_enqueued_jobs(only: ShipFunnelRefreshJob) { get stats_ship_funnel_path } + + assert_response :success + assert_select "h1", "Jeremy Funnel" + assert_select "[data-controller='ship-funnel'] svg[data-ship-funnel-target='chart']" + assert_select ".ship-funnel__footer", /Loops not pictured/ + assert_select ".ship-funnel__refresh", count: 0 + assert_select ".ship-funnel__back", count: 0 + end + + test "an admin gets the update button and a way back to admin" do + sign_in admin + Rails.cache.write(ShipFunnel::CACHE_KEY, FUNNEL) + + get stats_ship_funnel_path + + assert_select ".ship-funnel__refresh" + assert_select ".ship-funnel__back" + end + + test "only an admin can recompute" do + sign_in create_user(slack_id: "U_SHIP_FUNNEL_MEMBER", display_name: "ship_funnel_member") + + ShipFunnel.stub(:compute, -> { flunk "a non-admin must not trigger a recompute" }) { post refresh_stats_ship_funnel_path } + + assert_response :forbidden + assert_nil ShipFunnel.cached + end + + private + + def admin + create_user(slack_id: "U_SHIP_FUNNEL_ADMIN", display_name: "ship_funnel_admin").tap { |user| user.grant_role!(:admin) } + end +end diff --git a/test/jobs/ship_funnel_refresh_job_test.rb b/test/jobs/ship_funnel_refresh_job_test.rb new file mode 100644 index 000000000..9d651bec3 --- /dev/null +++ b/test/jobs/ship_funnel_refresh_job_test.rb @@ -0,0 +1,29 @@ +require "test_helper" + +class ShipFunnelRefreshJobTest < ActiveSupport::TestCase + FUNNEL = { links: [], generated_at: Time.current }.freeze + + # The test environment's null store drops every write, and these are about the cache + setup do + @cache = Rails.cache + Rails.cache = ActiveSupport::Cache::MemoryStore.new + end + + teardown { Rails.cache = @cache } + + test "a queued refresh leaves an already warm cache alone" do + Rails.cache.write(ShipFunnel::CACHE_KEY, FUNNEL) + + ShipFunnel.stub(:compute, -> { flunk "should not recompute a warm cache" }) { ShipFunnelRefreshJob.perform_now } + + assert_equal FUNNEL, ShipFunnel.cached + end + + test "the scheduled refresh recomputes even when the cache is warm" do + Rails.cache.write(ShipFunnel::CACHE_KEY, { stale: true }) + + ShipFunnel.stub(:compute, FUNNEL) { ShipFunnelRefreshJob.perform_now(force: true) } + + assert_equal FUNNEL, ShipFunnel.cached + end +end diff --git a/test/services/ship_funnel_test.rb b/test/services/ship_funnel_test.rb new file mode 100644 index 000000000..cdda25cab --- /dev/null +++ b/test/services/ship_funnel_test.rb @@ -0,0 +1,65 @@ +require "test_helper" + +class ShipFunnelTest < ActiveSupport::TestCase + include UserFactory + + setup { @user = create_user(slack_id: "u-ship-funnel", display_name: "shipfunnel") } + + test "a ship waiting on Shipwrights flows from shipped into the Shipwrights queue" do + ship(hours: 3) + + assert_link "Shipped", "Shipwrights review", hours: 3, ships: 1 + assert_link "Shipwrights review", "Waiting on Shipwrights", hours: 3, ships: 1 + end + + test "a banned user's ship leaves as fraud, whatever stage it reached" do + ship(hours: 2, status: "approved") + @user.update_column(:banned, true) + + assert_link "Shipped", "Fraud", hours: 2, ships: 1 + assert_no_link "Shipped", "Shipwrights review" + end + + test "an approved reship that skipped Shipwrights enters GOI through the reship node" do + ship(hours: 4, status: "approved") + + assert_link "Shipped", "Reship, skipped review", hours: 4, ships: 1 + assert_link "Reship, skipped review", "GOI review", hours: 4, ships: 1 + assert_link "GOI review", "Approved, no GOI review", hours: 4, ships: 1 + end + + test "the loops count ships with more than one GOI review" do + event = ship(hours: 1, status: "approved") + 2.times { Certification::Ysws.create!(user: @user, project: event.post.project, post_ship_event: event, original_minutes: 60) } + + assert_equal 1, funnel[:loops][:goi_twice] + end + + private + + def funnel + @funnel ||= Certification::YswsAirtable.stub(:table, Struct.new(:rows) { def all(**) = rows }.new([])) do + ShipFunnel.new.to_h + end + end + + def ship(hours:, status: "pending") + project = Project.create!(title: "Funnel #{SecureRandom.hex(4)}") + Project::Membership.create!(project: project, user: @user, role: :owner) + event = Post::ShipEvent.create!(body: "Ship it", uploading_attachments: true) + Post.create!(project: project, user: @user, postable: event) + event.update_columns(hours_at_ship: hours, certification_status: status) + event + end + + def link(source, target) = funnel[:links].find { |l| l[:kind] == "software" && l[:source] == source && l[:target] == target } + + def assert_link(source, target, hours:, ships:) + found = link(source, target) + assert found, "expected a #{source} → #{target} link in #{funnel[:links].map { |l| [ l[:source], l[:target] ] }}" + assert_in_delta hours, found[:hours], 0.001 + assert_equal ships, found[:ships] + end + + def assert_no_link(source, target) = assert_nil(link(source, target)) +end