studio-engine 0.87.0 → 0.88.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e3bc2b4fbaacbbcb3895cdcc95f4caa6421df1cc1cf16c9e53d8bdd5aa19f3a7
4
- data.tar.gz: b453a06ef41d2bd56b23a56e2f80bc29f19bd793702dbf23e6b1f7236f0cfb37
3
+ metadata.gz: e16f65ea44055886fea1ffb0743a9415caa612f858caa0ff7dabb7c87beeeb15
4
+ data.tar.gz: 9690ad40a9e766646995ddfa1c69c8c22191e3509bb944d5c184ea5c1a1b21e2
5
5
  SHA512:
6
- metadata.gz: f88b17d347d21f137b61a91d0e1d0e70c67e3b464e36f1f0de7bb198798c914fd9a09af6a567639cae488ae67aabebdbac811c6a2a55f1ba02f9bdada74bb8bc
7
- data.tar.gz: 975c4a433ea0dac45d70a683afc799984ee027c27a33e6cf823467cc545f01dda4575ef6623efd830b4bdf486cb4a29b53cee7e0886b441681a30af3b7f4ff38
6
+ metadata.gz: ae4786abebc9f1d9dc86cc3992f4a6f4225c4fceaa20b73fca12ddd43afad1e4d6ef2807a8d3302bdf2d811792995fdac42961a02c6b70637f8923e841e61402
7
+ data.tar.gz: 3ba6c43faf9e1f7a03596b70924fc099469d8c95ce967f63e4a2ba8f4f0da0112db6068e701e739fa7e3ce5435bd07ee456b2982fec91ebb9561a4bc3dd18b09
data/CHANGELOG.md CHANGED
@@ -4,6 +4,34 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.88.0 — 2026-10-05
8
+
9
+ ### Added
10
+
11
+ - **Surveys.** Define a survey in app code — `Studio.define_survey "first-game"
12
+ do ... end` in `config/surveys/*.rb`, loaded on boot and each reload — with six
13
+ question types (`emoji_scale`, `rating`, `choice`, `multi_choice`,
14
+ `short_text`, `long_text`), each with `required:` and `help:`. The engine
15
+ serves it at `GET /surveys/:slug`: one question per screen with a progress
16
+ bar, tap-to-advance, number keys and Enter, autosave of every answer, resume
17
+ where the respondent left off, a thank-you screen with the survey's
18
+ `next_action`, `prefers-reduced-motion` support, and a single-form fallback
19
+ without JavaScript. Answers are stored in the new `studio_survey_responses`
20
+ table (`Studio::SurveyResponse`) with a snapshot of each question's label and
21
+ the chosen option's label, so editing a survey never rewrites old responses.
22
+ **Opt in** with `config.draw_survey_routes = true`; **run
23
+ `bin/rails studio_engine:install:migrations && bin/rails db:migrate`** first.
24
+ - **`config.survey_ref_resolver`** (`->(controller) { ref }`) attributes an email
25
+ arrival, and **`config.on_survey_completed`** (`->(response) { ... }`) runs once
26
+ when a response completes; a raising hook is logged, never shown. A survey
27
+ answers to a signed-in user, an attributed email arrival, or anyone when it
28
+ declares `allow_anonymous true`.
29
+ - **The survey results panel at `/admin/surveys`**, admin-only: started,
30
+ completed and completion rate per survey; per-question distribution bars and
31
+ averages, a scrolling list of text answers with the respondent's username or
32
+ "anonymous"; date-range and completed-only filters; CSV export. **Opt in**
33
+ with `config.draw_admin_survey_routes = true`. See `docs/SURVEYS.md`.
34
+
7
35
  ## 0.87.0 — 2026-10-04
8
36
 
9
37
  ### Changed
