diff --git a/e2e/ui.spec.ts b/e2e/ui.spec.ts index 011ba29..32c036f 100644 --- a/e2e/ui.spec.ts +++ b/e2e/ui.spec.ts @@ -163,6 +163,46 @@ test("Schnellerfassung legt Idee und Aufgabe mit einem Feld an", async ({ page } await expect(page.getByText("Aus der Bahn notiert")).toBeVisible(); }); +test("Dialoge weichen der Bildschirmtastatur aus", async ({ page }) => { + await page.setViewportSize({ width: 375, height: 667 }); + await page.getByRole("button", { name: "Schnell erfassen" }).click(); + await expect(page.getByRole("dialog")).toBeVisible(); + + const TASTATUR = 336; + + /** + * Eine echte Tastatur laesst sich nicht einblenden. Stattdessen meldet + * visualViewport eine kleinere Hoehe – genau die Schnittstelle, an der + * der Dialog haengt. Ueberschrieben wird am echten Objekt: der Dialog + * merkt sich beim Oeffnen eine Referenz darauf. + */ + await page.evaluate((tastatur) => { + const fenster = window.visualViewport; + if (!fenster) throw new Error("visualViewport fehlt"); + const hoehe = window.innerHeight - tastatur; + Object.defineProperty(fenster, "height", { get: () => hoehe, configurable: true }); + Object.defineProperty(fenster, "offsetTop", { get: () => 0, configurable: true }); + fenster.dispatchEvent(new Event("resize")); + }, TASTATUR); + + await page.waitForTimeout(300); + + // Eingabefeld und Absendeknopf muessen oberhalb der Tastatur liegen. + // Vorher stand der Dialog mittig im vollen Fenster – und damit zur + // Haelfte darunter. + const frei = 667 - TASTATUR; + for (const ziel of [ + page.locator("#quick-title"), + page.getByRole("dialog").getByRole("button", { name: "Anlegen" }), + ]) { + const kasten = await ziel.boundingBox(); + expect(kasten).not.toBeNull(); + expect(kasten!.y + kasten!.height).toBeLessThanOrEqual(frei); + } + + await page.keyboard.press("Escape"); +}); + test("Aufgabenuebersicht sammelt ueber alle Projekte und hakt ab", async ({ page }) => { await page.getByRole("link", { name: "Aufgaben", exact: true }).click(); await expect(page.getByRole("heading", { name: "Aufgaben", level: 1 })).toBeVisible(); diff --git a/src/app/tasks/page.tsx b/src/app/tasks/page.tsx index d55f417..d3095db 100644 --- a/src/app/tasks/page.tsx +++ b/src/app/tasks/page.tsx @@ -42,6 +42,7 @@ export default async function TasksPage() { dueDate: task.dueDate ? task.dueDate.toISOString() : null, labels: task.labels, recurrence: task.recurrence, + sourceIssue: task.sourceIssue, sourceUrl: task.sourceUrl, bucket: dueBucket(task.dueDate, jetzt), project: task.project, diff --git a/src/components/tasks/TaskDialog.tsx b/src/components/tasks/TaskDialog.tsx index 00ebbd9..0003de6 100644 --- a/src/components/tasks/TaskDialog.tsx +++ b/src/components/tasks/TaskDialog.tsx @@ -2,12 +2,33 @@ import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; -import { Loader2, Trash2 } from "lucide-react"; -import type { Recurrence, Task, TaskStatus } from "@prisma/client"; +import { ExternalLink, GitPullRequestArrow, Loader2, Trash2 } from "lucide-react"; +import type { Recurrence, TaskStatus } from "@prisma/client"; import { Modal } from "@/components/ui/Modal"; import { RECURRENCE_META, TASK_STATUS_META, TASK_STATUS_ORDER } from "@/lib/tasks"; import { cn, parseTags } from "@/lib/utils"; +/** + * Was der Dialog von einer Aufgabe tatsaechlich liest. + * + * Bewusst nicht der vollstaendige Prisma-Datensatz: die Aufgabenuebersicht + * ueber alle Projekte reicht eine schlankere Fassung durch, in der das Datum + * als Zeichenkette ankommt – ein Date ueberlebt den Weg vom Server zum + * Browser nicht. Ein echtes Task erfuellt diese Form ebenfalls. + */ +export interface EditableTask { + id: string; + title: string; + description: string | null; + status: TaskStatus; + dueDate: Date | string | null; + labels: string[]; + recurrence: Recurrence | null; + /** Gesetzt, sobald die Aufgabe an einem Git-Issue haengt. */ + sourceIssue?: number | null; + sourceUrl?: string | null; +} + /** Datum fuer – ortszeitbezogen, nicht UTC. */ function toDateInput(value: Date | string | null): string { if (!value) return ""; @@ -26,7 +47,7 @@ export function TaskDialog({ }: { open: boolean; projectId: string; - task: Task | null; + task: EditableTask | null; initialStatus: TaskStatus; onClose: () => void; }) { @@ -38,10 +59,38 @@ export function TaskDialog({ const [labels, setLabels] = useState(""); const [recurrence, setRecurrence] = useState(""); const [busy, setBusy] = useState(false); + const [issueBusy, setIssueBusy] = useState(false); const [error, setError] = useState(null); const isEdit = Boolean(task); + /** + * Legt zur Aufgabe ein Issue im verknuepften Repository an. + * + * Ausdruecklich per Knopf und nicht beim Speichern: was in ein fremdes + * System geschrieben wird, soll jemand gewollt haben. + */ + async function createIssue() { + if (!task) return; + setIssueBusy(true); + setError(null); + try { + const response = await fetch(`/api/tasks/${task.id}/issue`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.error ?? "Issue konnte nicht angelegt werden."); + onClose(); + router.refresh(); + } catch (caught) { + setError(caught instanceof Error ? caught.message : String(caught)); + } finally { + setIssueBusy(false); + } + } + useEffect(() => { if (!open) return; setTitle(task?.title ?? ""); @@ -231,6 +280,54 @@ export function TaskDialog({

) : null} + {/* Verknuepfung mit einem Git-Issue. Nur beim Bearbeiten – zu einer + Aufgabe, die es noch nicht gibt, laesst sich keins anlegen. */} + {isEdit ? ( +
+ {task?.sourceUrl || task?.sourceIssue ? ( +

+ + Verknuepft mit + {task.sourceUrl ? ( + + Issue #{task.sourceIssue} + + + ) : ( + Issue #{task.sourceIssue} + )} + + Erledigt schliesst das Issue, ein Zurueckziehen oeffnet es wieder. + +

+ ) : ( +
+ + Noch nicht mit einem Issue verknuepft. + + +
+ )} +
+ ) : null} +
{isEdit ? (
); } @@ -275,10 +291,12 @@ function TaskRow({ task, busy, onToggle, + onEdit, }: { task: TaskOverviewItem; busy: boolean; onToggle: () => void; + onEdit: () => void; }) { const meta = TASK_STATUS_META[task.status]; const erledigt = task.status === "DONE"; @@ -303,14 +321,21 @@ function TaskRow({
- {task.title} - + {task.recurrence ? ( (null); useEffect(() => setMounted(true), []); + /** + * Der Dialog richtet sich nach dem *sichtbaren* Fenster, nicht nach dem + * Layoutfenster. + * + * Auf dem Telefon schiebt sich die Tastatur ueber die untere Haelfte, + * ohne dass sich daran etwas fuer die Seite aendert: "position: fixed" + * und "100vh" beziehen sich weiter auf das volle Fenster. Ein zentrierter + * Dialog liegt damit zur Haelfte unter der Tastatur – genau dort, wo das + * Eingabefeld ist. + * + * Die VisualViewport-API meldet, was wirklich zu sehen ist. Hoehe und + * oberer Rand daraus zu setzen rueckt den Dialog in den freien Bereich, + * und weil der Behaelter scrollbar ist, holt der Browser das fokussierte + * Feld von selbst ins Bild. + */ + useEffect(() => { + if (!open) return; + const fenster = window.visualViewport; + if (!fenster) return; + + const nachfuehren = () => { + setSichtfenster({ height: fenster.height, top: fenster.offsetTop }); + }; + nachfuehren(); + + fenster.addEventListener("resize", nachfuehren); + // Beim Scrollen mit offener Tastatur wandert der sichtbare Ausschnitt. + fenster.addEventListener("scroll", nachfuehren); + return () => { + fenster.removeEventListener("resize", nachfuehren); + fenster.removeEventListener("scroll", nachfuehren); + setSichtfenster(null); + }; + }, [open]); + useEffect(() => { if (!open) return; const onKey = (event: KeyboardEvent) => { @@ -53,7 +89,14 @@ export function Modal({ if (!mounted || !open) return null; return createPortal( -
+