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"""