From 849dde4a6208713a257612a4f363810480e733ac Mon Sep 17 00:00:00 2001 From: Ismael Dosil Date: Fri, 8 May 2026 10:25:05 -0300 Subject: [PATCH 1/2] feat(usage): track logins per user with date range filter - Backend: write loginEvents doc on each signin (preserves lastLogin) - Backend: getUsersLoginCounts(start, end) batch aggregation - UI: date range picker on All Users dashboard with 7d/30d/90d presets - UI: new Logins column with sort, alongside existing Last Login - CSV export includes login count for selected range - Default range: last 30 days Closes CHALK-110 Closes CHALK-111 Closes CHALK-112 Closes CHALK-113 Closes CHALK-114 --- src/components/Firebase/Firebase.tsx | 24 +++++++ .../UsersComponents/AllUsersTable.tsx | 70 ++++++++++++++++--- .../protected/AdminViews/AllUsersPage.tsx | 37 ++++++++-- 3 files changed, 119 insertions(+), 12 deletions(-) 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 })} /> From 097df01fe0b9859dc2fcfafc759036a2a79ab27d Mon Sep 17 00:00:00 2001 From: Ismael Dosil Date: Fri, 8 May 2026 19:38:01 -0300 Subject: [PATCH 2/2] fix(usage): wire login counts to UsersPage AllUsersTable instance The /LeadersAllUsers route also renders and was missing the new required props introduced for login tracking. Adds parallel load via Promise.all and a range-change handler matching the AllUsersPage pattern. Closes CHALK-115 --- src/views/protected/UsersViews/UsersPage.tsx | 31 ++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) 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