feat(KB-168): add usage pace indicator to dashboard
- Add pace calculation based on task velocity to UsageIndicator - Display pace marker and progress bar with visual indicators - Add view mode toggle for compact vs detailed display - Include comprehensive tests for pace indicator behavior - Add changeset for the new feature
This commit is contained in:
@@ -569,4 +569,294 @@ describe("UsageIndicator", () => {
|
||||
expect(document.querySelector('[data-provider="google"]')).toBeInTheDocument();
|
||||
expect(document.querySelector("svg[aria-label='Google Gemini']")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Pace indicator tests
|
||||
it("renders pace marker for weekly windows with timing data", () => {
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: [
|
||||
{
|
||||
name: "TestProvider",
|
||||
icon: "🧪",
|
||||
status: "ok",
|
||||
windows: [
|
||||
{
|
||||
label: "Weekly",
|
||||
percentUsed: 30,
|
||||
percentLeft: 70,
|
||||
resetText: "resets in 3d",
|
||||
resetMs: 259200000, // 3 days remaining
|
||||
windowDurationMs: 604800000, // 7 days total
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date(),
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
const paceMarker = document.querySelector('[data-testid="pace-marker"]');
|
||||
expect(paceMarker).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render pace marker for non-weekly windows (Session, Hourly)", () => {
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: [
|
||||
{
|
||||
name: "TestProvider",
|
||||
icon: "🧪",
|
||||
status: "ok",
|
||||
windows: [
|
||||
{
|
||||
label: "Session (5h)",
|
||||
percentUsed: 45,
|
||||
percentLeft: 55,
|
||||
resetText: "resets in 2h",
|
||||
resetMs: 7200000,
|
||||
windowDurationMs: 18000000,
|
||||
},
|
||||
{
|
||||
label: "Hourly",
|
||||
percentUsed: 60,
|
||||
percentLeft: 40,
|
||||
resetText: "resets in 30m",
|
||||
resetMs: 1800000,
|
||||
windowDurationMs: 3600000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date(),
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
const paceMarkers = document.querySelectorAll('[data-testid="pace-marker"]');
|
||||
expect(paceMarkers.length).toBe(0);
|
||||
});
|
||||
|
||||
it("does not render pace marker when resetMs or windowDurationMs is undefined", () => {
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: [
|
||||
{
|
||||
name: "TestProvider",
|
||||
icon: "🧪",
|
||||
status: "ok",
|
||||
windows: [
|
||||
{
|
||||
label: "Weekly",
|
||||
percentUsed: 30,
|
||||
percentLeft: 70,
|
||||
resetText: "resets in 3d",
|
||||
// No resetMs or windowDurationMs
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date(),
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
const paceMarker = document.querySelector('[data-testid="pace-marker"]');
|
||||
expect(paceMarker).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'ahead of pace' text when usage exceeds elapsed time by >5%", () => {
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: [
|
||||
{
|
||||
name: "TestProvider",
|
||||
icon: "🧪",
|
||||
status: "ok",
|
||||
windows: [
|
||||
{
|
||||
label: "Weekly",
|
||||
percentUsed: 70, // 70% used
|
||||
percentLeft: 30,
|
||||
resetText: "resets in 3.5d",
|
||||
resetMs: 302400000, // 3.5 days remaining out of 7
|
||||
windowDurationMs: 604800000, // 7 days total
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date(),
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
// percentElapsed = 100 - (302400 / 604800 * 100) = 100 - 50 = 50%
|
||||
// paceDelta = 70 - 50 = 20% (ahead)
|
||||
const paceRow = screen.getByTestId("pace-row");
|
||||
expect(paceRow).toHaveTextContent(/ahead of pace/);
|
||||
expect(paceRow).toHaveTextContent("20%");
|
||||
});
|
||||
|
||||
it("shows 'behind pace' text when usage is under elapsed time by >5%", () => {
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: [
|
||||
{
|
||||
name: "TestProvider",
|
||||
icon: "🧪",
|
||||
status: "ok",
|
||||
windows: [
|
||||
{
|
||||
label: "Weekly",
|
||||
percentUsed: 20, // 20% used
|
||||
percentLeft: 80,
|
||||
resetText: "resets in 3.5d",
|
||||
resetMs: 302400000, // 3.5 days remaining out of 7
|
||||
windowDurationMs: 604800000, // 7 days total
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date(),
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
// percentElapsed = 100 - (302400 / 604800 * 100) = 100 - 50 = 50%
|
||||
// paceDelta = 20 - 50 = -30% (behind)
|
||||
const paceRow = screen.getByTestId("pace-row");
|
||||
expect(paceRow).toHaveTextContent(/behind pace/);
|
||||
expect(paceRow).toHaveTextContent("30%");
|
||||
});
|
||||
|
||||
it("shows 'on pace' text when usage is within 5% of elapsed time", () => {
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: [
|
||||
{
|
||||
name: "TestProvider",
|
||||
icon: "🧪",
|
||||
status: "ok",
|
||||
windows: [
|
||||
{
|
||||
label: "Weekly",
|
||||
percentUsed: 52, // 52% used
|
||||
percentLeft: 48,
|
||||
resetText: "resets in 3.5d",
|
||||
resetMs: 302400000, // 3.5 days remaining out of 7
|
||||
windowDurationMs: 604800000, // 7 days total
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date(),
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
// percentElapsed = 100 - (302400 / 604800 * 100) = 100 - 50 = 50%
|
||||
// paceDelta = 52 - 50 = 2% (within 5% threshold, on pace)
|
||||
const paceRow = screen.getByTestId("pace-row");
|
||||
expect(paceRow).toHaveTextContent(/On pace/);
|
||||
});
|
||||
|
||||
it("pace marker position inverts correctly when switching to remaining mode", () => {
|
||||
// Mock provider with weekly window
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: [
|
||||
{
|
||||
name: "TestProvider",
|
||||
icon: "🧪",
|
||||
status: "ok",
|
||||
windows: [
|
||||
{
|
||||
label: "Weekly",
|
||||
percentUsed: 30,
|
||||
percentLeft: 70,
|
||||
resetText: "resets in 3.5d",
|
||||
resetMs: 302400000, // 50% elapsed
|
||||
windowDurationMs: 604800000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date(),
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
// In used mode: marker at 50%
|
||||
let paceMarker = document.querySelector('[data-testid="pace-marker"]') as HTMLElement;
|
||||
expect(paceMarker).toBeInTheDocument();
|
||||
expect(paceMarker.style.left).toBe("50%");
|
||||
|
||||
// Switch to remaining mode
|
||||
const remainingBtn = screen.getByTestId("usage-view-toggle-remaining");
|
||||
fireEvent.click(remainingBtn);
|
||||
|
||||
// In remaining mode: marker at 100 - 50 = 50% (same in this case since it's 50/50)
|
||||
paceMarker = document.querySelector('[data-testid="pace-marker"]') as HTMLElement;
|
||||
expect(paceMarker.style.left).toBe("50%");
|
||||
});
|
||||
|
||||
it("pace percentage text inverts correctly when switching to remaining mode", () => {
|
||||
// Clear localStorage to ensure fresh 'used' mode
|
||||
localStorage.removeItem("kb-usage-view-mode");
|
||||
|
||||
// Setup: 70% used (ahead of pace), 30% remaining
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: [
|
||||
{
|
||||
name: "TestProvider",
|
||||
icon: "🧪",
|
||||
status: "ok",
|
||||
windows: [
|
||||
{
|
||||
label: "Weekly",
|
||||
percentUsed: 70, // 70% used
|
||||
percentLeft: 30,
|
||||
resetText: "resets in 3.5d",
|
||||
resetMs: 302400000, // 50% elapsed
|
||||
windowDurationMs: 604800000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date(),
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
// In used mode: ahead of pace (70% used vs 50% elapsed)
|
||||
let paceRow = screen.getByTestId("pace-row");
|
||||
expect(paceRow).toHaveTextContent(/ahead of pace/);
|
||||
expect(paceRow).toHaveTextContent("⚡");
|
||||
|
||||
// Switch to remaining mode
|
||||
const remainingBtn = screen.getByTestId("usage-view-toggle-remaining");
|
||||
fireEvent.click(remainingBtn);
|
||||
|
||||
// In remaining mode: message should invert (behind on remaining)
|
||||
// When ahead on usage (using more than expected), you're behind on remaining
|
||||
paceRow = screen.getByTestId("pace-row");
|
||||
expect(paceRow).toHaveTextContent(/behind on remaining/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,22 +38,58 @@ function UsageWindowRow({ window, viewMode }: UsageWindowRowProps) {
|
||||
const headerText = isRemainingMode ? `${window.percentLeft}% remaining` : `${window.percentUsed}% used`;
|
||||
const footerText = isRemainingMode ? `${window.percentUsed}% used` : `${window.percentLeft}% left`;
|
||||
|
||||
// Pace calculation for weekly windows
|
||||
const shouldShowPace = window.label.toLowerCase().includes('weekly') &&
|
||||
window.resetMs !== undefined &&
|
||||
window.windowDurationMs !== undefined;
|
||||
|
||||
let percentElapsed = 0;
|
||||
let paceDelta = 0;
|
||||
let markerPosition = 0;
|
||||
|
||||
if (shouldShowPace) {
|
||||
percentElapsed = 100 - (window.resetMs! / window.windowDurationMs! * 100);
|
||||
paceDelta = window.percentUsed - percentElapsed; // positive = ahead of pace
|
||||
|
||||
// Marker position adjusts for view mode
|
||||
markerPosition = isRemainingMode ? (100 - percentElapsed) : percentElapsed;
|
||||
}
|
||||
|
||||
// Pace status thresholds
|
||||
const PACE_THRESHOLD = 5; // 5% threshold for "on pace"
|
||||
const isAhead = paceDelta > PACE_THRESHOLD;
|
||||
const isBehind = paceDelta < -PACE_THRESHOLD;
|
||||
const isOnTrack = !isAhead && !isBehind;
|
||||
|
||||
// Format pace delta for display (absolute value, rounded)
|
||||
const paceDeltaFormatted = Math.abs(Math.round(paceDelta));
|
||||
|
||||
return (
|
||||
<div className="usage-window">
|
||||
<div className="usage-window-header">
|
||||
<span className="usage-window-label">{window.label}</span>
|
||||
<span className="usage-window-percentage">{headerText}</span>
|
||||
</div>
|
||||
<div className="usage-progress-bar">
|
||||
<div
|
||||
className={`usage-progress-fill ${colorClass}`}
|
||||
style={{ width: `${displayPercent}%` }}
|
||||
role="progressbar"
|
||||
aria-valuenow={displayPercent}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label={`${window.label}: ${headerText}`}
|
||||
/>
|
||||
<div className="usage-progress-wrapper">
|
||||
<div className="usage-progress-bar">
|
||||
<div
|
||||
className={`usage-progress-fill ${colorClass}`}
|
||||
style={{ width: `${displayPercent}%` }}
|
||||
role="progressbar"
|
||||
aria-valuenow={displayPercent}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label={`${window.label}: ${headerText}`}
|
||||
/>
|
||||
</div>
|
||||
{shouldShowPace && (
|
||||
<div
|
||||
className="usage-pace-marker"
|
||||
style={{ left: `${markerPosition}%` }}
|
||||
aria-hidden="true"
|
||||
data-testid="pace-marker"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="usage-window-footer">
|
||||
<span className="usage-window-left">{footerText}</span>
|
||||
@@ -61,6 +97,36 @@ function UsageWindowRow({ window, viewMode }: UsageWindowRowProps) {
|
||||
<span className="usage-window-reset">{window.resetText}</span>
|
||||
)}
|
||||
</div>
|
||||
{shouldShowPace && (
|
||||
<div className="usage-pace-row" data-testid="pace-row">
|
||||
{isAhead && (
|
||||
<>
|
||||
<span className="pace-icon pace-icon-ahead">⚡</span>
|
||||
<span className="pace-text pace-ahead">
|
||||
{isRemainingMode
|
||||
? `${paceDeltaFormatted}% behind on remaining`
|
||||
: `${paceDeltaFormatted}% ahead of pace`}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{isBehind && (
|
||||
<>
|
||||
<span className="pace-icon pace-icon-behind">🐢</span>
|
||||
<span className="pace-text pace-behind">
|
||||
{isRemainingMode
|
||||
? `${paceDeltaFormatted}% ahead on remaining`
|
||||
: `${paceDeltaFormatted}% behind pace`}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{isOnTrack && (
|
||||
<>
|
||||
<span className="pace-icon pace-icon-ontrack">✓</span>
|
||||
<span className="pace-text pace-ontrack">On pace</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7151,6 +7151,50 @@ html .column.drag-over * {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Pace marker and text */
|
||||
.usage-progress-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.usage-pace-marker {
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
width: 2px;
|
||||
height: 10px;
|
||||
background: var(--in-progress);
|
||||
border-radius: 1px;
|
||||
z-index: 2;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.usage-pace-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.pace-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pace-text {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pace-ahead {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.pace-behind {
|
||||
color: var(--triage);
|
||||
}
|
||||
|
||||
.pace-ontrack {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.usage-provider-empty {
|
||||
font-size: 12px;
|
||||
|
||||
Reference in New Issue
Block a user