@supalytics/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +110 -0
- package/bin/supalytics +2 -0
- package/package.json +37 -0
- package/src/api.ts +311 -0
- package/src/commands/countries.ts +93 -0
- package/src/commands/events.ts +133 -0
- package/src/commands/login.ts +83 -0
- package/src/commands/logout.ts +15 -0
- package/src/commands/pages.ts +93 -0
- package/src/commands/query.ts +162 -0
- package/src/commands/realtime.ts +110 -0
- package/src/commands/referrers.ts +95 -0
- package/src/commands/sites.ts +65 -0
- package/src/commands/stats.ts +104 -0
- package/src/commands/trend.ts +95 -0
- package/src/config.ts +96 -0
- package/src/index.ts +79 -0
- package/src/ui.ts +79 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { query, formatNumber, formatPercent, formatDuration } from "../api";
|
|
4
|
+
import { getDefaultSite } from "../config";
|
|
5
|
+
import { logo, parsePeriod } from "../ui";
|
|
6
|
+
|
|
7
|
+
export const statsCommand = new Command("stats")
|
|
8
|
+
.description("Overview stats: pageviews, visitors, bounce rate, revenue")
|
|
9
|
+
.argument("[period]", "Time period: today, yesterday, week, month, year, 7d, 30d, 90d, 12mo, all", "30d")
|
|
10
|
+
.option("-s, --site <site>", "Site to query")
|
|
11
|
+
.option("--start <date>", "Start date (YYYY-MM-DD)")
|
|
12
|
+
.option("--end <date>", "End date (YYYY-MM-DD)")
|
|
13
|
+
.option("-f, --filter <filters...>", "Filters in format 'field:operator:value'")
|
|
14
|
+
.option("--no-revenue", "Exclude revenue metrics")
|
|
15
|
+
.option("--json", "Output as JSON")
|
|
16
|
+
.action(async (period, options) => {
|
|
17
|
+
const site = options.site || (await getDefaultSite());
|
|
18
|
+
|
|
19
|
+
if (!site) {
|
|
20
|
+
console.error(chalk.red("Error: No site specified. Use --site or set a default with `supalytics login --site`"));
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const dateRange = options.start && options.end
|
|
25
|
+
? [options.start, options.end] as [string, string]
|
|
26
|
+
: parsePeriod(period);
|
|
27
|
+
|
|
28
|
+
const filters = options.filter
|
|
29
|
+
? options.filter.map((f: string) => {
|
|
30
|
+
const parts = f.split(":");
|
|
31
|
+
if (parts.length < 3) {
|
|
32
|
+
console.error(chalk.red(`Invalid filter format: ${f}. Use 'field:operator:value'`));
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
const [field, operator, ...valueParts] = parts;
|
|
36
|
+
return [field, operator, valueParts.join(":")] as [string, string, string];
|
|
37
|
+
})
|
|
38
|
+
: undefined;
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const metrics = ["pageviews", "visitors", "bounce_rate", "avg_session_duration"];
|
|
42
|
+
if (options.revenue !== false) {
|
|
43
|
+
metrics.push("revenue", "conversions", "conversion_rate");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const response = await query(site, {
|
|
47
|
+
metrics,
|
|
48
|
+
filters,
|
|
49
|
+
date_range: dateRange,
|
|
50
|
+
include_revenue: options.revenue !== false,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
if (options.json) {
|
|
54
|
+
console.log(JSON.stringify(response, null, 2));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const m = response.data[0]?.metrics || {};
|
|
59
|
+
const [startDate, endDate] = response.meta.date_range;
|
|
60
|
+
|
|
61
|
+
// Logo
|
|
62
|
+
console.log(logo());
|
|
63
|
+
|
|
64
|
+
// Site and date range
|
|
65
|
+
console.log(chalk.bold(` ${site}`));
|
|
66
|
+
console.log(chalk.dim(` ${startDate} → ${endDate}`));
|
|
67
|
+
console.log();
|
|
68
|
+
|
|
69
|
+
// Main metrics - clean 2x2 grid
|
|
70
|
+
const col = 20;
|
|
71
|
+
|
|
72
|
+
const visitors = formatNumber(m.visitors || 0);
|
|
73
|
+
const pageviews = formatNumber(m.pageviews || 0);
|
|
74
|
+
const bounceRate = formatPercent(m.bounce_rate || 0);
|
|
75
|
+
const duration = formatDuration(m.avg_session_duration || 0);
|
|
76
|
+
|
|
77
|
+
console.log(` ${"VISITORS".padEnd(col)}${"PAGEVIEWS"}`);
|
|
78
|
+
console.log(` ${chalk.bold(visitors.padEnd(col))}${chalk.bold(pageviews)}`);
|
|
79
|
+
console.log();
|
|
80
|
+
console.log(` ${"BOUNCE RATE".padEnd(col)}${"AVG DURATION"}`);
|
|
81
|
+
console.log(` ${chalk.bold(bounceRate.padEnd(col))}${chalk.bold(duration)}`);
|
|
82
|
+
|
|
83
|
+
// Revenue section
|
|
84
|
+
if (options.revenue !== false && m.revenue !== undefined) {
|
|
85
|
+
const revenue = "$" + (m.revenue / 100).toFixed(2);
|
|
86
|
+
const conversions = String(m.conversions || 0);
|
|
87
|
+
const convRate = formatPercent(m.conversion_rate || 0);
|
|
88
|
+
|
|
89
|
+
console.log();
|
|
90
|
+
console.log(chalk.dim(" ────────────────────────────────────────"));
|
|
91
|
+
console.log();
|
|
92
|
+
console.log(` ${"REVENUE".padEnd(col)}${"CONVERSIONS"}`);
|
|
93
|
+
console.log(` ${chalk.green.bold(revenue.padEnd(col))}${chalk.bold(conversions)}`);
|
|
94
|
+
console.log();
|
|
95
|
+
console.log(` CONVERSION RATE`);
|
|
96
|
+
console.log(` ${chalk.bold(convRate)}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
console.log();
|
|
100
|
+
} catch (error) {
|
|
101
|
+
console.error(chalk.red(`Error: ${(error as Error).message}`));
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { query, formatNumber } from "../api";
|
|
4
|
+
import { getDefaultSite } from "../config";
|
|
5
|
+
|
|
6
|
+
export const trendCommand = new Command("trend")
|
|
7
|
+
.description("Daily visitor trend")
|
|
8
|
+
.option("-s, --site <site>", "Site to query")
|
|
9
|
+
.option("-p, --period <period>", "Time period (7d, 30d, 90d, 12mo, all)", "30d")
|
|
10
|
+
.option("--start <date>", "Start date (YYYY-MM-DD)")
|
|
11
|
+
.option("--end <date>", "End date (YYYY-MM-DD)")
|
|
12
|
+
.option("-f, --filter <filters...>", "Filters in format 'field:operator:value' (e.g., 'is:country:US')")
|
|
13
|
+
.option("--no-revenue", "Exclude revenue metrics")
|
|
14
|
+
.option("--json", "Output as JSON")
|
|
15
|
+
.action(async (options) => {
|
|
16
|
+
const site = options.site || (await getDefaultSite());
|
|
17
|
+
|
|
18
|
+
if (!site) {
|
|
19
|
+
console.error(chalk.red("Error: No site specified. Use --site or set a default with `supalytics login --site`"));
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const dateRange = options.start && options.end
|
|
24
|
+
? [options.start, options.end] as [string, string]
|
|
25
|
+
: options.period;
|
|
26
|
+
|
|
27
|
+
// Parse filters
|
|
28
|
+
const filters = options.filter
|
|
29
|
+
? options.filter.map((f: string) => {
|
|
30
|
+
const parts = f.split(":");
|
|
31
|
+
if (parts.length < 3) {
|
|
32
|
+
console.error(chalk.red(`Invalid filter format: ${f}. Use 'field:operator:value'`));
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
const [field, operator, ...valueParts] = parts;
|
|
36
|
+
return [field, operator, valueParts.join(":")] as [string, string, string];
|
|
37
|
+
})
|
|
38
|
+
: undefined;
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const metrics = ["visitors"];
|
|
42
|
+
if (options.revenue !== false) {
|
|
43
|
+
metrics.push("revenue");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const response = await query(site, {
|
|
47
|
+
metrics,
|
|
48
|
+
dimensions: ["date"],
|
|
49
|
+
filters,
|
|
50
|
+
date_range: dateRange,
|
|
51
|
+
limit: 1000, // Get all days
|
|
52
|
+
include_revenue: options.revenue !== false,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
if (options.json) {
|
|
56
|
+
console.log(JSON.stringify(response, null, 2));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const [startDate, endDate] = response.meta.date_range;
|
|
61
|
+
|
|
62
|
+
console.log();
|
|
63
|
+
console.log(chalk.bold("Daily Trend"), chalk.dim(`${startDate} → ${endDate}`));
|
|
64
|
+
console.log();
|
|
65
|
+
|
|
66
|
+
if (response.data.length === 0) {
|
|
67
|
+
console.log(chalk.dim(" No data"));
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Find max for sparkline scaling
|
|
72
|
+
const maxVisitors = Math.max(...response.data.map((r) => r.metrics.visitors || 0));
|
|
73
|
+
|
|
74
|
+
for (const result of response.data) {
|
|
75
|
+
const date = result.dimensions?.date || "";
|
|
76
|
+
const visitors = result.metrics.visitors || 0;
|
|
77
|
+
|
|
78
|
+
// Simple bar chart
|
|
79
|
+
const barLength = maxVisitors > 0 ? Math.round((visitors / maxVisitors) * 20) : 0;
|
|
80
|
+
const bar = "█".repeat(barLength) + chalk.dim("░".repeat(20 - barLength));
|
|
81
|
+
|
|
82
|
+
let line = ` ${date} ${bar} ${formatNumber(visitors).padStart(5)}`;
|
|
83
|
+
|
|
84
|
+
if (options.revenue !== false && result.metrics.revenue != null) {
|
|
85
|
+
line += ` ${chalk.green("$" + (result.metrics.revenue / 100).toFixed(0))}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
console.log(line);
|
|
89
|
+
}
|
|
90
|
+
console.log();
|
|
91
|
+
} catch (error) {
|
|
92
|
+
console.error(chalk.red(`Error: ${(error as Error).message}`));
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
});
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { homedir } from "os";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
|
|
4
|
+
const CONFIG_DIR = join(homedir(), ".supalytics");
|
|
5
|
+
const CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
6
|
+
|
|
7
|
+
interface SiteConfig {
|
|
8
|
+
apiKey: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface Config {
|
|
12
|
+
// Map of domain -> API key
|
|
13
|
+
sites: Record<string, SiteConfig>;
|
|
14
|
+
// Default site domain
|
|
15
|
+
defaultSite?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function getConfig(): Promise<Config> {
|
|
19
|
+
try {
|
|
20
|
+
const file = Bun.file(CONFIG_FILE);
|
|
21
|
+
if (await file.exists()) {
|
|
22
|
+
const config = await file.json();
|
|
23
|
+
// Migrate old config format if needed
|
|
24
|
+
if (config.apiKey && !config.sites) {
|
|
25
|
+
return {
|
|
26
|
+
sites: {},
|
|
27
|
+
defaultSite: config.defaultSite,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
return { sites: {}, ...config };
|
|
31
|
+
}
|
|
32
|
+
} catch {
|
|
33
|
+
// Config doesn't exist or is invalid
|
|
34
|
+
}
|
|
35
|
+
return { sites: {} };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function saveConfig(config: Config): Promise<void> {
|
|
39
|
+
await Bun.write(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function addSite(domain: string, apiKey: string): Promise<void> {
|
|
43
|
+
const config = await getConfig();
|
|
44
|
+
config.sites[domain] = { apiKey };
|
|
45
|
+
// Set as default if it's the first site
|
|
46
|
+
if (!config.defaultSite || Object.keys(config.sites).length === 1) {
|
|
47
|
+
config.defaultSite = domain;
|
|
48
|
+
}
|
|
49
|
+
await saveConfig(config);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function removeSite(domain: string): Promise<boolean> {
|
|
53
|
+
const config = await getConfig();
|
|
54
|
+
if (!config.sites[domain]) {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
delete config.sites[domain];
|
|
58
|
+
// Update default if we removed the default site
|
|
59
|
+
if (config.defaultSite === domain) {
|
|
60
|
+
const remaining = Object.keys(config.sites);
|
|
61
|
+
config.defaultSite = remaining.length > 0 ? remaining[0] : undefined;
|
|
62
|
+
}
|
|
63
|
+
await saveConfig(config);
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function setDefaultSite(domain: string): Promise<boolean> {
|
|
68
|
+
const config = await getConfig();
|
|
69
|
+
if (!config.sites[domain]) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
config.defaultSite = domain;
|
|
73
|
+
await saveConfig(config);
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function getApiKeyForSite(domain: string): Promise<string | undefined> {
|
|
78
|
+
// Check env var first
|
|
79
|
+
if (process.env.SUPALYTICS_API_KEY) {
|
|
80
|
+
return process.env.SUPALYTICS_API_KEY;
|
|
81
|
+
}
|
|
82
|
+
const config = await getConfig();
|
|
83
|
+
return config.sites[domain]?.apiKey;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function getDefaultSite(): Promise<string | undefined> {
|
|
87
|
+
const config = await getConfig();
|
|
88
|
+
return config.defaultSite;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function getSites(): Promise<string[]> {
|
|
92
|
+
const config = await getConfig();
|
|
93
|
+
return Object.keys(config.sites);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export { CONFIG_DIR, CONFIG_FILE };
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { program } from "commander";
|
|
3
|
+
import { loginCommand } from "./commands/login";
|
|
4
|
+
import { logoutCommand } from "./commands/logout";
|
|
5
|
+
import { sitesCommand, defaultCommand, removeCommand } from "./commands/sites";
|
|
6
|
+
import { statsCommand } from "./commands/stats";
|
|
7
|
+
import { pagesCommand } from "./commands/pages";
|
|
8
|
+
import { referrersCommand } from "./commands/referrers";
|
|
9
|
+
import { countriesCommand } from "./commands/countries";
|
|
10
|
+
import { trendCommand } from "./commands/trend";
|
|
11
|
+
import { queryCommand } from "./commands/query";
|
|
12
|
+
import { eventsCommand } from "./commands/events";
|
|
13
|
+
import { realtimeCommand } from "./commands/realtime";
|
|
14
|
+
|
|
15
|
+
const description = `CLI for Supalytics web analytics.
|
|
16
|
+
|
|
17
|
+
Multi-site Support:
|
|
18
|
+
Add sites: supalytics login <api-key> (auto-detects domain from API key)
|
|
19
|
+
List sites: supalytics sites
|
|
20
|
+
Set default: supalytics default <domain>
|
|
21
|
+
Remove site: supalytics remove <domain>
|
|
22
|
+
Query other: supalytics stats --site <domain>
|
|
23
|
+
|
|
24
|
+
API keys start with 'sly_' and can be created at https://supalytics.co/settings/api
|
|
25
|
+
|
|
26
|
+
Date Ranges:
|
|
27
|
+
--period: 7d, 14d, 30d, 90d, 12mo, all (default: 30d)
|
|
28
|
+
--start/--end: Custom range in YYYY-MM-DD format
|
|
29
|
+
|
|
30
|
+
Filters (-f, --filter):
|
|
31
|
+
Format: field:operator:value
|
|
32
|
+
|
|
33
|
+
Fields:
|
|
34
|
+
page, country, region, city, browser, os, device,
|
|
35
|
+
referrer, utm_source, utm_medium, utm_campaign, utm_content, utm_term,
|
|
36
|
+
event, event_property, exit_link
|
|
37
|
+
|
|
38
|
+
Operators:
|
|
39
|
+
is Exact match (supports comma-separated: "country:is:US,UK")
|
|
40
|
+
is_not Exclude exact match
|
|
41
|
+
contains Substring match
|
|
42
|
+
not_contains Exclude substring
|
|
43
|
+
starts_with Prefix match
|
|
44
|
+
|
|
45
|
+
Examples:
|
|
46
|
+
-f "country:is:US"
|
|
47
|
+
-f "page:contains:/blog"
|
|
48
|
+
-f "device:is:mobile"
|
|
49
|
+
-f "referrer:is:twitter.com"
|
|
50
|
+
-f "event:is:signup"
|
|
51
|
+
-f "event_property:is:plan:premium"
|
|
52
|
+
|
|
53
|
+
Output:
|
|
54
|
+
--json Raw JSON output (useful for piping to other tools or AI)
|
|
55
|
+
--no-revenue Exclude revenue metrics from output`;
|
|
56
|
+
|
|
57
|
+
program
|
|
58
|
+
.name("supalytics")
|
|
59
|
+
.description(description)
|
|
60
|
+
.version("0.1.0");
|
|
61
|
+
|
|
62
|
+
// Auth & site management commands
|
|
63
|
+
program.addCommand(loginCommand);
|
|
64
|
+
program.addCommand(logoutCommand);
|
|
65
|
+
program.addCommand(sitesCommand);
|
|
66
|
+
program.addCommand(defaultCommand);
|
|
67
|
+
program.addCommand(removeCommand);
|
|
68
|
+
|
|
69
|
+
// Analytics commands
|
|
70
|
+
program.addCommand(statsCommand);
|
|
71
|
+
program.addCommand(pagesCommand);
|
|
72
|
+
program.addCommand(referrersCommand);
|
|
73
|
+
program.addCommand(countriesCommand);
|
|
74
|
+
program.addCommand(trendCommand);
|
|
75
|
+
program.addCommand(queryCommand);
|
|
76
|
+
program.addCommand(eventsCommand);
|
|
77
|
+
program.addCommand(realtimeCommand);
|
|
78
|
+
|
|
79
|
+
program.parse();
|
package/src/ui.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
|
|
3
|
+
// Brand color
|
|
4
|
+
const brand = chalk.hex("#FF2500");
|
|
5
|
+
|
|
6
|
+
// Parse friendly period names into date ranges
|
|
7
|
+
export function parsePeriod(period: string): string | [string, string] {
|
|
8
|
+
const today = new Date();
|
|
9
|
+
const yyyy = (d: Date) => d.toISOString().split("T")[0];
|
|
10
|
+
|
|
11
|
+
switch (period.toLowerCase()) {
|
|
12
|
+
case "today":
|
|
13
|
+
return [yyyy(today), yyyy(today)];
|
|
14
|
+
case "yesterday": {
|
|
15
|
+
const yesterday = new Date(today);
|
|
16
|
+
yesterday.setDate(yesterday.getDate() - 1);
|
|
17
|
+
return [yyyy(yesterday), yyyy(yesterday)];
|
|
18
|
+
}
|
|
19
|
+
case "week":
|
|
20
|
+
case "this week": {
|
|
21
|
+
const startOfWeek = new Date(today);
|
|
22
|
+
startOfWeek.setDate(today.getDate() - today.getDay());
|
|
23
|
+
return [yyyy(startOfWeek), yyyy(today)];
|
|
24
|
+
}
|
|
25
|
+
case "month":
|
|
26
|
+
case "this month": {
|
|
27
|
+
const startOfMonth = new Date(today.getFullYear(), today.getMonth(), 1);
|
|
28
|
+
return [yyyy(startOfMonth), yyyy(today)];
|
|
29
|
+
}
|
|
30
|
+
case "year":
|
|
31
|
+
case "this year": {
|
|
32
|
+
const startOfYear = new Date(today.getFullYear(), 0, 1);
|
|
33
|
+
return [yyyy(startOfYear), yyyy(today)];
|
|
34
|
+
}
|
|
35
|
+
case "last week": {
|
|
36
|
+
const endOfLastWeek = new Date(today);
|
|
37
|
+
endOfLastWeek.setDate(today.getDate() - today.getDay() - 1);
|
|
38
|
+
const startOfLastWeek = new Date(endOfLastWeek);
|
|
39
|
+
startOfLastWeek.setDate(endOfLastWeek.getDate() - 6);
|
|
40
|
+
return [yyyy(startOfLastWeek), yyyy(endOfLastWeek)];
|
|
41
|
+
}
|
|
42
|
+
case "last month": {
|
|
43
|
+
const startOfLastMonth = new Date(today.getFullYear(), today.getMonth() - 1, 1);
|
|
44
|
+
const endOfLastMonth = new Date(today.getFullYear(), today.getMonth(), 0);
|
|
45
|
+
return [yyyy(startOfLastMonth), yyyy(endOfLastMonth)];
|
|
46
|
+
}
|
|
47
|
+
default:
|
|
48
|
+
// Return as-is for 7d, 30d, etc.
|
|
49
|
+
return period;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ASCII logo
|
|
54
|
+
export function logo(): string {
|
|
55
|
+
return `
|
|
56
|
+
${brand("░█▀▀░█░█░█▀█░█▀█░█░░░█░█░▀█▀░▀█▀░█▀▀░█▀▀")}
|
|
57
|
+
${brand("░▀▀█░█░█░█▀▀░█▀█░█░░░░█░░░█░░░█░░█░░░▀▀█")}
|
|
58
|
+
${brand("░▀▀▀░▀▀▀░▀░░░▀░▀░▀▀▀░░▀░░░▀░░▀▀▀░▀▀▀░▀▀▀")}
|
|
59
|
+
`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function sparkBar(value: number, max: number, width: number = 15): string {
|
|
63
|
+
if (max === 0) return chalk.dim("░".repeat(width));
|
|
64
|
+
const filled = Math.round((value / max) * width);
|
|
65
|
+
const empty = width - filled;
|
|
66
|
+
return chalk.cyan("█".repeat(filled)) + chalk.dim("░".repeat(empty));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function truncate(str: string, max: number): string {
|
|
70
|
+
if (str.length <= max) return str;
|
|
71
|
+
return str.slice(0, max - 3) + "...";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function formatRevenue(cents: number): string {
|
|
75
|
+
if (cents >= 100000) {
|
|
76
|
+
return chalk.green("$" + (cents / 100000).toFixed(1) + "K");
|
|
77
|
+
}
|
|
78
|
+
return chalk.green("$" + (cents / 100).toFixed(0));
|
|
79
|
+
}
|