Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions tests/breakdown_ui/component-breakdown.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# Portfolio Overview - Component Breakdown

## Component Hierarchy

```
PortfolioOverviewPage
├── Header
│ ├── PageTitle
│ └── SearchBar
├── KPISection
│ └── KPICard (reusable)
├── PortfolioTable
│ ├── TableHeader
│ ├── GroupRow (reusable)
│ └── CompanyRow (reusable)
└── LoadingSpinner (conditional)
```

## Component Details

### **1. PortfolioOverviewPage** (Container)
- **Purpose**: Main page container, manages state and data fetching
- **Props**: None (top-level component)
- **State**: `companies[]`, `kpis[]`, `searchTerm`, `loading`
- **Responsibilities**:
- Fetch portfolio data
- Handle search filtering
- Pass data down to child components

### **2. Header** (Layout)
- **Purpose**: Top section with title and search
- **Props**: `searchTerm`, `onSearchChange`
- **Children**: PageTitle, SearchBar

### **3. PageTitle** (Presentational)
- **Purpose**: Display "Portfolio Overview" heading
- **Props**: `title` (string)
- **Reusable**: Yes - could be used across different pages

### **4. SearchBar** (Interactive)
- **Purpose**: Filter all portfolio data
- **Props**: `value`, `onChange`, `placeholder`
- **Reusable**: Yes - standard input component
- **Features**: Real-time filtering, clear button

### **5. KPISection** (Container)
- **Purpose**: Display key metrics in a row
- **Props**: `kpis[]`
- **Children**: Multiple KPICard components

### **6. KPICard** (Reusable)
- **Purpose**: Display individual metric
- **Props**: `title`, `value`, `change`, `trend`
- **Reusable**: Yes - used for each KPI metric
- **Variants**: Could support different value types (currency, percentage, count)

### **7. PortfolioTable** (Complex Container)
- **Purpose**: Main data display with companies and groups
- **Props**: `companies[]`, `searchTerm`
- **Children**: TableHeader, GroupRow[], CompanyRow[]
- **Features**:
- Grouping logic
- Sorting capabilities
- Responsive design

### **8. TableHeader** (Layout)
- **Purpose**: Column headers with sorting
- **Props**: `columns[]`, `sortBy`, `onSort`
- **Features**: Sortable columns, proper accessibility

### **9. GroupRow** (Reusable)
- **Purpose**: Aggregated data for company groups
- **Props**: `groupName`, `totalInvestment`, `companyCount`, `avgMetrics`
- **Reusable**: Yes - any grouped data display
- **Features**: Expand/collapse functionality

### **10. CompanyRow** (Reusable)
- **Purpose**: Individual company data
- **Props**: `company` (object with name, investment, metrics)
- **Reusable**: Yes - could be used in other company lists
- **Features**: Action buttons, status indicators

## Key Reusability Patterns

### **1. Data Display Components**
- `KPICard` - Any metric display across the app
- `CompanyRow` - Any company listing feature
- `SearchBar` - Any search functionality

### **2. Layout Components**
- `Header` - Consistent page headers
- `PageTitle` - Standardized page titles
- `TableHeader` - Any data table implementation

### **3. Utility Components**
- `LoadingSpinner` - Any loading state
- `GroupRow` - Any grouped data visualization

## Trade-offs Made

### **Chosen: Separate SearchBar vs. Integrated Header Search**
**Decision**: Created separate `SearchBar` component instead of building search into `Header`

**Why**:
- **Reusability**: SearchBar can be used in other contexts (modal searches, filters)
- **Testing**: Easier to unit test search logic in isolation
- **Flexibility**: Can easily move search position without restructuring Header

**Trade-off**: Slightly more props drilling, but gains modularity and reusability

### **Component Granularity**
**Decision**: Split into smaller, focused components rather than fewer large ones

**Benefits**: Better reusability, easier testing, clearer responsibilities
**Cost**: More props drilling, slightly more complexity

This structure balances reusability with simplicity while maintaining clear separation of concerns.
85 changes: 50 additions & 35 deletions tests/unsubmitted_forms/cleanup_unsubmitted_forms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,52 +28,67 @@ import { update_job_status } from "./generic_scheduler";

