Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesSpace ContextSpace context: working inside a branch › space

Space context: working inside a branch › space

Status: plan, decisions resolved 2026-10-12, nothing built · Linear: TKN-459 (parent TKN-358) · Owner: Saar Companions: wireframes.md · prototype/index.html (open the file directly; no build, no network) Builds on: ADR-0024 (spaces, buckets, layered policy) · ADR-0023 (capability without a role) · ADR-0004 (org isolation)

Three levels (decision 16, 2026-10-15, TKN-479; ADR-0024 D13):

LevelProduct term (en / he / ru)What it isData
BranchBranch / סניף / ФилиалA separate business: own billing, own everythingorganizations row (TKN-358)
HallSpace in code and in this document; Hall in the product / מתחם / ЗалA perimeter inside the business that can have its own plans, reports and schedule. Everything this document calls a “space” is a halllocations row, kind = 'hall'
AreaArea / אזור / ЗонаA place inside a hall (“Main Floor”, “Floor B”). Every plan, report, schedule and rule of the hall applies; it only says where a class runslocations row, kind = 'area', parent_id = its hall (§8.5)

This document is the source of truth for the working-context model. Saar resolved the open decisions on 2026-10-12 (§14). It specifies no visual world beyond the existing Deep Ocean system (apps/web/DESIGN.md).

Summary

  • Staff work inside a space (for example Parquet) or in All spaces. One control, in the place of today’s org switcher, shows and changes Business › Space.
  • Spaces are for focus, not restriction. Every staff member can see and switch to every space by default. The owner may restrict a specific staff member to chosen spaces; that is an explicit opt-in, enforced by the server and presented quietly.
  • The active space lives in the URL (?space=<id>). A cookie remembers the last choice per business and fills the URL only when a link carries no space. The server checks the parameter against the caller’s spaces.
  • Every session has a space. Each business has a default space. A session inherits its class type’s space, and changing a class type’s space moves its future sessions that still follow it, after a preview.
  • The schedule folds by space inside each view, like the coaching program board: every view groups its grid into collapsible space sections. The active space is open and pinned first; the other spaces fold to a count per day. Dragging a card to another space section moves the session.
  • Money is always the whole business. Reports that can be attributed to a space (attendance, sessions, fill rate, check-ins, trials) follow the space; revenue, payments, debt and plans say “Whole business”.
  • Businesses with one space (most customers) see no change at all: no URL parameter, no chip, no extra request, and the control is today’s org switcher. Their one space is the default space and stays invisible.
  • One flag, spaces (it shipped behind its own space-context flag, folded into spaces on 2026-10-18). The network level (HQ) has a reserved slot in the control, the URL and the type, and nothing is built for it (TKN-386 is not validated).

1. Job and audience

Visitor mode: Operate. Scanability, consistency, native expectations and the real usage scene outrank expression.

The person arriving is staff at a fitness business, usually on a phone between classes or at the desk by the door. They come to do one thing in one place: run the 18:00 class in Parquet, book a walk-in, call a lead about a trial. Today the product makes them configure the space per entity (class type default, then each session) and gives them no way to look at one space first. On a phone they can’t even see which business they are in: at 390px the top bar shows the page title and nothing else.

The job: let staff work inside the space they are in, always show them which context they are in, and put new things in that space without being asked.

Personas × scenarios

The table describes the default, where every staff member has every space. An owner can restrict a staff member (§6.5); the last column says what that changes.

Persona (who they are in Taikan today)SceneDefault context on loginCan switch toComes to answerMust never happenIf the owner restricts them
Coach who works in Parquet: coachPhone between classes; tablet kiosk at the Parquet doorThe last space used; on first visit, the space of their next coached session today, else All spacesEvery space, All spaces, other businesses where they hold a membershipWhat is my next class here and who is booked? Who is new, injured, or missing a waiver? Check in the walk-in. What is programmed today?Seeing payments, debts, leads or reports (role matrix, unchanged)Parquet only; the switcher is a label, not a menu. The sessions they coach elsewhere stay visible
Coach who teaches in several spaces: coachPhone; sometimes the deskThe last space used; on first visit, the space of their next coached session, else All spacesEvery space, All spacesWhere do I teach next (the board’s rows answer it)? Which of my classes is under-booked this week?Finance, leads (role matrix)“All my spaces (n)” and each of their spaces
Admin or front desk: admin (a front-desk person who only works leads is a coach with a Sales grant, row below)Desk computer by the entrance; phone on the floorThe last space used; on first visit All spacesEvery active space, All spaces, other businesses where grantedWho is arriving now? Book a walk-in into Parquet 18:00. Who owes money? Which class is full with a waitlist? Move a class to another hallPayment-provider settings and Taikan billing (owner only, unchanged). Other businesses’ dataRare. Their spaces only for space-bound data; money and members stay whole business
Sales: a coach with a Sales grant (ADR-0023), or an admin. A lead wants a trial in ParquetDesk; phone and WhatsAppAll spaces: leads belong to the businessEvery space. A space focuses trials and session suggestions (§5.3)Who do I call now? Which trials are today? Did the trial show? Book the trial into a Parquet classMember conversations and payment settings (ADR-0023, unchanged)Trials only into their spaces’ sessions; the Sales grant still decides which leads
Owner, one space (the majority)Phone between classes; laptop at nightThe business. Nothing new appearsOther businesses, exactly as todayUnchangedAny new control, parameter or request. The one space is the default space and stays invisiblen/a
Owner, several spaces (Victor, CrossFit Rishon City, 3 spaces, Pro)Laptop and phoneThe last space used; on first visit All spacesAny space, All spaces, other businessesHow full is each hall this week? Where do classes clash? Which space gets the attendance? How is the business doing (revenue is business-wide)? Which staff, if any, should work only in one hall?A number that looks like a space’s when it is the whole business: every number says “Whole business” or names the spacen/a (the owner is never restricted)
HQ / network owner (reserved, TKN-386)LaptopToday: the last business used. Later: Network › Business › SpaceToday: businesses where they hold a membership, and the spaces inside eachToday: one business at a time. Later: compare branchesAny business where they hold no membership, even by editing the URL (TKN-28 scenario 3). A role in A gives no authority in Bn/a
Member (member app, separate shell, brief)PhoneNo working context. The member app never shows a context switcherNothing to switch. The schedule opens on their derived main space (§6.8), with every other space one tap away; it never limits what they can bookWhere is my class? How many entries do I have left, per bucket (“CrossFit 9 left, Parquet 4 left”, TKN-446)?Seeing staff restrictions or other members’ bookings. Booking outside their buckets is refused with not_covered_by_plan (TKN-446)n/a

A nutrition expert or physio is not a persona here: they are staff (usually a coach) and use the same defaults. PRODUCT.md keeps “not nutrition coaching”.


2. Outcome and proof

Primary outcome: in a business with 2+ spaces, staff stop setting the space by hand, every session lands in a space, and every screen tells them which space they are looking at.

ProofHow it is measured
Sessions get their space without anyone typing itPostHog class_session_created gains space_source: context | class_type | template | org_default | manual. Target: 90% or more not manual in 2+ space businesses. After the NOT NULL migration, zero sessions without a space
Moving a class to another hall is one gestureclass_session_space_moved {via: drag | menu | sheet | cascade, override}. Victor moves a session by dragging it to another space row without opening the sheet
Nobody loses track of the contextspace_context_switched {from, to, route, via: sidebar | topbar | deep_link}. Qualitative check with Victor and Oron on a phone: they can name the context without opening a menu
A restriction holds where the owner set oneIntegration specs per space-bound endpoint, modelled on the cross-org specs (FIT-124): a staff member restricted to Parquet gets no Main hall data through the web API, Spotter and MCP, and still gets their own coached sessions
One-space businesses are untouchedFor a user with one membership in a one-space business, the visual diff of the sidebar header, the top bar and the schedule is empty. No space parameter is ever written. No extra request on any dashboard route
Rishon City’s real case worksWith 3 spaces: Victor plans the week on the folded board; a coach who works in Parquet opens straight into Parquet and folds the other halls away

Product truth. PRODUCT.md now says users are “almost always a single branch; most train in one space, and a minority (Pro) run two to five spaces” and keeps “not an enterprise multi-location platform” (applied 2026-10-12, §15.3). The design therefore treats multiple spaces as a Pro feature that appears only when it is used, never as a new navigation layer for everyone.


3. Selected direction

Thesis: a lens, with an optional fence. The working context is a lens carried in the URL and shown in one switcher. It decides what comes first, what is expanded, and where new things go. It never hides another space from someone allowed to see it. A fence exists only where the owner builds one for a specific staff member; the server enforces it, and the interface mentions it in one quiet line.

  • Visual authority: Deep Ocean (apps/web/DESIGN.md), the shadcn sidebar shell, unchanged. No new colour, no new component family. The control is today’s org-switcher block with a second line. The schedule board reuses the coaching program board’s WeekGrid, row toggle and CollapsedCell.
  • Focal moment: the switcher, exactly one on screen: the sidebar header on desktop (business, then the space line), or one button at the start of the top bar when the sidebar isn’t visible: [(o) Parquet ▾] Schedule. One of the two is visible at every width, including phone, where today nothing shows the context.
  • Sequence: login → context resolved before the first scoped fetch → every scoped region loads for that context → switching keeps the route and re-focuses in place.
  • Implementation consequence: one useWorkingContext() hook, one spaceId query parameter on space-bound reads, one SpaceScopeService on the server, one default space per business. Pages don’t invent their own space filters or second space controls; the switcher is the only one.

Rejected alternatives

AlternativeWhy not
A space filter on each pageStaff would set it again on every page, and nothing tells them the context globally. This is the same “too many settings to remember” problem moved into filters
One lane per space in the schedule, or a schedule filtered to one spaceRejected by Saar (2026-10-12). Filtering hides the other halls, which a desk needs to see; lanes break down past three spaces and on phone. The folded board keeps every space in view, focused on one
Spaces as a hard access boundary for everyoneRejected by Saar (2026-10-12). Most businesses don’t need it, and a forbidden page punishes a coach for following a link. Restriction is opt-in per staff member
A separate org per spaceIt breaks the shared entry quota across spaces (TKN-446, Victor’s pooled 9 + 4), splits one member into several, and contradicts ADR-0024 D1
Context only in a cookie (like today’s active org)Deep links open in the recipient’s last space, two tabs fight over one value, and the URL doesn’t say what the page shows
A space segment in the path (/dashboard/s/<space>/…)It moves about 40 pages under a new dynamic segment and gives one-space businesses a segment they don’t need. The query parameter gives the same deep links for a fraction of the change
Colour per spaceThe One Teal Rule and Green Means Done already use the meaningful hues; status colours would collide with space colours. The space’s name as plain text is unambiguous, and it appears only where §7.6 allows (decision 5)

4. Scope and anti-goals

In scope: the context model (shape, URL, default, API, optional restrictions), the switcher and the context indicator, the scope of every dashboard route and key action, creation defaults, the default space and the session-space invariant, the folded schedule and moving sessions, the class-type space cascade, reports scoping, how ADR-0024 surfaces in context, implementation slices. Deliverables: this plan, wireframes.md, and a lo-fi clickable prototype.

Fidelity: planning and lo-fi wireframes. No production code, migrations or app UI changes in this pass. The one product-doc change is the PRODUCT.md positioning, applied on Saar’s decision.

Anti-goals

  • No HQ dashboard, network role, network table or consolidated cross-branch report (reserved only; TKN-386, TKN-387).
  • No new membership_role values (no sales, front-desk or nutrition role). Capabilities follow the ADR-0023 pattern.
  • No restriction by default, and no forbidden page.
  • No space for business-wide money: payments, subscriptions, debt and revenue are never allocated to a space.
  • No per-space colour theme, no per-space branding.
  • No change for one-space businesses.
  • No member-app context switcher. The member sees spaces as facts on sessions and as a schedule filter.
  • No capacity pooling or utilisation planning across spaces (TKN-391).

5. Scope map

Legend: Space: the data carries a space; the page focuses on the active space and keeps the others reachable. Business: the data has no space, so the page ignores the context and shows a “Whole business” tag when a single space is active. Both: in a single space the page focuses or filters (a removable chip, never a hidden row); in All spaces it aggregates, with a breakdown by space. Either way, a space is named only in the places listed in §7.6; rows, items and tiles carry no space name.

For a staff member the owner restricted, space-bound data from other spaces is never returned by the server (§6.5); business data follows the role matrix as today.

5.1 Dashboard routes

All routes are under /[lang]/dashboard/ unless noted.

