Merge: t-paliad-102 — link Verlauf entries to deadlines/appointments/notes
This commit is contained in:
@@ -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}`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user