Letzte Änderungen

Woran zuletzt gearbeitet wurde — direkt aus der Versionsverwaltung.

Donnerstag, 10. September 2026

Let the user list be carried out as a CSV file

Reading a participant list in was possible; getting one back out was not. Now it is, and which columns go into the file — and in what order — is chosen on the way.

The catalogue is one list in the service: seventeen columns, each with the key its heading is stored under and the way it reads a value out of an account. Nothing else defines what can be exported, so the form, the headings and the values cannot drift apart.

The order is the order of the rows in the form. Every checkbox carries the same name, so a browser submits them exactly as they stand on the page, and the arrows only have to move the row itself — there is no second field running alongside to be kept in step, and with no scripting at all the default order still works.

That default is the layout of a participant list, column for column, so an export can be edited and read back in without being rearranged first. A test writes one and imports it to keep that true.

The export follows the search box: what the list shows is what the file holds, and the page says so before the download. Roles come out of one query rather than one per person.

dd17db6 Wuestengecko 22:13

Let an import fill in what an account is missing

An address that already had an account was skipped whole, so a course list full of people who signed up years ago carried its postcodes and birth dates in and left them at the door.

A checkbox now offers the other way: fill in what is empty. Only that. Anything already written stays, and so do the email address, the role and the approval — a list that has been through three hands does not get to overrule what someone entered about themselves. The preview names the fields per row, so "fills: date of birth, street, house number" is visible before anything is saved.

Which fields those are is one table in the service, read by both the preview and the run, so nothing can be announced and then not done. The organisation sits beside it rather than in it: it has two columns of which only one is ever set, and its id may not exist until the import creates it.

Off by default. Skipping is still what happens unless somebody asks for more.

2a2ffdf Wuestengecko 22:08

Let a participant list be read in as accounts

Inviting a course full of people one form at a time was the only way in. Now a CSV file does it: the kind that comes out of a course organiser's spreadsheet, semicolons and all.

It reads in two steps. The first only looks: it maps the columns, checks every row and shows a table of what would happen — created, skipped because the address already has an account, skipped because it stands twice in the file, or rejected with a reason. Nothing exists yet. The second step does it, and reads the file again on the way so an account that appeared in the meantime is not created twice.

The columns are matched by name, not by position, and by several spellings each: Mail and E-Mail are the same column, so are Straße and Strasse, Nachname and Name. Extra columns are ignored. Only an address column is required. Herr and Frau become the word that stands before the first name in a letter, Lieber and Liebe; anything else written there is kept as it is. A fire department that is not on the maintained list is added to it, and the preview says which names those are before it happens.

Invitation mails are a checkbox, off by default. Creating a hundred accounts can be undone one at a time; a hundred sent mails cannot.

The list carries a postal address, and there was nowhere to put one, so accounts have one now: street, house number, postcode and town, all optional, in the profile and in the user editor. Letters can reach for them through {{Anschrift}}, {{Adresse}}, {{Strasse}}, {{Hausnummer}}, {{PLZ}} and {{Ort}} — the address block leaves out the lines that would be empty.

CsvReader is the counterpart to CsvWriter: it takes the delimiter from a sep= line or counts what the header uses, reads UTF-8 with or without a byte order mark and falls back to Latin-1 for what Excel saves, and lets quotes protect delimiters and line breaks. What CsvWriter writes, it reads back.

46a73ed Wuestengecko 21:56

Let a letter be written in HTML and nothing else

Every letter — a message, a message template, a document template — could be written either in Markdown or in HTML, and each one carried the choice in a column of its own. Two ways to write the same thing meant two ways for it to come out wrong, and the Markdown side never quite sufficed: a right-aligned date line, a coloured footer, anything set precisely still had to be written as HTML in the middle of it.

So the choice is gone and HTML stays. The radio buttons are out of the four forms, the badge is out of both template lists, and the Format column is dropped from Messages, MessageTemplates and DocumentTemplates. The hint under the text field now names the tags that carry: p, strong, em, lists, tables.

What the application itself writes had to follow: the schedule placeholder builds a table in HTML, and so do the sample letter and the heading above the schedule document.

Text already stored as Markdown will show its asterisks and pipes as written — there is no longer anything that reads them.

2a518c1 Wuestengecko 21:51

Mittwoch, 9. September 2026

Let a tall letterhead still reach both edges of the sheet

The cap that keeps a square logo from filling the page was set at 70mm, and it did more than guard against the absurd case. An image is scaled whole or not at all, so capping the height narrows the picture: a letterhead of two to one came out 140mm wide on a 210mm sheet, short of both edges, however the side margin was set.

The header may now be 118mm tall, which is anything down to about 1.8 to 1 — the shape a letterhead actually has. The footer keeps a quarter of the page, since a band taller than that is not a band.

The guard stays: a square image still yields rather than taking the whole sheet and leaving the document unable to lay out.

799287e Wuestengecko 22:14

Stop the footer image from squeezing the footer line out of the page

With a footer image in place the footer line vanished, whatever colour it was given. Not hidden behind the band: outside the page.

The text sat in a layer that took its size from the image. A band twenty millimetres tall and a bottom text margin of twenty millimetres — the default — left the line nowhere to stand, so it was laid out past the edge and never drawn. The narrower the band, the more certainly it disappeared, which is why it looked like the image was to blame.

The text now carries the footer's height and the image follows it: the line sits its own margin above the bottom edge, and the footer is given a minimum height so it still reaches under the band. The height the band needs is worked out from the image's own proportions, which QuestPDF will tell us.

Measured rather than assumed. Rendering the reported case against the previous version puts no ink where the line belongs; against this one, five hundred pixels of it.

A footer line only lands on the band when the bottom text margin is smaller than the band is tall. That is geometry, not a setting, so the hint beside the field now says it.

e72ab5f Wuestengecko 21:26

Give the header a margin, the images a strength, the footer its colour

Three things about the letterhead, one of them a fault I had left.

The header image had no distance from the top edge — only the footer got one. It has its own now, the counterpart to the footer's.

Both images take an opacity, so a crest can sit behind the text rather than on top of it. QuestPDF has no opacity for containers, so this is a white veil drawn over the picture: the sheet is white, so the result is the same, and it saves pulling in an imaging library to rewrite bytes. On coloured stock it would not be the same. There is none here.

The footer line was drawn in a hardcoded grey. On a dark band that is invisible, which is what it looked like when the text had gone missing — it was there all along, in grey on navy. The colour now comes from the text, where the author can set it, and only the size is prescribed.

Which needed the renderer to read colours at all, and to read them in the form they actually arrive in: the sanitiser rewrites every colour, named or hex, as rgba(). Parsing only names and hex — as the first attempt did — meant parsing the one form that never occurs.

The colour test now compares two colours rather than a colour against no colour. The old shape passed whether or not colour was applied, which is the kind of test that reports success while proving nothing.

ac7faa1 Wuestengecko 21:18

Let the letterhead reach the edge of the sheet, and the text not

A letterhead band that stops where the text margin stops is not a letterhead — it is a picture in the text column. The page margin used to enclose header, content and footer alike, so there was no way to say that one of them reaches further than the others.

The page margin is now zero and each region keeps its own distance. The header and footer images get a side margin of their own, nil by default, which puts them edge to edge; the text keeps the margins it had. The footer image also gets its own distance from the bottom, so it can sit where it belongs rather than where the text ends.

The footer is stacked rather than sequential: the image behind, the footer line and page number in front of it. An address can therefore stand on a coloured band instead of underneath one.

The footer line is read as HTML, which is what makes that worth having — centred, or a table putting the address beside the bank details. It goes through the same sanitiser as everything else.

Both images are capped at 70mm tall. Stretched to the width of the sheet, a square logo would be 210mm high, leaving no room for header, text and footer together; QuestPDF answered that with an exception nobody could act on. Now the picture yields instead.

a4da7b7 Wuestengecko 20:53

Let a teacher sign the letters they send

A call-up that ends with a typed name reads like a form. The signature under it was the last reason to finish these letters somewhere else.

