diff --git a/README.md b/README.md index 4f3db0e..bf6eb4d 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ -# @chiffre/template-library +# @chiffre/analytics-core -[![NPM](https://img.shields.io/npm/v/@chiffre/template-library?color=red)](https://www.npmjs.com/package/@chiffre/template-library) -[![MIT License](https://img.shields.io/github/license/chiffre-io/template-library.svg?color=blue)](https://github.com/chiffre-io/template-library/blob/next/LICENSE) -[![Continuous Integration](https://github.com/chiffre-io/template-library/workflows/Continuous%20Integration/badge.svg?branch=next)](https://github.com/chiffre-io/template-library/actions) -[![Coverage Status](https://coveralls.io/repos/github/chiffre-io/template-library/badge.svg?branch=next)](https://coveralls.io/github/chiffre-io/template-library?branch=next) -[![Dependabot Status](https://api.dependabot.com/badges/status?host=github&repo=chiffre-io/template-library)](https://dependabot.com) +[![NPM](https://img.shields.io/npm/v/@chiffre/analytics-core?color=red)](https://www.npmjs.com/package/@chiffre/analytics-core) +[![MIT License](https://img.shields.io/github/license/chiffre-io/analytics-core.svg?color=blue)](https://github.com/chiffre-io/analytics-core/blob/next/LICENSE) +[![Continuous Integration](https://github.com/chiffre-io/analytics-core/workflows/Continuous%20Integration/badge.svg?branch=next)](https://github.com/chiffre-io/analytics-core/actions) +[![Coverage Status](https://coveralls.io/repos/github/chiffre-io/analytics-core/badge.svg?branch=next)](https://coveralls.io/github/chiffre-io/analytics-core?branch=next) +[![Dependabot Status](https://api.dependabot.com/badges/status?host=github&repo=chiffre-io/analytics-core)](https://dependabot.com) -Template for Chiffre libraries +Types & definitions for Chiffre Analytics ## License -[MIT](https://github.com/chiffre-io/template-library/blob/next/LICENSE) - Made with ❤️ by [François Best](https://francoisbest.com). +[MIT](https://github.com/chiffre-io/analytics-core/blob/next/LICENSE) - Made with ❤️ by [François Best](https://francoisbest.com). diff --git a/package.json b/package.json index 6092987..1b213d7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "@chiffre/template-library", + "name": "@chiffre/analytics-core", "version": "0.0.0-semantically-released", - "description": "Template for Chiffre libraries", + "description": "Types & definitions for Chiffre Analytics", "main": "dist/index.js", "license": "MIT", "author": { @@ -11,11 +11,11 @@ }, "repository": { "type": "git", - "url": "https://github.com/chiffre-io/template-library" + "url": "https://github.com/chiffre-io/analytics-core" }, "keywords": [ "chiffre", - "template" + "analytics" ], "publishConfig": { "access": "public" @@ -28,14 +28,15 @@ "build": "run-s build:clean build:ts", "ci": "run-s build test" }, - "dependencies": {}, + "dependencies": { + "nanoid": "^2.1.11" + }, "devDependencies": { "@commitlint/config-conventional": "^8.3.4", - "@types/faker": "^4.1.10", "@types/jest": "^25.1.4", + "@types/nanoid": "^2.1.0", "@types/node": "^13.9.1", "commitlint": "^8.3.5", - "faker": "^4.1.0", "husky": "^4.2.3", "jest": "^25.1.0", "npm-run-all": "^4.1.5", @@ -46,7 +47,7 @@ "jest": { "verbose": true, "preset": "ts-jest/presets/js-with-ts", - "testEnvironment": "node" + "testEnvironment": "jsdom" }, "husky": { "hooks": { diff --git a/src/events.ts b/src/events.ts new file mode 100644 index 0000000..aecbb17 --- /dev/null +++ b/src/events.ts @@ -0,0 +1,132 @@ +import { SessionData } from './session' +import { PageVisitData } from './navigation' + +export interface Event { + type: K + time: number + data?: T[K] +} + +// --- + +export interface GenericDataPoint { + name: string + value: T + meta?: Meta +} + +export type GenericEvents = { + 'generic:number': GenericDataPoint + 'generic:numbers': GenericDataPoint[] + 'generic:string': GenericDataPoint + 'generic:strings': GenericDataPoint[] +} +export type GenericEvent = Event + +export function isGenericEvent(event: AllEvents): event is GenericEvent { + return ( + isGenericNumberEvent(event) || + isGenericNumbersEvent(event) || + isGenericStringEvent(event) || + isGenericStringsEvent(event) + ) +} +export function isGenericNumberEvent( + event: AllEvents +): event is Event { + return event.type === 'generic:number' +} +export function isGenericNumbersEvent( + event: AllEvents +): event is Event { + return event.type === 'generic:numbers' +} +export function isGenericStringEvent( + event: AllEvents +): event is Event { + return event.type === 'generic:string' +} +export function isGenericStringsEvent( + event: AllEvents +): event is Event { + return event.type === 'generic:strings' +} + +// -- + +export interface BrowserEventData { + sid: string + path: string +} + +export type BrowserDataPoint = BrowserEventData & T + +export type BrowserEvents = { + 'session:start': BrowserDataPoint + 'session:dnt': never + 'session:end': BrowserDataPoint + 'page:visit': BrowserDataPoint + 'page:hide': BrowserDataPoint + 'page:show': BrowserDataPoint +} +export type BrowserEvent = Event + +export function isBrowserEvent(event: AllEvents): event is BrowserEvent { + return ( + isSessionStartEvent(event) || + isSessionEndEvent(event) || + isPageVisitEvent(event) || + isPageHideEvent(event) || + isPageShowEvent(event) + ) +} +export function isSessionStartEvent( + event: AllEvents +): event is Event { + return event.type === 'session:start' +} +export function isSessionDNTEvent( + event: AllEvents +): event is Event { + return event.type === 'session:dnt' +} +export function isSessionEndEvent( + event: AllEvents +): event is Event { + return event.type === 'session:end' +} +export function isPageVisitEvent( + event: AllEvents +): event is Event { + return event.type === 'page:visit' +} +export function isPageHideEvent( + event: AllEvents +): event is Event { + return event.type === 'page:hide' +} +export function isPageShowEvent( + event: AllEvents +): event is Event { + return event.type === 'page:show' +} + +function eventFactory() { + return function createEvent( + type: K, + data?: Events[K] + ): Event { + return { + type, + time: Date.now(), + data + } + } +} + +export const createGenericEvent = eventFactory() +export const createBrowserEvent = eventFactory() + +export type EventSender = (event: Event) => void + +export type AllEvents = GenericEvent | BrowserEvent diff --git a/src/index.test.ts b/src/index.test.ts index f1313cf..f0c893f 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,5 +1,36 @@ -import hello from './index' +import { sessionStart, sessionEnd, pageHide, pageShow } from './session' +import { + isBrowserEvent, + isSessionStartEvent, + isSessionEndEvent, + isPageHideEvent, + isPageShowEvent +} from './events' -test('testing works', () => { - expect(hello('World')).toEqual('Hello, World !') +test('sessionStart', () => { + const event = sessionStart() + expect(event.type).toEqual('session:start') + expect(isBrowserEvent(event)).toEqual(true) + expect(isSessionStartEvent(event)).toEqual(true) +}) + +test('sessionEnd', () => { + const event = sessionEnd() + expect(event.type).toEqual('session:end') + expect(isBrowserEvent(event)).toEqual(true) + expect(isSessionEndEvent(event)).toEqual(true) +}) + +test('pageHide', () => { + const event = pageHide() + expect(event.type).toEqual('page:hide') + expect(isBrowserEvent(event)).toEqual(true) + expect(isPageHideEvent(event)).toEqual(true) +}) + +test('pageShow', () => { + const event = pageShow() + expect(event.type).toEqual('page:show') + expect(isBrowserEvent(event)).toEqual(true) + expect(isPageShowEvent(event)).toEqual(true) }) diff --git a/src/index.ts b/src/index.ts index b21024d..16537cb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,3 @@ -export default (name: string) => `Hello, ${name} !` +export * from './events' +export * from './session' +export * from './navigation' diff --git a/src/navigation.ts b/src/navigation.ts new file mode 100644 index 0000000..6ee1afc --- /dev/null +++ b/src/navigation.ts @@ -0,0 +1,24 @@ +import { createBrowserEvent, EventSender } from './events' +import { sessionID } from './session' + +export interface PageVisitData { + from: string +} + +export const setupPageVisitListeners = (send: EventSender) => { + let oldPath = window.location.pathname + + setInterval(() => { + const newPath = window.location.pathname + if (oldPath === newPath) { + return + } + const event = createBrowserEvent(`page:visit`, { + sid: sessionID, + from: oldPath, + path: newPath + }) + oldPath = newPath + send(event) + }, 500) +} diff --git a/src/session.ts b/src/session.ts new file mode 100644 index 0000000..3dfe21c --- /dev/null +++ b/src/session.ts @@ -0,0 +1,72 @@ +import nanoid from 'nanoid' +import { createBrowserEvent, EventSender } from './events' + +export const sessionID = nanoid() + +export interface SessionData { + ua: string // user-agent + os: string // operating system + ref: string // referer + lang: string // language + tzo: number // timezone offset from UTC in minutes + vp: { + // viewport dimensions + w: number + h: number + } + lvd?: string // last visit date, ISO-8601 +} + +export const sessionStart = () => { + const event = createBrowserEvent('session:start', { + ua: navigator.userAgent, + os: navigator.platform, + ref: document.referrer, + lang: navigator.language, + tzo: new Date().getTimezoneOffset(), + vp: { + w: window.innerWidth, + h: window.innerHeight + }, + lvd: window.localStorage.getItem('chiffre:last-visit-date') || undefined, + sid: sessionID, + path: window.location.pathname + }) + window.localStorage.setItem( + 'chiffre:last-visit-date', + new Date().toISOString().slice(0, 10) // YYYY-MM-DD + ) + return event +} + +export const sessionEnd = () => { + return createBrowserEvent('session:end', { + sid: sessionID, + path: window.location.pathname + }) +} + +export const pageHide = () => { + return createBrowserEvent('page:hide', { + sid: sessionID, + path: window.location.pathname + }) +} + +export const pageShow = () => { + return createBrowserEvent('page:show', { + sid: sessionID, + path: window.location.pathname + }) +} + +export const setupSessionListeners = (send: EventSender) => { + const startEvent = sessionStart() + window.addEventListener('beforeunload', () => { + send(sessionEnd()) + }) + window.addEventListener('visibilitychange', () => { + send(document.hidden ? pageHide() : pageShow()) + }) + send(startEvent) +} diff --git a/yarn.lock b/yarn.lock index cdba3e4..6d6e959 100644 --- a/yarn.lock +++ b/yarn.lock @@ -516,11 +516,6 @@ resolved "https://registry.yarnpkg.com/@types/color-name/-/color-name-1.1.1.tgz#1c1261bbeaa10a8055bbc5d8ab84b7b2afc846a0" integrity sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ== -"@types/faker@^4.1.10": - version "4.1.10" - resolved "https://registry.yarnpkg.com/@types/faker/-/faker-4.1.10.tgz#ec31466931086122b05be719d084989ffe3d6eb6" - integrity sha512-Z1UXXNyxUcuu7CSeRmVizMgH7zVYiwfiTgXMnSTvsYDUnVt3dbMSpPdfG/H41IBiclgFGQJgLVdDFeinhhmWTg== - "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": version "2.0.1" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz#42995b446db9a48a11a07ec083499a860e9138ff" @@ -549,7 +544,14 @@ jest-diff "^25.1.0" pretty-format "^25.1.0" -"@types/node@^13.9.1": +"@types/nanoid@^2.1.0": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@types/nanoid/-/nanoid-2.1.0.tgz#41edfda78986e9127d0dc14de982de766f994020" + integrity sha512-xdkn/oRTA0GSNPLIKZgHWqDTWZsVrieKomxJBOQUK9YDD+zfSgmwD5t4WJYra5S7XyhTw7tfvwznW+pFexaepQ== + dependencies: + "@types/node" "*" + +"@types/node@*", "@types/node@^13.9.1": version "13.9.1" resolved "https://registry.yarnpkg.com/@types/node/-/node-13.9.1.tgz#96f606f8cd67fb018847d9b61e93997dabdefc72" integrity sha512-E6M6N0blf/jiZx8Q3nb0vNaswQeEyn0XlupO+xN6DtJ6r6IT4nXrTry7zhIfYvFCl3/8Cu6WIysmUBKiqV0bqQ== @@ -1538,11 +1540,6 @@ extsprintf@^1.2.0: resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f" integrity sha1-4mifjzVvrWLMplo6kcXfX5VRaS8= -faker@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/faker/-/faker-4.1.0.tgz#1e45bbbecc6774b3c195fad2835109c6d748cc3f" - integrity sha1-HkW7vsxndLPBlfrSg1EJxtdIzD8= - fast-deep-equal@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.1.tgz#545145077c501491e33b15ec408c294376e94ae4" @@ -2881,6 +2878,11 @@ ms@^2.1.1: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== +nanoid@^2.1.11: + version "2.1.11" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-2.1.11.tgz#ec24b8a758d591561531b4176a01e3ab4f0f0280" + integrity sha512-s/snB+WGm6uwi0WjsZdaVcuf3KJXlfGl2LcxgwkEwJF0D/BWzVWAZW/XY4bFaiR7s0Jk3FPvlnepg1H1b1UwlA== + nanomatch@^1.2.9: version "1.2.13" resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119"