Skip to content

Commit ab0eac8

Browse files
committed
feat(api,frontend): add X-Total-Count header, implement server-side sorting, add skeleton screens, and improve logging
- Issue #240: Add X-Total-Count header to paginated endpoints (GET /api/campaigns, GET /api/campaigns/:id/pledges) - X-Total-Count header exposes total item count for pagination UI - Header added to CORS Access-Control-Expose-Headers - Works for both cache HIT and MISS cases - Issue #241: Replace console.log/console.error with structured logger - Replaced startup banner console.log calls with logInfo - Maintains no-console ESLint rule compliance - Issue #242: Add server-side ?sort and ?order query parameters - Replace old sort fields (newest, percentFunded, totalPledged) with new ones (createdAt, pledgedAmount, targetAmount) - Support sorting by deadline, pledgedAmount, createdAt, targetAmount - Default sorting: createdAt desc - Return 400 error for invalid sort fields - Updated both backend campaignStore and frontend components - Issue #245: Add loading skeleton screens for campaign list - Created SkeletonCard component with animate-pulse animation - Renders 6 skeleton cards during initial load - Improved perceived performance during data fetch - Added accessibility attributes (aria-busy, aria-label) Resolves #240 Resolves #241 Resolves #242 Resolves #245
1 parent 8f68e03 commit ab0eac8

6 files changed

Lines changed: 81 additions & 35 deletions

File tree

backend/src/index.ts

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ app.use(
102102
}
103103
},
104104
credentials: true,
105+
exposedHeaders: ['X-Total-Count', 'X-RateLimit-Limit', 'X-RateLimit-Remaining', 'X-RateLimit-Reset', 'Retry-After'],
105106
}),
106107
);
107108