data/README.md CHANGED
@@ -43,6 +43,7 @@ resolved.
43
43
  - **Transactional emails**: `Studio::EmailCatalog` — every email an app sends, its type, a live preview, and its banner — plus the shared `/admin/emails` page. Every app inherits the standard emails and their artwork on day one, and can register its own workflows and upload its own banners. See [Transactional emails](#transactional-emails).
44
44
  - **Site footer**: `<%= studio_site_footer %>` in the layout renders the public site's footer. It is on by default: with nothing configured it prints the app's name, logo and the legal links the app has routes for, and no address. `config.site_footer` declares the app's own facts (brand, social profiles, link columns, an address with a live map, legal links), every part optional; `config.site_footer_address` adds a location in one setting; `config.site_footer = false` turns the footer off. Leaflet is vendored and served by the engine. See [`docs/SITE_FOOTER.md`](docs/SITE_FOOTER.md).
45
45
  - **Booking**: a simple scheduler on Google Calendar appointment schedules, with or without the footer. With `config.booking_url`, `studio_booking_frame` embeds Google's booking page, `studio_booking_link` opens it in a popup, and `config.draw_booking_routes = true` draws `/schedule` (or name the app's own page with `config.booking_path`). `config.booking_crop` crops the frame to the slot picker at rest, from numbers `bin/booking-crop-measure` reads off the app's own schedule. See [`docs/BOOKING.md`](docs/BOOKING.md).
46
+ - **Surveys**: define a survey in app code (`config/surveys/*.rb`, `Studio.define_survey`) with six question types — emoji scale, 1–5 rating, single and multiple choice, short and long text — and the engine serves it at `/surveys/:slug`: one question per screen, autosave, resume, keyboard shortcuts, and a no-JavaScript fallback. `config.on_survey_completed` and `config.survey_ref_resolver` let the app fire its own goals and attribute email arrivals. An admin panel at `/admin/surveys` breaks results down per question and exports CSV. Opt in with `config.draw_survey_routes = true` and `config.draw_admin_survey_routes = true`. See [`docs/SURVEYS.md`](docs/SURVEYS.md).
46
47
 
47
48
  ## Configuration
48
49
 
@@ -137,6 +138,8 @@ Set `Studio.draw_session_routes = true` to add the session-drift rehydrate endpo
137
138
 
138
139
  Set `Studio.draw_booking_routes = true` to add the booking page (`GET /schedule`, helper `studio_booking_path`) — off by default because mcritchie-studio draws its own `/schedule` until it adopts this one. An app that keeps its own booking page names it with `Studio.booking_path` instead ([`docs/BOOKING.md`](docs/BOOKING.md)).
139
140
 
141
+ Set `Studio.draw_survey_routes = true` to add the public survey pages (`/surveys/:slug`, helper `studio_survey_path`) and `Studio.draw_admin_survey_routes = true` to add the admin results panel (`/admin/surveys`, helpers `admin_surveys_path` / `admin_survey_path`) — both off by default because an app may already own those paths. Both need the `studio_survey_responses` table ([`docs/SURVEYS.md`](docs/SURVEYS.md)).
142
+
140
143
  **Magic links need the `studio_links` table.** Install it with `bin/rails studio_engine:install:migrations && bin/rails db:migrate` (install all of them) before enabling `:magic_link` — never by hand-copying the migration, which collides with the task's own copy on `class CreateStudioLinks`. Without the table, the first sign-in raises `Studio::Link::MissingTable`.
141
144
 