RouteScopeData rule (what carries the space, or why it is business-wide)One space activeAll spaces
overviewBothFeed items from sessions, bookings and check-ins use class_sessions.location_id. Debt, payment and lead items have no spaceThis space’s items, then business items; no tags or space names on items (§12.4)Every item as one plain total; no per-space counts
homeBothThe Train/Track/Thrive cards read the same sources as their pagesTrain counts for this spaceTotals
scheduleSpaceclass_sessions.location_id, NOT NULL after S4 (§8)Folded board (§8.1): this space’s row expanded and pinned first, the other rows folded to counts per dayEvery space row; expanded up to 5 spaces, folded above 5 with a search
schedule/sessions/[id]/builderSpace (entity)The session’s location_idOpens for any session the caller may seeSame
programs (incl. templates)Business config, space-aware applyPrograms have no space. program_templates.default_location_id and program_template_slots.location_id exist; the UI has no slot space picker yetApply targets this space (§7.7)Apply resolves slot → template → class type → org default; never “needs a space”
coachingBusinessCohorts and assignments have no spaceUnchanged, “Whole business” tagUnchanged
courses (incl. [id])BothLive events: course_sessions.location_id (stays nullable: online events use location_text). The catalogue has no spaceLive events in this space first; no new labelsUnchanged
workouts, movementsBusinessLibrary content has no spaceUnchanged, tagUnchanged
members, members/[id]Both (focus only)Members belong to the business for everyone. “Active in” and the main space are derived on the server (§5.3, §6.8)A removable “Active in Parquet” chip in the search bar, on by default, then a third group “No check-ins in the last 60 days (n)” that the chip never hides; clearing the chip shows the whole business. Members whose main space is Parquet sort first. Rows carry no space names. Never hidden, also for restricted staffToday’s list: no “Active in” column, no space names in rows
leads (Sales)BothLeads have no space. The trial’s space is a new nullable lead_trial_appointments.location_idLeads with a trial in this space plus leads with no trial yet; a line “12 more in other spaces”. Rows carry no spaceThe whole queue; no trial-space column
messagesBothConversations have no space. Announcements get an audience scope (§5.3)Inbox focused on members active here (removable chip). A new announcement defaults to “Members active in Parquet”Everything. A new announcement defaults to everyone
tasksBothNew nullable tasks.location_id; null means the whole businessTasks in this space, then whole-business tasks; no labelsAll tasks; no space column
plans, plans/[id], plans/groups/[id]BusinessPlans have no space. plan_entitlement_buckets.scope names spaces (TKN-446): coverage is set in the bucket editor and reported only in Reports (§12.2)Unchanged, “Whole business” tag; no coverage badgeUnchanged; no coverage column
paymentsBusinessPayments and subscriptions carry no space and are never allocatedUnchanged, “Whole business” tagUnchanged
analyticsBothAttendance, sessions, fill rate, check-ins and trials carry a space. Revenue, payments, debt, active members and retention are business-wideSpace reports for this space with no space names on tiles; business tiles tagged “Whole business” only next to space tiles, one tag per business-only tab (§12, S5)Totals and a breakdown table per space
automations (incl. [id], new)BusinessAutomations have no space. Later, a space condition on triggers using the ADR-0024 D9 condition modelUnchanged, tagUnchanged
forms (incl. [formId], new)BusinessA waiver belongs to the person; the booking gate is business-wideUnchanged, tagUnchanged
join-linksBusinessLinks have no space (a trial space on a link is a later option)Unchanged, tagUnchanged
minisite, brandingBusinessThe public face of the businessUnchanged, tagUnchanged
audit-logBusinessOwner-only ledgerUnchanged, tagUnchanged
settings/locations (Spaces)BusinessThe locations rows; the default space; per-staff restrictions are set on the staff rowUnchangedUnchanged
settings/bookingBothbooking_policy_rules.scope can name a space (TKN-447). Org columns are the default layerThe effective rule for this space, each setting with decidedBy (“Business default” or “This space’s rule”, §9)The org default, then the per-space overrides by name: this is where a space’s rule is configured (§7.6, item 6)
Other settings tabs (general, billing, payments, integrations, notifications, preferences, admin)BusinessOrg columnsUnchanged, tagUnchanged
/[lang]/kiosk/[sessionId]Space (entity)The session’s spaceHeader shows the spacen/a
/[lang]/kiosk/space/[spaceId] (new, S7)SpaceSessions in this space now and nextDoor mode follows the current sessionn/a
Spotter (panel, not a route)BothTools call the same services as the membershipReads focus on this space; writes default locationId to it; a write’s confirmation names the space, like the sheet’s Space field (§7.6, item 3)Reads span the caller’s spaces; writes use the class type’s space or the org default

5.2 Key actions

ActionScopeRule
Create a sessionSpaceSpace = the active space; in All spaces, the class type’s space, else the org default. Shown and changeable at the top of the sheet. Never empty
Move a session to another spaceSpaceDrag the card to another space row, use “Move to space” on the card, or change the space at the top of the sheet. TKN-444 conflict and capacity checks run; owner/admin may override with a reason. The session stops following its class type (§8.4)
Change a class type’s spaceSpace (cascade)Moves its future sessions that still follow it, after a preview (§8.4). Past sessions never move
Apply a template, copy a week, replicate a daySpaceIn a space: every slot without its own space lands in the active space; copy-week copies only this space. In All: slot → template → class type → org default
Book a member into a session (staff)SpaceThe member can be anyone in the business (members are never hidden)
Check in, mark no-show, cancel a sessionSpaceAs today
Create a class typeBusinessSpace prefilled with the active space; empty means “org default space”
Create a taskBothlocation_id = the active space, with a “Whole business” toggle
Send an announcementBothAudience = members active in the active space; the author can widen it
Book a trial (Sales)BothTrial space = the active space; session suggestions come from that space first
Create a plan (owner/admin)BusinessWith plan buckets (TKN-446): the first bucket’s scope is prefilled with the active space
Charge, refund, change a subscriptionBusinessUnchanged. Never filtered by space
Export a reportBothThe export carries the spaceId it was run with, and says so in the file header; business reports say “Whole business”
Restrict a staff member to spacesBusinessOwner only, on the staff row; audited (§6.5)
Set the default spaceBusinessOwner/admin in Settings → Spaces; exactly one per business

5.3 The hard ones, settled

  • Members. A member belongs to the business, never to a space, and no one’s member list is limited by space, including restricted staff. The server derives two read-only facts per member (§6.8) and the client never computes them (PRODUCT.md principle 4):
    • activeInSpaceIds: the spaces where they checked in at least once in the last 60 days (attended bookings only). Many members are active in two or three spaces. Plan coverage is not part of it (§6.8).
    • mainSpaceId: the space where they checked in most in the last 60 days (§6.8). It orders the list and attributes counts; it is never shown on a row and never limits anything.
    • In a space, the list shows three groups in this order: members whose main space is this space, other members active in this space, then “No check-ins in the last 60 days (n)”: members with no attended booking in the window, which covers new joiners, freshly converted leads, and members of a business that rarely checks people in. The “Active in Parquet” chip, which anyone can clear, filters the first two groups only; the third group always shows, so a new joiner is never invisible from a space view.
    • The count line reads “{active} active in Parquet · {notActive} not active here”, with a separate line “{n} with no check-ins in the last 60 days”. notActive excludes that group.
    • Rows carry no space names: no “Active in” column, no chips, no ”· main”. The focus chip and the count line carry the context; the one per-space line under “Classes this month” on the member page is the only place a member’s spaces are listed (§7.6). In All spaces the list is today’s list.
    • Search always covers the whole business, so a walk-in from another hall is one search away.
  • Plans. Business-wide catalogue, the same in every context: no coverage badge, column or reordering. A plan’s spaces are set in its bucket editor (TKN-446) and reported only in the Reports block “Plan coverage vs. check-ins by space” (§12.2).
  • Leads and trials. A lead belongs to the business. A trial is where a space appears: lead_trial_appointments gets a nullable location_id (today it has trial_type text and no session link). In a space, the Sales queue shows leads with a trial in this space plus leads with no trial yet, and a line “12 more in other spaces”. Lead and trial rows carry no space name. Space never narrows the Sales grant (ADR-0023 own/team still decides which leads).
  • Tasks. tasks gets a nullable location_id. A space shows its tasks, then whole-business tasks; task rows carry no space name. Lead tasks follow the Sales scope (ADR-0023 R6), not the space.
  • Messages and announcements. Direct conversations stay business-wide. Announcements gain an audience scope using the ADR-0024 D9 condition model (space in [...], meaning members active in those spaces). When the audience is “members active in Parquet”, the author also sees an opt-in checkbox ”+ n with no check-ins in the last 60 days”, unchecked by default, so a new joiner can get the welcome note without receiving every hall’s news. Recipients are resolved and stored when the announcement is sent, so a member who moves later doesn’t change who received it.
  • Analytics. Space reports come from sessions and trials (attendance, sessions, fill rate, check-ins, no-shows, trials). In All spaces: totals, then one row per space. Distinct-person counts by “active in” are not additive; the table says so in a footnote. Next to them, a “by main space” view counts each person once, under their main space (plus a “No check-ins in the last 60 days” row), so its rows add up to the total. Revenue, payments, debt, plans, active members and retention stay whole-business, tagged, and ignore the space (S5). Plan coverage appears only in Reports, in one aggregated block, “Plan coverage vs. check-ins by space” (§12.2).
  • Spotter AI. The <org_context> block gains the space (Parquet (id) or all spaces). Read tools focus on the active space; write tools default locationId to it; a write’s confirmation names the space (“Create 12 sessions in Parquet”), the same job as the sheet’s Space field. A restricted caller’s tools go through the same services, so the restriction holds. Each user turn stores the context it was sent from.
  • Automations. Business-wide and owner/admin only. A later trigger condition can name a space (“trial booked in Parquet”) through the D9 model. No context default.
  • Check-in kiosk. Today’s kiosk is per session (/kiosk/:sessionId) and inherits the session’s space; its header will show the space. A new door mode (/kiosk/space/:spaceId) follows the current and next session in one space, for the tablet at the Parquet door. GPS self check-in keeps using each space’s coordinates (ADR-0024 D1).
  • Settings and policies. Settings are business-wide except the booking policy tab, which shows the effective rule for the active space under TKN-447/448 (§9). Spaces, the default space and staff restrictions are edited at business level.

6. Context model

6.1 Shape

// libs/shared — illustrative, not code to add in this pass type SpaceSelection = { kind: 'all' } | { kind: 'one'; spaceId: string }; type WorkingContext = { level: 'branch'; // 'network' reserved for TKN-386; not implemented orgId: string; space: SpaceSelection; // always { kind: 'all' } when the org has 0–1 active spaces };

The context is {orgId, spaceId | 'all'}. The level discriminant is the only HQ seam in the type: adding 'network' later is additive.

6.2 Where it lives (decision 1)

OptionDeep linksSeveral tabsURL reflects stateCost
Query parameter ?space=<id> (chosen)YesYesYesOne scopedHref() for links; a link without the parameter falls back safely
Path segment /dashboard/s/<space>/…YesYesYesAbout 40 pages move under a dynamic segment
Cookie only (today’s org model)NoNoNoCheapest
Server-side user preferenceNoNoNoCross-device default only
  • ?space=<locationId> or ?space=all on every dashboard URL of a business with 2+ active spaces. Never written for 0–1 spaces. Analytics already keeps its state in the URL (?tab=…&from=…&to=…), so this follows an existing pattern and meets the Web Interface Guidelines rule “URL reflects state”.
  • Several tabs: each tab owns its URL. Deep links: schedule?space=<parquet>&week=2026-10-12 opens Parquet for anyone allowed to see it.
  • Remembering: cookie taikan_space, a map { [orgId]: spaceId | 'all' }, written only on an explicit switch. It fills a URL that has no space (login, links in emails and push notifications, old bookmarks). It never overrides a URL that has one.
  • Server default: the protected layout already reads the active-org cookie and /users/me on the server (lib/api.ts). It resolves the default space the same way and passes it down, following the ActiveOrgProvider pattern. The client replaces the URL without a history entry. Scoped queries wait for the resolved context, so nothing flashes the wrong space.
  • Org stays where it is: the active business remains in the taikan_active_org cookie. A space id implies its org, so a deep link with a space from another business the user works in switches the business (full navigation) and announces “Opened in {org}“.

6.3 Default context on login (no space in the URL)

  1. The business has 0–1 active spaces → no space. Stop.
  2. The cookie remembers a space for this business and the caller may still use it → that space (or all).
  3. The caller is restricted to exactly one space → that space.
  4. The caller coaches a session in the next 24 hours → that session’s space (/users/me nextSessionSpaceId).
  5. Otherwise → all (“All my spaces (n)” for a restricted caller).

6.4 How the API receives it

  • Query parameter spaceId on space-bound reads, matching existing camelCase filters (weekStart, programId). For example GET /organizations/:orgId/class-sessions?weekStart=…&spaceId=….
  • For the board, spaceId orders and expands; it does not filter. The week endpoint returns every space the caller may see, with per-day counts for every row and full cards for expanded rows (expand=<ids>), so a folded row costs a count, not a payload.
  • No parameter means everything the caller may see. Clients that don’t know about spaces keep working: the mobile app, MCP, older web builds.
  • Writes use locationId in the body (it already exists on sessions). When omitted, the server fills it: class type space, else the org default (§8.3). It never stores null.
  • Business-wide endpoints don’t accept spaceId.
  • Why not a header: React Query keys come from path and query, so a parameter separates the cache per space by construction. MCP and Spotter tools map a parameter to an argument one-to-one. Logs and Sentry show it.
  • /users/me adds, per membership, the business’s active spaces (id, name, sortOrder, isDefault) and the caller’s spaceScope, so the switcher renders with no extra request.

6.5 Optional staff restrictions (decisions 2, 3, 7)

Default: no restriction. Every staff member has every space and switches freely. The owner may restrict a specific staff member to chosen spaces; that is the only restriction mechanism.

memberships.space_scope enum('all','assigned') NOT NULL DEFAULT 'all' membership_spaces membership_id uuid FK memberships(id) ON DELETE CASCADE location_id uuid FK locations(id) organization_id uuid FK organizations(id) granted_by_id uuid FK users(id) created_at timestamptz PRIMARY KEY (membership_id, location_id) INDEX (organization_id, location_id)
  • Why a table and an explicit mode: foreign keys, cascade, one audit row per change, an index for “who works in Parquet” (TKN-313), and an empty set never means “everything”: assigned with no rows means no space-bound data.
  • Who: the owner restricts coaches and admins. The owner is never restricted; the API refuses it. Members have no working context. Every existing membership starts as all, which is today’s behavior, so the migration is additive.
  • Where: Settings → Staff, on the staff row: “Spaces: All spaces ▾” with “Only these spaces…”. Audited as membership.spaces_updated with before and after.

Enforcement (decision 1). SpaceScopeService.scopeFor(membership) is read on every request, so a change applies on the next request.

  • A space-bound read with an explicit spaceId outside the caller’s scope returns 403 space_not_in_scope and no data. A read without spaceId returns only the caller’s spaces.
  • Space-bound rows (sessions, bookings, check-ins, course sessions, kiosk rosters, template applies, trials) filter on location_id IN (allowed) or coach_membership_id = the caller: a coach always sees the sessions they coach.
  • Writes with a locationId outside scope are refused; a restricted caller’s create defaults to their first space.
  • Members, plans, leads and business data are not space-filtered for anyone. The role matrix still decides access to modules; PermissionsService.enforce does not change.
  • Spotter and MCP tools inherit enforcement because they call the same services.

