From 1504fb68da916ac2dcc742d7e842eff549a2c91e Mon Sep 17 00:00:00 2001 From: Rob OConnell Date: Fri, 21 Aug 2026 13:44:20 -0400 Subject: [PATCH 1/9] docs: plan student visibility preview hotfix --- ...08-21-student-visibility-preview-hotfix.md | 343 ++++++++++++++++++ ...tudent-visibility-preview-hotfix-design.md | 84 +++++ 2 files changed, 427 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-21-student-visibility-preview-hotfix.md create mode 100644 docs/superpowers/specs/2026-08-21-student-visibility-preview-hotfix-design.md diff --git a/docs/superpowers/plans/2026-08-21-student-visibility-preview-hotfix.md b/docs/superpowers/plans/2026-08-21-student-visibility-preview-hotfix.md new file mode 100644 index 0000000..799084a --- /dev/null +++ b/docs/superpowers/plans/2026-08-21-student-visibility-preview-hotfix.md @@ -0,0 +1,343 @@ +# Student Visibility Preview Hotfix Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an authenticated staff-only student preview that enforces logged-out resource and link visibility without changing production authentication, backend behavior, or workflow exposure. + +**Architecture:** Keep the real session intact and derive a separate `canBypassVisibility` boolean from authentication plus the `preview=student` query parameter. `CourseRecords` owns preview state and passes visibility authority explicitly to `RecordCard` and `RecordTable`; pure URL and policy helpers provide unit-test coverage without adding dependencies. + +**Tech Stack:** React 18, React Router, Reactstrap, Vitest 3, ESLint 9, Vite 5 + +**Spec:** `docs/superpowers/specs/2026-08-21-student-visibility-preview-hotfix-design.md` + +## Global Constraints + +- Base all work on production commit `eacfc14d8fdff51030b989f6fd640c8357d44973`. +- Do not change `AuthContext`, cookies, tokens, permissions, backend APIs, or persisted visibility data. +- Do not modify `src/pages/Admin.jsx`, `src/components/layout/AppRoutes.jsx`, `src/components/layout/Header.jsx`, `src/config/api.config.js`, `.env.production`, `.env.staging`, or `server/workflow-admin/`. +- `preview=student` may only make a view more restrictive; it must never grant visibility. +- Preserve all unrelated URL query parameters when entering or leaving preview. +- Do not add runtime or development dependencies. + +--- + +### Task 1: Add tested student-preview policy helpers + +**Files:** +- Create: `src/util/studentPreview.js` +- Create: `src/util/studentPreview.test.js` +- Modify: `package.json` + +**Interfaces:** +- Produces: `isStudentPreview(search: string): boolean` +- Produces: `withStudentPreview(search: string, enabled: boolean): string` +- Produces: `canBypassVisibility(isAuthenticated: boolean, studentPreview: boolean): boolean` + +- [ ] **Step 1: Add the test command and failing helper tests** + +Add `"test": "vitest run"` to `package.json` scripts and create: + +```js +import { describe, expect, it } from 'vitest'; +import { + canBypassVisibility, + isStudentPreview, + withStudentPreview, +} from './studentPreview'; + +describe('student preview policy', () => { + it('recognizes only the explicit student preview value', () => { + expect(isStudentPreview('?preview=student')).toBe(true); + expect(isStudentPreview('?preview=staff')).toBe(false); + expect(isStudentPreview('')).toBe(false); + }); + + it('allows only non-preview authenticated sessions to bypass visibility', () => { + expect(canBypassVisibility(true, false)).toBe(true); + expect(canBypassVisibility(true, true)).toBe(false); + expect(canBypassVisibility(false, false)).toBe(false); + expect(canBypassVisibility(false, true)).toBe(false); + }); + + it('adds preview without losing existing parameters', () => { + expect(withStudentPreview('?college=smith§ion=01', true)) + .toBe('?college=smith§ion=01&preview=student'); + }); + + it('removes only preview when exiting', () => { + expect(withStudentPreview('?college=smith&preview=student§ion=01', false)) + .toBe('?college=smith§ion=01'); + }); +}); +``` + +- [ ] **Step 2: Run the test and verify it fails** + +Run: + +```bash +npm test -- src/util/studentPreview.test.js +``` + +Expected: FAIL because `src/util/studentPreview.js` does not exist. + +- [ ] **Step 3: Implement the pure helpers** + +Create `src/util/studentPreview.js`: + +```js +const PREVIEW_PARAM = 'preview'; +const STUDENT_PREVIEW_VALUE = 'student'; + +export const isStudentPreview = (search = '') => { + return new URLSearchParams(search).get(PREVIEW_PARAM) === STUDENT_PREVIEW_VALUE; +}; + +export const withStudentPreview = (search = '', enabled) => { + const params = new URLSearchParams(search); + + if (enabled) { + params.set(PREVIEW_PARAM, STUDENT_PREVIEW_VALUE); + } else { + params.delete(PREVIEW_PARAM); + } + + const query = params.toString(); + return query ? `?${query}` : ''; +}; + +export const canBypassVisibility = (isAuthenticated, studentPreview) => { + return Boolean(isAuthenticated && !studentPreview); +}; +``` + +- [ ] **Step 4: Run the helper tests** + +Run: + +```bash +npm test -- src/util/studentPreview.test.js +``` + +Expected: 4 tests PASS. + +- [ ] **Step 5: Commit the policy helper** + +```bash +git add package.json src/util/studentPreview.js src/util/studentPreview.test.js +git commit -m "test: define student preview visibility policy" +``` + +--- + +### Task 2: Add preview state and controls to the course page + +**Files:** +- Modify: `src/pages/CourseRecords.jsx:1-50, 517-543, 756-843, 1045-1130, 1177-1338` + +**Interfaces:** +- Consumes: `isStudentPreview(location.search)` +- Consumes: `withStudentPreview(location.search, enabled)` +- Consumes: `canBypassVisibility(isAuthenticated, studentPreview)` +- Produces: `canBypassResourceVisibility: boolean` passed to record views + +- [ ] **Step 1: Import the policy helpers and derive page state** + +Import the three helpers from `../util/studentPreview`. Immediately after reading authentication state, derive: + +```js +const studentPreview = isStudentPreview(location.search); +const canBypassResourceVisibility = canBypassVisibility( + isAuthenticated, + studentPreview +); + +const setStudentPreview = useCallback((enabled) => { + navigate({ + pathname: location.pathname, + search: withStudentPreview(location.search, enabled), + hash: location.hash, + }, { replace: true }); +}, [location.hash, location.pathname, location.search, navigate]); +``` + +- [ ] **Step 2: Replace page-level visibility authority** + +Within resource filtering, hidden-resource counts, and the “not currently available” summary, replace visibility uses of `isAuthenticated` with `canBypassResourceVisibility`. Keep `isAuthenticated` only for deciding whether to show staff preview controls. + +Update hook dependency arrays accordingly. Do not replace authentication used for unrelated session behavior. + +- [ ] **Step 3: Add the preview action and active banner** + +Immediately after the course-information block and before the display-mode control (currently between lines 1086 and 1088), render **Preview as student** only when `isAuthenticated && !studentPreview`. When `studentPreview` is true, render: + +```jsx + + + Student preview is active.{' '} + Resources and links outside their visibility dates are hidden as they are for students. + + + +``` + +The enter action calls `setStudentPreview(true)`. Reuse Reactstrap components already imported by the page; add no new package. + +- [ ] **Step 4: Pass visibility authority to every record view** + +Pass the same prop through every render path: + +```jsx + + +``` + +Cover grouped cards, ungrouped cards, split-view physical cards, split-view electronic cards, and the table view. Remove the currently ineffective `isAuthenticated={isAuthenticated}` props passed to `RecordCard`. + +- [ ] **Step 5: Run targeted lint** + +Run: + +```bash +npx eslint src/pages/CourseRecords.jsx src/util/studentPreview.js src/util/studentPreview.test.js +``` + +Expected: exit 0. + +- [ ] **Step 6: Commit the course-page controls** + +```bash +git add src/pages/CourseRecords.jsx +git commit -m "feat: add student preview controls" +``` + +--- + +### Task 3: Make card and table views honor explicit visibility authority + +**Files:** +- Modify: `src/components/page-sections/course-record/RecordCard.jsx:18, 40-56, 171-214, 250, 522-625, 758-899` +- Modify: `src/components/page-sections/course-record/RecordTable.jsx:14, 40-56, 96-224, 355-373, 585-632, 746, 1061-1082` + +**Interfaces:** +- Consumes: `canBypassVisibility: boolean` from `CourseRecords` +- Produces: matching resource and link visibility behavior in card and table views + +- [ ] **Step 1: Update `RecordCard`** + +Add `canBypassVisibility = false` to its props, remove the direct `useAuth` import and hook, and use the prop everywhere the component currently uses `isAuthenticated` for: + +- whole-resource visibility; +- primary-link visibility; +- additional-link visibility; +- staff-only visibility date text and popovers; +- decisions to render unavailable resources. + +Add `canBypassVisibility: PropTypes.bool` and its default value. Do not change tracking behavior or URLs. + +- [ ] **Step 2: Update `RecordTable`** + +Add `canBypassVisibility = false` to its props, remove the direct `useAuth` import and hook, and use the prop everywhere the table currently uses `isAuthenticated` for filtering, primary links, additional links, and staff visibility information. + +Add `canBypassVisibility: PropTypes.bool` and its default value. Ensure both combined and split table paths use the same authority. + +- [ ] **Step 3: Run tests and targeted lint** + +Run: + +```bash +npm test -- src/util/studentPreview.test.js +npx eslint src/pages/CourseRecords.jsx src/components/page-sections/course-record/RecordCard.jsx src/components/page-sections/course-record/RecordTable.jsx src/util/studentPreview.js src/util/studentPreview.test.js +``` + +Expected: 4 tests PASS and ESLint exits 0. + +- [ ] **Step 4: Build the production artifact** + +Run: + +```bash +npm run build +``` + +Expected: Vite completes a production build and writes assets under `/course-reserves/`. Existing bundle-size or Browserslist warnings are acceptable; compilation errors are not. + +- [ ] **Step 5: Commit the record-view integration** + +```bash +git add src/components/page-sections/course-record/RecordCard.jsx src/components/page-sections/course-record/RecordTable.jsx +git commit -m "fix: enforce student visibility during preview" +``` + +--- + +### Task 4: Verify production isolation and the original bug scenario + +**Files:** +- No source changes expected +- Record results in the pull-request description + +**Interfaces:** +- Consumes: completed hotfix branch +- Produces: release evidence and a go/no-go decision + +- [ ] **Step 1: Confirm protected production files are unchanged** + +Run: + +```bash +git diff --exit-code eacfc14d8fdff51030b989f6fd640c8357d44973 -- \ + .env.production \ + .env.staging \ + src/pages/Admin.jsx \ + src/components/layout/AppRoutes.jsx \ + src/components/layout/Header.jsx \ + src/config/api.config.js \ + server/workflow-admin +``` + +Expected: no diff and exit 0. + +- [ ] **Step 2: Run the complete automated verification** + +Run: + +```bash +npm test +npm run build +``` + +Expected: all tests PASS and production build exits 0. + +- [ ] **Step 3: Smoke-test visibility in staging or a production-equivalent environment** + +Use one course containing: + +- a whole electronic resource outside its visibility window; +- a primary link outside its visibility window; +- an additional link outside its visibility window; +- a currently visible resource and link as controls. + +Verify this matrix: + +| Session/view | Out-of-window content | In-window content | Staff date annotations | +| --- | --- | --- | --- | +| Fully logged out | Hidden | Visible | Hidden | +| Staff, default view | Visible | Visible | Visible | +| Staff, `preview=student` | Hidden | Visible | Hidden | + +Repeat in card/table and combined/split views. Enter and exit preview and confirm the course, college, and section remain unchanged. + +- [ ] **Step 4: Verify workflow behavior has not regressed** + +Using the production build, confirm the Workflow dropdown, workflow-template tab, mention badge, and workflow notification bell remain absent from normal production navigation. Do not treat the already-known direct-URL exposure as part of this hotfix; document it for the separate workflow-hardening issue. + +- [ ] **Step 5: Make the release decision** + +Proceed only if the logged-out and staff-preview rows match exactly. If the logged-out row exposes an out-of-window resource or link, stop the release and open a separate persistence/normalization hotfix before deploying student preview. + +- [ ] **Step 6: Prepare rollback information** + +Record production baseline `eacfc14d8fdff51030b989f6fd640c8357d44973` and its previously deployed artifact in the release notes. Because this branch has no schema or API changes, rollback is redeployment of that artifact. diff --git a/docs/superpowers/specs/2026-08-21-student-visibility-preview-hotfix-design.md b/docs/superpowers/specs/2026-08-21-student-visibility-preview-hotfix-design.md new file mode 100644 index 0000000..26baf13 --- /dev/null +++ b/docs/superpowers/specs/2026-08-21-student-visibility-preview-hotfix-design.md @@ -0,0 +1,84 @@ +# Student Visibility Preview Hotfix Design + +**Status:** Approved in conversation on 2026-08-21 + +**Production baseline:** `eacfc14d8fdff51030b989f6fd640c8357d44973` (`main`) + +## Problem + +Authenticated staff always bypass resource and link visibility windows on the public course-records page. That behavior lets staff manage and verify future or expired resources, but it also makes the visibility toggles appear ineffective during training. + +The hotfix must give staff an accurate student-facing preview without logging them out, changing authentication, changing stored visibility settings, or exposing workflow features that production intentionally keeps out of navigation. + +## Goals + +- Add a clearly labeled **Preview as student** action for authenticated users on the public course-records page. +- In preview mode, apply the same resource, primary-link, and additional-link visibility decisions used for logged-out visitors. +- Make preview mode unmistakable with a persistent banner and an **Exit student preview** action. +- Preserve the current URL and all unrelated query parameters when entering or leaving preview mode. +- Preserve the current production build mode, authentication, API endpoints, workflow navigation behavior, and backend behavior. + +## Non-goals + +- Do not change `AuthContext`, authentication cookies, tokens, roles, or permissions. +- Do not change resource visibility values in the database. +- Do not change `VisibilityDates.jsx`, `UnifiedVisibilityControl.jsx`, resource persistence, or backend APIs in this hotfix. +- Do not modify workflow components, routes, polling, environment files, or production feature gates. +- Do not attempt to fix the existing direct-URL workflow exposure in the same branch; track that as a separate production-hardening change. + +## Design + +### Preview state + +The URL query parameter `preview=student` is the source of truth. It survives a refresh, can be exited without losing course-identifying parameters, and is safe if shared: it can only make the view more restrictive. + +Only authenticated users see the preview control. If a logged-out user opens a URL containing `preview=student`, normal student restrictions still apply. + +### Visibility authority + +Authentication and visibility authority must be separate concepts: + +```js +const canBypassVisibility = isAuthenticated && !isStudentPreview; +``` + +`isAuthenticated` continues to describe the real session. `canBypassVisibility` is passed to every visibility decision in `CourseRecords`, `RecordCard`, and `RecordTable`. Preview mode must also suppress staff-only visibility dates and unavailable-link annotations so that the result matches a logged-out view. + +The helper that interprets and updates the preview query parameter will live in `src/util/studentPreview.js`. Keeping URL manipulation pure makes it testable without adding a component-testing dependency to this production hotfix. + +### User interface + +Authenticated users see **Preview as student** near the course-page controls. When enabled, the page displays a warning banner: + +> Student preview is active. Resources and links outside their visibility dates are hidden as they are for students. + +The banner includes **Exit student preview**. Both actions preserve `college`, `courseListingId`, `id`, `section`, and any future query parameters. + +### Failure behavior + +Preview has no API dependency. Invalid preview parameter values are ignored. If visibility data is absent, existing visibility behavior remains unchanged. + +If a logged-out production smoke test shows that a saved toggle is not honored, release is blocked. That would confirm a separate persistence or normalization defect and must not be hidden by the preview UX. + +## Files + +- Create `src/util/studentPreview.js` — preview query parsing, query updates, and visibility-bypass calculation. +- Create `src/util/studentPreview.test.js` — pure unit tests for preview semantics and parameter preservation. +- Modify `package.json` — expose the existing Vitest dependency through `npm test`. +- Modify `src/pages/CourseRecords.jsx` — render the control/banner and supply visibility authority. +- Modify `src/components/page-sections/course-record/RecordCard.jsx` — consume explicit visibility authority. +- Modify `src/components/page-sections/course-record/RecordTable.jsx` — consume explicit visibility authority. + +## Acceptance criteria + +- Logged-in staff default view remains unchanged. +- Logged-in staff student preview matches a logged-out view for whole resources, primary links, and additional links. +- Preview works in card, table, combined, and split views. +- Entering and exiting preview preserves all unrelated query parameters. +- No authentication, backend, workflow, route, environment, or deployment files change. +- Unit tests, targeted lint, and the production Vite build pass. +- A manual smoke test confirms saved visibility settings work while fully logged out before release. + +## Rollout and rollback + +Build from this hotfix branch using the existing production command, deploy through the current production process, and smoke-test both staff and logged-out sessions. Rollback consists of redeploying the artifact from production baseline `eacfc14d` because this hotfix has no schema, API, or persistent-data changes. From 454fde6ef41e2e6b3fb5f2ec099b21fc6d5dea36 Mon Sep 17 00:00:00 2001 From: Rob OConnell Date: Sat, 22 Aug 2026 10:26:36 -0400 Subject: [PATCH 2/9] test: define student preview visibility policy --- .../task-1-report.md | 55 +++++++++++++++++++ package.json | 1 + src/util/studentPreview.js | 23 ++++++++ src/util/studentPreview.test.js | 31 +++++++++++ 4 files changed, 110 insertions(+) create mode 100644 .superpowers/sdd/2026-08-21-student-visibility-preview-hotfix/task-1-report.md create mode 100644 src/util/studentPreview.js create mode 100644 src/util/studentPreview.test.js diff --git a/.superpowers/sdd/2026-08-21-student-visibility-preview-hotfix/task-1-report.md b/.superpowers/sdd/2026-08-21-student-visibility-preview-hotfix/task-1-report.md new file mode 100644 index 0000000..d7b9876 --- /dev/null +++ b/.superpowers/sdd/2026-08-21-student-visibility-preview-hotfix/task-1-report.md @@ -0,0 +1,55 @@ +# Task 1 report: student-preview policy helpers + +## What changed + +- Added the `test` script (`vitest run`) to `package.json`. +- Added `src/util/studentPreview.js` with `isStudentPreview`, `withStudentPreview`, and `canBypassVisibility`. +- Added four focused tests in `src/util/studentPreview.test.js` covering explicit preview recognition, authenticated bypass policy, parameter-preserving enablement, and preview removal. + +## Files changed + +- `package.json` +- `src/util/studentPreview.js` +- `src/util/studentPreview.test.js` + +## RED evidence + +Command (after adding the prescribed test and test script, before implementing the helper): + +```text +npm test -- src/util/studentPreview.test.js +``` + +The repository's existing `vitest.workspace.js` attempted to load a Storybook configuration absent from this worktree and failed first with `SB_CORE-SERVER_0006`. To capture the planned missing-module RED result, the workspace file was temporarily moved aside (then restored), and the same command produced: + +```text +Error: Failed to load url ./studentPreview (resolved id: ./studentPreview) in .../src/util/studentPreview.test.js. Does the file exist? +Test Files 1 failed (1) +Tests no tests +exit_code=1 +``` + +## GREEN evidence + +Command: + +```text +npm test -- src/util/studentPreview.test.js +``` + +With the pre-existing Storybook workspace file temporarily moved aside (and restored immediately after the run): + +```text +✓ src/util/studentPreview.test.js (4 tests) 1ms +Test Files 1 passed (1) +Tests 4 passed (4) +exit_code=0 +``` + +## Self-review + +The implementation matches the brief exactly: URLSearchParams handles query parsing/encoding, enabling sets the explicit `preview=student` value while retaining other parameters, disabling deletes only the preview key, and visibility bypass requires authentication without student preview. No dependencies or unrelated files were changed. + +## Concerns + +The worktree's existing Vitest workspace references `.storybook`, which is absent here; an unmodified `npm test -- src/util/studentPreview.test.js` therefore fails with Storybook `SB_CORE-SERVER_0006` before running tests. The helper test result above was obtained using the same command with only that workspace file temporarily moved aside. diff --git a/package.json b/package.json index 9605a25..eaa0614 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "version": "1.1.1", "type": "module", "scripts": { + "test": "vitest run", "dev": "vite", "build": "vite build", "build:staging": "vite build --mode staging", diff --git a/src/util/studentPreview.js b/src/util/studentPreview.js new file mode 100644 index 0000000..6a7fb4c --- /dev/null +++ b/src/util/studentPreview.js @@ -0,0 +1,23 @@ +const PREVIEW_PARAM = 'preview'; +const STUDENT_PREVIEW_VALUE = 'student'; + +export const isStudentPreview = (search = '') => { + return new URLSearchParams(search).get(PREVIEW_PARAM) === STUDENT_PREVIEW_VALUE; +}; + +export const withStudentPreview = (search = '', enabled) => { + const params = new URLSearchParams(search); + + if (enabled) { + params.set(PREVIEW_PARAM, STUDENT_PREVIEW_VALUE); + } else { + params.delete(PREVIEW_PARAM); + } + + const query = params.toString(); + return query ? `?${query}` : ''; +}; + +export const canBypassVisibility = (isAuthenticated, studentPreview) => { + return Boolean(isAuthenticated && !studentPreview); +}; diff --git a/src/util/studentPreview.test.js b/src/util/studentPreview.test.js new file mode 100644 index 0000000..021182e --- /dev/null +++ b/src/util/studentPreview.test.js @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { + canBypassVisibility, + isStudentPreview, + withStudentPreview, +} from './studentPreview'; + +describe('student preview policy', () => { + it('recognizes only the explicit student preview value', () => { + expect(isStudentPreview('?preview=student')).toBe(true); + expect(isStudentPreview('?preview=staff')).toBe(false); + expect(isStudentPreview('')).toBe(false); + }); + + it('allows only non-preview authenticated sessions to bypass visibility', () => { + expect(canBypassVisibility(true, false)).toBe(true); + expect(canBypassVisibility(true, true)).toBe(false); + expect(canBypassVisibility(false, false)).toBe(false); + expect(canBypassVisibility(false, true)).toBe(false); + }); + + it('adds preview without losing existing parameters', () => { + expect(withStudentPreview('?college=smith§ion=01', true)) + .toBe('?college=smith§ion=01&preview=student'); + }); + + it('removes only preview when exiting', () => { + expect(withStudentPreview('?college=smith&preview=student§ion=01', false)) + .toBe('?college=smith§ion=01'); + }); +}); From 2df62b926629f40e3aa4226cbb993015a48a0e2d Mon Sep 17 00:00:00 2001 From: Rob OConnell Date: Sat, 22 Aug 2026 10:31:22 -0400 Subject: [PATCH 3/9] test: isolate student preview unit test command --- .../task-1-report.md | 55 ------------------- package.json | 2 +- 2 files changed, 1 insertion(+), 56 deletions(-) delete mode 100644 .superpowers/sdd/2026-08-21-student-visibility-preview-hotfix/task-1-report.md diff --git a/.superpowers/sdd/2026-08-21-student-visibility-preview-hotfix/task-1-report.md b/.superpowers/sdd/2026-08-21-student-visibility-preview-hotfix/task-1-report.md deleted file mode 100644 index d7b9876..0000000 --- a/.superpowers/sdd/2026-08-21-student-visibility-preview-hotfix/task-1-report.md +++ /dev/null @@ -1,55 +0,0 @@ -# Task 1 report: student-preview policy helpers - -## What changed - -- Added the `test` script (`vitest run`) to `package.json`. -- Added `src/util/studentPreview.js` with `isStudentPreview`, `withStudentPreview`, and `canBypassVisibility`. -- Added four focused tests in `src/util/studentPreview.test.js` covering explicit preview recognition, authenticated bypass policy, parameter-preserving enablement, and preview removal. - -## Files changed - -- `package.json` -- `src/util/studentPreview.js` -- `src/util/studentPreview.test.js` - -## RED evidence - -Command (after adding the prescribed test and test script, before implementing the helper): - -```text -npm test -- src/util/studentPreview.test.js -``` - -The repository's existing `vitest.workspace.js` attempted to load a Storybook configuration absent from this worktree and failed first with `SB_CORE-SERVER_0006`. To capture the planned missing-module RED result, the workspace file was temporarily moved aside (then restored), and the same command produced: - -```text -Error: Failed to load url ./studentPreview (resolved id: ./studentPreview) in .../src/util/studentPreview.test.js. Does the file exist? -Test Files 1 failed (1) -Tests no tests -exit_code=1 -``` - -## GREEN evidence - -Command: - -```text -npm test -- src/util/studentPreview.test.js -``` - -With the pre-existing Storybook workspace file temporarily moved aside (and restored immediately after the run): - -```text -✓ src/util/studentPreview.test.js (4 tests) 1ms -Test Files 1 passed (1) -Tests 4 passed (4) -exit_code=0 -``` - -## Self-review - -The implementation matches the brief exactly: URLSearchParams handles query parsing/encoding, enabling sets the explicit `preview=student` value while retaining other parameters, disabling deletes only the preview key, and visibility bypass requires authentication without student preview. No dependencies or unrelated files were changed. - -## Concerns - -The worktree's existing Vitest workspace references `.storybook`, which is absent here; an unmodified `npm test -- src/util/studentPreview.test.js` therefore fails with Storybook `SB_CORE-SERVER_0006` before running tests. The helper test result above was obtained using the same command with only that workspace file temporarily moved aside. diff --git a/package.json b/package.json index eaa0614..7100105 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "version": "1.1.1", "type": "module", "scripts": { - "test": "vitest run", + "test": "vitest run --config vite.config.js", "dev": "vite", "build": "vite build", "build:staging": "vite build --mode staging", From 9d458973cffcfc941f7332f11e462d59e47a2c44 Mon Sep 17 00:00:00 2001 From: Rob OConnell Date: Sat, 22 Aug 2026 10:33:22 -0400 Subject: [PATCH 4/9] test: isolate student preview unit workspace --- package.json | 2 +- vitest.unit.workspace.js | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 vitest.unit.workspace.js diff --git a/package.json b/package.json index 7100105..74868d1 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "version": "1.1.1", "type": "module", "scripts": { - "test": "vitest run --config vite.config.js", + "test": "vitest run --workspace vitest.unit.workspace.js", "dev": "vite", "build": "vite build", "build:staging": "vite build --mode staging", diff --git a/vitest.unit.workspace.js b/vitest.unit.workspace.js new file mode 100644 index 0000000..8097e94 --- /dev/null +++ b/vitest.unit.workspace.js @@ -0,0 +1 @@ +export default ['vite.config.js']; From ca2396aeca165f5dcc0b03b80e7591f58c2ed474 Mon Sep 17 00:00:00 2001 From: Rob OConnell Date: Sat, 22 Aug 2026 10:36:52 -0400 Subject: [PATCH 5/9] feat: add student preview controls --- src/pages/CourseRecords.jsx | 59 ++++++++++++++++++++++++++++++------- 1 file changed, 49 insertions(+), 10 deletions(-) diff --git a/src/pages/CourseRecords.jsx b/src/pages/CourseRecords.jsx index 1f4eaea..d2965e7 100644 --- a/src/pages/CourseRecords.jsx +++ b/src/pages/CourseRecords.jsx @@ -23,6 +23,11 @@ import { import RecordCard from '../components/page-sections/course-record/RecordCard'; import RecordTable from '../components/page-sections/course-record/RecordTable'; import CoursePermalink from '../components/common/CoursePermalink'; +import { + isStudentPreview, + withStudentPreview, + canBypassVisibility, +} from '../util/studentPreview'; function CourseRecords() { const location = useLocation(); @@ -47,6 +52,19 @@ function CourseRecords() { const [searchQuery, setSearchQuery] = useState(''); const { isAuthenticated } = useAuth(); + const studentPreview = isStudentPreview(location.search); + const canBypassResourceVisibility = canBypassVisibility( + isAuthenticated, + studentPreview + ); + + const setStudentPreview = useCallback((enabled) => { + navigate({ + pathname: location.pathname, + search: withStudentPreview(location.search, enabled), + hash: location.hash, + }, { replace: true }); + }, [location.hash, location.pathname, location.search, navigate]); // Display mode: "card" (detailed) vs. "table" (compact) const [displayMode, setDisplayMode] = useState('card'); @@ -517,7 +535,7 @@ function CourseRecords() { // Helper function to check if a record should be visible const isRecordVisible = useCallback((item) => { if (item.isElectronic && item.resource) { - if (isAuthenticated) return true; + if (canBypassResourceVisibility) return true; const now = new Date(); @@ -540,7 +558,7 @@ function CourseRecords() { } } return true; - }, [isAuthenticated]); + }, [canBypassResourceVisibility]); // NEW: Combine grouped and ungrouped items with improved ordering logic // Handle different sorting scenarios based on order values @@ -762,7 +780,7 @@ function CourseRecords() { // Check record visibility const checkRecordVisibility = (item) => { if (item.isElectronic && item.resource) { - if (isAuthenticated) return { isVisible: true }; + if (canBypassResourceVisibility) return { isVisible: true }; const now = new Date(); @@ -840,7 +858,7 @@ function CourseRecords() { totalCount: hiddenCount + visibleCount, nextAvailableDate }; - }, [records, isAuthenticated]); + }, [records, canBypassResourceVisibility]); const { hiddenCount, visibleCount, nextAvailableDate } = processedRecords; @@ -1085,6 +1103,26 @@ function CourseRecords() { )} + {isAuthenticated && !studentPreview && ( +
+ +
+ )} + + {studentPreview && ( + + + Student preview is active.{' '} + Resources and links outside their visibility dates are hidden as they are for students. + + + + )} +
)} - {/* Show message when primary link is not available */} - {resource.item_url && !isPrimaryLinkVisibleCheck && ( -
- Access Resource: The link to this resource is not currently available due to visibility restrictions. -
- )} - {showVisibilityDates && (
Visibility Window:{' '} {visibilityInfo.usePrimaryLinkVisibility ? ( <> - {visibilityInfo.startDate ? `From ${new Date(visibilityInfo.startDate).toLocaleDateString()}` : 'No start date'}{' '} - {visibilityInfo.endDate ? `until ${new Date(visibilityInfo.endDate).toLocaleDateString()}` : 'No end date'} + {visibilityInfo.startDate ? `From ${formatVisibilityDate(visibilityInfo.startDate)}` : 'No start date'}{' '} + {visibilityInfo.endDate ? `until ${formatVisibilityDate(visibilityInfo.endDate, 'end')}` : 'No end date'} ) : ( <> - {visibilityInfo.startDate ? `From ${new Date(visibilityInfo.startDate).toLocaleDateString()}` : 'No start date'}{' '} - {visibilityInfo.endDate ? `until ${new Date(visibilityInfo.endDate).toLocaleDateString()}` : 'No end date'} + {visibilityInfo.startDate ? `From ${formatVisibilityDate(visibilityInfo.startDate)}` : 'No start date'}{' '} + {visibilityInfo.endDate ? `until ${formatVisibilityDate(visibilityInfo.endDate, 'end')}` : 'No end date'} )}
@@ -596,22 +596,14 @@ const RecordCard = ({

Additional Resources

- {resource.links.map((link, index) => { - // Check if this individual link is visible - const isCurrentLinkVisible = isLinkVisible(link, canBypassVisibility); - - // Always render the link item, but conditionally show the actual link - return ( -
+ {visibleAdditionalLinks.map((link, index) => ( +

{link.title || 'Additional Resource'}

{link.use_proxy === "1" && ( Proxy Enabled )} - {!isCurrentLinkVisible && ( - Not Currently Available - )}
@@ -623,37 +615,26 @@ const RecordCard = ({ {canBypassVisibility && link.use_link_visibility && (link.start_visibility || link.end_visibility) && (
Link Visibility:{' '} - {link.start_visibility ? `From ${new Date(link.start_visibility).toLocaleDateString()}` : 'No start date'}{' '} - {link.end_visibility ? `until ${new Date(link.end_visibility).toLocaleDateString()}` : 'No end date'} + {link.start_visibility ? `From ${formatVisibilityDate(link.start_visibility)}` : 'No start date'}{' '} + {link.end_visibility ? `until ${formatVisibilityDate(link.end_visibility, 'end')}` : 'No end date'}
)} - {/* Only show the actual link if it's visible */} - {isCurrentLinkVisible && ( - handleAdditionalLinkClick(e, link)} - className="text-primary d-block mb-1" - target="_blank" - rel="noreferrer noopener" - aria-label={`Access ${link.title || 'additional resource'} (opens in new tab)`} - > - - {link.url} - - - )} - - {/* Show a message when link is not available */} - {!isCurrentLinkVisible && ( -

- Link not currently available due to visibility restrictions. -

- )} -
- ); - })} + handleAdditionalLinkClick(e, link)} + className="text-primary d-block mb-1" + target="_blank" + rel="noreferrer noopener" + aria-label={`Access ${link.title || 'additional resource'} (opens in new tab)`} + > + + {link.url} + + +
+ ))}
)} @@ -779,10 +760,10 @@ const RecordCard = ({
Visibility Window
{visibilityInfo.startDate && ( -
From: {new Date(visibilityInfo.startDate).toLocaleDateString()}
+
From: {formatVisibilityDate(visibilityInfo.startDate)}
)} {visibilityInfo.endDate && ( -
Until: {new Date(visibilityInfo.endDate).toLocaleDateString()}
+
Until: {formatVisibilityDate(visibilityInfo.endDate, 'end')}
)}
diff --git a/src/components/page-sections/course-record/RecordCard.test.jsx b/src/components/page-sections/course-record/RecordCard.test.jsx new file mode 100644 index 0000000..0540717 --- /dev/null +++ b/src/components/page-sections/course-record/RecordCard.test.jsx @@ -0,0 +1,83 @@ +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import RecordCard from './RecordCard'; + +const futureDate = '2099-01-01T00:00:00.000Z'; + +const renderCard = (resource, canBypassVisibility = false, options = {}) => ( + renderToStaticMarkup(createElement(RecordCard, { + recordItem: { + id: 'card-resource', + isElectronic: true, + copiedItem: { instanceId: 'card-instance', title: 'Card resource' }, + resource, + }, + availability: {}, + openAccordions: {}, + toggleAccordion: () => {}, + customization: {}, + courseInfo: {}, + collegeParam: 'test-college', + canBypassVisibility, + ...options, + })) +); + +describe('RecordCard student visibility', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('removes an out-of-window additional link entry for students but retains it for staff', () => { + const resource = { + links: [{ + link_id: 'future-card-link', + title: 'Hidden card link title', + description: 'Hidden card link description', + url: 'https://example.test/hidden-card-link', + use_proxy: '1', + use_link_visibility: '1', + start_visibility: futureDate, + }], + }; + + const studentMarkup = renderCard(resource); + const staffMarkup = renderCard(resource, true); + + expect(studentMarkup).not.toContain('Hidden card link title'); + expect(studentMarkup).not.toContain('Hidden card link description'); + expect(studentMarkup).not.toContain('https://example.test/hidden-card-link'); + expect(studentMarkup).not.toContain('Proxy Enabled'); + expect(studentMarkup).not.toContain('Not Currently Available'); + expect(studentMarkup).not.toContain('Link not currently available'); + + expect(staffMarkup).toContain('Hidden card link title'); + expect(staffMarkup).toContain('Hidden card link description'); + expect(staffMarkup).toContain('https://example.test/hidden-card-link'); + expect(staffMarkup).toContain('Proxy Enabled'); + expect(staffMarkup).toContain('Link Visibility'); + }); + + it('does not annotate a hidden primary link in student output', () => { + const markup = renderCard({ + item_url: 'https://example.test/future-primary', + use_primary_link_visibility: '1', + primary_link_start_visibility: futureDate, + }); + + expect(markup).not.toContain('https://example.test/future-primary'); + expect(markup).not.toContain('The link to this resource is not currently available'); + }); + + it('keeps a resource with a date-only end visible for the entire local end date', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 7, 22, 12, 0, 0)); + + const markup = renderCard({ end_visibility: '2026-08-22' }, false, { + showVisibilityMessages: false, + }); + + expect(markup).toContain('Card resource'); + }); +}); diff --git a/src/components/page-sections/course-record/RecordTable.jsx b/src/components/page-sections/course-record/RecordTable.jsx index b775a6c..dc842c8 100644 --- a/src/components/page-sections/course-record/RecordTable.jsx +++ b/src/components/page-sections/course-record/RecordTable.jsx @@ -13,8 +13,17 @@ import { // Import tracking service import { trackingService } from '../../../services/trackingService'; import { sanitizeHtml, containsHtml } from '../../../util/htmlUtils'; -import { isPrimaryLinkVisible, isLinkVisible, getVisibilityInfo } from '../../../util/resourceVisibility'; -import { useRecordsTextStore, selectRecordTableText, selectVisibilityText, selectAccessibilityText, selectCourseRecordsText, selectSplitViewText } from '../../../stores/recordsTextStore'; +import { + getVisibilityInfo, + isLinkVisible, + isPrimaryLinkVisible, + parseVisibilityDate, +} from '../../../util/resourceVisibility'; +import { useRecordsTextStore, selectRecordTableText, selectAccessibilityText, selectCourseRecordsText, selectSplitViewText } from '../../../stores/recordsTextStore'; + +const formatVisibilityDate = (value, boundary = 'start') => ( + parseVisibilityDate(value, boundary)?.toLocaleDateString() +); const getRecordVisibility = (item, canBypassVisibility) => { if (item.isElectronic && item.resource) { @@ -24,12 +33,8 @@ const getRecordVisibility = (item, canBypassVisibility) => { } const now = new Date(); - const startVisibility = item.resource.start_visibility - ? new Date(item.resource.start_visibility) - : null; - const endVisibility = item.resource.end_visibility - ? new Date(item.resource.end_visibility) - : null; + const startVisibility = parseVisibilityDate(item.resource.start_visibility); + const endVisibility = parseVisibilityDate(item.resource.end_visibility, 'end'); // If current time is before the start of the visibility window if (startVisibility && now < startVisibility) { @@ -52,7 +57,7 @@ const getRecordVisibility = (item, canBypassVisibility) => { return { isVisible: true }; }; -const getVisibleSplitLinks = (links, canBypassVisibility) => ( +const getVisibleLinks = (links, canBypassVisibility) => ( Array.isArray(links) ? links.filter(link => isLinkVisible(link, canBypassVisibility)) : [] @@ -96,7 +101,6 @@ const RecordTable = ({ // Get text from the store const recordTableText = useRecordsTextStore(selectRecordTableText); - const visibilityText = useRecordsTextStore(selectVisibilityText); const accessibilityText = useRecordsTextStore(selectAccessibilityText); const courseRecordsText = useRecordsTextStore(selectCourseRecordsText); const splitViewText = useRecordsTextStore(selectSplitViewText); @@ -153,13 +157,13 @@ const RecordTable = ({ if (item.resource?.start_visibility) { scheduleInfo.push({ title: item.copiedItem?.title, - date: new Date(item.resource.start_visibility), + date: parseVisibilityDate(item.resource.start_visibility), type: 'upcoming' }); } else if (item.resource?.end_visibility) { scheduleInfo.push({ title: item.copiedItem?.title, - date: new Date(item.resource.end_visibility), + date: parseVisibilityDate(item.resource.end_visibility, 'end'), type: 'past' }); } @@ -188,13 +192,13 @@ const RecordTable = ({ if (result.resource?.start_visibility) { scheduleInfo.push({ title: result.copiedItem?.title, - date: new Date(result.resource.start_visibility), + date: parseVisibilityDate(result.resource.start_visibility), type: 'upcoming' }); } else if (result.resource?.end_visibility) { scheduleInfo.push({ title: result.copiedItem?.title, - date: new Date(result.resource.end_visibility), + date: parseVisibilityDate(result.resource.end_visibility, 'end'), type: 'past' }); } @@ -368,10 +372,10 @@ const RecordTable = ({ ? item.resource.item_url : (!item.isElectronic ? (item.copiedItem?.uri || item.copiedItem?.url) : null); - const hasAdditionalLinks = item.isElectronic && - item.resource && - Array.isArray(item.resource.links) && - item.resource.links.length > 0; + const visibleAdditionalLinks = item.isElectronic + ? getVisibleLinks(item.resource?.links, canBypassVisibility) + : []; + const hasAdditionalLinks = visibleAdditionalLinks.length > 0; const isExpanded = expandedLinkItems[item.id] || false; @@ -418,10 +422,10 @@ const RecordTable = ({
Visibility Window
{visibilityInfo.startDate && ( -
From: {new Date(visibilityInfo.startDate).toLocaleDateString()}
+
From: {formatVisibilityDate(visibilityInfo.startDate)}
)} {visibilityInfo.endDate && ( -
Until: {new Date(visibilityInfo.endDate).toLocaleDateString()}
+
Until: {formatVisibilityDate(visibilityInfo.endDate, 'end')}
)}
@@ -522,34 +526,7 @@ const RecordTable = ({ style={{ color: customization.buttonPrimaryColor }} > - {item.resource.links.length} additional {item.resource.links.length === 1 ? 'link' : 'links'} - - - -
- )} - - ) : item.isElectronic && item.resource && item.resource.item_url && !isPrimaryLinkVisibleCheck ? ( -
- - - {visibilityText.notCurrentlyAvailable} - - {hasAdditionalLinks && ( -
-
@@ -753,7 +717,7 @@ const RecordTable = ({ .map(item => { const isPrimaryLinkVisibleCheck = isPrimaryLinkVisible(item.resource, canBypassVisibility); const resourceUrl = item.resource?.item_url && isPrimaryLinkVisibleCheck ? item.resource.item_url : null; - const visibleAdditionalLinks = getVisibleSplitLinks(item.resource?.links, canBypassVisibility); + const visibleAdditionalLinks = getVisibleLinks(item.resource?.links, canBypassVisibility); const hasAdditionalLinks = visibleAdditionalLinks.length > 0; const isExpanded = expandedLinkItems[item.id] || false; @@ -787,29 +751,6 @@ const RecordTable = ({ {recordTableText.access} - {hasAdditionalLinks && ( - - )} -
- ) : item.resource?.item_url && !isPrimaryLinkVisibleCheck ? ( -
- - - {visibilityText.notCurrentlyAvailable} - {hasAdditionalLinks && (
))} diff --git a/src/components/page-sections/course-record/RecordTable.test.jsx b/src/components/page-sections/course-record/RecordTable.test.jsx index cc475f8..74c272d 100644 --- a/src/components/page-sections/course-record/RecordTable.test.jsx +++ b/src/components/page-sections/course-record/RecordTable.test.jsx @@ -1,6 +1,6 @@ import { createElement } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import RecordTable from './RecordTable'; const futureDate = '2099-01-01T00:00:00.000Z'; @@ -40,6 +40,10 @@ const renderCombinedView = (records, canBypassVisibility = false) => renderToSta ); describe('RecordTable split-view visibility', () => { + afterEach(() => { + vi.useRealTimers(); + }); + it('honors visibility authority for resources outside their visibility window', () => { const records = [{ id: 'future-resource', @@ -62,7 +66,9 @@ describe('RecordTable split-view visibility', () => { { link_id: 'future-link', title: 'Future-only link', + description: 'Future-only split description', url: 'https://example.test/future-link', + use_proxy: '1', use_link_visibility: '1', start_visibility: futureDate, }, @@ -79,8 +85,15 @@ describe('RecordTable split-view visibility', () => { const bypassMarkup = renderSplitView(records, true); expect(studentMarkup).not.toContain('https://example.test/future-link'); + expect(studentMarkup).not.toContain('Future-only link'); + expect(studentMarkup).not.toContain('Future-only split description'); + expect(studentMarkup).not.toContain('Proxy Enabled'); + expect(studentMarkup).not.toContain('Not Currently Available'); + expect(studentMarkup).not.toContain('Link not currently available'); expect(studentMarkup).toContain('https://example.test/visible-link'); expect(bypassMarkup).toContain('https://example.test/future-link'); + expect(bypassMarkup).toContain('Future-only split description'); + expect(bypassMarkup).toContain('Link Visibility'); }); it('removes out-of-window resources from combined mode while retaining visible records', () => { @@ -99,4 +112,102 @@ describe('RecordTable split-view visibility', () => { expect(markup).toContain('Visible print resource'); expect(markup).not.toContain('Combined future-only resource'); }); + + it('removes hidden additional-link metadata and counts only visible links in combined student view', () => { + const records = [{ + id: 'combined-link-resource', + isElectronic: true, + copiedItem: { title: 'Combined link resource' }, + resource: { + links: [ + { + link_id: 'combined-hidden-link', + title: 'Hidden combined title', + description: 'Hidden combined description', + url: 'https://example.test/hidden-combined', + use_proxy: '1', + use_link_visibility: '1', + start_visibility: futureDate, + }, + { + link_id: 'combined-visible-link', + title: 'Visible combined title', + url: 'https://example.test/visible-combined', + }, + ], + }, + }]; + + const studentMarkup = renderCombinedView(records); + const staffMarkup = renderCombinedView(records, true); + + expect(studentMarkup).toContain('1 link available'); + expect(studentMarkup).not.toContain('2 links available'); + expect(studentMarkup).not.toContain('Hidden combined title'); + expect(studentMarkup).not.toContain('Hidden combined description'); + expect(studentMarkup).not.toContain('https://example.test/hidden-combined'); + expect(studentMarkup).not.toContain('Proxy Enabled'); + expect(studentMarkup).not.toContain('Not Currently Available'); + expect(studentMarkup).not.toContain('Link not currently available'); + + expect(staffMarkup).toContain('2 links available'); + expect(staffMarkup).toContain('Hidden combined title'); + expect(staffMarkup).toContain('Hidden combined description'); + expect(staffMarkup).toContain('https://example.test/hidden-combined'); + expect(staffMarkup).toContain('Proxy Enabled'); + expect(staffMarkup).toContain('Link Visibility'); + }); + + it('hides primary-link unavailable annotations from students in combined and split views', () => { + const records = [{ + id: 'future-primary-resource', + isElectronic: true, + copiedItem: { title: 'Future primary resource' }, + resource: { + item_url: 'https://example.test/future-primary', + use_primary_link_visibility: '1', + primary_link_start_visibility: futureDate, + }, + }]; + + expect(renderCombinedView(records)).not.toContain('Resource not currently available'); + expect(renderSplitView(records)).not.toContain('Resource not currently available'); + }); + + it('retains staff link visibility annotations in split view', () => { + const records = [{ + id: 'split-staff-link-resource', + isElectronic: true, + copiedItem: { title: 'Split staff link resource' }, + resource: { + links: [{ + link_id: 'split-staff-link', + title: 'Split staff-only link', + url: 'https://example.test/split-staff-link', + use_link_visibility: '1', + start_visibility: futureDate, + }], + }, + }]; + + const staffMarkup = renderSplitView(records, true); + + expect(staffMarkup).toContain('Split staff-only link'); + expect(staffMarkup).toContain('Link Visibility'); + }); + + it('keeps date-only resource ends visible through the local end date in combined and split views', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 7, 22, 12, 0, 0)); + + const records = [{ + id: 'end-date-resource', + isElectronic: true, + copiedItem: { title: 'Visible through today' }, + resource: { end_visibility: '2026-08-22' }, + }]; + + expect(renderCombinedView(records)).toContain('Visible through today'); + expect(renderSplitView(records)).toContain('Visible through today'); + }); }); diff --git a/src/pages/CourseRecords.jsx b/src/pages/CourseRecords.jsx index d2965e7..4499634 100644 --- a/src/pages/CourseRecords.jsx +++ b/src/pages/CourseRecords.jsx @@ -28,6 +28,7 @@ import { withStudentPreview, canBypassVisibility, } from '../util/studentPreview'; +import { parseVisibilityDate } from '../util/resourceVisibility'; function CourseRecords() { const location = useLocation(); @@ -541,12 +542,8 @@ function CourseRecords() { // Always check resource-level visibility dates if they're set if (item.resource.start_visibility !== null || item.resource.end_visibility !== null) { - const startVisibility = item.resource.start_visibility - ? new Date(item.resource.start_visibility + 'T00:00:00') - : null; - const endVisibility = item.resource.end_visibility - ? new Date(item.resource.end_visibility + 'T23:59:59') - : null; + const startVisibility = parseVisibilityDate(item.resource.start_visibility); + const endVisibility = parseVisibilityDate(item.resource.end_visibility, 'end'); if ((startVisibility && now < startVisibility)) { return false; @@ -791,12 +788,8 @@ function CourseRecords() { if (usePrimaryLinkVisibility) { // Use primary link visibility dates - const startVisibility = item.resource.primary_link_start_visibility - ? new Date(item.resource.primary_link_start_visibility + 'T00:00:00') - : null; - const endVisibility = item.resource.primary_link_end_visibility - ? new Date(item.resource.primary_link_end_visibility + 'T23:59:59') - : null; + const startVisibility = parseVisibilityDate(item.resource.primary_link_start_visibility); + const endVisibility = parseVisibilityDate(item.resource.primary_link_end_visibility, 'end'); // If current time is before the start of the primary link visibility window if (startVisibility && now < startVisibility) { @@ -816,12 +809,8 @@ function CourseRecords() { if (useResourceVisibility) { // Use resource-level visibility dates - const startVisibility = item.resource.start_visibility - ? new Date(item.resource.start_visibility + 'T00:00:00') - : null; - const endVisibility = item.resource.end_visibility - ? new Date(item.resource.end_visibility + 'T23:59:59') - : null; + const startVisibility = parseVisibilityDate(item.resource.start_visibility); + const endVisibility = parseVisibilityDate(item.resource.end_visibility, 'end'); if ((startVisibility && now < startVisibility)) { upcomingDates.push(startVisibility); diff --git a/src/util/resourceVisibility.js b/src/util/resourceVisibility.js index 0f2e334..cb87c49 100644 --- a/src/util/resourceVisibility.js +++ b/src/util/resourceVisibility.js @@ -2,16 +2,39 @@ * Utility functions for handling resource visibility logic */ +const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +/** + * Parse a visibility boundary without converting date-only values to UTC. + * Timestamp values keep their exact time and timezone semantics. + * + * @param {string|Date|null} value - Visibility boundary value + * @param {'start'|'end'} boundary - Boundary side to parse + * @returns {Date|null} Parsed boundary, or null when absent/invalid + */ +export const parseVisibilityDate = (value, boundary = 'start') => { + if (!value) return null; + + const date = value instanceof Date + ? new Date(value.getTime()) + : new Date( + DATE_ONLY_PATTERN.test(value) + ? `${value}T${boundary === 'end' ? '23:59:59.999' : '00:00:00.000'}` + : value + ); + + return Number.isNaN(date.getTime()) ? null : date; +}; + /** * Check if a primary resource link is visible based on visibility settings * * @param {Object} resource - The resource object containing visibility settings - * @param {boolean} isAuthenticated - Whether the user is authenticated + * @param {boolean} canBypassVisibility - Whether visibility restrictions can be bypassed * @returns {boolean} Whether the primary link should be visible */ -export const isPrimaryLinkVisible = (resource, isAuthenticated) => { - // Authenticated users see all links - if (isAuthenticated) return true; +export const isPrimaryLinkVisible = (resource, canBypassVisibility) => { + if (canBypassVisibility) return true; // If no resource, no link is visible if (!resource) return false; @@ -25,12 +48,8 @@ export const isPrimaryLinkVisible = (resource, isAuthenticated) => { if (usePrimaryLinkVisibility) { // Use primary link visibility dates - const startVisibility = resource.primary_link_start_visibility - ? new Date(resource.primary_link_start_visibility) - : null; - const endVisibility = resource.primary_link_end_visibility - ? new Date(resource.primary_link_end_visibility) - : null; + const startVisibility = parseVisibilityDate(resource.primary_link_start_visibility); + const endVisibility = parseVisibilityDate(resource.primary_link_end_visibility, 'end'); // If current time is before the start of the primary link visibility window if (startVisibility && now < startVisibility) { @@ -49,12 +68,8 @@ export const isPrimaryLinkVisible = (resource, isAuthenticated) => { // Only apply resource-level visibility dates if resource visibility is enabled if (useResourceVisibility) { - const startVisibility = resource.start_visibility - ? new Date(resource.start_visibility) - : null; - const endVisibility = resource.end_visibility - ? new Date(resource.end_visibility) - : null; + const startVisibility = parseVisibilityDate(resource.start_visibility); + const endVisibility = parseVisibilityDate(resource.end_visibility, 'end'); // If current time is before the start of the visibility window if (startVisibility && now < startVisibility) { @@ -75,12 +90,11 @@ export const isPrimaryLinkVisible = (resource, isAuthenticated) => { * Check if an individual resource link is visible based on its visibility settings * * @param {Object} link - The link object containing visibility settings - * @param {boolean} isAuthenticated - Whether the user is authenticated + * @param {boolean} canBypassVisibility - Whether visibility restrictions can be bypassed * @returns {boolean} Whether the link should be visible */ -export const isLinkVisible = (link, isAuthenticated) => { - // Authenticated users see all links - if (isAuthenticated) return true; +export const isLinkVisible = (link, canBypassVisibility) => { + if (canBypassVisibility) return true; // If no link, it's not visible if (!link) return false; @@ -93,8 +107,8 @@ export const isLinkVisible = (link, isAuthenticated) => { if (!useLinkVisibility) return true; const now = new Date(); - const linkStartDate = link.start_visibility ? new Date(link.start_visibility) : null; - const linkEndDate = link.end_visibility ? new Date(link.end_visibility) : null; + const linkStartDate = parseVisibilityDate(link.start_visibility); + const linkEndDate = parseVisibilityDate(link.end_visibility, 'end'); if (linkStartDate && now < linkStartDate) return false; if (linkEndDate && now > linkEndDate) return false; @@ -106,11 +120,11 @@ export const isLinkVisible = (link, isAuthenticated) => { * Get visibility information for displaying to authenticated users * * @param {Object} resource - The resource object containing visibility settings - * @param {boolean} isAuthenticated - Whether the user is authenticated + * @param {boolean} canBypassVisibility - Whether visibility restrictions can be bypassed * @returns {Object} Object containing visibility date information */ -export const getVisibilityInfo = (resource, isAuthenticated) => { - if (!isAuthenticated || !resource) { +export const getVisibilityInfo = (resource, canBypassVisibility) => { + if (!canBypassVisibility || !resource) { return { showVisibilityDates: false }; } diff --git a/src/util/resourceVisibility.test.js b/src/util/resourceVisibility.test.js new file mode 100644 index 0000000..345b22f --- /dev/null +++ b/src/util/resourceVisibility.test.js @@ -0,0 +1,32 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { isLinkVisible, isPrimaryLinkVisible } from './resourceVisibility'; + +describe('visibility date boundaries', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('keeps date-only end dates visible through the local calendar day', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 7, 22, 12, 0, 0)); + + expect(isLinkVisible({ + use_link_visibility: '1', + end_visibility: '2026-08-22', + }, false)).toBe(true); + expect(isPrimaryLinkVisible({ + use_primary_link_visibility: '1', + primary_link_end_visibility: '2026-08-22', + }, false)).toBe(true); + }); + + it('preserves exact end-time semantics for timestamp values', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-22T16:00:00.000Z')); + + expect(isLinkVisible({ + use_link_visibility: '1', + end_visibility: '2026-08-22T15:59:59.000Z', + }, false)).toBe(false); + }); +});