94.2% faster to close a ticket than before Easol See the detail → 92.4% faster to first reply than before Easol See the detail → +76.4% more tickets settled in one reply than staff alone See the detail → +4.3% happier customers when AI helps See the detail → +10.2% more happy customers than before Easol See the detail → 53.2% fewer conversations end on a sour note than before Easol See the detail → +60.3% more conversations end happier than they started, against before Easol See the detail → 61.9% of answered tickets closed by AI alone See the detail → 5,165 tickets AI has worked on See the detail →

AI answers most tickets, closes them faster, and customers end up happier

AI carries most of the work

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.

Tickets close in hours, not days

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.

One reply is usually enough

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%.

Customers score it higher

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.

Conversations end better, and sour far less often

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.

Worth knowing
  • These compare different tickets in different months, not the same work done two ways. The sides are lopsided: 3,831 AI-only against 571 before-Easol tickets with a close time, and 3,835 against 1,032 replied.
  • Happiness and mood are read by AI from the conversation, not asked in a survey, and only scored tickets count.
  • AI alone is a shade slower to first reply than staff-only tickets, and AI-helped trails AI alone on several measures, so AI involved is not one uniform experience.
  • Time to close is measured differently on each side: Easol tickets stop when marked resolved, old email threads at the last email out. The middle ticket holds up; the average does not.
  • Booking changes by AI are too few to judge, and a ticket does not cause a chargeback — it more likely means the order was messy already.
Before Easol On Easol

Where the numbers come from

One entry per figure: what it means, the SQL that made it, and when it was last re-checked.

Happier customers when AI helps ai_satisfaction_uplift_pct

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

More one-reply fixes with AI ai_one_touch_uplift_pct

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

Faster to close than before Easol resolution_speedup_vs_pre_easol_pct

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

Faster first reply than before Easol first_response_speedup_vs_pre_easol_pct

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

Share of answered tickets closed by AI alone full_ai_share_of_handled_pct

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 AI worked on ai_touched_tickets

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

Fewer conversations end on a sour note souring_cut_vs_pre_easol_pct

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

More conversations end happier than they started mood_lift_uplift_vs_pre_easol_pct

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

More happy customers than before Easol csat_uplift_vs_pre_easol_pct

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

All tickets tickets_total

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

Tickets by who handled them tickets_by_bucket

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 with a mood score tickets_with_sentiment

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

Tickets answered on Easol handled_tickets_total

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 with a close time resolved_tickets_by_bucket

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

Typical time to close (hours) median_resolution_hours_by_bucket

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

Average time to close (hours) avg_resolution_hours_by_bucket

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

Typical wait for a first reply (hours) median_first_response_hours_by_bucket

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 that got a reply replied_tickets_by_bucket

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 settled in one reply one_touch_by_bucket

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

One-reply rate one_touch_pct_by_bucket

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

Mood at the start avg_opening_sentiment_by_bucket

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

Mood at the end avg_closing_sentiment_by_bucket

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

Mood change, start to end avg_sentiment_delta_by_bucket

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

Share ending worse than they started mood_worsened_pct_by_bucket

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

Share ending better than they started mood_improved_pct_by_bucket

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

Sour endings: AI alone vs before Easol mood_worsened_pct_full_ai_vs_pre_easol

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

Happier endings: AI alone vs before Easol mood_improved_pct_full_ai_vs_pre_easol

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

Average happiness score (1–5) avg_satisfaction_by_bucket

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

Share that read as sorted sentiment_resolved_pct_by_bucket

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

Share of happy tickets (4–5) csat_pct_by_bucket

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

Happy tickets: AI involved vs before Easol csat_pct_ai_touched_vs_pre_easol

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

Scored tickets by who handled them sentiment_scored_tickets_by_bucket

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

Happiness score: AI involved vs staff alone avg_satisfaction_ai_touched_vs_no_ai

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 by staff human_booking_edits

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 changes that went live human_booking_edits_applied

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

Typical staff edit time (hours) human_edit_median_hours_create_to_apply

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 by AI ai_booking_edits

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 changes that went live ai_booking_edits_applied

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 with a support ticket orders_with_ticket

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 with no support ticket orders_without_ticket

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

Chargebacks, orders with a ticket disputes_with_ticket

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

Chargebacks, orders with no ticket disputes_without_ticket

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

Money disputed, orders with a ticket disputed_amount_with_ticket

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

Money disputed, orders with no ticket disputed_amount_without_ticket

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

Chargeback rate, orders with a ticket dispute_rate_with_ticket_pct

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

Chargeback rate, orders with no ticket dispute_rate_without_ticket_pct

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

How they ended, orders with a ticket dispute_outcomes_with_ticket

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

How they ended, orders with no ticket dispute_outcomes_without_ticket

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

Before Easol: typical first reply each week (hours) pre_easol_weekly_first_response_hours

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

Before Easol: typical time to close each week (hours) pre_easol_weekly_resolution_hours

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

AI alone: typical first reply each week (hours) full_ai_weekly_first_response_hours

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

AI alone: typical time to close each week (hours) full_ai_weekly_resolution_hours

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

AI helped: typical first reply each week (hours) partial_ai_weekly_first_response_hours

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

AI helped: typical time to close each week (hours) partial_ai_weekly_resolution_hours

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

Staff only: typical first reply each week (hours) no_ai_weekly_first_response_hours

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

Staff only: typical time to close each week (hours) no_ai_weekly_resolution_hours

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