diff --git a/README.md b/README.md index db889cf..f7f4800 100644 --- a/README.md +++ b/README.md @@ -149,13 +149,14 @@ if err != nil { ## Live Activities -There are five types of Live Activities: +There are six types of Live Activities: - `stats`: best for showing business numbers side by side, such as revenue, sales, new users, conversion, refunds, or any other value you want visible at a glance - `metrics`: best for live percentage values that change often, like server CPU, memory usage, disk usage, or error rate - `segmented_progress`: best for anything that moves through clear stages, like deployments, onboarding flows, backups, ETL pipelines, migrations, and AI agent runs - `progress`: best for tracking real-time progress with percentage, like tasks, backups, migrations, syncs, or uploads - `alert`: best for status updates, such as feature adoption, reactivation, onboarding blockers, incidents, escalations, and other operational states +- `timer`: best for countdowns and elapsed runtime, like benchmark runs, uploads, backups, transcodes, and long-running jobs ### Start & Update Live Activity @@ -283,6 +284,33 @@ activitysmith.LiveActivities.Stream( ) ``` +#### Timer + +

+ Timer Live Activity showing a benchmark run countdown +

+ +```go +activitysmith.LiveActivities.Stream( + "benchmark-run", + activitysmithsdk.LiveActivityStreamInput{ + Title: "Benchmark Run", + Subtitle: "sampling", + Type: "timer", + DurationSeconds: 300, + Color: "cyan", + }, +) +``` + +For a countdown, send `duration_seconds`. You can update `title`, `subtitle`, `color`, or any other visible field as the work changes. Leave `duration_seconds` out unless you want to change the timer. + +To start at 00:00 and count up, set `counts_down: false` and leave out `duration_seconds`. + ### End Live Activity Call `EndStream(...)` with the same `streamKey` to dismiss the Live Activity. You can include final values before it is removed. By default, iOS removes the Live Activity after two minutes. Set `AutoDismissMinutes` to choose a different dismissal time, including `0` for immediate dismissal. @@ -392,7 +420,7 @@ Add more context to Live Activities with icons and badges. #### Icon -Supported Live Activity types: `stats`, `metrics`, `progress`, `segmented_progress`, and `alert`. +Supported Live Activity types: `stats`, `metrics`, `progress`, `segmented_progress`, `alert`, and `timer`.

0 || in.numberOfStepsSet || @@ -282,9 +288,20 @@ func (in LiveActivityContentStateInput) isSet() bool { in.percentageSet || in.valueSet || in.upperLimitSet || + in.durationSecondsSet || + in.countsDownSet || in.autoDismissMinutesSet } +func (in LiveActivityContentStateInput) applyTimerFields(properties *map[string]interface{}) { + if in.DurationSeconds != 0 || in.durationSecondsSet { + setAdditionalProperty(properties, "duration_seconds", in.DurationSeconds) + } + if in.countsDownSet { + setAdditionalProperty(properties, "counts_down", in.CountsDown) + } +} + func (in LiveActivityContentStateInput) applyAlertFields(properties *map[string]interface{}) { if in.Message != "" { setAdditionalProperty(properties, "message", in.Message) @@ -321,6 +338,7 @@ func (in LiveActivityContentStateInput) applyStart(state *generated.ContentState if len(in.Metrics) > 0 { state.SetMetrics(append([]generated.ActivityMetric{}, in.Metrics...)) } + in.applyTimerFields(&state.AdditionalProperties) in.applyAlertFields(&state.AdditionalProperties) } @@ -355,6 +373,7 @@ func (in LiveActivityContentStateInput) applyUpdate(state *generated.ContentStat if len(in.Metrics) > 0 { state.SetMetrics(append([]generated.ActivityMetric{}, in.Metrics...)) } + in.applyTimerFields(&state.AdditionalProperties) in.applyAlertFields(&state.AdditionalProperties) } @@ -396,6 +415,7 @@ func (in LiveActivityContentStateInput) applyEndBase(state *generated.ContentSta if len(in.Metrics) > 0 { state.SetMetrics(append([]generated.ActivityMetric{}, in.Metrics...)) } + in.applyTimerFields(&state.AdditionalProperties) in.applyAlertFields(&state.AdditionalProperties) } @@ -433,6 +453,7 @@ func (in LiveActivityContentStateInput) applyStream(state *generated.StreamConte if in.AutoDismissMinutes != 0 || in.autoDismissMinutesSet { state.SetAutoDismissMinutes(in.AutoDismissMinutes) } + in.applyTimerFields(&state.AdditionalProperties) in.applyAlertFields(&state.AdditionalProperties) } @@ -466,6 +487,18 @@ func (in LiveActivityContentStateInput) WithUpperLimit(v float32) LiveActivityCo return in } +func (in LiveActivityContentStateInput) WithDurationSeconds(v float32) LiveActivityContentStateInput { + in.DurationSeconds = v + in.durationSecondsSet = true + return in +} + +func (in LiveActivityContentStateInput) WithCountsDown(v bool) LiveActivityContentStateInput { + in.CountsDown = v + in.countsDownSet = true + return in +} + func (in LiveActivityContentStateInput) WithAutoDismissMinutes(v int32) LiveActivityContentStateInput { in.AutoDismissMinutes = v in.autoDismissMinutesSet = true @@ -474,29 +507,33 @@ func (in LiveActivityContentStateInput) WithAutoDismissMinutes(v int32) LiveActi // LiveActivityStartInput is a handwritten DX input with plain optional values. type LiveActivityStartInput struct { - ContentState LiveActivityContentStateInput - Title string - NumberOfSteps int32 - CurrentStep int32 - Percentage float32 - Value float32 - UpperLimit float32 - Type string - Subtitle string - Message string - Icon LiveActivityAlertIconInput - Badge LiveActivityAlertBadgeInput - Color string - StepColor string - Metrics []generated.ActivityMetric - Action *LiveActivityActionInput - Channels []string - - numberOfStepsSet bool - currentStepSet bool - percentageSet bool - valueSet bool - upperLimitSet bool + ContentState LiveActivityContentStateInput + Title string + NumberOfSteps int32 + CurrentStep int32 + Percentage float32 + Value float32 + UpperLimit float32 + DurationSeconds float32 + CountsDown bool + Type string + Subtitle string + Message string + Icon LiveActivityAlertIconInput + Badge LiveActivityAlertBadgeInput + Color string + StepColor string + Metrics []generated.ActivityMetric + Action *LiveActivityActionInput + Channels []string + + numberOfStepsSet bool + currentStepSet bool + percentageSet bool + valueSet bool + upperLimitSet bool + durationSecondsSet bool + countsDownSet bool } func (in LiveActivityStartInput) toGenerated() generated.LiveActivityStartRequest { @@ -529,6 +566,12 @@ func (in LiveActivityStartInput) toGenerated() generated.LiveActivityStartReques if in.UpperLimit != 0 || in.upperLimitSet { req.ContentState.SetUpperLimit(in.UpperLimit) } + if in.DurationSeconds != 0 || in.durationSecondsSet { + setAdditionalProperty(&req.ContentState.AdditionalProperties, "duration_seconds", in.DurationSeconds) + } + if in.countsDownSet { + setAdditionalProperty(&req.ContentState.AdditionalProperties, "counts_down", in.CountsDown) + } if in.Subtitle != "" { req.ContentState.SetSubtitle(in.Subtitle) } @@ -589,6 +632,18 @@ func (in LiveActivityStartInput) WithUpperLimit(v float32) LiveActivityStartInpu return in } +func (in LiveActivityStartInput) WithDurationSeconds(v float32) LiveActivityStartInput { + in.DurationSeconds = v + in.durationSecondsSet = true + return in +} + +func (in LiveActivityStartInput) WithCountsDown(v bool) LiveActivityStartInput { + in.CountsDown = v + in.countsDownSet = true + return in +} + func (in LiveActivityStartInput) WithAction(v LiveActivityActionInput) LiveActivityStartInput { in.Action = &v return in @@ -596,29 +651,33 @@ func (in LiveActivityStartInput) WithAction(v LiveActivityActionInput) LiveActiv // LiveActivityUpdateInput is a handwritten DX input with plain optional values. type LiveActivityUpdateInput struct { - ActivityID string - ContentState LiveActivityContentStateInput - Title string - CurrentStep int32 - Percentage float32 - Value float32 - UpperLimit float32 - Type string - Subtitle string - Message string - Icon LiveActivityAlertIconInput - Badge LiveActivityAlertBadgeInput - Color string - StepColor string - NumberOfSteps int32 - Metrics []generated.ActivityMetric - Action *LiveActivityActionInput - - numberOfStepsSet bool - currentStepSet bool - percentageSet bool - valueSet bool - upperLimitSet bool + ActivityID string + ContentState LiveActivityContentStateInput + Title string + CurrentStep int32 + Percentage float32 + Value float32 + UpperLimit float32 + DurationSeconds float32 + CountsDown bool + Type string + Subtitle string + Message string + Icon LiveActivityAlertIconInput + Badge LiveActivityAlertBadgeInput + Color string + StepColor string + NumberOfSteps int32 + Metrics []generated.ActivityMetric + Action *LiveActivityActionInput + + numberOfStepsSet bool + currentStepSet bool + percentageSet bool + valueSet bool + upperLimitSet bool + durationSecondsSet bool + countsDownSet bool } func (in LiveActivityUpdateInput) toGenerated() generated.LiveActivityUpdateRequest { @@ -649,6 +708,12 @@ func (in LiveActivityUpdateInput) toGenerated() generated.LiveActivityUpdateRequ if in.UpperLimit != 0 || in.upperLimitSet { req.ContentState.SetUpperLimit(in.UpperLimit) } + if in.DurationSeconds != 0 || in.durationSecondsSet { + setAdditionalProperty(&req.ContentState.AdditionalProperties, "duration_seconds", in.DurationSeconds) + } + if in.countsDownSet { + setAdditionalProperty(&req.ContentState.AdditionalProperties, "counts_down", in.CountsDown) + } if in.Type != "" { req.ContentState.SetType(in.Type) } @@ -712,6 +777,18 @@ func (in LiveActivityUpdateInput) WithUpperLimit(v float32) LiveActivityUpdateIn return in } +func (in LiveActivityUpdateInput) WithDurationSeconds(v float32) LiveActivityUpdateInput { + in.DurationSeconds = v + in.durationSecondsSet = true + return in +} + +func (in LiveActivityUpdateInput) WithCountsDown(v bool) LiveActivityUpdateInput { + in.CountsDown = v + in.countsDownSet = true + return in +} + func (in LiveActivityUpdateInput) WithAction(v LiveActivityActionInput) LiveActivityUpdateInput { in.Action = &v return in @@ -726,6 +803,8 @@ type LiveActivityEndInput struct { Percentage float32 Value float32 UpperLimit float32 + DurationSeconds float32 + CountsDown bool Type string Subtitle string Message string @@ -743,6 +822,8 @@ type LiveActivityEndInput struct { percentageSet bool valueSet bool upperLimitSet bool + durationSecondsSet bool + countsDownSet bool autoDismissMinutesSet bool } @@ -774,6 +855,12 @@ func (in LiveActivityEndInput) toGenerated() generated.LiveActivityEndRequest { if in.UpperLimit != 0 || in.upperLimitSet { req.ContentState.SetUpperLimit(in.UpperLimit) } + if in.DurationSeconds != 0 || in.durationSecondsSet { + setAdditionalProperty(&req.ContentState.AdditionalProperties, "duration_seconds", in.DurationSeconds) + } + if in.countsDownSet { + setAdditionalProperty(&req.ContentState.AdditionalProperties, "counts_down", in.CountsDown) + } if in.Type != "" { req.ContentState.SetType(in.Type) } @@ -840,6 +927,18 @@ func (in LiveActivityEndInput) WithUpperLimit(v float32) LiveActivityEndInput { return in } +func (in LiveActivityEndInput) WithDurationSeconds(v float32) LiveActivityEndInput { + in.DurationSeconds = v + in.durationSecondsSet = true + return in +} + +func (in LiveActivityEndInput) WithCountsDown(v bool) LiveActivityEndInput { + in.CountsDown = v + in.countsDownSet = true + return in +} + // WithAutoDismissMinutes forces inclusion of auto_dismiss_minutes, including explicit zero. func (in LiveActivityEndInput) WithAutoDismissMinutes(v int32) LiveActivityEndInput { in.AutoDismissMinutes = v @@ -854,30 +953,34 @@ func (in LiveActivityEndInput) WithAction(v LiveActivityActionInput) LiveActivit // LiveActivityStreamInput is a handwritten DX input with plain optional values. type LiveActivityStreamInput struct { - ContentState LiveActivityContentStateInput - Title string - NumberOfSteps int32 - CurrentStep int32 - Percentage float32 - Value float32 - UpperLimit float32 - Type string - Subtitle string - Message string - Icon LiveActivityAlertIconInput - Badge LiveActivityAlertBadgeInput - Color string - StepColor string - Metrics []generated.ActivityMetric - Action *LiveActivityActionInput - Alert *generated.AlertPayload - Channels []string - - numberOfStepsSet bool - currentStepSet bool - percentageSet bool - valueSet bool - upperLimitSet bool + ContentState LiveActivityContentStateInput + Title string + NumberOfSteps int32 + CurrentStep int32 + Percentage float32 + Value float32 + UpperLimit float32 + DurationSeconds float32 + CountsDown bool + Type string + Subtitle string + Message string + Icon LiveActivityAlertIconInput + Badge LiveActivityAlertBadgeInput + Color string + StepColor string + Metrics []generated.ActivityMetric + Action *LiveActivityActionInput + Alert *generated.AlertPayload + Channels []string + + numberOfStepsSet bool + currentStepSet bool + percentageSet bool + valueSet bool + upperLimitSet bool + durationSecondsSet bool + countsDownSet bool } func (in LiveActivityStreamInput) toGenerated() generated.LiveActivityStreamRequest { @@ -910,6 +1013,12 @@ func (in LiveActivityStreamInput) toGenerated() generated.LiveActivityStreamRequ if in.UpperLimit != 0 || in.upperLimitSet { req.ContentState.SetUpperLimit(in.UpperLimit) } + if in.DurationSeconds != 0 || in.durationSecondsSet { + setAdditionalProperty(&req.ContentState.AdditionalProperties, "duration_seconds", in.DurationSeconds) + } + if in.countsDownSet { + setAdditionalProperty(&req.ContentState.AdditionalProperties, "counts_down", in.CountsDown) + } if in.Type != "" { req.ContentState.SetType(in.Type) } @@ -972,6 +1081,18 @@ func (in LiveActivityStreamInput) WithUpperLimit(v float32) LiveActivityStreamIn return in } +func (in LiveActivityStreamInput) WithDurationSeconds(v float32) LiveActivityStreamInput { + in.DurationSeconds = v + in.durationSecondsSet = true + return in +} + +func (in LiveActivityStreamInput) WithCountsDown(v bool) LiveActivityStreamInput { + in.CountsDown = v + in.countsDownSet = true + return in +} + func (in LiveActivityStreamInput) WithAction(v LiveActivityActionInput) LiveActivityStreamInput { in.Action = &v return in @@ -979,29 +1100,33 @@ func (in LiveActivityStreamInput) WithAction(v LiveActivityActionInput) LiveActi // LiveActivityStreamEndInput is an optional payload for ending a managed stream. type LiveActivityStreamEndInput struct { - ContentState LiveActivityContentStateInput - Title string - NumberOfSteps int32 - CurrentStep int32 - Percentage float32 - Value float32 - UpperLimit float32 - Type string - Subtitle string - Message string - Icon LiveActivityAlertIconInput - Badge LiveActivityAlertBadgeInput - Color string - StepColor string - Metrics []generated.ActivityMetric - Action *LiveActivityActionInput - Alert *generated.AlertPayload - - numberOfStepsSet bool - currentStepSet bool - percentageSet bool - valueSet bool - upperLimitSet bool + ContentState LiveActivityContentStateInput + Title string + NumberOfSteps int32 + CurrentStep int32 + Percentage float32 + Value float32 + UpperLimit float32 + DurationSeconds float32 + CountsDown bool + Type string + Subtitle string + Message string + Icon LiveActivityAlertIconInput + Badge LiveActivityAlertBadgeInput + Color string + StepColor string + Metrics []generated.ActivityMetric + Action *LiveActivityActionInput + Alert *generated.AlertPayload + + numberOfStepsSet bool + currentStepSet bool + percentageSet bool + valueSet bool + upperLimitSet bool + durationSecondsSet bool + countsDownSet bool } func (in LiveActivityStreamEndInput) toGenerated() generated.LiveActivityStreamDeleteRequest { @@ -1034,6 +1159,12 @@ func (in LiveActivityStreamEndInput) toGenerated() generated.LiveActivityStreamD if in.UpperLimit != 0 || in.upperLimitSet { contentState.SetUpperLimit(in.UpperLimit) } + if in.DurationSeconds != 0 || in.durationSecondsSet { + setAdditionalProperty(&contentState.AdditionalProperties, "duration_seconds", in.DurationSeconds) + } + if in.countsDownSet { + setAdditionalProperty(&contentState.AdditionalProperties, "counts_down", in.CountsDown) + } if in.Type != "" { contentState.SetType(in.Type) } @@ -1095,6 +1226,18 @@ func (in LiveActivityStreamEndInput) WithUpperLimit(v float32) LiveActivityStrea return in } +func (in LiveActivityStreamEndInput) WithDurationSeconds(v float32) LiveActivityStreamEndInput { + in.DurationSeconds = v + in.durationSecondsSet = true + return in +} + +func (in LiveActivityStreamEndInput) WithCountsDown(v bool) LiveActivityStreamEndInput { + in.CountsDown = v + in.countsDownSet = true + return in +} + func (in LiveActivityStreamEndInput) WithAction(v LiveActivityActionInput) LiveActivityStreamEndInput { in.Action = &v return in diff --git a/services_test.go b/services_test.go index c2d1116..765545a 100644 --- a/services_test.go +++ b/services_test.go @@ -706,6 +706,68 @@ func TestProgressInputsSerializeProgressFieldsWithoutSegmentedFields(t *testing. } } +func TestTimerInputsSerializeTimerFields(t *testing.T) { + server, requests := newAPITestServer(t) + defer server.Close() + + client, err := New("test-api-key") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + overrideHostForTests(client, server.URL) + + startInput := LiveActivityStartInput{ + Title: "Benchmark Run", + Subtitle: "sampling performance", + Type: LiveActivityTypeTimer, + Color: "cyan", + }.WithDurationSeconds(300).WithCountsDown(true) + + updateInput := LiveActivityUpdateInput{ + ActivityID: "act-1", + Title: "Benchmark Run", + Type: LiveActivityTypeTimer, + Subtitle: "complete", + Color: "cyan", + } + + if _, err := client.LiveActivities.Start(startInput); err != nil { + t.Fatalf("Start returned error: %v", err) + } + if _, err := client.LiveActivities.Update(updateInput); err != nil { + t.Fatalf("Update returned error: %v", err) + } + + if len(*requests) != 2 { + t.Fatalf("expected 2 requests, got %d", len(*requests)) + } + + startBody := (*requests)[0].Body + if !strings.Contains(startBody, `"type":"timer"`) { + t.Fatalf("start body missing timer type: %s", startBody) + } + if !strings.Contains(startBody, `"duration_seconds":300`) { + t.Fatalf("start body missing duration_seconds: %s", startBody) + } + if !strings.Contains(startBody, `"counts_down":true`) { + t.Fatalf("start body missing counts_down: %s", startBody) + } + + updateBody := (*requests)[1].Body + if !strings.Contains(updateBody, `"type":"timer"`) { + t.Fatalf("update body missing timer type: %s", updateBody) + } + if !strings.Contains(updateBody, `"subtitle":"complete"`) { + t.Fatalf("update body missing subtitle: %s", updateBody) + } + if !strings.Contains(updateBody, `"color":"cyan"`) { + t.Fatalf("update body missing color: %s", updateBody) + } + if strings.Contains(updateBody, `"duration_seconds"`) { + t.Fatalf("update body should preserve timer window when duration_seconds is omitted: %s", updateBody) + } +} + func TestStatsInputsSerializeMetricValuesAndColors(t *testing.T) { server, requests := newAPITestServer(t) defer server.Close() diff --git a/version.go b/version.go index d046de4..d4d044d 100644 --- a/version.go +++ b/version.go @@ -1,3 +1,3 @@ package activitysmith -const Version = "1.5.0" +const Version = "1.7.0"