Add exercise field to profiles; update filters, components, and database schema

This commit is contained in:
MartinBraquet
2026-07-31 18:02:51 +02:00
parent 37e8a420d6
commit 14c3887f03
22 changed files with 230 additions and 37 deletions

View File

@@ -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/<file>.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`.

View File

@@ -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",

View File

@@ -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 ' : '') +

View File

@@ -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 04. 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.`,

View File

@@ -7,13 +7,18 @@ Apply them with `./scripts/migrate.sh backend/supabase/migrations/<file>.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/<file>.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_<name>.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

View File

@@ -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;

View File

@@ -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);

View File

@@ -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": "24 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...",

View File

@@ -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...",

View File

@@ -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(),

View File

@@ -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(),

View File

@@ -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',
'24 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)

View File

@@ -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<string, string> = {
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') {

View File

@@ -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<FilterFields> = {
has_kids: undefined,
wants_kids_strength: undefined,
is_smoker: undefined,
exercise: undefined,
psychedelics: undefined,
cannabis: undefined,
psychedelics_intention: undefined,

View File

@@ -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

View File

@@ -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)

View File

@@ -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)],

View File

@@ -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 (
<span className={clsx('text-semibold', highlightedClass)}>
{t('filter.any_exercise', 'Exercise')}
</span>
)
}
if (length > 1) {
return (
<span>
<span className={clsx('font-semibold', highlightedClass)}>
{t('filter.multiple', 'Multiple')}
</span>
</span>
)
}
const option = options[0]
const label = Object.entries(EXERCISE_CHOICES).find(([_, v]) => v === option)?.[0] || option
return (
<div>
<span className={clsx('font-semibold', highlightedClass)}>
{t(`profile.exercise.${option}`, label)}
</span>
</div>
)
}
export function ExerciseFilter(props: {
filters: Partial<FilterFields>
updateFilter: (newState: Partial<FilterFields>) => void
}) {
const {filters, updateFilter} = props
return (
<Col className="gap-4">
<MultiCheckbox
selected={filters.exercise ?? []}
choices={EXERCISE_CHOICES as any}
translationPrefix={'profile.exercise'}
onChange={(c) => {
updateFilter({exercise: c})
}}
/>
</Col>
)
}

View File

@@ -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}
/>
</FilterSection>
<FilterSection
title={t('profile.optional.languages', 'Languages')}
openFilter={openFilter}
setOpenFilter={setOpenFilter}
isActive={hasAny(filters.languages || undefined)}
selection={
<LanguageFilterText
options={filters.languages as string[] | undefined}
// highlightedClass={
// hasAny(filters.languages || undefined) ? 'text-primary-600' : 'text-ink-900'
// }
/>
}
>
<LanguageFilter filters={filters} updateFilter={updateFilter} />
</FilterSection>
</FilterGroup>
{/* Lifestyle Group */}
@@ -574,6 +592,16 @@ function Filters(props: {
<DietFilter filters={filters} updateFilter={updateFilter} />
</FilterSection>
<FilterSection
title={t('profile.optional.exercise', 'Exercise')}
openFilter={openFilter}
setOpenFilter={setOpenFilter}
isActive={hasAny(filters.exercise || undefined)}
selection={<ExerciseFilterText options={filters.exercise as string[] | undefined} />}
>
<ExerciseFilter filters={filters} updateFilter={updateFilter} />
</FilterSection>
<FilterSection
title={t('profile.optional.drinks_per_month', 'Drinks')}
openFilter={openFilter}
@@ -651,23 +679,6 @@ function Filters(props: {
>
<CannabisFilter filters={filters} updateFilter={updateFilter} />
</FilterSection>
<FilterSection
title={t('profile.optional.languages', 'Languages')}
openFilter={openFilter}
setOpenFilter={setOpenFilter}
isActive={hasAny(filters.languages || undefined)}
selection={
<LanguageFilterText
options={filters.languages as string[] | undefined}
// highlightedClass={
// hasAny(filters.languages || undefined) ? 'text-primary-600' : 'text-ink-900'
// }
/>
}
>
<LanguageFilter filters={filters} updateFilter={updateFilter} />
</FilterSection>
</FilterGroup>
{/* Values & Beliefs Group */}

View File

@@ -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: {
/>
</Col>
<Category title={t('profile.optional.diet', 'Diet')} />
<Col className={clsx(colClassName)}>
{/*<label className={clsx(labelClassName)}>*/}
{/* {t('profile.optional.diet', 'Diet')}*/}
{/*</label>*/}
<MultiCheckbox
choices={DIET_CHOICES}
selected={profile['diet'] ?? []}
translationPrefix={'profile.diet'}
onChange={(selected) => setProfile('diet', selected)}
/>
</Col>
<Category title={t('profile.optional.category.morality', 'Morality')} />
<AddOptionEntry
title={t('profile.optional.causes', 'Causes')}
@@ -1192,6 +1179,38 @@ export const OptionalProfileUserForm = (props: {
label={'interests'}
/>
<Category title={t('profile.optional.category.lifestyle', 'Lifestyle')} />
<Col className={clsx(colClassName)}>
<label className={clsx(labelClassName)}>{t('profile.optional.diet', 'Diet')}</label>
<MultiCheckbox
choices={DIET_CHOICES}
selected={profile['diet'] ?? []}
translationPrefix={'profile.diet'}
onChange={(selected) => setProfile('diet', selected)}
/>
</Col>
<Col className={clsx(colClassName)}>
<label className={clsx(labelClassName)}>
{t('profile.optional.exercise', 'Exercise')}
</label>
<Select
value={profile['exercise'] ?? ''}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
setProfile('exercise', e.target.value || null)
}
className={'w-full sm:w-80'}
>
<option value=""></option>
{Object.entries(EXERCISE_CHOICES).map(([label, value]) => (
<option key={value} value={value}>
{t(`profile.exercise.${value}`, label)}
</option>
))}
</Select>
</Col>
<Category title={t('profile.optional.category.substances', 'Substances')} />
<Col className={clsx(colClassName)}>

View File

@@ -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: {
<Politics profile={profile} />
<Religion profile={profile} />
<Diet profile={profile} />
<Exercise profile={profile} />
<Smoker profile={profile} />
<Drinks profile={profile} />
<Cannabis profile={profile} />
@@ -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 (
<AboutRow
title={t('profile.exercise', 'Exercise')}
text={t(`profile.exercise.${exercise}`, INVERTED_EXERCISE_CHOICES[exercise] ?? exercise)}
/>
)
}
function LanguagesSection(props: {profile: Profile}) {
const t = useT()
const {profile} = props

View File

@@ -127,7 +127,13 @@ export function RangeSlider(props: {
return (
<RxSlider.Root
className={clsx(className, 'relative flex h-7 touch-none select-none items-center')}
className={clsx(
className,
'relative flex h-7 touch-none select-none items-center',
// mark labels are absolutely positioned below the track, so they'd otherwise spill past
// the container's own padding and get clipped
marks && 'mb-5',
)}
value={dragValues}
step={step ?? 1}
onValueChange={(vals: number[]) => setDragValues([vals[0], vals[1]])} // update continuously for UI feedback