Skip to content

Commit 2a9a99f

Browse files
committed
feat(signage-manager): add schedule view for displays and zones
1 parent 54941f1 commit 2a9a99f

7 files changed

Lines changed: 1295 additions & 251 deletions

File tree

apps/signage-manager/src/app/app.config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,13 @@ const APP_ROUTES: Routes = [
3636
(m) => m.PlaylistsSectionComponent,
3737
),
3838
},
39+
{
40+
path: 'schedules',
41+
loadComponent: () =>
42+
import('./schedules/schedules.component').then(
43+
(m) => m.SchedulesSectionComponent,
44+
),
45+
},
3946
{
4047
path: 'displays/:id',
4148
loadComponent: () =>

apps/signage-manager/src/app/displays/display-schedule.component.ts

Lines changed: 48 additions & 251 deletions
Large diffs are not rendered by default.
Lines changed: 361 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,361 @@
1+
import { DatePipe } from '@angular/common';
2+
import { Component, input, signal } from '@angular/core';
3+
import { MatRippleModule } from '@angular/material/core';
4+
import { MatTooltipModule } from '@angular/material/tooltip';
5+
import { RouterLink } from '@angular/router';
6+
import { IconComponent } from '@placeos/components';
7+
import { format, startOfDay } from 'date-fns';
8+
import {
9+
MINUTES_PER_DAY,
10+
ScheduleBlock,
11+
ScheduleTimelineRow,
12+
} from './signage-schedule.util';
13+
14+
@Component({
15+
selector: 'schedule-timeline',
16+
template: `
17+
<div timeline class="z-0 grid min-h-0 flex-1 overflow-auto">
18+
<div
19+
corner
20+
class="bg-base-100 border-base-300 sticky top-0 left-0 z-40 flex flex-col justify-end border-r border-b px-4 pb-2"
21+
>
22+
<div
23+
class="text-base-content/50 text-[10px] font-semibold tracking-[0.2em] uppercase"
24+
>
25+
{{ view_tab() === 'displays' ? 'Displays' : 'Zones' }}
26+
</div>
27+
</div>
28+
<div
29+
time-headers
30+
class="border-base-300 bg-base-100 sticky top-0 z-30 flex h-14 items-end border-b"
31+
[style.width]="timeline_width + 'rem'"
32+
>
33+
@for (hour of hours; track hour; let i = $index) {
34+
<div
35+
class="relative flex h-full items-end pb-2"
36+
[style.width]="block_width + 'rem'"
37+
>
38+
<div
39+
class="text-base-content/50 w-full text-center text-[10px] tabular-nums"
40+
>
41+
{{ formatHour(hour) }}
42+
</div>
43+
@if (i !== 0) {
44+
<div
45+
class="bg-base-300/60 absolute top-0 left-0 h-2.5 w-px"
46+
></div>
47+
}
48+
</div>
49+
}
50+
</div>
51+
<div
52+
row-headers
53+
class="border-base-300 bg-base-100 sticky left-0 z-40 border-r"
54+
[style.height]="rows().length * row_height + 'rem'"
55+
>
56+
@for (row of rows(); track row.id; let i = $index) {
57+
<div
58+
class="border-base-200 flex w-full items-center gap-2 border-b px-2 transition-colors duration-100 sm:gap-3 sm:px-3"
59+
[style.height]="row_height + 'rem'"
60+
[class.row-highlight]="hovered_row() === i"
61+
(mouseenter)="hovered_row.set(i)"
62+
(mouseleave)="clearHoveredRow(i)"
63+
>
64+
<div
65+
class="bg-base-content/6 hidden h-8 w-8 shrink-0 items-center justify-center rounded-md sm:flex"
66+
>
67+
<icon class="text-base-content/50 text-base">{{
68+
row.icon
69+
}}</icon>
70+
</div>
71+
<div class="min-w-0 flex-1">
72+
<a
73+
class="block truncate text-xs font-medium hover:underline sm:text-sm"
74+
[routerLink]="row.route"
75+
>
76+
{{ row.name }}
77+
</a>
78+
<div
79+
class="text-base-content/50 truncate text-[10px] sm:text-[11px]"
80+
>
81+
{{ row.subtitle }}
82+
</div>
83+
</div>
84+
<div
85+
class="bg-base-content/6 text-base-content/60 hidden rounded-md px-1.5 py-0.5 text-[10px] font-semibold tabular-nums sm:block"
86+
>
87+
{{ row.blocks.length }}
88+
</div>
89+
</div>
90+
}
91+
</div>
92+
<div
93+
timeline-grid
94+
class="relative z-0 overflow-hidden"
95+
[style.width]="timeline_width + 'rem'"
96+
[style.height]="rows().length * row_height + 'rem'"
97+
>
98+
@for (hour of hours; track hour; let i = $index) {
99+
<div
100+
class="bg-base-content/6 absolute top-0 h-full w-px"
101+
[style.left]="i * block_width + 'rem'"
102+
></div>
103+
}
104+
@for (row of rows(); track row.id; let i = $index) {
105+
<div
106+
class="absolute left-0 w-full transition-colors duration-100"
107+
[style.top]="i * row_height + 'rem'"
108+
[style.height]="row_height + 'rem'"
109+
[class.row-highlight]="hovered_row() === i"
110+
(mouseenter)="hovered_row.set(i)"
111+
(mouseleave)="clearHoveredRow(i)"
112+
></div>
113+
<div
114+
class="border-base-content/6 absolute left-0 w-full border-b"
115+
[style.top]="i * row_height + row_height + 'rem'"
116+
></div>
117+
118+
@if (!row.blocks.length) {
119+
<div
120+
class="text-base-content/30 pointer-events-none absolute left-4 flex items-center gap-1.5 text-[11px]"
121+
[style.top]="i * row_height + 'rem'"
122+
[style.height]="row_height + 'rem'"
123+
>
124+
<icon class="text-sm">event_busy</icon>
125+
No playlists scheduled
126+
</div>
127+
}
128+
129+
@for (
130+
block of row.blocks;
131+
track block.playlist.id +
132+
'_' +
133+
block.start_minutes +
134+
'_' +
135+
(block.source_label || '') +
136+
'_' +
137+
$index
138+
) {
139+
<a
140+
matRipple
141+
class="schedule-block absolute z-10 text-left"
142+
[style.left]="
143+
timeToOffset(block.start_minutes) + '%'
144+
"
145+
[style.top]="i * row_height + 0.375 + 'rem'"
146+
[style.width]="
147+
durationToOffset(visibleDuration(block)) + '%'
148+
"
149+
[style.height]="row_height - 0.75 + 'rem'"
150+
[style.min-width.rem]="2"
151+
[routerLink]="['/playlists', block.playlist.id]"
152+
[matTooltip]="blockTooltip(row, block)"
153+
[attr.aria-label]="blockAriaLabel(row, block)"
154+
(mouseenter)="hovered_row.set(i)"
155+
(mouseleave)="clearHoveredRow(i)"
156+
>
157+
<div
158+
class="relative flex h-full w-full flex-col overflow-hidden rounded-md border px-2 py-1"
159+
[style.background-color]="
160+
blockBackgroundColor(block)
161+
"
162+
[style.color]="blockTextColor(block)"
163+
[style.border-color]="blockBorderColor(block)"
164+
[class.border-dashed]="
165+
block.source_type === 'zone' &&
166+
view_tab() === 'displays'
167+
"
168+
>
169+
<div
170+
class="truncate text-[11px] leading-tight font-semibold"
171+
>
172+
{{ block.playlist.name }}
173+
</div>
174+
<div
175+
class="truncate text-[10px] leading-tight opacity-70"
176+
>
177+
{{
178+
block.all_day ? 'All day' : block.label
179+
}}
180+
</div>
181+
@if (requiresApproval(block)) {
182+
<div
183+
class="mt-auto truncate text-[10px] leading-tight font-medium"
184+
>
185+
Awaiting approval
186+
</div>
187+
}
188+
@if (
189+
block.source_label &&
190+
view_tab() === 'displays'
191+
) {
192+
<div
193+
class="mt-auto truncate text-[10px] leading-tight opacity-60"
194+
>
195+
{{
196+
block.source_type === 'display'
197+
? 'Direct'
198+
: 'via ' + block.source_label
199+
}}
200+
</div>
201+
}
202+
</div>
203+
</a>
204+
}
205+
}
206+
207+
@if (show_current_time()) {
208+
<div
209+
class="pointer-events-none absolute inset-y-0 z-30"
210+
[style.left]="timeToOffset(current_minutes()) + '%'"
211+
>
212+
<div
213+
class="bg-error absolute -top-0.5 left-1/2 h-2 w-2 -translate-x-1/2 rounded-full"
214+
></div>
215+
<div class="bg-error h-full w-0.5"></div>
216+
</div>
217+
}
218+
</div>
219+
</div>
220+
`,
221+
styles: [
222+
`
223+
:host {
224+
display: flex;
225+
min-height: 0;
226+
flex: 1;
227+
}
228+
229+
[timeline] {
230+
grid-template-columns: 9rem auto;
231+
grid-template-rows: 3.5rem auto;
232+
}
233+
234+
@media (min-width: 640px) {
235+
[timeline] {
236+
grid-template-columns: 16rem auto;
237+
}
238+
}
239+
240+
.row-highlight {
241+
background-color: color-mix(
242+
in srgb,
243+
var(--info) 6%,
244+
transparent
245+
);
246+
}
247+
248+
.schedule-block {
249+
transition:
250+
transform 120ms ease,
251+
z-index 0ms;
252+
}
253+
.schedule-block:hover {
254+
z-index: 20;
255+
transform: scaleY(1.04);
256+
}
257+
.schedule-block > div {
258+
box-shadow: 0 1px 2px rgb(0 0 0 / 0.06);
259+
transition: box-shadow 120ms ease;
260+
}
261+
.schedule-block:hover > div {
262+
box-shadow: 0 3px 8px rgb(0 0 0 / 0.12);
263+
}
264+
`,
265+
],
266+
imports: [
267+
DatePipe,
268+
MatRippleModule,
269+
MatTooltipModule,
270+
RouterLink,
271+
IconComponent,
272+
],
273+
})
274+
export class ScheduleTimelineComponent {
275+
public readonly rows = input<ScheduleTimelineRow[]>([]);
276+
public readonly view_tab = input<'displays' | 'zones'>('displays');
277+
public readonly selected_date = input.required<Date>();
278+
public readonly current_minutes = input(0);
279+
public readonly show_current_time = input(false);
280+
public readonly playlist_approval_status = input<Record<string, boolean>>(
281+
{},
282+
);
283+
284+
public readonly block_width = 6;
285+
public readonly row_height = 4;
286+
public readonly hours = Array.from({ length: 24 }, (_, index) => index);
287+
public readonly timeline_width = this.hours.length * this.block_width;
288+
public readonly hovered_row = signal(-1);
289+
290+
public clearHoveredRow(index: number) {
291+
if (this.hovered_row() === index) this.hovered_row.set(-1);
292+
}
293+
294+
public formatHour(hour: number) {
295+
const date = startOfDay(new Date());
296+
date.setHours(hour);
297+
return format(date, 'haaa').replace('AM', 'am').replace('PM', 'pm');
298+
}
299+
300+
public timeToOffset(minutes: number) {
301+
return +((Math.max(0, minutes) / MINUTES_PER_DAY) * 100).toFixed(2);
302+
}
303+
304+
public durationToOffset(duration: number) {
305+
return +(
306+
(Math.min(MINUTES_PER_DAY, Math.max(duration, 0)) /
307+
MINUTES_PER_DAY) *
308+
100
309+
).toFixed(2);
310+
}
311+
312+
public visibleDuration(block: ScheduleBlock) {
313+
return block.all_day
314+
? MINUTES_PER_DAY
315+
: Math.max(
316+
15,
317+
Math.min(
318+
block.duration_minutes,
319+
MINUTES_PER_DAY - block.start_minutes,
320+
),
321+
);
322+
}
323+
324+
public requiresApproval(block: ScheduleBlock) {
325+
const approvals = this.playlist_approval_status();
326+
return block.playlist.id in approvals && !approvals[block.playlist.id];
327+
}
328+
329+
public blockBackgroundColor(block: ScheduleBlock) {
330+
return this.requiresApproval(block) ? '#fef3c7' : block.bg_color;
331+
}
332+
333+
public blockTextColor(block: ScheduleBlock) {
334+
return this.requiresApproval(block) ? '#92400e' : block.text_color;
335+
}
336+
337+
public blockBorderColor(block: ScheduleBlock) {
338+
return this.requiresApproval(block) ? '#f59e0b' : block.text_color;
339+
}
340+
341+
public blockTooltip(row: ScheduleTimelineRow, block: ScheduleBlock) {
342+
const source =
343+
block.source_label && this.view_tab() === 'displays'
344+
? `\nSource: ${
345+
block.source_type === 'display'
346+
? 'Display'
347+
: block.source_label
348+
}`
349+
: '';
350+
const approval = this.requiresApproval(block)
351+
? '\nStatus: Awaiting Approval'
352+
: '';
353+
return `${row.name}\nPlaylist: ${block.playlist.name}\nTime: ${block.all_day ? 'All day' : block.label}${source}${approval}`;
354+
}
355+
356+
public blockAriaLabel(row: ScheduleTimelineRow, block: ScheduleBlock) {
357+
return `${row.name}, ${block.playlist.name}, ${
358+
block.all_day ? 'all day' : block.label
359+
}`;
360+
}
361+
}

0 commit comments

Comments
 (0)