Skip to main content
Version: 1.0.0

Subscriptions

Author: Carmine Antonio Bonavoglia Creation Date: 29/10/2025
Last Reviewer: Carmine Antonio Bonavoglia Last Updated: 31/10/2025

Overview

DataConsumption.js is the primary page for managing user subscription plans and monitoring data consumption (GB used, SKU counts). It displays a hierarchical subscription table with main plans and add-ons, implements sophisticated caching to minimize API calls, and provides role-based access control (RBAC). The page allows users to toggle between active and expired subscriptions, load additional rows incrementally, view detailed consumption charts, and request new subscriptions.

File location: /src/pages/DataConsumption.js

Key responsibilities:

  • Display active/expired subscriptions in a hierarchical table (main subscriptions + add-ons)
  • Track consumption metrics (GB used, SKU count, consumption limits)
  • Cache subscriptions per type (1-minute TTL) to optimize repeated toggles
  • Filter subscriptions by user role and catalogue access
  • Provide pagination via "Load More" button (30 items per load)
  • Open detail modals with consumption charts and timeline
  • Support subscription upgrade/request workflow via email

Architecture & Component Hierarchy

DataConsumption (page)

├─ HeaderComponent
│ ├─ Title "Subscriptions plans"
│ ├─ Centered ToggleButtons (Active/Expired)
│ └─ PrimaryButton "Add Subscription" (visible for non-restricted roles)

├─ ToggleButtons Component
│ ├─ Value: 'active' | 'expired'
│ └─ onChange → handleSubscriptionTypeChange
│ ├─ Check cache for new type
│ ├─ Use cached data if available (no API call)
│ └─ Fetch fresh data if not in cache

├─ Main Content
│ ├─ [Loading State] Spin component
│ ├─ [Error State] Card with error message
│ ├─ [Empty State] Card with no data message
│ └─ [Data State] InfiniteScroll Table + Load More Button
│ └─ Table Columns:
│ ├─ Type (MAIN | ADD-ON, hierarchical indent)
│ ├─ Plan Name (bold for main, regular for add-ons)
│ ├─ Plan Type (e.g., "ARShades Branded License")
│ ├─ Consumption (gbUsed/limit, with LIMIT EXCEEDED badge)
│ ├─ Start Date (formatted DD/MM/YYYY)
│ ├─ End Date (formatted DD/MM/YYYY)
│ ├─ Days Left / Days Since Expiry (with progress bar)
│ └─ Actions (Details button → ConsumptionDetailsModal)

├─ Modals
│ ├─ ConsumptionDetailsModal
│ │ ├─ Displays full subscription details
│ │ ├─ Renders consumption charts (monthly bar, pie consumed/remaining)
│ │ ├─ Shows timeline of renewals (if available)
│ │ └─ Links to TimelineRenewalsModal
│ │
│ └─ Subscription Request Modal
│ ├─ Title: "Request New Subscription"
│ ├─ Text area for requirements
│ └─ Send button → sendSubscriptionRequestEmail()

└─ useSubscriptions Hook
├─ Manages subscriptions state + cache
├─ Handles role-based filtering
├─ Groups subscriptions (main/add-ons hierarchy)
└─ Provides fetchSubscriptions & getSubscriptionsFromCache

State Management

Component-level State

// Modal management
const [modalVisible, setModalVisible] = useState(false);
const [selectedSubscription, setSelectedSubscription] = useState(null);

// Toggle & filtering
const [subscriptionType, setSubscriptionType] = useState("active"); // 'active' | 'expired'

// Pagination (Load More)
const [visibleItems, setVisibleItems] = useState(30); // Show first 30 items
const ITEMS_PER_LOAD = 30; // Load 30 at a time

// Error handling
const [error, setError] = useState(null);

// Upgrade modal
const [upgradeModal, setUpgradeModal] = useState({
visible: false,
subscription: null,
});
const [upgradeMessage, setUpgradeMessage] = useState("");
const [sendingUpgradeRequest, setSendingUpgradeRequest] = useState(false);

// From useSubscriptions hook
const {
subscriptions, // Array of filtered/grouped subscriptions
loading, // Global loading state
userRole, // 'Admin', 'Cliente', 'Modellista', 'ModellerSupervisor'
subscriptionsCache, // { active, expired, lastFetched, userProfile }
fetchSubscriptions, // (type, forceRefresh?) → Promise
getSubscriptionsFromCache, // (type) → void (local state only)
} = useSubscriptions();

