diff --git a/src/components/Firebase/Firebase.tsx b/src/components/Firebase/Firebase.tsx index b383e0867..22c7640b4 100644 --- a/src/components/Firebase/Firebase.tsx +++ b/src/components/Firebase/Firebase.tsx @@ -321,6 +321,10 @@ class Firebase { await this.db.collection('users').doc(userCredential.user.uid).update({ lastLogin: firebase.firestore.FieldValue.serverTimestamp() }) + await this.db.collection('loginEvents').add({ + userId: userCredential.user.uid, + timestamp: firebase.firestore.FieldValue.serverTimestamp() + }) } return userCredential }) @@ -4844,6 +4848,26 @@ class Firebase { return lastActionMap } + getUsersLoginCounts = async ( + startDate: Date, + endDate: Date + ): Promise> => { + const counts = new Map() + const snapshot = await this.db + .collection('loginEvents') + .where('timestamp', '>=', startDate) + .where('timestamp', '<=', endDate) + .get() + + snapshot.docs.forEach(doc => { + const userId = doc.data().userId + if (!userId) return + counts.set(userId, (counts.get(userId) || 0) + 1) + }) + + return counts + } + getAllUsers = async () => { const result: Array<{ id: string diff --git a/src/components/UsersComponents/AllUsersTable.tsx b/src/components/UsersComponents/AllUsersTable.tsx index 256e15a90..ac17f704e 100644 --- a/src/components/UsersComponents/AllUsersTable.tsx +++ b/src/components/UsersComponents/AllUsersTable.tsx @@ -12,6 +12,8 @@ import { Switch, Tooltip, } from '@material-ui/core' +import { MuiPickersUtilsProvider, KeyboardDatePicker } from '@material-ui/pickers' +import DateFnsUtils from '@date-io/date-fns' import React from 'react' import GetAppIcon from '@material-ui/icons/GetApp' import EditIcon from '@material-ui/icons/Edit' @@ -43,6 +45,10 @@ const StatusBadge = styled.span<{ archived: boolean }>` interface Props { users: Types.User[] + loginCounts: Map + rangeStart: Date + rangeEnd: Date + onRangeChange: (start: Date, end: Date) => void onUserClick?: (user: Types.User) => void onArchiveClick?: (user: Types.User) => void } @@ -90,12 +96,19 @@ class AllUsersTable extends React.Component { users.sort((a, b) => { const isDateField = sortField === 'lastLogin' || sortField === 'lastAction' - const aVal = isDateField - ? (a[sortField]?.getTime() || 0) - : String(a[sortField] || '').toLowerCase() - const bVal = isDateField - ? (b[sortField]?.getTime() || 0) - : String(b[sortField] || '').toLowerCase() + const isLoginCount = sortField === 'loginCount' + let aVal: number | string + let bVal: number | string + if (isLoginCount) { + aVal = this.props.loginCounts.get(a.id) || 0 + bVal = this.props.loginCounts.get(b.id) || 0 + } else if (isDateField) { + aVal = a[sortField]?.getTime() || 0 + bVal = b[sortField]?.getTime() || 0 + } else { + aVal = String(a[sortField] || '').toLowerCase() + bVal = String(b[sortField] || '').toLowerCase() + } return sortDir === 'asc' ? (aVal > bVal ? 1 : -1) : (aVal < bVal ? 1 : -1) }) @@ -115,15 +128,33 @@ class AllUsersTable extends React.Component { return user.lastActionType ? `${user.lastActionType} - ${date}` : date } + formatRangeLabel = () => { + const fmt = (d: Date) => d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + return `${fmt(this.props.rangeStart)} \u2013 ${fmt(this.props.rangeEnd)}` + } + + applyPreset = (days: number) => { + const end = new Date() + end.setHours(23, 59, 59, 999) + const start = new Date() + start.setDate(start.getDate() - days + 1) + start.setHours(0, 0, 0, 0) + this.props.onRangeChange(start, end) + } + handleExport = () => { const users = this.getFilteredUsers() - const headers = ['Last Name', 'First Name', 'Email', 'Role', 'Program', 'Status', 'Last Login', 'Last Action', 'Action Type'] + const headers = [ + 'Last Name', 'First Name', 'Email', 'Role', 'Program', 'Status', + 'Last Login', `Logins (${this.formatRangeLabel()})`, 'Last Action', 'Action Type' + ] const escape = (val: string) => `"${(val || '').replace(/"/g, '""')}"` const rows = users.map(u => [ escape(u.lastName), escape(u.firstName), escape(u.email), escape(this.formatRole(u.role)), escape(u.program || ''), escape(u.archived ? 'Archived' : 'Active'), escape(this.formatDate(u.lastLogin)), + String(this.props.loginCounts.get(u.id) || 0), escape(this.formatDate(u.lastAction)), escape(u.lastActionType || '') ].join(',')) @@ -149,6 +180,8 @@ class AllUsersTable extends React.Component { ) + const { rangeStart, rangeEnd, onRangeChange } = this.props + return ( @@ -177,6 +210,25 @@ class AllUsersTable extends React.Component { + + Login range: + + date && onRangeChange(date, rangeEnd)} + /> + date && onRangeChange(rangeStart, date)} + /> + + + + + + @@ -188,13 +240,14 @@ class AllUsersTable extends React.Component { + {paginated.length === 0 ? ( - No users found + No users found ) : paginated.map(user => ( {user.lastName} @@ -214,6 +267,7 @@ class AllUsersTable extends React.Component { {this.formatDate(user.lastLogin)} + {this.props.loginCounts.get(user.id) || 0} {this.formatLastAction(user)} e.stopPropagation()} style={{ textAlign: 'center' }}> diff --git a/src/views/protected/AdminViews/AllUsersPage.tsx b/src/views/protected/AdminViews/AllUsersPage.tsx index 6eff6ef9b..6dfc61f0d 100644 --- a/src/views/protected/AdminViews/AllUsersPage.tsx +++ b/src/views/protected/AdminViews/AllUsersPage.tsx @@ -12,6 +12,9 @@ interface Props { isAdmin: boolean } interface State { loading: boolean users: Types.User[] + loginCounts: Map + rangeStart: Date + rangeEnd: Date editOpen: boolean archiveOpen: boolean selected: Types.User | null @@ -20,21 +23,43 @@ interface State { email: string } +const defaultRange = (): { start: Date; end: Date } => { + const end = new Date() + end.setHours(23, 59, 59, 999) + const start = new Date() + start.setDate(start.getDate() - 29) + start.setHours(0, 0, 0, 0) + return { start, end } +} + class AllUsersPage extends React.Component { static contextType = FirebaseContext context!: Firebase state: State = { - loading: true, users: [], editOpen: false, archiveOpen: false, + loading: true, users: [], loginCounts: new Map(), + rangeStart: defaultRange().start, rangeEnd: defaultRange().end, + editOpen: false, archiveOpen: false, selected: null, firstName: '', lastName: '', email: '', } componentDidMount() { - this.context.getAllUsers() - .then(users => this.setState({ users, loading: false })) + const { rangeStart, rangeEnd } = this.state + Promise.all([ + this.context.getAllUsers(), + this.context.getUsersLoginCounts(rangeStart, rangeEnd) + ]) + .then(([users, loginCounts]) => this.setState({ users, loginCounts, loading: false })) .catch(() => { alert('Error loading users'); this.setState({ loading: false }) }) } + handleRangeChange = (start: Date, end: Date) => { + this.setState({ rangeStart: start, rangeEnd: end }) + this.context.getUsersLoginCounts(start, end) + .then(loginCounts => this.setState({ loginCounts })) + .catch(() => alert('Error loading login counts')) + } + handleUserClick = (user: Types.User) => { this.setState({ selected: user, firstName: user.firstName, lastName: user.lastName, @@ -66,7 +91,7 @@ class AllUsersPage extends React.Component { render() { const { isAdmin } = this.props - const { loading, users, editOpen, archiveOpen, selected, firstName, lastName, email } = this.state + const { loading, users, loginCounts, rangeStart, rangeEnd, editOpen, archiveOpen, selected, firstName, lastName, email } = this.state return (
@@ -86,6 +111,10 @@ class AllUsersPage extends React.Component { ) : ( this.setState({ selected: user, archiveOpen: true })} /> diff --git a/src/views/protected/UsersViews/UsersPage.tsx b/src/views/protected/UsersViews/UsersPage.tsx index 0b8589b7a..2cded0d31 100644 --- a/src/views/protected/UsersViews/UsersPage.tsx +++ b/src/views/protected/UsersViews/UsersPage.tsx @@ -113,6 +113,9 @@ interface State { sendToSites: Array allUsers: Types.User[] allUsersLoading: boolean + allUsersLoginCounts: Map + allUsersRangeStart: Date + allUsersRangeEnd: Date editDialogOpen: boolean archiveDialogOpen: boolean selectedUser: Types.User | null @@ -138,6 +141,12 @@ class UsersPage extends React.Component { constructor(props: Props) { super(props); + const rangeEnd = new Date() + rangeEnd.setHours(23, 59, 59, 999) + const rangeStart = new Date() + rangeStart.setDate(rangeStart.getDate() - 29) + rangeStart.setHours(0, 0, 0, 0) + this.state = { currentPage: "", coachData: [], @@ -150,6 +159,9 @@ class UsersPage extends React.Component { sendToSites: [], allUsers: [], allUsersLoading: true, + allUsersLoginCounts: new Map(), + allUsersRangeStart: rangeStart, + allUsersRangeEnd: rangeEnd, editDialogOpen: false, archiveDialogOpen: false, selectedUser: null, @@ -573,14 +585,25 @@ class UsersPage extends React.Component { if (this.props.userRole !== 'admin') return this.setState({ allUsersLoading: true }) try { - const users = await this.context.getAllUsers() - this.setState({ allUsers: users, allUsersLoading: false }) + const { allUsersRangeStart, allUsersRangeEnd } = this.state + const [users, allUsersLoginCounts] = await Promise.all([ + this.context.getAllUsers(), + this.context.getUsersLoginCounts(allUsersRangeStart, allUsersRangeEnd) + ]) + this.setState({ allUsers: users, allUsersLoginCounts, allUsersLoading: false }) } catch (e) { console.error('Error loading all users:', e) this.setState({ allUsersLoading: false }) } } + handleAllUsersRangeChange = (start: Date, end: Date) => { + this.setState({ allUsersRangeStart: start, allUsersRangeEnd: end }) + this.context.getUsersLoginCounts(start, end) + .then(allUsersLoginCounts => this.setState({ allUsersLoginCounts })) + .catch(e => console.error('Error loading login counts:', e)) + } + handleAllUserClick = (user: Types.User) => { this.setState({ selectedUser: user, @@ -712,6 +735,10 @@ class UsersPage extends React.Component {
Edit