diff --git a/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.test.tsx b/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.test.tsx
index 865cd5df55..d625c0c4a4 100644
--- a/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.test.tsx
+++ b/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.test.tsx
@@ -6,10 +6,30 @@ import { formatRunDate } from "ol-utilities"
import invariant from "tiny-invariant"
import user from "@testing-library/user-event"
import { renderWithTheme } from "../../test-utils"
+import { AvailabilityEnum } from "api"
+import { factories } from "api/test-utils"
// This is a pipe followed by a zero-width space
const SEPARATOR = "|"
+// Helper function to create a date N days from today
+const daysFromToday = (days: number): string => {
+ const date = new Date()
+ date.setDate(date.getDate() + days)
+ return date.toISOString()
+}
+
+// Helper to format date as "Month DD, YYYY"
+const formatTestDate = (isoDate: string): string => {
+ const date = new Date(isoDate)
+ const options: Intl.DateTimeFormatOptions = {
+ year: "numeric",
+ month: "long",
+ day: "2-digit",
+ }
+ return date.toLocaleDateString("en-US", options)
+}
+
describe("Learning resource info section pricing", () => {
test("Free course, no certificate", () => {
renderWithTheme()
@@ -106,7 +126,12 @@ describe("Learning resource info section start date", () => {
const course = courses.free.dated
const run = course.runs?.[0]
invariant(run)
- const runDate = formatRunDate(run, false)
+ const runDate = formatRunDate(
+ run,
+ false,
+ course.availability,
+ course.best_run_id,
+ )
invariant(runDate)
renderWithTheme()
@@ -115,22 +140,79 @@ describe("Learning resource info section start date", () => {
within(section).getByText(runDate)
})
- test("Uses next_start_date when available", () => {
+ test("Uses best_run_id when available", () => {
+ const run = courses.free.dated.runs?.[0]
+ invariant(run)
+ const startDate = daysFromToday(30)
+ const enrollmentStart = daysFromToday(15)
+ const course = {
+ ...courses.free.dated,
+ availability: AvailabilityEnum.Dated,
+ best_run_id: 1,
+ runs: [
+ {
+ ...run,
+ id: 1,
+ start_date: startDate,
+ enrollment_start: enrollmentStart,
+ },
+ ],
+ }
+ renderWithTheme()
+
+ const section = screen.getByTestId("drawer-info-items")
+ within(section).getByText("Starts:")
+ within(section).getByText(formatTestDate(startDate))
+ })
+
+ test("Shows run date when best_run_id matches a run", () => {
+ const startDate = daysFromToday(45)
+ const run = factories.learningResources.run({
+ id: 1,
+ start_date: startDate,
+ enrollment_start: null,
+ })
const course = {
...courses.free.dated,
- next_start_date: "2024-03-15T00:00:00Z",
+ best_run_id: 1,
+ runs: [run],
}
renderWithTheme()
const section = screen.getByTestId("drawer-info-items")
within(section).getByText("Starts:")
- within(section).getByText("March 15, 2024")
+ within(section).getByText(formatTestDate(startDate))
})
- test("Falls back to run date when next_start_date is null", () => {
+ test("Uses enrollment_start when it is later than start_date", () => {
+ const run = courses.free.dated.runs?.[0]
+ invariant(run)
+ const startDate = daysFromToday(30)
+ const enrollmentStart = daysFromToday(40) // Later than start_date
const course = {
...courses.free.dated,
- next_start_date: null,
+ availability: AvailabilityEnum.Dated,
+ best_run_id: 1,
+ runs: [
+ {
+ ...run,
+ id: 1,
+ start_date: startDate,
+ enrollment_start: enrollmentStart,
+ },
+ ],
+ }
+ renderWithTheme()
+
+ const section = screen.getByTestId("drawer-info-items")
+ within(section).getByText("Starts:")
+ within(section).getByText(formatTestDate(enrollmentStart))
+ })
+
+ test("Falls back to null when best_run_id does not match any run", () => {
+ const course = {
+ ...courses.free.dated,
+ best_run_id: 999,
}
const run = course.runs?.[0]
invariant(run)
@@ -141,14 +223,88 @@ describe("Learning resource info section start date", () => {
const section = screen.getByTestId("drawer-info-items")
within(section).getByText("Starts:")
within(section).getByText(runDate)
- expect(within(section).queryByText("March 15, 2024")).toBeNull()
+ })
+
+ test("Shows today's date when best run start date is in the past", () => {
+ const run = courses.free.dated.runs?.[0]
+ invariant(run)
+ const pastStartDate = daysFromToday(-30) // 30 days ago
+ const pastEnrollmentStart = daysFromToday(-45) // 45 days ago
+ const todayDate = new Date().toISOString()
+ const course = {
+ ...courses.free.dated,
+ availability: AvailabilityEnum.Dated,
+ best_run_id: 1,
+ runs: [
+ {
+ ...run,
+ id: 1,
+ start_date: pastStartDate,
+ enrollment_start: pastEnrollmentStart,
+ },
+ ],
+ }
+ renderWithTheme()
+
+ const section = screen.getByTestId("drawer-info-items")
+ within(section).getByText("Starts:")
+ within(section).getByText(formatTestDate(todayDate))
+ })
+
+ test("Shows no start date when best_run_id is null", () => {
+ const run = courses.free.dated.runs?.[0]
+ invariant(run)
+ const course = {
+ ...courses.free.dated,
+ best_run_id: null,
+ runs: [
+ {
+ ...run,
+ id: 1,
+ start_date: null,
+ enrollment_start: null,
+ },
+ ],
+ }
+ renderWithTheme()
+
+ const section = screen.getByTestId("drawer-info-items")
+ // Should not show a start date section at all when best run is null and no dates exist
+ expect(within(section).queryByText("Starts:")).toBeNull()
+ })
+
+ test("Shows no start date when best run has null dates", () => {
+ const run = courses.free.dated.runs?.[0]
+ invariant(run)
+ const course = {
+ ...courses.free.dated,
+ best_run_id: 1,
+ runs: [
+ {
+ ...run,
+ id: 1,
+ start_date: null,
+ end_date: null,
+ },
+ ],
+ }
+ renderWithTheme()
+
+ const section = screen.getByTestId("drawer-info-items")
+ // Should not show a start date section when best run has null dates
+ expect(within(section).queryByText("Starts:")).toBeNull()
})
test("As taught in date(s)", () => {
const course = courses.free.anytime
const run = course.runs?.[0]
invariant(run)
- const runDate = formatRunDate(run, true)
+ const runDate = formatRunDate(
+ run,
+ true,
+ course.availability,
+ course.best_run_id,
+ )
invariant(runDate)
renderWithTheme()
@@ -168,7 +324,9 @@ describe("Learning resource info section start date", () => {
}
return 0
})
- .map((run) => formatRunDate(run, false))
+ .map((run) =>
+ formatRunDate(run, false, course.availability, course.best_run_id),
+ )
.slice(0, 2)
.join(SEPARATOR)}Show more`
invariant(expectedDateText)
@@ -180,31 +338,6 @@ describe("Learning resource info section start date", () => {
})
})
- test("Multiple run dates with next_start_date uses next_start_date as first date", () => {
- const course = {
- ...courses.multipleRuns.sameData,
- next_start_date: "2024-01-15T00:00:00Z",
- }
- const sortedDates = course.runs
- ?.sort((a, b) => {
- if (a?.start_date && b?.start_date) {
- return Date.parse(a.start_date) - Date.parse(b.start_date)
- }
- return 0
- })
- .map((run) => formatRunDate(run, false))
- .filter((date) => date !== null)
-
- // First date should be next_start_date, second should be original second date
- const expectedDateText = `January 15, 2024${SEPARATOR}${sortedDates?.[1]}Show more`
- renderWithTheme()
-
- const section = screen.getByTestId("drawer-info-items")
- within(section).getAllByText((_content, node) => {
- return node?.textContent === expectedDateText || false
- })
- })
-
test("If data is different then dates, formats, locations and prices are not shown", () => {
const course = courses.multipleRuns.differentData
renderWithTheme()
@@ -227,11 +360,8 @@ describe("Learning resource info section start date", () => {
expect(runDates.children.length).toBe(totalRuns + 1)
})
- test("Anytime courses with next_start_date should not replace first date in 'As taught in' section", () => {
- const course = {
- ...courses.free.anytime,
- next_start_date: "2024-03-15T00:00:00Z",
- }
+ test("Anytime courses show 'Anytime' and semester/year in 'As taught in' section", () => {
+ const course = courses.free.anytime
renderWithTheme()
@@ -242,14 +372,17 @@ describe("Learning resource info section start date", () => {
within(section).getByText("As taught in:")
- expect(within(section).queryByText("March 15, 2024")).toBeNull()
-
const runDates = within(section).getByTestId("drawer-run-dates")
expect(runDates).toBeInTheDocument()
const firstRun = course.runs?.[0]
invariant(firstRun)
- const firstRunDate = formatRunDate(firstRun, true)
+ const firstRunDate = formatRunDate(
+ firstRun,
+ true,
+ course.availability,
+ course.best_run_id,
+ )
invariant(firstRunDate)
expect(within(section).getByText(firstRunDate)).toBeInTheDocument()
})
diff --git a/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.tsx b/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.tsx
index e120fa771e..8225ece2d9 100644
--- a/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.tsx
+++ b/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.tsx
@@ -31,7 +31,6 @@ import {
getLearningResourcePrices,
showStartAnytime,
NoSSR,
- formatDate,
} from "ol-utilities"
import { theme, Link } from "ol-components"
import DifferingRunsTable from "./DifferingRunsTable"
@@ -173,7 +172,14 @@ const InfoItemValue: React.FC = ({
const totalRunsWithDates = (resource: LearningResource) => {
return (
resource.runs
- ?.map((run) => formatRunDate(run, showStartAnytime(resource)))
+ ?.map((run) =>
+ formatRunDate(
+ run,
+ showStartAnytime(resource),
+ resource.availability,
+ resource.best_run_id,
+ ),
+ )
.filter((date) => date !== null).length || 0
)
}
@@ -181,24 +187,19 @@ const totalRunsWithDates = (resource: LearningResource) => {
const RunDates: React.FC<{ resource: LearningResource }> = ({ resource }) => {
const [showingMore, setShowingMore] = useState(false)
const anytime = showStartAnytime(resource)
- let sortedDates = resource.runs
+
+ const sortedDates = resource.runs
?.sort((a, b) => {
if (a?.start_date && b?.start_date) {
return Date.parse(a.start_date) - Date.parse(b.start_date)
}
return 0
})
- .map((run) => formatRunDate(run, anytime))
+ .map((run) =>
+ formatRunDate(run, anytime, resource.availability, resource.best_run_id),
+ )
.filter((date) => date !== null)
- const nextStartDate = resource.next_start_date
- ? formatDate(resource.next_start_date, "MMMM DD, YYYY")
- : null
-
- if (sortedDates && nextStartDate && !anytime) {
- // Replace the first date with next_start_date
- sortedDates = [nextStartDate, ...sortedDates.slice(1)]
- }
if (!sortedDates || sortedDates.length === 0) {
return null
}
diff --git a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.test.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.test.tsx
index 83176b0652..930dbd1ff2 100644
--- a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.test.tsx
+++ b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.test.tsx
@@ -8,6 +8,24 @@ import { factories } from "api/test-utils"
import { getByImageSrc } from "ol-test-utilities"
import { renderWithTheme } from "../../test-utils"
+// Helper function to create a date N days from today
+const daysFromToday = (days: number): string => {
+ const date = new Date()
+ date.setDate(date.getDate() + days)
+ return date.toISOString()
+}
+
+// Helper to format date as "Month DD, YYYY"
+const formatTestDate = (isoDate: string): string => {
+ const date = new Date(isoDate)
+ const options: Intl.DateTimeFormatOptions = {
+ year: "numeric",
+ month: "long",
+ day: "2-digit",
+ }
+ return date.toLocaleDateString("en-US", options)
+}
+
const setup = (props: LearningResourceCardProps) => {
// TODO Browser Router will need to be replaced with a Next.js router mock or alternative strategy
return renderWithTheme()
@@ -20,9 +38,16 @@ describe("Learning Resource Card", () => {
])(
"Renders resource type, title and start date as a labeled article",
({ resourceType, expectedLabel }) => {
+ const startDate = daysFromToday(30)
+ const run = factories.learningResources.run({
+ id: 1,
+ start_date: startDate,
+ enrollment_start: null,
+ })
const resource = factories.learningResources.resource({
resource_type: resourceType,
- next_start_date: "2026-01-01",
+ best_run_id: 1,
+ runs: [run],
})
setup({ resource })
@@ -34,7 +59,7 @@ describe("Learning Resource Card", () => {
within(card).getByText(expectedLabel)
within(card).getByText(resource.title)
within(card).getByText("Starts:")
- within(card).getByText("January 01, 2026")
+ within(card).getByText(formatTestDate(startDate))
},
)
@@ -55,20 +80,75 @@ describe("Learning Resource Card", () => {
})
test("Displays run start date", () => {
+ const startDate = daysFromToday(45)
+ const run = factories.learningResources.run({
+ id: 1,
+ start_date: startDate,
+ })
const resource = factories.learningResources.resource({
resource_type: ResourceTypeEnum.Course,
- next_start_date: null,
- runs: [
- factories.learningResources.run({
- start_date: "2026-01-01",
- }),
- ],
+ best_run_id: 1,
+ runs: [run],
+ })
+
+ setup({ resource })
+
+ screen.getByText("Starts:")
+ screen.getByText(formatTestDate(startDate))
+ })
+
+ test("Shows today's date when best run start date is in the past", () => {
+ const pastStartDate = daysFromToday(-30) // 30 days ago
+ const todayDate = new Date().toISOString()
+ const run = factories.learningResources.run({
+ id: 1,
+ start_date: pastStartDate,
+ enrollment_start: null,
+ })
+ const resource = factories.learningResources.resource({
+ resource_type: ResourceTypeEnum.Course,
+ best_run_id: 1,
+ runs: [run],
})
setup({ resource })
screen.getByText("Starts:")
- screen.getByText("January 01, 2026")
+ screen.getByText(formatTestDate(todayDate))
+ })
+
+ test("Shows no start date when best_run_id is null", () => {
+ const run = factories.learningResources.run({
+ id: 1,
+ start_date: null,
+ enrollment_start: null,
+ })
+ const resource = factories.learningResources.resource({
+ resource_type: ResourceTypeEnum.Course,
+ best_run_id: null,
+ runs: [run],
+ })
+
+ setup({ resource })
+
+ expect(screen.queryByText("Starts:")).toBeNull()
+ })
+
+ test("Shows no start date when best run has null dates", () => {
+ const run = factories.learningResources.run({
+ id: 1,
+ start_date: null,
+ enrollment_start: null,
+ })
+ const resource = factories.learningResources.resource({
+ resource_type: ResourceTypeEnum.Course,
+ best_run_id: 1,
+ runs: [run],
+ })
+
+ setup({ resource })
+
+ expect(screen.queryByText("Starts:")).toBeNull()
})
test.each([
diff --git a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.tsx
index 6e31b21d08..53040127e7 100644
--- a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.tsx
+++ b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.tsx
@@ -14,7 +14,7 @@ import {
getReadableResourceType,
DEFAULT_RESOURCE_IMG,
getLearningResourcePrices,
- getResourceDate,
+ getBestResourceStartDate,
showStartAnytime,
getResourceLanguage,
} from "ol-utilities"
@@ -143,7 +143,7 @@ const StartDate: React.FC<{ resource: LearningResource; size?: Size }> = ({
size,
}) => {
const anytime = showStartAnytime(resource)
- const startDate = getResourceDate(resource)
+ const startDate = getBestResourceStartDate(resource)
const format = size === "small" ? "MMM DD, YYYY" : "MMMM DD, YYYY"
const formatted = anytime
? "Anytime"
diff --git a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.test.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.test.tsx
index b1cdba10dd..3b4189808e 100644
--- a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.test.tsx
+++ b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.test.tsx
@@ -8,6 +8,24 @@ import { factories } from "api/test-utils"
import { getByImageSrc } from "ol-test-utilities"
import { renderWithTheme } from "../../test-utils"
+// Helper function to create a date N days from today
+const daysFromToday = (days: number): string => {
+ const date = new Date()
+ date.setDate(date.getDate() + days)
+ return date.toISOString()
+}
+
+// Helper to format date as "Month DD, YYYY"
+const formatTestDate = (isoDate: string): string => {
+ const date = new Date(isoDate)
+ const options: Intl.DateTimeFormatOptions = {
+ year: "numeric",
+ month: "long",
+ day: "2-digit",
+ }
+ return date.toLocaleDateString("en-US", options)
+}
+
const setup = (props: LearningResourceListCardProps) => {
return renderWithTheme()
}
@@ -19,9 +37,16 @@ describe("Learning Resource List Card", () => {
])(
"Renders resource type, title and start date as a labeled article",
({ resourceType, expectedLabel }) => {
+ const startDate = daysFromToday(30)
+ const run = factories.learningResources.run({
+ id: 1,
+ start_date: startDate,
+ enrollment_start: null,
+ })
const resource = factories.learningResources.resource({
resource_type: resourceType,
- next_start_date: "2026-01-01",
+ best_run_id: 1,
+ runs: [run],
})
setup({ resource })
@@ -33,7 +58,7 @@ describe("Learning Resource List Card", () => {
within(card).getByText(expectedLabel)
within(card).getByText(resource.title)
within(card).getByText("Starts:")
- within(card).getByText("January 01, 2026")
+ within(card).getByText(formatTestDate(startDate))
},
)
@@ -54,20 +79,75 @@ describe("Learning Resource List Card", () => {
})
test("Displays run start date", () => {
+ const startDate = daysFromToday(45)
+ const run = factories.learningResources.run({
+ id: 1,
+ start_date: startDate,
+ })
const resource = factories.learningResources.resource({
resource_type: ResourceTypeEnum.Course,
- next_start_date: null,
- runs: [
- factories.learningResources.run({
- start_date: "2026-01-01",
- }),
- ],
+ best_run_id: 1,
+ runs: [run],
+ })
+
+ setup({ resource })
+
+ screen.getByText("Starts:")
+ screen.getByText(formatTestDate(startDate))
+ })
+
+ test("Shows today's date when best run start date is in the past", () => {
+ const pastStartDate = daysFromToday(-30) // 30 days ago
+ const todayDate = new Date().toISOString()
+ const run = factories.learningResources.run({
+ id: 1,
+ start_date: pastStartDate,
+ enrollment_start: null,
+ })
+ const resource = factories.learningResources.resource({
+ resource_type: ResourceTypeEnum.Course,
+ best_run_id: 1,
+ runs: [run],
})
setup({ resource })
screen.getByText("Starts:")
- screen.getByText("January 01, 2026")
+ screen.getByText(formatTestDate(todayDate))
+ })
+
+ test("Shows no start date when best_run_id is null", () => {
+ const run = factories.learningResources.run({
+ id: 1,
+ start_date: null,
+ enrollment_start: null,
+ })
+ const resource = factories.learningResources.resource({
+ resource_type: ResourceTypeEnum.Course,
+ best_run_id: null,
+ runs: [run],
+ })
+
+ setup({ resource })
+
+ expect(screen.queryByText("Starts:")).toBeNull()
+ })
+
+ test("Shows no start date when best run has null dates", () => {
+ const run = factories.learningResources.run({
+ id: 1,
+ start_date: null,
+ enrollment_start: null,
+ })
+ const resource = factories.learningResources.resource({
+ resource_type: ResourceTypeEnum.Course,
+ best_run_id: 1,
+ runs: [run],
+ })
+
+ setup({ resource })
+
+ expect(screen.queryByText("Starts:")).toBeNull()
})
test.each([
diff --git a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.tsx
index bf02f48950..c7218d2362 100644
--- a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.tsx
+++ b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.tsx
@@ -14,7 +14,7 @@ import {
DEFAULT_RESOURCE_IMG,
pluralize,
getLearningResourcePrices,
- getResourceDate,
+ getBestResourceStartDate,
showStartAnytime,
getResourceLanguage,
} from "ol-utilities"
@@ -165,7 +165,7 @@ export const StartDate: React.FC<{ resource: LearningResource }> = ({
resource,
}) => {
const anytime = showStartAnytime(resource)
- const startDate = getResourceDate(resource)
+ const startDate = getBestResourceStartDate(resource)
const formatted = anytime
? "Anytime"
: startDate &&
diff --git a/frontends/ol-utilities/src/learning-resources/learning-resources.test.ts b/frontends/ol-utilities/src/learning-resources/learning-resources.test.ts
index 8820912322..d532e84333 100644
--- a/frontends/ol-utilities/src/learning-resources/learning-resources.test.ts
+++ b/frontends/ol-utilities/src/learning-resources/learning-resources.test.ts
@@ -1,86 +1,8 @@
-import { allRunsAreIdentical, findBestRun } from "./learning-resources"
+import { allRunsAreIdentical } from "./learning-resources"
import * as factories from "api/test-utils/factories"
-import { faker } from "@faker-js/faker/locale/en"
import { CourseResourceDeliveryInnerCodeEnum } from "api"
const makeRun = factories.learningResources.run
-const fromNow = (days: number): string => {
- const date = new Date()
- date.setDate(date.getDate() + days)
- return date.toISOString()
-}
-
-const { shuffle } = faker.helpers
-
-describe("findBestRun", () => {
- const future = makeRun({
- start_date: fromNow(5),
- end_date: fromNow(30),
- title: "future",
- })
- const farFuture = makeRun({
- start_date: fromNow(50),
- end_date: fromNow(80),
- title: "farFuture",
- })
- const past = makeRun({
- start_date: fromNow(-30),
- end_date: fromNow(-5),
- title: "past",
- })
- const farPast = makeRun({
- start_date: fromNow(-70),
- end_date: fromNow(-60),
- title: "farPast",
- })
- const current1 = makeRun({
- start_date: fromNow(-5),
- end_date: fromNow(10),
- title: "current1",
- })
- const current2 = makeRun({
- start_date: fromNow(-10),
- end_date: fromNow(5),
- title: "current2",
- })
- const undated = makeRun({
- start_date: null,
- end_date: null,
- title: "undated",
- })
-
- it("returns undefined if no runs", () => {
- expect(findBestRun([])).toBeUndefined()
- })
-
- it("Picks current run if available", () => {
- const runs = [past, current1, current2, future, farFuture, undated]
- const expected = current1
- const actual = findBestRun(shuffle(runs))
- expect(actual).toEqual(expected)
- })
-
- it("Picks future if no current runs", () => {
- const runs = [farPast, past, future, farFuture, undated]
- const expected = future
- const actual = findBestRun(shuffle(runs))
- expect(actual).toEqual(expected)
- })
-
- it("Picks recent past if no future or current", () => {
- const runs = [past, farPast, undated]
- const expected = past
- const actual = findBestRun(shuffle(runs))
- expect(actual).toEqual(expected)
- })
-
- test("undated OK as last resort", () => {
- const runs = [undated]
- const expected = undated
- const actual = findBestRun(shuffle(runs))
- expect(actual).toEqual(expected)
- })
-})
describe("allRunsAreIdentical", () => {
test("returns true if no runs", () => {
diff --git a/frontends/ol-utilities/src/learning-resources/learning-resources.ts b/frontends/ol-utilities/src/learning-resources/learning-resources.ts
index f5cf709935..f3ace3c1d6 100644
--- a/frontends/ol-utilities/src/learning-resources/learning-resources.ts
+++ b/frontends/ol-utilities/src/learning-resources/learning-resources.ts
@@ -1,4 +1,3 @@
-import moment from "moment"
import type { LearningResource, LearningResourceRun } from "api"
import { DeliveryEnum, ResourceTypeEnum } from "api"
import { capitalize } from "lodash"
@@ -42,62 +41,11 @@ const resourceThumbnailSrc = (
config: EmbedlyConfig,
) => embedlyCroppedImage(image?.url ?? DEFAULT_RESOURCE_IMG, config)
-const DATE_FORMAT = "YYYY-MM-DD[T]HH:mm:ss[Z]"
-/**
- * Parse date string into a moment object.
- *
- * If date is null or undefined, a Moment object is returned.
- * Invalid dates return false for all comparisons.
- */
-const asMoment = (date?: string | null) => moment(date, DATE_FORMAT)
-const isCurrent = (run: LearningResourceRun) =>
- asMoment(run.start_date).isSameOrBefore() && asMoment(run.end_date).isAfter()
-
-/**
- * Sort dates descending, with invalid dates last.
- */
-const datesDescendingSort = (
- aString: string | null | undefined,
- bString: string | null | undefined,
-) => {
- const a = asMoment(aString)
- const b = asMoment(bString)
- // if both invalid, tie
- if (!a.isValid() && !b.isValid()) return 0
- // if only one invalid, the other is better
- if (!a.isValid()) return 1
- if (!b.isValid()) return -1
- // if both valid, sort descending
- return -a.diff(b)
-}
-
-/**
- * Find "best" running: prefer current, then nearest future, then nearest past.
- */
-const findBestRun = (
- runs: LearningResourceRun[],
-): LearningResourceRun | undefined => {
- const sorted = runs.sort((a, b) =>
- datesDescendingSort(a.start_date, b.start_date),
- )
-
- const current = sorted.find(isCurrent)
- if (current) return current
-
- // Closest to now will be last in the sorted array
- const future = sorted.filter((run) =>
- asMoment(run.start_date).isSameOrAfter(),
- )
- if (future.length > 0) return future[future.length - 1]
-
- // Closest to now will be first in the sorted array
- const past = sorted.filter((run) => asMoment(run.start_date).isBefore())
- return past[0] ?? sorted[0]
-}
-
const formatRunDate = (
run: LearningResourceRun,
asTaughtIn: boolean,
+ availability?: string | null,
+ bestRunId?: number | null,
): string | null => {
if (asTaughtIn) {
const semester = capitalize(run.semester ?? "")
@@ -111,6 +59,32 @@ const formatRunDate = (
return formatDate(run.start_date, "MMMM, YYYY")
}
}
+
+ // For the best run in dated resources, use special logic
+ if (run.id === bestRunId && availability === "dated" && !asTaughtIn) {
+ if (!run.start_date && !run.enrollment_start) return null
+
+ // Get the max of start_date and enrollment_start
+ let bestStart: string
+ if (run.start_date && run.enrollment_start) {
+ bestStart =
+ Date.parse(run.start_date) > Date.parse(run.enrollment_start)
+ ? run.start_date
+ : run.enrollment_start
+ } else {
+ bestStart = (run.start_date || run.enrollment_start)!
+ }
+
+ // If the best start date is in the future, show it; otherwise show today
+ const now = new Date()
+ const bestStartDate = new Date(bestStart)
+ if (bestStartDate > now) {
+ return formatDate(bestStart, "MMMM DD, YYYY")
+ } else {
+ return formatDate(new Date().toISOString(), "MMMM DD, YYYY")
+ }
+ }
+
if (run.start_date) {
return formatDate(run.start_date, "MMMM DD, YYYY")
}
@@ -168,7 +142,6 @@ export {
embedlyCroppedImage,
resourceThumbnailSrc,
getReadableResourceType,
- findBestRun,
formatRunDate,
allRunsAreIdentical,
getResourceLanguage,
diff --git a/frontends/ol-utilities/src/learning-resources/pricing.ts b/frontends/ol-utilities/src/learning-resources/pricing.ts
index 7b13bd0e75..17a58e4f27 100644
--- a/frontends/ol-utilities/src/learning-resources/pricing.ts
+++ b/frontends/ol-utilities/src/learning-resources/pricing.ts
@@ -4,8 +4,8 @@ import {
LearningResourceRun,
ResourceTypeEnum,
} from "api"
-import { findBestRun } from "ol-utilities"
import getSymbolFromCurrency from "currency-symbol-map"
+import moment from "moment"
/*
* This constant represents the value displayed when a course is free.
@@ -123,11 +123,35 @@ export const showStartAnytime = (resource: LearningResource) => {
)
}
-export const getResourceDate = (resource: LearningResource): string | null => {
- const startDate =
- resource.next_start_date ?? findBestRun(resource.runs ?? [])?.start_date
+/**
+ * Gets the best start date for a learning resource based on best_run_id.
+ * Returns the max of start_date and enrollment_start from the best run.
+ * Returns null if best_run_id is null, run not found, or both dates are null.
+ */
+export const getBestResourceStartDate = (
+ resource: LearningResource,
+): string | null => {
+ const bestRun = resource.runs?.find((run) => run.id === resource.best_run_id)
+ if (!bestRun) return null
+
+ if (!bestRun.start_date && !bestRun.enrollment_start) return null
+
+ let bestStart: string
+ if (bestRun.start_date && bestRun.enrollment_start) {
+ bestStart =
+ Date.parse(bestRun.start_date) > Date.parse(bestRun.enrollment_start)
+ ? bestRun.start_date
+ : bestRun.enrollment_start
+ } else {
+ bestStart = (bestRun.start_date || bestRun.enrollment_start)!
+ }
+
+ const currentDate = moment()
+ const bestStartMoment = moment(bestStart)
- return startDate ?? null
+ return bestStartMoment.isAfter(currentDate)
+ ? bestStart
+ : currentDate.toISOString()
}
export const getCurrencySymbol = (currencyCode: string) => {
diff --git a/learning_resources/etl/loaders.py b/learning_resources/etl/loaders.py
index 301eeede58..57d9bc65f0 100644
--- a/learning_resources/etl/loaders.py
+++ b/learning_resources/etl/loaders.py
@@ -46,7 +46,6 @@
Video,
VideoChannel,
VideoPlaylist,
- now_in_utc,
)
from learning_resources.utils import (
add_parent_topics_to_learning_resource,
@@ -139,12 +138,8 @@ def load_run_dependent_values(
Returns:
tuple[datetime.time | None, list[Decimal], str]: date, prices, and availability
"""
- now = now_in_utc()
best_run = resource.best_run
if resource.published and best_run:
- resource.next_start_date = max(
- best_run.start_date or best_run.enrollment_start or now, now
- )
resource.availability = best_run.availability
resource.prices = (
best_run.prices
@@ -163,6 +158,13 @@ def load_run_dependent_values(
resource.time_commitment = best_run.time_commitment
resource.min_weekly_hours = best_run.min_weekly_hours
resource.max_weekly_hours = best_run.max_weekly_hours
+ next_run = resource.next_run
+ if resource.published and next_run:
+ resource.next_start_date = (
+ max(filter(None, [next_run.start_date, next_run.enrollment_start]))
+ if next_run.start_date or next_run.enrollment_start
+ else None
+ )
else:
resource.next_start_date = None
resource.save()
diff --git a/learning_resources/etl/loaders_test.py b/learning_resources/etl/loaders_test.py
index 2af2c9b3a2..b9d9bd19c8 100644
--- a/learning_resources/etl/loaders_test.py
+++ b/learning_resources/etl/loaders_test.py
@@ -382,7 +382,7 @@ def test_load_program_bad_platform(mocker):
@pytest.mark.parametrize("delivery", [LearningResourceDelivery.hybrid.name, None])
@pytest.mark.parametrize("has_upcoming_run", [True, False])
@pytest.mark.parametrize("has_departments", [True, False])
-def test_load_course( # noqa: PLR0913,PLR0912,PLR0915, C901
+def test_load_course( # noqa: PLR0913, PLR0912, PLR0915
mock_upsert_tasks,
course_exists,
is_published,
@@ -497,11 +497,10 @@ def test_load_course( # noqa: PLR0913,PLR0912,PLR0915, C901
result = load_course(props, blocklist, [], config=CourseLoaderConfig(prune=True))
assert result.professional is True
- if is_published and is_run_published and not blocklisted:
- if has_upcoming_run:
- assert result.next_start_date == start_date
- else:
- assert result.next_start_date.date() == now.date()
+ if is_published and is_run_published and not blocklisted and has_upcoming_run:
+ assert result.next_start_date == start_date
+ else:
+ assert result.next_start_date is None
assert result.prices == (
[Decimal("0.00"), Decimal("49.00")]
if is_run_published and result.certification
@@ -1748,15 +1747,17 @@ def test_load_run_dependent_values(certification):
course = LearningResourceFactory.create(
is_course=True, certification=certification, runs=[]
)
+ assert course.runs.count() == 0
closest_date = now_in_utc() + timedelta(days=1)
furthest_date = now_in_utc() + timedelta(days=2)
- run = LearningResourceRunFactory.create(
+ best_run = LearningResourceRunFactory.create(
learning_resource=course,
published=True,
availability=Availability.dated.name,
prices=[Decimal("0.00"), Decimal("20.00")],
resource_prices=LearningResourcePriceFactory.create_batch(2),
start_date=closest_date,
+ enrollment_start=None,
location="Portland, ME",
duration="3 - 4 weeks",
min_weeks=3,
@@ -1772,6 +1773,7 @@ def test_load_run_dependent_values(certification):
prices=[Decimal("0.00"), Decimal("50.00")],
resource_prices=LearningResourcePriceFactory.create_batch(2),
start_date=furthest_date,
+ enrollment_start=None,
location="Portland, OR",
duration="7 - 9 weeks",
min_weeks=7,
@@ -1781,15 +1783,23 @@ def test_load_run_dependent_values(certification):
max_weekly_hours=19,
)
result = load_run_dependent_values(course)
- assert result.next_start_date == course.next_start_date == closest_date
- assert result.prices == course.prices == ([] if not certification else run.prices)
+ course.refresh_from_db()
+ assert (
+ result.prices == course.prices == ([] if not certification else best_run.prices)
+ )
+ assert (
+ result.next_start_date
+ == course.next_start_date
+ == best_run.start_date
+ == closest_date
+ )
assert (
list(result.resource_prices)
== list(course.resource_prices.all())
- == ([] if not certification else list(run.resource_prices.all()))
+ == ([] if not certification else list(best_run.resource_prices.all()))
)
assert result.availability == course.availability == Availability.dated.name
- assert result.location == course.location == run.location
+ assert result.location == course.location == best_run.location
for key in [
"duration",
"time_commitment",
@@ -1798,7 +1808,7 @@ def test_load_run_dependent_values(certification):
"min_weekly_hours",
"max_weekly_hours",
]:
- assert getattr(result, key) == getattr(course, key) == getattr(run, key)
+ assert getattr(result, key) == getattr(course, key) == getattr(best_run, key)
def test_load_run_dependent_values_resets_next_start_date():
@@ -1829,6 +1839,57 @@ def test_load_run_dependent_values_resets_next_start_date():
assert course.next_start_date is None
+@pytest.mark.parametrize(
+ ("has_start_date", "has_enrollment_start", "expect_next_start_date"),
+ [
+ (True, True, True),
+ (True, False, True),
+ (False, True, False), # next_run requires start_date > now
+ (False, False, False),
+ ],
+)
+def test_load_run_dependent_values_next_start_date(
+ has_start_date, has_enrollment_start, expect_next_start_date
+):
+ """Test that next_start_date is correctly set from the next_run (future runs only)"""
+ course = LearningResourceFactory.create(is_course=True, published=True, runs=[])
+
+ now = now_in_utc()
+ future_start = now + timedelta(days=30)
+ future_enrollment_start = now + timedelta(days=15)
+
+ # Create a run with future dates
+ LearningResourceRunFactory.create(
+ learning_resource=course,
+ published=True,
+ start_date=future_start if has_start_date else None,
+ enrollment_start=future_enrollment_start if has_enrollment_start else None,
+ )
+
+ # Call load_run_dependent_values
+ result = load_run_dependent_values(course)
+
+ # Refresh course from database
+ course.refresh_from_db()
+
+ # Verify that next_start_date is set correctly
+ if expect_next_start_date:
+ # next_start_date should be the max of start_date and enrollment_start
+ expected_date = max(
+ filter(
+ None,
+ [
+ future_start if has_start_date else None,
+ future_enrollment_start if has_enrollment_start else None,
+ ],
+ )
+ )
+ assert result.next_start_date == expected_date
+ else:
+ # No future dates, so next_start_date should be None
+ assert result.next_start_date is None
+
+
@pytest.mark.parametrize(
("is_scholar_course", "tag_counts", "expected_score"),
[
diff --git a/learning_resources/models.py b/learning_resources/models.py
index 33317c4169..2b8e8e89ba 100644
--- a/learning_resources/models.py
+++ b/learning_resources/models.py
@@ -535,6 +535,15 @@ def audience(self) -> str | None:
return self.platform.audience
return None
+ @cached_property
+ def next_run(self) -> Optional["LearningResourceRun"]:
+ """Returns the next run for the learning resource"""
+ return (
+ self.runs.filter(Q(published=True) & Q(start_date__gt=timezone.now()))
+ .order_by("start_date")
+ .first()
+ )
+
@cached_property
def best_run(self) -> Optional["LearningResourceRun"]:
"""Returns the most current/upcoming enrollable run for the learning resource"""