export const cleanup_unsubmitted_forms = async (job: JobScheduleQueue) => {
try {
//Find forms that were created 7 days ago and have not been submitted
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60);
const sevenDaysAgoPlusOneDay = new Date(
sevenDaysAgo.getTime() + 24 * 60 * 60 * 1000
);
// Find forms that were created MORE than 7 days ago and have not been submitted
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);

// Get all expired tokens with their relationships in one query for better performance
const expiredTokens = await prisma.publicFormsTokens.findMany({
where: {
createdAt: {
gte: sevenDaysAgo, // greater than or equal to 7 days ago
lt: sevenDaysAgoPlusOneDay, // but less than 7 days ago + 1 day
lt: sevenDaysAgo, // Less than 7 days ago = older than 7 days
},
},
include: {
relationship: {
where: {
status: "new",
},
},
},
});
Comment on lines 35 to 48
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Prevent accidental data loss; include zero-relationship tokens; push filtering into the DB.

Today we: (a) skip tokens with zero relationships and (b) might delete tokens/entities even when there are non-"new" relationships present (we only include "new" relationships but never exclude the presence of others). Both are correctness/data‑loss risks.

  • Include tokens that have zero relationships (still unsubmitted).
  • Exclude any token that has at least one non-"new" relationship.
  • Reduce payload by selecting only fields we actually use.
  • Then drop the in-memory filter.

Apply:

-    const expiredTokens = await prisma.publicFormsTokens.findMany({
-      where: {
-        createdAt: {
-          lt: sevenDaysAgo, // Less than 7 days ago = older than 7 days
-        },
-      },
-      include: {
-        relationship: {
-          where: {
-            status: "new",
-          },
-        },
-      },
-    });
+    const expiredTokens = await prisma.publicFormsTokens.findMany({
+      where: {
+        createdAt: { lt: sevenDaysAgo },
+        entityId: { not: null },
+        // Vacuously true when there are zero relationships -> included.
+        relationship: { every: { status: "new" } },
+      },
+      select: {
+        token: true,
+        entityId: true,
+        relationship: {
+          where: { status: "new" },
+          select: { id: true },
+        },
+      },
+    });
@@
-    const tokensToDelete = expiredTokens.filter(
-      (token) => token.entityId && token.relationship.length > 0
-    );
+    const tokensToDelete = expiredTokens;

Also applies to: 50-53


for (const token of expiredTokens) {
const relationship = await prisma.relationship.findFirst({
where: {
product_id: token.productId,
status: "new",
},
// Filter tokens that have valid entityId and unsubmitted relationships
const tokensToDelete = expiredTokens.filter(
(token) => token.entityId && token.relationship.length > 0
);

if (tokensToDelete.length === 0) {
await update_job_status(job.id, "completed");
return;
}

// Batch delete operations for better performance
const entityIds = tokensToDelete.map((token) => token.entityId).filter(Boolean);
const tokenIds = tokensToDelete.map((token) => token.token);
const relationshipIds = tokensToDelete.flatMap((token) =>
token.relationship.map((rel) => rel.id)
);

await prisma.$transaction(async (tx) => {
// Delete relationships first to avoid foreign key constraints
if (relationshipIds.length > 0) {
await tx.relationship.deleteMany({
where: { id: { in: relationshipIds } },
});
}

// Delete tokens
await tx.publicFormsTokens.deleteMany({
where: { token: { in: tokenIds } },
});

if (relationship) {
await prisma.$transaction([
// Delete relationship
prisma.relationship.delete({
where: { id: relationship.id },
}),
// // Delete the token
prisma.publicFormsTokens.delete({
where: { token: token.token },
}),
// Delete all corpus items associated with the entity
prisma.new_corpus.deleteMany({
where: {
entity_id: token.entityId || "",
},
}),
// Delete the entity (company)
prisma.entity.delete({
where: { id: token.entityId || "" },
}),
]);
// Delete corpus items associated with entities
if (entityIds.length > 0) {
await tx.new_corpus.deleteMany({
where: { entity_id: { in: entityIds } },
});

// Delete entities last
await tx.entity.deleteMany({
where: { id: { in: entityIds } },
});
}
}
});

await update_job_status(job.id, "completed");
} catch (error) {
Expand Down