Relaxed presentation. The client resolves the URL against /users/me before any fetch, so the 403 is a backstop the UI never shows. A link to a space outside the caller’s scope opens their own space with one quiet line: “Main hall isn’t in your spaces, showing Parquet”. No lock, no error page.

6.6 What “All spaces” means

  • Board: every space row, in sort_order; with more than 5 spaces, rows fold and a search appears (the program board’s COMPACT_ABOVE).
  • Lists: the union of the caller’s spaces; rows carry no space name (§7.6). Where the split matters, a breakdown block shows it.
  • Counts and sums: totals first, then one row per space. Every session has exactly one space (§8.3), so session-based totals equal the sum of the rows.
  • Distinct people (unique attendees, members active) are not additive; the breakdown says so in a footnote.
  • For a restricted caller, “All my spaces (n)” is the union of their spaces plus sessions they coach.
  • Business-wide numbers (revenue, payments, debt, active members, retention) appear once, never split, and untagged: nothing in All spaces is filtered, so no tag is needed.

6.7 Missing, other-business and closed spaces

SituationResponseWhat the user sees
Space in this business, outside a restricted caller’s scopeClient resolves first; API backstop 403 space_not_in_scope, no dataTheir own space, with the line “{space} isn’t in your spaces, showing {mine}“
Space id from another business where the caller is staffNot an errorFull navigation to that business and space, “Opened in {org}“
Space id the caller can’t see anywhere404 (ADR-0004)The standard not-found page with “Go to {current space}“
Space deactivated404 space_inactive”Main hall is closed. Its history stays in All spaces.” with “Show all spaces”. Deactivating a space first moves its future sessions (§8.4 preview)
Restriction added while the page is openThe next request comes back without the removed spaceThe page re-focuses on the caller’s first space with the same quiet line
Role cannot use the module (a coach opens analytics)403 from the role matrix, unchangedToday’s role guard; not a space concern

6.8 The member read model: presence is check-ins (approved 2026-10-12, revised the same day)

Members belong to the business. Plan and attendance are separate signals. The plan is the booking gate (TKN-446 buckets decide where a member may book) and changes over time; it drives no widget and is not part of any field below. Presence is what the member actually did: attended (checked-in) bookings only, bookings.status = 'attended' (equivalently checked_in_at not null), on sessions in the space. Booked, confirmed, waitlisted and no-show bookings don’t count. Note that the auto no-show job turns an unchecked booking into no_show 2 hours after class, so a business that never checks people in has almost no presence.

Three facts are derived by the server on read, returned on member lists and the member detail, and never stored or edited.

FieldRuleUsed forNever used for
activeInSpaceIdsSpaces with at least one check-in in the last 60 daysThe members focus chip and count line, announcement audiences, “active in” report counts, cross-space membersAccess. A member may book wherever their plan allows
mainSpaceIdThe space with the most check-ins in the last 60 days; ties go to the space of the most recent check-in. No check-ins: null (no plan fallback)The member app’s schedule opens on it; the “by main space” report view; members list order (main space here first)Limiting, filtering or hiding anything; a label on any row. Never stored, never editable
noRecentCheckInsactiveInSpaceIds is emptyThe “No check-ins in the last 60 days” group in space views and the opt-in checkbox on announcementsHiding the member

The same presence rule drives every member-based space metric: “active in”, “trained in {space}”, main space, training across spaces, “new to {space}” (first-ever check-in in that space) and “last active in”. Session-derived widgets that count bookings (Today’s classes, fill, hall load, waitlist, no-show rate) are unchanged: they describe sessions, not member presence.

Few check-ins. When fewer than 10% of the business’s (or the space’s) past bookings in the 60-day window were checked in, every member-based space widget and the members list show one quiet line, “Space numbers come from check-ins. Few check-ins were recorded in the last 60 days.” with a link “Set up check-in” (kiosk door mode, QR). The widget stays; it is never hidden.

  • Computed in one SQL pass per page of members (a grouped count of attended bookings joined to class_sessions for the window), next to the existing member list query; no new column, no cache table. If EXPLAIN shows it is heavy, an index on bookings (membership_id, status, checked_in_at) is the first step.
  • The 60-day window matches “active in”, so the two never disagree about whether a member trains somewhere.
  • In a one-space business the fields exist but nothing renders them.

7. Switcher behavior

7.1 One control, one placement at a time

Exactly one switcher is on screen at any time (decision 15). It is one component with one menu; where it sits depends on whether the sidebar is visible.

SituationThe switcherThe other placement
Desktop, sidebar openThe sidebar header. It replaces OrgSwitcher in the same place. Line 1: logo and business name, as today. Line 2, only with 2+ spaces: a pin icon and the space (“Parquet”, “All spaces”, “All my spaces (2)”), steel 12pxThe top bar shows today’s page breadcrumb only: no space button
Desktop, sidebar hidden (today’s collapse is shadcn’s default offcanvas: sidebar-nav.tsx sets no collapsible)One button at the start of the top bar, after the sidebar toggle and before the page breadcrumb: [(o) Parquet ▾]. Its menu holds the business tooThe sidebar isn’t rendered. If an icon rail (collapsible="icon") is adopted later, its header collapses to the logo, never a second switcher
Phone, sidebar sheet closedThe same top-bar buttonThe sidebar header is off screen
Phone, sidebar sheet openThe sheet’s headerThe top-bar button sits under the sheet’s scrim and is inert
  • The switcher is the context indicator. With the sidebar open it is the sidebar header; otherwise it is the top-bar button. Either way one is always visible, which keeps the context in view for every persona, the HQ user included.
  • Page titles carry no ”· Parquet” suffix (§7.6). In a single space the only space label in the chrome is the switcher itself.
  • On a phone the menu opens as a bottom sheet; on desktop it is a popover anchored to the switcher.
  • When the placement changes while the menu is open (the sidebar is toggled, or the window crosses the phone breakpoint), the menu closes.

7.2 Menu content

  1. Spaces group (2+ spaces the caller may use): All spaces first (“All my spaces (n)” when restricted), then each space in sort_order. A check marks the current one. With more than 7 spaces a filter field appears at the top.
  2. Switch organization group (only with 2+ memberships, reusing today’s label and rows): each business with the caller’s role.
  3. Reserved, not rendered: a leading Network group for TKN-386.

Someone restricted to one space has no Spaces group; with one membership as well, the switcher is a plain label, not a button.

7.3 Keyboard and screen reader

  • The trigger is a button with aria-haspopup="menu", aria-expanded, aria-controls, and an accessible name such as “Working context: CrossFit Rishon City, Parquet. Change.” Enter, Space and ArrowDown open the menu.
  • The menu is role="menu"; each group is role="group" labelled by its heading; items are menuitemradio with aria-checked, the Radix pattern today’s switcher already uses. Radix receives dir so arrow keys follow reading order.
  • ArrowUp and ArrowDown move, Home and End jump, typing a letter jumps to the matching item, Enter or Space selects, Escape closes and returns focus to the switcher that opened the menu, Tab closes.
  • After a switch, focus returns to the switcher that opened the menu. If the switch hid it (on a phone the sidebar sheet closes on switch), focus goes to the switcher now on screen, the top-bar button. A polite live region says “Now working in Parquet”. No toast.
  • No global keyboard shortcut in v1: ⌘J already opens Spotter.
  • Touch targets are at least 44px on phone; visible focus uses the DESIGN.md ring.

7.4 What happens on switch

  • Space to space (same business): the route stays and re-focuses. The URL is replaced with the new space; other parameters (week, tab, from, to) are kept. On the board, the new space’s row expands and moves first, and the previous one folds; nothing disappears. Scoped regions show skeletons in their own geometry; business-wide regions don’t refetch.
  • Detail page of a space-bound entity (session builder, kiosk): stays on the entity; the switcher shows the new context. Detail pages of business entities (member, plan) stay.
  • Business to business: a full navigation, as today, which clears the cache and re-runs the server role redirects. It lands on the same route when the target role can open it (entity ids dropped), else on the role home.
  • The cookie is updated on every explicit switch.

7.5 Unsaved changes

When a form is dirty (an open session sheet with edits, a task draft, an announcement draft), the switch is held and a dialog asks “Discard unsaved changes?”, “Switching to Main hall closes the new session without saving.” The buttons are Keep editing (ghost, focused by default) and Discard and switch (destructive). A clean form switches without asking. The same guard covers beforeunload for business switches.

When the switcher is in the top bar (phone, or desktop with the sidebar hidden), sheets open below the top bar and leave it live, so the switcher stays visible and reachable while a sheet is open and the guard above catches the switch; the sidebar and the page behind the sheet become inert. With the sidebar open on desktop, sheets behave as today: the sidebar header stays visible but is inert until the sheet closes.

7.6 Where a space is shown (decision 5, revised 2026-10-12)

The rule is an allowlist. A space name appears only in these places; everywhere else the switcher is the indicator, and rows, items, cards and tiles carry no space name.

  1. The switcher (§7.1): the sidebar header or the top-bar button, whichever is on screen, and its menu, plus the switcher’s own one-line notices (§6.7) and the switch announcement.
  2. The folded schedule board: the row headers (on a phone, the section headers), because the rows are the spaces. Session cards inside the rows carry none.
  3. The session sheet: the Space field at the top, where you see and change it; the “Move to space” menu; the override dialog; and the move’s own “moved to {space} · Undo” line. Spotter’s write confirmation does the same job, so it names the space too.
  4. Breakdown blocks, in All spaces only: per-space rows inside a widget or report (“Main hall 112 · Parquet 45 · Open gym 46”), the Overview “Spaces this week” panel, the Reports space tables (including “Plan coverage vs. check-ins by space”) and “Training across spaces”. These blocks are the space data. In a single-space view they don’t render.
  5. Member detail stats: one small per-space line under “Classes this month” (“Parquet 6 · Main hall 3”). This is the only place a member’s spaces are listed.
  6. Where a space is configured: Settings → Spaces (with the per-space booking rules in Settings → Booking, TKN-447/448), the template-slot and class-type space pickers, the trial’s session picker, a plan’s bucket scope (TKN-446), and the kiosk header.
  7. The area label (§8.5): an area’s name, never a hall’s, as a small muted label on the session card and in the session sheet’s Area field, and in the schedule’s area filter chip. Areas appear nowhere else: not in the switcher, breakdowns, report splits, plan coverage or schedule sections.

Everything else carries no space name, in particular:

  • Members list: no “Active in” column, no chips, no ”· main”. The focus chip in the search bar and the count line (“131 active in Parquet · 174 not active here”) stay, because they state the context, not a row’s space.
  • ActionCenter: no per-space counts or labels on items. In All spaces each item is one plain total; the switch narrows it.
  • At-risk callout rows, Today’s classes rows, task rows, session cards, the coach’s agenda (next class, later today, “Yours in other spaces”), search results, lead and trial rows.
  • Announcements: the audience selector names the space once, because it is a choice (“Members active in Parquet”); nothing else does.
  • Page titles: no ”· Parquet” suffix; the switcher already says it.

How a name is written: plain text, no colour, no monogram. Always the full name, never abbreviated; on narrow widths it truncates with an ellipsis and keeps the full name in title and the accessible name. Plain text survives colour blindness, dark mode and RTL.

The “Whole business” tag is quiet and rare: small muted text, no border or fill, shown only in a single space where a number ignores the space.

  • Once in the page header when the whole page ignores the space (Payments, Coaching, Plans, Settings; tooltip “This page isn’t filtered by space.”), or once in a section header when a whole section or tab does (Reports → Revenue, Reports → Retention, the Overview’s business trend row).
  • On a card only where cards on the same screen differ. On Members, “Trained this week” follows the space and carries nothing, while “New this month” and “Churned this month” are tagged.
  • In All spaces nothing is filtered, so there are no tags. “By main space” is never a tag either: an attributed number says how it was counted in its muted sub-line.

One-space businesses show none of this.

7.7 Creating inside a space: the main payoff

EntityIn a single spaceIn All spacesRestricted caller
Session (single create)Space = active space, shown at the top of the sheet (“Space: Parquet · Change”)Class type’s space, else the org default; shown the same way, never emptyTheir space; “Change” offers only their spaces
Session (create from a board cell)The row’s spaceThe row’s spaceTheir rows only
Template apply, copy week, replicate dayEvery slot without its own space lands here; copy-week copies only this spacePer slot: slot → template → class type → org default. No “needs a space” rowOnly into their spaces
Template slotThe slot space picker (missing in the UI today) defaults to the active spaceEmpty (inherits as above)Their spaces only
Class typeSpace prefilled with the active space; sessions then inherit it (§8.4)Empty = org defaultTheir spaces
Tasklocation_id = active space; a “Whole business” toggleWhole businessAs in a single space
AnnouncementAudience = members active in this space; an opt-in checkbox ”+ n with no check-ins in the last 60 days”, unchecked by defaultEveryoneAs in a single space
Trial (Sales)Trial space = active space; suggested sessions from this space firstPick a session; its space becomes the trial’s space (the picker groups sessions by space, §7.6 item 6)Their spaces’ sessions
Plan (owner/admin, TKN-446)First quota’s scope prefilled with the spaceNo quota (every class shares the plan’s entries)n/a
KioskOpens the door mode for this spaceAsks which spaceTheir spaces
Spotter writeslocationId defaults to the active space; the confirmation names itClass type’s space, else the org defaultEnforced by the service

7.8 One-space businesses and one-membership users

  • 0–1 active spaces: the control is exactly today’s org switcher: a plain block with one membership, the dropdown with two or more. No space line, no top-bar button, no space parameter, no “Whole business” tags, no extra request. The schedule looks as it does today (one row, no row header, no toggle).
  • The top-bar button appears (when the switcher is in the top bar) when the business has 2+ active spaces or the user has 2+ active memberships. In the second case it shows only the business name, which fixes the phone gap for people who work in several businesses (TKN-28). A user with one membership in a one-space business sees nothing new.
  • Going from 1 to 2 spaces turns the control on at the next navigation. Going from 2 to 1 turns it off and strips the parameter.

8. The folded schedule and the session’s space

8.1 Space sections in every view (replaces the space filter and the per-space lanes)

