Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
307fdd1e05 | ||
|
|
635cd77291 | ||
|
|
3ae285af35 | ||
|
|
feb264cebd | ||
|
|
a32475ffee | ||
|
|
7132b6c65d | ||
|
|
30046ed7b4 | ||
|
|
8a5b552930 |
+167
@@ -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>
|
||||
+2
-2
@@ -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"
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user