diff options
Diffstat (limited to 'src/chart')
| -rw-r--r-- | src/chart/drive.ts | 122 | ||||
| -rw-r--r-- | src/chart/hashtag.ts | 37 | ||||
| -rw-r--r-- | src/chart/index.ts | 285 | ||||
| -rw-r--r-- | src/chart/network.ts | 64 | ||||
| -rw-r--r-- | src/chart/notes.ts | 114 | ||||
| -rw-r--r-- | src/chart/per-user-drive.ts | 101 | ||||
| -rw-r--r-- | src/chart/per-user-following.ts | 128 | ||||
| -rw-r--r-- | src/chart/per-user-notes.ts | 94 | ||||
| -rw-r--r-- | src/chart/per-user-reactions.ts | 45 | ||||
| -rw-r--r-- | src/chart/users.ts | 75 |
10 files changed, 1065 insertions, 0 deletions
diff --git a/src/chart/drive.ts b/src/chart/drive.ts new file mode 100644 index 0000000000..ff454c750a --- /dev/null +++ b/src/chart/drive.ts @@ -0,0 +1,122 @@ +import autobind from 'autobind-decorator'; +import Chart, { Obj } from './'; +import DriveFile, { IDriveFile } from '../models/drive-file'; +import { isLocalUser } from '../models/user'; + +/** + * ドライブに関するチャート + */ +type DriveLog = { + local: { + /** + * 集計期間時点での、全ドライブファイル数 + */ + totalCount: number; + + /** + * 集計期間時点での、全ドライブファイルの合計サイズ + */ + totalSize: number; + + /** + * 増加したドライブファイル数 + */ + incCount: number; + + /** + * 増加したドライブ使用量 + */ + incSize: number; + + /** + * 減少したドライブファイル数 + */ + decCount: number; + + /** + * 減少したドライブ使用量 + */ + decSize: number; + }; + + remote: DriveLog['local']; +}; + +class DriveChart extends Chart<DriveLog> { + constructor() { + super('drive'); + } + + @autobind + protected async getTemplate(init: boolean, latest?: DriveLog): Promise<DriveLog> { + const calcSize = (local: boolean) => DriveFile + .aggregate([{ + $match: { + 'metadata._user.host': local ? null : { $ne: null }, + 'metadata.deletedAt': { $exists: false } + } + }, { + $project: { + length: true + } + }, { + $group: { + _id: null, + usage: { $sum: '$length' } + } + }]) + .then(res => res.length > 0 ? res[0].usage : 0); + + const [localCount, remoteCount, localSize, remoteSize] = init ? await Promise.all([ + DriveFile.count({ 'metadata._user.host': null }), + DriveFile.count({ 'metadata._user.host': { $ne: null } }), + calcSize(true), + calcSize(false) + ]) : [ + latest ? latest.local.totalCount : 0, + latest ? latest.remote.totalCount : 0, + latest ? latest.local.totalSize : 0, + latest ? latest.remote.totalSize : 0 + ]; + + return { + local: { + totalCount: localCount, + totalSize: localSize, + incCount: 0, + incSize: 0, + decCount: 0, + decSize: 0 + }, + remote: { + totalCount: remoteCount, + totalSize: remoteSize, + incCount: 0, + incSize: 0, + decCount: 0, + decSize: 0 + } + }; + } + + @autobind + public async update(file: IDriveFile, isAdditional: boolean) { + const update: Obj = {}; + + update.totalCount = isAdditional ? 1 : -1; + update.totalSize = isAdditional ? file.length : -file.length; + if (isAdditional) { + update.incCount = 1; + update.incSize = file.length; + } else { + update.decCount = 1; + update.decSize = file.length; + } + + await this.inc({ + [isLocalUser(file.metadata._user) ? 'local' : 'remote']: update + }); + } +} + +export default new DriveChart(); diff --git a/src/chart/hashtag.ts b/src/chart/hashtag.ts new file mode 100644 index 0000000000..976fd0c84b --- /dev/null +++ b/src/chart/hashtag.ts @@ -0,0 +1,37 @@ +import autobind from 'autobind-decorator'; +import * as mongo from 'mongodb'; +import Chart, { Partial } from './'; + +/** + * ハッシュタグに関するチャート + */ +type HashtagLog = { + /** + * 投稿された数 + */ + count: number; +}; + +class HashtagChart extends Chart<HashtagLog> { + constructor() { + super('hashtag', true); + } + + @autobind + protected async getTemplate(init: boolean, latest?: HashtagLog): Promise<HashtagLog> { + return { + count: 0 + }; + } + + @autobind + public async update(hashtag: string, userId: mongo.ObjectId) { + const inc: Partial<HashtagLog> = { + count: 1 + }; + + await this.incIfUnique(inc, 'users', userId.toHexString(), hashtag); + } +} + +export default new HashtagChart(); diff --git a/src/chart/index.ts b/src/chart/index.ts new file mode 100644 index 0000000000..48934dba3d --- /dev/null +++ b/src/chart/index.ts @@ -0,0 +1,285 @@ +/** + * チャートエンジン + */ + +const nestedProperty = require('nested-property'); +import autobind from 'autobind-decorator'; +import * as mongo from 'mongodb'; +import db from '../db/mongodb'; +import { ICollection } from 'monk'; + +export type Obj = { [key: string]: any }; + +export type Partial<T> = { + [P in keyof T]?: Partial<T[P]>; +}; + +type ArrayValue<T> = { + [P in keyof T]: T[P] extends number ? Array<T[P]> : ArrayValue<T[P]>; +}; + +type Span = 'day' | 'hour'; + +//#region Chart Core +type Log<T extends Obj> = { + _id: mongo.ObjectID; + + /** + * 集計のグループ + */ + group?: any; + + /** + * 集計日時 + */ + date: Date; + + /** + * 集計期間 + */ + span: Span; + + /** + * データ + */ + data: T; + + /** + * ユニークインクリメント用 + */ + unique?: Obj; +}; + +/** + * 様々なチャートの管理を司るクラス + */ +export default abstract class Chart<T> { + protected collection: ICollection<Log<T>>; + protected abstract async getTemplate(init: boolean, latest?: T, group?: any): Promise<T>; + + constructor(name: string, grouped = false) { + this.collection = db.get<Log<T>>(`chart.${name}`); + if (grouped) { + this.collection.createIndex({ span: -1, date: -1, group: -1 }, { unique: true }); + } else { + this.collection.createIndex({ span: -1, date: -1 }, { unique: true }); + } + } + + @autobind + private convertQuery(x: Obj, path: string): Obj { + const query: Obj = {}; + + const dive = (x: Obj, path: string) => { + Object.entries(x).forEach(([k, v]) => { + const p = path ? `${path}.${k}` : k; + if (typeof v === 'number') { + query[p] = v; + } else { + dive(v, p); + } + }); + }; + + dive(x, path); + + return query; + } + + @autobind + private async getCurrentLog(span: Span, group?: any): Promise<Log<T>> { + const now = new Date(); + const y = now.getFullYear(); + const m = now.getMonth(); + const d = now.getDate(); + const h = now.getHours(); + + const current = + span == 'day' ? new Date(y, m, d) : + span == 'hour' ? new Date(y, m, d, h) : + null; + + // 現在(今日または今のHour)のログ + const currentLog = await this.collection.findOne({ + group: group, + span: span, + date: current + }); + + if (currentLog) { + return currentLog; + } + + // 集計期間が変わってから、初めてのチャート更新なら + // 最も最近のログを持ってくる + // * 例えば集計期間が「日」である場合で考えると、 + // * 昨日何もチャートを更新するような出来事がなかった場合は、 + // * ログがそもそも作られずドキュメントが存在しないということがあり得るため、 + // * 「昨日の」と決め打ちせずに「もっとも最近の」とします + const latest = await this.collection.findOne({ + group: group, + span: span + }, { + sort: { + date: -1 + } + }); + + if (latest) { + // 現在のログを初期挿入 + const data = await this.getTemplate(false, latest.data); + + const log = await this.collection.insert({ + group: group, + span: span, + date: current, + data: data + }); + + return log; + } else { + // ログが存在しなかったら + // * Misskeyインスタンスを建てて初めてのチャート更新時など + + // 空のログを作成 + const data = await this.getTemplate(true, null, group); + + const log = await this.collection.insert({ + group: group, + span: span, + date: current, + data: data + }); + + return log; + } + } + + @autobind + protected commit(query: Obj, group?: any, uniqueKey?: string, uniqueValue?: string): void { + const update = (log: Log<T>) => { + // ユニークインクリメントの場合、指定のキーに指定の値が既に存在していたら弾く + if ( + uniqueKey && + log.unique && + log.unique[uniqueKey] && + log.unique[uniqueKey].includes(uniqueValue) + ) return; + + // ユニークインクリメントの指定のキーに値を追加 + if (uniqueKey) { + query['$push'] = { + [`unique.${uniqueKey}`]: uniqueValue + }; + } + + this.collection.update({ + _id: log._id + }, query); + }; + + this.getCurrentLog('day', group).then(log => update(log)); + this.getCurrentLog('hour', group).then(log => update(log)); + } + + @autobind + protected inc(inc: Partial<T>, group?: any): void { + this.commit({ + $inc: this.convertQuery(inc, 'data') + }, group); + } + + @autobind + protected incIfUnique(inc: Partial<T>, key: string, value: string, group?: any): void { + this.commit({ + $inc: this.convertQuery(inc, 'data') + }, group, key, value); + } + + @autobind + public async getChart(span: Span, range: number, group?: any): Promise<ArrayValue<T>> { + const promisedChart: Promise<T>[] = []; + + const now = new Date(); + const y = now.getFullYear(); + const m = now.getMonth(); + const d = now.getDate(); + const h = now.getHours(); + + const gt = + span == 'day' ? new Date(y, m, d - range) : + span == 'hour' ? new Date(y, m, d, h - range) : null; + + const logs = await this.collection.find({ + group: group, + span: span, + date: { + $gt: gt + } + }, { + sort: { + date: -1 + }, + fields: { + _id: 0 + } + }); + + for (let i = (range - 1); i >= 0; i--) { + const current = + span == 'day' ? new Date(y, m, d - i) : + span == 'hour' ? new Date(y, m, d, h - i) : + null; + + const log = logs.find(l => l.date.getTime() == current.getTime()); + + if (log) { + promisedChart.unshift(Promise.resolve(log.data)); + } else { // 隙間埋め + const latest = logs.find(l => l.date.getTime() < current.getTime()); + promisedChart.unshift(this.getTemplate(false, latest ? latest.data : null)); + } + } + + const chart = await Promise.all(promisedChart); + + const res: ArrayValue<T> = {} as any; + + /** + * [{ + * xxxxx: 1, + * yyyyy: 5 + * }, { + * xxxxx: 2, + * yyyyy: 6 + * }, { + * xxxxx: 3, + * yyyyy: 7 + * }] + * + * を + * + * { + * xxxxx: [1, 2, 3], + * yyyyy: [5, 6, 7] + * } + * + * にする + */ + const dive = (x: Obj, path?: string) => { + Object.entries(x).forEach(([k, v]) => { + const p = path ? `${path}.${k}` : k; + if (typeof v == 'object') { + dive(v, p); + } else { + nestedProperty.set(res, p, chart.map(s => nestedProperty.get(s, p))); + } + }); + }; + + dive(chart[0]); + + return res; + } +} +//#endregion diff --git a/src/chart/network.ts b/src/chart/network.ts new file mode 100644 index 0000000000..fce47099d1 --- /dev/null +++ b/src/chart/network.ts @@ -0,0 +1,64 @@ +import autobind from 'autobind-decorator'; +import Chart, { Partial } from './'; + +/** + * ネットワークに関するチャート + */ +type NetworkLog = { + /** + * 受信したリクエスト数 + */ + incomingRequests: number; + + /** + * 送信したリクエスト数 + */ + outgoingRequests: number; + + /** + * 応答時間の合計 + * TIP: (totalTime / incomingRequests) でひとつのリクエストに平均でどれくらいの時間がかかったか知れる + */ + totalTime: number; + + /** + * 合計受信データ量 + */ + incomingBytes: number; + + /** + * 合計送信データ量 + */ + outgoingBytes: number; +}; + +class NetworkChart extends Chart<NetworkLog> { + constructor() { + super('network'); + } + + @autobind + protected async getTemplate(init: boolean, latest?: NetworkLog): Promise<NetworkLog> { + return { + incomingRequests: 0, + outgoingRequests: 0, + totalTime: 0, + incomingBytes: 0, + outgoingBytes: 0 + }; + } + + @autobind + public async update(incomingRequests: number, time: number, incomingBytes: number, outgoingBytes: number) { + const inc: Partial<NetworkLog> = { + incomingRequests: incomingRequests, + totalTime: time, + incomingBytes: incomingBytes, + outgoingBytes: outgoingBytes + }; + + await this.inc(inc); + } +} + +export default new NetworkChart(); diff --git a/src/chart/notes.ts b/src/chart/notes.ts new file mode 100644 index 0000000000..738778e72a --- /dev/null +++ b/src/chart/notes.ts @@ -0,0 +1,114 @@ +import autobind from 'autobind-decorator'; +import Chart, { Obj } from '.'; +import Note, { INote } from '../models/note'; +import { isLocalUser } from '../models/user'; + +/** + * 投稿に関するチャート + */ +type NotesLog = { + local: { + /** + * 集計期間時点での、全投稿数 + */ + total: number; + + /** + * 増加した投稿数 + */ + inc: number; + + /** + * 減少した投稿数 + */ + dec: number; + + diffs: { + /** + * 通常の投稿数の差分 + */ + normal: number; + + /** + * リプライの投稿数の差分 + */ + reply: number; + + /** + * Renoteの投稿数の差分 + */ + renote: number; + }; + }; + + remote: NotesLog['local']; +}; + +class NotesChart extends Chart<NotesLog> { + constructor() { + super('notes'); + } + + @autobind + protected async getTemplate(init: boolean, latest?: NotesLog): Promise<NotesLog> { + const [localCount, remoteCount] = init ? await Promise.all([ + Note.count({ '_user.host': null }), + Note.count({ '_user.host': { $ne: null } }) + ]) : [ + latest ? latest.local.total : 0, + latest ? latest.remote.total : 0 + ]; + + return { + local: { + total: localCount, + inc: 0, + dec: 0, + diffs: { + normal: 0, + reply: 0, + renote: 0 + } + }, + remote: { + total: remoteCount, + inc: 0, + dec: 0, + diffs: { + normal: 0, + reply: 0, + renote: 0 + } + } + }; + } + + @autobind + public async update(note: INote, isAdditional: boolean) { + const update: Obj = { + diffs: {} + }; + + update.total = isAdditional ? 1 : -1; + + if (isAdditional) { + update.inc = 1; + } else { + update.dec = 1; + } + + if (note.replyId != null) { + update.diffs.reply = isAdditional ? 1 : -1; + } else if (note.renoteId != null) { + update.diffs.renote = isAdditional ? 1 : -1; + } else { + update.diffs.normal = isAdditional ? 1 : -1; + } + + await this.inc({ + [isLocalUser(note._user) ? 'local' : 'remote']: update + }); + } +} + +export default new NotesChart(); diff --git a/src/chart/per-user-drive.ts b/src/chart/per-user-drive.ts new file mode 100644 index 0000000000..3decedeb3b --- /dev/null +++ b/src/chart/per-user-drive.ts @@ -0,0 +1,101 @@ +import autobind from 'autobind-decorator'; +import Chart, { Obj } from './'; +import DriveFile, { IDriveFile } from '../models/drive-file'; + +/** + * ユーザーごとのドライブに関するチャート + */ +type PerUserDriveLog = { + /** + * 集計期間時点での、全ドライブファイル数 + */ + totalCount: number; + + /** + * 集計期間時点での、全ドライブファイルの合計サイズ + */ + totalSize: number; + + /** + * 増加したドライブファイル数 + */ + incCount: number; + + /** + * 増加したドライブ使用量 + */ + incSize: number; + + /** + * 減少したドライブファイル数 + */ + decCount: number; + + /** + * 減少したドライブ使用量 + */ + decSize: number; +}; + +class PerUserDriveChart extends Chart<PerUserDriveLog> { + constructor() { + super('perUserDrive', true); + } + + @autobind + protected async getTemplate(init: boolean, latest?: PerUserDriveLog, group?: any): Promise<PerUserDriveLog> { + const calcSize = () => DriveFile + .aggregate([{ + $match: { + 'metadata.userId': group, + 'metadata.deletedAt': { $exists: false } + } + }, { + $project: { + length: true + } + }, { + $group: { + _id: null, + usage: { $sum: '$length' } + } + }]) + .then(res => res.length > 0 ? res[0].usage : 0); + + const [count, size] = init ? await Promise.all([ + DriveFile.count({ 'metadata.userId': group }), + calcSize() + ]) : [ + latest ? latest.totalCount : 0, + latest ? latest.totalSize : 0 + ]; + + return { + totalCount: count, + totalSize: size, + incCount: 0, + incSize: 0, + decCount: 0, + decSize: 0 + }; + } + + @autobind + public async update(file: IDriveFile, isAdditional: boolean) { + const update: Obj = {}; + + update.totalCount = isAdditional ? 1 : -1; + update.totalSize = isAdditional ? file.length : -file.length; + if (isAdditional) { + update.incCount = 1; + update.incSize = file.length; + } else { + update.decCount = 1; + update.decSize = file.length; + } + + await this.inc(update, file.metadata.userId); + } +} + +export default new PerUserDriveChart(); diff --git a/src/chart/per-user-following.ts b/src/chart/per-user-following.ts new file mode 100644 index 0000000000..fac4a1619f --- /dev/null +++ b/src/chart/per-user-following.ts @@ -0,0 +1,128 @@ +import autobind from 'autobind-decorator'; +import Chart, { Obj } from './'; +import Following from '../models/following'; +import { IUser, isLocalUser } from '../models/user'; + +/** + * ユーザーごとのフォローに関するチャート + */ +type PerUserFollowingLog = { + local: { + /** + * フォローしている + */ + followings: { + /** + * 合計 + */ + total: number; + + /** + * フォローした数 + */ + inc: number; + + /** + * フォロー解除した数 + */ + dec: number; + }; + + /** + * フォローされている + */ + followers: { + /** + * 合計 + */ + total: number; + + /** + * フォローされた数 + */ + inc: number; + + /** + * フォロー解除された数 + */ + dec: number; + }; + }; + + remote: PerUserFollowingLog['local']; +}; + +class PerUserFollowingChart extends Chart<PerUserFollowingLog> { + constructor() { + super('perUserFollowing', true); + } + + @autobind + protected async getTemplate(init: boolean, latest?: PerUserFollowingLog, group?: any): Promise<PerUserFollowingLog> { + const [ + localFollowingsCount, + localFollowersCount, + remoteFollowingsCount, + remoteFollowersCount + ] = init ? await Promise.all([ + Following.count({ followerId: group, '_followee.host': null }), + Following.count({ followeeId: group, '_follower.host': null }), + Following.count({ followerId: group, '_followee.host': { $ne: null } }), + Following.count({ followeeId: group, '_follower.host': { $ne: null } }) + ]) : [ + latest ? latest.local.followings.total : 0, + latest ? latest.local.followers.total : 0, + latest ? latest.remote.followings.total : 0, + latest ? latest.remote.followers.total : 0 + ]; + + return { + local: { + followings: { + total: localFollowingsCount, + inc: 0, + dec: 0 + }, + followers: { + total: localFollowersCount, + inc: 0, + dec: 0 + } + }, + remote: { + followings: { + total: remoteFollowingsCount, + inc: 0, + dec: 0 + }, + followers: { + total: remoteFollowersCount, + inc: 0, + dec: 0 + } + } + }; + } + + @autobind + public async update(follower: IUser, followee: IUser, isFollow: boolean) { + const update: Obj = {}; + + update.total = isFollow ? 1 : -1; + + if (isFollow) { + update.inc = 1; + } else { + update.dec = 1; + } + + this.inc({ + [isLocalUser(follower) ? 'local' : 'remote']: { followings: update } + }, follower._id); + this.inc({ + [isLocalUser(followee) ? 'local' : 'remote']: { followers: update } + }, followee._id); + } +} + +export default new PerUserFollowingChart(); diff --git a/src/chart/per-user-notes.ts b/src/chart/per-user-notes.ts new file mode 100644 index 0000000000..9558f5c839 --- /dev/null +++ b/src/chart/per-user-notes.ts @@ -0,0 +1,94 @@ +import autobind from 'autobind-decorator'; +import Chart, { Obj } from './'; +import Note, { INote } from '../models/note'; +import { IUser } from '../models/user'; + +/** + * ユーザーごとの投稿に関するチャート + */ +type PerUserNotesLog = { + /** + * 集計期間時点での、全投稿数 + */ + total: number; + + /** + * 増加した投稿数 + */ + inc: number; + + /** + * 減少した投稿数 + */ + dec: number; + + diffs: { + /** + * 通常の投稿数の差分 + */ + normal: number; + + /** + * リプライの投稿数の差分 + */ + reply: number; + + /** + * Renoteの投稿数の差分 + */ + renote: number; + }; +}; + +class PerUserNotesChart extends Chart<PerUserNotesLog> { + constructor() { + super('perUserNotes', true); + } + + @autobind + protected async getTemplate(init: boolean, latest?: PerUserNotesLog, group?: any): Promise<PerUserNotesLog> { + const [count] = init ? await Promise.all([ + Note.count({ userId: group, deletedAt: null }), + ]) : [ + latest ? latest.total : 0 + ]; + + return { + total: count, + inc: 0, + dec: 0, + diffs: { + normal: 0, + reply: 0, + renote: 0 + } + }; + } + + @autobind + public async update(user: IUser, note: INote, isAdditional: boolean) { + const update: Obj = { + diffs: {} + }; + + update.total = isAdditional ? 1 : -1; + + if (isAdditional) { + update.inc = 1; + } else { + update.dec = 1; + } + + if (note.replyId != null) { + update.diffs.reply = isAdditional ? 1 : -1; + } else if (note.renoteId != null) { + update.diffs.renote = isAdditional ? 1 : -1; + } else { + update.diffs.normal = isAdditional ? 1 : -1; + } + + await this.inc(update, user._id); + } +} + +export default new PerUserNotesChart(); diff --git a/src/chart/per-user-reactions.ts b/src/chart/per-user-reactions.ts new file mode 100644 index 0000000000..a31952ea22 --- /dev/null +++ b/src/chart/per-user-reactions.ts @@ -0,0 +1,45 @@ +import autobind from 'autobind-decorator'; +import Chart from './'; +import { IUser, isLocalUser } from '../models/user'; +import { INote } from '../models/note'; + +/** + * ユーザーごとのリアクションに関するチャート + */ +type PerUserReactionsLog = { + local: { + /** + * リアクションされた数 + */ + count: number; + }; + + remote: PerUserReactionsLog['local']; +}; + +class PerUserReactionsChart extends Chart<PerUserReactionsLog> { + constructor() { + super('perUserReaction', true); + } + + @autobind + protected async getTemplate(init: boolean, latest?: PerUserReactionsLog, group?: any): Promise<PerUserReactionsLog> { + return { + local: { + count: 0 + }, + remote: { + count: 0 + } + }; + } + + @autobind + public async update(user: IUser, note: INote) { + this.inc({ + [isLocalUser(user) ? 'local' : 'remote']: { count: 1 } + }, note.userId); + } +} + +export default new PerUserReactionsChart(); diff --git a/src/chart/users.ts b/src/chart/users.ts new file mode 100644 index 0000000000..547e595b01 --- /dev/null +++ b/src/chart/users.ts @@ -0,0 +1,75 @@ +import autobind from 'autobind-decorator'; +import Chart, { Obj } from './'; +import User, { IUser, isLocalUser } from '../models/user'; + +/** + * ユーザーに関するチャート + */ +type UsersLog = { + local: { + /** + * 集計期間時点での、全ユーザー数 + */ + total: number; + + /** + * 増加したユーザー数 + */ + inc: number; + + /** + * 減少したユーザー数 + */ + dec: number; + }; + + remote: UsersLog['local']; +}; + +class UsersChart extends Chart<UsersLog> { + constructor() { + super('users'); + } + + @autobind + protected async getTemplate(init: boolean, latest?: UsersLog): Promise<UsersLog> { + const [localCount, remoteCount] = init ? await Promise.all([ + User.count({ host: null }), + User.count({ host: { $ne: null } }) + ]) : [ + latest ? latest.local.total : 0, + latest ? latest.remote.total : 0 + ]; + + return { + local: { + total: localCount, + inc: 0, + dec: 0 + }, + remote: { + total: remoteCount, + inc: 0, + dec: 0 + } + }; + } + + @autobind + public async update(user: IUser, isAdditional: boolean) { + const update: Obj = {}; + + update.total = isAdditional ? 1 : -1; + if (isAdditional) { + update.inc = 1; + } else { + update.dec = 1; + } + + await this.inc({ + [isLocalUser(user) ? 'local' : 'remote']: update + }); + } +} + +export default new UsersChart(); |