Hook-level State (useSubscriptions)

const [subscriptions, setSubscriptions] = useState([]); // Current list
const [loading, setLoading] = useState(true); // Loading indicator
const [userRole, setUserRole] = useState(null); // User role for RBAC
const [subscriptionsCache, setSubscriptionsCache] = useState({
active: [], // Cached active subscriptions (grouped)
expired: [], // Cached expired subscriptions (grouped)
lastFetched: null, // Timestamp of last fetch
userProfile: null, // Cached user profile { role, clientRef, list_catalogues }
});

Caching Strategy (Advanced)

The useSubscriptions hook implements a multi-level caching strategy to minimize API calls:

Cache Structure

subscriptionsCache = {
active: Array<Subscription>, // Grouped active subscriptions (main + add-ons)
expired: Array<Subscription>, // Grouped expired subscriptions
lastFetched: number, // Unix timestamp of last API fetch
userProfile: { // Cached user context
clientRef: string,
list_catalogues: Array<string>,
role: 'Admin' | 'Cliente' | 'Modellista' | ...
}
}

Cache Validation (1-minute TTL)

const cacheExpiry = 60000; // 1 minute
const isCacheValid =
subscriptionsCache.lastFetched &&
currentTime - subscriptionsCache.lastFetched < cacheExpiry &&
!forceRefresh;

if (isCacheValid && subscriptionsCache[subscriptionType]) {
// Use cached data - NO API CALL
setSubscriptions(subscriptionsCache[subscriptionType]);
setUserRole(subscriptionsCache.userProfile?.role || null);
return;
}

Two-pass API Fetch (First time only)

// 1. Fetch user profile ONCE (if not cached)
const userProfile = await getUserProfile();

// 2. Fetch BOTH active AND expired simultaneously
const [activeSubscriptions, expiredSubscriptions] = await Promise.all([
getSubscriptions(clientRef, role, ["Active"]),
getSubscriptions(clientRef, role, ["Expired", "Renewed"]),
]);

// 3. Both results cached for next toggle
setSubscriptionsCache({
active: processedActive,
expired: processedExpired,
lastFetched: currentTime,
userProfile: userProfile,
});

Toggle Behavior (Cache-aware)

const handleSubscriptionTypeChange = (newType) => {
setSubscriptionType(newType);
setVisibleItems(30); // Reset pagination

// Check cache FIRST
if (subscriptionsCache[newType]) {
// Use cached data - instant update
getSubscriptionsFromCache(newType);
} else {
// Cache miss - fetch fresh data
fetchSubscriptions(newType);
}
};

Benefit: User toggles between Active/Expired instantly (from cache) without network requests.


Subscription Grouping & Hierarchy

Grouping Algorithm (from useSubscriptions)

The groupSubscriptions() function organizes subscriptions into a hierarchy:

1. Main Subscriptions (level: 1, isMainSubscription: true)
├─ Add-on 1 (level: 2, mainSubscriptionRef: main.id)
├─ Add-on 2 (level: 2, mainSubscriptionRef: main.id)
└─ Add-on 3 (level: 2, mainSubscriptionRef: main.id)

2. Additional Main Subscriptions (level: 1)
├─ Add-on A (level: 2, mainSubscriptionRef: ...)
└─ Add-on B (level: 2, mainSubscriptionRef: ...)

3. Orphaned Add-ons (level: 1, isOrphaned: true)
└─ Add-ons without mainSubscriptionRef or with missing reference

Grouping Steps

