CI / pruefen (pull_request) Canceled after 0s
Eine Seite, die aus dem vorhandenen Aktivitaetsverlauf zusammenfasst, was in
einer Woche ueber alle Projekte hinweg passiert ist: Kennzahlen oben, darunter
die Vorgaenge nach Kalendertagen gebuendelt, neuester Tag zuerst. Blaettern in
frueher Wochen ueber ?woche=-n.
Der Verlauf kennt keinen Besitzer - die Trennung laeuft ueber das Projekt,
"project: { ownerId }" steht als erste Bedingung.
Zwei Dinge, die sonst still falsch werden:
- **Wochengrenzen** stehen als pruefbare Funktion in lib/review, nicht in der
Seite. Gerechnet wird ueber Berliner Kalendertage, nicht ueber "sieben mal
24 Stunden": liegt eine Zeitumstellung in der Woche, hat sie 167 oder 169
Stunden. Und im Container laeuft die Anwendung nach UTC, ein Eintrag von
Sonntagabend fiele sonst in die falsche Woche.
- **"Aufgaben erledigt"** wird am meta-Feld abgelesen (to: "DONE") und nicht
am Anzeigetext. Der Text ist zum Lesen da und aendert sich mit der
Formulierung; eine Zaehlung daran wuerde beim naechsten umformulierten
Einzeiler unbemerkt kippen. Eintraege ohne meta zaehlen nicht mit - lieber
untertreiben als raten.
Damit completeTaskFromIssue dieselbe Grundlage liefert, schreibt es jetzt
ebenfalls from/to ins meta, wie es das Verschieben von Hand schon tut.
Die Zuordnung Symbol/Farbe je Eintragsart lag bisher in ActivityFeed und wird
jetzt von zwei Stellen gebraucht - herausgezogen nach components/activityKinds,
sonst laufen die Kopien beim naechsten neuen Eintragstyp auseinander.
Erreichbar ueber die Schnellsuche und einen Verweis vom Dashboard. Bewusst
kein vierter Punkt in der Kopfleiste - die ist auf dem Telefon schon eng.
Geprueft: 20 neue Unit-Tests (Wochengrenzen inklusive beider Zeitumstellungen,
Jahreswechsel, Zaehlung), ein E2E-Test ueber Aufruf und Blaettern, axe-core auf
375px und 1280px ueber drei Zustaende - auch die leere Woche - ohne
Beanstandung, kein seitlicher Ueberlauf.
Refs #2
204 lines
6.6 KiB
TypeScript
204 lines
6.6 KiB
TypeScript
import type { ActivityKind } from "@prisma/client";
|
||
|
||
/**
|
||
* Wochenrueckblick: welche Woche, und was zaehlt darin.
|
||
*
|
||
* Die Wochenrechnung steht hier als reine Funktion und nicht in der Seite –
|
||
* Wochengrenzen sind der Teil, der still falsch wird, und so laesst er sich
|
||
* pruefen.
|
||
*/
|
||
|
||
/** Wochenbeginn ist Montag, wie in Deutschland ueblich (ISO 8601). */
|
||
export const WOCHENBEGINN = 1;
|
||
|
||
/**
|
||
* Kalendertag in fester Zeitzone.
|
||
*
|
||
* Dieselbe Ueberlegung wie bei den Faelligkeiten in lib/tasks: im Container
|
||
* laeuft die Anwendung nach UTC. Um 23:30 Berliner Zeit waere dort noch der
|
||
* Vortag, und ein Eintrag von Sonntagabend fiele in die falsche Woche.
|
||
*/
|
||
const TAG_FORMAT = new Intl.DateTimeFormat("en-CA", {
|
||
timeZone: "Europe/Berlin",
|
||
year: "numeric",
|
||
month: "2-digit",
|
||
day: "2-digit",
|
||
});
|
||
|
||
/** "2026-08-17" in Berliner Zeit. */
|
||
export function tagesschluessel(wert: Date): string {
|
||
return TAG_FORMAT.format(wert);
|
||
}
|
||
|
||
/** Wochentag in Berliner Zeit, 0 = Sonntag. */
|
||
function wochentag(wert: Date): number {
|
||
const [jahr, monat, tag] = tagesschluessel(wert).split("-").map(Number);
|
||
return new Date(Date.UTC(jahr, monat - 1, tag)).getUTCDay();
|
||
}
|
||
|
||
export interface Woche {
|
||
/** Beginn des Montags, als Zeitpunkt fuer die Abfrage. */
|
||
von: Date;
|
||
/** Beginn des folgenden Montags – ausschliesslich. */
|
||
bis: Date;
|
||
/** 0 = laufende Woche, -1 = vorige, … */
|
||
versatz: number;
|
||
/** Die sieben Kalendertage der Woche, Montag zuerst. */
|
||
tage: string[];
|
||
}
|
||
|
||
/**
|
||
* Grenzen der Woche, in der `jetzt` liegt – um `versatz` Wochen verschoben.
|
||
*
|
||
* Gerechnet wird ueber die Kalendertage in Berliner Zeit und dann in UTC
|
||
* zurueckgerechnet. Der Umweg ist noetig, weil zwischen zwei Montagen eine
|
||
* Zeitumstellung liegen kann: "sieben mal 24 Stunden" waere dann um eine
|
||
* Stunde daneben.
|
||
*/
|
||
export function wocheUm(jetzt: Date, versatz = 0): Woche {
|
||
const [jahr, monat, tag] = tagesschluessel(jetzt).split("-").map(Number);
|
||
const heuteUtc = Date.UTC(jahr, monat - 1, tag);
|
||
|
||
// Rueckwaerts bis zum Montag.
|
||
const abstandZumMontag = (wochentag(jetzt) - WOCHENBEGINN + 7) % 7;
|
||
const montag = new Date(heuteUtc - (abstandZumMontag - versatz * 7) * 86_400_000);
|
||
|
||
const tage: string[] = [];
|
||
for (let i = 0; i < 7; i += 1) {
|
||
tage.push(new Date(montag.getTime() + i * 86_400_000).toISOString().slice(0, 10));
|
||
}
|
||
|
||
const von = berlinMitternacht(tage[0]);
|
||
return {
|
||
von,
|
||
// Ende ist die Mitternacht *nach* dem Sonntag – nicht "von + 7 Tage":
|
||
// liegt eine Zeitumstellung dazwischen, ist die Woche 167 oder 169
|
||
// Stunden lang.
|
||
bis: berlinMitternacht(naechsterTag(tage[6])),
|
||
versatz,
|
||
tage,
|
||
};
|
||
}
|
||
|
||
/** Ein Kalendertag weiter, rein auf der Zeichenkette gerechnet. */
|
||
function naechsterTag(tag: string): string {
|
||
const [j, m, t] = tag.split("-").map(Number);
|
||
return new Date(Date.UTC(j, m - 1, t) + 86_400_000).toISOString().slice(0, 10);
|
||
}
|
||
|
||
/**
|
||
* Um wie viel geht die Berliner Uhr zu diesem Zeitpunkt der UTC voraus?
|
||
*
|
||
* Ermittelt statt fest verdrahtet, weil zwischen Sommer- und Winterzeit eine
|
||
* Stunde Unterschied liegt und ein Rueckblick ueber die Umstellung hinweg
|
||
* sonst um eine Stunde verrutscht.
|
||
*/
|
||
function zonenversatzMs(zeitpunkt: Date): number {
|
||
// sv-SE liefert "2026-08-17 14:30:00" – nah genug an ISO, um es nach dem
|
||
// Austausch des Leerzeichens wieder einlesen zu koennen.
|
||
const berlin = new Intl.DateTimeFormat("sv-SE", {
|
||
timeZone: "Europe/Berlin",
|
||
dateStyle: "short",
|
||
timeStyle: "medium",
|
||
}).format(zeitpunkt);
|
||
return Date.parse(`${berlin.replace(" ", "T")}Z`) - zeitpunkt.getTime();
|
||
}
|
||
|
||
/** Der Zeitpunkt, an dem in Berlin dieser Kalendertag beginnt. */
|
||
export function berlinMitternacht(tag: string): Date {
|
||
const basis = new Date(`${tag}T00:00:00Z`);
|
||
// Die Umstellung findet nachts um zwei statt, nie um Mitternacht – der
|
||
// Versatz an dieser Stelle ist also eindeutig.
|
||
return new Date(basis.getTime() - zonenversatzMs(basis));
|
||
}
|
||
|
||
export interface Kennzahlen {
|
||
gesamt: number;
|
||
projekte: number;
|
||
erledigt: number;
|
||
neueAufgaben: number;
|
||
notizen: number;
|
||
}
|
||
|
||
/** Was ein Verlaufseintrag fuer die Zaehlung hergeben muss. */
|
||
export interface ZaehlbarerEintrag {
|
||
kind: ActivityKind;
|
||
projectId: string;
|
||
meta?: unknown;
|
||
}
|
||
|
||
/**
|
||
* Kennzahlen einer Woche.
|
||
*
|
||
* "Erledigt" kommt aus dem `meta`-Feld (`to: "DONE"`) und nicht aus dem
|
||
* Anzeigetext. Der Text ist zum Lesen da und aendert sich mit der
|
||
* Formulierung – eine Zaehlung, die daran haengt, wird beim naechsten
|
||
* umformulierten Einzeiler still falsch.
|
||
*
|
||
* Fuer aeltere Eintraege ohne `to` bleibt ein Rueckfall auf den Text: der
|
||
* Verlauf haelt bis zu 200 Eintraege je Projekt, darunter also durchaus
|
||
* welche von vor dieser Aenderung.
|
||
*/
|
||
export function zaehleWoche(eintraege: ZaehlbarerEintrag[]): Kennzahlen {
|
||
let erledigt = 0;
|
||
let neueAufgaben = 0;
|
||
let notizen = 0;
|
||
const projekte = new Set<string>();
|
||
|
||
for (const eintrag of eintraege) {
|
||
projekte.add(eintrag.projectId);
|
||
|
||
if (eintrag.kind === "TASK_ADDED" || eintrag.kind === "TASKS_IMPORTED") {
|
||
neueAufgaben += 1;
|
||
} else if (eintrag.kind === "NOTE_ADDED") {
|
||
notizen += 1;
|
||
} else if (eintrag.kind === "TASK_MOVED" && istErledigt(eintrag)) {
|
||
erledigt += 1;
|
||
}
|
||
}
|
||
|
||
return { gesamt: eintraege.length, projekte: projekte.size, erledigt, neueAufgaben, notizen };
|
||
}
|
||
|
||
function istErledigt(eintrag: ZaehlbarerEintrag): boolean {
|
||
const meta = eintrag.meta;
|
||
if (meta && typeof meta === "object" && "to" in meta) {
|
||
return (meta as { to?: unknown }).to === "DONE";
|
||
}
|
||
return false;
|
||
}
|
||
|
||
const MONAT_FORMAT = new Intl.DateTimeFormat("de-DE", {
|
||
timeZone: "Europe/Berlin",
|
||
month: "long",
|
||
});
|
||
|
||
/**
|
||
* Beschriftung einer Woche: "17.–23. August 2026", ueber einen
|
||
* Monatswechsel hinweg "31. August–6. September 2026".
|
||
*
|
||
* Der Punkt hinter der Tageszahl kommt von Hand: Intl liefert bei
|
||
* `day: "numeric"` nur die Ziffer, und mit `day: "2-digit"` staende dort
|
||
* "07." statt "7.".
|
||
*/
|
||
export function wochenTitel(woche: Woche): string {
|
||
const teile = (tag: string) => {
|
||
const [j, m, t] = tag.split("-").map(Number);
|
||
return { tag: t, monat: MONAT_FORMAT.format(new Date(Date.UTC(j, m - 1, t, 12))), jahr: j };
|
||
};
|
||
|
||
const von = teile(woche.tage[0]);
|
||
const bis = teile(woche.tage[6]);
|
||
const gleicherMonat = von.monat === bis.monat && von.jahr === bis.jahr;
|
||
|
||
const links = gleicherMonat ? `${von.tag}.` : `${von.tag}. ${von.monat}`;
|
||
return `${links}–${bis.tag}. ${bis.monat} ${bis.jahr}`;
|
||
}
|
||
|
||
/** "Diese Woche", "Vorige Woche", sonst der Zeitraum. */
|
||
export function wochenName(woche: Woche): string {
|
||
if (woche.versatz === 0) return "Diese Woche";
|
||
if (woche.versatz === -1) return "Vorige Woche";
|
||
return wochenTitel(woche);
|
||
}
|