Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .claude/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@
"runtimeExecutable": "./run.sh",
"runtimeArgs": ["preview"],
"port": 8080
},
{
"name": "whichday-login",
"runtimeExecutable": "env",
"runtimeArgs": ["WHICHDAY_ACCESS_MODE=login", "./run.sh", "preview"],
"port": 8080
}
]
}
32 changes: 20 additions & 12 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,31 +5,39 @@
**Before writing or editing any code in this repository, read
[`CODING_CONVENTIONS.md`](./CODING_CONVENTIONS.md) and follow every rule in
it.** The conventions cover naming, comments, code structure, Java idioms,
Vaadin form bindings, CSS organisation, persistence patterns, and CI.
Vaadin, CSS organisation, i18n, persistence, access control and CI.

In particular, recurring expectations:

- Identifiers use full, meaningful words — no single-letter or abbreviated
names anywhere (variables, parameters, lambda captures, loop indices).
- Add a comment **only** when the code is not self-explanatory; explain
*why*, never *what*. No section-divider comments.
- Keep view / orchestrator classes thin. Extract forms, charts, grids, and
other distinct concerns into their own component classes with a focused
public API.
- Keep view classes thin. Anything with a shape of its own — a calendar, a
ballot, a row — is its own component class with a focused public API. A
view builds in `beforeEnter`, never in its constructor.
- No user-facing string literals in code: every one is a key in
`vaadin-i18n/translations.properties`, resolved through `getTranslation`.
A label has to be true — a button that navigates does not say "send".
- Colors live in `colors.css` as semantic tokens. Component CSS files
reference them through `var(--color-…)`; never inline raw hex / rgb /
named colors.
- Form bindings go through `Binder<T>` with `bind(getter, setter)` and
appropriate validators. Use `ValueChangeMode.LAZY` for live recalculation.
- Mutable beans use Lombok (`@Getter @Setter @NoArgsConstructor
@AllArgsConstructor`). Spring Boot 4 / Jackson 3 imports come from
`tools.jackson.*`.
- Immutable records are the default. JPA entities take **no** Lombok — see
§4 for why, and for the one exception.
- **Two access modes.** `WHICHDAY_ACCESS_MODE` picks `login` or `anonymous`
at deploy time, and a change to one screen usually has to answer for
both. Who may do what is decided in `PollService`, never by which button
a screen draws.
- Build and test through `./run.sh <task>`. Never invoke `mvn` directly and
never set `JAVA_HOME` by hand.

If a request would violate a convention, push back and propose the
convention-compliant alternative before proceeding.

## When in doubt

Re-read `CODING_CONVENTIONS.md` first. If a situation isn't covered, match
the style of the surrounding code and propose adding the new rule to the
conventions file.
Re-read `CODING_CONVENTIONS.md` first. `docs/REQUIREMENTS.md` records what
the application has to do and why each decision was taken; `docs/issues/`
records what is still wrong. If a situation isn't covered, match the style
of the surrounding code and propose adding the new rule to the conventions
file.
129 changes: 70 additions & 59 deletions CODING_CONVENTIONS.md

Large diffs are not rendered by default.

24 changes: 20 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,26 +24,42 @@ client and two people already in it — and `application.properties` defaults to
that, so nothing needs configuring:

```bash
./run.sh env up
./run.sh run
```

Open <http://localhost:8080> and it redirects you to Keycloak. Sign in as **`ada` /
`ada`**; `miro` / `miro` is the second person, which is who you invite. See
That is anonymous mode, which is the default and needs nothing brought up — no
Keycloak, no configuration. Open <http://localhost:8080>, type a name, and you are in.

Login mode is the other half of the application and wants the development Keycloak:

```bash
./run.sh env up
```

```bash
WHICHDAY_ACCESS_MODE=login ./run.sh run
```

It now redirects you to Keycloak. Sign in as **`ada` / `ada`**; `miro` / `miro` is the
second person, which is who you invite. See
[`environment/dev/README.md`](environment/dev/README.md) for the realm, the admin
console and how to add a third.

To point at a provider of your own instead, set all three — the defaults are a
laptop's and none of them belongs anywhere else:

```bash
export WHICHDAY_ACCESS_MODE=login
export WHICHDAY_OIDC_ISSUER_URI=https://accounts.example.com
export WHICHDAY_OIDC_CLIENT_ID=...
export WHICHDAY_OIDC_CLIENT_SECRET=...
```

Register `http://localhost:8080/login/oauth2/code/oidc` with that client.

Both modes share one database, so a poll made in one is visible in the other's tables —
which is a development convenience and nothing more. A deployment picks a mode once.

The database appears at `./data/whichday.mv.db` on first start and survives every
restart, devtools included. `./run.sh resetdb` deletes it and the app creates an empty
one next time. The tests never touch it — they run against an in-memory database.
Expand All @@ -54,7 +70,7 @@ one next time. The tests never touch it — they run against an in-memory databa

| Task | What it does |
| --------- | ---------------------------------------------------------------- |
| `env` | The development Keycloak: `up` (default), `down`, `logs`, `reset` |
| `env` | The development Keycloak, for login mode only: `up` (default), `down`, `logs`, `reset` |
| `run` | Start the app in dev mode |
| `test` | Unit and browserless tests, with the JaCoCo gate |
| `verify` | The same against a production build |
Expand Down
65 changes: 57 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,32 @@ only, multi-select voting, and the day with the most votes wins.

A mobile-first Vaadin application on the Aura theme.

## Two ways in

A deployment picks one, once, with `WHICHDAY_ACCESS_MODE`. It cannot be both, and it
cannot be switched at runtime.