A teacher stores theirs in their profile and a template places it with {{Unterschrift}}. It sits in the protected store, not under wwwroot where the avatars live: a scanned signature is not something to hand out to whoever guesses a URL. Only its owner can fetch it back, and only to look at it on the profile page.

On paper only. A mail has no place to keep the image, and a broken image box under the greeting would read as a fault, so the mail path drops the marker instead of shipping an empty frame.

The image never travels inside the text. The placeholder leaves a marked empty <img> and the renderer draws the picture where it finds one — a data: URL in the src would have meant allowing that scheme through the sanitiser everywhere, including in an href. Without a signature stored the placeholder resolves to nothing at all, so a template written by one teacher still works in another's hands.

The two copies of the markdown pipeline and the sanitiser are now one, since both had to learn the same marker.

c9eb182 Wuestengecko 10:09

Let a paragraph say which margin it belongs to

The date of a letter sits on the right. Markdown has no way to say so, and the renderer had no way to hear it — every paragraph came out flush left.

Paragraphs and headings now honour text-align: right, center, justify or left, read from the style attribute. The same line of HTML works in a mail client, so there is one spelling to learn rather than one for paper and another for the inbox.

Which means the letter formats accept HTML mixed into Markdown, where they used to escape it. A letter occasionally needs something Markdown cannot express, and the case against raw HTML — a script reaching the browser of whoever reviews the message — was already answered by the sanitiser every format goes through. News posts and content pages keep escaping it; nothing there needs the exception.

The invitation scaffold uses it for its dateline, so a new template carries a working example of the spelling.

493f19f Wuestengecko 10:00

Put the block back, and make a line break survive the trip

The scaffold carries the recipient block again — brigade, name, date of birth. Every line of it fills from the account, which is what was wrong with the version that got removed: that one asked for a postal address nobody had recorded.

Three lines only stay three lines because the letter pipelines now read a newline as a newline. Markdown folds consecutive lines into one paragraph, which is right for prose and wrong for an address block and a signature — they would have arrived as a single run-on line. Blank lines still separate paragraphs, so nothing else about writing changes.

News posts and content pages keep the old rule. There, wrapped prose is the normal case, and existing pages were written under it.

0d639a8 Wuestengecko 09:54

Drop the address block from the invitation scaffold

The scaffold opened with the recipient's brigade and name as an address block, copied from the letter it was modelled on. Nothing fills it: an account carries no street and no postcode, so the block rendered as two lines that read like an address and are not one.

It is gone, and with it the sender's town in the dateline — that was one particular brigade's town, hardcoded into a default that every installation starts from. The date stands on its own; whoever writes the letter puts their town in front of it if they want one.

746cca8 Wuestengecko 09:43

Let people say how they want to be addressed

The invitation opened with "Lieber" for everyone, because that is what the letter it was modelled on happened to say. Guessing the right word from a name is exactly the kind of guess that goes wrong, and goes wrong in the one line the reader sees first.

So the account carries it, and the person themselves sets it — on their profile, at registration, and, for accounts that predate the field, in user administration. It is free text with suggestions rather than a fixed list: "Lieber" and "Liebe" cover nearly everyone, but a list that covers everyone does not exist, and someone who prefers "Moin" should not have to be one of the two.

Left empty it stays "Hallo", which is right for everybody and is what every existing account reads as until somebody says otherwise.

Templates get {{Anrede}} for the word alone and {{AnredeName}} for the whole greeting; the starter template now opens with the latter. The word alone is there so a template that pairs it with a surname can do so — the application does not decide which name the greeting belongs with.

a685df7 Wuestengecko 09:40

Say in the Dockerfile that PDFs need nothing installed

The image gained a note next to the GSSAPI line, because the question comes up: rendering a PDF usually means a font stack, and often a whole browser. QuestPDF carries its own Skia and its own typeface, links against neither fontconfig nor freetype, and needs no package here.

Verified rather than assumed — the renderer's tests run green inside a plain Debian container with no font packages present.

5de7faa Wuestengecko 09:16

Set every participant their own letter, and keep it with them

The call-up to a course is the same letter thirty times over, with a different name, brigade and birth date in it. It was a mail merge in a word processor against a spreadsheet exported from here — so the addresses aged, the dates were retyped, and what went to whom survived only as a folder of PDFs on somebody's desktop.

A document template is written like a message template, with the same placeholders, and carries a letterhead and a file name pattern. From the course page it renders once per participant: their name in the salutation, their brigade in the address block, the course's own sessions in the schedule on the second page, behind a \newpage.

Each letter is filed with the participant it was written for, whether or not it is sent. Sending is the same action continued: one message, each recipient carrying their own letter as a personal attachment, and the filed copy learns when it actually arrived and which message carried it. The attachment is a copy of the file, so deleting one never empties the other.

The participant page now holds a participant's papers in one place — generated letters, files put there by hand, and the proofs from their registration, which used to be the only thing kept there. What a participant may see, they see on their own course page: whoever misplaces the mail finds the call-up in the portal.

f865f04 Wuestengecko 09:09

Put the dates in the envelope, on the brigade's own paper

An invitation without the schedule is half a letter. Until now the schedule lived in a word processor, was exported by hand every year, and was attached from the desktop — so it aged separately from the sessions the application already knows.

A message can now carry attachments: files picked from disk, and the course schedule, which the application sets itself from the sessions on record. An attachment without a recipient goes to everyone; one that names a recipient goes only to them, so a single message can carry a different letter for each participant without becoming thirty messages.

The PDF is set here rather than by a browser or an office suite: text is written in Markdown or HTML, both are parsed to the same document tree, and QuestPDF puts it on the page. That keeps the container free of Chromium and LibreOffice, at the price of an agreed vocabulary — headings, paragraphs, emphasis, lists, tables, rules, quotes, preformatted text and page breaks. Free CSS has nothing here to run it.

Letterhead is kept separately, because a brigade has one letterhead and many letters: an image at the head and foot of every page, a line of small print beneath, the margins between them, and page numbers where a multi-page document wants them. A sample letter renders on demand — the margin under the letterhead is the kind of thing you only see on paper.

5697d4b Wuestengecko 09:00

Write a course letter once and let it fill in the names

The same letter goes out every course: the invitation, the reminder, the note about a cancelled session. Rewriting it each time meant retyping the dates and looking up how the last one was worded.

A message can now be kept as a template — a name, a subject, a text — and be pulled into the compose form. What differs between recipients is written as a placeholder: {{Vorname}}, {{Lehrgangsart}}, {{ErsterTermin}}, {{Terminliste}} and the rest of the catalogue that sits foldable beside the text field. They are resolved at send time, once per recipient, so everyone reads their own name and their own dates.

A placeholder that is not in the catalogue stays as it is. A typo therefore shows up in the preview as braces rather than vanishing into a blank in thirty mailboxes; the preview renders the message the way the first recipient will read it, through the same code that sends it.

Bodies are written in Markdown or in HTML, whichever suits the letter — both end up as the same mail. Both are sanitised: a template is written by a teacher but read back by whoever reviews the message, and a script in it would run in their browser.

Templates belong to the course organisation, not to a person: every teacher sees and uses all of them, and the one who wrote a template — along with administrators — may change it.

593ce88 Wuestengecko 08:45

Write to a course, or to whoever needs to hear it

Reaching a course meant leaving the application: export the participants, paste the addresses into a mail client, and hope nobody was missed. The list of who was written to existed only in somebody's Sent folder.

A message is now written here. The recipients come from a course — participants, instructors, or both — or, for administrators, from a free selection out of the whole roster. Every address gets its own mail, so nobody learns who else is on the list.

What went out is kept. The sent view names every recipient with the address it went to and whether it arrived; a failure carries its reason and can be tried again, and a second attempt skips those already reached. Address and name are written into the record, so the log still reads correctly after an account changes its address or disappears.

Teachers reach the courses they created, the same boundary as the course pages themselves. The whole roster stays with administrators — otherwise this page would be a way past the role assignment to every address in the system.

3a17db2 Wuestengecko 08:23

Montag, 31. August 2026

