@ossy/analytics 3.11.1 → 3.12.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.
@@ -0,0 +1,103 @@
1
+ import { beforeEach, describe, expect, it, jest } from '@jest/globals'
2
+
3
+ const resolveCountryCodeFromIp = jest.fn()
4
+
5
+ jest.unstable_mockModule('./resolve-country-from-ip.js', () => ({
6
+ resolveCountryCodeFromIp,
7
+ }))
8
+
9
+ const {
10
+ PageViewLocationProjection,
11
+ pageViewLocationScopeId,
12
+ } = await import('./page-view-location.aggregate.js')
13
+
14
+ describe('PageViewLocationProjection', () => {
15
+ beforeEach(() => {
16
+ resolveCountryCodeFromIp.mockReset()
17
+ })
18
+
19
+ it('scopes by workspaceId:host', () => {
20
+ expect(pageViewLocationScopeId('ws-1', 'www.Ossy.se')).toBe('ws-1:ossy.se')
21
+ expect(PageViewLocationProjection.scopeFromEvent({
22
+ payload: { belongsTo: 'ws-1', content: { host: 'ossy.se' } },
23
+ })).toBe('ws-1:ossy.se')
24
+ expect(PageViewLocationProjection.scopeFromEvent({
25
+ payload: { belongsTo: 'ws-1', content: {} },
26
+ })).toBeNull()
27
+ })
28
+
29
+ it('folds MaxMind country into daily byDay buckets from event.created', async () => {
30
+ resolveCountryCodeFromIp.mockResolvedValue('SE')
31
+ const state = PageViewLocationProjection.initialState('ws-1:ossy.se')
32
+ const next = await PageViewLocationProjection.Apply({
33
+ resourceId: 'pv-1',
34
+ created: Date.parse('2026-09-05T12:00:00.000Z'),
35
+ ip: '203.0.113.10',
36
+ payload: {
37
+ belongsTo: 'ws-1',
38
+ content: { host: 'ossy.se' },
39
+ },
40
+ }, state)
41
+
42
+ expect(resolveCountryCodeFromIp).toHaveBeenCalledWith('203.0.113.10')
43
+ expect(next).toMatchObject({ workspaceId: 'ws-1', host: 'ossy.se' })
44
+ expect(next.byDay).toEqual({ '2026-09-05': { SE: 1 } })
45
+ expect(next).not.toHaveProperty('views')
46
+ })
47
+
48
+ it('increments the same day/country on further views', async () => {
49
+ resolveCountryCodeFromIp.mockResolvedValue('se')
50
+ const mid = await PageViewLocationProjection.Apply({
51
+ resourceId: 'pv-1',
52
+ created: Date.parse('2026-09-05T08:00:00.000Z'),
53
+ ip: '203.0.113.10',
54
+ payload: {
55
+ belongsTo: 'ws-1',
56
+ content: { host: 'ossy.se' },
57
+ },
58
+ }, PageViewLocationProjection.initialState('ws-1:ossy.se'))
59
+ const next = await PageViewLocationProjection.Apply({
60
+ resourceId: 'pv-2',
61
+ created: Date.parse('2026-09-05T18:00:00.000Z'),
62
+ ip: '203.0.113.11',
63
+ payload: {
64
+ belongsTo: 'ws-1',
65
+ content: { host: 'ossy.se' },
66
+ },
67
+ }, mid)
68
+
69
+ expect(next.byDay).toEqual({ '2026-09-05': { SE: 2 } })
70
+ })
71
+
72
+ it('leaves byDay unchanged when lookup misses', async () => {
73
+ resolveCountryCodeFromIp.mockResolvedValue(null)
74
+ const next = await PageViewLocationProjection.Apply({
75
+ resourceId: 'pv-2',
76
+ created: 42,
77
+ ip: '198.51.100.1',
78
+ payload: { belongsTo: 'ws-1', content: { host: 'ossy.se' } },
79
+ }, PageViewLocationProjection.initialState('ws-1:ossy.se'))
80
+
81
+ expect(next.byDay).toEqual({})
82
+ })
83
+
84
+ it('skips MaxMind when host is missing', async () => {
85
+ resolveCountryCodeFromIp.mockResolvedValue('SE')
86
+ const next = await PageViewLocationProjection.Apply({
87
+ resourceId: 'pv-3',
88
+ created: 42,
89
+ ip: '203.0.113.10',
90
+ payload: { belongsTo: 'ws-1', content: {} },
91
+ }, { workspaceId: 'ws-1', host: '', byDay: {} })
92
+
93
+ expect(resolveCountryCodeFromIp).not.toHaveBeenCalled()
94
+ expect(next.byDay).toEqual({})
95
+ })
96
+
97
+ it('invalidates the analytics get-page-view-stats action cache', () => {
98
+ expect(PageViewLocationProjection.cacheKeys('ws-1:ossy.se')).toEqual([
99
+ 'projection:@ossy/analytics/data/page-view-location:ws-1:ossy.se',
100
+ 'action:@ossy/analytics/actions/get-page-view-stats',
101
+ ])
102
+ })
103
+ })
@@ -0,0 +1,85 @@
1
+ import maxmind from 'maxmind'
2
+
3
+ /**
4
+ * Resolve ISO 3166-1 alpha-2 country from an IP via MaxMind GeoLite2-Country.
5
+ *
6
+ * Used by **PageViewLocationProjection** (not HTTP ingest). Database path:
7
+ * `GEOLITE2_COUNTRY_MMDB` (or `OSSY_GEOLITE2_COUNTRY_MMDB`). When unset /
8
+ * unreadable / lookup miss → null (callers omit `countryCode`).
9
+ * Never reads viewer-supplied country headers (#769).
10
+ *
11
+ * @param {string | null | undefined} ip
12
+ * @returns {Promise<string | null>}
13
+ */
14
+
15
+ /** @type {import('maxmind').Reader<import('maxmind').CountryResponse> | null | undefined} */
16
+ let reader
17
+ /** @type {string | null | undefined} */
18
+ let openedPath
19
+ /** @type {Promise<import('maxmind').Reader<import('maxmind').CountryResponse> | null> | null} */
20
+ let opening
21
+
22
+ function resolveMmdbPath () {
23
+ const raw = process.env.GEOLITE2_COUNTRY_MMDB
24
+ ?? process.env.OSSY_GEOLITE2_COUNTRY_MMDB
25
+ ?? ''
26
+ const path = String(raw).trim()
27
+ return path || null
28
+ }
29
+
30
+ async function getReader () {
31
+ const path = resolveMmdbPath()
32
+ if (!path) {
33
+ reader = null
34
+ openedPath = null
35
+ return null
36
+ }
37
+ if (reader && openedPath === path) return reader
38
+ if (opening) return opening
39
+
40
+ opening = (async () => {
41
+ try {
42
+ reader = await maxmind.open(path)
43
+ openedPath = path
44
+ return reader
45
+ } catch {
46
+ reader = null
47
+ openedPath = path
48
+ return null
49
+ } finally {
50
+ opening = null
51
+ }
52
+ })()
53
+
54
+ return opening
55
+ }
56
+
57
+ /** Test helper — reset cached reader between specs. */
58
+ export function resetGeoLite2CountryReaderForTests () {
59
+ reader = undefined
60
+ openedPath = undefined
61
+ opening = null
62
+ }
63
+
64
+ /**
65
+ * @param {string | null | undefined} ip
66
+ * @returns {Promise<string | null>}
67
+ */
68
+ export async function resolveCountryCodeFromIp (ip) {
69
+ if (typeof ip !== 'string' || !ip.trim()) return null
70
+ const lookup = await getReader()
71
+ if (!lookup) return null
72
+
73
+ try {
74
+ const result = lookup.get(ip.trim())
75
+ const code = result?.country?.iso_code
76
+ ?? result?.registered_country?.iso_code
77
+ if (typeof code !== 'string') return null
78
+ const normalized = code.trim().toUpperCase()
79
+ if (!/^[A-Z]{2}$/.test(normalized)) return null
80
+ if (normalized === 'XX' || normalized === 'ZZ') return null
81
+ return normalized
82
+ } catch {
83
+ return null
84
+ }
85
+ }
@@ -0,0 +1,52 @@
1
+ import { describe, expect, it, jest, beforeEach, afterEach } from '@jest/globals'
2
+
3
+ const open = jest.fn()
4
+
5
+ jest.unstable_mockModule('maxmind', () => ({
6
+ default: { open },
7
+ }))
8
+
9
+ const { resolveCountryCodeFromIp, resetGeoLite2CountryReaderForTests } = await import('./resolve-country-from-ip.js')
10
+
11
+ describe('resolveCountryCodeFromIp', () => {
12
+ const previousGeo = process.env.GEOLITE2_COUNTRY_MMDB
13
+ const previousOssy = process.env.OSSY_GEOLITE2_COUNTRY_MMDB
14
+
15
+ beforeEach(() => {
16
+ resetGeoLite2CountryReaderForTests()
17
+ open.mockReset()
18
+ delete process.env.GEOLITE2_COUNTRY_MMDB
19
+ delete process.env.OSSY_GEOLITE2_COUNTRY_MMDB
20
+ })
21
+
22
+ afterEach(() => {
23
+ resetGeoLite2CountryReaderForTests()
24
+ if (previousGeo == null) delete process.env.GEOLITE2_COUNTRY_MMDB
25
+ else process.env.GEOLITE2_COUNTRY_MMDB = previousGeo
26
+ if (previousOssy == null) delete process.env.OSSY_GEOLITE2_COUNTRY_MMDB
27
+ else process.env.OSSY_GEOLITE2_COUNTRY_MMDB = previousOssy
28
+ })
29
+
30
+ it('returns null when no mmdb path is configured', async () => {
31
+ expect(await resolveCountryCodeFromIp('8.8.8.8')).toBeNull()
32
+ expect(open).not.toHaveBeenCalled()
33
+ })
34
+
35
+ it('returns ISO country from MaxMind when the reader opens', async () => {
36
+ process.env.GEOLITE2_COUNTRY_MMDB = '/var/lib/GeoLite2-Country.mmdb'
37
+ open.mockResolvedValue({
38
+ get: (ip) => (ip === '8.8.8.8' ? { country: { iso_code: 'us' } } : null),
39
+ })
40
+
41
+ expect(await resolveCountryCodeFromIp('8.8.8.8')).toBe('US')
42
+ expect(await resolveCountryCodeFromIp('1.1.1.1')).toBeNull()
43
+ expect(await resolveCountryCodeFromIp('')).toBeNull()
44
+ expect(open).toHaveBeenCalledWith('/var/lib/GeoLite2-Country.mmdb')
45
+ })
46
+
47
+ it('returns null when MaxMind open fails', async () => {
48
+ process.env.GEOLITE2_COUNTRY_MMDB = '/missing.mmdb'
49
+ open.mockRejectedValue(new Error('ENOENT'))
50
+ expect(await resolveCountryCodeFromIp('8.8.8.8')).toBeNull()
51
+ })
52
+ })
@@ -0,0 +1,110 @@
1
+ const DOMAIN_SCHEMA_ID = '@ossy/domains/schema/managed-domain'
2
+
3
+ /**
4
+ * @param {string} raw
5
+ * @returns {string}
6
+ */
7
+ export function normalizeHost (raw) {
8
+ const host = String(raw || '').split(':')[0].trim().toLowerCase()
9
+ if (!host) return ''
10
+ return host.replace(/^www\./, '')
11
+ }
12
+
13
+ export function isLocalHost (host) {
14
+ return !host
15
+ || host === 'localhost'
16
+ || host === '127.0.0.1'
17
+ || host === '::1'
18
+ || host.endsWith('.localhost')
19
+ }
20
+
21
+ function hostFromHeaderUrl (value) {
22
+ if (!value) return ''
23
+ try {
24
+ return normalizeHost(new URL(String(value)).hostname)
25
+ } catch {
26
+ return ''
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Host for public ingest. Prefer Origin (cross-origin POST) then Referer,
32
+ * then req.hostname. Client payload must not choose the Host.
33
+ *
34
+ * @param {{ hostname?: string, headers?: Record<string, string>, get?: Function } | null | undefined} req
35
+ * @returns {string}
36
+ */
37
+ export function hostFromRequest (req) {
38
+ const headers = req?.headers || {}
39
+ const get = typeof req?.get === 'function'
40
+ ? (name) => req.get(name)
41
+ : (name) => headers[name] || headers[String(name).toLowerCase()]
42
+
43
+ return hostFromHeaderUrl(get('origin'))
44
+ || hostFromHeaderUrl(get('referer'))
45
+ || normalizeHost(req?.hostname || get('host'))
46
+ }
47
+
48
+ /**
49
+ * Workspace that registered this Host, or undefined. No cookie/config fallback.
50
+ *
51
+ * @param {{
52
+ * host: string,
53
+ * findDomain?: (host: string) => Promise<string | undefined>,
54
+ * }} opts
55
+ * @returns {Promise<string | undefined>}
56
+ */
57
+ export async function resolvePageViewWorkspaceId ({ host, findDomain } = {}) {
58
+ const normalized = normalizeHost(host)
59
+ if (!normalized || !findDomain) return undefined
60
+ return findDomain(normalized)
61
+ }
62
+
63
+ function domainNameVariants (host) {
64
+ const normalized = normalizeHost(host)
65
+ if (!normalized) return []
66
+ return [normalized, `www.${normalized}`]
67
+ }
68
+
69
+ /**
70
+ * @param {{ findOne: Function }} collection
71
+ * @param {string} host
72
+ * @returns {Promise<string | undefined>}
73
+ */
74
+ export async function workspaceIdForRegisteredHost (collection, host) {
75
+ const names = domainNameVariants(host)
76
+ if (!collection?.findOne || names.length === 0) return undefined
77
+ const doc = await collection.findOne({
78
+ type: DOMAIN_SCHEMA_ID,
79
+ 'state.status': { $ne: 'removed' },
80
+ $or: [
81
+ { 'state.name': { $in: names } },
82
+ { 'state.content.Domain': { $in: names } },
83
+ { 'state.content.domain': { $in: names } },
84
+ ],
85
+ })
86
+ return doc?.state?.belongsTo || undefined
87
+ }
88
+
89
+ const CONTENT_KEYS = ['path', 'section', 'language', 'referrer', 'userAgent', 'visitorId']
90
+
91
+ /**
92
+ * Persist only page-view fields. `host` always comes from the server.
93
+ * When the view happened is resource `created` (envelope), not content.
94
+ * Connection `ip` is stamped on the eventstore row at append (ADR 0016) — never from the client.
95
+ *
96
+ * @param {Record<string, unknown>} payload
97
+ * @param {{ userAgent?: string, path?: string, host?: string }} fallbacks
98
+ */
99
+ export function pageViewContentFromPayload (payload, fallbacks = {}) {
100
+ const src = payload && typeof payload === 'object' ? payload : {}
101
+ const content = {}
102
+ for (const key of CONTENT_KEYS) {
103
+ const value = src[key]
104
+ if (value != null && value !== '') content[key] = value
105
+ }
106
+ if (content.userAgent == null && fallbacks.userAgent) content.userAgent = fallbacks.userAgent
107
+ content.path = content.path || fallbacks.path || '/'
108
+ if (fallbacks.host) content.host = fallbacks.host
109
+ return content
110
+ }
@@ -0,0 +1,101 @@
1
+ import { describe, expect, it, jest } from '@jest/globals'
2
+ import {
3
+ hostFromRequest,
4
+ isLocalHost,
5
+ normalizeHost,
6
+ pageViewContentFromPayload,
7
+ resolvePageViewWorkspaceId,
8
+ workspaceIdForRegisteredHost,
9
+ } from './resolve-page-view-workspace.js'
10
+
11
+ describe('normalizeHost', () => {
12
+ it('strips port and www', () => {
13
+ expect(normalizeHost('www.Ossy.se:443')).toBe('ossy.se')
14
+ })
15
+ })
16
+
17
+ describe('hostFromRequest', () => {
18
+ it('prefers Origin over hostname so cross-origin ingest uses the page Host', () => {
19
+ expect(hostFromRequest({
20
+ hostname: 'ossy.se',
21
+ headers: { origin: 'https://www.customer.com', host: 'ossy.se' },
22
+ })).toBe('customer.com')
23
+ })
24
+
25
+ it('falls back to hostname when Origin is missing', () => {
26
+ expect(hostFromRequest({ hostname: 'www.ossy.se:443' })).toBe('ossy.se')
27
+ })
28
+ })
29
+
30
+ describe('resolvePageViewWorkspaceId', () => {
31
+ it('looks up the Host and ignores cookies', async () => {
32
+ const findDomain = jest.fn(async () => 'ws-ossy')
33
+ const id = await resolvePageViewWorkspaceId({ host: 'ossy.se', findDomain })
34
+ expect(id).toBe('ws-ossy')
35
+ expect(findDomain).toHaveBeenCalledWith('ossy.se')
36
+ })
37
+
38
+ it('looks up localhost like any other Host', async () => {
39
+ const findDomain = jest.fn(async () => 'ws-local')
40
+ expect(await resolvePageViewWorkspaceId({
41
+ host: 'localhost',
42
+ findDomain,
43
+ })).toBe('ws-local')
44
+ expect(findDomain).toHaveBeenCalledWith('localhost')
45
+ })
46
+
47
+ it('returns undefined for unknown Hosts', async () => {
48
+ expect(await resolvePageViewWorkspaceId({
49
+ host: 'unknown.example',
50
+ findDomain: async () => undefined,
51
+ })).toBeUndefined()
52
+ })
53
+ })
54
+
55
+ describe('workspaceIdForRegisteredHost', () => {
56
+ it('matches apex or www name', async () => {
57
+ const findOne = jest.fn(async () => ({ state: { belongsTo: 'ws-1' } }))
58
+ await expect(workspaceIdForRegisteredHost({ findOne }, 'ossy.se')).resolves.toBe('ws-1')
59
+ expect(findOne).toHaveBeenCalledWith(expect.objectContaining({
60
+ $or: expect.arrayContaining([
61
+ { 'state.name': { $in: ['ossy.se', 'www.ossy.se'] } },
62
+ { 'state.content.Domain': { $in: ['ossy.se', 'www.ossy.se'] } },
63
+ ]),
64
+ }))
65
+ })
66
+
67
+ it('matches a Domains UI document by content.Domain', async () => {
68
+ const findOne = jest.fn(async () => ({ state: { belongsTo: 'ws-1' } }))
69
+ await expect(workspaceIdForRegisteredHost({ findOne }, 'localhost')).resolves.toBe('ws-1')
70
+ expect(findOne).toHaveBeenCalledWith(expect.objectContaining({
71
+ $or: expect.arrayContaining([
72
+ { 'state.content.Domain': { $in: ['localhost', 'www.localhost'] } },
73
+ ]),
74
+ }))
75
+ })
76
+ })
77
+
78
+ describe('pageViewContentFromPayload', () => {
79
+ it('keeps page-view fields, sets server host, drops workspaceId / ip / eventAt', () => {
80
+ expect(pageViewContentFromPayload({
81
+ path: '/pricing',
82
+ visitorId: 'v1',
83
+ workspaceId: 'ws-spoof',
84
+ ip: '1.2.3.4',
85
+ host: 'evil.example',
86
+ eventAt: 100,
87
+ }, { userAgent: 'Mozilla', path: '/', host: 'ossy.se' })).toEqual({
88
+ path: '/pricing',
89
+ visitorId: 'v1',
90
+ userAgent: 'Mozilla',
91
+ host: 'ossy.se',
92
+ })
93
+ })
94
+ })
95
+
96
+ describe('isLocalHost', () => {
97
+ it('treats loopback as local', () => {
98
+ expect(isLocalHost('localhost')).toBe(true)
99
+ expect(isLocalHost('ossy.se')).toBe(false)
100
+ })
101
+ })
@@ -1,15 +1,18 @@
1
1
  {
2
2
  "analytics/home.documentTitle": "Analys",
3
3
  "analytics.home.title": "Analys",
4
- "analytics.home.description": "Workspace-KPIer för medlemmar, resurser och trafik.",
4
+ "analytics.home.description": "Unika besökare och sidvisningar för en registrerad domän.",
5
5
  "analytics.home.overview": "Översikt",
6
6
  "analytics.home.traffic": "Trafik",
7
7
  "analytics.home.loading": "Laddar analys...",
8
8
  "analytics.home.error": "Kunde inte ladda analysdata.",
9
- "analytics.home.members": "Medlemmar",
10
- "analytics.home.resources": "Resurser",
11
- "analytics.home.workspaceKpisError": "Kunde inte ladda workspace-KPIer.",
12
- "analytics.home.totalPageViews": "Totala sidvisningar (30 dagar)",
9
+ "analytics.home.empty": "Registrera en domän för att se besökare och sidvisningar för den.",
10
+ "analytics.home.emptyEnableDomains": "Aktivera Domäner för workspace, registrera sedan en domän för att se besökare.",
11
+ "analytics.home.addDomain": "Lägg till en domän",
12
+ "analytics.home.enableDomains": "Aktivera domäner",
13
+ "analytics.home.hostLabel": "Domän",
14
+ "analytics.home.uniqueVisitors": "Besökare (30 dagar)",
15
+ "analytics.home.totalPageViews": "Sidvisningar (30 dagar)",
13
16
  "analytics.home.trackedPages": "Spårade sidor (30 dagar)",
14
17
  "analytics.home.daysWithTraffic": "Dagar med trafik",
15
18
  "analytics.home.topPaths": "Topp-sökvägar",
@@ -17,18 +20,26 @@
17
20
  "analytics.home.unknownPath": "(okänd sökväg)",
18
21
  "analytics.home.dailyPageViews": "Dagliga sidvisningar",
19
22
  "analytics.home.noDailyData": "Ingen daglig data ännu.",
20
- "analytics.home.cover.title": "Se hur workspace används",
21
- "analytics.home.cover.text": "Medlemmar, resurser och sidtrafik KPIer för team som bygger plattformen.",
23
+ "analytics.home.locations": "Besökarnas platser",
24
+ "analytics.home.noLocationData": "Ingen platsdata ännu. Land härleds från besökarens IP (GeoLite2) när det finns — råa IP-adresser visas inte.",
25
+ "analytics.home.cover.title": "Se vilka som besöker din sajt",
26
+ "analytics.home.cover.text": "Unika besökare och sidvisningar för varje domän du registrerar — även de som aldrig loggar in.",
22
27
  "analytics.home.cover.ctaPrimary": "Kom igång gratis",
23
- "analytics.home.features.members.title": "Medlemmar",
24
- "analytics.home.features.members.text": "Se hur många personer som finns i workspace.",
25
- "analytics.home.features.resources.title": "Resurser",
26
- "analytics.home.features.resources.text": "Följ hur mycket innehåll och data ni lagrar.",
28
+ "analytics.home.features.visitors.title": "Besökare",
29
+ "analytics.home.features.visitors.text": "Räkna alla en registrerad domän anonyma besökare och inloggade medlemmar.",
30
+ "analytics.home.features.paths.title": "Topp-sökvägar",
31
+ "analytics.home.features.paths.text": "Se vilka sidor folk öppnar mest under de senaste 30 dagarna.",
27
32
  "analytics.home.features.traffic.title": "Trafik",
28
- "analytics.home.features.traffic.text": "Sidvisningar de senaste 30 dagarna, per sökväg och dag.",
33
+ "analytics.home.features.traffic.text": "Välj en domän i taget. Varje domän har egna besökar-, sidvisnings- och plats-siffror.",
29
34
  "analytics.sales.overview": "Översikt",
30
35
  "analytics.sales.features": "Funktioner",
31
36
  "analytics.sales.enable": "Aktivera analys",
32
- "analytics.sales.enableDescription": "Aktivera analys för workspace för att se KPIer för medlemmar, resurser och trafik.",
33
- "analytics.sales.enableError": "Kunde inte aktivera analys. Försök igen eller kontakta support."
37
+ "analytics.sales.enableDescription": "Aktivera analys för att se besökare och sidvisningar för domäner du registrerar.",
38
+ "analytics.sales.enableError": "Kunde inte aktivera analys. Försök igen eller kontakta support.",
39
+ "@ossy/analytics/actions/create-page-view.label": "Skapa sidvisning",
40
+ "@ossy/analytics/actions/create-page-view.description": "Registrera en sidvisning för en registrerad domän",
41
+ "@ossy/analytics/actions/get-page-view-stats.label": "Hämta sidvisningsstatistik",
42
+ "@ossy/analytics/actions/get-page-view-stats.description": "Hämta besökar- och sidvisningsstatistik för en registrerad domän",
43
+ "@ossy/analytics/actions/get-workspace-kpis.label": "Hämta workspace-KPIer",
44
+ "@ossy/analytics/actions/get-workspace-kpis.description": "Antal medlemmar och resurser för aktuellt workspace"
34
45
  }
@@ -1,8 +1,8 @@
1
1
  import { useEffect, useRef, useState } from 'react'
2
- import { GetPageViewStats } from '@ossy/resources'
2
+ import { metadata as GetPageViewStats } from './get-page-view-stats.action.js'
3
3
  import { useSdk } from '@ossy/sdk-react'
4
4
 
5
- export function usePageViewStats({ workspaceId, from, to, limit = 10 }) {
5
+ export function usePageViewStats({ workspaceId, host, from, to, limit = 10 }) {
6
6
  const sdk = useSdk()
7
7
  const invokeRef = useRef(sdk.invoke)
8
8
  invokeRef.current = sdk.invoke
@@ -10,23 +10,28 @@ export function usePageViewStats({ workspaceId, from, to, limit = 10 }) {
10
10
  const [status, setStatus] = useState('idle')
11
11
  const [data, setData] = useState({
12
12
  totalViews: 0,
13
+ uniqueVisitors: 0,
13
14
  dailyViews: [],
14
15
  topPaths: [],
16
+ byCountry: [],
15
17
  })
16
18
 
17
19
  useEffect(() => {
18
- if (!workspaceId) return undefined
20
+ if (!workspaceId || !host) return undefined
19
21
 
20
22
  let cancelled = false
21
- setStatus((current) => (current === 'success' ? current : 'loading'))
23
+ setStatus('loading')
22
24
 
23
- invokeRef.current(GetPageViewStats, { from, to, limit })
25
+ // countryLimit stays at the task default (~250 ISO codes); do not reuse top-paths `limit`.
26
+ invokeRef.current(GetPageViewStats, { host, from, to, limit })
24
27
  .then((json) => {
25
28
  if (cancelled) return
26
29
  setData({
27
30
  totalViews: json?.totalViews || 0,
31
+ uniqueVisitors: json?.uniqueVisitors || 0,
28
32
  dailyViews: json?.dailyViews || [],
29
33
  topPaths: json?.topPaths || [],
34
+ byCountry: json?.byCountry || [],
30
35
  })
31
36
  setStatus('success')
32
37
  })
@@ -38,7 +43,7 @@ export function usePageViewStats({ workspaceId, from, to, limit = 10 }) {
38
43
  return () => {
39
44
  cancelled = true
40
45
  }
41
- }, [workspaceId, from, to, limit])
46
+ }, [workspaceId, host, from, to, limit])
42
47
 
43
48
  return {
44
49
  status,
@@ -8,18 +8,18 @@ export const metadata = {
8
8
  }
9
9
 
10
10
  /**
11
- * After enabling analytics, wait until workspace KPIs and page-view stats
12
- * settle so the product home overview is ready (not just mounted).
11
+ * After enabling analytics, wait until the product home settles.
12
+ * New workspaces have no domain, so the overview is empty not traffic-ready.
13
13
  */
14
14
  export default {
15
15
  title: 'View analytics overview',
16
16
  description:
17
- 'Signed-in user enables analytics and sees the product overview settle to ready',
17
+ 'Signed-in user enables analytics and sees an empty Host state or traffic for a registered domain',
18
18
  steps: [
19
19
  ...enableAnalyticsFlow.steps,
20
20
  {
21
21
  result: {
22
- selector: '[data-analytics-overview-status="ready"]',
22
+ selector: '[data-analytics-overview-status="empty"], [data-analytics-overview-status="ready"]',
23
23
  timeout: 20000,
24
24
  },
25
25
  },
@@ -1,45 +0,0 @@
1
- import { useEffect, useRef, 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 invokeRef = useRef(sdk.invoke)
8
- invokeRef.current = sdk.invoke
9
-
10
- const [status, setStatus] = useState('idle')
11
- const [data, setData] = useState({
12
- members: 0,
13
- resources: 0,
14
- })
15
-
16
- useEffect(() => {
17
- if (!workspaceId) return undefined
18
-
19
- let cancelled = false
20
- setStatus((current) => (current === 'success' ? current : 'loading'))
21
-
22
- invokeRef.current(GetWorkspaceKpis, {})
23
- .then((json) => {
24
- if (cancelled) return
25
- setData({
26
- members: json?.members || 0,
27
- resources: json?.resources || 0,
28
- })
29
- setStatus('success')
30
- })
31
- .catch(() => {
32
- if (cancelled) return
33
- setStatus('error')
34
- })
35
-
36
- return () => {
37
- cancelled = true
38
- }
39
- }, [workspaceId])
40
-
41
- return {
42
- status,
43
- ...data,
44
- }
45
- }