142
145
  In non-production local requests, this also draws `/_studio/local_emails`, a local email inbox for agent/worktree proof flows. Set `LOCAL_EMAIL_CAPTURE=1` or run with `AGENT_WORKTREE=1` to record outbox rows without sending real email.
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # The survey results panel (docs/SURVEYS.md). Admin-only, drawn only when
5
+ # Studio.draw_admin_survey_routes is on.
6
+ #
7
+ # GET /admin/surveys every survey: started, completed, rate
8
+ # GET /admin/surveys/:slug per-question breakdown, filterable
9
+ # GET /admin/surveys/:slug/export the same filtered responses as CSV
10
+ #
11
+ # A slug with stored responses but no definition left in app code still lists
12
+ # and exports (its breakdown needs the definition, so its page says so).
13
+ class AdminSurveysController < ::ApplicationController
14
+ before_action :require_admin
15
+ before_action :require_table
16
+
17
+ def index
18
+ started = Studio::SurveyResponse.group(:survey_slug).count
19
+ completed = Studio::SurveyResponse.completed.group(:survey_slug).count
20
+ slugs = (Studio.surveys.map(&:slug) + started.keys).uniq.sort
21
+ @rows = slugs.map do |slug|
22
+ s = started.fetch(slug, 0)
23
+ c = completed.fetch(slug, 0)
24
+ { slug: slug, survey: Studio.survey(slug), started: s, completed: c,
25
+ rate: Studio::Survey::Breakdown.percent(c, s) }
26
+ end
27
+ end
28
+
29
+ def show
30
+ @slug = params[:slug].to_s
31
+ @survey = Studio.survey(@slug)
32
+ @filters = filters
33
+ scope = filtered_scope
34
+ @started = scope.count
35
+ @completed = scope.completed.count
36
+ return unless @survey
37
+
38
+ @breakdown = Studio::Survey::Breakdown.new(@survey, breakdown_rows(scope))
39
+ end
40
+
41
+ def export
42
+ slug = params[:slug].to_s
43
+ survey = Studio.survey(slug)
44
+ @filters = filters
45
+ @slug = slug
46
+ rows = filtered_scope.includes(:user).order(:id).map do |r|
47
+ { response_id: r.id, survey_version: r.survey_version, status: r.completed? ? "completed" : "in_progress",
48
+ started_at: r.started_at, completed_at: r.completed_at, respondent: r.respondent_label,
49
+ user_id: r.user_id, email_ref: r.email_ref, user_agent_class: r.user_agent_class, answers: r.answers }
50
+ end
51
+ csv = Studio::Survey::Export.new(survey, rows).to_csv
52
+ send_data csv, type: "text/csv; charset=utf-8",
53
+ filename: "survey-#{slug.parameterize}-#{Time.current.strftime("%Y%m%d")}.csv"
54
+ end
55
+
56
+ private
57
+
58
+ def require_table
59
+ return if Studio::SurveyResponse.table_ready?
60
+
61
+ raise Studio::SurveyResponse::MissingTable,
62
+ "studio_survey_responses is not installed — run bin/rails studio_engine:install:migrations && bin/rails db:migrate"
63
+ end
64
+
65
+ def filters
66
+ {
67
+ from: parse_date(params[:from]),
68
+ to: parse_date(params[:to]),
69
+ completed: params[:completed] == "1"
70
+ }
71
+ end
72
+
73
+ def parse_date(value)
74
+ Date.iso8601(value.to_s)
75
+ rescue ArgumentError, TypeError
76
+ nil
77
+ end
78
+
79
+ # Dates filter on started_at, inclusive of both whole days.
80
+ def filtered_scope
81
+ scope = Studio::SurveyResponse.for_survey(@slug)
82
+ scope = scope.where(started_at: @filters[:from].beginning_of_day..) if @filters[:from]
83
+ scope = scope.where(started_at: ..@filters[:to].end_of_day) if @filters[:to]
84
+ scope = scope.completed if @filters[:completed]
85
+ scope
86
+ end
87
+
88
+ def breakdown_rows(scope)
89
+ scope.includes(:user).order(:id).map do |r|
90
+ { answers: r.answers, respondent: r.respondent_label, at: r.completed_at || r.updated_at }
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # The public survey pages (docs/SURVEYS.md). Drawn only when
5
+ # Studio.draw_survey_routes is on.
6
+ #
7
+ # GET /surveys/:slug the survey: one question per screen with JS,
8
+ # a single form without it
9
+ # PATCH /surveys/:slug/answers/:key autosave one answer (JSON)
10
+ # POST /surveys/:slug submit: store every answer, complete, thank
11
+ # GET /surveys/:slug/thanks the thank-you screen and next action
12
+ #
13
+ # A response row is created on the FIRST ANSWER, not on page view, so a link
14
+ # preview bot or a bounce never counts as "started".
15
+ class SurveysController < ::ApplicationController
16
+ skip_before_action :require_authentication, raise: false
17
+
18
+ before_action :load_survey
19
+ before_action :require_table
20
+ before_action :require_respondent
21
+
22
+ def show
23
+ @response = locate_response
24
+ return redirect_to(studio_survey_thanks_path(@survey.slug), status: :see_other) if @response&.completed?
25
+
26
+ @errors = {}
27
+ render_survey
28
+ end
29
+
30
+ def answer
31
+ question = @survey.question(params[:key])
32
+ return render(json: { error: "unknown question" }, status: :not_found) unless question
33
+
34
+ response = open_response!
35
+ return render(json: { error: "already complete" }, status: :conflict) unless response
36
+
37
+ ok, error = rescue_survey_write(response) { response.record_answer(question, params[:value]) }
38
+ if ok
39
+ render json: { saved: true, key: question.key, answered: response.answers.keys & @survey.keys }
40
+ else
41
+ render json: { saved: false, error: error }, status: :unprocessable_entity
42
+ end
43
+ end
44
+
45
+ def submit
46
+ response = open_response!
47
+ return redirect_to(studio_survey_thanks_path(@survey.slug), status: :see_other) unless response
48
+
49
+ submitted = submitted_answers
50
+ @errors = {}
51
+ Studio::SurveyResponse.transaction do
52
+ @survey.questions.each do |question|
53
+ next unless submitted.key?(question.key)
54
+
55
+ ok, error = response.record_answer(question, submitted[question.key])
56
+ @errors[question.key] = error unless ok
57
+ end
58
+ end
59
+ response.missing_required(@survey).each { |key| @errors[key] ||= "This one is required." }
60
+
61
+ if @errors.empty? && response.complete!(@survey)
62
+ run_completed_hook(response)
63
+ redirect_to studio_survey_thanks_path(@survey.slug), status: :see_other
64
+ elsif response.completed?
65
+ # A racing submit completed it first; that one fired the hook.
66
+ redirect_to studio_survey_thanks_path(@survey.slug), status: :see_other
67
+ else
68
+ @response = response
69
+ render_survey(status: :unprocessable_entity)
70
+ end
71
+ end
72
+
73
+ def thanks
74
+ @response = locate_response
75
+ redirect_to(studio_survey_path(@survey.slug), status: :see_other) unless @response&.completed?
76
+ end
77
+
78
+ private
79
+
80
+ def load_survey
81
+ @survey = Studio.survey(params[:slug])
82
+ render :not_found, status: :not_found unless @survey
83
+ end
84
+
85
+ def require_table
86
+ return if Studio::SurveyResponse.table_ready?
87
+
88
+ raise Studio::SurveyResponse::MissingTable,
89
+ "studio_survey_responses is not installed — run bin/rails studio_engine:install:migrations && bin/rails db:migrate"
90
+ end
91
+
92
+ # Signed in, arrived by an attributed email link, or an anonymous survey.
93
+ def require_respondent
94
+ return if @survey.allow_anonymous? || survey_user || survey_ref.present?
95
+
96
+ render :sign_in_required, status: :unauthorized
97
+ end
98
+
99
+ def render_survey(status: :ok)
100
+ @resume_key = @errors.keys.first || @response&.resume_key(@survey)
101
+ @resume_index = @resume_key ? @survey.keys.index(@resume_key) : nil
102
+ render :show, status: status
103
+ end
104
+
105
+ def survey_user
106
+ return @survey_user if defined?(@survey_user)
107
+
108
+ @survey_user = respond_to?(:current_user, true) ? current_user : nil
109
+ end
110
+
111
+ def survey_ref
112
+ return @survey_ref if defined?(@survey_ref)
113
+
114
+ @survey_ref = begin
115
+ Studio.survey_ref_resolver&.call(self).to_s.strip.presence
116
+ rescue StandardError => e
117
+ log_survey_error(e)
118
+ nil
119
+ end
120
+ end
121
+
122
+ def token_key = "studio_survey_#{@survey.slug}"
123
+
124
+ def session_token
125
+ session[token_key] ||= SecureRandom.urlsafe_base64(24)
126
+ end
127
+
128
+ def locate_response
129
+ Studio::SurveyResponse.locate(@survey, user: survey_user, token: session[token_key])
130
+ end
131
+
132
+ # The visitor's open response, created on demand; nil if theirs is complete.
133
+ def open_response!
134
+ existing = locate_response
135
+ return nil if existing&.completed?
136
+
137
+ response = existing || Studio::SurveyResponse.open_for!(
138
+ @survey, user: survey_user, token: session_token, email_ref: survey_ref, user_agent: request.user_agent
139
+ )
140
+ response.update!(email_ref: survey_ref.first(255)) if response.email_ref.blank? && survey_ref.present?
141
+ response
142
+ end
143
+
144
+ def submitted_answers
145
+ raw = params[:answers]
146
+ return {} unless raw.respond_to?(:permit)
147
+
148
+ permitted = @survey.questions.map { |q| q.multi? ? { q.key => [] } : q.key }
149
+ raw.permit(*permitted).to_h
150
+ end
151
+
152
+ def rescue_survey_write(target)
153
+ yield
154
+ rescue ActiveRecord::ActiveRecordError => e
155
+ log_survey_error(e, target: target)
156
+ [false, "Couldn't save that answer. Please try again."]
157
+ end
158
+
159
+ def run_completed_hook(response)
160
+ Studio.on_survey_completed&.call(response)
161
+ rescue StandardError => e
162
+ log_survey_error(e, target: response)
163
+ end
164
+
165
+ def log_survey_error(error, target: nil)
166
+ log = defined?(::ErrorLog) && ::ErrorLog.respond_to?(:capture!) ? ::ErrorLog.capture!(error) : nil
167
+ log.update(target: target) if log && target && log.respond_to?(:target=)
168
+ Rails.logger.error("[studio-survey] #{error.class}: #{error.message}")
169
+ rescue StandardError
170
+ Rails.logger.error("[studio-survey] #{error.class}: #{error.message}")
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # One respondent's answers to one Studio::Survey (docs/SURVEYS.md).
5
+ #
6
+ # The survey is app code; this row is the only thing stored. Each answer is a
7
+ # self-describing snapshot — value, question label, type, and the option
8
+ # label(s) chosen — so a response stays readable after its survey changes.
9
+ class SurveyResponse < ApplicationRecord
10
+ self.table_name = "studio_survey_responses"
11
+
12
+ class MissingTable < StandardError; end
13
+
14
+ belongs_to :user, class_name: "::User", optional: true
15
+
16
+ validates :survey_slug, presence: true
17
+ validates :started_at, presence: true
18
+
19
+ scope :completed, -> { where.not(completed_at: nil) }
20
+ scope :in_progress, -> { where(completed_at: nil) }
21
+ scope :for_survey, ->(slug) { where(survey_slug: slug.to_s) }
22
+
23
+ before_validation { self.started_at ||= Time.current }
24
+
25
+ UA_BOT = /bot|crawl|spider|slurp|preview|facebookexternalhit|curl|wget|python|headless/i
26
+ UA_TABLET = /ipad|tablet|kindle|silk|(android(?!.*mobile))/i
27
+ UA_MOBILE = /mobi|iphone|ipod|android|blackberry|opera mini|iemobile/i
28
+
29
+ class << self
30
+ def table_ready?
31
+ table_exists?
32
+ rescue ActiveRecord::ActiveRecordError, NameError
33
+ false
34
+ end
35
+
36
+ # The coarse device class stored instead of the raw user agent.
37
+ def user_agent_class(user_agent)
38
+ ua = user_agent.to_s
39
+ return "unknown" if ua.strip.empty?
40
+ return "bot" if ua.match?(UA_BOT)
41
+ return "tablet" if ua.match?(UA_TABLET)
42
+ return "mobile" if ua.match?(UA_MOBILE)
43
+
44
+ "desktop"
45
+ end
46
+
47
+ # The visitor's open response, or their latest completed one: by user
48
+ # first, then by the session token. An anonymous response found by token
49
+ # is claimed by the user who signs in mid-survey, and the claim drops the
50
+ # token: logout keeps the survey session key, so a token on a user's row
51
+ # would hand it to the next person to sign in on that browser.
52
+ def locate(survey, user: nil, token: nil)
53
+ scope = for_survey(survey.slug)
54
+ found = user && scope.where(user_id: user.id).order(completed_at: :desc, id: :desc).find_by(completed_at: nil)
55
+ found ||= token.present? && scope.where(session_token: token, user_id: nil).order(id: :desc).first
56
+ found ||= user && scope.where(user_id: user.id).order(id: :desc).first
57
+ return nil unless found
58
+
59
+ found.update!(user_id: user.id, session_token: nil) if user && found.user_id.nil? && found.open?
60
+ found
61
+ end
62
+
63
+ # Find the visitor's open response or start one. The partial unique
64
+ # indexes make a double-click race lose to the row that won.
65
+ def open_for!(survey, user: nil, token:, email_ref: nil, user_agent: nil)
66
+ existing = locate(survey, user: user, token: token)
67
+ return existing if existing&.open?
68
+
69
+ create!(survey_slug: survey.slug, survey_version: survey.version, user_id: user&.id,
70
+ session_token: (token unless user), email_ref: email_ref.to_s.strip.presence&.first(255),
71
+ user_agent_class: user_agent_class(user_agent), answers: {})
72
+ rescue ActiveRecord::RecordNotUnique
73
+ locate(survey, user: user, token: token) || raise
74
+ end
75
+ end
76
+
77
+ def open? = completed_at.nil?
78
+ def completed? = completed_at.present?
79
+
80
+ def answer_entry(key) = (answers || {})[key.to_s]
81
+ def answer_value(key) = answer_entry(key)&.dig("value")
82
+
83
+ def answered?(question)
84
+ entry = answer_entry(question.key)
85
+ entry.is_a?(Hash) && !question.blank_value?(entry["value"])
86
+ end
87
+
88
+ # Stores one answer (or clears it, for a blank). Returns [ok, error]. Never
89
+ # touches a completed response: a finished survey is a record, not a draft.
90
+ def record_answer(question, raw)
91
+ return [false, "This survey is already complete."] if completed?
92
+
93
+ value, error = question.normalize(raw)
94
+ return [false, error] if error
95
+
96
+ next_answers = (answers || {}).dup
97
+ if value.nil?
98
+ next_answers.delete(question.key)
99
+ else
100
+ next_answers[question.key] = {
101
+ "value" => value, "label" => question.label, "type" => question.type.to_s,
102
+ "display" => question.display(value), "answered_at" => Time.current.utc.iso8601
103
+ }
104
+ end
105
+ self.answers = next_answers
106
+ self.current_key = question.key
107
+ save!
108
+ [true, nil]
109
+ end
110
+
111
+ def missing_required(survey)
112
+ survey.questions.select { |q| q.required? && !answered?(q) }.map(&:key)
113
+ end
114
+
115
+ # The first unanswered question — where a returning respondent resumes.
116
+ def resume_key(survey)
117
+ survey.questions.find { |q| !answered?(q) }&.key || survey.questions.last.key
118
+ end
119
+
120
+ # Completes the response if every required question is answered. Returns
121
+ # true only when THIS call completed it (false if incomplete or already
122
+ # done). The flip is one conditional UPDATE, so two racing submits (a no-JS
123
+ # double click) cannot both win and fire on_survey_completed twice; the
124
+ # loser reloads and sees the winner's completed_at.
125
+ def complete!(survey)
126
+ return false if completed? || missing_required(survey).any?
127
+
128
+ now = Time.current
129
+ flipped = self.class.where(id: id, completed_at: nil)
130
+ .update_all(completed_at: now, survey_version: survey.version, updated_at: now)
131
+ unless flipped == 1
132
+ reload
133
+ return false
134
+ end
135
+
136
+ self.completed_at = now
137
+ self.survey_version = survey.version
138
+ self.updated_at = now
139
+ clear_attribute_changes(%w[completed_at survey_version updated_at])
140
+ true
141
+ end
142
+
143
+ # "username" for a signed-in respondent, "anonymous" otherwise.
144
+ def respondent_label
145
+ return "anonymous" unless user
146
+
147
+ user.try(:username).presence || user.try(:display_name).presence || "user ##{user.id}"
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,41 @@
1
+ <%# /admin/surveys — every survey with its funnel (Studio::AdminSurveysController).
2
+ "Started" counts responses with at least one answer: a row is created on the
3
+ first answer, never on a page view. %>
4
+ <% content_for(:title, "Surveys") %>
5
+ <div class="max-w-4xl mx-auto px-4 py-6" data-admin-surveys>
6
+ <h1 class="text-2xl font-extrabold text-heading mb-1">Surveys</h1>
7
+ <p class="text-sm text-secondary mb-6">Defined in <code>config/surveys/</code>. A response counts as started at its first answer.</p>
8
+
9
+ <% if @rows.empty? %>
10
+ <div class="empty-state">
11
+ <p class="text-heading font-semibold mb-1">No surveys yet</p>
12
+ <p class="text-sm text-muted">Define one with <code>Studio.define_survey</code> — see the engine's docs/SURVEYS.md.</p>
13
+ </div>
14
+ <% else %>
15
+ <div class="card overflow-x-auto">
16
+ <table class="w-full text-sm">
17
+ <thead>
18
+ <tr class="text-left border-b border-subtle">
19
+ <th scope="col" class="px-4 py-3 label-upper font-medium">Survey</th>
20
+ <th scope="col" class="px-4 py-3 label-upper font-medium text-right">Started</th>
21
+ <th scope="col" class="px-4 py-3 label-upper font-medium text-right">Completed</th>
22
+ <th scope="col" class="px-4 py-3 label-upper font-medium text-right">Completion</th>
23
+ </tr>
24
+ </thead>
25
+ <tbody>
26
+ <% @rows.each do |row| %>
27
+ <tr class="border-b border-subtle last:border-0" data-admin-survey-row="<%= row[:slug] %>">
28
+ <td class="px-4 py-3">
29
+ <a href="<%= admin_survey_path(row[:slug]) %>" class="font-semibold text-heading hover:underline"><%= row[:survey]&.title || row[:slug] %></a>
30
+ <div class="text-xs text-muted font-mono">/surveys/<%= row[:slug] %><% unless row[:survey] %> · definition removed<% end %></div>
31
+ </td>
32
+ <td class="px-4 py-3 text-right tabular-nums text-body" data-stat="started"><%= row[:started] %></td>
33
+ <td class="px-4 py-3 text-right tabular-nums text-body" data-stat="completed"><%= row[:completed] %></td>
34
+ <td class="px-4 py-3 text-right tabular-nums font-semibold text-heading" data-stat="rate"><%= row[:started].zero? ? "—" : "#{row[:rate]}%" %></td>
35
+ </tr>
36
+ <% end %>
37
+ </tbody>
38
+ </table>
39
+ </div>
40
+ <% end %>
41
+ </div>
@@ -0,0 +1,99 @@
1
+ <%# /admin/surveys/:slug — one survey's results (Studio::AdminSurveysController).
2
+ Distribution bars for scale and choice questions (percent of those who
3
+ answered that question), a scrolling list for text answers, and filters
4
+ that the CSV export carries along. %>
5
+ <% title = @survey&.title || @slug %>
6
+ <% content_for(:title, "#{title} · Surveys") %>
7
+ <% filter_params = { from: @filters[:from]&.iso8601, to: @filters[:to]&.iso8601, completed: (@filters[:completed] ? "1" : nil) }.compact %>
8
+ <div class="max-w-4xl mx-auto px-4 py-6" data-admin-survey="<%= @slug %>">
9
+ <a href="<%= admin_surveys_path %>" class="text-sm text-secondary hover:underline">&larr; All surveys</a>
10
+ <div class="flex flex-wrap items-end justify-between gap-3 mt-2 mb-6">
11
+ <div>
12
+ <h1 class="text-2xl font-extrabold text-heading"><%= title %></h1>
13
+ <p class="text-xs text-muted font-mono">/surveys/<%= @slug %><% if @survey %> · version <%= @survey.version %><% end %></p>
14
+ </div>
15
+ <a href="<%= admin_survey_export_path(@slug, **filter_params) %>" class="btn btn-neutral btn-sm" data-survey-export>Export CSV</a>
16
+ </div>
17
+
18
+ <form method="get" action="<%= admin_survey_path(@slug) %>" class="card p-4 mb-6 flex flex-wrap items-end gap-4" data-survey-filters>
19
+ <label class="flex flex-col gap-1 text-sm">
20
+ <span class="label-upper">From</span>
21
+ <input type="date" name="from" value="<%= @filters[:from]&.iso8601 %>" class="input-field py-2">
22
+ </label>
23
+ <label class="flex flex-col gap-1 text-sm">
24
+ <span class="label-upper">To</span>
25
+ <input type="date" name="to" value="<%= @filters[:to]&.iso8601 %>" class="input-field py-2">
26
+ </label>
27
+ <label class="flex items-center gap-2 text-sm text-body py-2">
28
+ <input type="checkbox" name="completed" value="1" <%= "checked" if @filters[:completed] %>>
29
+ Completed only
30
+ </label>
31
+ <button type="submit" class="btn btn-primary btn-sm">Apply</button>
32
+ <% if filter_params.any? %><a href="<%= admin_survey_path(@slug) %>" class="text-sm text-secondary hover:underline py-2">Clear</a><% end %>
33
+ </form>
34
+
35
+ <div class="grid grid-cols-3 gap-3 mb-6">
36
+ <div class="card p-4 text-center">
37
+ <div class="label-upper mb-1">Started</div>
38
+ <div class="text-2xl font-bold text-heading tabular-nums" data-stat="started"><%= @started %></div>
39
+ </div>
40
+ <div class="card p-4 text-center">
41
+ <div class="label-upper mb-1">Completed</div>
42
+ <div class="text-2xl font-bold text-heading tabular-nums" data-stat="completed"><%= @completed %></div>
43
+ </div>
44
+ <div class="card p-4 text-center">
45
+ <div class="label-upper mb-1">Completion</div>
46
+ <div class="text-2xl font-bold text-heading tabular-nums" data-stat="rate"><%= @started.zero? ? "—" : "#{Studio::Survey::Breakdown.percent(@completed, @started)}%" %></div>
47
+ </div>
48
+ </div>
49
+
50
+ <% if @breakdown.nil? %>
51
+ <div class="empty-state">
52
+ <p class="text-heading font-semibold mb-1">This survey's definition was removed</p>
53
+ <p class="text-sm text-muted">Its responses are still stored — the CSV export lists every answer under its original question key.</p>
54
+ </div>
55
+ <% else %>
56
+ <div class="flex flex-col gap-4">
57
+ <% @breakdown.results.each_with_index do |result, i| %>
58
+ <% q = result.question %>
59
+ <section class="card p-5" data-survey-question="<%= q.key %>">
60
+ <div class="flex flex-wrap items-baseline justify-between gap-2 mb-4">
61
+ <h2 class="text-base font-bold text-heading"><span class="text-muted font-medium mr-1"><%= i + 1 %>.</span><%= q.label %></h2>
62
+ <p class="text-xs text-muted">
63
+ <%= pluralize(result.answered, "answer") %><% if result.average %> · average <span class="font-semibold text-heading" data-survey-average><%= result.average %></span> / 5<% end %>
64
+ </p>
65
+ </div>
66
+
67
+ <% if result.text? %>
68
+ <% if result.entries.empty? %>
69
+ <p class="text-sm text-muted">No answers yet.</p>
70
+ <% else %>
71
+ <ul class="flex flex-col gap-2 overflow-y-auto pr-1" style="max-height: 22rem;" data-survey-text-answers>
72
+ <% result.entries.each do |entry| %>
73
+ <li class="bg-inset rounded-lg px-3 py-2">
74
+ <p class="text-sm text-body whitespace-pre-line break-words"><%= entry.text %></p>
75
+ <p class="text-xs text-muted mt-1"><%= entry.respondent %><% if entry.at %> · <%= entry.at.strftime("%b %-d, %Y") %><% end %></p>
76
+ </li>
77
+ <% end %>
78
+ </ul>
79
+ <% end %>
80
+ <% else %>
81
+ <ul class="flex flex-col gap-2" data-survey-buckets>
82
+ <% result.buckets.each do |bucket| %>
83
+ <li class="grid items-center gap-3" style="grid-template-columns: minmax(6rem, 11rem) 1fr 4.5rem;" data-survey-bucket="<%= bucket.value %>">
84
+ <span class="text-sm text-body truncate" title="<%= bucket.label %>">
85
+ <% if bucket.face %><span aria-hidden="true" class="mr-1"><%= bucket.face %></span><% end %><%= bucket.label %><% end_label = { 1 => q.low_label, 5 => q.high_label }[bucket.value] if q.type == :rating %><% if end_label %> <span class="text-xs text-muted">· <%= end_label %></span><% end %><% if bucket.retired %> <span class="text-xs text-muted">(retired)</span><% end %>
86
+ </span>
87
+ <span class="h-3 rounded-full bg-inset overflow-hidden" role="img" aria-label="<%= bucket.percent %> percent">
88
+ <span class="block h-full rounded-full" style="width: <%= bucket.percent %>%; background: var(--color-cta);"></span>
89
+ </span>
90
+ <span class="text-sm tabular-nums text-right text-heading"><span data-survey-percent><%= bucket.percent %>%</span> <span class="text-xs text-muted">(<span data-survey-count><%= bucket.count %></span>)</span></span>
91
+ </li>
92
+ <% end %>
93
+ </ul>
94
+ <% end %>
95
+ </section>
96
+ <% end %>
97
+ </div>
98
+ <% end %>
99
+ </div>