const groupSubscriptions = (subscriptions, type) => {
const targetStatus =
type === "active" ? ["active"] : ["expired", "renewed"];

// 1. Filter main subscriptions
const mainSubscriptions = subscriptions.filter(
(sub) =>
sub.isMainSubscription === true &&
targetStatus.includes((sub.status || "").toLowerCase())
);

// 2. Filter add-ons linked to existing main subscriptions
const addOns = subscriptions.filter(
(sub) =>
sub.isMainSubscription === false &&
targetStatus.includes((sub.status || "").toLowerCase()) &&
mainSubscriptions.some(
(main) => main.id === sub.mainSubscriptionRef?.trim()
)
);

// 3. Find orphaned add-ons (no main subscription found)
const orphanedAddOns = subscriptions.filter(
(sub) =>
sub.isMainSubscription === false &&
targetStatus.includes((sub.status || "").toLowerCase()) &&
(!sub.mainSubscriptionRef?.trim() ||
!mainSubscriptions.some(
(main) => main.id === sub.mainSubscriptionRef?.trim()
))
);

// 4. Build hierarchical output with level markers
const groupedData = [];
let keyCounter = 0;

// Add main subscriptions + related add-ons
mainSubscriptions.forEach((mainSub) => {
groupedData.push({
...mainSub,
level: 1,
key: `main-${mainSub.id}-${keyCounter++}`,
});

const relatedAddOns = addOns.filter(
(addOn) => addOn.mainSubscriptionRef.trim() === mainSub.id
);

relatedAddOns.forEach((addOn) => {
groupedData.push({
...addOn,
level: 2,
key: `addon-${addOn.id}-${keyCounter++}`,
});
});
});

// Add orphaned add-ons as level 1
orphanedAddOns.forEach((orphanedSub) => {
groupedData.push({
...orphanedSub,
level: 1,
isOrphaned: true,
key: `orphaned-${orphanedSub.id}-${keyCounter++}`,
});
});

return groupedData;
};

Role-based Filtering (RBAC)

User Profile Fetch

const userProfile = await getUserProfile();
const { clientRef, list_catalogues: userCatalogues = [], role } = userProfile;

Filtering Logic

const filterSubscriptionsByCatalogues = (
subscriptions,
userCatalogues,
userRole
) => {
// Admin sees ALL subscriptions
if (userRole === "Admin") {
return subscriptions;
}

// Non-admin: filter by catalogue access
if (!userCatalogues || userCatalogues.length === 0) {
return []; // No catalogues = no subscriptions visible
}

// Return only subscriptions with at least one catalogue match
return subscriptions.filter((sub) => {
const subCatalogues = sub.list_catalogues?.list_catalogues_refs || [];
return subCatalogues.some((catalogId) =>
userCatalogues.includes(catalogId)
);
});
};

Role Permissions

RoleCan View All SubscriptionsCan Add SubscriptionButton Visibility
Super Admin✅ (all)"Add Subscription" visible
Admin✅ (client scope)"Add Subscription" visible
Member❌ (filtered by catalogue access)"Add Subscription" visible
Modellista❌ (filtered by catalogue access)"Add Subscription" visible
ModellerSupervisor❌ (filtered by catalogue access)"Add Subscription" hidden

Data Loading & Processing

fetchSubscriptions(type, forceRefresh?) from useSubscriptions

const fetchSubscriptions = useCallback(
async (subscriptionType, forceRefresh = false) => {
try {
// 1. Check cache validity
const currentTime = Date.now();
const isCacheValid =
subscriptionsCache.lastFetched &&
currentTime - subscriptionsCache.lastFetched < 60000 && // 1 min TTL
!forceRefresh;

if (isCacheValid && subscriptionsCache[subscriptionType]) {
// Fast path: use cache
setSubscriptions(subscriptionsCache[subscriptionType]);
setUserRole(subscriptionsCache.userProfile?.role || null);
setLoading(false);
return;
}

setLoading(true);

// 2. Fetch user profile (with cache)
let userProfile = subscriptionsCache.userProfile;
if (!userProfile || forceRefresh) {
userProfile = await getUserProfile();
}

// 3. Parallel fetch: active AND expired
const [activeSubscriptions, expiredSubscriptions] =
await Promise.all([
getSubscriptions(userProfile.clientRef, userProfile.role, [
"Active",
]),
getSubscriptions(userProfile.clientRef, userProfile.role, [
"Expired",
"Renewed",
]),
]);

// 4. Apply filtering (catalogues + role)
const activeFiltered = filterSubscriptionsByCatalogues(
activeSubscriptions,
userProfile.list_catalogues,
userProfile.role
);

const expiredFiltered = filterSubscriptionsByCatalogues(
expiredSubscriptions,
userProfile.list_catalogues,
userProfile.role
);

// 5. Fetch catalogue names for display
const allCatalogueIds = new Set([
...userProfile.list_catalogues,
...activeFiltered.flatMap(
(s) => s.list_catalogues?.list_catalogues_refs || []
),
...expiredFiltered.flatMap(
(s) => s.list_catalogues?.list_catalogues_refs || []
),
]);
const catalogueNames = await getCatalogueNames([
...allCatalogueIds,
]);

// 6. Enrich subscriptions with consumption metrics
const activeProcessed = await Promise.all(
activeFiltered.map(async (sub) => {
const catalogueRefs =
sub.list_catalogues?.list_catalogues_refs || [];
const skuUsed = await calculateSkuUsed(catalogueRefs);
const gbUsed = await calculateGbUsed(sub);

return {
...sub,
skuUsed,
gbUsed,
catalogueNames: catalogueRefs
.map((id) => catalogueNames[id] || id)
.join(", "),
};
})
);

const expiredProcessed = await Promise.all(
expiredFiltered.map(async (sub) => {
// Same enrichment as active...
})
);

// 7. Group into hierarchy
const activeGrouped = groupSubscriptions(activeProcessed, "active");
const expiredGrouped = groupSubscriptions(
expiredProcessed,
"expired"
);

// 8. Cache results
setSubscriptionsCache({
active: activeGrouped,
expired: expiredGrouped,
lastFetched: currentTime,
userProfile: userProfile,
});

// 9. Set current list based on requested type
const currentList =
subscriptionType === "active" ? activeGrouped : expiredGrouped;
setSubscriptions(currentList);
setUserRole(userProfile.role);
} catch (error) {
console.error("❌ Error loading subscriptions:", error);
messageApi.error(
"An error occurred whilst retrieving data. Please try again later."
);
} finally {
setLoading(false);
}
},
[messageApi, subscriptionsCache]
);