Let one pass mark decide both the verdict and the grade

The analytics page reported a 75 % pass rate next to four failing grades out of eight submissions, and both numbers were right. An exam carried a pass mark of its own while the grade scale carried a second one, so a submission at exactly 50 % counted as passed and was graded 6,0 by a scale that only started passing at 60 %.

The scale is now pulled onto the exam's pass mark: the worst passing grade sits exactly there. The scale's own pass point is gone from the exam form and stays only as the fallback for exams that name no pass mark. Bands below the pass point are dropped instead of being left to sit out of order.

Failing is now 5,0 rather than 6,0, and the grade distribution shows the school grades it can actually produce — Sehr gut through Ausreichend, then Mangelhaft — instead of the invented spans 5,0-5,9 and 6,0-6,9.

2096fec Wuestengecko 23:46

Freitag, 21. August 2026

Count a machinist among those expected at a session

The box named only instructors, though a session assigns machinists too and they are expected just as much. Both now appear under "Du bist eingeteilt", each with the role beside the date — it decides what one prepares. Where someone is both, instructor wins, being the role with the preparation.

The list no longer hangs on the teacher role either: a machinist may well be a participant, and would never have seen it. A session someone is assigned to also drops out of their participant list above, where it would have stood a second time saying less.

dc1d2a8 Wuestengecko 17:32

Give a participant's name somewhere to lead

The participants list held names and nothing else. For the talk at the end of a course an instructor had to gather from three pages how often someone came and how they did in the exams. The name now leads to a page that holds both: the attendance with every recorded date, and the exams released to this course with the state and points of the latest attempt — a row leading into that submission where there is one.

An exam nobody sat says so rather than showing a zero.

5b7b1db Wuestengecko 17:12

Answer at the course what used to need a spreadsheet or a detour

Two questions an instructor asks about their own course had no place on its page. Who was there how often was answered only by the CSV export, so the answer left the application to be read; the page now carries the whole register, participants down the side and dates across the top, with the count beside each name. A date with no entry shows as a dot: nobody has written it down yet, which is not the same as an absence.

And nothing at the course mentioned exams, although exams are released to courses — one had to know the exam to find the way. The course now lists them with how far each has come: submitted of participants, waiting to be marked, published. What waits leads straight into the marking queue for that exam, and the analysis of points and grades stays where it already lives, one link away.

f470c01 Wuestengecko 17:09

Tell an instructor what is theirs to do

"Für dich" counted only the courses someone attends. Teaching one showed nowhere, though the session says who instructs it — the evening one is expected at is the same question whether one comes to learn or to teach, so the box now carries both, kept apart because the reason to be there differs.

Beneath them stand the numbers that were only reachable by opening the pages themselves: submissions waiting to be marked, applications waiting for an answer. Both lead where they are handled, the first straight into the submitted filter.

What someone may see is still decided by the access services; the overview only counts what they hand it. The service loses the name Participant, since it now answers for both sides.

81222a1 Wuestengecko 17:03

Show a course's documents to the instructor as well

Yesterday's section put the released handouts on the course page — but only where the reader is a participant. An instructor opening their own course saw nothing, and had to go to the document tree to find out what their people actually have. The management page now lists the same thing.

01f2a95 Wuestengecko 16:57

Fix the display timezone in the code instead of the environment

The container now carries TZ=Europe/Berlin, but that only holds where this compose file is used; started another way, every printed time would drift back by two hours. The zone now stands in the code beside the fixed language: AppTime.Zone is Europe/Berlin, and every place that showed a local time, asked for "now" or for "today" goes through it.

Storage is untouched — UTC, as before. Only the reading end is fixed, and it no longer asks the machine what time it thinks it is. Where the system has no timezone data at all, it falls back to the machine, which is no worse than before and better than failing on the first date shown.

The container keeps its TZ so its logs read the same way.

24024a4 Wuestengecko 16:35

Run the container in the timezone the application is written for

Every local time the app printed was two hours behind: the container had no timezone, so it ran in UTC and ToLocalTime had nothing to convert to. It showed on the account timestamps and equally on all 27 other places that print a local time, on DateTime.Now in export file names, and on "today" — which between midnight and two in the morning was still yesterday.

The stored values were never wrong; they are UTC and stay so. Only the reading end lacked the ground to stand on, and the image already carries the timezone data.

77175c5 Wuestengecko 16:27

Sort the start page column by who is reading it

Signed in, the column opened with an invitation to go find a course — written for a visitor who has none, and standing above the courses the reader is already in. It now shows only to visitors, together with the three steps that explain the way in.

What stays for a signed-in reader moves together: their own state first, then the shortcuts, which had sat below the open courses.

585fda1 Wuestengecko 16:20

Let the exams menu lead straight there for a student

A teacher has three entries under it; a student has one, and still had to open a dropdown to reach it. Where nothing but "Prüfung ablegen" hangs beneath, the menu item is now the link itself.

51891e5 Wuestengecko 15:35

Name the course an exam belongs to, and how long it takes

An exam is released to courses, but nothing said so: in two parallel courses the title was the only clue. The course now stands on the open exam, on the row of a finished one and above the result, wherever a release names it — an exam open to everyone names none, which is equally true.

The card also carries the time limit. Whether tonight holds 20 minutes or 60 decided whether to start, and until now that stood one click further on.

c14b9c5 Wuestengecko 15:33

Put a course's documents on the course page

Handouts are released to a course, but only the document tree showed them, and nothing there said which course they belonged to. The course page now lists what was released to it — folders lead into the tree, files download straight away — so a participant finds the script for tonight where the course is, not by searching a tree.

Only what was released to the course itself is listed. What lies inside a released folder is found in that folder, and naming it all here would make the list as long as the whole store.

c980d54 Wuestengecko 15:29

Show participants their own attendance on the course page

Instructors kept the register; the person it was about could not see it. A participant now finds on the course page how often they were there, which dates are marked excused or missed, and how many past sessions the instructor has not entered yet — that gap is an open list, not an absence, and says so.

Sessions where someone taught rather than attended stay out of the count.

6ec5b17 Wuestengecko 15:26

Tell a participant when and where they are next expected

The start page knew nothing about the person reading it. It now opens with "Für dich": the next sessions of the courses they attend, with date, time, place and topic, and beneath them what waits — open exams, courses still missing a proof. Both were reachable before only by going to look.

"Meine Lehrgänge" gains the same date on each card and stops sorting by start date alone, which put a course beginning in December above the one running tonight. Running courses come first, then what is coming, then what is done.

257d759 Wuestengecko 15:21

Colour the score on a student's result and fold away what was right

The breakdown listed every question the same way, so finding the ones that cost points meant reading all of them. Each question is now a fold, open exactly where points are missing and closed where the answer earned them all, and the score beside it carries the same colours the teacher's review uses: green at full marks, a lighter red for partial credit, a firm one for nothing. The styles and the fold come from that page rather than a second set of their own.

The partial had a blank line between every line it held; those are gone with the rewrite.

47908ce Wuestengecko 15:01

Open a row by clicking it in the five lists that still did not

Content pages, news posts, the questions behind a topic or tag, course templates and the sessions of a course each kept their way in behind a pencil at the end of the row while the row itself did nothing. They now follow the rule the other lists already state: the row leads to the thing, the name stays a link for the keyboard and the middle mouse button, and what goes beyond opening stays visible beside it — deleting a news post, taking attendance for a session.

A course session had two destinations, so the row had to pick one: it edits the session, and attendance keeps its own link.

Where a topic lists sessions of courses that belong to someone else, those rows stay dead, as their names already were — there is nothing behind them for this reader.

With the last pencil gone, its partial has no user left.

b3e0a35 Wuestengecko 14:50

Open a finished attempt by clicking its row

The student's list of results was the one list where the row did nothing and an eye at its end held the only way in. It now behaves like every other list: the whole row leads to the result, the title stays a link so the keyboard and the middle mouse button still work, and the icon column is gone.

03f7471 Wuestengecko 14:41

Collapse the migrations into one, for a database starting empty

