Skip to content

Commit abe4770

Browse files
committed
release: prepare v0.1.12
1 parent a75c549 commit abe4770

23 files changed

Lines changed: 1186 additions & 277 deletions

.github/workflows/release.yml

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -200,12 +200,8 @@ jobs:
200200
if: runner.os == 'macOS'
201201
run: |
202202
mkdir -p dist/release
203-
create-dmg \
204-
--volname "GetTokens" \
205-
--window-size 660 400 \
206-
--icon-size 100 \
207-
"dist/release/${{ matrix.asset-name }}" \
208-
"build/bin/GetTokens.app"
203+
chmod +x scripts/package-macos-dmg.sh
204+
scripts/package-macos-dmg.sh "dist/release/${{ matrix.asset-name }}" "build/bin/GetTokens.app"
209205
210206
- name: Sign and notarize macOS DMG
211207
if: runner.os == 'macOS'

app.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,10 @@ type LocalProjectedUsageResponse struct {
258258
Details []LocalProjectedUsageDetail `json:"details"`
259259
}
260260

261+
type LocalProjectedUsageSettings struct {
262+
RefreshIntervalMinutes int `json:"refreshIntervalMinutes"`
263+
}
264+
261265
func NewApp() *App {
262266
return &App{
263267
core: wailsapp.New(Version, ReleaseLabel, GitHubRepo),
@@ -401,6 +405,14 @@ func (a *App) GetCodexLocalUsage() (*LocalProjectedUsageResponse, error) {
401405
return mapLocalProjectedUsageResponse(result), nil
402406
}
403407

408+
func (a *App) RefreshCodexLocalUsage() (*LocalProjectedUsageResponse, error) {
409+
result, err := a.core.RefreshCodexLocalUsage()
410+
if err != nil {
411+
return nil, err
412+
}
413+
return mapLocalProjectedUsageResponse(result), nil
414+
}
415+
404416
func (a *App) RebuildCodexLocalUsage() (*LocalProjectedUsageResponse, error) {
405417
result, err := a.core.RebuildCodexLocalUsage()
406418
if err != nil {
@@ -409,6 +421,28 @@ func (a *App) RebuildCodexLocalUsage() (*LocalProjectedUsageResponse, error) {
409421
return mapLocalProjectedUsageResponse(result), nil
410422
}
411423

424+
func (a *App) GetLocalProjectedUsageSettings() (*LocalProjectedUsageSettings, error) {
425+
result, err := a.core.GetLocalProjectedUsageSettings()
426+
if err != nil {
427+
return nil, err
428+
}
429+
return &LocalProjectedUsageSettings{
430+
RefreshIntervalMinutes: result.RefreshIntervalMinutes,
431+
}, nil
432+
}
433+
434+
func (a *App) UpdateLocalProjectedUsageSettings(input LocalProjectedUsageSettings) (*LocalProjectedUsageSettings, error) {
435+
result, err := a.core.UpdateLocalProjectedUsageSettings(wailsapp.LocalProjectedUsageSettings{
436+
RefreshIntervalMinutes: input.RefreshIntervalMinutes,
437+
})
438+
if err != nil {
439+
return nil, err
440+
}
441+
return &LocalProjectedUsageSettings{
442+
RefreshIntervalMinutes: result.RefreshIntervalMinutes,
443+
}, nil
444+
}
445+
412446
func (a *App) StartCodexOAuth() (*OAuthStartResult, error) {
413447
result, err := a.core.StartCodexOAuth()
414448
if err != nil {

frontend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
"build": "vite build",
99
"preview": "vite preview",
1010
"typecheck": "tsc --noEmit",
11-
"test:unit": "node --test src/utils/version.test.mjs src/utils/pagePersistence.test.mjs src/features/settings/settingsRelease.test.mjs src/features/accounts/tests/accountConfig.test.mjs src/features/accounts/tests/accountFilters.test.mjs src/features/accounts/tests/accountSelectors.test.mjs src/features/accounts/tests/accountSelection.test.mjs src/features/accounts/tests/accountTransfer.test.mjs src/features/accounts/tests/accountPresentation.test.mjs src/features/accounts/tests/accountOAuth.test.mjs src/features/accounts/tests/accountCardInteractions.test.mjs src/features/accounts/tests/accountHealthMeta.test.mjs src/features/accounts/tests/accountRotation.test.mjs src/features/accounts/tests/accountUsage.test.mjs src/features/accounts/tests/usageDesk.test.mjs src/features/accounts/tests/openAICompatible.test.mjs src/components/biz/accountDetailClipboard.test.mjs"
11+
"test:unit": "node --test src/utils/version.test.mjs src/utils/pagePersistence.test.mjs src/features/settings/settingsRelease.test.mjs src/features/settings/settingsLocalUsage.test.mjs src/features/accounts/tests/accountConfig.test.mjs src/features/accounts/tests/accountFilters.test.mjs src/features/accounts/tests/accountSelectors.test.mjs src/features/accounts/tests/accountSelection.test.mjs src/features/accounts/tests/accountTransfer.test.mjs src/features/accounts/tests/accountPresentation.test.mjs src/features/accounts/tests/accountOAuth.test.mjs src/features/accounts/tests/accountCardInteractions.test.mjs src/features/accounts/tests/accountHealthMeta.test.mjs src/features/accounts/tests/accountRotation.test.mjs src/features/accounts/tests/accountUsage.test.mjs src/features/accounts/tests/usageDesk.test.mjs src/features/accounts/tests/openAICompatible.test.mjs src/components/biz/accountDetailClipboard.test.mjs"
1212
},
1313
"dependencies": {
1414
"lucide-react": "^1.11.0",

frontend/src/features/accounts/components/UsageDeskWorkspace.tsx

Lines changed: 467 additions & 242 deletions
Large diffs are not rendered by default.

frontend/src/features/accounts/model/usageDesk.ts

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ export interface UsageDeskProjectedStats {
9494

9595
export type UsageDeskChartUnit = 'count' | 'tokens';
9696
export type UsageDeskRangeOption = 'TODAY' | '7D' | '14D' | '30D' | '全部';
97+
export type UsageDeskResolution = '1M' | '5M' | '15M' | '30M' | '60M';
9798

9899
function isRecord(value: unknown): value is Record<string, unknown> {
99100
return value !== null && typeof value === 'object' && !Array.isArray(value);
@@ -114,16 +115,22 @@ function buildDayKey(date: Date) {
114115
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
115116
}
116117

117-
function buildMinuteKey(date: Date) {
118-
return `${buildDayKey(date)} ${pad2(date.getHours())}:${pad2(date.getMinutes())}`;
118+
function buildMinuteKey(date: Date, resolution: UsageDeskResolution = '1M') {
119+
const mins = date.getMinutes();
120+
const resValue = parseInt(resolution);
121+
const roundedMins = Math.floor(mins / resValue) * resValue;
122+
return `${buildDayKey(date)} ${pad2(date.getHours())}:${pad2(roundedMins)}`;
119123
}
120124

121125
function buildDayLabel(dayKey: string) {
122126
return dayKey.slice(5);
123127
}
124128

125-
function buildMinuteLabel(date: Date) {
126-
return `${pad2(date.getHours())}:${pad2(date.getMinutes())}`;
129+
function buildMinuteLabel(date: Date, resolution: UsageDeskResolution = '1M') {
130+
const mins = date.getMinutes();
131+
const resValue = parseInt(resolution);
132+
const roundedMins = Math.floor(mins / resValue) * resValue;
133+
return `${pad2(date.getHours())}:${pad2(roundedMins)}`;
127134
}
128135

129136
export function formatUsageDeskChartValue(value: number, unit: UsageDeskChartUnit): string {
@@ -196,6 +203,14 @@ export function resolveUsageDeskLinkedRowKey(
196203
].join('|');
197204
}
198205

206+
export function buildUsageDeskChartPointStyle(x: number, y: number) {
207+
return {
208+
left: `${x}px`,
209+
top: `${y}px`,
210+
transform: 'translate(-50%, -50%)',
211+
};
212+
}
213+
199214
function formatUsageDeskCompactNumber(value: number): string {
200215
const normalized = Math.round(value * 10) / 10;
201216
if (Number.isInteger(normalized)) {
@@ -242,6 +257,7 @@ export function collectUsageDeskObservedDetails(usageData: unknown): UsageDeskOb
242257
export function buildUsageDeskObservedSnapshot(
243258
usageData: unknown,
244259
selectedDayKey?: string | null,
260+
resolution: UsageDeskResolution = '1M',
245261
): UsageDeskObservedSnapshot {
246262
const details = collectUsageDeskObservedDetails(usageData);
247263
if (details.length === 0) {
@@ -304,10 +320,10 @@ export function buildUsageDeskObservedSnapshot(
304320
const date = parseTimestamp(detail.timestamp);
305321
if (!date || buildDayKey(date) !== resolvedDayKey) return;
306322

307-
const minuteKey = buildMinuteKey(date);
323+
const minuteKey = buildMinuteKey(date, resolution);
308324
const minutePoint = minuteMap.get(minuteKey) ?? {
309325
minuteKey,
310-
label: buildMinuteLabel(date),
326+
label: buildMinuteLabel(date, resolution),
311327
success: 0,
312328
failure: 0,
313329
};
@@ -320,7 +336,7 @@ export function buildUsageDeskObservedSnapshot(
320336
minuteMap.set(minuteKey, minutePoint);
321337

322338
const minuteRow = minuteRowMap.get(minuteKey) ?? {
323-
timeLabel: buildMinuteLabel(date),
339+
timeLabel: buildMinuteLabel(date, resolution),
324340
provider: detail.provider,
325341
success: 0,
326342
failure: 0,
@@ -343,7 +359,7 @@ export function buildUsageDeskObservedSnapshot(
343359
const minuteRows = Array.from(minuteRowMap.entries())
344360
.sort((a, b) => b[0].localeCompare(a[0]))
345361
.map(([, row]) => {
346-
if (row.requests === 1) {
362+
if (row.requests === 1 && resolution === '1M') {
347363
return {
348364
timeLabel: row.timeLabel,
349365
provider: row.provider,
@@ -406,6 +422,7 @@ export function collectUsageDeskProjectedDetails(payload: unknown): UsageDeskPro
406422
export function buildUsageDeskProjectedSnapshot(
407423
payload: unknown,
408424
selectedDayKey?: string | null,
425+
resolution: UsageDeskResolution = '1M',
409426
): UsageDeskProjectedSnapshot {
410427
const details = collectUsageDeskProjectedDetails(payload);
411428
if (details.length === 0) {
@@ -473,10 +490,10 @@ export function buildUsageDeskProjectedSnapshot(
473490
const date = parseTimestamp(detail.timestamp);
474491
if (!date || buildDayKey(date) !== resolvedDayKey) return;
475492

476-
const minuteKey = buildMinuteKey(date);
493+
const minuteKey = buildMinuteKey(date, resolution);
477494
const point = minuteMap.get(minuteKey) ?? {
478495
minuteKey,
479-
label: buildMinuteLabel(date),
496+
label: buildMinuteLabel(date, resolution),
480497
requests: 0,
481498
totalTokens: 0,
482499
};
@@ -486,7 +503,7 @@ export function buildUsageDeskProjectedSnapshot(
486503
minuteMap.set(minuteKey, point);
487504

488505
const minuteRow = minuteRowMap.get(minuteKey) ?? {
489-
timeLabel: buildMinuteLabel(date),
506+
timeLabel: buildMinuteLabel(date, resolution),
490507
provider: detail.provider,
491508
totalTokens: 0,
492509
requests: 0,

frontend/src/features/accounts/tests/usageDesk.test.mjs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import test from 'node:test';
22
import assert from 'node:assert/strict';
33

44
import {
5+
buildUsageDeskChartPointStyle,
56
buildUsageDeskObservedSnapshot,
67
buildUsageDeskProjectedSnapshot,
78
collectUsageDeskObservedDetails,
@@ -13,6 +14,14 @@ import {
1314
resolveUsageDeskRangeDrilldownDayKey,
1415
} from '../model/usageDesk.ts';
1516

17+
test('buildUsageDeskChartPointStyle keeps hit area centered on the plotted coordinate', () => {
18+
assert.deepEqual(buildUsageDeskChartPointStyle(128, 96), {
19+
left: '128px',
20+
top: '96px',
21+
transform: 'translate(-50%, -50%)',
22+
});
23+
});
24+
1625
test('collectUsageDeskObservedDetails keeps provider and model from nested usage payload', () => {
1726
const details = collectUsageDeskObservedDetails({
1827
apis: {

frontend/src/features/settings/SettingsFeature.tsx

Lines changed: 111 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,22 @@
1-
import { useState } from 'react';
2-
import { ApplyUpdate, CheckUpdate } from '../../../wailsjs/go/main/App';
1+
import { useEffect, useState } from 'react';
2+
import {
3+
ApplyUpdate,
4+
CheckUpdate,
5+
GetLocalProjectedUsageSettings,
6+
UpdateLocalProjectedUsageSettings,
7+
} from '../../../wailsjs/go/main/App';
38
import { BrowserOpenURL, Quit } from '../../../wailsjs/runtime/runtime';
49
import SegmentedControl from '../../components/ui/SegmentedControl';
510
import { useDebug } from '../../context/DebugContext';
611
import { useI18n } from '../../context/I18nContext';
712
import { useTheme } from '../../context/ThemeContext';
813
import { mapCheckedRelease } from './settingsRelease';
14+
import {
15+
localProjectedUsageRefreshIntervalOptions,
16+
parseLocalProjectedUsageRefreshIntervalMinutes,
17+
resolveLocalProjectedUsageRefreshIntervalID,
18+
type LocalProjectedUsageRefreshIntervalID,
19+
} from './settingsLocalUsage';
920
import { toErrorMessage } from '../../utils/error';
1021
import { formatAppVersion } from '../../utils/version';
1122
import type { LocaleCode, ReleaseInfo, SegmentedOption, ThemeMode } from '../../types';
@@ -45,9 +56,44 @@ export default function SettingsFeature({
4556
const [isCheckingUpdate, setIsCheckingUpdate] = useState(false);
4657
const [isApplyingUpdate, setIsApplyingUpdate] = useState(false);
4758
const [isOpeningRelease, setIsOpeningRelease] = useState(false);
59+
const [localUsageInterval, setLocalUsageInterval] = useState<LocalProjectedUsageRefreshIntervalID>('15');
60+
const [localUsageMessage, setLocalUsageMessage] = useState('');
61+
const [isLoadingLocalUsageSettings, setIsLoadingLocalUsageSettings] = useState(true);
62+
const [isSavingLocalUsageSettings, setIsSavingLocalUsageSettings] = useState(false);
4863
const currentVersionLabel = formatAppVersion(version);
4964
const latestReleaseLabel = availableRelease ? formatAppVersion(availableRelease.version) : '—';
5065

66+
useEffect(() => {
67+
let mounted = true;
68+
69+
async function loadLocalUsageSettings() {
70+
setIsLoadingLocalUsageSettings(true);
71+
setLocalUsageMessage('');
72+
try {
73+
const settings = await trackRequest<any>(
74+
'GetLocalProjectedUsageSettings',
75+
{ args: [] },
76+
() => GetLocalProjectedUsageSettings(),
77+
);
78+
if (!mounted) return;
79+
setLocalUsageInterval(resolveLocalProjectedUsageRefreshIntervalID(settings?.refreshIntervalMinutes ?? 15));
80+
} catch (error) {
81+
if (!mounted) return;
82+
setLocalUsageMessage(`${t('settings.local_usage_refresh_failed')}: ${toErrorMessage(error)}`);
83+
} finally {
84+
if (mounted) {
85+
setIsLoadingLocalUsageSettings(false);
86+
}
87+
}
88+
}
89+
90+
void loadLocalUsageSettings();
91+
92+
return () => {
93+
mounted = false;
94+
};
95+
}, [t, trackRequest]);
96+
5197
async function handleCheckUpdate() {
5298
setIsCheckingUpdate(true);
5399
setUpdateMessage('');
@@ -109,6 +155,28 @@ export default function SettingsFeature({
109155
setIsOpeningRelease(false);
110156
}
111157

158+
async function handleLocalUsageIntervalChange(value: LocalProjectedUsageRefreshIntervalID) {
159+
setLocalUsageInterval(value);
160+
setIsSavingLocalUsageSettings(true);
161+
setLocalUsageMessage('');
162+
try {
163+
const settings = await trackRequest<any>(
164+
'UpdateLocalProjectedUsageSettings',
165+
{ refreshIntervalMinutes: parseLocalProjectedUsageRefreshIntervalMinutes(value) },
166+
() =>
167+
UpdateLocalProjectedUsageSettings({
168+
refreshIntervalMinutes: parseLocalProjectedUsageRefreshIntervalMinutes(value),
169+
}),
170+
);
171+
setLocalUsageInterval(resolveLocalProjectedUsageRefreshIntervalID(settings?.refreshIntervalMinutes ?? 15));
172+
setLocalUsageMessage(t('settings.local_usage_refresh_saved'));
173+
} catch (error) {
174+
setLocalUsageMessage(`${t('settings.local_usage_refresh_failed')}: ${toErrorMessage(error)}`);
175+
} finally {
176+
setIsSavingLocalUsageSettings(false);
177+
}
178+
}
179+
112180
return (
113181
<div className="h-full w-full overflow-auto p-12" data-collaboration-id="PAGE_SETTINGS">
114182
<div className="mx-auto max-w-6xl space-y-6 pb-10">
@@ -263,6 +331,47 @@ export default function SettingsFeature({
263331
</div>
264332
</div>
265333
</section>
334+
335+
<section className="space-y-3">
336+
<div className="flex items-center gap-2">
337+
<span className="bg-[var(--border-color)] px-1.5 py-0.5 font-mono text-[8px] font-black uppercase text-[var(--bg-main)]">
338+
03
339+
</span>
340+
<h3 className="text-xs font-black uppercase italic tracking-tighter text-[var(--text-primary)]">
341+
{t('settings.local_usage_refresh')}
342+
</h3>
343+
</div>
344+
345+
<div className="card-swiss !p-0 divide-y-2 divide-[var(--border-color)]">
346+
<div className="space-y-3 p-5">
347+
<div className="flex items-center justify-between">
348+
<label className="text-[9px] font-black uppercase italic tracking-widest text-[var(--text-muted)]">
349+
{t('settings.local_usage_refresh_interval')}
350+
</label>
351+
<span className="font-mono text-[8px] font-bold italic opacity-30 text-[var(--text-muted)]">
352+
LOCAL_PROJECTED_USAGE
353+
</span>
354+
</div>
355+
<SegmentedControl
356+
options={localProjectedUsageRefreshIntervalOptions}
357+
value={localUsageInterval}
358+
onChange={(value) => void handleLocalUsageIntervalChange(value)}
359+
/>
360+
<div className="text-[9px] font-bold uppercase leading-5 tracking-widest text-[var(--text-muted)]">
361+
{isLoadingLocalUsageSettings
362+
? t('settings.local_usage_refresh_loading')
363+
: isSavingLocalUsageSettings
364+
? t('settings.local_usage_refresh_saving')
365+
: t('settings.local_usage_refresh_hint')}
366+
</div>
367+
{localUsageMessage ? (
368+
<div className="border border-dashed border-[var(--border-color)] bg-[var(--bg-main)] px-3 py-2 text-[9px] font-bold uppercase leading-5 tracking-widest text-[var(--text-primary)]">
369+
{localUsageMessage}
370+
</div>
371+
) : null}
372+
</div>
373+
</div>
374+
</section>
266375
</div>
267376
</div>
268377
</div>
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert/strict';
3+
4+
import {
5+
localProjectedUsageRefreshIntervalOptions,
6+
parseLocalProjectedUsageRefreshIntervalMinutes,
7+
resolveLocalProjectedUsageRefreshIntervalID,
8+
} from './settingsLocalUsage.ts';
9+
10+
test('resolveLocalProjectedUsageRefreshIntervalID falls back to 15 minutes', () => {
11+
assert.equal(resolveLocalProjectedUsageRefreshIntervalID(5), '5');
12+
assert.equal(resolveLocalProjectedUsageRefreshIntervalID(30), '30');
13+
assert.equal(resolveLocalProjectedUsageRefreshIntervalID(7), '15');
14+
});
15+
16+
test('parseLocalProjectedUsageRefreshIntervalMinutes converts segmented value to minutes', () => {
17+
assert.equal(parseLocalProjectedUsageRefreshIntervalMinutes('5'), 5);
18+
assert.equal(parseLocalProjectedUsageRefreshIntervalMinutes('60'), 60);
19+
});
20+
21+
test('localProjectedUsageRefreshIntervalOptions keep expected order', () => {
22+
assert.deepEqual(
23+
localProjectedUsageRefreshIntervalOptions.map((option) => option.id),
24+
['5', '15', '30', '60'],
25+
);
26+
});

0 commit comments

Comments
 (0)