@chrischall/pickuppatrol-mcp 0.0.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "PickUp Patrol school-dismissal tools for Claude Code",
9
- "version": "0.0.0"
9
+ "version": "0.1.1"
10
10
  },
11
11
  "plugins": [
12
12
  {
@@ -14,7 +14,7 @@
14
14
  "displayName": "PickUp Patrol",
15
15
  "source": "./",
16
16
  "description": "Read and change your children's school dismissal plans in PickUp Patrol — defaults, day-by-day changes and school cutoff times — via MCP",
17
- "version": "0.0.0",
17
+ "version": "0.1.1",
18
18
  "author": {
19
19
  "name": "Chris Chall"
20
20
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pickuppatrol",
3
3
  "displayName": "PickUp Patrol",
4
- "version": "0.0.0",
4
+ "version": "0.1.1",
5
5
  "description": "Read and change your children's school dismissal plans in PickUp Patrol — defaults, day-by-day changes and school cutoff times — via MCP",
6
6
  "author": {
7
7
  "name": "Chris Chall"
package/README.md CHANGED
@@ -16,7 +16,8 @@ no captcha. The server signs in with your own email and password and holds the
16
16
  resulting session in memory.
17
17
 
18
18
  Every request shape is captured in [`docs/PICKUPPATROL-API.md`](docs/PICKUPPATROL-API.md),
19
- read off the shipped client rather than guessed.
19
+ read off the shipped client rather than guessed, and exercised against a real
20
+ account — reads, a write, and its restore.
20
21
 
21
22
  ## Install
22
23
 
@@ -78,10 +79,12 @@ a validation message rather than an opaque 400.
78
79
 
79
80
  **Writes are verified by re-reading.** A 2xx from PickUp Patrol is not proof: a
80
81
  change made after the school's cutoff is accepted and silently ignored. Every
81
- write re-reads the affected dates and compares the transportation id — never
82
- `ModifiedDate`, which advances on its own and would make every write look
83
- successful. The result says `verified: true/false`, and names the dates that did
84
- not move.
82
+ write re-reads the affected dates and compares the transportation id **and the
83
+ note** — never `ModifiedDate`, which advances on its own and would make every
84
+ write look successful. The note is part of the proof because every dismissal
85
+ option seen so far requires one, which makes a note-only edit ordinary: an
86
+ id-only comparison would report success from a field that never had to move.
87
+ The result says `verified: true/false`, and names the dates that did not move.
85
88
 
86
89
  ## Without the MCP server
87
90
 
@@ -100,6 +103,18 @@ npm run test:coverage # coverage-enforced at 100%
100
103
  Tests never touch the network: the transport is injected, and the MCP tools run
101
104
  through a real in-memory client/server pair.
102
105
 
106
+ ## Two things about the API worth knowing
107
+
108
+ **`GetPlanEdit` returns a date's override, not the effective plan.** A date with
109
+ no specific plan reads back `TransportationId: null` even when the student has a
110
+ weekly default for that weekday. `pup_get_plan` passes that through as-is;
111
+ `pup_list_students` is where the weekly defaults live.
112
+
113
+ **Sign-in is a session cookie, not a JWT.** `Authenticate` returns
114
+ `BearerToken: null` on this deployment and sets `ss-id`/`ss-pid`/`ss-opt`. The
115
+ client keeps whichever the server returns and sends both, so a future switch to
116
+ JWTs needs no change here.
117
+
103
118
  ## Safety notes
104
119
 
105
120
  - A rejected sign-in is **never retried**. PickUp Patrol counts failed attempts
package/dist/bundle.js CHANGED
@@ -31503,7 +31503,7 @@ var PickUpPatrolClient = class {
31503
31503
  var client = new PickUpPatrolClient();
31504
31504
 
31505
31505
  // src/version.ts
31506
- var VERSION = "0.0.0";
31506
+ var VERSION = "0.1.1";
31507
31507
 
31508
31508
  // src/dates.ts
31509
31509
  var WEEKDAY_NAMES = [
@@ -31878,12 +31878,11 @@ Hint: ${err.hint}`);
31878
31878
  }
31879
31879
 
31880
31880
  // src/tools/plans.ts
31881
- function expectedTransportationId(student, planDate, requested) {
31882
- if (requested !== null) return requested;
31883
- const dayId = dateToDayId(planDate);
31884
- if (dayId === null) return void 0;
31885
- const fallback = (student.DefaultPlans ?? []).find((plan) => plan.DayId === dayId);
31886
- return fallback === void 0 ? void 0 : fallback.TransportationId ?? null;
31881
+ function proofsMatch(a, b) {
31882
+ return a.transportationId === b.transportationId && (a.note ?? "").trim() === (b.note ?? "").trim();
31883
+ }
31884
+ function expectedPlanState(requested) {
31885
+ return requested.transportationId === null ? { transportationId: null, note: null } : requested;
31887
31886
  }
31888
31887
  async function resolveTransportation(client2, schoolId, transportationId) {
31889
31888
  const options = await client2.getTransportations(schoolId);
@@ -31951,24 +31950,30 @@ function registerPlanTools(server, client2) {
31951
31950
  const gate = previewUnlessConfirmed(confirm, action, "PUT", "UpdatePlans", { Plans: plans });
31952
31951
  if (gate) return gate;
31953
31952
  await client2.updatePlans(plans);
31953
+ const expected = expectedPlanState({
31954
+ transportationId: transportation_id,
31955
+ note: plans[0]?.Note ?? null
31956
+ });
31954
31957
  const verification = await Promise.all(
31955
31958
  dates.map(async (date5) => {
31956
31959
  const after = await client2.getPlanEdit(date5, student_id);
31957
- const expected = expectedTransportationId(student, date5, transportation_id);
31958
- const actual = after.TransportationId ?? null;
31960
+ const actual = {
31961
+ transportationId: after.TransportationId ?? null,
31962
+ note: after.Note ?? null
31963
+ };
31959
31964
  return {
31960
31965
  date: date5,
31961
31966
  weekday: weekdayOf(date5),
31962
- transportationId: actual,
31967
+ transportationId: actual.transportationId,
31963
31968
  transportation: after.TransportationName ?? null,
31964
- note: after.Note ?? null,
31969
+ note: actual.note,
31965
31970
  earlyDismissalTime: after.EarlyDismissalTime ?? null,
31966
31971
  locked: after.IsLocked ?? false,
31967
- verified: expected === void 0 ? null : actual === expected
31972
+ verified: proofsMatch(actual, expected)
31968
31973
  };
31969
31974
  })
31970
31975
  );
31971
- const failed = verification.filter((v) => v.verified === false);
31976
+ const failed = verification.filter((v) => !v.verified);
31972
31977
  return textResult({
31973
31978
  action,
31974
31979
  applied: verification,
@@ -32093,10 +32098,14 @@ function registerDefaultPlanTools(server, client2) {
32093
32098
  if (gate) return gate;
32094
32099
  await client2.updateStudent(payload);
32095
32100
  const after = await client2.getStudent(student_id);
32096
- const changed = new Set(dayIds);
32097
- const unchanged = [...changed].filter((dayId) => {
32101
+ const sentNote = payload.DefaultPlans?.find((p) => p.DayId === dayIds[0])?.Note ?? null;
32102
+ const unchanged = [...new Set(dayIds)].filter((dayId) => {
32098
32103
  const plan = (after.DefaultPlans ?? []).find((p) => p.DayId === dayId);
32099
- return plan?.TransportationId !== transportation.TransportationId;
32104
+ if (plan === void 0) return true;
32105
+ return !proofsMatch(
32106
+ { transportationId: plan.TransportationId ?? null, note: plan.Note ?? null },
32107
+ { transportationId: transportation.TransportationId, note: sentNote }
32108
+ );
32100
32109
  });
32101
32110
  return textResult({
32102
32111
  action,
package/dist/lib.d.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  export { PickUpPatrolClient, type ClientOptions } from './client.js';
7
7
  export { PickUpPatrolAuth, describeResponseStatus, collectCookieHeader, BASE_URL, BASE_PATH, type AuthOptions, type PupSession, type FetchLike, } from './auth.js';
8
8
  export { buildPlanUpdates, applyDefaultPlans, clearDefaultPlans, assertTransportationAllowed, normalizeNote, normalizeEarlyDismissal, normalizeCarNumber, DEFAULT_PLAN_LABEL, type PlanInput, type DefaultPlanInput, } from './plans.js';
9
- export { dayIdToName, nameToDayId, dateToDayId, WEEKDAY_NAMES, type WeekdayName } from './dates.js';
9
+ export { expectedPlanState, proofsMatch, resolveTransportation, type PlanProof, } from './tools/plans.js';
10
+ export { dayIdToName, nameToDayId, dateToDayId, weekdayOf, WEEKDAY_NAMES, type WeekdayName } from './dates.js';
10
11
  export { VERSION } from './version.js';
11
12
  export type * from './types.js';
package/dist/lib.js CHANGED
@@ -6,5 +6,6 @@
6
6
  export { PickUpPatrolClient } from './client.js';
7
7
  export { PickUpPatrolAuth, describeResponseStatus, collectCookieHeader, BASE_URL, BASE_PATH, } from './auth.js';
8
8
  export { buildPlanUpdates, applyDefaultPlans, clearDefaultPlans, assertTransportationAllowed, normalizeNote, normalizeEarlyDismissal, normalizeCarNumber, DEFAULT_PLAN_LABEL, } from './plans.js';
9
- export { dayIdToName, nameToDayId, dateToDayId, WEEKDAY_NAMES } from './dates.js';
9
+ export { expectedPlanState, proofsMatch, resolveTransportation, } from './tools/plans.js';
10
+ export { dayIdToName, nameToDayId, dateToDayId, weekdayOf, WEEKDAY_NAMES } from './dates.js';
10
11
  export { VERSION } from './version.js';
@@ -3,7 +3,7 @@ import { McpToolError, textResult } from '@chrischall/mcp-utils';
3
3
  import { applyDefaultPlans, clearDefaultPlans } from '../plans.js';
4
4
  import { dayIdToName, nameToDayId } from '../dates.js';
5
5
  import { summarizeDefaultPlans } from './account.js';
6
- import { resolveTransportation } from './plans.js';
6
+ import { proofsMatch, resolveTransportation } from './plans.js';
7
7
  import { previewUnlessConfirmed, schemaConfirm } from './_confirm.js';
8
8
  import { withHints } from './_errors.js';
9
9
  /**
@@ -125,13 +125,19 @@ export function registerDefaultPlanTools(server, client) {
125
125
  if (gate)
126
126
  return gate;
127
127
  await client.updateStudent(payload);
128
- // Re-read and check the weekdays we changed actually hold the new
129
- // option. DefaultsModifiedDate is not compared — it advances by itself.
128
+ // Re-read and check the weekdays we changed actually hold the new option
129
+ // AND the new note. The note alone is a normal edit here — every option at
130
+ // this school requires one — so comparing the id by itself would report
131
+ // success without observing the change. DefaultsModifiedDate stays out of
132
+ // the comparison entirely: it advances by itself, which would make every
133
+ // write look successful.
130
134
  const after = await client.getStudent(student_id);
131
- const changed = new Set(dayIds);
132
- const unchanged = [...changed].filter((dayId) => {
135
+ const sentNote = payload.DefaultPlans?.find((p) => p.DayId === dayIds[0])?.Note ?? null;
136
+ const unchanged = [...new Set(dayIds)].filter((dayId) => {
133
137
  const plan = (after.DefaultPlans ?? []).find((p) => p.DayId === dayId);
134
- return plan?.TransportationId !== transportation.TransportationId;
138
+ if (plan === undefined)
139
+ return true;
140
+ return !proofsMatch({ transportationId: plan.TransportationId ?? null, note: plan.Note ?? null }, { transportationId: transportation.TransportationId, note: sentNote });
135
141
  });
136
142
  return textResult({
137
143
  action,
@@ -1,15 +1,28 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import type { PickUpPatrolClient } from '../client.js';
3
- import type { Student, Transportation } from '../types.js';
3
+ import type { Transportation } from '../types.js';
4
+ /** The fields whose change proves a plan write actually landed. */
5
+ export interface PlanProof {
6
+ transportationId: number | null;
7
+ note: string | null;
8
+ }
9
+ /** Compare two proofs, ignoring whitespace the service may normalise off a note. */
10
+ export declare function proofsMatch(a: PlanProof, b: PlanProof): boolean;
4
11
  /**
5
- * The transportation id a date should show once a write has landed.
12
+ * What `GetPlanEdit` should report once a write has landed.
6
13
  *
7
- * For a normal change that is the id we asked for. For a revert
8
- * (`TransportationId: null`) the date falls back to the student's default for
9
- * that weekday, so the id to expect is that default's — and when the student
10
- * has no default for the day there is nothing to assert, hence `undefined`.
14
+ * Deliberately more than the transportation id: every dismissal option at the
15
+ * schools seen so far requires a note, so changing only the note is an ordinary
16
+ * edit — and an id-only comparison would report success without ever observing
17
+ * it. Same false-green as diffing a field that cannot move.
18
+ *
19
+ * A revert expects an EMPTY slot, not the weekday default. `GetPlanEdit`
20
+ * reports the date's override, not the effective plan: verified live on a
21
+ * Friday where the student had a Friday default and the date still read back
22
+ * `TransportationId: null`. Expecting the default here would report every
23
+ * successful revert as a failure.
11
24
  */
12
- export declare function expectedTransportationId(student: Student, planDate: string, requested: number | null): number | null | undefined;
25
+ export declare function expectedPlanState(requested: PlanProof): PlanProof;
13
26
  /** Resolve a transportation id against the school's list, or fail with the options. */
14
27
  export declare function resolveTransportation(client: PickUpPatrolClient, schoolId: number, transportationId: number): Promise<Transportation>;
15
28
  export declare function registerPlanTools(server: McpServer, client: PickUpPatrolClient): void;
@@ -1,25 +1,31 @@
1
1
  import { z } from 'zod';
2
2
  import { McpToolError, textResult } from '@chrischall/mcp-utils';
3
3
  import { buildPlanUpdates } from '../plans.js';
4
- import { dateToDayId, weekdayOf } from '../dates.js';
4
+ import { weekdayOf } from '../dates.js';
5
5
  import { previewUnlessConfirmed, schemaConfirm } from './_confirm.js';
6
6
  import { withHints } from './_errors.js';
7
+ /** Compare two proofs, ignoring whitespace the service may normalise off a note. */
8
+ export function proofsMatch(a, b) {
9
+ return a.transportationId === b.transportationId && (a.note ?? '').trim() === (b.note ?? '').trim();
10
+ }
7
11
  /**
8
- * The transportation id a date should show once a write has landed.
12
+ * What `GetPlanEdit` should report once a write has landed.
13
+ *
14
+ * Deliberately more than the transportation id: every dismissal option at the
15
+ * schools seen so far requires a note, so changing only the note is an ordinary
16
+ * edit — and an id-only comparison would report success without ever observing
17
+ * it. Same false-green as diffing a field that cannot move.
9
18
  *
10
- * For a normal change that is the id we asked for. For a revert
11
- * (`TransportationId: null`) the date falls back to the student's default for
12
- * that weekday, so the id to expect is that default's — and when the student
13
- * has no default for the day there is nothing to assert, hence `undefined`.
19
+ * A revert expects an EMPTY slot, not the weekday default. `GetPlanEdit`
20
+ * reports the date's override, not the effective plan: verified live on a
21
+ * Friday where the student had a Friday default and the date still read back
22
+ * `TransportationId: null`. Expecting the default here would report every
23
+ * successful revert as a failure.
14
24
  */
15
- export function expectedTransportationId(student, planDate, requested) {
16
- if (requested !== null)
17
- return requested;
18
- const dayId = dateToDayId(planDate);
19
- if (dayId === null)
20
- return undefined;
21
- const fallback = (student.DefaultPlans ?? []).find((plan) => plan.DayId === dayId);
22
- return fallback === undefined ? undefined : (fallback.TransportationId ?? null);
25
+ export function expectedPlanState(requested) {
26
+ return requested.transportationId === null
27
+ ? { transportationId: null, note: null }
28
+ : requested;
23
29
  }
24
30
  /** Resolve a transportation id against the school's list, or fail with the options. */
25
31
  export async function resolveTransportation(client, schoolId, transportationId) {
@@ -102,22 +108,30 @@ export function registerPlanTools(server, client) {
102
108
  // compare the one field that proves it. ModifiedDate is deliberately not
103
109
  // compared: it advances on its own, which would make every write look
104
110
  // successful.
111
+ // One expectation for the whole call: every date in a single UpdatePlans
112
+ // gets the same option and note, so this does not vary per date.
113
+ const expected = expectedPlanState({
114
+ transportationId: transportation_id,
115
+ note: plans[0]?.Note ?? null,
116
+ });
105
117
  const verification = await Promise.all(dates.map(async (date) => {
106
118
  const after = await client.getPlanEdit(date, student_id);
107
- const expected = expectedTransportationId(student, date, transportation_id);
108
- const actual = after.TransportationId ?? null;
119
+ const actual = {
120
+ transportationId: after.TransportationId ?? null,
121
+ note: after.Note ?? null,
122
+ };
109
123
  return {
110
124
  date,
111
125
  weekday: weekdayOf(date),
112
- transportationId: actual,
126
+ transportationId: actual.transportationId,
113
127
  transportation: after.TransportationName ?? null,
114
- note: after.Note ?? null,
128
+ note: actual.note,
115
129
  earlyDismissalTime: after.EarlyDismissalTime ?? null,
116
130
  locked: after.IsLocked ?? false,
117
- verified: expected === undefined ? null : actual === expected,
131
+ verified: proofsMatch(actual, expected),
118
132
  };
119
133
  }));
120
- const failed = verification.filter((v) => v.verified === false);
134
+ const failed = verification.filter((v) => !v.verified);
121
135
  return textResult({
122
136
  action,
123
137
  applied: verification,
package/dist/version.d.ts CHANGED
@@ -7,4 +7,4 @@
7
7
  * annotation, so there is exactly one line to keep in sync and
8
8
  * `versionSyncTest` has exactly one line to check.
9
9
  */
10
- export declare const VERSION = "0.0.0";
10
+ export declare const VERSION = "0.1.1";
package/dist/version.js CHANGED
@@ -7,4 +7,4 @@
7
7
  * annotation, so there is exactly one line to keep in sync and
8
8
  * `versionSyncTest` has exactly one line to check.
9
9
  */
10
- export const VERSION = '0.0.0'; // x-release-please-version
10
+ export const VERSION = '0.1.1'; // x-release-please-version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrischall/pickuppatrol-mcp",
3
- "version": "0.0.0",
3
+ "version": "0.1.1",
4
4
  "license": "MIT",
5
5
  "mcpName": "io.github.chrischall/pickuppatrol-mcp",
6
6
  "description": "PickUp Patrol MCP server for Claude — developed and maintained by AI (Claude Code)",
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/chrischall/pickuppatrol-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "0.0.0",
9
+ "version": "0.1.1",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@chrischall/pickuppatrol-mcp",
14
- "version": "0.0.0",
14
+ "version": "0.1.1",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  }
@@ -28,6 +28,8 @@ pup_login() {
28
28
  -o /tmp/pup_auth.json -w '%{http_code}' >/tmp/pup_code
29
29
  [ "$(cat /tmp/pup_code)" = 200 ] || { jq -r '.ResponseStatus.Message' /tmp/pup_auth.json >&2; return 1; }
30
30
  export PUP_JAR="$jar"
31
+ # This deployment returns cookies only — BearerToken comes back null. The
32
+ # JWT path exists server-side, so keep a token if one ever appears.
31
33
  PUP_TOKEN=$(jq -r '.BearerToken // empty' /tmp/pup_auth.json); export PUP_TOKEN
32
34
  }
33
35
 
@@ -60,6 +62,8 @@ pup GET GetChildren | jq '.[] | select(.StudentId==1050046) | .DefaultPlans
60
62
  pup GET "GetTransportations?SchoolId=1703" | jq -r '.[] | select(.IsActive)
61
63
  | "\(.TransportationId)\t\(.Name)\tnote=\(.IsNoteRequired)\tcar=\(.UseCarNumbers)\tearly=\(.IsEarlyDismissal)"'
62
64
 
65
+ # GetPlanEdit returns the date's OVERRIDE, not the effective plan: a date with
66
+ # no specific plan reads back null even when a weekday default exists.
63
67
  pup GET "GetPlanEdit?PlanDate=2026-08-17&StudentId=1050046" | jq '{TransportationName,Note,IsLocked}'
64
68
  pup GET "GetInvalidPlanDates?SchoolId=1703" | jq 'length' # non-school days
65
69
  ```
@@ -103,5 +107,8 @@ A 2xx is not proof. The school silently ignores a change past its cutoff time.
103
107
  pup GET "GetPlanEdit?PlanDate=2026-08-17&StudentId=1050046" | jq '.TransportationId'
104
108
  ```
105
109
 
106
- Compare `TransportationId` — **not** `ModifiedDate`, which advances on its own
107
- and would make every write look successful.
110
+ Compare `TransportationId` **and `Note`** — never `ModifiedDate`, which advances
111
+ on its own and would make every write look successful. The note matters: every
112
+ option seen so far is `IsNoteRequired`, so a note-only edit is ordinary, and an
113
+ id-only comparison would pass without observing it. After *clearing* a date,
114
+ expect `null` — not the weekday default.
@@ -6,8 +6,9 @@ token value is recorded.
6
6
 
7
7
  - Base: `https://app.pickuppatrol.net/api/json/reply/<DtoName>`
8
8
  - GET args on the query string; POST/PUT/PATCH args in a JSON body
9
- - Auth: session cookies from `Authenticate`, plus `Authorization: Bearer <jwt>`
10
- when the deployment issues one
9
+ - Auth: session cookies from `Authenticate` (`ss-id`, `ss-pid`, `ss-opt`). This
10
+ deployment returns `BearerToken: null`; send `Authorization: Bearer …` too if
11
+ one ever appears
11
12
  - Errors: ServiceStack `{"ResponseStatus":{"ErrorCode","Message","Errors":[…]}}`;
12
13
  an unauthenticated call to any authorised DTO is a bare `401`
13
14
 
@@ -21,7 +22,8 @@ curl -sS -c jar -X POST "$PUP/Authenticate" -H 'Content-Type: application/json'
21
22
  ```
22
23
 
23
24
  Response: `{UserId, SessionId, UserName, DisplayName, BearerToken, RefreshToken,
24
- Roles, Permissions, ResponseStatus}`.
25
+ Roles, Permissions, ResponseStatus}` — `BearerToken` and `RefreshToken` come
26
+ back null here; the session is the cookies.
25
27
 
26
28
  Two-factor accounts (`User.OtpTypeId` set) need `POST /CreateOtp {OtpTypeId}`
27
29
  then `PUT /VerifyOtp {Otp, RememberMe}`. Token refresh is
@@ -85,6 +87,11 @@ TransportationName SchoolName BusRouteUrl ValidationErrors EarlyDismissalTime
85
87
  CarNumber LimitedIds IsNotePrivate
86
88
  ```
87
89
 
90
+ Returns the date's **override**, not the effective plan: a date with no specific
91
+ plan reads back `TransportationId: null` even when the student has a weekly
92
+ default for that weekday. Merge with `GetStudent(...).DefaultPlans` yourself to
93
+ know what actually happens on a date.
94
+
88
95
  ## Write DTOs
89
96
 
90
97
  | DTO | Verb | Body |
@@ -159,10 +166,16 @@ dropped field is a field cleared.
159
166
 
160
167
  ## Verifying a write
161
168
 
162
- Re-read and compare the field that proves the change:
169
+ Re-read and compare the fields that prove the change — the transportation id
170
+ **and the note**. Every option seen so far is `IsNoteRequired`, so a note-only
171
+ edit is an ordinary change and an id-only comparison would pass without ever
172
+ observing it.
173
+
174
+ - one-off plan → `GetPlanEdit(PlanDate, StudentId)` → `.TransportationId`, `.Note`
175
+ - weekly defaults → `GetStudent(StudentId).DefaultPlans[]` → `.TransportationId`, `.Note`
163
176
 
164
- - one-off plan → `GetPlanEdit(PlanDate, StudentId).TransportationId`
165
- - weekly defaults → `GetStudent(StudentId).DefaultPlans[].TransportationId`
177
+ After *clearing* a date, expect `TransportationId: null` — not the weekday
178
+ default, which `GetPlanEdit` does not merge in.
166
179
 
167
180
  Exclude `ModifiedDate` and `DefaultsModifiedDate`: they advance on their own, so
168
181
  including them makes every write look successful.