Nothing is deployed and everything held so far was test data, so the seven migrations that grew alongside the model have no one to carry forward. One InitialCreate now describes the schema as it stands, including the merged topics — the merge migration and its careful data move are gone with the rest, having served their one purpose.

43037ff Wuestengecko 14:30

Let the session count on a topic lead to those sessions

The question count beside a topic already opened the questions carrying it; the count of sessions sat there as a plain number. It now leads to a page that names them: the dates of the courses where the topic is taught, and the sessions of the templates that place it.

Courses belonging to other teachers are listed too — they make up part of the number, so leaving them out would leave it unexplained — but only what the reader may open is a link.

5ca53cb Wuestengecko 10:11

Edit tags the way topics are edited

Renaming a topic happens in its row; renaming a tag meant a pencil icon leading to another page, and deleting hid behind a "more actions" menu. The tag list now has the same shape as the topic list: the name is an input with a save button beside it, the count links to the questions, and delete sits in the row. The row-actions partial had no other user and is gone, as is the edit form on the questions page — both lists now hold the only way to rename or delete their entries.

A tag still goes even while questions carry it: unlike a topic it labels rather than classifies, and the confirmation says the links will go with it.

454fb53 Wuestengecko 10:08

Drop the cross links between topics, templates and tags

Each of these pages carried a button to one of the others in its heading. The menu leads to all three, so the buttons only doubled a way that was already there — and with the heading free of them, two of the pages no longer need a row that splits left from right.

The links inside the "no topics yet" notices stay: they appear where the lack is noticed, and point at the page that fixes it.

77f5383 Wuestengecko 09:58

Merge question categories and course topics into one list of topics

Both were the same thing under two names: a list of Id, Name and creator, holding "Atemschutz", "Brennen und Löschen" and "Gerätekunde" twice over in this installation. They are now one entity, Topic, called "Themen" wherever it shows: on a question, on a course session and in the administration menu.

The list is shared. Course topics used to belong to whoever created them, questions' categories were global and kept by administrators; a shared list is what the duplicate names asked for. Teachers see and use the same entries and maintain them, as they did with topics. The creator stays on the record as its origin only, and is now nullable with SET NULL, so a topic outlives the account that entered it — deleting such a user used to fail outright when a session hung on their topic.

The maintenance page merges too: the topic list carries what the category page could — a question count, linking to those questions — and what the topic page could: renaming in place. A topic in use cannot be deleted; the counts beside it say why. What was the category page is gone, and question settings keep the tags.

The migration carries the data across: same names become one topic, case ignored, and every assignment follows. Verified on a copy of the live database — 6 categories and 8 topics became 11 topics with all 31 question assignments and all session and template assignments intact.

1230372 Wuestengecko 09:51

Move topics and course templates into the administration menu

Both are lists one sets up once and rarely touches again, next to the course types they sit beside now. The courses menu keeps what one works in daily: all courses, one's own, and the course administration.

Teachers see the administration menu again, with these two entries and nothing else — every other item in it is gated on the administrator role on its own. Who may edit what does not change: both pages stay open to teachers, and a teacher building a course still picks from the topics and templates as before.

927ec40 Wuestengecko 09:19

Make the three visibility cases exclude each other and searchable

"Alle Schüler" and single courses could be ticked at once, which says nothing the first tick does not already say. The form now offers three cases as radio buttons: teachers only, all students, or named courses. "Nur für Lehrer sichtbar" is also the way to take a grant back — it clears what was given, which the form had no word for before. What a folder above passes down stays untouched, as the note there says.

The course list grows with every year, so it sits behind a search field: type, pick, and the chosen ones stand above it as chips, in the same shape the tag input on questions already uses. Picking a course selects its radio button, so the two cannot drift apart.

The server no longer trusts the form to be consistent: the chosen case decides what is written, so courses sent alongside "all students" are ignored rather than stored.

d7b02ec Wuestengecko 08:59

Give the tree the click behaviour a tree usually has

A click on a folder expands or collapses it, a click on a file marks it, and a double click opens either — the way a tree pane behaves in a file manager or an editor. Folders join the selection through their checkbox, which is also how an editor tree does it. The name stays a link so the keyboard and "open in new tab" keep working, but its plain left click belongs to the row; in the student view, where there is nothing to mark, the file link stays a link.

Creating a folder and uploading move above the tree. They always act on the folder that is open, never on the selection, so they do not belong among the actions for what is selected — and the label now names the folder they hit.

905b370 Wuestengecko 08:46

Select documents by clicking the row and pack a selection into a ZIP

A click anywhere on a row now marks it; only the name still opens the folder or fetches the file. Shift picks a whole stretch, and a marked row is tinted so the selection is visible without hunting for ticked boxes. Where there is nothing to tick — the student view — a click still walks into the folder.

"Als ZIP herunterladen" packs whatever is selected. Folders go in with everything below them and keep their shape, empty ones included; a file missing from disk is left out instead of failing the whole archive. The zip is written to a temporary file that removes itself once the response is sent, so a large branch does not have to fit in memory. A single selected folder gives the download its name.

af54c58 Wuestengecko 08:32

Turn the document area into a tree with an action panel beside it

Folders now expand where they stand instead of only opening as their own page; the children are fetched on first expand, through the same access check the page itself uses, so an expanded branch shows exactly what an opened folder would. Clicking a row still opens the folder or fetches the file.

The table of columns gives way to a tree on the left and a panel on the right that holds everything one does with a selection. Rows carry a checkbox, shift picks a whole stretch, and the panel names what is selected and offers "Öffnen", "Umbenennen", "Sichtbarkeit", "Verschieben", "Kopieren" and "Löschen". Creating a folder and uploading stay tied to the folder that is open, not to the selection, and say so.

Moving and copying take a target folder; a folder cannot travel below itself, and a name already taken at the target stays where it is instead of aborting the whole run — what stayed behind is reported. A copy gets its own file on disk and the visibilities of the original, and a name that is taken gets a "(Kopie)" suffix. Visibility now accepts several items at once, preselected with what they already have in common.

Deleting a folder takes its content with it, after a confirmation that names how many subfolders and files go along. The database keeps its restriction, so nothing cascades silently — the service deletes documents first, then the folders from the deepest level up.

5b912be Wuestengecko 08:23

Reorder the main menu and move administration beside the profile

The menu now reads Neuigkeiten, Lehrgänge, Prüfungen, Fragen, Dokumente, Archiv — the areas one works in, in that order. Administration sits on the right next to the avatar: it is visited rarely and on purpose.

"Lehrgänge" stood twice, once as the public list and once as the organiser list under Administration, while exams and questions each keep everything under one menu. The organiser entries move into the courses menu below a divider: "Lehrgänge verwalten", "Themen", "Lehrgangsvorlagen". The course types stay in Administration, next to the categories and tags — master data, administrators only.

The two list pages stay separate: the public one is a card view in _PublicLayout for visitors, the other a filtered table for organisers, the same split exams have between Exams and StudentExams.

With the organiser entries gone, nothing creator-facing is left in Administration, so it shows for administrators only.

c0c8a6f Wuestengecko 08:02

Donnerstag, 20. August 2026

Replace the three document areas with one tree and per-item visibility

The area model decided visibility by location: a file was visible because of the folder it sat in. That forced the same file to be filed in several places to reach several audiences, and it made "who can see this" a question you answered by tracing where a document had been put rather than by reading its row.

There is now one tree and one question per item: who may see it. Nobody, until a teacher says so — an item without a grant stays with the teachers, which is what the teacher area did implicitly and now says out loud. Grants are "all students" or named courses, and both can apply at once.

Rights run downward and only add. A folder granted to a course carries every script later dropped into it, which is the whole point; there is deliberately no way to narrow a child, because then no row could be read on its own. The listing shows own grants in blue and inherited ones grey with an arrow, so the column answers the question without opening anything.

A grant deep in the tree needs the folders above it to be walkable or it sits behind a locked door. Those appear for the student marked "(nur Weg)" and hand over nothing themselves — not their files, only the way down.