Table Columns & Renderers

Column: Type (Hierarchical Display)

{
title: 'Type',
dataIndex: 'level',
key: 'level',
width: 90,
render: (level, record) => (
<div style={{
display: 'flex',
alignItems: 'center',
paddingLeft: level === 2 ? '1.25rem' : '0rem'
}}>
{level === 2 && <span style={{ marginRight: '0.5rem' }}>└─</span>}
<Tag color={level === 1 ? (record.isMainSubscription ? 'green' : 'blue') : 'blue'}>
{level === 1
? (record.isMainSubscription ? 'MAIN' : 'ADD-ON')
: 'ADD-ON'
}
</Tag>
</div>
),
}

Renders:

  • Level 1, isMainSubscription: MAIN tag (green)
  • Level 1, ADD-ON: ADD-ON tag (blue)
  • Level 2: ADD-ON tag (blue) with tree indent

Column: Consumption (with Limit Exceeded Badge)

{
title: 'Consumption',
key: 'consumption',
width: 120,
render: (_, record) => {
const gbUsed = record.gbUsed || 0;
const consumptionLimit = record.list_catalogues?.consumptionLimit || 0;

// Check if exceeded
const gbExceeded = consumptionLimit > 0 && gbUsed > consumptionLimit;

return (
<div style={{
fontSize: '0.875rem',
color: gbExceeded ? '#ff4d4f' : '#333',
textAlign: 'center'
}}>
<div style={{ fontWeight: 'bold' }}>
{gbUsed.toFixed(1)}/{consumptionLimit === 0 ? '∞' : consumptionLimit} GB
</div>
{gbExceeded && (
<div style={{
marginTop: '0.25rem',
padding: '0.125rem 0.25rem',
backgroundColor: '#fff2f0',
border: '1px solid #ffccc7',
borderRadius: '0.25rem',
fontSize: '0.625rem',
fontWeight: 'bold',
color: '#ff4d4f'
}}>
LIMIT EXCEEDED
</div>
)}
</div>
);
},
}

Renders:

  • gbUsed/limit in bold (or gbUsed/∞ if no limit)
  • Red text if exceeded
  • Red badge "LIMIT EXCEEDED" if exceeded

Column: Days Left (with Progress Bar)

{
title: subscriptionType === 'active' ? 'Days Left' : 'Days Since Expiry',
key: 'daysLeft',
width: 120,
render: renderDaysLeft,
}

Rendering logic:

  • Green: greater than 50% time remaining
  • Orange: 25-50% time remaining
  • Red: less than 25% time remaining
  • Grey: Expired
  • For expired subscriptions: prefix with a plus sign (e.g., "plus 42 days since expiry")

Implementation notes: The renderDaysLeft helper function:

  1. Calculates days remaining/expired using calculateDaysLeft()
  2. Renders a Progress component for visual feedback
  3. Displays the numeric value with appropriate color coding
  4. Prepends '+' to the number for expired subscriptions

Pagination (Load More)

Implementation

const [visibleItems, setVisibleItems] = useState(30);
const ITEMS_PER_LOAD = 30;

// Get visible subscriptions
const visibleSubscriptions = subscriptions.slice(0, visibleItems);
const hasMoreItems = subscriptions.length > visibleItems;

