Letzte Änderungen

Woran zuletzt gearbeitet wurde — direkt aus der Versionsverwaltung.

Donnerstag, 24. September 2026

Rebuild the document area as "Files", laid out like Nextcloud

Files is now its own entry in the main menu instead of "Course material" under courses. The page is a file manager: sections on the left (all files, recently added, and shared for instructors), the open folder as a list or tiles with an icon per file type, a menu per row, a details panel on the right, multi-selection that turns the header into an action bar, inline renaming and new folders, and drag-and-drop upload of several files with progress. Name conflicts after an upload are resolved for all files on one page. Access rules are unchanged: instructors see everything, participants only what is shared with them. /Dokumente links keep working.

Course pages use the same file icons through a shared partial, the navigation bar is tightened between 992 and 1199 px so the extra entry fits, and the demo data now brings folders and real files with all three kinds of visibility.

6e57ac6 Wuestengecko 09:08

Catch all mail of the local stack in Mailpit

The compose stack now runs Mailpit next to the application and points SMTP at it, so a local instance never mails real addresses, even with real accounts restored from a backup. Its web interface is on 127.0.0.1:8025.

793a12a Wuestengecko 08:42

Share one fake host environment across the tests

DocumentServiceTests, TestStorage and SmtpEmailServiceTests each carried their own private IWebHostEnvironment stub. They now use the shared TestEnvironment, which takes an optional web root for the callers that need one.

2962b82 Wuestengecko 08:38

Let administrators mark an installation as a test environment

A switch under Settings marks the installation as a test environment, with an optional note. Every page then carries a striped banner that stays in the sticky header, browser tabs show "TEST ·" before the title, and every mail subject starts with "[TEST]".

The flag belongs to the installation, not to its data, so it is not part of a backup: restoring a backup of the real system into the test system must not make it look real.

de1e415 Wuestengecko 08:30

Let every instructor edit all courses, exams, questions and templates

Who created something no longer decides who may change it. Any instructor can now manage every course, edit and archive every exam and question, edit every template and review every submission. Deleting stays with administrators, as do the system message templates.

The per-exam release to other instructors is obsolete and removed, including its table.

e58c840 Wuestengecko 08:05

Dienstag, 22. September 2026

Make the copy to a fire brigade editable like everything else

When a brigade gets a copy of what its members were sent, the part that explains the copy — "this went to the following members of X", the names, the note about attachments — was written into the code. Twice, in fact: once for letters, once for messages, near enough word for word.

It is a system template now, editable under Post → E-mail templates like the reminders. {{Mitglieder}} hands over the finished list, because a template cannot loop, and {{Anlagen}} is empty when nothing is attached, because a template cannot ask either.

Both callers now go through MessageService, which also means the letter branch and the message branch can no longer drift apart.

The template deliberately has no subject: the copy is not a message of its own, it keeps the subject of the one it copies. A test insisted every system template bring one — it now says why this one does not, and a second test holds that exception in place.

45c97fb Wuestengecko 21:17

Let a letter state how the exam went

The certificate comes from the Landratsamt; this is the confirmation the association issues alongside it. The placeholders for it are new: the grade as a figure and by name, passed or not passed, points out of the maximum, and the bonus points with what they were for.

The result is the last published attempt at an exam released for that course. Only a published one — until the results are out the grade is not settled, and a letter naming it would arrive before the announcement. Whoever sat no exam leaves those placeholders empty rather than having a grade invented for them.

{{Zusatzpunktesatz}} hands over a finished sentence instead of its parts. A template has no conditions, and somebody who earned no bonus would otherwise read the stump of a sentence with the number missing — the same approach {{Einteilung}} takes in the session reminder.

The placeholders carry no "written" in their names, only in their descriptions: a practical exam will one day stand beside this one, not in its place.

b83e3c3 Wuestengecko 21:03

Keep the subject out of the confirmation too

The subject was the last thing in that mail that somebody outside had written. Two hundred characters are enough for "your parcel is waiting, see example.com" — cut to eighty it was a poorer channel, but still one.

Now nothing in the confirmation comes from the form. The placeholder is gone with it, not just unused: what does not exist cannot find its way back into the template later, and the truncation it needed goes too.

The local copy of the template still held {{Betreff}} from this afternoon. Unknown placeholders are deliberately left standing so a typo shows up rather than silently emptying, which would have put the literal text in the mail. It was untouched from its default, so it was dropped and written again on start — the server has never had it.

b4d9bb4 Wuestengecko 20:44

Confirm the enquiry without carrying its text back

Somebody writing in can ask for a confirmation. It names the subject and when the message arrived — and deliberately not what they wrote. There is no placeholder for the message text at all.

Otherwise the form would send arbitrary text to an arbitrary address, from this domain, with its signature underneath and clean through SPF and DKIM. What goes out in your name that way eventually costs every message its way into an inbox, invitations and reminders included.

What is left is kept small. The subject is cut to 80 characters in the confirmation: enough for a real one, too little for a message, and nothing in it becomes clickable. A rate limit allows three submissions per ten minutes per address — anyone actually writing does not fill in a form by the second. And an administrator can switch the confirmation off under Settings → E-mail: the checkbox then disappears, and posting the field by hand gets nothing either, because the setting decides and not the form.

The wording is a system template like the reminders, editable under Post → E-mail templates, and it carries the signature — this one does go out.

9e3c82b Wuestengecko 20:36

Answer the person who asked, and spare them our address

A message from the contact form comes in; it does not go out. It was still getting the association's signature and logo underneath, which tells our own mailbox its own address, and a reply went to that same mailbox instead of to whoever had the question.

SendAsync now takes a reply address and whether the footer belongs under the text. Both keep their previous behaviour by default, so the thirteen other callers are untouched — and a test holds that default down, since ordinary mail losing its footer would be the quiet kind of mistake.

An unusable reply address costs the convenient way back, not the message: it is logged and left off rather than throwing.

435aca9 Wuestengecko 20:25

Let the trail here step back from the text

The breadcrumb read like the first line of the page: it drew its colour from --aachtal-graphit, which is very nearly black, and the grey fallback written beside it never applied because the variable is always defined. The current page was set in bold on top of that — emphasising the one station that already stands below it as the heading.

It is beiwerk now: 13px, grey, the current page a shade darker and no longer bold.

Not italic, though the question came up. Measured at phone width: Wikipedia, MDN and Bootstrap's own component all set their breadcrumbs upright and lean on size and colour instead. Over several links italics read worse, and the separators sit badly against slanted text.

7f3e703 Wuestengecko 20:03

Put a contact form in the footer

Somebody with a question about a course usually has no account yet, so the form is open to everyone. The message goes to the address the application already writes from — printing that address on every page would only earn it junk mail.

Against automated submissions there is a field nobody sees: filled in, the message is quietly dropped and the sender gets the same confirmation as anyone else, since telling a machine what gave it away only helps it. No picture puzzle — that stops people who see badly and stops very little else.

Without a configured mailbox the form says so instead of accepting the message. Letting it look like it went through would leave somebody waiting for an answer that was never going anywhere.