A document now lives in exactly one folder. Multiple placements existed to reach multiple audiences, which is what grants do now, and one location is what a file browser means.

Existing data moves across rather than being dropped: each file takes the first named folder it was filed in, general placements become "all students", course placements become that course, folder shares become grants on the folder, and the area roots disappear with their children rising to the top.

aa95124 Wuestengecko 21:26

Record when an account was created and when it last signed in

Both are shown to the user in their own profile and to administrators on the account page.

The creation stamp is set in SaveChangesAsync next to the ones already there, so registration, invitation, first-run setup and the demo seeder are covered at once and a future path cannot forget it. Accounts that predate the field stay empty and read "nicht erfasst" — backfilling them with the migration timestamp would state something untrue about every existing account.

The login stamp is set at the four places a sign-in actually happens rather than centrally in the SignInManager: RefreshSignInAsync runs there too, on profile and password changes, and would keep moving a date labelled "last signed in" to moments that were not a sign-in.

4442564 Wuestengecko 21:10

Give user administration an edit page instead of controls in the row

Changing a user meant a role dropdown wedged into the table and nothing at all for the rest: an organisation, a name, an email could not be corrected by an administrator once the account existed. The list now behaves like the question list — the row opens the account — and everything editable sits on one page.

Two things stay locked on your own account: the role and the approval flag. Both are how you get back in, and the form guards them server-side as well, since a disabled field is a suggestion and a hand-written post is not.

Removing your own role was already refused; that guard moves along, as does the one keeping the last administrator in place. Password, blocking and deletion remain separate actions in the row menu — those are interventions, not fields.

UpdateRole goes with the dropdown that called it. Leaving a second, unreachable path for the same change would only be a place for the two to drift apart.

e0b85e8 Wuestengecko 21:10

Let teachers reach the documents of an archived course

Opening the document area of an archived course gave a teacher a 404 while its participants kept reading the same folder: the lookup ran through a list that filters archived courses out, so the course simply was not found. The area was never closed on purpose — it was closed by a filter meant for a different list.

Archiving a course does not take its material away from the people who attended it, so the person responsible for that material has to keep reaching it too. Otherwise a wrong document stays wrong for exactly as long as anyone still has it. The course now resolves regardless of archive state, and the hub lists archived courses for teachers, marked, so the area is reachable without typing a URL.

The same filter sat under the share picker, where it was worse than a 404: a share pointing at a course that was archived afterwards no longer rendered as a checkbox, and the next save — which treats the page as the complete answer — would have dropped it without a word. Already-shared courses now stay in the list even when archived.

25e8f00 Wuestengecko 21:00

Keep the owning area out of a borrowed folder's breadcrumb

The folder chain already stopped at the share boundary, but the crumb before it came straight from the folder's own area and was rendered unconditionally. A participant who opened a shared teacher folder read "Lehrerbereich" above it, linked — naming the area the share deliberately does not hand over, and pointing at a page that would turn them away.

The breadcrumb now omits the area whenever the viewer cannot open it, so a borrowed folder reads Dokumente / <folder> and stops there. Teachers, who may open every area, still see the full path.

Found by walking a shared subfolder as a course participant rather than by reading the view.

0b006db Wuestengecko 20:50

Share whole folders into an area instead of picking documents one by one

Assigning a course its material meant ticking off every file, and doing it again whenever a script was added. A folder is the unit teachers already think in, so it becomes the unit they can hand over.

A share is a link, not a copy — the same choice the documents themselves already follow. The folder stays in its own area and the target area sees it alongside its own folders; anything filed into it later is there without touching the share again. The subtree comes along, because a shared folder whose subfolders stayed locked would be worse than no share at all.

That widens what a student may open beyond "which area is this folder in", so visibility now walks the share edges: the folders of their own areas, plus every descendant of a folder shared to them. Breadcrumbs stop at the share boundary, or they would march a student up into the teacher area.

Sharing a folder into the area it already lives in is refused, as is sharing an area root — that last one would hand over the whole teacher area at a stroke.

Course creation grows the same picker, so a new course can start with its material already in place instead of being filled in afterwards.

428eb5d Wuestengecko 20:42

Open exams and courses by clicking the row, as the question list does

The two lists carried a pencil button while the question list had already moved on: there the row itself opens the entry. Two lists behaving differently in the same app is a thing to relearn on every visit, so both now follow the question list and the pencil goes.

The click target stays what the pencil pointed at, which is Details for both. For exams the row is only clickable for someone who may actually edit it, so the title renders as plain text otherwise — a link leading to a refusal is worse than no link.

550d484 Wuestengecko 20:42

Mittwoch, 19. August 2026

Offer to carry placements over when an upload repeats a name

Re-uploading a corrected script quietly produced a second document reading exactly like the first, and left every placement pointing at the old one. The teacher saw their upload in the teacher area and had no way to tell that the course was still handing out the outdated file — the failure was silent, which is the worst shape for it.

An upload whose name already exists now asks. Taking it as a new version lets the fresh file inherit the old one's placements and drops the old one, so the correction reaches every course the previous version was filed into. Keeping both stays available and does what it did before. The question lists where the old version sits, because that is what the decision turns on.

Markdown joins the accepted types, and the two forms above a folder stack their button under the field — side by side they squeezed the file input until its name showed as "K…lt".

7b08973 Wuestengecko 21:20

Pin down who may download which document

The visibility rule spans three tables — a document, its placements, and the participant list of the course a placement points at — and a mistake there hands students the teacher area rather than failing loudly. The rule is fixed here: teacher-area-only stays hidden, general is visible to everyone, and a course document only reaches that course's participants.

The area root carries its own quirks and is covered too: it never shows up as a subfolder of itself, it is left out of breadcrumbs, and there is exactly one per area. Alongside it the difference between removing a placement and deleting the file, which is the pair most likely to be confused later.

c797c3f Wuestengecko 21:06

Add a document library shared between teachers and their courses

Teachers had no place to hand out scripts and forms; anything not tied to an exam went past the application entirely. There are three areas now: a teacher area only teachers and administrators reach, a general one every signed-in account sees, and one per course for its participants. Each is organised into folders and subfolders, and files may also sit directly in an area.

A file exists once and is placed, rather than copied, so the same script can appear under a course and in the general area while a corrected version replaces it everywhere at once. Every upload is additionally placed into the teacher area, whichever area it was made from, so the library stays complete without anyone having to file it there. Removing a document from a folder only drops that placement; deleting it drops the file and every placement it had.

Students read and never write: no upload, no folders, and the teacher area is invisible to them. Documents live outside wwwroot and are only served through an action that checks, per request, whether the caller can open a folder the file is placed in.

f649ef4 Wuestengecko 21:03

Stop asking for the old password after a magic-link sign-in

Somebody who follows a sign-in link usually does so because the password escapes them — and was then asked for exactly that before being allowed to set a new one. The link already proves control of the mailbox, which is what any password reset comes down to, so the session records how it was signed in and the change runs through a reset token instead.

Changing the email address still asks for the password. There the check guards against a session being used to move the account elsewhere, and that is a separate decision from this one.

6ebe0ae Wuestengecko 20:49

Gather everything archived under one menu and align the list filters

Archived courses, exams and questions each had their own arrangement: questions offered a three-way filter, courses a different one, exams nothing at all, and what was archived sat mixed into the working lists. There is an Archiv menu now with one entry per kind, and the working lists show only what is in use — the filter that used to reveal the rest is gone rather than merely defaulted.

The three filters now share one shape, a search and two selects in a card above the list, with the fields each kind actually has: courses by type and period (starting, running, finished), exams by author and status, questions by category, tag and kind. Exams had no filter at all before, and their list is built from a view model now instead of a bare list plus three ViewBag entries.

af9c514 Wuestengecko 20:41

Open an archived question for reading instead of turning it away

Clicking one produced a notice that it cannot be edited, a redirect to the list and, on the way, the loss of whatever filters were set. Yet looking up which answers a question offered is the commonest reason to open an archived one at all — an archive nobody can read into is a wastebasket.

