@nyaruka/temba-components 0.158.1 → 0.159.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,880 @@
1
+ import { css, html, PropertyValueMap, TemplateResult } from 'lit';
2
+ import { property, state } from 'lit/decorators.js';
3
+ import {
4
+ CustomEventType,
5
+ ObjectReference,
6
+ ScheduledEvent,
7
+ ScheduledEventType
8
+ } from '../interfaces';
9
+ import { EndpointMonitorElement } from '../store/EndpointMonitorElement';
10
+ import { Icon } from '../Icons';
11
+
12
+ interface EventsResponse {
13
+ now: string;
14
+ campaigns: ObjectReference[];
15
+ future_count: number;
16
+ future: ScheduledEvent[];
17
+ past: ScheduledEvent[];
18
+ next_before: string | null;
19
+ next_after: string | null;
20
+ }
21
+
22
+ // four distinct primary hues assigned per campaign - chosen for maximum spread
23
+ // so the first handful of campaigns read clearly apart. Purple is reserved for
24
+ // broadcasts and green is reserved for triggers (matches the flow pill hue).
25
+ // The trailing four entries are darker shades of the primaries for the rare
26
+ // case where a contact belongs to more than four campaigns at once.
27
+ const CAMPAIGN_COLORS = [
28
+ '#1a86d0', // blue
29
+ '#e8843f', // orange
30
+ '#ec407a', // pink
31
+ '#2bb2a6', // teal
32
+ '#0a5290', // deep blue
33
+ '#a25320', // deep orange
34
+ '#a02153', // deep pink
35
+ '#176a61' // deep teal
36
+ ];
37
+
38
+ // shared "broadcast purple" used elsewhere for broadcasts
39
+ const BROADCAST_COLOR = '#8e5ea7';
40
+
41
+ // triggers use the same green as the flow pill
42
+ const TRIGGER_COLOR = '#16a34a';
43
+
44
+ export class ContactEvents extends EndpointMonitorElement {
45
+ @property({ type: String })
46
+ contact: string;
47
+
48
+ @property({ type: Object, attribute: false })
49
+ data: EventsResponse;
50
+
51
+ @property({ type: String })
52
+ lang_now = 'Now';
53
+
54
+ @property({ type: String })
55
+ lang_show_older = 'Show older events';
56
+
57
+ @property({ type: String })
58
+ lang_show_more = 'Show more upcoming events';
59
+
60
+ @property({ type: String })
61
+ lang_more = 'Show more';
62
+
63
+ @property({ type: String })
64
+ lang_campaigns_label = 'Campaigns';
65
+
66
+ @property({ type: String })
67
+ lang_empty = 'No events for this contact yet.';
68
+
69
+ @property({ type: String })
70
+ lang_projected_info =
71
+ 'This is a projected timeline based on current schedules. Past entries may not reflect what actually happened, and upcoming entries are limited to the next year.';
72
+
73
+ // older past events accumulated through pagination
74
+ @state()
75
+ private olderEvents: ScheduledEvent[] = [];
76
+
77
+ // further-upcoming events accumulated through pagination
78
+ @state()
79
+ private newerEvents: ScheduledEvent[] = [];
80
+
81
+ // cursor for paging further back, null when there's nothing older
82
+ @state()
83
+ private nextBefore: string | null = null;
84
+
85
+ // cursor for paging further forward, null when there's nothing further out
86
+ @state()
87
+ private nextAfter: string | null = null;
88
+
89
+ @state()
90
+ private loadingMore = false;
91
+
92
+ @state()
93
+ private loadingMoreFuture = false;
94
+
95
+ // stable campaign uuid -> color assignments
96
+ private campaignColors: { [uuid: string]: string } = {};
97
+
98
+ static get styles() {
99
+ return css`
100
+ :host {
101
+ display: block;
102
+ }
103
+
104
+ .empty {
105
+ padding: 4em 1em;
106
+ text-align: center;
107
+ color: var(--text-color);
108
+ opacity: 0.55;
109
+ }
110
+
111
+ /* row of campaign pills the contact is currently a member of */
112
+ .campaigns {
113
+ display: flex;
114
+ flex-wrap: wrap;
115
+ align-items: center;
116
+ gap: 0.4em;
117
+ padding: 0 0.5em 0.5em;
118
+ }
119
+
120
+ .campaigns-label {
121
+ font-size: 0.75em;
122
+ font-weight: 500;
123
+ text-transform: uppercase;
124
+ letter-spacing: 0.05em;
125
+ color: var(--text-color);
126
+ opacity: 0.55;
127
+ margin-right: 0.25em;
128
+ }
129
+
130
+ /* each pill is colored with its campaign's hue - background, border
131
+ and text all derived from --pill-hue. read-only badges, not links */
132
+ .campaign-pill {
133
+ display: inline-flex;
134
+ align-items: center;
135
+ gap: 6px;
136
+ font-size: 0.78em;
137
+ line-height: 1.5;
138
+ padding: 0.05em 0.65em;
139
+ border-radius: 999px;
140
+ white-space: nowrap;
141
+ background: color-mix(
142
+ in srgb,
143
+ var(--pill-hue) 12%,
144
+ var(--color-widget-bg, #fff)
145
+ );
146
+ border: 1px solid
147
+ color-mix(in srgb, var(--pill-hue) 25%, var(--color-widget-bg, #fff));
148
+ color: var(--pill-hue);
149
+ }
150
+
151
+ /* status-badge dot leading each campaign pill, in the same hue */
152
+ .campaign-dot {
153
+ display: inline-block;
154
+ width: 7px;
155
+ height: 7px;
156
+ border-radius: 50%;
157
+ background: var(--pill-hue);
158
+ }
159
+
160
+ .timeline {
161
+ display: flex;
162
+ flex-direction: column;
163
+ padding: 0.5em 0;
164
+ }
165
+
166
+ .row {
167
+ display: flex;
168
+ align-items: stretch;
169
+ }
170
+
171
+ /* relative time, vertically centered on its dot. mute via color
172
+ (not opacity) so the hovered date tooltip renders at full alpha
173
+ and doesn't share a stacking context with the column */
174
+ .time {
175
+ width: 7em;
176
+ flex-shrink: 0;
177
+ display: flex;
178
+ align-items: center;
179
+ justify-content: flex-end;
180
+ padding-right: 0.65em;
181
+ font-size: 0.8em;
182
+ color: var(--text-3, #7b8593);
183
+ white-space: nowrap;
184
+ }
185
+
186
+ .time temba-tip {
187
+ cursor: default;
188
+ }
189
+
190
+ /* the subway rail */
191
+ .track {
192
+ display: flex;
193
+ flex-direction: column;
194
+ align-items: center;
195
+ width: 1.75em;
196
+ flex-shrink: 0;
197
+ }
198
+
199
+ .line {
200
+ width: 2px;
201
+ flex: 1;
202
+ background: var(--color-borders, rgba(0, 0, 0, 0.12));
203
+ }
204
+
205
+ .line.hidden {
206
+ background: transparent;
207
+ }
208
+
209
+ .dot {
210
+ width: 18px;
211
+ height: 18px;
212
+ border-radius: 50%;
213
+ box-sizing: border-box;
214
+ flex-shrink: 0;
215
+ z-index: 1;
216
+ /* same border on past and future - only the fill differs */
217
+ border: 2px solid var(--dot-color);
218
+ display: flex;
219
+ align-items: center;
220
+ justify-content: center;
221
+ /* icons inside the dot take the dot's hue */
222
+ --icon-color: var(--dot-color);
223
+ }
224
+
225
+ /* past events are filled with a lighter tint of the same hue and keep
226
+ the solid border - they're settled, whether real or projected */
227
+ .dot.past {
228
+ background: color-mix(
229
+ in srgb,
230
+ var(--dot-color) 25%,
231
+ var(--color-widget-bg, #fff)
232
+ );
233
+ }
234
+
235
+ /* future events haven't occurred yet - signal that with a dotted
236
+ border and the faintest tint of the hue inside */
237
+ .dot.future {
238
+ border-style: dotted;
239
+ background: color-mix(
240
+ in srgb,
241
+ var(--dot-color) 8%,
242
+ var(--color-widget-bg, #fff)
243
+ );
244
+ }
245
+
246
+ /* the now dot and label both paint with the SPA content background
247
+ color - they look transparent but actually mask the rail and divider
248
+ behind them, so the "Now" marker reads as a clean break */
249
+ .dot.now {
250
+ border: 2px solid var(--border-strong, #9ca3af);
251
+ background: var(--color-widget-bg, #fff);
252
+ }
253
+
254
+ /* round button on the rail that pages further back / forward when
255
+ clicked - up arrow above the past, down arrow below the future */
256
+ .pager-dot {
257
+ width: 18px;
258
+ height: 18px;
259
+ border-radius: 50%;
260
+ box-sizing: border-box;
261
+ border: 2px solid var(--border-strong, #9ca3af);
262
+ background: var(--color-widget-bg, #fff);
263
+ display: flex;
264
+ align-items: center;
265
+ justify-content: center;
266
+ padding: 0;
267
+ cursor: pointer;
268
+ flex-shrink: 0;
269
+ z-index: 1;
270
+ --icon-color: var(--border-strong, #9ca3af);
271
+ transition:
272
+ border-color 0.15s ease,
273
+ color 0.15s ease;
274
+ }
275
+
276
+ .pager-dot:hover {
277
+ border-color: var(--color-link-primary);
278
+ --icon-color: var(--color-link-primary);
279
+ }
280
+
281
+ .pager-dot.loading {
282
+ cursor: default;
283
+ opacity: 0.5;
284
+ }
285
+
286
+ /* "More" label next to the pager dot - shares the event-row spacing
287
+ and font size so the pager row sits on the same vertical rhythm */
288
+ .pager-label {
289
+ display: flex;
290
+ align-items: center;
291
+ padding: 0.4em 0.75em;
292
+ margin: 0.1em 0;
293
+ color: var(--border-strong, #9ca3af);
294
+ cursor: pointer;
295
+ }
296
+
297
+ .pager-label:hover {
298
+ color: var(--color-link-primary);
299
+ }
300
+
301
+ /* hovering either the dot or the label highlights both so they read
302
+ as one interactive element - but the empty space of the row stays
303
+ inert (no hover, no click) */
304
+ .row.pager-row:has(.pager-dot:hover) .pager-label,
305
+ .row.pager-row:has(.pager-label:hover) .pager-label {
306
+ color: var(--color-link-primary);
307
+ }
308
+
309
+ .row.pager-row:has(.pager-label:hover) .pager-dot,
310
+ .row.pager-row:has(.pager-dot:hover) .pager-dot {
311
+ border-color: var(--color-link-primary);
312
+ --icon-color: var(--color-link-primary);
313
+ }
314
+
315
+ .row.pager-row.loading .pager-dot,
316
+ .row.pager-row.loading .pager-label,
317
+ .row.pager-row.loading:has(.pager-dot:hover) .pager-dot,
318
+ .row.pager-row.loading:has(.pager-dot:hover) .pager-label,
319
+ .row.pager-row.loading:has(.pager-label:hover) .pager-dot,
320
+ .row.pager-row.loading:has(.pager-label:hover) .pager-label {
321
+ cursor: default;
322
+ opacity: 0.5;
323
+ color: var(--border-strong, #9ca3af);
324
+ border-color: var(--border-strong, #9ca3af);
325
+ --icon-color: var(--border-strong, #9ca3af);
326
+ }
327
+
328
+ /* the "now" divider - a rule running the full width */
329
+ .row.now-row {
330
+ position: relative;
331
+ }
332
+
333
+ .row.now-row::after {
334
+ content: '';
335
+ position: absolute;
336
+ left: 0;
337
+ right: 0;
338
+ top: 50%;
339
+ border-top: 1px solid var(--color-borders, rgba(0, 0, 0, 0.2));
340
+ z-index: 0;
341
+ }
342
+
343
+ /* notice that campaign timelines are projections - sits below the
344
+ timeline, bordered with the same dotted style as past campaign dots */
345
+ .projection-note {
346
+ max-width: 500px;
347
+ margin: 0.5em 0.75em;
348
+ padding: 0.5em 0.75em;
349
+ border: 2px dotted var(--color-borders, rgba(0, 0, 0, 0.3));
350
+ border-radius: var(--curvature);
351
+ font-size: 0.8em;
352
+ line-height: 1.45;
353
+ color: var(--text-color);
354
+ opacity: 0.7;
355
+ }
356
+
357
+ .now-label {
358
+ z-index: 1;
359
+ display: flex;
360
+ align-items: center;
361
+ margin: 1.5em 0;
362
+ padding: 0 0.6em;
363
+ background: var(--color-widget-bg, #fff);
364
+ font-size: 0.75em;
365
+ font-weight: 600;
366
+ text-transform: uppercase;
367
+ letter-spacing: 0.08em;
368
+ color: var(--text-color);
369
+ opacity: 0.5;
370
+ }
371
+
372
+ /* flat event entry - no card chrome. column layout stacks the title
373
+ above any secondary metadata like the repeat schedule */
374
+ .event {
375
+ flex-grow: 1;
376
+ min-width: 0;
377
+ display: flex;
378
+ flex-direction: column;
379
+ gap: 0.15em;
380
+ padding: 0.4em 0.75em;
381
+ margin: 0.1em 0;
382
+ border-radius: var(--curvature);
383
+ }
384
+
385
+ .event.clickable {
386
+ cursor: pointer;
387
+ }
388
+
389
+ .event.clickable:hover {
390
+ background: var(--color-selection, rgba(0, 0, 0, 0.04));
391
+ }
392
+
393
+ .title {
394
+ min-width: 0;
395
+ display: flex;
396
+ align-items: center;
397
+ gap: 0.45em;
398
+ color: var(--text-color);
399
+ line-height: 1.4;
400
+ }
401
+
402
+ .title-text {
403
+ flex: 1;
404
+ min-width: 0;
405
+ display: -webkit-box;
406
+ -webkit-line-clamp: 2;
407
+ -webkit-box-orient: vertical;
408
+ overflow: hidden;
409
+ }
410
+ `;
411
+ }
412
+
413
+ // the endpoint returns a custom {future, past, next_before} payload rather than
414
+ // the paginated {results: [...]} shape that the store's fetch machinery expects,
415
+ // so we fetch and assign data ourselves instead of relying on a monitored url
416
+ private async loadEvents(): Promise<void> {
417
+ if (!this.contact) {
418
+ this.data = null;
419
+ return;
420
+ }
421
+
422
+ // capture the contact at request time so a slower in-flight response for a
423
+ // previous contact can't overwrite data for the contact we're now showing
424
+ const requestedContact = this.contact;
425
+ try {
426
+ const response = await this.store.getUrl(
427
+ `/contact/events/${encodeURIComponent(this.contact)}/`,
428
+ { force: true }
429
+ );
430
+ if (this.contact !== requestedContact) {
431
+ return;
432
+ }
433
+ this.data = response.json;
434
+ } catch {
435
+ // on failure leave the prior data in place so a transient blip doesn't
436
+ // wipe the timeline; data is only cleared when the contact changes
437
+ // (handled in updated()) or when there's no contact at all
438
+ }
439
+ }
440
+
441
+ public refresh(): void {
442
+ this.loadEvents();
443
+ }
444
+
445
+ protected updated(
446
+ changes: PropertyValueMap<any> | Map<PropertyKey, unknown>
447
+ ): void {
448
+ super.updated(changes);
449
+
450
+ if (changes.has('contact')) {
451
+ // colors are assigned per-uuid in order of first appearance, so a switch
452
+ // to a new contact must drop the previous assignments to avoid drift
453
+ this.campaignColors = {};
454
+ // blank the timeline immediately on a contact switch so the previous
455
+ // contact's events aren't briefly visible while the new fetch is in flight
456
+ this.data = null;
457
+ // in-flight pager requests for the prior contact will bail on stale
458
+ // contact, so reset the loading flags so the new contact's pagers aren't
459
+ // blocked by the previous contact's still-resolving request
460
+ this.loadingMore = false;
461
+ this.loadingMoreFuture = false;
462
+ this.loadEvents();
463
+ }
464
+
465
+ if (changes.has('data')) {
466
+ // a fresh first page resets any paged-in events in both directions
467
+ this.olderEvents = [];
468
+ this.newerEvents = [];
469
+ this.nextBefore = this.data ? (this.data.next_before ?? null) : null;
470
+ this.nextAfter = this.data ? (this.data.next_after ?? null) : null;
471
+
472
+ // eagerly assign campaign colors for everything in the first page so
473
+ // render() can be a pure read of the map. Pager handlers extend the map
474
+ // when later pages introduce campaigns not in the first page.
475
+ if (this.data) {
476
+ this.assignCampaignColors([
477
+ ...(this.data.past || []),
478
+ ...(this.data.future || [])
479
+ ]);
480
+ }
481
+
482
+ // only notify consumers when we actually have data - skip the null state
483
+ // between contact switch and first response (and any transient errors)
484
+ // so listeners don't see a spurious count:0
485
+ if (this.data !== null && this.data !== undefined) {
486
+ // the badge reflects the total count of upcoming events, not just
487
+ // what's currently visible on this page
488
+ const count =
489
+ typeof this.data.future_count === 'number'
490
+ ? this.data.future_count
491
+ : Array.isArray(this.data.future)
492
+ ? this.data.future.length
493
+ : 0;
494
+ this.fireCustomEvent(CustomEventType.DetailsChanged, { count });
495
+ }
496
+ }
497
+ }
498
+
499
+ // assign stable per-uuid colors for any campaigns seen in `events` that
500
+ // aren't already in the map. Called from updated() on data change and from
501
+ // pager handlers after a page is appended, so render() never mutates state.
502
+ private assignCampaignColors(events: ScheduledEvent[]): void {
503
+ for (const event of events) {
504
+ if (event.type !== ScheduledEventType.CampaignEvent || !event.campaign) {
505
+ continue;
506
+ }
507
+ const uuid = event.campaign.uuid;
508
+ if (!this.campaignColors[uuid]) {
509
+ const used = Object.keys(this.campaignColors).length;
510
+ this.campaignColors[uuid] =
511
+ CAMPAIGN_COLORS[used % CAMPAIGN_COLORS.length];
512
+ }
513
+ }
514
+ }
515
+
516
+ private getCampaignColor(uuid: string): string {
517
+ return this.campaignColors[uuid] || CAMPAIGN_COLORS[0];
518
+ }
519
+
520
+ private getColor(event: ScheduledEvent): string {
521
+ if (event.type === ScheduledEventType.ScheduledTrigger) {
522
+ return TRIGGER_COLOR;
523
+ }
524
+
525
+ if (event.type !== ScheduledEventType.CampaignEvent || !event.campaign) {
526
+ return BROADCAST_COLOR;
527
+ }
528
+
529
+ return this.getCampaignColor(event.campaign.uuid);
530
+ }
531
+
532
+ // broadcasts and triggers carry their type icon inside the dot;
533
+ // campaign events use a plain dot since the campaign pill carries the context
534
+ private getDotIcon(event: ScheduledEvent): string | null {
535
+ if (event.type === ScheduledEventType.ScheduledTrigger) {
536
+ return Icon.trigger;
537
+ }
538
+ if (event.type !== ScheduledEventType.CampaignEvent) {
539
+ return Icon.broadcast;
540
+ }
541
+ return null;
542
+ }
543
+
544
+ public handleEventClicked(event: ScheduledEvent) {
545
+ this.fireCustomEvent(CustomEventType.Selection, event);
546
+ }
547
+
548
+ // clicking a campaign or flow pill navigates to that entity's read page
549
+ // instead of bubbling up to the row's event-read navigation
550
+ public handlePillClicked(e: Event, ref: ObjectReference) {
551
+ e.stopPropagation();
552
+ this.fireCustomEvent(CustomEventType.Selection, ref);
553
+ }
554
+
555
+ // activate a non-button row on Enter/Space (matches native button behavior)
556
+ // so keyboard users can reach clickable timeline rows and pager labels
557
+ private handleActivationKey(e: KeyboardEvent, action: () => void) {
558
+ if (e.key === 'Enter' || e.key === ' ') {
559
+ e.preventDefault();
560
+ action();
561
+ }
562
+ }
563
+
564
+ public async handleShowOlder() {
565
+ if (this.loadingMore || !this.nextBefore) {
566
+ return;
567
+ }
568
+
569
+ this.loadingMore = true;
570
+ // capture the contact at request time so a paged response that returns
571
+ // after the user has switched contacts can't append onto the new timeline
572
+ const requestedContact = this.contact;
573
+ const url = `/contact/events/${encodeURIComponent(
574
+ this.contact
575
+ )}/?before=${encodeURIComponent(this.nextBefore)}`;
576
+
577
+ try {
578
+ const response = await this.store.getUrl(url, { force: true });
579
+ if (this.contact !== requestedContact) {
580
+ return;
581
+ }
582
+ const page = response.json as EventsResponse;
583
+ const newPast = page.past || [];
584
+ this.assignCampaignColors(newPast);
585
+ this.olderEvents = [...this.olderEvents, ...newPast];
586
+ this.nextBefore = page.next_before ?? null;
587
+ } catch {
588
+ if (this.contact !== requestedContact) {
589
+ return;
590
+ }
591
+ // on failure leave the accumulated events in place but stop offering
592
+ // the same failing page - clearing the cursor hides the pager
593
+ this.nextBefore = null;
594
+ } finally {
595
+ // only clear the flag if we're still on the original contact - the
596
+ // contact-change handler in updated() already cleared it, and clearing
597
+ // again here would unblock the new contact's in-flight pager request
598
+ // and allow a duplicate fetch of the same cursor
599
+ if (this.contact === requestedContact) {
600
+ this.loadingMore = false;
601
+ }
602
+ }
603
+ }
604
+
605
+ public async handleShowMore() {
606
+ if (this.loadingMoreFuture || !this.nextAfter) {
607
+ return;
608
+ }
609
+
610
+ this.loadingMoreFuture = true;
611
+ const requestedContact = this.contact;
612
+ const url = `/contact/events/${encodeURIComponent(
613
+ this.contact
614
+ )}/?after=${encodeURIComponent(this.nextAfter)}`;
615
+
616
+ try {
617
+ const response = await this.store.getUrl(url, { force: true });
618
+ if (this.contact !== requestedContact) {
619
+ return;
620
+ }
621
+ const page = response.json as EventsResponse;
622
+ const newFuture = page.future || [];
623
+ this.assignCampaignColors(newFuture);
624
+ this.newerEvents = [...this.newerEvents, ...newFuture];
625
+ this.nextAfter = page.next_after ?? null;
626
+ } catch {
627
+ if (this.contact !== requestedContact) {
628
+ return;
629
+ }
630
+ this.nextAfter = null;
631
+ } finally {
632
+ if (this.contact === requestedContact) {
633
+ this.loadingMoreFuture = false;
634
+ }
635
+ }
636
+ }
637
+
638
+ private renderTime(event: ScheduledEvent): TemplateResult {
639
+ // anchor durations to the server's "now" so they stay tolerant of clock
640
+ // skew and don't silently drift when the page is left open
641
+ return html`
642
+ <temba-tip text=${this.store.formatDate(event.scheduled)} position="top">
643
+ ${this.store.getShortDurationFromIso(event.scheduled, this.data?.now)}
644
+ </temba-tip>
645
+ `;
646
+ }
647
+
648
+ private renderEvent(event: ScheduledEvent): TemplateResult {
649
+ const clickable = event.type !== ScheduledEventType.SentBroadcast;
650
+ const isMessage = !!event.message;
651
+
652
+ // message events display the message text; flow-bearing events show a
653
+ // clickable flow pill linking to the flow (the "start" action is implied)
654
+ const body = isMessage
655
+ ? html`<div class="title-text">${event.message}</div>`
656
+ : event.flow
657
+ ? html`<temba-label
658
+ type="flow"
659
+ clickable
660
+ @click=${(e: Event) => this.handlePillClicked(e, event.flow)}
661
+ >${event.flow.name}</temba-label
662
+ >`
663
+ : html``;
664
+
665
+ return html`
666
+ <div
667
+ class="event ${clickable ? 'clickable' : ''}"
668
+ role=${clickable ? 'button' : 'presentation'}
669
+ tabindex=${clickable ? '0' : '-1'}
670
+ @click=${clickable ? () => this.handleEventClicked(event) : null}
671
+ @keydown=${clickable
672
+ ? (e: KeyboardEvent) =>
673
+ this.handleActivationKey(e, () => this.handleEventClicked(event))
674
+ : null}
675
+ >
676
+ <div class="title">${body}</div>
677
+ </div>
678
+ `;
679
+ }
680
+
681
+ private renderRow(
682
+ time: TemplateResult,
683
+ dot: TemplateResult,
684
+ content: TemplateResult,
685
+ first: boolean,
686
+ last: boolean
687
+ ): TemplateResult {
688
+ return html`
689
+ <div class="row">
690
+ <div class="time">${time}</div>
691
+ <div class="track">
692
+ <div class="line ${first ? 'hidden' : ''}"></div>
693
+ ${dot}
694
+ <div class="line ${last ? 'hidden' : ''}"></div>
695
+ </div>
696
+ ${content}
697
+ </div>
698
+ `;
699
+ }
700
+
701
+ private renderCampaigns(campaigns: ObjectReference[]): TemplateResult | null {
702
+ if (campaigns.length === 0) {
703
+ return null;
704
+ }
705
+ return html`
706
+ <div class="campaigns">
707
+ <div class="campaigns-label">${this.lang_campaigns_label}</div>
708
+ ${campaigns.map(
709
+ (campaign) =>
710
+ html`<div
711
+ class="campaign-pill"
712
+ style="--pill-hue:${this.getCampaignColor(campaign.uuid)}"
713
+ >
714
+ <span class="campaign-dot"></span>${campaign.name}
715
+ </div>`
716
+ )}
717
+ </div>
718
+ `;
719
+ }
720
+
721
+ public render(): TemplateResult {
722
+ if (!this.data) {
723
+ return html``;
724
+ }
725
+
726
+ // tolerate a malformed/empty response rather than throwing
727
+ const campaigns = Array.isArray(this.data.campaigns)
728
+ ? this.data.campaigns
729
+ : [];
730
+ const future = [
731
+ ...(Array.isArray(this.data.future) ? this.data.future : []),
732
+ ...this.newerEvents
733
+ ];
734
+ const pastDescending = [
735
+ ...(Array.isArray(this.data.past) ? this.data.past : []),
736
+ ...this.olderEvents
737
+ ];
738
+
739
+ if (
740
+ campaigns.length === 0 &&
741
+ future.length === 0 &&
742
+ pastDescending.length === 0
743
+ ) {
744
+ return html`<div class="empty">
745
+ <slot name="empty">${this.lang_empty}</slot>
746
+ </div>`;
747
+ }
748
+
749
+ // the timeline reads oldest to newest, top to bottom: an "older" pager,
750
+ // then past events, the "now" divider, upcoming events, and a "show more"
751
+ // pager at the bottom
752
+ type Row = {
753
+ kind: 'event' | 'now' | 'more-past' | 'more-future';
754
+ event?: ScheduledEvent;
755
+ past?: boolean;
756
+ };
757
+ const rows: Row[] = [];
758
+
759
+ if (this.nextBefore) {
760
+ rows.push({ kind: 'more-past' });
761
+ }
762
+ [...pastDescending]
763
+ .reverse()
764
+ .forEach((event) => rows.push({ kind: 'event', event, past: true }));
765
+ rows.push({ kind: 'now' });
766
+ future.forEach((event) => rows.push({ kind: 'event', event, past: false }));
767
+ if (this.nextAfter) {
768
+ rows.push({ kind: 'more-future' });
769
+ }
770
+
771
+ return html`
772
+ ${this.renderCampaigns(campaigns)}
773
+ <div class="timeline">
774
+ ${rows.map((row, idx) => {
775
+ const first = idx === 0;
776
+ const last = idx === rows.length - 1;
777
+
778
+ if (row.kind === 'now') {
779
+ return html`
780
+ <div class="row now-row">
781
+ <div class="time"></div>
782
+ <div class="track">
783
+ <div class="line ${first ? 'hidden' : ''}"></div>
784
+ <div class="dot now"></div>
785
+ <div class="line ${last ? 'hidden' : ''}"></div>
786
+ </div>
787
+ <div class="now-label">${this.lang_now}</div>
788
+ </div>
789
+ `;
790
+ }
791
+
792
+ if (row.kind === 'more-past') {
793
+ return html`
794
+ <div class="row pager-row ${this.loadingMore ? 'loading' : ''}">
795
+ <div class="time"></div>
796
+ <div class="track">
797
+ <div class="line ${first ? 'hidden' : ''}"></div>
798
+ <button
799
+ class="pager-dot ${this.loadingMore ? 'loading' : ''}"
800
+ @click=${this.handleShowOlder}
801
+ aria-label=${this.lang_show_older}
802
+ title=${this.lang_show_older}
803
+ >
804
+ <temba-icon name=${Icon.up_double} size="1"></temba-icon>
805
+ </button>
806
+ <div class="line ${last ? 'hidden' : ''}"></div>
807
+ </div>
808
+ <div
809
+ class="pager-label"
810
+ role="button"
811
+ tabindex="0"
812
+ aria-label=${this.lang_show_older}
813
+ @click=${this.handleShowOlder}
814
+ @keydown=${(e: KeyboardEvent) =>
815
+ this.handleActivationKey(e, () => this.handleShowOlder())}
816
+ title=${this.lang_show_older}
817
+ >
818
+ ${this.lang_more}
819
+ </div>
820
+ </div>
821
+ `;
822
+ }
823
+
824
+ if (row.kind === 'more-future') {
825
+ return html`
826
+ <div
827
+ class="row pager-row ${this.loadingMoreFuture ? 'loading' : ''}"
828
+ >
829
+ <div class="time"></div>
830
+ <div class="track">
831
+ <div class="line ${first ? 'hidden' : ''}"></div>
832
+ <button
833
+ class="pager-dot ${this.loadingMoreFuture ? 'loading' : ''}"
834
+ @click=${this.handleShowMore}
835
+ aria-label=${this.lang_show_more}
836
+ title=${this.lang_show_more}
837
+ >
838
+ <temba-icon name=${Icon.down_double} size="1"></temba-icon>
839
+ </button>
840
+ <div class="line ${last ? 'hidden' : ''}"></div>
841
+ </div>
842
+ <div
843
+ class="pager-label"
844
+ role="button"
845
+ tabindex="0"
846
+ aria-label=${this.lang_show_more}
847
+ @click=${this.handleShowMore}
848
+ @keydown=${(e: KeyboardEvent) =>
849
+ this.handleActivationKey(e, () => this.handleShowMore())}
850
+ title=${this.lang_show_more}
851
+ >
852
+ ${this.lang_more}
853
+ </div>
854
+ </div>
855
+ `;
856
+ }
857
+
858
+ const dotIcon = this.getDotIcon(row.event);
859
+ return this.renderRow(
860
+ this.renderTime(row.event),
861
+ html`<div
862
+ class="dot ${row.past ? 'past' : 'future'}"
863
+ style="--dot-color:${this.getColor(row.event)}"
864
+ >
865
+ ${dotIcon
866
+ ? html`<temba-icon name=${dotIcon} size="0.65"></temba-icon>`
867
+ : null}
868
+ </div>`,
869
+ this.renderEvent(row.event),
870
+ first,
871
+ last
872
+ );
873
+ })}
874
+ </div>
875
+ ${future.length > 0
876
+ ? html`<div class="projection-note">${this.lang_projected_info}</div>`
877
+ : null}
878
+ `;
879
+ }
880
+ }