From c91694efe9800b74c9f6c7fb801bacf24e408e4e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 6 May 2026 14:38:08 +0000 Subject: [PATCH 1/2] feat(#136): user_gyms table + db helpers (PR 1 WIP) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation work for the multi-instruktør epic. Lands the user_gyms membership table in Supabase and the read helper plus auto-join helper that downstream PRs (joint-class history, opt-out toggle, multi-BU sportySync) will build on. Migration applied to remote Supabase project (kyolnraqudwrjjbtxhwx): - create table user_gyms (id, user_id, sporty_business_unit_id, role, created_at) with unique(user_id, sporty_business_unit_id) - RLS: self select/insert/delete; auth.uid() = user_id - Backfilled both existing users to BU 8 (Sporty Thon Senter Ski) db.js additions: - DEFAULT_SPORTY_BUSINESS_UNIT_ID = 8 - fetchMyGyms() — read; trusts RLS - ensureGymMembership(buId) — idempotent upsert; called on first login (wire-up in App.jsx is the next commit) Remaining for PR 1 (handover to local dev): - Wire ensureGymMembership() into App.jsx auth flow - Settings.jsx: read-only "Min gym" card (Sporty Thon Senter Ski) - i18n keys settings.my_gym_*, settings.my_gym_membership, settings.my_gym_future_hint in nb/en/fa - Comment on sportySync.js BU 8 hardcode pointing at PR 5 follow-up - CLAUDE.md architecture note for user_gyms - npm run build verification Plan: /root/.claude/plans/robust-exploring-sutton.md https://claude.ai/code/session_01PNmJDWGM4eWSDPFFHmJiyY --- app/src/lib/db.js | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/app/src/lib/db.js b/app/src/lib/db.js index d103a97..2cf4bf6 100644 --- a/app/src/lib/db.js +++ b/app/src/lib/db.js @@ -369,3 +369,31 @@ export async function updateSession(sessionId, exercises, gymCalendarId, { repla }); if (error) throw error; } + +// ── USER GYMS ───────────────────────────────────────────────────────── + +export const DEFAULT_SPORTY_BUSINESS_UNIT_ID = 8; + +export async function fetchMyGyms() { + const { data, error } = await supabase + .from("user_gyms") + .select("id, sporty_business_unit_id, role, created_at") + .order("created_at", { ascending: true }); + if (error) throw error; + return data || []; +} + +export async function ensureGymMembership(buId = DEFAULT_SPORTY_BUSINESS_UNIT_ID) { + const { data: { user } } = await supabase.auth.getUser(); + if (!user) return null; + const { data, error } = await supabase + .from("user_gyms") + .upsert( + { user_id: user.id, sporty_business_unit_id: buId }, + { onConflict: "user_id,sporty_business_unit_id", ignoreDuplicates: true } + ) + .select() + .maybeSingle(); + if (error) throw error; + return data; +} From d61bfd32da0dd5836f6c9798a72cf174da180bb8 Mon Sep 17 00:00:00 2001 From: Christopher Rotnes Date: Wed, 6 May 2026 16:48:51 +0200 Subject: [PATCH 2/2] =?UTF-8?q?feat(#136):=20wire=20gym=20membership=20?= =?UTF-8?q?=E2=80=94=20auth=20hook,=20Settings=20card,=20i18n,=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - App.jsx: call ensureGymMembership() on session resolve + auth state change - Settings.jsx: read-only "Min gym" card (Carbon Tag) between Utseende and Kontakt - nb/en/fa locales: myGym, myGymMembership, myGymFutureHint keys - sportySync.js: comment flagging BU 8 hardcode for future multi-gym removal - CLAUDE.md: architecture note for user_gyms, opt-out sharing model, role placeholder Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 1 + app/api/sportySync.js | 2 ++ app/public/locales/en/translation.json | 5 ++++- app/public/locales/fa/translation.json | 5 ++++- app/public/locales/nb/translation.json | 5 ++++- app/src/App.jsx | 7 ++++++- app/src/components/Settings.jsx | 19 ++++++++++++++++++- 7 files changed, 39 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 409646d..e9f5b4a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -190,6 +190,7 @@ week_plan_days - **Claude API proxy:** `app/api/claude.js` verifies incoming Supabase JWTs via `GET /auth/v1/user`. Requires `ANTHROPIC_API_KEY`, `SUPABASE_URL`, and `SUPABASE_ANON_KEY` as Azure app settings. Use `SUPABASE_ANON_KEY` (no `VITE_` prefix) — the `VITE_` prefix is Vite build-time only and is invisible to the Azure Functions runtime. - **CI/CD build split:** the frontend is pre-built in the GitHub Actions runner (`npm ci && npm run build` with `VITE_*` in `env:`), then the Azure SWA action uploads `app/dist/` directly (`app_location: "app/dist"`). This bypasses Oryx for the frontend — Oryx strips `VITE_*` env vars before spawning Vite and they never reach the bundle. Oryx still handles the API (`app/api`). `vite.config.js` has a build-time assertion that fails immediately if the required vars are missing. - **Supabase client explicit apikey header:** `createClient` is called with `global: { headers: { apikey: supabaseKey } }` in `app/src/lib/supabase.js`. The Supabase JS v2 fetch interceptor should add this automatically, but it was not reaching browser requests — passing it in `global.headers` puts it directly on `PostgrestClient`'s base headers, bypassing the interceptor. Do not remove this option. +- **Multi-instruktør gym membership:** `user_gyms` table links each user to a Sporty business unit (`sporty_business_unit_id`). Primary users are instruktører; sharing default is opt-out scoped to the same gym. `ensureGymMembership(buId)` in `db.js` does an idempotent upsert on sign-in (called in `App.jsx`). The `role` column is a text placeholder (`'instruktor'`) — it will be replaced by a FK to a dedicated temporal `roles` table in a later issue. `DEFAULT_SPORTY_BUSINESS_UNIT_ID = 8` mirrors the hardcoded BU in `sportySync.js`; both must move to a DB config when multi-gym support lands. Backfilled rows exist for both current users. ## Known limitations - SVG body is improved but still geometrically simplified — not anatomically precise; key muscles (traps, lats) use path shapes, rest are ellipses diff --git a/app/api/sportySync.js b/app/api/sportySync.js index 5922e24..d317892 100644 --- a/app/api/sportySync.js +++ b/app/api/sportySync.js @@ -1,5 +1,7 @@ import { app } from '@azure/functions'; +// BU 8 is hardcoded for the single-gym MVP (Sporty Thon Senter Ski). +// When multi-gym support lands, replace with a DB lookup via user_gyms.sporty_business_unit_id. const SPORTY_BASE_URL = 'https://sporty.no/api/v1/businessunits/8/groupactivities'; diff --git a/app/public/locales/en/translation.json b/app/public/locales/en/translation.json index 69310dc..17a68aa 100644 --- a/app/public/locales/en/translation.json +++ b/app/public/locales/en/translation.json @@ -274,7 +274,10 @@ "language": "Language", "languageNorwegian": "Norsk", "languageEnglish": "English", - "languagePersian": "فارسی" + "languagePersian": "فارسی", + "myGym": "My gym", + "myGymMembership": "Sporty Thon Senter Ski", + "myGymFutureHint": "Coming soon: choose your gym and see shared session history with other instructors." }, "report": { "heroMuscles_one": "{{count}} muscle", diff --git a/app/public/locales/fa/translation.json b/app/public/locales/fa/translation.json index 99ce9d0..5e3fea5 100644 --- a/app/public/locales/fa/translation.json +++ b/app/public/locales/fa/translation.json @@ -274,7 +274,10 @@ "language": "زبان", "languageNorwegian": "Norsk", "languageEnglish": "English", - "languagePersian": "فارسی" + "languagePersian": "فارسی", + "myGym": "باشگاه من", + "myGymMembership": "Sporty Thon Senter Ski", + "myGymFutureHint": "به زودی: انتخاب باشگاه و مشاهده تاریخچه مشترک با سایر مربیان." }, "report": { "heroMuscles_one": "{{count}} عضله", diff --git a/app/public/locales/nb/translation.json b/app/public/locales/nb/translation.json index 29e247d..1d9282c 100644 --- a/app/public/locales/nb/translation.json +++ b/app/public/locales/nb/translation.json @@ -274,7 +274,10 @@ "language": "Språk", "languageNorwegian": "Norsk", "languageEnglish": "English", - "languagePersian": "فارسی" + "languagePersian": "فارسی", + "myGym": "Min gym", + "myGymMembership": "Sporty Thon Senter Ski", + "myGymFutureHint": "Fremtidig: velg gym og se felles økthistorikk med andre instruktører." }, "report": { "heroMuscles_one": "{{count}} muskel", diff --git a/app/src/App.jsx b/app/src/App.jsx index d1af07b..04977d6 100644 --- a/app/src/App.jsx +++ b/app/src/App.jsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; import { supabase } from "./lib/supabase"; +import { ensureGymMembership } from "./lib/db"; import { NavContext } from "./lib/NavContext"; import Login from "./components/Login"; import Home from "./components/Home"; @@ -22,9 +23,13 @@ function App() { const [reportPrefill, setReportPrefill] = useState(null); useEffect(() => { - supabase.auth.getSession().then(({ data: { session } }) => setSession(session)); + supabase.auth.getSession().then(({ data: { session } }) => { + setSession(session); + if (session) ensureGymMembership().catch(() => {}); + }); const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => { setSession(session); + if (session) ensureGymMembership().catch(() => {}); }); return () => subscription.unsubscribe(); }, []); diff --git a/app/src/components/Settings.jsx b/app/src/components/Settings.jsx index fddaee4..3ba4be5 100644 --- a/app/src/components/Settings.jsx +++ b/app/src/components/Settings.jsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { Toggle, Button, RadioButtonGroup, RadioButton } from "@carbon/react"; +import { Toggle, Button, RadioButtonGroup, RadioButton, Tag } from "@carbon/react"; import { useTranslation } from "react-i18next"; import PageShell, { SectionLabel, PageHeading } from "./PageShell"; import BodyPanel from "./BodyPanel"; @@ -79,6 +79,23 @@ export default function Settings() { /> + {t("settings.myGym")} +
+
+ + {t("settings.myGymMembership")} + +

+ {t("settings.myGymFutureHint")} +

+
+
+ {t("settings.contact")}