Merge: t-paliad-102 — link Verlauf entries to deadlines/appointments/notes

This commit is contained in:
m
2026-05-03 18:39:16 +02:00
6 changed files with 140 additions and 30 deletions

View File

@@ -252,12 +252,37 @@ function renderActivity(items: ActivityEntry[]): void {
}).join("");
}
// Resolve an activity row to the most-specific deep-link target. Mirrors the
// rules in projects-detail.ts:eventDetailHref so the activity feed and the
// project Verlauf agree on where each event family points. Falls back to the
// owning project page when no metadata is wired (older rows or _deleted/
// deadlines_imported events). Wired families: checklist_*, deadline_*,
// appointment_*, note_created — see t-paliad-097/102.
function activityHref(e: ActivityEntry): string {
const action = e.action ?? "";
if (action.startsWith("checklist_") && action !== "checklist_deleted" && e.metadata) {
const id = (e.metadata as Record<string, unknown>)["checklist_instance_id"];
if (typeof id === "string" && id) {
return `/checklists/instances/${id}`;
const meta = (e.metadata ?? null) as Record<string, unknown> | null;
if (meta) {
if (action.startsWith("checklist_") && action !== "checklist_deleted") {
const id = meta["checklist_instance_id"];
if (typeof id === "string" && id) return `/checklists/instances/${id}`;
}
if (
action.startsWith("deadline_") &&
action !== "deadline_deleted" &&
action !== "deadlines_imported"
) {
const id = meta["deadline_id"];
if (typeof id === "string" && id) return `/deadlines/${id}`;
}
if (action.startsWith("appointment_") && action !== "appointment_deleted") {
const id = meta["appointment_id"];
if (typeof id === "string" && id) return `/appointments/${id}`;
}
if (action === "note_created") {
const apptID = meta["appointment_id"];
if (typeof apptID === "string" && apptID) return `/appointments/${apptID}`;
const deadlineID = meta["deadline_id"];
if (typeof deadlineID === "string" && deadlineID) return `/deadlines/${deadlineID}`;
}
}
return `/projects/${e.project_id}`;

View File

@@ -589,26 +589,60 @@ function renderEvents() {
}
// wrapEventTitleLink turns the event title into a hyperlink to the originating
// entity when the metadata carries the right ID. Today the only wired-up case
// is checklist_* events (metadata.checklist_instance_id → /checklists/instances/{id});
// the same pattern can be extended for deadline_*/appointment_* events later.
// checklist_deleted is intentionally not linked — the instance is gone.
// entity when the metadata carries the right ID. Wired-up event families:
// - checklist_* (except _deleted) → /checklists/instances/{checklist_instance_id}
// - deadline_* (except _deleted, deadlines_imported) → /deadlines/{deadline_id}
// - appointment_* (except _deleted) → /appointments/{appointment_id}
// - note_created → /appointments/{id} | /deadlines/{id} | /projects/{id}
// (notes have no standalone page; route to the most-specific parent)
// _deleted events are intentionally not linked — the entity is gone.
// deadlines_imported is bulk and has no single deadline_id, so it stays plain.
// CSS pairs with this: when the title is wrapped in <a class="entity-event-link">,
// a ::before pseudo-element expands the click surface to the whole event card.
function wrapEventTitleLink(e: ProjectEvent, escapedTitle: string): string {
const meta = e.metadata;
const evType = e.event_type ?? "";
if (
evType.startsWith("checklist_") &&
evType !== "checklist_deleted" &&
meta && typeof meta === "object"
) {
const id = (meta as Record<string, unknown>)["checklist_instance_id"];
if (typeof id === "string" && id) {
return `<a href="/checklists/instances/${esc(id)}" class="entity-event-link">${escapedTitle}</a>`;
}
const href = eventDetailHref(e);
if (href) {
return `<a href="${href}" class="entity-event-link">${escapedTitle}</a>`;
}
return escapedTitle;
}
// eventDetailHref resolves a ProjectEvent to a deep-link URL, or null if the
// event has no clickable target. Kept separate so the dashboard activity feed
// can reuse the same routing rules without duplicating the wrap logic.
function eventDetailHref(e: ProjectEvent): string | null {
const meta = e.metadata;
const evType = e.event_type ?? "";
if (!meta || typeof meta !== "object") return null;
const m = meta as Record<string, unknown>;
if (evType.startsWith("checklist_") && evType !== "checklist_deleted") {
const id = m["checklist_instance_id"];
if (typeof id === "string" && id) return `/checklists/instances/${esc(id)}`;
}
if (
evType.startsWith("deadline_") &&
evType !== "deadline_deleted" &&
evType !== "deadlines_imported"
) {
const id = m["deadline_id"];
if (typeof id === "string" && id) return `/deadlines/${esc(id)}`;
}
if (evType.startsWith("appointment_") && evType !== "appointment_deleted") {
const id = m["appointment_id"];
if (typeof id === "string" && id) return `/appointments/${esc(id)}`;
}
if (evType === "note_created") {
const apptID = m["appointment_id"];
if (typeof apptID === "string" && apptID) return `/appointments/${esc(apptID)}`;
const deadlineID = m["deadline_id"];
if (typeof deadlineID === "string" && deadlineID) return `/deadlines/${esc(deadlineID)}`;
const projectID = m["project_id"];
if (typeof projectID === "string" && projectID) return `/projects/${esc(projectID)}`;
}
return null;
}
function initEventsLoadMore() {
const btn = document.getElementById("project-events-loadmore");
if (!btn) return;

View File

@@ -4888,6 +4888,9 @@ input[type="range"]::-moz-range-thumb {
border-radius: var(--radius);
background: var(--color-surface);
box-shadow: var(--shadow);
/* Anchor the ::before pseudo-element on .entity-event-link so the whole
card can act as a hit-target without nesting block content in <a>. */
position: relative;
}
@media (max-width: 640px) {
@@ -4896,6 +4899,20 @@ input[type="range"]::-moz-range-thumb {
}
}
/* Card-wide affordance when the event has a deep-link (deadline/appointment/
note/checklist). Cursor and hover-lift give the row the same feel as the
row-click pattern used in .entity-table — see t-paliad-099/102. */
.entity-event:has(.entity-event-link) {
cursor: pointer;
transition: border-color 0.12s ease, box-shadow 0.12s ease;
}
.entity-event:has(.entity-event-link:hover),
.entity-event:has(.entity-event-link:focus-visible) {
border-color: var(--color-accent-fg);
box-shadow: var(--shadow-hover, var(--shadow));
}
.entity-event-date {
font-size: 0.8rem;
color: var(--color-text-muted);
@@ -4907,12 +4924,23 @@ input[type="range"]::-moz-range-thumb {
font-size: 0.9rem;
}
/* When the event title is wrapped in a link (e.g. checklist_* events linking
to /checklists/instances/{id}), inherit the title's weight/size and only
colour the link to the accent on hover. */
/* When the event title is wrapped in a link (checklist/deadline/appointment/
note_created — see wrapEventTitleLink in projects-detail.ts), the title text
inherits the parent's weight/colour. The ::before pseudo-element grows the
link's hit-area to fill the entire .entity-event card so a click anywhere
on the row navigates — the title text is the only visible link, but the
whole card is the click surface. */
.entity-event-link {
color: inherit;
text-decoration: none;
position: static;
}
.entity-event-link::before {
content: "";
position: absolute;
inset: 0;
border-radius: var(--radius);
}
.entity-event-link:hover {
@@ -4920,6 +4948,10 @@ input[type="range"]::-moz-range-thumb {
text-decoration: underline;
}
.entity-event-link:focus-visible {
outline: none;
}
.entity-event-desc {
font-size: 0.85rem;
color: var(--color-text-muted);

View File

@@ -278,10 +278,12 @@ func (s *AppointmentService) Create(ctx context.Context, userID uuid.UUID, input
if input.ProjectID != nil {
// Description carries value-only payload (the appointment title); frontend
// renders via the localized event.description.appointment_* template. Same
// pattern for updated/deleted below.
// pattern for updated/deleted below. Metadata carries the appointment id
// so the Verlauf entry deep-links to /appointments/{id} (t-paliad-102).
desc := title
descPtr := &desc
if err := insertProjectEvent(ctx, tx, *input.ProjectID, userID, "appointment_created", "Appointment created", descPtr); err != nil {
if err := insertProjectEventWithMeta(ctx, tx, *input.ProjectID, userID, "appointment_created", "Appointment created", descPtr,
map[string]any{"appointment_id": id}); err != nil {
return nil, err
}
}
@@ -369,7 +371,8 @@ func (s *AppointmentService) Update(ctx context.Context, userID, appointmentID u
if current.ProjectID != nil {
desc := current.Title
descPtr := &desc
if err := insertProjectEvent(ctx, tx, *current.ProjectID, userID, "appointment_updated", "Appointment updated", descPtr); err != nil {
if err := insertProjectEventWithMeta(ctx, tx, *current.ProjectID, userID, "appointment_updated", "Appointment updated", descPtr,
map[string]any{"appointment_id": appointmentID}); err != nil {
return nil, err
}
}

View File

@@ -403,7 +403,8 @@ func (s *DeadlineService) Update(ctx context.Context, userID, deadlineID uuid.UU
// Same pattern below for completed/reopened/deleted/created.
desc := current.Title
descPtr := &desc
if err := insertProjectEvent(ctx, tx, current.ProjectID, userID, "deadline_updated", "Deadline updated", descPtr); err != nil {
if err := insertProjectEventWithMeta(ctx, tx, current.ProjectID, userID, "deadline_updated", "Deadline updated", descPtr,
map[string]any{"deadline_id": deadlineID}); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
@@ -437,7 +438,8 @@ func (s *DeadlineService) Complete(ctx context.Context, userID, deadlineID uuid.
}
desc := current.Title
descPtr := &desc
if err := insertProjectEvent(ctx, tx, current.ProjectID, userID, "deadline_completed", "Deadline completed", descPtr); err != nil {
if err := insertProjectEventWithMeta(ctx, tx, current.ProjectID, userID, "deadline_completed", "Deadline completed", descPtr,
map[string]any{"deadline_id": deadlineID}); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
@@ -477,7 +479,8 @@ func (s *DeadlineService) Reopen(ctx context.Context, userID, deadlineID uuid.UU
}
desc := current.Title
descPtr := &desc
if err := insertProjectEvent(ctx, tx, current.ProjectID, userID, "deadline_reopened", "Deadline reopened", descPtr); err != nil {
if err := insertProjectEventWithMeta(ctx, tx, current.ProjectID, userID, "deadline_reopened", "Deadline reopened", descPtr,
map[string]any{"deadline_id": deadlineID}); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
@@ -642,7 +645,8 @@ func (s *DeadlineService) insert(ctx context.Context, userID, projectID uuid.UUI
desc := strings.TrimSpace(input.Title)
descPtr := &desc
if err := insertProjectEvent(ctx, tx, projectID, userID, "deadline_created", "Deadline created", descPtr); err != nil {
if err := insertProjectEventWithMeta(ctx, tx, projectID, userID, "deadline_created", "Deadline created", descPtr,
map[string]any{"deadline_id": id}); err != nil {
return uuid.Nil, err
}
if err := tx.Commit(); err != nil {

View File

@@ -248,10 +248,22 @@ func (s *NoteService) insertWithAudit(ctx context.Context, userID uuid.UUID, con
if projectAuditID != nil {
// Description carries the value-only payload (the parent slug); the
// frontend renders it via the localized event.note.added_to template.
// Metadata carries the most-specific anchor so the Verlauf entry can
// deep-link to the deadline/appointment/project the note hangs on
// (notes don't have their own page — t-paliad-102).
title := "Note added"
desc := parentLabel
descPtr := &desc
if err := insertProjectEvent(ctx, tx, *projectAuditID, userID, "note_created", title, descPtr); err != nil {
meta := map[string]any{"note_id": id}
switch {
case parent.DeadlineID != nil:
meta["deadline_id"] = *parent.DeadlineID
case parent.AppointmentID != nil:
meta["appointment_id"] = *parent.AppointmentID
case parent.ProjectID != nil:
meta["project_id"] = *parent.ProjectID
}
if err := insertProjectEventWithMeta(ctx, tx, *projectAuditID, userID, "note_created", title, descPtr, meta); err != nil {
return uuid.Nil, err
}
}