@oxyhq/bloom 0.29.6 → 0.30.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/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 +42 -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 +301 -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 +210 -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 +42 -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 +296 -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 +205 -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 +15 -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 +15 -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 +39 -18
- package/src/avatar-group/types.ts +15 -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 +303 -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 +156 -0
- package/src/stat-bar/index.ts +7 -0
- package/src/stat-bar/types.ts +65 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { StyleProp, ViewStyle } from 'react-native';
|
|
2
|
+
|
|
3
|
+
/** A single day cell in the {@link ActivityHeatmap}. */
|
|
4
|
+
export interface ActivityHeatmapDay {
|
|
5
|
+
/** ISO `YYYY-MM-DD` date (parsed in UTC). */
|
|
6
|
+
date: string;
|
|
7
|
+
/** Activity count for that day. */
|
|
8
|
+
count: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ActivityHeatmapProps {
|
|
12
|
+
/** Per-day activity counts. Days not present are treated as count 0. */
|
|
13
|
+
data: ActivityHeatmapDay[];
|
|
14
|
+
/**
|
|
15
|
+
* Number of days to render, ending at `endDate`.
|
|
16
|
+
* @default 364
|
|
17
|
+
*/
|
|
18
|
+
numDays?: number;
|
|
19
|
+
/**
|
|
20
|
+
* Last (rightmost) day of the grid as `YYYY-MM-DD`. Defaults to the latest
|
|
21
|
+
* date present in `data`. The component never reads the system clock, so pass
|
|
22
|
+
* `endDate` explicitly to anchor the grid to "today".
|
|
23
|
+
*/
|
|
24
|
+
endDate?: string;
|
|
25
|
+
/**
|
|
26
|
+
* Ascending count thresholds partitioning positive counts into color levels.
|
|
27
|
+
* With N thresholds a positive count maps to `colorScale[number of thresholds
|
|
28
|
+
* met]` (clamped to the scale). Count 0 always uses `emptyColor`.
|
|
29
|
+
* @default [1, 3, 6, 10]
|
|
30
|
+
*/
|
|
31
|
+
levels?: number[];
|
|
32
|
+
/**
|
|
33
|
+
* Colors low→high. Index 0 is the faintest activity shade, the last index the
|
|
34
|
+
* strongest. Defaults to five opacity steps of `theme.colors.primary`.
|
|
35
|
+
*/
|
|
36
|
+
colorScale?: string[];
|
|
37
|
+
/**
|
|
38
|
+
* Color for zero-activity days.
|
|
39
|
+
* @default theme.colors.backgroundSecondary
|
|
40
|
+
*/
|
|
41
|
+
emptyColor?: string;
|
|
42
|
+
/**
|
|
43
|
+
* Side length of each day cell in px.
|
|
44
|
+
* @default 12
|
|
45
|
+
*/
|
|
46
|
+
cellSize?: number;
|
|
47
|
+
/**
|
|
48
|
+
* Gap between cells in px (both axes).
|
|
49
|
+
* @default 3
|
|
50
|
+
*/
|
|
51
|
+
gap?: number;
|
|
52
|
+
/**
|
|
53
|
+
* First day of the week: 0 = Sunday, 1 = Monday. Controls both the row order
|
|
54
|
+
* and how the leading partial week aligns.
|
|
55
|
+
* @default 0
|
|
56
|
+
*/
|
|
57
|
+
weekStartsOn?: 0 | 1;
|
|
58
|
+
/**
|
|
59
|
+
* 12 short month names indexed by month (0 = January), for the month-label
|
|
60
|
+
* row. Pass localized names; defaults to English abbreviations.
|
|
61
|
+
*/
|
|
62
|
+
monthLabels?: string[];
|
|
63
|
+
/**
|
|
64
|
+
* 7 short weekday names indexed by day of week (0 = Sunday). When provided, a
|
|
65
|
+
* weekday-label column is rendered to the left of the grid. Omit for no
|
|
66
|
+
* weekday column. Pass empty strings for rows you want left blank.
|
|
67
|
+
*/
|
|
68
|
+
weekdayLabels?: string[];
|
|
69
|
+
/** Called with the pressed day. When set, each populated cell is pressable. */
|
|
70
|
+
onPressDay?: (day: ActivityHeatmapDay) => void;
|
|
71
|
+
/** Container style override. */
|
|
72
|
+
style?: StyleProp<ViewStyle>;
|
|
73
|
+
testID?: string;
|
|
74
|
+
}
|
|
@@ -133,3 +133,17 @@ export const Sizes: Story = {
|
|
|
133
133
|
</View>
|
|
134
134
|
),
|
|
135
135
|
};
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Row layout: avatars placed adjacent with a positive gap and no separator
|
|
139
|
+
* ring — the "top tokens" / adjacent-icons strip. Still collapses to `+N`.
|
|
140
|
+
*/
|
|
141
|
+
export const RowLayout: Story = {
|
|
142
|
+
render: () => (
|
|
143
|
+
<View style={{ gap: 16 }}>
|
|
144
|
+
<AvatarGroup layout="row" items={WITH_PHOTOS} size={28} max={4} />
|
|
145
|
+
<AvatarGroup layout="row" items={WITH_PHOTOS} size={36} max={5} spacing={10} />
|
|
146
|
+
</View>
|
|
147
|
+
),
|
|
148
|
+
name: 'Row layout',
|
|
149
|
+
};
|