19af28b Wuestengecko 20:01

Describe a row menu instead of writing it out four times

The ⋮ menu is the same in every list: the same button, the same icon, the same dropdown, the same classes on every entry. What differs is which entries appear. All four partials spelled out the whole thing, so every entry repeated the anti-forgery token, the hidden id and the decision of whether it is a link or a form.

Each one now says what it offers — RowAction.Link, RowAction.Post with its question, RowAction.Separator — and _RowActions renders it. The separators are worked out rather than placed: a view sets one where it belongs without knowing whether the entries behind it exist for this user, and a rule drops it when nothing follows. Nine tests hold that down, including the case where nothing is left at all and the button itself must not appear.

_RowDeleteForm from earlier today is gone, having been absorbed. So is QuestionBank's copyConfirm, which was read into a variable that no form had used for some time — the rewrite made it visible.

3266c89 Wuestengecko 19:52

Separate deciding on an application from making one

Approving and rejecting an application, and looking at the proofs that came with it, sat in TrainingCoursesController among the pages that manage a course. They belong to the application, not to the course.

They do not belong in TrainingCourseRegistrationController either: that one is the applicant's side, mostly open so a visitor can apply at all. Putting the decisions there would run open and restricted paths side by side in one file, which is the wrong place to be casual about it. RegistrationApprovalController is the teacher's side, and every action in it is behind the role.

Four redirects said nameof(Details), which the new controller has none of — the compiler caught all four. They name TrainingCourses now.

Addresses unchanged: /TrainingCourses/ApproveRegistration answers 405 to a GET and /TrainingCourses/DownloadProof redirects to sign-in, both from the running container.

TrainingCoursesController is down to 987 lines, from 1434 this morning.

bd00953 Wuestengecko 19:47

Let one partial hold the delete in a row menu

Four row-action partials each spelled out the same form: the same confirmation, the same hidden id, the same red entry at the foot of the menu. The question bank's carried the list state as well, so a deletion returns to the filtered page rather than the first unfiltered one — which is exactly the kind of addition that reaches one copy and not the other three.

_RowDeleteForm takes the id, the question to ask, and whatever else has to travel with it.

35871cb Wuestengecko 19:45

Give the attendance list a controller of its own

Attendance hangs on the evening, not on the course: it is kept on one night, not managed over a term. It now has its own controller and its own view folder, which takes another 198 lines out of TrainingCoursesController.

Its access check goes through CreatorContentAccessService like everyone else's, so the new controller states no rule of its own.

The three ways back out of that page pointed at actions by name only — "Details", "EditSession" — which in the new controller would have led nowhere. They name TrainingCourses now. The addresses themselves stay put: /TrainingCourses/Attendance/1 answers with the sign-in redirect and /TrainingCourses/AddAttendee with 405, both from the running container.

TrainingCoursesController is down from 1434 lines to 1112.

f449f39 Wuestengecko 19:41

Move the three course exports into a controller of their own

TrainingCoursesController ran to 1434 lines across five subjects. The exports are the cleanest cut: three actions that display nothing and hand back a file, with no views behind them.

They no longer reach for FindAccessibleCourseAsync and its Includes — they only ever needed the access check and the name that goes on the file, and they query their rows separately anyway.

The addresses stay: an explicit route keeps them under /TrainingCourses, so bookmarks still work. Checked against the running container — /TrainingCourses/ExportParticipants/1 answers with the sign-in redirect, not a 404.

The README's directory listing still named Admin and DemoData, which both went this afternoon, and had never heard of half the controllers. It now lists what is there, and the demo data tool with it.

786f68c Wuestengecko 19:38

Say the same check and the same rule only once

Grading and publishing both opened with the same twenty lines: load the attempt with the same four Includes, give up if it is not there, refuse if its exam is archived. Kept twice, the next addition lands in one of them — and the one left behind is the one that lets something through on an archived exam. Both now ask FindModifiableAttemptAsync, which either hands back the attempt or the answer that goes out instead.

The two upcoming-sessions queries each carried the rule for what still lies ahead: later than today, or today and not yet over. That rule has to mean one thing, so it moved into NochBevorstehend. What the two do with the rows stays where it is — a duty carries the role it was assigned in, an own evening does not.

Left alone: the letterhead measurements, copied into both the edit form and the print job. They look alike, but PdfLetterhead is deliberately init-only, and a shared interface would have to give that up to save thirteen assignments.

3fec5ea Wuestengecko 19:35

Build the demo data as a backup instead of shipping the code

Two thousand lines of demo accounts, courses, questions and submissions sat in Services/ and were compiled into every deployment, to be run from a page in the settings — fixtures kept like production code, in the one place they are never wanted.

They now live in DemoData/, a tool of their own. It fills an empty database, hands it to the backup export the application already has, and writes a zip. That file goes back in through the restore everyone already uses, so the application itself no longer knows anything about demo data: the seeder, its controller, its view, its view model, its menu entry and its twenty-two resource strings are gone.

scripts/demodaten.sh does the whole round: it creates a throwaway database beside the real one, runs the tool, and drops it again on the way out, whatever happens in between.

Generated rather than checked in, because the data is relative to today — a course runs from today minus 28 to today plus 28 days. A frozen file would be all past within months, and the landing page that lists upcoming courses would stand empty. The backup carries no settings either: mailbox credentials have no business in a demo package.

652514e Wuestengecko 19:30

Undo the blank line that had been inserted after every line

Seventeen files carried a blank line after each line of code, splitting method calls and conditions down the middle. Where a blank line had been meant, two stood. ReviewController read 895 lines and held 618; QuestionBank/Create.cshtml 591 for 333. Program.cs, the access services and the exam views were in the same state.

It reverses exactly: inside a run where the pattern holds, every second line is the original, and a blank line that stood there before now stands twice. The undoubling is confined to those runs, so a paragraph somebody meant elsewhere stays a paragraph.

Nothing but blank lines moved — `git diff --ignore-blank-lines` over the whole change comes back empty.

07567e7 Wuestengecko 19:20

Clear the three warnings the build had been carrying

All three were real, not noise.

A session topic was copied from a template with `Topic = templateTopic.Topic` onto a non-nullable navigation property, and the very next line asked whether that topic was null — the code knew it might be and assigned it anyway. It now attaches the topic only when it was loaded; the link itself rides on the id regardless.

The e-mail settings page passed a nullable from-address straight into a localised sentence that names it.

Setup checked for a missing connection string only when it was setting up a database, and silenced the other branch with a `!`. The check now covers both: without a string there is nothing to connect to, and where the database already stands it cannot be missing anyway.

b872699 Wuestengecko 19:14

Give the three background services one clockwork

SessionReminderService, DataRetentionService and AutomaticBackupService each carried the same loop: wait a couple of minutes so the application can come up, then a PeriodicTimer, then a do/while around one pass, plus a SafeWaitAsync that swallows the cancellation a shutdown throws, plus a look at whether setup has even run. Thirty lines, three times, word for word — and a fourth service would have copied them a fourth time.

