From d1841a04fa3bf28cd3c149a871bfca112b8a8a49 Mon Sep 17 00:00:00 2001 From: Emil Rondahl Date: Thu, 2 Mar 2023 10:23:55 +0100 Subject: [PATCH 1/2] Completed sessions count endpoint --- functions/src/api/index.ts | 2 + functions/src/api/sessionsCount/index.spec.ts | 60 ++++++++++++++++ functions/src/api/sessionsCount/index.ts | 15 ++++ functions/src/controllers/sessions.spec.ts | 25 +++++++ functions/src/controllers/sessions.ts | 11 +++ .../src/models/completedSessionsCount.spec.ts | 72 +++++++++++++++++++ .../src/models/completedSessionsCount.ts | 46 ++++++++++++ 7 files changed, 231 insertions(+) create mode 100644 functions/src/api/sessionsCount/index.spec.ts create mode 100644 functions/src/api/sessionsCount/index.ts create mode 100644 functions/src/models/completedSessionsCount.spec.ts create mode 100644 functions/src/models/completedSessionsCount.ts diff --git a/functions/src/api/index.ts b/functions/src/api/index.ts index 8cb2fcc42b..b4203216e5 100644 --- a/functions/src/api/index.ts +++ b/functions/src/api/index.ts @@ -3,6 +3,7 @@ import Koa from 'koa'; import {killSwitchRouter} from './killswitch'; import {sessionsRouter} from './sessions'; +import {sessionsCountRouter} from './sessionsCount'; import {userRouter} from './user'; import {postsRouter} from './posts'; import sentryErrorHandler from '../lib/sentry'; @@ -20,6 +21,7 @@ app.on('error', localErrorHandler); const rootRouter = createApiRouter(); rootRouter .use('/sessions', sessionsRouter.routes()) + .use('/sessionsCount', sessionsCountRouter.routes()) .use('/killSwitch', killSwitchRouter.routes()) .use('/user', userRouter.routes()) .use('/posts', postsRouter.routes()); diff --git a/functions/src/api/sessionsCount/index.spec.ts b/functions/src/api/sessionsCount/index.spec.ts new file mode 100644 index 0000000000..4e9ecb6472 --- /dev/null +++ b/functions/src/api/sessionsCount/index.spec.ts @@ -0,0 +1,60 @@ +import request from 'supertest'; +import Koa from 'koa'; + +import {sessionsCountRouter} from '.'; +import {createApiRouter} from '../../lib/routers'; +import createMockServer from '../lib/createMockServer'; +import * as sessionsController from '../../controllers/sessions'; +import {CompletedSessionsCount} from '../../../../shared/src/types/CompletedSessions'; + +jest.mock('../../controllers/sessions'); + +const mockGetCompletedSessionsCount = jest.mocked( + sessionsController.getCompletedSessionsCount, +); + +const getMockCustomClaims = jest.fn(); +const router = createApiRouter(); +router.use('/completedSessionsCount', sessionsCountRouter.routes()); +const mockServer = createMockServer( + async (ctx: Koa.Context, next: Koa.Next) => { + ctx.user = { + id: 'some-user-id', + customClaims: getMockCustomClaims(), + }; + await next(); + }, + router.routes(), + router.allowedMethods(), +); + +beforeEach(async () => { + jest.clearAllMocks(); +}); + +afterAll(() => { + mockServer.close(); +}); + +describe('/completedSessionsCount', () => { + it('should return completed sessions count', async () => { + mockGetCompletedSessionsCount.mockResolvedValueOnce([ + { + exerciseId: 'some-exercise-id', + publicCount: 1, + } as CompletedSessionsCount, + ]); + + const response = await request(mockServer).get('/completedSessionsCount'); + + expect(mockGetCompletedSessionsCount).toHaveBeenCalledTimes(1); + expect(response.status).toEqual(200); + expect(response.headers).toMatchObject({'cache-control': 'max-age=1800'}); + expect(response.body).toEqual([ + { + exerciseId: 'some-exercise-id', + publicCount: 1, + }, + ]); + }); +}); diff --git a/functions/src/api/sessionsCount/index.ts b/functions/src/api/sessionsCount/index.ts new file mode 100644 index 0000000000..2e7455a37e --- /dev/null +++ b/functions/src/api/sessionsCount/index.ts @@ -0,0 +1,15 @@ +import {createApiRouter} from '../../lib/routers'; +import * as sessionsController from '../../controllers/sessions'; + +const sessionsCountRouter = createApiRouter(); + +sessionsCountRouter.get('/', async ctx => { + const completedSessionsCount = + await sessionsController.getCompletedSessionsCount(); + + ctx.set('Cache-Control', 'max-age=1800'); + ctx.status = 200; + ctx.body = completedSessionsCount; +}); + +export {sessionsCountRouter}; diff --git a/functions/src/controllers/sessions.spec.ts b/functions/src/controllers/sessions.spec.ts index ecbfb3cc6f..d76276fe1e 100644 --- a/functions/src/controllers/sessions.spec.ts +++ b/functions/src/controllers/sessions.spec.ts @@ -20,6 +20,7 @@ const mockDailyApi = { const mockGenerateVerificationCode = jest.fn(); import * as sessionModel from '../models/session'; +import * as completedSessionsCountModel from '../models/completedSessionsCount'; import { createSession, joinSession, @@ -30,6 +31,7 @@ import { getSessionToken, updateInterestedCount, getSession, + getCompletedSessionsCount, } from './sessions'; import {getPublicUserInfo} from '../models/user'; import {SessionType} from '../../../shared/src/types/Session'; @@ -40,6 +42,7 @@ import { ValidateSessionError, } from '../../../shared/src/errors/Session'; import {generateSessionToken} from '../lib/dailyUtils'; +import {CompletedSessionsCount} from '../../../shared/src/types/CompletedSessions'; jest.mock('../lib/utils', () => ({ ...jest.requireActual('../lib/utils'), @@ -48,6 +51,7 @@ jest.mock('../lib/utils', () => ({ jest.mock('../lib/dailyApi', () => mockDailyApi); jest.mock('../models/dynamicLinks', () => mockDynamicLinks); jest.mock('../models/session'); +jest.mock('../models/completedSessionsCount'); jest.mock('../models/user'); jest.mock('../lib/dailyUtils'); @@ -66,6 +70,9 @@ const mockGetSessionByInviteCode = sessionModel.getSessionByInviteCode as jest.Mock; const mockGetPublicUserInfo = getPublicUserInfo as jest.Mock; const mockGenerateSessionToken = generateSessionToken as jest.Mock; +const mockGetCompletedSessionsCount = jest.mocked( + completedSessionsCountModel.getCompletedSessionsCount, +); jest.useFakeTimers().setSystemTime(new Date('2022-10-10T09:00:00Z')); @@ -117,6 +124,24 @@ describe('sessions - controller', () => { }); }); + describe('getCompletedSessionsCount', () => { + it('should return completed sessions count', async () => { + mockGetCompletedSessionsCount.mockResolvedValueOnce([ + { + exerciseId: 'some-exercise-id', + publicCount: 1, + } as CompletedSessionsCount, + ]); + + expect(await getCompletedSessionsCount()).toEqual([ + { + exerciseId: 'some-exercise-id', + publicCount: 1, + }, + ]); + }); + }); + describe('getSession', () => { it('should return the private session', async () => { mockGetSessionById.mockResolvedValueOnce({ diff --git a/functions/src/controllers/sessions.ts b/functions/src/controllers/sessions.ts index c4e55273fb..172a3869cc 100644 --- a/functions/src/controllers/sessions.ts +++ b/functions/src/controllers/sessions.ts @@ -2,6 +2,7 @@ import dayjs from 'dayjs'; import {createSessionInviteLink} from '../models/dynamicLinks'; import * as sessionModel from '../models/session'; import * as userModel from '../models/user'; +import * as completedSessionsCountModel from '../models/completedSessionsCount'; import {getPublicUserInfo} from '../models/user'; import * as dailyApi from '../lib/dailyApi'; import { @@ -86,6 +87,10 @@ export const getSession = async ( return mapSession(session); }; +export const getCompletedSessionsCount = async () => { + return completedSessionsCountModel.getCompletedSessionsCount(); +}; + export const createSession = async ( userId: string, { @@ -204,6 +209,12 @@ export const updateSessionState = async ( if (data.ended) { sessionModel.updateSession(sessionId, {ended: true}); + completedSessionsCountModel.addCompletedSessionsCount( + session.exerciseId, + 0, + session.type === SessionType.private ? 1 : 0, + session.type === SessionType.public ? 1 : 0, + ); } await sessionModel.updateSessionState(sessionId, removeEmpty(data)); diff --git a/functions/src/models/completedSessionsCount.spec.ts b/functions/src/models/completedSessionsCount.spec.ts new file mode 100644 index 0000000000..cc2dfe7990 --- /dev/null +++ b/functions/src/models/completedSessionsCount.spec.ts @@ -0,0 +1,72 @@ +import {mockFirebase} from 'firestore-jest-mock'; + +mockFirebase( + { + database: { + completedSessionsCount: [], + }, + }, + {includeIdsInData: true, mutable: true}, +); + +import {firestore} from 'firebase-admin'; +import {Timestamp} from 'firebase-admin/firestore'; + +import {getCompletedSessionsCount} from './completedSessionsCount'; + +const completedSessionsCount = [ + { + id: 'some-exercise-id', + exerciseId: 'some-exercise-id', + asyncCount: 0, + privateCount: 1, + publicCount: 1, + updatedAt: Timestamp.now(), + }, + { + id: 'some-other-exercise-id', + exerciseId: 'some-other-exercise-id', + asyncCount: 0, + privateCount: 2, + publicCount: 2, + updatedAt: Timestamp.now(), + }, +]; + +beforeEach( + async () => + await Promise.all( + completedSessionsCount.map(count => + firestore() + .collection('completedSessionsCount') + .doc(count.id) + .set(count), + ), + ), +); + +afterEach(() => { + jest.clearAllMocks(); +}); + +describe('completedSessionsCount model', () => { + describe('getCompletedSessionsCount', () => { + it('should get a completedSessionsCount', async () => { + const completedSessionsCount = await getCompletedSessionsCount(); + expect(completedSessionsCount).toEqual([ + { + exerciseId: 'some-exercise-id', + asyncCount: 0, + privateCount: 1, + publicCount: 1, + }, + { + exerciseId: 'some-other-exercise-id', + asyncCount: 0, + privateCount: 2, + publicCount: 2, + }, + ]); + }); + }); +}); diff --git a/functions/src/models/completedSessionsCount.ts b/functions/src/models/completedSessionsCount.ts new file mode 100644 index 0000000000..8e14055210 --- /dev/null +++ b/functions/src/models/completedSessionsCount.ts @@ -0,0 +1,46 @@ +import {firestore} from 'firebase-admin'; +import {Timestamp} from 'firebase-admin/firestore'; +import {omit} from 'ramda'; +import {getData} from '../../../shared/src/modelUtils/firestore'; + +import { + CompletedSessionsCount, + CompletedSessionsCountData, +} from '../../../shared/src/types/CompletedSessions'; + +const COMPLETED_SESSIONS_COUNT_COLLECTION = 'completedSessionsCount'; + +export const getCompletedSessionsCount = async () => { + const completedSessions = firestore().collection( + COMPLETED_SESSIONS_COUNT_COLLECTION, + ); + const snapshot = await completedSessions.get(); + + return snapshot.docs + .map(doc => getData(doc)) + .map(omit(['id', 'updatedAt'])); +}; + +export const addCompletedSessionsCount = async ( + exerciseId: string, + asyncCount: number, + privateCount: number, + publicCount: number, +) => { + const completedSessionsRef = firestore() + .collection(COMPLETED_SESSIONS_COUNT_COLLECTION) + .doc(exerciseId); + + await firestore().runTransaction(async transaction => { + const completedSession = getData( + await transaction.get(completedSessionsRef), + ); + transaction.set(completedSessionsRef, { + exerciseId, + asyncCount: (completedSession?.asyncCount ?? 0) + asyncCount, + privateCount: (completedSession?.privateCount ?? 0) + privateCount, + publicCount: (completedSession?.publicCount ?? 0) + publicCount, + updatedAt: Timestamp.now(), + }); + }); +}; From 3ec97ecc944d60b8057f2c55ae3116b724744f2a Mon Sep 17 00:00:00 2001 From: Emil Rondahl Date: Thu, 2 Mar 2023 10:44:58 +0100 Subject: [PATCH 2/2] display completed sessions count --- .../CompletedSessionsCount.tsx | 69 ++++++++++++ client/src/lib/sessions/api/sessionsCount.ts | 21 ++++ .../hooks/useCompletedSessionsCount.spec.ts | 106 ++++++++++++++++++ .../hooks/useCompletedSessionsCount.ts | 56 +++++++++ client/src/lib/sessions/state/state.ts | 8 ++ .../ui/Component.CompletedSessionsCount.json | 8 ++ shared/src/types/CompletedSessions.ts | 14 +++ shared/src/types/generated/Collection.ts | 14 +++ 8 files changed, 296 insertions(+) create mode 100644 client/src/lib/components/CompletedSessionsCount/CompletedSessionsCount.tsx create mode 100644 client/src/lib/sessions/api/sessionsCount.ts create mode 100644 client/src/lib/sessions/hooks/useCompletedSessionsCount.spec.ts create mode 100644 client/src/lib/sessions/hooks/useCompletedSessionsCount.ts create mode 100644 content/src/ui/Component.CompletedSessionsCount.json create mode 100644 shared/src/types/CompletedSessions.ts create mode 100644 shared/src/types/generated/Collection.ts diff --git a/client/src/lib/components/CompletedSessionsCount/CompletedSessionsCount.tsx b/client/src/lib/components/CompletedSessionsCount/CompletedSessionsCount.tsx new file mode 100644 index 0000000000..ed1e954e41 --- /dev/null +++ b/client/src/lib/components/CompletedSessionsCount/CompletedSessionsCount.tsx @@ -0,0 +1,69 @@ +import React, {useEffect, useState} from 'react'; +import {useTranslation} from 'react-i18next'; +import styled from 'styled-components/native'; +import {COLORS} from '../../../../../shared/src/constants/colors'; +import {Collection} from '../../../../../shared/src/types/generated/Collection'; +import {HKGroteskBold, HKGroteskRegular} from '../../constants/fonts'; +import useCompletedSessionsCount from '../../sessions/hooks/useCompletedSessionsCount'; +import {Spacer4} from '../Spacers/Spacer'; +import {Body12} from '../Typography/Body/Body'; + +const Footer = styled.View({ + flexDirection: 'row', + justifyContent: 'flex-start', + alignItems: 'baseline', +}); + +const NumberText = styled(Body12)({ + fontFamily: HKGroteskBold, +}); + +const FooterText = styled.Text.attrs({allowFontScaling: false})({ + color: COLORS.BLACK, + fontSize: 10, + lineHeight: 14, + fontFamily: HKGroteskRegular, +}); + +type CompletedSessionsCountProps = { + collection: Collection | null; + emptyComponent?: React.ReactNode; +}; + +export const CompletedSessionsCount: React.FC = ({ + collection, + emptyComponent, +}) => { + const {t} = useTranslation('Component.CompletedSessionsCount'); + const {getCompletedSessionsCountByCollection} = useCompletedSessionsCount(); + const [completedSessionsCount, setCompletedSessionsCount] = useState(0); // TODO: get this from some storage + + useEffect(() => { + if (collection) { + setCompletedSessionsCount( + getCompletedSessionsCountByCollection(collection), + ); + } + }, [ + collection, + setCompletedSessionsCount, + getCompletedSessionsCountByCollection, + ]); + + if (!completedSessionsCount) { + if (emptyComponent) { + return <>{emptyComponent}; + } + return null; + } + + return ( +
+ {completedSessionsCount} + + {t('text')} +
+ ); +}; + +export default React.memo(CompletedSessionsCount); diff --git a/client/src/lib/sessions/api/sessionsCount.ts b/client/src/lib/sessions/api/sessionsCount.ts new file mode 100644 index 0000000000..6f7c69c507 --- /dev/null +++ b/client/src/lib/sessions/api/sessionsCount.ts @@ -0,0 +1,21 @@ +import {CompletedSessionsCount} from '../../../../../shared/src/types/CompletedSessions'; +import apiClient from '../../apiClient/apiClient'; + +const SESSIONS_COUNT_ENDPOINT = '/sessionsCount'; + +export const fetchCompletedSessionsCount = async (): Promise< + Array +> => { + try { + const response = await apiClient(SESSIONS_COUNT_ENDPOINT); + if (!response.ok) { + throw new Error(await response.text()); + } + + return response.json(); + } catch (cause) { + console.log(cause); + + throw new Error('Could not fetch completed sessions count', {cause}); + } +}; diff --git a/client/src/lib/sessions/hooks/useCompletedSessionsCount.spec.ts b/client/src/lib/sessions/hooks/useCompletedSessionsCount.spec.ts new file mode 100644 index 0000000000..ff391f51e0 --- /dev/null +++ b/client/src/lib/sessions/hooks/useCompletedSessionsCount.spec.ts @@ -0,0 +1,106 @@ +import {act, renderHook} from '@testing-library/react-hooks'; +import fetchMock, {enableFetchMocks} from 'jest-fetch-mock'; +import {Collection} from '../../../../../shared/src/types/generated/Collection'; +import useSessionsState from '../state/state'; +import useCompletedSessionsCount from './useCompletedSessionsCount'; + +enableFetchMocks(); + +afterEach(() => { + fetchMock.resetMocks(); + jest.clearAllMocks(); +}); + +describe('useCompletedSessionsCount', () => { + const useTestHook = () => { + const { + getCompletedSessionsCountByCollection, + getCompletedSessionsCountByExerciseId, + } = useCompletedSessionsCount(); + + return { + getCompletedSessionsCountByCollection, + getCompletedSessionsCountByExerciseId, + }; + }; + + it('should load completed sessions count', async () => { + useSessionsState.setState({ + completedSessionsCount: null, + }); + fetchMock.mockResponseOnce( + JSON.stringify([{exerciseId: 'some-exercise-id', publicCount: 1}]), + {status: 200}, + ); + + await act(async () => { + renderHook(() => useTestHook()); + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + describe('getCompletedSessionsCountByExerciseId', () => { + it('should sum the counts for an exercise', async () => { + useSessionsState.setState({ + completedSessionsCount: [ + { + exerciseId: 'some-exercise-id', + asyncCount: 0, + privateCount: 1, + publicCount: 2, + }, + { + exerciseId: 'some-other-exercise-id', + asyncCount: 0, + privateCount: 1, + publicCount: 2, + }, + ], + }); + + const {result} = renderHook(() => useTestHook()); + + expect( + result.current.getCompletedSessionsCountByExerciseId( + 'some-exercise-id', + ), + ).toEqual(3); + }); + }); + + describe('getCompletedSessionsCountByCollection', () => { + it('should sum the counts for a collection', async () => { + useSessionsState.setState({ + completedSessionsCount: [ + { + exerciseId: 'some-exercise-id', + asyncCount: 0, + privateCount: 1, + publicCount: 2, + }, + { + exerciseId: 'some-other-exercise-id', + asyncCount: 0, + privateCount: 1, + publicCount: 2, + }, + { + exerciseId: 'some-third-exercise-id', + asyncCount: 0, + privateCount: 1, + publicCount: 2, + }, + ], + }); + + const {result} = renderHook(() => useTestHook()); + + expect( + result.current.getCompletedSessionsCountByCollection({ + exercises: ['some-exercise-id', 'some-other-exercise-id'], + } as Collection), + ).toEqual(6); + }); + }); +}); diff --git a/client/src/lib/sessions/hooks/useCompletedSessionsCount.ts b/client/src/lib/sessions/hooks/useCompletedSessionsCount.ts new file mode 100644 index 0000000000..17cf64182e --- /dev/null +++ b/client/src/lib/sessions/hooks/useCompletedSessionsCount.ts @@ -0,0 +1,56 @@ +import {useCallback, useEffect} from 'react'; +import {Collection} from '../../../../../shared/src/types/generated/Collection'; +import * as sessionsCountApi from '../api/sessionsCount'; +import useSessionsState from '../state/state'; + +const useCompletedSessionsCount = () => { + const setCompletedSessionsCount = useSessionsState( + state => state.setCompletedSessionsCount, + ); + const completedSessionsCount = useSessionsState( + state => state.completedSessionsCount, + ); + + const fetchCompletedSessionsCount = useCallback(async () => { + setCompletedSessionsCount( + await sessionsCountApi.fetchCompletedSessionsCount(), + ); + }, [setCompletedSessionsCount]); + + useEffect(() => { + if (completedSessionsCount === null) { + fetchCompletedSessionsCount(); + } + }, [completedSessionsCount, fetchCompletedSessionsCount]); + + const getCompletedSessionsCountByExerciseId = useCallback( + (exerciseId: string) => { + return completedSessionsCount + ?.filter(cs => cs.exerciseId === exerciseId) + .reduce( + (sum, count) => + sum + count.asyncCount + count.privateCount + count.publicCount, + 0, + ); + }, + [completedSessionsCount], + ); + + const getCompletedSessionsCountByCollection = useCallback( + (collection: Collection) => { + return collection.exercises.reduce( + (sum, exerciseId) => + sum + getCompletedSessionsCountByExerciseId(exerciseId), + 0, + ); + }, + [getCompletedSessionsCountByExerciseId], + ); + + return { + getCompletedSessionsCountByExerciseId, + getCompletedSessionsCountByCollection, + }; +}; + +export default useCompletedSessionsCount; diff --git a/client/src/lib/sessions/state/state.ts b/client/src/lib/sessions/state/state.ts index 7433995e95..649e06fdef 100644 --- a/client/src/lib/sessions/state/state.ts +++ b/client/src/lib/sessions/state/state.ts @@ -1,26 +1,34 @@ import {LiveSession} from '../../../../../shared/src/types/Session'; import {create} from 'zustand'; +import {CompletedSessionsCount} from '../../../../../shared/src/types/CompletedSessions'; type State = { isLoading: boolean; sessions: LiveSession[] | null; + completedSessionsCount: CompletedSessionsCount[] | null; }; type Actions = { setIsLoading: (isLoading: State['isLoading']) => void; setSessions: (sessions: State['sessions']) => void; + setCompletedSessionsCount: ( + completedSessionsCount: State['completedSessionsCount'], + ) => void; reset: () => void; }; const initialState: State = { isLoading: false, sessions: null, + completedSessionsCount: null, }; const useSessionsState = create()(set => ({ ...initialState, setIsLoading: isLoading => set({isLoading}), setSessions: sessions => set({sessions}), + setCompletedSessionsCount: completedSessionsCount => + set({completedSessionsCount}), reset: () => set(initialState), })); diff --git a/content/src/ui/Component.CompletedSessionsCount.json b/content/src/ui/Component.CompletedSessionsCount.json new file mode 100644 index 0000000000..70f6e8549e --- /dev/null +++ b/content/src/ui/Component.CompletedSessionsCount.json @@ -0,0 +1,8 @@ +{ + "en": { + "text": "maningful sessions this month" + }, + "pt": {}, + "sv": {}, + "es": {} +} diff --git a/shared/src/types/CompletedSessions.ts b/shared/src/types/CompletedSessions.ts new file mode 100644 index 0000000000..1da5ab3320 --- /dev/null +++ b/shared/src/types/CompletedSessions.ts @@ -0,0 +1,14 @@ +import {Timestamp} from 'firebase-admin/firestore'; + +type CompletedSessionsCountFields = { + exerciseId: string; + asyncCount: number; + privateCount: number; + publicCount: number; +}; + +export type CompletedSessionsCountData = CompletedSessionsCountFields & { + updatedAt: Timestamp; +}; + +export type CompletedSessionsCount = CompletedSessionsCountFields; diff --git a/shared/src/types/generated/Collection.ts b/shared/src/types/generated/Collection.ts new file mode 100644 index 0000000000..8cf0737254 --- /dev/null +++ b/shared/src/types/generated/Collection.ts @@ -0,0 +1,14 @@ +/* eslint-disable */ +/* tslint:disable */ + +export interface CollectionImage { + description?: string; + source?: string; +} + +export interface Collection { + id: any; + name: string; + image?: CollectionImage; + exercises: any[]; +}