From e58b87d02425cdd1d168a9ddd5a2f471ee983800 Mon Sep 17 00:00:00 2001 From: Ismael Dosil Date: Tue, 27 Jan 2026 20:16:20 -0300 Subject: [PATCH] feat(all-users): add Last Action column showing most recent activity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add getUsersLastAction() method with optimized batch queries - Query 5 collections in parallel (observations, knowledgeChecks, conferencePlans, actionPlans, emails) - Build Map in memory for O(1) lookups - Add lastAction to User interface and getAllUsers() result - Add sortable "Last Action" column to AllUsersTable - Include Last Action in CSV export Performance: 5 queries total instead of 2,995 (599 users × 5 collections) Closes CHALK-090 Closes CHALK-091 Closes CHALK-092 --- src/components/Firebase/Firebase.tsx | 97 ++++++++++++++++++- .../UsersComponents/AllUsersTable.tsx | 18 ++-- src/constants/Types.tsx | 1 + 3 files changed, 105 insertions(+), 11 deletions(-) diff --git a/src/components/Firebase/Firebase.tsx b/src/components/Firebase/Firebase.tsx index 471c58c09..e2cceacad 100644 --- a/src/components/Firebase/Firebase.tsx +++ b/src/components/Firebase/Firebase.tsx @@ -4737,6 +4737,89 @@ class Firebase { * Gets all users from Firestore with relevant information for admin dashboard * @returns {Array} Array of user objects with id, name, email, role, status, and lastLogin */ + /** + * Get last action date for all users by querying activity collections. + * Uses optimized approach: 5 total queries instead of per-user queries. + * @returns Map - Last action date per user + */ + getUsersLastAction = async (): Promise> => { + const lastActionMap = new Map() + + const updateIfNewer = (userId: string, date: Date | null) => { + if (!date || !userId) return + const current = lastActionMap.get(userId) + if (!current || date > current) { + lastActionMap.set(userId, date) + } + } + + // Helper to extract userId from /user/ID format (used in observations) + const extractUserId = (ref: string): string => { + if (!ref) return '' + return ref.startsWith('/user/') ? ref.replace('/user/', '') : ref + } + + // Query all 5 collections in parallel for better performance + const [observations, knowledgeChecks, conferencePlans, actionPlans, emails] = await Promise.all([ + this.db.collection('observations').get(), + this.db.collection('knowledgeChecks').get(), + this.db.collection('conferencePlans').get(), + this.db.collection('actionPlans').get(), + this.db.collection('emails').get() + ]) + + // 1. Observations (largest collection - 20K+) + observations.docs.forEach(doc => { + const data = doc.data() + const userId = extractUserId(data.teacher) + const endDate = data.end?.toDate?.() || null + updateIfNewer(userId, endDate) + }) + + // 2. Knowledge Checks (6K+) + knowledgeChecks.docs.forEach(doc => { + const data = doc.data() + const userId = data.answeredBy + const timestamp = data.timestamp?.toDate?.() || null + updateIfNewer(userId, timestamp) + }) + + // 3. Conference Plans + conferencePlans.docs.forEach(doc => { + const data = doc.data() + const userId = data.teacher + const created = data.dateCreated?.toDate?.() || null + const modified = data.dateModified?.toDate?.() || null + updateIfNewer(userId, created) + updateIfNewer(userId, modified) + }) + + // 4. Action Plans + actionPlans.docs.forEach(doc => { + const data = doc.data() + const userId = data.teacher + const created = data.dateCreated?.toDate?.() || null + const modified = data.dateModified?.toDate?.() || null + updateIfNewer(userId, created) + updateIfNewer(userId, modified) + }) + + // 5. Emails (check both sender and recipient) + emails.docs.forEach(doc => { + const data = doc.data() + const senderId = data.user + const recipientId = data.recipientId + const created = data.dateCreated?.toDate?.() || null + const modified = data.dateModified?.toDate?.() || null + updateIfNewer(senderId, created) + updateIfNewer(senderId, modified) + updateIfNewer(recipientId, created) + updateIfNewer(recipientId, modified) + }) + + return lastActionMap + } + getAllUsers = async () => { const result: Array<{ id: string @@ -4747,17 +4830,22 @@ class Firebase { program: string archived: boolean lastLogin: Date | null + lastAction: Date | null }> = [] - // Fetch all programs to build a lookup map - const programsSnapshot = await this.db.collection('programs').get() + // Fetch programs, users, and last action data in parallel + const [programsSnapshot, usersSnapshot, lastActionMap] = await Promise.all([ + this.db.collection('programs').get(), + this.db.collection('users').get(), + this.getUsersLastAction() + ]) + + // Build programs lookup map const programsMap = new Map() programsSnapshot.docs.forEach(doc => { programsMap.set(doc.id, doc.data().name || '') }) - const usersSnapshot = await this.db.collection('users').get() - usersSnapshot.docs.forEach(doc => { const data = doc.data() // Get first program name from user's programs array @@ -4800,6 +4888,7 @@ class Firebase { program: programName, archived: data.archived || false, lastLogin: data.lastLogin ? data.lastLogin.toDate() : null, + lastAction: lastActionMap.get(doc.id) || null, }) }) diff --git a/src/components/UsersComponents/AllUsersTable.tsx b/src/components/UsersComponents/AllUsersTable.tsx index c85d59c63..8785e8414 100644 --- a/src/components/UsersComponents/AllUsersTable.tsx +++ b/src/components/UsersComponents/AllUsersTable.tsx @@ -89,11 +89,12 @@ class AllUsersTable extends React.Component { if (statusFilter) users = users.filter(u => u.archived === (statusFilter === 'archived')) users.sort((a, b) => { - const aVal = sortField === 'lastLogin' - ? (a.lastLogin?.getTime() || 0) + const isDateField = sortField === 'lastLogin' || sortField === 'lastAction' + const aVal = isDateField + ? (a[sortField]?.getTime() || 0) : String(a[sortField] || '').toLowerCase() - const bVal = sortField === 'lastLogin' - ? (b.lastLogin?.getTime() || 0) + const bVal = isDateField + ? (b[sortField]?.getTime() || 0) : String(b[sortField] || '').toLowerCase() return sortDir === 'asc' ? (aVal > bVal ? 1 : -1) : (aVal < bVal ? 1 : -1) }) @@ -110,13 +111,14 @@ class AllUsersTable extends React.Component { handleExport = () => { const users = this.getFilteredUsers() - const headers = ['Last Name', 'First Name', 'Email', 'Role', 'Program', 'Status', 'Last Login'] + const headers = ['Last Name', 'First Name', 'Email', 'Role', 'Program', 'Status', 'Last Login', 'Last Action'] 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)) + escape(this.formatDate(u.lastLogin)), + escape(this.formatDate(u.lastAction)) ].join(',')) const csv = [headers.join(','), ...rows].join('\n') const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' }) @@ -179,12 +181,13 @@ class AllUsersTable extends React.Component { + Edit {paginated.length === 0 ? ( - No users found + No users found ) : paginated.map(user => ( {user.lastName} @@ -204,6 +207,7 @@ class AllUsersTable extends React.Component { {this.formatDate(user.lastLogin)} + {this.formatDate(user.lastAction)} e.stopPropagation()} style={{ textAlign: 'center' }}> this.props.onUserClick?.(user)}> diff --git a/src/constants/Types.tsx b/src/constants/Types.tsx index 64e162e8f..4ef5b9db9 100644 --- a/src/constants/Types.tsx +++ b/src/constants/Types.tsx @@ -233,6 +233,7 @@ export interface User { id: string }>, lastLogin?: Date, + lastAction?: Date, email?: string, school?: string, program?: string,