The schedule folds by space inside each of its existing views (Expanded, Condensed, Programming); there is no separate “spaces” view. Each space is a collapsible section holding that view’s normal grid for the space’s sessions. The shared layer is apps/web/src/components/overview/schedule/space-sections.tsx; the folding follows the coaching program board (program-board.tsx: chevron toggle, folded counts, COMPACT_ABOVE, search, attention first).

  • A section is the space’s header (name, capacity, mode, a chevron toggle with aria-expanded) and, while open, the view’s own grid for that space: the expanded view’s hour grid, the condensed view’s day columns, or the programming view’s class-type × day matrix. Cards in a section carry no space label; an empty slot or “add” in a section creates the session in that space.
  • Folded section: the header plus one count per day (“3”, or ”·” for none), and an “overlapping classes” badge when it holds a clash.
  • All spaces: every section in sort_order. Up to 5 start open; above COMPACT_ABOVE = 5 they start folded and a search field filters them.
  • One space active: its section is open and pinned first; the others fold to counts. They are never filtered away (except for a restricted caller, whose other spaces don’t exist). Folding is local view state, not context.
  • Attention first: within the folded sections, one with a clash in an exclusive space sorts before the others.
  • Clashes: two live sessions overlapping in an exclusive space each show an amber marker with a visible tooltip (“Overlaps another class in this space”); the marker is focusable and opens the session. Clashes come from overrides and from data created before the spaces rules applied, since every space starts exclusive.
  • Expanded view: stacked sections, each with its own hour grid for that space (never side-by-side space columns: lanes were rejected).
  • Programming view: each section lists the class types with sessions in that space, with that space’s counts. Assigning a workout still sets the class type’s day for every space.
  • Sessions in a closed space gather in an “Other spaces” section, which takes no new sessions or drops.
  • One space in the business, or the spaces flag off: no sections; the three views are today’s.
  • Phone: the same stacked collapsible sections.

8.2 Moving a session to another space

  • Drag: dragging a session card from one space section onto the same day in another section moves the session; the same day in other sections are the only move targets, so a move never changes the time, and those day columns highlight as targets while dragging. A drop on another day copies the session there instead (TKN-478, unflagged: scheduling › copy a session to another day).
  • Keyboard and touch alternative: each card’s menu has “Move to Main hall / Open gym”. The sheet has the same control at its top.
  • Checks: the TKN-444 rules run on the target space: an exclusive overlap returns space_conflict, a capacity above the space returns space_capacity_exceeded. Owner/admin see the existing override dialog with a reason (audited class_session.space_override); a coach sees why and can’t override. Nothing moves until the server accepts.
  • Feedback: the card appears in its new row with a brief teal edge; a polite live region says “CrossFit 18:00 moved to Main hall”; an “Undo” link in the line moves it back with the same checks.
  • Restricted callers can drag only between their own spaces.

8.3 Every session has a space (decision 4)

  • Default space. Every business has exactly one default space: locations.is_default boolean NOT NULL DEFAULT false with a partial unique index (organization_id) WHERE is_default AND is_active. A business with no space gets an auto-created “Main space”, invisible while it is the only one. Settings → Spaces marks the default and lets owner/admin change it; the default can’t be deactivated until another is chosen.
  • Resolution on create: explicit locationId → class type’s space → org default. The server applies it for every writer (web, Spotter create_many, templates, copy-week, MCP, mobile), so no client has to know.
  • Migration sequence, each step a separate migration reviewed with db:generate and applied only with owner approval:
    1. M1 add locations.is_default and the partial unique index.
    2. M2 (data) mark the lowest sort_order active space of each business as default; create “Main space” where a business has none.
    3. M3 (data) backfill class_sessions.location_id where null: the class type’s default_location_id, else the org default. Report counts per business before and after.
    4. M4 class_sessions.location_id SET NOT NULL (migration 0177_session_hall_required). course_sessions.location_id stays nullable (online events use location_text); program_template_slots.location_id stays nullable (null = inherit).
  • Tier: the auto-created space counts as the one space Lite allows (maxSpaces).

8.4 The class type’s space is inherited, with a preview

  • class_types.default_location_id stops being a one-time prefill and becomes the class type’s space: its sessions inherit it. Null means “the org default space”. The UI label changes from “Default space” to “Space”.
  • A session follows its class type while its space equals the class type’s current space (or the org default when the class type has none). A session moved by hand to another space no longer matches, so it stays where it was put. No new column.
  • Changing a class type’s space (A → B) moves every future session of that class type whose space is still A. Past sessions never move. Sessions in other spaces don’t move.
  • Preview first: the save shows “12 future sessions move from Parquet to Main hall. 2 clash in Main hall (exclusive)”, listing the clashing sessions with date, time and the session they clash with. The owner/admin can override the clashes with one reason, or leave them where they are. Nothing changes until “Move 12 sessions” is confirmed. The same TKN-444 checks and audit run per session.
  • Deactivating a space uses the same preview to move its future sessions to the org default first.

8.5 Areas inside a hall (decision 16, TKN-479)

Why. Every prod business has spaces off, so no locations row has ever meant a hall: each one is a place inside the business, like Kinetics’ “Main Floor” and “Floor B” at the same address. Treating them as halls (the earlier M2/M3) would have made Floor B, first by name, Kinetics’ default hall and put its 126 unplaced sessions there.

Model.

  • locations.kind (hall | area, enum location_kind, default hall) and locations.parent_id. An area’s parent_id is a hall of the same business; a hall has none. Guarded by a check (kind = 'area' ⇔ parent_id IS NOT NULL) and a composite foreign key (parent_id, organization_id) → (id, organization_id), so an area can’t point at another business; the service refuses an area under an area.
  • An area has a name, a capacity and its own concurrency (exclusive/shared). is_default, auto_created and sort_order mean what they did for halls; only a hall can be the default (the partial unique index is over halls).
  • class_sessions.location_id is the hall (required, §8.3; NOT NULL in M4). class_sessions.area_id is the optional area, always one of that hall’s areas.
  • class_types.default_location_id is the class type’s hall (null = the business default, §8.4); class_types.default_area_id its optional area. program_template_slots.area_id likewise next to the slot’s hall.
  • Course sessions keep their one optional location_id (it names an area after the migration; display only).

Migration (replaces the earlier 0174–0176, which ran on no shared database).

  1. Schema (0174): the enum and columns above. Existing rows keep the default kind = 'hall' until step 2.
  2. Each business gets one hidden default hall (auto_created, shared, is_default, named in its locale: “Main space” / מתחם ראשי / Основной зал), and every existing location becomes one of its areas, shared: 0173 had defaulted every row to exclusive, but no business ever chose a rule and nothing clashed before spaces. With spaces on nothing clashes until staff mark an area or hall exclusive. New halls and areas start shared too; exclusive is an opt-in (ADR-0024 D2, amended 2026-10-16).
  3. Sessions: area_id ← the old location_id; location_id ← the business’s hall. Sessions with no location get the hall and no area. Class types and template slots move their location to the area column the same way, leaving the hall column null (the default hall).

Kinetics afterwards: one hidden hall; areas Main Floor and Floor B; its three placed sessions keep their floor; the other 126 are in the hall with no area. Nothing visible changes.

spaces off (every business today): identical to now.

  • The “Location” field, picker and Settings → Locations list the default hall’s areas, and create areas under it. Nothing else uses areas.
  • A locationId sent by any client (web, mobile, MCP, Spotter) that names an area is read as that area in its hall, so old clients keep working.
  • Responses keep locationId (the hall) and add areaId and area; location, the display object mobile, emails and the minisite show, is the area if there is one, else the hall unless it is hidden. Today’s location names therefore render where they render now.
  • The tier limit counts active areas against the old Location limit (Lite 1 / Pro 1 / Elite unlimited, PRE_SPACES_MAX_SPACES), the same rows it counts today.

spaces on.

  • Settings → Spaces lists halls, each with its areas. The first hall a business creates adopts the hidden hall (same row, made visible), so its areas and sessions stay with it. Halls count toward the tier hall limit (Lite 1 / Pro 2 / Elite unlimited); areas don’t.
  • Halls are the spaces of everything above: the switcher, ?space, spaceId, schedule sections, Overview/Reports splits, events. Areas never enter SpaceScopeService; a spaceId naming an area is space_not_found.
  • Areas show only per §7.6 item 7: a small label on the card and sheet, an Area field in the sheet when the session’s hall has areas (default: the class type’s area when it belongs to that hall; its empty choice reads “Whole hall” / כל המתחם / Весь зал, since a session without an area uses the whole hall), and an area filter chip on the schedule (“All areas”, a filter, not a place).
  • Moving a session to another hall clears its area (an area belongs to one hall).

One hall (or none of its own) with spaces on. A business with the hidden default hall, or a single visible hall, plus areas (Kinetics’ shape) has no hall to choose:

  • The session sheets (create, and the detail sheet’s fields) show no Hall field, only the Area field with the default hall’s areas. The create sends the lone hall, or no locationId at all, which the server reads as the default hall. The Hall field and the sheet’s space control appear from two visible halls, the same rule that makes the hall required on create (choosesHall in session-places.ts).
  • Booking rules (Settings → Booking) and plan entry quotas offer no hall picker, and a rule or quota for every hall reads with no hall at all (no “in all halls”): with one hall a hall scope is the whole business. A rule or quota already scoped to a hall (set when there were two) still names it, and keeps its picker so the hall can be cleared. Same rule, hasHallChoice in session-places.ts, read through useScopeOptions.
  • Settings → Spaces, while no hall is listed, offers Add area (en “Add area” / he הוספת אזור / ru Добавить зону) next to Add hall. It creates with kind: 'area' and no parentId, so the area joins the hidden hall with the other areas waiting there; the first hall added still adopts it with them. With one visible hall its row’s own “Add area” does the same.
  • POST …/locations takes an optional kind: area without parentId goes into the default hall (defaultSpaceId), hidden or not; hall with a parentId is a 400, and so is hall with the flag off. Without kind the earlier rules stand: parentId or the flag off makes an area, otherwise a hall.

Clash and capacity rules (ADR-0024 D2/D3, enforced only with spaces on, as before).

  • A session in an area clashes with live sessions overlapping it in the same area when the area is exclusive.
  • A session without an area clashes with any live session overlapping it in the hall when the hall is exclusive.
  • An exclusive hall makes any overlap in the hall a clash, whatever the areas.
  • Capacity must not exceed the area’s capacity and the hall’s capacity, where set.
  • Every hall and area is shared unless staff opt into exclusive (ADR-0024 D2, amended 2026-10-16): the hidden default hall, every legacy area and every new one. A business meets no clash until staff mark an area or hall exclusive.
  • Override, audit (class_session.space_override, now recording areaId too) and the 409 codes are unchanged.

9. Interaction with ADR-0024

  • Policies show the effective rule for the active space. In a single space, Settings → Booking (TKN-448) asks the resolver for {space: Parquet} and shows each setting with its value and decidedBy (“Business default”, “This space’s rule”). Editing writes a space-layer rule. In All spaces the page shows the org default layer, then a list of per-space overrides. Precedence stays pinned session > class type > space > program > org (D10).
  • The session sheet’s “Rules for this class” panel (TKN-448) uses the session’s own hall, not the context.
  • Policy scopes name halls only. A rule’s or bucket’s space condition lists hall ids (locations.kind='hall', Hebrew מתחם); a session in an area is judged by its hall. Areas are never a scope. Built: scheduling behavior › Booking policy rules.
  • The board shows conflicts per space (D2): an exclusive clash created with an override shows both cards in the same cell with a warning edge; a shared space allows overlapping cards. The row header shows the space capacity (D3); each card shows booked / capacity.
  • D4 changes: “a class type does not belong to a space” still holds (no ownership), but default_location_id is now inherited (§8.4) and class_sessions.location_id becomes NOT NULL (§8.3). This amends D4; record it in ADR-0024’s changelog when S4 lands.
  • Template apply groups the conflicts[] report by space (D2).
  • Buckets (D5) drive the plan focus on the plans page and the booking gate. They are not part of “active in” or the main space; plan coverage appears only in the Reports block “Plan coverage vs. check-ins by space”.
  • Tier (maxSpaces: Lite 1, Pro 2, Elite unlimited): the control appears only with 2+ active spaces. A business that downgrades with more spaces keeps the control; adding a space stays blocked.
  • Flags: spaces governs conflict and capacity enforcement and, since 2026-10-18, everything in this plan too (decision 9; the plan first shipped behind its own space-context flag), except the default-space data migrations, which are invisible on their own.

10. States and ranges

DimensionRealistic rangeDesign response
Active spaces per business1 (most, incl. the auto-created one), 2–5 (Pro), 6–20 (Elite, rare)1: nothing shown. 2–5: rows expanded in All spaces. 6+: rows fold, board search, menu filter from 8
Space name length”Gym” to “Functional training hall, upper floor”Truncate with the full name in the accessible name and a tooltip; the top-bar button caps at about 18 characters on phone
Businesses per user1 (most), 2–3, up to about 10Org group appears from 2; filter field from 8
Restricted staffNone in most businesses; a few in large onesUnrestricted is the norm. Restricted: label instead of menu at 1 space; “All my spaces (n)” at 2+
Sessions per space per day0–12Expanded cells stack cards; folded cells show the count
Class-type space change0–200 future sessionsPreview with counts; clashes listed, capped at 20 with “and 14 more”
LoadingEvery switchSkeletons in the board’s geometry (WeekGrid skeleton); the label updates at once; no spinner
ErrorA scoped request failsInline error inside that region with Retry; the context stays
EmptyA space with no sessions this weekIts row shows ”·” per day when folded; expanded, each cell keeps its add button and the row says “No sessions in Parquet this week”
Move refusedClash or capacityThe override dialog (owner/admin) or a one-line reason (coach); the card returns to its row
Out of scope, missing, closedDeep links, restrictions§6.7
Offline (kiosk)Wi-Fi drop at the doorThe kiosk keeps the last roster with a “Reconnecting…” line