@@ -234,10 +235,15 @@ export function parseCampaignListFilters(query: {
234235
sort?: CampaignSortField;
235236
order?: SortOrder;
236237
} {
237-
const VALID_SORT_FIELDS: CampaignSortField[] = ['newest', 'deadline', 'percentFunded', 'totalPledged'];
238+
const VALID_SORT_FIELDS: CampaignSortField[] = ['createdAt', 'deadline', 'pledgedAmount', 'targetAmount'];
238239
const VALID_ORDERS: SortOrder[] = ['asc', 'desc'];
239240
const rawSort = normalizeQueryValue(query.sort);
240241
const rawOrder = normalizeQueryValue(query.order);
242+
243+
if (rawSort && !VALID_SORT_FIELDS.includes(rawSort as CampaignSortField)) {
244+
throw new AppError(`Invalid sort field: ${rawSort}. Supported fields: ${VALID_SORT_FIELDS.join(', ')}`, 400, 'INVALID_SORT_FIELD');
245+
}
246+
241247
return {
242248
asset: normalizeAssetFilter(query.asset),
243249
status: normalizeStatusFilter(query.status),
@@ -351,8 +357,10 @@ app.get('/api/campaigns', (req: Request, res: Response) => {
351357

352358
const cached = getCampaignCacheEntry(cacheKey);
353359
if (cached) {
360+
const cachedData = JSON.parse(cached);
354361
res.setHeader('Cache-Control', 'max-age=5');
355362
res.setHeader('X-Cache', 'HIT');
363+
res.setHeader('X-Total-Count', String(cachedData.pagination.total));
356364
res.setHeader('Content-Type', 'application/json');
357365
res.send(cached);
358366
return;
@@ -401,6 +409,7 @@ app.get('/api/campaigns', (req: Request, res: Response) => {
401409

402410
res.setHeader('Cache-Control', 'max-age=5');
403411
res.setHeader('X-Cache', 'MISS');
412+
res.setHeader('X-Total-Count', String(totalCount));
404413
res.setHeader('Content-Type', 'application/json');
405414
res.send(responseBody);
406415
});
@@ -447,6 +456,7 @@ app.get('/api/campaigns/:id/pledges', (req: Request, res: Response) => {
447456
Math.ceil(totalCount / paginationResult.limit),
448457
);
449458

459+
res.setHeader('X-Total-Count', String(totalCount));
450460
res.json({
451461
data: pledges,
452462
pagination: {
@@ -763,17 +773,12 @@ function printStartupBanner(): void {
763773
const dbPath = process.env.DB_PATH || path.join(__dirname, '..', '..', 'data', 'campaigns.db');
764774
const nodeEnv = process.env.NODE_ENV || 'development';
765775

766-
/* eslint-disable no-console */
767-
console.log('');
768-
console.log('╔════════════════════════════════════════════════════════════╗');
769-
console.log('║ Stellar Goal Vault Backend - Starting Up ║');
770-
console.log('╠════════════════════════════════════════════════════════════╣');
771-
console.log(`║ Port: ${config.port.toString().padEnd(42)}║`);
772-
console.log(`║ Environment: ${nodeEnv.padEnd(42)}║`);
773-
console.log(`║ Database Path: ${dbPath.padEnd(42)}║`);
774-
console.log('╚════════════════════════════════════════════════════════════╝');
775-
console.log('');
776-
/* eslint-enable no-console */
776+
logInfo('startup_banner', {
777+
message: 'Stellar Goal Vault Backend - Starting Up',
778+
port: config.port,
779+
environment: nodeEnv,
780+
databasePath: dbPath,
781+
}, config.logLevel);
777782
}
778783

779784
export function configureHttpServer(server: Server): Server {

backend/src/services/campaignStore.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,7 @@ export function calculateProgress(campaign: CampaignRecord, at = nowInSeconds())
273273
};
274274
}
275275

276-
export type CampaignSortField = 'newest' | 'deadline' | 'percentFunded' | 'totalPledged';
276+
export type CampaignSortField = 'createdAt' | 'deadline' | 'pledgedAmount' | 'targetAmount';
277277
export type SortOrder = 'asc' | 'desc';
278278

279279
export interface ListCampaignsOptions {
@@ -411,21 +411,21 @@ export function listCampaigns(options?: ListCampaignsOptions): ListCampaignsResu
411411
const totalCount = (db.prepare(countQuery).get(...params) as { total: number }).total;
412412

413413
// Build ORDER BY clause from sort options
414-
const sortField = options?.sort ?? 'newest';
414+
const sortField = options?.sort ?? 'createdAt';
415415
const sortOrder = options?.order ?? 'desc';
416416
const orderDir = sortOrder === 'asc' ? 'ASC' : 'DESC';
417417
let orderByClause: string;
418418
switch (sortField) {
419419
case 'deadline':
420-
orderByClause = `campaigns.deadline ${sortOrder === 'desc' ? 'DESC' : 'ASC'}`;
420+
orderByClause = `campaigns.deadline ${orderDir}`;
421421
break;
422-
case 'percentFunded':
423-
orderByClause = `(CAST(campaigns.pledged_amount AS REAL) / CAST(campaigns.target_amount AS REAL)) ${orderDir}`;
424-
break;
425-
case 'totalPledged':
422+
case 'pledgedAmount':
426423
orderByClause = `campaigns.pledged_amount ${orderDir}`;
427424
break;
428-
case 'newest':
425+
case 'targetAmount':
426+
orderByClause = `campaigns.target_amount ${orderDir}`;
427+
break;
428+
case 'createdAt':
429429
default:
430430
orderByClause = `campaigns.created_at ${orderDir}`;
431431
break;

frontend/src/components/CampaignsTable.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
import { SearchInput } from "./SearchInput";
1414
import { SortDropdown, SortOption } from "./SortDropdown";
1515
import { AddressAvatar } from "./AddressAvatar";
16+
import { SkeletonCard } from "./SkeletonCard";
1617
import { useWindowVirtualizer } from "@tanstack/react-virtual";
1718
import { useMediaQuery } from "../hooks/useMediaQuery";
1819

@@ -74,11 +75,11 @@ export function CampaignsTable({
7475
invalidUrlCampaignId = null,
7576
}: CampaignsTableProps) {
7677
const [searchParams, setSearchParams] = useSearchParams();
77-
const urlSort = (searchParams.get('sort') as SortOption | null) ?? 'newest';
78+
const urlSort = (searchParams.get('sort') as SortOption | null) ?? 'createdAt';
7879
const urlOrder = (searchParams.get('order') as 'asc' | 'desc' | null) ?? 'desc';
7980
const urlStatus = (searchParams.get('status') as StatusFilterValue | null) ?? '';
80-
const VALID_SORTS: SortOption[] = ['newest', 'deadline', 'percentFunded', 'totalPledged'];
81-
const sortBy: SortOption = VALID_SORTS.includes(urlSort) ? urlSort : 'newest';
81+
const VALID_SORTS: SortOption[] = ['createdAt', 'deadline', 'pledgedAmount', 'targetAmount'];
82+
const sortBy: SortOption = VALID_SORTS.includes(urlSort) ? urlSort : 'createdAt';
8283
const [assetCode, setAssetCode] = useState("");
8384
const [statusFilter, setStatusFilter] = useState<StatusFilterValue>(urlStatus);
8485
const [searchQuery, setSearchQuery] = useState("");
@@ -147,6 +148,7 @@ export function CampaignsTable({
147148
}, [hasMore, onLoadMore]);
148149

149150
const isEmpty = campaigns.length === 0;
151+
const SKELETON_COUNT = 6;
150152

151153
const assetOptions = useMemo(
152154
() => getDistinctAssetCodes(campaigns),
@@ -196,6 +198,11 @@ export function CampaignsTable({
196198
<h2>Campaign board</h2>
197199
<p className="muted">Loading campaigns...</p>
198200
</div>
201+
<div className="cards-only" aria-busy="true" aria-label="Loading campaigns">
202+
{Array.from({ length: SKELETON_COUNT }).map((_, index) => (
203+
<SkeletonCard key={`skeleton-${index}`} />
204+
))}
205+
</div>
199206
</section>
200207
);
201208
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
export function SkeletonCard() {
2+
return (
3+
<article className="campaign-card animate-pulse">
4+
<div className="campaign-card-main">
5+
<div className="campaign-card-header">
6+
<div className="h-6 bg-gray-300 rounded w-3/4 mb-2"></div>
7+
<div className="h-6 bg-gray-300 rounded w-1/4"></div>
8+
</div>
9+
<div
10+
className="campaign-creator mono"
11+
style={{
12+
display: "flex",
13+
alignItems: "center",
14+
gap: 10,
15+
marginBottom: 12,
16+
}}
17+
>
18+
<div className="h-6 w-6 bg-gray-300 rounded-full"></div>
19+
<div className="h-6 bg-gray-300 rounded flex-1"></div>
20+
</div>
21+
<div className="campaign-progress">
22+
<div className="h-4 bg-gray-300 rounded w-full mb-2"></div>
23+
<div className="h-4 bg-gray-300 rounded w-1/2"></div>
24+
</div>
25+
<div className="campaign-meta">
26+
<div className="h-4 bg-gray-300 rounded w-1/3"></div>
27+
</div>
28+
</div>
29+
<div className="campaign-card-actions">
30+
<div className="h-10 bg-gray-300 rounded w-full"></div>
31+
</div>
32+
</article>
33+
);
34+
}

frontend/src/components/SortDropdown.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export type SortOption = 'newest' | 'deadline' | 'percentFunded' | 'totalPledged';
1+
export type SortOption = 'createdAt' | 'deadline' | 'pledgedAmount' | 'targetAmount';
22

33
export interface SortDropdownProps {
44
value: SortOption;
@@ -16,10 +16,10 @@ export function SortDropdown({ value, onChange, disabled = false }: SortDropdown
1616
className="control-select"
1717
style={{ cursor: disabled ? 'not-allowed' : 'pointer', opacity: disabled ? 0.55 : 1 }}
1818
>
19-
<option value="newest">Newest</option>
19+
<option value="createdAt">Created At</option>
2020
<option value="deadline">Deadline</option>
21-
<option value="percentFunded">Percent Funded</option>
22-
<option value="totalPledged">Total Pledged</option>
21+
<option value="pledgedAmount">Pledged Amount</option>
22+
<option value="targetAmount">Target Amount</option>
2323
</select>
2424
);
2525
}

frontend/src/components/campaignsTableUtils.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ export function applyFilters(
7575
* This ensures the selected campaign state is preserved during sorting.
7676
*
7777
* @param campaigns - Array of campaigns to sort
78-
* @param sortBy - Sort option (newest, deadline, percentFunded, totalPledged)
78+
* @param sortBy - Sort option (createdAt, deadline, pledgedAmount, targetAmount)
7979
* @returns Sorted array of campaigns
8080
*/
8181
export function sortCampaigns(campaigns: Campaign[], sortBy: SortOption): Campaign[] {
@@ -86,7 +86,7 @@ export function sortCampaigns(campaigns: Campaign[], sortBy: SortOption): Campai
8686
let comparison = 0;
8787

8888
switch (sortBy) {
89-
case 'newest':
89+
case 'createdAt':
9090
// Sort by createdAt descending (newest first)
9191
comparison = b.createdAt - a.createdAt;
9292
break;
@@ -96,16 +96,16 @@ export function sortCampaigns(campaigns: Campaign[], sortBy: SortOption): Campai
9696
comparison = a.deadline - b.deadline;
9797
break;
9898

99-
case 'percentFunded':
100-
// Sort by percentFunded descending (highest first)
101-
comparison = b.progress.percentFunded - a.progress.percentFunded;
102-
break;
103-
104-
case 'totalPledged':
99+
case 'pledgedAmount':
105100
// Sort by pledgedAmount descending (largest first)
106101
comparison = b.pledgedAmount - a.pledgedAmount;
107102
break;
108103

104+
case 'targetAmount':
105+
// Sort by targetAmount descending (largest first)
106+
comparison = b.targetAmount - a.targetAmount;
107+
break;
108+
109109
default:
110110
// No sorting for unknown options
111111
comparison = 0;

0 commit comments

Comments
 (0)