@ossy/analytics 3.0.8 → 3.4.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/README.md ADDED
@@ -0,0 +1,30 @@
1
+ # @ossy/analytics
2
+
3
+ Analytics feature package for the Ossy platform. Shows **workspace-scoped** traffic and insight KPIs.
4
+
5
+ Platform-wide (ops) insights belong in a future back-office surface — see [ADR 0014](../../docs/adr/0014-system-insights-in-back-office.md).
6
+
7
+ ## Pages
8
+
9
+ | Page | Path | Audience |
10
+ |------|------|----------|
11
+ | `analytics/home` | `/analytics` · `/analys` | Workspace members (entitled package) |
12
+
13
+ ## Actions
14
+
15
+ | Action | Access | Notes |
16
+ |--------|--------|-------|
17
+ | `@ossy/analytics/actions/get-workspace-kpis` | `workspace` | Member count + non-removed resource count for the current workspace |
18
+ | `@ossy/resources/actions/get-page-view-stats` | `workspace` | Page-view aggregates (implemented in `@ossy/resources`) |
19
+
20
+ ## Client usage
21
+
22
+ ```js
23
+ import { GetWorkspaceKpis } from '@ossy/analytics'
24
+
25
+ await sdk.invoke(GetWorkspaceKpis, {})
26
+ ```
27
+
28
+ ## Preview
29
+
30
+ Enable Analytics in workspace entitlements (or `devEntitlements` in `@ossy/app-test`), then open `/analytics`.
package/package.json CHANGED
@@ -1,13 +1,16 @@
1
1
  {
2
2
  "name": "@ossy/analytics",
3
- "description": "Analytics module — workspace analytics for website traffic",
4
- "version": "3.0.8",
3
+ "description": "Analytics module — workspace insight KPIs",
4
+ "version": "3.4.0",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "module": "./src/index.js",
8
8
  "exports": {
9
9
  ".": "./src/index.js"
10
10
  },
11
+ "scripts": {
12
+ "test": "NODE_OPTIONS=--experimental-vm-modules jest --verbose"
13
+ },
11
14
  "author": "Ossy <yourfriends@ossy.se> (https://ossy.se)",
12
15
  "license": "MIT",
13
16
  "ossy": {
@@ -21,11 +24,20 @@
21
24
  "/src",
22
25
  "README.md"
23
26
  ],
27
+ "dependencies": {
28
+ "@ossy/event-store": "^3.4.0",
29
+ "@ossy/workspaces": "^3.4.0"
30
+ },
24
31
  "peerDependencies": {
25
32
  "@ossy/design-system": "*",
26
33
  "@ossy/resources": "*",
27
34
  "@ossy/sdk-react": "*",
35
+ "@ossy/workspaces": "*",
28
36
  "react": "*"
29
37
  },
30
- "gitHead": "980b380c9dee0de78ef3ebabe86efcec1c223a9a"
38
+ "devDependencies": {
39
+ "@jest/globals": "^30.2.0",
40
+ "jest": "^30.2.0"
41
+ },
42
+ "gitHead": "d36b69444268d172bc3e8e1dc77e67afc63f8650"
31
43
  }
package/src/Definition.js CHANGED
@@ -1,7 +1,7 @@
1
1
  export const Definition = {
2
2
  id: 'analytics',
3
3
  title: 'Analytics',
4
- description: 'Workspace analytics for website traffic and top performing pages.',
4
+ description: 'Workspace insight KPIs for members, resources, and traffic.',
5
5
  icon: 'chart',
6
6
  status: ['beta'],
7
7
  }
@@ -1,6 +1,7 @@
1
1
  import React, { useMemo } from 'react'
2
2
  import { Text, View, Page, useLocale } from '@ossy/design-system'
3
3
  import { usePageViewStats } from './usePageViewStats.js'
4
+ import { useWorkspaceKpis } from './useWorkspaceKpis.js'
4
5
  import { useSdk } from '@ossy/sdk-react'
5
6
  import { GetWorkspace } from '@ossy/workspaces'
6
7
 
@@ -35,12 +36,15 @@ export default function AnalyticsPage() {
35
36
  return { from: now - 30 * 24 * 60 * 60 * 1000, to: now }
36
37
  }, [])
37
38
 
