diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index df361e9..1b7cc5c 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -3319,3 +3319,542 @@ hr { .transport-fields:has(input[type="radio"][value="driving"]:checked) .seats-field { display: block; } + +/* ============================================================ + THE ANNUAL RUBY EMBASSY REPORT (/report) + ============================================================ */ + +/* --- Hero ---------------------------------------------------- */ + +.report-hero { + padding: 3rem 0 2.5rem; + text-align: center; + border-bottom: 1px solid #d8d3c4; + margin-bottom: 3rem; +} + +.report-hero__eyebrow { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.22em; + color: #C7374C; + font-weight: 600; + margin-bottom: 1.25rem; +} + +.report-hero__title { + font-family: "Playfair Display", serif; + font-weight: 900; + font-size: clamp(2.5rem, 6vw, 4.25rem); + line-height: 1.05; + color: #0C2866; + margin-bottom: 1.5rem; + letter-spacing: -0.01em; +} + +.report-hero__dateline { + font-style: italic; + color: #525C66; + font-size: 1.0625rem; + margin-bottom: 2rem; +} + +.report-hero__dateline strong { + color: #0C2866; + font-style: normal; + font-weight: 700; +} + +.report-hero__lede { + max-width: 36rem; + margin: 0 auto; + text-align: justify; + hyphens: auto; + color: #1f242a; + font-size: 1rem; + line-height: 1.7; +} + +.report-hero__lede p + p { margin-top: 1rem; } +.report-hero__lede strong { color: #0C2866; } + +/* --- Article ------------------------------------------------- */ + +.report-article { + margin-top: 4rem; + padding-top: 2.5rem; + border-top: 2px solid #0C2866; +} + +.report-article:first-of-type { margin-top: 0; } + +.report-article__eyebrow { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.22em; + color: #C7374C; + font-weight: 700; + margin-bottom: 0.5rem; +} + +.report-article__title { + font-family: "Playfair Display", serif; + font-weight: 700; + font-size: clamp(1.75rem, 3.5vw, 2.5rem); + line-height: 1.15; + color: #0C2866; + margin-bottom: 1.25rem; +} + +.report-article__lede { + max-width: 42rem; + color: #1f242a; + font-size: 1.0625rem; + line-height: 1.7; + margin-bottom: 2.5rem; + font-style: italic; +} + +/* --- Block (one stat within an article) ---------------------- */ + +.report-block { + margin-top: 2.5rem; + padding-top: 2rem; + border-top: 1px solid #e6e2d4; +} + +.report-block:first-of-type { border-top: 0; padding-top: 0; } + +.report-block__label { + font-size: 0.875rem; + text-transform: uppercase; + letter-spacing: 0.12em; + color: #0C2866; + font-weight: 700; + margin-bottom: 0.5rem; +} + +.report-block__sub { + color: #525C66; + font-size: 0.9375rem; + line-height: 1.6; + margin-bottom: 1.5rem; +} + +.report-block__sub strong { color: #0C2866; } + +/* --- Big number callout (e.g., median coding years) ---------- */ + +.report-bignum { + text-align: center; + padding: 2rem 1rem; + background: #FAFAF6; + border: 1px solid #e6e2d4; + border-radius: 8px; + margin-bottom: 1.5rem; +} + +.report-bignum__value { + font-family: "Playfair Display", serif; + font-weight: 900; + font-size: clamp(3.5rem, 9vw, 5.5rem); + line-height: 1; + color: #0C2866; +} + +.report-bignum__caption { + font-style: italic; + color: #525C66; + font-size: 0.9375rem; + margin-top: 0.5rem; +} + +/* --- Pull-quotes (1d standout responses) --------------------- */ + +.report-pullquotes { + margin-top: 1rem; + padding: 1.25rem 1.5rem; + border-left: 3px solid #C7374C; + background: #fdfcf7; +} + +.report-pullquotes__label { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.14em; + color: #525C66; + font-weight: 600; + margin-bottom: 0.75rem; +} + +.report-pullquotes__list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.report-pullquote { + font-family: "Playfair Display", serif; + font-style: italic; + font-size: 1.125rem; + color: #1f242a; + line-height: 1.5; +} + +/* --- Ranked list (1f languages, 2c versions) ----------------- */ + +.report-ranked { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.375rem; + counter-reset: rank; +} + +.report-ranked__row { + display: grid; + grid-template-columns: 2.25rem 1fr auto; + align-items: center; + gap: 0.75rem; + padding: 0.625rem 0.875rem; + background: #fdfcf7; + border: 1px solid #e6e2d4; + border-radius: 4px; +} + +.report-ranked__rank { + font-family: "Playfair Display", serif; + font-size: 1.25rem; + color: #C7374C; + font-weight: 700; + text-align: center; +} + +.report-ranked__name { + font-weight: 500; + color: #0C2866; + font-size: 1rem; +} + +.report-ranked__count { + font-size: 0.875rem; + color: #525C66; + font-variant-numeric: tabular-nums; +} + +/* --- Notable mentions row (1f) ------------------------------- */ + +.report-notable { + margin-top: 1rem; + padding: 1rem 1.25rem; + background: #FAFAF6; + border-radius: 6px; +} + +.report-notable__label { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.14em; + color: #525C66; + font-weight: 600; + margin-bottom: 0.5rem; +} + +.report-notable__list { + font-family: "Playfair Display", serif; + font-style: italic; + font-size: 1rem; + color: #1f242a; + line-height: 1.7; +} + +.report-notable__item { + white-space: nowrap; +} + +/* --- Horizontal bar chart (1e proficiency, 2b first ruby) ---- */ + +.report-bars { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.report-bar-row { + display: grid; + grid-template-columns: minmax(8rem, 1fr) 2fr auto; + gap: 1rem; + align-items: center; +} + +.report-bar-row__label { + font-size: 0.9375rem; + color: #1f242a; + font-weight: 500; +} + +.report-bar-row__track { + height: 1.25rem; + background: #f0ece0; + border-radius: 2px; + overflow: hidden; +} + +.report-bar-row__fill { + height: 100%; + background: linear-gradient(90deg, #0C2866 0%, #1e4185 100%); + transition: width 0.4s ease; + min-width: 2px; +} + +.report-bar-row__fill--alt { + background: linear-gradient(90deg, #C7374C 0%, #d85d70 100%); +} + +.report-bar-row__value { + font-variant-numeric: tabular-nums; + font-weight: 600; + color: #0C2866; + font-size: 0.9375rem; + white-space: nowrap; +} + +.report-bar-row__count { + color: #6b7280; + font-weight: 400; + font-size: 0.8125rem; +} + +@media (max-width: 640px) { + .report-bar-row { + grid-template-columns: 1fr auto; + grid-template-rows: auto auto; + gap: 0.25rem 0.75rem; + } + .report-bar-row__label { grid-column: 1 / 2; grid-row: 1; } + .report-bar-row__value { grid-column: 2 / 3; grid-row: 1; } + .report-bar-row__track { grid-column: 1 / -1; grid-row: 2; } +} + +/* --- Donut chart (1g purpose) -------------------------------- */ + +.report-donut-wrap { + display: grid; + grid-template-columns: auto 1fr; + gap: 2.5rem; + align-items: center; +} + +@media (max-width: 640px) { + .report-donut-wrap { + grid-template-columns: 1fr; + justify-items: center; + gap: 1.5rem; + } +} + +.report-donut { + width: 220px; + height: 220px; + border-radius: 50%; + position: relative; + flex-shrink: 0; +} + +.report-donut__hole { + position: absolute; + inset: 22%; + background: #FAFAF6; + border-radius: 50%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.report-donut__hole-value { + font-family: "Playfair Display", serif; + font-size: 2.5rem; + font-weight: 700; + color: #0C2866; + line-height: 1; +} + +.report-donut__hole-caption { + font-size: 0.6875rem; + text-transform: uppercase; + letter-spacing: 0.12em; + color: #525C66; + margin-top: 0.25rem; +} + +.report-donut-legend { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.625rem; +} + +.report-donut-legend__row { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 0.75rem; +} + +.report-donut-legend__swatch { + width: 0.875rem; + height: 0.875rem; + border-radius: 2px; +} + +.report-donut-legend__name { + font-size: 0.9375rem; + color: #1f242a; +} + +.report-donut-legend__pct { + font-variant-numeric: tabular-nums; + font-weight: 600; + color: #0C2866; + font-size: 0.9375rem; +} + +/* --- Callouts grid (1h sentiment, 1i persona) ---------------- */ + +.report-callouts { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 0.625rem; +} + +@media (max-width: 640px) { + .report-callouts { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; + } +} + +.report-callout { + background: #fdfcf7; + border: 1px solid #e6e2d4; + border-radius: 6px; + padding: 1rem 0.625rem; + text-align: center; +} + +.report-callout--alt { + background: #FAFAF6; +} + +.report-callout__value { + font-family: "Playfair Display", serif; + font-weight: 700; + font-size: 2.25rem; + color: #0C2866; + line-height: 1.05; +} + +.report-callout__label { + margin-top: 0.5rem; + font-size: 0.875rem; + font-weight: 500; + color: #1f242a; + line-height: 1.3; +} + +.report-callout__sub { + margin-top: 0.375rem; + font-size: 0.75rem; + color: #6b7280; + font-variant-numeric: tabular-nums; +} + +/* --- Pull-quote stat (5c gems affidavit) --------------------- */ + +.report-pullquote-stat { + margin: 0; + padding: 2.5rem 1.5rem; + text-align: center; + background: #0C2866; + color: #ffffff; + border-radius: 8px; + position: relative; +} + +.report-pullquote-stat__value { + font-family: "Playfair Display", serif; + font-weight: 900; + font-size: clamp(3rem, 8vw, 5rem); + line-height: 1; + color: #F5BE3D; +} + +.report-pullquote-stat__caption { + font-family: "Playfair Display", serif; + font-style: italic; + font-size: 1.25rem; + margin-top: 0.75rem; + color: #ffffff; +} + +.report-pullquote-stat__caption em { + color: #F5BE3D; + font-style: italic; +} + +.report-pullquote-stat__detail { + margin-top: 1.25rem; + font-size: 0.875rem; + color: #b8c2d4; + font-variant-numeric: tabular-nums; +} + +/* --- Closing dispatch ---------------------------------------- */ + +.report-closing { + margin-top: 4.5rem; + padding-top: 2.5rem; + border-top: 2px solid #0C2866; + text-align: center; + color: #525C66; + font-style: italic; + font-size: 0.9375rem; + line-height: 1.7; +} + +.report-closing__sigil { + font-family: "Playfair Display", serif; + font-size: 1.5rem; + color: #C7374C; + margin-bottom: 1rem; + letter-spacing: 0.5em; + padding-left: 0.5em; +} + +.report-closing strong { + color: #0C2866; + font-style: normal; + font-weight: 600; +} + +/* --- Empty state --------------------------------------------- */ + +.report-empty { + margin-top: 3rem; + padding: 3rem 2rem; + text-align: center; + background: #FAFAF6; + border: 1px solid #e6e2d4; + border-radius: 8px; + color: #525C66; + font-style: italic; + line-height: 1.7; +} diff --git a/app/controllers/report_controller.rb b/app/controllers/report_controller.rb new file mode 100644 index 0000000..f207d28 --- /dev/null +++ b/app/controllers/report_controller.rb @@ -0,0 +1,7 @@ +class ReportController < ApplicationController + skip_before_action :authenticate_user! + + def index + @report = Rails.cache.fetch("annual_report:v1", expires_in: 1.hour) { AnnualReport.build } + end +end diff --git a/app/services/annual_report.rb b/app/services/annual_report.rb new file mode 100644 index 0000000..4fe5ccf --- /dev/null +++ b/app/services/annual_report.rb @@ -0,0 +1,146 @@ +class AnnualReport + Stat = Data.define(:question, :total_respondents, :breakdown, :extra) + Result = Data.define(:total_passport_holders, :stats, :generated_at) + + STRUCTURED_IDS = %w[1e 1g 1h 1i 2b 5c].freeze + CODING_YEARS_ID = "1d".freeze + OTHER_LANGUAGE_ID = "1f".freeze + SENTIMENTAL_VERSION_ID = "2c".freeze + ALL_FEATURED_IDS = (STRUCTURED_IDS + [ CODING_YEARS_ID, OTHER_LANGUAGE_ID, SENTIMENTAL_VERSION_ID ]).freeze + + CODING_YEARS_QUOTES = [ + "21, old enough to party", + "Let's just say... it's more and a 1/4 century!", + "A bit" + ].freeze + + LANGUAGE_NOTABLE_MENTIONS = [ + "Acts of Service", + "Wilderness", + "Nonviolent Communication", + "Engrish", + "Visual FoxPro", + "There are other languages?" + ].freeze + + LANGUAGE_ALIASES = { + "js" => "JavaScript", + "javascript" => "JavaScript", + "ts" => "TypeScript", + "typescript" => "TypeScript" + }.freeze + + REFUSAL_PHRASES = [ + "i refuse", "i hold no", "n/a", "none", "no attachment", "sentimental attachments to come" + ].freeze + + def self.build + questions = Question.where(external_id: ALL_FEATURED_IDS).index_by(&:external_id) + + stats = {} + STRUCTURED_IDS.each do |id| + next unless questions[id] + stats[id] = structured_stat(questions[id]) + end + stats[CODING_YEARS_ID] = coding_years_stat(questions[CODING_YEARS_ID]) if questions[CODING_YEARS_ID] + stats[OTHER_LANGUAGE_ID] = other_language_stat(questions[OTHER_LANGUAGE_ID]) if questions[OTHER_LANGUAGE_ID] + stats[SENTIMENTAL_VERSION_ID] = sentimental_version_stat(questions[SENTIMENTAL_VERSION_ID]) if questions[SENTIMENTAL_VERSION_ID] + + Result.new( + total_passport_holders: EmbassyApplication.submitted.count, + stats: stats, + generated_at: Time.current + ) + end + + def self.structured_stat(question) + answers = submitted_answers_for(question) + breakdown = + if question.field_type_checkbox_group? + tally_options(answers.pluck(:value_array).compact.flatten, question.options) + else + tally_options(answers.pluck(:value_text).compact.reject(&:blank?), question.options) + end + Stat.new(question: question, total_respondents: answers.count, breakdown: breakdown, extra: {}) + end + + def self.coding_years_stat(question) + raw = submitted_answers_for(question).pluck(:value_text).compact.reject(&:blank?) + parsed = raw.filter_map { |v| v[/\d+/]&.to_i }.select { |n| n.between?(0, 80) } + sorted = parsed.sort + median = sorted.empty? ? nil : sorted[sorted.length / 2] + Stat.new( + question: question, + total_respondents: raw.count, + breakdown: {}, + extra: { + median: median, + min: sorted.min, + max: sorted.max, + parsed_count: parsed.count, + quotes: CODING_YEARS_QUOTES + } + ) + end + + def self.other_language_stat(question) + raw = submitted_answers_for(question).pluck(:value_text).compact.reject(&:blank?) + tokens = raw.flat_map { |v| v.split(/[,\/]|\s+and\s+/i) } + .map { |t| t.strip.gsub(/[^[:alnum:][:space:]+#.-]/, "") } + .reject(&:blank?) + .map { |t| LANGUAGE_ALIASES[t.downcase] || titleize_language(t) } + counts = tokens.tally.sort_by { |_, c| -c }.first(7).to_h + Stat.new( + question: question, + total_respondents: raw.count, + breakdown: counts, + extra: { notable_mentions: LANGUAGE_NOTABLE_MENTIONS } + ) + end + + def self.sentimental_version_stat(question) + raw = submitted_answers_for(question).pluck(:value_text).compact.reject(&:blank?) + cleaned = raw.reject { |v| REFUSAL_PHRASES.any? { |p| v.downcase.include?(p) } } + versions = cleaned.filter_map { |v| v[/\d+(?:\.\d+){0,2}/] } + counts = versions.tally.sort_by { |_, c| -c }.first(5).to_h + parseable = versions.filter_map { |v| Gem::Version.new(v) rescue nil }.sort + Stat.new( + question: question, + total_respondents: raw.count, + breakdown: counts, + extra: { + min_version: parseable.first&.to_s, + max_version: parseable.last&.to_s + } + ) + end + + def self.submitted_answers_for(question) + EmbassyApplicationAnswer + .joins(:embassy_application) + .where(question_id: question.id, embassy_applications: { state: "submitted" }) + end + + def self.tally_options(values, options) + counts = values.tally + options.each_with_object({}) { |opt, h| h[opt] = counts[opt] || 0 } + end + + def self.titleize_language(token) + # Preserve common all-caps or specific casings + case token.downcase + when "html" then "HTML" + when "css" then "CSS" + when "sql" then "SQL" + when "c++" then "C++" + when "c#" then "C#" + when "hcl" then "HCL" + else + token.split(/\s+/).map(&:capitalize).join(" ") + end + end + + private_class_method :structured_stat, :coding_years_stat, :other_language_stat, + :sentimental_version_stat, :submitted_answers_for, :tally_options, + :titleize_language +end diff --git a/app/views/report/index.html.erb b/app/views/report/index.html.erb new file mode 100644 index 0000000..1dfc63f --- /dev/null +++ b/app/views/report/index.html.erb @@ -0,0 +1,296 @@ +<% content_for(:title, "The Annual Ruby Embassy Report") %> + +<% + total = @report.total_passport_holders + percent = ->(count) { total.zero? ? 0 : ((count.to_f / total) * 100).round } +%> + +
+
+ + <%# === Hero =================================================== %> +
+

For the Public Record

+

The Annual Ruby
Embassy Report

+ +
+

+ This document hereby attests, on behalf of the Embassy and for the public record, the aggregate sentiments declared during this year's passport applications. The figures herein reflect <%= total %> adjudicated <%= "submission".pluralize(total) %> as of the date of filing. +

+

+ Free-text declarations have been omitted from the public record except where noted. All standings are anonymous. +

+
+
+ + <% if total.zero? %> +

+ The Embassy has not yet received any adjudicated submissions. This page will populate automatically as applications are processed. +

+ <% else %> + + <%# === Article I — Demographics =============================== %> +
+

Article I

+

Demographics & Standing

+

+ The Embassy first examined the basic vital statistics of its diplomats — declared experience, fluencies beyond Ruby, and self-reported proficiency. +

+ + <%# 1d — Coding Years %> + <% if (s = @report.stats["1d"]) %> + <% ext = s.extra %> +
+

§1 · Age, in Coding Years

+

Self-reported. Of <%= s.total_respondents %> declarations, <%= ext[:parsed_count] %> contained a parseable figure.

+ + <% if ext[:median] %> +
+
<%= ext[:median] %>
+
years (median), ranging <%= ext[:min] %>–<%= ext[:max] %>
+
+ <% end %> + + <% if ext[:quotes].any? %> +
+
Notable declarations on the record
+
    + <% ext[:quotes].each do |q| %> +
  • “<%= q %>”
  • + <% end %> +
+
+ <% end %> +
+ <% end %> + + <%# 1f — Primary Other Language %> + <% if (s = @report.stats["1f"]) %> +
+

§2 · Primary Language Other Than Ruby

+

Of <%= s.total_respondents %> declarations, the following languages were named most frequently. Multi-language responses are counted individually.

+ +
    + <% s.breakdown.each_with_index do |(lang, count), i| %> +
  1. + <%= i + 1 %> + <%= lang %> + <%= count %> <%= "mention".pluralize(count) %> +
  2. + <% end %> +
+ + <% if s.extra[:notable_mentions].any? %> +
+
Notable mentions, for the record
+

+ <% s.extra[:notable_mentions].each_with_index do |m, i| %>“<%= m %>”<%= " · ".html_safe unless i == s.extra[:notable_mentions].length - 1 %><% end %> +

+
+ <% end %> +
+ <% end %> + + <%# 1e — Declared Ruby Proficiency (BAR CHART) %> + <% if (s = @report.stats["1e"]) %> +
+

§3 · Declared Ruby Proficiency

+

Self-rated. <%= s.total_respondents %> diplomats responded.

+ +
+ <% s.breakdown.each do |label, count| %> +
+
<%= label %>
+
+
+
+
<%= percent.call(count) %>% · <%= count %>
+
+ <% end %> +
+
+ <% end %> +
+ + <%# === Article II — Statement of Purpose ====================== %> +
+

Article II

+

Statement of Purpose & Character

+

+ Diplomats were asked to declare their reasons for visit, their current emotional standing, and their personal coding constitution. Their responses are recorded below. +

+ + <%# 1g — Purpose of Visit (DONUT) %> + <% if (s = @report.stats["1g"]) %> + <% + total_picks = s.breakdown.values.sum + colors = ["#0C2866", "#C7374C", "#F5BE3D", "#525C66"] + stops = [] + cumulative = 0.0 + s.breakdown.each_with_index do |(_, count), i| + pct = total_picks.zero? ? 0 : (count.to_f / total_picks * 100) + stops << "#{colors[i % colors.length]} #{cumulative}% #{cumulative + pct}%" + cumulative += pct + end + gradient = stops.empty? ? "#e5e7eb 0% 100%" : stops.join(", ") + %> +
+

§1 · Stated Purpose of Visit

+

<%= s.total_respondents %> diplomats declared one or more purposes. Multi-select, so percentages reflect share of total selections (<%= total_picks %>).

+ +
+
+
+
<%= s.total_respondents %>
+
declared
+
+
+
    + <% s.breakdown.each_with_index do |(label, count), i| %> + <% pct = total_picks.zero? ? 0 : (count.to_f / total_picks * 100).round %> +
  • + + <%= label %> + <%= pct %>% +
  • + <% end %> +
+
+
+ <% end %> + + <%# 1h — Most Recent Sentiment (CALLOUTS) %> + <% if (s = @report.stats["1h"]) %> +
+

§2 · Most Recent Sentiment Experienced While Programming

+

<%= s.total_respondents %> diplomats declared one or more sentiments. Percentages reflect share of diplomats who selected each.

+ +
+ <% s.breakdown.each do |label, count| %> +
+
<%= percent.call(count) %>%
+
<%= label %>
+
<%= count %> of <%= total %>
+
+ <% end %> +
+
+ <% end %> + + <%# 1i — Developer Persona (CALLOUTS) %> + <% if (s = @report.stats["1i"]) %> +
+

§3 · Declared Developer Persona

+

<%= s.total_respondents %> diplomats claimed one or more identities.

+ +
+ <% s.breakdown.each do |label, count| %> +
+
<%= percent.call(count) %>%
+
<%= label %>
+
<%= count %> of <%= total %>
+
+ <% end %> +
+
+ <% end %> +
+ + <%# === Article III — Heritage ================================= %> +
+

Article III

+

Heritage & Lineage

+

+ Each diplomat traces their own arc through Ruby. The Embassy invited declarations of first encounter and lasting attachment. +

+ + <%# 2b — First Ruby release (RANKED LIST) %> + <% if (s = @report.stats["2b"]) %> +
+

§1 · First Ruby Release Remembered

+

<%= s.total_respondents %> diplomats responded.

+ +
+ <% s.breakdown.each do |label, count| %> +
+
<%= label %>
+
+
+
+
<%= percent.call(count) %>% · <%= count %>
+
+ <% end %> +
+
+ <% end %> + + <%# 2c — Sentimental Ruby version (RANKED + RANGE) %> + <% if (s = @report.stats["2c"]) %> + <% ext = s.extra %> +
+

§2 · Ruby Release Held in Sentimental Attachment

+

+ <%= s.total_respondents %> of <%= total %> diplomats declared an attachment. + <% if ext[:min_version] && ext[:max_version] && ext[:min_version] != ext[:max_version] %> + Declarations span from <%= ext[:min_version] %> to <%= ext[:max_version] %>. + <% end %> +

+ + <% if s.breakdown.any? %> +
    + <% s.breakdown.each_with_index do |(version, count), i| %> +
  1. + <%= i + 1 %> + Ruby <%= version %> + <%= count %> <%= "mention".pluralize(count) %> +
  2. + <% end %> +
+ <% end %> +
+ <% end %> +
+ + <%# === Article IV — Material Declarations ===================== %> +
+

Article IV

+

Material Declarations

+

+ Finally, a matter of customs. +

+ + <%# 5c — Possession of unreleased gems (PULL-QUOTE STAT) %> + <% if (s = @report.stats["5c"]) %> + <% + yes_count = s.breakdown["Yes"] || 0 + fifth_count = s.breakdown["I plead the fifth"] || 0 + no_count = s.breakdown["No"] || 0 + %> +
+

§1 · Possession of Unreleased Gems of Unknown Provenance

+

Mandatory affidavit. <%= s.total_respondents %> diplomats responded.

+ +
+
<%= percent.call(fifth_count) %>%
+
of diplomats plead the fifth
+
+ <%= percent.call(no_count) %>% answered “No” · <%= percent.call(yes_count) %>% answered “Yes” +
+
+
+ <% end %> +
+ + <%# === Closing Dispatch ======================================= %> + + + <% end %> +
+
diff --git a/config/cache.yml b/config/cache.yml index 19d4908..2535887 100644 --- a/config/cache.yml +++ b/config/cache.yml @@ -6,6 +6,7 @@ default: &default namespace: <%= Rails.env %> development: + database: cache <<: *default test: diff --git a/config/routes.rb b/config/routes.rb index 400055d..8a7d78b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -2,6 +2,9 @@ # Health check get "up" => "rails/health#show", as: :rails_health_check + # Public — The Annual Ruby Embassy Report + get "report", to: "report#index", as: :report + # Public — magic-link auth resource :session, only: %i[new create destroy], controller: "sessions" do get :callback, on: :collection diff --git a/test/controllers/report_controller_test.rb b/test/controllers/report_controller_test.rb new file mode 100644 index 0000000..b174acd --- /dev/null +++ b/test/controllers/report_controller_test.rb @@ -0,0 +1,8 @@ +require "test_helper" + +class ReportControllerTest < ActionDispatch::IntegrationTest + test "should get index" do + get report_url + assert_response :success + end +end