dsh-plugin-panel 0.1.0 → 0.1.2

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/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # 插件管理 · dsh-plugin-panel
2
2
 
3
- 给 DeepSeek Harness 右侧栏用的插件管理器。装上以后右侧栏会多一个「插件管理」标签,把已安装的插件列出来,并且告诉你每个插件到底从哪里进。
3
+ DSH 官方插件管理翻不动(还没修),所以自己做了一个。欢迎使用。
4
+
5
+ 这是个给 DeepSeek Harness 右侧栏用的插件管理器。装上以后右侧栏会多一个「插件管理」标签,把已安装的插件列出来,并且告诉你每个插件到底从哪里进。
4
6
 
5
7
  ![插件管理面板](docs/screenshot.png)
6
8
 
@@ -24,6 +26,12 @@
24
26
  - **打开所在位置**:在访达里定位到这个插件的实际目录。
25
27
  - **启停**:不必回设置页,详情里直接停用或启用。
26
28
 
29
+ ## 市场趋势
30
+
31
+ 面板里有一个「市场」视图,显示 `dsh-plugin-shop` 目录每天收录了多少插件,以及被过滤掉多少。
32
+
33
+ 数据来自该目录每天发布的 npm 快照(每个版本就是当天的一份记录),首次打开会回溯历史,之后每天增量补齐,缓存 6 小时。它统计的是这个目录收录的数,不是 npm 全站的包数。
34
+
27
35
  ## 安装
28
36
 
