promotional banner

Classic Calendar - Revived

Forking and maintaining the Classic Calendar addon for WoW Classic
Back to Files

ClassicCalendar-v1.6.7

File nameClassicCalendar-ClassicCalendar-v1.6.7.zip
Uploader
PmptastyPmptasty
Uploaded
Sep 14, 2026
Downloads
2.5K
Size
443.2 KB
Flavors
Classic TBCClassic
File ID
8876530
Type
R
Release
Supported game versions
  • 2.5.6
  • 1.15.9

What's new

Classic Calendar

[v1.6.7] (2026-09-12)

Login freeze: the hidden calendar redrew its whole month grid every time ANY addon moved the month

  • Fixed a 7-8 second single-frame stall at login, attributed by the client's profiler to AllTheThings, that was almost entirely this addon's redraw. Measured in game with per-call timers (peer-review 2026-09-12): 69 calls to CalendarFrame_Update totalling 16,146 ms, every one inside an ATT C_Calendar.SetMonth / SetAbsMonth call (16,149 ms of month changes against 64 ms of calendar reads), with the calendar window never open. Two facts established: the client dispatches CALENDAR_UPDATE_EVENT_LIST synchronously, inside SetMonth, to every registered frame -- so a handler's cost is paid by the caller and blamed on the caller -- and one CalendarFrame_Update cost 230-560 ms on Era.
  • Harness pin moved ca46bbf -> 55b0c88. Adopts 42d606b's three deliveries against this addon's 2026-09-08 contracts: the inert OnSizeChanged mixin bodies and the yielding SetElementExtent wrapper are deleted from Tests/env_cc.lua, the dead GetServerTimeLocal guard with them, framexml_spec's BLIZZARD_FUNCTIONS excuse list is empty (the declined-then-reversed CreateScrollBoxListLinearView is real now), and wow.withoutApi drives the one IsOfficer branch that had been recorded as unspeccable -- the harness measured that the removal always took and a reset() was undoing it. 577 passing on the new pin.
  • CalendarFrame_OnEvent now returns early on CALENDAR_UPDATE_EVENT_LIST while CalendarFrame is hidden. Blizzard's handler has no gate and needs none -- Blizzard_Calendar is LoadOnDemand, so it is not registered until the player first opens the calendar. This addon is always loaded and registers in OnLoad, so it paid a full 42-day redraw for every month change any addon made, from login onward, into a frame nobody could see. Nothing is lost: CalendarFrame_OnShow resets to today and redraws unconditionally. The other two handlers for this event (CalendarTodayView.lua, the event picker) already checked IsShown(). In-place rather than a Patches.lua SetScript wrapper: the function already carries our PLAYER_LOGIN branch, and a wrapper drops silently if anything re-sets OnEvent later. Location: ClassicCalendar.lua.
  • Each holiday's day-window is now computed once when the schedule is built, not per holiday per day per redraw. stubbedGetNumDayEvents, stubbedGetDayEvent and HolidayCoversTime each derived time(SetMinTime(holiday.startDate)) -- a table copy plus an os.time -- on every call, for every holiday, for every one of the 42 day buttons: up to 500 x 42 x 3 per redraw. That arithmetic, not the grid work, was most of the 230-560 ms. GetClassicHolidays now stamps minStartTime / maxEndTime on each cached entry after the sort; the two hot loops in Patches.lua and HolidayCoversTime in HolidayData.lua read the fields. The fields live on the cached entry, so the day-rollover and /calrefresh rebuilds regenerate them with it, and every startDate / endDate assignment in the addon (29 sites) is inside the schedule build, upstream of the precompute -- nothing mutates a date after caching. HolidayCoversTime keeps a derive-on-absence fallback because specs and newGetHolidayInfo may pass raw holiday tables; the hot loops deliberately do not, so an entry that somehow lacked the field would fail loudly rather than silently go slow. The numSequenceDays arithmetic at Patches.lua ~529 still derives time(SetMinTime(endDate)) itself: substituting maxEndTime there is off by one across a DST boundary, and that line only runs for a matched holiday. Very likely also this addon's own 850 ms line in the client's login profiler, since Questie's OpenCalendar() provokes the same event at login.
  • With the calendar OPEN, another addon walking the month no longer redraws every step, and no longer leaves the grid on whatever month the walk stopped. The client's viewed month is process-global: ATT's scan (18 months back, 31 x SetMonth(1), back to today) moved it for us too. Patches.lua now records the month this addon asked for (state.viewed, moved only by stubbedSetAbsMonth / stubbedSetMonth) and ClassicCalendar_ClientMonthIsForeign() compares C_Calendar.GetMonthInfo(0) against it. A shown frame receiving the event on a foreign month skips the redraw and schedules ClassicCalendar_RestoreViewedMonthSoon() -- one C_Timer.After(0) per burst -- which, if the client is still elsewhere once the walk is over, calls C_Calendar.SetAbsMonth back to our month. Deferred deliberately: restoring inside the handler would move the month back under the other addon mid-scan, corrupting its results and firing another event into every listener from inside their handler. Goes through C_Calendar directly rather than stubbedSetAbsMonth, so the selected-event tracking survives -- our shadow state was right, only the client drifted. A walk that passes through our month redraws once, correctly; a 33-step scan now costs 2 redraws instead of 33. The restore calls CalendarFrame_Update directly as well as relying on the event, so a flavour that delivered the event late could never leave a stale grid -- one spare redraw per foreign burst, accepted and pinned by a spec.
  • CalendarFrame_OpenToGuildEventIndex now goes through stubbedSetAbsMonth, not C_Calendar.SetAbsMonth. Its own comment says it replicates OnShow, which uses the stubbed form. The raw call left the shadow on the previously viewed month, so the following stubbedSetMonth(offset) stepped from the wrong base, and with the foreign-month check it would have read as another addon's move and been undone next frame.
  • state.presentDate was the wrong field to detect a foreign move with. UpdateCalendarState re-points it at whichever day the player clicks, including a greyed-out day of the adjacent month, so it does not name the viewed month. state.viewed is kept separately and moves only when this addon moves the month.
  • Tests/env_cc.lua now models the client's viewed month as shared state, with SetMonth / SetAbsMonth dispatching CALENDAR_UPDATE_EVENT_LIST synchronously to every registered frame, as measured. GetMonthInfo follows the harness clock lazily until something moves the month -- a snapshot at reset broke darkmoon_spec, which parks wow.epoch after cc.reset(). A CalendarFrame left shown at the end of an example keeps redrawing, for real, in every later spec file that moves the month (the registry keeps referenced frames across resets), so hiddenredraw_spec hides it in after_each.
  • Added Tests/hiddenredraw_spec.lua, 11 examples, driving the real XML-built CalendarFrame through its bound OnEvent script: hidden -> zero redraws; shown -> one per event; Show() rebuilds; a 33-step foreign scan costs 2 redraws; the player's month is restored whether the scan ends elsewhere or on today; one timer per burst; the selected-event record survives the restore; the player's own paging stays synchronous; the guild-event jump is never read as foreign. Plus one in holidaydata_spec proving HolidayCoversTime consults the precomputed window (a cached window that disagrees with its dates wins).
  • Not measured in game yet. The 230-560 ms and 8 s figures are the investigating session's probe; the effect of these changes is predicted from the mechanism, not yet observed. ATT's own half (a scan re-run every realm-hour because the native Era calendar never has holiday events) is being fixed upstream, but per the operator this addon must be robust to a stock ATT regardless.

Officer check now delegates to LibGuildRoster

  • WorldBuffDB:IsOfficer() delegates to LibGuildRoster:IsOfficer() (shipped in the library's MINOR 12 specifically because this predicate was being re-derived by this addon, by TOGBankClassic and by the library's own consumers). Same rule -- the granted officer-note permission, never the rank index -- now in one home. GuildRoster is already a hard dependency and WorldBuffDB.lua already loaded the library, so this adds nothing. The comment at WorldBuffDB.lua:209 had named this as the end state since v1.6.5; GuildRoster's re-delivered note about the bare-global call (fixed in v1.6.6) is what surfaced that it had never been done. Location: WorldBuffDB.lua.
  • The old body is kept as the degrade path for a GuildRoster older than MINOR 12 (no IsOfficer method) or absent, so a mismatched install answers false rather than raising -- the SetShown() call sites cannot survive an error. The IsInGuild() guard stays in front of the delegation: the library guards too, but this file should say the permission is meaningless outside a guild itself.
  • Two specs added to Tests/officergate_spec.lua. The delegation is proved by sentinel, not by agreement: the library's IsOfficer is replaced with one that contradicts the permission and the addon must follow the library; its nil ("unknowable") answer must come back as a real false. The fallback is proved by deleting the method and steering the namespaced permission. The "called in exactly ONE shipped file" scan still holds -- the fallback is that one file.