// Handle Load More
const handleLoadMore = () => {
setVisibleItems((prev) => prev + ITEMS_PER_LOAD);
};

// Render Load More button
{
hasMoreItems && (
<div
style={{
textAlign: "center",
marginTop: "1.5rem",
padding: "1rem",
}}
>
<OutlineButton
onClick={handleLoadMore}
style={{
minWidth: "8rem",
height: "2.5rem",
}}
>
Load More ({subscriptions.length - visibleItems} remaining)
</OutlineButton>
</div>
);
}

Behavior:

  • Show 30 items initially
  • "Load More" button shows remaining count: ({total - visible} remaining)
  • Each click adds 30 more items
  • Button disappears when visibleItems >= subscriptions.length

1. ConsumptionDetailsModal

Trigger: "Details" button on any subscription row

Content:

  • Subscription metadata (name, type, status, dates)
  • Consumption charts (monthly bar + pie chart consumed vs remaining)
  • Timeline of renewals (if available)
  • Button to open TimelineRenewalsModal

Implementation:

const handleOpenModal = (subscription) => {
setSelectedSubscription(subscription);
setModalVisible(true);
};

const handleCloseModal = () => {
setModalVisible(false);
setSelectedSubscription(null);
};

// Render
<ConsumptionDetailsModal
visible={modalVisible}
onClose={handleCloseModal}
subscription={selectedSubscription}
/>;

2. Subscription Request Modal

Trigger: "Add Subscription" button (visible for non-restricted roles)

Workflow:

  1. User clicks "Add Subscription"
  2. Modal opens with:
    • Informative banner (subscription management coming soon)
    • Text area for requirements (max 1000 chars)
    • "Cancel" and "Send Request" buttons
  3. User enters requirements and clicks "Send Request"
  4. sendSubscriptionRequestEmail() called
  5. Email sent to Spaarkly support with user profile + message
  6. Success message shown, modal closes

Implementation:

const [upgradeModal, setUpgradeModal] = useState({ visible: false });
const [upgradeMessage, setUpgradeMessage] = useState("");
const [sendingUpgradeRequest, setSendingUpgradeRequest] = useState(false);

// Handle send
const handleSendRequest = async () => {
if (!upgradeMessage.trim()) {
message.error("Please enter a message...");
return;
}

setSendingUpgradeRequest(true);

try {
const userProfile = await getUserProfileForEmail();
await sendSubscriptionRequestEmail(userProfile, upgradeMessage);

message.success("Subscription request sent successfully!");
setUpgradeModal({ visible: false });
setUpgradeMessage("");
} catch (error) {
message.error("Failed to send subscription request.");
} finally {
setSendingUpgradeRequest(false);
}
};

// Render modal
<Modal
title="Request New Subscription"
open={upgradeModal.visible}
onCancel={() => setUpgradeModal({ visible: false })}
footer={[
<Button
key="cancel"
onClick={() => setUpgradeModal({ visible: false })}
>
Cancel
</Button>,
<Button
key="send"
type="primary"
loading={sendingUpgradeRequest}
onClick={handleSendRequest}
>
Send Request
</Button>,
]}
>
<Input.TextArea
rows={4}
value={upgradeMessage}
onChange={(e) => setUpgradeMessage(e.target.value)}
placeholder="Describe your subscription requirements..."
maxLength={1000}
showCount
/>
</Modal>;

Helper Functions

calculateDaysLeft(subscription)

const calculateDaysLeft = (subscription) => {
if (!subscription.endDate || !subscription.startDate) {
return { daysLeft: "ND", percentage: 0, color: "#d9d9d9" };
}

const today = dayjs();

// Parse start/end dates (handle multiple formats)
let startDate, endDate;

if (typeof subscription.startDate === "string") {
startDate = dayjs(subscription.startDate);
} else if (subscription.startDate?.seconds) {
startDate = dayjs.unix(subscription.startDate.seconds);
} else {
startDate = dayjs(subscription.startDate);
}

if (typeof subscription.endDate === "string") {
endDate = dayjs(subscription.endDate);
} else if (subscription.endDate?.seconds) {
endDate = dayjs.unix(subscription.endDate.seconds);
} else {
endDate = dayjs(subscription.endDate);
}

// Calculate durations
const totalDays = endDate.diff(startDate, "day");
const daysLeft = endDate.diff(today, "day");
const daysPassed = today.diff(startDate, "day");

// Progress percentage (0-100)
const percentage =
totalDays > 0 ? Math.min((daysPassed / totalDays) * 100, 100) : 0;

// Color based on remaining percentage
const daysLeftPercentage = totalDays > 0 ? (daysLeft / totalDays) * 100 : 0;

let color;
if (daysLeft < 0) {
color = "#d9d9d9"; // Grey (Expired)
} else if (daysLeftPercentage > 50) {
color = "#52c41a"; // Green
} else if (daysLeftPercentage > 25) {
color = "#faad14"; // Orange
} else {
color = "#ff4d4f"; // Red
}

return {
daysLeft: Math.max(daysLeft, 0),
percentage: Math.max(percentage, 0),
color,
isExpired: daysLeft < 0,
};
};