11. Interaction and layout

  • Hierarchy: switcher, then page title, then content. In the sidebar header it is today’s org block with the space line; as a top-bar button it is quieter than the page title (13px medium, hairline chip) and louder than body text.
  • Topology: one switcher on screen at a time (§7.1): the sidebar header while the sidebar is visible on desktop, else one button at the start of the top bar. The menu is a popover on desktop (288px) and a bottom sheet on phone (48px rows, env(safe-area-inset-bottom), overscroll-behavior: contain).
  • Responsive: at 390px the top bar is toggle, switcher button, title (truncates first), Spotter. While the sidebar sheet is open, its header is the switcher and the top bar is inert under the scrim. The board becomes stacked space sections for one day (§8.1).
  • RTL: logical properties only. The breadcrumb separator and the row chevron flip (rtl:rotate-180, rtl:rotate-90 as on the program board); never a literal ”›”. Days run from the start side. Hebrew is the default case.
  • Motion: menu fade and scale 150ms; row fold 150ms; the moved card’s teal edge fades over 1s; all off under prefers-reduced-motion. Transitions list their properties.
  • Light and dark: token colours only. Selection is teal, never green.
  • One Teal Rule: the switcher has no filled button. The schedule’s one filled button is “New session”; the cascade preview’s is “Move N sessions”.
  • Test ids are a contract: keep org-switcher-trigger, org-switcher-item-<orgId>; add context-switcher-topbar (the top-bar placement; the sidebar placement keeps org-switcher-trigger), space-switcher-item-<spaceId>, space-switcher-item-all, space-section, space-section-toggle, space-section-day-count, space-move-option, space-clash-marker.

11.1 Suggested i18n keys (not added)

Keyenheru
context.trigger.ariaLabelWorking context: {context}. Changeהקשר עבודה: {context}. החלפהРабочий контекст: {context}. Сменить
context.menu.spacesSpaces (reuse settings.nav.locations)מתחמיםЗалы
context.menu.organizationsSwitch organization (reuse orgSwitcher.switchOrganization)החלפת ארגוןСменить организацию
context.allSpacesAll spacesכל המתחמיםВсе залы
context.allMySpacesAll my spaces ({count})כל המתחמים שלי ({count})Все мои залы ({count})
context.wholeBusinessWhole businessכל העסקВесь клуб
context.wholeBusinessHintThis page isn’t filtered by space.העמוד הזה לא מסונן לפי מתחם.Эта страница не фильтруется по залам.
context.switchedNow working in {context}עברת אל {context}Контекст: {context}
context.openedInOpened in {org}נפתח ב־{org}Открыто в «{org}»
context.notInYourSpaces{space} isn’t in your spaces, showing {mine}{space} לא במתחמים שלך, מוצג {mine}Зала «{space}» нет в вашем списке, показан «{mine}»
context.closed.title{space} is closed{space}: לא פעילЗал «{space}» закрыт
context.closed.actionShow all spacesהצגת כל המתחמיםПоказать все залы
context.discard.titleDiscard unsaved changes?לבטל את השינויים שלא נשמרו?Отменить несохранённые изменения?
context.discard.bodySwitching to {context} closes {item} without saving.המעבר אל {context} יסגור את {item} בלי לשמור.Переход в «{context}» закроет {item} без сохранения.
context.discard.keepKeep editingלהמשיך לערוךПродолжить редактирование
context.discard.confirmDiscard and switchלבטל ולעבורОтменить и перейти
context.activeInActive in {space}פעילים ב־{space}Активны в «{space}»
context.moreElsewhere{count} more in other spaces (leads only)עוד {count} במתחמים אחריםЕщё {count} в других залах
leads.noTrialYetNo trial yetעדיין בלי אימון ניסיוןПробное ещё не назначено
context.activeCount{active} active in {space}{active} פעילים ב־{space}Активны в «{space}»: {active}
context.notActiveHere{count} not active here{count} לא פעילים כאןНе занимаются здесь: {count}
context.noRecentCheckInsNo check-ins in the last 60 days ({count})ללא צ׳ק־אין ב־60 הימים האחרונים ({count})Без отметок за последние 60 дней ({count})
context.noRecentCheckInsHintSpace numbers count check-ins only: attended classes in the last 60 days.מספרי המתחמים סופרים רק צ׳ק־אין: שיעורים שבהם נכחו ב־60 הימים האחרונים.Цифры по залам считают только отметки о посещении за последние 60 дней.
context.fewCheckInsSpace numbers come from check-ins. Few check-ins were recorded in the last 60 days.מספרי המתחמים מבוססים על צ׳ק־אין. ב־60 הימים האחרונים נרשמו מעט צ׳ק־אינים.Цифры по залам основаны на отметках о посещении. За последние 60 дней их было мало.
context.fewCheckInsActionSet up check-inהגדרת צ׳ק־איןНастроить отметку посещений
reports.coverage.titlePlan coverage vs. check-ins by spaceכיסוי מסלול מול צ׳ק־אין לפי מתחםПокрытие абонементом и посещения по залам
reports.coverage.coveredPlan covers {space}המסלול מכסה את {space}Абонемент покрывает «{space}»
reports.coverage.checkedInChecked in ({days} days)עשו צ׳ק־אין ({days} ימים)Отметились ({days} дн.)
reports.coverage.neverCheckedInCovered, never checked inמכוסים, בלי צ׳ק־איןПокрыты, но ни разу не отметились
reports.coverage.pendingAvailable once plans are tied to spacesיהיה זמין כשהמסלולים יקושרו למתחמיםПоявится, когда абонементы будут привязаны к залам
announcements.includeNoCheckIns+ {count} with no check-ins in the last 60 days+ {count} ללא צ׳ק־אין ב־60 הימים האחרונים+ ещё {count} без отметок за 60 дней
reports.byMainSpaceBy main spaceלפי מתחם עיקריПо основному залу
reports.byActiveInBy active inלפי פעילות במתחםПо активности в зале
context.sessionSpaceSpace: {space}מתחם: {space}Зал: {space}
spaceBoard.expandRow / collapseRowExpand row / Collapse row (reuse the program board’s keys)הרחבת שורה / כיווץ שורהРазвернуть / Свернуть строку
spaceBoard.searchSearch spaces…חיפוש מתחם…Поиск зала…
spaceBoard.moveToMove to spaceהעברה למתחםПеренести в зал
spaceBoard.moved{session} moved to {space}{session} הועבר ל־{space}«{session}» перенесено в «{space}»
spaceBoard.undoUndoביטולОтменить
spaceBoard.emptyRowNo sessions in {space} this weekאין השבוע שיעורים ב־{space}На этой неделе в «{space}» нет занятий
stats.wholeBusinessWhole business (reuse context.wholeBusiness)כל העסקВесь клуб
stats.byMainSpaceof {total} · by main spaceמתוך {total} · לפי מתחם עיקריиз {total} · по основному залу
stats.ofTotalof {total}מתוך {total}из {total}
stats.toggleActiveInActive inפעילים בАктивны в
stats.toggleMainSpaceMain spaceמתחם עיקריОсновной зал
stats.overlapFootnoteMembers who train in several spaces are counted in each, so rows add up to more than the total.מי שמתאמן בכמה מתחמים נספר בכל אחד מהם, ולכן השורות מסתכמות ביותר מהסה״כ.Кто занимается в нескольких залах, учтён в каждом, поэтому сумма строк больше итога.
stats.otherOtherאחרДругое
stats.revenueWholeBusinessWhole business. Revenue isn’t split by space.כל העסק. ההכנסות לא מחולקות לפי מתחם.Весь клуб. Доходы не делятся по залам.
stats.visitsPerVisitorVisits per visitorביקורים למתאמןПосещений на человека
stats.leftByMainSpaceLeft, by main spaceעזבו, לפי מתחם עיקריУшли, по основному залу
stats.membersByMainSpaceMembers by main spaceלקוחות לפי מתחם עיקריУчастники по основному залу
stats.attributionHintCounted under the space where they checked in most in the last 60 days.נספרים במתחם שבו עשו הכי הרבה צ׳ק־אין ב־60 הימים האחרונים.Учтены в зале, где чаще всего отмечались за 60 дней.
members.ribbon.activeHereActive hereפעילים כאןАктивны здесь
members.ribbon.ofMembersof {count} membersמתוך {count} לקוחותиз {count} участников
members.ribbon.ofActiveHereof {count} active hereמתוך {count} פעילים כאןиз {count} активных здесь
members.ribbon.newToASpaceNew to a spaceחדשים במתחםВпервые в зале
members.ribbon.lastActiveInLast active inהיו פעילים לאחרונה בПоследний раз занимались в
members.ribbon.mainSpaceSplitMembers by main spaceלקוחות לפי מתחם עיקריУчастники по основному залу
members.imported.noCheckInsNone of them has checked in yet.אף אחד מהם עוד לא עשה צ׳ק־אין.Никто из них ещё не отмечался.
members.crossSpace.titleTraining across spacesמתאמנים בכמה מתחמיםЗанимаются в нескольких залах
members.crossSpace.all{count} of {total} train in 2+ spaces ({pct}){count} מתוך {total} מתאמנים ב־2 מתחמים ומעלה ({pct}){count} из {total} занимаются в 2+ залах ({pct})
members.crossSpace.allThreeAll threeבשלושתםВо всех трёх
members.detail.bySpace{space} {count}{space} {count}{space} {count}
spacesPanel.titleSpaces this weekמתחמים השבועЗалы на этой неделе
spacesPanel.hallLoadHall loadעומס מתחםЗагрузка зала
spacesPanel.hallLoadHintBooked seat-hours ÷ offered seat-hours.שעות מקום שהוזמנו ÷ שעות מקום שהוצעו.Забронированные места-часы ÷ предложенные.
spacesPanel.peakPeak {day} {time}שיא {day} {time}Пик {day} {time}
spacesPanel.hours{count} h scheduled{count} שעות בלוחЗапланировано {count} ч
spacesPanel.overlaps{count} overlaps · {overrides} overrides{count} חפיפות · {overrides} אישורים חריגיםПересечений: {count} · Исключений: {overrides}
spacesPanel.crossSpace{count} of {total} train in 2+ spaces{count} מתוך {total} מתאמנים ב־2 מתחמים ומעלה{count} из {total} занимаются в 2+ залах
spacesPanel.waitlist{value} waitlisted per 100 seats{value} בהמתנה לכל 100 מקומות{value} в листе ожидания на 100 мест
spacesPanel.noShowNo-show {value}לא הגיעו {value}Неявки {value}
spacesPanel.lateCancelLate cancel {value}ביטול מאוחר {value}Поздняя отмена {value}
spacesPanel.emptyNo sessions in {space} this weekאין השבוע שיעורים ב־{space}На этой неделе в «{space}» нет занятий
insights.items.operations.no_shows_recent.title{count} no-shows in the last 7 days{count} לא הגיעו ב־7 הימים האחרוניםНеявок за 7 дней: {count}
insights.items.operations.space_overlaps.title{count} overlapping sessions in exclusive spaces{count} שיעורים חופפים במתחמים בלעדייםПересекающихся занятий в эксклюзивных залах: {count}
classType.spaceSpaceמתחםЗал
classType.spaceHelpSessions of this class type take place in this space.השיעורים מסוג זה מתקיימים במתחם הזה.Занятия этого типа проходят в этом зале.
classType.cascade.titleMove future sessions?להעביר את השיעורים העתידיים?Перенести будущие занятия?
classType.cascade.body{count} future sessions move from {from} to {to}. Past sessions stay.{count} שיעורים עתידיים יעברו מ־{from} ל־{to}. שיעורים שעברו יישארו.{count} будущих занятий перейдут из «{from}» в «{to}». Прошедшие останутся.
classType.cascade.clashes{count} clash in {to}.{count} מתנגשים ב־{to}.{count} пересекаются в «{to}».
classType.cascade.confirmMove {count} sessionsהעברת {count} שיעוריםПеренести занятия: {count}
settings.spaces.defaultDefault spaceמתחם ברירת מחדלЗал по умолчанию
staff.spaces.allAll spacesכל המתחמיםВсе залы
staff.spaces.onlyOnly these spaces…רק המתחמים האלה…Только эти залы…

Hebrew attaches prepositions to names with a maqaf (ל־{space}, ב־{space}). Russian quotes names in «». Counts use ICU plural forms when the keys are added.


12. Stats widgets: metrics and space breakdown

Requested by Saar on 2026-10-12. Source: a code-verified inventory of every stats widget, done on 2026-10-12. Each inventory ID used below (O1–O11, M1–M8, S1–S6, C1–C7, P1–P7, R…) points to its row there. Only session-derived metrics (class_sessions → bookings → bookings.checked_in_at) can be split by space today. Payments, subscriptions, memberships, leads, tasks, assignments, daily programming and forms have no space. No endpoint takes a space parameter today.

12.1 Rules

  1. No space for money, ever. Receipts, MRR, debt, refunds, plan revenue and payment methods are whole business in every view.
  2. The switcher labels filtered numbers; tiles don’t. In a single space, a tile that follows the space carries no space name and no tag. A number that ignores the context keeps its value when the space changes and is marked by the quiet “Whole business” rule (§7.6): once per page or section that ignores the space, on a card only where it sits next to cards that follow the space. In All spaces there are no tags. Attributed numbers say “by main space” in their muted sub-line, never as a tag.
  3. Space behaviour has three kinds:
    • Follows space: session-derived; filtered by spaceId.
    • Whole business: money, subscriptions, member growth, leads, cancellations, coaching, and tasks without a space. It ignores ?space=.
    • Attributed: people are counted under their derived main space (§6.8, check-ins only). The tile always says how it was attributed. Plan coverage is never a widget; it appears only in the Reports block “Plan coverage vs. check-ins by space”.
  4. Breakdowns that don’t add up carry a footnote marker (*). The main-space split of distinct people adds up, including a “No check-ins in the last 60 days” row where people are concerned. The “active in” split overlaps; it is available on a toggle and footnoted.
  5. Few check-ins: every member-based space widget shows the quiet line from §6.8 when check-ins are under 10% of past bookings in the window, with a link to check-in setup. The widget is never hidden.
  6. In All spaces, every follows-space tile gets a breakdown: a compact row per space with the value, a share bar, and the share of the total. Ratios (fill, attendance rate, no-show rate) show the value per space without a share bar, because ratios have no share.
  7. In one space, the tile shows the space’s value and, where it helps, a muted line “of {business total}”. No space name, no breakdown rows, no per-space blocks (§7.6 item 4).
  8. Charts.
    • One space: the space’s series, with a faint whole-business line only where comparison helps (attendance by day, fill). Not on counts, where the scales differ.
    • All spaces: stacked areas or bars per space, at most 5 spaces, then “Other”, with a legend using the space names. Heatmaps and tables stay totals, and a cell’s tooltip lists the split.
  9. Single-space businesses see exactly today’s widgets: no tags, no breakdowns, no new tiles, no new requests.
  10. Roles are unchanged from the matrix: analytics O,A; paymentAnalytics O,A; leads O,A; tasks O,A,C; classSessions O,A,C. Coaches keep the Today panel, Pending tasks, the schedule strip and their ActionCenter rules. A staff member the owner restricted gets breakdowns only for their spaces, and business totals stay hidden from them only where the role already hides them.

