关于 data-transformers
用于集中管理 API 路由和仪表盘数据整形逻辑的技能,涵盖聚合、排名、趋势计算和清理,便于复用与测试。
主要功能
- 01分类与数量聚合
- 02Top-N 排名
- 03跨周期趋势计算
- 04数据清理
- 05可复用的纯函数
适用场景
- 01统一 API 响应格式
- 02生成仪表盘聚合数据
- 03在多个路由间复用转换逻辑
- 04单独测试数据整形逻辑
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ dadbodgeoff/ drift --skill data-transformers安装后在 Claude Code 中输入/data-transformers确认已加载。
详细介绍
数据转换器
集中管理转换逻辑,确保 API 路由中的数据整形保持一致。
何时使用此技能
- 数据转换逻辑分散在各个路由中
- 需要在各个端点间保持输出格式一致
- 希望转换函数可测试且可复用
- 构建包含聚合数据的仪表板
核心概念
将所有转换逻辑集中在一处:
- 聚合器(类别总计、计数)
- 排名器(按分数选出前 N 项)
- 趋势计算器(比较不同时间段)
- 清理器(验证并清理数据)
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ Raw Data │────▶│ Transformers │────▶│ API Output │
└─────────────┘ └──────────────┘ └─────────────┘
实现
TypeScript
// lib/transformers.ts
// ============================================
// Category Aggregation
// ============================================
interface CategoryTotals {
[category: string]: number;
}
function aggregateCategories(
items: Array<{ category: string; count?: number }>
): CategoryTotals {
const totals: CategoryTotals = {};
for (const item of items) {
const category = item.category?.toUpperCase() || 'OTHER';
totals[category] = (totals[category] || 0) + (item.count ?? 1);
}
return totals;
}
function categoriesToBreakdown(
totals: CategoryTotals,
previousTotals?: CategoryTotals
): Array<{ category: string; count: number; percentage: number; trend: string }> {
const total = Object.values(totals).reduce((sum, count) => sum + count, 0);
return Object.entries(totals)
.map(([category, count]) => {
let trend: 'increasing' | 'stable' | 'decreasing' = 'stable';
if (previousTotals) {
const prevCount = previousTotals[category] ?? 0;
const change = count - prevCount;
if (change > prevCount * 0.1) trend = 'increasing';
else if (change < -prevCount * 0.1) trend = 'decreasing';
}
return {
category,
count,
percentage: total > 0 ? count / total : 0,
trend,
};
})
.sort((a, b) => b.count - a.count);
}
// ============================================
// Ranking
// ============================================
interface Rankable {
score: number;
count: number;
}
function rankItems<T extends Rankable>(
items: T[],
limit = 5
): (T & { rank: number })[] {
return items
.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
return b.count - a.count;
})
.slice(0, limit)
.map((item, index) => ({ ...item, rank: index + 1 }));
}
// ============================================
// Trend Calculation
// ============================================
type SimpleTrend = 'increasing' | 'stable' | 'decreasing';
function calculateTrend(current: number, previous: number): SimpleTrend {
if (previous === 0) return 'stable';
const change = (current - previous) / previous;
if (change > 0.1) return 'increasing';
if (change < -0.1) return 'decreasing';
return 'stable';
}
function calculateRollingAverage(values: number[], window = 7): number {
if (values.length === 0) return 0;
const slice = values.slice(-window);
return slice.reduce((sum, v) => sum + v, 0) / slice.length;
}
function calculatePercentChange(current: number, previous: number): number {
if (previous === 0) return current > 0 ? 100 : 0;
return ((current - previous) / previous) * 100;
}
// ============================================
// Data Sanitization
// ============================================
interface Hotspot {
country: string;
countryCode: string;
lat: number;
lon: number;
riskScore: number;
eventCount: number;
}
function sanitizeHotspot(raw: Partial<Hotspot>): Hotspot | null {
if (!raw.country || !raw.countryCode) return null;
return {
country: raw.country,
countryCode: raw.countryCode,
lat: raw.lat ?? 0,
lon: raw.lon ?? 0,
riskScore: Math.min(100, Math.max(0, raw.riskScore ?? 0)),
eventCount: Math.max(0, raw.eventCount ?? 0),
};
}
function filterValidHotspots(hotspots: Partial<Hotspot>[]): Hotspot[] {
return hotspots
.map(sanitizeHotspot)
.filter((h): h is Hotspot => h !== null);
}
// ============================================
// String Utilities
// ============================================
function truncate(str: string, maxLen: number): string {
if (!str) return '';
return str.length > maxLen ? str.slice(0, maxLen - 3) + '...' : str;
}
function slugify(str: string): string {
return str
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim();
}
// ============================================
// Date Utilities
// ============================================
function formatRelativeTime(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
}
export {
aggregateCategories,
categoriesToBreakdown,
rankItems,
calculateTrend,
calculateRollingAverage,
calculatePercentChange,
sanitizeHotspot,
filterValidHotspots,
truncate,
slugify,
formatRelativeTime,
};
使用示例
API 路由
// api/dashboard/route.ts
import {
aggregateCategories,
rankItems,
filterValidHotspots
} from '@/lib/transformers';
export async function GET() {
const rawData = await fetchFromDatabase();
return Response.json({
categories: aggregateCategories(rawData.predictions),
topHotspots: rankItems(filterValidHotspots(rawData.hotspots), 5),
trend: calculateTrend(rawData.todayCount, rawData.yesterdayCount),
});
}
仪表板组件
const breakdown = categoriesToBreakdown(
currentTotals,
previousTotals
);
// Returns:
// [
// { category: 'MILITARY', count: 150, percentage: 0.45, trend: 'increasing' },
// { category: 'POLITICAL', count: 100, percentage: 0.30, trend: 'stable' },
// ...
// ]
最佳实践
- 将所有转换器放在一个文件中,便于查找和测试
- 使用纯函数,不产生副作用,输出可预测
- 处理边缘情况,包括空数组、缺失字段和 null 值
- 确保类型安全,适当使用 TypeScript 泛型
- 从 types 包导出,以便在前端和后端共享
常见错误
- 将转换逻辑分散在各个路由中
- 未处理边缘情况(空数组、null 值)
- 修改输入数据,而不是返回新对象
- 对可空返回值缺少类型守卫
- 未对转换器进行独立测试
相关模式
- api-client - 在 API 响应中使用转换器
- validation-quarantine - 转换前进行验证
- snapshot-aggregation - 为仪表板聚合数据
评论
支持 Markdown