@oxyhq/bloom 0.29.6 → 0.30.1
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/lib/commonjs/activity-heatmap/ActivityHeatmap.js +304 -0
- package/lib/commonjs/activity-heatmap/ActivityHeatmap.js.map +1 -0
- package/lib/commonjs/activity-heatmap/bucketByDay.js +51 -0
- package/lib/commonjs/activity-heatmap/bucketByDay.js.map +1 -0
- package/lib/commonjs/activity-heatmap/index.js +20 -0
- package/lib/commonjs/activity-heatmap/index.js.map +1 -0
- package/lib/commonjs/activity-heatmap/types.js +6 -0
- package/lib/commonjs/activity-heatmap/types.js.map +1 -0
- package/lib/commonjs/avatar-group/AvatarGroupBase.js +44 -18
- package/lib/commonjs/avatar-group/AvatarGroupBase.js.map +1 -1
- package/lib/commonjs/composition-bar/CompositionBar.js +159 -0
- package/lib/commonjs/composition-bar/CompositionBar.js.map +1 -0
- package/lib/commonjs/composition-bar/index.js +13 -0
- package/lib/commonjs/composition-bar/index.js.map +1 -0
- package/lib/commonjs/composition-bar/types.js +6 -0
- package/lib/commonjs/composition-bar/types.js.map +1 -0
- package/lib/commonjs/dot-grid-meter/DotGridMeter.js +69 -0
- package/lib/commonjs/dot-grid-meter/DotGridMeter.js.map +1 -0
- package/lib/commonjs/dot-grid-meter/index.js +13 -0
- package/lib/commonjs/dot-grid-meter/index.js.map +1 -0
- package/lib/commonjs/dot-grid-meter/types.js +6 -0
- package/lib/commonjs/dot-grid-meter/types.js.map +1 -0
- package/lib/commonjs/index.js +59 -12
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/index.web.js +59 -12
- package/lib/commonjs/index.web.js.map +1 -1
- package/lib/commonjs/profile-card/ProfileCard.js +311 -0
- package/lib/commonjs/profile-card/ProfileCard.js.map +1 -0
- package/lib/commonjs/profile-card/index.js +13 -0
- package/lib/commonjs/profile-card/index.js.map +1 -0
- package/lib/commonjs/profile-card/types.js +6 -0
- package/lib/commonjs/profile-card/types.js.map +1 -0
- package/lib/commonjs/stat-bar/StatBar.js +213 -0
- package/lib/commonjs/stat-bar/StatBar.js.map +1 -0
- package/lib/commonjs/stat-bar/index.js +13 -0
- package/lib/commonjs/stat-bar/index.js.map +1 -0
- package/lib/commonjs/stat-bar/types.js +6 -0
- package/lib/commonjs/stat-bar/types.js.map +1 -0
- package/lib/module/activity-heatmap/ActivityHeatmap.js +299 -0
- package/lib/module/activity-heatmap/ActivityHeatmap.js.map +1 -0
- package/lib/module/activity-heatmap/bucketByDay.js +47 -0
- package/lib/module/activity-heatmap/bucketByDay.js.map +1 -0
- package/lib/module/activity-heatmap/index.js +5 -0
- package/lib/module/activity-heatmap/index.js.map +1 -0
- package/lib/module/activity-heatmap/types.js +4 -0
- package/lib/module/activity-heatmap/types.js.map +1 -0
- package/lib/module/avatar-group/AvatarGroupBase.js +44 -18
- package/lib/module/avatar-group/AvatarGroupBase.js.map +1 -1
- package/lib/module/composition-bar/CompositionBar.js +154 -0
- package/lib/module/composition-bar/CompositionBar.js.map +1 -0
- package/lib/module/composition-bar/index.js +4 -0
- package/lib/module/composition-bar/index.js.map +1 -0
- package/lib/module/composition-bar/types.js +4 -0
- package/lib/module/composition-bar/types.js.map +1 -0
- package/lib/module/dot-grid-meter/DotGridMeter.js +64 -0
- package/lib/module/dot-grid-meter/DotGridMeter.js.map +1 -0
- package/lib/module/dot-grid-meter/index.js +4 -0
- package/lib/module/dot-grid-meter/index.js.map +1 -0
- package/lib/module/dot-grid-meter/types.js +4 -0
- package/lib/module/dot-grid-meter/types.js.map +1 -0
- package/lib/module/index.js +6 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/index.web.js +6 -0
- package/lib/module/index.web.js.map +1 -1
- package/lib/module/profile-card/ProfileCard.js +306 -0
- package/lib/module/profile-card/ProfileCard.js.map +1 -0
- package/lib/module/profile-card/index.js +4 -0
- package/lib/module/profile-card/index.js.map +1 -0
- package/lib/module/profile-card/types.js +4 -0
- package/lib/module/profile-card/types.js.map +1 -0
- package/lib/module/stat-bar/StatBar.js +208 -0
- package/lib/module/stat-bar/StatBar.js.map +1 -0
- package/lib/module/stat-bar/index.js +4 -0
- package/lib/module/stat-bar/index.js.map +1 -0
- package/lib/module/stat-bar/types.js +4 -0
- package/lib/module/stat-bar/types.js.map +1 -0
- package/lib/typescript/commonjs/activity-heatmap/ActivityHeatmap.d.ts +4 -0
- package/lib/typescript/commonjs/activity-heatmap/ActivityHeatmap.d.ts.map +1 -0
- package/lib/typescript/commonjs/activity-heatmap/bucketByDay.d.ts +9 -0
- package/lib/typescript/commonjs/activity-heatmap/bucketByDay.d.ts.map +1 -0
- package/lib/typescript/commonjs/activity-heatmap/index.d.ts +4 -0
- package/lib/typescript/commonjs/activity-heatmap/index.d.ts.map +1 -0
- package/lib/typescript/commonjs/activity-heatmap/types.d.ts +73 -0
- package/lib/typescript/commonjs/activity-heatmap/types.d.ts.map +1 -0
- package/lib/typescript/commonjs/avatar-group/AvatarGroupBase.d.ts.map +1 -1
- package/lib/typescript/commonjs/avatar-group/types.d.ts +23 -1
- package/lib/typescript/commonjs/avatar-group/types.d.ts.map +1 -1
- package/lib/typescript/commonjs/composition-bar/CompositionBar.d.ts +4 -0
- package/lib/typescript/commonjs/composition-bar/CompositionBar.d.ts.map +1 -0
- package/lib/typescript/commonjs/composition-bar/index.d.ts +3 -0
- package/lib/typescript/commonjs/composition-bar/index.d.ts.map +1 -0
- package/lib/typescript/commonjs/composition-bar/types.d.ts +29 -0
- package/lib/typescript/commonjs/composition-bar/types.d.ts.map +1 -0
- package/lib/typescript/commonjs/dot-grid-meter/DotGridMeter.d.ts +4 -0
- package/lib/typescript/commonjs/dot-grid-meter/DotGridMeter.d.ts.map +1 -0
- package/lib/typescript/commonjs/dot-grid-meter/index.d.ts +3 -0
- package/lib/typescript/commonjs/dot-grid-meter/index.d.ts.map +1 -0
- package/lib/typescript/commonjs/dot-grid-meter/types.d.ts +36 -0
- package/lib/typescript/commonjs/dot-grid-meter/types.d.ts.map +1 -0
- package/lib/typescript/commonjs/index.d.ts +10 -0
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/index.web.d.ts +10 -0
- package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/profile-card/ProfileCard.d.ts +4 -0
- package/lib/typescript/commonjs/profile-card/ProfileCard.d.ts.map +1 -0
- package/lib/typescript/commonjs/profile-card/index.d.ts +3 -0
- package/lib/typescript/commonjs/profile-card/index.d.ts.map +1 -0
- package/lib/typescript/commonjs/profile-card/types.d.ts +86 -0
- package/lib/typescript/commonjs/profile-card/types.d.ts.map +1 -0
- package/lib/typescript/commonjs/stat-bar/StatBar.d.ts +4 -0
- package/lib/typescript/commonjs/stat-bar/StatBar.d.ts.map +1 -0
- package/lib/typescript/commonjs/stat-bar/index.d.ts +3 -0
- package/lib/typescript/commonjs/stat-bar/index.d.ts.map +1 -0
- package/lib/typescript/commonjs/stat-bar/types.d.ts +62 -0
- package/lib/typescript/commonjs/stat-bar/types.d.ts.map +1 -0
- package/lib/typescript/module/activity-heatmap/ActivityHeatmap.d.ts +4 -0
- package/lib/typescript/module/activity-heatmap/ActivityHeatmap.d.ts.map +1 -0
- package/lib/typescript/module/activity-heatmap/bucketByDay.d.ts +9 -0
- package/lib/typescript/module/activity-heatmap/bucketByDay.d.ts.map +1 -0
- package/lib/typescript/module/activity-heatmap/index.d.ts +4 -0
- package/lib/typescript/module/activity-heatmap/index.d.ts.map +1 -0
- package/lib/typescript/module/activity-heatmap/types.d.ts +73 -0
- package/lib/typescript/module/activity-heatmap/types.d.ts.map +1 -0
- package/lib/typescript/module/avatar-group/AvatarGroupBase.d.ts.map +1 -1
- package/lib/typescript/module/avatar-group/types.d.ts +23 -1
- package/lib/typescript/module/avatar-group/types.d.ts.map +1 -1
- package/lib/typescript/module/composition-bar/CompositionBar.d.ts +4 -0
- package/lib/typescript/module/composition-bar/CompositionBar.d.ts.map +1 -0
- package/lib/typescript/module/composition-bar/index.d.ts +3 -0
- package/lib/typescript/module/composition-bar/index.d.ts.map +1 -0
- package/lib/typescript/module/composition-bar/types.d.ts +29 -0
- package/lib/typescript/module/composition-bar/types.d.ts.map +1 -0
- package/lib/typescript/module/dot-grid-meter/DotGridMeter.d.ts +4 -0
- package/lib/typescript/module/dot-grid-meter/DotGridMeter.d.ts.map +1 -0
- package/lib/typescript/module/dot-grid-meter/index.d.ts +3 -0
- package/lib/typescript/module/dot-grid-meter/index.d.ts.map +1 -0
- package/lib/typescript/module/dot-grid-meter/types.d.ts +36 -0
- package/lib/typescript/module/dot-grid-meter/types.d.ts.map +1 -0
- package/lib/typescript/module/index.d.ts +10 -0
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/index.web.d.ts +10 -0
- package/lib/typescript/module/index.web.d.ts.map +1 -1
- package/lib/typescript/module/profile-card/ProfileCard.d.ts +4 -0
- package/lib/typescript/module/profile-card/ProfileCard.d.ts.map +1 -0
- package/lib/typescript/module/profile-card/index.d.ts +3 -0
- package/lib/typescript/module/profile-card/index.d.ts.map +1 -0
- package/lib/typescript/module/profile-card/types.d.ts +86 -0
- package/lib/typescript/module/profile-card/types.d.ts.map +1 -0
- package/lib/typescript/module/stat-bar/StatBar.d.ts +4 -0
- package/lib/typescript/module/stat-bar/StatBar.d.ts.map +1 -0
- package/lib/typescript/module/stat-bar/index.d.ts +3 -0
- package/lib/typescript/module/stat-bar/index.d.ts.map +1 -0
- package/lib/typescript/module/stat-bar/types.d.ts +62 -0
- package/lib/typescript/module/stat-bar/types.d.ts.map +1 -0
- package/package.json +56 -1
- package/src/__tests__/StatWidgets.test.tsx +184 -0
- package/src/__tests__/public-api-contract.test.ts +15 -0
- package/src/activity-heatmap/ActivityHeatmap.stories.tsx +108 -0
- package/src/activity-heatmap/ActivityHeatmap.tsx +289 -0
- package/src/activity-heatmap/bucketByDay.ts +49 -0
- package/src/activity-heatmap/index.ts +3 -0
- package/src/activity-heatmap/types.ts +74 -0
- package/src/avatar-group/AvatarGroup.stories.tsx +14 -0
- package/src/avatar-group/AvatarGroupBase.tsx +48 -20
- package/src/avatar-group/types.ts +23 -1
- package/src/composition-bar/CompositionBar.stories.tsx +70 -0
- package/src/composition-bar/CompositionBar.tsx +161 -0
- package/src/composition-bar/index.ts +2 -0
- package/src/composition-bar/types.ts +30 -0
- package/src/dot-grid-meter/DotGridMeter.stories.tsx +47 -0
- package/src/dot-grid-meter/DotGridMeter.tsx +71 -0
- package/src/dot-grid-meter/index.ts +2 -0
- package/src/dot-grid-meter/types.ts +36 -0
- package/src/index.ts +24 -0
- package/src/index.web.ts +24 -0
- package/src/profile-card/ProfileCard.stories.tsx +210 -0
- package/src/profile-card/ProfileCard.tsx +269 -0
- package/src/profile-card/index.ts +9 -0
- package/src/profile-card/types.ts +86 -0
- package/src/stat-bar/StatBar.stories.tsx +61 -0
- package/src/stat-bar/StatBar.tsx +126 -0
- package/src/stat-bar/index.ts +7 -0
- package/src/stat-bar/types.ts +65 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Text } from 'react-native';
|
|
3
|
+
import { render, fireEvent } from '@testing-library/react-native';
|
|
4
|
+
|
|
5
|
+
import { BloomThemeProvider } from '../theme/BloomThemeProvider';
|
|
6
|
+
import {
|
|
7
|
+
CompositionBar,
|
|
8
|
+
DotGridMeter,
|
|
9
|
+
StatBar,
|
|
10
|
+
ActivityHeatmap,
|
|
11
|
+
ProfileCard,
|
|
12
|
+
bucketByDay,
|
|
13
|
+
} from '../index';
|
|
14
|
+
import type { CompositionCategory, AvatarGroupItem } from '../index';
|
|
15
|
+
|
|
16
|
+
function renderWithTheme(ui: React.ReactElement) {
|
|
17
|
+
return render(
|
|
18
|
+
<BloomThemeProvider mode="dark" colorPreset="teal">
|
|
19
|
+
{ui}
|
|
20
|
+
</BloomThemeProvider>,
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const CATEGORIES: CompositionCategory[] = [
|
|
25
|
+
{ key: 'content', name: 'Content', amount: 300, color: '#6366F1' },
|
|
26
|
+
{ key: 'social', name: 'Social', amount: 100, color: '#10B981' },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const TOP: AvatarGroupItem[] = [
|
|
30
|
+
{ id: 'a', uri: 'https://example.com/a.png' },
|
|
31
|
+
{ id: 'b', uri: 'https://example.com/b.png' },
|
|
32
|
+
{ id: 'c', uri: 'https://example.com/c.png' },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
describe('CompositionBar', () => {
|
|
36
|
+
it('renders the hint when nothing is selected and fires onSelect on press', () => {
|
|
37
|
+
const onSelect = jest.fn();
|
|
38
|
+
const { getByText, getByLabelText } = renderWithTheme(
|
|
39
|
+
<CompositionBar
|
|
40
|
+
categories={CATEGORIES}
|
|
41
|
+
selectedKey={null}
|
|
42
|
+
onSelect={onSelect}
|
|
43
|
+
hintLabel="Tap a segment"
|
|
44
|
+
/>,
|
|
45
|
+
);
|
|
46
|
+
expect(getByText('Tap a segment')).toBeTruthy();
|
|
47
|
+
fireEvent.press(getByLabelText('Content'));
|
|
48
|
+
expect(onSelect).toHaveBeenCalledWith('content');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('shows the formatted readout for the selected segment', () => {
|
|
52
|
+
const { getByText } = renderWithTheme(
|
|
53
|
+
<CompositionBar
|
|
54
|
+
categories={CATEGORIES}
|
|
55
|
+
selectedKey="content"
|
|
56
|
+
onSelect={() => {}}
|
|
57
|
+
hintLabel="Tap a segment"
|
|
58
|
+
formatReadout={(points, percent) => `${points} @ ${percent}`}
|
|
59
|
+
/>,
|
|
60
|
+
);
|
|
61
|
+
// Content is 300 of 400 → 75%.
|
|
62
|
+
expect(getByText('300 @ 75')).toBeTruthy();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('DotGridMeter', () => {
|
|
67
|
+
it('renders with a progressbar a11y value', () => {
|
|
68
|
+
const { getByTestId } = renderWithTheme(
|
|
69
|
+
<DotGridMeter filled={7} total={12} testID="dots" />,
|
|
70
|
+
);
|
|
71
|
+
const node = getByTestId('dots');
|
|
72
|
+
expect(node.props.accessibilityValue).toEqual({ min: 0, max: 12, now: 7 });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('clamps filled to total', () => {
|
|
76
|
+
const { getByTestId } = renderWithTheme(
|
|
77
|
+
<DotGridMeter filled={99} total={5} testID="dots" />,
|
|
78
|
+
);
|
|
79
|
+
expect(getByTestId('dots').props.accessibilityValue.now).toBe(5);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('StatBar', () => {
|
|
84
|
+
it('renders progress with min/max labels', () => {
|
|
85
|
+
const { getByText } = renderWithTheme(
|
|
86
|
+
<StatBar variant="progress" label="TX" value={100} max={200} minLabel="0" maxLabel="200" />,
|
|
87
|
+
);
|
|
88
|
+
expect(getByText('TX')).toBeTruthy();
|
|
89
|
+
expect(getByText('200')).toBeTruthy();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('renders split with a rounded percent and both values', () => {
|
|
93
|
+
const { getByText } = renderWithTheme(
|
|
94
|
+
<StatBar
|
|
95
|
+
variant="split"
|
|
96
|
+
label="Net"
|
|
97
|
+
percent={62.4}
|
|
98
|
+
leftValue="+10"
|
|
99
|
+
rightValue="-4"
|
|
100
|
+
/>,
|
|
101
|
+
);
|
|
102
|
+
expect(getByText('62%')).toBeTruthy();
|
|
103
|
+
expect(getByText('+10')).toBeTruthy();
|
|
104
|
+
expect(getByText('-4')).toBeTruthy();
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('ActivityHeatmap', () => {
|
|
109
|
+
it('renders without crashing and shows a month label', () => {
|
|
110
|
+
const data = [
|
|
111
|
+
{ date: '2026-06-01', count: 2 },
|
|
112
|
+
{ date: '2026-06-15', count: 9 },
|
|
113
|
+
];
|
|
114
|
+
const { getByTestId } = renderWithTheme(
|
|
115
|
+
<ActivityHeatmap data={data} endDate="2026-06-30" numDays={60} testID="heat" />,
|
|
116
|
+
);
|
|
117
|
+
expect(getByTestId('heat')).toBeTruthy();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('renders an empty container when there is no data and no endDate', () => {
|
|
121
|
+
const { getByTestId } = renderWithTheme(
|
|
122
|
+
<ActivityHeatmap data={[]} testID="heat-empty" />,
|
|
123
|
+
);
|
|
124
|
+
expect(getByTestId('heat-empty')).toBeTruthy();
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('bucketByDay', () => {
|
|
129
|
+
it('groups items by UTC day and sorts ascending', () => {
|
|
130
|
+
const items = [
|
|
131
|
+
{ at: '2026-06-02T10:00:00Z' },
|
|
132
|
+
{ at: '2026-06-01T00:00:00Z' },
|
|
133
|
+
{ at: '2026-06-02T23:59:00Z' },
|
|
134
|
+
];
|
|
135
|
+
expect(bucketByDay(items, (i) => i.at)).toEqual([
|
|
136
|
+
{ date: '2026-06-01', count: 1 },
|
|
137
|
+
{ date: '2026-06-02', count: 2 },
|
|
138
|
+
]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('skips unparseable dates', () => {
|
|
142
|
+
const items = [{ at: 'not-a-date' }, { at: '2026-06-01' }];
|
|
143
|
+
expect(bucketByDay(items, (i) => i.at)).toEqual([{ date: '2026-06-01', count: 1 }]);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe('ProfileCard', () => {
|
|
148
|
+
it('renders headline, subtitle, metric label and footer label; fires onPress', () => {
|
|
149
|
+
const onPress = jest.fn();
|
|
150
|
+
const { getByText, getByTestId } = renderWithTheme(
|
|
151
|
+
<ProfileCard
|
|
152
|
+
testID="card"
|
|
153
|
+
variant="wallet"
|
|
154
|
+
avatar={{ source: 'https://example.com/a.png', name: 'Wallet' }}
|
|
155
|
+
value="$167,395"
|
|
156
|
+
subtitle="*5bF5"
|
|
157
|
+
headlineIcon={<Text>ICON</Text>}
|
|
158
|
+
metric={{ kind: 'dots', label: 'Token diversity', filled: 3, total: 10 }}
|
|
159
|
+
footer={{ label: 'Top tokens', items: TOP }}
|
|
160
|
+
onPress={onPress}
|
|
161
|
+
/>,
|
|
162
|
+
);
|
|
163
|
+
expect(getByText('$167,395')).toBeTruthy();
|
|
164
|
+
expect(getByText('*5bF5')).toBeTruthy();
|
|
165
|
+
expect(getByText('Token diversity')).toBeTruthy();
|
|
166
|
+
expect(getByText('Top tokens')).toBeTruthy();
|
|
167
|
+
fireEvent.press(getByTestId('card'));
|
|
168
|
+
expect(onPress).toHaveBeenCalledTimes(1);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('renders the wide layout with a progress metric', () => {
|
|
172
|
+
const { getByText } = renderWithTheme(
|
|
173
|
+
<ProfileCard
|
|
174
|
+
layout="wide"
|
|
175
|
+
avatar={{ source: null, name: 'Ada' }}
|
|
176
|
+
value="12,480"
|
|
177
|
+
subtitle="@ada"
|
|
178
|
+
metric={{ kind: 'progress', label: 'Weekly goal', value: 5, max: 7 }}
|
|
179
|
+
/>,
|
|
180
|
+
);
|
|
181
|
+
expect(getByText('12,480')).toBeTruthy();
|
|
182
|
+
expect(getByText('Weekly goal')).toBeTruthy();
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -19,6 +19,11 @@ const REPRESENTATIVE_PARTS = [
|
|
|
19
19
|
'AdmonitionIcon', 'PromptInputTextarea',
|
|
20
20
|
] as const;
|
|
21
21
|
|
|
22
|
+
// Stat / profile widget components added to the public surface.
|
|
23
|
+
const NEW_FLAT_COMPONENTS = [
|
|
24
|
+
'CompositionBar', 'DotGridMeter', 'StatBar', 'ActivityHeatmap', 'ProfileCard',
|
|
25
|
+
] as const;
|
|
26
|
+
|
|
22
27
|
/**
|
|
23
28
|
* A flat React component export is either a plain function component or a wrapped
|
|
24
29
|
* component object (React.memo / forwardRef), which carries a `$$typeof` symbol.
|
|
@@ -51,4 +56,14 @@ describe('public API contract', () => {
|
|
|
51
56
|
expect(isReactComponent(api[name])).toBe(true);
|
|
52
57
|
}
|
|
53
58
|
});
|
|
59
|
+
|
|
60
|
+
it('exposes the stat/profile widget components as flat components', () => {
|
|
61
|
+
for (const name of NEW_FLAT_COMPONENTS) {
|
|
62
|
+
expect(isReactComponent(api[name])).toBe(true);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('exposes bucketByDay as a helper function', () => {
|
|
67
|
+
expect(typeof api.bucketByDay).toBe('function');
|
|
68
|
+
});
|
|
54
69
|
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View, ScrollView } from 'react-native';
|
|
3
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
4
|
+
|
|
5
|
+
import { ActivityHeatmap, bucketByDay } from './index';
|
|
6
|
+
import type { ActivityHeatmapDay } from './index';
|
|
7
|
+
import { BloomThemeProvider } from '../theme';
|
|
8
|
+
|
|
9
|
+
const END_DATE = '2026-06-30';
|
|
10
|
+
|
|
11
|
+
/** Deterministic pseudo-random sample so stories render stably. */
|
|
12
|
+
function sampleData(days: number, endKey: string): ActivityHeatmapDay[] {
|
|
13
|
+
const [y, m, d] = endKey.split('-').map(Number);
|
|
14
|
+
const end = Date.UTC(y ?? 2026, (m ?? 1) - 1, d ?? 1);
|
|
15
|
+
const out: ActivityHeatmapDay[] = [];
|
|
16
|
+
let seed = 42;
|
|
17
|
+
for (let i = 0; i < days; i++) {
|
|
18
|
+
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
|
|
19
|
+
const roll = seed % 100;
|
|
20
|
+
const count = roll < 45 ? 0 : roll < 70 ? 1 : roll < 85 ? 3 : roll < 95 ? 7 : 14;
|
|
21
|
+
const ts = end - (days - 1 - i) * 86_400_000;
|
|
22
|
+
const dt = new Date(ts);
|
|
23
|
+
const key = `${dt.getUTCFullYear()}-${String(dt.getUTCMonth() + 1).padStart(2, '0')}-${String(
|
|
24
|
+
dt.getUTCDate(),
|
|
25
|
+
).padStart(2, '0')}`;
|
|
26
|
+
if (count > 0) out.push({ date: key, count });
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const DATA = sampleData(364, END_DATE);
|
|
32
|
+
|
|
33
|
+
const meta: Meta<typeof ActivityHeatmap> = {
|
|
34
|
+
title: 'Data Display/ActivityHeatmap',
|
|
35
|
+
component: ActivityHeatmap,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export default meta;
|
|
39
|
+
|
|
40
|
+
type Story = StoryObj<typeof ActivityHeatmap>;
|
|
41
|
+
|
|
42
|
+
export const Light: Story = {
|
|
43
|
+
render: () => (
|
|
44
|
+
<BloomThemeProvider mode="light">
|
|
45
|
+
<View style={{ padding: 24 }}>
|
|
46
|
+
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
|
47
|
+
<ActivityHeatmap
|
|
48
|
+
data={DATA}
|
|
49
|
+
endDate={END_DATE}
|
|
50
|
+
weekdayLabels={['', 'Mon', '', 'Wed', '', 'Fri', '']}
|
|
51
|
+
/>
|
|
52
|
+
</ScrollView>
|
|
53
|
+
</View>
|
|
54
|
+
</BloomThemeProvider>
|
|
55
|
+
),
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const Dark: Story = {
|
|
59
|
+
render: () => (
|
|
60
|
+
<BloomThemeProvider mode="dark">
|
|
61
|
+
<View style={{ padding: 24, backgroundColor: '#000' }}>
|
|
62
|
+
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
|
63
|
+
<ActivityHeatmap
|
|
64
|
+
data={DATA}
|
|
65
|
+
endDate={END_DATE}
|
|
66
|
+
weekdayLabels={['', 'Mon', '', 'Wed', '', 'Fri', '']}
|
|
67
|
+
/>
|
|
68
|
+
</ScrollView>
|
|
69
|
+
</View>
|
|
70
|
+
</BloomThemeProvider>
|
|
71
|
+
),
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const CustomScale: Story = {
|
|
75
|
+
render: () => (
|
|
76
|
+
<BloomThemeProvider mode="dark">
|
|
77
|
+
<View style={{ padding: 24, backgroundColor: '#000' }}>
|
|
78
|
+
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
|
79
|
+
<ActivityHeatmap
|
|
80
|
+
data={DATA}
|
|
81
|
+
endDate={END_DATE}
|
|
82
|
+
numDays={140}
|
|
83
|
+
cellSize={14}
|
|
84
|
+
gap={4}
|
|
85
|
+
colorScale={['#0E3B2E', '#146B4C', '#1FA971', '#38D896', '#7DF3BD']}
|
|
86
|
+
/>
|
|
87
|
+
</ScrollView>
|
|
88
|
+
</View>
|
|
89
|
+
</BloomThemeProvider>
|
|
90
|
+
),
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/** Demonstrates the `bucketByDay` helper grouping raw timestamps. */
|
|
94
|
+
export const FromRawEvents: Story = {
|
|
95
|
+
render: () => {
|
|
96
|
+
const events = DATA.flatMap((d) => Array.from({ length: d.count }, () => ({ at: d.date })));
|
|
97
|
+
const bucketed = bucketByDay(events, (e) => e.at);
|
|
98
|
+
return (
|
|
99
|
+
<BloomThemeProvider mode="light">
|
|
100
|
+
<View style={{ padding: 24 }}>
|
|
101
|
+
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
|
102
|
+
<ActivityHeatmap data={bucketed} endDate={END_DATE} />
|
|
103
|
+
</ScrollView>
|
|
104
|
+
</View>
|
|
105
|
+
</BloomThemeProvider>
|
|
106
|
+
);
|
|
107
|
+
},
|
|
108
|
+
};
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import React, { memo, useMemo } from 'react';
|
|
2
|
+
import { View, Pressable, StyleSheet } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { useTheme } from '../theme/use-theme';
|
|
5
|
+
import { Text } from '../typography';
|
|
6
|
+
import type { ActivityHeatmapDay, ActivityHeatmapProps } from './types';
|
|
7
|
+
|
|
8
|
+
const DAY_MS = 86_400_000;
|
|
9
|
+
const DEFAULT_NUM_DAYS = 364;
|
|
10
|
+
const DEFAULT_LEVELS = [1, 3, 6, 10];
|
|
11
|
+
const DEFAULT_MONTHS = [
|
|
12
|
+
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
|
13
|
+
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
|
|
14
|
+
];
|
|
15
|
+
/** Opacity steps (low→high) used to derive the default color scale from primary. */
|
|
16
|
+
const DEFAULT_ALPHAS = [0.16, 0.32, 0.52, 0.75, 1];
|
|
17
|
+
const LABEL_FONT_SIZE = 10;
|
|
18
|
+
const MONTH_ROW_HEIGHT = 16;
|
|
19
|
+
const WEEKDAY_COL_WIDTH = 26;
|
|
20
|
+
|
|
21
|
+
const DATE_KEY_RE = /^(\d{4})-(\d{2})-(\d{2})/;
|
|
22
|
+
|
|
23
|
+
function parseDateKey(key: string): number | null {
|
|
24
|
+
const match = DATE_KEY_RE.exec(key);
|
|
25
|
+
if (match) {
|
|
26
|
+
const year = Number(match[1]);
|
|
27
|
+
const month = Number(match[2]);
|
|
28
|
+
const day = Number(match[3]);
|
|
29
|
+
const ts = Date.UTC(year, month - 1, day);
|
|
30
|
+
return Number.isNaN(ts) ? null : ts;
|
|
31
|
+
}
|
|
32
|
+
const parsed = Date.parse(key);
|
|
33
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function formatUtcKey(ts: number): string {
|
|
37
|
+
const d = new Date(ts);
|
|
38
|
+
const year = d.getUTCFullYear();
|
|
39
|
+
const month = String(d.getUTCMonth() + 1).padStart(2, '0');
|
|
40
|
+
const day = String(d.getUTCDate()).padStart(2, '0');
|
|
41
|
+
return `${year}-${month}-${day}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Count of ascending thresholds `count` meets — the color-scale index. */
|
|
45
|
+
function levelForCount(count: number, levels: number[]): number {
|
|
46
|
+
if (count <= 0) return 0;
|
|
47
|
+
let level = 0;
|
|
48
|
+
for (const threshold of levels) {
|
|
49
|
+
if (count >= threshold) level += 1;
|
|
50
|
+
else break;
|
|
51
|
+
}
|
|
52
|
+
return level;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function parseRgb(color: string): { r: number; g: number; b: number } | null {
|
|
56
|
+
const trimmed = color.trim();
|
|
57
|
+
if (trimmed.startsWith('#')) {
|
|
58
|
+
let hex = trimmed.slice(1);
|
|
59
|
+
if (hex.length === 3) hex = hex.split('').map((c) => c + c).join('');
|
|
60
|
+
if (hex.length !== 6) return null;
|
|
61
|
+
const r = parseInt(hex.slice(0, 2), 16);
|
|
62
|
+
const g = parseInt(hex.slice(2, 4), 16);
|
|
63
|
+
const b = parseInt(hex.slice(4, 6), 16);
|
|
64
|
+
if ([r, g, b].some((v) => Number.isNaN(v))) return null;
|
|
65
|
+
return { r, g, b };
|
|
66
|
+
}
|
|
67
|
+
const match = /rgba?\(([^)]+)\)/i.exec(trimmed);
|
|
68
|
+
if (!match || match[1] === undefined) return null;
|
|
69
|
+
const parts = match[1].split(/[\s,/]+/).filter(Boolean).map(Number);
|
|
70
|
+
if (parts.length < 3) return null;
|
|
71
|
+
const [r, g, b] = parts;
|
|
72
|
+
if (r === undefined || g === undefined || b === undefined) return null;
|
|
73
|
+
if ([r, g, b].some((v) => Number.isNaN(v))) return null;
|
|
74
|
+
return { r, g, b };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Compose `color` at `alpha`; returns the input unchanged if it can't parse. */
|
|
78
|
+
function withAlpha(color: string, alpha: number): string {
|
|
79
|
+
const rgb = parseRgb(color);
|
|
80
|
+
if (!rgb) return color;
|
|
81
|
+
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface Cell {
|
|
85
|
+
key: string;
|
|
86
|
+
count: number;
|
|
87
|
+
color: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* A GitHub-style contribution graph: columns of weeks × 7 weekday rows of
|
|
92
|
+
* rounded square cells, each colored by its day's activity level. Pure `<View>`
|
|
93
|
+
* composition (no SVG), rendering identically on web and native. The component
|
|
94
|
+
* lays out at its natural width — wrap it in a horizontal `ScrollView` for
|
|
95
|
+
* narrow viewports. All date math runs in UTC and the system clock is never
|
|
96
|
+
* read; anchor the grid with `endDate`.
|
|
97
|
+
*/
|
|
98
|
+
const ActivityHeatmapComponent: React.FC<ActivityHeatmapProps> = ({
|
|
99
|
+
data,
|
|
100
|
+
numDays = DEFAULT_NUM_DAYS,
|
|
101
|
+
endDate,
|
|
102
|
+
levels = DEFAULT_LEVELS,
|
|
103
|
+
colorScale,
|
|
104
|
+
emptyColor,
|
|
105
|
+
cellSize = 12,
|
|
106
|
+
gap = 3,
|
|
107
|
+
weekStartsOn = 0,
|
|
108
|
+
monthLabels,
|
|
109
|
+
weekdayLabels,
|
|
110
|
+
onPressDay,
|
|
111
|
+
style,
|
|
112
|
+
testID,
|
|
113
|
+
}) => {
|
|
114
|
+
const { colors } = useTheme();
|
|
115
|
+
const empty = emptyColor ?? colors.backgroundSecondary;
|
|
116
|
+
const scale = useMemo(
|
|
117
|
+
() => colorScale ?? DEFAULT_ALPHAS.map((alpha) => withAlpha(colors.primary, alpha)),
|
|
118
|
+
[colorScale, colors.primary],
|
|
119
|
+
);
|
|
120
|
+
const sortedLevels = useMemo(() => [...levels].sort((a, b) => a - b), [levels]);
|
|
121
|
+
const months = monthLabels ?? DEFAULT_MONTHS;
|
|
122
|
+
|
|
123
|
+
const grid = useMemo(() => {
|
|
124
|
+
// Sum counts per UTC day.
|
|
125
|
+
const countByKey = new Map<string, number>();
|
|
126
|
+
let latest: number | null = null;
|
|
127
|
+
for (const entry of data) {
|
|
128
|
+
const ts = parseDateKey(entry.date);
|
|
129
|
+
if (ts === null) continue;
|
|
130
|
+
const key = formatUtcKey(ts);
|
|
131
|
+
countByKey.set(key, (countByKey.get(key) ?? 0) + entry.count);
|
|
132
|
+
if (latest === null || ts > latest) latest = ts;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const providedEnd = endDate ? parseDateKey(endDate) : null;
|
|
136
|
+
const endTs = providedEnd ?? latest;
|
|
137
|
+
if (endTs === null) return null;
|
|
138
|
+
|
|
139
|
+
const days = Math.max(1, Math.floor(numDays));
|
|
140
|
+
const rawStart = endTs - (days - 1) * DAY_MS;
|
|
141
|
+
// Align the first column to the start-of-week boundary.
|
|
142
|
+
const back = (new Date(rawStart).getUTCDay() - weekStartsOn + 7) % 7;
|
|
143
|
+
const gridStart = rawStart - back * DAY_MS;
|
|
144
|
+
const columnCount = Math.ceil((Math.floor((endTs - gridStart) / DAY_MS) + 1) / 7);
|
|
145
|
+
|
|
146
|
+
const maxColor = scale.length - 1;
|
|
147
|
+
const columns: Array<Array<Cell | null>> = [];
|
|
148
|
+
for (let c = 0; c < columnCount; c++) {
|
|
149
|
+
const column: Array<Cell | null> = [];
|
|
150
|
+
for (let r = 0; r < 7; r++) {
|
|
151
|
+
const ts = gridStart + (c * 7 + r) * DAY_MS;
|
|
152
|
+
if (ts > endTs) {
|
|
153
|
+
column.push(null);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const key = formatUtcKey(ts);
|
|
157
|
+
const count = countByKey.get(key) ?? 0;
|
|
158
|
+
const level = Math.min(levelForCount(count, sortedLevels), maxColor);
|
|
159
|
+
const color = count <= 0 ? empty : scale[level] ?? empty;
|
|
160
|
+
column.push({ key, count, color });
|
|
161
|
+
}
|
|
162
|
+
columns.push(column);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Month labels: first column whose leading day starts a new month.
|
|
166
|
+
const monthMarks: Array<{ x: number; label: string }> = [];
|
|
167
|
+
let lastMonth = -1;
|
|
168
|
+
for (let c = 0; c < columnCount; c++) {
|
|
169
|
+
const firstTs = gridStart + c * 7 * DAY_MS;
|
|
170
|
+
const month = new Date(firstTs).getUTCMonth();
|
|
171
|
+
if (month !== lastMonth) {
|
|
172
|
+
monthMarks.push({ x: c * (cellSize + gap), label: months[month] ?? DEFAULT_MONTHS[month] ?? '' });
|
|
173
|
+
lastMonth = month;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return { columns, monthMarks };
|
|
178
|
+
}, [data, endDate, numDays, weekStartsOn, sortedLevels, scale, empty, cellSize, gap, months]);
|
|
179
|
+
|
|
180
|
+
if (!grid) {
|
|
181
|
+
return <View style={style} testID={testID} />;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const { columns, monthMarks } = grid;
|
|
185
|
+
const hasWeekdayCol = Array.isArray(weekdayLabels);
|
|
186
|
+
const weekdayColWidth = hasWeekdayCol ? WEEKDAY_COL_WIDTH : 0;
|
|
187
|
+
const monthRowMarginLeft = hasWeekdayCol ? weekdayColWidth + gap : 0;
|
|
188
|
+
const cellRadius = Math.max(2, Math.round(cellSize * 0.25));
|
|
189
|
+
|
|
190
|
+
return (
|
|
191
|
+
<View style={style} testID={testID}>
|
|
192
|
+
<View style={[styles.monthRow, { marginLeft: monthRowMarginLeft }]}>
|
|
193
|
+
{monthMarks.map((mark, i) => (
|
|
194
|
+
<Text
|
|
195
|
+
key={`${mark.label}-${i}`}
|
|
196
|
+
numberOfLines={1}
|
|
197
|
+
style={[styles.label, { left: mark.x, color: colors.textTertiary }]}
|
|
198
|
+
>
|
|
199
|
+
{mark.label}
|
|
200
|
+
</Text>
|
|
201
|
+
))}
|
|
202
|
+
</View>
|
|
203
|
+
|
|
204
|
+
<View style={styles.body}>
|
|
205
|
+
{hasWeekdayCol && (
|
|
206
|
+
<View style={{ width: weekdayColWidth, marginRight: gap }}>
|
|
207
|
+
{Array.from({ length: 7 }, (_, r) => {
|
|
208
|
+
const dow = (weekStartsOn + r) % 7;
|
|
209
|
+
return (
|
|
210
|
+
<View
|
|
211
|
+
key={r}
|
|
212
|
+
style={{
|
|
213
|
+
height: cellSize,
|
|
214
|
+
marginBottom: r < 6 ? gap : 0,
|
|
215
|
+
justifyContent: 'center',
|
|
216
|
+
}}
|
|
217
|
+
>
|
|
218
|
+
<Text numberOfLines={1} style={[styles.weekdayLabel, { color: colors.textTertiary }]}>
|
|
219
|
+
{weekdayLabels[dow] ?? ''}
|
|
220
|
+
</Text>
|
|
221
|
+
</View>
|
|
222
|
+
);
|
|
223
|
+
})}
|
|
224
|
+
</View>
|
|
225
|
+
)}
|
|
226
|
+
|
|
227
|
+
<View style={styles.grid}>
|
|
228
|
+
{columns.map((column, c) => (
|
|
229
|
+
<View key={c} style={{ marginRight: c < columns.length - 1 ? gap : 0 }}>
|
|
230
|
+
{column.map((cell, r) => {
|
|
231
|
+
const cellStyle = {
|
|
232
|
+
width: cellSize,
|
|
233
|
+
height: cellSize,
|
|
234
|
+
borderRadius: cellRadius,
|
|
235
|
+
marginBottom: r < 6 ? gap : 0,
|
|
236
|
+
};
|
|
237
|
+
if (!cell) {
|
|
238
|
+
return <View key={r} style={cellStyle} />;
|
|
239
|
+
}
|
|
240
|
+
if (onPressDay) {
|
|
241
|
+
const day: ActivityHeatmapDay = { date: cell.key, count: cell.count };
|
|
242
|
+
return (
|
|
243
|
+
<Pressable
|
|
244
|
+
key={r}
|
|
245
|
+
onPress={() => onPressDay(day)}
|
|
246
|
+
accessibilityRole="button"
|
|
247
|
+
accessibilityLabel={`${cell.key}: ${cell.count}`}
|
|
248
|
+
style={[cellStyle, styles.cell, { backgroundColor: cell.color }]}
|
|
249
|
+
/>
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
return (
|
|
253
|
+
<View key={r} style={[cellStyle, styles.cell, { backgroundColor: cell.color }]} />
|
|
254
|
+
);
|
|
255
|
+
})}
|
|
256
|
+
</View>
|
|
257
|
+
))}
|
|
258
|
+
</View>
|
|
259
|
+
</View>
|
|
260
|
+
</View>
|
|
261
|
+
);
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const styles = StyleSheet.create({
|
|
265
|
+
monthRow: {
|
|
266
|
+
height: MONTH_ROW_HEIGHT,
|
|
267
|
+
position: 'relative',
|
|
268
|
+
},
|
|
269
|
+
label: {
|
|
270
|
+
position: 'absolute',
|
|
271
|
+
top: 0,
|
|
272
|
+
fontSize: LABEL_FONT_SIZE,
|
|
273
|
+
},
|
|
274
|
+
body: {
|
|
275
|
+
flexDirection: 'row',
|
|
276
|
+
},
|
|
277
|
+
grid: {
|
|
278
|
+
flexDirection: 'row',
|
|
279
|
+
},
|
|
280
|
+
cell: {
|
|
281
|
+
borderCurve: 'continuous',
|
|
282
|
+
},
|
|
283
|
+
weekdayLabel: {
|
|
284
|
+
fontSize: LABEL_FONT_SIZE,
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
export const ActivityHeatmap = memo(ActivityHeatmapComponent);
|
|
289
|
+
ActivityHeatmap.displayName = 'ActivityHeatmap';
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { ActivityHeatmapDay } from './types';
|
|
2
|
+
|
|
3
|
+
const DATE_KEY_RE = /^(\d{4})-(\d{2})-(\d{2})/;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Normalize a date input to a `YYYY-MM-DD` key in UTC, or `null` if it can't be
|
|
7
|
+
* parsed. Strings already prefixed with an ISO date are sliced directly (no
|
|
8
|
+
* timezone shift); everything else is parsed and formatted in UTC.
|
|
9
|
+
*/
|
|
10
|
+
function toDateKey(value: string | Date): string | null {
|
|
11
|
+
if (value instanceof Date) {
|
|
12
|
+
const ts = value.getTime();
|
|
13
|
+
if (Number.isNaN(ts)) return null;
|
|
14
|
+
return formatUtcKey(ts);
|
|
15
|
+
}
|
|
16
|
+
if (DATE_KEY_RE.test(value)) return value.slice(0, 10);
|
|
17
|
+
const parsed = Date.parse(value);
|
|
18
|
+
if (Number.isNaN(parsed)) return null;
|
|
19
|
+
return formatUtcKey(parsed);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function formatUtcKey(ts: number): string {
|
|
23
|
+
const d = new Date(ts);
|
|
24
|
+
const year = d.getUTCFullYear();
|
|
25
|
+
const month = String(d.getUTCMonth() + 1).padStart(2, '0');
|
|
26
|
+
const day = String(d.getUTCDate()).padStart(2, '0');
|
|
27
|
+
return `${year}-${month}-${day}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Group arbitrary items by their `YYYY-MM-DD` day and count how many fall on
|
|
32
|
+
* each day. Returns entries sorted ascending by date — ready to hand to
|
|
33
|
+
* {@link ActivityHeatmap}'s `data` prop. Items whose date can't be parsed are
|
|
34
|
+
* skipped.
|
|
35
|
+
*/
|
|
36
|
+
export function bucketByDay<T>(
|
|
37
|
+
items: T[],
|
|
38
|
+
getDate: (item: T) => string | Date,
|
|
39
|
+
): ActivityHeatmapDay[] {
|
|
40
|
+
const counts = new Map<string, number>();
|
|
41
|
+
for (const item of items) {
|
|
42
|
+
const key = toDateKey(getDate(item));
|
|
43
|
+
if (key === null) continue;
|
|
44
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
45
|
+
}
|
|
46
|
+
return [...counts.entries()]
|
|
47
|
+
.map(([date, count]) => ({ date, count }))
|
|
48
|
+
.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
|
|
49
|
+
}
|