Compare commits

...
9 changed files with 318 additions and 7 deletions
@@ -0,0 +1,167 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
// The credit_usage stat from the overview stats endpoint:
// { current, previous, trend, daily: [{ date, value }] }
usage: { type: Object, default: null },
});
const { t } = useI18n();
const daily = computed(() => props.usage?.daily ?? []);
// Long ranges (90 days) get too many daily bars to read, so past this many
// days the series is bucketed into weeks.
const WEEKLY_THRESHOLD = 35;
const isWeekly = computed(() => daily.value.length > WEEKLY_THRESHOLD);
// One bucket per day, or per 7 consecutive days on long ranges (the last week
// may be partial and always includes today). A bucket is unrecorded (null)
// only when every day in it predates credit tracking.
const buckets = computed(() => {
if (!isWeekly.value) {
return daily.value.map(day => ({
start: day.date,
end: day.date,
value: day.value,
}));
}
const weeks = [];
for (let i = 0; i < daily.value.length; i += 7) {
const chunk = daily.value.slice(i, i + 7);
const recorded = chunk.filter(day => day.value !== null);
weeks.push({
start: chunk[0].date,
end: chunk[chunk.length - 1].date,
value: recorded.length
? +recorded.reduce((sum, day) => sum + day.value, 0).toFixed(2)
: null,
});
}
return weeks;
});
// Bar heights as a percentage of the peak bucket, with a small floor so even
// the lowest one stays visible. Unrecorded buckets render as muted
// placeholder bars.
const bars = computed(() => {
const max = Math.max(...buckets.value.map(bucket => bucket.value ?? 0), 0);
return buckets.value.map(bucket => ({
...bucket,
key: bucket.start,
recorded: bucket.value !== null,
height:
max === 0 || bucket.value === null
? 6
: Math.max(6, Math.round((bucket.value / max) * 100)),
}));
});
// Bucket dates arrive as date-only strings ('2026-07-17'), which new Date()
// would parse as midnight UTC and shift a day back for viewers west of UTC.
// Parse the parts as a local date so the label matches the server-side bucket.
const formatDate = date => {
const [year, month, day] = date.split('-').map(Number);
return new Date(year, month - 1, day).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
});
};
const barTooltip = bar => {
const period =
bar.start === bar.end
? formatDate(bar.start)
: `${formatDate(bar.start)} ${formatDate(bar.end)}`;
const usage = bar.recorded
? `${bar.value} ${t('CAPTAIN.OVERVIEW.CREDITS.UNIT')}`
: t('CAPTAIN.OVERVIEW.CREDITS.NO_DATA');
return `${period} · ${usage}`;
};
const total = computed(() =>
props.usage ? props.usage.current.toLocaleString() : '—'
);
const trend = computed(() => {
if (!props.usage?.trend) return '';
const sign = props.usage.trend > 0 ? '+' : '';
return `${sign}${props.usage.trend}%`;
});
const axisStart = computed(() =>
daily.value.length ? formatDate(daily.value[0].date) : ''
);
const axisEnd = computed(() =>
daily.value.length ? formatDate(daily.value[daily.value.length - 1].date) : ''
);
</script>
<template>
<section
class="flex flex-col gap-4 p-5 border rounded-xl bg-n-solid-1 border-n-weak"
>
<div class="flex items-start justify-between gap-4">
<div class="flex flex-col gap-1">
<div class="flex items-center gap-1.5">
<h3 class="text-sm font-medium text-n-slate-12">
{{ $t('CAPTAIN.OVERVIEW.CREDITS.TITLE') }}
</h3>
<span
v-tooltip="$t('CAPTAIN.OVERVIEW.CREDITS.DISCLAIMER')"
class="cursor-help i-lucide-info size-3.5 text-n-slate-9"
/>
</div>
<div class="flex items-baseline gap-2">
<span class="text-2xl font-semibold tabular-nums text-n-slate-12">
{{ total }}
</span>
<span class="text-sm text-n-slate-11">
{{ $t('CAPTAIN.OVERVIEW.CREDITS.UNIT') }}
</span>
<span
v-if="trend"
class="text-sm font-medium tabular-nums text-n-slate-11"
>
{{ trend }}
</span>
</div>
</div>
<div class="flex items-center gap-2 mt-1">
<span class="rounded-full size-2.5 bg-n-brand" />
<span class="text-xs text-n-slate-11">
{{
isWeekly
? $t('CAPTAIN.OVERVIEW.CREDITS.LEGEND_WEEKLY')
: $t('CAPTAIN.OVERVIEW.CREDITS.LEGEND')
}}
</span>
</div>
</div>
<div class="flex flex-col justify-end flex-1">
<div class="flex items-end flex-1 gap-1 min-h-24">
<div
v-for="bar in bars"
:key="bar.key"
v-tooltip="barTooltip(bar)"
class="flex-1 rounded-t transition-colors"
:class="
bar.recorded
? 'bg-n-brand hover:bg-n-brand/70'
: 'bg-n-alpha-2 hover:bg-n-alpha-3'
"
:style="{ height: `${bar.height}%` }"
/>
</div>
<div class="flex items-center justify-between mt-3">
<span class="text-xs text-n-slate-10">{{ axisStart }}</span>
<span class="text-xs text-n-slate-10">{{ axisEnd }}</span>
</div>
</div>
</section>
</template>
@@ -57,13 +57,13 @@ const stats = computed(() => [
{{ $t('CAPTAIN.OVERVIEW.KNOWLEDGE.COVERAGE', { pct: approvedPct }) }}
</span>
</div>
<div class="w-full h-2 overflow-hidden rounded-full bg-n-alpha-2">
<div class="w-full h-2 mt-4 overflow-hidden rounded-full bg-n-alpha-2">
<div
class="h-full rounded-full bg-n-brand"
:style="{ width: `${approvedPct}%` }"
/>
</div>
<div class="grid grid-cols-3 gap-3">
<div class="grid grid-cols-3 gap-3 mt-4">
<RouterLink
v-for="stat in stats"
:key="stat.key"
@@ -26,7 +26,7 @@ const onActivate = () => {
<template>
<div
class="flex flex-col gap-3 p-5 group bg-n-solid-1"
class="flex flex-col gap-3 p-5 bg-n-solid-1"
:class="
clickable
? 'cursor-pointer transition-colors hover:bg-n-slate-2/50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-n-brand'
@@ -43,7 +43,7 @@ const onActivate = () => {
<span
v-if="hint"
v-tooltip="hint"
class="transition-opacity opacity-0 cursor-help i-lucide-info size-3.5 text-n-slate-10 group-hover:opacity-100"
class="cursor-help i-lucide-info size-3.5 text-n-slate-9"
/>
</div>
<div v-if="loading" class="flex items-end justify-between gap-2">
@@ -460,6 +460,14 @@
"PENDING": "Pending FAQs",
"DOCUMENTS": "Documents"
},
"CREDITS": {
"TITLE": "Credit usage",
"UNIT": "credits",
"LEGEND": "Daily credits used",
"LEGEND_WEEKLY": "Weekly credits used",
"NO_DATA": "No data recorded",
"DISCLAIMER": "Shows credits used by this assistant only. Your account's total credit usage also includes other assistants and features."
},
"LINKS": {
"DOCS": {
"TITLE": "Captain docs",
@@ -13,6 +13,7 @@ import WelcomeCard from 'dashboard/components-next/captain/pageComponents/overvi
import MetricCard from 'dashboard/components-next/captain/pageComponents/overview/MetricCard.vue';
import AssistantDrilldownDrawer from 'dashboard/components-next/captain/pageComponents/overview/AssistantDrilldownDrawer.vue';
import KnowledgeCard from 'dashboard/components-next/captain/pageComponents/overview/KnowledgeCard.vue';
import CreditUsageCard from 'dashboard/components-next/captain/pageComponents/overview/CreditUsageCard.vue';
import QuickLinks from 'dashboard/components-next/captain/pageComponents/overview/QuickLinks.vue';
import InboxBanner from 'dashboard/components-next/captain/pageComponents/overview/InboxBanner.vue';
import CoverageBanner from 'dashboard/components-next/captain/pageComponents/overview/CoverageBanner.vue';
@@ -205,7 +206,10 @@ const closeDrilldown = () => {
/>
</div>
<KnowledgeCard :knowledge="stats?.knowledge" />
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<KnowledgeCard :knowledge="stats?.knowledge" />
<CreditUsageCard :usage="stats?.credit_usage" />
</div>
<QuickLinks />
</div>
@@ -0,0 +1,124 @@
# Computes per-assistant credit consumption (from agent_sessions) for the
# Captain Overview page: window totals for the trend plus a per-day series for
# the usage chart.
#
# Days are bucketed on the viewer's calendar day, so a completed day's sum never
# changes. Those sums are cached per (assistant, timezone, date) with a long TTL
# and only the still-accruing current day is computed live on every request.
class Captain::AssistantCreditUsageBuilder
CACHE_TTL = 30.days
# Credit tracking on agent_sessions began on this date. Earlier days are still
# returned in `daily` but with a nil value ("no data recorded") and are
# excluded from the queried span and the window totals.
DATA_EPOCH = Date.new(2026, 7, 17)
def initialize(assistant, window)
@assistant = assistant
@window = window
end
# => { current:, previous:, daily: [{ date:, value: }, ...] }
# `daily` covers the current window only; `previous` is the total for the
# preceding equal-length day span, used for the trend. A nil daily value means
# the day predates credit tracking.
def build
sums = daily_sums(recorded_dates)
{
current: total(sums, current_dates),
previous: total(sums, previous_dates),
daily: current_dates.map { |date| { date: date, value: sums[date] } }
}
end
private
attr_reader :assistant, :window
# Credit usage is bucketed on calendar days (unlike the rolling timestamp
# windows of the other metrics): a day-count range means exactly N calendar
# days ending today in the viewer's timezone, and month ranges follow the
# window's calendar boundaries.
def current_dates
@current_dates ||= if day_range?
(today - (window.range.to_i - 1))..today
else
to_dates(window.current)
end
end
# Day ranges derive the comparison span from the current dates (the N days
# right before them), since current_dates redefines the window as N calendar
# days ending today. Named month ranges follow window.previous so the trend
# compares the same periods as every other overview metric.
def previous_dates
if day_range?
(current_dates.first - current_dates.count)..(current_dates.first - 1)
else
to_dates(window.previous)
end
end
def day_range?
window.range.match?(/\A\d+\z/)
end
def to_dates(range)
range.first.in_time_zone(timezone).to_date..range.last.in_time_zone(timezone).to_date
end
# Pre-epoch days are never queried or cached; they fall out of the sums hash
# and surface as nil daily values / zero contribution to totals. While the
# previous span is fully pre-epoch this leaves `previous` at 0, so the
# frontend hides the trend.
def recorded_dates
(previous_dates.to_a + current_dates.to_a).select { |date| date >= DATA_EPOCH }
end
def daily_sums(dates)
cacheable = dates.select { |date| date < today }
cached = Rails.cache.read_multi(*cacheable.map { |date| cache_key(date) })
missing = dates.reject { |date| cached.key?(cache_key(date)) }
live = live_sums(missing)
missing.each do |date|
Rails.cache.write(cache_key(date), live[date], expires_in: CACHE_TTL) if date < today
end
dates.index_with { |date| cached.fetch(cache_key(date)) { live[date] } }
end
# One grouped scan covering the span of the requested dates. Zero-filled via
# groupdate so cache entries exist even for days without sessions.
def live_sums(dates)
return {} if dates.empty?
span = dates.min.in_time_zone(timezone).beginning_of_day..dates.max.in_time_zone(timezone).end_of_day
Captain::AgentSession
.where(assistant_id: assistant.id, created_at: span)
.group_by_day(:created_at, time_zone: timezone, range: span)
.sum(:credits_consumed)
.to_h { |day, value| [day.to_date, (value || 0).round(2)] }
end
def total(sums, dates)
dates.sum { |date| sums[date].to_f }.round(2)
end
def today
@today ||= Time.current.in_time_zone(timezone).to_date
end
def timezone
window.timezone
end
def cache_key(date)
"captain_credit_usage/#{assistant.id}/#{timezone_name}/#{date}"
end
def timezone_name
@timezone_name ||= timezone.is_a?(String) ? timezone : timezone.name
end
end
@@ -57,6 +57,7 @@ class Captain::AssistantStatsBuilder
hours_saved: pack(current[:hours_saved], previous[:hours_saved], :percent),
reopen_rate: pack(current[:reopen], previous[:reopen], :point),
conversation_depth: pack(current[:depth], previous[:depth], :absolute),
credit_usage: credit_usage,
knowledge: knowledge
}
end
@@ -181,6 +182,13 @@ class Captain::AssistantStatsBuilder
rate(reopened, resolved_scope.distinct.count(:conversation_id))
end
# Credits consumed by the assistant's agent sessions: window totals packed like
# the other trend metrics, plus the per-day series for the usage chart.
def credit_usage
usage = Captain::AssistantCreditUsageBuilder.new(assistant, window).build
pack(usage[:current], usage[:previous], :percent).merge(daily: usage[:daily])
end
# Approved/pending FAQ counts and the document total in a single round trip.
def knowledge
approved, pending, documents = Captain::AssistantResponse.by_assistant(assistant.id).reorder(nil).pick(
@@ -14,7 +14,7 @@ class Captain::AssistantStatsWindow
DEFAULT_RANGE = '30'.freeze
ALLOWED_RANGES = %w[7 30 90 this_month last_month].freeze
attr_reader :range
attr_reader :range, :timezone
def initialize(range = DEFAULT_RANGE, timezone_offset = nil)
@range = ALLOWED_RANGES.include?(range.to_s) ? range.to_s : DEFAULT_RANGE
@@ -27,7 +27,7 @@ RSpec.describe Captain::AssistantStatsBuilder do
expect(metrics.keys).to contain_exactly(
:conversations_handled, :auto_resolution_rate, :handoff_rate,
:hours_saved, :reopen_rate, :conversation_depth, :knowledge
:hours_saved, :reopen_rate, :conversation_depth, :credit_usage, :knowledge
)
expect(metrics[:conversations_handled]).to include(:current, :previous, :trend)
end