Turn the note filter around: yours by default, colleagues added
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m36s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m10s

Yesterday's version had it the other way — everyone visible, untick to
hide. The decision from the business side is the opposite: you see your
own notes, and you tick the colleagues you also want. So note_mutes
becomes note_subscriptions and the predicate flips from `not exists` to
`exists`.

The existing rows are not carried over. The meaning inverts rather than
the sign: converting faithfully ("everyone except the muted") would write
almost the whole roster into the new table and reproduce exactly the state
the change is meant to end. Anyone opening the setting tomorrow would
think it had not taken effect. The table is a day old; what is lost is a
few ticks from trying it out.

What this costs is worth saying plainly: the silent case that could not
happen under exceptions can happen now. Do not tick a colleague and you
will not see her follow-ups — not while she is on holiday either. That is
the flip side of the decision, and it is written down in the migration
rather than discovered later.

Each note now says who wrote it. Own notes read "von mir" rather than
repeating your own name, which would sit on every second line and tell
nobody anything. The flag is computed on the server: the user id is
already there, and threading it through four components for one word is a
poor trade. The counter on the button follows the same turn — "+2" for
what you added, nothing when you added nothing.

Verified: 21 tests, five mutation-checked (restoring `not exists`,
dropping the own-notes clause, inverting the default, hiding the author,
and printing your own name instead of "von mir" each turn them red). 489
tests, typecheck, lint, schema drift and build clean. The migration is
reviewed but not run — no reachable database here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 11:36:38 +02:00
parent 6903013548
commit e8e675fd07
8 changed files with 217 additions and 80 deletions

View File

