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):
| Level | Product term (en / he / ru) | What it is | Data |
|---|---|---|---|
| Branch | Branch / סניף / Филиал | A separate business: own billing, own everything | organizations row (TKN-358) |
| Hall | Space 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 hall | locations row, kind = 'hall' |
| Area | Area / אזור / Зона | 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 runs | locations 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 ownspace-contextflag, folded intospaceson 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) | Scene | Default context on login | Can switch to | Comes to answer | Must never happen | If the owner restricts them |
|---|---|---|---|---|---|---|
Coach who works in Parquet: coach | Phone between classes; tablet kiosk at the Parquet door | The last space used; on first visit, the space of their next coached session today, else All spaces | Every space, All spaces, other businesses where they hold a membership | What 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: coach | Phone; sometimes the desk | The last space used; on first visit, the space of their next coached session, else All spaces | Every space, All spaces | Where 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 floor | The last space used; on first visit All spaces | Every active space, All spaces, other businesses where granted | Who 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 hall | Payment-provider settings and Taikan billing (owner only, unchanged). Other businesses’ data | Rare. 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 Parquet | Desk; phone and WhatsApp | All spaces: leads belong to the business | Every 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 class | Member 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 night | The business. Nothing new appears | Other businesses, exactly as today | Unchanged | Any new control, parameter or request. The one space is the default space and stays invisible | n/a |
| Owner, several spaces (Victor, CrossFit Rishon City, 3 spaces, Pro) | Laptop and phone | The last space used; on first visit All spaces | Any space, All spaces, other businesses | How 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 space | n/a (the owner is never restricted) |
| HQ / network owner (reserved, TKN-386) | Laptop | Today: the last business used. Later: Network › Business › Space | Today: businesses where they hold a membership, and the spaces inside each | Today: one business at a time. Later: compare branches | Any business where they hold no membership, even by editing the URL (TKN-28 scenario 3). A role in A gives no authority in B | n/a |
| Member (member app, separate shell, brief) | Phone | No working context. The member app never shows a context switcher | Nothing 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 book | Where 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.
| Proof | How it is measured |
|---|---|
| Sessions get their space without anyone typing it | PostHog 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 gesture | class_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 context | space_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 one | Integration 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 untouched | For 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 works | With 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’sWeekGrid, row toggle andCollapsedCell. - 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, onespaceIdquery parameter on space-bound reads, oneSpaceScopeServiceon 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
| Alternative | Why not |
|---|---|
| A space filter on each page | Staff 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 space | Rejected 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 everyone | Rejected 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 space | It 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 space | The 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_rolevalues (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.
| Route | Scope | Data rule (what carries the space, or why it is business-wide) | One space active | All spaces |
|---|---|---|---|---|
overview | Both | Feed items from sessions, bookings and check-ins use class_sessions.location_id. Debt, payment and lead items have no space | This 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 |
home | Both | The Train/Track/Thrive cards read the same sources as their pages | Train counts for this space | Totals |
schedule | Space | class_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 day | Every space row; expanded up to 5 spaces, folded above 5 with a search |
schedule/sessions/[id]/builder | Space (entity) | The session’s location_id | Opens for any session the caller may see | Same |
programs (incl. templates) | Business config, space-aware apply | Programs have no space. program_templates.default_location_id and program_template_slots.location_id exist; the UI has no slot space picker yet | Apply targets this space (§7.7) | Apply resolves slot → template → class type → org default; never “needs a space” |
coaching | Business | Cohorts and assignments have no space | Unchanged, “Whole business” tag | Unchanged |
courses (incl. [id]) | Both | Live events: course_sessions.location_id (stays nullable: online events use location_text). The catalogue has no space | Live events in this space first; no new labels | Unchanged |
workouts, movements | Business | Library content has no space | Unchanged, tag | Unchanged |
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 staff | Today’s list: no “Active in” column, no space names in rows |
leads (Sales) | Both | Leads have no space. The trial’s space is a new nullable lead_trial_appointments.location_id | Leads with a trial in this space plus leads with no trial yet; a line “12 more in other spaces”. Rows carry no space | The whole queue; no trial-space column |
messages | Both | Conversations 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 |
tasks | Both | New nullable tasks.location_id; null means the whole business | Tasks in this space, then whole-business tasks; no labels | All tasks; no space column |
plans, plans/[id], plans/groups/[id] | Business | Plans 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 badge | Unchanged; no coverage column |
payments | Business | Payments and subscriptions carry no space and are never allocated | Unchanged, “Whole business” tag | Unchanged |
analytics | Both | Attendance, sessions, fill rate, check-ins and trials carry a space. Revenue, payments, debt, active members and retention are business-wide | Space 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) | Business | Automations have no space. Later, a space condition on triggers using the ADR-0024 D9 condition model | Unchanged, tag | Unchanged |
forms (incl. [formId], new) | Business | A waiver belongs to the person; the booking gate is business-wide | Unchanged, tag | Unchanged |
join-links | Business | Links have no space (a trial space on a link is a later option) | Unchanged, tag | Unchanged |
minisite, branding | Business | The public face of the business | Unchanged, tag | Unchanged |
audit-log | Business | Owner-only ledger | Unchanged, tag | Unchanged |
settings/locations (Spaces) | Business | The locations rows; the default space; per-staff restrictions are set on the staff row | Unchanged | Unchanged |
settings/booking | Both | booking_policy_rules.scope can name a space (TKN-447). Org columns are the default layer | The 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) | Business | Org columns | Unchanged, tag | Unchanged |
/[lang]/kiosk/[sessionId] | Space (entity) | The session’s space | Header shows the space | n/a |
/[lang]/kiosk/space/[spaceId] (new, S7) | Space | Sessions in this space now and next | Door mode follows the current session | n/a |
| Spotter (panel, not a route) | Both | Tools call the same services as the membership | Reads 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
| Action | Scope | Rule |
|---|---|---|
| Create a session | Space | Space = 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 space | Space | Drag 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 space | Space (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 day | Space | In 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) | Space | The member can be anyone in the business (members are never hidden) |
| Check in, mark no-show, cancel a session | Space | As today |
| Create a class type | Business | Space prefilled with the active space; empty means “org default space” |
| Create a task | Both | location_id = the active space, with a “Whole business” toggle |
| Send an announcement | Both | Audience = members active in the active space; the author can widen it |
| Book a trial (Sales) | Both | Trial space = the active space; session suggestions come from that space first |
| Create a plan (owner/admin) | Business | With plan buckets (TKN-446): the first bucket’s scope is prefilled with the active space |
| Charge, refund, change a subscription | Business | Unchanged. Never filtered by space |
| Export a report | Both | The 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 spaces | Business | Owner only, on the staff row; audited (§6.5) |
| Set the default space | Business | Owner/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”.
notActiveexcludes 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_appointmentsgets a nullablelocation_id(today it hastrial_typetext 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-0023own/teamstill decides which leads). - Tasks.
tasksgets a nullablelocation_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)orall spaces). Read tools focus on the active space; write tools defaultlocationIdto 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)
| Option | Deep links | Several tabs | URL reflects state | Cost |
|---|---|---|---|---|
Query parameter ?space=<id> (chosen) | Yes | Yes | Yes | One scopedHref() for links; a link without the parameter falls back safely |
Path segment /dashboard/s/<space>/… | Yes | Yes | Yes | About 40 pages move under a dynamic segment |
| Cookie only (today’s org model) | No | No | No | Cheapest |
| Server-side user preference | No | No | No | Cross-device default only |
?space=<locationId>or?space=allon 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-12opens 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 nospace(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/meon the server (lib/api.ts). It resolves the default space the same way and passes it down, following theActiveOrgProviderpattern. 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_orgcookie. 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)
- The business has 0–1 active spaces → no space. Stop.
- The cookie remembers a space for this business and the caller may still use it → that space (or
all). - The caller is restricted to exactly one space → that space.
- The caller coaches a session in the next 24 hours → that session’s space (
/users/menextSessionSpaceId). - Otherwise →
all(“All my spaces (n)” for a restricted caller).
6.4 How the API receives it
- Query parameter
spaceIdon space-bound reads, matching existing camelCase filters (weekStart,programId). For exampleGET /organizations/:orgId/class-sessions?weekStart=…&spaceId=…. - For the board,
spaceIdorders 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
locationIdin 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/meadds, per membership, the business’s active spaces (id,name,sortOrder,isDefault) and the caller’sspaceScope, 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”:
assignedwith 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_updatedwith 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
spaceIdoutside the caller’s scope returns 403space_not_in_scopeand no data. A read withoutspaceIdreturns 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)orcoach_membership_id= the caller: a coach always sees the sessions they coach. - Writes with a
locationIdoutside 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.enforcedoes 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’sCOMPACT_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
| Situation | Response | What the user sees |
|---|---|---|
| Space in this business, outside a restricted caller’s scope | Client resolves first; API backstop 403 space_not_in_scope, no data | Their own space, with the line “{space} isn’t in your spaces, showing {mine}“ |
| Space id from another business where the caller is staff | Not an error | Full navigation to that business and space, “Opened in {org}“ |
| Space id the caller can’t see anywhere | 404 (ADR-0004) | The standard not-found page with “Go to {current space}“ |
| Space deactivated | 404 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 open | The next request comes back without the removed space | The 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, unchanged | Today’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.
| Field | Rule | Used for | Never used for |
|---|---|---|---|
activeInSpaceIds | Spaces with at least one check-in in the last 60 days | The members focus chip and count line, announcement audiences, “active in” report counts, cross-space members | Access. A member may book wherever their plan allows |
mainSpaceId | The 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 |
noRecentCheckIns | activeInSpaceIds is empty | The “No check-ins in the last 60 days” group in space views and the opt-in checkbox on announcements | Hiding 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_sessionsfor the window), next to the existing member list query; no new column, no cache table. IfEXPLAINshows it is heavy, an index onbookings (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.
| Situation | The switcher | The other placement |
|---|---|---|
| Desktop, sidebar open | The 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 12px | The 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 too | The 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 closed | The same top-bar button | The sidebar header is off screen |
| Phone, sidebar sheet open | The sheet’s header | The 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
- 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. - Switch organization group (only with 2+ memberships, reusing today’s label and rows): each business with the caller’s role.
- 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
buttonwitharia-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 isrole="group"labelled by its heading; items aremenuitemradiowitharia-checked, the Radix pattern today’s switcher already uses. Radix receivesdirso 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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
| Entity | In a single space | In All spaces | Restricted 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 empty | Their space; “Change” offers only their spaces |
| Session (create from a board cell) | The row’s space | The row’s space | Their rows only |
| Template apply, copy week, replicate day | Every slot without its own space lands here; copy-week copies only this space | Per slot: slot → template → class type → org default. No “needs a space” row | Only into their spaces |
| Template slot | The slot space picker (missing in the UI today) defaults to the active space | Empty (inherits as above) | Their spaces only |
| Class type | Space prefilled with the active space; sessions then inherit it (§8.4) | Empty = org default | Their spaces |
| Task | location_id = active space; a “Whole business” toggle | Whole business | As in a single space |
| Announcement | Audience = members active in this space; an opt-in checkbox ”+ n with no check-ins in the last 60 days”, unchecked by default | Everyone | As in a single space |
| Trial (Sales) | Trial space = active space; suggested sessions from this space first | Pick 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 space | No quota (every class shares the plan’s entries) | n/a |
| Kiosk | Opens the door mode for this space | Asks which space | Their spaces |
| Spotter writes | locationId defaults to the active space; the confirmation names it | Class type’s space, else the org default | Enforced 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
spaceparameter, 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; aboveCOMPACT_ABOVE = 5they 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
spacesrules applied, since every space startsexclusive. - 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
spacesflag 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
exclusiveoverlap returnsspace_conflict, a capacity above the space returnsspace_capacity_exceeded. Owner/admin see the existing override dialog with a reason (auditedclass_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 falsewith 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, Spottercreate_many, templates, copy-week, MCP, mobile), so no client has to know. - Migration sequence, each step a separate migration reviewed with
db:generateand applied only with owner approval:- M1 add
locations.is_defaultand the partial unique index. - M2 (data) mark the lowest
sort_orderactive space of each business as default; create “Main space” where a business has none. - M3 (data) backfill
class_sessions.location_idwhere null: the class type’sdefault_location_id, else the org default. Report counts per business before and after. - M4
class_sessions.location_id SET NOT NULL(migration0177_session_hall_required).course_sessions.location_idstays nullable (online events uselocation_text);program_template_slots.location_idstays nullable (null = inherit).
- M1 add
- 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_idstops 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, enumlocation_kind, defaulthall) andlocations.parent_id. An area’sparent_idis 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_createdandsort_ordermean what they did for halls; only a hall can be the default (the partial unique index is over halls). class_sessions.location_idis the hall (required, §8.3; NOT NULL in M4).class_sessions.area_idis the optional area, always one of that hall’s areas.class_types.default_location_idis the class type’s hall (null = the business default, §8.4);class_types.default_area_idits optional area.program_template_slots.area_idlikewise 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).
- Schema (0174): the enum and columns above. Existing rows keep the default
kind = 'hall'until step 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 toexclusive, but no business ever chose a rule and nothing clashed before spaces. Withspaceson nothing clashes until staff mark an area or hall exclusive. New halls and areas startsharedtoo;exclusiveis an opt-in (ADR-0024 D2, amended 2026-10-16). - Sessions:
area_id← the oldlocation_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
locationIdsent 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 addareaIdandarea;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 enterSpaceScopeService; aspaceIdnaming an area isspace_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
locationIdat 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 (choosesHallinsession-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,
hasHallChoiceinsession-places.ts, read throughuseScopeOptions. - 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 noparentId, 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 …/locationstakes an optionalkind:areawithoutparentIdgoes into the default hall (defaultSpaceId), hidden or not;hallwith aparentIdis a 400, and so ishallwith the flag off. Withoutkindthe earlier rules stand:parentIdor 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
exclusivehall 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
sharedunless staff opt intoexclusive(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 recordingareaIdtoo) 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 anddecidedBy(“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
spacecondition 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
exclusiveclash created with an override shows both cards in the same cell with a warning edge; asharedspace 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_idis now inherited (§8.4) andclass_sessions.location_idbecomes 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:
spacesgoverns conflict and capacity enforcement and, since 2026-10-18, everything in this plan too (decision 9; the plan first shipped behind its ownspace-contextflag), except the default-space data migrations, which are invisible on their own.
10. States and ranges
| Dimension | Realistic range | Design response |
|---|---|---|
| Active spaces per business | 1 (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 user | 1 (most), 2–3, up to about 10 | Org group appears from 2; filter field from 8 |
| Restricted staff | None in most businesses; a few in large ones | Unrestricted is the norm. Restricted: label instead of menu at 1 space; “All my spaces (n)” at 2+ |
| Sessions per space per day | 0–12 | Expanded cells stack cards; folded cells show the count |
| Class-type space change | 0–200 future sessions | Preview with counts; clashes listed, capped at 20 with “and 14 more” |
| Loading | Every switch | Skeletons in the board’s geometry (WeekGrid skeleton); the label updates at once; no spinner |
| Error | A scoped request fails | Inline error inside that region with Retry; the context stays |
| Empty | A space with no sessions this week | Its row shows ”·” per day when folded; expanded, each cell keeps its add button and the row says “No sessions in Parquet this week” |
| Move refused | Clash or capacity | The override dialog (owner/admin) or a one-line reason (coach); the card returns to its row |
| Out of scope, missing, closed | Deep links, restrictions | §6.7 |
| Offline (kiosk) | Wi-Fi drop at the door | The 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-90as 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>; addcontext-switcher-topbar(the top-bar placement; the sidebar placement keepsorg-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)
| Key | en | he | ru |
|---|---|---|---|
context.trigger.ariaLabel | Working context: {context}. Change | הקשר עבודה: {context}. החלפה | Рабочий контекст: {context}. Сменить |
context.menu.spaces | Spaces (reuse settings.nav.locations) | מתחמים | Залы |
context.menu.organizations | Switch organization (reuse orgSwitcher.switchOrganization) | החלפת ארגון | Сменить организацию |
context.allSpaces | All spaces | כל המתחמים | Все залы |
context.allMySpaces | All my spaces ({count}) | כל המתחמים שלי ({count}) | Все мои залы ({count}) |
context.wholeBusiness | Whole business | כל העסק | Весь клуб |
context.wholeBusinessHint | This page isn’t filtered by space. | העמוד הזה לא מסונן לפי מתחם. | Эта страница не фильтруется по залам. |
context.switched | Now working in {context} | עברת אל {context} | Контекст: {context} |
context.openedIn | Opened 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.action | Show all spaces | הצגת כל המתחמים | Показать все залы |
context.discard.title | Discard unsaved changes? | לבטל את השינויים שלא נשמרו? | Отменить несохранённые изменения? |
context.discard.body | Switching to {context} closes {item} without saving. | המעבר אל {context} יסגור את {item} בלי לשמור. | Переход в «{context}» закроет {item} без сохранения. |
context.discard.keep | Keep editing | להמשיך לערוך | Продолжить редактирование |
context.discard.confirm | Discard and switch | לבטל ולעבור | Отменить и перейти |
context.activeIn | Active in {space} | פעילים ב־{space} | Активны в «{space}» |
context.moreElsewhere | {count} more in other spaces (leads only) | עוד {count} במתחמים אחרים | Ещё {count} в других залах |
leads.noTrialYet | No trial yet | עדיין בלי אימון ניסיון | Пробное ещё не назначено |
context.activeCount | {active} active in {space} | {active} פעילים ב־{space} | Активны в «{space}»: {active} |
context.notActiveHere | {count} not active here | {count} לא פעילים כאן | Не занимаются здесь: {count} |
context.noRecentCheckIns | No check-ins in the last 60 days ({count}) | ללא צ׳ק־אין ב־60 הימים האחרונים ({count}) | Без отметок за последние 60 дней ({count}) |
context.noRecentCheckInsHint | Space numbers count check-ins only: attended classes in the last 60 days. | מספרי המתחמים סופרים רק צ׳ק־אין: שיעורים שבהם נכחו ב־60 הימים האחרונים. | Цифры по залам считают только отметки о посещении за последние 60 дней. |
context.fewCheckIns | Space numbers come from check-ins. Few check-ins were recorded in the last 60 days. | מספרי המתחמים מבוססים על צ׳ק־אין. ב־60 הימים האחרונים נרשמו מעט צ׳ק־אינים. | Цифры по залам основаны на отметках о посещении. За последние 60 дней их было мало. |
context.fewCheckInsAction | Set up check-in | הגדרת צ׳ק־אין | Настроить отметку посещений |
reports.coverage.title | Plan coverage vs. check-ins by space | כיסוי מסלול מול צ׳ק־אין לפי מתחם | Покрытие абонементом и посещения по залам |
reports.coverage.covered | Plan covers {space} | המסלול מכסה את {space} | Абонемент покрывает «{space}» |
reports.coverage.checkedIn | Checked in ({days} days) | עשו צ׳ק־אין ({days} ימים) | Отметились ({days} дн.) |
reports.coverage.neverCheckedIn | Covered, never checked in | מכוסים, בלי צ׳ק־אין | Покрыты, но ни разу не отметились |
reports.coverage.pending | Available once plans are tied to spaces | יהיה זמין כשהמסלולים יקושרו למתחמים | Появится, когда абонементы будут привязаны к залам |
announcements.includeNoCheckIns | + {count} with no check-ins in the last 60 days | + {count} ללא צ׳ק־אין ב־60 הימים האחרונים | + ещё {count} без отметок за 60 дней |
reports.byMainSpace | By main space | לפי מתחם עיקרי | По основному залу |
reports.byActiveIn | By active in | לפי פעילות במתחם | По активности в зале |
context.sessionSpace | Space: {space} | מתחם: {space} | Зал: {space} |
spaceBoard.expandRow / collapseRow | Expand row / Collapse row (reuse the program board’s keys) | הרחבת שורה / כיווץ שורה | Развернуть / Свернуть строку |
spaceBoard.search | Search spaces… | חיפוש מתחם… | Поиск зала… |
spaceBoard.moveTo | Move to space | העברה למתחם | Перенести в зал |
spaceBoard.moved | {session} moved to {space} | {session} הועבר ל־{space} | «{session}» перенесено в «{space}» |
spaceBoard.undo | Undo | ביטול | Отменить |
spaceBoard.emptyRow | No sessions in {space} this week | אין השבוע שיעורים ב־{space} | На этой неделе в «{space}» нет занятий |
stats.wholeBusiness | Whole business (reuse context.wholeBusiness) | כל העסק | Весь клуб |
stats.byMainSpace | of {total} · by main space | מתוך {total} · לפי מתחם עיקרי | из {total} · по основному залу |
stats.ofTotal | of {total} | מתוך {total} | из {total} |
stats.toggleActiveIn | Active in | פעילים ב | Активны в |
stats.toggleMainSpace | Main space | מתחם עיקרי | Основной зал |
stats.overlapFootnote | Members who train in several spaces are counted in each, so rows add up to more than the total. | מי שמתאמן בכמה מתחמים נספר בכל אחד מהם, ולכן השורות מסתכמות ביותר מהסה״כ. | Кто занимается в нескольких залах, учтён в каждом, поэтому сумма строк больше итога. |
stats.other | Other | אחר | Другое |
stats.revenueWholeBusiness | Whole business. Revenue isn’t split by space. | כל העסק. ההכנסות לא מחולקות לפי מתחם. | Весь клуб. Доходы не делятся по залам. |
stats.visitsPerVisitor | Visits per visitor | ביקורים למתאמן | Посещений на человека |
stats.leftByMainSpace | Left, by main space | עזבו, לפי מתחם עיקרי | Ушли, по основному залу |
stats.membersByMainSpace | Members by main space | לקוחות לפי מתחם עיקרי | Участники по основному залу |
stats.attributionHint | Counted under the space where they checked in most in the last 60 days. | נספרים במתחם שבו עשו הכי הרבה צ׳ק־אין ב־60 הימים האחרונים. | Учтены в зале, где чаще всего отмечались за 60 дней. |
members.ribbon.activeHere | Active here | פעילים כאן | Активны здесь |
members.ribbon.ofMembers | of {count} members | מתוך {count} לקוחות | из {count} участников |
members.ribbon.ofActiveHere | of {count} active here | מתוך {count} פעילים כאן | из {count} активных здесь |
members.ribbon.newToASpace | New to a space | חדשים במתחם | Впервые в зале |
members.ribbon.lastActiveIn | Last active in | היו פעילים לאחרונה ב | Последний раз занимались в |
members.ribbon.mainSpaceSplit | Members by main space | לקוחות לפי מתחם עיקרי | Участники по основному залу |
members.imported.noCheckIns | None of them has checked in yet. | אף אחד מהם עוד לא עשה צ׳ק־אין. | Никто из них ещё не отмечался. |
members.crossSpace.title | Training across spaces | מתאמנים בכמה מתחמים | Занимаются в нескольких залах |
members.crossSpace.all | {count} of {total} train in 2+ spaces ({pct}) | {count} מתוך {total} מתאמנים ב־2 מתחמים ומעלה ({pct}) | {count} из {total} занимаются в 2+ залах ({pct}) |
members.crossSpace.allThree | All three | בשלושתם | Во всех трёх |
members.detail.bySpace | {space} {count} | {space} {count} | {space} {count} |
spacesPanel.title | Spaces this week | מתחמים השבוע | Залы на этой неделе |
spacesPanel.hallLoad | Hall load | עומס מתחם | Загрузка зала |
spacesPanel.hallLoadHint | Booked seat-hours ÷ offered seat-hours. | שעות מקום שהוזמנו ÷ שעות מקום שהוצעו. | Забронированные места-часы ÷ предложенные. |
spacesPanel.peak | Peak {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.noShow | No-show {value} | לא הגיעו {value} | Неявки {value} |
spacesPanel.lateCancel | Late cancel {value} | ביטול מאוחר {value} | Поздняя отмена {value} |
spacesPanel.empty | No 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.space | Space | מתחם | Зал |
classType.spaceHelp | Sessions of this class type take place in this space. | השיעורים מסוג זה מתקיימים במתחם הזה. | Занятия этого типа проходят в этом зале. |
classType.cascade.title | Move 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.confirm | Move {count} sessions | העברת {count} שיעורים | Перенести занятия: {count} |
settings.spaces.default | Default space | מתחם ברירת מחדל | Зал по умолчанию |
staff.spaces.all | All spaces | כל המתחמים | Все залы |
staff.spaces.only | Only 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
- No space for money, ever. Receipts, MRR, debt, refunds, plan revenue and payment methods are whole business in every view.
- 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.
- 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”.
- Follows space: session-derived; filtered by
- 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. - 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.
- 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.
- 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).
- 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.
- Single-space businesses see exactly today’s widgets: no tags, no breakdowns, no new tiles, no new requests.
- Roles are unchanged from the matrix:
analyticsO,A;paymentAnalyticsO,A;leadsO,A;tasksO,A,C;classSessionsO,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
nonebucket.
Segmentation column:
- “today” means the API already returns it.
- “new” means it needs a new
groupBy. The new values areclassType,coach,weekdayHour,plan,newVsReturningandoutcome, where outcome is attended, no-show, late cancel or unmarked.
Overview (OverviewV2)
| Widget (inventory) | Metric as computed today | Window and comparison | Space behaviour | In one space | In All spaces | Extra segmentation | API change |
|---|---|---|---|---|---|---|---|
| ActionCenter count (O1) | Number of non-zero rules, sorted by severity, then count | Per rule | Per item: see §12.4 | This space’s items, then business items; no tags, no space names; the count is the plain number of items | Every item as one plain total; no per-space counts or names | By 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 people | Today; no comparison | Follows 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 classes | Class 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 utilization | Rolling 30 days | Follows 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-in | Today | Follows space | This space’s rows | Every session; rows carry no space name, the per-space summary rows above carry the split | n/a | Same call as O2 |
| Pending tasks (O5) | First 25 open tasks by due date | Open now | Tasks with a space follow it (S6 tasks.location_id); tasks without a space are whole business | This space’s tasks, then whole-business tasks; no labels | All tasks; no labels | Assignee, type (today) | spaceId on /tasks (S6) |
| Member growth (O6) | Joined / left per org-local month; active at month end; delta = latest − previous | 6 months; month over month | Whole business | Same value; one “Whole business” tag on the business trend row | Same, no tag | Source (import vs join, needs new) | None |
| Revenue (O7) | Net receipts per month (receipts SQL); collected MTD; expected monthly = MRR + presale committed; outstanding = Σ debt | 12 months; MTD vs full prior month | Whole business | Same value; the trend row’s one tag | Same, no tag | Method, 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 source | All time / month | Whole business. Trials by space live in Reports (S6) | Same value; the trend row’s one tag | Same, no tag | Source, status, campaign (today) | None |
| Today coaching, roster (O10–O11) | Workouts due today, 7-day completion, needs attention | Today / 7 days | Whole business (coaching has no space) | Same; tagged on its card, next to space cards | Same, no tag | Program (today) | None |
Members, Payments, Schedule, Coaching
| Widget (inventory) | Metric as computed today | Window and comparison | Space behaviour | In one space | In All spaces | Extra segmentation | API change |
|---|---|---|---|---|---|---|---|
| Members: total + roster donut (M1) | All member memberships, any status, in exclusive buckets: former > invited > in debt > overdue > leaving > with plans > no plan | Now | Whole 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 up | Rolling 7 days | Numerator 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 month | Month to date | Whole 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 delta | Month to date | Whole business; “Last active in” is a breakdown (All spaces only) | 8, tagged | 8; 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/a | groupBy=mainSpace on /analytics/members/summary (main space as of ended_at) |
| Members: imported awaiting invite (M5) | Live roster members with account status not invited | Now | Whole 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”, tagged | Same | n/a | None |
| Members: at-risk callout (M6) | Members with a past-due or debt subscription, or a debt amount; sample of 5 sorted by debt; total debt | Now | Count 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 space | Reason (today) | spaceId on /analytics/members/at-risk (filters members by activeInSpaceIds; omits totalDebt when set) |
| Members: list heading count (M7) | total of the filtered page | Now | Follows 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 today | n/a | Counts come with the /members response when spaceId is set |
| Member detail stats (M8) | Attended classes this month, total, last visit | Month / all time | One per-space line for the member; a member page isn’t filtered by the context | Same 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 today | Whole business | Same; one page tag | Same, no tag | Method, 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 timezone | This week | Follows space | Parquet value, muted “of 43” | Total + per-space rows with share bar | Class type (today via program), weekday (new) | spaceId, groupBy=space on /programs/:id week stats |
| Schedule strip: members (S3) | Distinct booking members this week | This week | Follows space; distinct people | Members who booked Parquet | Main-space split adds up; “active in” toggle footnoted | n/a | Same + groupBy=mainSpace |
| Schedule strip: fill rate (S4) | Bookings ÷ Σ capacity of capped sessions (can exceed 100%) | This week | Follows space | Parquet % | Total %, per-space % (no share bar) | Class type (new) | Same |
| Schedule strip: workouts set (S5) | Class days with a published workout ÷ class days | This week | Follows 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 day | Class days in Parquet | Total + per space; footnoted, since a shared class day counts in two spaces* | n/a | spaceId on /daily-programming/board (session count per cell) |
| Schedule drafts count (S6) | Draft sessions in the week | Visible week | Follows space | Drafts in Parquet | All drafts | n/a | Same as the board |
| Coaching strip and program stats (C1–C7) | Plans, enrolled, workouts, completion | This week | Whole business | Same; one tag on the strip or the page | Same, no tag | Program (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 today | Window and comparison | Space behaviour | In one space | In All spaces | Extra segmentation | API change |
|---|---|---|---|---|---|---|---|
| Snapshot: active members | Member memberships with status active | Now; no delta | Attributed 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 month | Receipts MTD per currency; % vs same point last month | MTD vs prior MTD | Whole business | Same, tagged (next to space tiles) | Same, no tag | Method (today) | None |
| Snapshot: classes this week | Published sessions in the week (includes upcoming) | This week | Follows space | 11, muted “of 43” | 43; Main hall 18 · Parquet 11 · Open gym 14 | Class type (new) | spaceId, groupBy=space |
| Snapshot: attendance rate | Attended ÷ booked, ended sessions MTD; pp vs prior MTD | MTD vs prior MTD | Follows space | 91.0% (+0.4 pp) | 91.3%; Main hall 91.4 · Parquet 91.0 · Open gym 91.5 | Class type, coach (new) | Same |
| Snapshot: new members this month | Joined MTD, excluding invited | MTD; absolute vs prior | Whole business (a new joiner has no main space yet) | Same, tagged | Same, no tag | Source (new) | None |
| Snapshot: active members with no visit | Active roster with no attended booking on ended sessions this month | MTD; lower is better | Attributed by main space; a visit anywhere counts | 4, 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 = 27 | n/a | groupBy=mainSpace |
| Snapshot: signed for opening | Scheduled subscriptions, monthly | Now | Whole business | Same, tagged | Same, no tag | n/a | None |
| Attendance: members who visited | Distinct members with ≥1 attended booking | Selected range | Follows space; distinct people | 124, 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 member | Attended ÷ 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 so | Selected range | Follows 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.2 | n/a | Same |
| Attendance: no-show rate | No-show ÷ booked | Selected range | Follows space | 6.2% | 5.7%; Main hall 5.3 · Parquet 6.2 · Open gym 5.9 | Class type, coach, hour (new) | Same |
| Attendance: attended per class | Attended ÷ sessions | Selected range | Follows space | 5.5 | 6.1; Main hall 7.3 · Parquet 5.5 · Open gym 4.3 | Class type (today) | Same |
| Attendance: who came (mix bar) | Booked split into attended / no-show / unmarked | Selected range | Follows space | One bar | One bar per space plus a total bar | Outcome incl. late cancel (new, see §12.3) | Same |
| Attendance by day (area chart) | Attended per org-local day | Selected range | Follows space | Parquet line with a faint whole-business line | Stacked areas per space (≤5, then “Other”) | n/a | groupBy=space |
| Busiest hours (heatmap) | Attended by weekday × start hour | Selected range | Follows space | Parquet heatmap | Totals; the tooltip lists the per-space split | Weekday × hour (today) | Same |
| Attendance table by class type | Sessions, booked, attended, no-show, unmarked, rate | Selected range | Follows space | Rows for Parquet | One row per class type × space, grouped by space, with subtotals | Class type (today), coach (new) | Same |
| Popular classes (ranking) | Class types by booked, top N + Other | Selected range | Follows space | Parquet ranking | Bars stacked by space | Class type (today) | Same |
| Capacity utilization | Σ booked in capped sessions ÷ Σ capacity; unlimited counted apart | Selected range | Follows space | 69.0% | 71.2%; Main hall 78.0 · Parquet 69.0 · Open gym 58.0 | Class type (today) | Same |
| Revenue tab (all tiles, charts, tables) | Receipts per currency; methods; monthly | Selected range | Whole business. In a single space the tab carries one tag in its header, “Whole business. Revenue isn’t split by space.”; the tiles carry none | Same values, one tab tag | Same, no tag | Method, currency (today); plan (new) | None |
| Retention: members on date, joined, net, average tenure, retained after 90 days | Membership lifecycle (not paid retention) | months back from asOf | Whole business | Same; one tab tag. No attributed lines: they are breakdowns (All spaces only) | Same, no tag, with the two “by main space” breakdowns below | Cohort month (today) | None |
| Retention: members on date by main space (new) | Members on asOf, grouped by main space as of asOf | As of date | Attributed by main space | Not shown (a breakdown, All spaces only) | Main hall 158 · Parquet 86 · Open gym 61 · no check-ins 13 = 318 | n/a | groupBy=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 left | Window | Attributed by main space | Not shown (a breakdown, All spaces only) | Main hall 21 · Parquet 12 · Open gym 6 · no main space 2 = 41 | n/a | Same; derived on read from bookings before ended_at |
| Trials (new tile, S6) | Trial appointments, from lead_trial_appointments.location_id | Selected range | Follows space | 9 | 26; Main hall 14 · Parquet 9 · Open gym 3 | Outcome (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 coverage | Last 60 days; as of today for coverage | Attributed 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).
| Widget | Definition and formula | Data source | Phone fallback | Empty state |
|---|---|---|---|---|
| Hall load | Seats 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” = Σ duration | class_sessions (starts_at, ends_at, capacity, location_id) × bookings | One line per space: “Parquet 66% · peak Sun 19:30" | "No sessions in {space} this week” |
| Overlaps and overrides | Current overlaps between live sessions in exclusive spaces this week, plus overrides recorded this week | Overlaps: a self-join on class_sessions. Overrides: audit class_session.space_override | One line: “1 overlap · 1 override” | Hidden when both are 0 |
| Cross-space members | Active 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 hall | First attended booking per (member, space) from bookings × class_sessions, derived on read | Rows under “New this month”, in All spaces: “Main hall 9 · Parquet 11 · Open gym 6" | "Nobody new to {space} this month” |
| Waitlist pressure | Waitlisted bookings on upcoming sessions ÷ capacity of those sessions, per 100 seats, per space | bookings.status = 'waitlisted' × class_sessions | Inside the per-space line: “Parquet 1.7 per 100 seats" | "No waitlists this week” |
| No-show and late-cancel rate | No-show ÷ booked; late cancels ÷ (booked + late cancels), per space, ended sessions in the range | bookings.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 window | Inside the per-space line | ”No ended sessions yet” |
Dropped:
- Prevented clashes: a 409
space_conflictis 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.
| Rule | Space | In one space | In All spaces |
|---|---|---|---|
operations.waitlisted_bookings (“class full with a waitlist”) | Follows space | Only this space’s count, no label | One total, no per-space counts or names |
operations.sessions_missing_workout | Follows space | Only this space, no label | One 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 more | Follows space | Only this space, no label | One total, no per-space counts |
operations.space_overlaps (new): exclusive-space overlaps in the next 7 days | Follows space | Only this space, no label | One total, no per-space counts |
| All money, member, lead, task, form, check-in-form and birthday rules (O1a–O1p, O1s–O1v) | Whole business | Shown after the space items, untagged | Shown 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.
| Slice | Change | Depends on | Flag | Migration | Risks | Acceptance |
|---|---|---|---|---|---|---|
| S1 Context plumbing | WorkingContext 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_id | TKN-444 merged | space-context | None | A 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 restrictions | Default 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_updated | S1; coordinates with TKN-313 | space-context | Enum + memberships.space_scope + membership_spaces (additive; everyone starts all) | A forgotten space-bound endpoint ignores the restriction (spec per endpoint); MCP tokens | Without 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 UI | ContextSwitcher 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 states | S1 | space-context | None | e2e drivers depend on the test ids (kept); phone top bar crowding; RTL chevrons | One-space, one-membership user: empty visual diff. Keyboard-only switch works. Screen readers announce the switch. Hebrew mirrors |
| S4 Folded schedule and session space | The 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-444 | space-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-approved | Backfill 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 space | Reports 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 model | space-context | None. Indexes only if EXPLAIN asks: class_sessions (organization_id, location_id, starts_at); bookings (membership_id, created_at) for the main space | Space 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 space | Overview 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 line | S1, S6 (member read model, tasks); S4 for overlaps | space-context | None. 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_snapshot | In 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, leads | The 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” group | S1 | space-context (trials also sales-workspace-v2) | Three additive nullable columns and an announcement audience column | Audience 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 Kiosk | Kiosk 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’ doors | S4 (S2 for restrictions) | space-context | None | A tablet left signed in after a restriction: the next poll returns only allowed sessions | Door mode moves to the next session by itself |
| S8 HQ reserve | Docs 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 concludes | TKN-386 decision note | None | None | Speculative abstraction (one discriminant and docs) | Nothing user-visible |
New API parameters, all on existing endpoints and all optional:
spaceIdon space-bound reads;groupBy=spaceon session-derived analytics;groupBy=mainSpaceon people counts;asOfreused 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 callsresolve(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>, filteringclass_sessions.area_id) adds an optionalareaIdto 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.
| Event | Side, flag | Properties | When |
|---|---|---|---|
space_created / space_updated | API, spaces | concurrency, hasCapacity, activeSpaceCount, tier | A space is added or edited |
space_rule_blocked | API, spaces | rule (overlap | capacity), source (single | template | copy_week | create_many | replicate_day), count | A single create/edit answers 409, or a batch writer skips clashing sessions (one event per batch) |
space_rule_overridden | API, spaces | rule, role | An owner/admin saves through a rule with a reason |
space_limit_reached | API, spaces | tier, limit | A new space is refused by the tier cap (the upsell signal) |
space_cascade_applied | API, spaces | sessionsChanged | A class type’s space changed and its future sessions followed, after the preview |
space_session_moved | Web, spaces | via (drag | menu | sheet | undo), overridden | The server accepted a move to another space |
space_sections_viewed | Web, spaces | view (expanded | condensed | programming), spaceCount, pinned | A schedule view opened folded by space, once per view and shape |
space_context_switched | Web, spaces | via (sidebar | topbar | deep_link), fromAll, toAll, spaceCount, route | An explicit switch, or a link into another business’s space |
space_kiosk_opened | Web, spaces | mode (door | regular), spaceCount | A hall’s door opens, or a single session’s kiosk opens in a business with the context |
space_kiosk_advanced | Web, spaces | fromIdle (no session was checking in before), spaceCount | An open door moves on to another session by itself |
space_panel_viewed | Web, spaces | panel (overview_spaces_week | reports_spaces), spaceCount, hasOverlaps, fewCheckIns | A 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_grouped | Web, spaces | report (attendance | revenue | retention: the open tab), view (main_space | active_in), spaceCount | The 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.
- 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). - 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).
- 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).
- 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).
- 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).
- Money is whole business: reports get a space scope for attributable reports only (S5).
- Out-of-scope link: opens the caller’s own space with one quiet line (§6.7).
- No specialist persona (§1).
- One flag:
spaces(§13) (it shipped behind its ownspace-contextflag, folded intospaceson 2026-10-18). - PRODUCT.md: the positioning proposal is applied (§15.3).
- 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
mainSpaceIdthat 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). - 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).
- 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).
- 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).
- 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).
- 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}.jsonwhen 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
maxSpacesstand; 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).