The previous post put an Announced Course’s page on the internet: the pitch and a Waitlist form, but no curriculum and no price. It also left that page reachable by direct link only. The catalog query still read status == "published", so a stranger could join a Waitlist only if someone had already sent them the link.

This item lets them in. The query change is a single line. Everything else in the item was about deciding what the card must not say.

The catalog in Portuguese: “CATÁLOGO · 66 CURSOS”, and a first row of three cards each topped by a dark “EM BREVE” band; lower down, a card whose band also reads “Abre 12 nov 2026”

One query, two grids

def list_catalog_courses do
  Repo.all(
    from c in Course,
      where: c.status in ["announced", "published"] and c.visibility == "public",
      order_by: [
        asc: fragment("CASE WHEN ? = 'announced' THEN 0 ELSE 1 END", c.status),
        desc: c.inserted_at
      ]
  )
end

The landing page used to load its courses with lines identical to the catalog’s. Now both call this function, so the two grids cannot drift apart. That is also why CatalogComponents shares the card between them.

Three decisions sit in those lines, and each was argued during refinement:

  • Announced sorts first. An announcement is news with a deadline, and a Published Course will still be there next week. The counter-argument was accepted as real: a visitor who came to buy meets an unbuyable card first. At this catalog size that costs one row, not a wall, and the order is cheap to reverse.
  • visibility == "public" stays. A private Announced Course is not in the grid. Its page exists for people who were already given access.
  • The eyebrow counts every card. “66 cursos” over a grid of 66 cards. Any other number would misstate what is on screen.

A variant, not a card with parts hidden

An Announced Course has no Paddle Price and cannot be enrolled in. The obvious way to render it is the ordinary card with :ifs around the price, the badges and the stars. That version is fragile: every future addition to the card has to remember the Announced case, and forgetting it fails silently.

So the card got a second function head, matched on status:

defp course_card(%{course: %Course{status: "announced"}} = assigns) do
  # band, thumbnail, language pill, title, description. Nothing else.
end

It deliberately leaves out four things:

  • No price, no “Free”, no “Free preview”. There is nothing to buy or preview.
  • No star line. A Review needs a Completion, and a Course with no Lessons has none.
  • No data-pp-price-id node. This one matters most. The PaddlePrices hook batches a single PricePreview() call over every such node on the page, and an Announced Course has no Price to put in that batch. Keeping it out of the DOM keeps it out of the call.

The tests assert on that absence directly, in the rendered HTML. In the browser, the check was a count of [data-pp-price-id] inside Announced cards, which came back as zero.

The band, and the date it drops

The band is one component, announce_band/1, rendered on the card and on the Course’s public page, so the two always agree:

The public page of an Announced Course: the dark band reads “EM BREVE” on the left and “Abre 12 nov 2026” on the right, above the hero; below, the title, the not-yet-published notice and the Waitlist panel

The date is the new domain term, Launch Date: an optional day the Instructor expects the Course to open. It is optional because an Instructor who announces a Course often has no idea when it will be finished. An invented date is worse than none, because it gets missed in public.

A Launch Date is only useful while it is still ahead, so the band shows it until the day it names and drops it the day after:

