Files
nebula/src/lib/projects.ts
T
florian.wildeandClaude Opus 5 1400bca047 feat(board): freie Reihenfolge im Projektbrett
Karten lassen sich innerhalb einer Spalte anordnen, die Position bleibt am
Projekt. Das Aufgabenbrett kann das laengst - dass ausgerechnet das
Projektbrett der gewaehlten Sortierung folgte, war ein Bruch.

Die Sortierung im Kopf ist in dieser Ansicht deshalb abgeschaltet. Sie
stehen zu lassen, ohne dass sie wirkt, waere irrefuehrend.

Umsortieren fasst das Aenderungsdatum nicht an. Sonst schoebe ein Aufraeumen
im Brett saemtliche Projekte in "Zuletzt geaendert" nach oben - das Brett
wuerde die Liste umsortieren, ohne dass sich inhaltlich etwas geaendert hat.
Ein Statuswechsel dagegen ist eine echte Aenderung und darf das Datum
setzen. Nur er kommt auch in den Verlauf; ein blosser Rangwechsel nicht,
sonst bestuende der Verlauf bald aus nichts anderem.

Dabei fiel auf, dass neu angelegte, importierte und per Status verschobene
Projekte gar keine Position bekamen und sich alle auf 0 stapelten. Der Rang
wird jetzt an allen vier Stellen gesetzt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 14:30:17 +02:00

109 lines
3.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Prisma, type ProjectStatus } from "@prisma/client";
import { prisma } from "@/lib/db";
import { slugify } from "@/lib/utils";
/**
* Erzeugt einen je Besitzer eindeutigen Slug, notfalls mit angehaengtem
* Zaehler. Zwei Benutzer duerfen also beide ein Projekt "dashboard" haben.
*/
export async function uniqueSlug(
name: string,
ownerId: string,
ignoreId?: string,
): Promise<string> {
const base = slugify(name) || "projekt";
let candidate = base;
let counter = 2;
// In der Praxis endet die Schleife nach wenigen Runden.
for (let attempt = 0; attempt < 50; attempt += 1) {
const existing = await prisma.project.findFirst({
where: { slug: candidate, ownerId },
select: { id: true },
});
if (!existing || existing.id === ignoreId) return candidate;
candidate = `${base}-${counter}`;
counter += 1;
}
return `${base}-${Date.now()}`;
}
/**
* Naechste freie Position am Ende einer Statusspalte im Brett.
*
* Muss bei jedem Anlegen und bei jedem Statuswechsel gesetzt werden – sonst
* stehen alle auf 0 und das Brett zeigt eine Reihenfolge, die sich bei jedem
* Laden anders ergeben kann.
*/
export async function nextProjectPosition(
ownerId: string,
status: ProjectStatus,
): Promise<number> {
const last = await prisma.project.findFirst({
where: { ownerId, status },
orderBy: { position: "desc" },
select: { position: true },
});
return (last?.position ?? -1) + 1;
}
/**
* Verbindungsfelder, die an den Browser gehen duerfen.
* tokenCipher ist bewusst nicht dabei.
*/
export const connectionSafeSelect = Prisma.validator<Prisma.GitConnectionSelect>()({
id: true,
name: true,
provider: true,
baseUrl: true,
username: true,
tokenHint: true,
lastCheckedAt: true,
lastCheckOk: true,
lastCheckMessage: true,
createdAt: true,
updatedAt: true,
});
/**
* Ein Repository mitsamt Verbindung und Stand. Die Reihenfolge ist ueberall
* dieselbe: das fuehrende zuerst, denn auf der Karte ist nur fuer eines Platz.
*/
export const repositoryInclude = Prisma.validator<Prisma.ProjectInclude>()({
repositories: {
include: { connection: { select: connectionSafeSelect }, snapshot: true },
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
},
});
export const projectListInclude = Prisma.validator<Prisma.ProjectInclude>()({
...repositoryInclude,
_count: { select: { notes: true, tasks: true } },
// Nur die offenen Aufgaben zaehlen – fuer die Anzeige "3 von 8" auf der
// Karte, ohne die Aufgaben selbst mitzuladen.
tasks: { where: { status: "DONE" }, select: { id: true } },
});
export const projectDetailInclude = Prisma.validator<Prisma.ProjectInclude>()({
...repositoryInclude,
notes: {
orderBy: [{ pinned: "desc" }, { updatedAt: "desc" }],
},
tasks: {
orderBy: [{ status: "asc" }, { position: "asc" }],
},
// Erste Seite des Verlaufs; weitere holt die Anzeige bei Bedarf nach.
activities: {
orderBy: { createdAt: "desc" },
take: 20,
select: {
id: true,
kind: true,
summary: true,
createdAt: true,
user: { select: { username: true, displayName: true } },
},
},
_count: { select: { notes: true, tasks: true } },
});