diff --git a/CLAUDE.md b/CLAUDE.md index 827226a7..e7106d58 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,6 +63,10 @@ Database: yarn --cwd=backend/api regen-types-dev # rebuild common/src/supabase/schema.ts ``` +Every new migration must also be appended to `backend/supabase/migration.sql` (`\i +backend/supabase/migrations/.sql`, before the closing `COMMIT;`) — that ordered list is what rebuilds +the schema from scratch. + ## Adding an API endpoint (3 files across 2 packages) 1. **Schema** — add entry (method, `authed`, Zod `props`, `returns`) in `common/src/api/schema.ts`. diff --git a/backend/api/package.json b/backend/api/package.json index 796b9277..d7ce243a 100644 --- a/backend/api/package.json +++ b/backend/api/package.json @@ -1,6 +1,6 @@ { "name": "@compass/api", - "version": "1.56.0", + "version": "1.57.0", "private": true, "description": "Backend API endpoints", "main": "src/serve.ts", diff --git a/backend/api/src/get-profiles.ts b/backend/api/src/get-profiles.ts index a75398c6..0e726b68 100644 --- a/backend/api/src/get-profiles.ts +++ b/backend/api/src/get-profiles.ts @@ -4,6 +4,7 @@ import {type APIHandler} from 'api/helpers/endpoint' import { DIET_CHOICES, EDUCATION_CHOICES, + EXERCISE_CHOICES, GENDERS, LANGUAGE_CHOICES, MBTI_CHOICES, @@ -88,6 +89,7 @@ export type profileQueryType = { wants_kids_strength?: number | undefined has_kids?: number | undefined is_smoker?: boolean | undefined + exercise?: string[] | undefined shortBio?: boolean | undefined psychedelics?: string[] | undefined cannabis?: string[] | undefined @@ -147,6 +149,7 @@ const choiceFields = [ {field: 'gender', choices: GENDERS}, {field: 'education_level', choices: EDUCATION_CHOICES}, {field: 'mbti', choices: MBTI_CHOICES}, + {field: 'exercise', choices: EXERCISE_CHOICES}, ] // Define array choice fields to search @@ -290,6 +293,7 @@ export const loadProfiles = async (props: profileQueryType, db?: SupabaseDirectC causes, work, is_smoker, + exercise, shortBio, psychedelics, cannabis, @@ -629,6 +633,8 @@ export const loadProfiles = async (props: profileQueryType, db?: SupabaseDirectC {is_smoker}, ), + exercise?.length && where(`exercise = ANY($(exercise))`, {exercise}), + psychedelics?.length && where( (psychedelics.includes('never_not_interested') ? 'psychedelics IS NULL OR ' : '') + diff --git a/backend/api/src/llm-extract-profile.ts b/backend/api/src/llm-extract-profile.ts index d442f3f2..6ca01f8d 100644 --- a/backend/api/src/llm-extract-profile.ts +++ b/backend/api/src/llm-extract-profile.ts @@ -6,6 +6,7 @@ import { CANNABIS_CHOICES, DIET_CHOICES, EDUCATION_CHOICES, + EXERCISE_CHOICES, GENDERS, LANGUAGE_CHOICES, MBTI_CHOICES, @@ -113,6 +114,7 @@ async function validateProfileFields( 'gender', 'education_level', 'mbti', + 'exercise', 'psychedelics', 'cannabis', 'headline', @@ -467,6 +469,7 @@ export async function callLLM( relationship_status: Object.values(RELATIONSHIP_STATUS_CHOICES), cannabis: Object.values(CANNABIS_CHOICES), education_level: Object.values(EDUCATION_CHOICES), + exercise: Object.values(EXERCISE_CHOICES), gender: Object.values(GENDERS), mbti: Object.values(MBTI_CHOICES), psychedelics: Object.values(PSYCHEDELICS_CHOICES), @@ -511,6 +514,7 @@ export async function callLLM( 'Number 0–4. How strongly they want kids (0 = definitely not, 4 = definitely yes).', diet: `Array. Any of: ${validChoices.diet?.join(', ')}`, ethnicity: `Array. Any of: ${validChoices.ethnicity?.join(', ')}`, + exercise: `String. One of: ${validChoices.exercise?.join(', ')}. How often they exercise, only if explicitly stated.`, // Substances psychedelics: `String. One of: ${validChoices.psychedelics?.join(', ')}. Usage frequency of psychedelics/plant medicine, only if explicitly stated.`, diff --git a/backend/supabase/CLAUDE.md b/backend/supabase/CLAUDE.md index 7fdce600..7e25583f 100644 --- a/backend/supabase/CLAUDE.md +++ b/backend/supabase/CLAUDE.md @@ -7,13 +7,18 @@ Apply them with `./scripts/migrate.sh backend/supabase/migrations/.sql` (s [`../../supabase/CLAUDE.md`](../../supabase/CLAUDE.md)). The `supabase/migrations/` folder at the repo root is generated/derived and gitignored — don't add migrations there. +**Every new migration must also be appended to [`migration.sql`](migration.sql)** as +`\i backend/supabase/migrations/.sql`, just before the closing `COMMIT;`. That file is the ordered +list used to rebuild the schema from scratch — a migration missing from it silently never runs on a fresh +database. Creating the migration file is not done until the line is added. + ## Layout ``` backend/supabase/ ├── makefile Regen type / schema targets ├── migrations/ Active migrations — YYYYMMDD_.sql, applied in filename order (source of truth) -├── migration.sql One-off SQL helpers +├── migration.sql Ordered \i list of every table file + migration — rebuilds the schema from scratch ├── users.sql, profiles.sql, ... Reference shapes — one file per table ├── functions.sql, functions_others.sql Postgres functions ├── extensions.sql Postgres extensions @@ -35,8 +40,9 @@ These targets are also exposed as `yarn --cwd=backend/api regen-types-dev` / `re ## Conventions - SQL is lowercase by convention across the codebase. -- Adding a new table: create a new migration in [`migrations/`](migrations/), apply it (see - `../../supabase/CLAUDE.md`), then run `make regen-types-dev` so the types in `common/` pick it up. +- Adding a new table or column: create a new migration in [`migrations/`](migrations/), add its `\i` line to + [`migration.sql`](migration.sql), apply it (see `../../supabase/CLAUDE.md`), then run `make regen-types-dev` + so the types in `common/` pick it up. - Don't hand-edit `common/src/supabase/schema.ts` — it gets overwritten by `regen-types`. ## Related docs diff --git a/backend/supabase/migration.sql b/backend/supabase/migration.sql index 0328d9f7..23b6353f 100644 --- a/backend/supabase/migration.sql +++ b/backend/supabase/migration.sql @@ -56,7 +56,10 @@ BEGIN; \i backend/supabase/email_unsubscribe_tokens.sql \i backend/supabase/migrations/20260524_add_orientation_to_profiles.sql \i backend/supabase/migrations/20260709_add_last_checked_at_to_bookmarked_searches.sql -\i backend/supabase/migrations/20260724_add_neurotype_to_profiles.sql \i backend/supabase/migrations/20260724_add_accessibility_notes_to_profiles.sql +\i backend/supabase/migrations/20260724_add_neurotype_to_profiles.sql \i backend/supabase/migrations/20260728_add_ban_reason_to_users.sql +\i backend/supabase/migrations/20260730_cap_profiles_users_reads.sql +\i backend/supabase/migrations/20260731_add_exercise_to_profiles.sql +\i backend/supabase/migrations/20260731_lock_activity_stars_compat.sql COMMIT; diff --git a/backend/supabase/migrations/20260731_add_exercise_to_profiles.sql b/backend/supabase/migrations/20260731_add_exercise_to_profiles.sql new file mode 100644 index 00000000..678deaa6 --- /dev/null +++ b/backend/supabase/migrations/20260731_add_exercise_to_profiles.sql @@ -0,0 +1,10 @@ +-- Add exercise frequency to profiles +-- Created: 2026-07-31 +-- +-- Scalar single-select (see EXERCISE_CHOICES in common/src/choices.ts): 'rarely' | 'sometimes' | 'often'. +-- NULL means "not answered" — there is deliberately no "prefer not to say" choice. + +ALTER TABLE profiles + ADD COLUMN IF NOT EXISTS exercise TEXT; + +CREATE INDEX IF NOT EXISTS profiles_exercise_idx ON profiles (exercise); diff --git a/common/messages/de.json b/common/messages/de.json index b0e86d34..197862ce 100644 --- a/common/messages/de.json +++ b/common/messages/de.json @@ -405,6 +405,7 @@ "filter.age.years": "Jahre", "filter.any": "Alle", "filter.any_cannabis": "Cannabis", + "filter.any_exercise": "Sport", "filter.any_causes": "Moralische Anliegen", "filter.any_diet": "Ernährung", "filter.any_drinks": "Getränke", @@ -448,6 +449,7 @@ "filter.label.cannabis": "Cannabis", "filter.label.cannabis_intention": "Cannabis (Absicht)", "filter.label.diet": "Ernährung", + "filter.label.exercise": "Sport", "filter.label.drinks": "Getränke", "filter.label.education_levels": "Bildung", "filter.label.has_kids": "Kinder", @@ -886,6 +888,10 @@ "profile.connection.default": "Verbindung", "profile.connection_goals": "Verbindungsziele", "profile.diet": "Ernährung", + "profile.exercise": "Sport", + "profile.exercise.rarely": "Einmal pro Woche oder weniger", + "profile.exercise.sometimes": "2–4 Mal pro Woche", + "profile.exercise.often": "5+ Mal pro Woche", "profile.diet.keto": "Ketogen", "profile.diet.omnivore": "Omnivor", "profile.diet.other": "Andere", @@ -1160,6 +1166,7 @@ "profile.optional.category.family": "Familie", "profile.optional.category.interested_in": "Wen ich suche", "profile.optional.category.morality": "Moral", + "profile.optional.category.lifestyle": "Lebensstil", "profile.optional.category.personal_info": "Persönliche Informationen", "profile.optional.category.photos": "Fotos", "profile.optional.category.psychology": "Psychologie", @@ -1172,6 +1179,7 @@ "profile.optional.connection_type": "Beziehungsart", "profile.optional.details": "Details", "profile.optional.diet": "Ernährungsweise", + "profile.optional.exercise": "Sport", "profile.optional.drinks_per_month": "Alkoholische Getränke pro Monat", "profile.optional.education_level": "Höchster Bildungsabschluss", "profile.optional.error.invalid_fields": "Einige Felder sind nicht korrekt...", diff --git a/common/messages/fr.json b/common/messages/fr.json index 10cca653..0757ebc7 100644 --- a/common/messages/fr.json +++ b/common/messages/fr.json @@ -405,6 +405,7 @@ "filter.age.years": "ans", "filter.any": "Tous", "filter.any_cannabis": "Cannabis", + "filter.any_exercise": "Activité physique", "filter.any_causes": "Causes morales", "filter.any_diet": "Régime alimentaire", "filter.any_drinks": "Boissons", @@ -448,6 +449,7 @@ "filter.label.cannabis": "Cannabis", "filter.label.cannabis_intention": "Cannabis (intentions)", "filter.label.diet": "Régime", + "filter.label.exercise": "Activité physique", "filter.label.drinks": "Boissons", "filter.label.education_levels": "Éducation", "filter.label.has_kids": "Enfants", @@ -885,6 +887,10 @@ "profile.connection.default": "une relation", "profile.connection_goals": "Objectifs de relation", "profile.diet": "Régime alimentaire", + "profile.exercise": "Activité physique", + "profile.exercise.rarely": "Une fois par semaine ou moins", + "profile.exercise.sometimes": "2 à 4 fois par semaine", + "profile.exercise.often": "5 fois ou plus par semaine", "profile.diet.keto": "Cétogène", "profile.diet.omnivore": "Omnivore", "profile.diet.other": "Autre", @@ -1159,6 +1165,7 @@ "profile.optional.category.family": "Famille", "profile.optional.category.interested_in": "Ce que je recherche", "profile.optional.category.morality": "Moralité", + "profile.optional.category.lifestyle": "Mode de vie", "profile.optional.category.personal_info": "Infos personnelles", "profile.optional.category.photos": "Photos", "profile.optional.category.psychology": "Psychologie", @@ -1171,6 +1178,7 @@ "profile.optional.connection_type": "Type de relation", "profile.optional.details": "Détails", "profile.optional.diet": "Régime alimentaire", + "profile.optional.exercise": "Activité physique", "profile.optional.drinks_per_month": "Boissons alcoolisées consommées par mois", "profile.optional.education_level": "Niveau d'études le plus élevé", "profile.optional.error.invalid_fields": "Certains champs sont incorrects...", diff --git a/common/src/api/schema.ts b/common/src/api/schema.ts index 366f1e07..e791c4a8 100644 --- a/common/src/api/schema.ts +++ b/common/src/api/schema.ts @@ -788,6 +788,7 @@ export const API = (_apiTypeCheck = { wants_kids_strength: z.coerce.number().optional(), has_kids: z.coerce.number().optional(), is_smoker: zBoolean.optional().optional(), + exercise: arraybeSchema.optional(), psychedelics: arraybeSchema.optional(), cannabis: arraybeSchema.optional(), psychedelics_intention: arraybeSchema.optional(), diff --git a/common/src/api/zod-types.ts b/common/src/api/zod-types.ts index e901e073..55de28ba 100644 --- a/common/src/api/zod-types.ts +++ b/common/src/api/zod-types.ts @@ -99,6 +99,7 @@ const optionalProfilesSchema = z.object({ image_descriptions: z.any().optional().nullable(), interests: z.array(z.string()).optional().nullable(), is_smoker: zBoolean.optional().nullable(), + exercise: z.string().optional().nullable(), links: z.record(linkValueSchema).optional(), mbti: z.string().optional().nullable(), occupation: z.string().optional().nullable(), diff --git a/common/src/choices.ts b/common/src/choices.ts index 6a81e21b..11798860 100644 --- a/common/src/choices.ts +++ b/common/src/choices.ts @@ -47,6 +47,14 @@ export const DIET_CHOICES = { Other: 'other', } as const +// Frequency buckets rather than intensity or identity labels: "active"/"moderate" are self-graded and +// drift a full bucket between users, whereas sessions-per-week reads the same to everyone. +export const EXERCISE_CHOICES = { + 'Once a week or less': 'rarely', + '2–4 times a week': 'sometimes', + '5+ times a week': 'often', +} as const + export const EDUCATION_CHOICES = { 'High school': 'high-school', College: 'some-college', @@ -384,6 +392,7 @@ export const INVERTED_RELATIONSHIP_STATUS_CHOICES = invert(RELATIONSHIP_STATUS_C export const INVERTED_ROMANTIC_CHOICES = invert(ROMANTIC_CHOICES) export const INVERTED_POLITICAL_CHOICES = invert(POLITICAL_CHOICES) export const INVERTED_DIET_CHOICES = invert(DIET_CHOICES) +export const INVERTED_EXERCISE_CHOICES = invert(EXERCISE_CHOICES) export const INVERTED_EDUCATION_CHOICES = invert(EDUCATION_CHOICES) export const INVERTED_RELIGION_CHOICES = invert(RELIGION_CHOICES) export const INVERTED_LANGUAGE_CHOICES = invert(LANGUAGE_CHOICES) diff --git a/common/src/filters-format.ts b/common/src/filters-format.ts index 727d3ff4..ea5c26c8 100644 --- a/common/src/filters-format.ts +++ b/common/src/filters-format.ts @@ -2,6 +2,7 @@ import { INVERTED_CANNABIS_CHOICES, INVERTED_DIET_CHOICES, INVERTED_EDUCATION_CHOICES, + INVERTED_EXERCISE_CHOICES, INVERTED_GENDERS, INVERTED_LANGUAGE_CHOICES, INVERTED_MBTI_CHOICES, @@ -35,6 +36,7 @@ const filterLabels: Record = { has_kids: '', wants_kids_strength: '', is_smoker: '', + exercise: 'Exercise', pref_relation_styles: '', pref_romantic_styles: '', interests: '', @@ -198,6 +200,10 @@ export function formatFilters( value = value.map((s) => translate(`profile.gender.${s}`, INVERTED_GENDERS[s])) } else if (key === 'genders') { value = value.map((s) => translate(`profile.gender.${s}`, INVERTED_GENDERS[s])) + } else if (key === 'exercise') { + value = value.map((s) => + translate(`profile.exercise.${s}`, INVERTED_EXERCISE_CHOICES[s] ?? s), + ) } else if (key === 'cannabis') { value = value.map((s) => translate(`profile.cannabis.${s}`, INVERTED_CANNABIS_CHOICES[s])) } else if (key === 'psychedelics') { diff --git a/common/src/filters.ts b/common/src/filters.ts index f12e9cb3..0826339c 100644 --- a/common/src/filters.ts +++ b/common/src/filters.ts @@ -16,6 +16,8 @@ export type FilterFields = { genders: string[] | null | undefined cannabis: string[] | null | undefined psychedelics: string[] | null | undefined + // Scalar on the profile, multi-select as a filter — same shape as `cannabis` above. + exercise: string[] | null | undefined education_levels: string[] | null | undefined mbti: string[] | null | undefined name: string | null | undefined @@ -91,6 +93,7 @@ export const initialFilters: Partial = { has_kids: undefined, wants_kids_strength: undefined, is_smoker: undefined, + exercise: undefined, psychedelics: undefined, cannabis: undefined, psychedelics_intention: undefined, diff --git a/common/src/supabase/schema.ts b/common/src/supabase/schema.ts index 2f3bed20..0f9dd969 100644 --- a/common/src/supabase/schema.ts +++ b/common/src/supabase/schema.ts @@ -1192,6 +1192,7 @@ export type Database = { drinks_per_month: number | null education_level: string | null ethnicity: string[] | null + exercise: string | null gender: string | null gender_details: string | null geodb_city_id: string | null @@ -1276,6 +1277,7 @@ export type Database = { drinks_per_month?: number | null education_level?: string | null ethnicity?: string[] | null + exercise?: string | null gender?: string | null gender_details?: string | null geodb_city_id?: string | null @@ -1360,6 +1362,7 @@ export type Database = { drinks_per_month?: number | null education_level?: string | null ethnicity?: string[] | null + exercise?: string | null gender?: string | null gender_details?: string | null geodb_city_id?: string | null @@ -1877,6 +1880,7 @@ export type Database = { drinks_per_month: number | null education_level: string | null ethnicity: string[] | null + exercise: string | null gender: string | null gender_details: string | null geodb_city_id: string | null diff --git a/tests/e2e/backend/utils/userInformation.ts b/tests/e2e/backend/utils/userInformation.ts index d0697072..e85d48a7 100644 --- a/tests/e2e/backend/utils/userInformation.ts +++ b/tests/e2e/backend/utils/userInformation.ts @@ -3,6 +3,7 @@ import { CANNABIS_CHOICES, DIET_CHOICES, EDUCATION_CHOICES, + EXERCISE_CHOICES, GENDERS, LANGUAGE_CHOICES, MBTI_CHOICES, @@ -60,6 +61,7 @@ class UserAccountInformationForSeeding { political_beliefs = Object.values(POLITICAL_CHOICES) religion = Object.values(RELIGION_CHOICES) diet = Object.values(DIET_CHOICES) + exercise = Object.values(EXERCISE_CHOICES) drinks_per_month = faker.number.int({min: 4, max: 40}) height_in_inches = faker.number.float({min: 56, max: 78, fractionDigits: 2}) ethnicity = Object.values(RACE_CHOICES) diff --git a/tests/e2e/utils/seedDatabase.ts b/tests/e2e/utils/seedDatabase.ts index b82dac64..6fda39d1 100644 --- a/tests/e2e/utils/seedDatabase.ts +++ b/tests/e2e/utils/seedDatabase.ts @@ -104,6 +104,7 @@ export async function seedDbUser( const fullProfile = { ...mediumProfile, + exercise: userInfo.randomElement(userInfo.exercise), cannabis: userInfo.randomElement(userInfo.cannabis), psychedelics: userInfo.randomElement(userInfo.psychedelics), cannabis_intention: [userInfo.randomElement(userInfo.cannabis_intention)], diff --git a/web/components/filters/exercise-filter.tsx b/web/components/filters/exercise-filter.tsx new file mode 100644 index 00000000..72896233 --- /dev/null +++ b/web/components/filters/exercise-filter.tsx @@ -0,0 +1,64 @@ +import clsx from 'clsx' +import {EXERCISE_CHOICES} from 'common/choices' +import {FilterFields} from 'common/filters' +import {Col} from 'web/components/layout/col' +import {MultiCheckbox} from 'web/components/multi-checkbox' +import {useT} from 'web/lib/locale' + +export function ExerciseFilterText(props: { + options: string[] | undefined + highlightedClass?: string +}) { + const {options, highlightedClass} = props + const length = (options ?? []).length + + const t = useT() + + if (!options || length < 1) { + return ( + + {t('filter.any_exercise', 'Exercise')} + + ) + } + + if (length > 1) { + return ( + + + {t('filter.multiple', 'Multiple')} + + + ) + } + + const option = options[0] + const label = Object.entries(EXERCISE_CHOICES).find(([_, v]) => v === option)?.[0] || option + + return ( +
+ + {t(`profile.exercise.${option}`, label)} + +
+ ) +} + +export function ExerciseFilter(props: { + filters: Partial + updateFilter: (newState: Partial) => void +}) { + const {filters, updateFilter} = props + return ( + + { + updateFilter({exercise: c}) + }} + /> + + ) +} diff --git a/web/components/filters/filters.tsx b/web/components/filters/filters.tsx index f09f275c..a0ca1ac2 100644 --- a/web/components/filters/filters.tsx +++ b/web/components/filters/filters.tsx @@ -18,6 +18,7 @@ import { import {CardSizeSelector} from 'web/components/filters/card-size-selector' import {DietFilter, DietFilterText} from 'web/components/filters/diet-filter' import {EducationFilter, EducationFilterText} from 'web/components/filters/education-filter' +import {ExerciseFilter, ExerciseFilterText} from 'web/components/filters/exercise-filter' import {FieldToggles} from 'web/components/filters/field-toggles' import {HasPhotoFilter} from 'web/components/filters/has-photo-filter' import {IncompleteProfilesToggle} from 'web/components/filters/incomplete-profiles-toggle' @@ -525,6 +526,23 @@ function Filters(props: { locationFilterProps={raisedInLocationFilterProps} /> + + + } + > + + {/* Lifestyle Group */} @@ -574,6 +592,16 @@ function Filters(props: { + } + > + + + - - - } - > - - {/* Values & Beliefs Group */} diff --git a/web/components/optional-profile-form.tsx b/web/components/optional-profile-form.tsx index e86212b8..cdbb70da 100644 --- a/web/components/optional-profile-form.tsx +++ b/web/components/optional-profile-form.tsx @@ -9,6 +9,7 @@ import { DEFAULT_ORIENTATIONS, DIET_CHOICES, EDUCATION_CHOICES, + EXERCISE_CHOICES, EXTRA_NEUROTYPES, GENDERS, GENDERS_PLURAL, @@ -1158,20 +1159,6 @@ export const OptionalProfileUserForm = (props: { /> - - - - {/**/} - setProfile('diet', selected)} - /> - - + + + + + setProfile('diet', selected)} + /> + + + + + + + diff --git a/web/components/profile-about.tsx b/web/components/profile-about.tsx index 885f0c8f..d7adc917 100644 --- a/web/components/profile-about.tsx +++ b/web/components/profile-about.tsx @@ -3,6 +3,7 @@ import { INVERTED_CANNABIS_CHOICES, INVERTED_DIET_CHOICES, INVERTED_EDUCATION_CHOICES, + INVERTED_EXERCISE_CHOICES, INVERTED_LANGUAGE_CHOICES, INVERTED_MBTI_CHOICES, INVERTED_NEUROTYPE_CHOICES, @@ -77,6 +78,7 @@ export default function ProfileAbout(props: { + @@ -632,6 +634,21 @@ function Diet(props: {profile: Profile}) { ) } +function Exercise(props: {profile: Profile}) { + const t = useT() + const {profile} = props + const exercise = profile.exercise + + if (!exercise) return null + + return ( + + ) +} + function LanguagesSection(props: {profile: Profile}) { const t = useT() const {profile} = props diff --git a/web/components/widgets/slider.tsx b/web/components/widgets/slider.tsx index 8c96e0d9..b1c647dd 100644 --- a/web/components/widgets/slider.tsx +++ b/web/components/widgets/slider.tsx @@ -127,7 +127,13 @@ export function RangeSlider(props: { return ( setDragValues([vals[0], vals[1]])} // update continuously for UI feedback