Test suite: two peer-review findings on the specs themselves

  • The source scans now read EVERY TOC through one shared discovery (peer-review finding 21). massinvite_spec and officergate_spec each opened ClassicCalendar.toc by name -- the hardcoded form finding 4 removed from tocorder_spec, re-typed in the spec written to close finding 18 -- and were complete only because tocorder_spec asserts the manifests are identical, an invariant neither cited. Tests/env_cc.lua now owns discoverTOCs(), shippedLuaFiles() (every TOC's .lua entries, each asserted to exist) and shippedSource(); all three specs use them, and the mass-invite scan asserts it scanned every listed file and at least seven, so "no offenders" over nothing cannot pass.
  • The slash-command documentation is asserted against the source (peer-review finding 22). New Tests/slashcommands_spec.lua, 8 examples. Registrations are enumerated from the source with string literals kept, reading every quoted /word on a SLASH_* line so the two multiple-assignment sites are counted -- the reviewer's own first pattern found 17 of 19. A floor of 19 guards the enumeration and a computed SLASH_ key is refused, since no text scan could see one. The set must equal README.md's commands plus a named list of four developer diagnostics, in both directions; the CurseForge description must carry every player-facing command; and CLAUDE.md's own list -- the one a session consults instead of the source -- must carry all nineteen. It was missing seven (/wb among them, which is how /wb reached no document) and is corrected. Prose facts are not enumerable this way. Suite: 576 passing.

[v1.6.6] (2026-09-08)

Invite list — the ON column no longer collides with LVL and Status

  • Fixed the invite list's ON column being overlapped by LVL on its left and Status on its right. Reported from a live guild event with 19 signups, and not reproducible on the developer's machine — which is the part that identifies the cause. It was read as a resolution/monitor problem and it is not one. The Status cell and the online dot are anchored to the row's right edge (ClassicCalendarTemplates.xml), while nameDelta — the growth the Name and Rank columns absorb when the window is widened — was computed from the invite list's width in CalendarEventFrame_ApplyWidth. Those two widths differ by exactly the scrollbar, which appears only once there are enough signups to scroll. So on a scrolling list the left-hand columns kept growing into space the row no longer had, and LVL slid underneath the dot. The variable is the signup count, not the screen: a 500px list with a scrollbar gave the columns 206px of growth while the row was 480px wide, putting LVL's right edge at 413 against a dot at 404 — nine pixels of overlap. Location: ClassicCalendar.lua.
  • The growth is now fitted once, against the width a row actually has. New CalendarEventInviteList_FitDeltas(rowWidth, nameDelta, rankDelta) trims the two deltas so LVL's right edge always clears the online dot by a fixed gap. It is applied in ApplyWidth and the fitted values are what get stored on the invite list, so every row and every column header downstream reads one already-correct answer and cannot disagree. Deliberately not re-fitted per row: a row is sized by the scroll box and is not guaranteed to have been sized when its initializer runs, so reading its width there would make a row's columns depend on how far through layout it happened to be — the same class of defect as the v1.6.5 "rows disagreeing with each other in one list" bug.
  • Clamping only the LVL column would have fixed the symptom and created a new one. Rank is 42 + rankDelta wide starting at 145 + nameDelta, so pinning LVL alone would simply have slid Rank underneath it instead. The deltas are the unit that has to be fitted. When the fit has to take space back it takes it from Name before Rank, because Rank's width was measured to fit the longest rank title in the guild by AutoFitWidth and shrinking it truncates "Guild Master", whereas Name already ellipsises.
  • No change at all when the list does not scroll. The gap constant is 6px, chosen because the row template's default width of 290 leaves exactly 7px between LVL's right edge (207) and the dot's left edge (214) — anything larger would make the shipped default layout fail its own invariant and claw back width nobody asked it to. Every existing user who is not scrolling sees the layout they already have.
  • Column headers are now placed over the row rather than over the list. New CalendarEventInviteList_RowInsets measures how far a row's edges sit inside the invite list's, and CalendarEventInviteList_AnchorSortButtons offsets every header by it. The Status and ON headers previously anchored to the list's right edge with a hardcoded 5px fudge whose own comment admitted it only held "in the common no-scrollbar case" — so with a scrollbar showing, all three right-hand headers sat right of the cells they name. ON's offset is now derived rather than fudged: the dot's centre is exactly 70px in from the row's right edge (Status is 60 wide, the 12px dot sits 4px left of it).
  • One deferred re-anchor after each width pass. The headers are placed by measuring a real row, and on first open — before the scroll box has acquired any — there is nothing to measure; the scrollbar also appears as a result of the row count, i.e. after the width pass has run. A single C_Timer.After(0) re-anchor makes the placement converge without the user having to drag the window. Headers only, so it cannot re-enter the width pass.
  • Added four specs to Tests/invitelist_spec.lua, including one that reproduces the reported case in numbers and first asserts that the pre-fix arithmetic genuinely overlapped, so it cannot pass vacuously. The 290px floor is stated in the spec rather than left as a silent gap: below the row template's own width the default column set does not fit at all, and no clamping of the deltas can help, because they are already zero.

New: mass invite tells a player why their invite did nothing

  • Mass-invite now whispers a player whose invite was refused because they are already in someone else's group. Feature request from crimsonmane (2026-09-04); the whisper text is his, verbatim: "Guild Calendar - Attempted Invite but you are already in a group." Location: Patches.lua, with one call added to _CalendarFrame_InviteToRaid in ClassicCalendar.lua.
  • It needs a system-message handler, not a return value. InviteUnit reports nothing — the refusal comes back asynchronously from the server as a CHAT_MSG_SYSTEM line, and that line names the player but says nothing about which invite it answers. So the addon records who it just mass-invited and correlates, within a 30-second window.
  • Only players WE invited, and only recently. Any other addon's invite produces the same system line; without the pending check this would whisper strangers, which is worse than not having the feature.
  • It checks both raid and party, not just party. In a raid, UnitInParty does not answer for members outside your own subgroup, so a party-only test would whisper most of your own raid. The membership is re-checked when the refusal arrives rather than trusted from the invite loop, because the group can change in between.
  • The pattern is built from ERR_ALREADY_IN_GROUP_S at runtime, never hardcoded. That global is localized — twelve spellings ship in GlobalStrings, and some put the player name in a different position. Matching English text would leave the feature silently dead on every non-English client, and invisibly so to whoever wrote it. The escaping happens before the %s is reopened as a capture, so punctuation in a translation cannot act as a wildcard.
  • Names are correlated on both spellings. The roster gives "Player-Realm"; the server's refusal names them without the realm. Keying only one would never match, and the feature would be dead for every connected-realm guild.
  • Added Tests/massinvite_spec.lua, 9 examples, including the non-English client, the realm-suffix correlation, the raid-subgroup case, whispering nobody twice, and refusing to whisper a stranger.
  • Sends through C_ChatInfo.SendChatMessage only; the or _G.SendChatMessage fallback is gone. It was a fallback that could not fall back. Blizzard_DeprecatedChatInfo/Deprecated_ChatInfo.lua in the Classic Era tree returns early unless the loadDeprecationFallbacks CVar is set, and when it does define the bare global its entire body is a call to C_ChatInfo.SendChatMessage — so the global cannot exist on a client where the namespaced form is missing, which is the only case the fallback was written for. Flagged as deprecated by the language server.
  • Modelled UnitInParty and UnitInRaid in the test env, which had neither. Both are used by shipped code (ClassicCalendar.lua:1094 and :5024), so every "is this player already grouped with me" branch raised offline and could not be specced — which is how the or/and bug below shipped. UnitInRaid returns a raid index or nil, as the client does, rather than a boolean.

Right-click "Invite to Raid" was offered to people already in your group

  • Fixed the invite-list row menu offering "Invite to Raid" to players who are already in your party or raid. The guard read (not UnitInParty(name) or not UnitInRaid(name)), which is false only when both hold — so it was true for essentially everyone and the option never hid. Now and, which is what "in neither my party nor my raid" actually means. Location: ClassicCalendar.lua:5024. (Peer-review finding 17)
  • The same predicate is spelled correctly 3900 lines away, in the mass-invite counter at :1094. One concept, two spellings, and the menu had the wrong one — the class peer-review finding 6 was about, still live in this file after that finding was closed.
  • Both halves of the check are needed, not just the party one: in a raid, UnitInParty does not answer for members outside your own subgroup, so a party-only test would re-offer an invite to most of the raid.

Darkmoon Faire always claimed to be in Mulgore, in every month of the year

  • Fixed the Darkmoon Faire's description naming the wrong zone. Reported 2026-09-07 for that same day, with a screenshot: the September faire showed its own correct dates (9/07 to 9/13) beside the text "The Darkmoon Faire is here, this time at the foot of Thunder Bluff" — which is the Mulgore description, on a month the faire is in Elwynn Forest. Location: Patches.lua, newGetHolidayInfo.
  • The schedule data was innocent, and that mismatch is the tell. Correct dates with wrong text means the lookup, not the table. newGetHolidayInfo resolved a day's description by matching the event's display name against each holiday's name — and every Darkmoon entry is named exactly "Darkmoon Faire", because the location appears only in the description. All twelve monthly entries therefore matched, and with no break the loop overwrote eventDesc on each one and ended holding the last entry in the table: December, a Mulgore month.
  • So it was wrong for the whole year, not just September — every faire in every month described itself as Mulgore, and was correct in exactly the six months that happen to be Mulgore. Being right half the time is why it survived this long: any spot check in an even month agrees.
  • The fix matches on the day as well as the name, using the same date-containment test stubbedGetDayEvent already uses to decide the event belongs to that day, so it cannot select a different holiday than the one the event came from — plus a break, since a second match would be that same ambiguity again.
  • Added Tests/darkmoon_spec.lua, which asserts the pairing across all twelve months of 2026. A single-month spec would have had a 50% chance of passing against the defect, so the shape of the assertion is the point. It also carries an anti-vacuous guard requiring that both zones are produced somewhere in the year — the defect returned one zone for all twelve. Mutation-verified: restoring the name-only match turns both examples red, with January (an Elwynn month) returning the Thunder Bluff text and January and December returning byte-identical strings.
  • Two env gates had to be opened for the spec to see anything, and both default off for good reason: the harness starts with no CVars at all, so calendarShowDarkmoon filtered the faire out entirely, and CCConfig is nil until the addon's first-login guard runs. Without them every month returns no event and the file passes by finding nothing.

Stopped a permanent guild-roster refresh loop that cost every OTHER addon in the client

  • Fixed the mass-invite frame answering GUILD_ROSTER_UPDATE with another roster request before checking whether it was visible. The request produces a response, the response fires the event, and the frame registers in OnLoad and never unregisters — so the loop ran from login to logout with the calendar closed and never opened. Now guarded by canRequestRosterUpdate and self:IsShown(). Location: ClassicCalendar.lua. (Peer-review finding 16, raised from LibGuildRoster-1.0)
  • The cost was paid by other addons, not this one, which is why nothing here ever looked slow. GUILD_ROSTER_UPDATE is a synchronous event that seven handlers answer in this client, one of which rebuilds a 978-member roster in ~27ms. LibGuildRoster measured bursts of 3 events every ~30 seconds while idle and 9 on a guildmate logging out.
  • This is inherited Blizzard code, verbatim, guard placement includedBlizzard_Calendar.lua:4170-4177 in the Classic Era tree is byte-identical. It bites here only because Blizzard's calendar is LoadOnDemand and does not exist until the player opens it, whereas this addon is always loaded: their guard of "the calendar is open" silently became "the calendar is installed". The fix shields users from an inherited client defect rather than repairing a bug of our own.
  • One of the finding's two cited sites was already guarded, and we say so rather than claiming both. CalendarCreateEventFrame_OnEvent wraps its entire body in if ( CalendarCreateEventFrame:IsShown() ) as its first statement, so its re-request never ran while hidden. The two blocks are identical in isolation and differ only in what encloses them — which is why the enclosing function has to be read, not the excerpt. A third frame registers the event and never re-requests at all.
  • Kept the request rather than deleting it, against the reviewer's stated preference, on their own strongest evidence: Blizzard's FriendsFrame makes the identical re-request inside the visibility guard. The mass-invite list is built from the roster, so refreshing it while that window is open is exactly what the request is for.
  • Specced against the harness's own call counter (guild.rosterUpdates), so it is measured rather than inferred: a hidden frame moves it by 0, a shown frame by 1. The second example matters as much as the first — without it the fix would be indistinguishable from deleting the feature.

Guild event creation, restored — a reported bug, and the same root cause as the officer check

  • Fixed being unable to create Guild events at all, while personal events still worked. Reported after v1.6.5: "Calendar was working great now I can no longer make Guild events. I can make personal not guild events. Asked several guild members if they can. They have the same issues." The asymmetry is the entire diagnosis. GenerateDayContextMenu adds the personal Create Event button first (ClassicCalendar.lua:2704), then enters an if IsInGuild() block whose first statement calls WorldBuffDB:IsOfficer() (:2709), and only after that adds Create Guild Event (:2717). Because IsOfficer called a global that exists on no client (see below), that line raised, the guild half of the menu was never built, and the personal half — already added — survived. It hit every member of every guild, which is exactly what the reporter found when they asked around. Fixed by the officer-check fix below; no change to the menu itself was needed. Location: WorldBuffDB.lua.
  • This is the same defect as peer-review finding 15, reaching players through a different door. The finding described the world-buff consequences (Clear All missing, WipeGuild refusing the Guild Master); nobody had connected it to guild-event creation, because nothing drove that menu. The two were diagnosed independently and turned out to be one line.
  • Added Tests/daycontextmenu_spec.lua — the menu had NO offline coverage at all, which is why this shipped. Four examples: an officer is offered both buttons; a non-officer is too while nobody has restricted creation (the gate fails open by design); the restriction still hides it once an officer sets it; and a guildless player is offered no guild button. Mutation-verified: restoring the bare-global call turns three of the four red with attempt to call global 'CanViewOfficerNote' (a nil value), the exact error players hit. The fourth stays green correctly, since a guildless player never enters the guild block.
  • Each example pins its own precondition, after a first draft that passed while proving nothing. cc.reset() re-seeds guild.canViewOfficerNote to true, so tunables set before the build were silently undone and the "non-officer" example ran as an officer. The examples now assert IsOfficer(), the restriction setting and IsInGuild() are what the example's name claims before asserting anything about the menu.
  • Modelled C_Calendar.GetMinDate and C_Calendar.GetMaxCreateDate in the test env. Three shipped call sites read them and not one was reachable offline: the unmodelled call returned nil, date.weekday raised, and every guard built on "may an event be created on this day" was undriveable. The day context menu sits entirely behind that guard. This is the missing edge that let the bug ship with a green suite.

Guild officer check — the permission API that does not exist

  • Fixed WorldBuffDB:IsOfficer() calling a function that exists on no WoW client. It called the bare global CanViewOfficerNote(). That global is not real: GlobalAPI.lua lists only C_GuildInfo.CanViewOfficerNote, the Classic Era client source has zero call sites for the bare spelling, Blizzard's own guild UI uses the namespaced form on every flavour, and it is not a deprecation fallback either. Depending on the caller this either raised, or — wherever the error was swallowed — degraded to falsy, meaning nobody was ever an officer: the world-buff "Clear All" button never appeared, WipeGuild refused the actual Guild Master, and the rank-confirm config would not open for anyone. That is the exact inverse of the v1.6.5 officer-permission fix, and much quieter, because nobody reports a button they never expected to see. Now feature-detects C_GuildInfo.CanViewOfficerNote and degrades to false rather than raising, since call sites assign the result straight into SetShown() during a UI update. Location: WorldBuffDB.lua. (Peer-review finding 15, raised from LibGuildRoster-1.0)
  • The offline suite was certifying the branch no client takes. Tests/env_cc.lua installed a bare CanViewOfficerNote stub, which was the only reason the officer specs were ever green. That stub is deleted — deleting it is the finding's verification step — and the specs now steer the harness's C_GuildInfo.CanViewOfficerNote, which is namespaced only, deliberately, for exactly this reason.

Internal: WorldBuffDB.lua and GuildRankConfirm.lua are both at 100% line coverage

  • WorldBuffDB.lua, 89% to 100%. The remaining lines were the library-absent guild-key fallback and two debug diagnostics. The fallback's specs assert the thing its comment actually claims -- that the hand-built key is identical to the one LibGuildRoster builds, so a client that starts before the roster library is ready cannot write to one store and read from another. The diagnostics are asserted on the counts they report, and that a dedup pass which merged nothing stays silent (it runs on every roster-ready, so a chatty one would print on every login forever).
  • GuildRankConfirm.lua, 40% to 100%, in a new Tests/rankconfig_spec.lua. Most of this file had never been driven offline at all. What it now pins, in order of what it would cost to get wrong: the 12-hour to 24-hour conversion in the create-mode event key (noon and midnight both, since settings are filed under that key and an off-by-twelve files them where nothing looks); the whole create-mode round trip, where an officer ticks ranks on an unsaved event and they are committed on creation -- including that a copied event does not inherit them; that the rank list includes ranks nobody currently holds, and falls back to the roster when the guild-control API is permission-gated; and that the dropdown writes, clears and hides as it should.
  • Every debug diagnostic in both files is asserted on what it says, not merely that it runs -- a line that reports a different number from the one it acted on is worse than no line, because it is read by someone already confused.

Fixed: two lines of addon chatter printed to chat on every login

  • The VersionCheck-1.0 integration announced itself unconditionally. ClassicCalendar.lua:182,184 printed either "VersionCheck-1.0 integration enabled (v...)" or "library not found or Enable method missing" on every login for every player, regardless of /caldebug. Both are now behind ClassicCalendar.debug.
  • This is the class v1.3.1 fixed everywhere else -- "debug messages bleeding through without /caldebug" -- and these two escaped it only because they were added afterwards. Found while checking what the version check actually does in order to document it.

Fixed: Hardcore realms were classed as neither Classic Era nor Season of Discovery

  • isClassicEra was not hasActiveSeason, and Hardcore IS a season. Blizzard's own Blizzard_GlueXMLBase/Vanilla/Constants.lua:166-170 keys SEASON_NAMES by Enum.SeasonID.Hardcore and Enum.SeasonID.FreshHardcore, so C_Seasons.HasActiveSeason() returns true on a Hardcore realm. Every Hardcore, Fresh Hardcore and Season of Mastery player therefore had both flavour flags false, with no branch owning them. isClassicEra is now the exact complement of isSoD, which is the only season whose schedule actually differs from Era's.
  • This reached nothing, and that was luck rather than safety. The global has no shipped reader today, so nothing rendered wrongly -- but the next site to gate on it would have taken the SoD path, or no path, for an entire realm type. Found by asking which client a bug reporter was on.
  • Tests/patches_spec.lua pins Hardcore and Fresh Hardcore individually, plus the property that matters more than either: across every season id, exactly one of the two flags is true. Mutation-verified -- restoring the old expression turns three examples red, one of them naming a season that belongs to neither branch.

Fixed: the mass-invite window could close itself on an officer who had just opened it

  • Two gates governed one feature and asked different questions. The Mass Invite button was enabled by our officer test, while the window itself stayed open only if the client's per-rank CanEditGuildEvent permission said so. For an officer whose guild rank lacks that client permission, the button worked, the window opened, and then the next guild-roster update hid it again with nothing said about why. Both now go through one definition, _CalendarFrame_CanMassInvite. (Peer-review finding 18)
  • The surviving question is the right one. The officer test already answers "no" when the player is guildless, which is the case the original code was written for ("if we are no longer in a guild, we can't mass invite"), so routing both through it keeps that intent and drops the second spelling.
  • Tests/massinvite_spec.lua asserts the two entry points AGREE, not merely that each works alone -- the defect was that they answered different questions about the same action. A source scan alongside it fails if any shipped file reintroduces the old call, which is the half a behaviour test cannot cover: finding 18 exists precisely because an earlier fix was applied at its known sites and never swept. Mutation-verified: putting the old call back turns three examples red, one of them reproducing the live symptom.
  • C_Calendar.CanSendInvite is now modelled in the test environment. It was falling through a permissive catch-all that returns nothing, so every gate reading it was dead-false offline and no test could reach the permitted branch.

The date-containment test is now one definition instead of three

  • Added HolidayCoversTime(holiday, eventTime) and HolidayIsEnabled(holiday) to HolidayData.lua, and routed every site through them. The "does this holiday cover this day" comparison was written out inline in stubbedGetNumDayEvents and stubbedGetDayEvent, and newGetHolidayInfo did not perform it at all -- which is exactly how the Darkmoon Faire came to name the wrong zone in every month. One concept, three spellings, one of them missing. (Peer-review finding 18)
  • The CVar gate is a separate function on purpose. "Does this holiday cover today" and "has the player switched this category on" are different questions; only the two counting/listing paths ask the second, because the description lookup describes an event one of them already produced. Keeping them apart is pinned by a spec.
  • Two assumptions behind the loops were checked rather than inherited. The early break is sound only because GetClassicHolidays returns the schedule sorted ascending by start date, and that ordering agrees with the containment key -- now stated in a comment at both sites, since it was load-bearing and unwritten. And SetMinTime/SetMaxTime are handed the shared cached holiday tables by all three sites; both copy rather than mutate, so these read paths cannot corrupt the schedule.
  • Six examples added to Tests/holidaydata_spec.lua, covering both end days, the day either side, a single-day holiday, the asymmetric 00:01/23:59 bounds, and nil tolerance (one caller derives its time from C_Calendar.GetMonthInfo, which can answer nil). Mutation-verified: changing the end bound to SetMinTime turns four examples red, one of them a pre-existing patches_spec example driving a real call site -- so the shared definition is bound where it is used, not only in its own unit test.

Removed: a world-buff legacy importer that never ran on any shipped build

  • Deleted the legacy import inside WorldBuffDB:Migrate(). It folded the old per-character arrays (WorldBuffRendData and its three siblings) into the account-wide store, and it could not execute from any starting state: Migrate's first statement is self:Init(), Init raises _dataFormat to the current format, and the importer's own gate then read >= WB_DATA_FORMAT and returned. Setting the marker back to 1 by hand did not reach it either, because Init runs first and raises it again. Verified twice in docs/AUDIT.md (finding 2 and the reviewer's follow-up) and never acted on. Location: WorldBuffDB.lua.
  • Nothing changes at runtime, which is the point. About 50 lines described an import no player ever received, so the file read as though legacy data was being carried forward when it was not. Migrate itself stays -- WorldBuffSync calls it on GUILD_ROSTER_UPDATE and in the login catch-up -- and now does what it always did: Init(), then return false. The one-time discard of the corrupt v1 store is Init's and is untouched.
  • Removed with it: the LEGACY_GLOBALS map and the legacyDroppedToBool, charKey and realmSuffix helpers, which had no other callers. The four legacy saved variables stay declared in both TOCs so an existing player's file keeps loading.
  • Specced rather than simply deleted. The two migration specs are kept with their assertions unchanged, because the guarantee a player depends on is the same either way: marking the store as v1 must not resurrect legacy data. The wbintegrity guard spec was inverted and made non-vacuous -- it read guard[name] or next(guard) == nil, which passes on an empty table, so it would have gone on passing after the deletion while testing nothing. It now asserts the guard stays empty, and goes red if an importer is ever reintroduced without a spec.
  • Line coverage on WorldBuffDB.lua went from 89.26% to 98.58% as a direct result; the dead importer was most of the shortfall.

Offline test suite and linting

  • Adopted a month of harness changes (submodule pin 5ada02fca46bbf). The suite runs 482 examples, all passing.
  • Added .luacheckrc, which this addon has never had. Without it every file reported dozens of "accessing undefined variable" warnings for WoW client globals — WorldBuffDB.lua alone produced 94 — which is the permanently non-empty report that nobody reads and in which a genuine warning has nowhere to stand out. The globals list is read from .luarc.json rather than copied into it, so the language server and luacheck cannot drift apart. The ported Blizzard sources have the purely cosmetic warning families switched off to keep them re-mergeable with upstream, but the 1xx family stays on everywhere — that is the one that catches a call to an API this client does not have, which is what the last three peer-review findings were all about.
  • It immediately caught two real accidental globals in the ported code, both leaking into _G from inside a function: CalendarFrame_UpdateFilterButtons assigned testDay where the local declared at the top of the function is testDate (a one-letter spelling mismatch), and CalendarDayContextMenu_ReportSpam was missing a local on reportInfo.
  • Added local models for four client APIs the harness does not carry, with the flavour checked in the Classic Era source rather than assumed: C_Calendar.EventGetTypesDisplayOrdered, C_Club.GetSubscribedClubs/GetClubInfo, Enum.ClubType, and ScrollBoxListLinearView:SetElementExtent. All four are genuinely present on Classic Era — Blizzard's own Classic calendar calls three of them — so the addon's unguarded use of them is correct code and these were missing models, not defects.

[v1.6.5] (2026-08-07)

World Buff sync — names resolved against the roster

  • Fixed a world-buff entry's Main Name being stored as typed, which made the record's identity differ per officer. mainName is folded into the entry's content hash, but WorldBuffs:SaveEntry resolved it against the guild roster only opportunistically and otherwise kept the raw text — deliberately, because a main is a grouping label rather than a required guild member. The consequence was that two officers recording the same fact authored two different versions of one record: typing Ghost and ghost for the same non-member main produced different canons, so each officer's edit reverted the other's, indefinitely, with the row appearing to "flip back" for no reason. All main names now go through WorldBuffDB:CanonicalMainName, which resolves through LibGuildRoster first — covering every main who is a guildmate, which is nearly all of them — and otherwise applies only the deterministic steps that are client-independent (trim, and collapse hyphen spacing the way the library's NormalizeName does before appending a realm). Location: WorldBuffDB.lua, WorldBuff.lua.
  • Known remaining gap, pinned by a spec rather than papered over: a main the roster has never heard of still diverges on capitalisation. There is no client-independent normalization available for such a name — appending our own realm differs per client (worse on a connected-realm cluster) and case-folding would corrupt the display value the officer chose. CanonicalMainName is deliberately the single call site, so a fuller name-normalization entry point in LibGuildRoster wires in at exactly one place; Tests/wbintegrity_spec.lua carries a spec that fails loudly, with instructions, the moment that stops being a gap.
  • The migration guard is keyed by the library's own name for the character. charKey hand-built UnitName("player") .. "-" .. <separately derived realm> — a second normalization that only happened to agree with the library's — and now uses GR:GetNormalizedPlayer(), the same string the roster keys this character under.

World Buff sync — connected-realm store key

  • Fixed the world-buff store being partitioned per realm on a connected-realm cluster. WorldBuffDB:GetGuildKey built "<realm>::<guildName>", deriving the realm itself. The store is account-wide and guild-keyed, so on a cluster — where a guild's members, and your own alts, sit on different realms — an Atiesh character and an OldBlanchy character in the same guild resolved two different buckets inside one SavedVariables file. Logging the second character showed an empty window until sync refilled it from a guildmate, and anything recorded while nobody else was online to re-send it was simply not there. The key is now "Faction-GuildName" — byte-identical to LibGuildRoster's GetHomeGuildKey, whose documentation is explicit that consumers must build the same string — obtained from the library when it is up and constructed identically by hand when it is not, so the key cannot change mid-session. Location: WorldBuffDB.lua.
  • Scope, stated precisely: this did not stop two online guildmates syncing. A receiver files an incoming row under its own key and the key never crosses the wire, so live push and catch-up both delivered regardless of realm. The damage was local to one account's characters. An earlier reading of this as a guild-wide sync failure was wrong and is not what was fixed.
  • Realm normalization is delegated to LibGuildRoster rather than re-derived. The old helper fell back to the non-normalized GetRealmName() when GetNormalizedRealmName was unavailable and squashed whitespace only, where the library also handles the punctuation a realm name can carry (Al'Akir, Ravenholdt - PvP). Two normalizations that only happened to agree; the library is the single source of truth for every realm suffix in play, because it is what NormalizeName appends to bare names and therefore what every roster key and canonical player key is built from.
  • Existing realm-scoped stores are adopted, not orphaned. WorldBuffDB:AdoptRealmScopedStores folds any "<realm>::<guildName>" store for the current guild into the new key on the next roster update, merged with the same last-writer-wins rules the sync uses (so it produces the identical result on every client and is idempotent). Entries carry their canon across untouched — this is a re-filing, not a re-authoring. The source stores are deliberately left in place rather than deleted.

World Buff sync — canonical hashes

  • Fixed the root cause of world-buff data corruption: a record's hash was re-derived by every client instead of being authored once. DeltaSync's canonical-hash contract is that a hash is the identity of a version of a record — minted once, by the client that saved it, stored on the record, and carried unchanged thereafter. ClassicCalendar violated it in three places at once. WorldBuffDB:_EnsureHx re-stamped every row in the store from local content on every index build (i.e. every login), silently overwriting each authoring officer's canon — including canons that had arrived from peers — with the current client's own opinion. PutEntry did the same on every receive, discarding the sender's statement about their own record. And _h was never placed on the wire at all, so there was no shared identity to carry: each client independently derived a number for every record, and the identity of a record therefore depended on who last logged in rather than on who saved it. Two guildmates holding byte-identical data could disagree on its hash permanently and re-offer the same rows forever, with no error raised anywhere. Hash minting is now confined to the two SAVE paths (UpsertLocal, MarkDeleted), plus a conditional mint at each of the two store-entry boundaries (PutEntry, and the adoption loop) for a record arriving from a peer old enough not to send one — four mint sites, and zero on any read path. Location: WorldBuffDB.lua, WorldBuffSync.lua.
  • The author's hash now travels with the record. entryToObj emits h; objToEntry carries it back into _h; PutEntry stores it verbatim. Backward-compatible in both directions — an older peer ignores the extra field, and a record arriving without one has its canon minted once at the boundary.
  • The entry hash no longer folds the map key. It could not: ApplyEntries deliberately re-files an incoming row under the receiver's canonical key (so an older peer cannot re-fragment a cleaned store), which means a key-inclusive canon is wrong the instant it crosses the wire and nothing would ever converge. The hash is now the identity of the record's content; the item roll-up still binds key to canon (key .. ":" .. canon), so a re-key remains visible at the item level, which is the correct level for it. Upgrade note: leaf values change, so every client's item hashes change once and the guild performs a single reconciliation pass on first login. Data-preserving — the merge is content-based.

World Buff sync — one tie-break, shared by every path

  • Fixed an exact-timestamp tie being resolved differently by different code paths, so two clients holding identical data could keep different rows and never converge. Three places resolve a same-key collision — AdoptRealmScopedStores, WorldBuffSync:ApplyEntries and WorldBuffDB:Dedupe — and last-writer-wins by lastModified cannot settle an exact tie, so each falls through to a content tie-break. Adoption compared _h while the other two compared the content checksum. Those orderings are uncorrelated: _h folds in lastModified and encodes booleans as "1"/"0" under a different fold, so on a tie the client that adopted a legacy store kept one row and a client that received the same pair over the wire kept the other. Both then believe they are converged, their item hashes differ permanently, and the offer/reject exchange repeats for the rest of the session with nothing raised. All three paths now call entryContentChecksum, which is field-for-field and separator-for-separator identical to WorldBuffSync's entryTiebreak over the same FNV-1a fold — verified rather than assumed, since the defect was precisely a belief that two functions matched. Location: WorldBuffDB.lua. (Peer-review finding 3)
  • AdoptRealmScopedStores moved below entryContentChecksum, and its position is load-bearing. The checksum is a file-local; with the function above it the name resolved to a nil global and would have thrown on the first tie rather than merely ordering wrongly. Noted because the ordering is invisible at the call site and easy to undo.
  • The other two paths were deliberately not aligned onto _h. A comparator that includes lastModified is not a tie-break at all in the general case — it can only be consulted once lastModified has already been found equal.
  • Added Tests/wbintegrity_spec.luabreaks an exact-timestamp tie the SAME way on every path. Each of the three paths already had thorough specs, in isolation, and none of them asked whether the paths agree — the defect sat in the gap between three green features. The spec seeds _h in the order opposite to the content ordering, resolves one identical tie through adoption and through the merge, and asserts both keep the same row: agreement, not a named winner, since which row survives is the implementation's business. A second assertion pins the survivor to one of the two candidates so it cannot pass by both paths dropping the entry. All three paths are driven, including Dedupe — which meets the tie from the other direction, two source keys collapsing onto one canonical key — because "agrees by construction" is precisely what had been believed about adoption. Mutation-verified per path: flipping adoption's comparator alone, or Dedupe's alone, turns the suite red on this spec.

Officer permissions — one definition, and it is now the granted permission

  • ⚠️ Behaviour change: "officer" now means the GM granted you officer-note access, not that your rank index is 0-2. The addon asked "is this player an officer" in ten places across five files using two incompatible rules. Eight tested rankIndex <= 2 — a position in the rank list, which is meaningless across guilds since rank names and meanings are entirely per-guild — and two tested CanViewOfficerNote(), a permission a GM grants per rank and can grant to any rank. In the very common 0 = GM, 1 = Officer, 2 = Alt layout, every Alt passed the rank checks: the world-buff "Clear All" button was shown to them and WipeGuild accepted them, letting an alt wipe the entire guild's world-buff dataset and propagate the wipe epoch to every member — while failing the permission check, so the same character could not open the rank-confirm config. A player was an officer to one feature and not another. There is now one WorldBuffDB:IsOfficer(), on the permission, and all ten sites route through it. The permission is the right rule for this: it is the only one of the two that reflects something a GM actually decided, and what it gates is destructive and guild-wide. Locations: WorldBuffDB.lua, WorldBuff.lua, WorldBuffSync.lua, ClassicCalendar_Options.lua, GuildRankConfirm.lua, ClassicCalendar.lua. (Peer-review finding 6)
  • What this changes for a guild in practice. A rank-0/1/2 character without officer-note access loses Clear All, the Officer settings tab and officer-gated event creation. A character at any lower rank with officer-note access gains them. Guilds where officer-note access already tracks rank 0-2 see no change at all. If someone loses access unexpectedly, the fix is for the GM to grant their rank officer-note viewing.
  • The finding named five sites; there were ten. The review said explicitly that its enumeration was "a floor, not a total" — it scanned file-scope local function declarations, so inline copies did not surface. Five more turned up: a second inline copy in WorldBuff.lua 1250 lines from the IsOfficer() it duplicated, two in ClassicCalendar_Options.lua (one of which drove the "Can create events" status row, so the panel would have reported a different answer than event creation enforced), and two of our patches inside the ported ClassicCalendar.lua — the /cal raids gate and the day context menu. A tenth was found by the new spec rather than by reading: CalendarCreateEventMassInviteButton_Update, where we had replaced Blizzard's CanEditGuildEvent() with a bare CanViewOfficerNote().
  • Added Tests/officergate_spec.lua — behaviour, plus a scan of the shipped source proving nothing re-derives the check. The behaviour specs pin that a low rank with the permission is an officer and a rank-2 without it is not, so neither can be satisfied by a gate that just answers one way. The source scan is the part that matters long-term: a behaviour spec cannot notice an eleventh site being added, only reading the source can. It takes its file list from the TOC (so a new file is scanned automatically), strips comments and string literals first (every comment explaining this fix names rankIndex <= 2), and asserts both that no file compares a rank index numerically and that CanViewOfficerNote is called in exactly one file. That second assertion is what found the tenth site. Mutation-verified by re-deriving the rank test in WorldBuff.lua, which fails it by name.
  • The offline CanViewOfficerNote stub was hardcoded to true, so no spec could model a member without the permission. It is now steerable via cc.canViewOfficerNote and deliberately independent of guildRankIndex — deriving one from the other would have made the very distinction this fix is about untestable.

Duplication removed where the copies had already drifted

  • Fixed an event title of nil raising instead of falling back to a placeholder, in the world-buff event path. SanitizeEventTitle existed twice — CalendarHelper.lua:725 and a local in WorldBuff.lua:1600 — with a line-for-line identical body and two differences. One was legitimate: a world-buff event wants the placeholder "World Buff Drop" where a raid event wants "Raid Event". The other was not: the CalendarHelper copy guarded title == nil and the WorldBuff copy did not, so an absent title returned a placeholder in one path and raised attempt to index a nil value in the other. One author handled it, the other did not, and nothing linked them. There is now one implementation taking the placeholder as an argument — the real difference kept, the accidental one gone. Locations: CalendarHelper.lua, WorldBuff.lua. (Peer-review finding 9)

  • Merged the duplicated Classic Era season shims. SafeC_Seasons and SafeEnumSeasonID were byte-identical in HolidayData.lua and Patches.lua, down to the rawget lint-avoidance and the fallback tables. These are client-compatibility shims, which is the code least worth having two of: the day a client changes shape you fix whichever copy you happened to open, and the other keeps answering the old way with nothing to indicate it exists. Resolved once in HolidayData.lua (which loads first) and published on the addon's private table. (Peer-review finding 8)

  • Deleted a dead, broken second adjustMonthByOffset, and pinned the surviving one on the cases that distinguish them. Two file-scope locals shared the name — Patches.lua:197 (live, absolute-month arithmetic, correct for any offset) and HolidayData.lua:230 (no call site anywhere). The dead one added the offset and then patched the result with month > 12 and month == 0 special cases, which is correct only for exactly ±1: from December with +2 it clamped to January and discarded the remainder, and from January with -2 it left month = -1, because == 0 never matched and no branch ran. Not a live defect — but an incorrect implementation under a plausible name, sitting in the addon's date-maths file, is what the next person needing to shift a month would find before finding the right one. Deleted. (Peer-review finding 10)

    The surviving copy was under-specced on exactly those cases, which is why the deletion alone was not the whole fix: stubbedSetMonth had only ever been driven with +1 and +3 within a single year — the range where the correct and the broken implementations agree. Tests/patches_spec.lua now drives six multi-month offsets across year boundaries in both directions, including ±18 months. Mutation-verified by substituting the deleted implementation, which fails it. Observing this needed a getter: UpdateCalendarState was a global setter with no counterpart and state is a file local, so the calendar's own date could only be inferred indirectly — GetCalendarPresentDate() is the symmetric read, returning a copy so a caller cannot move the calendar by accident.

  • Fixed world-buff dates being taken from your computer's clock instead of the realm's, which dated synced entries and guild events a day wrong for players in a different timezone from their server. Every date this feature defaults, displays or stores is a date about the server — when a buff dropped, when a guild event happens — but the four date pickers defaulted from date("%m/%d/%Y") and receivedDate was stamped the same way in four more places. For an Oceanic player on a US realm those are different calendar days for much of the day: at 09:00 local on 8 August it is 19:00 on 7 August server-side. The picker offered the 8th, the player accepted it, and the entry synced to the guild dated a day after every guildmate saw the drop — with the calendar event landing a day late and nothing raising an error. All of it now goes through GameToday() / GameTodayText(), and a stored server timestamp is rendered in the server's day via GameDateText() rather than the machine's. The field is monthDay, not dayC_DateAndTime.GetCurrentCalendarTime() returns the former, and reading the latter yields nil and silently produces a garbage date, so a spec pins it. Location: WorldBuff.lua. (Peer-review finding 11)

    The earlier fix in this release was on the rarest path and left a worse state behind. It corrected only the fallback used when C_Calendar.GetMonthInfo() returns nil, so the reference month became game time while the operand stayed machine time — one subtraction across two clocks, disagreeing by construction rather than by accident. The four pickers run every time the dialog opens and their default is what a player accepts unless they deliberately change it. ClampSelectedDateToWindow still reads the OS year and is deliberately left alone: it builds a ±1-year bound, which no timezone offset can move.

    A fifth picker default and two display sites were missed on the first pass, and the timezone helper itself was wrong. The fifth spelled its call date("*t") rather than date("%m")/date("%d")/date("%Y"), so a pattern search could not see it — it is the edit dialog's default when an entry has no date, on the same store-and-sync path as the other four. More seriously, GameDateText's offset was computed as GetServerTime() - time(), which is always ~0: both are Unix epoch seconds and epoch carries no timezone, so their difference is clock skew rather than a UTC offset, and the function reduced to exactly the machine-local formatting it was written to replace. (That GetServerTime is a true epoch count is settled by the client shipping a separate GetServerTimeLocal, which would be redundant otherwise, and by Blizzard's own expirationTime - GetServerTime() duration arithmetic.) The offset now comes from the server's wall clock via C_DateAndTime.GetCurrentCalendarTime(), and the "Last edited" tooltip and created-time suffix route through the same helper so a tooltip cannot show a different day from the row it describes. (Peer-review findings 12 and 13)

    Every arm of the stored-date repair is now specced, with the clocks deliberately diverged. The repair that rewrites a missing, malformed or out-of-range receivedDate stamps "today", so all of it had to move to server time — and only two of its five branches had any coverage. Four specs added, one per remaining arm, plus a guard asserting a well-formed date is left alone: without that, a repair path that simply overwrote every date would satisfy all the others while destroying real data. Mutation-verified, and the shape of the result is the point — reverting the helper fails the three repair specs and leaves the guard passing, which says each spec measures its own branch rather than all of them measuring one thing.

    The suite could not have caught this and now can. The harness derives both date() and C_DateAndTime from one epoch, so moving the clock moves both together and the machine and the realm always agree — the defect was unmodellable. The new specs override GetCurrentCalendarTime directly to drive the two apart, which is the only way to make them distinguishable. Mutation-verified.

  • Fixed the world-buff guild-event path falling back to the machine clock where its twin used game time. WorldBuffs:CreateGuildEventWithDate and CalendarHelper:CreateGuildEventWithDate compute the same month offset from the calendar's displayed month, and both need a fallback for when C_Calendar.GetMonthInfo() returns nil. One used C_DateAndTime.GetCurrentCalendarTime(); the other used date("%m") / date("%Y"), the local OS date. On a player whose machine sits in a different timezone from the realm, the two paths could land a month apart across a month or new-year boundary. The calendar is a server-side artefact and every other date in this addon is game time, so both now use it. Found by running the harness's new dupscan.lua over the addon rather than by a bug report. Location: WorldBuff.lua.

  • Added a spec asserting the invite-status table we SHIP matches the client's. Patches.lua assigns CALENDAR_INVITESTATUS_INFO as a global, overwriting the client's — so it, not the test environment's copy, is what every player gets. The spec added for finding 5 validated the test model; this one reads Patches.lua as text and compares it to Blizzard_FrameXMLBase/Shared/Constants.lua, so the artefact players actually receive is the one under test. The shipped table was already correct; it was simply unchecked, which is a different thing from being right. Mutation-verified.

Bug Fixes

  • Announcing a world buff drop that is already on the calendar no longer creates a second event. Two officers both seeing Rend go down and both hitting announce is the raid-night case: the world-buff path created a guild event unconditionally, while its CalendarHelper twin had done a duplicate check since it was written. The check is now shared. Matching is on title plus day, and world-buff titles are "<Player> Dropping <Item>", so a different player or a different buff never collides. Location: WorldBuff.lua, CalendarHelper.lua.

    The check needed fixing before it could be reused, and that is the interesting half. EventAlreadyExists compared start times with (hour or 0) * 60, which silently turns "no time given" into midnight. The world-buff path never calls EventSetTime and so has no time at all — meaning the check, reused as-is, would only ever have matched events between 00:00 and 00:05. It would have been dead precisely where it was being added. "No time given" is now its own case, answering the question that path is actually asking: is this drop already on the calendar that day. Mutation-verified — removing the branch fails the positive spec while leaving both negative guards passing, which is what shows each is measuring its own thing.

    The event title is now derived once. It was built inside the create form's timer callback, but the duplicate check has to run before the event is created — so a second derivation would have meant the check guarding one string while a different one got created, which reads exactly like the check not working. WorldBuffEventTitle() is the single definition and the callback uses it; SanitizeEventTitle moved to file scope to serve both.

  • Fixed the invite list's Class, Rank and LVL columns lining up for some rows and not others in the same event window. When the event window is widened, the Name and Rank columns grow and Class/Rank/Level shift right to match. That shift was applied only by the resize pass, which iterates ScrollBox:GetFrames() — the row frames that exist at that instant. The scroll box acquires rows lazily and recycles them as you scroll, so every row acquired afterwards kept the XML defaults (Class 99 / Rank 145 / Level 189) while the headers and its neighbours used the widened offsets. A row's column position therefore depended on when its frame happened to be created, which is why the misalignment looked arbitrary rather than systematic. The offsets now live in CalendarEventInviteList_LayoutRowColumns, called from the resize pass and from CalendarEventInviteList_InitButtonShared, so every row is laid out at the list's current width however it was acquired. Location: ClassicCalendar.lua. (BUG-030)

    Why no test caught it, which is the part worth recording. CalendarEventFrame_UpdateWidth cannot run offline at all — it raises on ScrollBox:GetFrames(), which the harness does not model — so the resize path had never executed in the suite. Worse, CalendarEventInviteList_InitButtonShared also died on its first status lookup, because CalendarUtil and CALENDAR_INVITESTATUS_INFO are real Classic Era client globals (Blizzard_FrameXMLUtil/CalendarUtil.lua, Blizzard_FrameXMLBase/Shared/Constants.lua) that nothing offline supplied. The busiest row-rendering function in the addon had zero offline coverage, and both halves of the column layout sat behind that. Tests/env_cc.lua now models both globals from the client source, and Tests/invitelist_spec.lua adds three specs — a freshly acquired row is laid out at the current width, two rows acquired at different times agree with each other, and a row can find its owning list — all parameterised across the Create and View windows. Mutation-verified: removing the initializer's layout call fails four of them. GetFrames() is raised in Tests/HARNESS_CONTRACT.md, deliberately without a local stub, since a stub returning {} would make the resize pass appear to run while iterating nothing.

  • Corrected four of ten invite-status colours in the offline test environment, and added a spec that reads Blizzard's source so they cannot drift again. CALENDAR_INVITESTATUS_INFO was hand-transcribed into Tests/env_cc.lua and Standby, Signed Up, Not Signed Up and Tentative all defaulted to NORMAL_FONT_COLOR — the client uses ORANGE, GREEN, GRAY and ORANGE. Nothing failed, because a wrong model does not crash: the obvious spec ("a signed-up raider's row is coloured correctly") would have asserted the model's answer and then passed through a real regression that flattened every status to normal. New Tests/clientmodel_spec.lua parses Blizzard_FrameXMLBase/Shared/Constants.lua off disk and asserts the model points at the same colour global and the same name string for every status, with a guard asserting the parse found all ten so it cannot go vacuous. No player-facing effect — this is test-environment only. (Peer-review finding 5)

  • Deleted two dead Enum shadows from Tests/env_cc.lua. Enum.CalendarStatus and Enum.CalendarEventType were written as _G.Enum.X = _G.Enum.X or {…}, and the harness (env/wow.lua:630-636) installs both first — so the local literals never applied. They had also gone stale: ours was missing HeroicDeprecated = 5, which the client and harness both carry. Found by mutation-testing the new enum spec and getting a pass — editing the literal changed nothing because the literal was dead, which is exactly the "verify the mutation actually landed" trap. clientmodel_spec.lua now asserts every Enum.CalendarStatus value against CalendarConstantsDocumentation.lua, mutation-verified at runtime rather than by editing a definition.

  • Added a spec asserting the unsuffixed ClassicCalendar.toc exists at all. The client tries AddonName_<Flavour>.toc first and falls through to AddonName.toc; with no unsuffixed file there is nothing to fall through to and the addon never loads on any flavour it does not explicitly name — not "loads with the wrong interface", never loads. Nothing in the suite caught that: the suffix check accepts any file that is base-or-suffixed, so two correctly-suffixed TOCs and no base would have satisfied every other assertion. Pinned after a fleet-wide sweep found real addons in exactly that state. Mutation-verified.

  • The TOC specs now discover the manifests instead of listing them. Tests/tocorder_spec.lua iterated a hardcoded TOCS table, so every guarantee in it — load order, files-exist, and the "all TOCs carry an identical file list" rule — silently skipped any manifest not named in that literal. Adding a flavour and forgetting to edit the spec left the new file checked by nothing, which is the same shape as the defect the file exists to catch. The list is now globbed from the addon root, with a guard asserting discovery found at least two so the whole file cannot pass vacuously, plus a new check that every TOC's suffix is one the client actually recognises — _BCC is deliberately absent from that set, being dead since Classic Anniversary 2.5.5 and the reason the BCC manifest went unloaded here for two releases. (Peer-review finding 4)

  • Fixed Zul'Gurub and Ahn'Qiraj Ruins showing their reset an hour late (US) or two hours early (EU). GetClassicRaidResets applied a regionHourAdjustment (+1, or -2 on EU) to the two 3-day raids only, so on a realm whose server reset is 07:00 the calendar showed Molten Core, Blackwing Lair, Naxxramas, AQ Temple and Onyxia's Lair at 7:00 AM while Zul'Gurub and AQ Ruins claimed 8:00 AM. A lockout's frequency differs between raids; its time of day does not. The adjustment is removed and every raid now uses the region's server-reset hour. Confirmed against the live calendar before changing anything, because guessing at reset data would put every ZG and AQ20 reset on the wrong hour for every player — and re-tested in the client afterwards, showing 7:00 AM across the board. Location: HolidayData.lua. (BUG-029)

    The suite was ratifying this bug, which is the more useful half of the story. A spec named "offsets the 3-day raids by an hour on NA, and back two on EU" asserted the defect, justified with "ZG / AQ Ruins run on their own cycle and do not share the weekly reset hour" — and the hour specs beside it filtered to frequency == 7, so they deliberately looked away from the only two entries that were wrong. The bug was therefore unreachable by running the tests. Replaced with specs asserting every raid shares the server hour on US, EU and KR, plus a guard asserting the reset table actually contains both frequencies so the assertion cannot go vacuous if a 3-day raid is ever removed. Re-baselining an assertion onto different behaviour needs a stated reason, and the reason is recorded in the spec itself. Mutation-verified: reintroducing the +1 fails four specs.

  • Added Tests/tocorder_spec.lua — 14 specs on the load-order guarantees that live in the .toc files, where no line counter can reach them. Prompted by a harness review-method entry: a line counts as covered the moment its condition is evaluated, not when it is true, so a guard can sit at 100% having never once fired — and a precondition expressed in a text manifest cannot be reached at all. This addon had four such preconditions and nothing asserted any of them: the locale table before the three files reading CLASSIC_CALENDAR_L at file scope; LibDataBroker-1.1 before LibDBIcon-1.0; ClassicCalendar.lua before GuildRankConfirm.lua (whose file-scope hooksecurefunc raises on a nil target and takes the rest of the file with it); and Patches.lua before Patches.xml. Also pinned: every listed file exists, both TOCs carry an identical load list (the CLAUDE.md rule, checked by hand until now — which is how the BCC TOC sat a patch behind for two releases), and each TOC declares an interface in its own flavour's range, pinning the flavour rather than the patch so a version bump doesn't fail it.

    It catches the bug that actually shipped. Deleting the LibDataBroker-1.1 line — reintroducing the defect that left players with no minimap button — fails three specs, including the one that names it and the TOC-drift check. That defect was invisible to every existing test because it lived in a file the suite never read.

  • Added Tests/invitelist_spec.lua — the invite-list render path is now covered, closing the verification owed for two harness contracts. Five specs on CalendarCreateEventInviteListScrollFrame_Update, the addon's busiest code path (it re-runs on every invite-list update) and where the signup-order numbers and online/offline column reach the player. Pinned: one element per named invite, an unnamed invite skipped with the numbering closing up behind it, an empty list still replacing the previous provider rather than leaving stale rows, and nothing at all happening until the server has resolved the names.

    The assertion reads the provider back off the ScrollBox with GetDataProvider() rather than capturing one by wrapping CreateDataProvider. That distinction is the whole point: a wrapped constructor proves the function built the right list and says nothing about whether it ever reached the widget — which is exactly how this path was nearly reported as working earlier, when fireEvent raised nothing while zero providers were built.

    Verified by mutation, and the first attempt was invalid — which is how the second gap was found. Removing the named-invite filter left every spec green, because there are two near-identical copies of this function and the mutation had landed on the View list (ClassicCalendar.lua:3966) while the specs drove the Create list (:4824). Re-run against the right line, both it and the names-ready guard produce clean failures. Worth stating as a rule: "the mutation didn't fail the suite" has two readings, and only one of them means a weak spec — twice this session it did, this time it meant a bad mutation.

    So the spec now runs against both lists, parameterised over the two, and the mutation that was silently green now fails. The duplication itself is left in place deliberately: the two functions differ only in frame names and one boolean, so merging is tempting, but this addon is a fork meant to stay re-mergeable with upstream Blizzard changes and CLAUDE.md asks for additive patches over edits to the ported source. Parameterising the spec closes the risk — two copies that must stay in step, exercised on one side only — without making the next upstream merge harder. Both the gap and the reasoning are recorded in docs/AUDIT.md so a reviewer proposing the merge weighs it against that constraint rather than treating the duplication as unexamined.

  • Fixed guild-wipe data resurrecting itself on a connected-realm cluster, and permanently desyncing that member. Found by the first peer review (docs/AUDIT.md finding 1) and verified here before acting. Every path that introduces world-buff entries filters on the guild wipe epoch — Migrate reads it at WorldBuffDB.lua:744 and filters at :767, ApplyEntries at WorldBuffSync.lua:299/:309 — except AdoptRealmScopedStores, which contained no reference to GetWipeAt at all and last-writer-wins-merged the old realm-scoped store straight in. The function's own docstring claimed it used "the SAME last-writer-wins rules the sync uses"; it did, but the sync's rules are LWW plus the wipe filter and only half had come across.

    The damage is worse than a stale row. An officer wipes the guild; a member still holding a pre-Faction-GuildName store logs in and adopts entries stamped before the wipe. Those rows are in their roll-up and nobody else's, so their item hash differs from the entire guild permanentlyClearEntriesBefore only re-runs when a newer epoch arrives, so it never self-heals, and every offer they make is rejected by each peer's inc > wipeAt, so the offer/reject exchange repeats for the rest of the session with nothing raised anywhere. That is precisely the divergence this addon's hashing design exists to prevent.

    Fixed by reading GetWipeAt(gkey) once and rejecting lastModified <= wipeAt before the last-writer-wins comparison — the ordering matters, or a cleared row still slips through the if not cur then apply = true branch when the current store has no row for that key. The settings loop is untouched; MergeSettingRecord was already wipe-aware. Spec: Tests/wbintegrity_spec.lua, "does not resurrect entries the guild wipe already cleared", which plants one row either side of the epoch so it fails both if the filter is missing and if it is applied too broadly. Location: WorldBuffDB.lua.

  • Fixed duplicate guild events being created three minutes apart across an hour boundary. CalendarHelper:EventAlreadyExists documented a "within 5 minutes tolerance" window but implemented it as eventTime.hour == hour and math.abs(eventTime.minute - minute) <= 5 — so the tolerance collapsed at every hour boundary and 19:58 versus 20:01 read as two different events. Now compared as minutes-into-the-day. Deliberately not wrap-aware, and that distinction was a mistake caught while writing the specs: both times belong to the same day, so 23:58 and 00:01 are twenty-four hours apart, not three, and wrapping would have silently refused a legitimate late-night raid because an early-morning one existed. Ten new specs cover the window, the title match, calendar-type filtering, and both non-duplicate directions. Location: CalendarHelper.lua.

  • Adopted the peer-review protocol: added docs/AUDIT.md. It is the inverse of a harness contract — a contract is raised by this addon and answered by the harness in its repo; an audit is raised by a review session and answered by this addon, in ours. Append-only in both directions, with a fixed finding answered in place rather than moved to a "Resolved" section, because a finding's value is its failure scenario sitting beside the code it describes. Three adoption steps done: the file created from the template, a pointer added to CLAUDE.md (the load-bearing one — CLAUDE.md loads unconditionally, whereas the watcher only catches writes made while a session is already running), and the session watcher widened to \( -name HARNESS_CONTRACT.md -o -name AUDIT.md \). Verified both ways: the watcher now lists ClassicCalendar/docs/AUDIT.md, and a -DryRun of the replication script shows it as [skip], so an audit — a list of this addon's defects and how to reproduce them — cannot reach players. docs was already in .pkgmeta's ignore:.

    No review has audited this addon yet, so the file starts with empty Findings. It is not empty of content: it carries the measured per-file coverage a first reviewer would otherwise have to derive, and two entries under Checked and correctCalendarTodayView_NavigateDay's date arithmetic and the world-buff row comparator, both of which look like the defect families found elsewhere in this addon and are genuinely sound. That section exists precisely so the next reviewer does not spend the same hours reaching the same relief.

  • Fixed a potential client freeze in the Today view. CalendarTodayViewFrame_Update turned the navigated date into a month offset by searching: step the offset by one, re-ask C_Calendar.GetMonthInfo, repeat until the month and year matched. That loop had no bound and no exit other than the API agreeing — and GetMonthInfo is documented with no stated range for offsetMonths while the calendar is a bounded window, so an offset the client declines to honour returns the same month forever. Reproduced against a GetMonthInfo clamped to ±2 months: still running after two million VM instructions. The Today view can be navigated a day at a time with no limit, so the date it holds can be arbitrarily far from the displayed month. The offset is now computed as the difference between two absolute month indices — exact, constant-time, and with nothing to search for. Location: CalendarTodayView.lua.

    Honestly scoped: I have not confirmed that the live client clamps offsetMonths — the documentation states no range either way. The fix stands on the loop being unsafe by construction rather than on a confirmed client behaviour, and it also removes an O(n) round-trip per render for free.

  • Fixed a new spec silently switching coverage measurement OFF for every spec file after it. Tests/todayview_spec.lua's hang guard installed a debug hook and then cleared it with a bare debug.sethook() — which removes whatever hook is installed, and Tests/wowapi/coverage.lua measures with a line hook. The suite stayed green throughout; only the reported percentages moved, and they moved enormously: WorldBuffDB read 42.86% in a full run against 87.63% when its own specs ran alone, and 0% with the offending file placed immediately before them. It now saves and restores the previous hook with debug.gethook(). Numbers that quietly become fiction are worse than no numbers, because nothing fails and there is nothing to point at.

    Worth recording how it was caught, since the first two hypotheses were both wrong: the figures moved in both directions at once (WorldBuffDB down, HolidayData up), which looked like the harness's coverage perf change that had just been adopted. Running the previous coverage.lua against identical code gave byte-identical output, which cleared the tool. Measuring the world-buff files against only their own specs reproduced the original numbers, which said the leak was ordering-dependent. Bisecting one spec file at a time named it. The corrected total is 52.89%, and HolidayData at 92.57% is a genuine rise from the new date specs rather than an artefact.

  • Added Tests/todayview_spec.lua — 7 specs, including two that must fail rather than hang. The offset is checked against an absolute-month oracle across a four-year span, and two specs drive a deliberately clamping client. Those two run under a bounded-instruction guard, which was not my first instinct and is the lesson worth keeping: the termination spec was guarded but the "reports the true offset when clamped" one was a plain call, so restoring the old loop to verify the guard took the whole suite past its timeout instead of failing one example. A suite that hangs reports nothing at all, which is strictly worse than one that fails. With both guarded, the mutation now produces two clean failures.

  • Fixed the calendar substituting its own event index for the client's on player-created events. stubbedGetEventIndex compared against state.presentDate.currentMonthOffset, but presentDate holds only year/month/day — the offset lives at state.currentMonthOffset, which the very next line uses correctly. So the comparison always read nil, the "the client already has an index and it agrees with us, hand it back" branch could never be taken against a client that had one, and the synthesised table was returned instead. That matters specifically for player-created events, which are the one part of C_Calendar the classic client gets right: the client's own index object is what the rest of Blizzard's calendar code compares against, so substituting ours can select the wrong event. Location: Patches.lua.

  • Fixed month stepping in the calendar for any offset other than ±1. adjustMonthByOffset did month = month + offset and then corrected with if month > 12 then month = 1 (remainder discarded — December plus two landed on January) and elseif month == 0 (so an offset below −1 matched neither branch and left month = -1, out of range and never wrapped). This is reachable rather than theoretical: ClassicCalendar.lua:2039 calls stubbedSetMonth with GetGuildEventSelectionInfo().offsetMonth, a server-supplied value that is however many months away the guild event is — so following a guild-event link to a raid three months out took the broken path. Replaced with arithmetic on an absolute month index, verified against os.time's own normalisation across 3,660 combinations spanning offsets −30 to +30. This was the third copy of this same defect in the codebase, after the two in WorldBuff.lua. Location: Patches.lua.

  • Noted, not fixed: state.presentDate.month and .year are written but never read. Four places assign them and nothing consumes them, which is why the month-stepping defect above had no visible symptom despite being reachable. The arithmetic is now correct regardless, but the fields are dead state — either something that should be reading them isn't, or they should go. Flagged rather than removed, because deleting state on the strength of a grep is how a subtle read gets missed.

  • Fixed the world-buff date pickers keeping a day the new month does not have. ChangeMonth and ChangeDatePickerMonth advanced month and left day untouched, so stepping from the 31st onto a shorter month left the dialog holding a date like 31 February. The calendar grid redrew correctly — it is built from month and year — so nothing looked wrong on screen while the date the guild event was actually created from was the corrupt one. Both steppers now clamp the day to the last real day of the target month (29 February in a leap year, not 28), and the day clamp runs after the ±1-year window clamp because that one can itself move the month. Location: WorldBuff.lua.

  • Fixed month navigation ignoring anything but a single step. The same two functions did month = month + delta and then corrected with if month > 12 then month = 1, which throws the remainder away: December plus two months landed on January rather than February, and January minus two on December rather than November. Replaced with arithmetic on an absolute month index, which is correct for any delta by construction. Both functions were separate copies of the same code and carried both defects; they now share one helper, so the next fix happens once. Location: WorldBuff.lua.

  • Fixed GetNthSundayOfMonth returning days that do not exist. It answered firstSunday + (n - 1) * 7 with no bounds check, so asking for a fifth Sunday in a month that has only four returned day 32, 33, 35 or 36 — in 125 of the 192 months between 2020 and 2035. Those look like ordinary day numbers and silently become a date in the following month as soon as anything passes them to time(). It now returns nil when the month has no Nth Sunday. The two live callers (the Pacific DST rules, which ask for the 2nd Sunday in March and the 1st in November) can never get nil, so no behaviour changes today — this closes a trap for the next caller. Also removed a dead if firstSunday == 8 branch that the formula makes unreachable. Location: CalendarHelper.lua.

  • Removed two phantom fields from three world-buff date reads. entry.receivedDate or entry.playerNameedDate or entry.droppededDate appeared in the row sorter, the row renderer and the edit dialog. Neither fallback is a field — both are the residue of a rename that replaced received inside receivedDate too, so they always read nil. Harmless, but they implied the entry format had three date fields when it has one. Location: WorldBuff.lua.

  • Fixed the Officer options tab losing every setting below the "Restrict Guild Events" checkbox. ClassicCalendar_Options.lua created chkRestrictGuildEventsText with CreateFontString and then called SetScript("OnEnter"/"OnLeave"/"OnMouseDown") and EnableMouse(true) on it. A FontString is a LayeredRegion, not a ScriptObject — Blizzard's own API documentation declares both methods only on SimpleScriptRegion — so the first of those calls raised attempt to call method 'SetScript' (a nil value). It ran at file scope, which means the error aborted the rest of the file: every option defined after line 879 was never created, on every login, for every player. The tooltip and click-through now live on a mouse-enabled Frame anchored over the label, which is the pattern the other four checkboxes in that file already used. Found by building the addon's real frames offline (Tests/framexml_spec.lua). Location: ClassicCalendar_Options.lua.

  • Fixed the minimap button failing to appear for players with a small addon collection. Both TOCs loaded Libs\LibDBIcon-1.0\LibDBIcon-1.0\LibDBIcon-1.0.lua without first loading the LibDataBroker-1.1 copy embedded alongside it, so LibDBIcon hit its own error(DBICON10 .. " requires LibDataBroker-1.1.") guard on line 12. The failure was invisible during development because LibStub is shared process-wide and 42 other installed addons register LibDataBroker-1.1 before ClassicCalendar loads — anyone whose addon set happened to include one of them was fine, and anyone else got a Lua error and no minimap icon. Libs\LibDBIcon-1.0\LibDataBroker-1.1\LibDataBroker-1.1.lua is now listed immediately before LibDBIcon in both ClassicCalendar.toc and ClassicCalendar_BCC.toc. Location: ClassicCalendar.toc, ClassicCalendar_BCC.toc.

  • Fixed seasonal holidays drifting an hour earlier on every schedule rebuild. addHolidayToSchedule (HolidayData.lua) assigned its DST-normalised dates back into the holiday argument — which is an element of the module-level static tables (CLASSIC_CALENDAR_HOLIDAYS, WeeklyHolidays, the Darkmoon schedules, battlegroundWeekends), not a copy. The adjustment was therefore cumulative: every regeneration re-read the already-adjusted startDate/endDate and called adjustDST on them again, subtracting another hour. GetClassicHolidays rebuilds its cache on a day rollover, on /calrefresh, and whenever the schedule exceeds 500 entries, so a long session or repeated refreshes walked every DST-period holiday backwards an hour at a time (Noblegarden 09:00 → 08:00 → 07:00, likewise Children's Week, Midsummer, Harvest Festival, Hallow's End). Fix: addHolidayToSchedule now takes a CopyTable of its argument, making it pure with respect to its input. getSoDEvents carried the identical defect — it shifted SoDEvents in place, compounding the regional hour offset on every call — and now builds copies too. Found by the new offline suite (Tests/holidaydata_spec.lua, "produces an identical schedule when regenerated"). Location: HolidayData.lua.

  • Fixed two mangled locale keys that silently reverted Russian and Chinese calendar filter text to English. A mid-file edit had welded two adjacent keys together in Locales/localization-world.lua: AuthorHeaderText and CALENDAR_FILTER_HOLIDAYS became AuthorHeaderTextHOLIDAYS (holding the filter translation — Праздники / 节日) plus an empty leftover CALENDAR_FILTER_, in both the ruRU and zhCN tables. Because ClassicCalendar_Options.lua's checkLocale() backfills any missing or empty key from enUS, the real CALENDAR_FILTER_HOLIDAYS resolved to "Holidays" on those clients while the correct translation sat under a key nothing ever asked for — invisible in the file and invisible in game. Also corrected esES's "Guild SettingsDesc" (a space in the key) to GuildSettingsDesc. Location: Locales/localization-world.lua.

  • Fixed WorldBuffs:SanitizeEntry writing the string "No" where the store's contract is a boolean. WorldBuffDB's entry format documents dropped as a canonical bool (never "Yes"/"No"), and both the leaf hash (e.dropped and "1" or "0") and the wire codec (e.dropped and true or false) rely on that. SanitizeEntry set cleanEntry.dropped = "No" for a not-dropped buff — a truthy value in Lua — so any entry passing through it would read back as dropped and hash differently from every peer. Only the dormant legacy per-character arrays currently run through this path (CleanupCorruptedData), which is why it never surfaced; it is now cleanEntry.dropped = cleanEntry.dropped and true or false. Location: WorldBuff.lua.

Testing

  • 🎯 The addon now loads every one of its files offline — the TOC sweep reports OK ClassicCalendar 15/15, given the client source root. It began this work at 9/14 and the worst-loading consumer of the shared harness. Getting there took two real addon fixes (the SetScript-on-a-FontString crash and the unloaded LibDataBroker) plus eleven harness contracts raised from this addon, the last of which — GameTimeFrame, delivered in dc5947a — was the final blocker at Patches.lua:1156. The local GameTimeFrame and CLASS_ICON_TCOORDS stand-ins are deleted, and 15/15 still holds with them gone, which is what proves the harness is carrying it rather than our own shadows.

    The condition is load-bearing and the number is 14/15 without it:

    lua Tests/wowapi/tools/verify-addons.lua <AddOns> ClassicCalendar "F:/Blizzard API Docs/wow-ui-source-classic_era"
    

    Omit that third argument and ClassicCalendar_Options.lua:796 stops on copyPopup.TitleText, a parentKey published by Blizzard's BasicFrameTemplateWithInset — the templates only resolve when the builder has the client's own XML to read. That is not a defect in either the addon or the harness (the documented invocation passes the root), but "15/15" unqualified would be wrong on any machine without that source tree, so the qualification travels with the number.

  • Adopted C_DateAndTime and CreateDataProvider from the harness (9bc5fe8), and deleted the last local stand-in. Both were requested with the reasoning stated rather than just the shape, and both shipped as specified — including the decision that C_DateAndTime derives from the driven clock while GetGameTime deliberately does not. GetServerTimeLocal and GetSecondsUntilWeeklyReset are still supplied here, but added onto the namespace rather than over it: assigning a C_* table wholesale destroys everything else in it, which is a hazard the harness has watched two other addons hit.

  • Making C_DateAndTime the harness's broke a spec, and the reason was the same defect this addon had just reported to the harness. holidaydata_spec rolled the day by assigning cc.now.monthDay, which worked only while cc.now was an independent authority the namespace read directly. Once the namespace derived from wow.epoch, those were two clocks and the write silently did nothing — the "each clock individually plausible, wrong only in their relationship" failure, sitting in our own test env while being written up as a contract. cc.now is now a derived read of the harness clock whose __newindex raises with an explanation, so the two cannot drift and the old idiom fails loudly instead of passing quietly. Location: Tests/env_cc.lua, Tests/holidaydata_spec.lua.

  • Verified the CreateDataProvider delivery properly, after nearly reporting it wrong. Firing CALENDAR_UPDATE_INVITE_LIST produced no error, which looked like success — but wrapping the constructor to count calls showed zero providers built: the event handler gates on frame visibility and never reached the render function, so "did not raise" meant "did not run". Calling CalendarCreateEventInviteListScrollFrame_Update directly shows the real state: it now runs end to end — names-ready check, signup-order refresh, invite collection, custom sort, provider built and filled with one element per invite (2 for two signups) — and stops on the next line at the ScrollBox. So the delivery moved the wall by the whole body of the function, and the remaining blocker is one call.

  • Adopted the ScrollBox:SetDataProvider delivery (7c0b169) and found it unreachable from the path this addon uses. The implementation is correct — CreateFrame(..., "WowScrollBoxList") returns a frame with a working setter and getter. But this addon declares its ScrollBoxes in XML (ClassicCalendarTemplates.xml:272 and three named frames in ClassicCalendar.xml), and XML inherits= resolves templates against a different registry than CreateFrame does: with registerBlizzardTemplates() called, Blizzard's own WowScrollBoxList declaration wins and the frame is built without the behaviour. Isolated to a single variable — the same XML build yields a working setter with the Blizzard templates not registered, and nil with them registered, the unresolved ScrollBoxBaseMixin/ScrollBoxListMixin being the tell. The consequence is that the two capabilities delivered this week are currently mutually exclusive here: Blizzard template resolution (needed for ClassicCalendar_Options.lua:796, and for 15/15) or a working ScrollBox, not both. Raised as a contract with the reproduction; the verification spec is deliberately not written yet, because one that passed against CreateFrame-built frames would be asserting a path the addon never takes.

  • That precedence gap is now fixed (37bdb71), and the invite-list render path is unblocked. A frames.templates builtin wins on the XML inherits= path too, not only via CreateFrame. Verified here: the probe that returned SetDataProvider = nil with Blizzard's templates registered now returns a function, with zero unresolved references. The peer-review boundary was corrected in the same window — a review session writes findings straight into the addon's docs/AUDIT.md and touches nothing else there, which is the reverse of the rule adopted an hour earlier. CLAUDE.md and docs/AUDIT.md were both corrected immediately, since a stale rule in an auto-loaded file is the expensive kind.

  • Raised ScrollBox:SetDataProvider as the next contract, with the measurement rather than a guess. Scoped deliberately to a setter and a getter, with what is not being asked for listed explicitly (no scrolling, no frame recycling, no ScrollUtil, no element factory) so the boundary is on the record before anyone implements to it. This is the addon's busiest code path — it re-runs on every invite-list update — and none of it is assertable until that lands.

  • Adopted two further harness commits (ac38ad8, 3206e75), which add wow.setBuild(flavour) and every flavour's build info. Independently useful confirmation of the TOC audit above: the harness's Classic Era default moved 11508 → 11509 in the same window, derived from the per-flavour TOCs three other addons ship and verified in exact agreement across all three.

  • Added four specs on stubbedGetEventIndex, which is what found the substituted-index defect above. The one that fails against the old code asserts object identity — that the client's own table is handed back, not a copy carrying the same numbers — because a copy passes any field-by-field comparison while still being the wrong object to hand to Blizzard's calendar code. The other two pin the negative cases (a stale client index for a different day must not be used; no client index at all must synthesise one).

  • Added Tests/rankautoconfirm_spec.lua — 14 specs on the Rank Auto-Confirm decision, the highest-consequence logic in the addon. It confirms guild members into a raid without asking, so a mistake is not a visual glitch — it is the wrong people in a real raid night, with nothing to prompt the officer to look. The specs drive the addon's own event frame with a real CALENDAR_UPDATE_INVITE_LIST, because the decision is gated three times before it reaches the rank comparison (which calendar window is open, the player's permission, and the event being a guild event) and a spec calling the comparison directly would prove none of those gates work. Assertions are on what the addon did — the recorded EventSetInviteStatus calls — not on a return value, since code that decides correctly and then fails to send would pass the latter. Pinned: an empty selection means nobody rather than everybody; only Signedup invites are actionable, so a decline is never overridden; a non-guild-member signup cannot match a rank; rank index 0 (Guild Master) is looked up as a key rather than tested for truth; each event's selection stays keyed to that event; and one server rejection does not abort the remaining signups. No defects found — the gates are correct.

  • Verified the new specs by mutation, and found one of them passing for the wrong reason. Deleting the guild-event gate was caught immediately. Deleting the officer gate was not: the test meant to pin it set both "cannot edit" and "not an officer", so ProcessSignups' own internal permission check stopped the run either way and the outer gate was never the reason the test passed. Added a case that isolates it — a non-officer who can edit the event, where only the outer gate can refuse — which does fail under that mutation. Recorded because it is the second time in this suite that a test has needed a deliberate break to prove it was testing anything.

  • Extracted WorldBuffs:SortEntriesForDisplay from the row renderer, and added Tests/wbordering_spec.lua. The row order is a correctness property, not a cosmetic one: the entries are replicated, so two guildmates whose stores have converged must see the same rows in the same sequence. The order comes from sorting the values of a hash table, and pairs() visits a Lua table in an order that depends on its internal layout — so the input sequence genuinely differs between two clients holding identical data, and only a total comparator stands between that and divergent windows. The 10 specs assert the property (same content, same output order, across six different insertion orders) rather than a hard-coded sequence. Deleting the final map-key tiebreak makes them fail with exactly the real-world symptom: one client rendering dup-a, dup-b and another dup-b, dup-a. The sort was already correct; separating it from the rendering is what made it assertable at all, since the old code needed a live ScrollFrame to reach.

  • Added Tests/datepicker_spec.lua — 14 specs written against independent oracles, which found four defects. Every assertion is checked against a value derived by walking real dates (os.date/os.time) rather than by repeating the implementation's own arithmetic, because a test that re-derives the same formula ratifies the bug instead of catching it. That is what surfaced the day-clamp, multi-step and Nth-Sunday problems above: the month-navigation specs assert only that the resulting date exists, and the Nth-Sunday specs compare against a month walked day by day. Notable properties now pinned: stepping twelve months from the 31st never produces an impossible date at any point (a single-step test misses a clamp that only corrupts on the second hop), both pickers obey the same rules (they are separate code paths and divergence between them is exactly what a shared spec catches), and every day the Sunday helper returns really is a Sunday.

  • Recorded that the leap-day path in the date picker is unreachable most years. The picker only offers last year through next year, so with the suite's fixed 2026 date no leap year is selectable at all; the spec has to steer the clock to 2027 to reach February 2028. Worth knowing rather than working around silently — it means the 29 February branch sees a real player roughly one year in four.

  • The addon's real UI is now built offline — all 16 TOC entries load, including both XML files. Adopted 82 commits of the shared WoWAPITesting harness (7bdaeabee0c796), which brought env/framexml.lua: a FrameXML builder that constructs actual frames from Patches.xml and ClassicCalendar.xml, resolving the ~2749 templates they inherit from Blizzard's own Classic Era source. Tests/framexml_spec.lua builds 11 top-level frames and 19 virtual templates and asserts two properties that matter more than any count: that nothing of ours is left unresolved, and that no handler of ours raises. Unresolved Blizzard mixins (ScrollBarMixin, NineSlicePanelMixin, …) are excused by name, never by count, so a newly-broken reference cannot hide inside a tolerance. That pair is what caught the SetScript-on-a-FontString crash and the missing RaiseFrameLevel. ClassicCalendar_Options.lua went from "excluded as frame-bound" to ~60% line coverage and CalendarTodayView.lua to ~24%; the suite is 344 specs, all passing.

  • Deleted every stand-in Tests/env_cc.lua held that the harness now owns. The suite was green against roughly 60 locally-defined globals — time/date, the whole Lua-4 compat layer, print, SendChatMessage, GetCVar, C_Timer, C_AddOns, SOUNDKIT, Minimap, UIPanelWindows, StaticPopup*, Settings, SettingsPanel, the UIDropDownMenu family and the frame factory — each of which shadowed the harness's version, so the tests exercised our copy while the shipped code runs against the client's. Two were actively wrong: the local sin/cos were math.sin/math.cos (radians) where WoW's are degree-based, and the hand-rolled strsplit dropped a trailing empty field. env_cc.lua now defines only the calendar surface the harness deliberately doesn't model, and opts the whole suite into the harness's real widget layer (env/frames.lua) — a suite-wide decision, because AceGUI and the addon's own files capture CreateFrame as an upvalue at load.

  • All clocks now agree on the date. cc.now drives the harness's wow.epoch, so time(), date(), GetServerTime() and C_DateAndTime cannot disagree about what day it is; cc.cvars / cc.printed / cc.chatSent / cc.timers are aliases for the harness's own recorded tables rather than duplicates.

  • Fixed the test environment silently disabling the mass-invite window. env_cc.lua modelled C_Calendar with an __index fallback returning function() end, so C_Calendar.GetDefaultGuildFilter() returned nothing and CalendarMassInviteFrame_OnLoad died on filter.minLevel. The API is documented in Classic Era as returning a non-nilable CalendarGuildFilterInfo of {minLevel, maxLevel, rank} and is modelled explicitly now. This was the harness's own "a permissive stub makes the wrong branch the only one that runs" lesson applying to our env.

  • Raised six harness contracts, each verified against Blizzard's source before being written, and all six were answered the same day (Tests/HARNESS_CONTRACT.md; harness commit f07f024, pin moved to 4470088). Four delivered whole — GetGameTime, CreateColor plus the font-colour globals as ColorMixin instances rather than plain tables, RaiseFrameLevel/LowerFrameLevel/RaiseFrameLevelByTwo, and UIPanelButton_OnLoad with three CALENDAR_* GlobalStrings. Two were declined on this side's own reasoning and are recorded as decisions, not gaps: a numeric Settings category ID (the registry is keyed on it and AceConfigDialog reads it back directly, so GetID() alone was shipped) and CreateScrollBoxListLinearView (stubbing a function whose real body does layout work would invent behaviour — the spec excuses it by name instead, which is honest about what is not covered).

  • The highest-severity contract turned out to be a regression in the harness, not a missing feature. Its steerable time() had been introduced the previous day and dropped its argument, so time({year=…, month=…}) returned the current clock instead of converting the date. That silently collapsed all nine of this addon's time({...}) call sites onto "now" — every computed holiday, Darkmoon window, battleground weekend, raid reset and DST boundary came back as the current day, with nothing raising. It surfaced here only because 16 date assertions went red on adoption; it is now the lead item in the harness's adoption log, worded as a direct warning, because an addon doing date arithmetic without such specs would have adopted it and never known. All six local stand-ins are now deleted, including the time() wrapper — it probed before wrapping and would have yielded on its own, but a wrapper that yields silently is one nobody re-reads.

  • Raised one further contract at the harness's request: C_DateAndTime, which it found while verifying the six and reported rather than implementing unasked. It is the last stop in the TOC sweep (Patches.lua:111, at 12/15) and has 17 call sites here. The request answers the harness's explicit question about which clock it should read — derived from the driven epoch, the opposite call from GetGameTime, because a full date that drifts from time() makes every "is this holiday today" comparison untestable, whereas GetGameTime's hour/minute models a realm-versus-local divergence that genuinely exists.

  • Added a multi-peer world-buff network to the test environment, and a data-integrity suite on top of it. Tests/env_cc.lua can now hold several guild members in one Lua state (newPeer / asPeer swap each peer's WorldBuffDB / WorldBuffSync state around its turn) wired to a loopback network shaped like DeltaSync's collect/dispatch cycle, so peers exchange real messages and are asserted to converge. pumpNetwork raises on a message storm rather than looping, which is what turns "peers agree on content but not on its hash" — a bug that is silent in game — into a hard failure. Tests/wbconvergence_spec.lua (25 specs) covers live push, catch-up, relay through a third member, crossing edits, tie-breaks, the guild wipe against an offline member, hostile senders and old-protocol traffic. Tests/wbintegrity_spec.lua (30 specs) asserts the opposite property — that nothing mutates a record — pushing distinctive values through every read, serialise, merge and relay path and deep-comparing with a differ that names the offending field; it also pins the domain constraint that a player has exactly one row per buff type (a world buff drops once per player, which is why there are four tables) so a re-record is an edit rather than a new event. Tests/wbhash_spec.lua (29 specs) proves the canon is never re-derived by planting a knowingly wrong hash and asserting it survives every path — deliberately stronger than comparing against the correct value, which a recomputing path would pass.

  • Verified the suite by mutation rather than by assuming it works. Each guard was checked by deliberately breaking the code it protects and confirming the failure, with the mutation verified as applied first: recompute-on-receive, recompute-on-index-build, dropping a field from the wire codec, coercing a boolean to a string, lower-casing a display name, aliasing the wire payload into the store, dropping setBy, keying a row per drop instead of per player, and removing key case-folding. This also caught two tests that were passing for the wrong reason — a stale-entry scenario that never exercised the live-push path (the catch-up diff was filtering it first), and a fresh() handle captured across a store swap that compared one client with itself.

  • Added an offline unit-test suite on the shared WoWAPITesting harness. Tests/wowapi is the harness as a git submodule (pinned at 7bdaeab); Tests/env_cc.lua is this addon's environment layer, supplying the calendar surface the harness deliberately doesn't model — C_Calendar / C_DateAndTime with a fixed calendar date, the CALENDAR_* string globals, Blizzard colour objects (ColorMixin-shaped, since the ported code calls :GetRGB() on them), the UIDropDownMenu family, and a frame factory whose CreateFontString/CreateTexture/CreateLine return real mock objects rather than the harness's blanket no-op, which is what lets the ported Blizzard files load at all. Real sibling libraries are loaded rather than stubbed: cc.readyGuildRoster() loads ../GuildRoster/LibGuildRoster-1.0.lua and drives it through actual GUILD_ROSTER_UPDATE events until it reports ready, and cc.ace.load() loads the installed Ace3. 237 specs, all passing, covering WorldBuffDB (~89% line coverage), WorldBuffSync (~76%), HolidayData (~75%), Patches (~50%), CalendarHelper (~39%), GuildRankConfirm (~28%) and WorldBuff (~13%); ClassicCalendar.lua, ClassicCalendar_Options.lua and CalendarTodayView.lua are excluded as frame-bound (the options panel reads regions supplied by Blizzard XML templates, e.g. copyPopup.TitleText). Notable properties pinned: the sync layer's determinism under out-of-order merges and exact-timestamp ties, the stubbedGetNumDayEvents/stubbedGetDayEvent count-versus-index contract across four month offsets (a mismatch there trips the getter's own assert and errors the calendar), HolidayData loading successfully under all ten supported locales, and WorldBuffs:GetFirstDayOfWeek's hand-rolled Zeller's congruence checked against the C library for every month from 2020 to 2030. Run with lua Tests/wowapi/run.lua from the addon root — Lua 5.1 only, no busted, no CI. See Tests/README.md. Location: Tests/, .busted, .gitmodules, .pkgmeta, .luarc.json.

  • Recorded that the legacy world-buff importer is structurally unreachable. WorldBuffDB:Migrate calls self:Init() as its first statement, and Init stamps _dataFormat = WB_DATA_FORMAT before Migrate's own version gate is evaluated — so the importer body cannot run from any starting state, not merely from current data. Behaviour is unchanged and correct (the v1.6.0 data was corrupt and is deliberately discarded in favour of clean data from sync), but bumping WB_DATA_FORMAT in future will not bring the importer back. Pinned by a spec so that is discovered locally rather than in the field. Location: Tests/worldbuffdb_spec.lua.

Build / Packaging

  • Renamed ClassicCalendar_BCC.toc to ClassicCalendar_TBC.toc, because _BCC is not a suffix the client recognises. Across all four of Blizzard's source trees they ship _Vanilla, _TBC, _Wrath, _Cata, _Mists, _Mainline and _Classic, and zero _BCC.toc; among installed third-party addons 51 use _TBC against 12 using _BCC. So the BCC TOC was never loaded by any client and Burning Crusade players silently fell back to the Classic Era TOC — which is also why the v1.6.3 "shows as out of date on BCC" fix did not take. Renamed with git mv so history follows the file.
  • Established that Wrath, Cataclysm and Mists are out of scope, and recorded why. TOCs for all three were drafted and then removed once the premise was checked: those clients have a working native calendar, and this addon exists only because vanilla and tbc do not. Blizzard's own Blizzard_Minimap_Classic.toc draws exactly that line — Classic\GameTime_NoCalendar.lua [AllowLoadGameType vanilla, tbc] against Wrath\GameTime.lua [AllowLoadGameType wrath, cata, mists] — so the client itself ships a no calendar minimap button on precisely the two flavours this addon targets, and our own GameTime_Wrath.lua is a port of the file on the far side of that gate. Shipping there would also have collided: Blizzard_Calendar is LoadOnDemand and declares the same global frame names this addon creates (CalendarFrame, CalendarViewEventFrame, CalendarCreateEventFrame, …), so a player opening the built-in calendar would have hit names already taken. The reasoning is now in CLAUDE.md and .pkgmeta so it isn't re-litigated. Supported flavours remain Classic Era / SoD and Burning Crusade.
  • Bumped the Classic Era TOC from Interface: 11508 to 11509 to match the live client (.build.info reports wow_classic_era 1.15.9.69109; 101 of the installed addons already declare 11509). The BCC TOC was already correct at 20506. The .pkgmeta comment recording both numbers had drifted to the previous pair and is corrected, and CLAUDE.md now says to read the version out of .build.info rather than guessing.
  • Fixed wow-version-replication.ps1 copying files the packager excludes. Its glob-to-regex translation compiled * to [^\\]*, which cannot cross a directory separator — but the BigWigs packager matches with a shell case statement, where * matches any string including /. So .pkgmeta's "*.txt" dropped Libs/LibDBIcon-1.0/CHANGES.txt from the released zip while the dev-sync script kept copying it into the other flavour installs: the two implementations of the same exclusion rules disagreeing, which is the exact drift this script has to avoid to be useful. * and ? now match separators as the packager's do. Verified with -DryRun, not by reasoning: the file moved from WOULD to [skip] and nothing else changed.
  • Moved the comment out of .pkgmeta's ignore: list. The file's own header states that comments belong above the list, and then carried one inside it. The verified, documented hazard is a trailing comment on a list item — yaml_listitem() strips one quote but not the comment, leaving an unbalanced quote that makes the packager upload an empty zip while exiting 0. A standalone comment line inside the list is a different case and I have not verified how the packager's reader treats it; following the canonical form removes the question rather than relying on the parser's tolerance.
  • Added Tests to .pkgmeta's ignore list (bare name, no trailing slash) so neither the specs nor the harness submodule reach the released zip. Removed the five dot-entries (.git, .github, .vscode, .luarc.json, .markdownlint.json) that were no-ops — the packager's copy_directory_tree() prunes anything beginning with . before it consults the ignore list, so listing them implied coverage they weren't providing.
  • Fixed wow-version-replication.ps1 replicating dev files into the other flavors. Its always-skip list named git metadata individually; it now skips any path component beginning with ., mirroring the packager's -name ".*" -prune exactly. That is load-bearing rather than cosmetic — this repo's .git is a one-line gitdir pointer file, so replicating it aimed the copy at the wrong repository — and it is the only thing keeping .busted, .gitmodules, .luarc.json and .markdownlint.json out of the synced installs now that dot-entries are (correctly) absent from .pkgmeta. Verified with -DryRun. Location: wow-version-replication.ps1.

Older releases

Releases v1.6.4 and older have been moved to CHANGELOG_ARCHIVE.md, so that this file stays within the size GitHub accepts for a release body. The moves also normalised those sections' punctuation to ASCII; the wording is unchanged.

Nothing has been deleted — the sections were moved whole, at version boundaries.