29
37
  ```bash
package/client.js CHANGED
@@ -3,10 +3,10 @@ window.__ModuleLoader__.load({
3
3
  factory(require) {
4
4
  const React = require('react')
5
5
  const h = React.createElement
6
- const PANEL_ID = 'dsh-plugins-ui'
6
+ const PANEL_ID = 'dsh-plugin-panel'
7
7
  const TAB_KIND = 'plugin-canvas'
8
- const NS = 'dsh-plugins-ui'
9
- const VERSION = 'v0.1.0'
8
+ const NS = 'dsh-plugin-panel'
9
+ const VERSION = 'v0.1.2'
10
10
  const PREF_KEY = 'dsh.plugin-list.pref.v2'
11
11
 
12
12
  const COPY = {
@@ -18,6 +18,16 @@ window.__ModuleLoader__.load({
18
18
  'list.builtin': '系统内置',
19
19
  'list.all': '全部',
20
20
  'list.refresh': '刷新',
21
+ 'list.market': '市场',
22
+ 'list.back': '返回',
23
+ 'list.marketTitle': '插件市场趋势',
24
+ 'list.marketHint': '来自 dsh-plugin-shop 目录的每日快照,不是 npm 全站统计。',
25
+ 'list.marketLoading': '正在取历史数据,第一次大约十几秒…',
26
+ 'list.marketError': '取不到目录数据。如果刚更新过插件,重启一次 DeepSeek Harness 再试。',
27
+ 'list.countNow': '当前收录',
28
+ 'list.countFiltered': '已过滤',
29
+ 'list.perDay': '日均新增',
30
+ 'list.spanDays': '覆盖天数',
21
31
  'list.viewList': '列表',
22
32
  'list.viewCard': '卡片',
23
33
  'list.open': '打开',
@@ -81,6 +91,16 @@ window.__ModuleLoader__.load({
81
91
  'list.builtin': 'Built-in',
82
92
  'list.all': 'All',
83
93
  'list.refresh': 'Refresh',
94
+ 'list.market': 'Market',
95
+ 'list.back': 'Back',
96
+ 'list.marketTitle': 'Plugin market trend',
97
+ 'list.marketHint': 'Daily snapshots of the dsh-plugin-shop catalog, not a site-wide npm count.',
98
+ 'list.marketLoading': 'Fetching history, about ten seconds the first time…',
99
+ 'list.marketError': 'Could not read the catalog. Restart DeepSeek Harness once if you just updated.',
100
+ 'list.countNow': 'Listed',
101
+ 'list.countFiltered': 'Filtered',
102
+ 'list.perDay': 'Per day',
103
+ 'list.spanDays': 'Days',
84
104
  'list.viewList': 'List',
85
105
  'list.viewCard': 'Cards',
86
106
  'list.open': 'Open',
@@ -253,6 +273,44 @@ window.__ModuleLoader__.load({
253
273
  return ''
254
274
  }
255
275
 
276
+ function trendChart(days) {
277
+ const W = 1000
278
+ const H = 340
279
+ const L = 76
280
+ const R = 28
281
+ const T = 24
282
+ const B = 52
283
+ const values = days.map((d) => d.count)
284
+ const max = Math.max(...values, 1) * 1.04
285
+ const n = days.length
286
+ const x = (i) => L + (W - L - R) * (n <= 1 ? 0.5 : i / (n - 1))
287
+ const y = (v) => T + (H - T - B) * (1 - v / max)
288
+ const line = days.map((d, i) => `${i === 0 ? 'M' : 'L'} ${x(i).toFixed(1)} ${y(d.count).toFixed(1)}`).join(' ')
289
+ const area = `${line} L ${x(n - 1).toFixed(1)} ${H - B} L ${x(0).toFixed(1)} ${H - B} Z`
290
+ const ticks = [0, 0.25, 0.5, 0.75, 1].map((r) => Math.round((max * r) / 1000) * 1000)
291
+ const labelIndexes = n <= 1 ? [0] : [0, Math.floor((n - 1) / 2), n - 1]
292
+ return h('svg', { className: 'dsh-pl-chart', viewBox: `0 0 ${W} ${H}`, role: 'img' },
293
+ ticks.map((tick, i) => h('line', {
294
+ key: `g${i}`, x1: L, x2: W - R, y1: y(tick), y2: y(tick),
295
+ stroke: 'var(--dsw-alias-border-l1)', strokeWidth: 1,
296
+ })),
297
+ ticks.map((tick, i) => h('text', {
298
+ key: `t${i}`, x: L - 12, y: y(tick) + 6, textAnchor: 'end',
299
+ fill: 'var(--dsw-alias-label-secondary)', fontSize: 20,
300
+ }, `${Math.round(tick / 1000)}k`)),
301
+ h('path', { d: area, fill: 'var(--dsw-alias-brand-primary)', opacity: 0.12 }),
302
+ h('path', { d: line, fill: 'none', stroke: 'var(--dsw-alias-brand-primary)', strokeWidth: 4, strokeLinejoin: 'round' }),
303
+ labelIndexes.map((i) => h('text', {
304
+ key: `d${i}`, x: x(i), y: H - B + 34, textAnchor: i === 0 ? 'start' : i === n - 1 ? 'end' : 'middle',
305
+ fill: 'var(--dsw-alias-label-secondary)', fontSize: 20,
306
+ }, days[i].day.slice(5))),
307
+ h('text', {
308
+ x: x(n - 1), y: y(values[n - 1]) - 16, textAnchor: 'end',
309
+ fill: 'var(--dsw-alias-label-primary)', fontSize: 26, fontWeight: 600,
310
+ }, String(values[n - 1])),
311
+ )
312
+ }
313
+
256
314
  function detailNames(plugin, bundle) {
257
315
  const names = []
258
316
  const moduleName = String((plugin && plugin.moduleName) || '')
@@ -349,6 +407,14 @@ window.__ModuleLoader__.load({
349
407
  throw new Error(result && result.error && result.error.message ? result.error.message : 'list failed')
350
408
  }
351
409
 
410
+ const SELF_NAMES = new Set(['dsh-plugin-panel', 'dsh-plugins-ui', 'dsh-plugin-canvas'])
411
+
412
+ function isSelf(plugin) {
413
+ const entry = String((plugin && plugin.entryId) || '').replace(/^include:/, '')
414
+ const moduleName = String((plugin && plugin.moduleName) || '')
415
+ return SELF_NAMES.has(entry) || SELF_NAMES.has(moduleName) || SELF_NAMES.has(shortModule(moduleName))
416
+ }
417
+
352
418
  function tabKindFor(plugin, entries, localeId) {
353
419
  const names = new Set([
354
420
  plugin.entryId,
@@ -380,7 +446,7 @@ window.__ModuleLoader__.load({
380
446
  function placesFor(plugin, guide, seats, localeId) {
381
447
  const names = namesOf(plugin)
382
448
  const places = []
383
- if (tabKindFor(plugin, guide, localeId)) places.push('right')
449
+ if (tabKindFor(plugin, guide, localeId) || isSelf(plugin)) places.push('right')
384
450
  for (const seat of seats || []) {
385
451
  const id = seat.id || seat.key
386
452
  const owner = seat.registrant
@@ -441,6 +507,9 @@ window.__ModuleLoader__.load({
441
507
  const [filter, setFilter] = React.useState(pref.current.filter)
442
508
  const [pins, setPins] = React.useState(pref.current.pins || [])
443
509
  const [view, setView] = React.useState(pref.current.layout === 'card' ? 'card' : 'list')
510
+ const [surface, setSurface] = React.useState('plugins')
511
+ const [trend, setTrend] = React.useState(null)
512
+ const [trendStatus, setTrendStatus] = React.useState('')
444
513
  const [plugins, setPlugins] = React.useState([])
445
514
  const [bundles, setBundles] = React.useState([])
446
515
  const [status, setStatus] = React.useState(listPlugins ? 'loading' : 'unavailable')
@@ -566,6 +635,27 @@ window.__ModuleLoader__.load({
566
635
  return () => { cancelled = true }
567
636
  }, [selectedId, plugins, bundles, loadFacts])
568
637
 
638
+ const loadTrend = React.useCallback(async () => {
639
+ if (typeof loadMarketTrend !== 'function') return
640
+ setTrendStatus('loading')
641
+ try {
642
+ const result = await loadMarketTrend()
643
+ const value = result && result.ok === true ? result.value : result
644
+ if (value && Array.isArray(value.days) && value.days.length) {
645
+ setTrend(value)
646
+ setTrendStatus('ready')
647
+ } else {
648
+ setTrendStatus('error')
649
+ }
650
+ } catch {
651
+ setTrendStatus('error')
652
+ }
653
+ }, [loadMarketTrend])
654
+
655
+ React.useEffect(() => {
656
+ if (surface === 'market' && trendStatus === '') void loadTrend()
657
+ }, [surface, trendStatus, loadTrend])
658
+
569
659
  const visible = React.useMemo(() => {
570
660
  const rows = plugins.filter((plugin) => {
571
661
  const origin = originOf(plugin)
@@ -711,6 +801,40 @@ window.__ModuleLoader__.load({
711
801
  }, label)
712
802
  }
713
803
 
804
+ function marketView() {
805
+ const days = trend && Array.isArray(trend.days) ? trend.days : []
806
+ const first = days[0]
807
+ const last = days[days.length - 1]
808
+ const span = first && last
809
+ ? Math.max(1, Math.round((Date.parse(last.day) - Date.parse(first.day)) / 86400000))
810
+ : 0
811
+ const perDay = first && last ? Math.round((last.count - first.count) / Math.max(span, 1)) : 0
812
+ return h('div', { className: 'dsh-pl-market' },
813
+ h('div', { className: 'dsh-pl-market-head' },
814
+ h('strong', null, textOf(t, 'list.marketTitle')),
815
+ h('p', { className: 'dsh-pl-note' }, textOf(t, 'list.marketHint'))),
816
+ trendStatus === 'loading'
817
+ ? h('p', { className: 'dsh-pl-state' }, textOf(t, 'list.marketLoading'))
818
+ : trendStatus === 'error'
819
+ ? h('p', { className: 'dsh-pl-state' }, textOf(t, 'list.marketError'))
820
+ : days.length
821
+ ? [
822
+ h('div', { className: 'dsh-pl-market-stats' },
823
+ stat(textOf(t, 'list.countNow'), last.count.toLocaleString()),
824
+ stat(textOf(t, 'list.countFiltered'), (last.rejected || 0).toLocaleString()),
825
+ stat(textOf(t, 'list.perDay'), `+${perDay.toLocaleString()}`),
826
+ stat(textOf(t, 'list.spanDays'), String(days.length))),
827
+ trendChart(days),
828
+ ]
829
+ : null)
830
+ }
831
+
832
+ function stat(label, value) {
833
+ return h('div', { className: 'dsh-pl-stat' },
834
+ h('span', null, label),
835
+ h('strong', null, value))
836
+ }
837
+
714
838
  return h('div', { className: 'dsh-pl' },
715
839
  h('style', null, CSS),
716
840
  h('header', { className: 'dsh-pl-bar' },
@@ -720,11 +844,18 @@ window.__ModuleLoader__.load({
720
844
  chip('builtin', textOf(t, 'list.builtin'), filter, (id) => { setFilter(id); remember({ filter: id }) }),
721
845
  chip('all', textOf(t, 'list.all'), filter, (id) => { setFilter(id); remember({ filter: id }) })),
722
846
  h('button', { type: 'button', onClick: () => { void load() } }, textOf(t, 'list.refresh')),
847
+ h('button', {
848
+ type: 'button',
849
+ className: `dsh-pl-market-btn${surface === 'market' ? ' is-on' : ''}`,
850
+ onClick: () => setSurface(surface === 'market' ? 'plugins' : 'market'),
851
+ }, surface === 'market' ? textOf(t, 'list.back') : textOf(t, 'list.market')),
723
852
  h('div', { className: 'dsh-pl-view', role: 'group', 'aria-label': 'view' },
724
853
  chip('list', textOf(t, 'list.viewList'), view, (id) => { setView(id); remember({ layout: id }) }),
725
854
  chip('card', textOf(t, 'list.viewCard'), view, (id) => { setView(id); remember({ layout: id }) })),
726
855
  h('span', { className: 'dsh-pl-version' }, VERSION))),
727
- status === 'loading' && plugins.length === 0
856
+ surface === 'market'
857
+ ? marketView()
858
+ : status === 'loading' && plugins.length === 0
728
859
  ? h('p', { className: 'dsh-pl-state' }, textOf(t, 'list.loading'))
729
860
  : status === 'unavailable'
730
861
  ? h('p', { className: 'dsh-pl-state' }, textOf(t, 'list.unavailable'))
@@ -846,6 +977,14 @@ window.__ModuleLoader__.load({
846
977
  .dsh-pl-md th { color: var(--dsw-alias-label-secondary); font-weight: 650; background: var(--dsw-alias-bg-layer-1); white-space: nowrap; }
847
978
  .dsh-pl-md td:first-child { white-space: nowrap; }
848
979
  .dsh-pl-md tr:last-child td { border-bottom: 0; }
980
+ .dsh-pl-market-btn.is-on { background: var(--dsw-alias-bg-layer-2); }
981
+ .dsh-pl-market { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 16px 18px 28px; }
982
+ .dsh-pl-market-head strong { display: block; font-size: 15px; margin-bottom: 6px; }
983
+ .dsh-pl-market-stats { display: flex; flex-wrap: wrap; gap: 10px; margin: 16px 0 8px; }
984
+ .dsh-pl-stat { display: flex; flex-direction: column; gap: 4px; min-width: 118px; padding: 10px 14px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px; background: var(--dsw-alias-bg-layer-1); }
985
+ .dsh-pl-stat span { font-size: 11px; color: var(--dsw-alias-label-secondary); }
986
+ .dsh-pl-stat strong { font-size: 20px; font-weight: 600; }
987
+ .dsh-pl-chart { display: block; width: 100%; height: auto; margin-top: 12px; }
849
988
  .dsh-pl-actions { display: flex; gap: 8px; align-items: center; margin-top: 16px; }
850
989
  `
851
990
 
@@ -862,6 +1001,7 @@ window.__ModuleLoader__.load({
862
1001
  const setPluginEnabled = pluginManager.setPluginEnabled.bind(pluginManager)
863
1002
  const rpcCall = ctx.connection.rpc.call.bind(ctx.connection.rpc)
864
1003
  const loadFacts = (payload) => rpcCall('/dsh-plugins-ui-rpc', 'facts', payload || {})
1004
+ const loadMarketTrend = (payload) => rpcCall('/dsh-plugins-ui-rpc', 'trend', payload || {})
865
1005
  const revealPackage = async (payload) => {
866
1006
  const input = payload && typeof payload === 'object' ? payload : {}
867
1007
  const dir = directoryFromSource(input.source)
@@ -945,7 +1085,7 @@ window.__ModuleLoader__.load({
945
1085
  name: 'sidebar.right.pane.tab',
946
1086
  key: PANEL_ID,
947
1087
  locale: NS,
948
- inject: () => ({ listPlugins, listBundles, readBundleDetail, setPluginEnabled, loadFacts, revealPackage, listGuide, subscribeGuide, listSeats, subscribeSeats, openPanel, t, readLocale, subscribeLocale }),
1088
+ inject: () => ({ listPlugins, listBundles, readBundleDetail, setPluginEnabled, loadFacts, revealPackage, loadMarketTrend, listGuide, subscribeGuide, listSeats, subscribeSeats, openPanel, t, readLocale, subscribeLocale }),
949
1089
  }, ListView)), 'plugin-list: tab body')
950
1090
  },
