From 11c5703e9fd1c912e9600aa4a4982992c546430c Mon Sep 17 00:00:00 2001 From: Matt Bertrand Date: Wed, 12 Nov 2025 14:38:00 -0500 Subject: [PATCH 1/6] Refactor next_start_date, add best_run_id --- learning_resources/etl/loaders.py | 8 +-- learning_resources/etl/loaders_test.py | 71 +++++++++++++++++++++----- learning_resources/views.py | 27 ++++++++-- 3 files changed, 86 insertions(+), 20 deletions(-) diff --git a/learning_resources/etl/loaders.py b/learning_resources/etl/loaders.py index 301eeede58..fd41c89284 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,11 +138,12 @@ 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.next_start_date = ( + max(filter(None, [best_run.start_date, best_run.enrollment_start])) + if best_run.start_date or best_run.enrollment_start + else None ) resource.availability = best_run.availability resource.prices = ( diff --git a/learning_resources/etl/loaders_test.py b/learning_resources/etl/loaders_test.py index 2af2c9b3a2..0b70d899d1 100644 --- a/learning_resources/etl/loaders_test.py +++ b/learning_resources/etl/loaders_test.py @@ -1,6 +1,6 @@ """Tests for ETL loaders""" -from datetime import timedelta +from datetime import datetime, timedelta from decimal import Decimal from pathlib import Path @@ -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, @@ -498,10 +498,9 @@ def test_load_course( # noqa: PLR0913,PLR0912,PLR0915, C901 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() + 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,43 @@ def test_load_run_dependent_values_resets_next_start_date(): assert course.next_start_date is None +@pytest.mark.parametrize( + ("start_dt", "enrollnment_start", "expected_next"), + [ + ("2025-01-01T10:00:00Z", "2024-12-15T10:00:00Z", "2025-01-01T10:00:00Z"), + ("2025-01-01T10:00:00Z", None, "2025-01-01T10:00:00Z"), + (None, "2024-12-15T10:00:00Z", "2024-12-15T10:00:00Z"), + (None, None, None), + ], +) +def test_load_run_dependent_values_next_start_date( + start_dt, enrollnment_start, expected_next +): + """Test that next_start_date is correctly set from the best_run""" + course = LearningResourceFactory.create(is_course=True, published=True, runs=[]) + + # Create multiple runs with different start dates + LearningResourceRunFactory.create( + learning_resource=course, + published=True, + start_date=datetime.fromisoformat(start_dt) if start_dt else None, + enrollment_start=datetime.fromisoformat(enrollnment_start) + if enrollnment_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 matches the earliest run's start date + assert result.next_start_date == ( + datetime.fromisoformat(expected_next) if expected_next else None + ) + + @pytest.mark.parametrize( ("is_scholar_course", "tag_counts", "expected_score"), [ diff --git a/learning_resources/views.py b/learning_resources/views.py index 2df588382c..2a9e2123e8 100644 --- a/learning_resources/views.py +++ b/learning_resources/views.py @@ -597,9 +597,17 @@ class LearningResourceListRelationshipViewSet(viewsets.GenericViewSet): permission_classes = (AnonymousAccessReadonlyPermission,) filter_backends = [MultipleOptionsFilterBackend] - queryset = LearningResourceRelationship.objects.select_related("parent", "child") http_method_names = ["patch"] + def get_queryset(self): + """Return queryset with properly prefetched child learning resources""" + return LearningResourceRelationship.objects.prefetch_related( + Prefetch( + "child", + queryset=LearningResource.objects.for_serialization(), + ) + ) + def get_serializer_class(self): if self.action == "userlists": return UserListRelationshipSerializer @@ -963,15 +971,26 @@ class UserListItemViewSet(NestedViewSetMixin, viewsets.ModelViewSet): Viewset for UserListRelationships """ - queryset = UserListRelationship.objects.prefetch_related("child").order_by( - "position" - ) + queryset = UserListRelationship.objects.order_by("position") serializer_class = UserListRelationshipSerializer pagination_class = DefaultPagination permission_classes = (HasUserListItemPermissions,) http_method_names = VALID_HTTP_METHODS parent_lookup_kwargs = {"userlist_id": "parent"} + def get_queryset(self): + """Return queryset with properly prefetched child learning resources""" + user = self.request.user if hasattr(self, "request") else None + # Start with the base queryset which gets filtered by NestedViewSetMixin + qs = super().get_queryset() + # Add prefetch for child learning resources + return qs.prefetch_related( + Prefetch( + "child", + queryset=LearningResource.objects.for_serialization(user=user), + ) + ) + def create(self, request, *args, **kwargs): user_list_id = kwargs.get("userlist_id") request.data["parent"] = user_list_id From 93947cedc769ced5a9f1e638517436e4874d59fa Mon Sep 17 00:00:00 2001 From: Matt Bertrand Date: Thu, 13 Nov 2025 09:14:19 -0500 Subject: [PATCH 2/6] Revert some changes meant for another PR --- learning_resources/views.py | 27 ++++----------------------- 1 file changed, 4 insertions(+), 23 deletions(-) diff --git a/learning_resources/views.py b/learning_resources/views.py index 2a9e2123e8..2df588382c 100644 --- a/learning_resources/views.py +++ b/learning_resources/views.py @@ -597,17 +597,9 @@ class LearningResourceListRelationshipViewSet(viewsets.GenericViewSet): permission_classes = (AnonymousAccessReadonlyPermission,) filter_backends = [MultipleOptionsFilterBackend] + queryset = LearningResourceRelationship.objects.select_related("parent", "child") http_method_names = ["patch"] - def get_queryset(self): - """Return queryset with properly prefetched child learning resources""" - return LearningResourceRelationship.objects.prefetch_related( - Prefetch( - "child", - queryset=LearningResource.objects.for_serialization(), - ) - ) - def get_serializer_class(self): if self.action == "userlists": return UserListRelationshipSerializer @@ -971,26 +963,15 @@ class UserListItemViewSet(NestedViewSetMixin, viewsets.ModelViewSet): Viewset for UserListRelationships """ - queryset = UserListRelationship.objects.order_by("position") + queryset = UserListRelationship.objects.prefetch_related("child").order_by( + "position" + ) serializer_class = UserListRelationshipSerializer pagination_class = DefaultPagination permission_classes = (HasUserListItemPermissions,) http_method_names = VALID_HTTP_METHODS parent_lookup_kwargs = {"userlist_id": "parent"} - def get_queryset(self): - """Return queryset with properly prefetched child learning resources""" - user = self.request.user if hasattr(self, "request") else None - # Start with the base queryset which gets filtered by NestedViewSetMixin - qs = super().get_queryset() - # Add prefetch for child learning resources - return qs.prefetch_related( - Prefetch( - "child", - queryset=LearningResource.objects.for_serialization(user=user), - ) - ) - def create(self, request, *args, **kwargs): user_list_id = kwargs.get("userlist_id") request.data["parent"] = user_list_id From 8870072bdbf7c840d430ab1656e0024fdba018b2 Mon Sep 17 00:00:00 2001 From: Matt Bertrand Date: Fri, 14 Nov 2025 11:01:54 -0500 Subject: [PATCH 3/6] Revcert changes to next_start_date (will be done in subsequent PR) --- learning_resources/etl/loaders.py | 8 +-- learning_resources/etl/loaders_test.py | 71 +++++--------------------- 2 files changed, 16 insertions(+), 63 deletions(-) diff --git a/learning_resources/etl/loaders.py b/learning_resources/etl/loaders.py index fd41c89284..301eeede58 100644 --- a/learning_resources/etl/loaders.py +++ b/learning_resources/etl/loaders.py @@ -46,6 +46,7 @@ Video, VideoChannel, VideoPlaylist, + now_in_utc, ) from learning_resources.utils import ( add_parent_topics_to_learning_resource, @@ -138,12 +139,11 @@ 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(filter(None, [best_run.start_date, best_run.enrollment_start])) - if best_run.start_date or best_run.enrollment_start - else None + resource.next_start_date = max( + best_run.start_date or best_run.enrollment_start or now, now ) resource.availability = best_run.availability resource.prices = ( diff --git a/learning_resources/etl/loaders_test.py b/learning_resources/etl/loaders_test.py index 0b70d899d1..2af2c9b3a2 100644 --- a/learning_resources/etl/loaders_test.py +++ b/learning_resources/etl/loaders_test.py @@ -1,6 +1,6 @@ """Tests for ETL loaders""" -from datetime import datetime, timedelta +from datetime import timedelta from decimal import Decimal from pathlib import Path @@ -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 +def test_load_course( # noqa: PLR0913,PLR0912,PLR0915, C901 mock_upsert_tasks, course_exists, is_published, @@ -498,9 +498,10 @@ def test_load_course( # noqa: PLR0913, PLR0912, PLR0915 assert result.professional is True if is_published and is_run_published and not blocklisted: - assert result.next_start_date == start_date - else: - assert result.next_start_date is None + if has_upcoming_run: + assert result.next_start_date == start_date + else: + assert result.next_start_date.date() == now.date() assert result.prices == ( [Decimal("0.00"), Decimal("49.00")] if is_run_published and result.certification @@ -1747,17 +1748,15 @@ 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) - best_run = LearningResourceRunFactory.create( + 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, @@ -1773,7 +1772,6 @@ 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, @@ -1783,23 +1781,15 @@ def test_load_run_dependent_values(certification): max_weekly_hours=19, ) result = load_run_dependent_values(course) - 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 result.next_start_date == course.next_start_date == closest_date + assert result.prices == course.prices == ([] if not certification else run.prices) assert ( list(result.resource_prices) == list(course.resource_prices.all()) - == ([] if not certification else list(best_run.resource_prices.all())) + == ([] if not certification else list(run.resource_prices.all())) ) assert result.availability == course.availability == Availability.dated.name - assert result.location == course.location == best_run.location + assert result.location == course.location == run.location for key in [ "duration", "time_commitment", @@ -1808,7 +1798,7 @@ def test_load_run_dependent_values(certification): "min_weekly_hours", "max_weekly_hours", ]: - assert getattr(result, key) == getattr(course, key) == getattr(best_run, key) + assert getattr(result, key) == getattr(course, key) == getattr(run, key) def test_load_run_dependent_values_resets_next_start_date(): @@ -1839,43 +1829,6 @@ def test_load_run_dependent_values_resets_next_start_date(): assert course.next_start_date is None -@pytest.mark.parametrize( - ("start_dt", "enrollnment_start", "expected_next"), - [ - ("2025-01-01T10:00:00Z", "2024-12-15T10:00:00Z", "2025-01-01T10:00:00Z"), - ("2025-01-01T10:00:00Z", None, "2025-01-01T10:00:00Z"), - (None, "2024-12-15T10:00:00Z", "2024-12-15T10:00:00Z"), - (None, None, None), - ], -) -def test_load_run_dependent_values_next_start_date( - start_dt, enrollnment_start, expected_next -): - """Test that next_start_date is correctly set from the best_run""" - course = LearningResourceFactory.create(is_course=True, published=True, runs=[]) - - # Create multiple runs with different start dates - LearningResourceRunFactory.create( - learning_resource=course, - published=True, - start_date=datetime.fromisoformat(start_dt) if start_dt else None, - enrollment_start=datetime.fromisoformat(enrollnment_start) - if enrollnment_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 matches the earliest run's start date - assert result.next_start_date == ( - datetime.fromisoformat(expected_next) if expected_next else None - ) - - @pytest.mark.parametrize( ("is_scholar_course", "tag_counts", "expected_score"), [ From 50291781db4486e6f12e8bdbf6dfbb4489c4bbce Mon Sep 17 00:00:00 2001 From: Matt Bertrand Date: Fri, 14 Nov 2025 12:16:59 -0500 Subject: [PATCH 4/6] Refactor start date --- .../InfoSection.test.tsx | 121 ++++++++++++++++-- .../LearningResourceExpanded/InfoSection.tsx | 14 +- .../LearningResourceCard.test.tsx | 12 +- .../LearningResourceCard.tsx | 4 +- .../LearningResourceListCard.test.tsx | 12 +- .../LearningResourceListCard.tsx | 4 +- .../learning-resources.test.ts | 80 +----------- .../learning-resources/learning-resources.ts | 55 -------- .../src/learning-resources/pricing.ts | 32 ++++- learning_resources/etl/loaders.py | 12 +- learning_resources/etl/loaders_test.py | 71 ++++++++-- learning_resources/models.py | 9 ++ 12 files changed, 242 insertions(+), 184 deletions(-) diff --git a/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.test.tsx b/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.test.tsx index 865cd5df55..4fa9285d55 100644 --- a/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.test.tsx +++ b/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.test.tsx @@ -115,10 +115,20 @@ 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 course = { ...courses.free.dated, - next_start_date: "2024-03-15T00:00:00Z", + best_run_id: 1, + runs: [ + { + ...run, + id: 1, + start_date: "2024-03-15", + enrollment_start: "2024-03-01", + }, + ], } renderWithTheme() @@ -127,10 +137,10 @@ describe("Learning resource info section start date", () => { within(section).getByText("March 15, 2024") }) - test("Falls back to run date when next_start_date is null", () => { + test("Falls back to run date when best_run_id is null", () => { const course = { ...courses.free.dated, - next_start_date: null, + best_run_id: null, } const run = course.runs?.[0] invariant(run) @@ -144,6 +154,74 @@ describe("Learning resource info section start date", () => { expect(within(section).queryByText("March 15, 2024")).toBeNull() }) + test("Uses enrollment_start when it is later than start_date", () => { + const run = courses.free.dated.runs?.[0] + invariant(run) + const course = { + ...courses.free.dated, + best_run_id: 1, + runs: [ + { + ...run, + id: 1, + start_date: "2024-03-01", + enrollment_start: "2024-03-15", + }, + ], + } + renderWithTheme() + + const section = screen.getByTestId("drawer-info-items") + within(section).getByText("Starts:") + within(section).getByText("March 15, 2024") + }) + + test("Falls back to run date 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) + const runDate = formatRunDate(run, false) + invariant(runDate) + renderWithTheme() + + const section = screen.getByTestId("drawer-info-items") + within(section).getByText("Starts:") + within(section).getByText(runDate) + }) + + test("Falls back to run date when best run has no 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, + enrollment_start: null, + }, + { + ...run, + id: 2, + start_date: "2024-05-01", + enrollment_start: null, + }, + ], + } + const runDate = formatRunDate(course.runs[1], false) + invariant(runDate) + renderWithTheme() + + const section = screen.getByTestId("drawer-info-items") + within(section).getByText("Starts:") + within(section).getByText(runDate) + }) + test("As taught in date(s)", () => { const course = courses.free.anytime const run = course.runs?.[0] @@ -180,10 +258,21 @@ describe("Learning resource info section start date", () => { }) }) - test("Multiple run dates with next_start_date uses next_start_date as first date", () => { + test("Multiple run dates with best_run_id uses best_run_id as first date", () => { + const firstRun = courses.multipleRuns.sameData.runs?.[0] + invariant(firstRun) const course = { ...courses.multipleRuns.sameData, - next_start_date: "2024-01-15T00:00:00Z", + best_run_id: 1, + runs: [ + { + ...firstRun, + id: 1, + start_date: "2024-01-15", + enrollment_start: "2024-01-01", + }, + ...(courses.multipleRuns.sameData.runs?.slice(1) ?? []), + ], } const sortedDates = course.runs ?.sort((a, b) => { @@ -195,7 +284,7 @@ describe("Learning resource info section start date", () => { .map((run) => formatRunDate(run, false)) .filter((date) => date !== null) - // First date should be next_start_date, second should be original second date + // First date should be from best_run_id, second should be original second date const expectedDateText = `January 15, 2024${SEPARATOR}${sortedDates?.[1]}Show more` renderWithTheme() @@ -227,10 +316,20 @@ 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", () => { + test("Anytime courses with best_run_id should not replace first date in 'As taught in' section", () => { + const firstRun = courses.free.anytime.runs?.[0] + invariant(firstRun) const course = { ...courses.free.anytime, - next_start_date: "2024-03-15T00:00:00Z", + best_run_id: 1, + runs: [ + { + ...firstRun, + id: 1, + start_date: "2024-03-15", + enrollment_start: "2024-03-01", + }, + ], } renderWithTheme() @@ -247,9 +346,7 @@ describe("Learning resource info section start date", () => { 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(course.runs[0], true) 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..9cb3a1c00b 100644 --- a/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.tsx +++ b/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.tsx @@ -32,6 +32,7 @@ import { showStartAnytime, NoSSR, formatDate, + getBestStartDate, } from "ol-utilities" import { theme, Link } from "ol-components" import DifferingRunsTable from "./DifferingRunsTable" @@ -191,13 +192,14 @@ const RunDates: React.FC<{ resource: LearningResource }> = ({ resource }) => { .map((run) => formatRunDate(run, anytime)) .filter((date) => date !== null) - const nextStartDate = resource.next_start_date - ? formatDate(resource.next_start_date, "MMMM DD, YYYY") - : null + const bestStartDate = (() => { + const date = getBestStartDate(resource) + return date ? formatDate(date, "MMMM DD, YYYY") : null + })() - if (sortedDates && nextStartDate && !anytime) { - // Replace the first date with next_start_date - sortedDates = [nextStartDate, ...sortedDates.slice(1)] + if (sortedDates && bestStartDate && !anytime) { + // Replace the first date with best_start_date + sortedDates = [bestStartDate, ...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..9819caa321 100644 --- a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.test.tsx +++ b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.test.tsx @@ -20,9 +20,15 @@ describe("Learning Resource Card", () => { ])( "Renders resource type, title and start date as a labeled article", ({ resourceType, expectedLabel }) => { + const run = factories.learningResources.run({ + id: 1, + start_date: "2026-01-01", + 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 }) @@ -54,10 +60,10 @@ describe("Learning Resource Card", () => { expect(title.parentElement).toHaveAttribute("lang", "en-us") }) - test("Displays run start date", () => { + test("Displays run start date when best_run_id is null", () => { const resource = factories.learningResources.resource({ resource_type: ResourceTypeEnum.Course, - next_start_date: null, + best_run_id: null, runs: [ factories.learningResources.run({ start_date: "2026-01-01", diff --git a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.tsx index 6e31b21d08..0dcb8ae0ad 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, + getBestStartDate, 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 = getBestStartDate(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..6d3a161076 100644 --- a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.test.tsx +++ b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.test.tsx @@ -19,9 +19,15 @@ describe("Learning Resource List Card", () => { ])( "Renders resource type, title and start date as a labeled article", ({ resourceType, expectedLabel }) => { + const run = factories.learningResources.run({ + id: 1, + start_date: "2026-01-01", + 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 }) @@ -53,10 +59,10 @@ describe("Learning Resource List Card", () => { expect(title).toHaveAttribute("lang", "pt-pt") }) - test("Displays run start date", () => { + test("Displays run start date when best_run_id is null", () => { const resource = factories.learningResources.resource({ resource_type: ResourceTypeEnum.Course, - next_start_date: null, + best_run_id: null, runs: [ factories.learningResources.run({ start_date: "2026-01-01", diff --git a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.tsx index bf02f48950..991a190989 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, + getBestStartDate, 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 = getBestStartDate(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..77b4b7a6f4 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,59 +41,6 @@ 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, @@ -168,7 +114,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..59724e602b 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,33 @@ 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 getBestStartDate = (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..0b70d899d1 100644 --- a/learning_resources/etl/loaders_test.py +++ b/learning_resources/etl/loaders_test.py @@ -1,6 +1,6 @@ """Tests for ETL loaders""" -from datetime import timedelta +from datetime import datetime, timedelta from decimal import Decimal from pathlib import Path @@ -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, @@ -498,10 +498,9 @@ def test_load_course( # noqa: PLR0913,PLR0912,PLR0915, C901 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() + 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,43 @@ def test_load_run_dependent_values_resets_next_start_date(): assert course.next_start_date is None +@pytest.mark.parametrize( + ("start_dt", "enrollnment_start", "expected_next"), + [ + ("2025-01-01T10:00:00Z", "2024-12-15T10:00:00Z", "2025-01-01T10:00:00Z"), + ("2025-01-01T10:00:00Z", None, "2025-01-01T10:00:00Z"), + (None, "2024-12-15T10:00:00Z", "2024-12-15T10:00:00Z"), + (None, None, None), + ], +) +def test_load_run_dependent_values_next_start_date( + start_dt, enrollnment_start, expected_next +): + """Test that next_start_date is correctly set from the best_run""" + course = LearningResourceFactory.create(is_course=True, published=True, runs=[]) + + # Create multiple runs with different start dates + LearningResourceRunFactory.create( + learning_resource=course, + published=True, + start_date=datetime.fromisoformat(start_dt) if start_dt else None, + enrollment_start=datetime.fromisoformat(enrollnment_start) + if enrollnment_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 matches the earliest run's start date + assert result.next_start_date == ( + datetime.fromisoformat(expected_next) if expected_next else 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""" From b7a37a691399964a6bc7fd7a6303eb666144a57e Mon Sep 17 00:00:00 2001 From: Matt Bertrand Date: Fri, 14 Nov 2025 14:30:46 -0500 Subject: [PATCH 5/6] more tweaks --- .../InfoSection.test.tsx | 126 ++++++++++++++---- .../LearningResourceExpanded/InfoSection.tsx | 3 +- .../LearningResourceCard.test.tsx | 94 +++++++++++-- .../LearningResourceListCard.test.tsx | 94 +++++++++++-- learning_resources/etl/loaders_test.py | 50 ++++--- 5 files changed, 300 insertions(+), 67 deletions(-) diff --git a/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.test.tsx b/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.test.tsx index 4fa9285d55..f475c0c8f6 100644 --- a/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.test.tsx +++ b/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.test.tsx @@ -6,10 +6,29 @@ import { formatRunDate } from "ol-utilities" import invariant from "tiny-invariant" import user from "@testing-library/user-event" import { renderWithTheme } from "../../test-utils" +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() @@ -118,6 +137,8 @@ describe("Learning resource info section start date", () => { 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, best_run_id: 1, @@ -125,8 +146,8 @@ describe("Learning resource info section start date", () => { { ...run, id: 1, - start_date: "2024-03-15", - enrollment_start: "2024-03-01", + start_date: startDate, + enrollment_start: enrollmentStart, }, ], } @@ -134,29 +155,33 @@ describe("Learning resource info section start date", () => { 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 best_run_id is null", () => { + 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, - best_run_id: null, + best_run_id: 1, + runs: [run], } - const run = course.runs?.[0] - invariant(run) - const runDate = formatRunDate(run, false) - invariant(runDate) renderWithTheme() const section = screen.getByTestId("drawer-info-items") within(section).getByText("Starts:") - within(section).getByText(runDate) - expect(within(section).queryByText("March 15, 2024")).toBeNull() + within(section).getByText(formatTestDate(startDate)) }) 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, best_run_id: 1, @@ -164,8 +189,8 @@ describe("Learning resource info section start date", () => { { ...run, id: 1, - start_date: "2024-03-01", - enrollment_start: "2024-03-15", + start_date: startDate, + enrollment_start: enrollmentStart, }, ], } @@ -173,10 +198,10 @@ describe("Learning resource info section start date", () => { const section = screen.getByTestId("drawer-info-items") within(section).getByText("Starts:") - within(section).getByText("March 15, 2024") + within(section).getByText(formatTestDate(enrollmentStart)) }) - test("Falls back to run date when best_run_id does not match any run", () => { + test("Falls back to null when best_run_id does not match any run", () => { const course = { ...courses.free.dated, best_run_id: 999, @@ -192,12 +217,37 @@ describe("Learning resource info section start date", () => { within(section).getByText(runDate) }) - test("Falls back to run date when best run has no dates", () => { + 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, 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, @@ -205,21 +255,35 @@ describe("Learning resource info section start date", () => { 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: 2, - start_date: "2024-05-01", + id: 1, + start_date: null, enrollment_start: null, }, ], } - const runDate = formatRunDate(course.runs[1], false) - invariant(runDate) renderWithTheme() const section = screen.getByTestId("drawer-info-items") - within(section).getByText("Starts:") - within(section).getByText(runDate) + // 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)", () => { @@ -261,6 +325,8 @@ describe("Learning resource info section start date", () => { test("Multiple run dates with best_run_id uses best_run_id as first date", () => { const firstRun = courses.multipleRuns.sameData.runs?.[0] invariant(firstRun) + const bestRunStartDate = daysFromToday(50) + const bestRunEnrollmentStart = daysFromToday(35) const course = { ...courses.multipleRuns.sameData, best_run_id: 1, @@ -268,8 +334,8 @@ describe("Learning resource info section start date", () => { { ...firstRun, id: 1, - start_date: "2024-01-15", - enrollment_start: "2024-01-01", + start_date: bestRunStartDate, + enrollment_start: bestRunEnrollmentStart, }, ...(courses.multipleRuns.sameData.runs?.slice(1) ?? []), ], @@ -285,7 +351,7 @@ describe("Learning resource info section start date", () => { .filter((date) => date !== null) // First date should be from best_run_id, second should be original second date - const expectedDateText = `January 15, 2024${SEPARATOR}${sortedDates?.[1]}Show more` + const expectedDateText = `${formatTestDate(bestRunStartDate)}${SEPARATOR}${sortedDates?.[1]}Show more` renderWithTheme() const section = screen.getByTestId("drawer-info-items") @@ -319,6 +385,8 @@ describe("Learning resource info section start date", () => { test("Anytime courses with best_run_id should not replace first date in 'As taught in' section", () => { const firstRun = courses.free.anytime.runs?.[0] invariant(firstRun) + const bestRunStartDate = daysFromToday(25) + const bestRunEnrollmentStart = daysFromToday(10) const course = { ...courses.free.anytime, best_run_id: 1, @@ -326,8 +394,8 @@ describe("Learning resource info section start date", () => { { ...firstRun, id: 1, - start_date: "2024-03-15", - enrollment_start: "2024-03-01", + start_date: bestRunStartDate, + enrollment_start: bestRunEnrollmentStart, }, ], } @@ -341,7 +409,9 @@ describe("Learning resource info section start date", () => { within(section).getByText("As taught in:") - expect(within(section).queryByText("March 15, 2024")).toBeNull() + expect( + within(section).queryByText(formatTestDate(bestRunStartDate)), + ).toBeNull() const runDates = within(section).getByTestId("drawer-run-dates") expect(runDates).toBeInTheDocument() diff --git a/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.tsx b/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.tsx index 9cb3a1c00b..ab0a4d61ee 100644 --- a/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.tsx +++ b/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.tsx @@ -182,6 +182,7 @@ const totalRunsWithDates = (resource: LearningResource) => { const RunDates: React.FC<{ resource: LearningResource }> = ({ resource }) => { const [showingMore, setShowingMore] = useState(false) const anytime = showStartAnytime(resource) + let sortedDates = resource.runs ?.sort((a, b) => { if (a?.start_date && b?.start_date) { @@ -202,7 +203,7 @@ const RunDates: React.FC<{ resource: LearningResource }> = ({ resource }) => { sortedDates = [bestStartDate, ...sortedDates.slice(1)] } if (!sortedDates || sortedDates.length === 0) { - return null + return [bestStartDate] } const totalDates = sortedDates?.length || 0 const showMore = totalDates > 2 diff --git a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.test.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.test.tsx index 9819caa321..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,10 @@ 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: "2026-01-01", + start_date: startDate, enrollment_start: null, }) const resource = factories.learningResources.resource({ @@ -40,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)) }, ) @@ -60,21 +79,76 @@ describe("Learning Resource Card", () => { expect(title.parentElement).toHaveAttribute("lang", "en-us") }) - test("Displays run start date when best_run_id is null", () => { + 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, - best_run_id: 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.test.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.test.tsx index 6d3a161076..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,10 @@ 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: "2026-01-01", + start_date: startDate, enrollment_start: null, }) const resource = factories.learningResources.resource({ @@ -39,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)) }, ) @@ -59,21 +78,76 @@ describe("Learning Resource List Card", () => { expect(title).toHaveAttribute("lang", "pt-pt") }) - test("Displays run start date when best_run_id is null", () => { + 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, - best_run_id: 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/learning_resources/etl/loaders_test.py b/learning_resources/etl/loaders_test.py index 0b70d899d1..b9d9bd19c8 100644 --- a/learning_resources/etl/loaders_test.py +++ b/learning_resources/etl/loaders_test.py @@ -1,6 +1,6 @@ """Tests for ETL loaders""" -from datetime import datetime, timedelta +from datetime import timedelta from decimal import Decimal from pathlib import Path @@ -497,7 +497,7 @@ def test_load_course( # noqa: PLR0913, PLR0912, PLR0915 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 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 @@ -1840,28 +1840,30 @@ def test_load_run_dependent_values_resets_next_start_date(): @pytest.mark.parametrize( - ("start_dt", "enrollnment_start", "expected_next"), + ("has_start_date", "has_enrollment_start", "expect_next_start_date"), [ - ("2025-01-01T10:00:00Z", "2024-12-15T10:00:00Z", "2025-01-01T10:00:00Z"), - ("2025-01-01T10:00:00Z", None, "2025-01-01T10:00:00Z"), - (None, "2024-12-15T10:00:00Z", "2024-12-15T10:00:00Z"), - (None, None, None), + (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( - start_dt, enrollnment_start, expected_next + has_start_date, has_enrollment_start, expect_next_start_date ): - """Test that next_start_date is correctly set from the best_run""" + """Test that next_start_date is correctly set from the next_run (future runs only)""" course = LearningResourceFactory.create(is_course=True, published=True, runs=[]) - # Create multiple runs with different start dates + 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=datetime.fromisoformat(start_dt) if start_dt else None, - enrollment_start=datetime.fromisoformat(enrollnment_start) - if enrollnment_start - else None, + 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 @@ -1870,10 +1872,22 @@ def test_load_run_dependent_values_next_start_date( # Refresh course from database course.refresh_from_db() - # Verify that next_start_date matches the earliest run's start date - assert result.next_start_date == ( - datetime.fromisoformat(expected_next) if expected_next else None - ) + # 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( From dc016f053bd46d2b26ea03ccafb70d43ab976b81 Mon Sep 17 00:00:00 2001 From: Matt Bertrand Date: Wed, 19 Nov 2025 15:54:09 -0500 Subject: [PATCH 6/6] feedback --- .../InfoSection.test.tsx | 94 ++++++------------- .../LearningResourceExpanded/InfoSection.tsx | 28 +++--- .../LearningResourceCard.tsx | 4 +- .../LearningResourceListCard.tsx | 4 +- .../learning-resources/learning-resources.ts | 28 ++++++ .../src/learning-resources/pricing.ts | 4 +- 6 files changed, 78 insertions(+), 84 deletions(-) diff --git a/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.test.tsx b/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.test.tsx index f475c0c8f6..d625c0c4a4 100644 --- a/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.test.tsx +++ b/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.test.tsx @@ -6,6 +6,7 @@ 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 @@ -125,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() @@ -141,6 +147,7 @@ describe("Learning resource info section start date", () => { const enrollmentStart = daysFromToday(15) const course = { ...courses.free.dated, + availability: AvailabilityEnum.Dated, best_run_id: 1, runs: [ { @@ -184,6 +191,7 @@ describe("Learning resource info section start date", () => { const enrollmentStart = daysFromToday(40) // Later than start_date const course = { ...courses.free.dated, + availability: AvailabilityEnum.Dated, best_run_id: 1, runs: [ { @@ -225,6 +233,7 @@ describe("Learning resource info section start date", () => { const todayDate = new Date().toISOString() const course = { ...courses.free.dated, + availability: AvailabilityEnum.Dated, best_run_id: 1, runs: [ { @@ -275,7 +284,7 @@ describe("Learning resource info section start date", () => { ...run, id: 1, start_date: null, - enrollment_start: null, + end_date: null, }, ], } @@ -290,7 +299,12 @@ describe("Learning resource info section start date", () => { 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() @@ -310,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) @@ -322,44 +338,6 @@ describe("Learning resource info section start date", () => { }) }) - test("Multiple run dates with best_run_id uses best_run_id as first date", () => { - const firstRun = courses.multipleRuns.sameData.runs?.[0] - invariant(firstRun) - const bestRunStartDate = daysFromToday(50) - const bestRunEnrollmentStart = daysFromToday(35) - const course = { - ...courses.multipleRuns.sameData, - best_run_id: 1, - runs: [ - { - ...firstRun, - id: 1, - start_date: bestRunStartDate, - enrollment_start: bestRunEnrollmentStart, - }, - ...(courses.multipleRuns.sameData.runs?.slice(1) ?? []), - ], - } - 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 from best_run_id, second should be original second date - const expectedDateText = `${formatTestDate(bestRunStartDate)}${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() @@ -382,23 +360,8 @@ describe("Learning resource info section start date", () => { expect(runDates.children.length).toBe(totalRuns + 1) }) - test("Anytime courses with best_run_id should not replace first date in 'As taught in' section", () => { - const firstRun = courses.free.anytime.runs?.[0] - invariant(firstRun) - const bestRunStartDate = daysFromToday(25) - const bestRunEnrollmentStart = daysFromToday(10) - const course = { - ...courses.free.anytime, - best_run_id: 1, - runs: [ - { - ...firstRun, - id: 1, - start_date: bestRunStartDate, - enrollment_start: bestRunEnrollmentStart, - }, - ], - } + test("Anytime courses show 'Anytime' and semester/year in 'As taught in' section", () => { + const course = courses.free.anytime renderWithTheme() @@ -409,14 +372,17 @@ describe("Learning resource info section start date", () => { within(section).getByText("As taught in:") - expect( - within(section).queryByText(formatTestDate(bestRunStartDate)), - ).toBeNull() - const runDates = within(section).getByTestId("drawer-run-dates") expect(runDates).toBeInTheDocument() - const firstRunDate = formatRunDate(course.runs[0], true) + const firstRun = course.runs?.[0] + invariant(firstRun) + 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 ab0a4d61ee..8225ece2d9 100644 --- a/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.tsx +++ b/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.tsx @@ -31,8 +31,6 @@ import { getLearningResourcePrices, showStartAnytime, NoSSR, - formatDate, - getBestStartDate, } from "ol-utilities" import { theme, Link } from "ol-components" import DifferingRunsTable from "./DifferingRunsTable" @@ -174,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 ) } @@ -183,27 +188,20 @@ 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 bestStartDate = (() => { - const date = getBestStartDate(resource) - return date ? formatDate(date, "MMMM DD, YYYY") : null - })() - - if (sortedDates && bestStartDate && !anytime) { - // Replace the first date with best_start_date - sortedDates = [bestStartDate, ...sortedDates.slice(1)] - } if (!sortedDates || sortedDates.length === 0) { - return [bestStartDate] + return null } const totalDates = sortedDates?.length || 0 const showMore = totalDates > 2 diff --git a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.tsx index 0dcb8ae0ad..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, - getBestStartDate, + 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 = getBestStartDate(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.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.tsx index 991a190989..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, - getBestStartDate, + getBestResourceStartDate, showStartAnytime, getResourceLanguage, } from "ol-utilities" @@ -165,7 +165,7 @@ export const StartDate: React.FC<{ resource: LearningResource }> = ({ resource, }) => { const anytime = showStartAnytime(resource) - const startDate = getBestStartDate(resource) + const startDate = getBestResourceStartDate(resource) const formatted = anytime ? "Anytime" : startDate && diff --git a/frontends/ol-utilities/src/learning-resources/learning-resources.ts b/frontends/ol-utilities/src/learning-resources/learning-resources.ts index 77b4b7a6f4..f3ace3c1d6 100644 --- a/frontends/ol-utilities/src/learning-resources/learning-resources.ts +++ b/frontends/ol-utilities/src/learning-resources/learning-resources.ts @@ -44,6 +44,8 @@ const resourceThumbnailSrc = ( const formatRunDate = ( run: LearningResourceRun, asTaughtIn: boolean, + availability?: string | null, + bestRunId?: number | null, ): string | null => { if (asTaughtIn) { const semester = capitalize(run.semester ?? "") @@ -57,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") } diff --git a/frontends/ol-utilities/src/learning-resources/pricing.ts b/frontends/ol-utilities/src/learning-resources/pricing.ts index 59724e602b..17a58e4f27 100644 --- a/frontends/ol-utilities/src/learning-resources/pricing.ts +++ b/frontends/ol-utilities/src/learning-resources/pricing.ts @@ -128,7 +128,9 @@ export const showStartAnytime = (resource: LearningResource) => { * 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 getBestStartDate = (resource: LearningResource): string | null => { +export const getBestResourceStartDate = ( + resource: LearningResource, +): string | null => { const bestRun = resource.runs?.find((run) => run.id === resource.best_run_id) if (!bestRun) return null