| | `anonymous` (the default) | `login` |
| --- | --- | --- |
| Getting in | type a name on the way past | an OIDC provider you run |
| Configuration | none | `WHICHDAY_OIDC_*`, and it will not start without them |
| Seeing a poll | anybody holding its link | the organizer or somebody invited |
| Answering one | anybody holding its link | the invitee, signed in at the address they were invited at |
| Changing one | the session that called it, or anybody with its six-digit admin code | the organizer |
| Home (`/`) | where a poll starts — there is no list | your polls, drafts and settled ones |
| Invitations | none; the link is the invitation | typed in by address, searched by account |

**Anonymous mode is Doodle's bargain, and it is worth reading twice.** Anybody who has
the link can open the poll and answer it — that is the point of not having accounts.
The organizer gets a six-digit code on the share screen, and it is the only way back to
changing a poll once the browser tab is gone: identity lives in the session and nothing
else. Nobody is emailed, nobody is reminded, and nothing knows who has not answered,
because nothing knows who was asked.

Choose `login` when the polls are a company's and it already has a provider. Choose
`anonymous` when the point is that a group can pick a day without anybody signing up
for anything.

## Run it (self-hosting)

A prebuilt, multi-architecture image (`linux/amd64` + `linux/arm64`) is published to
Expand All @@ -22,10 +48,15 @@ It is one container with its database inside it, and nothing else to bring up.
you want it.
- **Run one container, not two.** The database is a file, and only the process holding
it can open it — a second container on the same volume will not start.
- Signing in needs an OIDC client: `WHICHDAY_OIDC_ISSUER_URI`,
`WHICHDAY_OIDC_CLIENT_ID` and `WHICHDAY_OIDC_CLIENT_SECRET`. The application refuses
to start without the id and the secret.
- Behind a reverse proxy, see *Behind a proxy*.
- `WHICHDAY_ACCESS_MODE` is `anonymous` or `login`, and defaults to `anonymous`.
Anything else is a startup failure naming both.
- **`login` mode needs an OIDC client**: `WHICHDAY_OIDC_ISSUER_URI`,
`WHICHDAY_OIDC_CLIENT_ID` and `WHICHDAY_OIDC_CLIENT_SECRET`. It refuses to start
without the id and the secret, and it resolves the issuer as it starts — so an
unreachable provider is a container that will not come up. `anonymous` mode reads
none of the three, and does not need the provider to exist.
- Behind a reverse proxy, see *Behind a proxy*. It matters for `login` mode and for
the share links both modes hand out.

### docker compose

Expand All @@ -38,6 +69,7 @@ services:
volumes:
- whichday-data:/app/data
environment:
- WHICHDAY_ACCESS_MODE=login
- WHICHDAY_OIDC_ISSUER_URI=https://accounts.example.com
- WHICHDAY_OIDC_CLIENT_ID=change-me
- WHICHDAY_OIDC_CLIENT_SECRET=change-me
Expand All @@ -48,6 +80,14 @@ volumes:
whichday-data:
```

Anonymous mode is the same file with the four `WHICHDAY_OIDC_*` and `ACCESS_MODE` lines
dropped — it is the default, and it configures nothing:

```yaml
environment:
- FORWARD_HEADERS_STRATEGY=native
```

The left side of the mount is yours — the named volume above, or any host path. The
right side has to be whatever `WHICHDAY_DATA_DIR` says.

Expand All @@ -72,6 +112,7 @@ ContainerName=whichday
Image=docker.io/binarycodes/whichday:latest
PublishPort=8080:8080
Volume=whichday-data:/app/data
Environment=WHICHDAY_ACCESS_MODE=login
Environment=WHICHDAY_OIDC_ISSUER_URI=https://accounts.example.com
Environment=WHICHDAY_OIDC_CLIENT_ID=change-me
Environment=WHICHDAY_OIDC_CLIENT_SECRET=change-me
Expand All @@ -98,6 +139,9 @@ systemctl start whichday.service
`AutoUpdate=registry` is safe here because systemd stops the old container before
starting the new one, and only one process at a time may hold the database file.

Anonymous mode drops the same four lines here, leaving `FORWARD_HEADERS_STRATEGY` as
the only `Environment=` the unit needs.

### Behind a proxy

Anything that terminates TLS in front of this leaves the application seeing plain
Expand All @@ -110,20 +154,25 @@ and the redirect URI comes out as the address readers typed. It is off by defaul
because those headers are spoofable with nothing in front, so set it when there is a
proxy and only then.

The redirect URI to register with your OIDC client is
In `login` mode, the redirect URI to register with your OIDC client is
`https://whichday.example.com/login/oauth2/code/oidc`: the path is fixed, the origin is
whatever readers type.

## The screens

Two of these are `login` mode's alone. In `anonymous` mode `/` is where a poll starts,
`/new/invitees` leads back there, and `/who` stands in front of everything.

| Route | What it is |
| ------------------ | ----------------------------------------------------- |
| `/` | Your polls, your drafts, and the settled ones |
| `/` | Your polls, your drafts, and the settled ones — `login` mode |
| `/who` | Your name, and an admin code if you have one — `anonymous` mode |
| `/new` | Name a poll and say who decides it |
| `/new/invitees` | Find people by email address |
| `/new/invitees` | Find people by email address — `login` mode |
| `/poll/:id/days` | Put candidate days on the table |
| `/poll/:id/share` | The voting link, the invite list, the closing date |
| `/poll/:id` | The standings, live, and the button that locks a date |
| `/poll/:id` | The standings, live, and the way to settle them |
| `/poll/:id/settle` | Confirm the day, or pick between tied ones |
| `/poll/:id/locked` | The settled date |
| `/vote/:id` | Tap every day that works |
| `/vote/:id/none` | None of them work, and a day forward instead |
Expand Down
Loading