fix(ui): Dialoge weichen der Bildschirmtastatur aus
Auf dem Telefon verdeckte die Tastatur den halben Dialog – bei der Schnellerfassung genau das Eingabefeld, um das es geht. Ursache ist der Unterschied zwischen Layout- und Sichtfenster: die Tastatur schiebt sich darueber, ohne dass sich fuer die Seite etwas aendert. "position: fixed" und "100vh" beziehen sich weiter auf das volle Fenster, ein mittig stehender Dialog liegt damit zur Haelfte darunter. Der Dialog richtet sich jetzt nach window.visualViewport – Hoehe und oberer Rand kommen von dort und werden bei jeder Aenderung nachgefuehrt. Weil der Behaelter scrollbar ist, holt der Browser das fokussierte Feld dann von selbst ins Bild. Das gilt fuer alle Dialoge, nicht nur die Schnellerfassung. Dazu, weil es beim Nachsehen auffiel: Aufgaben lassen sich in der Uebersicht direkt bearbeiten. Bisher fuehrte von dort nur der Weg ins Projekt, und wer eine Faelligkeit aendern wollte, musste die Aufgabe im Brett wiederfinden. Der Dialog nimmt dafuer eine schlankere Form entgegen als den vollstaendigen Prisma-Datensatz. Geprueft mit einem nachgestellten Sichtfenster: Eingabefeld und Absende- knopf liegen bei 375x667 und aufgeklappter Tastatur vollstaendig im freien Bereich. Vorher lagen beide darunter. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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();
|
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 }) => {
|
test("Aufgabenuebersicht sammelt ueber alle Projekte und hakt ab", async ({ page }) => {
|
||||||
await page.getByRole("link", { name: "Aufgaben", exact: true }).click();
|
await page.getByRole("link", { name: "Aufgaben", exact: true }).click();
|
||||||
await expect(page.getByRole("heading", { name: "Aufgaben", level: 1 })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Aufgaben", level: 1 })).toBeVisible();
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export default async function TasksPage() {
|
|||||||
dueDate: task.dueDate ? task.dueDate.toISOString() : null,
|
dueDate: task.dueDate ? task.dueDate.toISOString() : null,
|
||||||
labels: task.labels,
|
labels: task.labels,
|
||||||
recurrence: task.recurrence,
|
recurrence: task.recurrence,
|
||||||
|
sourceIssue: task.sourceIssue,
|
||||||
sourceUrl: task.sourceUrl,
|
sourceUrl: task.sourceUrl,
|
||||||
bucket: dueBucket(task.dueDate, jetzt),
|
bucket: dueBucket(task.dueDate, jetzt),
|
||||||
project: task.project,
|
project: task.project,
|
||||||
|
|||||||
@@ -2,12 +2,33 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Loader2, Trash2 } from "lucide-react";
|
import { ExternalLink, GitPullRequestArrow, Loader2, Trash2 } from "lucide-react";
|
||||||
import type { Recurrence, Task, TaskStatus } from "@prisma/client";
|
import type { Recurrence, TaskStatus } from "@prisma/client";
|
||||||
import { Modal } from "@/components/ui/Modal";
|
import { Modal } from "@/components/ui/Modal";
|
||||||
import { RECURRENCE_META, TASK_STATUS_META, TASK_STATUS_ORDER } from "@/lib/tasks";
|
import { RECURRENCE_META, TASK_STATUS_META, TASK_STATUS_ORDER } from "@/lib/tasks";
|
||||||
import { cn, parseTags } from "@/lib/utils";
|
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 <input type="date"> – ortszeitbezogen, nicht UTC. */
|
/** Datum fuer <input type="date"> – ortszeitbezogen, nicht UTC. */
|
||||||
function toDateInput(value: Date | string | null): string {
|
function toDateInput(value: Date | string | null): string {
|
||||||
if (!value) return "";
|
if (!value) return "";
|
||||||
@@ -26,7 +47,7 @@ export function TaskDialog({
|
|||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
task: Task | null;
|
task: EditableTask | null;
|
||||||
initialStatus: TaskStatus;
|
initialStatus: TaskStatus;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -38,10 +59,38 @@ export function TaskDialog({
|
|||||||
const [labels, setLabels] = useState("");
|
const [labels, setLabels] = useState("");
|
||||||
const [recurrence, setRecurrence] = useState<string>("");
|
const [recurrence, setRecurrence] = useState<string>("");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [issueBusy, setIssueBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const isEdit = Boolean(task);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
setTitle(task?.title ?? "");
|
setTitle(task?.title ?? "");
|
||||||
@@ -231,6 +280,54 @@ export function TaskDialog({
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{/* Verknuepfung mit einem Git-Issue. Nur beim Bearbeiten – zu einer
|
||||||
|
Aufgabe, die es noch nicht gibt, laesst sich keins anlegen. */}
|
||||||
|
{isEdit ? (
|
||||||
|
<div className="rounded-lg border border-white/8 bg-white/2 px-3 py-2.5">
|
||||||
|
{task?.sourceUrl || task?.sourceIssue ? (
|
||||||
|
<p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-slate-400">
|
||||||
|
<GitPullRequestArrow className="h-3.5 w-3.5 shrink-0 text-violet-300" />
|
||||||
|
Verknuepft mit
|
||||||
|
{task.sourceUrl ? (
|
||||||
|
<a
|
||||||
|
href={task.sourceUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-violet-300 underline-offset-2 hover:underline"
|
||||||
|
>
|
||||||
|
Issue #{task.sourceIssue}
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-300">Issue #{task.sourceIssue}</span>
|
||||||
|
)}
|
||||||
|
<span className="w-full text-[11px] text-slate-500">
|
||||||
|
Erledigt schliesst das Issue, ein Zurueckziehen oeffnet es wieder.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
Noch nicht mit einem Issue verknuepft.
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost px-2.5 py-1.5 text-xs"
|
||||||
|
onClick={createIssue}
|
||||||
|
disabled={busy || issueBusy}
|
||||||
|
>
|
||||||
|
{issueBusy ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<GitPullRequestArrow className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
Als Issue anlegen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-2 pt-1">
|
<div className="flex items-center justify-between gap-2 pt-1">
|
||||||
{isEdit ? (
|
{isEdit ? (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { TaskStatus } from "@prisma/client";
|
import type { TaskStatus } from "@prisma/client";
|
||||||
import { QuickCapture } from "@/components/QuickCapture";
|
import { QuickCapture } from "@/components/QuickCapture";
|
||||||
|
import { TaskDialog } from "@/components/tasks/TaskDialog";
|
||||||
import {
|
import {
|
||||||
DUE_BUCKET_META,
|
DUE_BUCKET_META,
|
||||||
DUE_BUCKET_ORDER,
|
DUE_BUCKET_ORDER,
|
||||||
@@ -44,6 +45,8 @@ export function TaskOverview({ tasks }: { tasks: TaskOverviewItem[] }) {
|
|||||||
const [zeigeErledigte, setZeigeErledigte] = useState(false);
|
const [zeigeErledigte, setZeigeErledigte] = useState(false);
|
||||||
const [erfassenOffen, setErfassenOffen] = useState(false);
|
const [erfassenOffen, setErfassenOffen] = useState(false);
|
||||||
const [laufend, setLaufend] = useState<Set<string>>(new Set());
|
const [laufend, setLaufend] = useState<Set<string>>(new Set());
|
||||||
|
/** Welche Aufgabe gerade im Dialog liegt. null heisst: keiner offen. */
|
||||||
|
const [bearbeiten, setBearbeiten] = useState<TaskOverviewItem | null>(null);
|
||||||
|
|
||||||
const projekte = useMemo(() => {
|
const projekte = useMemo(() => {
|
||||||
const map = new Map<string, string>();
|
const map = new Map<string, string>();
|
||||||
@@ -253,6 +256,7 @@ export function TaskOverview({ tasks }: { tasks: TaskOverviewItem[] }) {
|
|||||||
task={task}
|
task={task}
|
||||||
busy={laufend.has(task.id)}
|
busy={laufend.has(task.id)}
|
||||||
onToggle={() => abhaken(task)}
|
onToggle={() => abhaken(task)}
|
||||||
|
onEdit={() => setBearbeiten(task)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -267,6 +271,18 @@ export function TaskOverview({ tasks }: { tasks: TaskOverviewItem[] }) {
|
|||||||
onClose={() => setErfassenOffen(false)}
|
onClose={() => setErfassenOffen(false)}
|
||||||
initialMode="aufgabe"
|
initialMode="aufgabe"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Derselbe Dialog wie im Projektbrett – er kennt seine Aufgabe ueber
|
||||||
|
die ID und braucht das Projekt nur zum Anlegen. */}
|
||||||
|
{bearbeiten ? (
|
||||||
|
<TaskDialog
|
||||||
|
open
|
||||||
|
projectId={bearbeiten.project.id}
|
||||||
|
task={bearbeiten}
|
||||||
|
initialStatus={bearbeiten.status}
|
||||||
|
onClose={() => setBearbeiten(null)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -275,10 +291,12 @@ function TaskRow({
|
|||||||
task,
|
task,
|
||||||
busy,
|
busy,
|
||||||
onToggle,
|
onToggle,
|
||||||
|
onEdit,
|
||||||
}: {
|
}: {
|
||||||
task: TaskOverviewItem;
|
task: TaskOverviewItem;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
}) {
|
}) {
|
||||||
const meta = TASK_STATUS_META[task.status];
|
const meta = TASK_STATUS_META[task.status];
|
||||||
const erledigt = task.status === "DONE";
|
const erledigt = task.status === "DONE";
|
||||||
@@ -303,14 +321,21 @@ function TaskRow({
|
|||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span
|
{/* Der Titel oeffnet den Bearbeiten-Dialog. Frueher fuehrte von
|
||||||
|
hier nur der Weg ins Projekt – wer eine Faelligkeit aendern
|
||||||
|
wollte, musste erst dorthin springen und die Aufgabe im Brett
|
||||||
|
wiederfinden. */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onEdit}
|
||||||
className={cn(
|
className={cn(
|
||||||
"truncate text-sm",
|
"truncate text-left text-sm transition-colors hover:text-violet-200",
|
||||||
erledigt ? "text-slate-500 line-through" : "text-slate-100",
|
erledigt ? "text-slate-500 line-through" : "text-slate-100",
|
||||||
)}
|
)}
|
||||||
|
title="Aufgabe bearbeiten"
|
||||||
>
|
>
|
||||||
{task.title}
|
{task.title}
|
||||||
</span>
|
</button>
|
||||||
{task.recurrence ? (
|
{task.recurrence ? (
|
||||||
<Repeat
|
<Repeat
|
||||||
className="h-3 w-3 shrink-0 text-slate-500"
|
className="h-3 w-3 shrink-0 text-slate-500"
|
||||||
|
|||||||
@@ -33,9 +33,45 @@ export function Modal({
|
|||||||
width = "lg",
|
width = "lg",
|
||||||
}: ModalProps) {
|
}: ModalProps) {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const [sichtfenster, setSichtfenster] = useState<{ height: number; top: number } | null>(null);
|
||||||
|
|
||||||
useEffect(() => setMounted(true), []);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const onKey = (event: KeyboardEvent) => {
|
const onKey = (event: KeyboardEvent) => {
|
||||||
@@ -53,7 +89,14 @@ export function Modal({
|
|||||||
if (!mounted || !open) return null;
|
if (!mounted || !open) return null;
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="fixed inset-0 z-[120] flex items-start justify-center overflow-y-auto p-3 sm:p-8">
|
<div
|
||||||
|
className="fixed inset-x-0 z-[120] flex items-start justify-center overflow-y-auto p-3 sm:p-8"
|
||||||
|
style={
|
||||||
|
sichtfenster
|
||||||
|
? { top: sichtfenster.top, height: sichtfenster.height }
|
||||||
|
: { top: 0, bottom: 0 }
|
||||||
|
}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Dialog schliessen"
|
aria-label="Dialog schliessen"
|
||||||
|
|||||||
+10
-3
@@ -40,8 +40,14 @@ export interface ConnectionWithCount extends ConnectionSafe {
|
|||||||
_count?: { repositories: number };
|
_count?: { repositories: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ein Repository samt Verbindung und zuletzt geholtem Stand. */
|
/**
|
||||||
export interface RepositoryWithSnapshot extends Repository {
|
* Ein Repository samt Verbindung und zuletzt geholtem Stand.
|
||||||
|
*
|
||||||
|
* Ohne webhookSecret: das Geheimnis verlaesst den Server nicht – siehe
|
||||||
|
* repositorySafeSelect. Ob einer eingerichtet ist, reicht die Projektseite
|
||||||
|
* getrennt durch.
|
||||||
|
*/
|
||||||
|
export interface RepositoryWithSnapshot extends Omit<Repository, "webhookSecret"> {
|
||||||
connection: ConnectionSafe | null;
|
connection: ConnectionSafe | null;
|
||||||
snapshot: RepoSnapshot | null;
|
snapshot: RepoSnapshot | null;
|
||||||
}
|
}
|
||||||
@@ -87,7 +93,8 @@ export interface TaskOverviewItem {
|
|||||||
dueDate: string | null;
|
dueDate: string | null;
|
||||||
labels: string[];
|
labels: string[];
|
||||||
recurrence: Recurrence | null;
|
recurrence: Recurrence | null;
|
||||||
/** Gesetzt, wenn die Aufgabe aus einem Git-Issue stammt. */
|
/** Gesetzt, wenn die Aufgabe an einem Git-Issue haengt. */
|
||||||
|
sourceIssue: number | null;
|
||||||
sourceUrl: string | null;
|
sourceUrl: string | null;
|
||||||
bucket: DueBucket;
|
bucket: DueBucket;
|
||||||
project: { id: string; name: string; accent: string; status: ProjectStatus };
|
project: { id: string; name: string; accent: string; status: ProjectStatus };
|
||||||
|
|||||||
Reference in New Issue
Block a user