Record what every model call spends, and show it

Nothing anywhere recorded what a model call cost: the harness used a
provider's usage numbers only to decide whether a turn had finished, then
dropped them. So a reply could not say what it took to make, one model
could not be compared with another on price, and there was no way to ask
what a day, a week or a month of the crew's work came to.

- `token_usages` records one row per call: user, bot, workspace, session,
  job and message, the harness, the provider and model that actually
  answered, the prompt and completion counts, and the cost when the
  provider reports one.
- `harness-usage-scope.ts` carries those ids in an AsyncLocalStorage
  scope that `run-bot-job.ts` enters once, so a run's turns, its session
  summary, its transcriptions, images and embeddings are all attributed
  without threading ids through every harness function. Nested runs merge
  rather than replace, and a null genuinely clears a field (a bot's own
  chat has no workspace).
- Work with no run behind it is charged to its owner instead:
  `run-with-fallback-scope.ts` reaches the workspace's creator else the
  bot's, and deliberately does not override a real run, so an ingest a
  bot asked for is not misattributed to the bot's owner.
- All six harnesses record through `withCandidates`'s `on_success`.
  Recording never throws: a model that answered must not lose its answer
  to a failed insert.
- A job's calls land before its message exists, so `complete-bot-job.ts`
  back-fills `message_id` once the message is inserted.
- The message's actions row shows what it cost, beside the timestamp and
  the play button, with the breakdown in its title.
- `/admin/usage` totals the install's spending for 7, 30 or 90 days: the
  total against the previous period, a day-by-day chart, a split per kind
  of call, and a table per model with the price per thousand tokens
  worked out from cost and tokens, which is what makes two models
  comparable. Crew managers only, since it is the whole install's work
  rather than one user's conversation. It sits with Routines and Activity
  in the user popover rather than under Settings, because it is a record
  of what happened, not something to configure.

Days are grouped in local time (`date(created_at / 1000, 'unixepoch',
'localtime')`), matching the local midnights the window builds and the
`YYYY-MM-DD` buckets it looks up. Grouping by the bare UTC day put a late
evening's calls under a name no bucket carried, so they vanished and the
chart's total silently disagreed with the totals card; a test pins it.