flowchart TD
    C["Announced Course"] --> Q{"launch_date?"}
    Q -->|"nil"| S["band: “Em breve”"]
    Q -->|"date ≥ today"| D["band: “Em breve · Abre 12 nov 2026”"]
    Q -->|"date < today"| P["band: “Em breve”"]
    P --> ST["Studio only:
banner in the Course header
+ marker in the Course list"] style D fill:#dce8dc,stroke:#5f7a5f style S fill:#f2e3c2,stroke:#b5912a style P fill:#f2e3c2,stroke:#b5912a style ST fill:#fbe6e1,stroke:#b14333

A passed date is the Instructor’s problem, not the visitor’s. Printing “Opened 20 Sep” over a Course that did not open would advertise a missed deadline on the most public page in the product. The visitor sees the undated band:

The same page for a Course whose Launch Date has passed: the band reads only “EM BREVE”

The Instructor, meanwhile, is told plainly, in the one place they will see it:

The Studio header of an Announced Course, with an amber banner beneath it: “A data de lançamento (20 set 2026) já passou. Os visitantes agora veem apenas “Em breve” — publique o curso ou defina uma nova data na aba Configurações.”

The Course list marks the same Course, so it can be spotted without opening each one:

The Studio course list: the “Lean Startup” row carries a second, clay-coloured badge “DATA DE LANÇAMENTO VENCIDA” beneath its ANUNCIADO badge

The whole rule lives on Course, as launch_date_passed?/2 and visible_launch_date/2. Both take today as an argument, so the tests can pin the boundary without mocking a clock. “Passed” means Date.compare(launch_date, today) == :lt: the date shows through the whole day it names.

Publishing or un-announcing the Course leaves the Launch Date on the record, the same way waitlist_id stays. The field only has meaning while the Course is Announced, and erasing it on a status change would throw away something the Instructor typed.

“12 Nov” on a Portuguese page

The date format looked like a detail and was not one. Calendar.strftime/2 only knows English month names, so the obvious one-liner prints “Abre 12 Nov 2026”. That is an English month inside a Portuguese sentence, on an unauthenticated page whose whole job is to win over a stranger.

The backlog already had a Card for locale-aware dates, Locale-aware date formatting. Its five call sites are all behind a login: a certificate, feedback, analytics, a review byline. It was also parked on an undecided question: a small month map, or a date library dependency? This item needed the answer now, so it made the call. The answer is the small map, written as gettext msgids:

def format(%Date{} = date) do
  gettext("%{day} %{month} %{year}",
    day: date.day,
    month: month_abbreviation(date.month),
    year: date.year
  )
end

defp month_abbreviation(5), do: pgettext("month", "May")

Two details are worth noting:

  • Every month is under a "month" context (pgettext). English uses the same four characters for the month and for the modal verb, and without a context the two would share one translation.
  • The order is a msgid too. A Locale that writes month-day-year can say so in its .po file instead of in code.

Result: “12 nov 2026” in pt, “12 Nov 2026” in en. The date-formatting Card now says its question is answered and it shrinks to “adopt ForgiaWeb.ShortDate at five call sites”.

The trap we saw coming

<input type="date"> is the first input in the Studio whose value the browser validates. The Course settings form holds everything: title, description, pricing, visibility, the Waitlist id and now the Launch Date. Without novalidate, a half-typed date makes the browser refuse to submit the whole form. The tab silently stops saving, with no round-trip, no flash and no error. It is worse than the email case from the last post, where only one form was affected.

The difference this time is when it was caught. The last item found the email version in the browser, after the suite was green, and wrote it into CLAUDE.md. This item’s refinement read that rule, grepped the Studio for browser-validated inputs, found none, and wrote the risk into the Conversation before any code existed. novalidate went in with the field.

Verification then did what render_submit cannot. It typed only a day into the date input, which leaves the field invalid, changed the title, and pressed Save:

The settings tab scrolled to the Waitlist section: the Brevo list id “7”, and below it the new “Data de lançamento” field holding a half-typed “12/dd/yyyy”, outlined in rust, with its hint text and the “Salvar curso” button beneath

The title saved. The half-typed date arrives as an empty string, so saving in that state clears the Launch Date, which is the honest reading of a date nobody finished typing. Setting a full date and then clearing it both round-tripped through the database.

Two false alarms

Two checks failed during verification, and neither was an app bug. They are worth recording because both looked convincing.

The settings tab “wouldn’t save the date.” The server log showed the submit arriving and no UPDATE following it, with "language" => "" in the params. The seed script had created the Courses with language: "pt", but the select only offers "pt-br". The select therefore rendered nothing as selected, the required-field validation failed, and nothing was written. Re-seeding with a real value fixed it. The general lesson: a seed script that writes values the UI cannot represent will make the UI look broken.

The Course list “had no marker.” The Playwright check looked for “Data de lançamento vencida” in each row’s innerText. innerText applies text-transform, and badges are uppercase, so the text it saw was “DATA DE LANÇAMENTO VENCIDA”. The screenshot settled it in a second. In a browser, textContent is the right thing to match copy against.

One more piece of copy

The empty catalog read “No published courses yet.” Now that the grid holds two statuses, the message says “No courses yet.” Rewording a msgid under --no-fuzzy loses its translation rather than inheriting a guess, so the Portuguese was rewritten in the same commit: “Nenhum curso ainda.” A catalog holding only Announced Courses is no longer empty, so this state has become harder to reach, but its copy still had to be true.

Still open

The language filter is its own Card and will have to decide whether it applies to Announced Courses. Nothing here pre-empts that. The query is one function, and the filter will wrap it.