PeriodicBackgroundService holds the clockwork now. A service says how long to wait, how often to run, where to log, and what one pass does; two of them add whether they may run at all. The setup check moved into the loop, so a pass is only ever called once there is a database to work on — which is what all three did anyway.

What stays with each service is the error handling: a pass that throws would end the service, so each catches its own and writes to the log. That differs per service and says so where it happens.

3f86bb4 Wuestengecko 19:13

Ask one place who may manage a course

The rule — an administrator, or whoever created the course — was written out four times: privately in TrainingCoursesController, again word for word in MessagesController, and twice more spelled into queries in CourseDocumentsController. CreatorContentAccessService already held it for the course list, so four copies waited to drift apart.

All four now ask the service, which gained CanManageCourseAsync next to the FilterManageableCourses it already had — the same pairing exams have. The private helpers stay as short call sites; they no longer state the rule themselves.

Five tests say what the rule allows, including the two cases a reader tends not to think of: nobody signed in, and a course that is not there.

The README pointed at the controller for this rule; it names the service now, and the service table says the rule lives there and nowhere else.

d4137f4 Wuestengecko 19:09

Put the appointments first and say what the menu holds

"When do I have to be where" is the question this application gets opened for most often, so the appointments now stand at the front of the bar, ahead of the courses. Two menu tests pinned the old order and were told the new one.

While there: the appointment list asked the database itself whether the viewer may manage a course, spelling out a rule that CreatorContentAccessService.FilterManageableCourses already holds — the very duplication this rewrite set out to remove. It asks the service now.

The README's navigation chapter described a bar that has not existed for a while: an "administration" menu holding topics and course types, and no sign of the appointments or the post. Diagram and table now say what NavMenuBuilder builds, including which role sees which entry.

60fd148 Wuestengecko 19:01

Write down what changed about reading and getting there

The README now says how a question is set apart from its answers, that the base font size is the browser's to decide, that a row in the appointment list leads straight to the attendance and the session behind it, and that an evening counts as your own when you teach it, not only when you attend it.

018fb4d Wuestengecko 18:57

Let the browser decide how big the writing is

The stylesheet set 14px on a phone and 16px from 768 pixels up: the smaller type on the smaller screen, which is the wrong way round. It also overrode whatever someone had set for themselves, so a reader who asks their phone for larger writing got ours instead.

Measured at 375 pixels wide, against the same paragraph: Wikipedia 16px, Spiegel 16px, the Bootstrap documentation 16px. This application: 14.

Input fields inherit that size, and Safari on iOS zooms in by itself whenever a field is under 16px — tapping the search box on the course list shifted the page and left the reader to pinch back out. At 16px it stays put.

Nothing is pinned any more, so the browser's own size applies. Checked the landing page, the course list, the about page and sign-in at phone width: nothing overflows sideways.

90bfaf1 Wuestengecko 18:56

Take the appointment list straight to the evening it shows

A course evening in the list offered one way out: the public course page. An instructor who wanted to record yesterday's attendance went from there to "manage course", into the course, down its session list, and arrived back at the evening he had just been looking at — five steps for a thing he already had in front of him.

The calendar entry now carries the session itself, not only the course it belongs to, so the row can offer attendance and the session directly. It offers them to whoever may actually manage that course — the same rule TrainingCoursesController applies — and sends them to the management page rather than the public one. Everyone else sees what they saw before.

While here: own sessions now means the evenings someone attends AND the ones where he is down as instructor or machinist. Counting attendance alone left the person who has to drive out there without his own evening under "for you". The calendar already folded the two together, so it shows what it showed; the sidebar gains the evenings it was missing.

The buttons wrap now — three of them in a row ran off the side of a phone.

43412eb Wuestengecko 18:50

Lay the right answer on green and put its mark behind it

The badge stood in front of the answer, which pushed the text in by its own width: the correct line started somewhere else than every line around it, and the eye lost the left edge it reads down.

It now follows the answer, and the line rests on the same faint green the model solution already uses. Every option carries the padding, so only the colour sets the right one apart and nothing shifts sideways.

de731a7 Wuestengecko 18:45

Show a question through one block, wherever it comes from

The exam page carried the same forty lines twice: once for a question still tied to the question bank, once for the copy an archived exam keeps. They render identically, because the snapshot exists to hold exactly what the question held — so every change to how a question looks had to be made twice, and the styling work just now proved the point.

Both branches now hand a QuestionDisplayViewModel to _QuestionBody. What genuinely differs between them — topics, tags, the button to take a question out again — sits above the block and stays in the view.

3f7826b Wuestengecko 18:43

Give the collapsible question card one name

The card that holds a question — a number on the left, the question, a figure on the right, and the whole thing folding open — existed twice under two names: review-question-* while grading, exam-answer-* in the result summary. The stylesheet already gave away that they were one thing, listing both selectors above every shared rule, which meant every change to the card had to be made in each branch or quietly drift apart.

Both are .question-card now, alongside the .question-text the questions themselves carry. The list around them keeps its own name: that one really is only used while grading.

Also dropped .course-notes-text, which no page has asked for.

7a32033 Wuestengecko 18:41

Drop the admin controller nothing reached any more

Questions() and Exams() had become plain redirects to the question bank and the exam list, which meant the two views behind them were never rendered again. The delete buttons on those pages were the only callers of DeleteQuestion and DeleteExam, so both actions had quietly stopped running some time ago — deleting is done in QuestionBankController and ExamsController now, with the owner checks those two carry.

Gone with them: the four view models only those views bound to, and NoExamsFound, which no other page asked for.

df37400 Wuestengecko 18:39

Let a question carry the answers under it

On an exam page the question sat in the same weight, size and colour as the options below it, separated only by half a character of indent. On a phone, where the base font drops to 14px, a card read as one run of equal lines and the eye had to search for where the next question began.

The question now carries a little more weight and size, and the options sit behind a thin rule that says what they belong to. Both are a shared .question-text and .question-options, so the exam page, the archived snapshot and the page a participant writes on all look alike — the writing page already asked for .question-text, which until now no stylesheet defined.

The collapsible question titles in grading and in the result summary move to the same weight, so a question looks the same wherever it appears.

33e3812 Wuestengecko 18:36

Let one pair of strings name the people who teach

ChooseTeachers and ChooseInstructors had grown into the same sentence once the roles were renamed, and two keys that must stay in step are one more chance to change only one of them.

The exam release now draws on the instructor pair, which every other list of teaching staff already used, and the teacher pair is gone.

e978611 Wuestengecko 18:28

Call them Ausbilder and Teilnehmer, not Lehrer and Schüler

The wording came from school and never fitted a fire service course: the people who hold one are Ausbilder, and those who sit in it are Teilnehmer. Both words were already in use elsewhere in the application, so the two older ones read as if they meant something else.

Renamed throughout the German wording — interface strings, comments, the README and the test fixtures. The English identifiers stay as they are: Teacher and Student name the Identity roles and reach into the database, and nothing about the display wording needs them touched.