It opens now with every field locked by a single disabled fieldset, so no control has to know about the mode, no save button, and a button to restore it for editing. The POST still refuses an archived question: the form offers no way to submit one, but that is the view's doing, not a rule.

50c7189 Wuestengecko 20:04

Fix editing a question, and open it by clicking its row

Editing threw. The edit action renders the create view, which reads the maximum score for a written question straight out of the ViewBag — and only the two create actions ever put it there. The value now travels on the model, where the view takes it from and where the next action cannot forget it.

The pencil column is gone from the question list; the row itself leads to the form, as it already does for submissions. Only for those allowed to edit: rows belonging to someone else carry no target and stay put. The remaining actions keep their menu.

38ae22e Wuestengecko 19:55

Move the analysis into the exams menu

It hung as a button on the submissions list, which made it look like a detail of that page rather than a destination of its own. It sits in the menu under Prüfungen now, beside the list it draws from, and reaches the same audience — the entry follows the same permission.

The "back to submissions" link on the analysis goes with it: both pages hang in the menu now, so nobody necessarily arrives from the other and "back" no longer describes the way.

37e32ad Wuestengecko 08:00

Restore the row link the selection form had disabled

Wrapping the list in a form for the bulk actions broke clicking a row: the handler skips anything inside an interactive element, and 'form' was on that list — which since the change matches every single cell. The controls inside are named individually anyway, so the entry goes.

The messages now distinguish one from many. "1 Abgaben archiviert" reads like a mistake, and it was one.

d61d6bd Wuestengecko 07:53

Publish and archive submissions in bulk from the review list

Publishing a finished cohort meant opening every submission in turn. The list now carries a checkbox per row and publishes the selection in one go. Anything in the selection that cannot be published — already out, exam archived, written questions still unmarked — is skipped and counted in the message rather than aborting the run, and the button greys out when the selection holds nothing publishable at all.

Archiving takes submissions out of the list and does nothing else: they stay, they keep counting in the analysis, and their own page still opens. A filter brings them back into view.

"Veröffentlicht von" is gone from the list, along with the query that loaded it.

aab9a1e Wuestengecko 07:41

Dienstag, 18. August 2026

Collapse the questions while marking and colour their scores

A marked submission now shows everything at once, which makes the page long. Each question folds away on its own, the list folds away as a whole, and three buttons switch between all, none and only the unmarked ones — the last being the state the page opens in, so what still needs work is what stands open. Folded questions stay in the form and are submitted with it; only their display is hidden.

The score of a question is green at full marks and red below. Partial credit stays red as asked but a shade lighter than a plain zero, otherwise it would no longer be visible that anything landed at all.

"Vorschlag verwenden (3)" broke into two lines wherever the column holding it was narrow, which on a tablet it is: 170 pixels. It reads "Vorschlag: 3" now and no longer wraps, with the full wording kept as its label for screen readers.

5564cef Wuestengecko 21:43

Fold the teacher's result page into the submission it belongs to

The marking page and the summary showed nearly the same thing, one after the other: since the score bar arrived, the marking page already carried points, percentage, grade and verdict alongside every question and answer. Three details were still missing — when it was handed in, the pass mark, and the name of the grade — and they now stand there too.

With that the summary route for teachers carries nothing of its own and is gone, along with the two services that only it still needed. What participants see is untouched: they keep their own summary, and it keeps the shared partial.

3b4c0c3 Wuestengecko 21:28

Make review rows clickable and drop the publisher column on narrow screens

An eye icon in its own column was the only way into a submission, while the rest of the row — the obvious thing to click — did nothing. The row now carries the target and site.js follows it. The link itself stays in the row so that keyboard, middle click and "copy link" keep working; anything interactive in the row keeps precedence, and selecting text is not treated as a click.

"Veröffentlicht von" leaves the table below the large breakpoint and appears under the exam title instead, the same way the user list already handles its secondary columns.

3977ec3 Wuestengecko 20:47

Add a demo exam that demonstrates partial credit, and tidy the marking page

Nothing in the demo data showed what the grading actually does with a partly correct answer. A separate exam now does only that: twelve questions carrying their own construction in the title — three to six alternatives, varying numbers of them correct — with alternatives called "Richtig 1" and "Falsch 1" so that a submission can be read without looking anything up. Eleven submissions each hold one selection pattern across the whole paper, from exactly right through everything ticked to nothing at all, which puts side by side what the same behaviour yields on questions of different sizes.

The two existing demo exams took every unarchived question in the database, which would have swallowed these test cases — and anything the installation's own users had written. They are now limited to their own pool.

On the marking page the actions were of three different sizes and did not line up. They are a grid of equal rows now, wide enough that the longest label stays on one line. The row holding points and note was bottom-aligned, so the button offering the suggested score pushed the note half a field lower than the points it belongs beside; both now start at the top.

111810c Wuestengecko 20:36

Show the running score while marking, and move publishing to the top

The page asked for points question by question without ever saying what they added up to. A bar at the top now carries the total, the percentage, the grade and pass or fail, and the total follows along as the fields are typed in — the choice questions form a fixed base the entries add onto.

The grade cannot follow along: it comes from the grade scale on the server, and computing it a second time in the browser would be a copy that drifts. It is therefore marked as no longer current as soon as the form differs from what was saved, rather than standing there looking authoritative.

Publishing sat at the very bottom, past every question. It belongs with the other actions in the header.

6968c32 Wuestengecko 20:22

Report working time per exam, and per question where exams differ

An average working time across all exams said nothing: one exam with four questions and one with nineteen take different lengths of time without the candidates doing anything differently. The full duration is now shown only where every exam involved asks the same number of questions; otherwise the time per question takes its place, which is comparable.

The per-exam and per-course breakdowns carry the figure as a column of their own. Per exam the scope is uniform by definition, so the full duration stands there.

b7e2f5f Wuestengecko 08:37

Count a submission towards the course whose exam it was

Per course and per exam disagreed, and the course figures were the wrong ones. A submission was assigned to every course its candidate happened to attend, so the radio exam turned up in the analysis of the Truppmann course purely because the same people sit in both — and the rows added up to more than twice the total.

What decides now is the release of the exam. Released to courses, it counts there and only there; without a release it is open to everyone, and then the candidate's courses remain. Overlap is still possible in that second case, so the table says so — but only when it actually occurs.

Average raw points across exams with different maximums were meaningless: 2 out of 4 and 17 out of 23 do not average, and the averaged maximum beside them is not an exam anyone sat. Where the maximums differ, the percentage takes over.

The archived demo exam gets the release to its own course that it was missing, without which it demonstrated exactly the confusion described above.

57c4eda Wuestengecko 08:33

Give the archived demo exam the cohort that sat it

The analysis groups by submission, so an exam without any never appeared in it — and the archived demo exam had none. That is backwards for what it depicts: an exam is archived after a cohort has written it, not before. Eight submissions are now created first and archiving pulls them onto the snapshots itself, which is the only order that works; an archived exam accepts no new ones.

A run that already produced the empty shell replaces it. That is safe precisely because it only happens when not a single submission hangs off it.

Where a table still leaves something out for want of submissions, the page now names it underneath. Otherwise it stays unclear whether an exam is missing or whether nobody has taken it.

18b79b5 Wuestengecko 08:15

Add a changelog page fed by the git history

The footer now leads to "Letzte Änderungen", where the development of the application is listed entry by entry, grouped by day.

Git is not available at run time — .dockerignore keeps .git out of the image and should keep doing so — so the history is written to a file at build time by a target that runs before every build. The file is committed, which means a build without git, such as the one in Docker, keeps the checked-in state instead of serving an empty page.

Fields are separated by 0x1f and entries by 0x1e. Any printable separator would eventually turn up inside a commit message and split the text in the wrong place.

Timestamps are shown in the zone the commit was made in rather than the server's: the container runs on UTC, where an evening commit would slide to the day before.

01a9c65 Wuestengecko 07:58

Show points and grade per submission and add a results analysis

The review list named students, exams and dates but not the one thing a review is about. Points and grade now stand in the table, and a separate page draws the rest: figures for the selected scope, the grade distribution, the same calculation per course and per exam, and the questions ordered by difficulty.

