+ {filteredDays.map((day) => {
+ if (day.visibleChanges.length === 0) return null;
+
+ const isExpanded = expandedDay === day.date;
+ const page = pageByDay[day.date] ?? 1;
+ const numPages = Math.max(
+ 1,
+ Math.ceil(day.visibleChanges.length / pageSize),
+ );
+ const safePage = Math.min(page, numPages);
+ const offset = (safePage - 1) * pageSize;
+ const pagedChanges = day.visibleChanges.slice(
+ offset,
+ offset + pageSize,
+ );
+
+ const rows = pagedChanges.map((change) => ({
+ id: `${day.date}-${change.pythLazerId}-${change.changeType}`,
+ data: {
+ symbol: change.symbol,
+ pythLazerId: change.pythLazerId,
+ changeType: (
+
+ {CHANGE_TYPE_LABELS[change.changeType]}
+
+ ),
+ status: formatStatus(change),
+ changedFields: summarizeChangedFields(change.changedFields),
+ },
+ }));
+
+ return (
+
{
+ setExpandedDay((previous) =>
+ previous === day.date ? null : day.date,
+ );
+ }}
+ afterIcon={isExpanded ? : }
+ >
+ {isExpanded ? "Hide details" : "Show details"}
+
+ }
+ >
+
+
+ Went Live: {day.totals.went_live}
+
+
+ Added: {day.totals.added}
+
+
+ Changed: {day.totals.changed}
+
+
+ Removed: {day.totals.removed}
+
+
+ Visible: {day.visibleChanges.length}
+
+
+
+ {isExpanded && (
+
+
+ label={`Pyth Pro changelog for ${formatDayLabel(day.date)}`}
+ columns={columns}
+ rows={rows}
+ rounded
+ fill
+ />
+ {
+ setPageByDay((previous) => ({
+ ...previous,
+ [day.date]: nextPage,
+ }));
+ }}
+ pageSize={pageSize}
+ onPageSizeChange={(nextSize: number) => {
+ setPageByDay({});
+ setPageSize(nextSize);
+ }}
+ />
+
+ )}
+
+ );
+ })}
+
+ )}
+
+ );
+};
+
+const formatDayLabel = (day: string) => {
+ const parsed = new Date(`${day}T00:00:00.000Z`);
+ if (Number.isNaN(parsed.getTime())) return `${day} (UTC)`;
+ return `${dayFormatter.format(parsed)} (UTC)`;
+};
+
+const formatStatus = (change: ChangeEntry) => {
+ if (change.statusBefore === change.statusAfter)
+ return change.statusAfter ?? "-";
+ return `${change.statusBefore ?? "-"} -> ${change.statusAfter ?? "-"}`;
+};
+
+const summarizeChangedFields = (changedFields: FieldDiff[]) => {
+ if (changedFields.length === 0) return "-";
+ const topFields = changedFields.slice(0, 3).map((field) => field.path);
+ if (changedFields.length <= 3) return topFields.join(", ");
+ return `${topFields.join(", ")}, +${String(changedFields.length - 3)} more`;
+};
+
diff --git a/apps/developer-hub/src/components/PriceFeedIdsProTable/index.tsx b/apps/developer-hub/src/components/PriceFeedIdsProTable/index.tsx
index d0c3651683..3627d8fda6 100644
--- a/apps/developer-hub/src/components/PriceFeedIdsProTable/index.tsx
+++ b/apps/developer-hub/src/components/PriceFeedIdsProTable/index.tsx
@@ -12,6 +12,7 @@ import { matchSorter } from "match-sorter";
import { useCallback, useEffect, useMemo, useState } from "react";
import { z } from "zod";
+import { errorToString } from "../../lib/error-to-string";
import styles from "./index.module.scss";
const FEED_STATES = ["stable", "coming_soon", "inactive"] as const;
@@ -371,12 +372,3 @@ type Col =
| "exponent"
| "state";
-const errorToString = (error: unknown) => {
- if (error instanceof Error) {
- return error.message;
- } else if (typeof error === "string") {
- return error;
- } else {
- return "An error occurred, please try again";
- }
-};
diff --git a/apps/developer-hub/src/data/pro-price-feed-changelog/types.ts b/apps/developer-hub/src/data/pro-price-feed-changelog/types.ts
new file mode 100644
index 0000000000..fc798e36c0
--- /dev/null
+++ b/apps/developer-hub/src/data/pro-price-feed-changelog/types.ts
@@ -0,0 +1,54 @@
+import { z } from "zod";
+
+export const scalarValueSchema = z.union([
+ z.string(),
+ z.number(),
+ z.boolean(),
+ z.null(),
+]);
+export type ScalarValue = z.infer;
+
+export const changeTypeSchema = z.enum([
+ "went_live",
+ "added",
+ "changed",
+ "removed",
+]);
+export type ChangeType = z.infer;
+
+export const fieldDiffSchema = z.object({
+ path: z.string(),
+ before: scalarValueSchema,
+ after: scalarValueSchema,
+});
+export type FieldDiff = z.infer;
+
+export const changeEntrySchema = z.object({
+ changeType: changeTypeSchema,
+ pythLazerId: z.number(),
+ symbol: z.string(),
+ name: z.string(),
+ statusBefore: z.string().nullable(),
+ statusAfter: z.string().nullable(),
+ changedFields: z.array(fieldDiffSchema),
+});
+export type ChangeEntry = z.infer;
+
+export const dailyRollupSchema = z.object({
+ date: z.string(),
+ totals: z.object({
+ went_live: z.number(),
+ added: z.number(),
+ changed: z.number(),
+ removed: z.number(),
+ }),
+ changes: z.array(changeEntrySchema),
+});
+export type DailyRollup = z.infer;
+
+export const dailyRollupFileSchema = z.object({
+ generatedAt: z.string(),
+ source: z.string(),
+ days: z.array(dailyRollupSchema),
+});
+export type DailyRollupFile = z.infer;
diff --git a/apps/developer-hub/src/lib/error-to-string.ts b/apps/developer-hub/src/lib/error-to-string.ts
new file mode 100644
index 0000000000..4b69ac0f61
--- /dev/null
+++ b/apps/developer-hub/src/lib/error-to-string.ts
@@ -0,0 +1,5 @@
+export const errorToString = (error: unknown): string => {
+ if (error instanceof Error) return error.message;
+ if (typeof error === "string") return error;
+ return "An error occurred, please try again";
+};