feat: add CostTimeline component and integrate into the main navigation view#2
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a new “Cost Timeline” analytics view to the billing area of the app, exposing month-by-month and day-by-day cost trends and integrating it into the existing dashboard navigation alongside Azure/NCE/Renewals.
Changes:
- Introduces
CostTimelinecomponent with monthly stacked breakdown, drill-down daily chart, top customers, and a sortable/filterable records table. - Adds a new “Cost Timeline” navigation tab (with
LineCharticon) and routes the main view rendering to the new component. - Updates header/search/history visibility logic to account for the new
timelineview.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| src/components/CostTimeline.tsx | New timeline view with aggregation + charts + drill-down UI for billing records. |
| src/App.tsx | Adds timeline to navigation state and renders CostTimeline via the main dashboard tab strip. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| .map(([day, total]) => ({ | ||
| day, | ||
| label: new Date(day).toLocaleDateString('en-GB', { day: '2-digit', month: 'short' }), | ||
| total, | ||
| })); |
There was a problem hiding this comment.
dailyData builds labels from a day string in YYYY-MM-DD form and then uses new Date(day). Date-only ISO strings are parsed as UTC, which can shift the displayed day in non-UTC timezones (and cause off-by-one labels). Consider constructing the date in local time (e.g., split day into y/m/d and use new Date(y, m-1, d)), or keep the original Date object from parseDay/aggregation for labeling.
| .map(([day, total]) => ({ | |
| day, | |
| label: new Date(day).toLocaleDateString('en-GB', { day: '2-digit', month: 'short' }), | |
| total, | |
| })); | |
| .map(([day, total]) => { | |
| const [year, month, date] = day.split('-').map(Number); | |
| const localDate = new Date(year, month - 1, date); | |
| return { | |
| day, | |
| label: localDate.toLocaleDateString('en-GB', { day: '2-digit', month: 'short' }), | |
| total, | |
| }; | |
| }); |
| {CATEGORIES.filter(cat => selectedData[cat] > 0).map(cat => ( | ||
| <div key={cat} style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', padding: '0.3rem 0.75rem', borderRadius: '20px', border: `1px solid ${CAT_COLORS[cat]}44`, background: `${CAT_COLORS[cat]}11`, fontSize: '0.8rem', color: 'var(--text-secondary)' }}> | ||
| <span style={{ width: 8, height: 8, borderRadius: '50%', background: CAT_COLORS[cat], display: 'inline-block' }} /> | ||
| {cat}: <strong style={{ color: 'var(--text-primary)' }}>{fmt(selectedData[cat], currency)}</strong> | ||
| <span style={{ color: 'var(--text-tertiary)' }}>({((selectedData[cat] / selectedData.total) * 100).toFixed(0)}%)</span> | ||
| </div> | ||
| ))} |
There was a problem hiding this comment.
The category percentage uses (selectedData[cat] / selectedData.total) * 100 without guarding against selectedData.total === 0. With mixed positive/negative category totals, total can be 0 even when a category is > 0, resulting in Infinity%/NaN% in the UI. Add a zero check (and ideally decide how to handle negative totals) before computing the percentage.
| {CATEGORIES.filter(cat => selectedData[cat] > 0).map(cat => ( | |
| <div key={cat} style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', padding: '0.3rem 0.75rem', borderRadius: '20px', border: `1px solid ${CAT_COLORS[cat]}44`, background: `${CAT_COLORS[cat]}11`, fontSize: '0.8rem', color: 'var(--text-secondary)' }}> | |
| <span style={{ width: 8, height: 8, borderRadius: '50%', background: CAT_COLORS[cat], display: 'inline-block' }} /> | |
| {cat}: <strong style={{ color: 'var(--text-primary)' }}>{fmt(selectedData[cat], currency)}</strong> | |
| <span style={{ color: 'var(--text-tertiary)' }}>({((selectedData[cat] / selectedData.total) * 100).toFixed(0)}%)</span> | |
| </div> | |
| ))} | |
| {CATEGORIES.filter(cat => selectedData[cat] > 0).map(cat => { | |
| const percentage = selectedData.total === 0 | |
| ? 0 | |
| : (selectedData[cat] / selectedData.total) * 100; | |
| return ( | |
| <div key={cat} style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', padding: '0.3rem 0.75rem', borderRadius: '20px', border: `1px solid ${CAT_COLORS[cat]}44`, background: `${CAT_COLORS[cat]}11`, fontSize: '0.8rem', color: 'var(--text-secondary)' }}> | |
| <span style={{ width: 8, height: 8, borderRadius: '50%', background: CAT_COLORS[cat], display: 'inline-block' }} /> | |
| {cat}: <strong style={{ color: 'var(--text-primary)' }}>{fmt(selectedData[cat], currency)}</strong> | |
| <span style={{ color: 'var(--text-tertiary)' }}>({percentage.toFixed(0)}%)</span> | |
| </div> | |
| ); | |
| })} |
| {topCustomers.map((c, i) => { | ||
| const pct = selectedData.total > 0 ? (c.value / selectedData.total) * 100 : 0; | ||
| return ( | ||
| <div key={c.name}> | ||
| <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.82rem', marginBottom: '0.2rem' }}> | ||
| <span style={{ color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '60%' }}>{c.name}</span> | ||
| <span style={{ color: 'var(--text-secondary)', flexShrink: 0 }}>{fmt(c.value, currency)}</span> | ||
| </div> | ||
| <div style={{ height: 4, borderRadius: 2, background: 'var(--bg-tertiary)', overflow: 'hidden' }}> | ||
| <div style={{ height: '100%', width: `${pct}%`, background: `hsl(${(i * 47) % 360}, 65%, 55%)`, borderRadius: 2, transition: 'width 0.4s ease' }} /> | ||
| </div> |
There was a problem hiding this comment.
Top customer bar width is computed directly from pct = (c.value / selectedData.total) * 100 and used as a CSS percentage. If selectedData.total is reduced by credits/refunds, pct can be > 100 (or negative), causing bars to overflow/underflow the container. Clamping the computed percentage (and/or basing it on sum of positive values) will keep the visualization stable.
…ation and dual-range filtering
This pull request introduces a new "Cost Timeline" view to the application. The main changes add navigation, UI elements, and logic to support the new timeline feature, integrating it alongside existing views like Dashboard, Azure, and Renewals.
New Feature: Cost Timeline View
CostTimelinecomponent and integrated it into the navigation and view logic, allowing users to switch to and display the new timeline view. [1] [2] [3]UI and Navigation Updates
LineCharticon for accessing the Cost Timeline, styled consistently with other navigation buttons.timelineview, ensuring correct display and interaction for search, history, and dashboard elements. [1] [2] [3]