One hint now reads "Ausbilder für diesen Termin auswählen" — spelling it out as "Ausbilder als Ausbilder" made the sentence say nothing twice.

fb1339e Wuestengecko 18:21

Montag, 21. September 2026

Put the logo above the signature, carried along in the mail

The mark now stands over the footer of every message. It travels as an embedded part that an img points at by its content id, not as a link to an address: mail clients do not fetch remote images on their own, and a logo that only appears after somebody clicks "show images" is not one. The width sits on the element as an attribute too, because Outlook does its arithmetic with those.

Shipped as a PNG converted from the wordmark, although the SVG is the nicer file — no common mail client renders SVG. An administrator can upload their own, drop back to the shipped one, or switch the picture off entirely; an uploaded logo lives in the protected store, since it is never served.

5d28d9b Wuestengecko 14:56

Put a frame and a signature under every mail

Mails went out as bare HTML fragments: whatever the template happened to say, in whatever font the mail client picked. Every message the application sends now gets a frame — font, line height, a readable width — and under it a signature, the way a letterhead carries a foot.

Both are added at the point of sending rather than in the individual texts. Otherwise there would be fifteen places to forget it, and the reminder template would have to know the association's address.

The administrator edits it under Administration → E-Mail, with a preview that uses the same frame the sending does. The shipped wording names the sender and says the message comes from the portal; it deliberately carries no address, because an invented one is worse than none.

ae17b77 Wuestengecko 14:49

Give the dates a page of their own, with everything on it

"Weitere Termine" sat under Lehrgänge, which was the wrong drawer: a meeting is not a course thing. Termine is now its own entry in the bar, and the list behind it answers the whole question instead of a third of it — course evenings, the nights somebody is assigned as instructor or machinist, and the meetings they are invited to, in one order with a badge saying which is which. Being assigned to an evening one also attends shows once, as the assignment, which is the more precise answer.

Creating still only happens for the dates that belong to no course; the others come into being at the course.

b1487da Wuestengecko 14:46

Say who is running the course, on the page and on the list

A participant could see when and where, but not who. The course page now names the instructors and the machinists with their brigade and how many evenings each of them takes, and the session list says who has which evening. Only for the people in the course and the people running it — the names of volunteers do not belong on a page every passer-by opens.

The attendance list knew about instructors but not about machinists, and it said nothing about either: they sat under "further people" like a guest. Both are now on the list by their assignment, each with a badge naming it — also for someone who is a participant as well and would otherwise stand among the others without it showing.

f38eba8 Wuestengecko 14:44

Sonntag, 20. September 2026

Add appointments that belong to no course

A meeting, a walk-through, a work detail: dates that nobody should have to invent a course for. They sit under Lehrgänge → Weitere Termine, show up in somebody's own dates like a course evening, and go through the same reminder service. Only whoever creates one notices the difference.

They keep their own table. A course session has a course, topics, attendance and an assignment, and a meeting has none of that; a course with an empty name would be a trap for every query about them. Who is invited is picked by hand — there is no circle that follows from anything else, and a leadership meeting does not concern everyone who teaches.

The reminder gets wording of its own, a second system template, because the first one speaks of a course that is not there.

cc967be Wuestengecko 20:19

Let a reviewer add bonus points with a reason

Some things do not fit in a question paper: a clean piece of reasoning, a grip during the practical, something nobody thought to ask about. A reviewer can now put points and a sentence on the submission itself, both optional.

Capped at a tenth of the total. Without a limit the bonus stops being an extra and becomes a second exam beside the first, and two submissions with different amounts of it cannot be compared any more. The server draws that line; the form only suggests it.

The bonus counts — in the points, the percentage, the grade, the pass mark, the analysis and the CSV. A supplement that merely sits next to the score would be none. It is also shown on its own in the result, because a total that cannot be recomputed from the answers looks like a mistake.

c291207 Wuestengecko 20:13

Bring the demo data up to what the application does now

The page promised "seven accounts, two teachers, four students" while the seeder was laying down twenty-six. It now says what it does, and it lays down a little more: message templates, so the picker when composing is not empty and the placeholders have something to stand in; a Mitmachen text; and salutations and reminder settings that differ between accounts, so both are visible from a demo run instead of being uniform.

Role assignment in the seeder reads DbSeeder.RolesHeldBy like everything else now.

fe3fbe8 Wuestengecko 20:10

Hand topics, tags and template deletion to the administration

A topic sits on the questions and on the sessions of every course at once: renaming it renames it everywhere, and two instructors naming the same thing differently split the stock. So the topic list now belongs to the administration, as the tag list already did. Both are still used by everyone — the lists are there to pick from when writing a question or planning a session, and a new tag still comes into being at the question.

Deleting a course template goes the same way. Creating and editing it stays with whoever teaches; only the last grab, which takes it away entirely, does not. A template carries its sessions and their topics and is rarely one evening's work.

The role circles changed with it. Public links carry none any more: whether somebody may see them is not a question. The administrator gets a circle of their own instead, a red A, so "only the administration" is something the page says rather than something the absence of a circle implies. And a role now brings its lesser ones along — an instructor holds the participant role too, which the green T was missing on "Meine Prüfungen". That rule lives in one place, DbSeeder.RolesHeldBy, which the role assignment reads as well.

13d53ef Wuestengecko 20:07

Show administrators who else can see each button

An administrator sees everything and therefore cannot tell, looking at a page, what is left of it for anybody else. Every link and every form that points at an action now carries a small circle in their view: a blue S when a student gets there too, a green T for a teacher. No circle means the function belongs to the administration alone.

The answer is read from the [Authorize] attributes through the action descriptors — the place that actually decides it. A hand-kept list beside it would be wrong at the first new controller, and a made-up answer about who sees what is worse than none, so an unknown target stays silent.

No view had to be touched: a tag helper attaches the circles to the anchors and forms that are already there.

80ca050 Wuestengecko 20:00

Say du everywhere, and greet people the way they asked to be greeted

Registration, invitations, the exam pages and three dozen other texts still addressed the reader as Sie. Nobody in a fire brigade does. Every text now says du, and the mails the application sends by itself open with the salutation from the profile — "Lieber Benjamin", not "Hallo Benjamin" — the same line the AnredeName placeholder puts in, now defined in one place.

A test in LocalizationKeyTests fails as soon as a text goes back to Sie. It looks for the capitalised possessive and for a word right before "Sie"; a "Sie" starting a sentence is left alone, because there it stands for a thing and is correct German.

673f68a Wuestengecko 19:56

Put the reminder wording into a template that can be edited

The reminder mail no longer reads out of the resource file. It now comes from "Erinnerung an einen Termin", a template among the others under Post, prefilled with the wording it had before. It can be rewritten, but not deleted, not archived, and it does not turn up in the picker when composing a message — it already has its occasion. Missing the row, the built-in wording applies and the next start puts it back.

Placeholders for this occasion come with it: the lead time, the session as a sentence, its date, time, end, venue and topic, and the duty line that stays empty for someone who only attends.

edcc244 Wuestengecko 19:54

Move Post to the end of the main menu

