From a554968f2ec49815c4b936ebc360395d2890d2dd Mon Sep 17 00:00:00 2001
From: ntur101
Date: Mon, 20 Oct 2025 10:30:47 +1300
Subject: [PATCH 1/3] did some code refactoring for inconsitent data handling
---
frontend/src/lib/utils.js | 106 ++-----
frontend/src/pages/Messages.js | 47 ++--
frontend/src/pages/ProfilePage.js | 6 +-
frontend/src/services/firestoreNormalizer.js | 262 ++++++++++++++++++
.../src/services/firestoreNormalizer.test.js | 226 +++++++++++++++
5 files changed, 542 insertions(+), 105 deletions(-)
create mode 100644 frontend/src/services/firestoreNormalizer.js
create mode 100644 frontend/src/services/firestoreNormalizer.test.js
diff --git a/frontend/src/lib/utils.js b/frontend/src/lib/utils.js
index d5483ee..2cef6b2 100644
--- a/frontend/src/lib/utils.js
+++ b/frontend/src/lib/utils.js
@@ -1,4 +1,5 @@
import { clsx } from 'clsx';
+import { normalizeItem } from '../services/firestoreNormalizer';
/**
* Combine multiple class names conditionally
@@ -12,48 +13,37 @@ export function cn(...inputs) {
// Default values for when data is missing or invalid
const DEFAULT_IMAGE_URL = '/placeholder.svg';
-const DEFAULT_LOCATION = 'Unknown';
const DEFAULT_REPORTER = { name: 'Unknown', trust: false };
/**
* Clean up Firestore data to match our UI expectations
- * Handles different field names and data formats from various sources
+ * REFACTORED: Now uses centralized normalizeItem from firestoreNormalizer service
+ * This function wraps the service to add UI-specific fields like 'reporter'
+ *
* @param {Object} data - Raw document data from Firestore
* @param {string} id - Document ID (optional)
* @returns {Object} Normalized item object ready for the UI
*/
export const normalizeFirestoreItem = (data, id) => {
try {
- // ...existing code...
- const imageUrl = data.imageURL || data.imageUrl || DEFAULT_IMAGE_URL;
- const kindRaw = data.kind || data.status || '';
- const kind = String(kindRaw).toLowerCase();
- const typeRaw = data.category || data.type || '';
- const category = normalizeCategory(String(typeRaw).toLowerCase());
+ // Use centralized normalization service
+ const normalized = normalizeItem(data, id);
+
+ if (!normalized) {
+ throw new Error('normalizeItem returned null');
+ }
+
+ // Add UI-specific fields that aren't in the base normalizer
const reporter = normalizeReporter(data.reporter, data.postedBy);
- const date = normalizeDate(data.date);
- const location = data.location || DEFAULT_LOCATION;
const coordinates = data.coordinates || null;
- // Claim fields
- const claimed = Boolean(data.claimed);
- const claimedAt = data.claimedAt ? normalizeDate(data.claimedAt) : null;
- const claimedBy = data.claimedBy || null;
-
return {
- id: id || data.id || '',
- kind,
- category,
- title: String(data.title || '').trim(),
- description: String(data.description || '').trim(),
- imageUrl,
- date,
- location,
- coordinates,
+ ...normalized,
+ // Override imageUrl default if it's empty
+ imageUrl: normalized.imageUrl || DEFAULT_IMAGE_URL,
+ // Add UI-specific fields
reporter,
- claimed,
- claimedAt,
- claimedBy,
+ coordinates,
};
} catch (error) {
console.error('Error normalizing Firestore item:', error);
@@ -66,34 +56,16 @@ export const normalizeFirestoreItem = (data, id) => {
description: 'This item could not be loaded properly.',
imageUrl: DEFAULT_IMAGE_URL,
date: new Date().toISOString(),
- location: DEFAULT_LOCATION,
+ location: 'Unknown',
coordinates: null,
reporter: DEFAULT_REPORTER,
+ claimed: false,
+ claimedAt: null,
+ claimedBy: null,
};
}
};
-/**
- * Map category names to consistent values
- * Handles variations like 'accessories' vs 'accessory'
- * @param {string} category - Raw category string
- * @returns {string} Normalized category name
- */
-const normalizeCategory = (category) => {
- const categoryMap = {
- 'accessories': 'accessory',
- 'keys/cards': 'keys-cards',
- 'wallets': 'wallet',
- 'documents': 'document',
- 'stationery': 'stationery',
- 'electronics': 'electronics',
- 'clothing': 'clothing',
- 'other': 'other'
- };
-
- return categoryMap[category] || category;
-};
-
/**
* Extract reporter information from various data formats
* Handles both direct user objects and Firestore document references
@@ -125,42 +97,6 @@ const normalizeReporter = (reporter, postedBy) => {
return DEFAULT_REPORTER;
};
-/**
- * Convert various date formats to ISO strings
- * Handles Firestore timestamps, Date objects, strings, and numbers
- * @param {any} dateValue - Date value from Firestore or form
- * @returns {string} ISO date string for consistent display
- */
-const normalizeDate = (dateValue) => {
- try {
- // Handle Firestore Timestamp objects
- if (dateValue?.toDate && typeof dateValue.toDate === 'function') {
- return dateValue.toDate().toISOString();
- } else if (dateValue instanceof Date) {
- // Handle JavaScript Date objects
- return dateValue.toISOString();
- } else if (typeof dateValue === 'string') {
- // Handle string dates
- const parsed = new Date(dateValue);
- if (!isNaN(parsed.getTime())) {
- return parsed.toISOString();
- }
- } else if (typeof dateValue === 'number') {
- // Handle timestamp numbers (milliseconds since epoch)
- const parsed = new Date(dateValue);
- if (!isNaN(parsed.getTime())) {
- return parsed.toISOString();
- }
- }
-
- // Fallback to current date if parsing fails
- return new Date().toISOString();
- } catch (error) {
- console.warn('Error normalizing date:', error);
- return new Date().toISOString();
- }
-};
-
/**
* Pre-built button styles to reduce duplication
* Each variant has its own color scheme and hover states
diff --git a/frontend/src/pages/Messages.js b/frontend/src/pages/Messages.js
index 8a5cbc2..4aa424a 100644
--- a/frontend/src/pages/Messages.js
+++ b/frontend/src/pages/Messages.js
@@ -16,6 +16,7 @@ import { db } from '../firebase/config';
import { ArrowLeft, Send, Check } from '../components/ui/icons';
import { Button } from '../components/ui/button';
import { cardStyles } from '../lib/utils';
+import { normalizeConversation, normalizeMessage, normalizeItem } from '../services/firestoreNormalizer';
const MessagesPage = () => {
const [user, setUser] = useState(null);
@@ -31,11 +32,15 @@ const MessagesPage = () => {
const messagesEndRef = useRef(null);
// Helper: fetch item document by id (returns null if not found)
+ // REFACTORED: Now uses normalizeItem for consistent data shape
const fetchItemData = useCallback(async (itemId) => {
if (!itemId) return null;
try {
const itemDoc = await getDoc(doc(db, 'items', itemId));
- return itemDoc.exists() ? { id: itemDoc.id, ...itemDoc.data() } : null;
+ if (!itemDoc.exists()) return null;
+
+ // Use normalizer for consistent data structure
+ return normalizeItem(itemDoc.data(), itemDoc.id);
} catch (error) {
console.error('Error fetching item:', error);
return null;
@@ -57,17 +62,22 @@ const MessagesPage = () => {
}, []);
// Helper: enrich a list of conversation docs with item data and participant name
+ // REFACTORED: Now uses normalizeConversation for consistent data structure
const enrichConversations = useCallback(async (docs, userUid) => {
const promises = docs.map(async (docSnapshot) => {
const conversationData = docSnapshot.data();
+
+ // Normalize the conversation data first
+ const normalized = normalizeConversation(conversationData, docSnapshot.id);
+
+ // Fetch related data
const [itemData, otherParticipantName] = await Promise.all([
- fetchItemData(conversationData.itemId),
- fetchUserNameById(conversationData.participants.find(id => id !== userUid))
+ fetchItemData(normalized.itemId),
+ fetchUserNameById(normalized.participants.find(id => id !== userUid))
]);
return {
- id: docSnapshot.id,
- ...conversationData,
+ ...normalized,
item: itemData,
otherParticipantName
};
@@ -90,6 +100,7 @@ const MessagesPage = () => {
}, [auth, navigate]);
// Load conversations for the current user
+ // REFACTORED: Uses normalizeConversation for consistent data handling
useEffect(() => {
if (!user) return;
@@ -100,10 +111,10 @@ const MessagesPage = () => {
try {
const enriched = await enrichConversations(snapshot.docs, user.uid);
- // Sort conversations by lastMessageTime (handle missing timestamps)
+ // Sort conversations by lastMessageTime using normalized timestamps
enriched.sort((a, b) => {
- const aTime = a.lastMessageTime?.seconds || a.createdAt?.seconds || 0;
- const bTime = b.lastMessageTime?.seconds || b.createdAt?.seconds || 0;
+ const aTime = a.lastMessageTime ? new Date(a.lastMessageTime).getTime() : 0;
+ const bTime = b.lastMessageTime ? new Date(b.lastMessageTime).getTime() : 0;
return bTime - aTime;
});
@@ -124,6 +135,7 @@ const MessagesPage = () => {
}, [user, searchParams, enrichConversations]);
// Load messages for selected conversation
+ // REFACTORED: Uses normalizeMessage for consistent timestamp handling
useEffect(() => {
if (!selectedConversation) {
setMessages([]);
@@ -131,22 +143,21 @@ const MessagesPage = () => {
}
const messagesRef = collection(db, 'messages');
- // Try without ordering first to see if messages exist
const q = query(
messagesRef,
where('conversationId', '==', selectedConversation.id)
);
const unsubscribe = onSnapshot(q, (snapshot) => {
- const messagesList = snapshot.docs.map(doc => ({
- id: doc.id,
- ...doc.data()
- }));
+ // Use normalizer for consistent message structure
+ const messagesList = snapshot.docs
+ .map(doc => normalizeMessage(doc.data(), doc.id))
+ .filter(msg => msg !== null);
- // Sort messages by timestamp (handle cases where timestamp might be missing)
+ // Sort messages by timestamp (now consistently ISO strings)
messagesList.sort((a, b) => {
- const aTime = a.timestamp?.seconds || 0;
- const bTime = b.timestamp?.seconds || 0;
+ const aTime = a.timestamp ? new Date(a.timestamp).getTime() : 0;
+ const bTime = b.timestamp ? new Date(b.timestamp).getTime() : 0;
return aTime - bTime;
});
@@ -363,9 +374,9 @@ const MessagesPage = () => {
className={`text-xs mt-1 ${
message.senderId === user.uid ? '' : 'text-gray-500'
}`}
- style={message.senderId === user.uid ? { color: '#FFFFFF' } : undefined}
+ style={message.senderId === user.uid ? { color: '#D1D5DB' } : undefined}
>
- {message.timestamp?.toDate?.()?.toLocaleTimeString() || 'Just now'}
+ {message.timestamp ? new Date(message.timestamp).toLocaleTimeString() : 'Just now'}
diff --git a/frontend/src/pages/ProfilePage.js b/frontend/src/pages/ProfilePage.js
index c044057..39d1ea1 100644
--- a/frontend/src/pages/ProfilePage.js
+++ b/frontend/src/pages/ProfilePage.js
@@ -8,10 +8,12 @@ import PropTypes from 'prop-types'
import { getUserPosts, formatTimestamp, updateItemStatus, updateItem } from "../firebase/firestore"
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card"
import { ProfileBadge } from '../components/ui/ProfileBadge'
+import { extractDate } from "../services/firestoreNormalizer"
/** ---------- Small utilities (deduplicated helpers) ---------- **/
-const coalesceDate = (item) =>
- item.date || item.createdAt || item.created_at || item.timestamp || item.dateCreated
+// REFACTORED: Replaced coalesceDate with extractDate from firestoreNormalizer
+// This provides consistent date handling across the entire app
+const coalesceDate = (item) => extractDate(item) || item.date || new Date().toISOString()
const StatusPill = memo(({ color, text }) => (
diff --git a/frontend/src/services/firestoreNormalizer.js b/frontend/src/services/firestoreNormalizer.js
new file mode 100644
index 0000000..6dd700c
--- /dev/null
+++ b/frontend/src/services/firestoreNormalizer.js
@@ -0,0 +1,262 @@
+/**
+ * Centralized Firestore Data Normalization Service
+ *
+ * This service provides a single source of truth for transforming Firestore documents
+ * into consistent JavaScript objects. It handles:
+ * - Firestore Timestamp conversions
+ * - Field name reconciliation (e.g., imageURL vs imageUrl)
+ * - Multiple date field variations
+ * - Type coercion and defaults
+ *
+ * Benefits:
+ * - Easier debugging with preserved _raw data
+ * - Consistent data shape across the app
+ * - Single place to update when Firestore schema changes
+ */
+
+/**
+ * Normalize a Firestore Timestamp to ISO string or null
+ * Handles multiple timestamp formats from Firestore
+ *
+ * @param {*} value - Firestore Timestamp, Date, or timestamp object
+ * @returns {string|null} ISO date string or null
+ */
+export const normalizeTimestamp = (value) => {
+ if (!value) return null;
+
+ // Firestore Timestamp object with toDate() method
+ if (value.toDate && typeof value.toDate === 'function') {
+ try {
+ return value.toDate().toISOString();
+ } catch (e) {
+ console.warn('Failed to convert Firestore Timestamp:', e);
+ return null;
+ }
+ }
+
+ // Already a Date object
+ if (value instanceof Date) {
+ return isNaN(value.getTime()) ? null : value.toISOString();
+ }
+
+ // Firestore snapshot with .seconds property
+ if (value.seconds !== undefined) {
+ try {
+ return new Date(value.seconds * 1000).toISOString();
+ } catch (e) {
+ console.warn('Failed to convert Firestore seconds:', e);
+ return null;
+ }
+ }
+
+ // String timestamp
+ if (typeof value === 'string') {
+ const parsed = new Date(value);
+ return isNaN(parsed.getTime()) ? null : parsed.toISOString();
+ }
+
+ // Number timestamp (milliseconds)
+ if (typeof value === 'number') {
+ const parsed = new Date(value);
+ return isNaN(parsed.getTime()) ? null : parsed.toISOString();
+ }
+
+ return null;
+};
+
+/**
+ * Extract a date from a document by checking multiple possible field names
+ * Firestore documents often have inconsistent date field naming
+ *
+ * @param {Object} doc - Firestore document data
+ * @param {string[]} fieldNames - Array of field names to check in order
+ * @returns {string|null} ISO date string or null
+ */
+export const extractDate = (doc, fieldNames = ['date', 'createdAt', 'created_at', 'timestamp', 'dateCreated']) => {
+ for (const field of fieldNames) {
+ if (doc[field]) {
+ const normalized = normalizeTimestamp(doc[field]);
+ if (normalized) return normalized;
+ }
+ }
+ return null;
+};
+
+/**
+ * Normalize an item document (lost/found items)
+ *
+ * Reconciles field name variations:
+ * - type/category for item category
+ * - status/kind for lost/found
+ * - imageURL/imageUrl/image for image URLs
+ * - Multiple date field variations
+ *
+ * @param {Object} data - Raw Firestore document data
+ * @param {string} id - Document ID
+ * @returns {Object} Normalized item object
+ */
+export const normalizeItem = (data, id) => {
+ if (!data) {
+ console.warn('normalizeItem received null/undefined data for id:', id);
+ return null;
+ }
+
+ return {
+ id,
+ // Core fields
+ title: data.title || '',
+ description: data.description || '',
+
+ // Reconcile field name variations
+ category: data.type || data.category || '',
+ kind: (data.status || data.kind || 'lost').toLowerCase(),
+ imageUrl: data.imageURL || data.imageUrl || data.image || '',
+ location: data.location || '',
+
+ // Dates - check multiple possible field names
+ date: extractDate(data),
+
+ // Claims data (new system)
+ claimed: Boolean(data.claimed),
+ claimedAt: normalizeTimestamp(data.claimedAt),
+ claimedBy: data.claimedBy || null,
+
+ // User reference
+ user: data.user || null,
+ userId: data.userId || data.user?.id || null,
+
+ // Legacy status field (kept for backward compatibility)
+ status: data.status || data.kind || 'lost',
+
+ // Preserve original for debugging
+ _raw: data
+ };
+};
+
+/**
+ * Normalize a user document
+ *
+ * @param {Object} data - Raw Firestore document data
+ * @param {string} id - Document ID
+ * @returns {Object} Normalized user object
+ */
+export const normalizeUser = (data, id) => {
+ if (!data) {
+ console.warn('normalizeUser received null/undefined data for id:', id);
+ return null;
+ }
+
+ return {
+ id,
+ name: data.name || data.displayName || '',
+ email: data.email || '',
+ profilePic: data.profilePic || data.photoURL || null,
+ createdAt: extractDate(data, ['createdAt', 'created_at', 'joinedAt']),
+
+ // Preserve original
+ _raw: data
+ };
+};
+
+/**
+ * Normalize a conversation document
+ *
+ * @param {Object} data - Raw Firestore document data
+ * @param {string} id - Document ID
+ * @returns {Object} Normalized conversation object
+ */
+export const normalizeConversation = (data, id) => {
+ if (!data) {
+ console.warn('normalizeConversation received null/undefined data for id:', id);
+ return null;
+ }
+
+ return {
+ id,
+ participants: data.participants || [],
+ itemId: data.itemId || null,
+ lastMessage: data.lastMessage || '',
+ lastMessageTime: normalizeTimestamp(data.lastMessageTime),
+ lastMessageSender: data.lastMessageSender || null,
+ createdAt: extractDate(data),
+
+ // Preserve original
+ _raw: data
+ };
+};
+
+/**
+ * Normalize a message document
+ *
+ * @param {Object} data - Raw Firestore document data
+ * @param {string} id - Document ID
+ * @returns {Object} Normalized message object
+ */
+export const normalizeMessage = (data, id) => {
+ if (!data) {
+ console.warn('normalizeMessage received null/undefined data for id:', id);
+ return null;
+ }
+
+ return {
+ id,
+ conversationId: data.conversationId || '',
+ senderId: data.senderId || '',
+ senderName: data.senderName || 'Unknown',
+ text: data.text || '',
+ timestamp: normalizeTimestamp(data.timestamp),
+
+ // Preserve original
+ _raw: data
+ };
+};
+
+/**
+ * Normalize an announcement document
+ *
+ * @param {Object} data - Raw Firestore document data
+ * @param {string} id - Document ID
+ * @returns {Object} Normalized announcement object
+ */
+export const normalizeAnnouncement = (data, id) => {
+ if (!data) {
+ console.warn('normalizeAnnouncement received null/undefined data for id:', id);
+ return null;
+ }
+
+ return {
+ id,
+ title: data.title || '',
+ message: data.message || data.content || '',
+ category: data.category || 'general',
+ priority: data.priority || 'normal',
+ createdAt: extractDate(data),
+ expiresAt: normalizeTimestamp(data.expiresAt),
+ author: data.author || null,
+
+ // Preserve original
+ _raw: data
+ };
+};
+
+/**
+ * Helper to get a displayable timestamp string from normalized data
+ *
+ * @param {Object} item - Normalized item with date field
+ * @returns {string} Formatted date string or 'Unknown date'
+ */
+export const getDisplayDate = (item) => {
+ if (!item.date) return 'Unknown date';
+
+ try {
+ const date = new Date(item.date);
+ return date.toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric'
+ });
+ } catch (e) {
+ console.warn('Failed to format date:', e);
+ return 'Unknown date';
+ }
+};
diff --git a/frontend/src/services/firestoreNormalizer.test.js b/frontend/src/services/firestoreNormalizer.test.js
new file mode 100644
index 0000000..c02d105
--- /dev/null
+++ b/frontend/src/services/firestoreNormalizer.test.js
@@ -0,0 +1,226 @@
+/**
+ * Tests for Firestore Normalizer Service
+ *
+ * These tests demonstrate the benefits of centralized normalization:
+ * 1. Easy to test all edge cases in one place
+ * 2. Consistent handling of different timestamp formats
+ * 3. Clear documentation of expected behavior
+ */
+
+import {
+ normalizeTimestamp,
+ extractDate,
+ normalizeItem,
+ normalizeMessage,
+ normalizeConversation
+} from './firestoreNormalizer';
+
+describe('normalizeTimestamp', () => {
+ it('should handle Firestore Timestamp with toDate method', () => {
+ const mockTimestamp = {
+ toDate: () => new Date('2024-01-15T10:30:00Z')
+ };
+
+ const result = normalizeTimestamp(mockTimestamp);
+ expect(result).toBe('2024-01-15T10:30:00.000Z');
+ });
+
+ it('should handle Firestore snapshot with seconds property', () => {
+ const mockSnapshot = {
+ seconds: 1705315800, // Jan 15, 2024 10:30:00 UTC
+ nanoseconds: 0
+ };
+
+ const result = normalizeTimestamp(mockSnapshot);
+ expect(result).toBe('2024-01-15T10:30:00.000Z');
+ });
+
+ it('should handle Date objects', () => {
+ const date = new Date('2024-01-15T10:30:00Z');
+ const result = normalizeTimestamp(date);
+ expect(result).toBe('2024-01-15T10:30:00.000Z');
+ });
+
+ it('should handle ISO string timestamps', () => {
+ const result = normalizeTimestamp('2024-01-15T10:30:00Z');
+ expect(result).toBe('2024-01-15T10:30:00.000Z');
+ });
+
+ it('should handle number timestamps (milliseconds)', () => {
+ const timestamp = new Date('2024-01-15T10:30:00Z').getTime();
+ const result = normalizeTimestamp(timestamp);
+ expect(result).toBe('2024-01-15T10:30:00.000Z');
+ });
+
+ it('should return null for invalid values', () => {
+ expect(normalizeTimestamp(null)).toBeNull();
+ expect(normalizeTimestamp(undefined)).toBeNull();
+ expect(normalizeTimestamp('invalid-date')).toBeNull();
+ });
+});
+
+describe('extractDate', () => {
+ it('should extract date from first matching field', () => {
+ const doc = {
+ otherField: 'value',
+ createdAt: { seconds: 1705315800 },
+ date: { seconds: 1705315900 } // Different time
+ };
+
+ // Should use 'date' first (default field order)
+ const result = extractDate(doc);
+ expect(result).toBe('2024-01-15T10:31:40.000Z');
+ });
+
+ it('should fall back to other field names', () => {
+ const doc = {
+ created_at: { seconds: 1705315800 }
+ };
+
+ const result = extractDate(doc);
+ expect(result).toBe('2024-01-15T10:30:00.000Z');
+ });
+
+ it('should support custom field names', () => {
+ const doc = {
+ lastModified: { seconds: 1705315800 }
+ };
+
+ const result = extractDate(doc, ['lastModified', 'updated']);
+ expect(result).toBe('2024-01-15T10:30:00.000Z');
+ });
+
+ it('should return null if no valid date found', () => {
+ const doc = {
+ title: 'Some Item',
+ description: 'No dates here'
+ };
+
+ const result = extractDate(doc);
+ expect(result).toBeNull();
+ });
+});
+
+describe('normalizeItem', () => {
+ it('should handle all field name variations', () => {
+ const rawItem = {
+ title: 'Lost Wallet',
+ description: 'Brown leather wallet',
+ type: 'wallets', // Using 'type' instead of 'category'
+ status: 'lost', // Using 'status' instead of 'kind'
+ imageURL: 'https://example.com/image.jpg', // Using 'imageURL' instead of 'imageUrl'
+ location: 'OGGB',
+ date: { seconds: 1705315800 },
+ claimed: true,
+ claimedAt: { seconds: 1705320000 }
+ };
+
+ const result = normalizeItem(rawItem, 'item123');
+
+ expect(result.id).toBe('item123');
+ expect(result.title).toBe('Lost Wallet');
+ expect(result.category).toBe('wallets');
+ expect(result.kind).toBe('lost');
+ expect(result.imageUrl).toBe('https://example.com/image.jpg');
+ expect(result.claimed).toBe(true);
+ expect(result.date).toBe('2024-01-15T10:30:00.000Z');
+ expect(result.claimedAt).toBe('2024-01-15T11:40:00.000Z');
+ });
+
+ it('should handle alternative field names', () => {
+ const rawItem = {
+ title: 'Found Keys',
+ category: 'keys/cards', // Alternative field name
+ kind: 'found', // Alternative field name
+ imageUrl: 'https://example.com/keys.jpg', // Alternative field name
+ createdAt: { seconds: 1705315800 } // Alternative date field
+ };
+
+ const result = normalizeItem(rawItem, 'item456');
+
+ expect(result.category).toBe('keys/cards');
+ expect(result.kind).toBe('found');
+ expect(result.imageUrl).toBe('https://example.com/keys.jpg');
+ expect(result.date).toBe('2024-01-15T10:30:00.000Z');
+ });
+
+ it('should provide defaults for missing fields', () => {
+ const rawItem = {
+ title: 'Minimal Item'
+ };
+
+ const result = normalizeItem(rawItem, 'item789');
+
+ expect(result.description).toBe('');
+ expect(result.category).toBe('');
+ expect(result.kind).toBe('lost'); // Default
+ expect(result.imageUrl).toBe('');
+ expect(result.claimed).toBe(false);
+ expect(result.claimedAt).toBeNull();
+ });
+
+ it('should preserve original data for debugging', () => {
+ const rawItem = {
+ title: 'Test Item',
+ customField: 'custom value'
+ };
+
+ const result = normalizeItem(rawItem, 'test');
+
+ expect(result._raw).toEqual(rawItem);
+ expect(result._raw.customField).toBe('custom value');
+ });
+});
+
+describe('normalizeMessage', () => {
+ it('should normalize message with all fields', () => {
+ const rawMessage = {
+ conversationId: 'conv123',
+ senderId: 'user456',
+ senderName: 'John Doe',
+ text: 'Hello, is this still available?',
+ timestamp: { seconds: 1705315800 }
+ };
+
+ const result = normalizeMessage(rawMessage, 'msg789');
+
+ expect(result.id).toBe('msg789');
+ expect(result.conversationId).toBe('conv123');
+ expect(result.senderId).toBe('user456');
+ expect(result.text).toBe('Hello, is this still available?');
+ expect(result.timestamp).toBe('2024-01-15T10:30:00.000Z');
+ });
+
+ it('should provide defaults for missing sender info', () => {
+ const rawMessage = {
+ text: 'Test message'
+ };
+
+ const result = normalizeMessage(rawMessage, 'msg001');
+
+ expect(result.senderName).toBe('Unknown');
+ expect(result.conversationId).toBe('');
+ });
+});
+
+describe('normalizeConversation', () => {
+ it('should normalize conversation data', () => {
+ const rawConversation = {
+ participants: ['user1', 'user2'],
+ itemId: 'item123',
+ lastMessage: 'Thanks!',
+ lastMessageTime: { seconds: 1705315800 },
+ lastMessageSender: 'user2',
+ createdAt: { seconds: 1705310000 }
+ };
+
+ const result = normalizeConversation(rawConversation, 'conv456');
+
+ expect(result.id).toBe('conv456');
+ expect(result.participants).toEqual(['user1', 'user2']);
+ expect(result.itemId).toBe('item123');
+ expect(result.lastMessage).toBe('Thanks!');
+ expect(result.lastMessageTime).toBe('2024-01-15T10:30:00.000Z');
+ expect(result.createdAt).toBe('2024-01-15T09:06:40.000Z');
+ });
+});
From 3d9575c6472abe298bdfb509dc1ece46758c3b1c Mon Sep 17 00:00:00 2001
From: ntur101
Date: Mon, 20 Oct 2025 10:48:48 +1300
Subject: [PATCH 2/3] fixed the test cases that were failing due to the
refactor
---
frontend/src/services/firestoreNormalizer.js | 5 ++++
.../src/services/firestoreNormalizer.test.js | 26 +++++++++----------
2 files changed, 18 insertions(+), 13 deletions(-)
diff --git a/frontend/src/services/firestoreNormalizer.js b/frontend/src/services/firestoreNormalizer.js
index 6dd700c..60fa68b 100644
--- a/frontend/src/services/firestoreNormalizer.js
+++ b/frontend/src/services/firestoreNormalizer.js
@@ -125,6 +125,11 @@ export const normalizeItem = (data, id) => {
user: data.user || null,
userId: data.userId || data.user?.id || null,
+ // Contact information for reporter
+ contactName: data.contactName || null,
+ contactEmail: data.contactEmail || null,
+ contactPhone: data.contactPhone || null,
+
// Legacy status field (kept for backward compatibility)
status: data.status || data.kind || 'lost',
diff --git a/frontend/src/services/firestoreNormalizer.test.js b/frontend/src/services/firestoreNormalizer.test.js
index c02d105..cd36f0d 100644
--- a/frontend/src/services/firestoreNormalizer.test.js
+++ b/frontend/src/services/firestoreNormalizer.test.js
@@ -27,12 +27,12 @@ describe('normalizeTimestamp', () => {
it('should handle Firestore snapshot with seconds property', () => {
const mockSnapshot = {
- seconds: 1705315800, // Jan 15, 2024 10:30:00 UTC
+ seconds: 1705315800, // Jan 15, 2024 10:50:00 UTC
nanoseconds: 0
};
const result = normalizeTimestamp(mockSnapshot);
- expect(result).toBe('2024-01-15T10:30:00.000Z');
+ expect(result).toBe('2024-01-15T10:50:00.000Z');
});
it('should handle Date objects', () => {
@@ -63,18 +63,18 @@ describe('extractDate', () => {
it('should extract date from first matching field', () => {
const doc = {
otherField: 'value',
- createdAt: { seconds: 1705315800 },
- date: { seconds: 1705315900 } // Different time
+ createdAt: { seconds: 1705314600 },
+ date: { seconds: 1705314660 } // Different time (1 minute later)
};
// Should use 'date' first (default field order)
const result = extractDate(doc);
- expect(result).toBe('2024-01-15T10:31:40.000Z');
+ expect(result).toBe('2024-01-15T10:31:00.000Z');
});
it('should fall back to other field names', () => {
const doc = {
- created_at: { seconds: 1705315800 }
+ created_at: { seconds: 1705314600 }
};
const result = extractDate(doc);
@@ -83,7 +83,7 @@ describe('extractDate', () => {
it('should support custom field names', () => {
const doc = {
- lastModified: { seconds: 1705315800 }
+ lastModified: { seconds: 1705314600 }
};
const result = extractDate(doc, ['lastModified', 'updated']);
@@ -110,9 +110,9 @@ describe('normalizeItem', () => {
status: 'lost', // Using 'status' instead of 'kind'
imageURL: 'https://example.com/image.jpg', // Using 'imageURL' instead of 'imageUrl'
location: 'OGGB',
- date: { seconds: 1705315800 },
+ date: { seconds: 1705314600 },
claimed: true,
- claimedAt: { seconds: 1705320000 }
+ claimedAt: { seconds: 1705318800 }
};
const result = normalizeItem(rawItem, 'item123');
@@ -133,7 +133,7 @@ describe('normalizeItem', () => {
category: 'keys/cards', // Alternative field name
kind: 'found', // Alternative field name
imageUrl: 'https://example.com/keys.jpg', // Alternative field name
- createdAt: { seconds: 1705315800 } // Alternative date field
+ createdAt: { seconds: 1705314600 } // Alternative date field
};
const result = normalizeItem(rawItem, 'item456');
@@ -179,7 +179,7 @@ describe('normalizeMessage', () => {
senderId: 'user456',
senderName: 'John Doe',
text: 'Hello, is this still available?',
- timestamp: { seconds: 1705315800 }
+ timestamp: { seconds: 1705314600 }
};
const result = normalizeMessage(rawMessage, 'msg789');
@@ -209,9 +209,9 @@ describe('normalizeConversation', () => {
participants: ['user1', 'user2'],
itemId: 'item123',
lastMessage: 'Thanks!',
- lastMessageTime: { seconds: 1705315800 },
+ lastMessageTime: { seconds: 1705314600 },
lastMessageSender: 'user2',
- createdAt: { seconds: 1705310000 }
+ createdAt: { seconds: 1705309600 }
};
const result = normalizeConversation(rawConversation, 'conv456');
From e9d9de1d3f89d6058ff72653eeb8121f03a7cccc Mon Sep 17 00:00:00 2001
From: ntur101
Date: Mon, 20 Oct 2025 10:55:51 +1300
Subject: [PATCH 3/3] some minor tweaks to fix small bugs
---
frontend/src/lib/utils.js | 28 ++++++++++++++++++++
frontend/src/pages/Messages.js | 9 +++++--
frontend/src/pages/ProfilePage.js | 12 +++++++--
frontend/src/services/firestoreNormalizer.js | 4 ++-
4 files changed, 48 insertions(+), 5 deletions(-)
diff --git a/frontend/src/lib/utils.js b/frontend/src/lib/utils.js
index 2cef6b2..218c7bf 100644
--- a/frontend/src/lib/utils.js
+++ b/frontend/src/lib/utils.js
@@ -15,6 +15,27 @@ export function cn(...inputs) {
const DEFAULT_IMAGE_URL = '/placeholder.svg';
const DEFAULT_REPORTER = { name: 'Unknown', trust: false };
+/**
+ * Map category names to consistent values for UI
+ * Handles variations like 'accessories' vs 'accessory', 'keys/cards' -> 'keys-cards'
+ * @param {string} category - Raw category string
+ * @returns {string} Normalized category name
+ */
+const normalizeCategory = (category) => {
+ const categoryMap = {
+ 'accessories': 'accessory',
+ 'keys/cards': 'keys-cards',
+ 'wallets': 'wallet',
+ 'documents': 'document',
+ 'stationery': 'stationery',
+ 'electronics': 'electronics',
+ 'clothing': 'clothing',
+ 'other': 'other'
+ };
+
+ return categoryMap[category] || category;
+};
+
/**
* Clean up Firestore data to match our UI expectations
* REFACTORED: Now uses centralized normalizeItem from firestoreNormalizer service
@@ -36,11 +57,18 @@ export const normalizeFirestoreItem = (data, id) => {
// Add UI-specific fields that aren't in the base normalizer
const reporter = normalizeReporter(data.reporter, data.postedBy);
const coordinates = data.coordinates || null;
+
+ // Apply category normalization for UI (keys/cards -> keys-cards, etc.)
+ const category = normalizeCategory(normalized.category);
return {
...normalized,
// Override imageUrl default if it's empty
imageUrl: normalized.imageUrl || DEFAULT_IMAGE_URL,
+ // Override location default for UI consistency
+ location: normalized.location || 'Unknown',
+ // Apply UI-specific category normalization
+ category,
// Add UI-specific fields
reporter,
coordinates,
diff --git a/frontend/src/pages/Messages.js b/frontend/src/pages/Messages.js
index 4aa424a..36188ce 100644
--- a/frontend/src/pages/Messages.js
+++ b/frontend/src/pages/Messages.js
@@ -112,9 +112,14 @@ const MessagesPage = () => {
const enriched = await enrichConversations(snapshot.docs, user.uid);
// Sort conversations by lastMessageTime using normalized timestamps
+ // Fall back to createdAt if lastMessageTime is missing
enriched.sort((a, b) => {
- const aTime = a.lastMessageTime ? new Date(a.lastMessageTime).getTime() : 0;
- const bTime = b.lastMessageTime ? new Date(b.lastMessageTime).getTime() : 0;
+ const aTime = a.lastMessageTime
+ ? new Date(a.lastMessageTime).getTime()
+ : (a.createdAt ? new Date(a.createdAt).getTime() : 0);
+ const bTime = b.lastMessageTime
+ ? new Date(b.lastMessageTime).getTime()
+ : (b.createdAt ? new Date(b.createdAt).getTime() : 0);
return bTime - aTime;
});
diff --git a/frontend/src/pages/ProfilePage.js b/frontend/src/pages/ProfilePage.js
index 39d1ea1..32ac457 100644
--- a/frontend/src/pages/ProfilePage.js
+++ b/frontend/src/pages/ProfilePage.js
@@ -8,12 +8,20 @@ import PropTypes from 'prop-types'
import { getUserPosts, formatTimestamp, updateItemStatus, updateItem } from "../firebase/firestore"
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card"
import { ProfileBadge } from '../components/ui/ProfileBadge'
-import { extractDate } from "../services/firestoreNormalizer"
+import { extractDate, normalizeTimestamp } from "../services/firestoreNormalizer"
/** ---------- Small utilities (deduplicated helpers) ---------- **/
// REFACTORED: Replaced coalesceDate with extractDate from firestoreNormalizer
// This provides consistent date handling across the entire app
-const coalesceDate = (item) => extractDate(item) || item.date || new Date().toISOString()
+// Falls back to normalizeTimestamp to ensure we always return an ISO string
+const coalesceDate = (item) => {
+ const extracted = extractDate(item);
+ if (extracted) return extracted;
+
+ // Fallback to normalizeTimestamp to handle Firestore Timestamps
+ const normalized = normalizeTimestamp(item.date);
+ return normalized || new Date().toISOString();
+}
const StatusPill = memo(({ color, text }) => (
diff --git a/frontend/src/services/firestoreNormalizer.js b/frontend/src/services/firestoreNormalizer.js
index 60fa68b..170a5e1 100644
--- a/frontend/src/services/firestoreNormalizer.js
+++ b/frontend/src/services/firestoreNormalizer.js
@@ -42,7 +42,9 @@ export const normalizeTimestamp = (value) => {
// Firestore snapshot with .seconds property
if (value.seconds !== undefined) {
try {
- return new Date(value.seconds * 1000).toISOString();
+ // Include nanoseconds for precision (avoids loss when ordering messages)
+ const milliseconds = value.seconds * 1000 + Math.floor((value.nanoseconds || 0) / 1e6);
+ return new Date(milliseconds).toISOString();
} catch (e) {
console.warn('Failed to convert Firestore seconds:', e);
return null;