formatDate(date)

const formatDate = (date) => {
if (!date) return "N/A";

if (date.seconds) {
return dayjs.unix(date.seconds).format("DD/MM/YYYY");
}

return dayjs(date).format("DD/MM/YYYY");
};

canAddSubscription(role)

const canAddSubscription = (role) => {
if (!role) return false;
// Only restricted roles (Modellista, ModellerSupervisor) cannot add
return !["Modellista", "ModellerSupervisor"].includes(role);
};

API Integration

Services Used

  • getUserProfile() — Fetch current user profile (cached in hook)

    • Returns: { clientRef, list_catalogues, role }
  • getSubscriptions(clientRef, role, statusFilter) — Fetch subscriptions

    • statusFilter: ["Active"] or ["Expired", "Renewed"]
    • Returns: Array<Subscription>
  • getCatalogueNames(catalogueIds) — Fetch catalogue display names

    • Returns: { [id]: name }
  • calculateSkuUsed(catalogueRefs) — Calculate total SKUs

    • Returns: number
  • calculateGbUsed(subscription) — Calculate total GB

    • Returns: number
  • getCatalogueData(subscriptionId, catalogueRefs) — Fetch detailed consumption

    • Returns: { monthly: Array, total: number }
  • getCounterData(subscriptionId, catalogueRefs) — Fetch counter/timeline data

    • Returns: Array<{ date, value }>
  • getMainDomain() — Fetch main domain for email

    • Returns: string
  • checkHistoryData(subscriptionId) — Check if timeline data exists

    • Returns: boolean
  • getUserProfileForEmail() — Fetch profile by current email

    • Returns: { email, firstName, lastName, role }
  • sendSubscriptionRequestEmail(userProfile, message) — Send upgrade request

Firestore Collections

  • Subscriptions — Main subscriptions collection

    • Fields: id, name, type, status, startDate, endDate, isMainSubscription, mainSubscriptionRef, list_catalogues, gbUsed, skuUsed
  • Catalogues — Catalogue metadata

    • Fields: id, name, consumptionLimit

Error Handling & Edge Cases

Error Scenarios

  1. API failure during fetch:

    • Caught in try/catch in fetchSubscriptions()
    • Message shown: "An error occurred whilst retrieving data. Please try again later."
    • Loading state reset to false
  2. No user profile:

    • getUserProfile() returns null
    • Component should show no subscriptions
  3. No catalogues for user:

    • Filtering returns empty array
    • Empty state displayed: "No active subscriptions found for this user."
  4. Consumption calculation errors:

    • calculateGbUsed() fails → defaults to 0
    • calculateSkuUsed() fails → defaults to 0
  5. Date parsing errors:

    • calculateDaysLeft() returns { daysLeft: 'ND', percentage: 0, color: '#d9d9d9' }
  6. Email send failure:

    • Caught in handleSendRequest() catch block
    • Error message shown: "Failed to send subscription request."
    • Sending button reset to non-loading state

Edge Cases

  • Zero consumption limit: Display as (unlimited)
  • Negative GB used: Cap to 0
  • Subscription with no end date: Show "N/A" for days left
  • Orphaned add-ons (no main subscription): Display at level 1 with isOrphaned: true flag
  • Toggle while loading: Prevents duplicate API calls (checked via loading state)
  • Cache expiry during user interaction: Automatic refetch on next toggle after 1 minute
  • Multiple modals open: States isolated per modal, no interference

Performance Considerations

Caching Benefits

  • First load: ~2 API calls (user profile, subscriptions × 2 status filters)
  • Toggle: 0 API calls (data from cache for 1 minute)
  • After 1 minute: Refreshes with 2 API calls if needed
  • Parallel fetches: Active + Expired fetched simultaneously (Promise.all)