For choice questions it also names the wrong option picked most often. That says more than a failure rate does — it shows which misconception is behind it, and therefore what the teaching should address.

Two things deliberately count differently. Points and grades come only from submissions whose written questions have been marked; before that a part of the score is missing and every average would come out too low, which is also why the list shows such a submission with points but no grade. The question statistics take every marked answer instead, including those from a submission still waiting: its choice questions were scored the moment it was handed in.

636e981 Wuestengecko 07:51

Montag, 17. August 2026

End the sessions of a blocked account instead of only barring new sign-ins

Blocking set a lockout date, which stops the next sign-in and nothing else. An account that was already signed in kept its cookie, and with it its roles, until that cookie expired — so blocking somebody who was at their desk did nothing at all. Updating the security stamp is what marks the existing sessions as void.

For that mark to be noticed, it has to be looked at. Identity checks it against the database every thirty minutes by default, which would have left the same hole open, only shorter. One minute keeps a block effective without looking the user up on every request.

1f3c8ec Wuestengecko 20:57

Always create the demo organisations instead of reusing what is there

The seeder took over whatever organisations already existed and only filled an empty list. After the setup wizard that list holds exactly one entry — the administrator's own — so every demo account ended up in it and the organisation picker never showed more than a single choice, although it is one of the things the demo data is meant to demonstrate.

The three demo organisations are now ensured by name, one at a time, and an archived one is brought back into service: accounts hanging off an entry that no selection list shows are of no use to anyone.

7ffdebd Wuestengecko 20:48

Give the demo data a running course, real attendance history and applications

Both demo courses lay in the future, so nothing ever showed a course in progress: no attendance history, and the session calendar never opened on the current month. A third course now runs across today, and attendance is recorded only for sessions that have actually taken place — on a future date it was a state the application itself never produces.

Applications were two pending rows on one course. They now cover all three states across all three courses, with reasons on the rejected ones. Who appears where is not arbitrary: approving an application turns it into a participant, so the approved applicants are exactly the ones on the roster, and the pending and rejected ones are exactly those who are not.

Rosters are keyed by the full course name now. With Truppmann Teil 1 and Teil 2 side by side, matching on the start of the name would have hit both, and the exam release picked whichever came first.

a9d369a Wuestengecko 20:41

Make multiple correct answers the rule in the demo question pool

One of seven choice questions had more than one correct answer, which is the wrong way round for fire service material — there it is the normal case. Ten more join it, covering crew roles, protective equipment, the emergency call, the hazards of an incident scene, extinguishing agents and breathing apparatus. Eleven of seventeen now have several correct answers.

The longer questionnaire moves the pass mark from 7.8 to 13.8 points, so the two attempts meant to fail were raised accordingly: with nine questions a handful of mistakes was enough, with nineteen it no longer is.

1fc3723 Wuestengecko 20:26

Fill the demo data with a full course roster and a spread of exam results

A demo course had three participants where fifteen is the usual strength, and two attempts where the review and result views want a range to show. The accounts now run A to Z, both courses get a roster of fifteen with a deliberate overlap, and the attempts come from a table covering everything from flawless to clearly failed.

The three kinds of mistake each show one part of the grading: a wrong answer and an unanswered one both score zero, while ticking the right options plus one too many is what produces partial credit. Without that last kind the result view would never show anything between zero and full marks.

753b60a Wuestengecko 20:22

Changes

381ee76 Wuestengecko 19:42

Samstag, 8. August 2026

Add a test project and stop dropping Include calls in projections

A query that projects the entity it started from silently loses its Include calls. The landing page did exactly that and had been throwing a 500 for every installation that has courses, because the course type came back unloaded. The attendance list had the quieter version of the same bug: the organisation column stayed empty unless another query happened to load it first.

Both queries now name the fields they need. The landing page query moved into TrainingCourseRegistrationService so a test can hold it to that.

The new xUnit project covers what has rules worth keeping: who may review an attempt, how attempts are scored and when a pass mark is met, when a course stops accepting applications, the CSV escaping, the organisation picker, the public content rendering and the translation switch.

Also removed: the leftover Home/Privacy view and action, superseded by /Datenschutz, and an unused query helper.

67c4023 Wuestengecko 21:05

Let administrators switch the translation aid off

Translating an exam sends its questions, answer options and the students' own free-text answers to MyMemory, a service outside this installation. That is a reasonable thing to want and an unreasonable thing to do silently, so it is now a switch under Administration, off unless somebody turns it on. Setting Translation:Enabled in the configuration decides it for good and locks the page.

While the aid is off the language picker disappears from the profile and the translation endpoint rejects requests, so no text leaves the installation.

a4a442d Wuestengecko 21:03

Export lists as CSV and make long lists searchable

Course participants, applications, attendance and exam results can now be downloaded as CSV. The format targets German spreadsheet software: semicolons, UTF-8 with a byte order mark and a leading sep= line, so a double click opens the file without a dialog. Cells starting with =, +, - or @ get a leading apostrophe so nothing is read as a formula.

The user administration gained a search over name, address and organisation, and both the user list and the question bank now page at 25 rows.

943d437 Wuestengecko 21:03

Make exams pass or fail, time-limited and repeatable

An exam can now carry a pass mark and a time limit, and a reviewer can let somebody sit it again — the three things that separated a practice run from a real Prüfung.

Pass mark is a percentage of the points, so it survives questions being added or reweighted. The result screen states bestanden or nicht bestanden next to the threshold; without a mark set, the exam reports points only, as before.

The time limit starts when the attempt does. The page counts down and submits on its own, but the deadline is stored on the attempt and checked on the server, so a manipulated page buys no extra time — a late request closes the attempt with whatever auto-save had captured, timestamped at the deadline rather than now.

Retakes: attempts are numbered per student and exam, and a finished one only reopens after a reviewer grants a retake. The earlier attempt is kept intact, which matters for a Wiederholungsprüfung that has to stay on record.

Courses gained a participant limit and a registration deadline. Pending applications count against the limit so a course cannot be overbooked while nobody has processed them, and the limit is checked again on submit in case somebody took the last place meanwhile. Without a deadline the start date applies, so registration never silently stays open after a course began.

Applicants who said they would hand a proof in later can now upload it, from a link that appears on the course card while something is outstanding.

5212923 Wuestengecko 16:36

Add attendance, registration notifications and managed course types

Attendance per session, mirroring the paper list so the data can be searched and exported later. Course participants and the session's instructors are listed automatically and default to present; anybody else — a guest instructor, someone from another course — can be added to a single session. Whether somebody was there as an instructor is recorded per session, since that changes from evening to evening.

Applicants now hear about their application by email: on receipt, on approval with start date and venue, and on rejection with the reason if one was given. Whoever is responsible for or organises the course gets a note about new applications. A failing mail server is logged and never breaks the action that triggered it — the registration is already saved by then.

Course types moved out of the enum into a list administrators maintain, so a new kind of course no longer needs a code change and a migration. The six former members are carried over as rows and existing courses are remapped by their old numeric value.

f8693f4 Wuestengecko 16:08

Add the public content area and make the app usable on phones

Follows the README: the site now has an area that works without an account, and the requirement list gained the explanations it was missing.

Public pages — news with its own section and detail pages, plus About, FAQ, imprint and privacy notice, all linked from a new footer. Administrators write them under Administration → Inhalte. Text is Markdown with embedded HTML disabled, so a content account cannot put scripts on a public page. News posts can be drafted and dated; the start page shows the three most recent ones.

Requirements can carry an explanation — which certificate counts, where to get it. It stays behind an expandable hint so the list itself remains scannable.

Mobile: checked at 375px, no page scrolls horizontally any more. - The navbar collapses at 992px instead of 576px; the menu had outgrown it - Lists drop secondary columns and stack them under the name instead, which gets user management onto a phone without sideways scrolling - A course's seven-column calendar is hidden below 768px, where it is unreadable; the session list underneath carries the same content - The three sign-in options stack instead of wrapping unevenly - Row actions and menu entries get a 40px minimum height

