feat(t-paliad-177): chart export — iCal feed (deadlines+appointments only)
Server-side endpoint GET /api/projects/{id}/timeline.ics returns a
VCALENDAR + one VEVENT per actual deadline (VALUE=DATE all-day) and
appointment (UTC timestamp). Projected / milestone / off_script rows
are deliberately skipped — faraday-Q6 / m's pick: a calendar feed
must never carry predicted dates the user never confirmed, otherwise
Outlook fills with rule_code-derived events that erode trust.
FormatTimelineICS reuses the existing caldav_ical.go escape helpers
and writes through the same canonical UIDs (paliad-deadline-<id> +
paliad-appointment-<id>) so a re-subscribe updates entries instead
of duplicating them. Stable across re-exports = lawyer-safe.
Visibility piggybacks on ProjectionService.For + ProjectService.GetByID
(same gates as the chart page handler). Content-Disposition filename
slugged for portable ASCII so Outlook + Apple Calendar agree.
4 tests pin the contract: only deadline/appointment kinds emit
VEVENTs; undated rows skip cleanly; RFC 5545 §3.3.11 escaping for
; , \ \\n; empty input still produces a valid VCALENDAR.
i18n: 1 new key DE+EN.
Design ref: docs/design-project-chart-2026-05-09.md §7.8.
This commit is contained in:
@@ -25,6 +25,7 @@ const (
|
||||
calProductID = "-//Paliad//Paliad Appointments//EN"
|
||||
calVersion = "2.0"
|
||||
icalDateUTC = "20060102T150405Z"
|
||||
icalDateOnly = "20060102"
|
||||
)
|
||||
|
||||
// terminUID is the canonical CalDAV UID for a Paliad Appointment. Paliad-owned
|
||||
@@ -34,6 +35,14 @@ func terminUID(id string) string {
|
||||
return "paliad-appointment-" + id + "@paliad.de"
|
||||
}
|
||||
|
||||
// deadlineUID is the canonical iCal UID for a Paliad Deadline exported via
|
||||
// the chart's iCal feed (t-paliad-177 Slice 2). Distinct prefix from
|
||||
// terminUID so subscribers can't confuse the two — and so a re-export
|
||||
// updates the same calendar entry instead of duplicating it.
|
||||
func deadlineUID(id string) string {
|
||||
return "paliad-deadline-" + id + "@paliad.de"
|
||||
}
|
||||
|
||||
// extractAppointmentID returns the Paliad Appointment id (uuid string) embedded in a
|
||||
// terminUID, or "" when the UID isn't ours.
|
||||
func extractAppointmentID(uid string) string {
|
||||
@@ -83,6 +92,73 @@ func formatAppointment(t *models.Appointment) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// FormatTimelineICS renders a single VCALENDAR with one VEVENT per
|
||||
// timeline row that is a real actual (kind == "deadline" or
|
||||
// "appointment"). Projected / milestone rows are deliberately skipped
|
||||
// (design §7.8, faraday-Q6 / m's pick: trust-erosion otherwise — a
|
||||
// calendar should never fire predicted dates the user never confirmed).
|
||||
//
|
||||
// Deadlines render as all-day events (DTSTART;VALUE=DATE) because the
|
||||
// substrate marshals due_date as UTC-midnight; appointments render as
|
||||
// timestamped UTC events. Both UIDs are stable across re-exports so an
|
||||
// Outlook subscriber sees deduped entries on every refresh.
|
||||
func FormatTimelineICS(events []TimelineEvent, projectTitle string) string {
|
||||
var b strings.Builder
|
||||
w := func(line string) {
|
||||
b.WriteString(line)
|
||||
b.WriteString("\r\n")
|
||||
}
|
||||
w("BEGIN:VCALENDAR")
|
||||
w("PRODID:" + calProductID)
|
||||
w("VERSION:" + calVersion)
|
||||
if projectTitle != "" {
|
||||
w("X-WR-CALNAME:" + escapeText("Paliad — "+projectTitle))
|
||||
}
|
||||
now := time.Now().UTC().Format(icalDateUTC)
|
||||
for _, ev := range events {
|
||||
if ev.Date == nil {
|
||||
continue
|
||||
}
|
||||
switch ev.Kind {
|
||||
case "deadline":
|
||||
w("BEGIN:VEVENT")
|
||||
if ev.DeadlineID != nil {
|
||||
w("UID:" + deadlineUID(ev.DeadlineID.String()))
|
||||
} else {
|
||||
// Synthetic UID — shouldn't happen for actuals, but be defensive.
|
||||
w("UID:paliad-timeline-" + now + "@paliad.de")
|
||||
}
|
||||
w("DTSTAMP:" + now)
|
||||
w("DTSTART;VALUE=DATE:" + ev.Date.UTC().Format(icalDateOnly))
|
||||
w("SUMMARY:" + escapeText(ev.Title))
|
||||
if ev.Description != "" {
|
||||
w("DESCRIPTION:" + escapeText(ev.Description))
|
||||
}
|
||||
w("END:VEVENT")
|
||||
case "appointment":
|
||||
w("BEGIN:VEVENT")
|
||||
if ev.AppointmentID != nil {
|
||||
w("UID:" + terminUID(ev.AppointmentID.String()))
|
||||
} else {
|
||||
w("UID:paliad-timeline-" + now + "@paliad.de")
|
||||
}
|
||||
w("DTSTAMP:" + now)
|
||||
w("DTSTART:" + ev.Date.UTC().Format(icalDateUTC))
|
||||
w("SUMMARY:" + escapeText(ev.Title))
|
||||
if ev.Description != "" {
|
||||
w("DESCRIPTION:" + escapeText(ev.Description))
|
||||
}
|
||||
w("END:VEVENT")
|
||||
default:
|
||||
// milestone / projected / off_script are visualisation-only —
|
||||
// never written to a calendar feed (design §7.8 + faraday-Q6).
|
||||
continue
|
||||
}
|
||||
}
|
||||
w("END:VCALENDAR")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func escapeText(s string) string {
|
||||
r := strings.NewReplacer(
|
||||
`\`, `\\`,
|
||||
|
||||
122
internal/services/caldav_ical_timeline_test.go
Normal file
122
internal/services/caldav_ical_timeline_test.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// t-paliad-177 Slice 2 — pins FormatTimelineICS behavior.
|
||||
//
|
||||
// Trust contract: lawyers subscribe the .ics URL in Outlook / Apple
|
||||
// Calendar; predicted dates must NOT appear (faraday-Q6 / m's pick),
|
||||
// and re-export must update (not duplicate) prior entries.
|
||||
|
||||
func TestFormatTimelineICS_OnlyDeadlinesAndAppointments(t *testing.T) {
|
||||
due := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC)
|
||||
start := time.Date(2026, 7, 1, 9, 30, 0, 0, time.UTC)
|
||||
dID := uuid.New()
|
||||
aID := uuid.New()
|
||||
|
||||
events := []TimelineEvent{
|
||||
{Kind: "deadline", Status: "open", Date: &due, Title: "Klageerwiderung", DeadlineID: &dID},
|
||||
{Kind: "appointment", Status: "open", Date: &start, Title: "Hearing", AppointmentID: &aID},
|
||||
{Kind: "milestone", Status: "done", Date: &due, Title: "Filed"},
|
||||
{Kind: "projected", Status: "predicted", Date: &due, Title: "Predicted R.29c"},
|
||||
{Kind: "projected", Status: "court_set", Date: &start, Title: "Court set HV"},
|
||||
}
|
||||
out := FormatTimelineICS(events, "Siemens ./. Huawei")
|
||||
|
||||
// Sanity: VCALENDAR boundaries.
|
||||
if !strings.HasPrefix(out, "BEGIN:VCALENDAR\r\n") {
|
||||
t.Fatalf("missing VCALENDAR start: %q", firstLines(out, 3))
|
||||
}
|
||||
if !strings.HasSuffix(out, "END:VCALENDAR\r\n") {
|
||||
t.Errorf("missing VCALENDAR end")
|
||||
}
|
||||
|
||||
// Should emit exactly 2 VEVENTs (1 deadline + 1 appointment), nothing for the 3 skipped kinds.
|
||||
if got := strings.Count(out, "BEGIN:VEVENT"); got != 2 {
|
||||
t.Errorf("VEVENT count = %d, want 2 (deadline + appointment only)", got)
|
||||
}
|
||||
|
||||
// Deadline → VALUE=DATE.
|
||||
if !strings.Contains(out, "DTSTART;VALUE=DATE:20260615") {
|
||||
t.Errorf("deadline DTSTART should be all-day VALUE=DATE format; got:\n%s", out)
|
||||
}
|
||||
// Appointment → UTC timestamp.
|
||||
if !strings.Contains(out, "DTSTART:20260701T093000Z") {
|
||||
t.Errorf("appointment DTSTART should be UTC timestamp; got:\n%s", out)
|
||||
}
|
||||
|
||||
// UIDs distinct + namespaced.
|
||||
if !strings.Contains(out, "UID:paliad-deadline-"+dID.String()+"@paliad.de") {
|
||||
t.Errorf("missing canonical deadline UID")
|
||||
}
|
||||
if !strings.Contains(out, "UID:paliad-appointment-"+aID.String()+"@paliad.de") {
|
||||
t.Errorf("missing canonical appointment UID")
|
||||
}
|
||||
|
||||
// X-WR-CALNAME from project title (escaped — ' . / ' contains no special chars but check it's there).
|
||||
if !strings.Contains(out, "X-WR-CALNAME:Paliad — Siemens ./. Huawei") {
|
||||
t.Errorf("X-WR-CALNAME missing or wrong: searching in:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatTimelineICS_UndatedRowsSkipped(t *testing.T) {
|
||||
dID := uuid.New()
|
||||
events := []TimelineEvent{
|
||||
{Kind: "deadline", Status: "open", Date: nil, Title: "Datum offen", DeadlineID: &dID},
|
||||
}
|
||||
out := FormatTimelineICS(events, "X")
|
||||
if strings.Contains(out, "BEGIN:VEVENT") {
|
||||
t.Errorf("undated deadlines must not emit a VEVENT (no DTSTART would be invalid iCal)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatTimelineICS_TitleEscaping(t *testing.T) {
|
||||
due := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC)
|
||||
dID := uuid.New()
|
||||
events := []TimelineEvent{
|
||||
{
|
||||
Kind: "deadline", Status: "open", Date: &due,
|
||||
Title: `Frist mit ; und , und \ und ` + "\n" + "Newline",
|
||||
Description: `Beschreibung mit Komma,`,
|
||||
DeadlineID: &dID,
|
||||
},
|
||||
}
|
||||
out := FormatTimelineICS(events, "")
|
||||
// RFC 5545 §3.3.11: ; , \ \n must be escaped.
|
||||
if !strings.Contains(out, `\;`) {
|
||||
t.Errorf("missing escaped semicolon")
|
||||
}
|
||||
if !strings.Contains(out, `\,`) {
|
||||
t.Errorf("missing escaped comma")
|
||||
}
|
||||
if !strings.Contains(out, `\\`) {
|
||||
t.Errorf("missing escaped backslash")
|
||||
}
|
||||
if !strings.Contains(out, `\n`) {
|
||||
t.Errorf("missing escaped newline")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatTimelineICS_EmptyInputProducesValidEmptyCalendar(t *testing.T) {
|
||||
out := FormatTimelineICS(nil, "Empty Matter")
|
||||
if !strings.HasPrefix(out, "BEGIN:VCALENDAR\r\n") {
|
||||
t.Errorf("empty input should still produce a valid VCALENDAR header")
|
||||
}
|
||||
if strings.Contains(out, "BEGIN:VEVENT") {
|
||||
t.Errorf("empty input should produce 0 VEVENTs")
|
||||
}
|
||||
if !strings.HasSuffix(out, "END:VCALENDAR\r\n") {
|
||||
t.Errorf("empty input should still close the VCALENDAR")
|
||||
}
|
||||
}
|
||||
|
||||
func firstLines(s string, n int) string {
|
||||
parts := strings.SplitN(s, "\r\n", n+1)
|
||||
return strings.Join(parts[:min(n, len(parts))], "\r\n")
|
||||
}
|
||||
Reference in New Issue
Block a user