diff --git a/app/journal-new/JournalNewPageContent.tsx b/app/journal-new/JournalNewPageContent.tsx
new file mode 100644
index 000000000..2aafca27f
--- /dev/null
+++ b/app/journal-new/JournalNewPageContent.tsx
@@ -0,0 +1,67 @@
+'use client';
+
+import { useState } from 'react';
+import { FeedContent } from '@/components/Feed/FeedContent';
+import { useFeed } from '@/hooks/useFeed';
+import { JournalV2FeedEntryItem } from '@/components/Journal/JournalV2FeedEntryItem';
+import {
+ JournalV2SortAndFilters,
+ type JournalSortOption,
+} from '@/components/Journal/JournalV2SortAndFilters';
+
+export function JournalNewPageContent() {
+ const [journalSort, setJournalSort] = useState('best');
+
+ const {
+ entries,
+ isLoading,
+ hasMore,
+ loadMore,
+ restoredScrollPosition,
+ page,
+ lastClickedEntryId,
+ } = useFeed('all', {
+ endpoint: 'journal_v2_feed',
+ ordering: journalSort,
+ });
+
+ return (
+ }
+ ordering={journalSort}
+ activeTab="journal-new"
+ restoredScrollPosition={restoredScrollPosition}
+ page={page}
+ lastClickedEntryId={lastClickedEntryId ?? undefined}
+ showGrantHeaders={false}
+ showPostHeaders={false}
+ showFundraiseHeaders={false}
+ noEntriesElement={
+
+
No journal entries yet
+
+ }
+ renderEntry={({
+ entry,
+ index,
+ ordering,
+ registerVisibleItem,
+ unregisterVisibleItem,
+ getVisibleItems,
+ }) => (
+
+ )}
+ />
+ );
+}
diff --git a/app/journal-new/page.tsx b/app/journal-new/page.tsx
new file mode 100644
index 000000000..3413ce31b
--- /dev/null
+++ b/app/journal-new/page.tsx
@@ -0,0 +1,43 @@
+import Link from 'next/link';
+import { Metadata } from 'next';
+import { Plus } from 'lucide-react';
+import { buildOpenGraphMetadata } from '@/lib/metadata';
+import { PageLayout } from '@/app/layouts/PageLayout';
+import { HeroHeader } from '@/components/ui/HeroHeader';
+import { RHJRightSidebar } from '@/components/Journal/RHJRightSidebar';
+import { JournalNewPageContent } from './JournalNewPageContent';
+
+export const metadata: Metadata = buildOpenGraphMetadata({
+ title: 'ResearchHub Journal',
+ description: 'Browse completed funded proposals and published Registered Reports.',
+ url: '/journal-new',
+});
+
+export default function JournalNewPage() {
+ return (
+
+ Browse funded proposals, Registered Reports, and results.
+
+ }
+ cta={
+
+
+ Proposal
+
+ }
+ />
+ }
+ rightSidebar={}
+ >
+
+
+ );
+}
diff --git a/components/Feed/FeedContent.tsx b/components/Feed/FeedContent.tsx
index 60c50033d..9deb86cba 100644
--- a/components/Feed/FeedContent.tsx
+++ b/components/Feed/FeedContent.tsx
@@ -48,6 +48,14 @@ interface FeedContentProps {
shouldRenderBountyAsComment?: boolean;
showBountyInfo?: boolean;
abstractCollapsedByDefault?: boolean;
+ renderEntry?: (props: {
+ entry: FeedEntry;
+ index: number;
+ ordering?: string;
+ registerVisibleItem: (index: number, unifiedDocumentId: string) => void;
+ unregisterVisibleItem: (index: number, unifiedDocumentId: string) => void;
+ getVisibleItems: (clickedUnifiedDocumentId: string) => string[];
+ }) => ReactNode;
/**
* Drop the default `max-w-4xl` cap on the feed list so it fills its parent.
* Use when the surrounding layout is already wider (e.g. sidebar-less pages).
@@ -84,6 +92,7 @@ export const FeedContent: FC = ({
shouldRenderBountyAsComment,
showBountyInfo = false,
abstractCollapsedByDefault,
+ renderEntry,
wideContent = false,
skeletonVariant,
}) => {
@@ -187,7 +196,16 @@ export const FeedContent: FC = ({
}
}
- const feedItem = (
+ const feedItem = renderEntry ? (
+ renderEntry({
+ entry,
+ index,
+ ordering,
+ registerVisibleItem,
+ unregisterVisibleItem,
+ getVisibleItems,
+ })
+ ) : (
void;
+ unregisterVisibleItem: (index: number, unifiedDocumentId: string) => void;
+ getVisibleItems: (clickedUnifiedDocumentId: string) => string[];
+}
+
+const STAGE_ICONS: Record = {
+ funding_opportunity: Landmark,
+ proposal: Coins,
+ registered_report: FileInput,
+ preprint: FileText,
+};
+
+const TRACKER_CLIP_PATHS = {
+ first: 'polygon(0 0, 100% 0, calc(100% - 12px) 100%, 0 100%)',
+ middle: 'polygon(12px 0, 100% 0, calc(100% - 12px) 100%, 0 100%)',
+ last: 'polygon(12px 0, 100% 0, 100% 100%, 0 100%)',
+} as const;
+
+/** Resolves the angled tracker shape for a stage position. */
+function resolveTrackerClipPath(stepIndex: number, stepCount: number): string {
+ if (stepIndex === 0) return TRACKER_CLIP_PATHS.first;
+ if (stepIndex === stepCount - 1) return TRACKER_CLIP_PATHS.last;
+ return TRACKER_CLIP_PATHS.middle;
+}
+
+export const JournalV2FeedEntryItem: FC = ({
+ entry,
+ index,
+ feedOrdering,
+ registerVisibleItem,
+ unregisterVisibleItem,
+ getVisibleItems,
+}) => {
+ const { updateLastClickedEntryId } = useNavigation();
+ const unifiedDocumentId = getUnifiedDocumentId(entry);
+ const viewModel = buildJournalV2FeedItemViewModel(entry);
+
+ const { ref } = useInView({
+ threshold: 0,
+ rootMargin: '50px',
+ onChange: (inView) => {
+ if (!unifiedDocumentId) return;
+ if (inView) {
+ registerVisibleItem(index, unifiedDocumentId);
+ } else {
+ unregisterVisibleItem(index, unifiedDocumentId);
+ }
+ },
+ });
+
+ const getImpressions = useCallback(() => {
+ if (!unifiedDocumentId) return undefined;
+ const visibleItems = getVisibleItems(unifiedDocumentId);
+ return visibleItems.length > 0 ? visibleItems : undefined;
+ }, [unifiedDocumentId, getVisibleItems]);
+
+ const { handleFeedItemClick } = useFeedItemAnalyticsTracking({
+ entry,
+ feedPosition: index + 1,
+ feedOrdering,
+ impression: getImpressions(),
+ });
+
+ if (!viewModel) {
+ return null;
+ }
+
+ const entryIdKey = `JOURNAL:${entry.id}`;
+
+ const handleCardClick = () => {
+ handleFeedItemClick();
+ updateLastClickedEntryId(entryIdKey);
+ };
+
+ const reviewSummary = viewModel.reviewSummary;
+ const proposalHref = viewModel.trackerSteps.find((step) => step.stage === 'proposal')?.href;
+
+ const renderTrackerStep = (step: JournalV2StageLink, stepIndex: number) => {
+ const StepIcon = STAGE_ICONS[step.stage];
+ const className = cn(
+ 'relative flex min-h-[54px] items-center justify-center gap-2 border px-3 py-2 text-xs font-semibold transition-colors',
+ step.href
+ ? 'border-primary-500 bg-primary-500 text-white hover:bg-primary-600'
+ : 'border-gray-200 bg-white text-gray-400',
+ stepIndex === 0 && 'rounded-l-lg',
+ stepIndex === viewModel.trackerSteps.length - 1 && 'rounded-r-lg'
+ );
+ const style = {
+ clipPath: resolveTrackerClipPath(stepIndex, viewModel.trackerSteps.length),
+ };
+ const content = (
+ <>
+
+ {step.label}
+ >
+ );
+
+ if (!step.href) {
+ return (
+
+ {content}
+
+ );
+ }
+
+ return (
+ e.stopPropagation()}
+ className={className}
+ style={style}
+ >
+ {content}
+
+ );
+ };
+
+ return (
+
+
+
+ {viewModel.imageUrl ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {viewModel.currentStageLabel}
+
+
+ {viewModel.title}
+
+
+
+
+
+
+ Average Review
+
+ {reviewSummary ? (
+
+ }
+ position="top"
+ width="w-[320px]"
+ >
+
+
+ {reviewSummary.average.toFixed(1)}/5
+
+
+ ) : (
+
--/5
+ )}
+
+
+
+ View
+
+
+
+
+
+
+
+ {viewModel.trackerSteps.map((step, stepIndex) => renderTrackerStep(step, stepIndex))}
+
+
+
+ );
+};
diff --git a/components/Journal/JournalV2SortAndFilters.tsx b/components/Journal/JournalV2SortAndFilters.tsx
new file mode 100644
index 000000000..d8649b3d7
--- /dev/null
+++ b/components/Journal/JournalV2SortAndFilters.tsx
@@ -0,0 +1,55 @@
+'use client';
+
+import { FC } from 'react';
+import { cn } from '@/utils/styles';
+
+export type JournalSortOption = 'best' | 'newest' | 'peer_review_score';
+
+export const JOURNAL_SORT_OPTIONS: ReadonlyArray<{
+ readonly label: string;
+ readonly value: JournalSortOption;
+}> = [
+ { label: 'Best', value: 'best' },
+ { label: 'Newest', value: 'newest' },
+ { label: 'Review score', value: 'peer_review_score' },
+];
+
+interface SortDropdownProps {
+ readonly sortBy: JournalSortOption;
+ readonly onSortChange: (value: JournalSortOption) => void;
+}
+
+function SortDropdown({ sortBy, onSortChange }: Readonly) {
+ return (
+
+ );
+}
+
+interface JournalV2SortAndFiltersProps {
+ readonly className?: string;
+ readonly sortBy: JournalSortOption;
+ readonly onSortChange: (value: JournalSortOption) => void;
+}
+
+export const JournalV2SortAndFilters: FC = ({
+ className,
+ sortBy,
+ onSortChange,
+}) => {
+ return (
+
+
+
+ );
+};
diff --git a/components/Journal/lib/journalV2FeedItem.ts b/components/Journal/lib/journalV2FeedItem.ts
new file mode 100644
index 000000000..a0a8ca4da
--- /dev/null
+++ b/components/Journal/lib/journalV2FeedItem.ts
@@ -0,0 +1,292 @@
+import {
+ FeedEntry,
+ FeedPaperContent,
+ FeedPostContent,
+ JournalV2FeedMetadata,
+ JournalV2LinkedWork,
+ Review,
+} from '@/types/feed';
+import { buildWorkUrl } from '@/utils/url';
+
+export type JournalV2Stage = 'funding_opportunity' | 'proposal' | 'registered_report' | 'preprint';
+
+export interface JournalV2StageLink {
+ stage: JournalV2Stage;
+ label: string;
+ href?: string;
+}
+
+export interface JournalV2ReviewSummary {
+ average: number;
+ count: number;
+ reviews: Review[];
+}
+
+export interface JournalV2FeedItemViewModel {
+ title: string;
+ href: string;
+ imageUrl?: string;
+ currentStageLabel: string;
+ reviewSummary?: JournalV2ReviewSummary;
+ trackerSteps: JournalV2StageLink[];
+}
+
+type JournalFeedContent = FeedPostContent | FeedPaperContent;
+
+/** Reads a finite review score without treating null as zero. */
+function readScore(value: unknown): number | undefined {
+ if (value === null || value === undefined || value === '') return undefined;
+ const score = Number(value);
+ return Number.isFinite(score) ? score : undefined;
+}
+
+/** Reads the post-facing ID from transformed journal metadata. */
+function readLinkedWorkId(work?: JournalV2LinkedWork): number | undefined {
+ return work?.postId ?? work?.id ?? undefined;
+}
+
+/** Builds a work URL from transformed journal metadata. */
+function buildHrefFromLinkedWork(
+ work: JournalV2LinkedWork | undefined,
+ contentType: 'funding_request' | 'paper' | 'post' | 'preregistration'
+): string | undefined {
+ const id = readLinkedWorkId(work);
+ if (!id) return undefined;
+
+ return buildWorkUrl({
+ id,
+ slug: work?.slug,
+ contentType,
+ });
+}
+
+/** Builds the future Registered Report page URL. */
+function buildReportHref(id: string | number, slug?: string): string {
+ return slug ? `/report/${id}/${slug}` : `/report/${id}`;
+}
+
+/** Builds the future Registered Report page URL from transformed journal metadata. */
+function buildReportHrefFromLinkedWork(work: JournalV2LinkedWork | undefined): string | undefined {
+ const id = readLinkedWorkId(work);
+ if (!id) return undefined;
+
+ return buildReportHref(id, work?.slug);
+}
+
+/** Returns true when the transformed feed content is a paper/preprint. */
+function isPaperContent(content: FeedEntry['content']): content is FeedPaperContent {
+ return content.contentType === 'PAPER';
+}
+
+/** Returns true when the transformed feed content is a post-like journal item. */
+function isPostContent(content: FeedEntry['content']): content is FeedPostContent {
+ return content.contentType === 'POST' || content.contentType === 'PREREGISTRATION';
+}
+
+/** Returns true when the item represents a Registered Report post. */
+function isRegisteredReportContent(
+ content: JournalFeedContent,
+ journalV2?: JournalV2FeedMetadata
+): boolean {
+ return (
+ (isPostContent(content) && content.postType === 'REGISTERED_REPORT') ||
+ journalV2?.documentType === 'REGISTERED_REPORT' ||
+ journalV2?.journalState === 'registered_report'
+ );
+}
+
+/** Builds the primary card link for the transformed journal item. */
+function buildPrimaryHref(content: JournalFeedContent, journalV2?: JournalV2FeedMetadata): string {
+ if (isPaperContent(content)) {
+ return buildWorkUrl({ id: content.id, slug: content.slug, contentType: 'paper' });
+ }
+
+ if (isRegisteredReportContent(content, journalV2)) {
+ return buildReportHref(content.id, content.slug);
+ }
+
+ if (content.contentType === 'PREREGISTRATION' || content.postType === 'PREREGISTRATION') {
+ return buildWorkUrl({ id: content.id, slug: content.slug, contentType: 'preregistration' });
+ }
+
+ return buildWorkUrl({ id: content.id, slug: content.slug, contentType: 'post' });
+}
+
+/** Builds the Funding Opportunity tracker link from transformed post IDs when present. */
+function buildFundingOpportunityHref(journalV2?: JournalV2FeedMetadata): string | undefined {
+ if (journalV2?.postIds) {
+ const grantPostId = journalV2.postIds.grantPostId;
+ if (!grantPostId) return undefined;
+
+ return buildWorkUrl({
+ id: grantPostId,
+ slug: journalV2.fundingOpportunity?.slug,
+ contentType: 'funding_request',
+ });
+ }
+
+ return buildHrefFromLinkedWork(journalV2?.fundingOpportunity, 'funding_request');
+}
+
+/** Builds the Proposal tracker link from transformed post IDs when present. */
+function buildProposalHref(
+ journalV2: JournalV2FeedMetadata | undefined,
+ content: JournalFeedContent,
+ primaryHref: string
+): string | undefined {
+ if (journalV2?.postIds) {
+ const proposalPostId = journalV2.postIds.proposalPostId;
+ if (!proposalPostId) return undefined;
+
+ return buildWorkUrl({
+ id: proposalPostId,
+ slug: journalV2.proposal?.slug,
+ contentType: 'preregistration',
+ });
+ }
+
+ if (isPostContent(content) && content.contentType === 'PREREGISTRATION') {
+ return primaryHref;
+ }
+
+ return buildHrefFromLinkedWork(journalV2?.proposal, 'preregistration');
+}
+
+/** Builds the Registered Report tracker link when that stage exists. */
+function buildRegisteredReportHref(
+ content: JournalFeedContent,
+ journalV2: JournalV2FeedMetadata | undefined,
+ primaryHref: string
+): string | undefined {
+ if (isRegisteredReportContent(content, journalV2)) {
+ return primaryHref;
+ }
+
+ const registeredReportHref = buildReportHrefFromLinkedWork(journalV2?.registeredReport);
+ if (registeredReportHref) return registeredReportHref;
+
+ const registeredReportPostId = journalV2?.registeredReportPostId;
+ if (!registeredReportPostId) return undefined;
+
+ return buildReportHref(registeredReportPostId, journalV2?.registeredReport?.slug);
+}
+
+/** Builds the Preprint tracker link when that stage exists. */
+function buildPreprintHref(
+ content: JournalFeedContent,
+ journalV2: JournalV2FeedMetadata | undefined,
+ primaryHref: string
+): string | undefined {
+ if (isPaperContent(content)) {
+ return primaryHref;
+ }
+
+ return buildHrefFromLinkedWork(journalV2?.preprint, 'paper');
+}
+
+/** Names the current stage represented by this feed entry. */
+function resolveCurrentStageLabel(
+ content: JournalFeedContent,
+ journalV2?: JournalV2FeedMetadata
+): string {
+ if (
+ isPaperContent(content) ||
+ journalV2?.journalState === 'preprint' ||
+ journalV2?.journalState === 'result'
+ ) {
+ return 'Preprint';
+ }
+
+ if (isRegisteredReportContent(content, journalV2)) {
+ return 'Registered Report';
+ }
+
+ return 'Funded Proposal';
+}
+
+/** Normalizes review scores to the x/5 scale shown in journal cards. */
+function normalizeScoreToFive(score: number): number {
+ return score > 5 ? score / 2 : score;
+}
+
+/** Converts transformed reviews to the x/5 score scale used by the journal feed. */
+function buildReviewFromTransformed(review: Review): Review | undefined {
+ const score = readScore(review.score);
+ if (score === undefined) return undefined;
+
+ return {
+ ...review,
+ score: normalizeScoreToFive(score),
+ };
+}
+
+/** Collects reviews from transformed content. */
+function collectReviews(content: JournalFeedContent): Review[] {
+ return Array.isArray(content.reviews)
+ ? content.reviews
+ .map((review) => buildReviewFromTransformed(review))
+ .filter((review): review is Review => !!review)
+ : [];
+}
+
+/** Calculates the average review score shown by the journal feed. */
+function calculateReviewSummary(reviews: Review[]): JournalV2ReviewSummary | undefined {
+ if (reviews.length === 0) return undefined;
+
+ const average = reviews.reduce((sum, review) => sum + review.score, 0) / reviews.length;
+ return {
+ average: Math.round(average * 10) / 10,
+ count: reviews.length,
+ reviews,
+ };
+}
+
+/** Builds the normalized data needed to render one journal v2 feed item. */
+export function buildJournalV2FeedItemViewModel(
+ entry: FeedEntry
+): JournalV2FeedItemViewModel | undefined {
+ const content = entry.content;
+ if (!isPostContent(content) && !isPaperContent(content)) {
+ return undefined;
+ }
+
+ const { journalV2 } = entry;
+ const primaryHref = buildPrimaryHref(content, journalV2);
+ const fundingOpportunityHref = buildFundingOpportunityHref(journalV2);
+ const proposalHref = buildProposalHref(journalV2, content, primaryHref);
+ const registeredReportHref = buildRegisteredReportHref(content, journalV2, primaryHref);
+ const preprintHref = buildPreprintHref(content, journalV2, primaryHref);
+ const imageUrl = isPaperContent(content)
+ ? content.previewImage || content.previewThumbnail || undefined
+ : content.previewImage || content.fundraise?.postImage || undefined;
+
+ return {
+ title: content.title,
+ href: primaryHref,
+ imageUrl,
+ currentStageLabel: resolveCurrentStageLabel(content, journalV2),
+ reviewSummary: calculateReviewSummary(collectReviews(content)),
+ trackerSteps: [
+ {
+ stage: 'funding_opportunity',
+ label: 'Funding Opportunity',
+ href: fundingOpportunityHref,
+ },
+ {
+ stage: 'proposal',
+ label: 'Proposal',
+ href: proposalHref,
+ },
+ {
+ stage: 'registered_report',
+ label: 'Registered Report',
+ href: registeredReportHref,
+ },
+ {
+ stage: 'preprint',
+ label: 'Preprint',
+ href: preprintHref,
+ },
+ ],
+ };
+}
diff --git a/components/tooltips/PeerReviewTooltip.tsx b/components/tooltips/PeerReviewTooltip.tsx
index 1133515d2..5d970fe9f 100644
--- a/components/tooltips/PeerReviewTooltip.tsx
+++ b/components/tooltips/PeerReviewTooltip.tsx
@@ -20,7 +20,10 @@ export function PeerReviewTooltip({ reviews, averageScore, href }: PeerReviewToo
};
const assessedReviews = reviews.filter((r) => r.isAssessed);
- const visibleReviews = assessedReviews.slice(0, MAX_VISIBLE_REVIEWERS);
+ const visibleReviews = (assessedReviews.length > 0 ? assessedReviews : reviews).slice(
+ 0,
+ MAX_VISIBLE_REVIEWERS
+ );
const reviewsHref = href ? (href.endsWith('/reviews') ? href : `${href}/reviews`) : undefined;
return (
diff --git a/hooks/useFeed.ts b/hooks/useFeed.ts
index 39c5d6079..e47b55818 100644
--- a/hooks/useFeed.ts
+++ b/hooks/useFeed.ts
@@ -12,7 +12,7 @@ interface UseFeedOptions {
hubSlug?: string;
contentType?: string;
source?: FeedSource;
- endpoint?: 'feed' | 'funding_feed' | 'grant_feed';
+ endpoint?: 'feed' | 'funding_feed' | 'grant_feed' | 'journal_v2_feed';
fundraiseStatus?: 'OPEN' | 'CLOSED';
grantId?: number;
createdBy?: number;
diff --git a/services/feed.service.ts b/services/feed.service.ts
index 31a8dc658..39bbc4df7 100644
--- a/services/feed.service.ts
+++ b/services/feed.service.ts
@@ -9,6 +9,7 @@ export class FeedService {
private static readonly BASE_PATH = '/api/feed';
private static readonly FUNDING_PATH = '/api/funding_feed';
private static readonly GRANT_PATH = '/api/grant_feed';
+ private static readonly JOURNAL_V2_PATH = '/api/journal_v2_feed';
private static readonly PENDING_MODERATION_PATH = '/api/moderator_feed/pending_moderation';
static async getFeed(params?: {
@@ -18,7 +19,7 @@ export class FeedService {
hubSlug?: string;
contentType?: string;
source?: 'all' | 'researchhub';
- endpoint?: 'feed' | 'funding_feed' | 'grant_feed' | 'pending_moderation';
+ endpoint?: 'feed' | 'funding_feed' | 'grant_feed' | 'journal_v2_feed' | 'pending_moderation';
fundraiseStatus?: 'OPEN' | 'CLOSED';
grantId?: number;
createdBy?: number;
@@ -57,6 +58,7 @@ export class FeedService {
const endpointToPath: Record = {
funding_feed: this.FUNDING_PATH,
grant_feed: this.GRANT_PATH,
+ journal_v2_feed: this.JOURNAL_V2_PATH,
pending_moderation: this.PENDING_MODERATION_PATH,
};
const basePath = endpointToPath[params?.endpoint ?? ''] ?? this.BASE_PATH;
diff --git a/types/feed.ts b/types/feed.ts
index d6affb928..743747050 100644
--- a/types/feed.ts
+++ b/types/feed.ts
@@ -280,6 +280,28 @@ export interface AssociatedGrant {
numApplicants: number;
}
+export interface JournalV2PostIds {
+ grantPostId: number | null;
+ proposalPostId: number | null;
+}
+
+export interface JournalV2LinkedWork {
+ id?: number;
+ postId?: number | null;
+ slug?: string;
+}
+
+export interface JournalV2FeedMetadata {
+ documentType?: string;
+ journalState?: string;
+ postIds?: JournalV2PostIds;
+ proposal?: JournalV2LinkedWork;
+ fundingOpportunity?: JournalV2LinkedWork;
+ registeredReport?: JournalV2LinkedWork;
+ registeredReportPostId?: number;
+ preprint?: JournalV2LinkedWork;
+}
+
export interface FeedEntry {
id: string;
recommendationId: string | null;
@@ -302,6 +324,7 @@ export interface FeedEntry {
externalMetrics?: ExternalMetrics;
nonprofit?: Nonprofit;
associatedGrants?: AssociatedGrant[];
+ journalV2?: JournalV2FeedMetadata;
searchMetadata?: {
highlightedTitle?: string;
highlightedSnippet?: string;
@@ -322,6 +345,10 @@ export interface RawApiFeedEntry {
recommendation_id: string | null;
content_type: string;
content_object: any;
+ post_ids?: {
+ grant_post_id: number | null;
+ proposal_post_id: number | null;
+ };
created_date: string;
action: string;
action_date: string;
@@ -458,6 +485,112 @@ export function getUnifiedDocumentId(content_object: any): string | undefined {
return undefined;
}
+const asRecord = (value: unknown): Record | undefined =>
+ value && typeof value === 'object' && !Array.isArray(value)
+ ? (value as Record)
+ : undefined;
+
+const firstRecord = (...values: unknown[]): Record | undefined => {
+ for (const value of values) {
+ if (Array.isArray(value)) {
+ const record = value.map((item) => asRecord(item)).find(Boolean);
+ if (record) return record;
+ } else {
+ const record = asRecord(value);
+ if (record) return record;
+ }
+ }
+
+ return undefined;
+};
+
+const asNumber = (value: unknown): number | undefined => {
+ const numberValue = Number(value);
+ return Number.isFinite(numberValue) && numberValue > 0 ? numberValue : undefined;
+};
+
+const transformJournalV2LinkedWork = (value: unknown): JournalV2LinkedWork | undefined => {
+ const record = asRecord(value);
+ if (!record) return undefined;
+
+ const id = asNumber(record.id);
+ const postId = asNumber(record.post_id) ?? asNumber(record.postId) ?? null;
+ const slug = typeof record.slug === 'string' && record.slug.length > 0 ? record.slug : undefined;
+
+ if (!id && !postId && !slug) return undefined;
+
+ return {
+ id,
+ postId,
+ slug,
+ };
+};
+
+const transformJournalV2FeedMetadata = (
+ feedEntry: RawApiFeedEntry
+): JournalV2FeedMetadata | undefined => {
+ const contentObject = asRecord(feedEntry.content_object);
+ if (!contentObject) return undefined;
+
+ const fundraise = asRecord(contentObject.fundraise);
+ const fundingOpportunity = firstRecord(
+ contentObject.funding_opportunity,
+ contentObject.fundingOpportunity,
+ contentObject.grant,
+ fundraise?.grant
+ );
+ const registeredReport = firstRecord(
+ asRecord(contentObject.registered_report)?.post,
+ asRecord(contentObject.registeredReport)?.post,
+ contentObject.registered_report,
+ contentObject.registeredReport,
+ contentObject.registered_report_post,
+ contentObject.registeredReportPost
+ );
+ const preprint = firstRecord(
+ contentObject.preprint,
+ contentObject.paper,
+ contentObject.result,
+ contentObject.results
+ );
+
+ const metadata: JournalV2FeedMetadata = {
+ documentType:
+ typeof contentObject.type === 'string' && contentObject.type.length > 0
+ ? contentObject.type
+ : undefined,
+ journalState:
+ typeof contentObject.journal_state === 'string' && contentObject.journal_state.length > 0
+ ? contentObject.journal_state
+ : undefined,
+ postIds: feedEntry.post_ids
+ ? {
+ grantPostId: feedEntry.post_ids.grant_post_id,
+ proposalPostId: feedEntry.post_ids.proposal_post_id,
+ }
+ : undefined,
+ proposal: transformJournalV2LinkedWork(contentObject.proposal),
+ fundingOpportunity: transformJournalV2LinkedWork(fundingOpportunity),
+ registeredReport: transformJournalV2LinkedWork(registeredReport),
+ registeredReportPostId:
+ asNumber(contentObject.registered_report_post_id) ??
+ asNumber(contentObject.registeredReportPostId),
+ preprint: transformJournalV2LinkedWork(preprint),
+ };
+
+ const hasJournalV2Metadata =
+ Boolean(metadata.postIds) ||
+ Boolean(metadata.journalState) ||
+ metadata.documentType === 'REGISTERED_REPORT' ||
+ Boolean(metadata.proposal) ||
+ Boolean(metadata.fundingOpportunity) ||
+ Boolean(metadata.registeredReport) ||
+ Boolean(metadata.registeredReportPostId) ||
+ Boolean(metadata.preprint);
+
+ return hasJournalV2Metadata ? metadata : undefined;
+};
+
export type TransformedContent = Content & BaseTransformed;
export type TransformedFeedEntry = FeedEntry & BaseTransformed;
@@ -931,12 +1064,12 @@ export const transformFeedEntry = (feedEntry: RawApiFeedEntry): FeedEntry => {
: [],
};
- // Add fundraise data if it's a PREREGISTRATION and has fundraise data
- if (isPreregistration && content_object.fundraise) {
+ // Journal v2 registered reports also carry the source proposal fundraise.
+ if (content_object.fundraise) {
try {
postEntry.fundraise = transformFundraise(content_object.fundraise);
} catch (fundraiseError) {
- console.error('Error transforming fundraise for proposal:', fundraiseError);
+ console.error('Error transforming fundraise for post:', fundraiseError);
}
}
@@ -1082,6 +1215,7 @@ export const transformFeedEntry = (feedEntry: RawApiFeedEntry): FeedEntry => {
image: g.image ?? null,
numApplicants: g.num_applicants ?? 0,
})),
+ journalV2: transformJournalV2FeedMetadata(feedEntry),
nonprofit: nonprofit
? {
id: nonprofit.id,