`canSeeRailItem` now decides who sees a popover or rail entry. The two
each filtered with their own inline rule and only one understood
`can_view`, so a gated page could be offered in the popover to everyone.
This commit is contained in:
2026-10-23 15:41:41 +01:00
parent 0048324d8a
commit d9aea8e9c8
75 changed files with 2916 additions and 66 deletions
@@ -7,6 +7,10 @@ import type {
import { AppData } from "@/src/data/app-data";
import embedText from "@/src/harness/embeddings/embed-text";
import HarnessError from "@/src/harness/harness-error";
import { grabHarnessUsageScope } from "@/src/harness/usage/harness-usage-scope";
import type { HarnessUsageScope } from "@/src/harness/usage/types";
import runWithFallbackScope from "@/src/harness/usage/run-with-fallback-scope";
import grabUsageOwnerUserId from "@/src/functions/backend/db/usage/grab-usage-owner";
import chunkText from "./chunk-text";
import extractBotFileText from "./extract-bot-file-text";
@@ -72,13 +76,27 @@ export default async function ingestBotFile({
return { status: "skipped", chunks: 0 };
}
/**
* A bot's own run already carries its user. The ingest queue has no run
* behind it, so everything this file costs - reading an image or an
* audio file, embedding its chunks - is entered under the owner's scope
* instead: it spends real tokens and must show on the usage page.
*/
const owner_scope: HarnessUsageScope | null = grabHarnessUsageScope()?.user_id
? null
: await resolveOwnerScope({ file });
try {
const text = await extractBotFileText({
write_path: media?.media_write_path,
text_content: media?.text_content,
name: file.name,
mime_type: file.mime_type,
read_media,
const text = await runWithFallbackScope({
scope: owner_scope,
task: () =>
extractBotFileText({
write_path: media?.media_write_path,
text_content: media?.text_content,
name: file.name,
mime_type: file.mime_type,
read_media,
}),
});
await clearChunks({ file_id: file.id });
@@ -107,11 +125,11 @@ export default async function ingestBotFile({
*/
for (let start = 0; start < chunks.length; start += AppData.EmbeddingBatchSize) {
const batch = chunks.slice(start, start + AppData.EmbeddingBatchSize);
const { vectors } = await embedText({
input: batch,
bot_id: file.bot_id!,
workspace_id: file.scope == "workspace" ? file.owner_id : null,
const { vectors } = await runEmbed({
scope: owner_scope,
signal,
file,
batch,
});
for (let i = 0; i < vectors.length; i++) {
@@ -252,3 +270,54 @@ async function markIngested({
values: [chunks > 0 ? 1 : 0, now, file_id],
}).catch(() => {});
}
/**
* One batch of a file's chunks, embedded under whatever attribution owns
* it: the run that asked for the ingest, else the file's owner.
*/
async function runEmbed({
scope,
signal,
file,
batch,
}: {
scope: HarnessUsageScope | null;
signal?: AbortSignal;
file: BUN_SQLITE_COBBLE_BOT_FILES;
batch: string[];
}) {
return await runWithFallbackScope({
scope,
task: () =>
embedText({
input: batch,
bot_id: file.bot_id!,
workspace_id: file.scope == "workspace" ? file.owner_id : null,
signal,
}),
});
}
/**
* The attribution a file the queue ingests reports under: its workspace's
* creator when it shares one, else its bot's.
*/
async function resolveOwnerScope({
file,
}: {
file: BUN_SQLITE_COBBLE_BOT_FILES;
}): Promise<HarnessUsageScope | null> {
const workspace_id = file.scope == "workspace" ? file.owner_id : null;
const user_id = await grabUsageOwnerUserId({ bot_id: file.bot_id, workspace_id });
if (!user_id) return null;
return {
user_id,
bot_id: file.scope == "bot" ? file.owner_id : file.bot_id,
workspace_id,
session_id: null,
message_id: null,
job_id: null,
};
}
@@ -89,6 +89,18 @@ export default async function completeBotJob({
media_ids: attachments,
});
if (job.id && message?.id) {
/**
* The job's model calls were written down as they happened, before
* its message existed. Now that it does, they belong to it, so the
* thread can show what the answer cost.
*/
await BunSQLite.sql({
sql: `UPDATE token_usages SET message_id = ? WHERE job_id = ? AND message_id IS NULL`,
values: [message.id, job.id],
}).catch(() => {});
}
const user_id = await grabJobUserId({ job });
const bot = !user_id
@@ -1,6 +1,7 @@
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
import type { BotMessageWithAttachments } from "@/src/types";
import BunSQLite from "@moduletrace/bun-sqlite";
import grabMessageUsage from "../usage/grab-message-usage";
import grabMessagesMedia from "./grab-messages-media";
import grabMessagesReactions from "./grab-messages-reactions";
import grabMessagesReplies from "./grab-messages-replies";
@@ -53,12 +54,13 @@ export default async function grabBotMessages({
const messages = res.payload || [];
const message_ids = messages.map((message) => Number(message.id));
const [media, replies, reactions] = await Promise.all([
const [media, replies, reactions, usage] = await Promise.all([
grabMessagesMedia({ message_ids }),
grabMessagesReplies({ message_ids }),
user_id
? grabMessagesReactions({ message_ids, user_id })
: Promise.resolve(new Map()),
grabMessageUsage({ message_ids }),
]);
/**
@@ -74,6 +76,8 @@ export default async function grabBotMessages({
const files = media.get(id);
const reply_to = replies.get(id);
const spent = usage.get(id);
return {
...message,
...(files
@@ -88,6 +92,7 @@ export default async function grabBotMessages({
: {}),
...(reply_to ? { reply_to } : {}),
...(user_id ? { reactions: reactions.get(id) || [] } : {}),
...(spent ? { usage: spent } : {}),
};
});
}
@@ -0,0 +1,101 @@
import { describe, expect, test } from "bun:test";
import bucketUsageDays, { localDate } from "./bucket-usage-days";
import { DayMs, startOfDay } from "./grab-usage-window";
function row(local_day: string, tokens: number) {
return {
local_day,
calls: 1,
prompt_tokens: tokens,
completion_tokens: 0,
cost: null,
};
}
const now = new Date(2026, 9, 23, 15, 0, 0).getTime();
const end = now;
const start = startOfDay({ at: now }) - 2 * DayMs;
describe("localDate", () => {
test("names the local calendar day, not the UTC one", () => {
/**
* Half an hour past local midnight is still the previous UTC day
* for anyone east of Greenwich, and the bug the chart had: the row
* was counted under one name and looked for under another.
*/
const at = new Date(2026, 9, 23, 0, 30, 0).getTime();
expect(localDate({ at })).toBe("2026-10-23");
});
test("pads a single-digit month and day", () => {
expect(localDate({ at: new Date(2026, 0, 5, 12, 0, 0).getTime() })).toBe("2026-01-05");
});
});
describe("bucketUsageDays", () => {
test("one bucket per day of the window, oldest first", () => {
const days = bucketUsageDays({ rows: [], start, end });
expect(days.length).toBe(3);
expect(days.map((day) => day.label)).toEqual(
[2, 1, 0].map((back) => localDate({ at: startOfDay({ at: now }) - back * DayMs })),
);
});
test("every row lands in its day, and yesterday's late call does not vanish", () => {
const today = localDate({ at: startOfDay({ at: now }) });
const yesterday = localDate({ at: startOfDay({ at: now }) - DayMs });
const days = bucketUsageDays({
rows: [row(today, 1500), row(yesterday, 400)],
start,
end,
});
const by_label = new Map(days.map((day) => [day.label, day.total_tokens]));
expect(by_label.get(today)).toBe(1500);
expect(by_label.get(yesterday)).toBe(400);
expect(days.reduce((total, day) => total + day.total_tokens, 0)).toBe(1900);
});
test("a day with nothing in it is still a bucket, at zero", () => {
const days = bucketUsageDays({
rows: [row(localDate({ at: startOfDay({ at: now }) }), 100)],
start,
end,
});
expect(days.filter((day) => day.total_tokens === 0).length).toBe(2);
expect(days.every((day) => day.calls === 0 || day.calls === 1)).toBe(true);
});
test("a bucket's midnight is that day's local midnight", () => {
const days = bucketUsageDays({ rows: [], start, end });
for (const day of days) {
expect(day.at).toBe(startOfDay({ at: day.at }));
expect(localDate({ at: day.at })).toBe(day.label);
}
});
test("cost and calls are summed, not counted twice", () => {
const label = localDate({ at: startOfDay({ at: now }) });
const days = bucketUsageDays({
rows: [
{ ...row(label, 100), calls: 3, cost: 0.25 },
{ ...row(label, 50), calls: 2, cost: 0.5 },
],
start,
end,
});
const bucket = days.find((day) => day.label === label);
expect(bucket?.total_tokens).toBe(150);
expect(bucket?.cost).toBe(0.75);
expect(days.reduce((total, day) => total + day.calls, 0)).toBe(5);
});
});
@@ -0,0 +1,115 @@
import { DayMs, startOfDay } from "./grab-usage-window";
import { toSummary } from "./grab-usage-totals";
import type { TokenUsageSummary } from "@/src/harness/usage/types";
type Row = {
/**
* The day the call was made, `YYYY-MM-DD`, already in local time.
*/
local_day: string;
calls: number | null;
prompt_tokens: number | null;
completion_tokens: number | null;
cost: number | null;
};
type Params = {
rows: Row[];
start: number;
end: number;
};
export type UsageDay = {
/**
* The day's local midnight (epoch ms).
*/
at: number;
/**
* `YYYY-MM-DD`, in local time.
*/
label: string;
} & TokenUsageSummary;
/**
* The rows SQL grouped by local day, laid onto one bucket per day of the
* window, oldest first. Days with nothing in them are still there, at
* zero, so the bars keep their spacing, and every row lands in a bucket:
* a day outside the window would be a bug in the query's own bounds.
*/
export default function bucketUsageDays({ rows, start, end }: Params): UsageDay[] {
const by_day = new Map<string, TokenUsageSummary>();
for (const row of rows) {
/**
* Accumulated, not replaced: the query groups by day, so a repeated
* day means someone asked for two sets of rows, and the later one
* must add to the earlier rather than hide it.
*/
const label = String(row.local_day);
const summary = toSummary({ row });
by_day.set(label, mergeSummaries({ into: by_day.get(label), summary }));
}
const days: UsageDay[] = [];
const last = startOfDay({ at: end });
for (let at = startOfDay({ at: start }); at <= last; at += DayMs) {
const label = localDate({ at });
days.push({
at,
label,
...(by_day.get(label) || emptySummary()),
});
}
return days;
}
/**
* The day a local midnight falls on, in local time.
*
* Never `toISOString`: an epoch millisecond read in UTC is a different
* calendar day for anyone east or west of Greenwich, and a call counted
* under one day would then look for a bucket named after another and
* disappear from the chart (which is exactly what it did before).
*/
export function localDate({ at }: { at: number }) {
const date = new Date(at);
return [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, "0"),
String(date.getDate()).padStart(2, "0"),
].join("-");
}
function emptySummary(): TokenUsageSummary {
return { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0, calls: 0 };
}
/**
* One summary added onto another. A day that reported no cost still costs
* nothing; only a real cost is carried, so an unpriced day doesn't turn a
* priced one into zero.
*/
function mergeSummaries({
into,
summary,
}: {
into?: TokenUsageSummary;
summary: TokenUsageSummary;
}): TokenUsageSummary {
const prompt_tokens = (into?.prompt_tokens || 0) + summary.prompt_tokens;
const completion_tokens = (into?.completion_tokens || 0) + summary.completion_tokens;
const cost = (into?.cost || 0) + (summary.cost || 0);
return {
prompt_tokens,
completion_tokens,
total_tokens: prompt_tokens + completion_tokens,
...(cost > 0 ? { cost } : {}),
calls: (into?.calls || 0) + summary.calls,
};
}
@@ -0,0 +1,56 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { TokenUsageSummary } from "@/src/harness/usage/types";
type Params = {
/**
* The messages whose calls are summed. A message with no calls is
* absent from the answer.
*/
message_ids: number[];
};
/**
* What each of these messages cost, summed over every model call it took
* (chat turns, media readings, image generations). This is what a
* message's token line shows.
*/
export default async function grabMessageUsage({ message_ids }: Params) {
const totals = new Map<number, TokenUsageSummary>();
const ids = [...new Set(message_ids.map(Number).filter((id) => id > 0))];
if (!ids.length) return totals;
const res = await BunSQLite.sql<{
message_id: number;
prompt_tokens: number | null;
completion_tokens: number | null;
cost: number | null;
calls: number;
}>({
sql: `SELECT message_id,
SUM(prompt_tokens) AS prompt_tokens,
SUM(completion_tokens) AS completion_tokens,
SUM(cost) AS cost,
COUNT(*) AS calls
FROM token_usages
WHERE message_id IN (${ids.map(() => "?").join(", ")})
GROUP BY message_id`,
values: ids,
});
for (const row of res.payload || []) {
const prompt_tokens = Number(row.prompt_tokens) || 0;
const completion_tokens = Number(row.completion_tokens) || 0;
const cost = Number(row.cost);
totals.set(Number(row.message_id), {
prompt_tokens,
completion_tokens,
total_tokens: prompt_tokens + completion_tokens,
...(Number.isFinite(cost) && cost > 0 ? { cost } : {}),
calls: Number(row.calls) || 0,
});
}
return totals;
}
@@ -0,0 +1,42 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import bucketUsageDays, { type UsageDay } from "./bucket-usage-days";
export type { UsageDay };
type Params = {
start: number;
end: number;
};
/**
* One bucket per day in the window, oldest first, each the tokens spent
* that day. Days with nothing in them are still there, at zero, so the
* bars keep their spacing.
*
* SQLite has no timezone support, so the day is taken with its `localtime`
* modifier, which reads the same server timezone the page's own day
* boundaries come from. Grouping by the bare UTC day instead put a late
* evening's calls in the previous day's bucket, and dropped them whenever
* no bucket carried that name.
*/
export default async function grabUsageDays({ start, end }: Params): Promise<UsageDay[]> {
const res = await BunSQLite.sql<{
local_day: string;
calls: number | null;
prompt_tokens: number | null;
completion_tokens: number | null;
cost: number | null;
}>({
sql: `SELECT date(created_at / 1000, 'unixepoch', 'localtime') AS local_day,
COUNT(*) AS calls,
SUM(prompt_tokens) AS prompt_tokens,
SUM(completion_tokens) AS completion_tokens,
SUM(cost) AS cost
FROM token_usages
WHERE created_at >= ? AND created_at < ?
GROUP BY local_day`,
values: [start, end],
});
return bucketUsageDays({ rows: res.payload || [], start, end });
}
@@ -0,0 +1,75 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import { toSummary } from "./grab-usage-totals";
import type { TokenUsageSummary } from "@/src/harness/usage/types";
type Params = {
start: number;
end: number;
/**
* How many the page shows, busiest first.
*/
limit: number;
};
export type UsageModelRow = {
/**
* The provider's own id for the model, as the call recorded it
* (`deepseek/deepseek-chat`). This is what a person compares, not the
* catalog row's numeric id.
*/
model_slug: string;
/**
* The catalog's display name, when the model is still listed. A model
* a provider stopped listing keeps its slug and loses its name.
*/
model_name: string | null;
provider_name: string;
provider_id: number | null;
harness: string;
} & TokenUsageSummary;
/**
* Every model the period ran, busiest first (by tokens), with what it
* spent. This is how one works out what a model costs: its tokens against
* its calls, for the window asked for.
*/
export default async function grabUsageModels({ start, end, limit }: Params): Promise<UsageModelRow[]> {
const res = await BunSQLite.sql<{
model_slug: string | null;
model_name: string | null;
provider_name: string | null;
provider_id: number | null;
harness: string | null;
calls: number | null;
prompt_tokens: number | null;
completion_tokens: number | null;
cost: number | null;
}>({
sql: `SELECT u.model_slug,
m.name AS model_name,
u.provider_name,
u.provider_id,
u.harness,
COUNT(*) AS calls,
SUM(u.prompt_tokens) AS prompt_tokens,
SUM(u.completion_tokens) AS completion_tokens,
SUM(u.cost) AS cost
FROM token_usages u
LEFT JOIN harness_models m
ON m.provider_id = u.provider_id AND m.slug = u.model_slug
WHERE u.created_at >= ? AND u.created_at < ?
GROUP BY u.provider_name, u.model_slug, u.harness
ORDER BY SUM(u.prompt_tokens) + SUM(u.completion_tokens) DESC
LIMIT ?`,
values: [start, end, limit],
});
return (res.payload || []).map((row) => ({
model_slug: row.model_slug || "unknown",
model_name: row.model_name || null,
provider_name: row.provider_name || "unknown",
provider_id: row.provider_id == null ? null : Number(row.provider_id),
harness: String(row.harness || "text"),
...toSummary({ row }),
}));
}
@@ -0,0 +1,53 @@
import { AppData } from "@/src/data/app-data";
import type { UsageOverview } from "@/src/types";
import grabUsageDays from "./grab-usage-days";
import grabUsageModels from "./grab-usage-models";
import grabUsageTotals from "./grab-usage-totals";
import grabUsageWindow, { DayMs } from "./grab-usage-window";
type Params = {
/**
* How many days back to total, today included.
*/
days?: number | string | null;
now?: number;
};
/**
* Everything the usage page shows for one window: the totals, the same day
* by day, per harness and per model, plus the day before the window and a
* rolling week, so the page can say whether work is going up or down.
*/
export default async function grabUsageOverview({
days,
now = Date.now(),
}: Params): Promise<UsageOverview> {
const window = grabUsageWindow({ days, now });
const previous_start = window.start - window.days * DayMs;
const [totals, days_by_day, models, previous, week] = await Promise.all([
grabUsageTotals({ start: window.start, end: window.end }),
grabUsageDays({ start: window.start, end: window.end }),
grabUsageModels({
start: window.start,
end: window.end,
limit: AppData.UsageModelPageSize,
}),
grabUsageTotals({ start: previous_start, end: window.start }),
grabUsageTotals({ start: window.end - 7 * DayMs, end: window.end }),
]);
return {
window: {
days: window.days,
start: window.start,
end: window.end,
},
totals: totals.totals,
by_harness: totals.by_harness,
days: days_by_day,
models,
previous: previous.totals,
week: week.totals,
};
}
@@ -0,0 +1,47 @@
import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
/**
* The bot whose file it is, or the bot the search ran for.
*/
bot_id?: number | string | null;
/**
* The workspace whose file it is, when it shares one.
*/
workspace_id?: number | string | null;
};
/**
* Whose usage a call made outside anyone's run belongs to: a file ingested
* by the queue, or a search that ran with no conversation behind it. The
* workspace's creator when there is a workspace, else the bot's, so an
* embedding never goes uncounted.
*/
export default async function grabUsageOwnerUserId({
bot_id,
workspace_id,
}: Params): Promise<number | null> {
const workspace = Number(workspace_id);
if (Number.isFinite(workspace) && workspace > 0) {
const row = await BunSQLite.sql<{ user_id: number }>({
sql: `SELECT user_id FROM workspaces WHERE id = ? LIMIT 1`,
values: [workspace],
}).then((res) => res.payload?.[0]);
if (row?.user_id) return Number(row.user_id) || null;
}
const bot = Number(bot_id);
if (Number.isFinite(bot) && bot > 0) {
const row = await BunSQLite.sql<{ user_id: number }>({
sql: `SELECT user_id FROM bots WHERE id = ? LIMIT 1`,
values: [bot],
}).then((res) => res.payload?.[0]);
if (row?.user_id) return Number(row.user_id) || null;
}
return null;
}
@@ -0,0 +1,97 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { TokenUsageSummary } from "@/src/harness/usage/types";
type Params = {
/**
* The window's start and end (epoch ms), from `grabUsageWindow`.
*/
start: number;
end: number;
};
type UsageRow = {
calls: number | null;
prompt_tokens: number | null;
completion_tokens: number | null;
cost: number | null;
};
/**
* One total for a whole period, and the same split by the harnesses the
* calls were for.
*/
export type UsageTotals = {
totals: TokenUsageSummary;
by_harness: (TokenUsageSummary & { harness: string })[];
};
/**
* What every model call in the period cost, in total and per harness
* (chat, media reading, transcribing, speaking, images, embeddings).
*/
export default async function grabUsageTotals({ start, end }: Params): Promise<UsageTotals> {
const res = await BunSQLite.sql<UsageRow & { harness: string | null }>({
sql: `SELECT harness,
COUNT(*) AS calls,
SUM(prompt_tokens) AS prompt_tokens,
SUM(completion_tokens) AS completion_tokens,
SUM(cost) AS cost
FROM token_usages
WHERE created_at >= ? AND created_at < ?
GROUP BY harness`,
values: [start, end],
});
const by_harness = (res.payload || []).map((row) => ({
harness: String(row.harness || "text"),
...toSummary({ row }),
}));
return {
totals: sumSummary({ summaries: by_harness }),
by_harness: by_harness.sort((a, b) => b.total_tokens - a.total_tokens),
};
}
/**
* Adds summaries into one. A call that reported no tokens still counts as
* a call.
*/
export function sumSummary({ summaries }: { summaries: TokenUsageSummary[] }): TokenUsageSummary {
let prompt_tokens = 0;
let completion_tokens = 0;
let cost = 0;
let calls = 0;
for (const summary of summaries) {
prompt_tokens += summary.prompt_tokens;
completion_tokens += summary.completion_tokens;
cost += summary.cost || 0;
calls += summary.calls;
}
return {
prompt_tokens,
completion_tokens,
total_tokens: prompt_tokens + completion_tokens,
...(cost > 0 ? { cost } : {}),
calls,
};
}
/**
* One grouped row (any shape with the four sums) as a summary.
*/
export function toSummary({ row }: { row: UsageRow }): TokenUsageSummary {
const prompt_tokens = Number(row.prompt_tokens) || 0;
const completion_tokens = Number(row.completion_tokens) || 0;
const cost = Number(row.cost);
return {
prompt_tokens,
completion_tokens,
total_tokens: prompt_tokens + completion_tokens,
...(Number.isFinite(cost) && cost > 0 ? { cost } : {}),
calls: Number(row.calls) || 0,
};
}
@@ -0,0 +1,57 @@
import { AppData } from "@/src/data/app-data";
type Params = {
/**
* How many days back the window starts, today included. Clamped to
* what the page offers.
*/
days?: number | string | null;
/**
* The moment "now" is, so a whole page (its totals and its days) is
* read at one instant.
*/
now?: number;
};
/**
* The period the usage page totals: a start, an end and the day buckets
* between them, oldest first, each a local midnight.
*/
export default function grabUsageWindow({ days, now = Date.now() }: Params) {
const asked = Number(days);
const count =
Number.isFinite(asked) && asked > 0
? Math.min(Math.round(asked), AppData.UsageMaxDays)
: AppData.UsageDefaultDays;
const end = now;
/**
* Whole days back from the start of today, so every bucket is a day's
* work rather than a sliding window's slice.
*/
const today = startOfDay({ at: now });
const start = today - (count - 1) * DayMs;
return {
days: count,
start,
end,
/**
* One bucket per day in the window, oldest first.
*/
buckets: Array.from({ length: count }, (_, index) => start + index * DayMs),
};
}
export const DayMs = 24 * 60 * 60 * 1000;
/**
* Local midnight of the day the moment falls in.
*/
export function startOfDay({ at }: { at: number }) {
const date = new Date(at);
date.setHours(0, 0, 0, 0);
return date.getTime();
}