fix(dashboard): Tagesachse lag einen Tag daneben, Bewerbungszahl erklaert

Zwei Sachen, die zusammen wie ein Rechenfehler aussahen:

- Bei der Einteilung "Tag" baute die Achse ihre Schluessel mit toISOString().
  Das rechnet nach UTC und liefert fuer den lokalen Tagesanfang in Zeitzonen
  oestlich von Greenwich den Vortag - jede Saeule fiel neben die Achse und das
  Diagramm blieb leer, obwohl die Kennzahlen Werte zeigten. Die Schluessel kommen
  jetzt aus den Ortsdatumsteilen. Monat, Quartal und Jahr waren nie betroffen,
  die haben ihre Schluessel schon so gebaut.

- Die Kennzahl "Bewerbungen" zaehlt alle Akquise-Eintraege des Zeitraums, auch
  die, aus denen eine Opportunity geworden ist. Die stehen nicht mehr in der
  Akquise-Liste, also ist die Zahl regelmaessig hoeher als die Liste lang ist.
  Das stand nirgends: jetzt sagt die Fussnote der Kachel, wie viele uebernommen
  wurden, und unter den Kacheln steht ein Satz dazu.

Dazu passen bei 30 Tagen jetzt alle Abschnitte nebeneinander (schmalere
Spalten), und passt es doch nicht, zeigt das Diagramm das Ende des Zeitraums -
die aktuellen Tage stehen rechts und interessieren.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-08-18 15:32:03 +02:00
co-authored by Claude Opus 5
parent 6f57483f33
commit 6a81258ca0
3 changed files with 58 additions and 8 deletions
+4 -2
View File
@@ -96,7 +96,7 @@ geschnitten wird - Tag, Monat, Quartal oder Jahr.
| Kachel | Was gezählt wird |
| --- | --- |
| **Bewerbungen** | Einträge in der Akquise im Zeitraum. Jeder Eintrag ist eine versendete Bewerbung |
| **Bewerbungen** | Einträge in der Akquise mit Funddatum im Zeitraum. Jeder Eintrag ist eine versendete Bewerbung - **auch die, aus denen inzwischen eine Opportunity geworden ist**. Die Zahl ist deshalb regelmäßig höher als die Akquise-Liste lang ist; unter den Kacheln steht, wie viele übernommen wurden. Ein von Hand gelöschter Eintrag zählt nicht mehr mit |
| **Zu Opportunity** | Bewerbungen, aus denen eine Opportunity geworden ist - dazu die Quote |
| **Gewonnen** | Opportunities, die in die Phase *Beauftragt* gekommen sind |
| **Beim Kunden** | Opportunities, die **jetzt** in *Kundenvorstellung* oder *Interview Kunde* stehen - unabhängig vom Zeitraum |
@@ -106,7 +106,9 @@ geschnitten wird - Tag, Monat, Quartal oder Jahr.
Das Säulendiagramm zeigt dieselben drei Reihen über der Zeit: Bewerbungen, daraus
entstandene Opportunities und gewonnene Projekte. Leere Abschnitte bleiben
sichtbar - eine Woche ohne Bewerbung ist eine Aussage. Der Mauszeiger auf einer
Säule nennt Abschnitt, Reihe und Wert.
Säule nennt Abschnitt, Reihe und Wert. Passen nicht alle Abschnitte nebeneinander,
zeigt das Diagramm das **Ende** des Zeitraums und lässt sich nach links scrollen -
die aktuellen Tage stehen rechts.
Rechts daneben steht **Bewerbungen je Berater** für denselben Zeitraum. Einträge
ohne hinterlegten Berater erscheinen als *Ohne Berater*.
+41 -4
View File
@@ -42,7 +42,20 @@ const BUCKETS: { id: DashboardBucket; label: string }[] = [
{ id: 'jahr', label: 'Jahr' },
];
const iso = (date: Date) => date.toISOString().slice(0, 10);
/**
* Ortsdatum als `JJJJ-MM-TT`.
*
* Bewusst nicht `toISOString()`: das rechnet nach UTC und liefert in Zeitzonen
* oestlich von Greenwich fuer den lokalen Tagesanfang den Vortag. Die
* Tagesschluessel liefen damit einen Tag hinter denen der Datenbank her - die
* Saeulen fielen neben die Achse und das Diagramm blieb leer, obwohl die
* Kennzahlen Werte zeigten.
*/
function iso(date: Date): string {
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${date.getFullYear()}-${month}-${day}`;
}
/** Anfang und Ende einer Vorgabe - `to` ist immer heute. */
function rangeDates(range: (typeof RANGES)[number]): { from: string; to: string } {
@@ -295,15 +308,19 @@ export const Dashboard: React.FC<DashboardProps> = ({ opportunities, events, isL
value={totals.applications}
accent="border-l-sky-500"
icon="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
hint="Einträge in der Akquise im gewählten Zeitraum"
footnote={`versendet · ${periodLabel}`}
hint="Alle Akquise-Einträge mit Funddatum im Zeitraum - auch die, aus denen inzwischen eine Opportunity geworden ist und die deshalb nicht mehr in der Akquise-Liste stehen."
footnote={
totals.converted > 0
? `versendet · ${totals.converted} davon übernommen`
: `versendet · ${periodLabel}`
}
/>
<StatCard
label="Zu Opportunity"
value={totals.converted}
accent="border-l-violet-500"
icon="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"
hint="Bewerbungen, aus denen eine Opportunity geworden ist"
hint="Bewerbungen, aus denen eine Opportunity geworden ist. Diese Einträge stehen nicht mehr in der Akquise-Liste - sie leben als Opportunity weiter."
footnote={quote(totals.converted, totals.applications)}
/>
<StatCard
@@ -324,6 +341,26 @@ export const Dashboard: React.FC<DashboardProps> = ({ opportunities, events, isL
/>
</div>
{/*
* Erklaerung direkt unter den Kacheln: die Zahl der Bewerbungen ist
* regelmaessig hoeher als die Akquise-Liste lang ist, weil uebernommene
* Eintraege dort verschwinden. Ohne diesen Satz sieht das nach einem
* Fehler aus.
*/}
<p className="px-1 text-xs text-ink-400">
<strong className="font-bold text-ink-500">Bewerbungen</strong> zählt alle
Akquise-Einträge mit Funddatum im Zeitraum
{totals.converted > 0 && (
<>
{' '}
- davon sind <strong className="font-bold text-ink-500">{totals.converted}</strong> zu
einer Opportunity geworden und stehen deshalb nicht mehr in der Akquise-Liste
</>
)}
. Ein von Hand gelöschter Eintrag zählt nicht mehr mit - ein Fehleintrag soll die
Zahlen nicht verfälschen.
</p>
{/* Verlauf und Berater */}
<div className="grid gap-5 xl:grid-cols-[2fr_1fr]">
<Panel
+13 -2
View File
@@ -130,6 +130,7 @@ export const SERIES = [
*/
export const SeriesChart: React.FC<{ points: SeriesPoint[] }> = ({ points }) => {
const [grown, setGrown] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setGrown(false);
@@ -137,6 +138,16 @@ export const SeriesChart: React.FC<{ points: SeriesPoint[] }> = ({ points }) =>
return () => clearTimeout(timer);
}, [points]);
/*
* Passen nicht alle Abschnitte nebeneinander, wird das Ende gezeigt: bei 30
* Tagen stehen die aktuellen Tage rechts, und die interessieren. Ohne das
* sieht man den Anfang des Zeitraums - haeufig lauter leere Tage.
*/
useEffect(() => {
const box = scrollRef.current;
if (box) box.scrollLeft = box.scrollWidth;
}, [points]);
const max = Math.max(1, ...points.flatMap((p) => [p.applications, p.converted, p.won]));
// Runde Obergrenze, damit die Hilfslinien lesbare Werte tragen.
const step = Math.max(1, Math.ceil(max / 4));
@@ -153,8 +164,8 @@ export const SeriesChart: React.FC<{ points: SeriesPoint[] }> = ({ points }) =>
))}
</div>
<div className="scrollbar-slim overflow-x-auto">
<div className="flex" style={{ minWidth: `${Math.max(560, points.length * 74)}px` }}>
<div ref={scrollRef} className="scrollbar-slim overflow-x-auto">
<div className="flex" style={{ minWidth: `${Math.max(520, points.length * 46)}px` }}>
{/* Werteachse */}
<div className="relative w-10 shrink-0" style={{ height: '260px' }}>
{[4, 3, 2, 1, 0].map((line) => (