Data Processing

  • Consumption calculation: Per-subscription (CPU-bound, not I/O-bound)
  • Grouping: O(n) single pass through subscriptions
  • Filtering: O(n) linear scan per filter type

UI Rendering

  • Pagination: Initial 30 rows, load 30 at a time
  • Large lists: Consider virtual scrolling (react-window) for 1000+ rows
  • Modal charts: Rendered on demand (not precomputed)

Optimization Opportunities

  • Memoize subscription filters and groupings
  • Use React.memo() for table row components
  • Lazy-load ConsumptionDetailsModal content (fetch on modal open)
  • Implement react-window for virtual scrolling of large tables
  • Use SWR/React Query for automatic cache management
  • Debounce calendar timeline queries

State Flow Diagram

┌─────────────────────────────────────────────────────────────┐
│ User views DataConsumption page │
└──────────────────┬──────────────────────────────────────────┘


┌──────────────────────┐
│ Component mount │
└──────────┬───────────┘


┌──────────────────────────────────────┐
│ fetchSubscriptions('active') │
│ (useEffect on mount) │
└──────────┬───────────────────────────┘


┌──────────────────────────────────────┐
│ Fetch user profile │
│ Fetch [Active, Expired] subscriptions│
│ Filter by role & catalogues │
│ Enrich with GB/SKU metrics │
│ Group into hierarchy │
└──────────┬───────────────────────────┘


┌──────────────────────────────────────┐
│ Cache results (active + expired) │
│ Set subscriptions state = active │
│ Set loading = false │
└──────────┬───────────────────────────┘


┌──────────────────────────────────────┐
│ Table renders with 30 visible items │
└──────────┬───────────────────────────┘

┌──────────┴──────────┬────────────────┐
│ │ │
↓ ↓ ↓
┌─────────┐ ┌──────────────┐ ┌─────────────┐
│ Click │ │ Click Load │ │ Toggle to │
│ Details │ │ More │ │ Expired │
└────┬────┘ └──────┬───────┘ └────┬────────┘
│ │ │
↓ ↓ ↓
┌──────────────────┐ ┌──────────┐ ┌────────────────┐
│ Open modal with │ │ Add 30 │ │ Check cache │
│ consumption │ │ more │ │ (valid?) │
│ charts/timeline │ │ items to │ └────┬───────────┘
└──────────────────┘ │ visible │ │
└──────────┘ ┌────┴─────────────┐
│ YES │ NO
↓ ↓
┌──────────┐ ┌──────────────┐
│ Use │ │ Fetch fresh │
│ cached │ │ data │
│ data │ └──────┬───────┘
│ (instant)│ │
└──────────┘ ↓
│ ┌──────────────┐
│ │ Update both │
│ │ cache & list │
│ └──────┬───────┘
│ │
└───────┬───────────┘


┌──────────────────┐
│ Show expired │
│ subscriptions │
│ (or active) │
└──────────────────┘

Testing Strategy

Unit Tests

// calculateDaysLeft
test("calculateDaysLeft returns correct values for active subscription", () => {
const today = dayjs();
const subscription = {
startDate: today.subtract(10, "days").toDate(),
endDate: today.add(20, "days").toDate(),
};

const result = calculateDaysLeft(subscription);

expect(result.daysLeft).toBe(20);
expect(result.percentage).toBeCloseTo(33, 0); // 10 days passed / 30 total
expect(result.color).toBe("#52c41a"); // Green (>50% remaining)
});

// formatDate
test("formatDate formats Firestore timestamp", () => {
const timestamp = { seconds: 1704067200 }; // 2024-01-01
expect(formatDate(timestamp)).toBe("01/01/2024");
});

// canAddSubscription
test("canAddSubscription returns false for ModellerSupervisor", () => {
expect(canAddSubscription("ModellerSupervisor")).toBe(false);
});

test("canAddSubscription returns true for Admin", () => {
expect(canAddSubscription("Admin")).toBe(true);
});

// filterSubscriptionsByCatalogues (RBAC)
test("Admin sees all subscriptions", () => {
const subscriptions = [
{ id: 1, list_catalogues: { list_catalogues_refs: ["cat1"] } },
{ id: 2, list_catalogues: { list_catalogues_refs: ["cat2"] } },
];

const result = filterSubscriptionsByCatalogues(
subscriptions,
["cat1"],
"Admin"
);
expect(result).toHaveLength(2);
});

