Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -606,3 +606,10 @@ PowerShell:
$env:PORT = "8080"
npm start
```


### Custom project groups

Open **Projects → Project groups** (also available from Settings) to combine two or more projects under a custom name. Edit a group to rename it or change its members; **Ungroup** restores the original projects. Groups affect project totals, model breakdowns and conversation labels without modifying source sessions. The editor lists projects across all dates. Each project can belong to one custom group. Preferences are saved in this browser only and are not synchronized between browsers or devices.

Exact-name duplicates with a single matching GitHub repository are automatically combined by default. Expand **Automatic grouping options** on the grouping page to inspect the matches or disable this browser preference. Ambiguous repositories and explicit manual memberships are never combined automatically.
127 changes: 120 additions & 7 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ import { codexCreditsOfCalls as rawCreditsOfCalls, fastMultiplierFor, usageProfi
import { apiCostOfCalls, apiPriceFor, mergeApiPricing } from "./api-pricing.js";
import { PRICING_CATALOG } from "./pricing-catalog.js";
import { PRICING_I18N, createPricingReport, pricingCatalogLabel, pricingHistoryMarkup, pricingDiagnosticsMarkup } from "./pricing-ui.js";
import { ADDITIONAL_I18N, LOCALE_TAGS, THEME_I18N, TIME_FORMAT_I18N, MIGRATION_I18N, resolveLanguage } from "./translations.js";
import { ADDITIONAL_I18N, LOCALE_TAGS, THEME_I18N, TIME_FORMAT_I18N, GROUP_I18N, MIGRATION_I18N, resolveLanguage } from "./translations.js";
import { chartDrilldownBuckets, chartDrilldownFilterRange, monthlyChartBuckets, nextChartGranularity, percentageOf, stackedChartSegments } from "./visualization.js";
import { latestTimestamp as rawLatestTimestamp, normalizeCustomRange, resolveDateRange, resolveWeeklyRange, timestampInRange, toDateTimeLocalValue } from "./date-range.js";
import { buildQuotaForecast, estimateQuotaCapacityCredits, interpolateForecastPercent, weeklyForecastTicks } from "./quota-forecast.js";
import { OVERVIEW_PROJECT_LIMIT, projectIdentity } from "./project-identity.js";
import { OVERVIEW_PROJECT_LIMIT, projectIdentity, normalizeProjectGroups, automaticProjectGroups, collapseProjectCatalog } from "./project-identity.js";

// Summary arrays keep the renderer shared with the quota/detail paths.
function summaryCalls(summary) { const calls = []; calls.summary = summary; return calls; }
Expand Down Expand Up @@ -263,9 +263,109 @@ for (const [language, messages] of Object.entries(THEME_I18N)) Object.assign(I18
for (const [language, messages] of Object.entries(TIME_FORMAT_I18N)) Object.assign(I18N[language], messages);
for (const [language, messages] of Object.entries(MIGRATION_I18N)) Object.assign(I18N[language], messages);

const PAGES = ["overview", "projects", "quota", "conversations", "settings"];
for (const [language, messages] of Object.entries(GROUP_I18N)) Object.assign(I18N[language], messages);
function loadProjectGroups() {
try { return normalizeProjectGroups(JSON.parse(localStorage.getItem("codex-usage-project-groups") || "[]")); }
catch { return []; }
}
function loadAutomaticGrouping() {
try { return localStorage.getItem("codex-usage-auto-project-groups") !== "false"; } catch { return true; }
}
const projectCatalogCache = new Map(), projectCatalogRequests = new Map();
function automaticGroups(catalog = projectCatalogCache.get(state.dataMode)?.projects || []) {
return state.automaticGrouping ? automaticProjectGroups(catalog, state.projectGroups) : [];
}
function effectiveProjectGroups() { return [...state.projectGroups, ...automaticGroups()]; }
async function ensureProjectCatalog(source) {
const cached = projectCatalogCache.get(source);
if (cached && Date.now() - cached.at < 60000) return true;
if (!projectCatalogRequests.has(source)) {
const request = (async () => {
try {
const query = {view:"project-groups", unknownProject:t("projects.unknown")};
const response = await fetch(`/api/page?${new URLSearchParams({source,query:JSON.stringify(query)})}`, {signal:AbortSignal.timeout(15000)});
const data = await readUsageResponse(response);
projectCatalogCache.set(source, {projects:data.pageData.projectCatalog, at:Date.now()});
return true;
} catch (error) {
$("#freshness").textContent = t("load.error", {error:error.message});
setPageLoading(false);
return false;
} finally { projectCatalogRequests.delete(source); }
})();
projectCatalogRequests.set(source, request);
}
return projectCatalogRequests.get(source);
}
let editingGroup = null, groupEditorSignature = null;
function saveProjectGroups(groups) {
try {
const normalized = normalizeProjectGroups(groups);
localStorage.setItem("codex-usage-project-groups", JSON.stringify(normalized));
state.projectGroups = normalized;
} catch {
$("#groupStatus").textContent = t("groups.storage");
return;
}
state.selectedProject = null;
editingGroup = null;
groupEditorSignature = null;
renderGroupEditor();
}
function renderGroupEditor() {
const rawProjects = state.data?.pageData?.projectCatalog || projectCatalogCache.get(state.dataMode)?.projects || (() => {
const originals = new Map();
for (const session of (state.data?.pageOnly ? [] : state.data?.sessions) || []) { const p = projectIdentity(session, t("projects.unknown")); originals.set(p.key, p); }
return [...originals.values()];
})();
const detectedGroups = automaticProjectGroups(rawProjects, state.projectGroups);
const projects = collapseProjectCatalog(rawProjects, state.automaticGrouping ? detectedGroups : []);
const catalogReady = state.data?.pageData?.view === "project-groups" || (state.data && !state.data.pageOnly);
const signature = JSON.stringify([state.automaticGrouping, Boolean(catalogReady), projects.map(p => [p.key,p.name]), state.projectGroups, state.language, editingGroup]);
if (signature === groupEditorSignature) return;
const automaticOptionsOpen = $("#groupEditor details")?.open;
const draftName = $("#groupName")?.value;
const draftMembers = $$("#groupForm input[name=member]:checked").flatMap(input => JSON.parse(input.dataset.members || "[]"));
const preserveDraft = Boolean(groupEditorSignature && editingGroup === JSON.parse(groupEditorSignature).at(-1));
groupEditorSignature = signature;
const current = state.projectGroups.find(g => g.id === editingGroup);
const occupied = new Set(state.projectGroups.filter(g => g.id !== editingGroup).flatMap(g => g.members));
const available = new Map(projects.map(p => [p.key, p]));
for (const key of current?.members || []) if (!available.has(key)) available.set(key, {key, name: t("groups.missing")});
$("#groupEditor").innerHTML = `<p class="dialog-copy">${escapeHtml(t("groups.copy"))}</p>
<details><summary>${t("groups.autoOptions")}</summary><label class="folder-filter-option"><input id="automaticGrouping" type="checkbox" ${state.automaticGrouping ? "checked" : ""}><span>${t("groups.autoEnable")}</span></label><p class="dialog-copy">${t("groups.autoCopy")}</p><ul>${detectedGroups.map(g=>`<li>${escapeHtml(g.name)}<small> ${escapeHtml(g.members.join(" + "))}</small></li>`).join("") || `<li>${t("groups.autoNone")}</li>`}</ul></details>
<div class="group-list">${state.projectGroups.map(g => `<article class="group-item"><div><strong>${escapeHtml(g.name)}</strong><p>${g.members.length} ${escapeHtml(t("nav.projects"))}</p></div><button type="button" class="primary-button" data-edit-group="${escapeHtml(g.id)}">${t("groups.edit")}</button><button type="button" class="primary-button" data-remove-group="${escapeHtml(g.id)}">${t("groups.remove")}</button></article>`).join('') || `<p>${t("groups.empty")}</p>`}</div>
<form id="groupForm"><h3>${escapeHtml(current?.name || t("groups.new"))}</h3><label for="groupName">${t("groups.name")}</label><input id="groupName" maxlength="120" required value="${escapeHtml(current?.name || '')}">
<fieldset><legend>${t("groups.members")}</legend><div class="group-options">${[...available.values()].sort((a,b) => a.name.localeCompare(b.name)).map(p => `<label class="folder-filter-option"><input type="checkbox" name="member" data-members="${escapeHtml(JSON.stringify(p.members || [p.key]))}" value="${escapeHtml(p.key)}" ${(p.members || [p.key]).some(key=>current?.members.includes(key)) ? 'checked' : ''} ${(p.members || [p.key]).some(key=>occupied.has(key)) ? 'disabled' : ''}><span>${escapeHtml(p.name)}<small>${escapeHtml(p.key)}</small></span></label>`).join('')}</div></fieldset>
<div class="group-actions"><button class="primary-button" type="submit">${t("groups.save")}</button><button class="primary-button" id="cancelGroup" type="button">${t("groups.cancel")}</button></div></form><p id="groupStatus" role="status">${catalogReady ? "" : escapeHtml(t("load.loading"))}</p>`;
$("#groupEditor details").open = Boolean(automaticOptionsOpen);
if (preserveDraft) {
$("#groupName").value = draftName || "";
$$("#groupForm input[name=member]").forEach(input => { input.checked = JSON.parse(input.dataset.members || "[]").some(key => draftMembers.includes(key)); });
}
$("#automaticGrouping").onchange = event => {
const enabled = event.target.checked;
try { localStorage.setItem("codex-usage-auto-project-groups", String(enabled)); }
catch { event.target.checked = state.automaticGrouping; $("#groupStatus").textContent = t("groups.storage"); return; }
state.automaticGrouping = enabled;
state.selectedProject = null;
renderGroupEditor();
};
$$("[data-edit-group]").forEach(b => b.onclick = () => { editingGroup = b.dataset.editGroup; renderGroupEditor(); $("#groupName").focus(); });
$$("[data-remove-group]").forEach(b => b.onclick = () => saveProjectGroups(state.projectGroups.filter(g => g.id !== b.dataset.removeGroup)));
$("#cancelGroup").onclick = () => { editingGroup = null; groupEditorSignature = null; renderGroupEditor(); };
$("#groupForm").onsubmit = event => {
event.preventDefault();
const name = $("#groupName").value.trim(), members = [...new Set([...new FormData(event.currentTarget).getAll('member')].flatMap(key => available.get(key)?.members || [key]))];
if (!name || members.length < 2) { $("#groupStatus").textContent = t("groups.error"); return; }
saveProjectGroups([...state.projectGroups.filter(g => g.id !== editingGroup), {id: editingGroup || crypto.randomUUID(), name, members}]);
};
}

const PAGES = ["project-groups", "overview", "projects", "quota", "conversations", "settings"];
for (const [language, messages] of Object.entries(PRICING_I18N)) Object.assign(I18N[language], messages);
const PAGE_TITLE_KEYS = {
"project-groups": "groups.title",
overview: "hero.title",
projects: "projects.title",
quota: "kpi.weeklyQuota",
Expand All @@ -290,6 +390,8 @@ function loadTimeFormat() {
}

const state = {
projectGroups: loadProjectGroups(),
automaticGrouping: loadAutomaticGrouping(),
data: null,
dataMode: loadDataMode(),
view: loadView(),
Expand Down Expand Up @@ -608,13 +710,13 @@ function formatDuration(ms) {
}

function sessionTitle(session) { return conversationTitle(session); }
function projectName(session) { return projectIdentity(session, t("projects.unknown")).name; }
function projectName(session) { return projectIdentity(session, t("projects.unknown"), effectiveProjectGroups()).name; }

function projectGroups(sessions) {
if (state.data?.pageData?.projects) return state.data.pageData.projects;
const groups = new Map();
for (const session of sessions) {
const identity = projectIdentity(session, t("projects.unknown"));
const identity = projectIdentity(session, t("projects.unknown"), effectiveProjectGroups());
const group = groups.get(identity.key) || { ...identity, paths: new Set(), sessions: [], calls: [] };
group.paths.add(session.cwd || "");
group.sessions.push(session);
Expand Down Expand Up @@ -745,6 +847,7 @@ function render() {
else if (state.view === "quota") renderQuotaPage();
else if (state.view === "conversations") renderTable(scopedSessions());
else if (state.view === "settings") renderSettingsNodes();
else if (state.view === "project-groups") renderGroupEditor();
renderFreshness();
}

Expand Down Expand Up @@ -1583,6 +1686,8 @@ function renderFreshness() {
start: range.start.toLocaleString(locale(), options),
end: range.resetsAt ? range.resetsAt.toLocaleString(locale(), options) : t("period.now"),
});
} else if (state.view === "project-groups") {
$("#periodLabel").textContent = t("period.all");
} else if (state.view === "settings") {
$("#periodLabel").textContent = t("nav.settings");
} else if (state.period === "custom") {
Expand Down Expand Up @@ -1690,6 +1795,7 @@ function savePricing() {
}

function applyUsageData(data) {
if (data.pageData?.projectCatalog) projectCatalogCache.set(state.dataMode, {projects:data.pageData.projectCatalog, at:Date.now()});
const changed = (state.data?.revision || state.data?.generatedAt) !== (data.revision || data.generatedAt) || state.data?.quotaOnly !== data.quotaOnly || state.data?.requestKey !== data.requestKey;
state.data = hydratePage(data);
if (!changed) return;
Expand Down Expand Up @@ -1767,6 +1873,9 @@ async function loadQuotaData(force, source, signal) {
}

async function loadData(force = false, silent = false) {
if (state.automaticGrouping && !["project-groups", "settings", "quota"].includes(state.view)) {
if (!(await ensureProjectCatalog(state.dataMode))) return;
}
clearTimeout(pageLoadTimer);
const quotaView = state.view === "quota";
const source = state.dataMode;
Expand Down Expand Up @@ -1803,7 +1912,10 @@ async function loadData(force = false, silent = false) {
if (quotaView && state.data?.quotaOnly && !state.data.quotaDetail) {
$("#quotaChart").innerHTML = `<p role="alert">${escapeHtml(t("load.error", { error: error.message }))}</p>`;
}
if (!silent || !state.data) {
if (state.view === "project-groups") {
$("#groupStatus").textContent = t("load.error", { error: error.message });
}
if (!silent || !state.data || state.view === "project-groups") {
$("#freshness").textContent = t("load.error", { error: error.message });
toast(t("load.errorToast"));
}
Expand Down Expand Up @@ -1831,7 +1943,7 @@ function pageQuery(view = state.view, id = null) {
const last = buckets.at(-1);
if (!fixedEnd && last && last.end.getTime() <= requestNow.getTime() + 1) last.end = new Date(requestNow.getTime() + 60000);
}
return { view, id, start:range.start?.toISOString() || null, end:fixedEnd ? range.end?.toISOString() : null,
return { view, id, projectGroups: view === "project-groups" ? [] : effectiveProjectGroups(), start:view === "project-groups" ? null : range.start?.toISOString() || null, end:view === "project-groups" ? null : fixedEnd ? range.end?.toISOString() : null,
node:view === "detail" && state.view !== "conversations" ? "all" : state.node, model:view === "detail" && state.view !== "conversations" ? "all" : state.model, folders:view === "detail" && state.view !== "conversations" ? [] : [...state.folders].sort(), search:state.query, usageThreshold:state.usageThreshold,
page:state.page, pageSize:state.pageSize, sortKey:state.sortKey, sortDirection:state.sortDirection,
project:state.selectedProject?.key, locale:locale(), unknownProject:t("projects.unknown"), untitled:t("conversation.untitled"), localNode:t("node.local"),
Expand Down Expand Up @@ -2113,6 +2225,7 @@ function setActiveNav(section) {

function syncPageChrome() {
const page = state.view;
if (page === "project-groups") renderGroupEditor();
document.body.dataset.page = page;
$$(".page").forEach((section) => { section.hidden = section.dataset.page !== page; });
$$('[data-period]').forEach((button) => button.classList.toggle("active", button.dataset.period === state.period));
Expand Down
3 changes: 3 additions & 0 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,9 @@ <h1 id="pageTitle">Coûts et activité</h1>
</article>
</section>

<section class="page" data-page="project-groups" hidden><article class="panel settings-card"><div class="panel-heading"><h2 data-i18n="groups.title">Regroupements de projets</h2></div><div class="settings-body" id="groupEditor"></div></article></section>
<section class="page" data-page="projects" hidden>
<p><a class="panel-link" href="#project-groups" data-i18n="groups.title">Regroupements de projets</a></p>
<div class="projects-layout">
<article class="panel project-list-panel">
<div class="panel-heading">
Expand Down Expand Up @@ -241,6 +243,7 @@ <h1 id="pageTitle">Coûts et activité</h1>

<section class="page" data-page="settings" hidden>
<div class="settings-layout">
<article class="panel settings-card"><div class="settings-body"><a class="primary-button settings-link" href="#project-groups" data-i18n="groups.title">Regroupements de projets</a></div></article>
<article class="panel settings-card">
<div class="panel-heading">
<h2 data-i18n="theme.appearance">Apparence</h2>
Expand Down
Loading