Skip to content

Welcome page: ask a new member where they are and whether they are looking#1016

Merged
wintermeyer merged 8 commits into
mainfrom
worktree-misc
Jul 23, 2026
Merged

Welcome page: ask a new member where they are and whether they are looking#1016
wintermeyer merged 8 commits into
mainfrom
worktree-misc

Conversation

@wintermeyer

@wintermeyer wintermeyer commented Jul 23, 2026

Copy link
Copy Markdown
Owner

TL;DR After the registration PIN, a new member now sees one skippable page (/system/welcome) asking where they are and whether they are job-hunting, so the location search, the job board and recruiter searches finally have something to work with.

Why

A fresh account arrives with a name, three tags and an email. Nothing in that says where this person is or whether they are looking — the two facts the ort: people search, the job board and a recruiter's saved search need. Asking during sign-up would only lengthen the form standing between a visitor and an account, so this asks once, right after the PIN, on a page that is trivial to skip.

The page

Where are you? Private/Work label, postal code, city, country. No street. Validation is deliberately lax: any single field is a complete answer, none is required, and a form with no location stores no address at all rather than failing. What is filled in becomes an ordinary profile address (so it answers ort:/city: searches like every other one).

Are you looking for a job? The availability status, and — revealed by the same [data-jobsearch-details] enhancement the Basics form uses — the minimum salary expectation and the new preferred workplace form. Cast by the ordinary User.changeset/2 with its existing visibility defaults (status: signed-in members, salary: nobody), so this page can never store something more public than the Basics form would.

Once means once. users.welcome_completed_at is the single gate: the redirect fires only when the PIN form's "registration" context and a nil stamp agree, the page sends everyone else home, and both buttons ("Speichern und weiter" / "Erstmal überspringen") stamp it. A member who navigates away is never asked again — everything lives under /settings. The migration backfills every existing account, so no current member ever sees it.

Also in here

  • users.desired_workplace_type (onsite/hybrid/remote, the same vocabulary a job posting uses): editable on the profile Basics form, appended to the availability pill ("Auf Jobsuche · Remote") under that status's visibility, cleared by the changeset when the status is cleared, and carried by the agent-format siblings (Preferred workplace: in md/txt, the raw value in JSON/XML).
  • Two bugs found on the way: the address card list rendered link(nil) for a description-less address (now falls back to "Address" like the show page), and two German translations had been corrupted into doubled sentences by an earlier extract+merge.
  • Docs: new sections in docs/architecture/profiles.md, the post-login landing in docs/architecture/authentication.md, the badge entry in .claude/rules/design.md.

Verified

mix precommit green (4468 tests). Smoke-tested end to end in a real browser: register → PIN → welcome page (German, panel reveal works, country list German) → save → profile shows the address card and the "Auf Jobsuche · Remote" pill → the bell already carries the username note → revisiting /system/welcome redirects to the profile → the Basics form shows the preference prefilled.

Also in here (from review)

  • The welcome page's cursor starts in the postal code; the salary amount lines up with the two selects beside it (components.css fixed 44px for text inputs and selects but never listed [type="number"] — fixed centrally, so the Basics form's row straightens too); the availability visibility is askable right there; "Beschäftigungsstatus" reads "Verfügbarkeit"; and the newcomer greeting moved one page later, onto the profile whose checklist it points at.
  • Country selects show localized names and keep storing the canonical English one, from Vutuv.Countries instead of a hand-kept list in the template.
  • New "username" notification kind: confirming the first PIN leaves a note naming the auto-assigned handle with the full /settings/security URL. Derived from the member's own users row (welcome_notified_at is gate + timestamp), so no table, no push and no email — and no retroactive welcome for existing members.
  • Sign-up email type reads Privat, Arbeit, Andere with Privat preselected.
  • /system/welcome is a one-shot URL: only the confirming PIN opens it, every later visit lands on the profile.

https://claude.ai/code/session_01KjcEda93QZv3a7BX2P2KiV

An AI agent wrote this text in my name, unreviewed by me. That is not meant impersonally: it cuts my workload down so much that I can get through far more.

A fresh account arrives with a name, three tags and an email. Nothing in
that says where the person is or whether they are open to offers, so the
two things that would make the rest of the site useful to them - the
`ort:` people search, the job board, a recruiter's saved search - have
nothing to work with. Asking during sign-up would only lengthen the form
that stands between a visitor and an account.