951
1091
  }
Binary file
Binary file
Binary file
package/index.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { createRequire } from 'node:module'
2
2
  import { spawn } from 'node:child_process'
3
- import { readFileSync, readdirSync, statSync } from 'node:fs'
3
+ import { mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
4
4
  import path from 'node:path'
5
5
  import { fileURLToPath } from 'node:url'
6
+ import { collectTrend } from './trend.js'
6
7
 
7
8
  const CHANNEL = '/dsh-plugins-ui-rpc'
8
9
  const MAX_SOURCE_BYTES = 1_200_000
@@ -63,6 +64,7 @@ function registerDetailRpc(ctx, services) {
63
64
  else if (method === 'counts') result = { ok: true, value: countTools(services, record.payload) }
64
65
  else if (method === 'facts') result = { ok: true, value: readFacts(record.payload) }
65
66
  else if (method === 'reveal') result = { ok: true, value: revealPackage(record.payload) }
67
+ else if (method === 'trend') result = { ok: true, value: await readTrend() }
66
68
  else throw new Error('Unknown detail method.')
67
69
  } catch (error) {
68
70
  const message = error instanceof Error ? error.message : String(error)
@@ -129,6 +131,33 @@ function directoryFromSource(source) {
129
131
  }
130
132
  }
131
133
 
134
+ function trendCacheFile() {
135
+ const profile = process.env.DSH_PROFILE_DIR || path.join(process.env.HOME || '', '.dsh/profiles/desktop')
136
+ return path.join(profile, '.plugin-panel', 'trend.json')
137
+ }
138
+
139
+ function readTrendCache() {
140
+ try {
141
+ return JSON.parse(readFileSync(trendCacheFile(), 'utf8'))
142
+ } catch {
143
+ return null
144
+ }
145
+ }
146
+
147
+ function writeTrendCache(value) {
148
+ try {
149
+ const file = trendCacheFile()
150
+ mkdirSync(path.dirname(file), { recursive: true })
151
+ writeFileSync(file, JSON.stringify(value))
152
+ } catch {
153
+ /* the chart still renders without a cache */
154
+ }
155
+ }
156
+
157
+ function readTrend() {
158
+ return collectTrend(readTrendCache, writeTrendCache)
159
+ }
160
+
132
161
  function readFacts(payload) {
133
162
  const input = payload && typeof payload === 'object' ? payload : {}
134
163
  const moduleName = typeof input.moduleName === 'string' ? input.moduleName : ''
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-panel",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Plugin manager for the DeepSeek Harness right sidebar: list installed plugins, see where each one lives, pin favourites, and open a plugin folder in Finder.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -11,6 +11,7 @@
11
11
  },
12
12
  "files": [
13
13
  "index.js",
14
+ "trend.js",
14
15
  "client.js",
15
16
  "catalog.js",
16
17
  "catalog.test.mjs",
package/trend.js ADDED
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Catalog trend: how many plugins the dsh-plugin-shop catalog listed on each
3
+ * day. The catalog publishes one immutable npm version per build, so the
4
+ * version list doubles as a dated history; we read only the small index.json
5
+ * of each version instead of downloading its multi-megabyte tarball.
6
+ */
7
+
8
+ const CATALOG = 'dsh-plugin-shop-catalog'
9
+ const CDN_BASES = ['https://cdn.jsdelivr.net/npm', 'https://unpkg.com']
10
+ const REGISTRY = 'https://registry.npmjs.org'
11
+ const MAX_PARALLEL = 6
12
+ const FETCH_TIMEOUT_MS = 20000
13
+
14
+ async function fetchJson(url, timeoutMs = FETCH_TIMEOUT_MS) {
15
+ const controller = new AbortController()
16
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
17
+ try {
18
+ const response = await fetch(url, { signal: controller.signal })
19
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
20
+ return await response.json()
21
+ } finally {
22
+ clearTimeout(timer)
23
+ }
24
+ }
25
+
26
+ async function fetchIndex(version) {
27
+ for (const base of CDN_BASES) {
28
+ try {
29
+ return await fetchJson(`${base}/${CATALOG}@${version}/v1/index.json`)
30
+ } catch {
31
+ /* try the next mirror */
32
+ }
33
+ }
34
+ return null
35
+ }
36
+
37
+ async function mapLimit(items, limit, worker) {
38
+ const queue = items.slice()
39
+ const runners = Array.from({ length: Math.min(limit, queue.length) }, async () => {
40
+ while (queue.length) {
41
+ const item = queue.shift()
42
+ try {
43
+ await worker(item)
44
+ } catch {
45
+ /* a single version failing must not abort the sweep */
46
+ }
47
+ }
48
+ })
49
+ await Promise.all(runners)
50
+ }
51
+
52
+ /** Fold per-version snapshots into one row per day, keeping the day's last build. */
53
+ function foldByDay(entries) {
54
+ const days = new Map()
55
+ for (const entry of entries) {
56
+ const day = (entry.builtAt || entry.publishedAt || '').slice(0, 10)
57
+ if (!day) continue
58
+ const current = days.get(day)
59
+ if (!current || (entry.builtAt || '') >= (current.builtAt || '')) days.set(day, { ...entry, day })
60
+ }
61
+ return [...days.values()].sort((a, b) => a.day.localeCompare(b.day))
62
+ }
63
+
64
+ async function collectTrend(readCache, writeCache) {
65
+ const cached = readCache()
66
+ if (cached && Array.isArray(cached.entries) && cached.entries.length) {
67
+ const age = Date.now() - (cached.fetchedAt || 0)
68
+ if (age < 6 * 3600 * 1000) return shape(cached.entries, cached.title, cached.at)
69
+ }
70
+
71
+ const meta = await fetchJson(`${REGISTRY}/${CATALOG}`)
72
+ const versions = Object.keys(meta.versions || {})
73
+ const published = meta.time || {}
74
+ const known = new Map((cached && Array.isArray(cached.entries) ? cached.entries : []).map((e) => [e.version, e]))
75
+ const missing = versions.filter((version) => !known.has(version))
76
+
77
+ await mapLimit(missing, MAX_PARALLEL, async (version) => {
78
+ const index = await fetchIndex(version)
79
+ if (!index) return
80
+ known.set(version, {
81
+ version,
82
+ count: Number(index.count) || 0,
83
+ rejected: Number(index.rejected) || 0,
84
+ builtAt: index.builtAt || published[version] || '',
85
+ publishedAt: published[version] || '',
86
+ })
87
+ })
88
+
89
+ const entries = [...known.values()].sort((a, b) => String(a.builtAt).localeCompare(String(b.builtAt)))
90
+ const at = new Date().toISOString()
91
+ writeCache({ entries, fetchedAt: Date.now(), at, title: CATALOG })
92
+ return shape(entries, CATALOG, at)
93
+ }
94
+
95
+ function shape(entries, title, at) {
96
+ const days = foldByDay(entries)
97
+ return {
98
+ title,
99
+ at,
100
+ days: days.map((d) => ({ day: d.day, count: d.count, rejected: d.rejected })),
101
+ versions: entries.length,
102
+ latest: days.length ? days[days.length - 1] : null,
103
+ first: days.length ? days[0] : null,
104
+ }
105
+ }
106
+
107
+ export { collectTrend }