[NEW] Engagement Dashboard (#16960)
parent
7ee5473189
commit
80c69c0fb8
@ -0,0 +1,11 @@ |
||||
import { Base } from './_Base'; |
||||
|
||||
export class Analytics extends Base { |
||||
constructor() { |
||||
super('analytics'); |
||||
this.tryEnsureIndex({ date: 1 }); |
||||
this.tryEnsureIndex({ 'room._id': 1, date: 1 }, { unique: true }); |
||||
} |
||||
} |
||||
|
||||
export default new Analytics(); |
||||
@ -0,0 +1,145 @@ |
||||
import { Random } from 'meteor/random'; |
||||
|
||||
import { BaseRaw } from './BaseRaw'; |
||||
import Analytics from '../models/Analytics'; |
||||
|
||||
export class AnalyticsRaw extends BaseRaw { |
||||
saveMessageSent({ room, date }) { |
||||
return this.update({ date, 'room._id': room._id, type: 'messages' }, { |
||||
$set: { |
||||
room: { _id: room._id, name: room.fname || room.name, t: room.t, usernames: room.usernames || [] }, |
||||
}, |
||||
$setOnInsert: { |
||||
_id: Random.id(), |
||||
date, |
||||
type: 'messages', |
||||
}, |
||||
$inc: { messages: 1 }, |
||||
}, { upsert: true }); |
||||
} |
||||
|
||||
saveUserData({ date }) { |
||||
return this.update({ date, type: 'users' }, { |
||||
$setOnInsert: { |
||||
_id: Random.id(), |
||||
date, |
||||
type: 'users', |
||||
}, |
||||
$inc: { users: 1 }, |
||||
}, { upsert: true }); |
||||
} |
||||
|
||||
saveMessageDeleted({ room, date }) { |
||||
return this.update({ date, 'room._id': room._id }, { |
||||
$inc: { messages: -1 }, |
||||
}); |
||||
} |
||||
|
||||
getMessagesSentTotalByDate({ start, end, options = {} }) { |
||||
const params = [ |
||||
{ |
||||
$match: { |
||||
type: 'messages', |
||||
date: { $gte: start, $lte: end }, |
||||
}, |
||||
}, |
||||
{ |
||||
$group: { |
||||
_id: '$date', |
||||
messages: { $sum: '$messages' }, |
||||
}, |
||||
}, |
||||
]; |
||||
if (options.sort) { |
||||
params.push({ $sort: options.sort }); |
||||
} |
||||
if (options.count) { |
||||
params.push({ $limit: options.count }); |
||||
} |
||||
return this.col.aggregate(params).toArray(); |
||||
} |
||||
|
||||
getMessagesOrigin({ start, end }) { |
||||
const params = [ |
||||
{ |
||||
$match: { |
||||
type: 'messages', |
||||
date: { $gte: start, $lte: end }, |
||||
}, |
||||
}, |
||||
{ |
||||
$group: { |
||||
_id: { t: '$room.t' }, |
||||
messages: { $sum: '$messages' }, |
||||
}, |
||||
}, |
||||
{ |
||||
$project: { |
||||
_id: 0, |
||||
t: '$_id.t', |
||||
messages: 1, |
||||
}, |
||||
}, |
||||
]; |
||||
return this.col.aggregate(params).toArray(); |
||||
} |
||||
|
||||
getMostPopularChannelsByMessagesSentQuantity({ start, end, options = {} }) { |
||||
const params = [ |
||||
{ |
||||
$match: { |
||||
type: 'messages', |
||||
date: { $gte: start, $lte: end }, |
||||
}, |
||||
}, |
||||
{ |
||||
$group: { |
||||
_id: { t: '$room.t', name: '$room.name', usernames: '$room.usernames' }, |
||||
messages: { $sum: '$messages' }, |
||||
}, |
||||
}, |
||||
{ |
||||
$project: { |
||||
_id: 0, |
||||
t: '$_id.t', |
||||
name: '$_id.name', |
||||
usernames: '$_id.usernames', |
||||
messages: 1, |
||||
}, |
||||
}, |
||||
]; |
||||
if (options.sort) { |
||||
params.push({ $sort: options.sort }); |
||||
} |
||||
if (options.count) { |
||||
params.push({ $limit: options.count }); |
||||
} |
||||
return this.col.aggregate(params).toArray(); |
||||
} |
||||
|
||||
getTotalOfRegisteredUsersByDate({ start, end, options = {} }) { |
||||
const params = [ |
||||
{ |
||||
$match: { |
||||
type: 'users', |
||||
date: { $gte: start, $lte: end }, |
||||
}, |
||||
}, |
||||
{ |
||||
$group: { |
||||
_id: '$date', |
||||
users: { $sum: '$users' }, |
||||
}, |
||||
}, |
||||
]; |
||||
if (options.sort) { |
||||
params.push({ $sort: options.sort }); |
||||
} |
||||
if (options.count) { |
||||
params.push({ $limit: options.count }); |
||||
} |
||||
return this.col.aggregate(params).toArray(); |
||||
} |
||||
} |
||||
|
||||
export default new AnalyticsRaw(Analytics.model.rawCollection()); |
||||
@ -0,0 +1,251 @@ |
||||
import { BaseRaw } from './BaseRaw'; |
||||
import Sessions from '../models/Sessions'; |
||||
|
||||
const matchBasedOnDate = (start, end) => { |
||||
if (start.year === end.year && start.month === end.month) { |
||||
return { |
||||
year: start.year, |
||||
month: start.month, |
||||
day: { $gte: start.day, $lte: end.day }, |
||||
}; |
||||
} |
||||
|
||||
if (start.year === end.year) { |
||||
return { |
||||
year: start.year, |
||||
$and: [{ |
||||
$or: [{ |
||||
month: { $gt: start.month }, |
||||
}, { |
||||
month: start.month, |
||||
day: { $gte: start.day }, |
||||
}], |
||||
}, { |
||||
$or: [{ |
||||
month: { $lt: end.month }, |
||||
}, { |
||||
month: end.month, |
||||
day: { $lte: end.day }, |
||||
}], |
||||
}], |
||||
}; |
||||
} |
||||
|
||||
return { |
||||
$and: [{ |
||||
$or: [{ |
||||
year: { $gt: start.year }, |
||||
}, { |
||||
year: start.year, |
||||
month: { $gt: start.month }, |
||||
}, { |
||||
year: start.year, |
||||
month: start.month, |
||||
day: { $gte: start.day }, |
||||
}], |
||||
}, { |
||||
$or: [{ |
||||
year: { $lt: end.year }, |
||||
}, { |
||||
year: end.year, |
||||
month: { $lt: end.month }, |
||||
}, { |
||||
year: end.year, |
||||
month: end.month, |
||||
day: { $lte: end.day }, |
||||
}], |
||||
}], |
||||
}; |
||||
}; |
||||
|
||||
const getGroupSessionsByHour = (_id) => { |
||||
const isOpenSession = { $not: ['$session.closedAt'] }; |
||||
const isAfterLoginAt = { $gte: ['$range', { $hour: '$session.loginAt' }] }; |
||||
const isBeforeClosedAt = { $lte: ['$range', { $hour: '$session.closedAt' }] }; |
||||
return { |
||||
$group: { |
||||
_id, |
||||
users: { |
||||
$sum: { |
||||
$cond: [ |
||||
{ |
||||
$or: [ |
||||
{ $and: [isOpenSession, isAfterLoginAt] }, |
||||
{ $and: [isAfterLoginAt, isBeforeClosedAt] }, |
||||
], |
||||
}, |
||||
1, |
||||
0, |
||||
], |
||||
}, |
||||
}, |
||||
}, |
||||
}; |
||||
}; |
||||
|
||||
const getSortByFullDate = () => ({ |
||||
year: -1, |
||||
month: -1, |
||||
day: -1, |
||||
}); |
||||
|
||||
const getProjectionByFullDate = () => ({ |
||||
day: '$_id.day', |
||||
month: '$_id.month', |
||||
year: '$_id.year', |
||||
}); |
||||
|
||||
export class SessionsRaw extends BaseRaw { |
||||
getActiveUsersBetweenDates({ start, end }) { |
||||
return this.col.aggregate([ |
||||
{ |
||||
$match: { |
||||
...matchBasedOnDate(start, end), |
||||
type: 'user_daily', |
||||
}, |
||||
}, |
||||
{ |
||||
$group: { |
||||
_id: '$userId', |
||||
}, |
||||
}, |
||||
]).toArray(); |
||||
} |
||||
|
||||
getActiveUsersOfPeriodByDayBetweenDates({ start, end }) { |
||||
return this.col.aggregate([ |
||||
{ |
||||
$match: { |
||||
...matchBasedOnDate(start, end), |
||||
type: 'user_daily', |
||||
}, |
||||
}, |
||||
{ |
||||
$group: { |
||||
_id: { |
||||
day: '$day', |
||||
month: '$month', |
||||
year: '$year', |
||||
}, |
||||
users: { $sum: 1 }, |
||||
}, |
||||
}, |
||||
{ |
||||
$project: { |
||||
_id: 0, |
||||
...getProjectionByFullDate(), |
||||
users: 1, |
||||
}, |
||||
}, |
||||
{ |
||||
$sort: { |
||||
...getSortByFullDate(), |
||||
}, |
||||
}, |
||||
]).toArray(); |
||||
} |
||||
|
||||
getBusiestTimeWithinHoursPeriod({ start, end }) { |
||||
const match = { |
||||
$match: { |
||||
type: 'computed-session', |
||||
loginAt: { $gte: start, $lte: end }, |
||||
}, |
||||
}; |
||||
const rangeProject = { |
||||
$project: { |
||||
range: { |
||||
$range: [0, 24], |
||||
}, |
||||
session: '$$ROOT', |
||||
}, |
||||
}; |
||||
const unwind = { |
||||
$unwind: '$range', |
||||
}; |
||||
const group = getGroupSessionsByHour('$range'); |
||||
const presentationProject = { |
||||
$project: { |
||||
_id: 0, |
||||
hour: '$_id', |
||||
users: 1, |
||||
}, |
||||
}; |
||||
const sort = { |
||||
$sort: { |
||||
hour: -1, |
||||
}, |
||||
}; |
||||
return this.col.aggregate([match, rangeProject, unwind, group, presentationProject, sort]).toArray(); |
||||
} |
||||
|
||||
getTotalOfSessionsByDayBetweenDates({ start, end }) { |
||||
return this.col.aggregate([ |
||||
{ |
||||
$match: { |
||||
...matchBasedOnDate(start, end), |
||||
type: 'user_daily', |
||||
}, |
||||
}, |
||||
{ |
||||
$group: { |
||||
_id: { year: '$year', month: '$month', day: '$day' }, |
||||
users: { $sum: '$sessions' }, |
||||
}, |
||||
}, |
||||
{ |
||||
$project: { |
||||
_id: 0, |
||||
...getProjectionByFullDate(), |
||||
users: 1, |
||||
}, |
||||
}, |
||||
{ |
||||
$sort: { |
||||
...getSortByFullDate(), |
||||
}, |
||||
}, |
||||
]).toArray(); |
||||
} |
||||
|
||||
getTotalOfSessionByHourAndDayBetweenDates({ start, end }) { |
||||
const match = { |
||||
$match: { |
||||
type: 'computed-session', |
||||
loginAt: { $gte: start, $lte: end }, |
||||
}, |
||||
}; |
||||
const rangeProject = { |
||||
$project: { |
||||
range: { |
||||
$range: [ |
||||
{ $hour: '$loginAt' }, |
||||
{ $sum: [{ $ifNull: [{ $hour: '$closedAt' }, 23] }, 1] }], |
||||
}, |
||||
session: '$$ROOT', |
||||
}, |
||||
|
||||
}; |
||||
const unwind = { |
||||
$unwind: '$range', |
||||
}; |
||||
const group = getGroupSessionsByHour({ range: '$range', day: '$session.day', month: '$session.month', year: '$session.year' }); |
||||
const presentationProject = { |
||||
$project: { |
||||
_id: 0, |
||||
hour: '$_id.range', |
||||
...getProjectionByFullDate(), |
||||
users: 1, |
||||
}, |
||||
}; |
||||
const sort = { |
||||
$sort: { |
||||
...getSortByFullDate(), |
||||
hour: -1, |
||||
}, |
||||
}; |
||||
return this.col.aggregate([match, rangeProject, unwind, group, presentationProject, sort]).toArray(); |
||||
} |
||||
} |
||||
|
||||
export default new SessionsRaw(Sessions.model.rawCollection()); |
||||
@ -0,0 +1,146 @@ |
||||
import { Box, Icon, Margins, Pagination, Select, Skeleton, Table, Tile } from '@rocket.chat/fuselage'; |
||||
import moment from 'moment'; |
||||
import React, { useMemo, useState } from 'react'; |
||||
|
||||
import { useTranslation } from '../../../../../../client/contexts/TranslationContext'; |
||||
import { Growth } from '../data/Growth'; |
||||
import { Section } from '../Section'; |
||||
import { useEndpointData } from '../../hooks/useEndpointData'; |
||||
|
||||
export function TableSection() { |
||||
const t = useTranslation(); |
||||
|
||||
const periodOptions = useMemo(() => [ |
||||
['last 7 days', t('Last_7_days')], |
||||
['last 30 days', t('Last_30_days')], |
||||
['last 90 days', t('Last_90_days')], |
||||
], [t]); |
||||
|
||||
const [periodId, setPeriodId] = useState('last 7 days'); |
||||
|
||||
const period = useMemo(() => { |
||||
switch (periodId) { |
||||
case 'last 7 days': |
||||
return { |
||||
start: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(7, 'days'), |
||||
end: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(1), |
||||
}; |
||||
|
||||
case 'last 30 days': |
||||
return { |
||||
start: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(30, 'days'), |
||||
end: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(1), |
||||
}; |
||||
|
||||
case 'last 90 days': |
||||
return { |
||||
start: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(90, 'days'), |
||||
end: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(1), |
||||
}; |
||||
} |
||||
}, [periodId]); |
||||
|
||||
const handlePeriodChange = (periodId) => setPeriodId(periodId); |
||||
|
||||
const [current, setCurrent] = useState(0); |
||||
const [itemsPerPage, setItemsPerPage] = useState(25); |
||||
|
||||
const params = useMemo(() => ({ |
||||
start: period.start.toISOString(), |
||||
end: period.end.toISOString(), |
||||
offset: current, |
||||
count: itemsPerPage, |
||||
}), [period, current, itemsPerPage]); |
||||
|
||||
const data = useEndpointData('GET', 'engagement-dashboard/channels/list', params); |
||||
|
||||
const channels = useMemo(() => { |
||||
if (!data) { |
||||
return; |
||||
} |
||||
|
||||
return data.channels.map(({ |
||||
room: { t, name, usernames, ts, _updatedAt }, |
||||
messages, |
||||
diffFromLastWeek, |
||||
}) => ({ |
||||
t, |
||||
name: name || usernames.join(' × '), |
||||
createdAt: ts, |
||||
updatedAt: _updatedAt, |
||||
messagesCount: messages, |
||||
messagesVariation: diffFromLastWeek, |
||||
})); |
||||
}, [data]); |
||||
|
||||
return <Section filter={<Select options={periodOptions} value={periodId} onChange={handlePeriodChange} />}> |
||||
<Box> |
||||
{channels && !channels.length && <Tile textStyle='p1' textColor='info' style={{ textAlign: 'center' }}> |
||||
{t('No_data_found')} |
||||
</Tile>} |
||||
{(!channels || channels.length) |
||||
&& <Table> |
||||
<Table.Head> |
||||
<Table.Row> |
||||
<Table.Cell>{'#'}</Table.Cell> |
||||
<Table.Cell>{t('Channel')}</Table.Cell> |
||||
<Table.Cell>{t('Created')}</Table.Cell> |
||||
<Table.Cell>{t('Last_active')}</Table.Cell> |
||||
<Table.Cell>{t('Messages_sent')}</Table.Cell> |
||||
</Table.Row> |
||||
</Table.Head> |
||||
<Table.Body> |
||||
{channels && channels.map(({ t, name, createdAt, updatedAt, messagesCount, messagesVariation }, i) => |
||||
<Table.Row key={i}> |
||||
<Table.Cell>{i + 1}.</Table.Cell> |
||||
<Table.Cell> |
||||
<Margins inlineEnd='x4'> |
||||
{(t === 'd' && <Icon name='at' />) |
||||
|| (t === 'c' && <Icon name='lock' />) |
||||
|| (t === 'p' && <Icon name='hashtag' />)} |
||||
</Margins> |
||||
{name} |
||||
</Table.Cell> |
||||
<Table.Cell> |
||||
{moment(createdAt).format('L')} |
||||
</Table.Cell> |
||||
<Table.Cell> |
||||
{moment(updatedAt).format('L')} |
||||
</Table.Cell> |
||||
<Table.Cell> |
||||
{messagesCount} <Growth>{messagesVariation}</Growth> |
||||
</Table.Cell> |
||||
</Table.Row>)} |
||||
{!channels && Array.from({ length: 5 }, (_, i) => |
||||
<Table.Row key={i}> |
||||
<Table.Cell> |
||||
<Skeleton width='100%' /> |
||||
</Table.Cell> |
||||
<Table.Cell> |
||||
<Skeleton width='100%' /> |
||||
</Table.Cell> |
||||
<Table.Cell> |
||||
<Skeleton width='100%' /> |
||||
</Table.Cell> |
||||
<Table.Cell> |
||||
<Skeleton width='100%' /> |
||||
</Table.Cell> |
||||
<Table.Cell> |
||||
<Skeleton width='100%' /> |
||||
</Table.Cell> |
||||
</Table.Row>)} |
||||
</Table.Body> |
||||
</Table>} |
||||
<Pagination |
||||
current={current} |
||||
itemsPerPage={itemsPerPage} |
||||
itemsPerPageLabel={() => t('Items_per_page:')} |
||||
showingResultsLabel={({ count, current, itemsPerPage }) => |
||||
t('Showing results %s - %s of %s', current + 1, Math.min(current + itemsPerPage, count), count)} |
||||
count={(data && data.total) || 0} |
||||
onSetItemsPerPage={setItemsPerPage} |
||||
onSetCurrent={setCurrent} |
||||
/> |
||||
</Box> |
||||
</Section>; |
||||
} |
||||
@ -0,0 +1,9 @@ |
||||
import React from 'react'; |
||||
|
||||
import { TableSection } from './TableSection'; |
||||
|
||||
export function ChannelsTab() { |
||||
return <> |
||||
<TableSection /> |
||||
</>; |
||||
} |
||||
@ -0,0 +1,14 @@ |
||||
import { Margins } from '@rocket.chat/fuselage'; |
||||
import React from 'react'; |
||||
|
||||
import { ChannelsTab } from '.'; |
||||
|
||||
export default { |
||||
title: 'admin/engagement/ChannelsTab', |
||||
component: ChannelsTab, |
||||
decorators: [ |
||||
(fn) => <Margins children={fn()} all='x24' />, |
||||
], |
||||
}; |
||||
|
||||
export const _default = () => <ChannelsTab />; |
||||
@ -0,0 +1,35 @@ |
||||
import { Box, Margins, Tabs } from '@rocket.chat/fuselage'; |
||||
import React from 'react'; |
||||
|
||||
import { useTranslation } from '../../../../../client/contexts/TranslationContext'; |
||||
import { Page } from '../../../../../client/components/basic/Page'; |
||||
import { UsersTab } from './UsersTab'; |
||||
import { MessagesTab } from './MessagesTab'; |
||||
import { ChannelsTab } from './ChannelsTab'; |
||||
|
||||
export function EngagementDashboardPage({ |
||||
tab = 'users', |
||||
onSelectTab, |
||||
}) { |
||||
const t = useTranslation(); |
||||
|
||||
const handleTabClick = onSelectTab ? (tab) => () => onSelectTab(tab) : () => undefined; |
||||
|
||||
return <Page> |
||||
<Page.Header title={t('Engagement_Dashboard')} /> |
||||
<Tabs> |
||||
<Tabs.Item selected={tab === 'users'} onClick={handleTabClick('users')}>{t('Users')}</Tabs.Item> |
||||
<Tabs.Item selected={tab === 'messages'} onClick={handleTabClick('messages')}>{t('Messages')}</Tabs.Item> |
||||
<Tabs.Item selected={tab === 'channels'} onClick={handleTabClick('channels')}>{t('Channels')}</Tabs.Item> |
||||
</Tabs> |
||||
<Page.Content style={{ padding: 0 }}> |
||||
<Margins all='x24'> |
||||
<Box> |
||||
{(tab === 'users' && <UsersTab />) |
||||
|| (tab === 'messages' && <MessagesTab />) |
||||
|| (tab === 'channels' && <ChannelsTab />)} |
||||
</Box> |
||||
</Margins> |
||||
</Page.Content> |
||||
</Page>; |
||||
} |
||||
@ -0,0 +1,11 @@ |
||||
import React from 'react'; |
||||
|
||||
import { EngagementDashboardPage } from './EngagementDashboardPage'; |
||||
|
||||
export default { |
||||
title: 'admin/engagement/EngagementDashboardPage', |
||||
component: EngagementDashboardPage, |
||||
decorators: [(fn) => <div children={fn()} style={{ height: '100vh' }} />], |
||||
}; |
||||
|
||||
export const _default = () => <EngagementDashboardPage />; |
||||
@ -0,0 +1,24 @@ |
||||
import React, { useEffect } from 'react'; |
||||
|
||||
import { useRoute, useRouteParameter } from '../../../../../client/contexts/RouterContext'; |
||||
import { useAdminSideNav } from '../../../../../client/hooks/useAdminSideNav'; |
||||
import { EngagementDashboardPage } from './EngagementDashboardPage'; |
||||
|
||||
export function EngagementDashboardRoute() { |
||||
useAdminSideNav(); |
||||
|
||||
const goToEngagementDashboard = useRoute('engagement-dashboard'); |
||||
|
||||
const tab = useRouteParameter('tab'); |
||||
|
||||
useEffect(() => { |
||||
if (!tab) { |
||||
goToEngagementDashboard.replacingState({ tab: 'users' }); |
||||
} |
||||
}, [tab]); |
||||
|
||||
return <EngagementDashboardPage |
||||
tab={tab} |
||||
onSelectTab={(tab) => goToEngagementDashboard({ tab })} |
||||
/>; |
||||
} |
||||
@ -0,0 +1,217 @@ |
||||
import { ResponsivePie } from '@nivo/pie'; |
||||
import { Box, Flex, Icon, Margins, Select, Skeleton, Table, Tile } from '@rocket.chat/fuselage'; |
||||
import moment from 'moment'; |
||||
import React, { useMemo, useState } from 'react'; |
||||
|
||||
import { useTranslation } from '../../../../../../client/contexts/TranslationContext'; |
||||
import { LegendSymbol } from '../data/LegendSymbol'; |
||||
import { useEndpointData } from '../../hooks/useEndpointData'; |
||||
import { Section } from '../Section'; |
||||
|
||||
export function MessagesPerChannelSection() { |
||||
const t = useTranslation(); |
||||
|
||||
const periodOptions = useMemo(() => [ |
||||
['last 7 days', t('Last_7_days')], |
||||
['last 30 days', t('Last_30_days')], |
||||
['last 90 days', t('Last_90_days')], |
||||
], [t]); |
||||
|
||||
const [periodId, setPeriodId] = useState('last 7 days'); |
||||
|
||||
const period = useMemo(() => { |
||||
switch (periodId) { |
||||
case 'last 7 days': |
||||
return { |
||||
start: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(7, 'days'), |
||||
end: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(1), |
||||
}; |
||||
|
||||
case 'last 30 days': |
||||
return { |
||||
start: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(30, 'days'), |
||||
end: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(1), |
||||
}; |
||||
|
||||
case 'last 90 days': |
||||
return { |
||||
start: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(90, 'days'), |
||||
end: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(1), |
||||
}; |
||||
} |
||||
}, [periodId]); |
||||
|
||||
const handlePeriodChange = (periodId) => setPeriodId(periodId); |
||||
|
||||
const params = useMemo(() => ({ |
||||
start: period.start.toISOString(), |
||||
end: period.end.toISOString(), |
||||
}), [period]); |
||||
|
||||
const pieData = useEndpointData('GET', 'engagement-dashboard/messages/origin', params); |
||||
const tableData = useEndpointData('GET', 'engagement-dashboard/messages/top-five-popular-channels', params); |
||||
|
||||
const [pie, table] = useMemo(() => { |
||||
if (!pieData || !tableData) { |
||||
return []; |
||||
} |
||||
|
||||
const pie = pieData.origins.reduce((obj, { messages, t }) => ({ ...obj, [t]: messages }), {}); |
||||
|
||||
const table = tableData.channels.reduce((entries, { t, messages, name, usernames }, i) => |
||||
[...entries, { i, t, name: name || usernames.join(' × '), messages }], []); |
||||
|
||||
return [pie, table]; |
||||
}, [period, pieData, tableData]); |
||||
|
||||
return <Section |
||||
title={t('Where_are_the_messages_being_sent?')} |
||||
filter={<Select options={periodOptions} value={periodId} onChange={handlePeriodChange} />} |
||||
> |
||||
<Flex.Container> |
||||
<Margins inline='neg-x12'> |
||||
<Box> |
||||
<Margins inline='x12'> |
||||
<Flex.Item grow={1} shrink={0} basis='0'> |
||||
<Box> |
||||
<Flex.Container alignItems='center' wrap='no-wrap'> |
||||
{pie |
||||
? <Box> |
||||
<Flex.Item grow={1} shrink={1}> |
||||
<Margins inline='x24'> |
||||
<Box style={{ position: 'relative', height: 300 }}> |
||||
<Box style={{ position: 'absolute', width: '100%', height: '100%' }}> |
||||
<ResponsivePie |
||||
data={[ |
||||
{ |
||||
id: 'd', |
||||
label: t('Private_Chats'), |
||||
value: pie.d, |
||||
color: '#FFD031', |
||||
}, |
||||
{ |
||||
id: 'c', |
||||
label: t('Private_Channels'), |
||||
value: pie.c, |
||||
color: '#2DE0A5', |
||||
}, |
||||
{ |
||||
id: 'p', |
||||
label: t('Public_Channels'), |
||||
value: pie.p, |
||||
color: '#1D74F5', |
||||
}, |
||||
]} |
||||
innerRadius={0.6} |
||||
colors={['#FFD031', '#2DE0A5', '#1D74F5']} |
||||
enableRadialLabels={false} |
||||
enableSlicesLabels={false} |
||||
animate={true} |
||||
motionStiffness={90} |
||||
motionDamping={15} |
||||
theme={{ |
||||
// TODO: Get it from theme
|
||||
axis: { |
||||
ticks: { |
||||
text: { |
||||
fill: '#9EA2A8', |
||||
fontFamily: 'Inter, -apple-system, system-ui, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Helvetica Neue", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Meiryo UI", Arial, sans-serif', |
||||
fontSize: 10, |
||||
fontStyle: 'normal', |
||||
fontWeight: '600', |
||||
letterSpacing: '0.2px', |
||||
lineHeight: '12px', |
||||
}, |
||||
}, |
||||
}, |
||||
tooltip: { |
||||
container: { |
||||
backgroundColor: '#1F2329', |
||||
boxShadow: '0px 0px 12px rgba(47, 52, 61, 0.12), 0px 0px 2px rgba(47, 52, 61, 0.08)', |
||||
borderRadius: 2, |
||||
}, |
||||
}, |
||||
}} |
||||
tooltip={({ value }) => <Box textStyle='p2' textColor='alternative'> |
||||
{t('Value_messages', { value })} |
||||
</Box>} |
||||
/> |
||||
</Box> |
||||
</Box> |
||||
</Margins> |
||||
</Flex.Item> |
||||
<Flex.Item basis='auto'> |
||||
<Margins block='neg-x4'> |
||||
<Box> |
||||
<Margins block='x4'> |
||||
<Box textColor='info' textStyle='p1'> |
||||
<LegendSymbol color='#FFD031' /> |
||||
{t('Private_Chats')} |
||||
</Box> |
||||
<Box textColor='info' textStyle='p1'> |
||||
<LegendSymbol color='#2DE0A5' /> |
||||
{t('Private_Channels')} |
||||
</Box> |
||||
<Box textColor='info' textStyle='p1'> |
||||
<LegendSymbol color='#1D74F5' /> |
||||
{t('Public_Channels')} |
||||
</Box> |
||||
</Margins> |
||||
</Box> |
||||
</Margins> |
||||
</Flex.Item> |
||||
</Box> |
||||
: <Skeleton variant='rect' height={300} />} |
||||
</Flex.Container> |
||||
</Box> |
||||
</Flex.Item> |
||||
<Flex.Item grow={1} shrink={0} basis='0'> |
||||
<Box> |
||||
<Margins blockEnd='x16'> |
||||
{table ? <Box textStyle='p1'>{t('Most_popular_channels_top_5')}</Box> : <Skeleton width='50%' />} |
||||
</Margins> |
||||
{table && !table.length && <Tile textStyle='p1' textColor='info' style={{ textAlign: 'center' }}> |
||||
{t('Not_enough_data')} |
||||
</Tile>} |
||||
{(!table || !!table.length) && <Table> |
||||
<Table.Head> |
||||
<Table.Row> |
||||
<Table.Cell>{'#'}</Table.Cell> |
||||
<Table.Cell>{t('Channel')}</Table.Cell> |
||||
<Table.Cell align='end'>{t('Number_of_messages')}</Table.Cell> |
||||
</Table.Row> |
||||
</Table.Head> |
||||
<Table.Body> |
||||
{table && table.map(({ i, t, name, messages }) => <Table.Row key={i}> |
||||
<Table.Cell>{i + 1}.</Table.Cell> |
||||
<Table.Cell> |
||||
<Margins inlineEnd='x4'> |
||||
{(t === 'd' && <Icon name='at' />) |
||||
|| (t === 'c' && <Icon name='lock' />) |
||||
|| (t === 'p' && <Icon name='hashtag' />)} |
||||
</Margins> |
||||
{name} |
||||
</Table.Cell> |
||||
<Table.Cell align='end'>{messages}</Table.Cell> |
||||
</Table.Row>)} |
||||
{!table && Array.from({ length: 5 }, (_, i) => <Table.Row key={i}> |
||||
<Table.Cell> |
||||
<Skeleton width='100%' /> |
||||
</Table.Cell> |
||||
<Table.Cell> |
||||
<Skeleton width='100%' /> |
||||
</Table.Cell> |
||||
<Table.Cell align='end'> |
||||
<Skeleton width='100%' /> |
||||
</Table.Cell> |
||||
</Table.Row>)} |
||||
</Table.Body> |
||||
</Table>} |
||||
</Box> |
||||
</Flex.Item> |
||||
</Margins> |
||||
</Box> |
||||
</Margins> |
||||
</Flex.Container> |
||||
</Section>; |
||||
} |
||||
@ -0,0 +1,169 @@ |
||||
import { ResponsiveBar } from '@nivo/bar'; |
||||
import { Box, Flex, Select, Skeleton } from '@rocket.chat/fuselage'; |
||||
import moment from 'moment'; |
||||
import React, { useMemo, useState } from 'react'; |
||||
|
||||
import { useTranslation } from '../../../../../../client/contexts/TranslationContext'; |
||||
import { CounterSet } from '../data/CounterSet'; |
||||
import { Section } from '../Section'; |
||||
import { useEndpointData } from '../../hooks/useEndpointData'; |
||||
|
||||
export function MessagesSentSection() { |
||||
const t = useTranslation(); |
||||
|
||||
const periodOptions = useMemo(() => [ |
||||
['last 7 days', t('Last_7_days')], |
||||
['last 30 days', t('Last_30_days')], |
||||
['last 90 days', t('Last_90_days')], |
||||
], [t]); |
||||
|
||||
const [periodId, setPeriodId] = useState('last 7 days'); |
||||
|
||||
const period = useMemo(() => { |
||||
switch (periodId) { |
||||
case 'last 7 days': |
||||
return { |
||||
start: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(7, 'days'), |
||||
end: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(1), |
||||
}; |
||||
|
||||
case 'last 30 days': |
||||
return { |
||||
start: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(30, 'days'), |
||||
end: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(1), |
||||
}; |
||||
|
||||
case 'last 90 days': |
||||
return { |
||||
start: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(90, 'days'), |
||||
end: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(1), |
||||
}; |
||||
} |
||||
}, [periodId]); |
||||
|
||||
const handlePeriodChange = (periodId) => setPeriodId(periodId); |
||||
|
||||
const params = useMemo(() => ({ |
||||
start: period.start.toISOString(), |
||||
end: period.end.toISOString(), |
||||
}), [period]); |
||||
|
||||
const data = useEndpointData('GET', 'engagement-dashboard/messages/messages-sent', params); |
||||
|
||||
const [ |
||||
countFromPeriod, |
||||
variatonFromPeriod, |
||||
countFromYesterday, |
||||
variationFromYesterday, |
||||
values, |
||||
] = useMemo(() => { |
||||
if (!data) { |
||||
return []; |
||||
} |
||||
|
||||
const values = Array.from({ length: moment(period.end).diff(period.start, 'days') + 1 }, (_, i) => ({ |
||||
date: moment(period.start).add(i, 'days').toISOString(), |
||||
newUsers: 0, |
||||
})); |
||||
for (const { day, users } of data.days) { |
||||
const i = moment(day).diff(period.start, 'days'); |
||||
values[i].newUsers += users; |
||||
} |
||||
|
||||
return [ |
||||
data.period.count, |
||||
data.period.variation, |
||||
data.yesterday.count, |
||||
data.yesterday.variation, |
||||
values, |
||||
]; |
||||
}, [data, period]); |
||||
|
||||
return <Section |
||||
title={t('Messages_sent')} |
||||
filter={<Select options={periodOptions} value={periodId} onChange={handlePeriodChange} />} |
||||
> |
||||
<CounterSet |
||||
counters={[ |
||||
{ |
||||
count: data ? countFromPeriod : <Skeleton variant='rect' width='3ex' height='1em' />, |
||||
variation: data ? variatonFromPeriod : 0, |
||||
description: periodOptions.find(([id]) => id === periodId)[1], |
||||
}, |
||||
{ |
||||
count: data ? countFromYesterday : <Skeleton variant='rect' width='3ex' height='1em' />, |
||||
variation: data ? variationFromYesterday : 0, |
||||
description: t('Yesterday'), |
||||
}, |
||||
]} |
||||
/> |
||||
<Flex.Container> |
||||
{data |
||||
? <Box style={{ height: 240 }}> |
||||
<Flex.Item align='stretch' grow={1} shrink={0}> |
||||
<Box style={{ position: 'relative' }}> |
||||
<Box style={{ position: 'absolute', width: '100%', height: '100%' }}> |
||||
<ResponsiveBar |
||||
data={values} |
||||
indexBy='date' |
||||
keys={['newUsers']} |
||||
groupMode='grouped' |
||||
padding={0.25} |
||||
margin={{ |
||||
// TODO: Get it from theme
|
||||
bottom: 20, |
||||
}} |
||||
colors={[ |
||||
// TODO: Get it from theme
|
||||
'#1d74f5', |
||||
]} |
||||
enableLabel={false} |
||||
enableGridY={false} |
||||
axisTop={null} |
||||
axisRight={null} |
||||
axisBottom={(values.length === 7 && { |
||||
tickSize: 0, |
||||
// TODO: Get it from theme
|
||||
tickPadding: 4, |
||||
tickRotation: 0, |
||||
format: (date) => moment(date).format('dddd'), |
||||
}) || null } |
||||
axisLeft={null} |
||||
animate={true} |
||||
motionStiffness={90} |
||||
motionDamping={15} |
||||
theme={{ |
||||
// TODO: Get it from theme
|
||||
axis: { |
||||
ticks: { |
||||
text: { |
||||
fill: '#9EA2A8', |
||||
fontFamily: 'Inter, -apple-system, system-ui, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Helvetica Neue", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Meiryo UI", Arial, sans-serif', |
||||
fontSize: '10px', |
||||
fontStyle: 'normal', |
||||
fontWeight: '600', |
||||
letterSpacing: '0.2px', |
||||
lineHeight: '12px', |
||||
}, |
||||
}, |
||||
}, |
||||
tooltip: { |
||||
container: { |
||||
backgroundColor: '#1F2329', |
||||
boxShadow: '0px 0px 12px rgba(47, 52, 61, 0.12), 0px 0px 2px rgba(47, 52, 61, 0.08)', |
||||
borderRadius: 2, |
||||
}, |
||||
}, |
||||
}} |
||||
tooltip={({ value }) => <Box textStyle='p2' textColor='alternative'> |
||||
{t('Value_users', { value })} |
||||
</Box>} |
||||
/> |
||||
</Box> |
||||
</Box> |
||||
</Flex.Item> |
||||
</Box> |
||||
: <Skeleton variant='rect' height={240} />} |
||||
</Flex.Container> |
||||
</Section>; |
||||
} |
||||
@ -0,0 +1,13 @@ |
||||
import { Divider } from '@rocket.chat/fuselage'; |
||||
import React from 'react'; |
||||
|
||||
import { MessagesSentSection } from './MessagesSentSection'; |
||||
import { MessagesPerChannelSection } from './MessagesPerChannelSection'; |
||||
|
||||
export function MessagesTab() { |
||||
return <> |
||||
<MessagesSentSection /> |
||||
<Divider /> |
||||
<MessagesPerChannelSection /> |
||||
</>; |
||||
} |
||||
@ -0,0 +1,14 @@ |
||||
import { Margins } from '@rocket.chat/fuselage'; |
||||
import React from 'react'; |
||||
|
||||
import { MessagesTab } from '.'; |
||||
|
||||
export default { |
||||
title: 'admin/engagement/MessagesTab', |
||||
component: MessagesTab, |
||||
decorators: [ |
||||
(fn) => <Margins children={fn()} all='x24' />, |
||||
], |
||||
}; |
||||
|
||||
export const _default = () => <MessagesTab />; |
||||
@ -0,0 +1,24 @@ |
||||
import { Box, Flex, InputBox, Margins } from '@rocket.chat/fuselage'; |
||||
import React from 'react'; |
||||
|
||||
export function Section({ |
||||
children, |
||||
title, |
||||
filter = <InputBox.Skeleton />, |
||||
}) { |
||||
return <Box> |
||||
<Margins block='x16'> |
||||
<Flex.Container alignItems='center' wrap='no-wrap'> |
||||
<Box> |
||||
<Flex.Item grow={1}> |
||||
<Box textStyle='s2' textColor='default'>{title}</Box> |
||||
</Flex.Item> |
||||
{filter && <Flex.Item grow={0}> |
||||
{filter} |
||||
</Flex.Item>} |
||||
</Box> |
||||
</Flex.Container> |
||||
{children} |
||||
</Margins> |
||||
</Box>; |
||||
} |
||||
@ -0,0 +1,223 @@ |
||||
import { ResponsiveLine } from '@nivo/line'; |
||||
import { Box, Flex, Skeleton, Tile } from '@rocket.chat/fuselage'; |
||||
import moment from 'moment'; |
||||
import React, { useMemo } from 'react'; |
||||
|
||||
import { useTranslation } from '../../../../../../client/contexts/TranslationContext'; |
||||
import { CounterSet } from '../data/CounterSet'; |
||||
import { LegendSymbol } from '../data/LegendSymbol'; |
||||
import { useEndpointData } from '../../hooks/useEndpointData'; |
||||
import { Section } from '../Section'; |
||||
|
||||
export function ActiveUsersSection() { |
||||
const t = useTranslation(); |
||||
|
||||
const period = useMemo(() => ({ |
||||
start: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(30, 'days'), |
||||
end: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(1), |
||||
}), []); |
||||
|
||||
const params = useMemo(() => ({ |
||||
start: period.start.clone().subtract(30, 'days').toISOString(), |
||||
end: period.end.toISOString(), |
||||
}), [period]); |
||||
|
||||
const data = useEndpointData('GET', 'engagement-dashboard/users/active-users', params); |
||||
|
||||
const [ |
||||
countDailyActiveUsers, |
||||
diffDailyActiveUsers, |
||||
countWeeklyActiveUsers, |
||||
diffWeeklyActiveUsers, |
||||
countMonthlyActiveUsers, |
||||
diffMonthlyActiveUsers, |
||||
dauValues, |
||||
wauValues, |
||||
mauValues, |
||||
] = useMemo(() => { |
||||
if (!data) { |
||||
return []; |
||||
} |
||||
|
||||
const createPoint = (i) => ({ |
||||
x: moment(period.start).add(i, 'days').toDate(), |
||||
y: 0, |
||||
}); |
||||
|
||||
const createPoints = () => Array.from({ length: moment(period.end).diff(period.start, 'days') + 1 }, (_, i) => createPoint(i)); |
||||
|
||||
const distributeValueOverPoints = (value, i, T, array, prev) => { |
||||
for (let j = 0; j < T; ++j) { |
||||
const k = i + j; |
||||
|
||||
if (k >= array.length) { |
||||
continue; |
||||
} |
||||
|
||||
if (k >= 0) { |
||||
array[k].y += value; |
||||
} |
||||
|
||||
if (k === -1) { |
||||
prev.y += value; |
||||
} |
||||
} |
||||
}; |
||||
|
||||
const dauValues = createPoints(); |
||||
const prevDauValue = createPoint(-1); |
||||
const wauValues = createPoints(); |
||||
const prevWauValue = createPoint(-1); |
||||
const mauValues = createPoints(); |
||||
const prevMauValue = createPoint(-1); |
||||
|
||||
for (const { users, day, month, year } of data.month) { |
||||
const i = moment.utc([year, month - 1, day, 0, 0, 0, 0]).diff(period.start, 'days'); |
||||
distributeValueOverPoints(users, i, 1, dauValues, prevDauValue); |
||||
distributeValueOverPoints(users, i, 7, wauValues, prevWauValue); |
||||
distributeValueOverPoints(users, i, 30, mauValues, prevMauValue); |
||||
} |
||||
|
||||
return [ |
||||
dauValues[dauValues.length - 1].y, |
||||
dauValues[dauValues.length - 1].y - prevDauValue.y, |
||||
wauValues[wauValues.length - 1].y, |
||||
wauValues[wauValues.length - 1].y - prevWauValue.y, |
||||
mauValues[mauValues.length - 1].y, |
||||
mauValues[mauValues.length - 1].y - prevMauValue.y, |
||||
dauValues, |
||||
wauValues, |
||||
mauValues, |
||||
]; |
||||
}, [period, data]); |
||||
|
||||
return <Section title={t('Active_users')} filter={null}> |
||||
<CounterSet |
||||
counters={[ |
||||
{ |
||||
count: data ? countDailyActiveUsers : <Skeleton variant='rect' width='3ex' height='1em' />, |
||||
variation: data ? diffDailyActiveUsers : 0, |
||||
description: <><LegendSymbol color='#D1EBFE' /> {t('Daily_Active_Users')}</>, |
||||
}, |
||||
{ |
||||
count: data ? countWeeklyActiveUsers : <Skeleton variant='rect' width='3ex' height='1em' />, |
||||
variation: data ? diffWeeklyActiveUsers : 0, |
||||
description: <><LegendSymbol color='#76B7FC' /> {t('Weekly_Active_Users')}</>, |
||||
}, |
||||
{ |
||||
count: data ? countMonthlyActiveUsers : <Skeleton variant='rect' width='3ex' height='1em' />, |
||||
variation: data ? diffMonthlyActiveUsers : 0, |
||||
description: <><LegendSymbol color='#1D74F5' /> {t('Monthly_Active_Users')}</>, |
||||
}, |
||||
]} |
||||
/> |
||||
<Flex.Container> |
||||
{data |
||||
? <Box style={{ height: 240 }}> |
||||
<Flex.Item align='stretch' grow={1} shrink={0}> |
||||
<Box style={{ position: 'relative' }}> |
||||
<Box style={{ position: 'absolute', width: '100%', height: '100%' }}> |
||||
<ResponsiveLine |
||||
data={[ |
||||
{ |
||||
id: 'dau', |
||||
data: dauValues, |
||||
}, |
||||
{ |
||||
id: 'wau', |
||||
data: wauValues, |
||||
}, |
||||
{ |
||||
id: 'mau', |
||||
data: mauValues, |
||||
}, |
||||
]} |
||||
xScale={{ |
||||
type: 'time', |
||||
format: 'native', |
||||
precision: 'day', |
||||
}} |
||||
xFormat='time:%Y-%m-%d' |
||||
yScale={{ |
||||
type: 'linear', |
||||
stacked: true, |
||||
}} |
||||
enableGridX={false} |
||||
enableGridY={false} |
||||
enablePoints={false} |
||||
useMesh |
||||
enableArea |
||||
areaOpacity={1} |
||||
enableCrosshair |
||||
crosshairType='bottom' |
||||
margin={{ |
||||
// TODO: Get it from theme
|
||||
top: 0, |
||||
bottom: 20, |
||||
right: 0, |
||||
left: 40, |
||||
}} |
||||
colors={[ |
||||
'#D1EBFE', |
||||
'#76B7FC', |
||||
'#1D74F5', |
||||
]} |
||||
axisLeft={{ |
||||
// TODO: Get it from theme
|
||||
tickSize: 0, |
||||
tickPadding: 4, |
||||
tickRotation: 0, |
||||
tickValues: 3, |
||||
}} |
||||
axisBottom={{ |
||||
// TODO: Get it from theme
|
||||
tickSize: 0, |
||||
tickPadding: 4, |
||||
tickRotation: 0, |
||||
tickValues: 'every 3 days', |
||||
format: (date) => moment(date).format(dauValues.length === 7 ? 'dddd' : 'L'), |
||||
}} |
||||
animate={true} |
||||
motionStiffness={90} |
||||
motionDamping={15} |
||||
theme={{ |
||||
// TODO: Get it from theme
|
||||
axis: { |
||||
ticks: { |
||||
text: { |
||||
fill: '#9EA2A8', |
||||
fontFamily: 'Inter, -apple-system, system-ui, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Helvetica Neue", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Meiryo UI", Arial, sans-serif', |
||||
fontSize: '10px', |
||||
fontStyle: 'normal', |
||||
fontWeight: '600', |
||||
letterSpacing: '0.2px', |
||||
lineHeight: '12px', |
||||
}, |
||||
}, |
||||
}, |
||||
tooltip: { |
||||
container: { |
||||
backgroundColor: '#1F2329', |
||||
boxShadow: '0px 0px 12px rgba(47, 52, 61, 0.12), 0px 0px 2px rgba(47, 52, 61, 0.08)', |
||||
borderRadius: 2, |
||||
}, |
||||
}, |
||||
}} |
||||
enableSlices='x' |
||||
sliceTooltip={({ slice: { points } }) => <Tile elevation='2'> |
||||
{points.map(({ serieId, data: { y: activeUsers } }) => |
||||
<Box key={serieId} textStyle='p2'> |
||||
{(serieId === 'dau' && t('DAU_value', { value: activeUsers })) |
||||
|| (serieId === 'wau' && t('WAU_value', { value: activeUsers })) |
||||
|| (serieId === 'mau' && t('MAU_value', { value: activeUsers }))} |
||||
</Box>)} |
||||
</Tile>} |
||||
/> |
||||
</Box> |
||||
</Box> |
||||
</Flex.Item> |
||||
</Box> |
||||
: <Skeleton variant='rect' height={240} />} |
||||
</Flex.Container> |
||||
</Section>; |
||||
} |
||||
@ -0,0 +1,250 @@ |
||||
import { ResponsiveBar } from '@nivo/bar'; |
||||
import { Box, Button, Chevron, Flex, Margins, Select, Skeleton } from '@rocket.chat/fuselage'; |
||||
import moment from 'moment'; |
||||
import React, { useMemo, useState } from 'react'; |
||||
|
||||
import { useTranslation } from '../../../../../../client/contexts/TranslationContext'; |
||||
import { Section } from '../Section'; |
||||
import { useEndpointData } from '../../hooks/useEndpointData'; |
||||
|
||||
function ContentForHours({ displacement, onPreviousDateClick, onNextDateClick }) { |
||||
const t = useTranslation(); |
||||
|
||||
const currentDate = useMemo(() => |
||||
moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }) |
||||
.subtract(1).subtract(displacement, 'days'), [displacement]); |
||||
const params = useMemo(() => ({ start: currentDate.toISOString() }), [currentDate]); |
||||
const data = useEndpointData('GET', 'engagement-dashboard/users/chat-busier/hourly-data', params); |
||||
const values = useMemo(() => { |
||||
if (!data) { |
||||
return []; |
||||
} |
||||
|
||||
const divider = 2; |
||||
const values = Array.from({ length: 24 / divider }, (_, i) => ({ |
||||
hour: divider * i, |
||||
users: 0, |
||||
})); |
||||
for (const { hour, users } of data.hours) { |
||||
const i = Math.floor(hour / divider); |
||||
values[i] = values[i] || { hour: divider * i, users: 0 }; |
||||
values[i].users += users; |
||||
} |
||||
|
||||
return values; |
||||
}, [data]); |
||||
|
||||
return <> |
||||
<Flex.Container alignItems='center' justifyContent='center'> |
||||
<Box> |
||||
<Button ghost square small onClick={onPreviousDateClick}> |
||||
<Chevron left size='20' style={{ verticalAlign: 'middle' }} /> |
||||
</Button> |
||||
<Flex.Item basis='25%'> |
||||
<Margins inline='x8'> |
||||
<Box is='span' style={{ textAlign: 'center' }}> |
||||
{currentDate.format(displacement < 7 ? 'dddd' : 'L')} |
||||
</Box> |
||||
</Margins> |
||||
</Flex.Item> |
||||
<Button ghost square small disabled={displacement === 0} onClick={onNextDateClick}> |
||||
<Chevron right size='20' style={{ verticalAlign: 'middle' }} /> |
||||
</Button> |
||||
</Box> |
||||
</Flex.Container> |
||||
<Flex.Container> |
||||
{data |
||||
? <Box style={{ height: 196 }}> |
||||
<Flex.Item align='stretch' grow={1} shrink={0}> |
||||
<Box style={{ position: 'relative' }}> |
||||
<Box style={{ position: 'absolute', width: '100%', height: '100%' }}> |
||||
<ResponsiveBar |
||||
data={values} |
||||
indexBy='hour' |
||||
keys={['users']} |
||||
groupMode='grouped' |
||||
padding={0.25} |
||||
margin={{ |
||||
// TODO: Get it from theme
|
||||
bottom: 20, |
||||
}} |
||||
colors={[ |
||||
// TODO: Get it from theme
|
||||
'#1d74f5', |
||||
]} |
||||
enableLabel={false} |
||||
enableGridY={false} |
||||
axisTop={null} |
||||
axisRight={null} |
||||
axisBottom={{ |
||||
tickSize: 0, |
||||
// TODO: Get it from theme
|
||||
tickPadding: 4, |
||||
tickRotation: 0, |
||||
tickValues: 'every 2 hours', |
||||
format: (hour) => moment().set({ hour, minute: 0, second: 0 }).format('LT'), |
||||
}} |
||||
axisLeft={null} |
||||
animate={true} |
||||
motionStiffness={90} |
||||
motionDamping={15} |
||||
theme={{ |
||||
// TODO: Get it from theme
|
||||
axis: { |
||||
ticks: { |
||||
text: { |
||||
fill: '#9EA2A8', |
||||
fontFamily: 'Inter, -apple-system, system-ui, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Helvetica Neue", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Meiryo UI", Arial, sans-serif', |
||||
fontSize: '10px', |
||||
fontStyle: 'normal', |
||||
fontWeight: '600', |
||||
letterSpacing: '0.2px', |
||||
lineHeight: '12px', |
||||
}, |
||||
}, |
||||
}, |
||||
tooltip: { |
||||
container: { |
||||
backgroundColor: '#1F2329', |
||||
boxShadow: '0px 0px 12px rgba(47, 52, 61, 0.12), 0px 0px 2px rgba(47, 52, 61, 0.08)', |
||||
borderRadius: 2, |
||||
}, |
||||
}, |
||||
}} |
||||
tooltip={({ value }) => <Box textStyle='p2' textColor='alternative'> |
||||
{t('Value_users', { value })} |
||||
</Box>} |
||||
/> |
||||
</Box> |
||||
</Box> |
||||
</Flex.Item> |
||||
</Box> |
||||
: <Skeleton variant='rect' height={196} />} |
||||
</Flex.Container> |
||||
</>; |
||||
} |
||||
|
||||
function ContentForDays({ displacement, onPreviousDateClick, onNextDateClick }) { |
||||
const currentDate = useMemo(() => moment.utc().subtract(displacement, 'weeks'), [displacement]); |
||||
const formattedCurrentDate = useMemo(() => { |
||||
const startOfWeekDate = currentDate.clone().subtract(6, 'days'); |
||||
return `${ startOfWeekDate.format('L') } - ${ currentDate.format('L') }`; |
||||
}, [currentDate]); |
||||
const params = useMemo(() => ({ start: currentDate.toISOString() }), [currentDate]); |
||||
const data = useEndpointData('GET', 'engagement-dashboard/users/chat-busier/weekly-data', params); |
||||
const values = useMemo(() => (data ? data.month.map(({ users, day, month, year }) => ({ |
||||
users, |
||||
day: moment.utc([year, month - 1, day, 0, 0, 0]).valueOf(), |
||||
})).sort(({ day: a }, { day: b }) => a - b) : []), [data]); |
||||
|
||||
return <> |
||||
<Flex.Container alignItems='center' justifyContent='center'> |
||||
<Box> |
||||
<Button ghost square small onClick={onPreviousDateClick}> |
||||
<Chevron left size='20' style={{ verticalAlign: 'middle' }} /> |
||||
</Button> |
||||
<Flex.Item basis='50%'> |
||||
<Margins inline='x8'> |
||||
<Box is='span' style={{ textAlign: 'center' }}> |
||||
{formattedCurrentDate} |
||||
</Box> |
||||
</Margins> |
||||
</Flex.Item> |
||||
<Button ghost square small disabled={displacement === 0} onClick={onNextDateClick}> |
||||
<Chevron right size='20' style={{ verticalAlign: 'middle' }} /> |
||||
</Button> |
||||
</Box> |
||||
</Flex.Container> |
||||
<Flex.Container> |
||||
{data |
||||
? <Box style={{ height: 196 }}> |
||||
<Flex.Item align='stretch' grow={1} shrink={0}> |
||||
<Box style={{ position: 'relative' }}> |
||||
<Box style={{ position: 'absolute', width: '100%', height: '100%' }}> |
||||
<ResponsiveBar |
||||
data={values} |
||||
indexBy='day' |
||||
keys={['users']} |
||||
groupMode='grouped' |
||||
padding={0.25} |
||||
margin={{ |
||||
// TODO: Get it from theme
|
||||
bottom: 20, |
||||
}} |
||||
colors={[ |
||||
// TODO: Get it from theme
|
||||
'#1d74f5', |
||||
]} |
||||
enableLabel={false} |
||||
enableGridY={false} |
||||
axisTop={null} |
||||
axisRight={null} |
||||
axisBottom={{ |
||||
tickSize: 0, |
||||
// TODO: Get it from theme
|
||||
tickPadding: 4, |
||||
tickRotation: 0, |
||||
tickValues: 'every 3 days', |
||||
format: (timestamp) => moment(timestamp).format('L'), |
||||
}} |
||||
axisLeft={null} |
||||
animate={true} |
||||
motionStiffness={90} |
||||
motionDamping={15} |
||||
theme={{ |
||||
// TODO: Get it from theme
|
||||
axis: { |
||||
ticks: { |
||||
text: { |
||||
fill: '#9EA2A8', |
||||
fontFamily: 'Inter, -apple-system, system-ui, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Helvetica Neue", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Meiryo UI", Arial, sans-serif', |
||||
fontSize: '10px', |
||||
fontStyle: 'normal', |
||||
fontWeight: '600', |
||||
letterSpacing: '0.2px', |
||||
lineHeight: '12px', |
||||
}, |
||||
}, |
||||
}, |
||||
}} |
||||
/> |
||||
</Box> |
||||
</Box> |
||||
</Flex.Item> |
||||
</Box> |
||||
: <Skeleton variant='rect' height={196} />} |
||||
</Flex.Container> |
||||
</>; |
||||
} |
||||
|
||||
export function BusiestChatTimesSection() { |
||||
const t = useTranslation(); |
||||
|
||||
const [timeUnit, setTimeUnit] = useState('hours'); |
||||
const timeUnitOptions = useMemo(() => [ |
||||
['hours', t('Hours')], |
||||
['days', t('Days')], |
||||
], [t]); |
||||
|
||||
const handleTimeUnitChange = (timeUnit) => { |
||||
setTimeUnit(timeUnit); |
||||
}; |
||||
|
||||
const [displacement, setDisplacement] = useState(0); |
||||
|
||||
const handlePreviousDateClick = () => setDisplacement((displacement) => displacement + 1); |
||||
const handleNextDateClick = () => setDisplacement((displacement) => displacement - 1); |
||||
|
||||
const Content = (timeUnit === 'hours' && ContentForHours) || (timeUnit === 'days' && ContentForDays); |
||||
|
||||
return <Section |
||||
title={t('When_is_the_chat_busier?')} |
||||
filter={<Select options={timeUnitOptions} value={timeUnit} onChange={handleTimeUnitChange} />} |
||||
> |
||||
<Content |
||||
displacement={displacement} |
||||
onPreviousDateClick={handlePreviousDateClick} |
||||
onNextDateClick={handleNextDateClick} |
||||
/> |
||||
</Section>; |
||||
} |
||||
@ -0,0 +1,169 @@ |
||||
import { ResponsiveBar } from '@nivo/bar'; |
||||
import { Box, Flex, Select, Skeleton } from '@rocket.chat/fuselage'; |
||||
import moment from 'moment'; |
||||
import React, { useMemo, useState } from 'react'; |
||||
|
||||
import { useTranslation } from '../../../../../../client/contexts/TranslationContext'; |
||||
import { useEndpointData } from '../../hooks/useEndpointData'; |
||||
import { CounterSet } from '../data/CounterSet'; |
||||
import { Section } from '../Section'; |
||||
|
||||
export function NewUsersSection() { |
||||
const t = useTranslation(); |
||||
|
||||
const periodOptions = useMemo(() => [ |
||||
['last 7 days', t('Last_7_days')], |
||||
['last 30 days', t('Last_30_days')], |
||||
['last 90 days', t('Last_90_days')], |
||||
], [t]); |
||||
|
||||
const [periodId, setPeriodId] = useState('last 7 days'); |
||||
|
||||
const period = useMemo(() => { |
||||
switch (periodId) { |
||||
case 'last 7 days': |
||||
return { |
||||
start: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(7, 'days'), |
||||
end: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(1), |
||||
}; |
||||
|
||||
case 'last 30 days': |
||||
return { |
||||
start: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(30, 'days'), |
||||
end: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(1), |
||||
}; |
||||
|
||||
case 'last 90 days': |
||||
return { |
||||
start: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(90, 'days'), |
||||
end: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(1), |
||||
}; |
||||
} |
||||
}, [periodId]); |
||||
|
||||
const handlePeriodChange = (periodId) => setPeriodId(periodId); |
||||
|
||||
const params = useMemo(() => ({ |
||||
start: period.start.toISOString(), |
||||
end: period.end.toISOString(), |
||||
}), [period]); |
||||
|
||||
const data = useEndpointData('GET', 'engagement-dashboard/users/new-users', params); |
||||
|
||||
const [ |
||||
countFromPeriod, |
||||
variatonFromPeriod, |
||||
countFromYesterday, |
||||
variationFromYesterday, |
||||
values, |
||||
] = useMemo(() => { |
||||
if (!data) { |
||||
return []; |
||||
} |
||||
|
||||
const values = Array.from({ length: moment(period.end).diff(period.start, 'days') + 1 }, (_, i) => ({ |
||||
date: moment(period.start).add(i, 'days').toISOString(), |
||||
newUsers: 0, |
||||
})); |
||||
for (const { day, users } of data.days) { |
||||
const i = moment(day).diff(period.start, 'days'); |
||||
values[i].newUsers += users; |
||||
} |
||||
|
||||
return [ |
||||
data.period.count, |
||||
data.period.variation, |
||||
data.yesterday.count, |
||||
data.yesterday.variation, |
||||
values, |
||||
]; |
||||
}, [data, period]); |
||||
|
||||
return <Section |
||||
title={t('New_users')} |
||||
filter={<Select options={periodOptions} value={periodId} onChange={handlePeriodChange} />} |
||||
> |
||||
<CounterSet |
||||
counters={[ |
||||
{ |
||||
count: data ? countFromPeriod : <Skeleton variant='rect' width='3ex' height='1em' />, |
||||
variation: data ? variatonFromPeriod : 0, |
||||
description: periodOptions.find(([id]) => id === periodId)[1], |
||||
}, |
||||
{ |
||||
count: data ? countFromYesterday : <Skeleton variant='rect' width='3ex' height='1em' />, |
||||
variation: data ? variationFromYesterday : 0, |
||||
description: t('Yesterday'), |
||||
}, |
||||
]} |
||||
/> |
||||
<Flex.Container> |
||||
{data |
||||
? <Box style={{ height: 240 }}> |
||||
<Flex.Item align='stretch' grow={1} shrink={0}> |
||||
<Box style={{ position: 'relative' }}> |
||||
<Box style={{ position: 'absolute', width: '100%', height: '100%' }}> |
||||
<ResponsiveBar |
||||
data={values} |
||||
indexBy='date' |
||||
keys={['newUsers']} |
||||
groupMode='grouped' |
||||
padding={0.25} |
||||
margin={{ |
||||
// TODO: Get it from theme
|
||||
bottom: 20, |
||||
}} |
||||
colors={[ |
||||
// TODO: Get it from theme
|
||||
'#1d74f5', |
||||
]} |
||||
enableLabel={false} |
||||
enableGridY={false} |
||||
axisTop={null} |
||||
axisRight={null} |
||||
axisBottom={(values.length === 7 && { |
||||
tickSize: 0, |
||||
// TODO: Get it from theme
|
||||
tickPadding: 4, |
||||
tickRotation: 0, |
||||
format: (date) => moment(date).format('dddd'), |
||||
}) || null } |
||||
axisLeft={null} |
||||
animate={true} |
||||
motionStiffness={90} |
||||
motionDamping={15} |
||||
theme={{ |
||||
// TODO: Get it from theme
|
||||
axis: { |
||||
ticks: { |
||||
text: { |
||||
fill: '#9EA2A8', |
||||
fontFamily: 'Inter, -apple-system, system-ui, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Helvetica Neue", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Meiryo UI", Arial, sans-serif', |
||||
fontSize: '10px', |
||||
fontStyle: 'normal', |
||||
fontWeight: '600', |
||||
letterSpacing: '0.2px', |
||||
lineHeight: '12px', |
||||
}, |
||||
}, |
||||
}, |
||||
tooltip: { |
||||
container: { |
||||
backgroundColor: '#1F2329', |
||||
boxShadow: '0px 0px 12px rgba(47, 52, 61, 0.12), 0px 0px 2px rgba(47, 52, 61, 0.08)', |
||||
borderRadius: 2, |
||||
}, |
||||
}, |
||||
}} |
||||
tooltip={({ value }) => <Box textStyle='p2' textColor='alternative'> |
||||
{t('Value_users', { value })} |
||||
</Box>} |
||||
/> |
||||
</Box> |
||||
</Box> |
||||
</Flex.Item> |
||||
</Box> |
||||
: <Skeleton variant='rect' height={240} />} |
||||
</Flex.Container> |
||||
</Section>; |
||||
} |
||||
@ -0,0 +1,166 @@ |
||||
import { ResponsiveHeatMap } from '@nivo/heatmap'; |
||||
import { Box, Flex, Select, Skeleton } from '@rocket.chat/fuselage'; |
||||
import moment from 'moment'; |
||||
import React, { useMemo, useState } from 'react'; |
||||
|
||||
import { useTranslation } from '../../../../../../client/contexts/TranslationContext'; |
||||
import { Section } from '../Section'; |
||||
import { useEndpointData } from '../../hooks/useEndpointData'; |
||||
|
||||
export function UsersByTimeOfTheDaySection() { |
||||
const t = useTranslation(); |
||||
|
||||
const periodOptions = useMemo(() => [ |
||||
['last 7 days', t('Last_7_days')], |
||||
['last 30 days', t('Last_30_days')], |
||||
['last 90 days', t('Last_90_days')], |
||||
], [t]); |
||||
|
||||
const [periodId, setPeriodId] = useState('last 7 days'); |
||||
|
||||
const period = useMemo(() => { |
||||
switch (periodId) { |
||||
case 'last 7 days': |
||||
return { |
||||
start: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(7, 'days'), |
||||
end: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(1), |
||||
}; |
||||
|
||||
case 'last 30 days': |
||||
return { |
||||
start: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(30, 'days'), |
||||
end: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(1), |
||||
}; |
||||
|
||||
case 'last 90 days': |
||||
return { |
||||
start: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(90, 'days'), |
||||
end: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(1), |
||||
}; |
||||
} |
||||
}, [periodId]); |
||||
|
||||
const handlePeriodChange = (periodId) => setPeriodId(periodId); |
||||
|
||||
const params = useMemo(() => ({ |
||||
start: period.start.toISOString(), |
||||
end: period.end.toISOString(), |
||||
}), [period]); |
||||
|
||||
const data = useEndpointData('GET', 'engagement-dashboard/users/users-by-time-of-the-day-in-a-week', params); |
||||
|
||||
const [ |
||||
dates, |
||||
values, |
||||
] = useMemo(() => { |
||||
if (!data) { |
||||
return []; |
||||
} |
||||
|
||||
const dates = Array.from({ length: moment(period.end).diff(period.start, 'days') + 1 }, |
||||
(_, i) => moment(period.start).add(i, 'days')); |
||||
|
||||
const values = Array.from({ length: 24 }, (_, hour) => ({ |
||||
hour: String(hour), |
||||
...dates.map((date) => ({ [date.toISOString()]: 0 })) |
||||
.reduce((obj, elem) => ({ ...obj, ...elem }), {}), |
||||
})); |
||||
|
||||
for (const { users, hour, day, month, year } of data.week) { |
||||
const date = moment([year, month - 1, day, 0, 0, 0, 0]).toISOString(); |
||||
values[hour][date] += users; |
||||
} |
||||
|
||||
return [ |
||||
dates.map((date) => date.toISOString()), |
||||
values, |
||||
]; |
||||
}, [data]); |
||||
|
||||
return <Section |
||||
title={t('Users_by_time_of_day')} |
||||
filter={<Select options={periodOptions} value={periodId} onChange={handlePeriodChange} />} |
||||
> |
||||
<Flex.Container> |
||||
{data |
||||
? <Box style={{ height: 696 }}> |
||||
<Flex.Item align='stretch' grow={1} shrink={0}> |
||||
<Box style={{ position: 'relative' }}> |
||||
<Box style={{ position: 'absolute', width: '100%', height: '100%' }}> |
||||
<ResponsiveHeatMap |
||||
data={values} |
||||
indexBy='hour' |
||||
keys={dates} |
||||
padding={4} |
||||
margin={{ |
||||
// TODO: Get it from theme
|
||||
left: 40, |
||||
bottom: 20, |
||||
}} |
||||
colors={[ |
||||
// TODO: Get it from theme
|
||||
'#E8F2FF', |
||||
'#D1EBFE', |
||||
'#A4D3FE', |
||||
'#76B7FC', |
||||
'#549DF9', |
||||
'#1D74F5', |
||||
'#10529E', |
||||
]} |
||||
cellOpacity={1} |
||||
enableLabels={false} |
||||
axisTop={null} |
||||
axisRight={null} |
||||
axisBottom={{ |
||||
// TODO: Get it from theme
|
||||
tickSize: 0, |
||||
tickPadding: 4, |
||||
tickRotation: 0, |
||||
format: (isoString) => (dates.length === 7 ? moment(isoString).format('dddd') : ''), |
||||
}} |
||||
axisLeft={{ |
||||
// TODO: Get it from theme
|
||||
tickSize: 0, |
||||
tickPadding: 4, |
||||
tickRotation: 0, |
||||
format: (hour) => moment().set({ hour: parseInt(hour, 10), minute: 0, second: 0 }).format('LT'), |
||||
}} |
||||
hoverTarget='cell' |
||||
animate={dates.length <= 7} |
||||
motionStiffness={90} |
||||
motionDamping={15} |
||||
theme={{ |
||||
// TODO: Get it from theme
|
||||
axis: { |
||||
ticks: { |
||||
text: { |
||||
fill: '#9EA2A8', |
||||
fontFamily: 'Inter, -apple-system, system-ui, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Helvetica Neue", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Meiryo UI", Arial, sans-serif', |
||||
fontSize: 10, |
||||
fontStyle: 'normal', |
||||
fontWeight: '600', |
||||
letterSpacing: '0.2px', |
||||
lineHeight: '12px', |
||||
}, |
||||
}, |
||||
}, |
||||
tooltip: { |
||||
container: { |
||||
backgroundColor: '#1F2329', |
||||
boxShadow: '0px 0px 12px rgba(47, 52, 61, 0.12), 0px 0px 2px rgba(47, 52, 61, 0.08)', |
||||
borderRadius: 2, |
||||
}, |
||||
}, |
||||
}} |
||||
tooltip={({ value }) => <Box textStyle='p2' textColor='alternative'> |
||||
{t('Value_users', { value })} |
||||
</Box>} |
||||
/> |
||||
</Box> |
||||
</Box> |
||||
</Flex.Item> |
||||
</Box> |
||||
: <Skeleton variant='rect' height={696} />} |
||||
</Flex.Container> |
||||
</Section>; |
||||
} |
||||
@ -0,0 +1,32 @@ |
||||
import { Box, Divider, Flex, Margins } from '@rocket.chat/fuselage'; |
||||
import React from 'react'; |
||||
|
||||
import { NewUsersSection } from './NewUsersSection'; |
||||
import { ActiveUsersSection } from './ActiveUsersSection'; |
||||
import { UsersByTimeOfTheDaySection } from './UsersByTimeOfTheDaySection'; |
||||
import { BusiestChatTimesSection } from './BusiestChatTimesSection'; |
||||
|
||||
export function UsersTab() { |
||||
return <> |
||||
<NewUsersSection /> |
||||
<Divider /> |
||||
<ActiveUsersSection /> |
||||
<Divider /> |
||||
<Flex.Container> |
||||
<Margins inline='x12'> |
||||
<Box> |
||||
<Margins inline='x12'> |
||||
<Flex.Item grow={1} shrink={0} basis='0'> |
||||
<UsersByTimeOfTheDaySection /> |
||||
</Flex.Item> |
||||
<Flex.Item grow={1} shrink={0} basis='0'> |
||||
<Box> |
||||
<BusiestChatTimesSection /> |
||||
</Box> |
||||
</Flex.Item> |
||||
</Margins> |
||||
</Box> |
||||
</Margins> |
||||
</Flex.Container> |
||||
</>; |
||||
} |
||||
@ -0,0 +1,14 @@ |
||||
import { Margins } from '@rocket.chat/fuselage'; |
||||
import React from 'react'; |
||||
|
||||
import { UsersTab } from '.'; |
||||
|
||||
export default { |
||||
title: 'admin/engagement/UsersTab', |
||||
component: UsersTab, |
||||
decorators: [ |
||||
(fn) => <Margins children={fn()} all='x24' />, |
||||
], |
||||
}; |
||||
|
||||
export const _default = () => <UsersTab />; |
||||
@ -0,0 +1,24 @@ |
||||
import { Box, Flex, Margins } from '@rocket.chat/fuselage'; |
||||
import React from 'react'; |
||||
|
||||
import { Growth } from './Growth'; |
||||
|
||||
export function Counter({ count, variation = 0, description }) { |
||||
return <> |
||||
<Flex.Container alignItems='end'> |
||||
<Box> |
||||
<Box is='span' textColor='default' textStyle='h1' style={{ fontSize: '3em', lineHeight: 1 }}> |
||||
{count} |
||||
</Box> |
||||
<Growth textStyle='s1'>{variation}</Growth> |
||||
</Box> |
||||
</Flex.Container> |
||||
<Margins block='x12'> |
||||
<Flex.Container alignItems='center'> |
||||
<Box textStyle='p1' textColor='hint'> |
||||
{description} |
||||
</Box> |
||||
</Flex.Container> |
||||
</Margins> |
||||
</>; |
||||
} |
||||
@ -0,0 +1,16 @@ |
||||
import React from 'react'; |
||||
|
||||
import { Counter } from './Counter'; |
||||
|
||||
export default { |
||||
title: 'admin/engagement/data/Counter', |
||||
component: Counter, |
||||
}; |
||||
|
||||
export const _default = () => <Counter count={123} />; |
||||
|
||||
export const withPositiveVariation = () => <Counter count={123} variation={4} />; |
||||
|
||||
export const withNegativeVariation = () => <Counter count={123} variation={-4} />; |
||||
|
||||
export const withDescription = () => <Counter count={123} description='Description' />; |
||||
@ -0,0 +1,16 @@ |
||||
import { Grid } from '@rocket.chat/fuselage'; |
||||
import React from 'react'; |
||||
|
||||
import { Counter } from './Counter'; |
||||
|
||||
export function CounterSet({ counters = [] }) { |
||||
return <Grid> |
||||
{counters.map(({ count, variation, description }, i) => <Grid.Item key={i}> |
||||
<Counter |
||||
count={count} |
||||
variation={variation} |
||||
description={description} |
||||
/> |
||||
</Grid.Item>)} |
||||
</Grid>; |
||||
} |
||||
@ -0,0 +1,16 @@ |
||||
import React from 'react'; |
||||
|
||||
import { CounterSet } from './CounterSet'; |
||||
|
||||
export default { |
||||
title: 'admin/engagement/data/CounterSet', |
||||
component: CounterSet, |
||||
}; |
||||
|
||||
export const _default = () => <CounterSet |
||||
counters={[ |
||||
{ count: 123, variation: 0 }, |
||||
{ count: 456, variation: 7 }, |
||||
{ count: 789, variation: -1, description: 'Description' }, |
||||
]} |
||||
/>; |
||||
@ -0,0 +1,16 @@ |
||||
import { Box } from '@rocket.chat/fuselage'; |
||||
import React from 'react'; |
||||
|
||||
import { NegativeGrowthSymbol } from './NegativeGrowthSymbol'; |
||||
import { PositiveGrowthSymbol } from './PositiveGrowthSymbol'; |
||||
|
||||
export function Growth({ children, ...props }) { |
||||
if (children === 0) { |
||||
return null; |
||||
} |
||||
|
||||
return <Box is='span' textColor={children < 0 ? 'danger' : 'success'} {...props}> |
||||
{children < 0 ? <NegativeGrowthSymbol /> : <PositiveGrowthSymbol />} |
||||
{String(Math.abs(children))} |
||||
</Box>; |
||||
} |
||||
@ -0,0 +1,23 @@ |
||||
import { Box, Margins } from '@rocket.chat/fuselage'; |
||||
import React from 'react'; |
||||
|
||||
import { Growth } from './Growth'; |
||||
|
||||
export default { |
||||
title: 'admin/engagement/data/Growth', |
||||
component: Growth, |
||||
decorators: [(fn) => <Margins children={fn()} all='x16' />], |
||||
}; |
||||
|
||||
export const positive = () => <Growth>{3}</Growth>; |
||||
|
||||
export const zero = () => <Growth>{0}</Growth>; |
||||
|
||||
export const negative = () => <Growth>{-3}</Growth>; |
||||
|
||||
export const withTextStyle = () => |
||||
['h1', 's1', 'c1', 'micro'] |
||||
.map((textStyle) => <Box key={textStyle}> |
||||
<Growth textStyle={textStyle}>{3}</Growth> |
||||
<Growth textStyle={textStyle}>{-3}</Growth> |
||||
</Box>); |
||||
@ -0,0 +1,85 @@ |
||||
import { ResponsiveBar } from '@nivo/bar'; |
||||
import { Box, Flex } from '@rocket.chat/fuselage'; |
||||
import React from 'react'; |
||||
|
||||
import { polychromaticColors } from './colors'; |
||||
|
||||
export function Histogram() { |
||||
return <Flex.Item align='stretch' grow={1} shrink={0}> |
||||
<Box style={{ position: 'relative' }}> |
||||
<Box style={{ position: 'absolute', width: '100%', height: '100%' }}> |
||||
<ResponsiveBar |
||||
data={[ |
||||
{ |
||||
utc: '-3', |
||||
users: Math.round(100 * Math.random()), |
||||
}, |
||||
{ |
||||
utc: '-5', |
||||
users: Math.round(100 * Math.random()), |
||||
}, |
||||
{ |
||||
utc: '+2', |
||||
users: Math.round(100 * Math.random()), |
||||
}, |
||||
{ |
||||
utc: '+8', |
||||
users: Math.round(100 * Math.random()), |
||||
}, |
||||
{ |
||||
utc: '-6', |
||||
users: Math.round(100 * Math.random()), |
||||
}, |
||||
{ |
||||
utc: '16', |
||||
users: Math.round(100 * Math.random()), |
||||
}, |
||||
]} |
||||
indexBy='utc' |
||||
keys={['users']} |
||||
groupMode='grouped' |
||||
layout='horizontal' |
||||
padding={0.25} |
||||
margin={{ left: 64, bottom: 20 }} |
||||
colors={[polychromaticColors[2]]} |
||||
enableLabel={false} |
||||
enableGridX |
||||
enableGridY={false} |
||||
axisTop={null} |
||||
axisRight={null} |
||||
axisBottom={{ |
||||
tickSize: 0, |
||||
tickPadding: 5, |
||||
tickRotation: 0, |
||||
format: (users) => `${ users }%`, |
||||
}} |
||||
axisLeft={{ |
||||
tickSize: 0, |
||||
tickPadding: 5, |
||||
tickRotation: 0, |
||||
format: (utc) => `UTF ${ utc }`, |
||||
}} |
||||
animate={true} |
||||
motionStiffness={90} |
||||
motionDamping={15} |
||||
theme={{ |
||||
font: 'inherit', |
||||
fontStyle: 'normal', |
||||
fontWeight: 600, |
||||
fontSize: 10, |
||||
lineHeight: 12, |
||||
letterSpacing: 0.2, |
||||
color: '#9EA2A8', |
||||
grid: { |
||||
line: { |
||||
stroke: '#CBCED1', |
||||
strokeWidth: 1, |
||||
strokeDasharray: '4 1.5', |
||||
}, |
||||
}, |
||||
}} |
||||
/> |
||||
</Box> |
||||
</Box> |
||||
</Flex.Item>; |
||||
} |
||||
@ -0,0 +1,16 @@ |
||||
import { Box, Flex, Margins } from '@rocket.chat/fuselage'; |
||||
import React from 'react'; |
||||
|
||||
import { Histogram } from './Histogram'; |
||||
|
||||
export default { |
||||
title: 'admin/engagement/data/Histogram', |
||||
component: Histogram, |
||||
decorators: [(fn) => <Margins all='x16'> |
||||
<Flex.Container> |
||||
<Box children={fn()} style={{ height: 240 }} /> |
||||
</Flex.Container> |
||||
</Margins>], |
||||
}; |
||||
|
||||
export const _default = () => <Histogram />; |
||||
@ -0,0 +1,19 @@ |
||||
import { Box, Margins } from '@rocket.chat/fuselage'; |
||||
import React from 'react'; |
||||
|
||||
export function LegendSymbol({ color = 'currentColor' }) { |
||||
return <Margins inlineEnd='x8'> |
||||
<Box |
||||
is='span' |
||||
aria-hidden='true' |
||||
style={{ |
||||
display: 'inline-block', |
||||
width: 12, |
||||
height: 12, |
||||
borderRadius: 2, |
||||
backgroundColor: color, |
||||
verticalAlign: 'baseline', |
||||
}} |
||||
/> |
||||
</Margins>; |
||||
} |
||||
@ -0,0 +1,25 @@ |
||||
import { Box, Margins } from '@rocket.chat/fuselage'; |
||||
import React from 'react'; |
||||
|
||||
import { LegendSymbol } from './LegendSymbol'; |
||||
import { monochromaticColors, polychromaticColors } from './colors'; |
||||
|
||||
export default { |
||||
title: 'admin/engagement/data/LegendSymbol', |
||||
component: LegendSymbol, |
||||
decorators: [(fn) => <Margins children={fn()} all='x16' />], |
||||
}; |
||||
|
||||
export const _default = () => <Box> |
||||
<LegendSymbol /> |
||||
Legend text |
||||
</Box>; |
||||
|
||||
export const withColor = () => <> |
||||
{monochromaticColors.map((color) => <Box key={color}> |
||||
<LegendSymbol color={color} /> {color} |
||||
</Box>)} |
||||
{polychromaticColors.map((color) => <Box key={color}> |
||||
<LegendSymbol color={color} /> {color} |
||||
</Box>)} |
||||
</>; |
||||
@ -0,0 +1,26 @@ |
||||
import React from 'react'; |
||||
|
||||
const style = { width: '1.5em', height: '1.5em', verticalAlign: '-0.5em' }; |
||||
|
||||
export const NegativeGrowthSymbol = (props) => |
||||
<svg |
||||
xmlns='http://www.w3.org/2000/svg' |
||||
viewBox='0 0 25 24' |
||||
style={style} |
||||
{...props} |
||||
> |
||||
<path |
||||
clipRule='evenodd' |
||||
fill='currentColor' |
||||
fillRule='evenodd' |
||||
d={`M4.70712 8.50049L8.55757 12.3509C9.14335 12.9367 10.0931 12.9367
|
||||
10.6789 12.3509L11.9282 11.1016L15.9295 15.1029L14.2054 15.1029C13.6531 |
||||
15.1029 13.2054 15.5506 13.2054 16.1029C13.2054 16.6551 13.6531 17.1029 |
||||
14.2054 17.1029L18.3437 17.1029C18.896 17.1029 19.3437 16.6551 19.3437 |
||||
16.1029L19.3437 11.9645C19.3437 11.4123 18.896 10.9645 18.3437 10.9645 |
||||
C17.7914 10.9645 17.3437 11.4123 17.3437 11.9645L17.3437 13.6886L12.9889 |
||||
9.33382C12.4031 8.74803 11.4534 8.74803 10.8676 9.33382L9.61823 10.5832 |
||||
L6.12133 7.08627C5.73081 6.69575 5.09765 6.69575 4.70712 7.08628C4.3166 |
||||
7.4768 4.3166 8.10996 4.70712 8.50049Z`}
|
||||
/> |
||||
</svg>; |
||||
@ -0,0 +1,16 @@ |
||||
import { Box, Margins } from '@rocket.chat/fuselage'; |
||||
import React from 'react'; |
||||
|
||||
import { NegativeGrowthSymbol } from './NegativeGrowthSymbol'; |
||||
|
||||
export default { |
||||
title: 'admin/engagement/data/NegativeGrowthSymbol', |
||||
component: NegativeGrowthSymbol, |
||||
decorators: [(fn) => <Margins children={fn()} all='x16' />], |
||||
}; |
||||
|
||||
export const _default = () => <NegativeGrowthSymbol />; |
||||
|
||||
export const withColor = () => <Box textColor='danger'> |
||||
<NegativeGrowthSymbol /> |
||||
</Box>; |
||||
@ -0,0 +1,26 @@ |
||||
import React from 'react'; |
||||
|
||||
const style = { width: '1.5em', height: '1.5em', verticalAlign: '-0.5em' }; |
||||
|
||||
export const PositiveGrowthSymbol = (props) => |
||||
<svg |
||||
xmlns='http://www.w3.org/2000/svg' |
||||
viewBox='0 0 24 24' |
||||
style={style} |
||||
{...props} |
||||
> |
||||
<path |
||||
clipRule='evenodd' |
||||
fill='currentColor' |
||||
fillRule='evenodd' |
||||
d={`M4.70712 15.3265L8.55757 11.4761C9.14335 10.8903 10.0931 10.8903
|
||||
10.6789 11.4761L11.9282 12.7254L15.9295 8.72417L14.2054 8.72417C13.6531 |
||||
8.72417 13.2054 8.27646 13.2054 7.72417C13.2054 7.17189 13.6531 6.72417 |
||||
14.2054 6.72417L18.3437 6.72418C18.896 6.72417 19.3437 7.17189 19.3437 |
||||
7.72418L19.3437 11.8625C19.3437 12.4148 18.896 12.8625 18.3437 12.8625 |
||||
C17.7914 12.8625 17.3437 12.4148 17.3437 11.8625L17.3437 10.1384L12.9889 |
||||
14.4932C12.4031 15.079 11.4534 15.079 10.8676 14.4932L9.61823 13.2439 |
||||
L6.12133 16.7408C5.73081 17.1313 5.09765 17.1313 4.70712 16.7408C4.3166 |
||||
16.3502 4.3166 15.7171 4.70712 15.3265Z`}
|
||||
/> |
||||
</svg>; |
||||
@ -0,0 +1,16 @@ |
||||
import { Box, Margins } from '@rocket.chat/fuselage'; |
||||
import React from 'react'; |
||||
|
||||
import { PositiveGrowthSymbol } from './PositiveGrowthSymbol'; |
||||
|
||||
export default { |
||||
title: 'admin/engagement/data/PositiveGrowthSymbol', |
||||
component: PositiveGrowthSymbol, |
||||
decorators: [(fn) => <Margins children={fn()} all='x16' />], |
||||
}; |
||||
|
||||
export const _default = () => <PositiveGrowthSymbol />; |
||||
|
||||
export const withColor = () => <Box textColor='success'> |
||||
<PositiveGrowthSymbol /> |
||||
</Box>; |
||||
@ -0,0 +1,2 @@ |
||||
export const monochromaticColors = ['#E8F2FF', '#D1EBFE', '#A4D3FE', '#76B7FC', '#549DF9', '#1D74F5', '#10529E']; |
||||
export const polychromaticColors = ['#FFD031', '#2DE0A5', '#1D74F5']; |
||||
@ -0,0 +1,52 @@ |
||||
import { useEffect, useState } from 'react'; |
||||
|
||||
import { useEndpoint } from '../../../../../client/contexts/ServerContext'; |
||||
import { useToastMessageDispatch } from '../../../../../client/contexts/ToastMessagesContext'; |
||||
|
||||
export const useEndpointData = (httpMethod, endpoint, params = {}) => { |
||||
const [data, setData] = useState(null); |
||||
|
||||
const getData = useEndpoint(httpMethod, endpoint); |
||||
const dispatchToastMessage = useToastMessageDispatch(); |
||||
|
||||
useEffect(() => { |
||||
let mounted = true; |
||||
|
||||
const fetchData = async () => { |
||||
try { |
||||
const timer = setTimeout(() => { |
||||
if (!mounted) { |
||||
return; |
||||
} |
||||
|
||||
setData(null); |
||||
}, 3000); |
||||
|
||||
const data = await getData(params); |
||||
|
||||
clearTimeout(timer); |
||||
|
||||
if (!data.success) { |
||||
throw new Error(data.status); |
||||
} |
||||
|
||||
if (!mounted) { |
||||
return; |
||||
} |
||||
|
||||
setData(data); |
||||
} catch (error) { |
||||
console.error(error); |
||||
dispatchToastMessage({ type: 'error', message: error }); |
||||
} |
||||
}; |
||||
|
||||
fetchData(); |
||||
|
||||
return () => { |
||||
mounted = false; |
||||
}; |
||||
}, [getData, params]); |
||||
|
||||
return data; |
||||
}; |
||||
@ -0,0 +1 @@ |
||||
import './routes'; |
||||
@ -0,0 +1,71 @@ |
||||
import { Blaze } from 'meteor/blaze'; |
||||
import { HTML } from 'meteor/htmljs'; |
||||
import { FlowRouter } from 'meteor/kadira:flow-router'; |
||||
import { BlazeLayout } from 'meteor/kadira:blaze-layout'; |
||||
import { Template } from 'meteor/templating'; |
||||
|
||||
import { hasAllPermission } from '../../../../app/authorization'; |
||||
import { AdminBox } from '../../../../app/ui-utils'; |
||||
import { hasLicense } from '../../license/client'; |
||||
|
||||
Template.EngagementDashboardRoute = new Blaze.Template('EngagementDashboardRoute', |
||||
() => HTML.DIV.call(null, { style: 'overflow: hidden; flex: 1 1 auto; height: 1%;' })); |
||||
|
||||
Template.EngagementDashboardRoute.onRendered(async function() { |
||||
const [ |
||||
{ createElement }, |
||||
{ render, unmountComponentAtNode }, |
||||
{ MeteorProvider }, |
||||
{ EngagementDashboardRoute }, |
||||
] = await Promise.all([ |
||||
import('react'), |
||||
import('react-dom'), |
||||
import('../../../../client/providers/MeteorProvider'), |
||||
import('./components/EngagementDashboardRoute'), |
||||
]); |
||||
|
||||
const container = this.firstNode; |
||||
|
||||
if (!container) { |
||||
return; |
||||
} |
||||
|
||||
this.autorun(() => { |
||||
const routeName = FlowRouter.getRouteName(); |
||||
if (routeName !== 'engagement-dashboard') { |
||||
unmountComponentAtNode(container); |
||||
} |
||||
}); |
||||
|
||||
render(createElement(MeteorProvider, { children: createElement(EngagementDashboardRoute) }), container); |
||||
}); |
||||
|
||||
let licensed = false; |
||||
|
||||
FlowRouter.route('/admin/engagement-dashboard/:tab?', { |
||||
name: 'engagement-dashboard', |
||||
action: () => { |
||||
if (!licensed) { |
||||
return; |
||||
} |
||||
|
||||
BlazeLayout.render('main', { center: 'EngagementDashboardRoute' }); |
||||
}, |
||||
}); |
||||
|
||||
hasLicense('engagement-dashboard').then((enabled) => { |
||||
if (!enabled) { |
||||
return; |
||||
} |
||||
|
||||
licensed = true; |
||||
|
||||
AdminBox.addOption({ |
||||
href: 'engagement-dashboard', |
||||
i18nLabel: 'Engagement Dashboard', |
||||
icon: 'file-keynote', |
||||
permissionGranted: () => hasAllPermission('view-statistics'), |
||||
}); |
||||
}).catch((error) => { |
||||
console.error('Error checking license.', error); |
||||
}); |
||||
@ -0,0 +1,26 @@ |
||||
import { check } from 'meteor/check'; |
||||
|
||||
import { API } from '../../../../../app/api'; |
||||
import { findAllChannelsWithNumberOfMessages } from '../lib/channels'; |
||||
import { transformDatesForAPI } from './helpers/date'; |
||||
|
||||
API.v1.addRoute('engagement-dashboard/channels/list', { authRequired: true }, { |
||||
get() { |
||||
const { start, end } = this.requestParams(); |
||||
const { offset, count } = this.getPaginationItems(); |
||||
|
||||
check(start, String); |
||||
check(end, String); |
||||
|
||||
const { channels, total } = Promise.await(findAllChannelsWithNumberOfMessages({ |
||||
...transformDatesForAPI(start, end), |
||||
options: { offset, count }, |
||||
})); |
||||
return API.v1.success({ |
||||
channels, |
||||
count: channels.length, |
||||
offset, |
||||
total, |
||||
}); |
||||
}, |
||||
}); |
||||
@ -0,0 +1,14 @@ |
||||
export const transformDatesForAPI = (start, end) => { |
||||
if (isNaN(Date.parse(start))) { |
||||
throw new Error('The "start" query parameter must be a valid date.'); |
||||
} |
||||
if (end && isNaN(Date.parse(end))) { |
||||
throw new Error('The "end" query parameter must be a valid date.'); |
||||
} |
||||
start = new Date(start); |
||||
end = new Date(end); |
||||
return { |
||||
start, |
||||
end, |
||||
}; |
||||
}; |
||||
@ -0,0 +1,3 @@ |
||||
import './messages'; |
||||
import './channels'; |
||||
import './users'; |
||||
@ -0,0 +1,41 @@ |
||||
import { check } from 'meteor/check'; |
||||
|
||||
import { API } from '../../../../../app/api'; |
||||
import { findWeeklyMessagesSentData, findMessagesSentOrigin, findTopFivePopularChannelsByMessageSentQuantity } from '../lib/messages'; |
||||
import { transformDatesForAPI } from './helpers/date'; |
||||
|
||||
API.v1.addRoute('engagement-dashboard/messages/messages-sent', { authRequired: true }, { |
||||
get() { |
||||
const { start, end } = this.requestParams(); |
||||
|
||||
check(start, String); |
||||
check(end, String); |
||||
|
||||
const data = Promise.await(findWeeklyMessagesSentData(transformDatesForAPI(start, end))); |
||||
return API.v1.success(data); |
||||
}, |
||||
}); |
||||
|
||||
API.v1.addRoute('engagement-dashboard/messages/origin', { authRequired: true }, { |
||||
get() { |
||||
const { start, end } = this.requestParams(); |
||||
|
||||
check(start, String); |
||||
check(end, String); |
||||
|
||||
const data = Promise.await(findMessagesSentOrigin(transformDatesForAPI(start, end))); |
||||
return API.v1.success(data); |
||||
}, |
||||
}); |
||||
|
||||
API.v1.addRoute('engagement-dashboard/messages/top-five-popular-channels', { authRequired: true }, { |
||||
get() { |
||||
const { start, end } = this.requestParams(); |
||||
|
||||
check(start, String); |
||||
check(end, String); |
||||
|
||||
const data = Promise.await(findTopFivePopularChannelsByMessageSentQuantity(transformDatesForAPI(start, end))); |
||||
return API.v1.success(data); |
||||
}, |
||||
}); |
||||
@ -0,0 +1,67 @@ |
||||
import { check } from 'meteor/check'; |
||||
|
||||
import { API } from '../../../../../app/api'; |
||||
import { |
||||
findWeeklyUsersRegisteredData, |
||||
findActiveUsersMonthlyData, |
||||
findBusiestsChatsInADayByHours, |
||||
findBusiestsChatsWithinAWeek, |
||||
findUserSessionsByHourWithinAWeek, |
||||
} from '../lib/users'; |
||||
import { transformDatesForAPI } from './helpers/date'; |
||||
|
||||
API.v1.addRoute('engagement-dashboard/users/new-users', { authRequired: true }, { |
||||
get() { |
||||
const { start, end } = this.requestParams(); |
||||
|
||||
check(start, String); |
||||
check(end, String); |
||||
|
||||
const data = Promise.await(findWeeklyUsersRegisteredData(transformDatesForAPI(start, end))); |
||||
return API.v1.success(data); |
||||
}, |
||||
}); |
||||
|
||||
API.v1.addRoute('engagement-dashboard/users/active-users', { authRequired: true }, { |
||||
get() { |
||||
const { start, end } = this.requestParams(); |
||||
|
||||
check(start, String); |
||||
check(end, String); |
||||
|
||||
const data = Promise.await(findActiveUsersMonthlyData(transformDatesForAPI(start, end))); |
||||
return API.v1.success(data); |
||||
}, |
||||
}); |
||||
|
||||
API.v1.addRoute('engagement-dashboard/users/chat-busier/hourly-data', { authRequired: true }, { |
||||
get() { |
||||
const { start } = this.requestParams(); |
||||
|
||||
const data = Promise.await(findBusiestsChatsInADayByHours(transformDatesForAPI(start))); |
||||
return API.v1.success(data); |
||||
}, |
||||
}); |
||||
|
||||
API.v1.addRoute('engagement-dashboard/users/chat-busier/weekly-data', { authRequired: true }, { |
||||
get() { |
||||
const { start } = this.requestParams(); |
||||
|
||||
check(start, String); |
||||
|
||||
const data = Promise.await(findBusiestsChatsWithinAWeek(transformDatesForAPI(start))); |
||||
return API.v1.success(data); |
||||
}, |
||||
}); |
||||
|
||||
API.v1.addRoute('engagement-dashboard/users/users-by-time-of-the-day-in-a-week', { authRequired: true }, { |
||||
get() { |
||||
const { start, end } = this.requestParams(); |
||||
|
||||
check(start, String); |
||||
check(end, String); |
||||
|
||||
const data = Promise.await(findUserSessionsByHourWithinAWeek(transformDatesForAPI(start, end))); |
||||
return API.v1.success(data); |
||||
}, |
||||
}); |
||||
@ -0,0 +1,6 @@ |
||||
import { onLicense } from '../../license/server'; |
||||
|
||||
onLicense('engagement-dashboard', async () => { |
||||
await import('./listeners'); |
||||
await import('./api'); |
||||
}); |
||||
@ -0,0 +1,27 @@ |
||||
import moment from 'moment'; |
||||
|
||||
import { Rooms } from '../../../../../app/models/server/raw'; |
||||
import { convertDateToInt, diffBetweenDaysInclusive } from './date'; |
||||
|
||||
export const findAllChannelsWithNumberOfMessages = async ({ start, end, options = {} }) => { |
||||
const daysBetweenDates = diffBetweenDaysInclusive(end, start); |
||||
const endOfLastWeek = moment(start).clone().subtract(1, 'days').toDate(); |
||||
const startOfLastWeek = moment(endOfLastWeek).clone().subtract(daysBetweenDates, 'days').toDate(); |
||||
const total = await Rooms.findChannelsWithNumberOfMessagesBetweenDate({ |
||||
start: convertDateToInt(start), |
||||
end: convertDateToInt(end), |
||||
startOfLastWeek: convertDateToInt(startOfLastWeek), |
||||
endOfLastWeek: convertDateToInt(endOfLastWeek), |
||||
onlyCount: true, |
||||
}).toArray(); |
||||
return { |
||||
channels: await Rooms.findChannelsWithNumberOfMessagesBetweenDate({ |
||||
start: convertDateToInt(start), |
||||
end: convertDateToInt(end), |
||||
startOfLastWeek: convertDateToInt(startOfLastWeek), |
||||
endOfLastWeek: convertDateToInt(endOfLastWeek), |
||||
options, |
||||
}), |
||||
total: total.length ? total[0].total : 0, |
||||
}; |
||||
}; |
||||
@ -0,0 +1,11 @@ |
||||
import moment from 'moment'; |
||||
|
||||
export const convertDateToInt = (date) => parseInt(moment(date).clone().format('YYYYMMDD')); |
||||
export const convertIntToDate = (intValue) => moment(intValue, 'YYYYMMDD').clone().toDate(); |
||||
export const diffBetweenDays = (start, end) => moment(new Date(start)).clone().diff(new Date(end), 'days'); |
||||
export const diffBetweenDaysInclusive = (start, end) => diffBetweenDays(start, end) + 1; |
||||
|
||||
export const getTotalOfWeekItems = (weekItems, property) => weekItems.reduce((acc, item) => { |
||||
acc += item[property]; |
||||
return acc; |
||||
}, 0); |
||||
@ -0,0 +1,85 @@ |
||||
import moment from 'moment'; |
||||
|
||||
import Analytics from '../../../../../app/models/server/raw/Analytics'; |
||||
import { roomTypes } from '../../../../../app/utils'; |
||||
import { convertDateToInt, diffBetweenDaysInclusive, convertIntToDate, getTotalOfWeekItems } from './date'; |
||||
|
||||
export const handleMessagesSent = (message, room) => { |
||||
const roomTypesToShow = roomTypes.getTypesToShowOnDashboard(); |
||||
if (!roomTypesToShow.includes(room.t)) { |
||||
return; |
||||
} |
||||
Promise.await(Analytics.saveMessageSent({ |
||||
date: convertDateToInt(message.ts), |
||||
room, |
||||
})); |
||||
return message; |
||||
}; |
||||
|
||||
export const handleMessagesDeleted = (message, room) => { |
||||
const roomTypesToShow = roomTypes.getTypesToShowOnDashboard(); |
||||
if (!roomTypesToShow.includes(room.t)) { |
||||
return; |
||||
} |
||||
Promise.await(Analytics.saveMessageDeleted({ |
||||
date: convertDateToInt(message.ts), |
||||
room, |
||||
})); |
||||
return message; |
||||
}; |
||||
|
||||
export const findWeeklyMessagesSentData = async ({ start, end }) => { |
||||
const daysBetweenDates = diffBetweenDaysInclusive(end, start); |
||||
const endOfLastWeek = moment(start).clone().subtract(1, 'days').toDate(); |
||||
const startOfLastWeek = moment(endOfLastWeek).clone().subtract(daysBetweenDates, 'days').toDate(); |
||||
const today = convertDateToInt(end); |
||||
const yesterday = convertDateToInt(moment(end).clone().subtract(1, 'days').toDate()); |
||||
const currentPeriodMessages = await Analytics.getMessagesSentTotalByDate({ |
||||
start: convertDateToInt(start), |
||||
end: convertDateToInt(end), |
||||
options: { count: daysBetweenDates, sort: { _id: -1 } }, |
||||
}); |
||||
const lastPeriodMessages = await Analytics.getMessagesSentTotalByDate({ |
||||
start: convertDateToInt(startOfLastWeek), |
||||
end: convertDateToInt(endOfLastWeek), |
||||
options: { count: daysBetweenDates, sort: { _id: -1 } }, |
||||
}); |
||||
const yesterdayMessages = (currentPeriodMessages.find((item) => item._id === yesterday) || {}).messages || 0; |
||||
const todayMessages = (currentPeriodMessages.find((item) => item._id === today) || {}).messages || 0; |
||||
const currentPeriodTotalOfMessages = getTotalOfWeekItems(currentPeriodMessages, 'messages'); |
||||
const lastPeriodTotalOfMessages = getTotalOfWeekItems(lastPeriodMessages, 'messages'); |
||||
return { |
||||
days: currentPeriodMessages.map((day) => ({ day: convertIntToDate(day._id), messages: day.messages })), |
||||
period: { |
||||
count: currentPeriodTotalOfMessages, |
||||
variation: currentPeriodTotalOfMessages - lastPeriodTotalOfMessages, |
||||
}, |
||||
yesterday: { |
||||
count: yesterdayMessages, |
||||
variation: todayMessages - yesterdayMessages, |
||||
}, |
||||
}; |
||||
}; |
||||
|
||||
export const findMessagesSentOrigin = async ({ start, end }) => { |
||||
const origins = await Analytics.getMessagesOrigin({ |
||||
start: convertDateToInt(start), |
||||
end: convertDateToInt(end), |
||||
}); |
||||
const roomTypesToShow = roomTypes.getTypesToShowOnDashboard(); |
||||
const responseTypes = origins.map((origin) => origin.t); |
||||
const missingTypes = roomTypesToShow.filter((type) => !responseTypes.includes(type)); |
||||
if (missingTypes.length) { |
||||
missingTypes.forEach((type) => origins.push({ messages: 0, t: type })); |
||||
} |
||||
return { origins }; |
||||
}; |
||||
|
||||
export const findTopFivePopularChannelsByMessageSentQuantity = async ({ start, end }) => { |
||||
const channels = await Analytics.getMostPopularChannelsByMessagesSentQuantity({ |
||||
start: convertDateToInt(start), |
||||
end: convertDateToInt(end), |
||||
options: { count: 5, sort: { messages: -1 } }, |
||||
}); |
||||
return { channels }; |
||||
}; |
||||
@ -0,0 +1,109 @@ |
||||
import moment from 'moment'; |
||||
|
||||
import Analytics from '../../../../../app/models/server/raw/Analytics'; |
||||
import Sessions from '../../../../../app/models/server/raw/Sessions'; |
||||
import { convertDateToInt, diffBetweenDaysInclusive, getTotalOfWeekItems, convertIntToDate } from './date'; |
||||
|
||||
export const handleUserCreated = (user) => { |
||||
Promise.await(Analytics.saveUserData({ |
||||
date: convertDateToInt(user.ts), |
||||
user, |
||||
})); |
||||
return user; |
||||
}; |
||||
|
||||
export const findWeeklyUsersRegisteredData = async ({ start, end }) => { |
||||
const daysBetweenDates = diffBetweenDaysInclusive(end, start); |
||||
const endOfLastWeek = moment(start).clone().subtract(1, 'days').toDate(); |
||||
const startOfLastWeek = moment(endOfLastWeek).clone().subtract(daysBetweenDates, 'days').toDate(); |
||||
const today = convertDateToInt(end); |
||||
const yesterday = convertDateToInt(moment(end).clone().subtract(1, 'days').toDate()); |
||||
const currentPeriodUsers = await Analytics.getTotalOfRegisteredUsersByDate({ |
||||
start: convertDateToInt(start), |
||||
end: convertDateToInt(end), |
||||
options: { count: daysBetweenDates, sort: { _id: -1 } }, |
||||
}); |
||||
const lastPeriodUsers = await Analytics.getTotalOfRegisteredUsersByDate({ |
||||
start: convertDateToInt(startOfLastWeek), |
||||
end: convertDateToInt(endOfLastWeek), |
||||
options: { count: daysBetweenDates, sort: { _id: -1 } }, |
||||
}); |
||||
const yesterdayUsers = (currentPeriodUsers.find((item) => item._id === yesterday) || {}).users || 0; |
||||
const todayUsers = (currentPeriodUsers.find((item) => item._id === today) || {}).users || 0; |
||||
const currentPeriodTotalUsers = getTotalOfWeekItems(currentPeriodUsers, 'users'); |
||||
const lastPeriodTotalUsers = getTotalOfWeekItems(lastPeriodUsers, 'users'); |
||||
return { |
||||
days: currentPeriodUsers.map((day) => ({ day: convertIntToDate(day._id), users: day.users })), |
||||
period: { |
||||
count: currentPeriodTotalUsers, |
||||
variation: currentPeriodTotalUsers - lastPeriodTotalUsers, |
||||
}, |
||||
yesterday: { |
||||
count: yesterdayUsers, |
||||
variation: todayUsers - yesterdayUsers, |
||||
}, |
||||
}; |
||||
}; |
||||
|
||||
export const findActiveUsersMonthlyData = async ({ start, end }) => { |
||||
const startOfPeriod = moment(start); |
||||
const endOfPeriod = moment(end); |
||||
|
||||
return { |
||||
month: await Sessions.getActiveUsersOfPeriodByDayBetweenDates({ |
||||
start: { |
||||
year: startOfPeriod.year(), |
||||
month: startOfPeriod.month() + 1, |
||||
day: startOfPeriod.date(), |
||||
}, |
||||
end: { |
||||
year: endOfPeriod.year(), |
||||
month: endOfPeriod.month() + 1, |
||||
day: endOfPeriod.date(), |
||||
}, |
||||
}), |
||||
}; |
||||
}; |
||||
|
||||
export const findBusiestsChatsInADayByHours = async ({ start }) => { |
||||
const now = moment(start); |
||||
const yesterday = moment(now).clone().subtract(24, 'hours'); |
||||
return { |
||||
hours: await Sessions.getBusiestTimeWithinHoursPeriod({ |
||||
start: yesterday.toDate(), |
||||
end: now.toDate(), |
||||
}), |
||||
}; |
||||
}; |
||||
|
||||
export const findBusiestsChatsWithinAWeek = async ({ start }) => { |
||||
const today = moment(start); |
||||
const startOfCurrentWeek = moment(today).clone().subtract(7, 'days'); |
||||
|
||||
return { |
||||
month: await Sessions.getTotalOfSessionsByDayBetweenDates({ |
||||
start: { |
||||
year: startOfCurrentWeek.year(), |
||||
month: startOfCurrentWeek.month() + 1, |
||||
day: startOfCurrentWeek.date(), |
||||
}, |
||||
end: { |
||||
year: today.year(), |
||||
month: today.month() + 1, |
||||
day: today.date(), |
||||
}, |
||||
}), |
||||
}; |
||||
}; |
||||
|
||||
export const findUserSessionsByHourWithinAWeek = async ({ start, end }) => { |
||||
const startOfPeriod = moment(start); |
||||
const endOfPeriod = moment(end); |
||||
|
||||
return { |
||||
week: await Sessions.getTotalOfSessionByHourAndDayBetweenDates({ |
||||
start: startOfPeriod.toDate(), |
||||
end: endOfPeriod.toDate(), |
||||
}), |
||||
}; |
||||
}; |
||||
@ -0,0 +1,2 @@ |
||||
import './messages'; |
||||
import './users'; |
||||
@ -0,0 +1,5 @@ |
||||
import { callbacks } from '../../../../../app/callbacks/server'; |
||||
import { handleMessagesSent, handleMessagesDeleted } from '../lib/messages'; |
||||
|
||||
callbacks.add('afterSaveMessage', handleMessagesSent); |
||||
callbacks.add('afterDeleteMessage', handleMessagesDeleted); |
||||
@ -0,0 +1,4 @@ |
||||
import { callbacks } from '../../../../../app/callbacks/server'; |
||||
import { handleUserCreated } from '../lib/users'; |
||||
|
||||
callbacks.add('afterCreateUser', handleUserCreated); |
||||
@ -1,4 +1,5 @@ |
||||
import '../app/auditing/client/index'; |
||||
import '../app/canned-responses/client/index'; |
||||
import '../app/engagement-dashboard/client/index'; |
||||
import '../app/license/client/index'; |
||||
import '../app/livechat-enterprise/client/index'; |
||||
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in new issue