12.2 Widget table

API columns:

  • “spaceId”: the endpoint takes ?spaceId= and filters.
  • “groupBy=space”: the response gains bySpace: [{ spaceId, …same metrics }].
  • “groupBy=mainSpace”: people are grouped by the derived main space (§6.8), with a none bucket.

Segmentation column:

  • “today” means the API already returns it.
  • “new” means it needs a new groupBy. The new values are classType, coach, weekdayHour, plan, newVsReturning and outcome, where outcome is attended, no-show, late cancel or unmarked.

Overview (OverviewV2)

Widget (inventory)Metric as computed todayWindow and comparisonSpace behaviourIn one spaceIn All spacesExtra segmentationAPI change
ActionCenter count (O1)Number of non-zero rules, sorted by severity, then countPer rulePer item: see §12.4This space’s items, then business items; no tags, no space names; the count is the plain number of itemsEvery item as one plain total; no per-space counts or namesBy category and severity (today)spaceId on GET /insights filters space rules
Today: day summary (O2)Classes = published sessions today (org-local day, LIMIT 20); booked = Σ confirmed; fill = Σ confirmed ÷ Σ capacity of capped sessions. Tighten: count booked as confirmed + attended + no_show, as O3 does, so a class that has already started doesn’t lose its checked-in peopleToday; no comparisonFollows space”3 classes · 37 booked · 77% full”, muted “of 11 classes today""11 classes · 154 booked · 66% full”, then one row per space: Main hall 5 · 94 · 71%, Parquet 3 · 37 · 77%, Open gym 3 · 23 · 44%, with a share bar on classesClass type, coach (row data today); hour (new)spaceId, groupBy=space on /sessions/upcoming-today; raise the LIMIT 20 for All spaces (three halls exceed it)
Today: “Avg fill · 30 days” chip (O3)Σ booked (confirmed+attended+no_show) ÷ Σ capacity, sessions from 30 days ago including future ones. Tighten: ended sessions only, matching the Reports utilizationRolling 30 daysFollows space”Avg fill · 30 days 70%” (this space’s value, no name)“70%“Class type (today)spaceId, groupBy=space on /analytics/classes/utilization
Today: session rows (O4)Per session booked/capacity, waitlist, checked-inTodayFollows spaceThis space’s rowsEvery session; rows carry no space name, the per-space summary rows above carry the splitn/aSame call as O2
Pending tasks (O5)First 25 open tasks by due dateOpen nowTasks with a space follow it (S6 tasks.location_id); tasks without a space are whole businessThis space’s tasks, then whole-business tasks; no labelsAll tasks; no labelsAssignee, type (today)spaceId on /tasks (S6)
Member growth (O6)Joined / left per org-local month; active at month end; delta = latest − previous6 months; month over monthWhole businessSame value; one “Whole business” tag on the business trend rowSame, no tagSource (import vs join, needs new)None
Revenue (O7)Net receipts per month (receipts SQL); collected MTD; expected monthly = MRR + presale committed; outstanding = Σ debt12 months; MTD vs full prior monthWhole businessSame value; the trend row’s one tagSame, no tagMethod, currency (today); plan (new)None
Lead funnel (O8–O9)Total leads, won (converted+customer), conversion %, new this month (tighten: server-local month start → org timezone), by sourceAll time / monthWhole business. Trials by space live in Reports (S6)Same value; the trend row’s one tagSame, no tagSource, status, campaign (today)None
Today coaching, roster (O10–O11)Workouts due today, 7-day completion, needs attentionToday / 7 daysWhole business (coaching has no space)Same; tagged on its card, next to space cardsSame, no tagProgram (today)None

Members, Payments, Schedule, Coaching

Widget (inventory)Metric as computed todayWindow and comparisonSpace behaviourIn one spaceIn All spacesExtra segmentationAPI change
Members: total + roster donut (M1)All member memberships, any status, in exclusive buckets: former > invited > in debt > overdue > leaving > with plans > no planNowWhole business for the total; in a space the donut becomes a people filter over members active in the space (not money attribution)“Active here 131”, muted “of 318 members”; the donut’s buckets over those 131: with plans 118 · overdue 3 · in debt 1 · leaving 3 · no plan 5 · former 1 (left within 60 days of their last booking)318 with today’s donut (with plans 267 · overdue 4 · in debt 3 · leaving 6 · not joined 8 · no plan 18 · former 12), plus a main-space split bar Main hall 158 · Parquet 86 · Open gym 61 · no check-ins 13 = 318; the “Active in” toggle shows Main hall 196 · Parquet 131 · Open gym 104 = 431*Bucket (today); plan (new)spaceId and groupBy=mainSpace on /members/roster-summary (buckets computed over the members active in the space)
Members: trained this week (M2)Today: distinct members with attended or confirmed bookings on sessions in [now−7d, now] ÷ all members. Tighten: with space-context on, attended (checked-in) bookings only, for the total and every split, so the split adds upRolling 7 daysNumerator follows space (check-ins only); distinct people”Trained this week 52”, muted “of 131 active here” (the denominator becomes the members active in the space)“203 of 318”; main-space split Main hall 112 · Parquet 45 · Open gym 46 = 203; “Active in” toggle Main hall 133 · Parquet 52 · Open gym 59 = 244*New vs returning (new)Same call: spaceId, groupBy=mainSpace, groupBy=space
Members: new this month (M3)Member memberships joined in the org-local month (registration flag excludes imports); delta vs prior full monthMonth to dateWhole business; “New to a space” is a breakdown (All spaces only)14, tagged (it sits next to “Trained this week”, which follows the space)14, plus “New to a space”: Main hall 9 · Parquet 11 · Open gym 6 = 26* (a member can be new to several spaces)Source (new)New: first check-in per space, groupBy=space on /analytics/members/summary (derived on read, no column)
Members: churned this month (M4)Member memberships ended in the month (MEMBERSHIP_ENDED_AT); inverted-good deltaMonth to dateWhole business; “Last active in” is a breakdown (All spaces only)8, tagged8; last active in Main hall 4 · Parquet 2 · Open gym 1 · no main space 1 = 8 (main space by check-ins over the 60 days before they left)n/agroupBy=mainSpace on /analytics/members/summary (main space as of ended_at)
Members: imported awaiting invite (M5)Live roster members with account status not invitedNowWhole business, tagged. By definition all of them are “with no check-ins in the last 60 days”, and the callout says so”5 members awaiting invite · none of them has checked in yet”, taggedSamen/aNone
Members: at-risk callout (M6)Members with a past-due or debt subscription, or a debt amount; sample of 5 sorted by debt; total debtNowCount and list follow space as a people filter; the debt total is whole business only”4 members need attention”; sample rows keep each member’s own debt or “Past due” and carry no space; no summed debt for a space”7 members need attention · ₪1,240 unpaid”; sample rows carry no spaceReason (today)spaceId on /analytics/members/at-risk (filters members by activeInSpaceIds; omits totalDebt when set)
Members: list heading count (M7)total of the filtered pageNowFollows the context line from §5.3”131 active in Parquet · 174 not active here” and “13 with no check-ins in the last 60 days”: the same numbers as M1 (131 + 174 + 13 = 318)“318 members”, as todayn/aCounts come with the /members response when spaceId is set
Member detail stats (M8)Attended classes this month, total, last visitMonth / all timeOne per-space line for the member; a member page isn’t filtered by the contextSame in any context”Classes this month 9” with one small line “Parquet 6 · Main hall 3” beneath; the total (142), the last visit (Today) and the page header carry no space, and there is no “main” marker. The only place a member’s spaces are listed (§7.6)Space (new)groupBy=space on /members/:id/stats (this month)
Payments KPIs, charts, debt banner (P1–P7)Receipts today, MTD, forecast recurring, monthly revenue, plan distribution, debt rows (tighten: debt banner counts subscription rows, not members)As todayWhole businessSame; one page tagSame, no tagMethod, plan (today)None
Schedule strip: sessions, bookings (S1–S2)Published sessions this week; bookings confirmed/attended/no-show. Tighten: the week uses server-local time, not org timezoneThis weekFollows spaceParquet value, muted “of 43”Total + per-space rows with share barClass type (today via program), weekday (new)spaceId, groupBy=space on /programs/:id week stats
Schedule strip: members (S3)Distinct booking members this weekThis weekFollows space; distinct peopleMembers who booked ParquetMain-space split adds up; “active in” toggle footnotedn/aSame + groupBy=mainSpace
Schedule strip: fill rate (S4)Bookings ÷ Σ capacity of capped sessions (can exceed 100%)This weekFollows spaceParquet %Total %, per-space % (no share bar)Class type (new)Same
Schedule strip: workouts set (S5)Class days with a published workout ÷ class daysThis weekFollows space for the day count; the workout itself is per class type, so a class day counts in each space where that class type runs that dayClass days in ParquetTotal + per space; footnoted, since a shared class day counts in two spaces*n/aspaceId on /daily-programming/board (session count per cell)
Schedule drafts count (S6)Draft sessions in the weekVisible weekFollows spaceDrafts in ParquetAll draftsn/aSame as the board
Coaching strip and program stats (C1–C7)Plans, enrolled, workouts, completionThis weekWhole businessSame; one tag on the strip or the pageSame, no tagProgram (today)None

Reports workspace (basic-reports)

Built in S5 (TKN-464). Reports follow the switcher’s ?space=; there is no second space control. With the context on, the snapshot (/analytics/dashboard), the attendance report (/analytics/attendance/report) and retention (/analytics/members/cohorts, All spaces only) are read with groupBy=space, which adds the split over every space whatever spaceId narrows the headline to (so a space’s figures equal its row, and session and check-in counts add up). The same groupBy=space adds bySpace to /analytics/attendance/summary, /analytics/attendance/daily and /bookings/attendance-trend; /analytics/classes/utilization has it since S5b. People come back in both views at once, so the “Main space | Active in” toggle never refetches: byMainSpace (each person once, under the space with the most check-ins in the 60 days before the reference time, ties to the latest, a “No check-ins in the last 60 days” row; adds up) and activeIn (every space they checked in at, or for visitors every space they visited in the range; footnoted *). The reference time is now for the snapshot, the range’s end for visitors, asOf for members on the date and each departure for “left”. This is a groupBy=space view rather than a separate groupBy=mainSpace. The “Spaces” section reads GET /analytics/spaces/range?from&to (the week report over a range; 404 space_context_off without the context), whose presence.activeIn is the “Checked in” column of plan coverage; the coverage endpoint itself waits on TKN-446, so its columns read “Available once plans are tied to spaces”. Revenue, payments, debt, plans and membership never take a space; in a single space each tab says so once. Attendance exports carry the space: one row per class and space with groupBy=space, the space’s id and name when scoped. Deferred: the Trials tile and column (S6: trials have no space yet), coverage columns (TKN-446).