So ask once, right after the registration PIN, on a page that is trivial
to skip: /system/welcome. Where are you (Private/Work label, postal code,
city, country - no street), and are you looking for a job (availability
status, and only once a status is picked, the minimum salary expectation
and the preferred workplace form).

Validation is deliberately lax. Any single location field is a complete
answer and none is required; a form with no location stores no address
rather than failing. The job group goes through the ordinary
User.changeset/2 with its existing visibility defaults, so this page can
never store something more public than the Basics form would.

users.welcome_completed_at is the single gate: the redirect fires only
when the PIN form's "registration" context and a nil stamp agree, the
page sends anyone else home, and both buttons stamp it. A member who
navigates away is never asked again - everything lives under /settings,
and nagging on every login is what this page is designed not to do. The
migration backfills every existing account.

New alongside it: users.desired_workplace_type (onsite/hybrid/remote,
the same vocabulary a job posting uses), editable on the profile Basics
form, shown on the same availability pill under the same visibility, and
carried by the agent-format siblings.

Two bugs found on the way and fixed here: the address card list rendered
`link(nil)` for a description-less address (now falls back to "Address",
like the show page), and two German translations had been corrupted into
doubled sentences by an earlier extract+merge.
New backward-compatible features (the one-time welcome page, the workplace
preference and the username notification), so a minor bump. 7.134.x was
taken by main while this branch was in review.

An AI agent wrote this text in my name, unreviewed by me. That is not meant
impersonally: it cuts my workload down so much that I can get through far more.

Claude-Session: https://claude.ai/code/session_01KjcEda93QZv3a7BX2P2KiV
Five things Stefan found while trying the page:

* The cursor now starts in the postal code, the first field of the first
  question, instead of the city beside it.
* The salary amount lined up neither with "Pro" nor "Währung" beside it:
  components.css fixes 44px for text inputs and selects but never listed
  `[type="number"]`, so the amount fell back to its content height. Fixed
  centrally, which straightens the same row on the Basics form too.
* Whether the availability is public was not askable here at all, so the
  page now carries the same visibility select the Basics form has, right
  under the status.
* "Beschäftigungsstatus" reads like a question about being employed. The
  field is about being open to offers, so it is "Verfügbarkeit" now -
  here and on the Basics form, matching the "Wer kann Ihre Verfügbarkeit
  sehen?" line right below it.
* The newcomer greeting appeared on the welcome page, one page too early:
  it points at the profile-completion checklist, which lives on the
  profile. The login now holds it back when it routes through the welcome
  page, and the welcome page raises it when it hands the member on. The
  wording moved to UserHelpers.registration_flash/1 so both callers share
  it, and the two "Danke!"/"Kein Problem." toasts I had invented are gone
  again - one greeting on the right page beats two on the wrong ones.
Three things from trying the flow in a German browser:

* The country select listed English names in an otherwise German form. It
  now shows the localized name and stores the canonical English one, which
  is what `addresses.country` has always held and what `Vutuv.Address`
  branches on. The list comes from `Vutuv.Countries` (the single source of
  the vocabulary, including its diacritic-folded sort) instead of a
  hand-kept copy, and a stored value the ISO list does not know - older
  imports spell a few countries differently - stays selectable, so an edit
  can never silently rewrite someone's country.

* A new member never chose their handle: vutuv generates it from their
  name, and nothing ever told them what it is. Confirming the first login
  PIN now leaves a note in the notifications feed - "Ihr vutuv-Username ist
  @egon_mueller. Sie können ihn jederzeit unter Anmeldung & Sicherheit
  ändern." - linking to the page that changes it. Derived from the member's
  own users row, so there is no notification table and no push, and
  deliberately no email: the PIN mail just arrived, and this is an in-app
  note, not a second message.

  `users.welcome_notified_at` is both the gate and the timestamp, stamped by
  the same `Accounts.activate_user/1` branch that flips `email_confirmed?`.
  A derived-from-`inserted_at` version would have handed every one of the
  existing members a retroactive welcome years after the fact (and put a
  permanent extra row into every notifications test); NULL means no note, so
  only accounts confirmed from here on get one.

* The sign-up form's email type reads Privat, Arbeit, Andere with Privat
  preselected - most people sign up with their private address. Order and
  values come from `Email.email_types/0` and the labels from the helper the
  email pages already use, so the three renderings cannot drift.
Two things from trying the flow:

* The note now reads "Ihr automatisch zugewiesener vutuv-Username ist
  @eddi_hund. Sie können ihn jederzeit unter http://…/settings/security
  ändern." — the full URL instead of the page's name, because this is the
  one place a member is told a handle they never chose, and the sentence
  should still work in a screenshot or a copy-paste. `url/1` builds it from
  the endpoint, so every installation prints its own host, and it stays
  plain text (the row is already the link, and an anchor inside an anchor
  is invalid HTML).

* /system/welcome is a one-shot URL now. It renders only when the account
  has never finished it AND this session was routed here by the confirming
  PIN (a `:welcome_pending` session key set beside the redirect). It still
  survives a reload and a failed submit, but a bookmark, a typed URL or a
  second session lands on the member's profile — deliberately the profile,
  not `Home.path/1`, which would bounce someone who already follows people
  to the feed. A logged-out visitor never reaches the controller: the
  settings pipeline's RequireLogin sends them to the start page.

  The welcome tests now walk the real entry path (register, then confirm
  the PIN with the registration context) instead of a plain login, so they
  exercise the same door a member comes through — which also caught that
  the German render has to start German: the locale plug stores what it
  resolved in the session, so switching the header afterwards is too late.

An AI agent wrote this text in my name, unreviewed by me. That is not meant
impersonally: it cuts my workload down so much that I can get through far more.

Claude-Session: https://claude.ai/code/session_01KjcEda93QZv3a7BX2P2KiV
On-site, hybrid and remote don't exclude each other: plenty of people take
hybrid or remote but not a five-day office, and someone flexible ticks all
three. The single-value column and its dropdown forced a choice that does
not exist, so `users.desired_workplace_type` becomes
`desired_workplace_types`, a NOT NULL array defaulting to the empty list -
"no preference" is now simply nothing ticked, and no call site has to tell
nil and [] apart.

Both forms render checkboxes with a leading hidden blank (that blank is
what makes unticking everything arrive as an empty list), and the changeset
normalizes what comes back: blanks dropped, duplicates dropped, survivors
put in the canonical on-site/hybrid/remote order, so what is stored depends
on what was ticked and never on the order of the clicks. The badge, the
`Preferred workplace:` fact line and the profile JSON/XML all read the set
through the one new `User.desired_workplace_line/1`, so the pill says
"Auf Jobsuche · Hybrid, Remote".

The column shipped only hours ago in this same unmerged branch, so dropping
it here is N-1 safe: the deployed release knows neither name.

An AI agent wrote this text in my name, unreviewed by me. That is not meant
impersonally: it cuts my workload down so much that I can get through far more.

Claude-Session: https://claude.ai/code/session_01KjcEda93QZv3a7BX2P2KiV
The row wrapped its entire text in one link, so the sentence read as a
single blue block and the handle led to the settings page rather than to
the member's own profile. Now the sentence is plain text with exactly two
links in it: the @handle goes to the profile, the spelled-out URL to the
page that changes it.

The two destinations sit in different places in German and English, so the
translation carries `{handle}` and `{url}` markers that the row splits on -
the same total `split_marker/2` the landing-page consent line uses, which
moved from `VutuvWeb.PageHTML` to `VutuvWeb.UI` now that a second page
needs it (it is imported everywhere, so the landing template is unchanged).
A botched .po still renders slightly wrong text instead of raising.

An AI agent wrote this text in my name, unreviewed by me. That is not meant
impersonally: it cuts my workload down so much that I can get through far more.

Claude-Session: https://claude.ai/code/session_01KjcEda93QZv3a7BX2P2KiV
A brand-new member now gets no toast at all. The welcome page greets them
in its own hero, and the profile it hands them to already shows the
completion checklist ("Profilfoto hinzufügen", "Kurzbeschreibung
hinzufügen") as clickable steps - the toast said the same thing again, in
passing, three seconds before it faded.

That removes the only caller of the greeting, so
`UserHelpers.registration_flash/1` goes with it; a login that skips the
welcome page falls through to the ordinary "Welcome back" greeting.

An AI agent wrote this text in my name, unreviewed by me. That is not meant
impersonally: it cuts my workload down so much that I can get through far more.

Claude-Session: https://claude.ai/code/session_01KjcEda93QZv3a7BX2P2KiV
@wintermeyer
wintermeyer merged commit 4a0c135 into main Jul 23, 2026
1 check passed
@wintermeyer
wintermeyer deleted the worktree-misc branch July 23, 2026 12:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant