diff --git a/src/dispatcher/__tests__/tracking.node.tsx b/src/dispatcher/__tests__/tracking.node.tsx new file mode 100644 index 000000000..1c485aa94 --- /dev/null +++ b/src/dispatcher/__tests__/tracking.node.tsx @@ -0,0 +1,169 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + */ + +import {computeUsageSummary, UsageSummary} from '../tracking'; +import {State} from '../../reducers/usageTracking'; + +test('Never focused', () => { + const state: State = { + timeline: [{type: 'TIMELINE_START', time: 100, isFocused: false}], + }; + const result = computeUsageSummary(state, 200); + expect(result).toReportTimeSpent('total', 0, 100); +}); + +test('Always focused', () => { + const state: State = { + timeline: [{type: 'TIMELINE_START', time: 100, isFocused: true}], + }; + const result = computeUsageSummary(state, 200); + expect(result).toReportTimeSpent('total', 100, 0); +}); + +test('Focused then unfocused', () => { + const state: State = { + timeline: [ + {type: 'TIMELINE_START', time: 100, isFocused: true}, + {type: 'WINDOW_FOCUS_CHANGE', time: 150, isFocused: false}, + ], + }; + const result = computeUsageSummary(state, 350); + expect(result).toReportTimeSpent('total', 50, 200); +}); + +test('Unfocused then focused', () => { + const state: State = { + timeline: [ + {type: 'TIMELINE_START', time: 100, isFocused: false}, + {type: 'WINDOW_FOCUS_CHANGE', time: 150, isFocused: true}, + ], + }; + const result = computeUsageSummary(state, 350); + expect(result).toReportTimeSpent('total', 200, 50); +}); + +test('Unfocused then focused then unfocused', () => { + const state: State = { + timeline: [ + {type: 'TIMELINE_START', time: 100, isFocused: false}, + {type: 'WINDOW_FOCUS_CHANGE', time: 150, isFocused: true}, + {type: 'WINDOW_FOCUS_CHANGE', time: 350, isFocused: false}, + ], + }; + const result = computeUsageSummary(state, 650); + expect(result).toReportTimeSpent('total', 200, 350); +}); + +test('Focused then unfocused then focused', () => { + const state: State = { + timeline: [ + {type: 'TIMELINE_START', time: 100, isFocused: true}, + {type: 'WINDOW_FOCUS_CHANGE', time: 150, isFocused: false}, + {type: 'WINDOW_FOCUS_CHANGE', time: 350, isFocused: true}, + ], + }; + const result = computeUsageSummary(state, 650); + expect(result).toReportTimeSpent('total', 350, 200); +}); + +test('Always focused plugin change', () => { + const state: State = { + timeline: [ + {type: 'TIMELINE_START', time: 100, isFocused: true}, + {type: 'PLUGIN_SELECTED', time: 150, plugin: 'Layout'}, + ], + }; + const result = computeUsageSummary(state, 200); + expect(result).toReportTimeSpent('total', 100, 0); + expect(result).toReportTimeSpent('Layout', 50, 0); +}); + +test('Focused then plugin change then unfocusd', () => { + const state: State = { + timeline: [ + {type: 'TIMELINE_START', time: 100, isFocused: true}, + {type: 'PLUGIN_SELECTED', time: 150, plugin: 'Layout'}, + {type: 'WINDOW_FOCUS_CHANGE', time: 350, isFocused: false}, + ], + }; + const result = computeUsageSummary(state, 650); + expect(result).toReportTimeSpent('total', 250, 300); + expect(result).toReportTimeSpent('Layout', 200, 300); +}); + +test('Multiple plugin changes', () => { + const state: State = { + timeline: [ + {type: 'TIMELINE_START', time: 100, isFocused: true}, + {type: 'PLUGIN_SELECTED', time: 150, plugin: 'Layout'}, + {type: 'PLUGIN_SELECTED', time: 350, plugin: 'Network'}, + {type: 'PLUGIN_SELECTED', time: 650, plugin: 'Layout'}, + {type: 'PLUGIN_SELECTED', time: 1050, plugin: 'Databases'}, + ], + }; + const result = computeUsageSummary(state, 1550); + expect(result).toReportTimeSpent('total', 1450, 0); + expect(result).toReportTimeSpent('Layout', 600, 0); + expect(result).toReportTimeSpent('Network', 300, 0); + expect(result).toReportTimeSpent('Databases', 500, 0); +}); + +declare global { + namespace jest { + interface Matchers { + toReportTimeSpent( + plugin: string, + focusedTimeSpent: number, + unfocusedTimeSpent: number, + ): R; + } + } +} + +expect.extend({ + toReportTimeSpent( + received: UsageSummary, + plugin: string, + focusedTimeSpent: number, + unfocusedTimeSpent: number, + ) { + const focusedPass = received[plugin].focusedTime === focusedTimeSpent; + const unfocusedPass = received[plugin].unfocusedTime === unfocusedTimeSpent; + if (!focusedPass) { + return { + message: () => + `expected ${JSON.stringify( + received, + )} to have focused time spent: ${focusedTimeSpent} for plugin ${plugin}, but was ${ + received[plugin]?.focusedTime + }`, + pass: false, + }; + } + + if (!unfocusedPass) { + return { + message: () => + `expected ${JSON.stringify( + received, + )} to have unfocused time spent: ${unfocusedTimeSpent} for plugin ${plugin}, but was ${ + received[plugin]?.unfocusedTime + }`, + pass: false, + }; + } + return { + message: () => + `expected ${JSON.stringify( + received, + )} not to have focused time spent: ${focusedTimeSpent} and unfocused: ${unfocusedTimeSpent}`, + pass: true, + }; + }, +}); diff --git a/src/dispatcher/application.tsx b/src/dispatcher/application.tsx index b07ca2438..df8750d63 100644 --- a/src/dispatcher/application.tsx +++ b/src/dispatcher/application.tsx @@ -46,23 +46,23 @@ export default (store: Store, logger: Logger) => { currentWindow.on('focus', () => { store.dispatch({ type: 'windowIsFocused', - payload: true, + payload: {isFocused: true, time: Date.now()}, }); }); currentWindow.on('blur', () => { store.dispatch({ type: 'windowIsFocused', - payload: false, + payload: {isFocused: false, time: Date.now()}, }); }); // windowIsFocussed is initialized in the store before the app is fully ready. // So wait until everything is up and running and then check and set the isFocussed state. window.addEventListener('flipper-store-ready', () => { - const isFocussed = currentWindow.isFocused(); + const isFocused = currentWindow.isFocused(); store.dispatch({ type: 'windowIsFocused', - payload: isFocussed, + payload: {isFocused: isFocused, time: Date.now()}, }); }); diff --git a/src/dispatcher/tracking.tsx b/src/dispatcher/tracking.tsx index 1c8dd08fb..dd49c6d6d 100644 --- a/src/dispatcher/tracking.tsx +++ b/src/dispatcher/tracking.tsx @@ -14,6 +14,25 @@ import {Store} from '../reducers/index'; import {Logger} from '../fb-interfaces/Logger'; import Client from '../Client'; import {getPluginBackgroundStats} from '../utils/messageQueue'; +import { + clearTimeline, + TrackingEvent, + State as UsageTrackingState, +} from '../reducers/usageTracking'; +import produce from 'immer'; + +const TIME_SPENT_EVENT = 'time-spent'; + +type UsageInterval = { + plugin: string | null; + length: number; + focused: boolean; +}; + +export type UsageSummary = { + total: {focusedTime: number; unfocusedTime: number}; + [pluginName: string]: {focusedTime: number; unfocusedTime: number}; +}; export default (store: Store, logger: Logger) => { let droppedFrames: number = 0; @@ -49,7 +68,27 @@ export default (store: Store, logger: Logger) => { clients, } = store.getState().connections; - if (!selectedDevice || !selectedPlugin) { + const currentTime = Date.now(); + const usageSummary = computeUsageSummary( + store.getState().usageTracking, + currentTime, + ); + + store.dispatch(clearTimeline(currentTime)); + + logger.track('usage', TIME_SPENT_EVENT, usageSummary.total); + for (const key of Object.keys(usageSummary)) { + if (key === 'total') { + logger.track('usage', TIME_SPENT_EVENT, usageSummary.total); + } + logger.track('usage', TIME_SPENT_EVENT, usageSummary[key], key); + } + + if ( + !store.getState().application.windowIsFocused || + !selectedDevice || + !selectedPlugin + ) { return; } @@ -82,3 +121,62 @@ export default (store: Store, logger: Logger) => { logger.track('usage', 'ping', info); }); }; + +export function computeUsageSummary( + state: UsageTrackingState, + currentTime: number, +) { + const intervals: UsageInterval[] = []; + let intervalStart = 0; + let isFocused = false; + let selectedPlugin: string | null = null; + + function startInterval(event: TrackingEvent) { + intervalStart = event.time; + if ( + event.type === 'TIMELINE_START' || + event.type === 'WINDOW_FOCUS_CHANGE' + ) { + isFocused = event.isFocused; + } + if (event.type === 'PLUGIN_SELECTED') { + selectedPlugin = event.plugin; + } + } + function endInterval(time: number) { + const length = time - intervalStart; + intervals.push({length, plugin: selectedPlugin, focused: isFocused}); + } + + for (const event of state.timeline) { + if ( + event.type === 'TIMELINE_START' || + event.type === 'WINDOW_FOCUS_CHANGE' || + event.type === 'PLUGIN_SELECTED' + ) { + if (event.type !== 'TIMELINE_START') { + endInterval(event.time); + } + startInterval(event); + } + } + endInterval(currentTime); + + return intervals.reduce( + (acc: UsageSummary, x: UsageInterval) => + produce(acc, draft => { + draft.total.focusedTime += x.focused ? x.length : 0; + draft.total.unfocusedTime += x.focused ? 0 : x.length; + const pluginName = x.plugin ?? 'none'; + draft[pluginName] = draft[pluginName] ?? { + focusedTime: 0, + unfocusedTime: 0, + }; + draft[pluginName].focusedTime += x.focused ? x.length : 0; + draft[pluginName].unfocusedTime += x.focused ? 0 : x.length; + }), + { + total: {focusedTime: 0, unfocusedTime: 0}, + }, + ); +} diff --git a/src/reducers/application.tsx b/src/reducers/application.tsx index db0ecb2f4..bc0c44316 100644 --- a/src/reducers/application.tsx +++ b/src/reducers/application.tsx @@ -90,7 +90,6 @@ type BooleanActionType = | 'leftSidebarVisible' | 'rightSidebarVisible' | 'rightSidebarAvailable' - | 'windowIsFocused' | 'downloadingImportData'; export type Action = @@ -98,6 +97,10 @@ export type Action = type: BooleanActionType; payload?: boolean; } + | { + type: 'windowIsFocused'; + payload: {isFocused: boolean; time: number}; + } | { type: 'SET_ACTIVE_SHEET'; payload: ActiveSheet; @@ -169,6 +172,7 @@ export const initialState: () => State = () => ({ }, statusMessages: [], xcodeCommandLineToolsDetected: false, + trackingTimeline: [], }); function statusMessage(sender: string, msg: string): string { @@ -191,7 +195,6 @@ export default function reducer( action.type === 'leftSidebarVisible' || action.type === 'rightSidebarVisible' || action.type === 'rightSidebarAvailable' || - action.type === 'windowIsFocused' || action.type === 'downloadingImportData' ) { const newValue = @@ -208,6 +211,11 @@ export default function reducer( [action.type]: newValue, }; } + } else if (action.type === 'windowIsFocused') { + return { + ...state, + windowIsFocused: action.payload.isFocused, + }; } else if (action.type === 'SET_ACTIVE_SHEET') { return { ...state, diff --git a/src/reducers/connections.tsx b/src/reducers/connections.tsx index 53f684df3..bb2257ab5 100644 --- a/src/reducers/connections.tsx +++ b/src/reducers/connections.tsx @@ -88,6 +88,7 @@ export type Action = selectedApp?: null | string; deepLinkPayload: null | string; selectedDevice?: null | BaseDevice; + time: number; }; } | { @@ -461,9 +462,10 @@ export const selectPlugin = (payload: { selectedApp?: null | string; selectedDevice?: BaseDevice | null; deepLinkPayload: null | string; + time?: number; }): Action => ({ type: 'SELECT_PLUGIN', - payload, + payload: {...payload, time: payload.time ?? Date.now()}, }); export const starPlugin = (payload: { diff --git a/src/reducers/index.tsx b/src/reducers/index.tsx index d0794717f..d1d383a5b 100644 --- a/src/reducers/index.tsx +++ b/src/reducers/index.tsx @@ -52,6 +52,10 @@ import healthchecks, { Action as HealthcheckAction, State as HealthcheckState, } from './healthchecks'; +import usageTracking, { + Action as TrackingAction, + State as TrackingState, +} from './usageTracking'; import user, {State as UserState, Action as UserAction} from './user'; import JsonFileStorage from '../utils/jsonFileReduxPersistStorage'; import LauncherSettingsStorage from '../utils/launcherSettingsStorage'; @@ -78,6 +82,7 @@ export type Actions = | SupportFormAction | PluginManagerAction | HealthcheckAction + | TrackingAction | {type: 'INIT'}; export type State = { @@ -93,6 +98,7 @@ export type State = { supportForm: SupportFormState; pluginManager: PluginManagerState; healthchecks: HealthcheckState & PersistPartial; + usageTracking: TrackingState; }; export type Store = ReduxStore; @@ -167,4 +173,5 @@ export default combineReducers({ }, healthchecks, ), + usageTracking, }); diff --git a/src/reducers/usageTracking.tsx b/src/reducers/usageTracking.tsx new file mode 100644 index 000000000..2924c8410 --- /dev/null +++ b/src/reducers/usageTracking.tsx @@ -0,0 +1,86 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + */ + +import {produce} from 'immer'; +import {remote} from 'electron'; +import {Actions} from './'; + +export type TrackingEvent = + | { + type: 'WINDOW_FOCUS_CHANGE'; + time: number; + isFocused: boolean; + } + | {type: 'PLUGIN_SELECTED'; time: number; plugin: string | null} + | {type: 'TIMELINE_START'; time: number; isFocused: boolean}; + +export type State = { + timeline: TrackingEvent[]; +}; +const INITAL_STATE: State = { + timeline: [ + { + type: 'TIMELINE_START', + time: Date.now(), + isFocused: remote.getCurrentWindow().isFocused(), + }, + ], +}; + +export type Action = + | { + type: 'windowIsFocused'; + payload: {isFocused: boolean; time: number}; + } + | {type: 'CLEAR_TIMELINE'; payload: {time: number; isFocused: boolean}}; + +export default function reducer( + state: State = INITAL_STATE, + action: Actions, +): State { + if (action.type === 'CLEAR_TIMELINE') { + return { + ...state, + timeline: [ + { + type: 'TIMELINE_START', + time: action.payload.time, + isFocused: action.payload.isFocused, + }, + ], + }; + } else if (action.type === 'windowIsFocused') { + return produce(state, draft => { + draft.timeline.push({ + type: 'WINDOW_FOCUS_CHANGE', + time: action.payload.time, + isFocused: action.payload.isFocused, + }); + }); + } else if (action.type === 'SELECT_PLUGIN') { + return produce(state, draft => { + draft.timeline.push({ + type: 'PLUGIN_SELECTED', + time: action.payload.time, + plugin: action.payload.selectedPlugin || null, + }); + }); + } + return state; +} + +export function clearTimeline(time: number): Action { + return { + type: 'CLEAR_TIMELINE', + payload: { + time, + isFocused: remote.getCurrentWindow().isFocused(), + }, + }; +} diff --git a/static/icons.json b/static/icons.json index 3d094b247..2f941851d 100644 --- a/static/icons.json +++ b/static/icons.json @@ -319,4 +319,4 @@ "card-person": [ 12 ] -} +} \ No newline at end of file diff --git a/static/index.js b/static/index.js index 3c8181c64..11632628a 100644 --- a/static/index.js +++ b/static/index.js @@ -97,7 +97,7 @@ let filePath = argv.file; // tracking setInterval(() => { - if (win && win.isFocused()) { + if (win) { win.webContents.send('trackUsage'); } }, 60 * 1000);