Correspondence sat between the courses and the exams, where it interrupted the run from a course to its questions. It is the thing one reaches for last, so it now stands last.

9f8ade3 Wuestengecko 19:51

Add a "Mitmachen" page that says how to join in

The footer now carries a fifth content page explaining what a fire brigade and what a single instructor can contribute to the training group. Like the others it is maintained under Administration → Inhalte, but unlike them it starts with a draft instead of a blank: the page exists to explain something, and an empty page in the footer explains nothing. Imprint and privacy notice keep their blank start — an invented legal text is worse than none.

33d3832 Wuestengecko 19:51

Back the installation up on its own, and keep a staggered set

The application now writes a full backup to the server by itself — daily at 2 a.m. out of the box, with the time and the interval in the administrator's hands. The packages are listed on the backup page, ready to download or to restore through the same preview an uploaded package goes through.

Kept are the last 5 days, plus one from each of the last 3 weeks and 2 months. Periods are counted, not files, so a week of downtime drops nothing that has not been replaced yet. What ran last is read from the folder itself rather than a note beside it: a failed run stays due instead of counting as done.

A backup on the same server is no backup against losing the server, which is what the download button and the README are for.

5f80e87 Wuestengecko 19:49

Let everyone choose when a session reminder reaches them

Participants and instructors now pick in their profile how long before a training session an email should arrive: a day before, the morning of the day (7 a.m.), three, two or one hour before — any combination, or none at all. New accounts start with the morning of the day; accounts that existed before this setting get nothing, because nobody asked them.

A background service looks every five minutes instead of arming a timer per session: sessions move, participants join, settings change, and an armed timer would know none of it. What went out is recorded in SessionReminders, which is the only thing keeping the same mail from going out again on the next pass.

e1516d2 Wuestengecko 19:44

Keep the question number in the link to the question

Clicking a question's text opened /QuestionBank/Edit?TagId=84 - the filter was there, the question was not. asp-all-route-data and the single asp-route-id fill the same property of the tag helper: one is the whole dictionary, the others are entries in it. Written side by side, the dictionary was assigned last and took the number with it. Nothing complains about this; the page just opens empty.

The number goes into the dictionary now, and the row link uses the same one. A test walks the tags of every view and fails when both spellings meet on one element - it is the only way this shows up short of clicking.

f7f6dfa Wuestengecko 16:46

Raise the database from migrations again, not from the model

Since the installation at feuerwehren-aachtal.de carries real questions and accounts, throwing the database away on every model change stopped being an option. The schema comes from Migrations/ again and is applied at startup, so an existing database grows with the model.

The initial migration is generated from today's model, not recovered from the old chain - that chain ended three model changes ago. It was held against the live schema before anything was written: applied to a throwaway database, it produces the same 352 columns, 133 indexes and 136 constraints as production, the migrations table itself being the only difference.

Both existing databases predate migrations and have no history table, so a start would have tried to create all fifty tables and failed on the ones already there. They were baselined by hand - the initial migration recorded as applied without running it. README and the server notes say how, for the case a backup from before comes back.

MigrationsTests keeps the model and the migrations together: it fails when a model change has no migration, and when a migration has no Down.

f74769f Wuestengecko 16:39

Come back to the list the way it was left

Saving an edited question went to the question list without saying which one: search, topic and tag filter, page and the archive switch were gone, and the filter had to be set again after every change. The same held for archiving, restoring, copying and deleting a question from that list.

The list state now travels along - as route values on the way into the question, as hidden fields through the form, and back out on the redirect. It sits under Filter. in the edit form because the question itself already has a field called Type, which would otherwise take the filter's place.

The mapping from filter to route values lived in three copies, one in each view and one in the controller. It is one method on the filter now and has tests. Its argument says whether position travels too: the way back wants page and archive switch, the switch between current stock and archive sets both itself and would only be fighting an old page number.

ListAsync writes the clamped page back into the filter, so what the view hands on is what the page shows.

223b495 Wuestengecko 16:28

Say what the master data import creates, and count it

The confirm button on the master data preview borrowed the wording from the user import and asked to create accounts. It also looked the text up without an argument, so the placeholder stayed on the page as {0}.

A new test reads every Localizer[...] out of the views and holds it against the resource file: the key has to exist, and a text with placeholders has to be given values wherever it is rendered directly. Where .Value hands the text to the page's javascript as a format template, {0} belongs there and stays.

It found two more pages naming a key that was never written, so the empty lists in the administration read NoQuestionsFound and NoExamsFound. A third test guards against a name given twice, which silently drops the second one - UnsavedChanges had a dead duplicate.

eae61ee Wuestengecko 16:20

Montag, 14. September 2026

Address the organisation by its name, not by an empty gap

The copy quoted the message instead of being one: an explanatory header, a list of names, and underneath the text with its placeholders left standing. It read like a report about a letter rather than the letter.

It is the letter now. The placeholders are filled as they are for anyone else — the course, its dates, the sender all resolve the same way — and the organisation takes the recipient's place, its name standing where a first name would. "Hallo {{AnredeName}}" becomes "Hallo Feuerwehr Aach" instead of reaching for someone who is not there. What is a person and not a brigade — birthday, address, rank — stays empty, as it should.

Who was written to now stands underneath, where a note belongs.

c8d9b10 Wuestengecko 23:22

Let a teacher carry a rank and a function, and sign with them

A letter went out under a bare name. Whoever teaches and signs now keeps a rank and a function in their profile, and four placeholders put them into letters and mail — the sender's for the line under the signature, the recipient's for the rare letter that goes to another teacher.

The rank is a list, not a field: ranks are what they are, and no brigade invents its own. Both forms stand in it separately, because a rank is held the way the person holds it, and a list offering only one takes that choice away. What comes back from the form is checked against the list — standing in that field does not make a word a rank.

Neither belongs to a participant, so neither is shown to one; the role decides, not the form that was posted back.

1b5dd7b Wuestengecko 23:12

Let an organisation have an address and get a copy

An organisation was a name and nothing else, so there was no way to keep a brigade's own office in the loop. It now carries a collecting address, and both ways of writing — mail and letters — offer to send a copy there.

One copy per organisation, not per member: eight people in one brigade are one mail to the brigade, not eight. It names who was written to, carries their letters as attachments, and quotes the text as it was written, with the placeholders left standing.

That last part needed a recipient to be able to hold a text of its own. A message personalises its body for every recipient, and an organisation is not a person — "Sehr geehrte Frau {{Nachname}}" would have gone nowhere.

6823a9e Wuestengecko 22:59

Let a letter go to anyone, not only to a course

A letter was tied to a course by the schema: to write one you first had to have a course in hand, and the document could not exist without it. The course is now optional on a generated document, so an administrator can pick any accounts at all — the placeholders around the course simply stay empty. Teachers keep writing to their own participants; writing to everyone in the house is not covered by holding a course.

Two places give such letters a home. The recipient finds them under "Meine Schreiben" in the profile, which a course-less letter would otherwise never reach. The sender finds everything in "Verschickte Briefe" under Post — across all recipients, with or without a course.