Also from the README: the application is named Florian Aachtal 1/8, course names in the list open the details as the interface description promises, and there is now a skip link, a favicon reference, a description meta tag and the usual nosniff / DENY / referrer headers.

3fb11c8 Wuestengecko 15:40

Freitag, 7. August 2026

Drop the English UI and add demo data seeding

German only: the application serves one German-speaking fire service, so the second language was upkeep without a reader. The German values moved into the neutral resource file, SharedResources.de.resx and the culture switcher are gone, and the request culture is pinned to "de" — which also fixes dates and numbers, which had been formatting as en-US.

Administration → Demodaten fills the database with seven demo accounts (two instructors, four participants, one awaiting approval), two upcoming courses with requirements and generated sessions, and two pending registrations. It is matched on stable keys, so running it twice adds nothing; the password for the accounts is entered on the page rather than hardcoded.

Administrators can now also set a password for a user directly. Until now the only way in was the invitation link, which is useless before SMTP is configured or when somebody cannot use their link. Handing out a password also settles an open invitation; approval stays a separate decision.

019b13f Wuestengecko 17:20

Advertise courses with a description instead of their requirements

The start page listed every requirement of every course, which crowded the cards with detail nobody needs while browsing — they are asked for during registration anyway, where they actually matter.

Courses now carry two free-text fields instead. The description takes the place of the requirement list on the start page and is clamped to four lines so cards stay even. The special notes — kit to bring, catering, parking — appear on the registration form and the course details page, not on the start page, since they only concern somebody who is actually signing up.

fc1da79 Wuestengecko 17:05

Configure SMTP through setup and an administration page

The setup wizard collected everything an installation needs except the mail server, which meant invitations and magic links silently went to the log until somebody edited appsettings by hand.

Setup now has an optional email section. It is deliberately skippable — an installation without SMTP still works, it just logs its messages — so the settings are also editable afterwards under Administration → E-Mail, with a button that sends a test message to the administrator's own address. Without that page a skipped step would have been a dead end.

Precedence follows the database: configuration wins, otherwise what was entered through the UI applies, stored in App_Data/email-settings.json. SmtpEmailService reads the settings per send, so changes take effect without a restart.

App_Data now has its own Docker volume; the settings would otherwise have been lost with the next rebuild.

fa076b9 Wuestengecko 16:34

Donnerstag, 6. August 2026

Persist the data protection key ring and quieten Npgsql

The key ring lived inside the container, so every rebuild issued a fresh set of keys. That signed everybody out, invalidated open antiforgery tokens and — the one that actually bites — killed every magic link that had been mailed but not opened yet, which with a 20 minute lifespan is easy to hit during a deploy. It now lives in its own volume, configurable through Storage:DataProtectionKeysPath. Verified by issuing a link, recreating the container and redeeming it afterwards.

The remaining "no XML encryptor configured" warning is inherent to Linux without a certificate; the keys are protected by file permissions instead.

Npgsql probes for GSSAPI on every connection and logged a load error on each start because libgssapi-krb5-2 is not in the aspnet image. Installing it removes the noise; password authentication was never affected.

1a32c60 Wuestengecko 23:27

Correct the application name to "Florian Aachtal - Ausbildung"

57d205b Wuestengecko 23:16

Translate the README to German

The audience is a German volunteer fire service, so the documentation follows the user interface language. Corrected several statements that had gone stale: demo users are no longer seeded, appsettings.json deliberately ships without a connection string, and the project layout still listed the removed NavView controller.

43a5d1c Wuestengecko 23:13

Fix three coherence issues and drop vestigial code

Found while reviewing the state after the recent round of changes.

Fixes: - The setup page carries a language switcher, but the setup middleware sent its form target to /Setup, so switching silently did nothing. /Culture now passes through. - The organisation picker offers an archived organisation to the member still linked to it, while validation rejected archived ones — saving an unchanged profile failed. Validation now accepts the id the user already has. - Setup stored the connection string before applying migrations, so a failure left the installation pointing at an unusable database with no way back to the form. The schema is now created against the entered details first.

Removals: - NavViewService and NavViewController implemented a teacher/student view switch that no longer exists: CanSwitchView returned a constant false, the view mode was never read from its cookie, and the mode parameter was ignored. The three helpers the landing page used duplicated NavMenuService, which it now uses directly. - 96 unused resource strings, mostly from the removed groups feature, plus the ones orphaned by splitting registration into two steps. - Three unused service members and the empty Views/Groups directory.

11e971e Wuestengecko 23:13

Name the application Florian Aachtal Ausbildung

"FireExam" stays as the internal working title for the repository and the .NET project, but users see the name the training group is known by, with "Ausbildergemeinschaft Feuerwehren Aachtal – Lehrgänge im Aachtal" as a subtitle under the landing page headline.

3e46e00 Wuestengecko 23:01

Add first-run setup, two-step registration and organisations

Three changes that all reach into the same files (user model, registration controller, resources), so they land together rather than as an invented sequence of commits that would not build on their own.

Setup wizard: a fresh installation redirects everything to /Setup until a database is known and an administrator exists, and closes the page afterwards. Docker Compose supplies the connection string, so setup only asks for the administrator there; a bare installation also asks for host, port, database and credentials, tests the connection and stores it in App_Data/setup.json. Demo accounts are gone — nothing ships with a known login any more.

Two-step course registration: applicants first identify themselves (create an account, confirm one with a password, or request a magic link that leads back to the form), and only then confirm the requirements. Nothing typed is lost while fetching an email. Applicants are signed in while their account still awaits approval, otherwise they could not finish what they started; the regular sign-in page keeps refusing unapproved accounts.

User details: first and last name are stored separately, FullName became a computed display property, and every account carries a date of birth.

Organisations: the free-text "fire department" became a list administrators maintain, since applicants also come from rescue services and the THW. Forms offer the list plus "other" with free text, and the admin page surfaces the free-text entries as candidates for the list.

c5bb1e6 Wuestengecko 23:01

Add passwordless sign-in via magic link

Participants can request a one-time sign-in link by email instead of typing a password. The form sits below the password form on the sign-in page.

- dedicated Identity token provider with a 20 minute lifespan, separate from the day-long password reset tokens - redeeming a link rotates the security stamp, making each link single-use - the confirmation page is identical whether or not an account exists, so it cannot be used to probe for addresses; accounts awaiting approval or an invitation never receive a link

Magic links require working SMTP. Without it SmtpEmailService writes the message body, including the link, to the application log.

908e3b5 Wuestengecko 22:05

Add course requirements and public course registration

Visitors see upcoming courses on the start page and apply for one in a single form: account details, requirement confirmations and proof uploads together.

- courses carry a list of requirements (free text, optionally demanding a proof document); editing a course updates them in place so confirmations and uploads already submitted survive - applying either creates an account or confirms an existing one by password; entering a known address reports the existing account and switches to sign-in instead of failing on a duplicate email - where a proof is demanded, applicants may tick "I will hand it in later" - organisers approve or reject applications on the course details page; approving creates the participant and unlocks the account - requirements are rebuilt from the course on POST, so a manipulated form cannot skip a confirmation or a mandatory upload - proofs are stored outside wwwroot under a generated name and served only through an action that checks course access

c12642d Wuestengecko 22:04

Import application source and move persistence to PostgreSQL

The application source was never versioned — only README.md and .gitignore were tracked. This imports it, already carrying the switch from the previous SQLite/PostgreSQL/MySQL setup to PostgreSQL only:

- single ApplicationDbContext on Npgsql, provider resolution and the MySQL/PostgreSQL context subclasses removed - SQLite and MySQL packages dropped, appsettings variants deleted - the three migration sets replaced by one InitialCreate - docker-compose starts the app together with a PostgreSQL container - DbSeeder no longer migrates legacy Creator/Schueler role names

Because the pre-change content of the modified files was never committed, the import and the PostgreSQL switch cannot be separated into two commits.

71518e7 Wuestengecko 22:04

Mittwoch, 5. August 2026

Initial commit

548fe70 Wuestengecko 22:33