Widget (inventory)Metric as computed todayWindow and comparisonSpace behaviourIn one spaceIn All spacesExtra segmentationAPI change
Snapshot: active membersMember memberships with status activeNow; no deltaAttributed by main space”86”, muted “of 318 · by main space”318; main split Main hall 158 · Parquet 86 · Open gym 61 · with no check-ins in the last 60 days 13 = 318; “active in” toggle Main hall 196 · Parquet 131 · Open gym 104 = 431*Plan (new)groupBy=mainSpace on /analytics/dashboard
Snapshot: net receipts this monthReceipts MTD per currency; % vs same point last monthMTD vs prior MTDWhole businessSame, tagged (next to space tiles)Same, no tagMethod (today)None
Snapshot: classes this weekPublished sessions in the week (includes upcoming)This weekFollows space11, muted “of 43”43; Main hall 18 · Parquet 11 · Open gym 14Class type (new)spaceId, groupBy=space
Snapshot: attendance rateAttended ÷ booked, ended sessions MTD; pp vs prior MTDMTD vs prior MTDFollows space91.0% (+0.4 pp)91.3%; Main hall 91.4 · Parquet 91.0 · Open gym 91.5Class type, coach (new)Same
Snapshot: new members this monthJoined MTD, excluding invitedMTD; absolute vs priorWhole business (a new joiner has no main space yet)Same, taggedSame, no tagSource (new)None
Snapshot: active members with no visitActive roster with no attended booking on ended sessions this monthMTD; lower is betterAttributed by main space; a visit anywhere counts4, muted “of 27 · by main space”27; Main hall 7 · Parquet 4 · Open gym 3 · with no check-ins in the last 60 days 13 = 27n/agroupBy=mainSpace
Snapshot: signed for openingScheduled subscriptions, monthlyNowWhole businessSame, taggedSame, no tagn/aNone
Attendance: members who visitedDistinct members with ≥1 attended bookingSelected rangeFollows space; distinct people124, muted “of 291 visitors”291; main split Main hall 151 · Parquet 82 · Open gym 58 = 291; “active in” toggle Main hall 188 · Parquet 124 · Open gym 98 = 410*New vs returning (new)spaceId, groupBy=space, groupBy=mainSpace on /analytics/attendance/report
Attendance: visits per active memberAttended ÷ active members today. Tighten for one space: active members aren’t per space, so the tile becomes “visits per visitor” (attended here ÷ visitors here) and says soSelected rangeFollows space (redefined, labelled)“Visits per visitor 3.0”4.0 per active member (business definition); per space visits per visitor Main hall 3.7 · Parquet 3.0 · Open gym 2.2n/aSame
Attendance: no-show rateNo-show ÷ bookedSelected rangeFollows space6.2%5.7%; Main hall 5.3 · Parquet 6.2 · Open gym 5.9Class type, coach, hour (new)Same
Attendance: attended per classAttended ÷ sessionsSelected rangeFollows space5.56.1; Main hall 7.3 · Parquet 5.5 · Open gym 4.3Class type (today)Same
Attendance: who came (mix bar)Booked split into attended / no-show / unmarkedSelected rangeFollows spaceOne barOne bar per space plus a total barOutcome incl. late cancel (new, see §12.3)Same
Attendance by day (area chart)Attended per org-local daySelected rangeFollows spaceParquet line with a faint whole-business lineStacked areas per space (≤5, then “Other”)n/agroupBy=space
Busiest hours (heatmap)Attended by weekday × start hourSelected rangeFollows spaceParquet heatmapTotals; the tooltip lists the per-space splitWeekday × hour (today)Same
Attendance table by class typeSessions, booked, attended, no-show, unmarked, rateSelected rangeFollows spaceRows for ParquetOne row per class type × space, grouped by space, with subtotalsClass type (today), coach (new)Same
Popular classes (ranking)Class types by booked, top N + OtherSelected rangeFollows spaceParquet rankingBars stacked by spaceClass type (today)Same
Capacity utilizationΣ booked in capped sessions ÷ Σ capacity; unlimited counted apartSelected rangeFollows space69.0%71.2%; Main hall 78.0 · Parquet 69.0 · Open gym 58.0Class type (today)Same
Revenue tab (all tiles, charts, tables)Receipts per currency; methods; monthlySelected rangeWhole business. In a single space the tab carries one tag in its header, “Whole business. Revenue isn’t split by space.”; the tiles carry noneSame values, one tab tagSame, no tagMethod, currency (today); plan (new)None
Retention: members on date, joined, net, average tenure, retained after 90 daysMembership lifecycle (not paid retention)months back from asOfWhole businessSame; one tab tag. No attributed lines: they are breakdowns (All spaces only)Same, no tag, with the two “by main space” breakdowns belowCohort month (today)None
Retention: members on date by main space (new)Members on asOf, grouped by main space as of asOfAs of dateAttributed by main spaceNot shown (a breakdown, All spaces only)Main hall 158 · Parquet 86 · Open gym 61 · no check-ins 13 = 318n/agroupBy=mainSpace&asOf= on /analytics/members/cohorts
Retention: left by main space (new)Members who left in the window, grouped by their main space over the 60 days before they leftWindowAttributed by main spaceNot shown (a breakdown, All spaces only)Main hall 21 · Parquet 12 · Open gym 6 · no main space 2 = 41n/aSame; derived on read from bookings before ended_at
Trials (new tile, S6)Trial appointments, from lead_trial_appointments.location_idSelected rangeFollows space926; Main hall 14 · Parquet 9 · Open gym 3Outcome (today)spaceId, groupBy=space on the Sales reports
Plan coverage vs. check-ins by space (new, Reports only)Per space: members whose current plan covers the space (TKN-446 bucket scopes), members who checked in there in the last 60 days, and covered-but-never-checked-in. The only place plans meet spaces in analytics; no dashboard widget uses plan coverageLast 60 days; as of today for coverageAttributed by plan coverage (labelled) next to presence (check-ins)Not shown (a Reports space table, All spaces only)One row per space; columns don’t add up across spaces (a plan can cover several), footnoted*Plan (today, via buckets)New GET /analytics/members/coverage?spaceId= (owner/admin). Until TKN-446 ships, the coverage columns read “Available once plans are tied to spaces” and only check-ins show

12.3 New space widgets (owner and admin, only with 2+ spaces)

These appear on the Overview as one “Spaces this week” panel, under Today and Pending tasks. In Reports they appear as a “Spaces” section on the Attendance tab, over the selected range. Both are breakdown blocks: All spaces only, never in a single space (§7.6 item 4).

WidgetDefinition and formulaData sourcePhone fallbackEmpty state
Hall loadSeats booked, weighted by duration, ÷ seats offered, weighted by duration, per space: Σ(duration × booked) ÷ Σ(duration × capacity) over published sessions in the week. Peak = the weekday-and-hour with the most booked seats. “Hours scheduled” = Σ durationclass_sessions (starts_at, ends_at, capacity, location_id) × bookingsOne line per space: “Parquet 66% · peak Sun 19:30""No sessions in {space} this week”
Overlaps and overridesCurrent overlaps between live sessions in exclusive spaces this week, plus overrides recorded this weekOverlaps: a self-join on class_sessions. Overrides: audit class_session.space_overrideOne line: “1 overlap · 1 override”Hidden when both are 0
Cross-space membersActive members with check-ins in 2 or more spaces in the last 60 days (activeInSpaceIds) ÷ active members, plus a pair matrix (members in exactly those spaces).The member read model (§6.8)All spaces: “113 of 318 train in 2+ spaces (36%)” and the pairs Main hall+Parquet 57 · Main hall+Open gym 35 · Parquet+Open gym 8 · all three 13 = 113. Shown on Overview and on the Members page, in All spaces only”Nobody trains in more than one space yet”
New to {space}Members whose first-ever check-in (attended booking) in that space falls in this month, whether they are new to the business or an existing member trying another hallFirst attended booking per (member, space) from bookings × class_sessions, derived on readRows under “New this month”, in All spaces: “Main hall 9 · Parquet 11 · Open gym 6""Nobody new to {space} this month”
Waitlist pressureWaitlisted bookings on upcoming sessions ÷ capacity of those sessions, per 100 seats, per spacebookings.status = 'waitlisted' × class_sessionsInside the per-space line: “Parquet 1.7 per 100 seats""No waitlists this week”
No-show and late-cancel rateNo-show ÷ booked; late cancels ÷ (booked + late cancels), per space, ended sessions in the rangebookings.status = 'no_show'. Late cancels: cancelled bookings whose cancelled_at falls inside the cancellation window (organizations.cancellation_window_hours before starts_at). This is exact for bookings that carry policy_snapshot.cancelDeadlineAt (written while booking-policy-rules is on, TKN-447); others use the org windowInside the per-space line”No ended sessions yet”

Dropped:

  • Prevented clashes: a 409 space_conflict is not recorded anywhere, so blocked attempts can’t be counted. A PostHog event could add this later if Victor asks for it.
  • Room utilisation against opening hours: spaces have no opening hours, so “hours used ÷ hours open” can’t be computed (TKN-391).

12.4 ActionCenter items with a space

Built in S5b (TKN-467). groupBy=space on /sessions/upcoming-today, /analytics/classes/utilization and /insights returns a split over every space, whatever spaceId narrows the headline figures to (so a space’s figures equal its row), with a spaceId: null row for sessions with no space or in a closed space, so session counts add up to the total. spaceId on /insights narrows the four space rules below and puts them first. The two new rules and “Spaces this week” (GET /analytics/spaces/week, 404 space_context_off without the context) run only while the org has the context (flag on, 2+ active spaces). Overlaps and overrides (the operations.space_overlaps rule and the panel’s overlap line) also need the spaces flag, because a hall or area marked exclusive only means something while the TKN-444 rules are enforced. The O2 “booked” tightening is not applied: the Today split counts confirmed bookings, as the rows do, so a one-space business keeps today’s numbers.

RuleSpaceIn one spaceIn All spaces
operations.waitlisted_bookings (“class full with a waitlist”)Follows spaceOnly this space’s count, no labelOne total, no per-space counts or names
operations.sessions_missing_workoutFollows spaceOnly this space, no labelOne total, no per-space counts
operations.no_shows_recent (new): no-shows on sessions that ended in the last 7 days, shown at 5 or moreFollows spaceOnly this space, no labelOne total, no per-space counts
operations.space_overlaps (new): exclusive-space overlaps in the next 7 daysFollows spaceOnly this space, no labelOne total, no per-space counts
All money, member, lead, task, form, check-in-form and birthday rules (O1a–O1p, O1s–O1v)Whole businessShown after the space items, untaggedShown untagged

A coach keeps exactly their current rules; their space rules follow the context.


13. Implementation slices

Each slice is a candidate Linear ticket under TKN-459. Every slice follows the TKN-444 constraints: strings in en, he and ru; behavior behind the one per-org PostHog flag (space-context then; spaces since 2026-10-18), default off and identical to today when off; db:generate reviewed and never db:migrate without owner approval; feature docs updated.

