diff --git a/docs/CAL-A01-accessibility-verification.md b/docs/CAL-A01-accessibility-verification.md
new file mode 100644
index 0000000000..03ba736171
--- /dev/null
+++ b/docs/CAL-A01-accessibility-verification.md
@@ -0,0 +1,47 @@
+# CAL-A01: Calendar accessibility verification
+
+## Changes and automated evidence
+
+The download options dialog focuses its first control (Grade) when it opens. Material supplies the focus trap, Escape handling and focus restoration. The result count and empty-result message share a persistent `role="status"` region, so changing a filter can announce whether a download is available. Decorative icons are hidden from the accessibility tree.
+
+The Web calendar switch, loading spinner and reminder save/cancel actions have explicit accessible names. Included units use ordinary chips with named native removal buttons; the add-unit menu has a visible **Add unit** button. These actions retain their saving guards. The subscription link, copy and regenerate controls retain their separate names and behaviors.
+
+`calendar-modal.component.spec.ts` renders the actual Material chips, menu trigger and switch and checks names, removal activation and disabled states. `download-filter-dialog.component.spec.ts` checks the live result region and empty state. `task-planner-card.component.spec.ts` checks the initial-focus configuration along with the grade/status/date download guards.
+
+These are DOM/component checks. They do not establish screen-reader output, rendered contrast, browser Tab order or a completed manual accessibility audit.
+
+## Observed keyboard pass — 21 September 2026
+
+The real local application was exercised with keyboard input only, in a Chromium browser at web `283b49336`, using the [synthetic fixture](calendar/evidence-20260921/environment.md). This was a keyboard pass, not a screen-reader audit.
+
+- Enter on **Download .ics** opened the dialog with focus on **Grade**. Home and arrow keys selected the grades; Space changed the grade range and exclusion checkbox.
+- Tab moved from Grade to the selected grade-range radio, then the exclusion checkbox, then Cancel. With a valid result the Download button was available.
+- Pass / Up to this grade / exclusion on produced the empty message and disabled Download. Tab from Cancel skipped that disabled button and wrapped to Grade.
+- Escape, Cancel and a successful download returned focus to the dashboard's **Download .ics** opener.
+- In Web calendar, the first loading state focused Close. Tab reached **Enable web calendar** after loading; Space enabled it. Saving temporarily disabled the affected controls. Unit removal and the Add unit menu were keyboard operable.
+- At a 390 × 844 viewport, all download options and both action buttons were visible without horizontal clipping. The normal viewport was restored afterward.
+
+
+
+
+
+The first native-app attempt paused when the Mac locked. It resumed after the Mac was unlocked, but no successful screen-reader session or recording has been verified. Accessibility-tree text is not evidence that a screen reader announced it. **CAL-A01 remains open** for the screen-reader session and recording.
+
+### Resumed attempt and current environment blocker
+
+On the resumed attempt, VoiceOver was switched on in macOS settings but did not start; two direct launch attempts timed out. No actual VoiceOver speech or live caption output was observed. OBS 32.1.2 crashed during initial scene creation and was recovered in Safe Mode. An empty **OnTrack CAL-A01 Evidence** scene was added and Mic/Aux was muted, but no recording was started.
+
+The test machine then stopped responding to native input, which prevented further testing or cleanup. VoiceOver could not be confirmed as turned off, so its final setting is unverified. These are test-environment failures, not evidence that the calendar controls passed or failed screen-reader use. A working test machine is required to verify the setting, complete the actual screen-reader pass and attach the recording to the PR.
+
+## Remaining validation
+
+Use synthetic student data. Record browser, OS and screen-reader versions, date, viewport and the commit tested. Do not put real subscription tokens in recordings.
+
+1. Tab to **Download .ics**, press Enter and confirm Grade receives focus. Change each grade with the keyboard, change Grade range and the submission checkbox, and confirm the count is announced once after each change.
+2. Select a zero-result combination. Confirm the explanation is announced and Download is disabled. Restore a valid selection and download with Enter/Space.
+3. Open again and press Escape. Confirm focus returns to **Download .ics**. Repeat using Cancel.
+4. Open Web calendar and toggle it with Space. Confirm the enabled/disabled state is announced. Navigate unit removal, Add unit, reminders, subscription link, Copy, Regenerate, Download a copy and Close without a pointer.
+5. Trigger a save and verify disabled controls cannot activate. Check confirmation dialog focus and return focus after cancellation.
+6. Repeat at 200% zoom and a phone-width viewport. Check focus visibility, scrolling and no horizontal clipping. Repeat in the supported light/dark themes.
+
+The checklist above is the full repeatable procedure; the observations in the preceding section identify the portions already exercised. The 200% zoom, theme variants, full WebCal control traversal, confirmation focus and spoken announcements remain unverified. Add the recording and actual observations before marking the ticket complete.
diff --git a/docs/CAL-C01-calendar-compatibility.md b/docs/CAL-C01-calendar-compatibility.md
new file mode 100644
index 0000000000..d522f8673e
--- /dev/null
+++ b/docs/CAL-C01-calendar-compatibility.md
@@ -0,0 +1,96 @@
+# CAL-C01: Calendar compatibility evidence
+
+**CAL-C01 is complete.** The four-event fixture was imported and checked in Apple Calendar, Google Calendar and Outlook on the web on 21 September 2026. All three showed the intended single all-day dates. Client-specific Unicode/newline display behavior is recorded below.
+
+## Reproducible fixture
+
+[calendar-compatibility.ics](calendar/calendar-compatibility.ics) is generated by the production frontend `buildIcsCalendar` function using synthetic tasks and a fixed `DTSTAMP` of 2026-09-20 00:00 UTC. It contains no account, subscription token or real student information.
+
+| UID | Expected title/details | Expected all-day date | Exclusive end |
+| -------- | --------------------------------------------------------------------------------- | --------------------- | ----------------- |
+| E-900001 | CAL-TEST: 1P: Year boundary | 31 December 2026 | 1 January 2027 |
+| E-900002 | CAL-TEST: 2C: Melbourne daylight saving starts | 4 October 2026 | 5 October 2026 |
+| E-900003 | CAL-TEST: 3D: Leap day | 29 February 2028 | 1 March 2028 |
+| E-900004 | Unicode café, calendar emoji, escaped punctuation, newline and long Unicode title | 20 September 2026 | 21 September 2026 |
+
+The automated frontend suite covers civil-date preservation at leap-year, daylight-saving and year boundaries; CRLF line endings; 75-octet UTF-8 folding; escaping; stable task-definition UIDs; invalid-date exclusion; and every grade range. See [CAL-T01](CAL-T01-test-coverage.md) for the exact test command and results.
+
+To regenerate the fixture from a dependency-installed checkout:
+
+```sh
+node_modules/.bin/esbuild src/app/api/services/ics-calendar-builder.ts --bundle --platform=node --format=cjs --outfile=/tmp/ontrack-calendar-builder.cjs
+node docs/calendar/generate-fixture.cjs /tmp/ontrack-calendar-builder.cjs
+```
+
+The generator supplies only synthetic task objects; the real event/date/escaping logic comes from the compiled production builder. Its timestamps and dates are fixed, so regenerating the file should produce no diff. A local `.gitattributes` entry preserves the fixture's required CRLF line endings in Git.
+
+## Client results recorded on 21 September 2026
+
+The four-event compatibility fixture was imported into real calendar applications. This is separate from the [six CAL101 option-filter downloads](calendar/evidence-20260921/exports/README.md), which demonstrate grade and submitted-task filtering.
+
+| Client | Import result | Date and text verification | Evidence |
+| --- | --- | --- | --- |
+| Apple Calendar | Four fixture events imported | All four appeared on the intended all-day dates, without an extra day; Unicode title value checked through native accessibility text | Native Calendar screenshots below |
+| Google Calendar | Application reported 4 of 4 events imported; all four original events verified | Exactly four results on the intended single all-day dates; Unicode and punctuation retained, with the newline displayed as a space | Search, daylight-saving and Unicode screenshots below |
+| Outlook Calendar | Import confirmation observed; selected-calendar search returned exactly four events | All four had the intended single all-day date; Unicode and punctuation retained with client-specific newline display normalization | Four-event search screenshot and text observations below |
+
+The Apple test used **Calendar 27.0 (3073)** on **macOS 27.0**, with the system timezone set to **Australia/Melbourne**. The Google import and follow-up verification used **Chrome 153.0.8010.48** on **macOS 27.0**. Its day grid displayed **GMT+10**; the Google Calendar account's configured timezone was not confirmed, so this report does not claim a Melbourne timezone setting for Google. Outlook on the web was also tested in native Chrome on macOS 27.0; its account's configured timezone was not confirmed. These are observed all-day civil-date results, not a claim about either web client's configured timezone.
+
+### Apple Calendar observations
+
+| Fixture case | Observed all-day date | Screenshot |
+| --- | --- | --- |
+| Unicode, escaped punctuation and long title | 20 September 2026 | [Unicode event](calendar/evidence-20260921/apple-unicode.png) |
+| Melbourne daylight-saving start | 4 October 2026 | [Daylight-saving event](calendar/evidence-20260921/apple-dst.png) |
+| Year boundary | 31 December 2026 | [Year-boundary event](calendar/evidence-20260921/apple-year-boundary.png) |
+| Leap day | 29 February 2028 | [Leap-day event](calendar/evidence-20260921/apple-leap-day.png) |
+
+Each event occupied its intended day rather than continuing into an additional day. The Unicode event retained its complete title in the native accessibility value, including the accented text, emoji, punctuation, backslash and line break. Its long title is visibly truncated by the event-detail viewport; the screenshot therefore does not show the entire title at once.
+
+
+
+### Google Calendar observations
+
+The original import reported **Imported 4 out of 4 events** in **OnTrack CAL-C01 Test 2026-09-21**. Before the detailed checks, one accidentally deleted synthetic event was restored from that dedicated calendar's Trash. The original imported events were then verified without re-importing or creating duplicates.
+
+On 21 September 2026, searching for `CAL-TEST` returned exactly four results. Each result was labelled **All day** and showed only the intended single date:
+
+| Fixture case | Observed all-day date |
+| --- | --- |
+| Unicode, escaped punctuation and long title | 20 September 2026 |
+| Melbourne daylight-saving start | 4 October 2026 |
+| Year boundary | 31 December 2026 |
+| Leap day | 29 February 2028 |
+
+
+
+The daylight-saving event's [day view and detail popup](calendar/evidence-20260921/google-dst.png) confirmed 4 October. The Unicode event's complete accessible name retained **café**, the calendar emoji, comma, semicolon, literal backslash and all 40 trailing **é** characters. Google's UI displayed the embedded newline as a space. The [detail popup](calendar/evidence-20260921/google-unicode.png) visually shortened the long title with an ellipsis; the complete accessible name was checked separately.
+
+Only the dedicated test calendar was enabled during verification. The seven original calendar visibility settings were restored afterwards. The retained captures show synthetic test-event content and no private email addresses.
+
+### Outlook Calendar observations
+
+On 21 September 2026, a blank calendar named **OnTrack CAL-C01 Test 2026-09-21** was created in the existing enterprise Outlook web application. The creation UI identified the new calendar as not visible to other people. **Upload from file** imported the same `calendar-compatibility.ics` fixture into that calendar, and Outlook displayed a successful import confirmation naming the file and destination. The attempted toast screenshot did not capture that confirmation and is not included as evidence.
+
+With only the test calendar selected, searching **Selected calendars** for `CAL-TEST` returned exactly four events. Each result was labelled **All day** and showed one date:
+
+| Fixture case | Observed all-day date |
+| --- | --- |
+| Unicode, escaped punctuation and long title | 20 September 2026 |
+| Melbourne daylight-saving start | 4 October 2026 |
+| Year boundary | 31 December 2026 |
+| Leap day | 29 February 2028 |
+
+
+
+The full accessible list title retained **café**, the calendar emoji, comma, semicolon, literal backslash and all 40 trailing **é** characters. Outlook normalized the embedded newline to whitespace in the list. Inspecting the Unicode event without changing or saving it showed **Sun 20/09/2026 (All day)**. Its title field removed the newline rather than replacing it with a space, joining that boundary as `andline breaks`; the list displayed `and line breaks`. The other tested title characters remained present. This is a client display-normalization observation, not an exact-newline-preservation result.
+
+The event pane was not captured because it exposed an account email address; the retained list screenshot shows only the four test entries. The personal calendar was deselected for the checks and its original visibility was restored afterward. No existing event was edited.
+
+Subscription refresh is a separate operation: an imported file never refreshes. Live-feed observations belong in [CAL-D00](CAL-D00-webcal-feed-test.md); the successful file-import checks do not establish that a subscription updates correctly.
+
+## Live-feed difference that needs special attention
+
+The API WebCal feed and **Download a copy** use the server serializer, while the unit **Download .ics** uses the frontend builder. On the tested API commit `d7f7a5b9c2d34ef279ac3a70bc58823def64005c`, task events set `DTEND` equal to `DTSTART`. The frontend uses an exclusive next-day `DTEND`. Test the live feed separately: a successful unit-file import does not validate the server's zero-duration all-day representation. The live feed also has optional timed HelpHub/class events, start dates and alarms absent from this frontend fixture.
+
+This report does not change the server contract or claim the existing feed works in a particular client without an observed result.
diff --git a/docs/CAL-D00-webcal-feed-test.md b/docs/CAL-D00-webcal-feed-test.md
new file mode 100644
index 0000000000..27d6fa2ca3
--- /dev/null
+++ b/docs/CAL-D00-webcal-feed-test.md
@@ -0,0 +1,76 @@
+# CAL-D00: What the existing WebCal feed produces
+
+This records the existing WebCal contract and the actual Apple Calendar subscription check completed on 21 September 2026. **CAL-D00 is complete:** the live feed rendered the five expected events, and manual refresh removed and restored an excluded unit's events.
+
+## Feed shape
+
+The feed is a single per-student iCalendar (`Webcal#to_ical`). The calendar declares a product id from the institution config. Merged API PR #147 corrected the refresh value to `PT4H` on both `X-PUBLISHED-TTL` and `REFRESH-INTERVAL`. This is a four-hour refresh hint; each calendar client decides when it actually refreshes.
+
+## Which tasks appear
+
+`Webcal#task_definitions` selects the task definitions for the student's enrolled, active projects, in active and currently-running units, and applies two filters:
+
+- units the student has excluded are left out (via `WebcalUnitExclusion`),
+- only task definitions at or below the student's target grade for that unit are included (`task_definitions.target_grade <= projects.target_grade`).
+
+## Events per task
+
+Each task definition produces one **end (due) event**, and also a **start event** if the student has enabled start dates.
+
+- **Title:** `"{unit code}: {task abbreviation}: {task name}"`, for example `COS10001: 2.3P: Pass Task 2.3 - My Drawing Procedure`. With start dates enabled, titles are prefixed `Start:` and `End:`.
+- **Date:** all-day, with `DTSTART` equal to `DTEND` in `YYYYMMDD` form and no time zone. The due-date chain uses the task's `effective_deadline_date` when a task record exists, then the target-grade-specific date for a flexible-date unit, then the task definition's `target_date`. Start events use the analogous `local_start_date`, target-grade-specific start date, and task definition `start_date` chain.
+- **UID:** `E-{taskDefinitionId}` for the end event and `S-{taskDefinitionId}` for the start event. These are keyed on the task definition, so the same task shares a UID across students, which is what lets a client update the event in place on refresh.
+- **Status:** `CONFIRMED`.
+- **Reminder:** if the student has set a reminder, each event carries a display alarm that triggers the configured time before the event.
+- **Custom properties:** `X-DOUBTFIRE-UNIT` (unit id) and `X-DOUBTFIRE-TASK` (task definition id).
+
+Task events do not set a description, URL, or location, and the feed does not exclude submitted or completed tasks; every applicable task definition appears regardless of submission state.
+
+## Optional learning sessions
+
+The current API also includes published HelpHubs, lectures and classes when `include_learning_sessions` is enabled. These are timed UTC events with their real end times, stable occurrence UIDs, a sequence and last-modified timestamp, optional location and joining URL. Cancelled sessions carry `STATUS:CANCELLED` and omit joining links. Sessions are restricted to current active units in which the student remains enrolled, and use the same unit exclusions; grade filtering only applies to tasks.
+
+The original source investigation used API `bb360dfa626f30e22c1382e20ef43c06ce6b38fa` and web `a35f2826ddff8a816175490c108f1794779c3254` on 20 September 2026. The live check below used the later revisions recorded in the environment notes. The server sets equal task DTSTART/DTEND, whereas the unit file exporter uses a next-day exclusive end. Apple Calendar rendered the server's five fixture events on their intended all-day dates; this result does not establish that representation's compatibility in other clients.
+
+## Observed Apple Calendar subscription — 21 September 2026
+
+The [isolated synthetic fixture](calendar/evidence-20260921/environment.md) ran on API `d7f7a5b9` and web `283b49336`, with development demo masking disabled for the two-unit UI. The real Web calendar switch enabled the account's feed. Both projects targeted High Distinction; start dates, reminders and learning sessions were off.
+
+The first attempt paused at Apple Calendar's warning about the local HTTP connection and then at the locked Mac. After the Mac was unlocked, **Continue** was selected on that warning. Subscription and refresh checks then completed in **Apple Calendar 27.0 (3073)** on **macOS 27.0**, with the system timezone **Australia/Melbourne**.
+
+A new subscription named **OnTrack CAL-D00 Local Test** was created with location **On My Mac**. Its automatic refresh setting was left at **Every week**. All refresh observations below followed an explicit **View → Refresh Calendars** command; no automatic refresh interval was tested or inferred from the feed's four-hour hint.
+
+### Initial feed
+
+Five subscribed events appeared as all-day events on these dates. Their complete titles and dates were checked in the native accessibility text; the month-view screenshot abbreviates titles to fit the cells.
+
+| Event | Observed all-day date |
+| --- | --- |
+| CAL101: 1P: Pass submitted for feedback | 23 September 2026 |
+| CAL101: 2C: Credit completed exercise | 24 September 2026 |
+| CAL101: 3D: Distinction redo exercise | 25 September 2026 |
+| CAL101: 4HD: High Distinction revision | 28 September 2026 |
+| CAL102: 1P: Second unit pass task | 29 September 2026 |
+
+The submitted and completed CAL101 tasks remained in the live feed, as the existing server contract specifies.
+
+
+
+### Unit exclusion and restoration
+
+| Action in OnTrack, followed by manual Calendar refresh | Observed result | First completed observation after refresh command |
+| --- | --- | --- |
+| Exclude CAL101 through the Web calendar dialog | Its four events disappeared; the CAL102 event on 29 September remained | 11 seconds |
+| Add CAL101 back through the Web calendar dialog | All five events were present again on their original dates | 27 seconds |
+
+These times are observation upper bounds, not measured refresh latencies: the client may have finished updating before the next completed inspection. The result establishes that the subscribed calendar reflected both changes after manual refresh.
+
+
+
+
+
+Only synthetic test events and built-in public holidays are visible in these captures. No subscription URL or token is included. This completes CAL-D00's live-client observation requirement for Apple Calendar. It does not claim automatic-refresh timing, Google/Outlook subscription compatibility, or a test of optional reminders, start events or learning sessions. The separate file-import results and client-specific display observations are in [CAL-C01](CAL-C01-calendar-compatibility.md).
+
+## Takeaway
+
+Source inspection shows that the feed covers a student's current units, filtered by target grade and by any units they exclude, as all-day events with stable UIDs and optional reminders and start dates. Its limits are narrow and specific: it always uses the saved target grade rather than a chosen one, and it includes completed tasks. Those two points are the subject of CAL-D02.
diff --git a/docs/CAL-D01-webcal-entry-point-audit.md b/docs/CAL-D01-webcal-entry-point-audit.md
new file mode 100644
index 0000000000..9ed2185b4a
--- /dev/null
+++ b/docs/CAL-D01-webcal-entry-point-audit.md
@@ -0,0 +1,257 @@
+# CAL-D01: WebCal entry point audit
+
+Recon and documentation only. No feature code or component changes were made for this
+ticket. All findings below are from reading the actual frontend source on
+`feature/CAL-D01-webcal-entry-point-audit` (branched off `feature/calendar-integration`),
+quoting real file paths and real code.
+
+## What WebCal is
+
+WebCal is an existing (since 2020, per project history) feed-subscription feature. A
+student enables it, gets a personal `.ics` feed URL, and can point any external calendar
+app (Google Calendar, Apple Calendar, Outlook) at that URL to subscribe. The subscribed
+calendar then pulls task due dates on a refresh interval. This is different from a one-off
+"add this single event" action (which is what CAL-F01 builds): WebCal is a standing
+subscription to a feed, not a per-task button.
+
+The entire frontend surface for WebCal is two things:
+
+- `src/app/api/services/webcal.service.ts`, a thin CRUD service for the user's webcal
+ settings (enabled, guid, includeStartDates, reminder, unitExclusions). It does not
+ render anything itself.
+- `src/app/common/modals/calendar-modal/calendar-modal.component.ts` (and its
+ `.component.html`), a Material dialog that is the actual settings UI: an enable toggle,
+ the `.ics` URL with a copy-to-clipboard button, reminder configuration, and per-unit
+ exclusion controls. Confirmed by reading the component directly, every mutation in it
+ (`this.webcalService.update(...)`, `this.webcalService.get({})`) goes through
+ `WebcalService`, and the dialog's own title, read from
+ `calendar-modal.component.html:4`, is:
+
+ ```html
+
Web calendar
+ ```
+
+ Note this title says "Web calendar", not "Calendar", a small labeling inconsistency
+ from the entry points that lead to it, covered below.
+
+The modal is opened exclusively through `CalendarModalService`
+(`src/app/common/modals/calendar-modal/calendar-modal.service.ts`):
+
+```ts
+public show(_task?: Task) {
+ this.dialog.open(CalendarModalComponent, {
+ height: 'h-min',
+ maxHeight: '90vh',
+ width: '800px',
+ maxWidth: '95vw',
+ });
+}
+```
+
+The `_task` parameter is unused (underscore-prefixed, never read inside `show`), and both
+real call sites pass `null`. This confirms the modal always shows the same
+account-level webcal settings, it does not open scoped to a specific task in any way,
+regardless of where it was triggered from.
+
+## Entry point 1: header avatar menu, "Calendar"
+
+**File:** `src/app/common/header/header.component.html`, lines 91-102, wired to
+`src/app/common/header/header.component.ts`, `openCalendar()` at lines 243-245.
+
+```html
+
+
+
+
+
+ person
+ My Profile
+
+
+ calendar_month
+ Calendar
+
+ ...
+
+```
+
+- **Visible label:** "Calendar", with a `calendar_month` Material icon.
+- **UI hierarchy:** top-right user avatar icon in the global header (always present,
+ `` has no role or breakpoint condition
+ around it), opens a dropdown menu containing My Profile, Calendar, About, Sign Out, in
+ that order.
+- **Header presence:** the header itself (`showHeader`) defaults to `true` and is only
+ hidden on sign-in, welcome/onboarding, the SCORM player, and during auth transitions
+ (confirmed by grepping every `.hideHeader()` call site: `authentication.service.ts`,
+ `welcome.component.ts`, `sign-in.component.ts`, `scorm-player.component.ts`). It is
+ present on the task dashboard route.
+
+## Entry point 2: Task Planner page, "Open calendar" button
+
+**File:** `src/app/projects/states/plan/project-plan.component.html`, lines 36-41, wired
+to `project-plan.component.ts`, `openCalendar()` at lines 80-82.
+
+```html
+
+ Subscribe to your unit calendar to be reminded about your due dates.
+
+ Open calendar
+
+
+```
+
+- **Visible label:** "Open calendar", plain text on a `mat-stroked-button`, with an
+ explanatory sentence directly above it ("Subscribe to your unit calendar to be reminded
+ about your due dates."). No icon on this specific button.
+- **UI hierarchy:** this button sits directly on the Task Planner page
+ (`/projects/:projectId/plan`), visible immediately on page load, not behind any further
+ menu on that page. Reaching the page itself requires going through the breadcrumb
+ dropdown in the header, see the click path below.
+- **The page itself is reached via a "Task Planner" link that does not say "calendar" in
+ its own label**, `src/app/common/header/task-dropdown/task-dropdown.component.html`,
+ lines 59-62:
+
+ ```html
+
+
+ Task Planner
+
+ ```
+
+ This reuses the same `calendar_month` icon glyph as entry point 1's "Calendar" menu
+ item, but the visible text label here is "Task Planner", not "Calendar". Two different
+ strings, one icon, worth flagging as a real discoverability inconsistency: the icon that
+ correctly signals "calendar" on one menu item is reused on a different item whose label
+ gives no calendar hint at all.
+
+## Click paths from `/projects/2/dashboard/2.3P`
+
+**Entry point 1 (header avatar, "Calendar"), 2 clicks:**
+
+1. Click the user avatar icon in the top-right of the header (always visible, the
+ `` inside `[matMenuTriggerFor]="menu2"`).
+2. In the dropdown that opens, click "Calendar" (second item, `calendar_month` icon,
+ between "My Profile" and "About").
+
+This opens the "Web calendar" dialog directly. 2 clicks, 0 page navigations.
+
+**Entry point 2 (Task Planner, "Open calendar"), 3 clicks:**
+
+1. Click the breadcrumb dropdown trigger in the header. At `/projects/2/dashboard/2.3P`
+ this button's visible text is derived from the matched route's `data.task` value. The
+ `dashboard/:taskAbbreviation` route
+ (`src/app/app.routes.ts:244-247`) has `data: {task: 'Dashboard'}`, and
+ `'Dashboard'` has no override in `task-dropdown.component.ts`'s `taskToShortName` map,
+ so the button reads literally **"Dashboard"** with a dropdown arrow icon next to it.
+ This is a guess about visual rendering specifically, confirmed from the data binding
+ and route data, not from an actual screenshot yet, screenshots are the placeholder
+ section below.
+2. In the dropdown, click "Task Planner" (third item, after Dashboard and a divider,
+ `calendar_month` icon, see entry point 2 above).
+3. On the Task Planner page that loads, click "Open calendar" (visible immediately, no
+ further menu).
+
+3 clicks, 1 page navigation, to reach the same dialog as entry point 1.
+
+## Discoverability assessment
+
+Answering the "nobody can find it" claim directly, with evidence rather than assertion:
+
+- **Neither entry point is visible without opening a menu first.** Entry point 1 requires
+ opening the avatar dropdown. Entry point 2 requires opening the breadcrumb dropdown,
+ then navigating to a whole separate page, where the button is visible without a further
+ menu, but getting there in the first place is still menu-gated. There is no persistent,
+ always-on-screen calendar affordance anywhere in the task dashboard itself, confirmed by
+ the earlier CAL-F01 recon of `task-description-card.component.html`'s
+ `mat-card-actions` block, which had no calendar link before CAL-F01 added one for a
+ different purpose (a single add-to-Google-Calendar action, not WebCal).
+- **Only one of the two visible labels says "calendar."** Entry point 1's menu item is
+ literally labeled "Calendar". Entry point 2's *menu item* is labeled "Task Planner", the
+ word "calendar" only appears once you have already navigated to that page and are
+ reading the "Open calendar" button and the sentence above it. A student scanning menu
+ labels for the word "calendar" would find entry point 1 but not entry point 2's link.
+- **The icon is reused inconsistently.** `calendar_month` appears on both entry point 1's
+ "Calendar" item and entry point 2's "Task Planner" item. Same glyph, one matches its
+ label, one does not. This undercuts the icon as a reliable visual cue across the app.
+- **The modal's own title ("Web calendar") differs from both entry points' labels**
+ ("Calendar", "Open calendar"). Three different strings for the same feature across the
+ full path, which is a small but real naming-consistency problem on top of the
+ discoverability one.
+
+Net: the "nobody can find it" claim holds up. The clearest path (entry point 1) is still
+2 clicks deep with no persistent visual presence on the page a student spends the most
+time on (the task dashboard), and the second path is both deeper and more ambiguously
+labeled.
+
+## Role differences
+
+Checked both entry points and their surrounding menus directly for role gating:
+
+- **Entry point 1 (header avatar menu) has no role gating.** The trigger button
+ (`header.component.html:91-93`) and every item inside `#menu2` (My Profile, Calendar,
+ About, Sign Out) are unconditional, no `@if` around any of them. This is different from
+ the separate admin-only menu in the same header (`#menu`, gated to
+ `currentUser.role === 'Admin' || currentUser.role === 'Convenor'` at line 60), which
+ confirms role gating exists elsewhere in this file and was deliberately not applied to
+ the Calendar item, this looks like an intentional "everyone gets this" choice, not an
+ oversight, though that is inferred from the surrounding code pattern, not from a comment
+ stating it explicitly.
+- **Entry point 2 depends on view context, not role directly.** The "Task Planner" item
+ in `task-dropdown.component.html` sits inside
+ `@if (currentProject !== null && currentView === 'PROJECT')` (line 54), the branch used
+ whenever anyone, student or staff, is viewing a specific project. Staff viewing a
+ student's project (`viewingOtherStudentProject`, defined in
+ `project-plan.component.ts:84-89`) still see the "Open calendar" button, it is not
+ wrapped in any conditional in the template. Worth flagging as a real behavioural quirk:
+ if a tutor opens a student's Task Planner page and clicks "Open calendar", the dialog
+ shows the **tutor's own** webcal settings (`webcalService.get({})` always fetches the
+ current authenticated user's webcal), not the student's. Nothing in the UI clarifies
+ this, a tutor could plausibly believe they are looking at or subscribing to the
+ student's calendar. This is a real, evidenced UX gap, not a guess, from directly
+ reading `webcal.service.ts`'s `get` call and the absence of any `projectId`/`userId`
+ parameter being passed.
+- **Staff viewing at UNIT level (not inside a specific project) see no calendar-related
+ entry point via entry point 2 at all.** The `@if (unitRole && currentView === 'UNIT')`
+ branch in the same file (line 77 onward, the Inbox/Explorer/Moderation/Students/etc.
+ menu for staff) has no calendar-related item anywhere in it. They still retain entry
+ point 1 (header avatar menu), which is present regardless of view context or role.
+
+Net: no role sees an *additional* WebCal entry point the others lack. If anything, staff
+in unit-level views temporarily lose access to entry point 2 until they navigate into a
+specific project, while retaining entry point 1 throughout.
+
+## Dead or unreachable entry points
+
+None found. Searched for every reference to `CalendarModalService`/`calendarModal.show`
+across the frontend (`grep` across `src/app`) and found exactly four files: the service
+definition, the two real call sites documented above
+(`header.component.ts`, `project-plan.component.ts`), and `header.component.spec.ts` (a
+test, not a UI entry point). No commented-out calendar or webcal markup was found anywhere
+in the templates searched. No feature flag or `isFeatureEnabled`-style condition gates
+either button. Both entry points are live and reachable exactly as described above.
+
+One adjacent false positive worth ruling out explicitly, since it shares a name: the
+`angular-calendar` npm package (imported as `CalendarEvent` from `'angular-calendar'`) is
+also used by `src/app/units/states/analytics/directives/analytics-tutor-times.component.ts`,
+for a tutor session-times week-view widget. This is a completely unrelated feature that
+happens to use the same third-party calendar-rendering library. It is not a WebCal entry
+point and should not be confused with one in any follow-up work.
+
+## Recommendation for CAL-F04
+
+CAL-F04 should not rely on either existing entry point as "sufficient" prior art, both are
+menu-gated and inconsistently labeled, which is the root cause this audit was asked to
+evidence. A visible entry point on the task dashboard itself, the page a student actually
+spends time on, is the right target. The `task-description-card.component.html`
+`mat-card-actions` row (already used by CAL-F01 for a related but distinct
+add-single-event action) is a reasonable candidate location to consider, since a student
+is already looking at that card when deciding whether they want ongoing reminders, not
+just a one-off addition. This is a recommendation, not a decision, the actual placement
+call belongs to whoever scopes CAL-F04, and should confirm with the team whether a WebCal
+entry point and CAL-F01's per-task button are meant to coexist in the same card or be
+kept visually distinct, since they are different mechanisms (standing subscription vs.
+one-off event) that could otherwise read as duplicates to a student.
+
+## Screenshots
+
+These are on the PR
diff --git a/docs/CAL-D02-webcal-filtering-investigation.md b/docs/CAL-D02-webcal-filtering-investigation.md
new file mode 100644
index 0000000000..a9be2b7371
--- /dev/null
+++ b/docs/CAL-D02-webcal-filtering-investigation.md
@@ -0,0 +1,60 @@
+# CAL-D02: Per-unit and grade filtering on the live WebCal feed
+
+Investigation only. This documents what the live WebCal feed can and cannot filter today, and what it would take to add finer filtering, so a decision can be made before any feature work.
+
+## What the feed already filters
+
+The premise that a student must "subscribe to everything or nothing" is not accurate. Two filters are already applied when the feed is generated.
+
+**Per-unit exclusion.** A student can exclude whole units from their feed. This is backed by the `WebcalUnitExclusion` model (`doubtfire-api/app/models/webcal_unit_exclusion.rb`), a join between a `Webcal` and a `Unit`. The task-definition query in `Webcal#task_definitions` (`webcal.rb`) excludes any unit whose id is in the student's exclusions:
+
+```ruby
+.where.not(units: { id: WebcalUnitExclusion.where(webcal_id: id).select(:unit_id) })
+```
+
+This is already editable from the front end. The Web calendar modal shows the student's units as removable chips (`calendar-modal.component`), and toggling a chip adds or removes a `WebcalUnitExclusion`. The real application screenshots below supplement the [labelled placement mockup](calendar/calendar-controls.svg).
+
+**Grade.** The same query only includes task definitions at or below the student's target grade:
+
+```ruby
+.where('task_definitions.target_grade <= projects.target_grade')
+```
+
+So the feed is already limited to the tasks the student needs for their saved target grade, not every task in the unit.
+
+## What the feed cannot filter today
+
+- **An independent grade override or HD-only subscription.** The grade filter uses `projects.target_grade`, the student's saved target for each unit, and includes all grades at or below it. Choosing a saved target of High Distinction still includes lower-grade tasks; it cannot produce an HD-only feed.
+- **Per-request filtering via the URL.** The public endpoint (`doubtfire-api/app/api/webcal_public_api.rb`) is `GET /webcal/:guid` and takes only the guid. It looks the webcal up and serves `webcal.to_ical.to_ical` with no query parameters, so a calendar client cannot ask for a filtered variant of the feed.
+- **Excluding completed or submitted tasks.** The feed iterates every applicable task definition regardless of the student's submission state, so completed tasks stay in the feed.
+
+## What finer filtering would take
+
+- **Grade selection independent of target grade.** The grade filter is a single clause in `Webcal#task_definitions`, so parameterising it is the small part. The larger part is deciding where the selected grade lives: either a new query parameter threaded from `webcal_public_api.rb` into `to_ical`/`task_definitions`, or a stored per-webcal grade override on the `Webcal` model, plus a control in the modal to set it. A stored setting is more consistent with how unit exclusions already work.
+- **Exclude completed tasks.** This needs the feed to consider each task's submission state, which means loading the student's `Task` records (the feed already loads them for date resolution in `to_ical`) and skipping the submitted/final states selected by the product policy. The unit download now treats Redo and Fix and resubmit as outstanding, while submitted states awaiting assessment or discussion are excluded. Reuse that definition if extending the feed so the controls agree. This is a change inside `to_ical`, plus a per-webcal toggle if it should be optional.
+- **Per-request variants.** Supporting URL parameters on the public endpoint would let a client hold several filtered subscriptions from one webcal, but it widens the public surface and is not needed for the two cases above. A stored per-webcal setting is the lower-risk route.
+
+## Actual settings screenshots — 21 September 2026
+
+These captures use the [synthetic fixture](calendar/evidence-20260921/environment.md), with development demo masking disabled. They show the current implementation, not a historical before-state. The capture area excludes the subscription URL.
+
+
+
+
+
+CAL101 was restored using **Add unit** after the capture. This supplies the screenshot requested by the duplicate CAL-D02 tracker entry. The separate calendar-client refresh check remains under [CAL-D00](CAL-D00-webcal-feed-test.md).
+
+## Assessment
+
+Unit filtering is already complete. Grade filtering exists but is tied to the saved target grade. The realistic next step, if the team wants it, is a per-webcal grade override and an optional "exclude completed" toggle, both stored on the `Webcal` and exposed in the existing modal, reusing the query that already filters by grade. This is a moderate change confined to `webcal.rb`, the `Webcal` model and settings, and the modal. It should be scoped as its own ticket and confirmed with the feature owner before any build, since it changes a live feed other students depend on.
+
+## Sources
+
+- `doubtfire-api/app/models/webcal.rb` (task_definitions scope, to_ical)
+- `doubtfire-api/app/models/webcal_unit_exclusion.rb`
+- `doubtfire-api/app/api/webcal_public_api.rb`
+- `doubtfire-web/src/app/common/modals/calendar-modal/calendar-modal.component.ts` (unit exclusion chips)
+
+## Scope check on 20 September 2026
+
+Rechecked the model, exclusions and public/settings APIs at API `bb360dfa626f30e22c1382e20ef43c06ce6b38fa`. The unit download already supports local grade/range/submission filters; it does not need a database migration or new public endpoint. No feed-filter behavior is changed by the calendar completion PR. Optional learning-session events also respect unit exclusions, and should remain independent of task grade/submission filters.
diff --git a/docs/CAL-D03-calendar-download-design.md b/docs/CAL-D03-calendar-download-design.md
new file mode 100644
index 0000000000..b1d872e14d
--- /dev/null
+++ b/docs/CAL-D03-calendar-download-design.md
@@ -0,0 +1,122 @@
+# Calendar download: design notes and codebase findings
+
+## Summary
+
+The calendar work adds the ability to download task due dates as an `.ics` file alongside the
+existing WebCal subscription (a live feed). It has two intended entry points:
+
+1. The per-unit **Progress Dashboard**, to download the current unit's tasks.
+2. The global **calendar modal**, to download across the units a student is enrolled in, using the
+ modal's existing unit selection to choose which units/subjects are included.
+
+The download is a one-time file. The WebCal subscription remains the option for a live-updating
+feed. The per-unit download and its grade selector are implemented; the cross-unit calendar-modal
+download remains follow-on work.
+
+## Placement
+
+### Progress Dashboard (per unit)
+
+- Component: `progress-dashboard.component`
+ (`src/app/projects/states/dashboard/directives/progress-dashboard/`), rendered by
+ `project-dashboard` at route `projects/:projectId/dashboard`.
+- The button fits in the "Plan Your Tasks" card (`task-planner-card.component`), beside "View Task
+ Planner". That card already receives the project (`[project]` input) and is about task due dates.
+ The implementation lives in that card and injects `FileDownloaderService` and `GradeService`.
+- Data readiness: the dashboard route resolves **progressively**. `project.resolver.ts:11` detects
+ `/dashboard` after stripping the query string; the progressive branch
+ (`project.resolver.ts:23-27`) emits a project stub immediately and loads tasks asynchronously. The
+ implementation therefore gates the button with `hasDownloadableTasks` until at least one task for
+ the selected grade is available. This differs from the Task Planner route
+ `projects/:projectId/plan`, which blocks until mapping completes.
+
+### Calendar modal (across units)
+
+- Component: `calendar-modal.component` (`src/app/common/modals/calendar-modal/`), opened from the
+ header calendar entry point and the Task Planner.
+- The modal already loads the user's projects:
+ `projectService.query(undefined, {params: {include_in_active: false}})`, filtering out explicitly
+ inactive teaching periods while retaining projects without teaching-period metadata
+ (`calendar-modal.component.ts:56-60`), held as `projects: Project[]` (`ts:30`).
+- The modal already has a unit include/exclude mechanism backed by `webcal.unitExclusions`:
+ `includedProjects` / `excludedProjects` getters (`ts:205-222`) and `includeExclusion` /
+ `removeExclusion` (`ts:227-246`), rendered as removable unit chips
+ (`calendar-modal.component.html:52-77`).
+- The modal already exposes the live feed URL (`webcalUrl` + `.ics`,
+ `calendar-modal.component.html:190`).
+- Projects returned by `query()` are list summaries and do not carry loaded task caches. A
+ client-side multi-unit build would need to load each unit's tasks first.
+
+## Date resolution
+
+- Resolve a task's date by calling `Task.localDueDate()`. Do not read `Task.dueDate`,
+ `TaskDefinition.dueDate`, or `TaskDefinition.targetDate` directly as a shortcut around it.
+- On `TaskDefinition` the field names are inverted relative to their meaning: `localDueDate()`
+ returns `targetDate` (`task-definition.ts:180`), while `localDeadlineDate()` and
+ `finalDeadlineDate()` return `dueDate` (`task-definition.ts:221, 245`). `dueDate` is the deadline;
+ `targetDate` is the due/target date.
+- `Task.localDueDate()` (`task.ts:294-312`) is the correct resolver. It handles flexible dates, the
+ student's custom target date, and grade target dates, and falls back through `dueDate` to
+ `definition.localDueDate()`. The shared event builder (`calendar-event-builder.ts`) already calls
+ it and returns a `YYYY-MM-DD` civil-date string, which avoids a daylight-saving off-by-one that a
+ `Date`-instant path would introduce.
+
+## ICS generation
+
+- No third-party ICS/iCalendar generation library is used. `ics-calendar-builder.ts` builds the
+ `VCALENDAR`/`VEVENT` text from the shared `calendar-event-builder.ts` result.
+- Text escaping follows RFC 5545 section 3.3.11, with backslash first: `\`->`\\`, `;`->`\;`,
+ `,`->`\,`, and every CR/LF form ->`\n`. Content lines use CRLF and are folded at 75 UTF-8 octets
+ without splitting a Unicode code point. Each `VEVENT` carries a UTC `DTSTAMP`.
+- All-day dates use the `YYYYMMDD` form. `DTSTART` is the task's civil due date and `DTEND` is the
+ following civil date because RFC 5545 defines `DTEND` as exclusive and later than `DTSTART`. This
+ deliberately corrects the legacy WebCal backend's equal-start/end convention; the same
+ exclusive-end rule is also required by the Google Calendar template URL.
+- Events also carry `STATUS:CONFIRMED`, `X-DOUBTFIRE-UNIT` (unit id), `X-DOUBTFIRE-TASK` (task
+ definition id), and a `UID` of the form `E-`.
+
+## Download mechanisms
+
+Two approaches exist in the codebase:
+
+- URL-based: `FileDownloaderService.downloadFile(url, filename)` (`file-downloader.service.ts:152`)
+ fetches a URL and saves the response as a file. This suits saving the existing WebCal feed URL
+ directly, in which case the server produces the `.ics` and the unit selection is already applied
+ by `unitExclusions`.
+- In-memory: build the string, `new Blob([ics], {type: 'text/calendar;charset=utf-8'})`,
+ `URL.createObjectURL`, then `FileDownloaderService.downloadBlobToFile(url, filename)`
+ (`file-downloader.service.ts:141`) and `releaseBlob(url)` (`file-downloader.service.ts:130`). The
+ per-unit download uses this approach. An equivalent self-contained pattern is used for CSV export
+ at `students-list.component.ts:140-156`.
+
+## Grade and task-status fields
+
+- Grade values: Fail -1, Pass 0, Credit 1, Distinction 2, High Distinction 3 (`unit.ts:91-95`,
+ `grade.service.ts`).
+- Per-task grade: `task.definition.targetGrade`. Limit to a target grade with
+ `task.definition.targetGrade <= grade`. `Project.activeTasks()` (`project.ts:182`) applies this
+ against the saved `project.targetGrade`; `unit.taskDefinitionsForGrade(grade)` (`unit.ts:239`) is
+ the definition-level equivalent.
+- `project.tasks` (`project.ts:125`) is unfiltered; a grade-limited download must apply the filter
+ explicitly.
+- Task completion: `task.inFinalState()` (`task.ts:670`) returns
+ `TaskStatus.FINAL_STATUSES.includes(status)`. `task.submissionDate` is available where a
+ "submitted" predicate is preferred.
+
+## Feature status and scope
+
+- Implemented: download a unit's calendar from the Progress Dashboard as an `.ics` file.
+- Implemented: select a target grade for that unit download without changing the student's saved
+ target grade.
+- Follow-on: download from the calendar modal across the user's included units/subjects.
+- Follow-on: optionally exclude completed tasks from either download.
+
+## Related existing behaviour
+
+The WebCal subscription generates events server-side (`doubtfire-api` `webcal.rb`) and already
+filters by target grade and by unit exclusions. The download reuses the shared event builder's bare
+title, civil date, and UID. That title/UID pairing matches the server feed only when
+`include_start_dates` is off; when it is on, the feed also emits `Start:` events and prefixes end
+events with `End:`. The one-time download deliberately does not recreate that reminder-style
+variant. Its exclusive next-day `DTEND` is intentionally standards-compliant even though the legacy
+server feed currently emits equal start and end dates.
diff --git a/docs/CAL-DOC01-calendar-how-to.md b/docs/CAL-DOC01-calendar-how-to.md
new file mode 100644
index 0000000000..995060a73d
--- /dev/null
+++ b/docs/CAL-DOC01-calendar-how-to.md
@@ -0,0 +1,69 @@
+# CAL-DOC01: Using the calendar features
+
+OnTrack offers a few ways to get your task due dates into your own calendar. There are two broad options: a one-off copy (a file or a single event you add now) and a live subscription (a feed that keeps updating as your dates change). Use a one-off copy when you just want the dates as they stand today, and a subscription when you want your calendar to stay in sync.
+
+## Add a single task to Google Calendar
+
+On a task's page, the task card shows an **Add to Google Calendar** button. It opens Google Calendar in a new tab with the event details for that one task's due date already filled in, ready for you to save. This adds one event only, and it does not update if the due date later changes. For a whole unit or for live updates, use the options below.
+
+
+
+Actual application screenshot, captured on 21 September 2026 with a synthetic CAL101 task.
+
+## Download a unit's due dates as a file
+
+On a unit's **Progress Dashboard**, the **Plan Your Tasks** card has a **Download .ics** button. It opens a **Download options** dialog, and confirming there downloads an `.ics` calendar file containing the due dates for that unit's tasks, which you can import into Google Calendar, Apple Calendar, Outlook, or any calendar app.
+
+
+
+Actual application screenshot with synthetic CAL101 data. The header calendar icon is also visible at the top right.
+
+The dialog has three choices:
+
+- **Grade.** Choose a grade level. It defaults to your current target grade.
+- **Up to this grade** or **This grade and above.** Up to this grade is the default and includes only the tasks required up to that grade, so if you are targeting a Credit you will not get the Distinction and High Distinction tasks. This grade and above includes that grade's tasks and every higher grade's instead.
+- **Exclude submitted and completed tasks.** On by default. Tasks awaiting feedback, discussion or demonstration, and final-state tasks, are left out. Tasks marked Redo or Fix and resubmit stay in the download because they require another submission. Untick it to include all matching dated tasks.
+
+For HD-only tasks, choose **High Distinction** and **This grade and above**. Choosing **Up to this grade** with High Distinction includes Pass, Credit and Distinction tasks too.
+
+
+
+The synthetic unit has one task at each grade. With submitted-task exclusion off, the HD-only selection above downloads one task. The actual downloads for all grades and the exclusion option are retained in the [export evidence](calendar/evidence-20260921/exports/README.md).
+
+The dialog announces how many tasks with valid due dates match your choices. Tasks without a resolvable due date cannot become calendar events. If nothing matches your choices, the dialog says so and its download button stays disabled. Your choices are not saved to your profile and do not change your target grade.
+
+The downloaded file is named after the unit and the options you chose (for example `COS10001-tasks-HD-outstanding.ics`, or `COS10001-tasks-D-and-above-outstanding.ics`), so downloads for different grades or settings do not overwrite each other. Like any downloaded file, it is a snapshot: it does not update if your dates later change.
+
+## Find and open the calendar
+
+A **calendar icon** in the top header opens the Web calendar dialog from anywhere. The same dialog is also reachable from the Task Planner. This is where you subscribe to the live feed or download a copy of it.
+
+## Subscribe to the live feed (WebCal)
+
+In the Web calendar dialog, enable the calendar to generate your personal subscription URL. Unlike a downloaded file, a subscription stays in sync: when a due date changes, your calendar app updates automatically on its next refresh.
+
+- **Choose which units to include.** The dialog lists your units as chips. Remove a unit to leave it out of the feed, and add it back from the menu.
+- **Set a reminder.** Optionally have your calendar remind you a set time before each event.
+- **Include start dates.** Optionally add each task's start date as well as its due date.
+- **Include unit HelpHubs, lectures and classes.** Opt in to published session times and joining links. Your unit exclusions also apply to sessions. This is disabled in demo mode.
+
+The subscription uses the saved target grade for each unit and continues to include submitted/completed tasks. The download dialog's local filters do not alter it. Keep the subscription URL private: anyone with the URL can retrieve its events. Regenerating the URL or disabling the calendar revokes the existing link; clients must subscribe again with the new URL.
+
+To subscribe, copy the URL and add it in your calendar app: in Google Calendar choose Add other calendars then From URL; in Apple Calendar choose File then New Calendar Subscription; in Outlook choose Add calendar then Subscribe from web. Then paste the URL.
+
+## Download a copy of the feed
+
+If you want the current state of your subscription as a one-off file rather than a live link, the Web calendar dialog has a **Download a copy** button (shown when the calendar is enabled). It saves the same events the subscription would deliver, for the units you have included, as an `.ics` file.
+
+## Which should I use?
+
+- A quick single date: **Add to Google Calendar** on the task.
+- One unit's dates as they stand today, with grade or outstanding-only filtering: **Download .ics** on the Progress Dashboard.
+- A calendar that keeps itself up to date across your units: **subscribe** to the WebCal feed.
+- A one-off snapshot of that whole feed: **Download a copy** in the dialog.
+
+## Keyboard use and client support
+
+Open Download .ics with Enter or Space; focus moves to Grade. Use Tab to reach the remaining options, Download and Cancel. Escape dismisses the dialog and returns focus to the opener. The result count is a status message. See [accessibility verification](CAL-A01-accessibility-verification.md) for completed component checks and the manual checks still to record.
+
+See [compatibility evidence](CAL-C01-calendar-compatibility.md) for the four-event fixture and the completed Apple, Google and Outlook import checks. All three showed the intended single all-day dates; the report records how each client displays long Unicode titles and embedded newlines. The separate [live-feed check](CAL-D00-webcal-feed-test.md) verified Apple Calendar subscription updates after manual refresh.
diff --git a/docs/CAL-T01-test-coverage.md b/docs/CAL-T01-test-coverage.md
new file mode 100644
index 0000000000..5ecbe5ffee
--- /dev/null
+++ b/docs/CAL-T01-test-coverage.md
@@ -0,0 +1,28 @@
+# CAL-T01: Test coverage for the calendar features
+
+Each calendar feature shipped with its own unit tests, so this note records the coverage rather than adding a separate suite.
+
+| Feature | Spec | Covers |
+| ----------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| CAL-F03 event builder | `calendar-event-builder.spec.ts` | the `localDueDate()` fallback chain, the no-due-date null case, and raw (unescaped) title output |
+| CAL-F01 Google Calendar button | `task-description-card.component.spec.ts` | button visibility, the keyboard handler, the built href, and mock isolation |
+| CAL-F02 .ics download | `ics-calendar-builder.spec.ts` and `task-planner-card.component.spec.ts` | escaping, `DTSTART` on the due date with an exclusive next-day `DTEND` (including across a year boundary), `YYYYMMDD` format, `STATUS` / `X-DOUBTFIRE-*` / `UID`, null-date skip, CRLF, a valid VCALENDAR envelope, `DTSTAMP`, and the disabled/enabled guard plus the download call chain |
+| CAL-F05/F06 grade selector | `task-planner-card.component.spec.ts` | the default grade, the highest-grade fallback, non-persistence, `hasDownloadableTasks`, the filename, actual downloaded Blob content for all four grades in both directions (including HD-only), and a discriminator proving the grade filter is applied |
+| CAL-F07 exclude submitted/completed | `task-planner-card.component.spec.ts` | excluded-when-on, included-when-off, composition with the grade filter, the guard, the filename suffix, all submitted/final states, resubmission states retained as outstanding, invalid/missing due dates, and an ICS-content discriminator |
+| CAL-F08 download a copy | `calendar-modal.component.spec.ts` | downloads with an enabled calendar and a guid, and the disabled and no-guid guards |
+
+Each feature includes at least one discriminating test, one that fails if the behaviour were bypassed rather than merely present. Every listed spec that installs Vitest spies uses `afterEach(() => vi.restoreAllMocks())` so those spies do not leak between tests in a file; `calendar-event-builder.spec.ts` installs no spies and therefore needs no cleanup hook.
+
+## Focused verification on 21 September 2026
+
+The additional dialog tests check the persistent live result count, focus configuration, actual Material switch/units controls, named and keyboard-reachable removal actions, and their saving guards. ICS tests include leap-day and Melbourne daylight-saving boundaries.
+
+Run the focused suite on Node 22 or the repository-supported runtime:
+
+```sh
+npm run test:ci -- --include='src/app/**/*calendar*.spec.ts' --include='src/app/**/task-planner-card.component.spec.ts' --include='src/app/**/download-filter-dialog.component.spec.ts' --include='src/app/**/task-description-card.component.spec.ts'
+```
+
+On a busy shared host, use `--runner-config=/tmp/calendar-vitest.config.mjs` containing `export default {test: {maxWorkers: 1, fileParallelism: false}};` and `NG_BUILD_MAX_WORKERS=2`. The initial unrestricted run exhausted worker startup timeouts before executing any tests; it was not a feature failure. The successful limited-worker run passed **7 files / 110 tests**. After explicitly setting the removal button tab index and adding Enter/Space assertions, the modal-only follow-up passed **1 file / 16 tests**. Changed TypeScript and templates also pass ESLint.
+
+The [compatibility fixture](calendar/calendar-compatibility.ics) was generated from the real production builder and checked for four events, CRLF, 75-octet line limits and the exclusive new-year end date. The [mockup](calendar/calendar-controls.svg) was rendered and visually inspected. These checks do not replace the unperformed client imports or screen-reader pass documented in [CAL-C01](CAL-C01-calendar-compatibility.md) and [CAL-A01](CAL-A01-accessibility-verification.md).
diff --git a/docs/CAL-UX01-placement-notes.md b/docs/CAL-UX01-placement-notes.md
new file mode 100644
index 0000000000..8e9fe6ae9e
--- /dev/null
+++ b/docs/CAL-UX01-placement-notes.md
@@ -0,0 +1,27 @@
+# CAL-UX01: Placement and styling of the calendar controls
+
+The calendar controls appear in the running application as shown below. These screenshots were captured on 21 September 2026 using the [synthetic CAL101 fixture](calendar/evidence-20260921/environment.md), after implementation. They record the current placement; they are not historical before-and-after screenshots.
+
+## Task action
+
+**Add to Google Calendar (CAL-F01)** appears in the task description card's action row. Its outlined button and calendar icon use the same Material conventions as the task's other resource actions. Resource buttons depend on the files available for that task; this synthetic task shows the calendar action by itself.
+
+
+
+## Unit download and global calendar
+
+**Download .ics (CAL-F02)** sits below **View Task Planner** in the Progress Dashboard's **Plan Your Tasks** card. The global calendar icon is in the top header beside the QR icon. Its placement lets a student reach the subscription settings without opening a particular task.
+
+
+
+## Download options
+
+The download button opens a standard Material dialog. **Grade** appears first, followed by vertically stacked grade-range choices, the submitted/completed-task checkbox, the result count, and the action buttons. The screenshot records **High Distinction + This grade and above**, which selects exactly the one HD task in this fixture.
+
+
+
+The actual [grade and submitted-task exports](calendar/evidence-20260921/exports/README.md) confirm the selections shown by these controls. The original [placement illustration](calendar/calendar-controls.svg) remains available as design documentation; the screenshots above provide the application evidence.
+
+**Download a copy (CAL-F08)** belongs to the Web calendar dialog alongside its subscription URL controls. That is the live-feed copy action; the unit download and its local grade/status filters remain in the Progress Dashboard.
+
+Opening Download options focuses Grade; the result count is a status message. See [CAL-A01](CAL-A01-accessibility-verification.md) for keyboard and assistive-technology verification. These desktop screenshots establish placement and styling; they do not establish a separate mobile or screen-reader test result.
diff --git a/docs/calendar/.gitattributes b/docs/calendar/.gitattributes
new file mode 100644
index 0000000000..21775e6aa7
--- /dev/null
+++ b/docs/calendar/.gitattributes
@@ -0,0 +1,2 @@
+# Preserve RFC 5545 CRLF content lines in downloadable calendar fixtures.
+*.ics -text whitespace=cr-at-eol
diff --git a/docs/calendar/calendar-compatibility.ics b/docs/calendar/calendar-compatibility.ics
new file mode 100644
index 0000000000..f2c844db98
--- /dev/null
+++ b/docs/calendar/calendar-compatibility.ics
@@ -0,0 +1,48 @@
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Doubtfire//Calendar Download//EN
+CALSCALE:GREGORIAN
+METHOD:PUBLISH
+BEGIN:VEVENT
+UID:E-900001
+SUMMARY:CAL-TEST: 1P: Year boundary
+STATUS:CONFIRMED
+DTSTART;VALUE=DATE:20261231
+DTEND;VALUE=DATE:20270101
+DTSTAMP:20260920T000000Z
+X-DOUBTFIRE-UNIT:9000
+X-DOUBTFIRE-TASK:900001
+END:VEVENT
+BEGIN:VEVENT
+UID:E-900002
+SUMMARY:CAL-TEST: 2C: Melbourne daylight saving starts
+STATUS:CONFIRMED
+DTSTART;VALUE=DATE:20261004
+DTEND;VALUE=DATE:20261005
+DTSTAMP:20260920T000000Z
+X-DOUBTFIRE-UNIT:9000
+X-DOUBTFIRE-TASK:900002
+END:VEVENT
+BEGIN:VEVENT
+UID:E-900003
+SUMMARY:CAL-TEST: 3D: Leap day
+STATUS:CONFIRMED
+DTSTART;VALUE=DATE:20280229
+DTEND;VALUE=DATE:20280301
+DTSTAMP:20260920T000000Z
+X-DOUBTFIRE-UNIT:9000
+X-DOUBTFIRE-TASK:900003
+END:VEVENT
+BEGIN:VEVENT
+UID:E-900004
+SUMMARY:CAL-TEST: 4HD: Unicode café 📅\; commas\, slashes \\ and\nline b
+ reaks — éééééééééééééééééééééééééééééééé
+ éééééééé
+STATUS:CONFIRMED
+DTSTART;VALUE=DATE:20260920
+DTEND;VALUE=DATE:20260921
+DTSTAMP:20260920T000000Z
+X-DOUBTFIRE-UNIT:9000
+X-DOUBTFIRE-TASK:900004
+END:VEVENT
+END:VCALENDAR
diff --git a/docs/calendar/calendar-controls.svg b/docs/calendar/calendar-controls.svg
new file mode 100644
index 0000000000..532bff72b3
--- /dev/null
+++ b/docs/calendar/calendar-controls.svg
@@ -0,0 +1,41 @@
+
+Calendar controls placement mockup
+Illustration of the task action row, dashboard download button, download options and web calendar subscription controls. Synthetic content; not a running application screenshot.
+
+
+Calendar controls · placement and keyboard flow
+CAL-UX01 design reference • synthetic illustration, not a screenshot • 20 September 2026
+
+Task description One task, one all-day event using its resolved due date.
+Task Sheet
+Resources
+Add to Google Calendar
+Calendar action follows existing resource actions; label stays visible.
+
+Plan Your Tasks
+Unit Progress Dashboard
+View Task Planner
+Download .ics
+Stacked actions fit narrow screens. Opens options →
+
+Download options Focus starts on Grade. Tab follows the controls below.
+Grade High Distinction ▾
+Up to this grade
+This grade and above
+High Distinction and above — HD tasks only
+✓ Exclude submitted and completed tasks
+1 task with due dates ready to download.
+Cancel Download .ics
+Web calendar
+Global calendar button / account menu
+TEST101 [remove]
+Add unit
+Subscription link · Copy · Regenerate
+Download a copy
+Grade ranges: Up to P / C / D / HD → 1 / 2 / 3 / 4 grade tiers HD and above → HD tier only No dated matches → explanation + disabled download
+
+Behavior and verification notes
+Grade and completion choices apply only to the unit download; the subscription keeps its saved settings.
+Redo and Fix and resubmit stay outstanding. Tasks without due dates cannot become events.
+Manual screen-reader, responsive-layout and calendar-client checks remain separate from this mockup.
+
diff --git a/docs/calendar/generate-fixture.cjs b/docs/calendar/generate-fixture.cjs
new file mode 100644
index 0000000000..0a0bb95f76
--- /dev/null
+++ b/docs/calendar/generate-fixture.cjs
@@ -0,0 +1,24 @@
+// Pass the absolute path of a bundle built from src/app/api/services/ics-calendar-builder.ts.
+const {buildIcsCalendar} = require(process.argv[2]);
+const {join} = require('node:path');
+const {writeFileSync} = require('node:fs');
+const cases = [
+ [900001, '1P', 'Year boundary', [2026, 11, 31]],
+ [900002, '2C', 'Melbourne daylight saving starts', [2026, 9, 4]],
+ [900003, '3D', 'Leap day', [2028, 1, 29]],
+ [
+ 900004,
+ '4HD',
+ 'Unicode café 📅; commas, slashes \\ and\nline breaks — ' + 'é'.repeat(40),
+ [2026, 8, 20],
+ ],
+];
+const tasks = cases.map(([id, abbreviation, name, date]) => ({
+ unit: {id: 9000, code: 'CAL-TEST'},
+ definition: {id, abbreviation, name},
+ localDueDate: () => new Date(...date),
+}));
+writeFileSync(
+ join(__dirname, 'calendar-compatibility.ics'),
+ buildIcsCalendar(tasks, new Date('2026-09-20T00:00:00Z')),
+);
diff --git a/src/app/api/models/webcal/webcal.ts b/src/app/api/models/webcal/webcal.ts
index a77f247b14..d42f78bd2e 100644
--- a/src/app/api/models/webcal/webcal.ts
+++ b/src/app/api/models/webcal/webcal.ts
@@ -5,6 +5,7 @@ export class Webcal extends Entity {
id: number;
guid: string;
includeStartDates: boolean;
+ includeLearningSessions: boolean = false;
userId: number;
reminder: {
time: number;
diff --git a/src/app/api/services/calendar-event-builder.spec.ts b/src/app/api/services/calendar-event-builder.spec.ts
new file mode 100644
index 0000000000..8d950367fa
--- /dev/null
+++ b/src/app/api/services/calendar-event-builder.spec.ts
@@ -0,0 +1,181 @@
+import {describe, expect, it} from 'vitest';
+import {Project} from '../models/project';
+import {Task} from '../models/task';
+import {TaskDefinition} from '../models/task-definition';
+import {Unit} from '../models/unit';
+import {buildCalendarEvent} from './calendar-event-builder';
+
+function buildTask(overrides: {
+ unitCode?: string;
+ abbreviation?: string;
+ name?: string;
+ taskDefId?: number;
+ dueDate?: Date;
+}): Task {
+ const unit = new Unit();
+ unit.code = overrides.unitCode ?? 'COS10001';
+ unit.allowFlexibleDates = false;
+
+ const definition = new TaskDefinition(unit);
+ definition.id = overrides.taskDefId ?? 42;
+ definition.abbreviation = overrides.abbreviation ?? '1.1P';
+ definition.name = overrides.name ?? 'Hello World';
+ definition.targetDate = undefined;
+
+ const task = new Task(unit);
+ task.definition = definition;
+ task.dueDate = overrides.dueDate;
+
+ return task;
+}
+
+describe('buildCalendarEvent', () => {
+ it('builds the title, uid and date for a normal task', () => {
+ const dueDate = new Date(2026, 8, 15);
+ const task = buildTask({
+ unitCode: 'COS10001',
+ abbreviation: '1.1P',
+ name: 'Hello World',
+ taskDefId: 42,
+ dueDate,
+ });
+
+ const event = buildCalendarEvent(task);
+
+ expect(event).not.toBeNull();
+ expect(event.title).toBe('COS10001: 1.1P: Hello World');
+ expect(event.uid).toBe('E-42');
+ expect(event.date).toBe('2026-09-15');
+ });
+
+ it('returns null when the task has no resolvable due date', () => {
+ const task = buildTask({dueDate: undefined});
+
+ const event = buildCalendarEvent(task);
+
+ expect(event).toBeNull();
+ });
+
+ it('returns null when the resolved due date is invalid', () => {
+ const task = buildTask({dueDate: new Date(Number.NaN)});
+
+ const event = buildCalendarEvent(task);
+
+ expect(event).toBeNull();
+ });
+
+ it('returns the title raw and unescaped, leaving escaping to the consumer', () => {
+ const dueDate = new Date(2026, 8, 15);
+ const task = buildTask({
+ unitCode: 'COS10001',
+ abbreviation: '1.1P',
+ name: "Report, Part 1; Draft's Notes",
+ dueDate,
+ });
+
+ const event = buildCalendarEvent(task);
+
+ // Raw comma, semicolon and apostrophe survive untouched. Neither URL-encoding nor
+ // ICS text-escaping (backslash before , ; and newlines) has been applied here. Both
+ // are the consumer's responsibility at the point each is serialized.
+ expect(event.title).toBe("COS10001: 1.1P: Report, Part 1; Draft's Notes");
+ });
+
+ it('returns null instead of throwing when a flexible-dates task has no project set', () => {
+ const unit = new Unit();
+ unit.code = 'COS10001';
+ unit.allowFlexibleDates = true;
+
+ const definition = new TaskDefinition(unit);
+ definition.id = 42;
+ definition.abbreviation = '1.1P';
+ definition.name = 'Hello World';
+ definition.targetDate = new Date(2026, 8, 20);
+
+ const task = new Task(unit);
+ task.definition = definition;
+ // task.project is intentionally left unset. On a flexible-dates unit,
+ // Task.localDueDate() dereferences this.project.targetGrade on this branch and
+ // throws rather than returning a falsy value, this proves the guard is widened
+ // to catch that and still return null, not propagate the exception.
+
+ let event: ReturnType;
+ expect(() => {
+ event = buildCalendarEvent(task);
+ }).not.toThrow();
+ expect(event).toBeNull();
+ });
+
+ describe('localDueDate fallback chain', () => {
+ it('uses the flexible-dates targetDueDate over task.dueDate', () => {
+ const unit = new Unit();
+ unit.code = 'COS10001';
+ unit.allowFlexibleDates = true;
+
+ const definition = new TaskDefinition(unit);
+ definition.id = 42;
+ definition.abbreviation = '1.1P';
+ definition.name = 'Hello World';
+
+ const task = new Task(unit);
+ task.definition = definition;
+ task.targetDueDate = new Date(2026, 8, 20);
+ // task.dueDate is set to a different date. If localDueDate() were swapped for
+ // task.dueDate this test would observe the wrong date and fail.
+ task.dueDate = new Date(2026, 8, 1);
+
+ const event = buildCalendarEvent(task);
+
+ expect(event.date).toBe('2026-09-20');
+ });
+
+ it('uses the flexible-dates grade-specific target date over task.dueDate', () => {
+ const unit = new Unit();
+ unit.code = 'COS10001';
+ unit.allowFlexibleDates = true;
+
+ const definition = new TaskDefinition(unit);
+ definition.id = 42;
+ definition.abbreviation = '1.1P';
+ definition.name = 'Hello World';
+ definition.gradeDueDates = [{targetGrade: 1, targetDueDate: new Date(2026, 8, 22)}];
+
+ const project = new Project(unit);
+ project.targetGrade = 1;
+
+ const task = new Task(unit);
+ task.definition = definition;
+ task.project = project;
+ // task.targetDueDate is intentionally left unset, forcing the grade-specific
+ // lookup branch. task.dueDate is set to a different date so this test would
+ // fail if localDueDate() were swapped for task.dueDate.
+ task.dueDate = new Date(2026, 8, 1);
+
+ const event = buildCalendarEvent(task);
+
+ expect(event.date).toBe('2026-09-22');
+ });
+
+ it('falls through to definition.targetDate when task.dueDate is unset', () => {
+ const unit = new Unit();
+ unit.code = 'COS10001';
+ unit.allowFlexibleDates = false;
+
+ const definition = new TaskDefinition(unit);
+ definition.id = 42;
+ definition.abbreviation = '1.1P';
+ definition.name = 'Hello World';
+ definition.targetDate = new Date(2026, 8, 25);
+
+ const task = new Task(unit);
+ task.definition = definition;
+ // task.dueDate is intentionally left unset. definition.targetDate is the only
+ // remaining source of a date, so this test would fail if localDueDate() were
+ // swapped for task.dueDate.
+
+ const event = buildCalendarEvent(task);
+
+ expect(event.date).toBe('2026-09-25');
+ });
+ });
+});
diff --git a/src/app/api/services/calendar-event-builder.ts b/src/app/api/services/calendar-event-builder.ts
new file mode 100644
index 0000000000..57483c2b2a
--- /dev/null
+++ b/src/app/api/services/calendar-event-builder.ts
@@ -0,0 +1,88 @@
+import {Task} from '../models/task';
+
+export interface WebCalEvent {
+ /**
+ * Raw, unescaped event title: "{unit.code}: {taskDef.abbreviation}: {taskDef.name}".
+ * Matches webcal.rb's event_name_for_task_definition bare-name case (no Start:/End: prefix).
+ * Deliberately not escaped for any target format, since URL encoding and ICS text escaping
+ * are incompatible schemes over the same string. Each consumer (CAL-F01, CAL-F02) must
+ * escape this for its own protocol at the point of use. Note for CAL-F02 specifically: the
+ * Ruby icalendar gem that auto-escapes text values for webcal.rb's own ICS output only exists
+ * on that Rails side. CAL-F02 is a frontend consumer, so it gets no escaping for free from
+ * that gem, it must implement RFC 5545 text escaping (backslash, comma, semicolon, newline)
+ * itself.
+ *
+ * Also for CAL-F02: webcal.rb pairs this same "E-{taskDefinitionId}" UID with a different
+ * title when the user's webcal has include_start_dates on, prefixing "End: " onto the name
+ * (webcal.rb:63-69). This builder always emits the bare name, so the (uid, title) pair it
+ * produces matches WebCal's own feed only for users who have include_start_dates off. CAL-F02
+ * must not assume this pairing is universal.
+ */
+ title: string;
+
+ /**
+ * Civil date, 'YYYY-MM-DD', not a Date instant and not a start/end pair.
+ *
+ * Not a start/end pair: webcal.rb sets DTEND == DTSTART for its ICS output, while Google
+ * Calendar's TEMPLATE URL needs an exclusive end date one day later. Those two conventions
+ * are incompatible, so there is no one (start, end) pair that would be correct for both
+ * consumers. Consumers derive their own end value from this single date. Do not "fix" this
+ * into a date range.
+ *
+ * Not a Date instant: Task.localDueDate()'s fallback branches do not agree on time-of-day.
+ * Task.dueDate, Task.targetDueDate and TaskDefinition.targetDate all map through
+ * MappingFunctions.mapDateToEndOfDay (23:59:59.999 local), but the flexible-dates grade
+ * lookup (TaskDefinition.gradeTargetDate, via gradeDueDates[].targetDueDate) maps through
+ * mapDateToDay (00:00:00.000 local) instead. A consumer calling toISOString().slice(0, 10)
+ * on the raw Date would get the previous day specifically on that branch, in any timezone
+ * ahead of UTC. Extracting the local calendar date once here, at the source, means every
+ * consumer works from the same civil date regardless of which branch produced it.
+ */
+ date: string;
+
+ /**
+ * ICS-consumer-only. Mirrors webcal.rb's "E-{taskDefinitionId}" scheme, keyed on the task
+ * definition, not the task instance, so every student on the same task shares this UID. This
+ * is WebCal's existing behaviour, matched here deliberately, not a bug to fix. Google
+ * Calendar's TEMPLATE URL has no identifier parameter, so CAL-F01 cannot use this field.
+ */
+ uid: string;
+}
+
+function toCivilDateString(date: Date): string {
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, '0');
+ const day = String(date.getDate()).padStart(2, '0');
+ return `${year}-${month}-${day}`;
+}
+
+/**
+ * Builds a calendar event description for a single student's task, matching WebCal's event
+ * format (see webcal.rb) so events built here look identical to the existing WebCal feed.
+ * Returns null when the task has no resolvable due date, since a calendar event with no date
+ * is meaningless. Callers use the null case to hide calendar-related UI, not as an error.
+ *
+ * Task.localDueDate() is not itself null-safe on every branch: on a flexible-dates unit, a
+ * Task with no project set throws when it dereferences this.project.targetGrade, rather than
+ * returning a falsy value. That is a gap in localDueDate()'s own contract, not something this
+ * function reimplements or works around by duplicating its fallback logic, it is only caught
+ * here so this function's own null contract holds for every input.
+ */
+export function buildCalendarEvent(task: Task): WebCalEvent | null {
+ let dueDate: Date;
+ try {
+ dueDate = task.localDueDate();
+ } catch {
+ return null;
+ }
+
+ if (!dueDate || Number.isNaN(dueDate.valueOf())) {
+ return null;
+ }
+
+ return {
+ title: `${task.unit.code}: ${task.definition.abbreviation}: ${task.definition.name}`,
+ date: toCivilDateString(dueDate),
+ uid: `E-${task.definition.id}`,
+ };
+}
diff --git a/src/app/api/services/ics-calendar-builder.spec.ts b/src/app/api/services/ics-calendar-builder.spec.ts
new file mode 100644
index 0000000000..80579677f3
--- /dev/null
+++ b/src/app/api/services/ics-calendar-builder.spec.ts
@@ -0,0 +1,192 @@
+import {afterEach, describe, expect, it, vi} from 'vitest';
+import {Task} from '../models/task';
+import {TaskDefinition} from '../models/task-definition';
+import {Unit} from '../models/unit';
+import {buildIcsCalendar} from './ics-calendar-builder';
+
+function buildTask(overrides: {
+ unitId?: number;
+ unitCode?: string;
+ abbreviation?: string;
+ name?: string;
+ taskDefId?: number;
+ dueDate?: Date;
+ targetDate?: Date;
+}): Task {
+ const unit = new Unit();
+ unit.id = overrides.unitId ?? 7;
+ unit.code = overrides.unitCode ?? 'COS10001';
+ unit.allowFlexibleDates = false;
+
+ const definition = new TaskDefinition(unit);
+ definition.id = overrides.taskDefId ?? 42;
+ definition.abbreviation = overrides.abbreviation ?? '1.1P';
+ definition.name = overrides.name ?? 'Hello World';
+ definition.targetDate = overrides.targetDate;
+
+ const task = new Task(unit);
+ task.definition = definition;
+ task.dueDate = overrides.dueDate;
+
+ return task;
+}
+
+// window.open leakage was the known cause the last time a spec here skipped this, see
+// task-description-card.component.spec.ts. Nothing in this file mocks window.open, but this
+// avoids the same class of leak for anything vi.spyOn touches in future tests added here.
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe('buildIcsCalendar', () => {
+ it('escapes backslash, semicolon and comma in the title, and leaves an apostrophe untouched', () => {
+ const dueDate = new Date(2026, 8, 15, 23, 59, 59, 999);
+ const task = buildTask({name: "A, B; C\\D's", dueDate});
+
+ const ics = buildIcsCalendar([task]);
+
+ // Backslash escaped first (\ -> \\), then ; -> \; and , -> \,. Apostrophe is not an
+ // RFC 5545 special character and must survive unescaped.
+ expect(ics).toContain("SUMMARY:COS10001: 1.1P: A\\, B\\; C\\\\D's");
+ });
+
+ it('sets DTSTART to the due date and exclusive DTEND to the following date', () => {
+ const dueDate = new Date(2026, 8, 15, 23, 59, 59, 999);
+ const task = buildTask({dueDate});
+
+ const ics = buildIcsCalendar([task]);
+
+ expect(ics).toContain('DTSTART;VALUE=DATE:20260915');
+ expect(ics).toContain('DTEND;VALUE=DATE:20260916');
+ });
+
+ it('advances an exclusive DTEND safely across year boundaries', () => {
+ const dueDate = new Date(2026, 11, 31, 23, 59, 59, 999);
+ const task = buildTask({dueDate});
+
+ const ics = buildIcsCalendar([task]);
+
+ expect(ics).toContain('DTSTART;VALUE=DATE:20261231');
+ expect(ics).toContain('DTEND;VALUE=DATE:20270101');
+ });
+
+ it.each([
+ [2028, 1, 28, '20280228', '20280229'],
+ [2028, 1, 29, '20280229', '20280301'],
+ [2026, 9, 4, '20261004', '20261005'],
+ [2026, 3, 5, '20260405', '20260406'],
+ ])(
+ 'keeps civil all-day dates at leap/DST boundaries: %i-%i-%i',
+ (year, month, day, start, end) => {
+ const task = buildTask({dueDate: new Date(Number(year), Number(month), Number(day))});
+ const ics = buildIcsCalendar([task]);
+ expect(ics).toContain(`DTSTART;VALUE=DATE:${start}`);
+ expect(ics).toContain(`DTEND;VALUE=DATE:${end}`);
+ },
+ );
+
+ it('includes STATUS, X-DOUBTFIRE-UNIT, X-DOUBTFIRE-TASK and UID', () => {
+ const dueDate = new Date(2026, 8, 15, 23, 59, 59, 999);
+ const task = buildTask({unitId: 7, taskDefId: 42, dueDate});
+
+ const ics = buildIcsCalendar([task]);
+
+ expect(ics).toContain('STATUS:CONFIRMED');
+ expect(ics).toContain('X-DOUBTFIRE-UNIT:7');
+ expect(ics).toContain('X-DOUBTFIRE-TASK:42');
+ expect(ics).toContain('UID:E-42');
+ });
+
+ it('skips a task whose buildCalendarEvent returns null', () => {
+ const withDate = buildTask({taskDefId: 1, dueDate: new Date(2026, 8, 15, 23, 59, 59, 999)});
+ const withoutDate = buildTask({taskDefId: 2, dueDate: undefined, targetDate: undefined});
+
+ const ics = buildIcsCalendar([withDate, withoutDate]);
+
+ expect(ics).toContain('UID:E-1');
+ expect(ics).not.toContain('UID:E-2');
+ expect((ics.match(/BEGIN:VEVENT/g) ?? []).length).toBe(1);
+ });
+
+ it('uses CRLF line endings throughout, not bare LF', () => {
+ const dueDate = new Date(2026, 8, 15, 23, 59, 59, 999);
+ const task = buildTask({dueDate});
+
+ const ics = buildIcsCalendar([task]);
+
+ expect(ics).toContain('\r\n');
+ const withoutCrlf = ics.replaceAll('\r\n', '');
+ expect(withoutCrlf.includes('\n')).toBe(false);
+ });
+
+ it('escapes every form of line break in text without leaving a control character', () => {
+ const task = buildTask({
+ name: 'First\r\nSecond\rThird\nFourth',
+ dueDate: new Date(2026, 8, 15, 23, 59, 59, 999),
+ });
+
+ const ics = buildIcsCalendar([task]);
+
+ expect(ics).toContain('SUMMARY:COS10001: 1.1P: First\\nSecond\\nThird\\nFourth');
+ expect(ics.replaceAll('\r\n', '')).not.toContain('\r');
+ });
+
+ it('folds long UTF-8 content lines at no more than 75 octets', () => {
+ const task = buildTask({
+ name: `Long task ${'é'.repeat(40)}`,
+ dueDate: new Date(2026, 8, 15, 23, 59, 59, 999),
+ });
+
+ const ics = buildIcsCalendar([task]);
+ const physicalSummaryLines = ics
+ .split('\r\n')
+ .filter((line) => line.startsWith('SUMMARY:') || line.startsWith(' '));
+
+ expect(physicalSummaryLines.length).toBeGreaterThan(1);
+ expect(physicalSummaryLines.every((line) => new TextEncoder().encode(line).length <= 75)).toBe(
+ true,
+ );
+ expect(physicalSummaryLines.slice(1).every((line) => line.startsWith(' '))).toBe(true);
+ });
+
+ it('falls through to definition.targetDate, proving the date comes from buildCalendarEvent, not a raw dueDate read', () => {
+ // task.dueDate is intentionally left unset. If this builder read task.dueDate directly
+ // instead of calling buildCalendarEvent(task), this task would be indistinguishable from
+ // one with no date at all and would be skipped, instead of resolving through
+ // localDueDate()'s fallback to definition.targetDate. This test would fail under that
+ // swap: it asserts the event is present, with the fallback date.
+ const task = buildTask({
+ taskDefId: 9,
+ dueDate: undefined,
+ targetDate: new Date(2026, 8, 25, 23, 59, 59, 999),
+ });
+
+ const ics = buildIcsCalendar([task]);
+
+ expect(ics).toContain('UID:E-9');
+ expect(ics).toContain('DTSTART;VALUE=DATE:20260925');
+ });
+
+ it('produces a valid VCALENDAR envelope', () => {
+ const dueDate = new Date(2026, 8, 15, 23, 59, 59, 999);
+ const task = buildTask({dueDate});
+
+ const ics = buildIcsCalendar([task]);
+
+ expect(ics.startsWith('BEGIN:VCALENDAR\r\n')).toBe(true);
+ expect(ics).toContain('VERSION:2.0');
+ expect(ics).toContain('CALSCALE:GREGORIAN');
+ expect(ics).toContain('METHOD:PUBLISH');
+ expect(ics.trimEnd().endsWith('END:VCALENDAR')).toBe(true);
+ });
+
+ it('includes a DTSTAMP in YYYYMMDDTHHMMSSZ form using the provided time', () => {
+ const dueDate = new Date(2026, 8, 15, 23, 59, 59, 999);
+ const task = buildTask({dueDate});
+ const now = new Date(Date.UTC(2026, 7, 1, 3, 4, 5));
+
+ const ics = buildIcsCalendar([task], now);
+
+ expect(ics).toContain('DTSTAMP:20260801T030405Z');
+ });
+});
diff --git a/src/app/api/services/ics-calendar-builder.ts b/src/app/api/services/ics-calendar-builder.ts
new file mode 100644
index 0000000000..b2d186b57e
--- /dev/null
+++ b/src/app/api/services/ics-calendar-builder.ts
@@ -0,0 +1,116 @@
+import {Task} from '../models/task';
+import {buildCalendarEvent} from './calendar-event-builder';
+
+const DEFAULT_PRODID = '-//Doubtfire//Calendar Download//EN';
+const MAX_CONTENT_LINE_BYTES = 75;
+const utf8Encoder = new TextEncoder();
+
+function escapeIcsText(text: string): string {
+ return text
+ .replace(/\\/g, '\\\\')
+ .replace(/;/g, '\\;')
+ .replace(/,/g, '\\,')
+ .replace(/\r\n|\r|\n/g, '\\n');
+}
+
+function formatIcsDate(civilDate: string): string {
+ return civilDate.replaceAll('-', '');
+}
+
+function nextCivilDate(civilDate: string): string {
+ const [year, month, day] = civilDate.split('-').map(Number);
+ const date = new Date(0);
+ date.setUTCHours(0, 0, 0, 0);
+ date.setUTCFullYear(year, month - 1, day + 1);
+
+ const pad = (value: number) => String(value).padStart(2, '0');
+ return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`;
+}
+
+/**
+ * Folds a content line at 75 UTF-8 octets without splitting a Unicode code point.
+ * Continuation lines start with a space, which counts toward their 75-octet limit.
+ */
+function foldIcsLine(line: string): string[] {
+ const folded: string[] = [];
+ let segment = '';
+ let segmentBytes = 0;
+
+ for (const character of line) {
+ const characterBytes = utf8Encoder.encode(character).length;
+ if (segment && segmentBytes + characterBytes > MAX_CONTENT_LINE_BYTES) {
+ folded.push(segment);
+ segment = ` ${character}`;
+ segmentBytes = 1 + characterBytes;
+ } else {
+ segment += character;
+ segmentBytes += characterBytes;
+ }
+ }
+
+ folded.push(segment);
+ return folded;
+}
+
+function formatIcsTimestamp(now: Date): string {
+ const pad = (value: number) => String(value).padStart(2, '0');
+ return (
+ `${now.getUTCFullYear()}${pad(now.getUTCMonth() + 1)}${pad(now.getUTCDate())}T` +
+ `${pad(now.getUTCHours())}${pad(now.getUTCMinutes())}${pad(now.getUTCSeconds())}Z`
+ );
+}
+
+/**
+ * Builds a VCALENDAR string, one VEVENT per task with a resolvable due date. Tasks
+ * buildCalendarEvent cannot resolve a date for are skipped, not an error, matching
+ * buildCalendarEvent's own null contract, not something reimplemented here. A pure function,
+ * not a service and not inlined into any component, so CAL-F08's modal download can reuse it
+ * directly.
+ *
+ * DTSTART is the task's due date and DTEND is the following date because RFC 5545 defines
+ * DTEND as exclusive and requires it to be later than DTSTART. STATUS:CONFIRMED and the
+ * X-DOUBTFIRE-UNIT / X-DOUBTFIRE-TASK custom properties are set per VEVENT, read from the task
+ * itself so every event is correctly attributed even if the tasks span more than one unit.
+ */
+export function buildIcsCalendar(
+ tasks: readonly Task[],
+ now: Date = new Date(),
+ productName: string = DEFAULT_PRODID,
+): string {
+ const dtstamp = formatIcsTimestamp(now);
+
+ const lines: string[] = [
+ 'BEGIN:VCALENDAR',
+ 'VERSION:2.0',
+ `PRODID:${escapeIcsText(productName)}`,
+ 'CALSCALE:GREGORIAN',
+ 'METHOD:PUBLISH',
+ ];
+
+ for (const task of tasks) {
+ const event = buildCalendarEvent(task);
+ if (!event) {
+ continue;
+ }
+
+ const startDate = formatIcsDate(event.date);
+ const endDate = formatIcsDate(nextCivilDate(event.date));
+
+ lines.push(
+ 'BEGIN:VEVENT',
+ `UID:${event.uid}`,
+ `SUMMARY:${escapeIcsText(event.title)}`,
+ 'STATUS:CONFIRMED',
+ `DTSTART;VALUE=DATE:${startDate}`,
+ `DTEND;VALUE=DATE:${endDate}`,
+ `DTSTAMP:${dtstamp}`,
+ `X-DOUBTFIRE-UNIT:${task.unit.id}`,
+ `X-DOUBTFIRE-TASK:${task.definition.id}`,
+ 'END:VEVENT',
+ );
+ }
+
+ lines.push('END:VCALENDAR');
+
+ return lines.flatMap(foldIcsLine).join('\r\n') + '\r\n';
+}
diff --git a/src/app/api/services/webcal.service.ts b/src/app/api/services/webcal.service.ts
index 5782fb190c..3f7d7ede80 100644
--- a/src/app/api/services/webcal.service.ts
+++ b/src/app/api/services/webcal.service.ts
@@ -16,6 +16,7 @@ export class WebcalService extends EntityService {
'id',
'guid',
'includeStartDates',
+ 'includeLearningSessions',
'userId',
'reminder',
'unitExclusions',
diff --git a/src/app/common/modals/calendar-modal/calendar-modal.component.html b/src/app/common/modals/calendar-modal/calendar-modal.component.html
index c6ae782ace..e94031f3e8 100644
--- a/src/app/common/modals/calendar-modal/calendar-modal.component.html
+++ b/src/app/common/modals/calendar-modal/calendar-modal.component.html
@@ -1,224 +1,307 @@
-
-
+
+
-
- @if (!webcal) {
-
-
+
+ {{ webcal?.enabled ? 'Enabled' : 'Disabled' }}
+
+
+
+
+
+
+ @if (!webcal && !loadError) {
+
+
+
+ Loading calendar settings…
+
+ }
+
+ @if (loadError) {
+
+
Calendar settings could not be loaded
+
Check your connection, then try again.
+
+ Retry
+
+
}
@if (webcal) {
-
Your web calendar is currently {{ webcal.enabled ? 'enabled' : 'disabled' }}.
+
+ Your web calendar is currently
+ {{ webcal.enabled ? 'enabled' : 'disabled' }} .
+
@if (!webcal.enabled) {
-
-
- The web calendar displays due dates of your tasks as calendar events in an iCalendar
- that can be added to any iCalendar client (e.g. Outlook/Google/iCloud calendar).
-
-
+
+ Enable the web calendar to subscribe to your task due dates from Google Calendar, Apple
+ Calendar, Outlook, or another iCalendar app. Once it is on, you can also include your unit
+ HelpHubs and classes.
+
}
@if (webcal.enabled) {
-
-
- The web calendar displays due dates of your tasks as calendar events. Use the following
- URL to subscribe to your web calendar from your iCalendar client.
-
+
+ Your task due dates are published as calendar events. Choose which units and dates to
+ include, and whether to add HelpHubs and classes, then follow the instructions for your
+ calendar app. Your calendar app decides how often it picks up changes.
+
-
-
- Options
- Included units in my calendar:
-
-
-
-
-
- @for (project of includedProjects; track project) {
-
- {{ project.unit.code }} {{ project.unit.name }}
- cancel
-
- }
- @if (excludedProjects.length > 0) {
-
- add
-
- @for (project of excludedProjects; track project) {
-
- {{ project.unit.code }} {{ project.unit.name }}
-
- }
-
-
- }
-
-
+
+
+ Calendar options
+ Units included in this calendar
+
-
+
+ @for (project of includedProjects; track project.id) {
+
+
+ {{ project.unit.code }} {{ project.unit.name }}
+
+
+ cancel
+
+
+ }
+
+
+ @if (excludedProjects.length > 0) {
+
- Remind me
-
-
- Time
-
-
-
- Unit
- add
+ Add unit
+
+
+ @for (project of excludedProjects; track project.id) {
+
+ {{ project.unit.code }} {{ project.unit.name }}
+
+ }
+
+ }
+
+ @if (!includedProjects.length) {
+
+ No units are currently included. Use Add unit to include one.
+
+ }
+
+
+ Event reminder
+
+
- Weeks
- Days
- Hours
- Minutes
-
-
- before each event
-
-
+ Remind me
+
+
+
+ Amount
+
+
+
+
+ Time unit
+
+ Weeks
+ Days
+ Hours
+ Minutes
+
+
+
+ before each event.
+
+
@if (
(!webcal.reminder && newReminderActive) ||
(webcal.reminder &&
(newReminderTime !== webcal.reminder.time ||
newReminderUnit !== webcal.reminder.unit))
) {
-
- save
-
-
- close
-
- }
-
-
-
- Include task start dates in web calendar
-
-
-
-
-
-
-
-
-
-
-
- @switch (selectedCalendarProviderIndex) {
- @case (0) {
- In Google Calendar, choose Add other calendars , choose
- From URL , then paste the URL below.
- }
- @case (1) {
- In Apple Calendar, choose File ,
- New Calendar Subscription , then paste the URL below.
- }
- @case (2) {
- In Outlook Calendar, choose Add calendar ,
- Subscribe from web , then paste the URL below.
- }
- }
-
-
-
-
-
-
- link {{ webcalUrl }}.ics
-
-
-
+
- {{ copying ? 'done' : 'content_copy' }}
+ Save reminder
- refresh
+ Cancel changes
-
+ }
+
+
+
+ Include task start dates in web calendar
+
+
+
+ Include unit HelpHubs, lectures and classes
+
+
+ @if (demoMode.enabled) {
+ Session subscription changes are turned off in demo mode.
+ }
+ This shares session times and joining links with your calendar provider. Your unit
+ exclusions apply to sessions too. Keep your subscription URL private.
+
+
+
+
+
+ Connect a calendar app
+
+
+
+
+
+
+
+ @switch (selectedCalendarProviderIndex) {
+ @case (0) {
+ In Google Calendar, choose Add other calendars , then
+ From URL , and paste the subscription URL below. Do this in a
+ desktop browser. The calendar then shows on your phone too.
+ }
+ @case (1) {
+ In Apple Calendar, choose File , then
+ New Calendar Subscription , and paste the subscription URL below.
+ }
+ @case (2) {
+ In Outlook Calendar, choose Add calendar , then
+ Subscribe from web , and paste the subscription URL below.
+ }
+ }
+
+
+
-
+
}
}
-
diff --git a/src/app/common/modals/calendar-modal/calendar-modal.component.spec.ts b/src/app/common/modals/calendar-modal/calendar-modal.component.spec.ts
new file mode 100644
index 0000000000..61ec9d7123
--- /dev/null
+++ b/src/app/common/modals/calendar-modal/calendar-modal.component.spec.ts
@@ -0,0 +1,585 @@
+import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
+import {CdkCopyToClipboard, Clipboard, ClipboardModule} from '@angular/cdk/clipboard';
+import {CommonModule} from '@angular/common';
+import {NO_ERRORS_SCHEMA} from '@angular/core';
+import {ComponentFixture, TestBed} from '@angular/core/testing';
+import {FormsModule} from '@angular/forms';
+import {MatButtonModule} from '@angular/material/button';
+import {MatCardModule} from '@angular/material/card';
+import {MatCheckboxModule} from '@angular/material/checkbox';
+import {MatChipsModule} from '@angular/material/chips';
+import {MAT_DIALOG_DATA, MatDialogModule} from '@angular/material/dialog';
+import {MatFormFieldModule} from '@angular/material/form-field';
+import {MatIconModule} from '@angular/material/icon';
+import {MatInputModule} from '@angular/material/input';
+import {MatMenuModule} from '@angular/material/menu';
+import {MatProgressSpinnerModule} from '@angular/material/progress-spinner';
+import {MatSelectModule} from '@angular/material/select';
+import {
+ MAT_SLIDE_TOGGLE_DEFAULT_OPTIONS,
+ MatSlideToggle,
+ MatSlideToggleModule,
+} from '@angular/material/slide-toggle';
+import {MatTabsModule} from '@angular/material/tabs';
+import {MatTooltipModule} from '@angular/material/tooltip';
+import {By} from '@angular/platform-browser';
+import {NoopAnimationsModule} from '@angular/platform-browser/animations';
+import {Subject, of, throwError} from 'rxjs';
+import {Project, Webcal} from 'src/app/api/models/doubtfire-model';
+import {ProjectService} from 'src/app/api/services/project.service';
+import {WebcalService} from 'src/app/api/services/webcal.service';
+import {FileDownloaderService} from 'src/app/common/file-downloader/file-downloader.service';
+import {DoubtfireConstants} from 'src/app/config/constants/doubtfire-constants';
+import {DEMO_TOOLS_AVAILABLE} from 'src/app/demo/demo-mode.store';
+import {AlertService} from '../../services/alert.service';
+import {ConfirmationModalService} from '../confirmation-modal/confirmation-modal.service';
+import {CalendarModalComponent} from './calendar-modal.component';
+
+function buildWebcal(): Webcal {
+ const webcal = new Webcal();
+ webcal.enabled = true;
+ webcal.guid = 'calendar-guid';
+ webcal.includeStartDates = false;
+ webcal.reminder = {time: 1, unit: 'W'};
+ webcal.unitExclusions = [];
+ return webcal;
+}
+
+function buildProject(id: number, code: string, name: string): Project {
+ return {
+ id,
+ unit: {
+ id,
+ code,
+ name,
+ teachingPeriod: {active: true},
+ },
+ } as Project;
+}
+
+describe('CalendarModalComponent', () => {
+ let fixture: ComponentFixture
;
+ let component: CalendarModalComponent;
+ let webcalService: {
+ get: ReturnType;
+ update: ReturnType;
+ };
+ let projectService: {query: ReturnType};
+ let fileDownloader: {downloadFileWithFeedback: ReturnType};
+
+ beforeEach(async () => {
+ webcalService = {
+ get: vi.fn(() => of(buildWebcal())),
+ update: vi.fn((webcal: Webcal) => of(webcal)),
+ };
+ projectService = {
+ query: vi.fn(() =>
+ of([
+ buildProject(1, 'COS10001', 'Introduction to Programming'),
+ buildProject(2, 'COS20007', 'Object Oriented Programming'),
+ ]),
+ ),
+ };
+ fileDownloader = {downloadFileWithFeedback: vi.fn()};
+
+ await TestBed.configureTestingModule({
+ declarations: [CalendarModalComponent],
+ imports: [
+ ClipboardModule,
+ CommonModule,
+ FormsModule,
+ MatButtonModule,
+ MatCardModule,
+ MatCheckboxModule,
+ MatChipsModule,
+ MatDialogModule,
+ MatFormFieldModule,
+ MatIconModule,
+ MatInputModule,
+ MatMenuModule,
+ MatProgressSpinnerModule,
+ MatSelectModule,
+ MatSlideToggleModule,
+ MatTabsModule,
+ MatTooltipModule,
+ NoopAnimationsModule,
+ ],
+ providers: [
+ {provide: DEMO_TOOLS_AVAILABLE, useValue: true},
+ {provide: MAT_DIALOG_DATA, useValue: {}},
+ {provide: WebcalService, useValue: webcalService},
+ {provide: ProjectService, useValue: projectService},
+ {provide: FileDownloaderService, useValue: fileDownloader},
+ {provide: DoubtfireConstants, useValue: {API_URL: 'https://api.example.test/api'}},
+ {provide: AlertService, useValue: {success: vi.fn(), error: vi.fn()}},
+ {provide: ConfirmationModalService, useValue: {show: vi.fn()}},
+ ],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(CalendarModalComponent);
+ component = fixture.componentInstance;
+ });
+
+ async function render(): Promise {
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges();
+ }
+
+ it('renders wrapped unit controls and one grammatical reminder group', async () => {
+ await render();
+
+ const chips: HTMLElement = fixture.nativeElement.querySelector('.calendar-unit-chips');
+ const reminder: HTMLElement = fixture.nativeElement.querySelector('.calendar-reminder-group');
+ const reminderText = reminder.textContent.replace(/\s+/g, ' ').trim();
+
+ expect(chips.textContent).toContain('COS10001 Introduction to Programming');
+ expect(chips.textContent).toContain('COS20007 Object Oriented Programming');
+ expect(reminder.querySelector('legend')?.textContent).toContain('Event reminder');
+ expect(reminderText).toMatch(/Remind me.*Amount.*Time unit.*before each event\./);
+ expect(reminder.querySelector('input[aria-label="Reminder amount"]')).not.toBeNull();
+ expect(reminder.querySelector('[aria-label="Reminder time unit"]')).not.toBeNull();
+ });
+
+ it('keeps all provider tabs visible and switches to provider-specific instructions', async () => {
+ await render();
+
+ const host: HTMLElement = fixture.nativeElement;
+ const tabLabels = Array.from(host.querySelectorAll('.mat-mdc-tab')).map((tab) =>
+ tab.textContent.trim(),
+ );
+
+ expect(tabLabels).toEqual(['Google', 'Apple', 'Outlook']);
+
+ component.selectedCalendarProviderIndex = 2;
+ fixture.detectChanges();
+
+ expect(
+ fixture.nativeElement.querySelector('.calendar-provider-help__instructions').textContent,
+ ).toContain('Subscribe from web');
+ });
+
+ it('keeps learning sessions opt-in and restores the saved preference after a failed update', async () => {
+ await render();
+ expect(component.webcal.includeLearningSessions).toBe(false);
+ const response: Subject = new Subject();
+ webcalService.update.mockReturnValue(response);
+
+ component.webcal.includeLearningSessions = true;
+ component.toggleIncludeLearningSessions();
+
+ expect(webcalService.update).toHaveBeenCalledWith(
+ expect.objectContaining({includeLearningSessions: true}),
+ );
+ expect(component.working).toBe(true);
+ response.error(new Error('offline'));
+ expect(component.webcal.includeLearningSessions).toBe(false);
+ expect(component.working).toBe(false);
+ });
+
+ it('blocks the session subscription preference in demo mode', async () => {
+ await render();
+ component.demoMode.configureScenario('calendar-spec', 1);
+ component.demoMode.setEnabled(true);
+ webcalService.update.mockClear();
+
+ component.webcal.includeLearningSessions = true;
+ component.toggleIncludeLearningSessions();
+ fixture.detectChanges();
+
+ expect(webcalService.update).not.toHaveBeenCalled();
+ expect(component.webcal.includeLearningSessions).toBe(false);
+ expect(fixture.nativeElement.querySelector('#calendar-sessions-hint').textContent).toContain(
+ 'turned off in demo mode',
+ );
+ component.demoMode.clearScenario();
+ });
+
+ it('uses the shared download feedback helper with a useful ICS filename', async () => {
+ await render();
+
+ component.downloadCalendar();
+
+ expect(fileDownloader.downloadFileWithFeedback).toHaveBeenCalledWith(
+ 'https://api.example.test/api/webcal/calendar-guid',
+ 'ontrack-calendar.ics',
+ {requestKey: 'web-calendar-ics'},
+ );
+ });
+
+ it('does not start an ICS download before a calendar URL exists', () => {
+ component.webcal = null;
+
+ component.downloadCalendar();
+
+ expect(fileDownloader.downloadFileWithFeedback).not.toHaveBeenCalled();
+ });
+
+ it('finishes a failed initial load with a retry action instead of a permanent spinner', async () => {
+ webcalService.get.mockReturnValue(throwError(() => new Error('offline')));
+
+ await render();
+
+ expect(component.working).toBe(false);
+ expect(component.loadError).toBe(true);
+ expect(fixture.nativeElement.querySelector('[role="alert"]').textContent).toContain(
+ 'Calendar settings could not be loaded',
+ );
+ expect(
+ fixture.nativeElement.querySelector('.calendar-dialog__error button').textContent,
+ ).toContain('Retry');
+ });
+
+ it('gives a newly-enabled reminder complete default values before saving', async () => {
+ const webcal = buildWebcal();
+ webcal.reminder = null;
+ webcalService.get.mockReturnValue(of(webcal));
+ await render();
+
+ component.newReminderActive = true;
+ component.onToggleReminderActive();
+
+ expect(component.newReminderTime).toBe(1);
+ expect(component.newReminderUnit).toBe('W');
+ });
+
+ it('keeps its switch from flipping itself without changing other slide toggles', async () => {
+ await render();
+
+ const webcalSwitch = fixture.debugElement.query(By.directive(MatSlideToggle))
+ .componentInstance as MatSlideToggle;
+ expect(webcalSwitch.defaults.disableToggleValue).toBe(true);
+ expect(TestBed.inject(MAT_SLIDE_TOGGLE_DEFAULT_OPTIONS).disableToggleValue).not.toBe(true);
+ });
+});
+
+const emptyProvider = {};
+
+describe('CalendarModalComponent save and download guards', () => {
+ let component: CalendarModalComponent;
+ let fixture: ComponentFixture;
+ let fileDownloaderStub: {downloadFileWithFeedback: ReturnType};
+
+ beforeEach(async () => {
+ fileDownloaderStub = {downloadFileWithFeedback: vi.fn()};
+
+ await TestBed.configureTestingModule({
+ declarations: [CalendarModalComponent],
+ providers: [
+ {provide: DEMO_TOOLS_AVAILABLE, useValue: true},
+ {provide: WebcalService, useValue: emptyProvider},
+ {provide: DoubtfireConstants, useValue: {API_URL: 'https://doubtfire.test/api'}},
+ {provide: AlertService, useValue: emptyProvider},
+ {provide: ProjectService, useValue: emptyProvider},
+ {provide: MAT_DIALOG_DATA, useValue: emptyProvider},
+ {provide: ConfirmationModalService, useValue: emptyProvider},
+ {provide: FileDownloaderService, useValue: fileDownloaderStub},
+ ],
+ schemas: [NO_ERRORS_SCHEMA],
+ })
+ .overrideComponent(CalendarModalComponent, {set: {template: ''}})
+ .compileComponents();
+
+ fixture = TestBed.createComponent(CalendarModalComponent);
+ component = fixture.componentInstance;
+ // The dialog opens in its loading state. These tests start after the webcal has loaded.
+ component.working = false;
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('restores saved settings and allows a retry after a failed save', () => {
+ const first: Subject = new Subject();
+ const retry: Subject = new Subject();
+ const update = vi.fn().mockReturnValueOnce(first).mockReturnValueOnce(retry);
+ const error = vi.fn();
+ const internals = component as unknown as {
+ webcalService: {update: typeof update};
+ alerts: {error: typeof error};
+ loadWebcal: (value: Webcal) => void;
+ };
+ internals.webcalService = {update};
+ internals.alerts = {error};
+ const webcal = Object.assign(new Webcal(), {
+ enabled: true,
+ guid: 'saved-guid',
+ unitExclusions: [],
+ includeStartDates: false,
+ });
+ internals.loadWebcal(webcal);
+
+ component.includeExclusion({unit: {id: 7}});
+ first.error(new Error('offline'));
+
+ expect(component.working).toBe(false);
+ expect(component.webcal.unitExclusions).toEqual([]);
+ expect(error).toHaveBeenCalledOnce();
+ component.includeExclusion({unit: {id: 7}});
+ expect(update).toHaveBeenCalledTimes(2);
+ expect(component.working).toBe(true);
+ });
+
+ it('does not download when the webcal is disabled', () => {
+ const webcal = new Webcal();
+ webcal.enabled = false;
+ webcal.guid = 'abc-123';
+ component.webcal = webcal;
+
+ component.downloadCalendar();
+
+ expect(fileDownloaderStub.downloadFileWithFeedback).not.toHaveBeenCalled();
+ });
+
+ it('does not download when the webcal is enabled but has no guid', () => {
+ const webcal = new Webcal();
+ webcal.enabled = true;
+ webcal.guid = undefined;
+ component.webcal = webcal;
+
+ component.downloadCalendar();
+
+ expect(fileDownloaderStub.downloadFileWithFeedback).not.toHaveBeenCalled();
+ });
+
+ it('refuses every other save while one is in flight and holds the download until it lands', () => {
+ // Each request gets its own pending response, so resolving the first cannot hide a second.
+ const pending: Subject[] = [];
+ const update = vi.fn(() => {
+ const response: Subject = new Subject();
+ pending.push(response);
+ return response.asObservable();
+ });
+ const internals = component as unknown as {
+ webcalService: {update: typeof update};
+ confirmationModal: {show: (title: string, message: string, confirm: () => void) => void};
+ };
+ internals.webcalService = {update};
+ // Hold on to each confirmation, so it can be confirmed later, while a save is running.
+ const confirmations: (() => void)[] = [];
+ internals.confirmationModal = {
+ show: (_title, _message, confirm) => confirmations.push(confirm),
+ };
+
+ const webcal = new Webcal();
+ webcal.enabled = true;
+ webcal.guid = 'abc-123';
+ webcal.unitExclusions = [];
+ webcal.reminder = {time: 1, unit: 'W'};
+ component.webcal = webcal;
+ component.newReminderActive = true;
+ component.newReminderTime = 1;
+ component.newReminderUnit = 'W';
+
+ // Open both confirmations before any save starts.
+ component.onChangeWebcalUrl();
+ component.onWebcalToggle();
+ expect(confirmations).toHaveLength(2);
+
+ component.includeExclusion({unit: {id: 1}});
+
+ // Every save entry point while the first save is pending.
+ component.includeExclusion({unit: {id: 2}});
+ component.removeExclusion({unit: {id: 1}});
+ component.toggleIncludeTaskStartDates();
+ component.newReminderTime = 3;
+ component.newReminderUnit = 'D';
+ component.onSaveReminderEdits();
+ component.newReminderActive = false;
+ component.onToggleReminderActive();
+ // Confirming the regenerate and disable dialogs now must not start a save either.
+ confirmations.forEach((confirm) => confirm());
+ component.downloadCalendar();
+
+ expect(update).toHaveBeenCalledOnce();
+ expect(webcal.unitExclusions).toEqual([1]);
+ expect(webcal.reminder).toEqual({time: 1, unit: 'W'});
+ expect(webcal.shouldChangeGuid).toBeFalsy();
+ expect(webcal.enabled).toBe(true);
+ // The refused reminder switch goes back to match the stored reminder.
+ expect(component.newReminderActive).toBe(true);
+ expect(component.newReminderTime).toBe(1);
+ expect(component.newReminderUnit).toBe('W');
+ expect(fileDownloaderStub.downloadFileWithFeedback).not.toHaveBeenCalled();
+
+ pending[0].next(webcal);
+ component.downloadCalendar();
+
+ expect(component.working).toBe(false);
+ expect(fileDownloaderStub.downloadFileWithFeedback).toHaveBeenCalledOnce();
+ });
+
+ it('does not download while a settings update is still saving', () => {
+ const webcal = new Webcal();
+ webcal.enabled = true;
+ webcal.guid = 'abc-123';
+ component.webcal = webcal;
+ component.working = true;
+
+ component.downloadCalendar();
+
+ expect(fileDownloaderStub.downloadFileWithFeedback).not.toHaveBeenCalled();
+ });
+});
+
+describe('CalendarModalComponent accessible URL controls', () => {
+ let fixture: ComponentFixture;
+ let clipboard: {copy: ReturnType};
+ let alerts: {success: ReturnType; error: ReturnType};
+
+ beforeEach(async () => {
+ clipboard = {copy: vi.fn().mockReturnValue(true)};
+ alerts = {success: vi.fn(), error: vi.fn()};
+ const webcal = Object.assign(new Webcal(), {
+ enabled: true,
+ guid: 'synthetic-calendar',
+ reminder: null,
+ unitExclusions: [],
+ });
+ await TestBed.configureTestingModule({
+ declarations: [CalendarModalComponent],
+ imports: [
+ ClipboardModule,
+ MatIconModule,
+ MatSlideToggleModule,
+ MatChipsModule,
+ MatMenuModule,
+ ],
+ schemas: [NO_ERRORS_SCHEMA],
+ providers: [
+ {provide: MAT_DIALOG_DATA, useValue: {}},
+ {provide: WebcalService, useValue: {get: () => of(webcal)}},
+ {provide: ProjectService, useValue: {query: () => of([])}},
+ {provide: DoubtfireConstants, useValue: {API_URL: 'https://example.test/api'}},
+ {provide: AlertService, useValue: alerts},
+ {provide: Clipboard, useValue: clipboard},
+ {provide: ConfirmationModalService, useValue: {}},
+ {provide: FileDownloaderService, useValue: {}},
+ ],
+ }).compileComponents();
+ fixture = TestBed.createComponent(CalendarModalComponent);
+ fixture.detectChanges();
+ });
+
+ it('renders a named subscription link with the complete ICS URL', () => {
+ const link: HTMLAnchorElement = fixture.nativeElement.querySelector(
+ 'a[aria-label^="Subscribe"]',
+ );
+ expect(link.href).toBe('https://example.test/api/webcal/synthetic-calendar.ics');
+ expect(link.getAttribute('aria-label')).toContain(link.href);
+ expect(link.querySelector('mat-icon')?.getAttribute('aria-hidden')).toBe('true');
+ });
+
+ it('copies the complete subscription URL from only the separate copy button', () => {
+ vi.useFakeTimers();
+ try {
+ const copyControls = fixture.debugElement.queryAll(By.directive(CdkCopyToClipboard));
+ expect(copyControls).toHaveLength(1);
+ const copy = copyControls[0].nativeElement as HTMLButtonElement;
+ expect(copy.tagName).toBe('BUTTON');
+ expect(copy.getAttribute('aria-label')).toBe('Copy web calendar URL');
+
+ copy.click();
+
+ expect(clipboard.copy).toHaveBeenCalledExactlyOnceWith(
+ 'https://example.test/api/webcal/synthetic-calendar.ics',
+ );
+ expect(alerts.success).toHaveBeenCalledOnce();
+ expect(alerts.error).not.toHaveBeenCalled();
+ expect(fixture.componentInstance.copying).toBe(true);
+ vi.runAllTimers();
+ expect(fixture.componentInstance.copying).toBe(false);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it('reports a refused clipboard write without claiming the URL was copied', () => {
+ clipboard.copy.mockReturnValue(false);
+ const copy: HTMLButtonElement = fixture.nativeElement.querySelector(
+ 'button[aria-label="Copy web calendar URL"]',
+ );
+
+ copy.click();
+
+ expect(clipboard.copy).toHaveBeenCalledExactlyOnceWith(
+ 'https://example.test/api/webcal/synthetic-calendar.ics',
+ );
+ expect(alerts.success).not.toHaveBeenCalled();
+ expect(alerts.error).toHaveBeenCalledOnce();
+ expect(fixture.componentInstance.copying).toBe(false);
+ });
+
+ it('gives the adjacent copy and regenerate buttons separate accessible names', () => {
+ const root: HTMLElement = fixture.nativeElement;
+ const buttons = Array.from(root.querySelectorAll('button'));
+ const copy = buttons.find((button) => button.textContent?.includes('Copy URL'))!;
+ const regenerate = buttons.find((button) => button.textContent?.includes('Regenerate URL'))!;
+ expect(copy.getAttribute('aria-label')).toBe('Copy web calendar URL');
+ expect(regenerate.getAttribute('aria-label')).toBe('Regenerate web calendar URL');
+ expect(copy.disabled).toBe(false);
+ expect(regenerate.disabled).toBe(false);
+ expect(
+ copy.compareDocumentPosition(regenerate) & Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+ });
+
+ it('names the enable switch and reminder controls', () => {
+ fixture.componentInstance.newReminderActive = true;
+ fixture.detectChanges();
+ const root: HTMLElement = fixture.nativeElement;
+ const toggle = root.querySelector('mat-slide-toggle button[role="switch"]');
+ expect(toggle?.getAttribute('aria-label')).toBe('Enable web calendar');
+ // The reminder actions are text buttons, so their visible text is their name.
+ const labels = Array.from(root.querySelectorAll('button')).map((button) =>
+ button.textContent?.trim(),
+ );
+ expect(labels).toContain('Save reminder');
+ expect(labels).toContain('Cancel changes');
+ });
+
+ it('uses named native buttons to exclude a unit and open the add-unit menu', () => {
+ fixture.componentInstance.projects = [
+ {id: 1, unit: {id: 1, code: 'TEST101', name: 'Test unit'}},
+ {id: 2, unit: {id: 2, code: 'TEST202', name: 'Other unit'}},
+ ] as Project[];
+ fixture.componentInstance.webcal.unitExclusions = [2];
+ fixture.detectChanges();
+ const root: HTMLElement = fixture.nativeElement;
+ const remove = root.querySelector(
+ 'button[aria-label="Exclude TEST101 from web calendar"]',
+ )!;
+ const exclude = vi
+ .spyOn(fixture.componentInstance, 'includeExclusion')
+ .mockImplementation(() => undefined);
+ expect(remove.disabled).toBe(false);
+ expect(remove.tabIndex).toBe(0);
+ remove.click();
+ expect(exclude).toHaveBeenCalledWith(fixture.componentInstance.projects[0]);
+ remove.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', keyCode: 13, bubbles: true}));
+ remove.dispatchEvent(new KeyboardEvent('keydown', {key: ' ', keyCode: 32, bubbles: true}));
+ expect(exclude).toHaveBeenCalledTimes(3);
+ const add = Array.from(root.querySelectorAll('button')).find((button) =>
+ button.textContent?.includes('Add unit'),
+ )!;
+ expect(add.getAttribute('aria-haspopup')).toBe('menu');
+ fixture.componentInstance.working = true;
+ fixture.detectChanges();
+ expect(remove.disabled).toBe(true);
+ expect(add.disabled).toBe(true);
+ });
+
+ it('disables the download a copy button while a settings update is saving', () => {
+ const root: HTMLElement = fixture.nativeElement;
+ const download = Array.from(root.querySelectorAll('button')).find((button) =>
+ button.textContent?.includes('Download .ics'),
+ )!;
+ expect(download.disabled).toBe(false);
+
+ fixture.componentInstance.working = true;
+ fixture.detectChanges();
+
+ expect(download.disabled).toBe(true);
+ });
+});
diff --git a/src/app/common/modals/calendar-modal/calendar-modal.component.ts b/src/app/common/modals/calendar-modal/calendar-modal.component.ts
index 9d464eca36..3f974c7d06 100644
--- a/src/app/common/modals/calendar-modal/calendar-modal.component.ts
+++ b/src/app/common/modals/calendar-modal/calendar-modal.component.ts
@@ -1,15 +1,10 @@
-import {
- AfterViewInit,
- ChangeDetectionStrategy,
- Component,
- Inject,
- OnInit,
- ViewChild,
-} from '@angular/core';
+import {ChangeDetectionStrategy, Component, Inject, OnInit} from '@angular/core';
import {MAT_DIALOG_DATA} from '@angular/material/dialog';
-import {MatSlideToggle} from '@angular/material/slide-toggle';
+import {MAT_SLIDE_TOGGLE_DEFAULT_OPTIONS} from '@angular/material/slide-toggle';
import {Project, ProjectService, Webcal, WebcalService} from 'src/app/api/models/doubtfire-model';
+import {FileDownloaderService} from 'src/app/common/file-downloader/file-downloader.service';
import {DoubtfireConstants} from 'src/app/config/constants/doubtfire-constants';
+import {DemoModeStore} from 'src/app/demo/demo-mode.store';
import {AlertService} from '../../services/alert.service';
import {ConfirmationModalService} from '../confirmation-modal/confirmation-modal.service';
@@ -19,12 +14,17 @@ import {ConfirmationModalService} from '../confirmation-modal/confirmation-modal
styleUrls: ['./calendar-modal.component.scss'],
changeDetection: ChangeDetectionStrategy.Eager,
standalone: false,
+ // The web calendar switch must not flip itself: its value follows `webcal.enabled`
+ // once the save succeeds. Provided here so only this dialog's switches get it.
+ // Writing it onto the injected defaults changed the one app-wide object, and every
+ // other slide toggle stopped responding once the dialog had been opened.
+ providers: [{provide: MAT_SLIDE_TOGGLE_DEFAULT_OPTIONS, useValue: {disableToggleValue: true}}],
})
-export class CalendarModalComponent implements OnInit, AfterViewInit {
- @ViewChild('webcalToggle') webcalToggle: MatSlideToggle;
-
+export class CalendarModalComponent implements OnInit {
webcal: Webcal | null;
+ private savedWebcal: Webcal | null = null;
working: boolean = true;
+ loadError: boolean = false;
copying: boolean = false;
selectedCalendarProviderIndex: number = 0;
projects: Project[] = [];
@@ -42,41 +42,72 @@ export class CalendarModalComponent implements OnInit, AfterViewInit {
private projectService: ProjectService,
@Inject(MAT_DIALOG_DATA) public data: object,
private confirmationModal: ConfirmationModalService,
+ private fileDownloader: FileDownloaderService,
+ readonly demoMode: DemoModeStore,
) {}
ngOnInit() {
- // Retrieve current webcal.
- this.working = true;
- this.webcalService.get({}).subscribe((webcal) => {
- this.loadWebcal(webcal);
- this.working = false;
- });
+ this.loadWebCalendar();
// Allow selection of units with active projects.
- this.projectService
- .query(undefined, {params: {include_in_active: false}})
- .subscribe((projects) => {
+ this.projectService.query(undefined, {params: {include_in_active: false}}).subscribe({
+ next: (projects) => {
this.projects = projects.filter((p) => p.unit.teachingPeriod?.active ?? true);
- });
+ },
+ error: () => {
+ this.projects = [];
+ this.alerts.error('Unable to load the units available for your web calendar.');
+ },
+ });
}
- ngAfterViewInit() {
- // Disallow the value of the slide toggle being changed by the user. Instead, its value is bound to the presence of
- // `this.webcal`.
- this.webcalToggle.defaults.disableToggleValue = true;
+ loadWebCalendar(): void {
+ this.working = true;
+ this.loadError = false;
+ this.webcalService.get({}).subscribe({
+ next: (webcal) => {
+ this.loadWebcal(webcal);
+ this.working = false;
+ },
+ error: () => {
+ this.webcal = null;
+ this.working = false;
+ this.loadError = true;
+ },
+ });
}
/**
* Retrieves the URL of the webcal relative to current API URL.
*/
get webcalUrl(): string | null {
- return this.webcal?.getUrl(this.constants.API_URL).toString();
+ return this.webcal?.guid ? this.webcal.getUrl(this.constants.API_URL).toString() : null;
+ }
+
+ get webcalDownloadUrl(): string | null {
+ if (!this.webcal?.guid) {
+ return null;
+ }
+
+ return new URL(`${this.constants.API_URL}/webcal/${this.webcal.guid}`).toString();
+ }
+
+ /**
+ * The subscription URL exactly as it is shown to the user, with the `.ics`
+ * suffix, so the copied string matches what is on screen.
+ */
+ get webcalSubscriptionUrl(): string | null {
+ const url = this.webcalUrl;
+ return url ? `${url}.ics` : null;
}
/**
* Invoked when the user toggles the webcal.
*/
onWebcalToggle() {
+ if (this.working || !this.webcal) {
+ return;
+ }
if (this.webcal.enabled) {
this.confirmationModal.show(
'Disable web calendar',
@@ -90,21 +121,64 @@ export class CalendarModalComponent implements OnInit, AfterViewInit {
}
private updateWebcalEnabled(enabled: boolean) {
+ this.saveWebcal(() => (this.webcal.enabled = enabled));
+ }
+
+ /**
+ * Saves the webcal, one request at a time. `apply` changes the model and only runs when no
+ * save is in flight, because an overlapping save would let the earlier response clear
+ * `working` while the later one is still pending, reopening Download a copy too early.
+ * Every save in this dialog goes through here, including the ones behind a confirmation.
+ * Returns false when a save was already running and nothing changed.
+ */
+ private saveWebcal(apply: () => void): boolean {
+ if (this.working || !this.webcal) {
+ return false;
+ }
+ const previous = this.savedWebcal ?? this.copyWebcal(this.webcal);
+ apply();
this.working = true;
- this.webcal.enabled = enabled;
+ this.webcalService.update(this.webcal).subscribe({
+ next: (webcal) => {
+ this.loadWebcal(webcal);
+ this.working = false;
+ },
+ error: () => {
+ this.loadWebcal(Object.assign(this.webcal, previous));
+ this.working = false;
+ this.alerts.error('Could not save calendar settings. Please try again.', 4000);
+ },
+ });
+ return true;
+ }
- this.webcalService.update(this.webcal).subscribe((webcal) => {
- this.loadWebcal(webcal);
- this.working = false;
+ /**
+ * Downloads the current web calendar feed as an .ics file. Skipped while a settings update
+ * is still saving, the same as every other control in this dialog, so the copy never reflects
+ * settings the server has not stored yet or a URL that is being regenerated.
+ */
+ downloadCalendar() {
+ if (this.working || !this.webcal?.enabled || !this.webcal.guid) {
+ return;
+ }
+
+ const feedUrl = `${this.constants.API_URL}/webcal/${this.webcal.guid}`;
+ this.fileDownloader.downloadFileWithFeedback(feedUrl, 'ontrack-calendar.ics', {
+ requestKey: 'web-calendar-ics',
});
}
/**
- * Displays a notification that the webcal URL has been copied.
- * `cdkCopyToClipboard` is expected do the actual copying.
- * Changes mat-icon temporarily for a second after copying.
+ * Invoked by cdkCopyToClipboard once it has attempted the copy. Only
+ * confirm on a real success, so a refused clipboard write does not claim
+ * the URL was copied. Changes mat-icon temporarily for a second after.
*/
- onCopyWebcalUrl() {
+ onCopyResult(success: boolean) {
+ if (!success) {
+ this.alerts.error('Could not copy the URL, select it and copy it manually', 4000);
+ return;
+ }
+
this.alerts.success('Web calendar URL copied to the clipboard', 2000);
this.copying = true;
@@ -117,17 +191,13 @@ export class CalendarModalComponent implements OnInit, AfterViewInit {
* Invoked when the user requests their webcal URL to be changed.
*/
onChangeWebcalUrl() {
+ if (this.working) {
+ return;
+ }
this.confirmationModal.show(
'Regenerate URL',
'Regenerating your calendar URL will disable the current subscription link. Any calendar apps using the old URL will stop updating until you subscribe again with the new one.',
- () => {
- this.working = true;
- this.webcal.shouldChangeGuid = true;
- this.webcalService.update(this.webcal).subscribe((webcal) => {
- this.loadWebcal(webcal);
- this.working = false;
- });
- },
+ () => this.saveWebcal(() => (this.webcal.shouldChangeGuid = true)),
);
}
@@ -147,13 +217,10 @@ export class CalendarModalComponent implements OnInit, AfterViewInit {
} else {
// ...and a reminder does exist, make backend request to remove it.
if (this.webcal.reminder) {
- this.working = true;
- this.webcal.reminder = null;
-
- this.webcalService.update(this.webcal).subscribe((webcal) => {
- this.loadWebcal(webcal);
- this.working = false;
- });
+ // If a save is already running, put the switch back to match the stored reminder.
+ if (!this.saveWebcal(() => (this.webcal.reminder = null))) {
+ this.loadWebcal(this.webcal);
+ }
// ...otherwise, reset.
} else {
@@ -167,15 +234,13 @@ export class CalendarModalComponent implements OnInit, AfterViewInit {
*/
onSaveReminderEdits() {
if (this.newReminderTime > 0) {
- this.working = true;
- this.webcal.reminder = {
- time: this.newReminderTime,
- unit: this.newReminderUnit,
- };
- this.webcalService.update(this.webcal).subscribe((webcal) => {
- this.loadWebcal(webcal);
- this.working = false;
- });
+ this.saveWebcal(
+ () =>
+ (this.webcal.reminder = {
+ time: this.newReminderTime,
+ unit: this.newReminderUnit,
+ }),
+ );
} else {
this.alerts.error('Please specify a valid reminder time', 2000);
}
@@ -192,11 +257,18 @@ export class CalendarModalComponent implements OnInit, AfterViewInit {
* Includes task 'Start Dates' in the Webcal.
*/
toggleIncludeTaskStartDates() {
- this.working = true;
- this.webcalService.update(this.webcal).subscribe((webcal) => {
- this.loadWebcal(webcal);
- this.working = false;
- });
+ // The checkbox has already changed the model through ngModel, so there is nothing to apply.
+ this.saveWebcal(() => undefined);
+ }
+
+ toggleIncludeLearningSessions() {
+ if (this.demoMode.enabled) {
+ if (this.savedWebcal) {
+ this.loadWebcal(this.copyWebcal(this.savedWebcal));
+ }
+ return;
+ }
+ this.saveWebcal(() => undefined);
}
/**
@@ -225,24 +297,21 @@ export class CalendarModalComponent implements OnInit, AfterViewInit {
* Removes the specified project exclusion from the webcal.
*/
removeExclusion(project) {
- this.working = true;
- this.webcal.unitExclusions = this.webcal.unitExclusions.filter((p) => p !== project.unit.id);
- this.webcalService.update(this.webcal).subscribe((webcal) => {
- this.loadWebcal(webcal);
- this.working = false;
- });
+ this.saveWebcal(
+ () =>
+ (this.webcal.unitExclusions = this.webcal.unitExclusions.filter(
+ (p) => p !== project.unit.id,
+ )),
+ );
}
/**
* Excludes the specified project from the webcal.
*/
includeExclusion(project) {
- this.working = true;
- this.webcal.unitExclusions = [...this.webcal.unitExclusions, project.unit.id];
- this.webcalService.update(this.webcal).subscribe((webcal) => {
- this.loadWebcal(webcal);
- this.working = false;
- });
+ this.saveWebcal(
+ () => (this.webcal.unitExclusions = [...this.webcal.unitExclusions, project.unit.id]),
+ );
}
/**
@@ -250,6 +319,7 @@ export class CalendarModalComponent implements OnInit, AfterViewInit {
*/
private loadWebcal(webcal: Webcal) {
this.webcal = webcal;
+ this.savedWebcal = webcal ? this.copyWebcal(webcal) : null;
if (webcal) {
if (webcal.reminder) {
this.newReminderActive = true;
@@ -261,4 +331,12 @@ export class CalendarModalComponent implements OnInit, AfterViewInit {
}
}
}
+
+ private copyWebcal(webcal: Webcal): Webcal {
+ return Object.assign(new Webcal(), webcal, {
+ reminder: webcal.reminder ? {...webcal.reminder} : null,
+ unitExclusions: [...(webcal.unitExclusions ?? [])],
+ shouldChangeGuid: false,
+ });
+ }
}
diff --git a/src/app/projects/states/dashboard/directives/progress-dashboard/task-planner-card/download-filter-dialog/download-filter-dialog.component.html b/src/app/projects/states/dashboard/directives/progress-dashboard/task-planner-card/download-filter-dialog/download-filter-dialog.component.html
new file mode 100644
index 0000000000..70b5e4489c
--- /dev/null
+++ b/src/app/projects/states/dashboard/directives/progress-dashboard/task-planner-card/download-filter-dialog/download-filter-dialog.component.html
@@ -0,0 +1,50 @@
+Download options
+
+
+
+ Grade
+
+ @for (value of data.gradeValues; track value) {
+ {{ data.gradeLabel(value) }}
+ }
+
+
+
+
+ Up to this grade
+ This grade and above
+
+
+ {{ directionLabel }}
+
+
+ Exclude submitted and completed tasks
+
+
+
+ @if (canConfirm) {
+ {{ matchingTaskCount }} {{ matchingTaskCount === 1 ? 'task' : 'tasks' }} with due dates ready
+ to download.
+ } @else {
+ No tasks with due dates match this selection.
+ }
+
+
+
+
+ Cancel
+
+ Download .ics
+
+
diff --git a/src/app/projects/states/dashboard/directives/progress-dashboard/task-planner-card/download-filter-dialog/download-filter-dialog.component.spec.ts b/src/app/projects/states/dashboard/directives/progress-dashboard/task-planner-card/download-filter-dialog/download-filter-dialog.component.spec.ts
new file mode 100644
index 0000000000..47997e7ff3
--- /dev/null
+++ b/src/app/projects/states/dashboard/directives/progress-dashboard/task-planner-card/download-filter-dialog/download-filter-dialog.component.spec.ts
@@ -0,0 +1,188 @@
+import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
+import {ComponentFixture, TestBed} from '@angular/core/testing';
+import {FormsModule} from '@angular/forms';
+import {MatButtonModule} from '@angular/material/button';
+import {MatButtonToggleModule} from '@angular/material/button-toggle';
+import {MatCheckboxModule} from '@angular/material/checkbox';
+import {MAT_DIALOG_DATA, MatDialogModule, MatDialogRef} from '@angular/material/dialog';
+import {MatFormFieldModule} from '@angular/material/form-field';
+import {MatSelectModule} from '@angular/material/select';
+import {NoopAnimationsModule} from '@angular/platform-browser/animations';
+import {
+ DownloadDirection,
+ DownloadFilterDialogComponent,
+ DownloadFilterDialogData,
+} from './download-filter-dialog.component';
+
+const GRADE_LABELS: Record = {
+ 0: 'Pass',
+ 1: 'Credit',
+ 2: 'Distinction',
+ 3: 'High Distinction',
+};
+
+function gradeLabel(grade: number): string {
+ return GRADE_LABELS[grade];
+}
+
+describe('DownloadFilterDialogComponent', () => {
+ let component: DownloadFilterDialogComponent;
+ let fixture: ComponentFixture;
+ let dialogRefStub: {close: ReturnType};
+ let matchingTaskCountStub: ReturnType<
+ typeof vi.fn<(grade: number, direction: DownloadDirection, excludeCompleted: boolean) => number>
+ >;
+ let dialogData: DownloadFilterDialogData;
+
+ function configure(data: DownloadFilterDialogData): void {
+ dialogData = data;
+
+ TestBed.resetTestingModule();
+ dialogRefStub = {close: vi.fn()};
+
+ TestBed.configureTestingModule({
+ declarations: [DownloadFilterDialogComponent],
+ imports: [
+ FormsModule,
+ MatButtonModule,
+ MatButtonToggleModule,
+ MatCheckboxModule,
+ MatDialogModule,
+ MatFormFieldModule,
+ MatSelectModule,
+ NoopAnimationsModule,
+ ],
+ providers: [
+ {provide: MAT_DIALOG_DATA, useValue: dialogData},
+ {provide: MatDialogRef, useValue: dialogRefStub},
+ ],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(DownloadFilterDialogComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ }
+
+ beforeEach(() => {
+ matchingTaskCountStub = vi.fn(() => 1);
+
+ configure({
+ gradeValues: [0, 1, 2, 3],
+ gradeLabel,
+ initialGrade: 2,
+ initialDirection: 'upTo',
+ initialExcludeCompleted: false,
+ matchingTaskCount: matchingTaskCountStub,
+ });
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('renders the grade select, direction toggle and exclude-completed checkbox', () => {
+ expect(fixture.nativeElement.querySelector('mat-select')).not.toBeNull();
+ expect(fixture.nativeElement.querySelector('mat-button-toggle-group')).not.toBeNull();
+ expect(fixture.nativeElement.querySelector('mat-checkbox')).not.toBeNull();
+ });
+
+ it('stacks the direction choices at full width for narrow screens', () => {
+ const group: HTMLElement = fixture.nativeElement.querySelector('mat-button-toggle-group');
+ const toggles: HTMLElement[] = Array.from(group.querySelectorAll('mat-button-toggle'));
+
+ expect(group.classList.contains('mat-button-toggle-vertical')).toBe(true);
+ expect(group.getAttribute('aria-label')).toBe('Grade range');
+ expect(toggles).toHaveLength(2);
+ toggles.forEach((toggle) => expect(toggle.classList.contains('w-full')).toBe(true));
+ });
+
+ it('seeds its selection from the initial values in dialog data', () => {
+ expect(component.grade).toBe(2);
+ expect(component.direction).toBe('upTo');
+ expect(component.excludeCompleted).toBe(false);
+ });
+
+ it('labels "up to this grade" as "Everything up to "', () => {
+ component.direction = 'upTo';
+ expect(component.directionLabel).toBe('Everything up to Distinction');
+ });
+
+ it('labels "this grade and above" as " and above"', () => {
+ component.direction = 'andAbove';
+ expect(component.directionLabel).toBe('Distinction and above');
+ });
+
+ it('disables confirm and shows a message when the selection matches no tasks', () => {
+ matchingTaskCountStub.mockReturnValue(0);
+ fixture.detectChanges();
+
+ expect(component.canConfirm).toBe(false);
+ const confirmButton: HTMLButtonElement = fixture.nativeElement.querySelector(
+ 'mat-dialog-actions button[color="primary"]',
+ );
+ expect(confirmButton.disabled).toBe(true);
+ expect(fixture.nativeElement.textContent).toContain(
+ 'No tasks with due dates match this selection.',
+ );
+ });
+
+ it('enables confirm when the selection matches at least one task', () => {
+ matchingTaskCountStub.mockReturnValue(3);
+ fixture.detectChanges();
+
+ expect(component.canConfirm).toBe(true);
+ const confirmButton: HTMLButtonElement = fixture.nativeElement.querySelector(
+ 'mat-dialog-actions button[color="primary"]',
+ );
+ expect(confirmButton.disabled).toBe(false);
+ });
+
+ it('announces the result count and the empty state in a persistent status region', () => {
+ matchingTaskCountStub.mockReturnValue(2);
+ fixture.detectChanges();
+ const status: HTMLElement = fixture.nativeElement.querySelector('[role="status"]');
+ expect(status.getAttribute('aria-atomic')).toBe('true');
+ expect(status.textContent?.replace(/\s+/g, ' ').trim()).toBe(
+ '2 tasks with due dates ready to download.',
+ );
+
+ matchingTaskCountStub.mockReturnValue(0);
+ fixture.detectChanges();
+ expect(fixture.nativeElement.querySelector('[role="status"]')).toBe(status);
+ expect(status.textContent).toContain('No tasks with due dates match this selection.');
+ });
+
+ it('closes the dialog with the current selection when confirmed', () => {
+ component.grade = 3;
+ component.direction = 'andAbove';
+ component.excludeCompleted = true;
+ matchingTaskCountStub.mockReturnValue(1);
+
+ component.confirm();
+
+ expect(dialogRefStub.close).toHaveBeenCalledWith({
+ grade: 3,
+ direction: 'andAbove',
+ excludeCompleted: true,
+ });
+ });
+
+ it('does not close the dialog when confirmed with a zero-match selection', () => {
+ // If canConfirm were bypassed, this would still close the dialog and hand back a
+ // selection that downloads an empty .ics.
+ matchingTaskCountStub.mockReturnValue(0);
+
+ component.confirm();
+
+ expect(dialogRefStub.close).not.toHaveBeenCalled();
+ });
+
+ it('closes the dialog with no result when cancelled', () => {
+ const cancelButton: HTMLButtonElement = fixture.nativeElement.querySelector(
+ 'mat-dialog-actions button[type="button"]:not([color="primary"])',
+ );
+ cancelButton.click();
+
+ expect(dialogRefStub.close).toHaveBeenCalledWith(undefined);
+ });
+});
diff --git a/src/app/projects/states/dashboard/directives/progress-dashboard/task-planner-card/download-filter-dialog/download-filter-dialog.component.ts b/src/app/projects/states/dashboard/directives/progress-dashboard/task-planner-card/download-filter-dialog/download-filter-dialog.component.ts
new file mode 100644
index 0000000000..a470c1e394
--- /dev/null
+++ b/src/app/projects/states/dashboard/directives/progress-dashboard/task-planner-card/download-filter-dialog/download-filter-dialog.component.ts
@@ -0,0 +1,78 @@
+import {ChangeDetectionStrategy, Component, Inject} from '@angular/core';
+import {MAT_DIALOG_DATA, MatDialogRef} from '@angular/material/dialog';
+
+export type DownloadDirection = 'upTo' | 'andAbove';
+
+export interface DownloadFilterSelection {
+ grade: number;
+ direction: DownloadDirection;
+ excludeCompleted: boolean;
+}
+
+export interface DownloadFilterDialogData {
+ gradeValues: number[];
+ gradeLabel: (grade: number) => string;
+ initialGrade: number;
+ initialDirection: DownloadDirection;
+ initialExcludeCompleted: boolean;
+ matchingTaskCount: (
+ grade: number,
+ direction: DownloadDirection,
+ excludeCompleted: boolean,
+ ) => number;
+}
+
+@Component({
+ selector: 'f-download-filter-dialog',
+ templateUrl: './download-filter-dialog.component.html',
+ changeDetection: ChangeDetectionStrategy.Eager,
+ standalone: false,
+})
+export class DownloadFilterDialogComponent {
+ grade: number;
+ direction: DownloadDirection;
+ excludeCompleted: boolean;
+
+ constructor(
+ @Inject(MAT_DIALOG_DATA) public data: DownloadFilterDialogData,
+ private dialogRef: MatDialogRef,
+ ) {
+ this.grade = data.initialGrade;
+ this.direction = data.initialDirection;
+ this.excludeCompleted = data.initialExcludeCompleted;
+ }
+
+ get selectedGradeLabel(): string {
+ return this.data.gradeLabel(this.grade);
+ }
+
+ /**
+ * "This grade and above" covers the exact-grade case at the top end (selecting HD yields
+ * HD-only), so the label must not read as a range that stops short of it.
+ */
+ get directionLabel(): string {
+ return this.direction === 'andAbove'
+ ? `${this.selectedGradeLabel} and above`
+ : `Everything up to ${this.selectedGradeLabel}`;
+ }
+
+ get matchingTaskCount(): number {
+ return this.data.matchingTaskCount(this.grade, this.direction, this.excludeCompleted);
+ }
+
+ get canConfirm(): boolean {
+ return this.matchingTaskCount > 0;
+ }
+
+ confirm(): void {
+ if (!this.canConfirm) {
+ return;
+ }
+
+ this.dialogRef.close({
+ grade: this.grade,
+ direction: this.direction,
+ excludeCompleted: this.excludeCompleted,
+ });
+ }
+}
diff --git a/src/app/projects/states/dashboard/directives/progress-dashboard/task-planner-card/task-planner-card.component.ts b/src/app/projects/states/dashboard/directives/progress-dashboard/task-planner-card/task-planner-card.component.ts
index c3d77da113..98cab2d86e 100644
--- a/src/app/projects/states/dashboard/directives/progress-dashboard/task-planner-card/task-planner-card.component.ts
+++ b/src/app/projects/states/dashboard/directives/progress-dashboard/task-planner-card/task-planner-card.component.ts
@@ -1,5 +1,16 @@
-import {ChangeDetectionStrategy, Component, Input} from '@angular/core';
+import {ChangeDetectionStrategy, Component, Inject, Input} from '@angular/core';
+import {MatDialog} from '@angular/material/dialog';
import {Project} from 'src/app/api/models/project';
+import {Task} from 'src/app/api/models/task';
+import {buildCalendarEvent} from 'src/app/api/services/calendar-event-builder';
+import {buildIcsCalendar} from 'src/app/api/services/ics-calendar-builder';
+import {FileDownloaderService} from 'src/app/common/file-downloader/file-downloader.service';
+import {GradeService} from 'src/app/common/services/grade.service';
+import {
+ DownloadDirection,
+ DownloadFilterDialogComponent,
+ DownloadFilterSelection,
+} from './download-filter-dialog/download-filter-dialog.component';
@Component({
selector: 'f-task-planner-card',
@@ -9,8 +20,158 @@ import {Project} from 'src/app/api/models/project';
standalone: false,
})
export class TaskPlannerCardComponent {
- @Input() project: Project;
+ private currentProject: Project;
+ private downloadGradeOverride: number | undefined;
+
+ @Input()
+ public get project(): Project {
+ return this.currentProject;
+ }
+
+ public set project(project: Project) {
+ if (project !== this.currentProject) {
+ this.downloadGradeOverride = undefined;
+ }
+ this.currentProject = project;
+ }
+
+ @Input() showTips = true;
public get unit() {
return this.project?.unit;
}
+
+ /**
+ * Follow the dashboard's current target until a different download grade is chosen.
+ * That choice stays local to this project and never changes its saved target grade.
+ */
+ public get selectedDownloadGrade(): number {
+ return this.downloadGradeOverride ?? this.project?.targetGrade ?? Math.max(...this.gradeValues);
+ }
+
+ public set selectedDownloadGrade(grade: number) {
+ const targetGrade = this.project?.targetGrade ?? Math.max(...this.gradeValues);
+ this.downloadGradeOverride = grade === targetGrade ? undefined : grade;
+ }
+
+ /**
+ * Local to the download only, not persisted, same as selectedDownloadGrade above. Defaults to
+ * "up to this grade" so an existing user's download is unchanged until they open the dialog
+ * and choose otherwise.
+ */
+ public downloadDirection: DownloadDirection = 'upTo';
+
+ /**
+ * Local to the download only, not persisted, same as selectedDownloadGrade above. Defaults to
+ * true: someone downloading a calendar of due dates almost always wants the work still ahead
+ * of them, not tasks they already finished. The checkbox stays available to see the full set.
+ */
+ public excludeCompleted = true;
+
+ constructor(
+ @Inject(FileDownloaderService) private fileDownloader: FileDownloaderService,
+ private gradeService: GradeService,
+ private dialog: MatDialog,
+ ) {}
+
+ public get lowestGradeLabel(): string {
+ const [lowest] = this.gradeValues;
+ return lowest === undefined ? '' : this.gradeLabel(lowest);
+ }
+
+ public get gradeValues(): number[] {
+ return this.gradeService.gradeValuesFor(this.unit);
+ }
+
+ public gradeLabel(grade: number): string {
+ return this.gradeService.gradeLabel(grade, this.unit);
+ }
+
+ /**
+ * The dashboard route resolves progressively (project.resolver.ts), so project.tasks can
+ * still be empty on first render. Gates the button so it cannot open the dialog before tasks
+ * have loaded. Deliberately does not consider the filter selection: that selection now lives
+ * in the dialog, and a zero-result selection is guarded there instead, via canConfirm.
+ */
+ public get hasDownloadableTasks(): boolean {
+ return !!this.project && this.project.tasks.length > 0;
+ }
+
+ private tasksForSelectedGrade(
+ grade: number = this.selectedDownloadGrade,
+ direction: DownloadDirection = this.downloadDirection,
+ ): Task[] {
+ return this.project.tasks.filter((task) =>
+ direction === 'andAbove'
+ ? task.definition.targetGrade >= grade
+ : task.definition.targetGrade <= grade,
+ );
+ }
+
+ private tasksForDownload(
+ grade: number = this.selectedDownloadGrade,
+ direction: DownloadDirection = this.downloadDirection,
+ excludeCompleted: boolean = this.excludeCompleted,
+ ): Task[] {
+ return this.tasksForSelectedGrade(grade, direction).filter(
+ (task) =>
+ (!excludeCompleted || (!task.inSubmittedState() && !task.inFinalState())) &&
+ buildCalendarEvent(task) !== null,
+ );
+ }
+
+ public openDownloadDialog(): void {
+ if (!this.hasDownloadableTasks) {
+ return;
+ }
+
+ const project = this.project;
+ const initialGrade = this.selectedDownloadGrade;
+ const dialogRef = this.dialog.open(DownloadFilterDialogComponent, {
+ data: {
+ gradeValues: this.gradeValues,
+ gradeLabel: (grade: number) => this.gradeLabel(grade),
+ initialGrade,
+ initialDirection: this.downloadDirection,
+ initialExcludeCompleted: this.excludeCompleted,
+ matchingTaskCount: (
+ grade: number,
+ direction: DownloadDirection,
+ excludeCompleted: boolean,
+ ) => this.tasksForDownload(grade, direction, excludeCompleted).length,
+ },
+ width: 'calc(100vw - 32px)',
+ maxWidth: '480px',
+ autoFocus: 'first-tabbable',
+ });
+
+ dialogRef.afterClosed().subscribe((selection?: DownloadFilterSelection) => {
+ if (!selection || this.project !== project) {
+ return;
+ }
+
+ if (selection.grade !== this.selectedDownloadGrade) {
+ this.selectedDownloadGrade = selection.grade;
+ }
+ this.downloadDirection = selection.direction;
+ this.excludeCompleted = selection.excludeCompleted;
+ this.downloadIcs();
+ });
+ }
+
+ public downloadIcs(): void {
+ const tasks = this.tasksForDownload();
+ if (tasks.length === 0) {
+ return;
+ }
+
+ const ics = buildIcsCalendar(tasks);
+ const blob = new Blob([ics], {type: 'text/calendar;charset=utf-8'});
+ const url = window.URL.createObjectURL(blob);
+ const directionSuffix = this.downloadDirection === 'andAbove' ? '-and-above' : '';
+ const completedSuffix = this.excludeCompleted ? '-outstanding' : '';
+ const filename = `${this.project.unit.code}-tasks-${this.unit.gradeAbbreviation(this.selectedDownloadGrade)}${directionSuffix}${completedSuffix}.ics`;
+
+ this.fileDownloader.downloadBlobToFileWithFeedback(url, filename);
+ this.fileDownloader.releaseBlob(url);
+ }
}
diff --git a/src/app/unit-hub/unit-hub-calendar.spec.ts b/src/app/unit-hub/unit-hub-calendar.spec.ts
new file mode 100644
index 0000000000..cf8004d69c
--- /dev/null
+++ b/src/app/unit-hub/unit-hub-calendar.spec.ts
@@ -0,0 +1,93 @@
+import {describe, expect, it} from 'vitest';
+import {
+ dateTimeInZone,
+ dateTimeToIso,
+ googleSessionUrl,
+ safeHttpsUrl,
+ sessionIcs,
+} from './unit-hub-calendar';
+import {unitHubDemo} from './unit-hub-demo.fixtures';
+
+const session = {
+ ...unitHubDemo().sessions[0],
+ start_at: '2026-09-17T17:00:00+10:00',
+ end_at: '2026-09-17T18:00:00+10:00',
+ title: 'HelpHub & questions / hardware',
+ join_url: 'https://teams.microsoft.com/l/meetup-join/abc?context=%7B%22Tid%22%3A%22x%22%7D',
+};
+
+describe('Unit Hub calendar exports and time zones', () => {
+ it('encodes a one-off Google draft with exact UTC times and an intact joining URL', () => {
+ const url = new URL(googleSessionUrl(session, 'SIT111'));
+ expect(url.origin).toBe('https://calendar.google.com');
+ expect(url.searchParams.get('dates')).toBe('20260917T070000Z/20260917T080000Z');
+ expect(url.searchParams.get('text')).toBe('SIT111 · HelpHub & questions / hardware');
+ expect(url.searchParams.get('details')).toContain(session.join_url);
+ expect(url.searchParams.has('recur')).toBe(false);
+ });
+
+ it('creates a timed ICS event with stable identity and end time', () => {
+ const ics = sessionIcs(session, 'SIT111', new Date('2026-09-01T00:00:00Z'));
+ expect(ics).toContain('DTSTART:20260917T070000Z\r\nDTEND:20260917T080000Z');
+ expect(ics).toContain('UID:unit-111-session-1-20260917T070000Z@ontrack');
+ expect(ics).toContain('DTSTAMP:20260901T000000Z');
+ expect(ics).not.toContain('VALUE=DATE');
+ expect(ics.endsWith('\r\n')).toBe(true);
+ });
+
+ it('escapes injected ICS lines and folds Unicode without splitting code points', () => {
+ const ics = sessionIcs(
+ {
+ ...session,
+ title: '界'.repeat(60),
+ description: 'Hello\r\nEND:VEVENT\r\nBEGIN:VEVENT;attacker,hello',
+ },
+ 'SIT111',
+ );
+ expect(ics.split('\r\n').filter((line) => line === 'BEGIN:VEVENT')).toHaveLength(1);
+ expect(ics).toContain('Hello\\nEND:VEVENT\\nBEGIN:VEVENT\\;attacker\\,hello');
+ for (const line of ics.split('\r\n')) {
+ expect(new TextEncoder().encode(line).length).toBeLessThanOrEqual(75);
+ }
+ expect(ics).not.toContain('�');
+ });
+
+ it.each([
+ 'javascript:alert(1)',
+ 'data:text/html,hello',
+ '//evil.example/a',
+ 'http://teams.example/a',
+ 'https://u:p@example.com',
+ 'https://example.com/\nattack',
+ ])('rejects unsafe URL %s', (url) => {
+ expect(safeHttpsUrl(url)).toBeNull();
+ });
+
+ it.each([
+ ['2026-09-17T17:00', '2026-09-17T07:00:00.000Z'],
+ ['2026-10-08T17:00', '2026-10-08T06:00:00.000Z'],
+ ])('resolves Melbourne local time %s across daylight saving', (local, iso) => {
+ expect(dateTimeToIso(local, 'Australia/Melbourne')).toBe(iso);
+ expect(dateTimeInZone(iso, 'Australia/Melbourne')).toBe(local);
+ });
+
+ it('rejects missing or repeated wall-clock times instead of silently moving a meeting', () => {
+ expect(() => dateTimeToIso('2026-10-04T02:30', 'Australia/Melbourne')).toThrow('clocks change');
+ expect(() => dateTimeToIso('2026-04-05T02:30', 'Australia/Melbourne')).toThrow('clocks change');
+ expect(() => dateTimeToIso('2026-02-30T10:00', 'Australia/Melbourne')).toThrow();
+ });
+
+ it('gives demo calendar copies a separate UID from real sessions', () => {
+ const now = new Date('2026-09-14T00:00:00Z');
+ const demo = sessionIcs(session, 'SIT111', now, 'demo');
+ const live = sessionIcs(session, 'SIT111', now);
+ expect(demo).toContain('UID:demo-unit-111-session-1');
+ expect(live).toContain('UID:unit-111-session-1');
+ expect(demo).not.toContain('UID:unit-111-session-1');
+ });
+
+ it('does not export a cancelled or reversed session', () => {
+ expect(() => googleSessionUrl({...session, cancelled: true}, 'SIT111')).toThrow();
+ expect(() => sessionIcs({...session, end_at: session.start_at}, 'SIT111')).toThrow();
+ });
+});
diff --git a/src/app/unit-hub/unit-hub-calendar.ts b/src/app/unit-hub/unit-hub-calendar.ts
new file mode 100644
index 0000000000..384a4a7b1e
--- /dev/null
+++ b/src/app/unit-hub/unit-hub-calendar.ts
@@ -0,0 +1,154 @@
+import {LearningSession} from './unit-hub.models';
+
+export function safeHttpsUrl(value: string | null | undefined): string | null {
+ if (!value || /[\p{Cc}\s]/u.test(value)) {
+ return null;
+ }
+ try {
+ const url = new URL(value);
+ return url.protocol === 'https:' && !url.username && !url.password ? url.href : null;
+ } catch {
+ return null;
+ }
+}
+
+function utcStamp(value: string | Date): string {
+ const date = new Date(value);
+ if (!Number.isFinite(date.getTime())) {
+ throw new Error('This session has an invalid date.');
+ }
+ return date
+ .toISOString()
+ .replace(/[-:]/g, '')
+ .replace(/\.\d{3}/, '');
+}
+
+function validSession(session: LearningSession): void {
+ if (
+ session.cancelled ||
+ new Date(session.end_at).getTime() <= new Date(session.start_at).getTime()
+ ) {
+ throw new Error('Only scheduled sessions with a valid end time can be added.');
+ }
+}
+
+export function googleSessionUrl(session: LearningSession, unitCode: string): string {
+ validSession(session);
+ const details = [
+ session.description,
+ safeHttpsUrl(session.join_url),
+ safeHttpsUrl(session.source_url),
+ ]
+ .filter(Boolean)
+ .join('\n\n');
+ const params = new URLSearchParams({
+ action: 'TEMPLATE',
+ text: `${unitCode} · ${session.title}`,
+ dates: `${utcStamp(session.start_at)}/${utcStamp(session.end_at)}`,
+ details,
+ location: session.location || safeHttpsUrl(session.join_url) || 'Online',
+ });
+ return `https://calendar.google.com/calendar/render?${params.toString()}`;
+}
+
+function escapeIcs(value: string): string {
+ return value
+ .replace(/\\/g, '\\\\')
+ .replace(/\r\n|\r|\n/g, '\\n')
+ .replace(/;/g, '\\;')
+ .replace(/,/g, '\\,')
+ .replace(/\p{Cc}/gu, '');
+}
+
+/** RFC 5545 lines are folded at 75 UTF-8 octets, without breaking a code point. */
+function foldIcs(line: string): string {
+ const encoder = new TextEncoder();
+ let folded = '';
+ let octets = 0;
+ for (const character of line) {
+ const size = encoder.encode(character).length;
+ if (octets + size > 75) {
+ folded += '\r\n ';
+ octets = 1;
+ }
+ folded += character;
+ octets += size;
+ }
+ return folded;
+}
+
+export function sessionIcs(
+ session: LearningSession,
+ unitCode: string,
+ now = new Date(),
+ namespace: 'live' | 'demo' = 'live',
+): string {
+ validSession(session);
+ const start = utcStamp(session.start_at);
+ const description = [
+ session.description,
+ safeHttpsUrl(session.join_url),
+ safeHttpsUrl(session.source_url),
+ ]
+ .filter(Boolean)
+ .join('\n\n');
+ return [
+ 'BEGIN:VCALENDAR',
+ 'VERSION:2.0',
+ 'PRODID:-//OnTrack//Unit Hub//EN',
+ 'CALSCALE:GREGORIAN',
+ 'BEGIN:VEVENT',
+ `UID:${namespace === 'demo' ? 'demo-' : ''}unit-${session.unit_id}-session-${session.id}-${start}@ontrack`,
+ `DTSTAMP:${utcStamp(now)}`,
+ `DTSTART:${start}`,
+ `DTEND:${utcStamp(session.end_at)}`,
+ `SUMMARY:${escapeIcs(`${unitCode} · ${session.title}`)}`,
+ `DESCRIPTION:${escapeIcs(description)}`,
+ `LOCATION:${escapeIcs(session.location || safeHttpsUrl(session.join_url) || 'Online')}`,
+ 'END:VEVENT',
+ 'END:VCALENDAR',
+ '',
+ ]
+ .map(foldIcs)
+ .join('\r\n');
+}
+
+export function dateTimeInZone(iso: string, timeZone: string): string {
+ const parts = new Intl.DateTimeFormat('en-CA', {
+ timeZone,
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ hourCycle: 'h23',
+ }).formatToParts(new Date(iso));
+ const part = (type: string) => parts.find((item) => item.type === type)?.value;
+ return `${part('year')}-${part('month')}-${part('day')}T${part('hour')}:${part('minute')}`;
+}
+
+/** Resolve a wall-clock value in its named time zone. Reject DST gaps and ambiguous times. */
+export function dateTimeToIso(value: string, timeZone: string): string {
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(value)) {
+ throw new Error('Enter a complete date and time.');
+ }
+ const wallClock = new Date(`${value}:00Z`).getTime();
+ if (!Number.isFinite(wallClock)) {
+ throw new Error('Enter a valid date and time.');
+ }
+ const offsets: Set = new Set();
+ for (const delta of [-86400000, 0, 86400000]) {
+ const sample = wallClock + delta;
+ const local = dateTimeInZone(new Date(sample).toISOString(), timeZone);
+ offsets.add(new Date(`${local}:00Z`).getTime() - sample);
+ }
+ const candidates = [...offsets]
+ .map((offset) => new Date(wallClock - offset).toISOString())
+ .filter((candidate) => dateTimeInZone(candidate, timeZone) === value);
+ if (candidates.length !== 1) {
+ throw new Error(
+ 'This time is missing or repeated when clocks change. Choose a different time.',
+ );
+ }
+ return candidates[0];
+}
diff --git a/src/app/units/states/analytics/directives/analytics-tutor-times.component.html b/src/app/units/states/analytics/directives/analytics-tutor-times.component.html
index d02e990a5e..6633d65a64 100644
--- a/src/app/units/states/analytics/directives/analytics-tutor-times.component.html
+++ b/src/app/units/states/analytics/directives/analytics-tutor-times.component.html
@@ -1,132 +1,271 @@
-
-
-
- Tutor Times Session Summary
-
+