test("Client only sees subscriptions with matching catalogues", () => {
const subscriptions = [
{ id: 1, list_catalogues: { list_catalogues_refs: ["cat1"] } },
{ id: 2, list_catalogues: { list_catalogues_refs: ["cat2"] } },
];

const result = filterSubscriptionsByCatalogues(
subscriptions,
["cat1"],
"Client"
);
expect(result).toHaveLength(1);
expect(result[0].id).toBe(1);
});

// groupSubscriptions (hierarchy)
test("groupSubscriptions creates correct hierarchy", () => {
const subscriptions = [
{ id: "main1", isMainSubscription: true, status: "Active" },
{
id: "addon1",
isMainSubscription: false,
mainSubscriptionRef: "main1",
status: "Active",
},
{
id: "addon2",
isMainSubscription: false,
mainSubscriptionRef: "main1",
status: "Active",
},
];

const result = groupSubscriptions(subscriptions, "active");

expect(result).toHaveLength(3);
expect(result[0].level).toBe(1); // main
expect(result[1].level).toBe(2); // addon1
expect(result[2].level).toBe(2); // addon2
});

Integration Tests

// Full cache workflow
test("Cache is used on toggle between Active/Expired", async () => {
// 1. Load page (fetch Active + Expired)
// 2. Toggle to Expired → uses cache (no API call)
// 3. Toggle back to Active → uses cache (no API call)
// 4. Wait >1 minute → cache expires
// 5. Toggle again → fresh fetch
});

// Pagination workflow
test("Load More appends items correctly", async () => {
// 1. Page loads, 30 items visible
// 2. Click Load More
// 3. Verify 60 items visible
// 4. Click Load More
// 5. Verify 90 items visible
// 6. Click Load More (if <totalItems)
// 7. Button disappears at end
});

// Subscription request workflow
test("User sends upgrade request successfully", async () => {
// 1. Click "Add Subscription"
// 2. Enter message
// 3. Click "Send Request"
// 4. Success message shown
// 5. Modal closes
// 6. Email sent to support@spaarkly.com
});

E2E Tests

// Full user journey
test("Admin views all subscriptions, Client views filtered", async () => {
// 1. Admin logs in → sees all 100 subscriptions
// 2. Admin clicks Details → modal opens with charts
// 3. Admin closes modal → back to list
// 4. Admin toggles to Expired → cache instant
// 5. Admin loads more → 60 items visible
// 6. Admin toggles to Active → cache instant
// 7. Admin logs out
// 8. Client logs in (same account with limited catalogues)
// 9. Client sees only 5 subscriptions (filtered by catalogue)
// 10. Client cannot see all subscriptions
});

Key Constants & Configuration

const PAGE_SIZE = 30; // Items per Load More
const ITEMS_PER_LOAD = 30;
const CACHE_TTL = 60000; // 1 minute cache validity
const ERROR_DEBOUNCE_TIME = 2000; // ms between error messages

// Consumption color thresholds
const CONSUMPTION_COLOR_THRESHOLDS = {
GREEN: 50, // >50% days remaining
ORANGE: 25, // 25-50% days remaining
RED: 0, // <25% days remaining
};

// Visible columns logic
const columnsForType = {
active: [
"type",
"name",
"planType",
"consumption",
"startDate",
"endDate",
"daysLeft",
"actions",
],
expired: [
"type",
"name",
"planType",
"consumption",
"startDate",
"endDate",
"actions",
], // No daysLeft column
};

  • /src/pages/DataConsumption.js — Main component (this file)
  • /src/services/hooks/useSubscriptions.js — Custom hook managing subscriptions state + caching
  • /src/components/DataConsumption/ConsumptionDetailsModal.js — Detail modal with charts
  • /src/components/DataConsumption/TimelineRenewalsModal.js — Timeline view for renewals
  • /src/components/DataConsumption/CardInformativeSection.js — Info cards in detail modal
  • /src/components/DataConsumption/ChartsConsumption.js — Consumption charts (monthly bar + pie)
  • /src/services/api/subscriptionApi.js — API client for subscription operations
  • /src/services/emailService.js — Email sending for upgrade requests
  • /src/components/Components/Buttons/ToggleButtons.js — Toggle component (Active/Expired)
  • /src/components/Components/Modals/FullScreenModal.js — Detail modal wrapper
  • Firestore: Subscriptions, Catalogues collections