Being on the letter pages also lit up the courses menu, left over from when that was the only way in.

ab94e02 Wuestengecko 22:40

Call mail mail and letters letters, and open a door to them

The area said "Nachrichten" for what is e-mail and "Dokumentvorlagen" for what becomes a letter on letterhead, so the button that produced those letters read "Dokumente erzeugen" and was not what anyone went looking for. Mail is now E-Mails and E-Mail-Vorlagen, paper is Briefvorlagen beside the Briefpapier it prints on, and the button says Briefe erzeugen.

The way there ran through one participant's page or a course's: the letter was reachable only once you had the course in hand. It now stands in the Post menu, and asks for the course itself — the intention first, the paperwork after.

A participant's own section was called "Unterlagen", the same word the courses menu now uses for the filing cabinet. It lists letters and proofs, and says so.

e22659b Wuestengecko 22:31

Move the filing cabinet in with the courses, the search to the edge

"Dokumente" stood as the one flat entry behind four dropdowns and read as an afterthought — and next to "Dokumentenvorlagen" it was twice almost the same name for two unrelated things. It now sits under the courses as "Unterlagen", where the material it holds belongs, and "Schriftverkehr" goes by the plainer "Post".

The search moves to the far right of the bar. Opening it there would have pushed past the edge, so the form runs the other way round: the magnifier stays put at the margin and the field grows to the left into free space.

4dc2dda Wuestengecko 22:26

Give the magnifier the colour of the bar it stands on

It took Bootstrap's default navbar colour and came out dark on the red gradient. The white the bar uses is set on the group beside it, and only from 992px up — below that the menu unfolds onto white, where a white magnifier would be no magnifier at all. The search now shares that rule, so it is white where the others are and dark where they are.

10ead97 Wuestengecko 22:22

Let a line break between two paragraphs be a line break

Writing <br> between two blocks did nothing at all, however many were added: a <br> carries no text, and a stretch carrying no text was taken for the source code breathing between tags and dropped. A paragraph made only of them fared little better — it collapsed to a single empty line, because a text block holding nothing but breaks does not keep them.

Both now become spacing, one item per line so a long run breaks across pages instead of standing taller than the sheet. Whitespace between tags is still nothing, which is what it was meant to be.

e615337 Wuestengecko 22:12

Let a topic find its questions too

Tags already reached the question bank; topics do the same job and were still left out. Whoever files a question under a topic looks for it there again.

92ee7b9 Wuestengecko 22:08

Make the search find things, and put it behind a magnifier

Searching for "lehrgang" found nothing, and rightly so by the old rules: no course carries the word in its name — it sits in the course type, which was never searched. PostgreSQL compares letter by letter besides, so "truppführer" missed "Truppführerlehrgang" as well. Both sides are folded now, and a course is found through its type.

Reach grew where it was asked for: a question through its tags, a person through both names at once, and three areas that were not searched at all — documents, news and the standing pages. Documents follow the same grants as the folder view, so search cannot become a way around them; drafts stay out of the news results.

The field itself is a magnifier now. It opens on click, closes on Escape or on leaving it empty, and a second click searches. The width sits on the form rather than on the input: as a flex item the form goes by its basis, and letting the input decide meant each waited on the other.

ad7595d Wuestengecko 22:04

Let <small> mean something in a letter

The tag fell through to the default branch and was set like ordinary text, so marking a footnote in a template did nothing at all.

Scaling it needed a base size, and the one the heading scale had been using was the constant 11 rather than the size the letterhead carries. A heading on a 9 pt letterhead therefore came out as large as on a 13 pt one. The base now travels with the text run, and <small> multiplies it — so small stays small inside a heading instead of falling back to body size.

8aa7d2f Wuestengecko 21:51

Sonntag, 13. September 2026

Leave the address to the letter, keep only the marks

The address field set the address itself and thereby decided where it stands. That is the template author's business: they write the address into the letter like everything else. So the field goes, and with it three columns, a fit check and the settings that fed it.

What remains is what was actually asked of it: the preview lays the window cutout and the two fold marks over the first sheet, and whoever wrote the letter sees whether their address falls inside. A checkbox beside the preview button decides whether it is drawn.

266bd1f Wuestengecko 18:44

Drop the return address line, put the window on a switch

The line above the recipient turned out not to be wanted: a letterhead already carries the sender, and typing it a second time above the address only repeated what the paper says. What it held open stays — the zone above the address is what puts the address where the window expects it.

In its place the preview asks instead of deciding: a checkbox beside the preview button says whether the window outline is drawn. It belongs to the form, not to the template — it is a question about this one preview.

b2908cf Wuestengecko 18:37

Let the return address be written with placeholders

The line above the recipient was a plain field, so the sender's own address had to be typed into every template and corrected in all of them after a move. It now runs through the same resolver as the letter body, and a new {{Absenderanschrift}} places what stands in the sender's profile, as one line for the window.

Along the way: an address block is joined with <br />, not with newlines, so the renderer split on the wrong thing and would have set the recipient as a single line with the markup showing. It now breaks on either.

3cb8540 Wuestengecko 18:30

Carry the address field through the master data list

Exporting document templates and reading them back dropped everything the address field is made of. Five columns now travel with them, named the way the rest of the file names things.

A list written before the address field existed still reads: a missing column falls back to what a new template would have brought along anyway.

81b4bec Wuestengecko 18:21

Let a letter carry an address field for a window envelope

A document template can now set an address field: a small return address line above the recipient's address, placed from the sheet edge rather than from the text margin — a window knows nothing of margins.

The preview draws the envelope window over the first sheet and writes underneath whether the field sits inside it, and by how much it does not. That marking belongs to the preview alone; a letter that goes to the post carries neither the outline nor the note.

2d0dbc6 Wuestengecko 18:13

Assign several participants at once, with a search

A course is filled with a year group, not one person at a time. The single select becomes the multi-select: search, chips for what is picked, and one submit for all of them.

The action takes an array now. Somebody already assigned is skipped rather than aborting the whole thing - with a multiple selection one stray tick would otherwise cost the entire assignment - and the message says how many actually went in. It still refuses an empty selection and anybody who is not an approved student.

Two things found on the way: the page had no scripts section, so the picker would never have updated its own label, and the course description was still rendered as plain text while the notes right below it had been Markdown since the earlier change.

7130b93 Wuestengecko 15:33

Let an invited account be put on an attendance list

The attendance list refused anybody whose invitation was still open. In the installation here that left exactly one selectable person out of thirteen, because twelve accounts had been invited and had not yet set a password.

A pending invitation says nothing about whether somebody stood in the room on a training evening, and the list is the digital counterpart of the paper one. Course participants and instructors never had that condition; the commit that introduced this one argues for the opposite - that a guest or somebody from another course can be added to a single session.

68cd8e7 Wuestengecko 15:23

Use the same picker everywhere something is picked from a list

Five checkbox lists become the multi-select: instructors and machinists of a session, the categories of a question, and the two release lists of an exam. All five were the same thing in a different shape - a grid or column of checkboxes that grows with the data and says nothing about what is ticked once it is longer than the screen. Chips are on in all of them, and the template editor gets them too.

