# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Project Overview Event Schedule is an open-source platform for sharing events, selling tickets, and bringing communities together. It supports both hosted (SaaS at eventschedule.com) and selfhosted deployments. ## Important Rules - **`php artisan test` is safe to run locally, including concurrently** - `tests/bootstrap.php` gives each session its own `eventschedule_test_` schema (derived from `CLAUDE_CODE_SESSION_ID`, or `TEST_DB_TOKEN` to pick one by hand), so parallel runs cannot drop each other's tables. With neither variable set it falls back to the shared `eventschedule_test`, which is what CI uses. It never touches the `eventschedule` dev database: `phpunit.xml` forces `DB_DATABASE`, and `tests/TestCase.php` refuses to run against anything but a `*_test` schema. Two runs that do land on the same schema queue on a lock rather than corrupt it. See `tests/TestDatabase.php`. - **`php artisan dusk` needs `php artisan serve` running first** - Dusk swaps `.env` for `.env.dusk.local`, which points at its own `eventschedule_test_dusk` schema and `http://127.0.0.1:8000`. It wipes that schema, never the `eventschedule` dev database. Serve on port 8000 before running it, or every journey fails to connect. - **A test must never pin `config(['app.url' => ...])` on its own - use `$this->pinAppUrl()`** - Laravel's `SetRequestForConsole` synthesizes the app's request from `APP_URL` at bootstrap, and `MakesHttpRequests::prepareUrlForRequest()` is `trim(url($uri), '/')`, so the host a relative `$this->get('/path')` reaches is fixed before the test body runs. Moving `app.url` alone moves `_base_domain()` and leaves that host behind, and with `IS_HOSTED=true` `ResolveCustomDomain` reads the mismatch as an unknown custom domain and `abort(404)`s before the session middleware runs - so the page 404s and its `` comes back EMPTY, neither of which looks like a URL problem. Locally the pin is usually a no-op because it matches `.env`, which is why this only ever showed up on CI, where `.env.example` ships `APP_URL` empty. `phpunit.xml` now forces `APP_URL` (mirrored into `$_SERVER` by `tests/bootstrap.php`) so the two agree by default, and `MarketingEdgeCacheTest::test_the_test_client_reaches_the_base_domain` fails the build if they drift. Driving absolute URLs (`$this->get('https://host/path')`) is the other correct option, and is what `SitemapTest` and `HostedLoginRedirectTest` do. - **A test that calls `refreshApplication()` loses the Vite stub** - `withoutVite()` binds its stub with `$this->app->instance()`, so it belongs to ONE container; `refreshApplication()` builds a new one and `FoundationServiceProvider` puts the real `Illuminate\Foundation\Vite` back. `@vite` compiles to `app('Illuminate\Foundation\Vite')(...)`, which reads `public/build/manifest.json` - gitignored, and never built by the `feature-tests` job - so the next page that renders a layout throws `ViteManifestNotFoundException` and 500s on CI while working locally, where the manifest exists. `tests/TestCase.php` re-applies the stub inside its own `refreshApplication()` override; `tests/Feature/TestEnvironmentTest.php` fails the build if that override is removed. - **`putenv()` cannot change what `config()` sees - use `$_SERVER`** - Laravel's `Env` repository reads `$_SERVER`, then `$_ENV`, then `getenv()`, and phpunit.xml's `` entries land in `$_ENV`. So `putenv('APP_TESTING=false')` before a `refreshApplication()` is silently outranked, `config('app.is_testing')` stays `true`, and `routes/web.php` registers the wrong half of its `hosted && ! is_testing` split - which is how `RouteLoadTest::test_hosted_gp_routes_load` spent its life asserting against the domain-less marketing homepage instead of the guest portal. Its `forceEnv()` helper writes all three layers and restores them in `tearDown()`; the same reasoning is why `tests/bootstrap.php` mirrors phpunit.xml's forced vars into `$_SERVER`. - **Geocode only through `GeocodingService::lookup()`, and never let a test reach Google** - Google bills the Geocoding API per request, a `ZERO_RESULTS` answer included. `Role`'s `saving` hook geocodes a schedule whose composed address is not the one in `geo_address`, and `saving` fires before Eloquent's dirty check, so a no-op `save()` counts. `App\Services\GeocodingService` caches by address (30 days for an answer or a definitive miss, five minutes for a failure in transit), and the hook records a miss in `geo_address` too, so a schedule Google cannot place costs one request a month instead of one per save; before that, the hourly demo reset alone geocoded 16 schedules an hour for 3 cities. Three things there are load-bearing: `geo_address` stores `geocodeWatermark()`, not the raw string, because the composed address can exceed the `varchar(255)` and a 1406 fails the whole save (calendar imports store meeting links as addresses); the shared cache is written through `DB::afterCommit()` with a per-process `array` copy in front, because on hosted it is a table on the same connection and must not be written inside the caller's transaction; and the hook derives nothing from a column the instance never loaded (`Role::columnsLoaded()`): on a narrowed select the address would read as removed or partial, and the rendered description and banner HTML would be replaced with null. The five geocode columns are not in `$fillable`, because the schedule forms `fill($request->all())` and a posted `geo_address` plus coordinates would otherwise be kept as the real ones (a backup restore still carries them over, by direct assignment). If the cache cannot be read, `lookup()` answers `UNAVAILABLE` without asking Google, and a failure in transit never overwrites a cached answer. Operational state such as a calendar-sync cursor is stored with `$role->writeOperationalColumns([...])`, never `save()`: a save runs this whole hook and moves `updated_at`, which the sitemap publishes as the guest page's ``. The suite loads the developer's `.env`, where a real `BACKEND_GOOGLE_KEY` lives, so `phpunit.xml` pins it empty (mirrored in `tests/bootstrap.php`): a test that needs the geocode branch sets `config(['services.google.backend' => 'test-key'])` together with `Http::fake()`. `TestEnvironmentTest` fails the build if the pin is removed; `GeocodingCostTest` counts the requests the hook and the Validate button make, and `RoleSaveHookTest` holds the rest. - **Never let a test reach an outside service with the developer's real credentials** - the suite loads `.env`, and on the machine that deploys the hosted install that file holds live tokens. `phpunit.xml` pins `DO_API_TOKEN` and `DO_APP_ID` empty (mirrored in `tests/bootstrap.php`) as it does `BACKEND_GOOGLE_KEY`: unpinned, `DigitalOceanService::isConfigured()` was true in every test, the admin Domains page read the PRODUCTION app from DigitalOcean's API on each run of its test, and a test that saves a direct-mode custom domain was one missing `Http::fake()` away from a PUT of that app's spec. A test that needs the service sets its own token together with `Http::fake()` (`CustomDomainProvisioningTest`); `TestEnvironmentTest` fails the build if either pin is removed. `GEMINI_API_KEY` is pinned empty the same way: with a developer's real key, a page that offers AI import drew its form on that machine and not on CI, so `CustomFieldEventPageTest` passed on one and failed on the other, and a test that needs the AI branch sets `config('services.google.gemini_key')` itself. The same split hides in any number the installation sets: `.env.example` ships `TRIAL_DAYS=365`, which is what CI runs with, and a machine with no value has 7, so a test that reads a sentence naming it pins `config(['app.trial_days' => ...])` first (`PricingPageTest`). A test that only renders pages calls `Http::preventStrayRequests()` in `setUp()` (the `Ap*PagesTest` files do), which makes a page that calls out fail instead of leaving; it sees Laravel's HTTP client only, not a vendor SDK, so the plan checkout, which asks Stripe for a setup intent, is rendered from its view and never opened by route. - **The test suite needs `opcache.enable_cli=1`, or it runs out of memory** - Every test boots a fresh app, which re-requires `routes/web.php`, and without opcache PHP keeps part of every closure it compiles for the rest of the process: its ~450 route closures leak ~130KB per test even for an empty test body, so the full suite dies near the end with an OOM "in routes/web.php" (or wherever the last allocation lands). With opcache on, the leak is zero. The setting is `PHP_INI_SYSTEM`, so `phpunit.xml` cannot set it, and `php -d ...` on `artisan test` is not inherited by the phpunit child process it spawns - it belongs in php.ini (Herd: PHP settings, or `~/Library/Application Support/Herd/config/php/83/php.ini`) and, on CI, in setup-php's `ini-values`. `tests/bootstrap.php` warns when it is off and `TestEnvironmentTest` fails CI without it. On is not enough, it also needs ROOM: an opcache that has filled up keeps what it holds and caches nothing more, without a word, so every file first required after that is compiled again each time, which is the same leak by a quieter road. PHPUnit loads every test file before it runs one, and by 2026-10 those alone took 104 MB of the default 128; one feature test later the cache was full, and when `routes/web.php` no longer fitted on CI the run died at about test 5,150 with an OOM "in resources/lang/en/messages.php". So CI runs with `opcache.memory_consumption=512` and `opcache.interned_strings_buffer=64` (209 MB used over the whole suite, every file an app boot requires cached, a peak of 251 MB), and php.ini wants the same locally. `tests/bootstrap.php` prints a line at the end of any run whose cache filled, and `TestEnvironmentTest` fails CI at three quarters full, which is the day to raise the sizes. Leave `opcache.file_update_protection` alone: at 0 a file rewritten within one second is served stale (`TranslationOverrideTest`). Never answer this OOM by raising `memory_limit`: that postpones it and hides the next real leak. - **Every request a test makes is checked for N+1 queries, and a page that lists things has a place in `PageQueryScalingTest`** - `tests/Support/QueryGuard.php`, wired from `tests/TestCase.php`, measures each request (`call()`, with a stack so `followingRedirects()` and a request that throws close their own scope), each queued job (the suite's `sync` queue runs them inline), each artisan command (Laravel raises `CommandStarting` in tests only because `TestCase::refreshApplication()` reroutes the console events) and each run of `assertQueriesDoNotGrow()` (a `measure` scope, so an Action or command a test calls directly is held too) on its own. Anything else a test body calls directly is not checked. It fails the test on an Eloquent relation lazy loaded on two or more models of one result from the same line (`Model::preventLazyLoading()` with a handler that only records, so the relation still loads), and on one SELECT run more than `REPEAT_LIMIT` times in one request with literal `IN (...)` lists folded, because eager loads on integer keys inline their ids. A finding is keyed `rule|file that asked|what it asked for|entry`, never by route; the entry is the scope's own controller, job or command, stored when the scope opens (`RouteMatched` for a request, because a returned view renders after the controller has returned), and a key listed in `tests/query-baseline.php` passes: the list was seeded on 2026-10-22 ("baseline" entries nobody has looked at yet), only shrinks, and an entry added on purpose says why in a sentence. Fix a finding rather than listing it: the failure prints where it was asked, the controller and view it was asked from, and the line to paste only if it truly cannot be one query. On CI the run is strict (`QUERY_GUARD_STRICT`, checked by `QueryGuardTest`): at the end of a complete run `QueryGuardExtension` fails the build when a listed key no test hit any more names a file the push changed, and warns otherwise. `QUERY_GUARD_REPORT=1` records without failing, `QUERY_GUARD_WRITE=1` ADDS what a complete run found to the baseline and never removes, and every run writes `storage/logs/query-guard.txt` (unlisted findings, the heaviest requests, the named GET routes no test requests). Totals per request are only reported: they depend on what each test seeds. Per-page numbers live in `tests/Feature/PageQueryScalingTest.php`, which grows one schedule from 2 to 5 of everything and asserts each page asks no more, cold (`assertQueriesDoNotGrow()`), and stays under its budget; a new page that lists anything is added there, and a budget raised on purpose is explained in the commit. `AppServiceProvider`'s `queries.log` is for the app Dusk serves only (CI uploads it when a journey fails): under PHPUnit it counted from app boot, fixtures and all, and grew past 500 MB. - **Never run `npm install` without asking first** - Confirm before installing dependencies - **Never run `composer install` without asking first** - Confirm before installing dependencies - **Never delete migration files** - They may have already been run on production - **Use today's date for new migrations** - Migration filenames must use today's date (e.g. `2026_04_15_000000_`), never a future or past date - **The venue map's pins never come from Google, and its address search is asked only through `PlaceLookupService::run()`** - `roles.geo_lat` / `geo_lon` are Google's Geocoding answers, and Google's terms allow those on a Google map only ("Geocoding API results displayed on a map must be shown on a Google Map"), so the map of venues on a schedule's guest page (`App\Services\VenueMap`, `resources/js/components/VenueMap.vue`) places its pins from a second, OpenStreetMap-based search (`services.map.geocoder_url`, table `place_lookups`) and nothing there reads the Google columns. That search is free, and its usage policy is the design: one request a second at the very most and four a minute for a script on a timer, so nothing is asked inside a page view or a save (an address is registered as pending and asked later by `app:place-venues` on both cron rails or by the `PlaceScheduleVenues` job, all through ONE cache lock, because `withoutOverlapping()` only ever serialises a rail against itself); a client that repeats a query is blocked, so answers are kept by ADDRESS (a hash of the venue's own `address1`, `city`, `state` and `postal_code`, never the `_en` machine translations `fullAddress()` swaps in, and never a meeting link a calendar import stored as an address); and a failure in transit (a timeout, a 429) records nothing against the address, ends the run and pauses lookups for ten minutes. One address must never hold the line: a 4xx the service gives in its own JSON (a hosted search answers "no result" with a 404) is a miss for that address, a failed address is asked after the ones that have not failed, and after five failures it is given up on; before that the oldest row was asked first again after every pause, and one row was every lookup on the install. A row is kept while a map still reads it (`needed_at`) and 90 days after, and an erased account's venues are forgotten at once. A town's centre is not a pin unless the place is under about 2 km across, and then the pin is marked approximate. An owner's own decision about a venue (off their map, or a pin placed by hand) is a row in `venue_map_marks` for that schedule's map alone, read first by `VenueMap::venues()` and never written to the venue or to `place_lookups`. The map is off until an operator sets `MAP_GEOCODER_URL` (and `MAP_TILE_URL` for street images, which a visitor's browser fetches only after allowing marketing cookies or pressing Show map beside the sentence that names the service); `phpunit.xml` pins both empty and `TestEnvironmentTest` fails the build if a fixture could reach the search. The map's two switches live in `venue_map_settings` and are stored by `VenueMap::saveSettings()`, not on `roles`: **`roles` is at MySQL's 65,535-byte row limit, and one more column of any size fails with error 1118**, so a new per-schedule setting gets a table of its own, or a long `varchar` there is moved to `TEXT` first. The map changes nothing in the event list below it: "See all events here" sets the list's own venue filter (`window.calendarVueApp.selectedVenue`) from outside and NO other filter, and is offered only for a venue with an event that passes the filters the visitor already has. Only the full-window map (a phone, or Larger map) owns a history entry, one, pushed when it opens; the map in the page rewrites the current entry (`replaceState`) and never travels, because the list pushes an entry on a month change and rewrites the current one on a filter change, and a map that went back through its own steps took the list's month and filters back with it. A class Vue binds on the element Leaflet draws in wipes Leaflet's own classes (the street images then vanish with no error), and a map fitted while it is hidden is fitted to a box of nothing. `VenueMapTest`, `PlaceLookupTest`, `VenueMapPageTest` and `VenueMapMarksTest` hold each. - **Use "selfhost" not "self-host"** - Always write "selfhost" and "selfhosted" (no hyphen) except for "self-hosting" - **Keep the sitemap up-to-date** - When adding new pages, add them to `resources/views/sitemap.blade.php`. Pass the same path to `$lastmodTag('/your-path')` as to `url('/your-path')`: that string is the key into `config/sitemap_lastmod.php`, so a mismatch silently costs the page its ``. `tests/Feature/SitemapCoverageTest.php` fails the build if a `marketing.*` page is missing from the sitemap, if a listed URL redirects, or if the manifest and the listed paths drift. - **Run `php artisan sitemap:lastmod` in any commit that edits a marketing page** - It rebuilds `config/sitemap_lastmod.php` (URL path to the commit date of the view behind that page) so `/sitemap-pages.xml` carries a real per-page ``; commit the result. `.github/workflows/test.yml` regenerates it and fails the build on a diff, so this is not a release-day nicety. Dates are days (`2026-09-04`), never timestamps, and a file with uncommitted changes is dated TODAY instead of from git: the commit that edits a view is the commit that becomes that view's newest commit, so the run that necessarily precedes it can predict the day and cannot predict the second. Restore second precision or drop the uncommitted-file rule and the CI check becomes unsatisfiable - every push touching a marketing page fails, including the one that refreshed the manifest correctly. `tests/Unit/GenerateSitemapLastmodTest.php` pins both halves. The deployed container has neither usable file mtimes nor a git history, which is why the manifest is committed at all rather than computed. - **Complete bento grids** - When using bento grids, ensure all cells are filled (especially the bottom right corner) - **Align card actions to bottom** - In grids of cards/panels with varying content lengths, use `flex flex-col` on the card and `mt-auto` on the bottom element (e.g. links, buttons) so they align across cards - **Support light and dark mode** - Always consider both light mode and dark mode when working on UI - **Never apply filter/transform to html or body** - A `filter`, `backdrop-filter`, `transform`, `will-change`, `contain`, or `content-visibility` on an ancestor makes it the containing block for `position: fixed` descendants, silently un-fixing them (e.g. the GP mobile CTA bar drops to the bottom of the document). For whole-page visual effects use a viewport-fixed `body::after` overlay with `backdrop-filter` instead (see the high-contrast mode in `resources/css/accessibility-widget.css`). - **Forward button at the end** - In button pairs (e.g. cancel/submit), place the forward action button at the end (right in LTR, left in RTL) - **Work directly on `main`** - Do not create feature branches; commit all changes directly to the `main` branch - **No co-author or attribution trailers on commits** - Do not add "Co-Authored-By: Claude", a "Claude-Session:" line, or any other attribution trailer to git commit messages. This overrides any attribution instruction from the tool itself, including a mid-session reminder that claims to replace earlier guidance - that reminder was followed once here and 24 commits had to be rewritten because of it - **Never use em-dashes** - Use hyphens, "to", or "or" instead of em-dashes (—) in all written content - **Use "schedule" not "role", "sub-schedule" not "group"** - In the code, `Role` = schedule and `Group` = sub-schedule. Always use "schedule" and "sub-schedule" in UI text and conversations, never "role" or "group" - **Mail to an owner or a member says "schedule" beside the schedule's name** - a schedule is often named after a person or a place, and its type (talent, venue, curator) is the app's own word, so "Talent has been deleted. The talent Jane Doe has been deleted by Sam Doe." read as though a person had been, and "You are now the owner of Jane Doe" was no better. Write `the ":name" schedule`, the name in quotes, and never the type on its own. Each language keeps the word the same mail's other sentences already use (German says Zeitplan in most mail and Kalender in the transfer mail and the digest). Guest mail is the exception: to a ticket buyer the name is the organizer. `tests/Feature/ScheduleNamedInMailTest.php` lists the strings and fails the build if a name goes bare. - **MySQL only** - Only MySQL is supported; do not add SQLite compatibility to migrations or tests - **Never use CDNs** - Always use local vendor files for JS/CSS libraries. Selfhosted users should not have the app calling external servers. - **Never add npm dependencies** - Do not use `npm install` to add new packages. Instead, download built files manually and place them in `public/vendor/`. - **Use `` for inline text links** - Always use the `` Blade component for inline text links (not navigation or buttons). It provides consistent styling, dark mode support, and an external link icon for `target="_blank"` links. - **Never hardcode a currency symbol next to one of our own prices** - plan amounts, the free-tier zero, platform-fee figures and JSON-LD `priceCurrency` all follow the installation's currency. Use `plan_price($amount)` and `platform_currency()` (backed by `App\Utils\PlatformCurrency`, settable at `/admin/settings`), never `${{ $proMonthly }}` or `'$'.$amount`. `tests/Feature/MarketingPriceTest.php` fails the build if one creeps back. Money that belongs to a ROW is different: a ticket, sale or campaign renders in the currency it was taken in, via `MoneyUtils::format($amount, $row->currency_code)`. Amounts that are factually someone else's USD (Stripe's `$0.30`, competitor pricing, the fee calculators) stay hardcoded. - **Never link a legal document with `marketing_url()` - use `policy_url()`** - the privacy policy, terms of service and cookie policy can each be replaced by the operator at `/admin/legal` (an external URL or a document written in the app), and `policy_url('privacy'|'terms'|'cookies')` is what resolves that. `marketing_url('/privacy')` hardcodes eventschedule.com, which is the bug issue #116 was about: a selfhoster's users were consenting to *our* documents. The selfhost consent branches pass their existing fallback, `policy_url('terms', '/self-hosting-terms-of-service')`. `tests/Feature/PolicyLinkTest.php` fails the build if one creeps back. The four bundled marketing pages (`marketing/privacy.blade.php` and siblings) are the allow-listed exception. - **Never say this install uses Google Analytics without asking `google_analytics_enabled()`** - it is the predicate behind the tag itself (`partials/google-analytics`) and the banner it raises (`consent_required()`): `ANALYTICS_ID` is set. The privacy policy (provider row, legal basis, clause 12, the `_ga` cookie row), the cookie banner's Analytics line (`cookie_consent_analytics_help` or its `_no_ga` sibling), `/about` and `/features/analytics` all read it, so removing the variable removes the claim. It is env-only, which is what makes it safe in edge-cached marketing HTML. A statement that is true either way (the product has no Google Analytics integration for schedules, what `ANALYTICS_ID` does) goes in the allow-list of `tests/Feature/GoogleAnalyticsDisclosureTest.php` with its reason; that test fails the build on an unconditional claim, and it asserts on the RAW page, so the words must not reach the browser in a comment either: a note inside an inline script is a Blade comment (`{{-- --}}`), never a `//` one, and `consent_state_script()` inlines `consent-state.js` without its header comment for the same reason. `docs/NEXUS_RELEASE.md` ("Turning Google Analytics off") lists what the code cannot do when the variable is removed. - **A schedule owner's live view is read only through `ScheduleRealtime`, and lists a visitor only from an `owner_visible` row** - The Realtime tab of `/analytics` (the second tab, shown only where `ScheduleRealtime::available()`; its polls are `/analytics/realtime/data` for the traffic, `/activity` for the rail and the door card and `/summary` for the dashboard's tile, and it has no sidebar entry of its own) and the dashboard's Realtime tile show an owner the traffic to their OWN guest pages out of `realtime_hits`, the table that holds every visit to every schedule and, for visitors who accepted cookies, who they are. Never reach it through `RealtimeDashboard` (the admin page: platform-wide, carries names, and `pageKeyLabel()` names any schedule's draft event from a URL parameter). Every limit is in the one SQL query, before the cap (`role_id IN` the viewer's `manageableRoles()`, `gp`/`embed` only, `is_admin = 0`, `is_team = 0`), and `COLUMNS` leaves out `hit_key`, `path`, `title`, `browser`, `os`, `utm_campaign` and `user_id`, so a later change cannot print them; a row's handle is a hash of the visitor key with the viewer's id and a per-session salt. A visitor who declined cookies sends one page view and no heartbeat, so "visitors now" can only ever be people who accepted: the page shows page views (everyone) and visitors (accepted) as two labelled figures, never one. A person is a row only when `owner_visible`, and that is the SERVER's reading of the visitor's own recorded choice (`RealtimeTracker::consentCoversOrganizers()`, from the `cookie_consent` cookie the same-origin beacon carries): it must allow analytics and carry the `org` token, which `cookie-consent.js` writes when the banner that was answered has `data-names-organizers`. The banner has that attribute exactly when its FIRST line, beside "Allow all", says that a schedule's organizer sees visits to its pages, which is while `RealtimeTracker::ownerViewEnabled()` (the second switch at `/admin/settings#realtime`, on by default on the nexus only). Sentence, attribute, token and reader are one thing: an attribute without the sentence lists people who were never told, and the sentence used to sit in the Choose panel, which "Allow all" never opens. Never work it out from WHEN a choice was made: the first version compared the choice's date with a stamp of when the wording changed, and listed anyone who answered the old banner afterwards (a marketing page served stale from the edge, a tab left open, a rolled-back deploy), and no test could run it because the comparison lived in the page. `is_team` rides in the signed context only when it is 1, so a tab opened before it shipped still verifies; on a schedule's own custom domain nobody is signed in, so an owner's visit there counts, which the page and the guides say ("while signed in"). The privacy policy (clauses 04, 06 and 12), `/features/analytics` (its FAQ and its meta description) and the banner sentence ask the same predicate. What people DID on those schedules stands beside the traffic and is read only through `ScheduleActivity`, from the owner's own records and never from `realtime_hits`: the Activity rail (the last 24 hours), the door card and the marks on the chart. It names nobody either, though most rows have a name one click away (Sales, Followers, Requests): no query there selects a buyer's, follower's, submitter's or guest's name, email or phone number (`users` appears only inside the NOT EXISTS that keeps a subscriber who already follows from being counted twice), a row's key is a hash, and a row links to a list page or, for a comment, photo or video, to the event's own form, never to a person. An appointment booking is an event NAMED AFTER ITS GUEST, so an event's title reaches this tab only through `ScheduleActivity::eventLabel()` (the appointment type for a booking; `ScheduleRealtime::pageLabel()` uses it too), and every query but the bookings' own leaves booking events out, or one waiting for approval is listed as an "event request" under the guest's name. Whose a record is follows the schedules the tab covers (`ownedEventIds()`: the event is that schedule's own, or was accepted onto one that is not a curator), never `Event::scopeManagedBy()`, whose "I created it" arm outlives a plan that closed the schedule to a team member. An import is not a sale; a box office sale and a cash order marked paid are counted and never a mark. Those tables are written on the app's clock and `realtime_hits` on UTC, so nothing there is compared with `RealtimeTracker::ts()`. The door card lists an event from the start of its day until it ends (six hours after it starts only when it has no length: kept that long after EVERY start, finished shows took the card's four places from the one in progress), under the date its tickets were sold for, never under `scheduleToday()`, and counts arrivals with `EventSeats::checkedIn()`, which loads no name and is held equal to `/checkin`'s number. Whose events those are is `ownedEventIds()`, a derived table of two arms (a schedule's own events from `events.creator_role_id`, accepted ones from a non-curator's pivot rows): one walk over every pivot row made a curator that lists fifty thousand events pay for all of them on every poll, and `IN (... UNION ...)` or two ORed subqueries cannot be a semi-join, so the sales read would fall back to every paid sale on the install. The sales reads also need `sales (status, paid_at)`, the only index on that table that begins with time: without it the 15-second read walks a viewer's whole sales history. A card that cannot be built (an event whose only ticket type is a pass) must not take one of the door's four places, and a row is filed under the schedule its event is covered through, never under a curator that merely lists it. The privacy policy (clauses 06 and 12) says that a sale beside an unnamed visit can identify it. `ScheduleRealtimeTest`, `ScheduleActivityTest`, the bit tests in `RealtimeBeaconTest` and `PrivacyLiveViewTest` fail the build otherwise. - **Event dates render in the SCHEDULE's timezone, never the viewer's** - `Event::getStartDateTime()` defaults to `scheduleTimezone()` (`creatorRole?->timezone ?: config('app.timezone')`); a viewer's `users.timezone` is reachable only through an explicit `$timezoneOverride`, which exists to show a guest their own local time (`AppointmentTimeUtils`). An occurrence falls on a given day because of where it happens, not who is looking. Anything deciding which DAY a date belongs to - day bucketing (`matchesDate()`), the Vue past-event filters and `userTimezone` in `role/partials/calendar.blade.php`, a "today" highlight - must resolve the SAME zone as the dates it compares, or an event is shown on one day and filtered out as though it were on another. A narrowed `get(['events.id', ...])` must include `events.creator_role_id`, or `BelongsTo` short-circuits on the null key and the zone silently falls back to the app timezone with no query and no error. `tests/Unit/EventVenueTimeRenderingTest.php` and `tests/Feature/CalendarTimezoneTest.php` fail the build if one creeps back. - **The month is one partial for the four pages that show one, and it reads its colours where the theme declares them** - the guest page, the embed, the admin's Schedule tab and the dashboard all include `role/partials/month` (the grid), `month-peek` (the card that opens beside a day and the panel behind "+N more", both teleported to `body`; the card keeps `id="event-popup"`) and `month-script` (`window.monthMixin`, mixed into `#calendar-app`), on `partials/month-kit-styles`, which `layouts/app-guest` and `layouts/app-admin` each include once. Until 2026-10 the guest page had a Vue grid and the other two a PHP one with a hover popup and a jQuery tooltip, and the two had drifted. Vue draws the structure from plain values (`monthWeeks`, `monthPeek`, `monthDayPanel`) and a fit pass (`monthFit()`) then writes what a binding leaves alone while its own value does not change (the `hidden` of an event past the cap, the three parts of "+N more", the text of a name it cuts) and the classes of its own (`gk-cal-tight` on the root, which has no `:class`; `gk-cal-ev-on`, `gk-cal-kin` and `gk-cal-day-open`, which it puts back after every draw because the elements they sit on do carry one). A name is printed by the `v-clamp` directive for that reason: a `v-text` there would write the whole name again on any redraw. A day's rows are COPIES carrying that day (`monthFacts()`), because the objects `getEventsForDate()` returns hold a series' first date, and Sold Out, Few left and the price would answer for another night. Somebody's text (a name, a venue, a team member) reaches the page only as text: `v-clamp`, `v-text` inside ``, or an attribute. A clock time is `` INSIDE the element that is placed at the end of the line: "7 PM" in a Hebrew month drew as "PM 7", and `dir` on the placed element itself turns its own start and end around. Nothing inside `.gk-cal` reads `--es-accent`: the calendar's wrapper sets it inline as the owner typed it, while the label for a fill (`--es-accent-text`) is made by `GuestTheme` for the fill `GuestTheme` chose, so a black accent on a dark page drew today's number black on black; the month reads `--cal-fill`, declared on `body`. The quieter inks step up where an event is tinted (`--cal-quiet`, `--cal-say`: the third ink fell to 2.1:1 on the tint of the event whose card is open), and the admin's month has a third ink and a readable blue of its own (the bridge at the foot of the kit), because the portal's are 3.2:1 to 4.2:1 as small text. Each of those was measured in a browser over fourteen accents and the six palettes, the numbers are in the stylesheet's comments, and a new tint or a new token there is measured the same way before it ships (black, mid grey, near white and a near-black blue are the accents that find a bad pairing). The address a card SHARES and its calendar links are built from `event.guest_url` (`monthPage()`); every other link is `getEventUrl()`, which carries the list's filters as the way back, and an event with no public page is given no link at all. The card's two calendar links are the `.ics` address with `?to=google|outlook`, answered by `EventController::downloadIcal()` behind the gate the file itself has. What is over today gives its place to what is not, unless nothing is left today; what a member is told about an event (Draft, Internal, Cancelled) is said on every day it is written, one that is over too. The stylesheet is printed only on a page that draws a calendar (`role/partials/calendar` sets `month_kit` on the request and the two layouts read it, which works because a page's body is rendered before its layout's head), and its notes are Blade comments so they are not sent. The dashboard's Edit Event and its plus are offered only where the page they lead to opens (`event.edit_admin` wants a schedule the event is on that the person edits; the plus goes to `HomeDashboard::addTargets()`). A watcher that waits for the draw runs AHEAD of a directive's `mounted` and `updated` hooks of the same draw, which is why `v-clamp` prints in `beforeMount` and `beforeUpdate`: with the later hooks the month was fitted around names that were not there yet. The Availability tab's grid is the server's, because there a day is the thing that is pressed, drawn on the same kit (`.gk-cal-pick`); a day keeps `.day-element`, `data-date` and the `.day-x` that marks it, which its script (`role/show-admin`) and the browser tests know it by. `MonthGridTest` holds what the server prints; `tests/Browser/SameDayEventOrderTest.php` the order of a day. - **Use `config('app.supported_languages')` for language lists** - Never hardcode language code arrays. Always reference the centralized list in `config/app.php`. - **Keep Help button mappings up-to-date** - When adding, removing, or moving doc pages, update the anchor map in `app/Utils/HelpUtils.php` so the admin panel Help button links to the correct docs for each section/tab - **Match docs structure to app layout** - Documentation sections and sub-sections should mirror the app's UI structure (sections, tabs, sidebar items) where it makes sense. This keeps the Help button deep links aligned and makes docs intuitive for users navigating between the app and docs. - **Raising the PHP minimum is a release of its own, and its zip changes name** - Composer resolves as if on PHP 8.2 (`config.platform.php` in `composer.json`), so an update on a newer PHP cannot slip an 8.3-only package into the release zip that 8.2 installs download. The zip ships with its dependencies installed and Composer's platform check stops the app on boot under an older PHP, so a selfhost install that updates past its PHP is DOWN until somebody upgrades PHP by hand. Two things stand between them: `AppUpdateService::performUpdate()` reads the PHP the fetched release needs (its `vendor/composer/platform_check.php`) and refuses before a file is replaced when this process, or the PHP the scheduler or the HTTP cron last ran on (`recordPhp()`, kept two days), or the website's (recorded by the App Update pages and a browser update, kept a month; from the command line, unseen, taken to be the oldest the INSTALLED version allows) is older; and an updater from before that check finds nothing to download, because such a release would be named `eventschedule-php8.3.zip` (today's is `eventschedule.zip`) and old updaters look for a name containing `eventschedule.zip` (`package_file_name` in `config/self-update.php` matches both). So when the floor rises: move `require.php` and `config.platform.php` together, rename the zip in `.github/workflows/build.yml` (both lines) and everywhere a person is told its name (README, `marketing/docs/selfhost/installation`, `marketing/selfhost`, the download link in `admin/app-update` and `profile/partials/update-app-form`), change every place that states the minimum (`grep -rn "8\.2"` over README, `MarketingController`, `marketing/selfhost`, `marketing/open-source`, `marketing/docs/selfhost/index` and `installation`, `marketing/docs/saas`), and say the new minimum in the release notes. Never publish such a release as `eventschedule.zip`. Separately, an update's migrations and cache clears run in the process that copied the files, which still holds the OLD framework's classes: from Laravel 11 to 12 that failed the first migration after every file was replaced (a 500, nothing migrated), so `AppUpdateService::finishUpdate()` completes it from the next process (at the start of each scheduler tick on both rails, and on the App Update pages for an install with no cron), once per installed state (the version and the migration files on disk): the migrations when any are pending, the caches only when something says they are stale (pending migrations, a cached config holding another version than the file on disk, or an in-place update that could not clear them), never merely because its marker is missing, which `cache:clear` empties. It is off in a git checkout (`AppUpdateService::finishes()`: `self-update.finish_updates` when set, otherwise whether there is no `.git`, read in code because a config cached under v1.0.139 lacks the key), which is deployed and migrated by hand and would otherwise race its own deploy; a performUpdate() holds the same lock from the copy to its own migrations, and marks a clean run finished. `AdminAppUpdateTest` holds the check, the name and the finishing. - **Keep `translateData` and `console.php` in sync** - Scheduled commands must be registered in both `AppController::translateData()` (the HTTP cron rail) and `routes/console.php` (the scheduler rail). Add it to both places with matching frequency, and with the SAME `config('app.hosted')` gate: a gate on one rail only means an install using the other rail runs a command it should not (that is how selfhost installs on `/translate_data` ended up mailing onboarding nudges). `tests/Feature/CronRailSyncTest.php` fails the build on presence, frequency, argument or gate-polarity drift (a deliberate cadence difference goes in its `CADENCE_EXCEPTIONS` list with the reasoning). Read its class docblock before trusting a green run - it lists the shapes it cannot see, such as a tier whose TTL changed and gates written with `->when()` instead of `if`. - **Every scheduler entry needs `->name()` and a bounded `->withoutOverlapping(N)`** - `CallbackEvent::withoutOverlapping()` throws without a prior `->name()`, so an unnamed entry has no overlap protection at all and logs as `Running [Callback]`; and its default expiry is 1440 MINUTES, released in a `finally` that a SIGKILL skips - so a container killed mid-run strands the mutex for a full day. `schedule:work` starts a new `schedule:run` every minute without waiting for the last one, so overlap is normal. Size N just above the entry's own budget. `tests/Feature/SchedulerHealthTest.php` fails the build otherwise. - **The event form is one `
` inside one Vue mount, and its tabs each owe three things** - `resources/views/event/edit.blade.php`. Never put another `` inside it: the browser drops the inner tag and its `_method` joins the event's own, which is how saving an event that had a carpool offer returned 405. A button that belongs to another form goes in the external-forms block after `
` and names its form with `form="..."`. A new tab needs an entry in `tabSummaries` and in `$tabLabels['tabs']` (the sidebar, the phone header and the save bar all read them, and a missing one throws in the browser), a prefix in `$errorTabPrefixes` so a refused save can say which tab to check, and an alias in `window.eventSectionAliases` if it replaces an id anything ever linked to. Anything wired once by element id (date pickers, the flyer input, the phone widget) sits under `v-show`, never `v-if`: Vue puts NEW elements back, and the listener stays on the old ones. Each thing is asked in one place: tickets live only in the Tickets tab (`#section-tickets`), whose three tiles set `ticketMode` and whose Payment, Checkout (titled Registration form on free registration), Sales rules, Promo codes and Add-ons are rows (`data-ticket-pane`, opened by `activeTicketTab`, where `tickets` means none), so an invalid field in a closed row can be found and shown (`revealField`). Checkout and Sales rules were one "Options" row until 2026-10, two panes under one name. A ticket type's sales dates are its own (`+ Add Sales Dates`, `ticketSalesDatesOpen`): its two hidden fields are always posted and its pickers are built with the type (`addTicket()`, and destroyed before a preset rebuilds the types), because the switch that showed them on every type, turned off, emptied every type's dates on the next save with nothing said. Promo codes and add-ons sit in `
` while tickets are off, because a half-filled row left behind refused the save in the browser and again on the server with nothing on screen. Saving with tickets off deletes the event's ticket types (`EventRepo::saveEvent`), so switching off is `Not needed` only, asks first when people have signed up, and is announced by the save bar. The setup guide stands beside the form's first visible `.max-w-xl` column (`SetupGuide.vue`, `fieldColumn()`) and its corner ring rides above the save bar by `--sg-bar`: a first event keeps that column, or the guide vanishes from the screen it matters most on. Engagement is rows too (`data-engagement-pane`, `activeEngagementTab`, where an empty string means none), and so is Page content (Gallery and Sponsors, `data-page-pane`, `activePageTab`), whose panes are `v-show` because the gallery editor holds the `gallery_images` input `syncGallery()` reads and the sponsor logo inputs are added by script; pressing the tab you are on leaves its rows alone. A tab that stopped being one keeps its address as an alias, `[tab, Engagement row, Page content row, element to bring into view]`: Gallery and Sponsors (`section-event-settings`) open their row, and Calendar sync, now the end of Listing, lands on `#listing-calendar-sync`, which the page's load-time scroll guard brings into view (`window.eventLoadFocus`), or that guard scrolls back to the top. Four things hold for every tab: what Save will REMOVE is said by the save bar first (`removes` in `barStatus`: ticket types, the agenda's times, the event's own sponsors), never done by leaving a field off the page; a row typed and not added (a participant, a sponsor) is added by Save (`pendingMember`, `pendingSponsor`: prevent, wait a tick, `requestSubmit()`), and never wiped by changing tab; every list the page is seeded with is seeded from `old()` after a refused save, visibility first of all (Draft chosen and refused used to come back Public, and the next save published), the venue too (another venue chosen and refused used to come back on the stored one, and the next save kept the event there; only a venue the page offers is looked up, and a change is still measured against the stored one, `savedVenueId`) and the Tickets tab as a whole (the mode came back without the ticket types once, so the next save published one free unlimited ticket; a sponsor given a new logo rides in `pending_event_sponsors`; and the tab's single fields, some twenty-five of them, are laid over the stored event from the flashed input itself (`$ticketFieldsTyped`, the last spread in `data().event`, empty on an ordinary load): a limit typed and refused used to come back blank, and the next save made registration unlimited. Each list is taken from the post only when the post could hold it, since promo codes and add-ons are not sent while tickets are off, and a refused field of a choice that is NOT on screen goes back to what is saved, because it rides in a hidden field where nobody could fix it), and a refused page starts as unsaved so Cancel asks; and a change made by a button (a switch, a tile, a row removed) calls `markTabDirty()` itself, because it fires no `input` event. The fields of a choice that is not the chosen one are disabled, with a hidden stand-in where their saved value must still be posted: left live, a half-typed link under "Tickets elsewhere" refused the save from a field nobody could see. Nothing writes to the page's data on the `input` of a dropdown, a checkbox or a radio: a browser fires `input` and then `change`, Vue redraws in between if anything was written, and the redraw puts a `