38
- const { status, totalViews, dailyViews, topPaths } = usePageViewStats({
39
+ const pageViews = usePageViewStats({
39
40
  workspaceId: workspace?.id,
40
41
  from,
41
42
  to,
42
43
  limit: 10,
43
44
  })
45
+ const workspaceKpis = useWorkspaceKpis({ workspaceId: workspace?.id })
46
+
47
+ const { status, totalViews, dailyViews, topPaths } = pageViews
44
48
 
45
49
  return (
46
50
  <Page
@@ -48,63 +52,76 @@ export default function AnalyticsPage() {
48
52
  description="analytics.home.description"
49
53
  >
50
54
  <View gap="m">
51
- {status === 'loading' && (
52
- <View surface="primary" roundness="m" inset="m">
53
- <Text>{t('analytics.home.loading')}</Text>
54
- </View>
55
- )}
55
+ {(workspaceKpis.status === 'loading' || status === 'loading') && (
56
+ <View surface="primary" roundness="m" inset="m">
57
+ <Text>{t('analytics.home.loading')}</Text>
58
+ </View>
59
+ )}
56
60
 
57
- {status === 'error' && (
58
- <View surface="primary" roundness="m" inset="m">
59
- <Text>{t('analytics.home.error')}</Text>
60
- </View>
61
- )}
61
+ {workspaceKpis.status === 'error' && (
62
+ <View surface="primary" roundness="m" inset="m">
63
+ <Text>{t('analytics.home.workspaceKpisError')}</Text>
64
+ </View>
65
+ )}
62
66
 
63
- {status === 'success' && (
64
- <>
67
+ {workspaceKpis.status === 'success' && (
65
68
  <View layout="row" gap="m">
66
- <SummaryCard label={t('analytics.home.totalPageViews')} value={`${totalViews}`} />
67
- <SummaryCard label={t('analytics.home.trackedPages')} value={`${topPaths.length}`} />
68
- <SummaryCard label={t('analytics.home.daysWithTraffic')} value={`${dailyViews.length}`} />
69
+ <SummaryCard label={t('analytics.home.members')} value={`${workspaceKpis.members}`} />
70
+ <SummaryCard label={t('analytics.home.resources')} value={`${workspaceKpis.resources}`} />
69
71
  </View>
72
+ )}
70
73
 
71
- <View layout="row" gap="m" style={{ alignItems: 'flex-start' }}>
72
- <View
73
- surface="primary"
74
- roundness="m"
75
- inset="m"
76
- gap="s"
77
- style={{ border: '1px solid var(--separator)', flex: 1 }}
78
- >
79
- <Text variant="heading-secondary" as="h2" text="analytics.home.topPaths" />
80
- {topPaths.length === 0 && <Text variant="small" text="analytics.home.noPageViews" />}
81
- {topPaths.map((item) => (
82
- <View key={item.path || 'unknown'} layout="row" justifyContent="space-between">
83
- <Text variant="small">{item.path || t('analytics.home.unknownPath')}</Text>
84
- <Text variant="small">{item.views}</Text>
85
- </View>
86
- ))}
74
+ {status === 'error' && (
75
+ <View surface="primary" roundness="m" inset="m">
76
+ <Text>{t('analytics.home.error')}</Text>
77
+ </View>
78
+ )}
79
+
80
+ {status === 'success' && (
81
+ <>
82
+ <View layout="row" gap="m">
83
+ <SummaryCard label={t('analytics.home.totalPageViews')} value={`${totalViews}`} />
84
+ <SummaryCard label={t('analytics.home.trackedPages')} value={`${topPaths.length}`} />
85
+ <SummaryCard label={t('analytics.home.daysWithTraffic')} value={`${dailyViews.length}`} />
87
86
  </View>
88
87
 
89
- <View
90
- surface="primary"
91
- roundness="m"
92
- inset="m"
93
- gap="s"
94
- style={{ border: '1px solid var(--separator)', flex: 1 }}
95
- >
96
- <Text variant="heading-secondary" as="h2" text="analytics.home.dailyPageViews" />
97
- {dailyViews.length === 0 && <Text variant="small" text="analytics.home.noDailyData" />}
98
- {dailyViews.map((item) => (
99
- <View key={item.date} layout="row" justifyContent="space-between">
100
- <Text variant="small">{item.date}</Text>
101
- <Text variant="small">{item.views}</Text>
102
- </View>
103
- ))}
88
+ <View layout="row" gap="m" style={{ alignItems: 'flex-start' }}>
89
+ <View
90
+ surface="primary"
91
+ roundness="m"
92
+ inset="m"
93
+ gap="s"
94
+ style={{ border: '1px solid var(--separator)', flex: 1 }}
95
+ >
96
+ <Text variant="heading-secondary" as="h2" text="analytics.home.topPaths" />
97
+ {topPaths.length === 0 && <Text variant="small" text="analytics.home.noPageViews" />}
98
+ {topPaths.map((item) => (
99
+ <View key={item.path || 'unknown'} layout="row" justifyContent="space-between">
100
+ <Text variant="small">{item.path || t('analytics.home.unknownPath')}</Text>
101
+ <Text variant="small">{item.views}</Text>
102
+ </View>
103
+ ))}
104
+ </View>
105
+
106
+ <View
107
+ surface="primary"
108
+ roundness="m"
109
+ inset="m"
110
+ gap="s"
111
+ style={{ border: '1px solid var(--separator)', flex: 1 }}
112
+ >
113
+ <Text variant="heading-secondary" as="h2" text="analytics.home.dailyPageViews" />
114
+ {dailyViews.length === 0 && <Text variant="small" text="analytics.home.noDailyData" />}
115
+ {dailyViews.map((item) => (
116
+ <View key={item.date} layout="row" justifyContent="space-between">
117
+ <Text variant="small">{item.date}</Text>
118
+ <Text variant="small">{item.views}</Text>
119
+ </View>
120
+ ))}
121
+ </View>
104
122
  </View>
105
- </View>
106
- </>
107
- )}
123
+ </>
124
+ )}
108
125
  </View>
109
126
  </Page>
110
127
  )
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "analytics/home.documentTitle": "Analytics",
3
3
  "analytics.home.title": "Analytics",
4
- "analytics.home.description": "Workspace analytics for website traffic and top performing pages.",
4
+ "analytics.home.description": "Workspace insight KPIs for members, resources, and traffic.",
5
5
  "analytics.home.loading": "Loading analytics...",
6
6
  "analytics.home.error": "Could not load analytics data.",
7
+ "analytics.home.members": "Members",
8
+ "analytics.home.resources": "Resources",
9
+ "analytics.home.workspaceKpisError": "Could not load workspace KPIs.",
7
10
  "analytics.home.totalPageViews": "Total page views (30 days)",
8
11
  "analytics.home.trackedPages": "Tracked pages (30 days)",
9
12
  "analytics.home.daysWithTraffic": "Days with traffic",
@@ -0,0 +1,4 @@
1
+ export const metadata = {
2
+ id: '@ossy/analytics/actions/get-workspace-kpis',
3
+ access: 'workspace',
4
+ }
@@ -0,0 +1,27 @@
1
+ import { Aggregate } from '@ossy/event-store'
2
+ import { Workspace } from '@ossy/workspaces/server'
3
+
4
+ export const metadata = { id: '@ossy/analytics/tasks/get-workspace-kpis' }
5
+
6
+ const RESOURCE_SCHEMA_TYPE = { $regex: /^@[^/]+\/[^/]+\/schema\// }
7
+
8
+ export async function run({ payload, req }) {
9
+ const workspaceId = payload?.workspaceId ?? req?.workspaceId
10
+ if (!workspaceId) throw Object.assign(new Error('workspaceId is required'), { status: 400 })
11
+
12
+ const workspace = await Aggregate.Of(Workspace, workspaceId).then(Aggregate.View())
13
+ const members = Array.isArray(workspace.users) ? workspace.users.length : 0
14
+
15
+ const resources = await Aggregate.Collection.countDocuments({
16
+ type: RESOURCE_SCHEMA_TYPE,
17
+ 'state.belongsTo': workspace.id,
18
+ 'state.status': { $ne: 'removed' },
19
+ })
20
+
21
+ return {
22
+ scope: 'workspace',
23
+ workspaceId: workspace.id,
24
+ members,
25
+ resources,
26
+ }
27
+ }
@@ -0,0 +1,61 @@
1
+ import { beforeEach, describe, expect, it, jest } from '@jest/globals'
2
+
3
+ const countDocuments = jest.fn()
4
+ const ofMock = jest.fn()
5
+ const viewMock = jest.fn()
6
+
7
+ jest.unstable_mockModule('@ossy/event-store', () => ({
8
+ Aggregate: {
9
+ Of: ofMock,
10
+ View: viewMock,
11
+ Collection: { countDocuments },
12
+ },
13
+ }))
14
+
15
+ jest.unstable_mockModule('@ossy/workspaces/server', () => ({
16
+ Workspace: { name: 'Workspace' },
17
+ }))
18
+
19
+ const { run } = await import('./get-workspace-kpis.task.js')
20
+
21
+ describe('get-workspace-kpis task', () => {
22
+ beforeEach(() => {
23
+ countDocuments.mockReset()
24
+ ofMock.mockReset()
25
+ viewMock.mockReset()
26
+ })
27
+
28
+ it('requires workspaceId', async () => {
29
+ await expect(run({ payload: {}, req: {} })).rejects.toMatchObject({
30
+ message: 'workspaceId is required',
31
+ status: 400,
32
+ })
33
+ })
34
+
35
+ it('returns member and resource counts for the workspace', async () => {
36
+ ofMock.mockReturnValue(Promise.resolve({ id: 'ws-1' }))
37
+ viewMock.mockReturnValue(() => ({ id: 'ws-1', users: ['u1', 'u2', 'u3'] }))
38
+ countDocuments.mockResolvedValue(7)
39
+
40
+ // Aggregate.Of(...).then(Aggregate.View()) — View() returns a function applied by then
41
+ ofMock.mockImplementation(() => ({
42
+ then(fn) {
43
+ return Promise.resolve(fn({ id: 'ws-1', users: ['u1', 'u2', 'u3'] }))
44
+ },
45
+ }))
46
+ viewMock.mockReturnValue((saved) => saved)
47
+
48
+ const result = await run({ payload: { workspaceId: 'ws-1' }, req: {} })
49
+
50
+ expect(result).toEqual({
51
+ scope: 'workspace',
52
+ workspaceId: 'ws-1',
53
+ members: 3,
54
+ resources: 7,
55
+ })
56
+ expect(countDocuments).toHaveBeenCalledWith(expect.objectContaining({
57
+ 'state.belongsTo': 'ws-1',
58
+ 'state.status': { $ne: 'removed' },
59
+ }))
60
+ })
61
+ })
package/src/index.js CHANGED
@@ -1 +1,2 @@
1
1
  export { Definition } from './Definition.js'
2
+ export { metadata as GetWorkspaceKpis } from './get-workspace-kpis.action.js'
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "analytics/home.documentTitle": "Analys",
3
3
  "analytics.home.title": "Analys",
4
- "analytics.home.description": "Workspace-analys för webbtrafik och mest besökta sidor.",
4
+ "analytics.home.description": "Workspace-KPIer för medlemmar, resurser och trafik.",
5
5
  "analytics.home.loading": "Laddar analys...",
6
6
  "analytics.home.error": "Kunde inte ladda analysdata.",
7
+ "analytics.home.members": "Medlemmar",
8
+ "analytics.home.resources": "Resurser",
9
+ "analytics.home.workspaceKpisError": "Kunde inte ladda workspace-KPIer.",
7
10
  "analytics.home.totalPageViews": "Totala sidvisningar (30 dagar)",
8
11
  "analytics.home.trackedPages": "Spårade sidor (30 dagar)",
9
12
  "analytics.home.daysWithTraffic": "Dagar med trafik",
@@ -0,0 +1,34 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { useSdk } from '@ossy/sdk-react'
3
+ import { metadata as GetWorkspaceKpis } from './get-workspace-kpis.action.js'
4
+
5
+ export function useWorkspaceKpis({ workspaceId }) {
6
+ const sdk = useSdk()
7
+ const [status, setStatus] = useState('idle')
8
+ const [data, setData] = useState({
9
+ members: 0,
10
+ resources: 0,
11
+ })
12
+
13
+ useEffect(() => {
14
+ if (!workspaceId) return
15
+
16
+ setStatus('loading')
17
+ sdk.invoke(GetWorkspaceKpis, {})
18
+ .then((json) => {
19
+ setData({
20
+ members: json?.members || 0,
21
+ resources: json?.resources || 0,
22
+ })
23
+ setStatus('success')
24
+ })
25
+ .catch(() => {
26
+ setStatus('error')
27
+ })
28
+ }, [sdk, workspaceId])
29
+
30
+ return {
31
+ status,
32
+ ...data,
33
+ }
34
+ }