Development guide for ValYouLadder. For an overview of the project, see README.md.
See ALGORITHM.md for a full breakdown of the similarity scoring, rate normalization, representativeness weighting, ESS confidence, and AI grounding logic.
- Estimation engine: IDF-weighted skill similarity, recency-adjusted daily rates, weighted percentile calculations (p25/p50/p75). The statistical range is the primary output — AI is an optional refinement layer on top.
- AI enhancement: Optional Gemini 2.5 Flash layer. The statistical estimate (p25/p50/p75) is pre-computed from community data and passed to Gemini as a grounding anchor, keeping AI output consistent with the data-driven range while allowing qualitative adjustments for scope, expertise, or market context.
- Implied day rate: When
days_of_workis provided, displayed as a subtext on database cards, detail dialogs, and similar project results — computed asyour_budget / days_of_work - Rate representativeness: Submitters flag whether a rate was standard / below market / above market. Below-market rates get 0.5× weight in the algorithm; above-market 0.85×. Submitters can optionally provide their standard rate, which always gets full weight (1.0×) and is used in place of
your_budgetfor estimation. - Freelancer vs studio split:
contracted_asfield distinguishes who the client contracted with, enabling rate comparisons across commercial structures - Currency selector: Searchable combobox in the header. Live exchange rates via frankfurter.app, cached 1hr in localStorage. Default currency is inferred from the browser locale on first visit.
- Feature gates: Three independent env-var flags —
VITE_DB_OPEN,VITE_SUBMISSIONS_OPEN,VITE_ESTIMATES_OPEN— each default open. Set any tofalseto close that feature independently (e.g. collect submissions before opening the database). - Anonymous submissions: No account required — edit token stored in browser localStorage and optionally emailed
- Email edit link: On submit, users can opt in to receive a one-time private edit link via Brevo. Email is deleted after sending and never stored.
- Newsletter: Separate opt-in on submit, About page, homepage CTA, and footer — managed via Brevo contacts
- Privacy: Project descriptions are processed by AI before storage — PII redacted, vulgar language removed, non-English text translated to English. Applied on submit and on every edit. GDPR-compliant right to erasure via
/unsubscribe - Admin panel: Role-based access via
user_rolestable, bulk selection with type-to-confirm delete, inline editing,updated_attracking. All writes go through theadmin-manageedge function (service role) to bypass RLS correctly. - Interactive background: Canvas-based banana particles with mouse repulsion, constellation lines, and click ripples
npm install
npm run devDev server starts at http://localhost:5173
| Command | Description |
|---|---|
npm run dev |
Start Vite dev server with HMR (uses .env) |
npm run dev:staging |
Dev server pointing at the staging Supabase project (uses .env.staging) |
npm run build |
Production build to dist/ |
npm run build:dev |
Development build (unminified, with source maps) |
npm run build:staging |
Build against the staging Supabase project |
npm run preview |
Serve the production build locally |
npm run lint |
Run ESLint |
npm test |
Run tests once (Vitest) |
npm run test:watch |
Run tests in watch mode |
Copy .env.example to .env and fill in your Supabase credentials:
VITE_SUPABASE_URL=https://your-project.supabase.co
VITE_SUPABASE_PUBLISHABLE_KEY=your-anon-jwt-key
VITE_SUPABASE_PROJECT_ID=your-project-ref| Variable | Description |
|---|---|
VITE_SUPABASE_URL |
Supabase project URL |
VITE_SUPABASE_PUBLISHABLE_KEY |
Supabase anon key (starts with eyJ…) — found under Settings > API |
VITE_SUPABASE_PROJECT_ID |
Supabase project ref |
VITE_DB_OPEN |
Set to false to hide the database (default: open) |
VITE_SUBMISSIONS_OPEN |
Set to false to disable new submissions (default: open) |
VITE_ESTIMATES_OPEN |
Set to false to disable the estimation tool (default: open) |
.env and .env.production are gitignored. Never commit real keys. See Staging / pre-production for the multi-environment setup.
src/
pages/ Route pages
Index, Database, Estimate, SubmitProject,
MySubmissions, Admin, Auth, About, Privacy
components/
ui/ shadcn/ui primitives (Button, Dialog, Table, etc.)
layout/ Layout shell, Header (with currency selector), Footer
estimate/ Estimation form, results, similar projects list
database/ Project detail dialog
admin/ Admin edit dialog
submit/ Edit submission dialog, verification step
gdpr/ Privacy consent checkbox
home/ Landing page sections (Hero, Features, CTA, etc.)
BrandName Renders "ValYouLadder" with the "You" accent — use everywhere the brand name appears
BananaParticles Canvas particle background (mouse repulsion, constellation lines, click ripples)
NewsletterSignup Inline newsletter signup (compact for footer, full for landing)
PreProdBanner Yellow warning banner — rendered by callers when a feature gate is closed
contexts/
AuthContext Supabase auth + admin role check
CurrencyContext Live exchange rates (frankfurter.app, 1hr cache), locale-inferred default, format()
hooks/ useMobile, useToast
integrations/
supabase/ Supabase client + auto-generated DB types
lib/
estimation IDF-weighted similarity algorithm
projectTypes Shared constants (project types, skills, countries, currencies, etc.)
mySubmissions localStorage token store for anonymous submission management
sanitize Client-side PII validation
config Feature gates: IS_DB_OPEN, IS_SUBMISSIONS_OPEN, IS_ESTIMATES_OPEN, SUPABASE_PROJECT_ID
supabase/
functions/ Deno edge functions
migrations/ SQL migration files
public/
favicon.svg ladder emoji SVG favicon
Run migrations in order via the Supabase CLI (npx supabase db push) or the SQL Editor. Files are in supabase/migrations/.
| Function | Purpose |
|---|---|
submit-project |
Inserts submission, generates edit token, sends Brevo email if requested |
manage-submission |
Token-authenticated edit and delete for anonymous submitters |
admin-manage |
Admin-only bulk delete, single delete, and edit |
estimate-rate |
Sends project details + similar projects to Gemini, returns {low, mid, high, reasoning, confidenceLevel, keyFactors} |
sanitize-description |
Sends project descriptions to Gemini to redact PII |
unsubscribe |
Handles mailing list opt-out |
Deploy all functions:
npx supabase functions deploy submit-project
npx supabase functions deploy manage-submission
npx supabase functions deploy admin-manage
npx supabase functions deploy estimate-rate
npx supabase functions deploy sanitize-description
npx supabase functions deploy unsubscribeSet these in the Supabase dashboard under Settings > Edge Functions > Secrets, or via CLI:
npx supabase secrets set GEMINI_API_KEY=your-gemini-key
npx supabase secrets set BREVO_API_KEY=your-brevo-key
npx supabase secrets set SITE_URL=https://valyouladder.com| Secret | Used by | Description |
|---|---|---|
GEMINI_API_KEY |
estimate-rate, sanitize-description |
Google AI Studio key |
BREVO_API_KEY |
submit-project |
Brevo transactional email + contact list |
SITE_URL |
submit-project |
Base URL for edit links in emails |
| Column | Type | Notes |
|---|---|---|
id |
UUID | PK, auto-generated |
project_type |
TEXT | commission, collaboration, technical, live-performance, tour, installation-temp/perm, etc. |
client_type |
TEXT | global-brand, institution, festival, musician, agency, private, etc. |
project_length |
TEXT | day, 2-5-days, 1-2-weeks, 1-3-months, 3-6-months, 6plus-months |
client_country |
TEXT | nullable — where the client is based |
project_location |
TEXT | where the work took place |
skills |
TEXT[] | multi-select from a curated list |
expertise_level |
TEXT | junior / mid / senior / expert |
your_role |
TEXT | solo / lead / key-contributor / subcontractor |
contracted_as |
TEXT | freelancer / studio — who the client contracted with |
rate_representativeness |
TEXT | nullable — standard / below_market / above_market |
standard_rate |
INTEGER | nullable — what the submitter would normally charge (when below/above market) |
rate_type |
TEXT | project / daily / hourly / retainer |
currency |
TEXT | ISO code, default USD |
your_budget |
INTEGER | what the submitter personally received |
total_budget |
INTEGER | nullable — full production budget if known |
days_of_work |
INTEGER | nullable — actual working days invested; used to compute implied day rate |
year_completed |
INTEGER | |
description |
TEXT | nullable — AI-processed before storage: PII redacted, vulgar language removed, translated to English |
created_at |
TIMESTAMPTZ | |
updated_at |
TIMESTAMPTZ | auto-updated via trigger |
The frontend is a static SPA — build with npm run build and deploy dist/ to any static host.
npx vercel deploy --prodAfter deploying, make sure the SITE_URL secret in Supabase matches the live URL so email edit links resolve correctly.
The project uses two Supabase projects (staging and production) and Vercel's preview deployments to keep untested changes off the live site.
┌─────────────────────┐ ┌──────────────────────────┐
│ Feature branch │──────▶│ Vercel preview deploy │
│ (any non-main) │ │ → staging Supabase │
└─────────────────────┘ └──────────────────────────┘
┌─────────────────────┐ ┌──────────────────────────┐
│ main branch │──────▶│ Vercel production deploy │
│ │ │ → production Supabase │
└─────────────────────┘ └──────────────────────────┘
- Create a new project in the Supabase dashboard (e.g. "creative-compass-staging"). The free tier is sufficient.
- Apply all migrations:
npx supabase link --project-ref <staging-project-id> npx supabase db push
- Set edge function secrets:
npx supabase secrets set \ GEMINI_API_KEY=your-key \ BREVO_API_KEY=your-key \ SITE_URL=https://your-vercel-preview-url \ --project-ref <staging-project-id>
- Deploy edge functions:
npx supabase functions deploy --project-ref <staging-project-id>
- Re-link to production when done:
npx supabase link --project-ref <production-project-ref>
In the Vercel dashboard under Settings > Environment Variables, set each variable with the appropriate scope:
| Variable | Production scope | Preview scope |
|---|---|---|
VITE_SUPABASE_URL |
https://<prod>.supabase.co |
https://<staging>.supabase.co |
VITE_SUPABASE_PUBLISHABLE_KEY |
prod anon key | staging anon key |
VITE_SUPABASE_PROJECT_ID |
prod project ref | staging project ref |
VITE_DB_OPEN |
(omit — open by default) | false to hide staging data |
VITE_SUBMISSIONS_OPEN |
(omit — open by default) | false to block staging submissions |
VITE_ESTIMATES_OPEN |
(omit — open by default) | false to disable staging estimates |
After this, every push to a non-main branch gets a Vercel preview deployment that talks to the staging database. Merges to main deploy to production with the production database.
Fill in .env.staging with the staging project credentials, then:
npm run dev:staging # dev server → staging Supabase
npm run build:staging # build against staging SupabaseThe default npm run dev uses .env (which should contain whichever project you work against most often).
Vite's mode system loads the env file matching the mode: --mode staging loads .env.staging, the default production build loads .env.production (if present), and development loads .env.
Always apply migrations to staging first, verify, then apply to production:
# 1. Apply to staging
npx supabase db push --project-ref <staging-project-id>
# 2. Test on a preview deployment or locally via dev:staging
# 3. Apply to production
npx supabase db push --project-ref <production-project-ref>If the migration adds or changes edge function behavior, deploy edge functions to both projects in the same order.
# Staging
npx supabase functions deploy --project-ref <staging-project-id>
# Production
npx supabase functions deploy --project-ref <production-project-ref>Each feature can be toggled independently via env vars. Set any to false in the Vercel Preview scope to close that section on staging while leaving production unaffected. A yellow PreProdBanner is shown on the relevant page when a gate is closed, displaying the Supabase project ID so it's obvious which backend you're connected to.
There is no public sign-up. To grant admin access:
- Create a user directly in the Supabase dashboard under Authentication > Users
- Grant the admin role via SQL:
INSERT INTO user_roles (user_id, role) VALUES ('your-auth-user-uuid', 'admin');
- Sign in at
/auth(not linked publicly) - Navigate to
/admin