On a client site, a party-house booking platform with an availability calendar and an owner dashboard, there was one interaction that looked trivial. A property owner clicks a single date cell to mark it "blocked", meaning unavailable. One click, one date toggled. That was the whole mental model when I built it.
In practice, though, every time an owner toggled a single date, every existing block on that calendar vanished with it. They would block July 20, and the July 5 to 12 range they had blocked earlier would suddenly clear. Each toggle behaved as if the calendar were empty, then overwrote everything.
There was a second symptom that made me more suspicious. When the calendar first loaded, every cell rendered as if nothing was blocked at all, even though the database clearly held stored date ranges. So both sides were broken. Writing threw away old data, and reading never showed the data that was there.
Tracing it
Since read and write were both wrong, I stopped guessing and opened the Network tab. I clicked one cell and looked at the payload dashboard.js was sending:
{ "date": "2026-07-20", "blocked": true }Clean, and reasonable for a single-cell click. Then I checked the REST endpoint receiving it. Here the first crack showed. The only endpoint that existed demanded a full range payload, {blocked_dates: [{start,end}, ...]}, not a per-cell {date, blocked}. The handler accepted my request, found no blocked_dates field, treated it as an empty array, and saved that empty array. That was the wipe.
But there was a subtler second layer. I looked at how the handler read the old state before writing:
// BEFORE: the meta treated as a PHP array
$ranges = get_post_meta( $id, '_pc_blocked_dates', true );
foreach ( $ranges as $r ) {
// ... never runs
}get_post_meta( ..., true ) here returns a JSON string, not an array. Whatever wrote this meta used wp_json_encode, so the stored shape really is a string. But toggle_blocked_date in class-pc-rest-availability.php read it as if it were a PHP array and ran foreach straight on it. Iterating over a string yields no ranges, so the handler was convinced there was nothing to preserve. Even if the payload shape had been right, this bad read would still make every write start from zero.
I had once written about wp_slash corrupting JSON in post meta, so my first reflex went there. But this time the backslashes were fine. The JSON in the database was valid and decoded cleanly. The problem was not corrupted data, it was code that forgot to json_decode a perfectly valid string.
The empty-calendar symptom turned out to be a third branch of the same root. The GET reader in dashboard.js received [{start,end}] ranges but treated them as a flat list of date strings, then called blockedDates.has(dateStr). Since the Set held range objects rather than single date strings, has() never matched, and every cell rendered as unblocked.
The root cause: two-sided contract drift
There was no single evil line here. What happened was the contract between JavaScript and REST drifting at three points at once. First, dashboard.js sent {date, blocked} per cell while the endpoint only understood a full blocked_dates array. Second, the handler read the JSON string meta as a PHP array without decoding, so it always started from an empty state. Third, the client reader equated {start,end} ranges with single date strings, so the has() check could never match. Each is small, but they stacked into "any toggle deletes everything".
The fix
I added an honest per-cell endpoint, POST /properties/{id}/availability/toggle, that accepts {date, blocked}, and I fixed the data flow end to end:
public function toggle_blocked_date( $request ) {
$id = (int) $request['id'];
$date = sanitize_text_field( $request['date'] );
$blocked = (bool) $request['blocked'];
// The meta is stored as a JSON string, not a PHP array.
$existing_raw = get_post_meta( $id, '_pc_blocked_dates', true );
$ranges = $existing_raw ? json_decode( $existing_raw, true ) : array();
// Flatten {start,end} ranges into single days, mutate, then coalesce back.
$days = pc_expand_ranges( $ranges );
if ( $blocked ) {
$days[ $date ] = true;
} else {
unset( $days[ $date ] );
}
$ranges = pc_coalesce_days( array_keys( $days ) );
update_post_meta( $id, '_pc_blocked_dates', wp_json_encode( $ranges ) );
return rest_ensure_response( array( 'blocked_dates' => $ranges ) );
}The line-by-line intent: json_decode on read, flatten ranges to single days so mutating one date is easy, coalesce contiguous days back into ranges, then wp_json_encode on write. On the client, dashboard.js points at the new toggle endpoint and expands GET ranges into date strings for the has() check:
async function toggleDate(propertyId, dateStr, blocked) {
await fetch(`/wp-json/pc/v1/properties/${propertyId}/availability/toggle`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': pcNonce },
body: JSON.stringify({ date: dateStr, blocked }),
});
}
// GET reader: expand {start,end} into date strings for .has()
const blockedDates = new Set();
for (const range of data.blocked_dates) {
for (let d = new Date(range.start); d <= new Date(range.end); d.setDate(d.getDate() + 1)) {
blockedDates.add(d.toISOString().slice(0, 10));
}
}I left the old full-array endpoint alive for bulk edits. Since both store the same JSON shape, reads stay consistent no matter where the data was written from.
Checklist
- When read and write are both wrong, suspect contract drift between client and API, not one lone bug.
- Meta written with
wp_json_encodeis a string. On read, alwaysjson_decode, neverforeachit directly. - Match the payload shape to what the endpoint expects. A single-cell click needs a per-cell endpoint, not a full-array one.
- Flatten ranges to single days for mutation and
Set.has()checks, then coalesce back into ranges when saving. - This is not the
wp_slashbug. Here the JSON was valid; only the decode was missing. Verify by reading the real payload in Network, not by guessing.
