Repository: oaknational/Oak-Web-Application
Pull request #3430: feat: my library unit card [LESQ-1339]
Author: login: dkyyau
Labels: name: released
Created: 2025-05-21T16:52:28Z | Merged: 2025-05-27T08:21:08Z

## Description

(this is Taha, swooping in to add tests while Dorothy is away)


This PR adds the MyLibraryUnitCard component for displaying saved units and their lessons, including published state and save/unsave functionality. Includes Storybook stories for complete and incomplete units, and a basic test scaffold.

Link to designs: https://www.figma.com/design/YcWQMMhHPVVmc47cHHEEAl/Oak-Design-Kit?node-id=14726-3427&t=CIbwWWl64Hs0dKxY-0

However- The storybook is not updating the save buttons state in the expected way, even though the test are passing. I think there's an issue with the storybook setup itself, but I'm not familiar enough yet to fix it. To double check this I rendered the component in OWA and tested the behaviour manually. 

![Kapture 2025-05-22 at 11 49 53](https://github.com/user-attachments/assets/6a73c178-dd5e-4510-b0f6-ad8b30d2e77e)

There are some unpleasant layout shifts that we might want to take note of to polish up in the future.

As this is an issue that's blocking other work it felt pertinent to just push this up rather than try to fix the storybook or fix the layout shifts. However I'm very happy to keep working on those things based on feedback.

## Issue(s)

Fixes [1339](https://www.notion.so/oaknationalacademy/I-want-a-unit-card-component-for-the-Oak-Hub-1cf26cc4e1b18079908bc584c06ad4ac)

## How to test

1. Visit https://deploy-preview-3430--owa-storybook.netlify.thenational.academy/?path=/docs/components-teachercomponents-mylibraryunitcard--docs
2. Check the rendered component against the designs

## Screenshots

How it should now look:

<img width="914" alt="Screenshot 2025-05-22 at 11 58 48" src="https://github.com/user-attachments/assets/7b853a78-9082-42e6-ba0e-bdd6b2b8036a" />


## Checklist

- [x] Added or updated tests where appropriate
- [x] Manually tested across browsers / devices
- [x] Considered impact on accessibility
- [ ] Design sign-off
- [ ] Approved by product owner
- [ ] Does this PR update a package with a breaking change



Diff:
diff --git a/src/components/TeacherComponents/MyLibraryUnitCard/MyLibraryUnitCard.stories.tsx b/src/components/TeacherComponents/MyLibraryUnitCard/MyLibraryUnitCard.stories.tsx
new file mode 100644
index 0000000000..16cf2a1de4
--- /dev/null
+++ b/src/components/TeacherComponents/MyLibraryUnitCard/MyLibraryUnitCard.stories.tsx
@@ -0,0 +1,174 @@
+import React from "react";
+import { Meta, StoryObj } from "@storybook/react";
+import { oakDefaultTheme, OakThemeProvider } from "@oaknational/oak-components";
+
+import MyLibraryUnitCard from "./MyLibraryUnitCard";
+
+const completeUnitLessons = [
+  {
+    slug: "journey-to-the-centre-of-the-earth-explaining-how-a-writer-uses-sentence-structures-for-effect",
+    order: 1,
+    title:
+      "Considering how Verne uses sentences in ‘Journey to the Centre of the Earth’",
+    _state: "published",
+    lesson_uid: "LESS-SXDKH-H1472",
+  },
+  {
+    slug: "crafting-sentence-structures-to-create-specific-effects",
+    order: 2,
+    title:
+      "Emulating how Verne uses sentences in ‘Journey to the Centre of the Earth’",
+    _state: "published",
+    lesson_uid: "LESS-HROSF-P1473",
+  },
+  {
+    slug: "using-punctuation-and-sentence-structures-to-create-impact",
+    order: 3,
+    title:
+      "'The War of the Worlds': How punctuation and sentence structures create impact",
+    _state: "published",
+    lesson_uid: "LESS-LJDDZ-N1474",
+  },
+  {
+    slug: "using-punctuation-to-create-specific-effects",
+    order: 4,
+    title:
+      "Using punctuation to create specific effects in descriptive writing",
+    _state: "published",
+    lesson_uid: "LESS-PDDEL-L1475",
+  },
+  {
+    slug: "semantic-fields",
+    order: 5,
+    title: "Using semantic fields in a piece of creative writing",
+    _state: "published",
+    lesson_uid: "LESS-BFVFW-W1477",
+  },
+];
+const incompleteUnitLessons = [
+  {
+    slug: "journey-to-the-centre-of-the-earth-explaining-how-a-writer-uses-sentence-structures-for-effect",
+    order: 1,
+    title:
+      "Considering how Verne uses sentences in ‘Journey to the Centre of the Earth’",
+    _state: "published",
+    lesson_uid: "LESS-SXDKH-H1472",
+  },
+  {
+    slug: "crafting-sentence-structures-to-create-specific-effects",
+    order: 2,
+    title:
+      "Emulating how Verne uses sentences in ‘Journey to the Centre of the Earth’",
+    _state: "new",
+    lesson_uid: "LESS-HROSF-P1473",
+  },
+  {
+    slug: "using-punctuation-and-sentence-structures-to-create-impact",
+    order: 3,
+    title:
+      "'The War of the Worlds': How punctuation and sentence structures create impact",
+    _state: "new",
+    lesson_uid: "LESS-LJDDZ-N1474",
+  },
+  {
+    slug: "using-punctuation-to-create-specific-effects",
+    order: 4,
+    title:
+      "Using punctuation to create specific effects in descriptive writing",
+    _state: "new",
+    lesson_uid: "LESS-PDDEL-L1475",
+  },
+  {
+    slug: "semantic-fields",
+    order: 5,
+    title: "Using semantic fields in a piece of creative writing",
+    _state: "published",
+    lesson_uid: "LESS-BFVFW-W1477",
+  },
+];
+const unit = {
+  index: 1,
+  unitTitle: "Fiction: Science Fiction Writing",
+  unitSlug: "fiction-science-fiction-writing",
+  programmeSlug: "english-secondary-ks4-aqa",
+  yearTitle: "Year 10",
+  saveTime: "2025-05-01T09:00:00.199406+00:00",
+  href: "/teachers/programmes/english-secondary-ks4-aqa/units/fiction-science-fiction-writing/lessons",
+  onSave: () => console.log("onSave!"),
+  isSaved: false,
+};
+
+const meta: Meta<typeof MyLibraryUnitCard> = {
+  component: MyLibraryUnitCard,
+  tags: ["autodocs"],
+  args: {
+    ...unit,
+    lessons: completeUnitLessons,
+  },
+  argTypes: {
+    index: {
+      control: {
+        type: "number",
+      },
+    },
+    unitTitle: {
+      control: {
+        type: "text",
+      },
+    },
+    unitSlug: {
+      control: {
+        type: "text",
+      },
+    },
+    programmeSlug: {
+      control: {
+        type: "text",
+      },
+    },
+    yearTitle: {
+      control: {
+        type: "text",
+      },
+    },
+    lessons: {
+      control: {
+        type: "object",
+      },
+    },
+    saveTime: {
+      control: {
+        type: "text",
+      },
+    },
+    href: {
+      control: {
+        type: "text",
+      },
+    },
+    isSaved: {
+      control: {
+        type: "boolean",
+      },
+    },
+  },
+};
+export default meta;
+
+type Story = StoryObj<typeof MyLibraryUnitCard>;
+
+export const CompleteUnit: Story = {
+  render: () => (
+    <OakThemeProvider theme={oakDefaultTheme}>
+      <MyLibraryUnitCard lessons={completeUnitLessons} {...unit} />
+    </OakThemeProvider>
+  ),
+};
+
+export const IncompleteUnit: Story = {
+  render: () => (
+    <OakThemeProvider theme={oakDefaultTheme}>
+      <MyLibraryUnitCard lessons={incompleteUnitLessons} {...unit} />
+    </OakThemeProvider>
+  ),
+};
diff --git a/src/components/TeacherComponents/MyLibraryUnitCard/MyLibraryUnitCard.test.tsx b/src/components/TeacherComponents/MyLibraryUnitCard/MyLibraryUnitCard.test.tsx
new file mode 100644
index 0000000000..72a44b238e
--- /dev/null
+++ b/src/components/TeacherComponents/MyLibraryUnitCard/MyLibraryUnitCard.test.tsx
@@ -0,0 +1,112 @@
+import { screen } from "@testing-library/dom";
+
+import MyLibraryUnitCard from "./MyLibraryUnitCard";
+
+import renderWithProviders from "@/__tests__/__helpers__/renderWithProviders";
+
+const render = renderWithProviders();
+
+jest.mock("@/hooks/useMediaQuery", () => ({
+  __esModule: true,
+  default: jest.fn().mockReturnValue(false),
+}));
+
+const generateLessons = (count: number, state: string) => {
+  return Array.from({ length: count }, (_, index) => ({
+    slug: `lesson-${index}`,
+    order: index,
+    title: `Lesson ${index}`,
+    _state: state,
+    lesson_uid: `LESS-${index}`,
+  }));
+};
+
+const completeUnitLessons = generateLessons(5, "published");
+const mockUnit = {
+  index: 1,
+  unitTitle: "Saved Unit",
+  unitSlug: "saved-unit",
+  programmeSlug: "english-secondary-ks4-aqa",
+  yearTitle: "Year 10",
+  saveTime: "2023-10-01T12:00:00Z",
+  href: "/saved-unit",
+  lessonCount: 5,
+  onSave: jest.fn(),
+  isSaved: false,
+};
+
+const incompleteUnitLessons = generateLessons(2, "new");
+
+describe("MyLibraryUnitCard", () => {
+  it("renders unit info", () => {
+    render(<MyLibraryUnitCard {...mockUnit} lessons={completeUnitLessons} />);
+    expect(screen.getByText(/Saved Unit/)).toBeInTheDocument();
+    expect(screen.getByText(/Year 10/)).toBeInTheDocument();
+    expect(screen.getByText(/5 lessons/)).toBeInTheDocument();
+    expect(screen.getByText(/Lesson 0/)).toBeInTheDocument();
+    // The save time is formatted as "Saved on _Date_ at _time_"
+    expect(screen.getByText(/Saved on/)).toBeInTheDocument();
+  });
+  it("renders a save button", () => {
+    render(<MyLibraryUnitCard {...mockUnit} lessons={completeUnitLessons} />);
+    const saveButton = screen.getByRole("button", {
+      name: /Save this unit: Saved Unit/i,
+    });
+    expect(saveButton).toBeInTheDocument();
+  });
+  it("calls onSave when save button is clicked", () => {
+    render(<MyLibraryUnitCard {...mockUnit} lessons={completeUnitLessons} />);
+    const saveButton = screen.getByRole("button", {
+      name: /Save this unit: Saved Unit/i,
+    });
+    saveButton.click();
+    expect(mockUnit.onSave).toHaveBeenCalled();
+  });
+  it("changes the save button when isSaved is true", () => {
+    render(
+      <MyLibraryUnitCard {...mockUnit} lessons={completeUnitLessons} isSaved />,
+    );
+    const saveButton = screen.getByRole("button", {
+      name: /Unsave this unit: Saved Unit/i,
+    });
+    expect(saveButton).toBeInTheDocument();
+    expect(saveButton).toHaveTextContent("Saved");
+  });
+
+  it("formats the save time correctly when saved on the same day", () => {
+    render(
+      <MyLibraryUnitCard
+        {...mockUnit}
+        lessons={completeUnitLessons}
+        // replace the save time with the current date
+        saveTime={new Date().toISOString()}
+      />,
+    );
+    // The save time is formatted as "Saved at _time_"
+    const saveTime = screen.getByText(/Saved at/);
+    expect(saveTime).toBeInTheDocument();
+  });
+
+  it("displays correct partial lesson count when some are unpublished", () => {
+    const mixedLessons = [
+      ...generateLessons(2, "published"),
+      ...generateLessons(3, "new"),
+    ];
+    render(<MyLibraryUnitCard {...mockUnit} lessons={mixedLessons} />);
+    expect(screen.getByText("2/5 lessons")).toBeInTheDocument();
+  });
+
+  it("renders incomplete unit lessons with correct font color", () => {
+    render(
+      <MyLibraryUnitCard
+        {...mockUnit}
+        lessons={incompleteUnitLessons}
+        isSaved
+      />,
+    );
+
+    const lesson0 = screen.getByText(/Lesson 0/);
+    const styles = getComputedStyle(lesson0);
+    expect(styles.color).toBe("rgb(128, 128, 128)");
+  });
+});
diff --git a/src/components/TeacherComponents/MyLibraryUnitCard/MyLibraryUnitCard.tsx b/src/components/TeacherComponents/MyLibraryUnitCard/MyLibraryUnitCard.tsx
new file mode 100644
index 0000000000..58161839a8
--- /dev/null
+++ b/src/components/TeacherComponents/MyLibraryUnitCard/MyLibraryUnitCard.tsx
@@ -0,0 +1,249 @@
+import {
+  OakFlex,
+  OakHeading,
+  OakSmallTertiaryInvertedButton,
+  OakP,
+  OakSpan,
+  OakBox,
+  OakLI,
+  OakSecondaryLink,
+} from "@oaknational/oak-components";
+import styled from "styled-components";
+
+import { resolveOakHref } from "@/common-lib/urls";
+
+const StyledOL = styled.ol`
+  list-style-type: none;
+`;
+
+const StyledOakLI = styled(OakLI)`
+  &:last-child {
+    padding-bottom: 0;
+  }
+`;
+
+const LessonLink = styled(OakSecondaryLink)`
+  text-decoration: none;
+
+  &:hover {
+    text-decoration: underline;
+  }
+`;
+
+const UnitLink = styled(OakSecondaryLink)`
+  text-decoration: none;
+
+  &:hover {
+    text-decoration: underline;
+  }
+`;
+
+const getLastSavedText = (date: string) => {
+  const dateObj = new Date(date);
+  const isToday = dateObj.toDateString() === new Date().toDateString();
+  const formattedDate = dateObj.toLocaleString("en-GB", {
+    day: "2-digit",
+    month: "2-digit",
+    year: "2-digit",
+  });
+  const formattedTime = dateObj.toLocaleString("en-GB", {
+    hour: "2-digit",
+    minute: "2-digit",
+  });
+  return isToday
+    ? `Saved at ${formattedTime}`
+    : `Saved on ${formattedDate} at ${formattedTime}`;
+};
+
+const UnitCardIndex = ({ index }: Pick<MyLibraryUnitCardProps, "index">) => (
+  <OakFlex
+    $pa={"inner-padding-l"}
+    $background={"bg-decorative1-main"}
+    $flexDirection={"column"}
+    $justifyContent={"center"}
+    $alignItems={"center"}
+    $width={["all-spacing-8", "all-spacing-11"]}
+    $height={["all-spacing-8", "all-spacing-11"]}
+    $borderRadius={"border-radius-m"}
+  >
+    <OakHeading tag="div" $font={["heading-6", "heading-5"]}>
+      {index}
+    </OakHeading>
+  </OakFlex>
+);
+
+const SaveButton = ({
+  unitTitle,
+  onSave,
+  isSaved,
+}: Pick<MyLibraryUnitCardProps, "unitTitle" | "onSave" | "isSaved">) => {
+  return (
+    <OakSmallTertiaryInvertedButton
+      iconName={isSaved ? "bookmark-filled" : "bookmark-outlined"}
+      isTrailingIcon
+      onClick={onSave}
+      aria-label={`${isSaved ? "Unsave" : "Save"} this unit: ${unitTitle} `}
+    >
+      {isSaved ? "Saved" : "Save"}
+    </OakSmallTertiaryInvertedButton>
+  );
+};
+
+const UnitCardHeader = ({ ...props }: MyLibraryUnitCardProps) => {
+  const { unitTitle, yearTitle, saveTime, onSave, isSaved } = props;
+
+  const lastSavedText = getLastSavedText(saveTime);
+
+  return (
+    <OakFlex $flexGrow={1} $alignItems={"start"} $gap={"space-between-xs"}>
+      <OakFlex
+        $flexGrow={1}
+        $flexDirection={"column"}
+        $gap={"space-between-ssx"}
+      >
+        <UnitLink href={props.href}>
+          <OakHeading tag="h3" $font={["heading-7", "heading-5"]}>
+            {unitTitle}
+          </OakHeading>
+        </UnitLink>
+        <OakP $color={"text-subdued"} $font={"body-2"}>
+          {yearTitle}
+          <OakSpan $ph={"inner-padding-xs"}>•</OakSpan>
+          {lastSavedText}
+        </OakP>
+      </OakFlex>
+      <OakBox $display={["none", "block"]}>
+        <SaveButton unitTitle={unitTitle} onSave={onSave} isSaved={isSaved} />
+      </OakBox>
+    </OakFlex>
+  );
+};
+
+const UnitCardContent = ({
+  lessonCountHeader,
+
+  ...props
+}: {
+  lessonCountHeader: string;
+} & MyLibraryUnitCardProps) => {
+  const { unitTitle, unitSlug, programmeSlug, lessons, onSave, isSaved } =
+    props;
+
+  return (
+    <OakFlex>
+      <OakBox
+        $display={["none", "flex"]}
+        $width={"all-spacing-11"}
+        $mr={"space-between-m"}
+      />
+
+      <OakFlex $flexDirection={"column"} $gap={"all-spacing-6"}>
+        <OakFlex $justifyContent={"space-between"}>
+          <OakP $font={"heading-light-7"}>{lessonCountHeader}</OakP>
+          <OakBox $display={["block", "none"]}>
+            <SaveButton
+              unitTitle={unitTitle}
+              onSave={onSave}
+              isSaved={isSaved}
+            />
+          </OakBox>
+        </OakFlex>
+        <OakBox
+          $borderColor={"border-decorative1-stronger"}
+          $bl={["border-solid-none", "border-solid-s"]}
+          $pl={["inner-padding-none", "inner-padding-xl"]}
+        >
+          <StyledOL>
+            {lessons
+              .sort((a, b) => a.order - b.order)
+              .map((lesson, i) => {
+                const href = resolveOakHref({
+                  page: "lesson-overview",
+                  programmeSlug,
+                  unitSlug,
+                  lessonSlug: lesson.slug,
+                });
+
+                return (
+                  <StyledOakLI
+                    key={lesson.slug}
+                    $pb={"inner-padding-l"}
+                    $font={"heading-light-7"}
+                  >
+                    <OakFlex>
+                      <OakP
+                        $textAlign={"right"}
+                        $mr={"space-between-sssx"}
+                        $color={
+                          lesson._state === "published"
+                            ? "text-primary"
+                            : "text-disabled"
+                        }
+                      >
+                        {i + 1}.{" "}
+                      </OakP>
+                      {lesson._state === "published" ? (
+                        <LessonLink $font={"heading-light-7"} href={href}>
+                          {lesson.title}
+                        </LessonLink>
+                      ) : (
+                        <OakP $color={"text-disabled"}>{lesson.title}</OakP>
+                      )}
+                    </OakFlex>
+                  </StyledOakLI>
+                );
+              })}
+          </StyledOL>
+        </OakBox>
+      </OakFlex>
+    </OakFlex>
+  );
+};
+
+type MyLibraryUnitCardProps = {
+  index: number;
+  unitTitle: string;
+  unitSlug: string;
+  programmeSlug: string;
+  yearTitle: string;
+  saveTime: string;
+  href: string;
+  lessons: {
+    slug: string;
+    order: number;
+    title: string;
+    _state: string;
+  }[];
+  onSave?: () => void;
+  isSaved?: boolean;
+};
+
+export default function MyLibraryUnitCard(props: MyLibraryUnitCardProps) {
+  const { index, lessons } = props;
+
+  const unpublishedLessonCount = lessons.filter(
+    (lesson) => lesson._state !== "published",
+  ).length;
+
+  const lessonCountHeader = unpublishedLessonCount
+    ? `${lessons.length - unpublishedLessonCount}/${lessons.length} lessons`
+    : `${lessons.length} lessons`;
+
+  return (
+    <OakFlex
+      $color={"text-primary"}
+      $flexDirection={"column"}
+      $background={"bg-primary"}
+      $borderRadius={"border-radius-m2"}
+      $pa={["inner-padding-m", "inner-padding-xl2"]}
+      $maxWidth={"all-spacing-23"}
+      $gap={"all-spacing-5"}
+    >
+      <OakFlex $gap={["space-between-s", "space-between-m"]}>
+        <UnitCardIndex index={index} />
+        <UnitCardHeader {...props} />
+      </OakFlex>
+      <UnitCardContent lessonCountHeader={lessonCountHeader} {...props} />
+    </OakFlex>
+  );
+}


Reviews:
user: login: Monika-Koziol
body: Hey! A few quick bits of feedback 😊

- [x] Could we make the unit title clickable, linking to the lesson listing page? Also, just a heads-up to make sure it has a focus state for keyboard navigation.
- [x] For consistency, all text should use the text-primary design token (that slightly off-black colour), except for the year line and similar metadata, which should use text-subdued.
- [x] On mobile view, would it be possible to left-align the lesson count (e.g. “5 lessons”) and the lesson list? It’ll help with readability and alignment.
Thanks so much—looking great overall!
state: CHANGES_REQUESTED
author_association: NONE
submitted_at: 2025-05-22T13:54:51Z
user: login: njblore
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T15:22:43Z
user: login: Taha-Hassan-Git
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T15:51:32Z
user: login: Taha-Hassan-Git
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T10:25:21Z
user: login: njblore
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T10:37:13Z
user: login: Taha-Hassan-Git
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T10:38:29Z
user: login: Monika-Koziol
body: @Taha-Hassan-Git thanks for making all the changes. Looking great. I'm approving but one small request, can the unit title that is now a link have an underline on hover? It's just to give it a stronger visual cue that it is clickable? Thanks. 
state: APPROVED
author_association: NONE
submitted_at: 2025-05-23T10:58:50Z
user: login: left23
body: Looks good! Thanks for the efforts :)
The only changed I request is to remove the `role=list` from the `<ol>` element as the element itself is a list so the role is redundant. See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/list_role#best_practices

I also noticed the Saved state doesn't display when I toggle it in Storybook but not sure if it should work in this context, or if this implies some further issue?

![Selection_639](https://github.com/user-attachments/assets/1cacd9f7-e61a-47d3-b83b-4147cad19605)

I'm approving this as I don't want to hold up anything. I am out of time for this week, and not back until Tuesday. Happy to review again then if for some reason this can't move ahead in the meantime. For a first iteration I think it's great.
state: APPROVED
author_association: NONE
submitted_at: 2025-05-23T12:29:56Z
user: login: njblore
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T12:54:05Z

Review comments:
diff_hunk: @@ -0,0 +1,233 @@
+import {
+  OakFlex,
+  OakHeading,
+  OakSmallTertiaryInvertedButton,
+  OakP,
+  OakSpan,
+  OakBox,
+  OakLI,
+  OakSecondaryLink,
+} from "@oaknational/oak-components";
+import styled from "styled-components";
+
+import useMediaQuery from "@/hooks/useMediaQuery";
+import { resolveOakHref } from "@/common-lib/urls";
+
+type MyLibraryUnitCardProps = {
+  index: number;
+  unitTitle: string;
+  unitSlug: string;
+  programmeSlug: string;
+  yearTitle: string;
+  saveTime: string;
+  href: string;
+  lessons: {
+    slug: string;
+    order: number;
+    title: string;
+    _state: string;
+    lesson_uid: string;
+  }[];
+  onSave?: () => void;
+  isSaved?: boolean;
+};
+
+const UnitCardIndex = ({ index }: Pick<MyLibraryUnitCardProps, "index">) => (
+  <OakFlex
+    $pa={"inner-padding-l"}
+    $background={"bg-decorative1-main"}
+    $flexDirection={"column"}
+    $justifyContent={"center"}
+    $alignItems={"center"}
+    $width={["all-spacing-8", "all-spacing-11"]}
+    $height={["all-spacing-8", "all-spacing-11"]}
+    $borderRadius={"border-radius-m"}
+  >
+    <OakHeading tag="div" $font={["heading-6", "heading-5"]}>
+      {index}
+    </OakHeading>
+  </OakFlex>
+);
+
+const SaveButton = ({
+  unitTitle,
+  onSave,
+  isSaved,
+}: Pick<MyLibraryUnitCardProps, "unitTitle" | "onSave" | "isSaved">) => (
+  <OakSmallTertiaryInvertedButton
+    iconName={isSaved ? "bookmark-filled" : "bookmark-outlined"}
+    isTrailingIcon
+    onClick={onSave}
+    aria-label={`${isSaved ? "Unsave" : "Save"} this unit: ${unitTitle} `}
+  >
+    {isSaved ? "Saved" : "Save"}
+  </OakSmallTertiaryInvertedButton>
+);
+
+const UnitCardHeader = ({
+  isMobile,
+  ...props
+}: { isMobile: boolean } & MyLibraryUnitCardProps) => {
+  const { unitTitle, yearTitle, saveTime, onSave, isSaved } = props;
+
+  return (
+    <OakFlex $flexGrow={1} $alignItems={"start"} $gap={"space-between-xs"}>
+      <OakFlex
+        $flexGrow={1}
+        $flexDirection={"column"}
+        $gap={"space-between-ssx"}
+      >
+        <OakHeading tag="h3" $font={["heading-7", "heading-5"]}>
+          {unitTitle}
+        </OakHeading>
+        <OakP $font={"body-2"}>
+          {yearTitle}
+          <OakSpan $ph={"inner-padding-xs"}>•</OakSpan>
+          Saved at{" "}
path: src/components/TeacherComponents/MyLibraryUnitCard/MyLibraryUnitCard.tsx
user: login: njblore
body: In the designs the timestamp reads differently depending on whether the `saveTime` is today, so if it's today it reads `Saved at 10:00` (no date) and otherwise it's `Saved on 10/05/25 at 10:00` 
created_at: 2025-05-22T15:09:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,233 @@
+import {
+  OakFlex,
+  OakHeading,
+  OakSmallTertiaryInvertedButton,
+  OakP,
+  OakSpan,
+  OakBox,
+  OakLI,
+  OakSecondaryLink,
+} from "@oaknational/oak-components";
+import styled from "styled-components";
+
+import useMediaQuery from "@/hooks/useMediaQuery";
+import { resolveOakHref } from "@/common-lib/urls";
+
+type MyLibraryUnitCardProps = {
+  index: number;
+  unitTitle: string;
+  unitSlug: string;
+  programmeSlug: string;
+  yearTitle: string;
+  saveTime: string;
+  href: string;
+  lessons: {
+    slug: string;
+    order: number;
+    title: string;
+    _state: string;
+    lesson_uid: string;
path: src/components/TeacherComponents/MyLibraryUnitCard/MyLibraryUnitCard.tsx
user: login: njblore
body: we can remove `_state` and `lesson_uid` from props we won't use those
created_at: 2025-05-22T15:10:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,233 @@
+import {
+  OakFlex,
+  OakHeading,
+  OakSmallTertiaryInvertedButton,
+  OakP,
+  OakSpan,
+  OakBox,
+  OakLI,
+  OakSecondaryLink,
+} from "@oaknational/oak-components";
+import styled from "styled-components";
+
+import useMediaQuery from "@/hooks/useMediaQuery";
+import { resolveOakHref } from "@/common-lib/urls";
+
+type MyLibraryUnitCardProps = {
+  index: number;
+  unitTitle: string;
+  unitSlug: string;
+  programmeSlug: string;
+  yearTitle: string;
+  saveTime: string;
+  href: string;
+  lessons: {
+    slug: string;
+    order: number;
+    title: string;
+    _state: string;
+    lesson_uid: string;
+  }[];
+  onSave?: () => void;
+  isSaved?: boolean;
+};
+
+const UnitCardIndex = ({ index }: Pick<MyLibraryUnitCardProps, "index">) => (
+  <OakFlex
+    $pa={"inner-padding-l"}
+    $background={"bg-decorative1-main"}
+    $flexDirection={"column"}
+    $justifyContent={"center"}
+    $alignItems={"center"}
+    $width={["all-spacing-8", "all-spacing-11"]}
+    $height={["all-spacing-8", "all-spacing-11"]}
+    $borderRadius={"border-radius-m"}
+  >
+    <OakHeading tag="div" $font={["heading-6", "heading-5"]}>
+      {index}
+    </OakHeading>
+  </OakFlex>
+);
+
+const SaveButton = ({
+  unitTitle,
+  onSave,
+  isSaved,
+}: Pick<MyLibraryUnitCardProps, "unitTitle" | "onSave" | "isSaved">) => (
+  <OakSmallTertiaryInvertedButton
+    iconName={isSaved ? "bookmark-filled" : "bookmark-outlined"}
+    isTrailingIcon
+    onClick={onSave}
+    aria-label={`${isSaved ? "Unsave" : "Save"} this unit: ${unitTitle} `}
+  >
+    {isSaved ? "Saved" : "Save"}
+  </OakSmallTertiaryInvertedButton>
+);
+
+const UnitCardHeader = ({
+  isMobile,
+  ...props
+}: { isMobile: boolean } & MyLibraryUnitCardProps) => {
+  const { unitTitle, yearTitle, saveTime, onSave, isSaved } = props;
+
+  return (
+    <OakFlex $flexGrow={1} $alignItems={"start"} $gap={"space-between-xs"}>
+      <OakFlex
+        $flexGrow={1}
+        $flexDirection={"column"}
+        $gap={"space-between-ssx"}
+      >
+        <OakHeading tag="h3" $font={["heading-7", "heading-5"]}>
+          {unitTitle}
+        </OakHeading>
+        <OakP $font={"body-2"}>
+          {yearTitle}
+          <OakSpan $ph={"inner-padding-xs"}>•</OakSpan>
+          Saved at{" "}
+          {new Date(saveTime).toLocaleString("en-GB", {
+            day: "2-digit",
+            month: "2-digit",
+            year: "2-digit",
+            hour: "numeric",
+            minute: "2-digit",
+          })}
+        </OakP>
+      </OakFlex>
+      {!isMobile && (
+        <SaveButton unitTitle={unitTitle} onSave={onSave} isSaved={isSaved} />
+      )}
+    </OakFlex>
+  );
+};
+
+const StyledOL = styled.ol`
+  list-style-type: none;
+`;
+
+const StyledOakLI = styled(OakLI)`
+  &:last-child {
+    padding-bottom: 0;
+  }
+`;
+
+const LessonLink = styled(OakSecondaryLink)`
+  text-decoration: none;
+  color: black !important;
+
+  &:hover {
+    text-decoration: underline;
+  }
+`;
+
+const UnitCardContent = ({
+  lessonCountHeader,
+  isMobile,
+  ...props
+}: {
+  lessonCountHeader: string;
+  isMobile: boolean;
+} & MyLibraryUnitCardProps) => {
+  const { unitTitle, unitSlug, programmeSlug, lessons, onSave, isSaved } =
+    props;
+
+  return (
+    <OakFlex>
+      {!isMobile && (
+        <OakBox $width={"all-spacing-11"} $mr={"space-between-m"} />
+      )}
+      <OakFlex $flexDirection={"column"} $gap={"all-spacing-6"}>
+        <OakFlex $justifyContent={"space-between"}>
+          <OakP $font={"heading-light-7"}>{lessonCountHeader}</OakP>
+          {isMobile && (
+            <SaveButton
+              unitTitle={unitTitle}
+              onSave={onSave}
+              isSaved={isSaved}
+            />
+          )}
+        </OakFlex>
+        <OakBox
+          $borderColor={"border-decorative1-stronger"}
+          $bl={isMobile ? "border-solid-none" : "border-solid-s"}
+          $pl={["inner-padding-none", "inner-padding-xl"]}
+        >
+          <StyledOL role="list">
+            {lessons.map((lesson, i) => {
path: src/components/TeacherComponents/MyLibraryUnitCard/MyLibraryUnitCard.tsx
user: login: njblore
body: we should sort lessons by `order`, they are _probably_ already sorted correctly but we can't be sure
created_at: 2025-05-22T15:12:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,233 @@
+import {
+  OakFlex,
+  OakHeading,
+  OakSmallTertiaryInvertedButton,
+  OakP,
+  OakSpan,
+  OakBox,
+  OakLI,
+  OakSecondaryLink,
+} from "@oaknational/oak-components";
+import styled from "styled-components";
+
+import useMediaQuery from "@/hooks/useMediaQuery";
+import { resolveOakHref } from "@/common-lib/urls";
+
+type MyLibraryUnitCardProps = {
+  index: number;
+  unitTitle: string;
+  unitSlug: string;
+  programmeSlug: string;
+  yearTitle: string;
+  saveTime: string;
+  href: string;
+  lessons: {
+    slug: string;
+    order: number;
+    title: string;
+    _state: string;
+    lesson_uid: string;
+  }[];
+  onSave?: () => void;
+  isSaved?: boolean;
+};
+
+const UnitCardIndex = ({ index }: Pick<MyLibraryUnitCardProps, "index">) => (
+  <OakFlex
+    $pa={"inner-padding-l"}
+    $background={"bg-decorative1-main"}
+    $flexDirection={"column"}
+    $justifyContent={"center"}
+    $alignItems={"center"}
+    $width={["all-spacing-8", "all-spacing-11"]}
+    $height={["all-spacing-8", "all-spacing-11"]}
+    $borderRadius={"border-radius-m"}
+  >
+    <OakHeading tag="div" $font={["heading-6", "heading-5"]}>
+      {index}
+    </OakHeading>
+  </OakFlex>
+);
+
+const SaveButton = ({
+  unitTitle,
+  onSave,
+  isSaved,
+}: Pick<MyLibraryUnitCardProps, "unitTitle" | "onSave" | "isSaved">) => (
+  <OakSmallTertiaryInvertedButton
+    iconName={isSaved ? "bookmark-filled" : "bookmark-outlined"}
+    isTrailingIcon
+    onClick={onSave}
+    aria-label={`${isSaved ? "Unsave" : "Save"} this unit: ${unitTitle} `}
+  >
+    {isSaved ? "Saved" : "Save"}
+  </OakSmallTertiaryInvertedButton>
+);
+
+const UnitCardHeader = ({
+  isMobile,
+  ...props
+}: { isMobile: boolean } & MyLibraryUnitCardProps) => {
+  const { unitTitle, yearTitle, saveTime, onSave, isSaved } = props;
+
+  return (
+    <OakFlex $flexGrow={1} $alignItems={"start"} $gap={"space-between-xs"}>
+      <OakFlex
+        $flexGrow={1}
+        $flexDirection={"column"}
+        $gap={"space-between-ssx"}
+      >
+        <OakHeading tag="h3" $font={["heading-7", "heading-5"]}>
+          {unitTitle}
+        </OakHeading>
+        <OakP $font={"body-2"}>
+          {yearTitle}
+          <OakSpan $ph={"inner-padding-xs"}>•</OakSpan>
+          Saved at{" "}
+          {new Date(saveTime).toLocaleString("en-GB", {
+            day: "2-digit",
+            month: "2-digit",
+            year: "2-digit",
+            hour: "numeric",
+            minute: "2-digit",
+          })}
+        </OakP>
+      </OakFlex>
+      {!isMobile && (
+        <SaveButton unitTitle={unitTitle} onSave={onSave} isSaved={isSaved} />
+      )}
+    </OakFlex>
+  );
+};
+
+const StyledOL = styled.ol`
+  list-style-type: none;
+`;
+
+const StyledOakLI = styled(OakLI)`
+  &:last-child {
+    padding-bottom: 0;
+  }
+`;
+
+const LessonLink = styled(OakSecondaryLink)`
+  text-decoration: none;
+  color: black !important;
+
+  &:hover {
+    text-decoration: underline;
+  }
+`;
+
+const UnitCardContent = ({
+  lessonCountHeader,
+  isMobile,
+  ...props
+}: {
+  lessonCountHeader: string;
+  isMobile: boolean;
+} & MyLibraryUnitCardProps) => {
+  const { unitTitle, unitSlug, programmeSlug, lessons, onSave, isSaved } =
+    props;
+
+  return (
+    <OakFlex>
+      {!isMobile && (
+        <OakBox $width={"all-spacing-11"} $mr={"space-between-m"} />
+      )}
+      <OakFlex $flexDirection={"column"} $gap={"all-spacing-6"}>
+        <OakFlex $justifyContent={"space-between"}>
+          <OakP $font={"heading-light-7"}>{lessonCountHeader}</OakP>
+          {isMobile && (
+            <SaveButton
+              unitTitle={unitTitle}
+              onSave={onSave}
+              isSaved={isSaved}
+            />
+          )}
+        </OakFlex>
+        <OakBox
+          $borderColor={"border-decorative1-stronger"}
+          $bl={isMobile ? "border-solid-none" : "border-solid-s"}
+          $pl={["inner-padding-none", "inner-padding-xl"]}
+        >
+          <StyledOL role="list">
+            {lessons.map((lesson, i) => {
+              const href = resolveOakHref({
+                page: "lesson-overview",
+                programmeSlug,
+                unitSlug,
+                lessonSlug: lesson.slug,
+              });
+
+              return (
+                <StyledOakLI
+                  key={lesson.slug}
+                  $pb={"inner-padding-l"}
+                  $font={"heading-light-7"}
+                >
+                  <OakFlex>
+                    <OakP
+                      $textAlign={"right"}
+                      $mr={"space-between-sssx"}
+                      style={{ minWidth: "1.5rem" }}
+                      $color={
+                        lesson._state === "published"
+                          ? "text-primary"
+                          : "text-disabled"
+                      }
+                    >
+                      {i + 1}.{" "}
+                    </OakP>
+                    {lesson._state === "published" ? (
+                      <LessonLink $font={"heading-light-7"} href={href}>
+                        {lesson.title}
+                      </LessonLink>
+                    ) : (
+                      <OakP $color={"text-disabled"}>{lesson.title}</OakP>
+                    )}
+                  </OakFlex>
+                </StyledOakLI>
+              );
+            })}
+          </StyledOL>
+        </OakBox>
+      </OakFlex>
+    </OakFlex>
+  );
+};
+
+export default function MyLibraryUnitCard(props: MyLibraryUnitCardProps) {
+  const { index, lessons } = props;
+
+  const unpublishedLessonCount = lessons.filter(
+    (lesson) => lesson._state !== "published",
+  ).length;
+
+  const lessonCountHeader = unpublishedLessonCount
+    ? `${lessons.length - unpublishedLessonCount}/${lessons.length} lessons`
+    : `${lessons.length} lessons`;
+
+  const isMobile = useMediaQuery("mobile");
path: src/components/TeacherComponents/MyLibraryUnitCard/MyLibraryUnitCard.tsx
user: login: njblore
body: we typically don't do media queries like this but use styles inline on properties, eg. `$display={['none', 'flex', 'block']}` would set display to none on mobile, flex on tablet and block on desktop 
created_at: 2025-05-22T15:17:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,98 @@
+import { screen } from "@testing-library/dom";
+
+import MyLibraryUnitCard from "./MyLibraryUnitCard";
+
+import renderWithProviders from "@/__tests__/__helpers__/renderWithProviders";
+
+const render = renderWithProviders();
+
+jest.mock("@/hooks/useMediaQuery", () => ({
+  __esModule: true,
+  default: jest.fn().mockReturnValue(false),
+}));
+
+const generateLessons = (count: number, state: string) => {
+  return Array.from({ length: count }, (_, index) => ({
+    slug: `lesson-${index}`,
+    order: index,
+    title: `Lesson ${index}`,
+    _state: state,
+    lesson_uid: `LESS-${index}`,
+  }));
+};
+
+const completeUnitLessons = generateLessons(5, "published");
+const mockUnit = {
+  index: 1,
+  unitTitle: "Saved Unit",
+  unitSlug: "saved-unit",
+  programmeSlug: "english-secondary-ks4-aqa",
+  yearTitle: "Year 10",
+  saveTime: "2023-10-01T12:00:00Z",
+  href: "/saved-unit",
+  lessonCount: 5,
+  onSave: jest.fn(),
+  isSaved: false,
+};
+
+const incompleteUnitLessons = generateLessons(2, "new");
+
+describe("MyLibraryUnitCard", () => {
+  it("renders unit info", () => {
+    render(<MyLibraryUnitCard {...mockUnit} lessons={completeUnitLessons} />);
+    expect(screen.getByText(/Saved Unit/)).toBeInTheDocument();
+    expect(screen.getByText(/Year 10/)).toBeInTheDocument();
+    expect(screen.getByText(/5 lessons/)).toBeInTheDocument();
+    expect(screen.getByText(/Lesson 0/)).toBeInTheDocument();
+    expect(screen.getByText(/01\/10\/23, 13:00/)).toBeInTheDocument();
+  });
+  it("renders a save button", () => {
+    render(<MyLibraryUnitCard {...mockUnit} lessons={completeUnitLessons} />);
+    const saveButton = screen.getByRole("button", {
+      name: /Save this unit: Saved Unit/i,
+    });
+    expect(saveButton).toBeInTheDocument();
+  });
+  it("calls onSave when save button is clicked", () => {
+    render(<MyLibraryUnitCard {...mockUnit} lessons={completeUnitLessons} />);
+    const saveButton = screen.getByRole("button", {
+      name: /Save this unit: Saved Unit/i,
+    });
+    saveButton.click();
+    expect(mockUnit.onSave).toHaveBeenCalled();
+  });
+  it("changes the save button when isSaved is true", () => {
+    render(
+      <MyLibraryUnitCard {...mockUnit} lessons={completeUnitLessons} isSaved />,
+    );
+    const saveButton = screen.getByRole("button", {
+      name: /Unsave this unit: Saved Unit/i,
+    });
+    expect(saveButton).toBeInTheDocument();
+    expect(saveButton).toHaveTextContent("Saved");
+  });
+
+  it("displays correct partial lesson count when some are unpublished", () => {
+    const mixedLessons = [
+      ...generateLessons(2, "published"),
+      ...generateLessons(3, "new"),
+    ];
+    render(<MyLibraryUnitCard {...mockUnit} lessons={mixedLessons} />);
+    expect(screen.getByText("2/5 lessons")).toBeInTheDocument();
+  });
+
+  it("renders incomplete unit lessons with correct font color", () => {
+    render(
+      <MyLibraryUnitCard
+        {...mockUnit}
+        lessons={incompleteUnitLessons}
+        isSaved
+      />,
+    );
+
+    const lesson0 = screen.getByText(/Lesson 0/);
+    const styles = getComputedStyle(lesson0);
+    console.log(styles);
path: src/components/TeacherComponents/MyLibraryUnitCard/MyLibraryUnitCard.test.tsx
user: login: njblore
body: 👀 
created_at: 2025-05-22T15:18:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,233 @@
+import {
+  OakFlex,
+  OakHeading,
+  OakSmallTertiaryInvertedButton,
+  OakP,
+  OakSpan,
+  OakBox,
+  OakLI,
+  OakSecondaryLink,
+} from "@oaknational/oak-components";
+import styled from "styled-components";
+
+import useMediaQuery from "@/hooks/useMediaQuery";
+import { resolveOakHref } from "@/common-lib/urls";
+
+type MyLibraryUnitCardProps = {
+  index: number;
+  unitTitle: string;
+  unitSlug: string;
+  programmeSlug: string;
+  yearTitle: string;
+  saveTime: string;
+  href: string;
+  lessons: {
+    slug: string;
+    order: number;
+    title: string;
+    _state: string;
+    lesson_uid: string;
+  }[];
+  onSave?: () => void;
+  isSaved?: boolean;
+};
+
+const UnitCardIndex = ({ index }: Pick<MyLibraryUnitCardProps, "index">) => (
+  <OakFlex
+    $pa={"inner-padding-l"}
+    $background={"bg-decorative1-main"}
+    $flexDirection={"column"}
+    $justifyContent={"center"}
+    $alignItems={"center"}
+    $width={["all-spacing-8", "all-spacing-11"]}
+    $height={["all-spacing-8", "all-spacing-11"]}
+    $borderRadius={"border-radius-m"}
+  >
+    <OakHeading tag="div" $font={["heading-6", "heading-5"]}>
+      {index}
+    </OakHeading>
+  </OakFlex>
+);
+
+const SaveButton = ({
path: src/components/TeacherComponents/MyLibraryUnitCard/MyLibraryUnitCard.tsx
user: login: njblore
body: just a note that Dorothy raised we should add this as a molecule to Oak Components as we are also using it in `OakUnitListItem` and `OakUnitListItemOptionalityCard` in oak components I think verbatim, can be done later
created_at: 2025-05-22T15:22:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,233 @@
+import {
+  OakFlex,
+  OakHeading,
+  OakSmallTertiaryInvertedButton,
+  OakP,
+  OakSpan,
+  OakBox,
+  OakLI,
+  OakSecondaryLink,
+} from "@oaknational/oak-components";
+import styled from "styled-components";
+
+import useMediaQuery from "@/hooks/useMediaQuery";
+import { resolveOakHref } from "@/common-lib/urls";
+
+type MyLibraryUnitCardProps = {
+  index: number;
+  unitTitle: string;
+  unitSlug: string;
+  programmeSlug: string;
+  yearTitle: string;
+  saveTime: string;
+  href: string;
+  lessons: {
+    slug: string;
+    order: number;
+    title: string;
+    _state: string;
+    lesson_uid: string;
path: src/components/TeacherComponents/MyLibraryUnitCard/MyLibraryUnitCard.tsx
user: login: Taha-Hassan-Git
body: I think we need ```_state``` later to render unpublished lessons in a different color, I'll def get rid of ```lesson_uid```
https://github.com/oaknational/Oak-Web-Application/blob/2b875a119f5db0d37ef1f37be2fd1a20354d3d1a/src/components/TeacherComponents/MyLibraryUnitCard/MyLibraryUnitCard.tsx#L175
created_at: 2025-05-22T15:51:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,233 @@
+import {
+  OakFlex,
+  OakHeading,
+  OakSmallTertiaryInvertedButton,
+  OakP,
+  OakSpan,
+  OakBox,
+  OakLI,
+  OakSecondaryLink,
+} from "@oaknational/oak-components";
+import styled from "styled-components";
+
+import useMediaQuery from "@/hooks/useMediaQuery";
+import { resolveOakHref } from "@/common-lib/urls";
+
+type MyLibraryUnitCardProps = {
+  index: number;
+  unitTitle: string;
+  unitSlug: string;
+  programmeSlug: string;
+  yearTitle: string;
+  saveTime: string;
+  href: string;
+  lessons: {
+    slug: string;
+    order: number;
+    title: string;
+    _state: string;
+    lesson_uid: string;
+  }[];
+  onSave?: () => void;
+  isSaved?: boolean;
+};
+
+const UnitCardIndex = ({ index }: Pick<MyLibraryUnitCardProps, "index">) => (
+  <OakFlex
+    $pa={"inner-padding-l"}
+    $background={"bg-decorative1-main"}
+    $flexDirection={"column"}
+    $justifyContent={"center"}
+    $alignItems={"center"}
+    $width={["all-spacing-8", "all-spacing-11"]}
+    $height={["all-spacing-8", "all-spacing-11"]}
+    $borderRadius={"border-radius-m"}
+  >
+    <OakHeading tag="div" $font={["heading-6", "heading-5"]}>
+      {index}
+    </OakHeading>
+  </OakFlex>
+);
+
+const SaveButton = ({
+  unitTitle,
+  onSave,
+  isSaved,
+}: Pick<MyLibraryUnitCardProps, "unitTitle" | "onSave" | "isSaved">) => (
+  <OakSmallTertiaryInvertedButton
+    iconName={isSaved ? "bookmark-filled" : "bookmark-outlined"}
+    isTrailingIcon
+    onClick={onSave}
+    aria-label={`${isSaved ? "Unsave" : "Save"} this unit: ${unitTitle} `}
+  >
+    {isSaved ? "Saved" : "Save"}
+  </OakSmallTertiaryInvertedButton>
+);
+
+const UnitCardHeader = ({
+  isMobile,
+  ...props
+}: { isMobile: boolean } & MyLibraryUnitCardProps) => {
+  const { unitTitle, yearTitle, saveTime, onSave, isSaved } = props;
+
+  return (
+    <OakFlex $flexGrow={1} $alignItems={"start"} $gap={"space-between-xs"}>
+      <OakFlex
+        $flexGrow={1}
+        $flexDirection={"column"}
+        $gap={"space-between-ssx"}
+      >
+        <OakHeading tag="h3" $font={["heading-7", "heading-5"]}>
+          {unitTitle}
+        </OakHeading>
+        <OakP $font={"body-2"}>
+          {yearTitle}
+          <OakSpan $ph={"inner-padding-xs"}>•</OakSpan>
+          Saved at{" "}
+          {new Date(saveTime).toLocaleString("en-GB", {
+            day: "2-digit",
+            month: "2-digit",
+            year: "2-digit",
+            hour: "numeric",
+            minute: "2-digit",
+          })}
+        </OakP>
+      </OakFlex>
+      {!isMobile && (
+        <SaveButton unitTitle={unitTitle} onSave={onSave} isSaved={isSaved} />
+      )}
+    </OakFlex>
+  );
+};
+
+const StyledOL = styled.ol`
+  list-style-type: none;
+`;
+
+const StyledOakLI = styled(OakLI)`
+  &:last-child {
+    padding-bottom: 0;
+  }
+`;
+
+const LessonLink = styled(OakSecondaryLink)`
+  text-decoration: none;
+  color: black !important;
+
+  &:hover {
+    text-decoration: underline;
+  }
+`;
+
+const UnitCardContent = ({
+  lessonCountHeader,
+  isMobile,
+  ...props
+}: {
+  lessonCountHeader: string;
+  isMobile: boolean;
+} & MyLibraryUnitCardProps) => {
+  const { unitTitle, unitSlug, programmeSlug, lessons, onSave, isSaved } =
+    props;
+
+  return (
+    <OakFlex>
+      {!isMobile && (
+        <OakBox $width={"all-spacing-11"} $mr={"space-between-m"} />
+      )}
+      <OakFlex $flexDirection={"column"} $gap={"all-spacing-6"}>
+        <OakFlex $justifyContent={"space-between"}>
+          <OakP $font={"heading-light-7"}>{lessonCountHeader}</OakP>
+          {isMobile && (
+            <SaveButton
+              unitTitle={unitTitle}
+              onSave={onSave}
+              isSaved={isSaved}
+            />
+          )}
+        </OakFlex>
+        <OakBox
+          $borderColor={"border-decorative1-stronger"}
+          $bl={isMobile ? "border-solid-none" : "border-solid-s"}
+          $pl={["inner-padding-none", "inner-padding-xl"]}
+        >
+          <StyledOL role="list">
+            {lessons.map((lesson, i) => {
+              const href = resolveOakHref({
+                page: "lesson-overview",
+                programmeSlug,
+                unitSlug,
+                lessonSlug: lesson.slug,
+              });
+
+              return (
+                <StyledOakLI
+                  key={lesson.slug}
+                  $pb={"inner-padding-l"}
+                  $font={"heading-light-7"}
+                >
+                  <OakFlex>
+                    <OakP
+                      $textAlign={"right"}
+                      $mr={"space-between-sssx"}
+                      style={{ minWidth: "1.5rem" }}
+                      $color={
+                        lesson._state === "published"
+                          ? "text-primary"
+                          : "text-disabled"
+                      }
+                    >
+                      {i + 1}.{" "}
+                    </OakP>
+                    {lesson._state === "published" ? (
+                      <LessonLink $font={"heading-light-7"} href={href}>
+                        {lesson.title}
+                      </LessonLink>
+                    ) : (
+                      <OakP $color={"text-disabled"}>{lesson.title}</OakP>
+                    )}
+                  </OakFlex>
+                </StyledOakLI>
+              );
+            })}
+          </StyledOL>
+        </OakBox>
+      </OakFlex>
+    </OakFlex>
+  );
+};
+
+export default function MyLibraryUnitCard(props: MyLibraryUnitCardProps) {
+  const { index, lessons } = props;
+
+  const unpublishedLessonCount = lessons.filter(
+    (lesson) => lesson._state !== "published",
+  ).length;
+
+  const lessonCountHeader = unpublishedLessonCount
+    ? `${lessons.length - unpublishedLessonCount}/${lessons.length} lessons`
+    : `${lessons.length} lessons`;
+
+  const isMobile = useMediaQuery("mobile");
path: src/components/TeacherComponents/MyLibraryUnitCard/MyLibraryUnitCard.tsx
user: login: Taha-Hassan-Git
body: Phew, this was a bit tough to do because the underlying button component has display: block on it and isn't override-able. I settled on making some wrapper components that hide/show their children at different breakpoints. I looked around to try and see what existing patterns you use to solve this problem but couldn't find any. Let me know how this looks to you. 
created_at: 2025-05-23T10:25:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,233 @@
+import {
+  OakFlex,
+  OakHeading,
+  OakSmallTertiaryInvertedButton,
+  OakP,
+  OakSpan,
+  OakBox,
+  OakLI,
+  OakSecondaryLink,
+} from "@oaknational/oak-components";
+import styled from "styled-components";
+
+import useMediaQuery from "@/hooks/useMediaQuery";
+import { resolveOakHref } from "@/common-lib/urls";
+
+type MyLibraryUnitCardProps = {
+  index: number;
+  unitTitle: string;
+  unitSlug: string;
+  programmeSlug: string;
+  yearTitle: string;
+  saveTime: string;
+  href: string;
+  lessons: {
+    slug: string;
+    order: number;
+    title: string;
+    _state: string;
+    lesson_uid: string;
+  }[];
+  onSave?: () => void;
+  isSaved?: boolean;
+};
+
+const UnitCardIndex = ({ index }: Pick<MyLibraryUnitCardProps, "index">) => (
+  <OakFlex
+    $pa={"inner-padding-l"}
+    $background={"bg-decorative1-main"}
+    $flexDirection={"column"}
+    $justifyContent={"center"}
+    $alignItems={"center"}
+    $width={["all-spacing-8", "all-spacing-11"]}
+    $height={["all-spacing-8", "all-spacing-11"]}
+    $borderRadius={"border-radius-m"}
+  >
+    <OakHeading tag="div" $font={["heading-6", "heading-5"]}>
+      {index}
+    </OakHeading>
+  </OakFlex>
+);
+
+const SaveButton = ({
+  unitTitle,
+  onSave,
+  isSaved,
+}: Pick<MyLibraryUnitCardProps, "unitTitle" | "onSave" | "isSaved">) => (
+  <OakSmallTertiaryInvertedButton
+    iconName={isSaved ? "bookmark-filled" : "bookmark-outlined"}
+    isTrailingIcon
+    onClick={onSave}
+    aria-label={`${isSaved ? "Unsave" : "Save"} this unit: ${unitTitle} `}
+  >
+    {isSaved ? "Saved" : "Save"}
+  </OakSmallTertiaryInvertedButton>
+);
+
+const UnitCardHeader = ({
+  isMobile,
+  ...props
+}: { isMobile: boolean } & MyLibraryUnitCardProps) => {
+  const { unitTitle, yearTitle, saveTime, onSave, isSaved } = props;
+
+  return (
+    <OakFlex $flexGrow={1} $alignItems={"start"} $gap={"space-between-xs"}>
+      <OakFlex
+        $flexGrow={1}
+        $flexDirection={"column"}
+        $gap={"space-between-ssx"}
+      >
+        <OakHeading tag="h3" $font={["heading-7", "heading-5"]}>
+          {unitTitle}
+        </OakHeading>
+        <OakP $font={"body-2"}>
+          {yearTitle}
+          <OakSpan $ph={"inner-padding-xs"}>•</OakSpan>
+          Saved at{" "}
+          {new Date(saveTime).toLocaleString("en-GB", {
+            day: "2-digit",
+            month: "2-digit",
+            year: "2-digit",
+            hour: "numeric",
+            minute: "2-digit",
+          })}
+        </OakP>
+      </OakFlex>
+      {!isMobile && (
+        <SaveButton unitTitle={unitTitle} onSave={onSave} isSaved={isSaved} />
+      )}
+    </OakFlex>
+  );
+};
+
+const StyledOL = styled.ol`
+  list-style-type: none;
+`;
+
+const StyledOakLI = styled(OakLI)`
+  &:last-child {
+    padding-bottom: 0;
+  }
+`;
+
+const LessonLink = styled(OakSecondaryLink)`
+  text-decoration: none;
+  color: black !important;
+
+  &:hover {
+    text-decoration: underline;
+  }
+`;
+
+const UnitCardContent = ({
+  lessonCountHeader,
+  isMobile,
+  ...props
+}: {
+  lessonCountHeader: string;
+  isMobile: boolean;
+} & MyLibraryUnitCardProps) => {
+  const { unitTitle, unitSlug, programmeSlug, lessons, onSave, isSaved } =
+    props;
+
+  return (
+    <OakFlex>
+      {!isMobile && (
+        <OakBox $width={"all-spacing-11"} $mr={"space-between-m"} />
+      )}
+      <OakFlex $flexDirection={"column"} $gap={"all-spacing-6"}>
+        <OakFlex $justifyContent={"space-between"}>
+          <OakP $font={"heading-light-7"}>{lessonCountHeader}</OakP>
+          {isMobile && (
+            <SaveButton
+              unitTitle={unitTitle}
+              onSave={onSave}
+              isSaved={isSaved}
+            />
+          )}
+        </OakFlex>
+        <OakBox
+          $borderColor={"border-decorative1-stronger"}
+          $bl={isMobile ? "border-solid-none" : "border-solid-s"}
+          $pl={["inner-padding-none", "inner-padding-xl"]}
+        >
+          <StyledOL role="list">
+            {lessons.map((lesson, i) => {
+              const href = resolveOakHref({
+                page: "lesson-overview",
+                programmeSlug,
+                unitSlug,
+                lessonSlug: lesson.slug,
+              });
+
+              return (
+                <StyledOakLI
+                  key={lesson.slug}
+                  $pb={"inner-padding-l"}
+                  $font={"heading-light-7"}
+                >
+                  <OakFlex>
+                    <OakP
+                      $textAlign={"right"}
+                      $mr={"space-between-sssx"}
+                      style={{ minWidth: "1.5rem" }}
+                      $color={
+                        lesson._state === "published"
+                          ? "text-primary"
+                          : "text-disabled"
+                      }
+                    >
+                      {i + 1}.{" "}
+                    </OakP>
+                    {lesson._state === "published" ? (
+                      <LessonLink $font={"heading-light-7"} href={href}>
+                        {lesson.title}
+                      </LessonLink>
+                    ) : (
+                      <OakP $color={"text-disabled"}>{lesson.title}</OakP>
+                    )}
+                  </OakFlex>
+                </StyledOakLI>
+              );
+            })}
+          </StyledOL>
+        </OakBox>
+      </OakFlex>
+    </OakFlex>
+  );
+};
+
+export default function MyLibraryUnitCard(props: MyLibraryUnitCardProps) {
+  const { index, lessons } = props;
+
+  const unpublishedLessonCount = lessons.filter(
+    (lesson) => lesson._state !== "published",
+  ).length;
+
+  const lessonCountHeader = unpublishedLessonCount
+    ? `${lessons.length - unpublishedLessonCount}/${lessons.length} lessons`
+    : `${lessons.length} lessons`;
+
+  const isMobile = useMediaQuery("mobile");
path: src/components/TeacherComponents/MyLibraryUnitCard/MyLibraryUnitCard.tsx
user: login: njblore
body: Nice! There is a bit of a shorthand to do what you've done with the wrappers and this is the usual pattern we'd use to do this kind of thing; you can replace each wrapper component with an `OakBox` with the display value set depending on whether it's for mobile or desktop, so `MobileOnlyWrapper` would be replaced with:
```
<OakBox $display={["block", "none"]}>
    <SaveButton ... />
</OakBox>
````
created_at: 2025-05-23T10:37:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,233 @@
+import {
+  OakFlex,
+  OakHeading,
+  OakSmallTertiaryInvertedButton,
+  OakP,
+  OakSpan,
+  OakBox,
+  OakLI,
+  OakSecondaryLink,
+} from "@oaknational/oak-components";
+import styled from "styled-components";
+
+import useMediaQuery from "@/hooks/useMediaQuery";
+import { resolveOakHref } from "@/common-lib/urls";
+
+type MyLibraryUnitCardProps = {
+  index: number;
+  unitTitle: string;
+  unitSlug: string;
+  programmeSlug: string;
+  yearTitle: string;
+  saveTime: string;
+  href: string;
+  lessons: {
+    slug: string;
+    order: number;
+    title: string;
+    _state: string;
+    lesson_uid: string;
+  }[];
+  onSave?: () => void;
+  isSaved?: boolean;
+};
+
+const UnitCardIndex = ({ index }: Pick<MyLibraryUnitCardProps, "index">) => (
+  <OakFlex
+    $pa={"inner-padding-l"}
+    $background={"bg-decorative1-main"}
+    $flexDirection={"column"}
+    $justifyContent={"center"}
+    $alignItems={"center"}
+    $width={["all-spacing-8", "all-spacing-11"]}
+    $height={["all-spacing-8", "all-spacing-11"]}
+    $borderRadius={"border-radius-m"}
+  >
+    <OakHeading tag="div" $font={["heading-6", "heading-5"]}>
+      {index}
+    </OakHeading>
+  </OakFlex>
+);
+
+const SaveButton = ({
+  unitTitle,
+  onSave,
+  isSaved,
+}: Pick<MyLibraryUnitCardProps, "unitTitle" | "onSave" | "isSaved">) => (
+  <OakSmallTertiaryInvertedButton
+    iconName={isSaved ? "bookmark-filled" : "bookmark-outlined"}
+    isTrailingIcon
+    onClick={onSave}
+    aria-label={`${isSaved ? "Unsave" : "Save"} this unit: ${unitTitle} `}
+  >
+    {isSaved ? "Saved" : "Save"}
+  </OakSmallTertiaryInvertedButton>
+);
+
+const UnitCardHeader = ({
+  isMobile,
+  ...props
+}: { isMobile: boolean } & MyLibraryUnitCardProps) => {
+  const { unitTitle, yearTitle, saveTime, onSave, isSaved } = props;
+
+  return (
+    <OakFlex $flexGrow={1} $alignItems={"start"} $gap={"space-between-xs"}>
+      <OakFlex
+        $flexGrow={1}
+        $flexDirection={"column"}
+        $gap={"space-between-ssx"}
+      >
+        <OakHeading tag="h3" $font={["heading-7", "heading-5"]}>
+          {unitTitle}
+        </OakHeading>
+        <OakP $font={"body-2"}>
+          {yearTitle}
+          <OakSpan $ph={"inner-padding-xs"}>•</OakSpan>
+          Saved at{" "}
+          {new Date(saveTime).toLocaleString("en-GB", {
+            day: "2-digit",
+            month: "2-digit",
+            year: "2-digit",
+            hour: "numeric",
+            minute: "2-digit",
+          })}
+        </OakP>
+      </OakFlex>
+      {!isMobile && (
+        <SaveButton unitTitle={unitTitle} onSave={onSave} isSaved={isSaved} />
+      )}
+    </OakFlex>
+  );
+};
+
+const StyledOL = styled.ol`
+  list-style-type: none;
+`;
+
+const StyledOakLI = styled(OakLI)`
+  &:last-child {
+    padding-bottom: 0;
+  }
+`;
+
+const LessonLink = styled(OakSecondaryLink)`
+  text-decoration: none;
+  color: black !important;
+
+  &:hover {
+    text-decoration: underline;
+  }
+`;
+
+const UnitCardContent = ({
+  lessonCountHeader,
+  isMobile,
+  ...props
+}: {
+  lessonCountHeader: string;
+  isMobile: boolean;
+} & MyLibraryUnitCardProps) => {
+  const { unitTitle, unitSlug, programmeSlug, lessons, onSave, isSaved } =
+    props;
+
+  return (
+    <OakFlex>
+      {!isMobile && (
+        <OakBox $width={"all-spacing-11"} $mr={"space-between-m"} />
+      )}
+      <OakFlex $flexDirection={"column"} $gap={"all-spacing-6"}>
+        <OakFlex $justifyContent={"space-between"}>
+          <OakP $font={"heading-light-7"}>{lessonCountHeader}</OakP>
+          {isMobile && (
+            <SaveButton
+              unitTitle={unitTitle}
+              onSave={onSave}
+              isSaved={isSaved}
+            />
+          )}
+        </OakFlex>
+        <OakBox
+          $borderColor={"border-decorative1-stronger"}
+          $bl={isMobile ? "border-solid-none" : "border-solid-s"}
+          $pl={["inner-padding-none", "inner-padding-xl"]}
+        >
+          <StyledOL role="list">
+            {lessons.map((lesson, i) => {
+              const href = resolveOakHref({
+                page: "lesson-overview",
+                programmeSlug,
+                unitSlug,
+                lessonSlug: lesson.slug,
+              });
+
+              return (
+                <StyledOakLI
+                  key={lesson.slug}
+                  $pb={"inner-padding-l"}
+                  $font={"heading-light-7"}
+                >
+                  <OakFlex>
+                    <OakP
+                      $textAlign={"right"}
+                      $mr={"space-between-sssx"}
+                      style={{ minWidth: "1.5rem" }}
+                      $color={
+                        lesson._state === "published"
+                          ? "text-primary"
+                          : "text-disabled"
+                      }
+                    >
+                      {i + 1}.{" "}
+                    </OakP>
+                    {lesson._state === "published" ? (
+                      <LessonLink $font={"heading-light-7"} href={href}>
+                        {lesson.title}
+                      </LessonLink>
+                    ) : (
+                      <OakP $color={"text-disabled"}>{lesson.title}</OakP>
+                    )}
+                  </OakFlex>
+                </StyledOakLI>
+              );
+            })}
+          </StyledOL>
+        </OakBox>
+      </OakFlex>
+    </OakFlex>
+  );
+};
+
+export default function MyLibraryUnitCard(props: MyLibraryUnitCardProps) {
+  const { index, lessons } = props;
+
+  const unpublishedLessonCount = lessons.filter(
+    (lesson) => lesson._state !== "published",
+  ).length;
+
+  const lessonCountHeader = unpublishedLessonCount
+    ? `${lessons.length - unpublishedLessonCount}/${lessons.length} lessons`
+    : `${lessons.length} lessons`;
+
+  const isMobile = useMediaQuery("mobile");
path: src/components/TeacherComponents/MyLibraryUnitCard/MyLibraryUnitCard.tsx
user: login: Taha-Hassan-Git
body: Of course there's a shorthand! I'll put this in, thanks.
created_at: 2025-05-23T10:38:29Z
author_association: CONTRIBUTOR

PR comments:
user: login: notion-workspace[bot]
created_at: 2025-05-21T16:52:33Z
body: <a href="https://www.notion.so/I-want-a-unit-card-component-for-the-Oak-Hub-1cf26cc4e1b18079908bc584c06ad4ac?pvs=8&n=github_linkback">I want a unit card component for the Oak Hub</a>
author_association: NONE
user: login: netlify[bot]
created_at: 2025-05-21T16:52:34Z
body: ### <span aria-hidden="true">✅</span> Deploy Preview for *owa-storybook* ready!


|  Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | b7e9b7d77d34a3dfac5b06d278295d0ff7d28370 |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/projects/owa-storybook/deploys/68357427511a34000835ac5f |
|<span aria-hidden="true">😎</span> Deploy Preview | [https://deploy-preview-3430--owa-storybook.netlify.app](https://deploy-preview-3430--owa-storybook.netlify.app) |
|<span aria-hidden="true">📱</span> Preview on mobile | <details><summary> Toggle QR Code... </summary><br /><br />![QR Code](https://app.netlify.com/qr-code/eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1cmwiOiJodHRwczovL2RlcGxveS1wcmV2aWV3LTM0MzAtLW93YS1zdG9yeWJvb2submV0bGlmeS5hcHAifQ.J5jV7HZ4xmNRUkT7DcuciBtV5U81Zkj0HBHPG-qgZvc)<br /><br />_Use your smartphone camera to open QR code link._</details> |
---
<!-- [owa-storybook Preview](https://deploy-preview-3430--owa-storybook.netlify.app) -->
_To edit notification comments on pull requests, go to your [Netlify project configuration](https://app.netlify.com/projects/owa-storybook/configuration/notifications#deploy-notifications)._
author_association: NONE
user: login: netlify[bot]
created_at: 2025-05-21T16:52:35Z
body: ### <span aria-hidden="true">✅</span> Deploy Preview for *oak-web-application* ready!


|  Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | b7e9b7d77d34a3dfac5b06d278295d0ff7d28370 |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/projects/oak-web-application/deploys/683574278f8c120008fdf2ae |
|<span aria-hidden="true">😎</span> Deploy Preview | [https://deploy-preview-3430--oak-web-application.netlify.app](https://deploy-preview-3430--oak-web-application.netlify.app) |
|<span aria-hidden="true">📱</span> Preview on mobile | <details><summary> Toggle QR Code... </summary><br /><br />![QR Code](https://app.netlify.com/qr-code/eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1cmwiOiJodHRwczovL2RlcGxveS1wcmV2aWV3LTM0MzAtLW9hay13ZWItYXBwbGljYXRpb24ubmV0bGlmeS5hcHAifQ.F2ZzinRPXXE_QnQDBdeN44O3-F6Uctjysbx8gcgPsG0)<br /><br />_Use your smartphone camera to open QR code link._</details> |
---
<!-- [oak-web-application Preview](https://deploy-preview-3430--oak-web-application.netlify.app) -->
_To edit notification comments on pull requests, go to your [Netlify project configuration](https://app.netlify.com/projects/oak-web-application/configuration/notifications#deploy-notifications)._
author_association: NONE
user: login: Taha-Hassan-Git
created_at: 2025-05-22T15:24:51Z
body: > Hey! A few quick bits of feedback 😊
> 
> * [x]  Could we make the unit title clickable, linking to the lesson listing page? Also, just a heads-up to make sure it has a focus state for keyboard navigation.
> * [x]  For consistency, all text should use the text-primary design token (that slightly off-black colour), except for the year line and similar metadata, which should use text-subdued.
> * [x]  On mobile view, would it be possible to left-align the lesson count (e.g. “5 lessons”) and the lesson list? It’ll help with readability and alignment.
>   Thanks so much—looking great overall!

Thanks for the speedy feedback. I've got a fix for the first couple of issues! Would you mind sending a screenshot of this alignment issue?

This is what I'm seeing at smaller breakpoints:

![Kapture 2025-05-22 at 16 18 48](https://github.com/user-attachments/assets/c3a2f831-8670-479f-8f4c-6197d73b1b1e)

author_association: CONTRIBUTOR
user: login: Monika-Koziol
created_at: 2025-05-22T16:02:16Z
body: @Taha-Hassan-Git Figma was automatically indenting the list items, so I couldn’t adjust the alignment there. But I was wondering: in code, would it be possible to align the list items with the lesson count? I’ve tried to annotate what I mean in the screenshot—hope it makes sense. And if it turns out to be a big lift, we can definitely leave it for now.

Thanks so much! 😊
![Screenshot 2025-05-22 at 16 58 31](https://github.com/user-attachments/assets/f7cdfabc-feb8-4444-a606-63ca321ecbfd)

author_association: NONE
user: login: Taha-Hassan-Git
created_at: 2025-05-22T16:11:18Z
body: > @Taha-Hassan-Git Figma was automatically indenting the list items, so I couldn’t adjust the alignment there. But I was wondering: in code, would it be possible to align the list items with the lesson count? I’ve tried to annotate what I mean in the screenshot—hope it makes sense. And if it turns out to be a big lift, we can definitely leave it for now.
> 
> Thanks so much! 😊 ![Screenshot 2025-05-22 at 16 58 31](https://private-user-images.githubusercontent.com/125288018/446656168-f7cdfabc-feb8-4444-a606-63ca321ecbfd.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NDc5MzAwMzgsIm5iZiI6MTc0NzkyOTczOCwicGF0aCI6Ii8xMjUyODgwMTgvNDQ2NjU2MTY4LWY3Y2RmYWJjLWZlYjgtNDQ0NC1hNjA2LTYzY2EzMjFlY2JmZC5wbmc_WC1BbXotQWxnb3JpdGhtPUFXUzQtSE1BQy1TSEEyNTYmWC1BbXotQ3JlZGVudGlhbD1BS0lBVkNPRFlMU0E1M1BRSzRaQSUyRjIwMjUwNTIyJTJGdXMtZWFzdC0xJTJGczMlMkZhd3M0X3JlcXVlc3QmWC1BbXotRGF0ZT0yMDI1MDUyMlQxNjAyMThaJlgtQW16LUV4cGlyZXM9MzAwJlgtQW16LVNpZ25hdHVyZT03NzUwMmMwMTIzYTQ0ZjA4YmYxYWE1OGFmOWMwNWVlZmY0ZmFhMTViY2JhYWEzYmFkMGE2YThhZjA3MDU3ZTNjJlgtQW16LVNpZ25lZEhlYWRlcnM9aG9zdCJ9.lZWki8h4EGRPydIh28_S45nIttiQi-bGyAGTxQYm6ZM)

The annotation is super clear, thank you! I'll give it a go. Seems quite doable!
author_association: CONTRIBUTOR
user: login: sonarqubecloud[bot]
created_at: 2025-05-27T08:19:26Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=oaknational_owa&pullRequest=3430) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [2 New issues](https://sonarcloud.io/project/issues?id=oaknational_owa&pullRequest=3430&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=oaknational_owa&pullRequest=3430&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=oaknational_owa&pullRequest=3430&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [100.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=oaknational_owa&pullRequest=3430&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=oaknational_owa&pullRequest=3430&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=oaknational_owa&pullRequest=3430)


author_association: NONE
user: login: oak-machine-user
created_at: 2025-05-27T08:23:31Z
body: :tada: This PR is included in version 1.752.0 :tada:

The release is available on [GitHub release](https://github.com/oaknational/Oak-Web-Application/releases/tag/v1.752.0)

Your **[semantic-release](https://github.com/semantic-release/semantic-release)** bot :package::rocket:
author_association: COLLABORATOR