Every page carrying the component now loads multi-select.js. Exams Details had no scripts section at all, which is how a picker there would have silently stopped updating its own label.

Two lists are deliberately left alone: the recipient list of a message and the participant list of a document run already have a search, select all and none, and show mail address and organisation. Folding them into a dropdown would take away more than it gives. The bulk-action tables in the question browser and the review list are not pickers at all.

510664c Wuestengecko 15:13

Show the chosen topics as chips and let the list be searched

Two additions to the shared multi-select, both driven by the session page: above three entries the button only carries their number, so what was actually picked was invisible, and fifty topics are not a list one scrolls through looking for one name.

The chips sit under the button. Every chip is rendered server-side and merely hidden while unpicked, so the question of which text colour is readable on which background stays where the answer is known and the script only toggles.

Both are opt-in where they would hurt: chips off by default, because Topics/Index puts a select in every table cell and a row of chips per row would wreck the table; the search appears from eight entries, since below that looking is faster than typing.

EditSession never loaded multi-select.js - the label it got in the last commit would have kept whatever it said on page load. Fixed here.

446e5e2 Wuestengecko 15:10

Show session topics as coloured chips, and pick them the way templates do

Two places where fifty topics were laid out as if there were five.

The editor of a session listed every topic as a checkbox in a three column grid - seventeen rows of them, and without the colours that identify a topic everywhere else. It now uses the same collapsed multi-select the template editor uses, built from the same view model. The label there pointed at a field that is not on the form, so it is a plain label now.

The session list showed the free-text Topic column, which is only the comma-joined copy of the assigned topics that SyncTopicText maintains - four topics made one long run of text. The column now renders the topics themselves through _ColorBadge, the same chip the question bank uses, and falls back to the free text for old sessions that were given one by hand.

218a632 Wuestengecko 15:05

Let course descriptions, notes and requirements be Markdown

The four free-text fields of a course now render through ContentService instead of going out as plain text: description, notes, each requirement and its explanation, at all nine places they appear.

They get their own pipeline. A page author writes Markdown and knows a blank line makes a paragraph; somebody typing into a textarea presses Enter because they want a new line. Without UseSoftlineBreakAsHardlineBreak every bring-this list written without blank lines would collapse into running text - and that is exactly how the descriptions already in the database are written.

ToFieldInlineHtml drops the wrapper of a single paragraph, because a block element has no business inside the label of a checkbox; several paragraphs keep their structure, since without it the text would run into one line.

The white-space: pre-line rules had to go with it. Markdown brings its own breaks now, and both together rendered every line twice as far apart - visible in the notes card before it was removed.

2cda491 Wuestengecko 14:57

Expire results, attendance and dormant participant accounts after five years

Three more rules in the retention service, in an order that matters: submissions and attendance expire first, and only then can the account that made them go, because the guard refuses an account that still holds results.

Account deletion is deliberately narrow, because deleting a user reaches much further than the account: TrainingCourses, Exams, Questions and TrainingCourseTemplates all cascade off CreatorId, and a deleted course takes its sessions, the attendance and everyone else's participation with it. Removing a dormant instructor would destroy other people's proof of training. So only accounts holding the student role and neither of the other two are considered, anyone still on an attendance list or in a course inside its retention stays, and an account whose age cannot be determined - no login, no creation date - stays too.

The content check the user administration already did moves into UserContentGuard so both paths ask the same question; it gains the templates it was missing. Profile picture, signature and participant documents are deleted with the account - their rows cascade, the files would not.

a9ab6c5 Wuestengecko 14:43

Delete applications and their proofs once a course has ended

The privacy notice names retention periods and nothing in the application knew one, so every promised deletion waited for somebody to remember it. DataRetentionService runs two minutes after start and then daily: it takes the applications of courses whose EndDate has passed, deletes the uploaded proof files and removes the rows.

Files go before rows. The other order would leave a file with no reference behind after an interrupted run - exactly the personal document meant to disappear, and with nothing left to say whose it is. This way the worst case is a proof that no longer opens, and the next run clears the row.

That somebody took part is untouched: it lives in TrainingCourseParticipant, the attendance and the results, none of which key off the application.

Retention:GraceDaysAfterCourseEnd allows a grace period for leadership that still needs proofs for the certificate; it defaults to 0 because that is what the privacy notice promises.

d5f94eb Wuestengecko 14:32

Default new accounts to no Gravatar

Gravatar was on for every new account, so the browser of anyone seeing a profile picture fetched it from Automattic in the US, carrying an MD5 of the pictured person's address. The hash protects little: a guessed address can be hashed and compared. Nobody creating an account to attend a course expects that, so it is now opt-in, and the hint in the profile says where the data goes. Existing accounts keep their setting.

The five creation sites set the flag explicitly, which would have overridden the model default - removed, so the default governs.

0afc62c Wuestengecko 14:09

Call a participant's exams what the page calls them

The entry read "Sit exam" and opened a page headed "My exams", which lists the finished ones too — it is an overview, not a button. One key now carries the name through the entry, the page and the breadcrumb, so they cannot drift apart again.

A menu that collapses to a single link takes that entry's name rather than the name of the kind above it. For a participant, whose exams menu holds one thing, the bar now says "My exams" instead of "Exams", and the page it opens says the same.

36f40c1 Wuestengecko 13:32

Name each entry after what you do behind it

The entries said what they held, not what happens there, and page headings often said something else again. Now the working list of each area carries a verb and the page it opens carries the same words, so what you clicked and what you land on read alike.

Courses: "Manage courses" for whoever holds any, "All courses" for everyone else — the promise follows the role. Questions: "Manage questions". Exams, where none of the four read well: "Manage exams", "Grade submissions", "Analyse results", "Sit exam".

The master data below the divider keeps its nouns; those are lists of things, not places of work. "All exams" stays where it means all — the empty choice in a filter — and the search results keep plain plurals as group headings.

62641d9 Wuestengecko 13:29

Let the New course button hang on the role, not on the stock

Merging the two course lists left "New course" behind a flag that meant "already holds at least one course". A newly appointed teacher, or a fresh installation, therefore had no way to create a first one — and since the list merge retired the "Manage courses" menu entry, no way in at all. The flag now asks the role, which is the question it was always meant to ask.

e9396b6 Wuestengecko 13:24

Show which image a letterhead carries, in the form and in the list

The form said an image was set and offered to remove it, but never showed which one — the stored name is generated, so there was nothing to recognise it by. A new Image action serves the header or footer image of a letterhead, and the form puts it above the remove box, on a chequer so a transparent band shows its edges. The list swaps the words "header image · footer image" for the strips themselves. The stored name rides along as a cache buster, so a replaced image shows up at once.

d30f7bb Wuestengecko 13:22

Let a footer image stand without text beside it

The footer's primary layer was the text column, and it decides how tall the footer gets. With neither footer text nor page numbers that column measures as empty, the whole footer collapsed to nothing, and it took the image with it — a letterhead that is only a band printed nothing at all. Where there is nothing else to put there, the band is now the primary layer and carries its own height.