@@ -6,7 +6,7 @@ import { withUser } from "@/lib/db";
import type { ActionResult } from "@/lib/db/rpc"; import type { ActionResult } from "@/lib/db/rpc";
/** /**
* Notizen einer Kollegin oder eines Kollegen ein- oder ausblenden. * Notizen einer Kollegin oder eines Kollegen hinzuwählen oder abwählen.
* *
* Kein Aufruf einer SQL-Funktion und kein Protokolleintrag, anders als bei * Kein Aufruf einer SQL-Funktion und kein Protokolleintrag, anders als bei
* allem, was Personaldaten ändert: das hier ist eine persönliche * allem, was Personaldaten ändert: das hier ist eine persönliche
@@ -15,7 +15,7 @@ import type { ActionResult } from "@/lib/db/rpc";
* Dasselbe Muster wie bei gespeicherten Auswertungen und Entwürfen * Dasselbe Muster wie bei gespeicherten Auswertungen und Entwürfen
* (actions/reports.ts, actions/hireDrafts.ts). * (actions/reports.ts, actions/hireDrafts.ts).
* *
* Abgesichert ist es trotzdem: die Regel `note_mutes_owner` lässt nur Zeilen * Abgesichert ist es trotzdem: die Regel `note_subscriptions_owner` lässt nur Zeilen
* zu, deren `user_id` die angemeldete Person ist. Eine fremde Einstellung * zu, deren `user_id` die angemeldete Person ist. Eine fremde Einstellung
* liesse sich auch mit erfundenen Werten nicht schreiben. * liesse sich auch mit erfundenen Werten nicht schreiben.
*/ */
@@ -25,28 +25,28 @@ export async function setNotizSichtbarkeit(payload: {
}): Promise<ActionResult> { }): Promise<ActionResult> {
const userId = await requireUserId(); const userId = await requireUserId();
// Die eigenen Notizen bleiben immer sichtbar. Die Prüfbedingung der // Die eigenen Notizen sind ohnehin immer dabei. Die Prüfbedingung der
// Tabelle weist das ohnehin ab; hier kommt die Meldung heraus, die jemand // Tabelle weist das ab; hier kommt die Meldung heraus, die jemand lesen
// lesen kann, statt einer Verletzungsmeldung aus der Datenbank. // kann, statt einer Verletzungsmeldung aus der Datenbank.
if (payload.kollegeId === userId) { if (payload.kollegeId === userId) {
return { success: false, error: "Die eigenen Notizen lassen sich nicht ausblenden." }; return { success: false, error: "Die eigenen Notizen sind immer dabei." };
} }
try { try {
await withUser(userId, async (tx) => { await withUser(userId, async (tx) => {
if (payload.sichtbar) { if (payload.sichtbar) {
await tx // `on conflict do nothing`: zweimal dasselbe Hinzuwählen ist kein
.deleteFrom("note_mutes")
.where("user_id", "=", userId)
.where("muted_user_id", "=", payload.kollegeId)
.execute();
} else {
// `on conflict do nothing`: zweimal dasselbe Ausblenden ist kein
// Fehler, sondern derselbe Wunsch — etwa wenn zwei Reiter offen sind. // Fehler, sondern derselbe Wunsch — etwa wenn zwei Reiter offen sind.
await tx await tx
.insertInto("note_mutes") .insertInto("note_subscriptions")
.values({ user_id: userId, muted_user_id: payload.kollegeId }) .values({ user_id: userId, author_user_id: payload.kollegeId })
.onConflict((oc) => oc.columns(["user_id", "muted_user_id"]).doNothing()) .onConflict((oc) => oc.columns(["user_id", "author_user_id"]).doNothing())
.execute();
} else {
await tx
.deleteFrom("note_subscriptions")
.where("user_id", "=", userId)
.where("author_user_id", "=", payload.kollegeId)
.execute(); .execute();
} }
}); });

View File

@@ -28,7 +28,7 @@ export function NotesBell({ notes, kollegen }: { notes: OpenNote[]; kollegen: Ko
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const sichtbarkeit = (k: Kollege) => eigen[k.id] ?? k.sichtbar; const sichtbarkeit = (k: Kollege) => eigen[k.id] ?? k.sichtbar;
const ausgeblendet = kollegen.filter((k) => !sichtbarkeit(k)).length; const dazugewaehlt = kollegen.filter((k) => sichtbarkeit(k)).length;
async function schalte(k: Kollege) { async function schalte(k: Kollege) {
const neu = !sichtbarkeit(k); const neu = !sichtbarkeit(k);
@@ -91,16 +91,16 @@ export function NotesBell({ notes, kollegen }: { notes: OpenNote[]; kollegen: Ko
> >
<Users className="h-3.5 w-3.5" /> <Users className="h-3.5 w-3.5" />
Kolleg:innen Kolleg:innen
{/* Nur wenn eingeschränkt: sonst ist die Zahl bei jedem {/* Nur wenn jemand dazugewählt ist: eine „0" bei jedem
Aufschlagen zu lesen, ohne je etwas zu bedeuten. */} Aufschlagen zu lesen, sagt nichts. */}
{ausgeblendet > 0 && <span className="text-warning-text">({ausgeblendet} aus)</span>} {dazugewaehlt > 0 && <span className="text-brand-700">+{dazugewaehlt}</span>}
</button> </button>
</div> </div>
{zeigeEinstellung && ( {zeigeEinstellung && (
<div className="border-b border-border bg-surface px-4 py-3"> <div className="border-b border-border bg-surface px-4 py-3">
<p className="mb-2 text-xs text-ink-muted"> <p className="mb-2 text-xs text-ink-muted">
Wessen Notizen hier erscheinen. Die eigenen sind immer dabei. Wessen Notizen hier zusätzlich erscheinen. Die eigenen sind immer dabei.
</p> </p>
{kollegen.length === 0 ? ( {kollegen.length === 0 ? (
<p className="py-2 text-sm text-ink-muted">Keine weiteren HR-Kolleg:innen freigeschaltet.</p> <p className="py-2 text-sm text-ink-muted">Keine weiteren HR-Kolleg:innen freigeschaltet.</p>
@@ -138,6 +138,13 @@ export function NotesBell({ notes, kollegen }: { notes: OpenNote[]; kollegen: Ko
<span className="text-xs text-ink-muted">{fmtDate(n.created_at)}</span> <span className="text-xs text-ink-muted">{fmtDate(n.created_at)}</span>
</div> </div>
<p className="mt-1 text-sm text-ink-body">{n.note_text}</p> <p className="mt-1 text-sm text-ink-body">{n.note_text}</p>
{/* Von wem die Notiz stammt. Sichtbar sind die eigenen und
die hinzugewählten — ohne diese Zeile wäre nicht zu
unterscheiden, welche davon man gerade liest. „Ich"
statt des eigenen Namens: den kennt man. */}
<p className="mt-1 text-xs text-ink-muted">
{n.vonMir ? "von mir" : `von ${n.author_name}`}
</p>
{n.due_date && <p className="mt-1 text-xs text-warning-text">🔔 fällig {fmtDate(n.due_date)}</p>} {n.due_date && <p className="mt-1 text-xs text-warning-text">🔔 fällig {fmtDate(n.due_date)}</p>}
<button <button
type="button" type="button"

View File

@@ -0,0 +1,91 @@
-- Die Sichtbarkeit dreht sich um: **wer hinzugewählt ist**, nicht wer
-- ausgeblendet ist.
--
-- ═══ Was sich ändert ═══
--
-- Bisher (note_mutes): sichtbar war alles, eine Zeile blendete eine Person
-- aus. Ab jetzt (note_subscriptions): sichtbar sind die eigenen Notizen, und
-- eine Zeile holt eine Kollegin dazu.
--
-- ═══ Warum die alten Zeilen nicht übernommen werden ═══
--
-- Die Bedeutung kehrt sich um, nicht bloss das Vorzeichen. Eine wörtliche
-- Übernahme — „alle ausser den Abgewählten" — trüge jeden Bestand in die
-- neue Tabelle und stellte damit genau den Zustand her, den die Umstellung
-- abschaffen soll: alle sehen alle. Wer die Einstellung morgen aufschlägt,
-- hielte die Änderung für wirkungslos.
--
-- Also fängt jede Person bei sich selbst an und wählt hinzu, wen sie
-- braucht. Die Tabelle ist einen Tag alt; was dabei verlorengeht, sind ein
-- paar Haken aus dem Ausprobieren.
--
-- ═══ Was das kostet ═══
--
-- Der stille Fall, der bei den Ausnahmen nicht auftreten konnte, tritt jetzt
-- auf: wer eine Kollegin nicht hinzuwählt, sieht deren Wiedervorlagen nicht —
-- auch nicht im Urlaubsfall. Das ist die Kehrseite der Entscheidung und
-- gehört benannt, nicht versteckt.
drop table if exists note_mutes;
create table if not exists note_subscriptions (
-- Wessen Einstellung das ist.
user_id uuid not null references app_users(id) on delete cascade,
-- Wessen Notizen zusätzlich erscheinen sollen.
author_user_id uuid not null references app_users(id) on delete cascade,
created_at timestamptz not null default now(),
primary key (user_id, author_user_id),
-- Sich selbst hinzuzuwählen ergibt keinen Sinn: die eigenen Notizen sind
-- ohnehin immer dabei. Eine Zeile dafür wäre ein Haken, der nichts tut,
-- und ein Haken, der sich entfernen liesse, ohne dass etwas geschieht.
constraint chk_note_abos_nicht_selbst check (user_id <> author_user_id)
);
comment on table note_subscriptions is
'Hinzugewählte Kolleg:innen je Person. Eine Zeile holt die Notizen von author_user_id in die Glocke von user_id. Ohne Zeile sieht man nur die eigenen.';
-- Der Zugriffsweg fragt immer „meine Auswahl": ohne Index ein Tabellenscan
-- je Seitenaufruf, mit ihm ein Indexzugriff.
create index if not exists idx_note_subscriptions_user on note_subscriptions (user_id);
alter table note_subscriptions enable row level security;
-- Eigentümergebunden wie hire_drafts und saved_reports: man sieht und ändert
-- ausschliesslich die eigenen Zeilen.
drop policy if exists "note_subscriptions_owner" on note_subscriptions;
create policy "note_subscriptions_owner" on note_subscriptions
for all
using (user_id = app_current_user_id() and is_hr_user())
with check (user_id = app_current_user_id() and is_hr_user());
-- ═══ Gegenprobe ═══════════════════════════════════════════════════
do $$
begin
if exists (select 1 from pg_tables where tablename = 'note_mutes') then
raise exception 'note_mutes steht noch — die alte Tabelle wurde nicht entfernt.';
end if;
if not exists (
select 1 from pg_tables where tablename = 'note_subscriptions' and rowsecurity
) then
raise exception 'note_subscriptions hat keinen Zeilenschutz.';
end if;
if not exists (
select 1 from pg_policies
where tablename = 'note_subscriptions' and policyname = 'note_subscriptions_owner'
) then
raise exception 'Die Eigentuemerregel auf note_subscriptions fehlt.';
end if;
begin
insert into note_subscriptions (user_id, author_user_id)
values ('00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000001');
raise exception 'Eine Person kann sich selbst hinzuwaehlen — die Pruefbedingung greift nicht.';
exception
when check_violation then null;
when foreign_key_violation then null;
end;
end $$;

View File

@@ -7,6 +7,14 @@ import type { Database } from "./types";
export type OpenNote = Database["public"]["Tables"]["employee_notes"]["Row"] & { export type OpenNote = Database["public"]["Tables"]["employee_notes"]["Row"] & {
employeeName: string; employeeName: string;
/**
* Ob die Notiz von der angemeldeten Person stammt.
*
* Hier bestimmt und nicht in der Komponente: die Kennung liegt auf dem
* Server, und sie nur für ein Etikett durch vier Komponenten zu reichen
* wäre ein hoher Preis für ein Wort.
*/
vonMir: boolean;
}; };
/** Was die Teilabfrage liefert: die Notiz plus den Namen aus dem Join. */ /** Was die Teilabfrage liefert: die Notiz plus den Namen aus dem Join. */
@@ -17,35 +25,39 @@ export type NotizZeile = Omit<Database["public"]["Tables"]["employee_notes"]["Ro
last_name: string | null; last_name: string | null;
}; };
// „Meine Notizen" (Topbar-Glocke): das gemeinsame, mitarbeiterübergreifende // „Meine Notizen" (Topbar-Glocke): die noch nicht erledigten HR-Notizen —
// Postfach aller noch nicht erledigten HR-Notizen — unabhängig davon, zu wem // mitarbeiterübergreifend, unabhängig davon, zu welcher Person sie gehören.
// sie gehören (mit Nutzer abgestimmt).
// //
// Seit note_mutes lässt sich einschränken, *wessen* Notizen man sehen will. // **Wessen** Notizen erscheinen, entscheidet jede Person selbst: die eigenen
// Der Standard bleibt „alle": wer niemanden abwählt, sieht denselben Topf wie // immer, dazu die hinzugewählten Kolleg:innen (note_subscriptions).
// vorher. //
// Bis September 2026 war es ein gemeinsamer Topf — jede HR-Person sah jede
// Notiz. Der Wunsch, sich standardmässig nur selbst zu sehen, kam aus dem
// Betrieb; die Kehrseite steht in der Migration.
// //
// Früher zwei Abfragen, in JavaScript zusammengeführt, weil die API-Schicht // Früher zwei Abfragen, in JavaScript zusammengeführt, weil die API-Schicht
// für eine einzelne verschachtelte Abfrage keine Verknüpfung anbot. Am // für eine einzelne verschachtelte Abfrage keine Verknüpfung anbot. Am
// direkten Zugang ist es schlicht ein Join. // direkten Zugang ist es schlicht ein Join.
/** /**
* Wessen Notizen diese Person sehen will. * Wessen Notizen diese Person sieht.
* *
* Der Standard ist unverändert: alle. Wer niemanden abgewählt hat, sieht * Die Vorgabe ist die eigene Person. Wer mehr sehen will, wählt Kolleg:innen
* weiterhin den gemeinsamen Topf — die Einstellung engt ein, sie öffnet * hinzu — die Einstellung öffnet, sie engt nicht ein.
* nichts.
* *
* Zwei Dinge, die zusammengehören: * Drei Fälle kommen durch:
* *
* 1. **Die eigenen Notizen immer.** Sie sind der Grund, warum es die Glocke * 1. **Die eigenen, immer.** Sie sind der Grund, warum es die Glocke gibt.
* gibt. `note_mutes` lässt einen Selbstbezug ohnehin nicht zu; die * `note_subscriptions` lässt einen Selbstbezug nicht zu; die Bedingung
* Bedingung steht hier trotzdem, damit die Zusage nicht davon abhängt, * steht hier trotzdem, damit die Zusage nicht davon abhängt, dass eine
* dass eine Prüfbedingung an anderer Stelle bestehen bleibt. * Prüfbedingung an anderer Stelle bestehen bleibt.
* *
* 2. **Notizen ohne Verfasser bleiben sichtbar.** `author_user_id` kann leer * 2. **Die hinzugewählten.** Ohne Auswahl bleibt es bei den eigenen — das
* sein. Eine Notiz auszublenden, weil niemand weiss, von wem sie ist, * ist die Vorgabe, nicht ein leerer Zustand, der noch zu füllen wäre.
* wäre genau der stille Verlust, den diese Liste verhindern soll. *
* 3. **Notizen ohne Verfasser.** `author_user_id` kann leer sein. Sie
* niemandem zu zeigen, weil sie niemandem gehören, hiesse sie
* verschwinden zu lassen.
* *
* Steht als eigener Ausdruck hier und nicht in der jeweiligen Seite, weil * Steht als eigener Ausdruck hier und nicht in der jeweiligen Seite, weil
* ihn zwei Stellen brauchen: die Glocke in der Kopfzeile und die Karte * ihn zwei Stellen brauchen: die Glocke in der Kopfzeile und die Karte
@@ -61,9 +73,9 @@ export function sichtbareNotizen(userId: string, spalte = "n.author_user_id"): E
return sql<SqlBool>`( return sql<SqlBool>`(
${verfasser} is null ${verfasser} is null
or ${verfasser} = ${userId} or ${verfasser} = ${userId}
or not exists ( or exists (
select 1 from note_mutes m select 1 from note_subscriptions s
where m.user_id = ${userId} and m.muted_user_id = ${verfasser}))`; where s.user_id = ${userId} and s.author_user_id = ${verfasser}))`;
} }
/** /**
@@ -98,12 +110,13 @@ export function offeneNotizenAbfrage(eb: OrgEb, userId: string) {
} }
/** Der reine Teil: aus den Zeilen die Notizen mit lesbarem Namen. */ /** Der reine Teil: aus den Zeilen die Notizen mit lesbarem Namen. */
export function baueOffeneNotizen(rows: NotizZeile[]): OpenNote[] { export function baueOffeneNotizen(rows: NotizZeile[], userId: string): OpenNote[] {
return rows.map((row) => { return rows.map((row) => {
const { first_name, last_name, ...note } = row; const { first_name, last_name, ...note } = row;
return { return {
...(note as Database["public"]["Tables"]["employee_notes"]["Row"]), ...(note as Database["public"]["Tables"]["employee_notes"]["Row"]),
employeeName: first_name && last_name ? fmtName(first_name, last_name) : "Unbekannt", employeeName: first_name && last_name ? fmtName(first_name, last_name) : "Unbekannt",
vonMir: row.author_user_id === userId,
}; };
}); });
} }
@@ -112,5 +125,5 @@ export async function loadOpenNotes(tx: Tx, userId: string): Promise<OpenNote[]>
const { notes } = await tx const { notes } = await tx
.selectNoFrom((eb) => [jsonArrayFrom(offeneNotizenAbfrage(eb, userId)).as("notes")]) .selectNoFrom((eb) => [jsonArrayFrom(offeneNotizenAbfrage(eb, userId)).as("notes")])
.executeTakeFirstOrThrow(); .executeTakeFirstOrThrow();
return baueOffeneNotizen(notes as NotizZeile[]); return baueOffeneNotizen(notes as NotizZeile[], userId);
} }

View File

@@ -31,9 +31,9 @@ export type ShellData = {
/** /**
* Die HR-Kolleg:innen für die Sichtbarkeitseinstellung der Glocke. * Die HR-Kolleg:innen für die Sichtbarkeitseinstellung der Glocke.
* *
* `sichtbar` ist die Vorgabe für den Haken: ohne Eintrag in note_mutes * `sichtbar` ist der Stand des Hakens: gesetzt, wenn die Person
* steht er. Die eigene Person steht nicht in der Liste — die eigenen * hinzugewählt ist. Die eigene Person steht nicht in der Liste — die
* Notizen lassen sich nicht abwählen. * eigenen Notizen sind immer dabei.
*/ */
kollegen: { id: string; name: string; sichtbar: boolean }[]; kollegen: { id: string; name: string; sichtbar: boolean }[];
}; };
@@ -88,8 +88,8 @@ export async function loadShellData(tx: Tx, userId: string): Promise<ShellErgebn
.orderBy("full_name") .orderBy("full_name")
).as("hrLeute"), ).as("hrLeute"),
jsonArrayFrom( jsonArrayFrom(
eb.selectFrom("note_mutes").select("muted_user_id").where("user_id", "=", userId) eb.selectFrom("note_subscriptions").select("author_user_id").where("user_id", "=", userId)
).as("mutes"), ).as("abos"),
jsonArrayFrom( jsonArrayFrom(
eb eb
.selectFrom("hire_drafts") .selectFrom("hire_drafts")
@@ -123,10 +123,10 @@ export async function loadShellData(tx: Tx, userId: string): Promise<ShellErgebn
openPositions: await resolveOpenPositions(tx, orgMaps, gelesen.open as OffeneStelle[], asOf), openPositions: await resolveOpenPositions(tx, orgMaps, gelesen.open as OffeneStelle[], asOf),
locations: gelesen.locations as Location[], locations: gelesen.locations as Location[],
drafts: gelesen.drafts as ShellData["drafts"], drafts: gelesen.drafts as ShellData["drafts"],
openNotes: baueOffeneNotizen(gelesen.notes as NotizZeile[]), openNotes: baueOffeneNotizen(gelesen.notes as NotizZeile[], userId),
kollegen: baueKollegen( kollegen: baueKollegen(
gelesen.hrLeute as { id: string; full_name: string | null; email: string }[], gelesen.hrLeute as { id: string; full_name: string | null; email: string }[],
(gelesen.mutes as { muted_user_id: string }[]).map((m) => m.muted_user_id) (gelesen.abos as { author_user_id: string }[]).map((a) => a.author_user_id)
), ),
}, },
}; };
@@ -140,12 +140,12 @@ export async function loadShellData(tx: Tx, userId: string): Promise<ShellErgebn
*/ */
export function baueKollegen( export function baueKollegen(
leute: { id: string; full_name: string | null; email: string }[], leute: { id: string; full_name: string | null; email: string }[],
abgewaehlt: string[] hinzugewaehlt: string[]
): ShellData["kollegen"] { ): ShellData["kollegen"] {
const stumm = new Set(abgewaehlt); const dabei = new Set(hinzugewaehlt);
return leute.map((p) => ({ return leute.map((p) => ({
id: p.id, id: p.id,
name: p.full_name?.trim() || p.email, name: p.full_name?.trim() || p.email,
sichtbar: !stumm.has(p.id), sichtbar: dabei.has(p.id),
})); }));
} }

View File

@@ -576,22 +576,20 @@ export type Database = {
// Organisation, über die Person die Verortung in der Akte. Die // Organisation, über die Person die Verortung in der Akte. Die
// Einbettung erspart an einem Dutzend Stellen eine zweite Abfrage. // Einbettung erspart an einem Dutzend Stellen eine zweite Abfrage.
}; };
// Abgewählte Kolleg:innen je Person. Eine Zeile blendet deren Notizen // Hinzugewählte Kolleg:innen je Person. Eine Zeile holt deren Notizen
// aus; ohne Zeile ist alles sichtbar. Gespeichert wird das Abgewählte, // in die Glocke; ohne Zeile sieht man nur die eigenen.
// nicht das Gewählte — sonst wäre eine neu hinzugekommene HR-Person für note_subscriptions: {
// alle unsichtbar, bis jede sie einzeln anhakt.
note_mutes: {
Row: { Row: {
user_id: string; user_id: string;
muted_user_id: string; author_user_id: string;
created_at: string; created_at: string;
}; };
Insert: { Insert: {
user_id: string; user_id: string;
muted_user_id: string; author_user_id: string;
created_at?: string; created_at?: string;
}; };
Update: Partial<Database["public"]["Tables"]["note_mutes"]["Insert"]>; Update: Partial<Database["public"]["Tables"]["note_subscriptions"]["Insert"]>;
}; };
}; };
Views: Record<string, never>; Views: Record<string, never>;

View File

@@ -21,6 +21,8 @@ const KOLLEGEN: Kollege[] = [
{ id: "b", name: "Bernd Huber", sichtbar: false }, { id: "b", name: "Bernd Huber", sichtbar: false },
]; ];
// Die Vorgabe ist die eigene Person: ohne Haken sieht man nur sich selbst.
function notiz(teil: Partial<OpenNote> = {}): OpenNote { function notiz(teil: Partial<OpenNote> = {}): OpenNote {
return { return {
id: "n1", id: "n1",
@@ -35,6 +37,7 @@ function notiz(teil: Partial<OpenNote> = {}): OpenNote {
done_at: null, done_at: null,
created_at: "2026-09-01T08:00:00.000Z", created_at: "2026-09-01T08:00:00.000Z",
employeeName: "Manuel Aigner", employeeName: "Manuel Aigner",
vonMir: false,
...teil, ...teil,
} as OpenNote; } as OpenNote;
} }
@@ -78,6 +81,7 @@ describe("Sichtbarkeit der Kolleg:innen", () => {
}); });
it("meldet beim Abwählen die Kennung und den neuen Stand", async () => { it("meldet beim Abwählen die Kennung und den neuen Stand", async () => {
// sichtbar: false heisst jetzt „Abo entfernen", nicht „ausblenden".
const user = userEvent.setup(); const user = userEvent.setup();
zeige(); zeige();
await oeffneEinstellung(user); await oeffneEinstellung(user);
@@ -111,16 +115,37 @@ describe("Sichtbarkeit der Kolleg:innen", () => {
expect(await screen.findByText("Nicht gespeichert.")).toBeInTheDocument(); expect(await screen.findByText("Nicht gespeichert.")).toBeInTheDocument();
}); });
it("nennt die Zahl der Ausgeblendeten nur, wenn eingeschränkt ist", async () => { it("nennt die Zahl der Hinzugewählten nur, wenn welche da sind", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const { unmount } = zeige(); const { unmount } = zeige();
await user.click(screen.getByRole("button", { name: /Meine Notizen/i })); await user.click(screen.getByRole("button", { name: /Meine Notizen/i }));
expect(screen.getByRole("button", { name: /Kolleg:innen/i })).toHaveTextContent("(1 aus)"); expect(screen.getByRole("button", { name: /Kolleg:innen/i })).toHaveTextContent("+1");
unmount(); unmount();
zeige(KOLLEGEN.map((k) => ({ ...k, sichtbar: true }))); // Niemand hinzugewählt: die Vorgabe. Eine „+0" wäre bei jedem
// Aufschlagen zu lesen und sagte nichts.
zeige(KOLLEGEN.map((k) => ({ ...k, sichtbar: false })));
await user.click(screen.getByRole("button", { name: /Meine Notizen/i })); await user.click(screen.getByRole("button", { name: /Meine Notizen/i }));
expect(screen.getByRole("button", { name: /Kolleg:innen/i })).not.toHaveTextContent("aus)"); expect(screen.getByRole("button", { name: /Kolleg:innen/i })).not.toHaveTextContent("+");
});
it("nennt an jeder Notiz den Verfasser", async () => {
// Sichtbar sind die eigenen und die hinzugewählten — ohne diese Zeile
// wäre nicht zu unterscheiden, welche davon man gerade liest.
const user = userEvent.setup();
zeige(KOLLEGEN, [notiz({ author_name: "Anna Berger" })]);
await user.click(screen.getByRole("button", { name: /Meine Notizen/i }));
expect(screen.getByText("von Anna Berger")).toBeInTheDocument();
});
it("sagt bei eigenen Notizen: von mir", async () => {
// Der eigene Name stünde an jeder zweiten Zeile und sagte nichts — man
// kennt ihn.
const user = userEvent.setup();
zeige(KOLLEGEN, [notiz({ vonMir: true, author_name: "Max Stubhan" })]);
await user.click(screen.getByRole("button", { name: /Meine Notizen/i }));
expect(screen.getByText("von mir")).toBeInTheDocument();
expect(screen.queryByText(/Max Stubhan/)).not.toBeInTheDocument();
}); });
it("kommt ohne Kolleg:innen zurecht", async () => { it("kommt ohne Kolleg:innen zurecht", async () => {

View File

@@ -42,12 +42,15 @@ describe("sichtbareNotizen", () => {
expect(sql()).toContain('"n"."author_user_id" = $'); expect(sql()).toContain('"n"."author_user_id" = $');
}); });
it("blendet nur aus, wer ausdrücklich abgewählt wurde", () => { it("holt dazu, wer ausdrücklich hinzugewählt wurde", () => {
// Vorzeichen und Tabelle zusammen: ein `not exists` auf derselben
// Tabelle kehrte die Bedeutung um, ohne dass ein Wort sich änderte.
const s = sql(); const s = sql();
expect(s).toContain("not exists"); expect(s).toContain("exists");
expect(s).toContain("from note_mutes m"); expect(s).not.toContain("not exists");
expect(s).toContain("m.user_id = $"); expect(s).toContain("from note_subscriptions s");
expect(s).toContain("m.muted_user_id = \"n\".\"author_user_id\""); expect(s).toContain("s.user_id = $");
expect(s).toContain("s.author_user_id = \"n\".\"author_user_id\"");
}); });
it("verknüpft die drei Fälle mit ODER, nicht mit UND", () => { it("verknüpft die drei Fälle mit ODER, nicht mit UND", () => {
@@ -89,16 +92,16 @@ describe("baueKollegen", () => {
{ id: "c", full_name: " ", email: "c@example.test" }, { id: "c", full_name: " ", email: "c@example.test" },
]; ];
it("hakt an, wer nicht abgewählt ist", () => { it("lässt ohne Auswahl alle Haken leer", () => {
// Der Standard ist „alle sichtbar": gespeichert wird das Abgewählte. // Die Vorgabe ist die eigene Person; wer mehr will, wählt hinzu.
expect(baueKollegen(leute, []).map((k) => k.sichtbar)).toEqual([true, true, true]); expect(baueKollegen(leute, []).map((k) => k.sichtbar)).toEqual([false, false, false]);
}); });
it("nimmt den Haken weg, wo eine Ausnahme steht", () => { it("setzt den Haken, wo ein Abo steht", () => {
expect(baueKollegen(leute, ["b"]).map((k) => [k.id, k.sichtbar])).toEqual([ expect(baueKollegen(leute, ["b"]).map((k) => [k.id, k.sichtbar])).toEqual([
["a", true], ["a", false],
["b", false], ["b", true],
["c", true], ["c", false],
]); ]);
}); });
@@ -112,9 +115,9 @@ describe("baueKollegen", () => {
]); ]);
}); });
it("kommt mit einer Ausnahme zurecht, zu der es niemanden mehr gibt", () => { it("kommt mit einem Abo zurecht, zu dem es niemanden mehr gibt", () => {
// Wer die Personalabteilung verlässt, verschwindet aus der Liste; die // Wer die Personalabteilung verlässt, verschwindet aus der Liste; die
// Zeile in note_mutes bleibt, bis der Fremdschlüssel sie räumt. // Zeile bleibt, bis der Fremdschlüssel sie räumt.
expect(baueKollegen(leute, ["längst-weg"])).toHaveLength(3); expect(baueKollegen(leute, ["längst-weg"])).toHaveLength(3);
}); });
}); });