SliceChangeDepends onFlagMigrationRisksAcceptance
S1 Context plumbingWorkingContext type; /users/me returns active spaces (isDefault) and spaceScope; useWorkingContext() reads ?space; scopedHref(); taikan_space cookie; server default via a provider; spaceId on space-bound reads; Spotter <org_context> gets the space; PostHog events get space_idTKN-444 mergedspace-contextNoneA link that drops the parameter (safe: cookie fallback); a cache key missing the space (one useScopedApiQuery helper)Flag off: identical. One-space org: no parameter ever written. Two tabs hold two spaces
S2 Optional staff space restrictionsDefault space_scope = all for everyone; the owner opts a staff member into “Only these spaces…” on the staff row; SpaceScopeService; server enforcement on explicit and implicit spaceId for space-bound data (403 space_not_in_scope, never data); own-coached sessions always visible; members and business data never space-filtered; client-side resolve with the quiet “{space} isn’t in your spaces” line; audit membership.spaces_updatedS1; coordinates with TKN-313space-contextEnum + memberships.space_scope + membership_spaces (additive; everyone starts all)A forgotten space-bound endpoint ignores the restriction (spec per endpoint); MCP tokensWithout a restriction nothing changes. A staff member restricted to Parquet gets no Main hall sessions via UI, API, Spotter or MCP, still sees the sessions they coach, and sees every member
S3 Switcher UIContextSwitcher replaces OrgSwitcher; one switcher on screen at a time (the sidebar header while the sidebar is open on desktop, the top-bar button otherwise; §7.1); menu groups; keyboard and ARIA; live region; dirty-form guard; sheets below the top bar; the quiet “Whole business” tag rule and the §7.6 allowlist; out-of-scope line, missing and closed statesS1space-contextNonee2e drivers depend on the test ids (kept); phone top bar crowding; RTL chevronsOne-space, one-membership user: empty visual diff. Keyboard-only switch works. Screen readers announce the switch. Hebrew mirrors
S4 Folded schedule and session spaceThe board (rows = spaces, WeekGrid, row toggle, CollapsedCell, COMPACT_ABOVE, search, attention rank), day view and phone sections; active space pinned and expanded; drag to move across space rows plus “Move to space” menu, with TKN-444 checks and the override dialog; the space control at the top of the session sheet; template apply active → slot → template → class type → org default (no “needs a space”); the slot space picker; the class-type space cascade with preview; the default space and the NOT NULL sequence (§8.3)S1, S3; TKN-444space-context (enforcement still spaces)M1 locations.is_default + partial unique index; M2 mark defaults / create “Main space”; M3 backfill class_sessions.location_id; M4 NOT NULL. Each separate, each owner-approvedBackfill puts legacy sessions in the wrong hall (report counts per business before M4, owner reviews); cascade moves too much (preview, future only, followers only); drag on touch (menu alternative)In Parquet, a new session saves to Parquet without touching the space. Dragging a card to Main hall moves it or shows the clash. Changing a class type’s space previews and moves only its future followers. No session without a space after M4
S5 Reports by spaceReports workspace per §12.2: spaceId, groupBy=space and groupBy=mainSpace on /analytics/dashboard, /analytics/attendance/report, /analytics/members/cohorts (two new attributed retention rows); snapshot tiles with no space names (attributed tiles say “by main space” in their sub-line; “Whole business” tags per §7.6); per-space breakdown rows with share bars; “active in” vs “main space” toggle with the overlap footnote; stacked charts (≤5 spaces, then Other) and the faint business line; Revenue and Retention tabs with one “Whole business” tag in a single space; the trials tile; the “Spaces” section (§12.3) and the “Plan coverage vs. check-ins by space” block on the Attendance tab, both All spaces only (new /analytics/members/coverage, coverage columns waiting on TKN-446); the few-check-ins line on member-based tiles; exports carry the space and the attribution. Report space scope: the switcher’s ?space=; Reports has no second space control. Tightened definitions from §12.2 (utilization ended-only; visits per visitor in a space)S1; S6 for trials and the member read modelspace-contextNone. Indexes only if EXPLAIN asks: class_sessions (organization_id, location_id, starts_at); bookings (membership_id, created_at) for the main spaceSpace attendance read as space revenue (tags, separate sections); the overlap toggle misread (footnote); utilisation against opening hours is out (TKN-391)Per-space session counts and main-space people sum to the totals; “active in” visibly doesn’t and carries *. Revenue reads the same in every space with “Whole business”. A one-space business sees today’s Reports unchanged
S5b Overview widgets by spaceOverview per §12.2: spaceId + groupBy=space on /sessions/upcoming-today (LIMIT raised), /analytics/classes/utilization, /insights (space rules filtered; new rules operations.no_shows_recent, operations.space_overlaps); Today panel breakdown rows; ActionCenter filtered by the space with plain totals in All spaces, no labels; the “Spaces this week” panel (hall load, overlaps and overrides, cross-space members, waitlist pressure, no-show and late-cancel rates); Members ribbon “trained this week” by space and by main space; Schedule strip spaceId on /programs/:id week stats and /daily-programming/board; the member detail’s one per-space lineS1, S6 (member read model, tasks); S4 for overlapsspace-contextNone. Reads the audit table for overrides by (organization_id, action, created_at) (check the existing index)Insight counts drifting between space and total (one query with groupBy); late-cancel approximated from the org window until TKN-447’s policy_snapshotIn Parquet every follows-space tile shows Parquet’s value with “of {total}” and no space name; every business tile keeps its value, tagged per §7.6; All spaces breakdowns sum where they should. A one-space business sees today’s Overview unchanged
S6 Members, tasks, messages, leadsThe member read model (§6.8: check-ins only; activeInSpaceIds, derived mainSpaceId, noRecentCheckIns; no plan input), the few-check-ins line with a link to check-in setup; member rows without space names, a removable focus chip, the “No check-ins in the last 60 days” group the chip never hides, the count line “{active} active · {notActive} not active here”, main-space-first sort, search always business-wide; the member app’s schedule opening on the main space; the Members header by space (§12.2 M1–M8, “New to {space}”, cross-space widget) with spaceId, groupBy=space and groupBy=mainSpace on /analytics/members/summary, /members/roster-summary, /analytics/members/at-risk and /members/:id/stats; tasks.location_id with defaults; announcement audience scope with the opt-in ”+ n with no check-ins in the last 60 days” checkbox (unchecked by default), recipients stored at send; inbox focus chip; lead_trial_appointments.location_id, trial defaults and the queue’s “No trial yet” groupS1space-context (trials also sales-workspace-v2)Three additive nullable columns and an announcement audience columnAudience resolved at send vs read (store recipients); leads without a space hidden by mistake (always show “No trial yet”)A task created in Parquet carries Parquet. An announcement from Parquet reaches members active in Parquet, plus members with no check-ins only when ticked. A new joiner shows under “No check-ins in the last 60 days” in every space view. No member is ever missing from the list for any staff member
S7 KioskKiosk header shows the space; door mode /[lang]/kiosk/space/[spaceId] follows now and next; from a space context the kiosk opens straight to that door; restricted staff open their spaces’ doorsS4 (S2 for restrictions)space-contextNoneA tablet left signed in after a restriction: the next poll returns only allowed sessionsDoor mode moves to the next session by itself
S8 HQ reserveDocs and seams only: the level discriminant; the /[lang]/network/* namespace reserved in the route inventory (not routed); an unrendered leading slot in the switcher; no schema. Revisit with an ADR when TKN-386 concludesTKN-386 decision noteNoneNoneSpeculative abstraction (one discriminant and docs)Nothing user-visible

New API parameters, all on existing endpoints and all optional:

  • spaceId on space-bound reads;
  • groupBy=space on session-derived analytics;
  • groupBy=mainSpace on people counts;
  • asOf reused for the main space on cohorts.

Without them, responses are byte-identical to today.

Downstream tickets that consume the context (not new slices): TKN-446 (plan focus, per-bucket balances), TKN-448 (effective policy per space), the member-app space filter, Spotter tool defaults. ADR-0024 D4 gets an amendment note when S4 lands (§9).


Kiosk door (S7, built)

/[lang]/kiosk/space/[hallId] is one hall’s check-in door: the regular kiosk’s QR code and roster for the session checking in now, the next session in a strip at the bottom, and the hall’s name in the header (§7.6 item 6). It asks GET /organizations/:orgId/sessions/door/:hallId every 30 s and again at the response’s advancesAt, so it moves on by itself:

  • Now is the session that hasn’t ended whose check-in opened last (30 minutes before its start, the self check-in window). Back-to-back classes hand over when the next one’s check-in opens. Next is the first session after it within 24 hours. With nothing checking in, the door says so and shows when check-in opens.
  • Entry: in the dashboard with one hall active (?space=), a class of that hall opens the hall’s door from the session sheet’s “Open display”. In All spaces, for a class in another hall, or with the context off, it opens that session’s kiosk as before.
  • Gate: the API goes through SpaceScopeService.resolveDoor, which calls resolve (the one place S2 will narrow a staff member’s halls) and adds the 2+ active halls rule. Flag off or one hall: 404, and the web route renders Next’s 404, exactly as before the route existed. A 403 on a later poll drops the roster (“This tablet’s access to {hall} ended”).
  • Later: an area door (?area=<areaId>, filtering class_sessions.area_id) adds an optional areaId to the door loader (findKioskDoor(orgId, clerkId, { hallId })) and to the route; nothing else changes.

Analytics

Event names live in libs/shared/src/lib/analytics/space-events.ts (SpaceEvents). Server events go through EventTrackingService.track with the organization group. Every event fires only while its flag is on for the org, and none carries a space’s name or any person’s details.

EventSide, flagPropertiesWhen
space_created / space_updatedAPI, spacesconcurrency, hasCapacity, activeSpaceCount, tierA space is added or edited
space_rule_blockedAPI, spacesrule (overlap | capacity), source (single | template | copy_week | create_many | replicate_day), countA single create/edit answers 409, or a batch writer skips clashing sessions (one event per batch)
space_rule_overriddenAPI, spacesrule, roleAn owner/admin saves through a rule with a reason
space_limit_reachedAPI, spacestier, limitA new space is refused by the tier cap (the upsell signal)
space_cascade_appliedAPI, spacessessionsChangedA class type’s space changed and its future sessions followed, after the preview
space_session_movedWeb, spacesvia (drag | menu | sheet | undo), overriddenThe server accepted a move to another space
space_sections_viewedWeb, spacesview (expanded | condensed | programming), spaceCount, pinnedA schedule view opened folded by space, once per view and shape
space_context_switchedWeb, spacesvia (sidebar | topbar | deep_link), fromAll, toAll, spaceCount, routeAn explicit switch, or a link into another business’s space
space_kiosk_openedWeb, spacesmode (door | regular), spaceCountA hall’s door opens, or a single session’s kiosk opens in a business with the context
space_kiosk_advancedWeb, spacesfromIdle (no session was checking in before), spaceCountAn open door moves on to another session by itself
space_panel_viewedWeb, spacespanel (overview_spaces_week | reports_spaces), spaceCount, hasOverlaps, fewCheckInsA space breakdown block came on screen with data, once per mount (All spaces, owner/admin, 2+ spaces). reports_spaces is the Reports “Spaces” section with plan coverage by space
space_report_groupedWeb, spacesreport (attendance | revenue | retention: the open tab), view (main_space | active_in), spaceCountThe Reports people view was switched (All spaces)

While a business has the context, the web registers space_id (a space id or all) as a PostHog super property, so every client event names the context it happened in. It is cleared when a business has no context and on every business switch, because PostHog persists super properties across page loads.

Sentry and log fields are in docs/architecture/observability.md#space-context.


14. Resolved decisions

Saar, 2026-10-12.

  1. URL: query parameter ?space=, enforced on the server against the caller’s spaces; a restricted caller gets no data from a space outside them (§6.2, §6.5).
  2. Access is opt-out: every staff member has every space by default; the owner may restrict a specific staff member to chosen spaces (§6.5).
  3. Relaxed presentation: no forbidden page; a coach always sees the sessions they coach; members stay business-wide for everyone and are only focused, never hidden (§5.3, §6.7).
  4. Every session has a space: a default space per business, a backfill and NOT NULL in separate owner-approved migrations, the class type’s space inherited with a previewed cascade for future followers, easy moves on the sheet and by drag (§8).
  5. Where a space is shown (revised 2026-10-12): an allowlist. A space name appears only in the context control, the board’s row headers, the session sheet (Space field, Move to space, the override dialog), breakdown blocks in All spaces, the member detail’s one per-space line, and where a space is configured (Settings → Spaces, the space pickers, the kiosk header). Nowhere else: no “Active in” column, no ”· main”, no page-title suffix, no names on ActionCenter items, Today rows, task rows, session cards, the agenda, search results or trial rows. Names are plain text in full, never abbreviated or coloured. “Whole business” tags are quiet: one per page or section that ignores the space, on a card only where cards on one screen differ, none in All spaces (§7.6).
  6. Money is whole business: reports get a space scope for attributable reports only (S5).
  7. Out-of-scope link: opens the caller’s own space with one quiet line (§6.7).
  8. No specialist persona (§1).
  9. One flag: spaces (§13) (it shipped behind its own space-context flag, folded into spaces on 2026-10-18).
  10. PRODUCT.md: the positioning proposal is applied (§15.3).
  11. Members across spaces (approved 2026-10-12): a “No check-ins in the last 60 days” group in every space view and as an opt-in on announcements; a derived, never-stored mainSpaceId that orders and drives the member app and a “by main space” report, but never limits; the count line “{active} active in {space} · {notActive} not active here” (§5.3, §6.8).
  12. Stats widgets (requested 2026-10-12): every widget is classed as follows space, Whole business or attributed (by main space); no space for money; breakdowns per space, in All spaces only, with footnotes where they overlap; a “Spaces this week” panel; ActionCenter items filtered by the space, never labelled (§12, S5, S5b, §7.6).
  13. Members page widgets (requested 2026-10-12): the Members header by space: the donut as a people filter over members active in the space, “New to a space” and churn by last main space as All-spaces breakdowns, the at-risk list filtered (never labelled, never a summed debt per space), the cross-space widget in All spaces, and one per-space line on the member detail (§12.2, §12.3).
  14. Presence is check-ins; plans are separate (2026-10-12): the plan is the booking gate and drives no widget; plan coverage appears only in the Reports block “Plan coverage vs. check-ins by space”. Active in, trained in, main space, training across spaces, new to and last active in count attended (checked-in) bookings only; no plan fallback for the main space. “Not active in any space yet” becomes “No check-ins in the last 60 days”. Member-based space widgets show a quiet few-check-ins line with a link to check-in setup, never hiding (§6.8, §12).
  15. One switcher on screen (2026-10-12): never two at once. With the sidebar open on desktop, the sidebar header is the switcher and the top bar shows the page title only. When the sidebar isn’t visible (phone, or desktop with the sidebar hidden), the switcher is one button at the start of the top bar, with the business inside its menu; while the phone sidebar sheet is open, its header takes over and the top bar is inert. Focus returns to the switcher that opened the menu. No page-title suffix (§7.1, §7.3–7.6).
  16. Three levels: branch, hall, area (Saar, 2026-10-15, TKN-479): a branch is its own business; a hall (this document’s “space”) has its own plans, reports and schedule; an area is a place inside a hall that only says where a class runs. Every existing location becomes an area of one hidden default hall; areas show only as a label, a sheet field and a filter chip (§7.6 item 7, §8.5).

Rejected (2026-10-12): the schedule space filter and the per-space lanes, replaced by the folded board (§8.1); access control as a hard boundary, replaced by decisions 2, 3 and 7.


15. Constraints and evidence

15.1 Binding constraints

  • Planning only in this pass: no production code, migrations or app UI. The PRODUCT.md positioning edit is the one product-doc change, on Saar’s decision.
  • Hebrew-first RTL, light and dark, phone width, logical properties, skeletons not spinners, the shadcn sidebar shell, One Teal Rule (apps/web/DESIGN.md).
  • Strings go to libs/shared/src/lib/i18n/dictionaries/{en,he,ru}.json when built (§11.1).
  • Test ids and accessible names used by e2e drivers are a contract (PRODUCT.md).
  • Every behavior change ships behind spaces, default off, identical to today when off.
  • Decisions made on 2026-10-11 and 2026-10-12 are not reopened; ADR-0024 D1–D3 and D5–D12 and maxSpaces stand; D4 is amended by decision 4 (§9).

15.2 What the running app showed (spaces branch, http://localhost:3010 , 1440px and 390px, Hebrew, dark)

  • The org switcher sits in the sidebar header and lists every active membership, including those where the user is a customer (“Taikan Demo Gym · לקוח”). Switching hard-navigates to the role home.
  • At 390px the top bar shows only the sidebar toggle, the page title and Spotter. The business name is visible only after opening the sidebar sheet. This is the gap the top-bar switcher button closes.
  • The new-session sheet hides Space under “Additional details (date, time, title, space, capacity & notes)”, defaulting to “No space”. This is the “remember to set the space” burden in one screen; §7.7 moves the space to the top and §8.3 removes “No space”.
  • Settings → Spaces lists spaces as אולמות (Hebrew “halls”); Russian uses Залы. On 2026-10-14 Saar changed the Hebrew term to מתחם / מתחמים (an area or compound, which fits a hall, a studio or an outdoor yard alike); Russian keeps Залы. The copy follows those terms; “branch” is not a UI word today, so the UI says the business name, “business” (עסק, клуб) or “organization” where existing keys already do.
  • Analytics already keeps its state in the URL (?tab=attendance&from=…&to=…&months=6): the precedent for ?space=.
  • The schedule week view has no space filter, and the session cards carry no space.
  • The coaching program board (program-board.tsx, week-grid.tsx) already has the row toggle, CollapsedCell, COMPACT_ABOVE = 5, search, attention ordering and drag between cells with dnd-kit that S4 reuses.
  • Pre-existing, outside this scope: at 390px the members header’s primary button (“הזמנת לקוח”) is clipped at the screen edge.

15.3 PRODUCT.md (applied 2026-10-12)

Applied to apps/web/PRODUCT.md in this worktree:

  • Users: “almost always a single location” became “almost always a single branch. Most train in one space; a minority (Pro) run two to five spaces, such as halls or rooms, inside that branch.”
  • Capabilities and constraints: kept “Not an enterprise multi-location platform” and “not nutrition coaching”, and added “Several spaces inside one branch are supported on Pro and above. Separate branches are separate organizations that a person switches between; there is no consolidated network view.”

15.4 Sources read

Linear: TKN-358 (with the 2026-10-09 comment and the review “Architect review: Arbox vs Taikan subscriptions and policies (2026-10-09)”), TKN-459, TKN-28, TKN-386, TKN-387, TKN-313, TKN-391, TKN-444, TKN-446, TKN-447, TKN-383, TKN-385, TKN-356, TKN-382. Code: libs/shared/src/lib/permissions/matrix.ts, apps/api/src/auth/permissions.service.ts, apps/api/src/auth/sales-workspace-access.service.ts, apps/web/src/components/shared/org-switcher.tsx, apps/web/src/providers/active-org-provider.tsx, apps/web/src/lib/active-org.ts, apps/web/src/components/overview/sidebar-nav.tsx, apps/web/src/config/dashboard-sections.ts, apps/web/src/components/overview/programming/{program-board,week-grid}.tsx, apps/web/PRODUCT.md, libs/db/src/lib/schema/{scheduling,program-templates,courses,tasks,announcements,sales-workspace,sales-workspace-access}.ts, docs/features/locations/behavior.md, docs/features/spotter-agent/behavior.md. Guidelines: Vercel Web Interface Guidelines (command.md).