bf027a9 Wuestengecko 13:22

Set the course filter for the column it actually sits in

The merged list lives in the narrower of the two columns, where four fields side by side squeezed the period box down to "Begi…". The search takes a row of its own now, and the two pickers below it have room. The placeholder came from the question bank and promised to search solutions and keywords; it names what a course list searches.

a88260f Wuestengecko 13:11

Bring the two course lists together into one

A course is a course. The public listing and the management list showed the same courses under two addresses, and anyone holding courses had to decide first in which role they were looking for one.

/Lehrgaenge is now the single list: it carries what is advertised plus everything the viewer holds, with the filter bar and archive switch the management list used to have. A card the viewer may manage says so and leads straight to the workbench; the count of applications waiting sits on the card. The management console itself stays where it is — that is a different page, not a second list.

The rule for which courses somebody may manage moved out of the controller into CreatorContentAccessService, so both sides read the same one. /TrainingCourses and its archive redirect to the list.

35818b8 Wuestengecko 13:08

One search field for every area

Search boxes existed in four lists, each its own. Someone looking for a name had to know first whether that person is held as a user, as a participant or as a pending application, and try three lists otherwise.

A field now sits in the bar between the working areas and the profile. SearchService puts the four queries behind one call and returns them grouped by kind, each group carrying a link into the list where the rest of the hits are. It searches only what the searcher may already see: teachers their own courses and exams, administrators everything, and everyone else the courses that are advertised.

fabee71 Wuestengecko 13:05

Build the bar from what the role gives, and drop a dead page

One entry is not a menu. The layout already knew that for a student's exams, but as a hand-written condition for that one case. The bar is now assembled as data — NavMenuBuilder decides what a role may see, and the partial that renders it collapses any menu down to a direct link when a single entry survives, and leaves out one where nothing does.

That retires three flags whose only job was to ask whether a menu would have anything in it, and it takes about a hundred lines of nested conditions out of the layout.

StudentExams.MyResults went too: no view linked to it, and it only redirected to the list that already shows what it promised.

058c5b4 Wuestengecko 13:04

Turn Administration into Settings, named groups and admins only

Fourteen entries from four different worlds hung under one point, held apart by three dividers that said nothing. Now that the master data lives beside what it feeds, nothing remains there that a teacher needs — so the entry is for administrators alone, and it is called what it is.

Three headings carry the grouping: people, operations, data. Demo data moves down beside the backup, which is the same kind of thing.

f5902be Wuestengecko 13:02

Let master data live beside what it feeds

Topics and tags belong to questions, course templates and course kinds to courses, and the letterhead to the correspondence printed on it — yet all five sat under Administration. Someone writing a question who found a topic missing had to leave the menu they were working in.

Each now hangs under the entry that uses it, and the entry lights up when you are on one of them. Only the controllers moved in the menu; their routes are untouched.

80ddfff Wuestengecko 13:02

Make the archive a filter in the list, not a place of its own

Courses, exams and questions each kept their archive as a separate menu entry with a list of its own, while the template lists had long done it the other way: a link above the list that switches the same list over. Two patterns for one thing, and the second is the better one — the archive turns up where you are already looking.

The three lists now carry that switch, taking the filter you set with you in both directions. The old /Archive addresses redirect onto the list with the switch on, so bookmarks keep working, and the Archiv menu entry is gone.

17b5dc6 Wuestengecko 13:01

Let the bar say where you are, and show the way you came

The navigation never marked the page you were on — after every click it looked exactly as before. Now the entry the current page belongs to carries a bar beneath it, and open menus mark the line you came in through. NavLocation keeps the archive lists apart from the running ones, which live in the same controllers but belong under another entry.

Four levels down, every page had helped itself to a back link of its own that jumped all the way to the top. A breadcrumb fed from ViewData now shows the whole path and lets each station be picked up again; the scattered back links it replaces are gone.

b7fa49b Wuestengecko 12:59

Let a test run send every mail to the sender instead of the recipient

6058689 Wuestengecko 12:48

Samstag, 12. September 2026

Keep a hidden bar hidden, even where a display class says otherwise

30e0137 Wuestengecko 19:42

Give the kinds of course colours of their own, a step deeper than the topics

7582169 Wuestengecko 19:37

Let a tag and a kind of course carry a colour too

3ce9a95 Wuestengecko 19:33

Offer more colours for a topic, and let contrast decide what is written on them

cf358b3 Wuestengecko 19:28

Let the settings beside the database travel with it, and a broken mail server not lock anyone out

b3ff41c Wuestengecko 19:22

Let a fresh installation start from a backup instead of a throwaway account

8fc0771 Wuestengecko 19:12

Let the message and document templates travel as a list too

0ef9b51 Wuestengecko 19:03

Let every list of names be saved the same way, in one go

41ea552 Wuestengecko 18:59

Let the whole list of topics be saved in one go, and a colour be picked with one click

b4006e5 Wuestengecko 18:50

Carry the log of changes up to the rolled-out commit

7f61f9e Wuestengecko 18:22

Write down which way the data goes out, and which way it comes back

3d6f38f Wuestengecko 18:13

Give a topic a colour and the courses it belongs to, and let it be picked from a list

645a034 Wuestengecko 18:13

Give what goes out a menu of its own, beside the courses

40e17a3 Wuestengecko 18:00

Let a list of master data be added to what is already there

76a5796 Wuestengecko 18:00

Let the whole installation leave in one file and come back the same

8e09a72 Wuestengecko 17:29

Read from the model what a backup would have to contain

36f6f2f Wuestengecko 17:21

Let a tag belong to everyone, the way a topic already does

d7ff893 Wuestengecko 17:18

Let the database be raised from the model, not from a chain of migrations

3e66d8e Wuestengecko 17:01

Donnerstag, 10. September 2026

Let a letter written between the tags reach the paper

A document template is written the way a letter is written: a heading, a few loose lines, a blank line, more loose lines. Only the heading and the odd paragraph carried a tag around it, and the PDF read the body's elements alone — so everything between the tags, which was most of the letter, was skipped without a word. What arrived on the paper was the heading, two paragraphs and three list items.

The renderer now walks the nodes instead of the elements. Blocks are set as before; whatever lies between them — free text, a <br>, a bold word — is gathered into a paragraph of its own and set where it stood.

Three things had to follow. \newpage is written as a line among others, not as a paragraph, so it is turned into a marker element before the content is split, and the schedule reaches its own page again. Whitespace is now collapsed the way a browser collapses it, because QuestPDF sets a newline as a line break — a paragraph indented over three source lines would otherwise come out over three lines, and a line ending in <br> would get an empty one behind it. And the signature is recognised in the middle of loose text, not only as a paragraph of its own.

Two smaller things on the way: a lone <li> without a list around it gets its bullet rather than being set as bare text, and a colour on an inline element no longer bleeds onto the siblings that follow it.

10e5685 Wuestengecko 22:44

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