bootsinthepark · everything up to 2026-08-03 · every figure double-checked
AI answers most tickets, closes them faster, and customers end up happier
AI has worked 5,165 tickets and closes 61.9% of answered tickets on its own. The split is 3,836 AI alone, 1,329 AI helped and 1,032 staff only.
The middle AI-only ticket closes in 8.3 hours. Before Easol it took 144.6. That is 94.2% off. Staff-only tickets on Easol also close in 8.3 hours. 3,831 AI-only and 571 before-Easol tickets have a close time.
91.3% of replied AI-only tickets end after a single reply, against 51.7% staff only. That is 76.4% more. When AI only helped, 69.2%.
Across 6,735 scored tickets, tickets AI was involved in average 4.3% higher than staff-only ones: 3.5 for AI alone, 3.44 AI helped, 3.34 staff only. Happy endings are 10.2% more common than before Easol.
Before Easol, 8.0% of conversations ended in a worse mood than they started and 3.6% ended in a better one. On AI-only tickets that is 3.7% and 5.7% — 53.2% fewer sour endings and 60.3% more happy ones.
One entry per figure: what it means, the SQL that made it, and when it was last re-checked.
How much higher the average happiness score is when AI was involved, against tickets staff handled alone. AI reads each conversation and scores it 1 to 5
WITH s AS (
SELECT
CASE
WHEN t.imported THEN 'pre_easol'
WHEN t.ai_resolved THEN 'full_ai'
WHEN (t.ai_draft_sent_count > 0 OR t.booking_action_performed_by_ai_count > 0) THEN 'partial_ai'
WHEN t.sent_message_count > 0 THEN 'no_ai'
ELSE 'no_reply'
END AS bucket,
sen.inferred_satisfaction
FROM reporting.inbox_tickets t
JOIN inbox_ticket_sentiments sen ON sen.ticket_id = t.id
WHERE t.company_id = $1 AND t.created_at < $2
)
SELECT ROUND(100.0 * (
(SELECT AVG(inferred_satisfaction) FROM s WHERE bucket IN ('full_ai', 'partial_ai'))
/ (SELECT AVG(inferred_satisfaction) FROM s WHERE bucket = 'no_ai') - 1
), 1) AS value
Source: app/models/inbox/ticket_sentiment.rb:19 (validates :inferred_satisfaction); written at app/workers/inbox/analyze_sentiment_worker.rb:24
Verified: 2026-08-04T12:48:25Z
How much more often a ticket ends after a single reply when AI handles it alone, against tickets staff handled alone
WITH b AS (
SELECT
CASE
WHEN imported THEN 'pre_easol'
WHEN ai_resolved THEN 'full_ai'
WHEN (ai_draft_sent_count > 0 OR booking_action_performed_by_ai_count > 0) THEN 'partial_ai'
WHEN sent_message_count > 0 THEN 'no_ai'
ELSE 'no_reply'
END AS bucket,
sent_message_count
FROM reporting.inbox_tickets
WHERE company_id = $1 AND created_at < $2
),
rates AS (
SELECT bucket,
COUNT(*) FILTER (WHERE sent_message_count = 1)::numeric
/ NULLIF(COUNT(*) FILTER (WHERE sent_message_count > 0), 0) AS rate
FROM b GROUP BY bucket
)
SELECT ROUND(100.0 * (
(SELECT rate FROM rates WHERE bucket = 'full_ai')
/ (SELECT rate FROM rates WHERE bucket = 'no_ai') - 1
), 1) AS value
Source: app/models/reporting/inbox/ticket.rb:176 (Reporting::Inbox::Ticket#sent_message_count)
Verified: 2026-08-03T16:10:59Z
Cut in the typical time to close a ticket — AI alone now, against the old email threads from before Easol
WITH r AS (
SELECT
CASE
WHEN imported THEN 'pre_easol'
WHEN ai_resolved THEN 'full_ai'
WHEN (ai_draft_sent_count > 0 OR booking_action_performed_by_ai_count > 0) THEN 'partial_ai'
WHEN sent_message_count > 0 THEN 'no_ai'
ELSE 'no_reply'
END AS bucket,
resolution_time_seconds
FROM reporting.inbox_tickets
WHERE company_id = $1 AND created_at < $2 AND resolution_time_seconds IS NOT NULL
)
SELECT ROUND((100.0 * (1 -
(SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY resolution_time_seconds) FROM r WHERE bucket = 'full_ai')
/ (SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY resolution_time_seconds) FROM r WHERE bucket = 'pre_easol')
))::numeric, 1) AS value
Source: app/models/reporting/inbox/ticket.rb:148 (Reporting::Inbox::Ticket::AttributesBuilder#resolution_time_seconds)
Verified: 2026-08-03T16:10:59Z
Cut in the typical wait for a first reply — AI alone now, against the old email threads from before Easol
Measured the same way on both sides: customer's first message to the first reply out. Against Easol tickets staff handled alone, AI is a shade slower, not faster
WITH r AS (
SELECT
CASE
WHEN imported THEN 'pre_easol'
WHEN ai_resolved THEN 'full_ai'
WHEN (ai_draft_sent_count > 0 OR booking_action_performed_by_ai_count > 0) THEN 'partial_ai'
WHEN sent_message_count > 0 THEN 'no_ai'
ELSE 'no_reply'
END AS bucket,
first_response_time_seconds
FROM reporting.inbox_tickets
WHERE company_id = $1 AND created_at < $2 AND first_response_time_seconds IS NOT NULL
)
SELECT ROUND((100.0 * (1 -
(SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY first_response_time_seconds) FROM r WHERE bucket = 'full_ai')
/ (SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY first_response_time_seconds) FROM r WHERE bucket = 'pre_easol')
))::numeric, 1) AS value
Source: app/models/reporting/inbox/ticket.rb:142 (Reporting::Inbox::Ticket::AttributesBuilder#first_response_time_seconds)
Verified: 2026-08-03T16:10:59Z
Of every ticket answered on Easol, how many the AI closed on its own
WITH b AS (
SELECT
CASE
WHEN imported THEN 'pre_easol'
WHEN ai_resolved THEN 'full_ai'
WHEN (ai_draft_sent_count > 0 OR booking_action_performed_by_ai_count > 0) THEN 'partial_ai'
WHEN sent_message_count > 0 THEN 'no_ai'
ELSE 'no_reply'
END AS bucket
FROM reporting.inbox_tickets
WHERE company_id = $1 AND created_at < $2
)
SELECT ROUND(100.0 * COUNT(*) FILTER (WHERE bucket = 'full_ai') / COUNT(*), 1) AS value
FROM b WHERE bucket IN ('no_ai', 'partial_ai', 'full_ai')
Source: app/models/reporting/inbox/ticket.rb:178-186,205-207 (Reporting::Inbox::Ticket::AttributesBuilder#imported, #ai_resolved, #ai_draft_sent_count, #booking_action_performed_by_ai_count)
Verified: 2026-08-03T16:10:59Z
Tickets where AI closed it, sent a reply, or changed a booking
WITH b AS (
SELECT
CASE
WHEN imported THEN 'pre_easol'
WHEN ai_resolved THEN 'full_ai'
WHEN (ai_draft_sent_count > 0 OR booking_action_performed_by_ai_count > 0) THEN 'partial_ai'
WHEN sent_message_count > 0 THEN 'no_ai'
ELSE 'no_reply'
END AS bucket
FROM reporting.inbox_tickets
WHERE company_id = $1 AND created_at < $2
)
SELECT COUNT(*) AS value FROM b WHERE bucket IN ('full_ai', 'partial_ai')
Source: app/models/reporting/inbox/ticket.rb:178-186,205-207 (Reporting::Inbox::Ticket::AttributesBuilder#imported, #ai_resolved, #ai_draft_sent_count, #booking_action_performed_by_ai_count)
Verified: 2026-08-03T16:10:59Z
Cut in how often a conversation ends in a worse mood than it started — AI alone now, against before Easol
8.0% of conversations ended worse than they started before Easol, against 3.7% on AI-only tickets now
WITH s AS (
SELECT
CASE
WHEN t.imported THEN 'pre_easol'
WHEN t.ai_resolved THEN 'full_ai'
WHEN (t.ai_draft_sent_count > 0 OR t.booking_action_performed_by_ai_count > 0) THEN 'partial_ai'
WHEN t.sent_message_count > 0 THEN 'no_ai'
ELSE 'no_reply'
END AS bucket,
sen.opening, sen.closing
FROM reporting.inbox_tickets t
JOIN inbox_ticket_sentiments sen ON sen.ticket_id = t.id
WHERE t.company_id = $1 AND t.created_at < $2
),
rates AS (
SELECT bucket, COUNT(*) FILTER (WHERE closing < opening)::numeric / COUNT(*) AS souring
FROM s GROUP BY bucket
)
SELECT ROUND(100.0 * (1 -
(SELECT souring FROM rates WHERE bucket = 'full_ai')
/ (SELECT souring FROM rates WHERE bucket = 'pre_easol')
), 1) AS value
Source: app/models/inbox/ticket_sentiment.rb:17-18 (opening/closing columns); written at app/workers/inbox/analyze_sentiment_worker.rb:22-23
Verified: 2026-08-04T12:48:25Z
How much more often a conversation ends in a better mood than it started — AI alone now, against before Easol
Small on both sides — 3.6% before Easol against 5.7% on AI-only tickets, and the before-Easol side is 20 tickets
WITH s AS (
SELECT
CASE
WHEN t.imported THEN 'pre_easol'
WHEN t.ai_resolved THEN 'full_ai'
WHEN (t.ai_draft_sent_count > 0 OR t.booking_action_performed_by_ai_count > 0) THEN 'partial_ai'
WHEN t.sent_message_count > 0 THEN 'no_ai'
ELSE 'no_reply'
END AS bucket,
sen.opening, sen.closing
FROM reporting.inbox_tickets t
JOIN inbox_ticket_sentiments sen ON sen.ticket_id = t.id
WHERE t.company_id = $1 AND t.created_at < $2
),
rates AS (
SELECT bucket, COUNT(*) FILTER (WHERE closing > opening)::numeric / COUNT(*) AS lift
FROM s GROUP BY bucket
)
SELECT ROUND(100.0 * (
(SELECT lift FROM rates WHERE bucket = 'full_ai')
/ (SELECT lift FROM rates WHERE bucket = 'pre_easol') - 1
), 1) AS value
Source: app/models/inbox/ticket_sentiment.rb:17-18 (opening/closing columns); written at app/workers/inbox/analyze_sentiment_worker.rb:22-23
Verified: 2026-08-04T12:48:25Z
How much more often a ticket ends happy (4 or 5 out of 5) when AI was involved, against before Easol. Happiness is read by AI from the conversation, not asked in a survey
WITH s AS (
SELECT
CASE
WHEN t.imported THEN 'pre_easol'
WHEN t.ai_resolved THEN 'full_ai'
WHEN (t.ai_draft_sent_count > 0 OR t.booking_action_performed_by_ai_count > 0) THEN 'partial_ai'
WHEN t.sent_message_count > 0 THEN 'no_ai'
ELSE 'no_reply'
END AS bucket,
sen.inferred_satisfaction
FROM reporting.inbox_tickets t
JOIN inbox_ticket_sentiments sen ON sen.ticket_id = t.id
WHERE t.company_id = $1 AND t.created_at < $2
)
SELECT ROUND(100.0 * (
(SELECT COUNT(*) FILTER (WHERE inferred_satisfaction >= 4)::numeric / NULLIF(COUNT(*), 0)
FROM s WHERE bucket IN ('full_ai', 'partial_ai'))
/ (SELECT COUNT(*) FILTER (WHERE inferred_satisfaction >= 4)::numeric / NULLIF(COUNT(*), 0)
FROM s WHERE bucket = 'pre_easol') - 1
), 1) AS value
Source: app/models/inbox/ticket_sentiment.rb:19 (Inbox::TicketSentiment validates :inferred_satisfaction); written at app/workers/inbox/analyze_sentiment_worker.rb:24
Verified: 2026-08-04T12:48:25Z
Every ticket in the inbox up to the cut-off date
SELECT COUNT(*) AS value FROM reporting.inbox_tickets WHERE company_id = $1 AND created_at < $2
Source: app/models/reporting/inbox/ticket.rb:6-18 (Reporting::Inbox::Ticket, table_name reporting.inbox_tickets, default_date_filter :created_at)
Verified: 2026-08-03T16:10:59Z
Every ticket split five ways: before Easol, AI alone, AI helped, staff only, and no reply yet
SELECT
CASE
WHEN imported THEN 'pre_easol'
WHEN ai_resolved THEN 'full_ai'
WHEN (ai_draft_sent_count > 0 OR booking_action_performed_by_ai_count > 0) THEN 'partial_ai'
WHEN sent_message_count > 0 THEN 'no_ai'
ELSE 'no_reply'
END AS label,
COUNT(*) AS value
FROM reporting.inbox_tickets
WHERE company_id = $1 AND created_at < $2
GROUP BY 1 ORDER BY 1
Source: app/models/reporting/inbox/ticket.rb:178-186,205-207 (Reporting::Inbox::Ticket::AttributesBuilder#imported, #ai_resolved, #ai_draft_sent_count, #booking_action_performed_by_ai_count; delegates to Inbox::Ticket#imported? and Inbox::AiResolution via ticket.ai_resolution)
Verified: 2026-08-03T16:10:59Z
Tickets where AI has read the conversation and scored it
SELECT COUNT(*) AS value FROM reporting.inbox_tickets t JOIN inbox_ticket_sentiments s ON s.ticket_id = t.id WHERE t.company_id = $1 AND t.created_at < $2
Source: app/models/inbox/ticket.rb:27 (Inbox::Ticket has_one :sentiment, class_name: Inbox::TicketSentiment); app/models/inbox/ticket_sentiment.rb:8-9 (Inbox::TicketSentiment belongs_to :ticket, table inbox_ticket_sentiments)
Verified: 2026-08-04T12:48:25Z
Every Easol ticket that got at least one reply, whoever sent it. This is what the AI share is a share of
SELECT COUNT(*) AS value FROM reporting.inbox_tickets t WHERE t.company_id = $1 AND t.created_at < $2 AND NOT t.imported AND (t.ai_resolved OR t.ai_draft_sent_count > 0 OR t.booking_action_performed_by_ai_count > 0 OR t.sent_message_count > 0)
Source: app/models/reporting/inbox/ticket.rb
Verified: 2026-08-03T16:10:59Z
Tickets we can time from open to close, split by who handled them
SELECT
CASE
WHEN imported THEN 'pre_easol'
WHEN ai_resolved THEN 'full_ai'
WHEN (ai_draft_sent_count > 0 OR booking_action_performed_by_ai_count > 0) THEN 'partial_ai'
WHEN sent_message_count > 0 THEN 'no_ai'
ELSE 'no_reply'
END AS label,
COUNT(resolution_time_seconds) AS value
FROM reporting.inbox_tickets
WHERE company_id = $1 AND created_at < $2
GROUP BY 1 ORDER BY 1
Source: app/models/reporting/inbox/ticket.rb:148 (Reporting::Inbox::Ticket::AttributesBuilder#resolution_time_seconds)
Verified: 2026-08-03T16:10:59Z
The middle ticket's time from first message to close, split by who handled them
Both clocks start at the first message. Easol tickets stop when marked resolved; old imported threads stop at the last email out, so a long email chain reads as one slow ticket
SELECT
CASE
WHEN imported THEN 'pre_easol'
WHEN ai_resolved THEN 'full_ai'
WHEN (ai_draft_sent_count > 0 OR booking_action_performed_by_ai_count > 0) THEN 'partial_ai'
WHEN sent_message_count > 0 THEN 'no_ai'
ELSE 'no_reply'
END AS label,
ROUND(((PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY resolution_time_seconds))/3600.0)::numeric, 1) AS value
FROM reporting.inbox_tickets
WHERE company_id = $1 AND created_at < $2 AND resolution_time_seconds IS NOT NULL
GROUP BY 1 ORDER BY 1
Source: app/models/reporting/inbox/ticket.rb:148 (Reporting::Inbox::Ticket::AttributesBuilder#resolution_time_seconds)
Verified: 2026-08-03T16:10:59Z
Mean time from first message to close, split by who handled them
A few old threads that sat open for months drag this up, which is why the claims use the middle ticket instead
SELECT
CASE
WHEN imported THEN 'pre_easol'
WHEN ai_resolved THEN 'full_ai'
WHEN (ai_draft_sent_count > 0 OR booking_action_performed_by_ai_count > 0) THEN 'partial_ai'
WHEN sent_message_count > 0 THEN 'no_ai'
ELSE 'no_reply'
END AS label,
ROUND((AVG(resolution_time_seconds)/3600.0)::numeric, 1) AS value
FROM reporting.inbox_tickets
WHERE company_id = $1 AND created_at < $2 AND resolution_time_seconds IS NOT NULL
GROUP BY 1 ORDER BY 1
Source: app/models/reporting/inbox/ticket.rb:148 (Reporting::Inbox::Ticket::AttributesBuilder#resolution_time_seconds)
Verified: 2026-08-03T16:10:59Z
The middle ticket's wait from the customer's message to the first reply, split by who handled them
SELECT
CASE
WHEN imported THEN 'pre_easol'
WHEN ai_resolved THEN 'full_ai'
WHEN (ai_draft_sent_count > 0 OR booking_action_performed_by_ai_count > 0) THEN 'partial_ai'
WHEN sent_message_count > 0 THEN 'no_ai'
ELSE 'no_reply'
END AS label,
ROUND(((PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY first_response_time_seconds))/3600.0)::numeric, 1) AS value
FROM reporting.inbox_tickets
WHERE company_id = $1 AND created_at < $2 AND first_response_time_seconds IS NOT NULL
GROUP BY 1 ORDER BY 1
Source: app/models/reporting/inbox/ticket.rb:142 (Reporting::Inbox::Ticket::AttributesBuilder#first_response_time_seconds)
Verified: 2026-08-03T16:10:59Z
Tickets with at least one reply sent, split by who handled them
SELECT
CASE
WHEN imported THEN 'pre_easol'
WHEN ai_resolved THEN 'full_ai'
WHEN (ai_draft_sent_count > 0 OR booking_action_performed_by_ai_count > 0) THEN 'partial_ai'
WHEN sent_message_count > 0 THEN 'no_ai'
ELSE 'no_reply'
END AS label,
COUNT(*) FILTER (WHERE sent_message_count > 0) AS value
FROM reporting.inbox_tickets
WHERE company_id = $1 AND created_at < $2
GROUP BY 1 ORDER BY 1
Source: app/models/reporting/inbox/ticket.rb:176 (Reporting::Inbox::Ticket#sent_message_count)
Verified: 2026-08-03T16:10:59Z
Tickets that needed exactly one reply, split by who handled them
SELECT
CASE
WHEN imported THEN 'pre_easol'
WHEN ai_resolved THEN 'full_ai'
WHEN (ai_draft_sent_count > 0 OR booking_action_performed_by_ai_count > 0) THEN 'partial_ai'
WHEN sent_message_count > 0 THEN 'no_ai'
ELSE 'no_reply'
END AS label,
COUNT(*) FILTER (WHERE sent_message_count = 1) AS value
FROM reporting.inbox_tickets
WHERE company_id = $1 AND created_at < $2
GROUP BY 1 ORDER BY 1
Source: app/models/reporting/inbox/ticket.rb:176 (Reporting::Inbox::Ticket#sent_message_count)
Verified: 2026-08-03T16:10:59Z
Of tickets that got a reply, the share that needed only one
No reply yet is blank here — nothing to divide by
SELECT
CASE
WHEN imported THEN 'pre_easol'
WHEN ai_resolved THEN 'full_ai'
WHEN (ai_draft_sent_count > 0 OR booking_action_performed_by_ai_count > 0) THEN 'partial_ai'
WHEN sent_message_count > 0 THEN 'no_ai'
ELSE 'no_reply'
END AS label,
ROUND(100.0 * COUNT(*) FILTER (WHERE sent_message_count = 1)
/ NULLIF(COUNT(*) FILTER (WHERE sent_message_count > 0), 0), 1) AS value
FROM reporting.inbox_tickets
WHERE company_id = $1 AND created_at < $2
GROUP BY 1 ORDER BY 1
Source: app/models/reporting/inbox/ticket.rb:176 (Reporting::Inbox::Ticket#sent_message_count)
Verified: 2026-08-03T16:10:59Z
Average mood of the customer's opening message, on a −2 to +2 scale, split by who handled the ticket
SELECT
CASE
WHEN t.imported THEN 'pre_easol'
WHEN t.ai_resolved THEN 'full_ai'
WHEN (t.ai_draft_sent_count > 0 OR t.booking_action_performed_by_ai_count > 0) THEN 'partial_ai'
WHEN t.sent_message_count > 0 THEN 'no_ai'
ELSE 'no_reply'
END AS label,
ROUND(AVG(s.opening)::numeric, 2) AS value
FROM reporting.inbox_tickets t
JOIN inbox_ticket_sentiments s ON s.ticket_id = t.id
WHERE t.company_id = $1 AND t.created_at < $2
GROUP BY 1 ORDER BY 1
Source: app/models/inbox/ticket_sentiment.rb:17 (Inbox::TicketSentiment validates :opening); written at app/workers/inbox/analyze_sentiment_worker.rb:22
Verified: 2026-08-04T12:48:25Z
Average mood at the end of the conversation, on a −2 to +2 scale, split by who handled the ticket
SELECT
CASE
WHEN t.imported THEN 'pre_easol'
WHEN t.ai_resolved THEN 'full_ai'
WHEN (t.ai_draft_sent_count > 0 OR t.booking_action_performed_by_ai_count > 0) THEN 'partial_ai'
WHEN t.sent_message_count > 0 THEN 'no_ai'
ELSE 'no_reply'
END AS label,
ROUND(AVG(s.closing)::numeric, 2) AS value
FROM reporting.inbox_tickets t
JOIN inbox_ticket_sentiments s ON s.ticket_id = t.id
WHERE t.company_id = $1 AND t.created_at < $2
GROUP BY 1 ORDER BY 1
Source: app/models/inbox/ticket_sentiment.rb:18 (Inbox::TicketSentiment validates :closing); written at app/workers/inbox/analyze_sentiment_worker.rb:23
Verified: 2026-08-04T12:48:25Z
How far the mood moved between the first and last message, in points on the −2 to +2 scale. Above zero means the customer left happier than they arrived
Most conversations end on the same score they started, so this average sits close to zero. The share that moves up or down is the clearer read
SELECT
CASE
WHEN t.imported THEN 'pre_easol'
WHEN t.ai_resolved THEN 'full_ai'
WHEN (t.ai_draft_sent_count > 0 OR t.booking_action_performed_by_ai_count > 0) THEN 'partial_ai'
WHEN t.sent_message_count > 0 THEN 'no_ai'
ELSE 'no_reply'
END AS label,
ROUND(AVG(s.closing - s.opening)::numeric, 2) AS value
FROM reporting.inbox_tickets t
JOIN inbox_ticket_sentiments s ON s.ticket_id = t.id
WHERE t.company_id = $1 AND t.created_at < $2
GROUP BY 1 ORDER BY 1
Source: app/models/inbox/ticket_sentiment.rb:17-18 (Inbox::TicketSentiment opening/closing columns); derived, no dedicated scope
Verified: 2026-08-04T12:48:25Z
Of scored tickets, the share where the closing mood is below the opening mood, split by who handled them
Staff-only and AI-helped tickets run longer, so their mood moves more in both directions. AI alone and before Easol sit at similar thread lengths (2.4 and 2.7 messages), which is the pair the headline compares
SELECT
CASE
WHEN t.imported THEN 'pre_easol'
WHEN t.ai_resolved THEN 'full_ai'
WHEN (t.ai_draft_sent_count > 0 OR t.booking_action_performed_by_ai_count > 0) THEN 'partial_ai'
WHEN t.sent_message_count > 0 THEN 'no_ai'
ELSE 'no_reply'
END AS label,
ROUND(100.0 * COUNT(*) FILTER (WHERE s.closing < s.opening) / COUNT(*), 1) AS value
FROM reporting.inbox_tickets t
JOIN inbox_ticket_sentiments s ON s.ticket_id = t.id
WHERE t.company_id = $1 AND t.created_at < $2
GROUP BY 1 ORDER BY 1
Source: app/models/inbox/ticket_sentiment.rb:17-18 (Inbox::TicketSentiment opening/closing columns); derived, no dedicated scope
Verified: 2026-08-04T12:48:25Z
Of scored tickets, the share where the closing mood is above the opening mood, split by who handled them
Most conversations never move, so these shares are small on every side. Before Easol this is 20 tickets out of 563
SELECT
CASE
WHEN t.imported THEN 'pre_easol'
WHEN t.ai_resolved THEN 'full_ai'
WHEN (t.ai_draft_sent_count > 0 OR t.booking_action_performed_by_ai_count > 0) THEN 'partial_ai'
WHEN t.sent_message_count > 0 THEN 'no_ai'
ELSE 'no_reply'
END AS label,
ROUND(100.0 * COUNT(*) FILTER (WHERE s.closing > s.opening) / COUNT(*), 1) AS value
FROM reporting.inbox_tickets t
JOIN inbox_ticket_sentiments s ON s.ticket_id = t.id
WHERE t.company_id = $1 AND t.created_at < $2
GROUP BY 1 ORDER BY 1
Source: app/models/inbox/ticket_sentiment.rb:17-18 (Inbox::TicketSentiment opening/closing columns); derived, no dedicated scope
Verified: 2026-08-04T12:48:25Z
The exact pair behind the headline — share of conversations that end worse than they started, AI alone now against before Easol
WITH s AS (
SELECT
CASE
WHEN t.imported THEN 'pre_easol'
WHEN t.ai_resolved THEN 'full_ai'
WHEN (t.ai_draft_sent_count > 0 OR t.booking_action_performed_by_ai_count > 0) THEN 'partial_ai'
WHEN t.sent_message_count > 0 THEN 'no_ai'
ELSE 'no_reply'
END AS bucket,
sen.opening, sen.closing
FROM reporting.inbox_tickets t
JOIN inbox_ticket_sentiments sen ON sen.ticket_id = t.id
WHERE t.company_id = $1 AND t.created_at < $2
)
SELECT bucket AS label,
ROUND(100.0 * COUNT(*) FILTER (WHERE closing < opening) / COUNT(*), 1) AS value
FROM s WHERE bucket IN ('full_ai', 'pre_easol')
GROUP BY 1 ORDER BY 1
Source: app/models/inbox/ticket_sentiment.rb:17-18 (Inbox::TicketSentiment opening/closing columns); derived, no dedicated scope
Verified: 2026-08-04T12:48:25Z
The exact pair behind the headline — share of conversations that end better than they started, AI alone now against before Easol
WITH s AS (
SELECT
CASE
WHEN t.imported THEN 'pre_easol'
WHEN t.ai_resolved THEN 'full_ai'
WHEN (t.ai_draft_sent_count > 0 OR t.booking_action_performed_by_ai_count > 0) THEN 'partial_ai'
WHEN t.sent_message_count > 0 THEN 'no_ai'
ELSE 'no_reply'
END AS bucket,
sen.opening, sen.closing
FROM reporting.inbox_tickets t
JOIN inbox_ticket_sentiments sen ON sen.ticket_id = t.id
WHERE t.company_id = $1 AND t.created_at < $2
)
SELECT bucket AS label,
ROUND(100.0 * COUNT(*) FILTER (WHERE closing > opening) / COUNT(*), 1) AS value
FROM s WHERE bucket IN ('full_ai', 'pre_easol')
GROUP BY 1 ORDER BY 1
Source: app/models/inbox/ticket_sentiment.rb:17-18 (Inbox::TicketSentiment opening/closing columns); derived, no dedicated scope
Verified: 2026-08-04T12:48:25Z
AI reads each conversation and scores how happy the customer seems, 1 to 5. Averaged by who handled the ticket
SELECT
CASE
WHEN t.imported THEN 'pre_easol'
WHEN t.ai_resolved THEN 'full_ai'
WHEN (t.ai_draft_sent_count > 0 OR t.booking_action_performed_by_ai_count > 0) THEN 'partial_ai'
WHEN t.sent_message_count > 0 THEN 'no_ai'
ELSE 'no_reply'
END AS label,
ROUND(AVG(s.inferred_satisfaction)::numeric, 2) AS value
FROM reporting.inbox_tickets t
JOIN inbox_ticket_sentiments s ON s.ticket_id = t.id
WHERE t.company_id = $1 AND t.created_at < $2
GROUP BY 1 ORDER BY 1
Source: app/models/inbox/ticket_sentiment.rb:19 (Inbox::TicketSentiment validates :inferred_satisfaction); written at app/workers/inbox/analyze_sentiment_worker.rb:24
Verified: 2026-08-04T12:48:25Z
Of scored tickets, the share where the conversation reads as sorted out, split by who handled them
SELECT
CASE
WHEN t.imported THEN 'pre_easol'
WHEN t.ai_resolved THEN 'full_ai'
WHEN (t.ai_draft_sent_count > 0 OR t.booking_action_performed_by_ai_count > 0) THEN 'partial_ai'
WHEN t.sent_message_count > 0 THEN 'no_ai'
ELSE 'no_reply'
END AS label,
ROUND(100.0 * COUNT(*) FILTER (WHERE s.resolution_status = 'resolved') / COUNT(*), 1) AS value
FROM reporting.inbox_tickets t
JOIN inbox_ticket_sentiments s ON s.ticket_id = t.id
WHERE t.company_id = $1 AND t.created_at < $2
GROUP BY 1 ORDER BY 1
Source: app/models/inbox/ticket_sentiment.rb:11-12 (Inbox::TicketSentiment enum :resolution_status); written at app/workers/inbox/analyze_sentiment_worker.rb:25
Verified: 2026-08-04T12:48:25Z
Of scored tickets, the share ending at 4 or 5 out of 5, split by who handled them
Read by AI from the conversation, not asked in a survey
SELECT
CASE
WHEN t.imported THEN 'pre_easol'
WHEN t.ai_resolved THEN 'full_ai'
WHEN (t.ai_draft_sent_count > 0 OR t.booking_action_performed_by_ai_count > 0) THEN 'partial_ai'
WHEN t.sent_message_count > 0 THEN 'no_ai'
ELSE 'no_reply'
END AS label,
ROUND(100.0 * COUNT(*) FILTER (WHERE s.inferred_satisfaction >= 4) / COUNT(*), 1) AS value
FROM reporting.inbox_tickets t
JOIN inbox_ticket_sentiments s ON s.ticket_id = t.id
WHERE t.company_id = $1 AND t.created_at < $2
GROUP BY 1 ORDER BY 1
Source: app/models/inbox/ticket_sentiment.rb:19 (Inbox::TicketSentiment validates :inferred_satisfaction); written at app/workers/inbox/analyze_sentiment_worker.rb:24
Verified: 2026-08-04T12:48:25Z
The exact pair behind the headline — happy tickets when AI was involved, against happy tickets before Easol
AI involved pools AI alone and AI helped, so it lands between those two, not on either. Read by AI, not asked in a survey
WITH s AS (
SELECT
CASE
WHEN t.imported THEN 'pre_easol'
WHEN t.ai_resolved THEN 'full_ai'
WHEN (t.ai_draft_sent_count > 0 OR t.booking_action_performed_by_ai_count > 0) THEN 'partial_ai'
WHEN t.sent_message_count > 0 THEN 'no_ai'
ELSE 'no_reply'
END AS bucket,
sen.inferred_satisfaction
FROM reporting.inbox_tickets t
JOIN inbox_ticket_sentiments sen ON sen.ticket_id = t.id
WHERE t.company_id = $1 AND t.created_at < $2
)
SELECT
CASE WHEN bucket = 'pre_easol' THEN 'pre_easol' ELSE 'ai_touched' END AS label,
ROUND(100.0 * COUNT(*) FILTER (WHERE inferred_satisfaction >= 4) / COUNT(*), 1) AS value
FROM s
WHERE bucket IN ('full_ai', 'partial_ai', 'pre_easol')
GROUP BY 1 ORDER BY 1
Source: app/models/reporting/inbox/ticket.rb
Verified: 2026-08-04T12:48:25Z
How many tickets each group has a mood score for. Every happiness figure is a share of these
WITH s AS (
SELECT
CASE
WHEN t.imported THEN 'pre_easol'
WHEN t.ai_resolved THEN 'full_ai'
WHEN (t.ai_draft_sent_count > 0 OR t.booking_action_performed_by_ai_count > 0) THEN 'partial_ai'
WHEN t.sent_message_count > 0 THEN 'no_ai'
ELSE 'no_reply'
END AS bucket,
sen.inferred_satisfaction
FROM reporting.inbox_tickets t
JOIN inbox_ticket_sentiments sen ON sen.ticket_id = t.id
WHERE t.company_id = $1 AND t.created_at < $2
)
SELECT bucket AS label, COUNT(*) AS value
FROM s
GROUP BY 1 ORDER BY 1
Source: app/models/reporting/inbox/ticket.rb
Verified: 2026-08-04T12:48:25Z
The exact pair behind the headline — average score when AI was involved, against tickets staff handled alone
AI involved pools AI alone and AI helped, so it lands between those two, not on either
WITH s AS (
SELECT
CASE
WHEN t.imported THEN 'pre_easol'
WHEN t.ai_resolved THEN 'full_ai'
WHEN (t.ai_draft_sent_count > 0 OR t.booking_action_performed_by_ai_count > 0) THEN 'partial_ai'
WHEN t.sent_message_count > 0 THEN 'no_ai'
ELSE 'no_reply'
END AS bucket,
sen.inferred_satisfaction
FROM reporting.inbox_tickets t
JOIN inbox_ticket_sentiments sen ON sen.ticket_id = t.id
WHERE t.company_id = $1 AND t.created_at < $2
)
SELECT
CASE WHEN bucket = 'no_ai' THEN 'no_ai' ELSE 'ai_touched' END AS label,
ROUND(AVG(inferred_satisfaction)::numeric, 2) AS value
FROM s
WHERE bucket IN ('full_ai', 'partial_ai', 'no_ai')
GROUP BY 1 ORDER BY 1
Source: app/models/reporting/inbox/ticket.rb
Verified: 2026-08-04T12:48:25Z
Booking changes made without AI
SELECT COUNT(*) AS value FROM booking_edits be JOIN finance_bookings fb ON fb.id = be.finance_booking_id WHERE fb.company_id = $1 AND be.created_at < $2 AND be.copilot_session_id IS NULL
Source: app/models/booking_edit.rb:32 (belongs_to :copilot_session)
Verified: 2026-08-03T16:10:59Z
Staff booking changes that were actually applied
SELECT COUNT(*) AS value FROM booking_edits be JOIN finance_bookings fb ON fb.id = be.finance_booking_id WHERE fb.company_id = $1 AND be.created_at < $2 AND be.copilot_session_id IS NULL AND be.applied_at IS NOT NULL
Source: app/models/booking_edit.rb:84 (BookingEdit#apply!, touch(:applied_at))
Verified: 2026-08-03T16:10:59Z
Middle time between starting a booking change and applying it
Tiny, because this times the edit screen, not the whole job
SELECT ROUND(((PERCENTILE_CONT(0.5) WITHIN GROUP ( ORDER BY EXTRACT(EPOCH FROM (be.applied_at - be.created_at))))/3600.0)::numeric, 3) AS value FROM booking_edits be JOIN finance_bookings fb ON fb.id = be.finance_booking_id WHERE fb.company_id = $1 AND be.created_at < $2 AND be.copilot_session_id IS NULL AND be.applied_at IS NOT NULL
Source: app/models/booking_edit.rb:84 (BookingEdit#apply!, touch(:applied_at))
Verified: 2026-08-03T16:10:59Z
Booking changes made in an AI session
SELECT COUNT(*) AS value FROM booking_edits be JOIN finance_bookings fb ON fb.id = be.finance_booking_id WHERE fb.company_id = $1 AND be.created_at < $2 AND be.copilot_session_id IS NOT NULL
Source: app/models/booking_edit.rb:32 (belongs_to :copilot_session)
Verified: 2026-08-03T16:10:59Z
AI booking changes that were actually applied
SELECT COUNT(*) AS value FROM booking_edits be JOIN finance_bookings fb ON fb.id = be.finance_booking_id WHERE fb.company_id = $1 AND be.created_at < $2 AND be.copilot_session_id IS NOT NULL AND be.applied_at IS NOT NULL
Source: app/models/booking_edit.rb:84 (BookingEdit#apply!, touch(:applied_at))
Verified: 2026-08-03T16:10:59Z
Orders where at least one booking has a ticket attached
WITH orders AS ( SELECT fb.finance_order_id, BOOL_OR(it.id IS NOT NULL) AS has_ticket FROM finance_bookings fb LEFT JOIN inbox_tickets it ON it.booking_id = fb.id AND it.created_at < $2 WHERE fb.company_id = $1 AND fb.created_at < $2 GROUP BY 1 ) SELECT COUNT(*) AS value FROM orders WHERE has_ticket
Source: app/models/inbox/ticket.rb:21 (Inbox::Ticket belongs_to :booking)
Verified: 2026-08-03T16:10:59Z
Orders where no booking has a ticket attached
WITH orders AS ( SELECT fb.finance_order_id, BOOL_OR(it.id IS NOT NULL) AS has_ticket FROM finance_bookings fb LEFT JOIN inbox_tickets it ON it.booking_id = fb.id AND it.created_at < $2 WHERE fb.company_id = $1 AND fb.created_at < $2 GROUP BY 1 ) SELECT COUNT(*) AS value FROM orders WHERE NOT has_ticket
Source: app/models/inbox/ticket.rb:21 (Inbox::Ticket belongs_to :booking)
Verified: 2026-08-03T16:10:59Z
Card disputes on orders that had a support ticket
SELECT COUNT(*) AS value
FROM stripe_disputes sd
WHERE sd.company_id = $1 AND sd.created_at < $2
AND EXISTS (
SELECT 1 FROM finance_bookings fb
JOIN inbox_tickets it ON it.booking_id = fb.id AND it.created_at < $2
WHERE fb.finance_order_id = sd.finance_order_id
)
Source: app/models/stripe_dispute.rb:7 (StripeDispute belongs_to :finance_order)
Verified: 2026-08-03T16:10:59Z
Card disputes on orders with no support ticket
SELECT COUNT(*) AS value
FROM stripe_disputes sd
WHERE sd.company_id = $1 AND sd.created_at < $2
AND NOT EXISTS (
SELECT 1 FROM finance_bookings fb
JOIN inbox_tickets it ON it.booking_id = fb.id AND it.created_at < $2
WHERE fb.finance_order_id = sd.finance_order_id
)
Source: app/models/stripe_dispute.rb:7 (StripeDispute belongs_to :finance_order)
Verified: 2026-08-03T16:10:59Z
Total disputed on orders that had a support ticket
SELECT COALESCE(SUM(sd.amount_disputed_fractional), 0) AS value
FROM stripe_disputes sd
WHERE sd.company_id = $1 AND sd.created_at < $2
AND EXISTS (
SELECT 1 FROM finance_bookings fb
JOIN inbox_tickets it ON it.booking_id = fb.id AND it.created_at < $2
WHERE fb.finance_order_id = sd.finance_order_id
)
Source: app/models/stripe_dispute.rb:11 (StripeDispute monetize :amount_disputed_fractional)
Verified: 2026-08-03T16:10:59Z
Total disputed on orders with no support ticket
SELECT COALESCE(SUM(sd.amount_disputed_fractional), 0) AS value
FROM stripe_disputes sd
WHERE sd.company_id = $1 AND sd.created_at < $2
AND NOT EXISTS (
SELECT 1 FROM finance_bookings fb
JOIN inbox_tickets it ON it.booking_id = fb.id AND it.created_at < $2
WHERE fb.finance_order_id = sd.finance_order_id
)
Source: app/models/stripe_dispute.rb:11 (StripeDispute monetize :amount_disputed_fractional)
Verified: 2026-08-03T16:10:59Z
Share of ticketed orders that got a card dispute
WITH orders AS ( SELECT fb.finance_order_id, BOOL_OR(it.id IS NOT NULL) AS has_ticket FROM finance_bookings fb LEFT JOIN inbox_tickets it ON it.booking_id = fb.id AND it.created_at < $2 WHERE fb.company_id = $1 AND fb.created_at < $2 GROUP BY 1 ) SELECT ROUND(100.0 * COUNT(*) FILTER (WHERE EXISTS ( SELECT 1 FROM stripe_disputes sd WHERE sd.finance_order_id = orders.finance_order_id AND sd.created_at < $2 )) / NULLIF(COUNT(*), 0), 2) AS value FROM orders WHERE has_ticket
Source: app/models/stripe_dispute.rb:7 (StripeDispute belongs_to :finance_order)
Verified: 2026-08-03T16:10:59Z
Share of non-ticketed orders that got a card dispute
WITH orders AS ( SELECT fb.finance_order_id, BOOL_OR(it.id IS NOT NULL) AS has_ticket FROM finance_bookings fb LEFT JOIN inbox_tickets it ON it.booking_id = fb.id AND it.created_at < $2 WHERE fb.company_id = $1 AND fb.created_at < $2 GROUP BY 1 ) SELECT ROUND(100.0 * COUNT(*) FILTER (WHERE EXISTS ( SELECT 1 FROM stripe_disputes sd WHERE sd.finance_order_id = orders.finance_order_id AND sd.created_at < $2 )) / NULLIF(COUNT(*), 0), 2) AS value FROM orders WHERE NOT has_ticket
Source: app/models/stripe_dispute.rb:7 (StripeDispute belongs_to :finance_order)
Verified: 2026-08-03T16:10:59Z
Card disputes on ticketed orders, grouped by how they ended
SELECT sd.stripe_status AS label, COUNT(*) AS value
FROM stripe_disputes sd
WHERE sd.company_id = $1 AND sd.created_at < $2
AND EXISTS (
SELECT 1 FROM finance_bookings fb
JOIN inbox_tickets it ON it.booking_id = fb.id AND it.created_at < $2
WHERE fb.finance_order_id = sd.finance_order_id
)
GROUP BY 1 ORDER BY 1
Source: app/models/stripe_dispute.rb:28 (StripeDispute.translate_payload stripe_status)
Verified: 2026-08-03T16:10:59Z
Card disputes on non-ticketed orders, grouped by how they ended
SELECT sd.stripe_status AS label, COUNT(*) AS value
FROM stripe_disputes sd
WHERE sd.company_id = $1 AND sd.created_at < $2
AND NOT EXISTS (
SELECT 1 FROM finance_bookings fb
JOIN inbox_tickets it ON it.booking_id = fb.id AND it.created_at < $2
WHERE fb.finance_order_id = sd.finance_order_id
)
GROUP BY 1 ORDER BY 1
Source: app/models/stripe_dispute.rb:28 (StripeDispute.translate_payload stripe_status)
Verified: 2026-08-03T16:10:59Z
The middle ticket's first reply for each week's tickets, before easol only. Weeks with under 10 tickets are left out so a handful cannot swing the line
Weeks are labelled by their Monday, and a ticket counts in the week it arrived
SELECT
DATE_TRUNC('week', created_at)::date::text AS label,
ROUND(((PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY first_response_time_seconds))/3600.0)::numeric, 1) AS value
FROM reporting.inbox_tickets
WHERE company_id = $1 AND created_at < $2 AND first_response_time_seconds IS NOT NULL
AND (imported)
GROUP BY 1
HAVING COUNT(*) >= 10
ORDER BY 1
Source: app/models/reporting/inbox/ticket.rb:142 (Reporting::Inbox::Ticket::AttributesBuilder#first_response_time_seconds)
Verified: 2026-08-03T16:10:59Z
The middle ticket's time to close for each week's tickets, before easol only. Weeks with under 10 tickets are left out so a handful cannot swing the line
Weeks are labelled by their Monday, and a ticket counts in the week it arrived
SELECT
DATE_TRUNC('week', created_at)::date::text AS label,
ROUND(((PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY resolution_time_seconds))/3600.0)::numeric, 1) AS value
FROM reporting.inbox_tickets
WHERE company_id = $1 AND created_at < $2 AND resolution_time_seconds IS NOT NULL
AND (imported)
GROUP BY 1
HAVING COUNT(*) >= 10
ORDER BY 1
Source: app/models/reporting/inbox/ticket.rb:148 (Reporting::Inbox::Ticket::AttributesBuilder#resolution_time_seconds)
Verified: 2026-08-03T16:10:59Z
The middle ticket's first reply for each week's tickets, ai alone only. Weeks with under 10 tickets are left out so a handful cannot swing the line
Weeks are labelled by their Monday, and a ticket counts in the week it arrived
SELECT
DATE_TRUNC('week', created_at)::date::text AS label,
ROUND(((PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY first_response_time_seconds))/3600.0)::numeric, 1) AS value
FROM reporting.inbox_tickets
WHERE company_id = $1 AND created_at < $2 AND first_response_time_seconds IS NOT NULL
AND (NOT imported AND ai_resolved)
GROUP BY 1
HAVING COUNT(*) >= 10
ORDER BY 1
Source: app/models/reporting/inbox/ticket.rb:142 (Reporting::Inbox::Ticket::AttributesBuilder#first_response_time_seconds)
Verified: 2026-08-03T16:10:59Z
The middle ticket's time to close for each week's tickets, ai alone only. Weeks with under 10 tickets are left out so a handful cannot swing the line
Weeks are labelled by their Monday, and a ticket counts in the week it arrived
SELECT
DATE_TRUNC('week', created_at)::date::text AS label,
ROUND(((PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY resolution_time_seconds))/3600.0)::numeric, 1) AS value
FROM reporting.inbox_tickets
WHERE company_id = $1 AND created_at < $2 AND resolution_time_seconds IS NOT NULL
AND (NOT imported AND ai_resolved)
GROUP BY 1
HAVING COUNT(*) >= 10
ORDER BY 1
Source: app/models/reporting/inbox/ticket.rb:148 (Reporting::Inbox::Ticket::AttributesBuilder#resolution_time_seconds)
Verified: 2026-08-03T16:10:59Z
The middle ticket's first reply for each week's tickets, ai helped only. Weeks with under 10 tickets are left out so a handful cannot swing the line
Weeks are labelled by their Monday, and a ticket counts in the week it arrived
SELECT
DATE_TRUNC('week', created_at)::date::text AS label,
ROUND(((PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY first_response_time_seconds))/3600.0)::numeric, 1) AS value
FROM reporting.inbox_tickets
WHERE company_id = $1 AND created_at < $2 AND first_response_time_seconds IS NOT NULL
AND (NOT imported AND NOT ai_resolved AND (ai_draft_sent_count > 0 OR booking_action_performed_by_ai_count > 0))
GROUP BY 1
HAVING COUNT(*) >= 10
ORDER BY 1
Source: app/models/reporting/inbox/ticket.rb:142 (Reporting::Inbox::Ticket::AttributesBuilder#first_response_time_seconds)
Verified: 2026-08-03T16:10:59Z
The middle ticket's time to close for each week's tickets, ai helped only. Weeks with under 10 tickets are left out so a handful cannot swing the line
Weeks are labelled by their Monday, and a ticket counts in the week it arrived
SELECT
DATE_TRUNC('week', created_at)::date::text AS label,
ROUND(((PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY resolution_time_seconds))/3600.0)::numeric, 1) AS value
FROM reporting.inbox_tickets
WHERE company_id = $1 AND created_at < $2 AND resolution_time_seconds IS NOT NULL
AND (NOT imported AND NOT ai_resolved AND (ai_draft_sent_count > 0 OR booking_action_performed_by_ai_count > 0))
GROUP BY 1
HAVING COUNT(*) >= 10
ORDER BY 1
Source: app/models/reporting/inbox/ticket.rb:148 (Reporting::Inbox::Ticket::AttributesBuilder#resolution_time_seconds)
Verified: 2026-08-03T16:10:59Z
The middle ticket's first reply for each week's tickets, staff only only. Weeks with under 10 tickets are left out so a handful cannot swing the line
Weeks are labelled by their Monday, and a ticket counts in the week it arrived
SELECT
DATE_TRUNC('week', created_at)::date::text AS label,
ROUND(((PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY first_response_time_seconds))/3600.0)::numeric, 1) AS value
FROM reporting.inbox_tickets
WHERE company_id = $1 AND created_at < $2 AND first_response_time_seconds IS NOT NULL
AND (NOT imported AND NOT ai_resolved AND ai_draft_sent_count = 0 AND booking_action_performed_by_ai_count = 0 AND sent_message_count > 0)
GROUP BY 1
HAVING COUNT(*) >= 10
ORDER BY 1
Source: app/models/reporting/inbox/ticket.rb:142 (Reporting::Inbox::Ticket::AttributesBuilder#first_response_time_seconds)
Verified: 2026-08-03T16:10:59Z
The middle ticket's time to close for each week's tickets, staff only only. Weeks with under 10 tickets are left out so a handful cannot swing the line
Weeks are labelled by their Monday, and a ticket counts in the week it arrived
SELECT
DATE_TRUNC('week', created_at)::date::text AS label,
ROUND(((PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY resolution_time_seconds))/3600.0)::numeric, 1) AS value
FROM reporting.inbox_tickets
WHERE company_id = $1 AND created_at < $2 AND resolution_time_seconds IS NOT NULL
AND (NOT imported AND NOT ai_resolved AND ai_draft_sent_count = 0 AND booking_action_performed_by_ai_count = 0 AND sent_message_count > 0)
GROUP BY 1
HAVING COUNT(*) >= 10
ORDER BY 1
Source: app/models/reporting/inbox/ticket.rb:148 (Reporting::Inbox::Ticket::AttributesBuilder#resolution_time_seconds)
Verified: 2026-08-03T16:10:59Z