diff --git a/src/pretix/presale/templates/pretixpresale/fragment_day_calendar.html b/src/pretix/presale/templates/pretixpresale/fragment_day_calendar.html new file mode 100644 index 0000000000..07d07c1b2c --- /dev/null +++ b/src/pretix/presale/templates/pretixpresale/fragment_day_calendar.html @@ -0,0 +1,123 @@ +{% load i18n %} +{% load eventurl %} +
+ + + {% for series, collection in collections %} +

+ {% if series %} + + {{ series.name }} + + {% else %} + {% trans "Single events" context "day calendar" %} + {% endif %} +

+ + {% endfor %} +
diff --git a/src/pretix/presale/templates/pretixpresale/organizers/calendar.html b/src/pretix/presale/templates/pretixpresale/organizers/calendar.html index ffcc83e39e..3767f331b1 100644 --- a/src/pretix/presale/templates/pretixpresale/organizers/calendar.html +++ b/src/pretix/presale/templates/pretixpresale/organizers/calendar.html @@ -20,21 +20,26 @@
diff --git a/src/pretix/presale/templates/pretixpresale/organizers/calendar_day.html b/src/pretix/presale/templates/pretixpresale/organizers/calendar_day.html new file mode 100644 index 0000000000..80b18fa07a --- /dev/null +++ b/src/pretix/presale/templates/pretixpresale/organizers/calendar_day.html @@ -0,0 +1,101 @@ +{% extends "pretixpresale/organizers/base.html" %} +{% load i18n %} +{% load rich_text %} +{% load eventurl %} +{% load urlreplace %} +{% block title %}{% trans "Event overview" %}{% endblock %} +{% block content %} + {% if organizer_homepage_text %} +
+ {{ organizer_homepage_text | rich_text }} +
+ {% endif %} +

{{ date|date:"DATE_FORMAT" }}

+
+ {% for f, v in request.GET.items %} + {% if f != "date" %} + + {% endif %} + {% endfor %} + +
+ {% include "pretixpresale/fragment_day_calendar.html" with show_avail=request.organizer.settings.event_list_availability %} +
+ {% if has_before %} + + + {{ before|date:"SHORT_DATE_FORMAT" }} + + {% endif %} + {% if has_after %} + + {{ after|date:"SHORT_DATE_FORMAT" }} + + + {% endif %} +
+ + {% if multiple_timezones %} +
+ {% blocktrans trimmed %} + Note that the events in this view are in different timezones. + {% endblocktrans %} +
+ {% endif %} +{% endblock %} diff --git a/src/pretix/presale/templates/pretixpresale/organizers/calendar_week.html b/src/pretix/presale/templates/pretixpresale/organizers/calendar_week.html index 84a07195a6..3d867af07a 100644 --- a/src/pretix/presale/templates/pretixpresale/organizers/calendar_week.html +++ b/src/pretix/presale/templates/pretixpresale/organizers/calendar_week.html @@ -21,22 +21,27 @@
diff --git a/src/pretix/presale/templates/pretixpresale/organizers/index.html b/src/pretix/presale/templates/pretixpresale/organizers/index.html index e8435df0af..55c9ea4b51 100644 --- a/src/pretix/presale/templates/pretixpresale/organizers/index.html +++ b/src/pretix/presale/templates/pretixpresale/organizers/index.html @@ -28,21 +28,26 @@ {% endif %}
diff --git a/src/pretix/presale/views/organizer.py b/src/pretix/presale/views/organizer.py index 8c057bc5cf..5b2797bc82 100644 --- a/src/pretix/presale/views/organizer.py +++ b/src/pretix/presale/views/organizer.py @@ -31,13 +31,15 @@ # Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations under the License. - import calendar import hashlib +import math from collections import defaultdict from datetime import date, datetime, time, timedelta +from functools import reduce from urllib.parse import quote +import dateutil import isoweek import pytz from django.conf import settings @@ -376,6 +378,10 @@ class OrganizerIndex(OrganizerViewMixin, EventListMixin, ListView): cv = CalendarView() cv.request = request return cv.get(request, *args, **kwargs) + elif style == "day": + cv = DayCalendarView() + cv.request = request + return cv.get(request, *args, **kwargs) elif style == "week": cv = WeekCalendarView() cv.request = request @@ -441,6 +447,11 @@ def add_events_for_days(request, baseqs, before, after, ebd, timezones): )) and event.settings.show_times else None ), + 'time_end_today': ( + datetime_to.time().replace(tzinfo=None) + if date_to == d and event.settings.show_times + else None + ), 'url': eventreverse(event, 'presale:event.index'), 'timezone': event.settings.timezone, }) @@ -516,6 +527,11 @@ def add_subevents_for_days(qs, before, after, ebd, timezones, event=None, cart_n )) and s.show_times else None ), + 'time_end_today': ( + datetime_to.time().replace(tzinfo=None) + if date_to == d and s.show_times + else None + ), 'event': se, 'url': ( eventreverse(se.event, 'presale:event.redeem', @@ -714,6 +730,334 @@ class WeekCalendarView(OrganizerViewMixin, EventListMixin, TemplateView): return ebd +class DayCalendarView(OrganizerViewMixin, EventListMixin, TemplateView): + template_name = 'pretixpresale/organizers/calendar_day.html' + + def _set_date_to_next_event(self): + next_ev = filter_qs_by_attr(Event.objects.using(settings.DATABASE_REPLICA).filter( + Q(date_from__gte=now()) | Q(date_to__isnull=False, date_to__gte=now()), + organizer=self.request.organizer, + live=True, + is_public=True, + date_from__gte=now(), + ), self.request).order_by('date_from').first() + next_sev = filter_qs_by_attr(SubEvent.objects.using(settings.DATABASE_REPLICA).filter( + Q(date_from__gte=now()) | Q(date_to__isnull=False, date_to__gte=now()), + event__organizer=self.request.organizer, + event__is_public=True, + event__live=True, + active=True, + is_public=True, + ), self.request).select_related('event').order_by('date_from').first() + + datetime_from = None + if (next_ev and next_sev and next_sev.date_from < next_ev.date_from) or (next_sev and not next_ev): + datetime_from = next_sev.date_from + next_ev = next_sev.event + elif next_ev: + datetime_from = next_ev.date_from + + if datetime_from: + self.tz = pytz.timezone(next_ev.settings.timezone) + self.date = datetime_from.astimezone(self.tz).date() + else: + self.tz = self.request.organizer.timezone + self.date = now().astimezone(self.tz).date() + + def _set_date(self): + if 'date' in self.request.GET: + self.tz = self.request.organizer.timezone + try: + self.date = dateutil.parser.parse(self.request.GET.get('date')).date() + except ValueError: + self.date = now().astimezone(self.tz).date() + else: + self._set_date_to_next_event() + + def get(self, request, *args, **kwargs): + self._set_date() + return super().get(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data() + + before = datetime( + self.date.year, self.date.month, self.date.day, 0, 0, 0, tzinfo=UTC + ) - timedelta(days=1) + after = datetime( + self.date.year, self.date.month, self.date.day, 0, 0, 0, tzinfo=UTC + ) + timedelta(days=1) + + ctx['date'] = self.date + ctx['cal_tz'] = self.tz + ctx['before'] = before + ctx['after'] = after + + ctx['has_before'], ctx['has_after'] = has_before_after( + self.request.organizer.events.filter( + sales_channels__contains=self.request.sales_channel.identifier + ), + SubEvent.objects.filter( + event__organizer=self.request.organizer, + event__is_public=True, + event__live=True, + event__sales_channels__contains=self.request.sales_channel.identifier + ), + before, + after, + ) + + ebd = self._events_by_day(before, after) + if not ebd[self.date]: + return ctx + + events = ebd[self.date] + shortest_duration = self._get_shortest_duration(events).total_seconds() // 60 + # pick the next biggest tick_duration based on shortest_duration, max. 180 minutes + tick_duration = next((d for d in [5, 10, 15, 30, 60, 120, 180] if d >= shortest_duration), 180) + + raster_size = min(self._get_raster_size(events), tick_duration) + events, start, end = self._rasterize_events(events, tick_duration=tick_duration, raster_size=raster_size) + calendar_duration = self._get_time_duration(start, end) + ctx["calendar_duration"] = self._format_duration(calendar_duration) + ctx['time_ticks'] = self._get_time_ticks(start, end, tick_duration) + ctx['start'] = datetime.combine(self.date, start) + ctx['raster_size'] = raster_size + # ctx['end'] = end + # size of each grid-column is based on shortest event duration and raster_size + # raster_size is based on start/end times, so it could happen we have a small raster but long running events + # raster_size will always be smaller or equals tick_duration + ctx['raster_to_shortest_ratio'] = round((8 * raster_size) / shortest_duration) + + ctx['events'] = events + + events_by_series = self._grid_for_template(events) + ctx['collections'] = events_by_series + ctx['no_headlines'] = not any([series for series, events in events_by_series]) + ctx['multiple_timezones'] = self._multiple_timezones + return ctx + + def _get_raster_size(self, events): + # get best raster-size for min. # of columns in grid + # due to grid-col-calculations in CSS raster_size cannot be bigger than 60 (minutes) + + # all start- and end-times (minute-part) except full hour + times = [ + e["time"].minute for e in events if e["time"] and e["time"].minute + ] + [ + e["time_end_today"].minute for e in events if "time_end_today" in e and e["time_end_today"] and e["time_end_today"].minute + ] + if not times: + # no time other than full hour, so raster can be 1 hour/60 minutes + return 60 + gcd = reduce(math.gcd, set(times)) + return next((d for d in [5, 10, 15, 30, 60] if d >= gcd), 60) + + def _get_time_duration(self, start, end): + midnight = time(0, 0) + return datetime.combine( + self.date if end != midnight else self.date + timedelta(days=1), + end + ) - datetime.combine( + self.date, + start + ) + + def _format_duration(self, duration): + return ":".join([ + "%02d" % i for i in ( + (duration.days * 24) + (duration.seconds // 3600), + (duration.seconds // 60) % 60 + ) + ]) + + def _floor_time(self, t, raster_size=5): + # raster_size based on minutes, might be factored into a helper class with a timedelta as raster + minutes = t.hour * 60 + t.minute + if minutes % raster_size: + minutes = (minutes // raster_size) * raster_size + return t.replace(hour=minutes // 60, minute=minutes % 60) + return t + + def _ceil_time(self, t, raster_size=5): + # raster_size based on minutes, might be factored into a helper class with a timedelta as raster + minutes = t.hour * 60 + t.minute + if not minutes % raster_size: + return t + minutes = math.ceil(minutes / raster_size) * raster_size + minute = minutes % 60 + hour = minutes // 60 + if hour > 23: + hour = hour % 24 + return t.replace(minute=minute, hour=hour) + + def _rasterize_events(self, events, tick_duration, raster_size=5): + rastered_events = [] + start, end = self._get_time_range(events) + start = self._floor_time(start, raster_size=tick_duration) + end = self._ceil_time(end, raster_size=tick_duration) + + midnight = time(0, 0) + for e in events: + e["offset_shift_start"] = 0 + if e["continued"]: + e["time_rastered"] = midnight + elif e["time"].minute % raster_size: + e["time_rastered"] = e["time"].replace(minute=(e["time"].minute // raster_size) * raster_size) + e["offset_shift_start"] = e["time"].minute % raster_size + else: + e["time_rastered"] = e["time"] + + e["offset_shift_end"] = 0 + if "time_end_today" in e and e["time_end_today"]: + if e["time_end_today"].minute % raster_size: + minute = math.ceil(e["time_end_today"].minute / raster_size) * raster_size + hour = e["time_end_today"].hour + if minute > 59: + minute = minute % 60 + hour = (hour + 1) % 24 + e["time_end_today_rastered"] = e["time_end_today"].replace(minute=minute, hour=hour) + e["offset_shift_end"] = raster_size - e["time_end_today"].minute % raster_size + else: + e["time_end_today_rastered"] = e["time_end_today"] + else: + e["time_end_today"] = e["time_end_today_rastered"] = time(0, 0) + + e["duration_rastered"] = self._format_duration(datetime.combine( + self.date if e["time_end_today_rastered"] != midnight else self.date + timedelta(days=1), + e["time_end_today_rastered"] + ) - datetime.combine( + self.date, + e['time_rastered'] + )) + + e["offset_rastered"] = datetime.combine(self.date, time(0, 0)) + self._get_time_duration(start, e["time_rastered"]) + + rastered_events.append(e) + + return rastered_events, start, end + + def _get_shortest_duration(self, events): + midnight = time(0, 0) + durations = [ + datetime.combine( + self.date if e.get('time_end_today') and e['time_end_today'] != midnight else self.date + timedelta(days=1), + e['time_end_today'] if e.get('time_end_today') else time(0, 0) + ) + - + datetime.combine( + self.date, + time(0, 0) if e['continued'] else e['time'] + ) + for e in events + ] + return min([d for d in durations]) + + def _get_time_range(self, events): + if any(e['continued'] for e in events) or any(e['time'] is None for e in events): + starting_at = time(0, 0) + else: + starting_at = min(e['time'] for e in events) + + if any(e.get('time_end_today') is None for e in events): + ending_at = time(0, 0) + else: + ending_at = max(e['time_end_today'] for e in events) + + return starting_at, ending_at + + def _get_time_ticks(self, start, end, tick_duration): + ticks = [] + tick_duration = timedelta(minutes=tick_duration) + + # convert time to datetime for timedelta calc + start = datetime.combine(self.date, start) + end = datetime.combine(self.date, end) + if end <= start: + end = end + timedelta(days=1) + + tick_start = start + offset = datetime.utcfromtimestamp(0) + duration = datetime.utcfromtimestamp(tick_duration.total_seconds()) + while tick_start < end: + tick = { + "start": tick_start, + "duration": duration, + "offset": offset, + } + ticks.append(tick) + tick_start += tick_duration + offset += tick_duration + + return ticks + + def _grid_for_template(self, events): + midnight = time(0, 0) + rows_by_collection = defaultdict(list) + + # We sort the events into "collections": all subevents from the same + # event series together and all non-series events into a "None" + # collection. Then, we look if there's already an event in the + # collection that overlaps, in which case we need to split the + # collection into multiple rows. + for counter, e in enumerate(events): + collection = e['event'].event if isinstance(e['event'], SubEvent) else None + + placed_in_row = False + for row in rows_by_collection[collection]: + if any( + (e['time_rastered'] < o['time_end_today_rastered'] or o['time_end_today_rastered'] == midnight) and + (o['time_rastered'] < e['time_end_today_rastered'] or e['time_end_today_rastered'] == midnight) + for o in row + ): + continue + row.append(e) + placed_in_row = True + break + + if not placed_in_row: + rows_by_collection[collection].append([e]) + + # flatten rows to one stream of events with attribute row + # for better keyboard-tab-order in html + for collection in rows_by_collection: + for i, row in enumerate(rows_by_collection[collection]): + concurrency = i + 1 + for e in row: + e["concurrency"] = concurrency + rows_by_collection[collection] = { + "concurrency": len(rows_by_collection[collection]), + "events": sorted([e for row in rows_by_collection[collection] for e in row], key=lambda d: d['time'] or time(0, 0)), + } + + def sort_key(c): + collection, row = c + if collection is None: + return '' + else: + return str(collection.name) + return sorted(rows_by_collection.items(), key=sort_key) + + def _events_by_day(self, before, after): + ebd = defaultdict(list) + timezones = set() + add_events_for_days(self.request, Event.annotated(self.request.organizer.events, 'web').using( + settings.DATABASE_REPLICA + ).filter( + sales_channels__contains=self.request.sales_channel.identifier + ), before, after, ebd, timezones) + add_subevents_for_days(filter_qs_by_attr(SubEvent.annotated(SubEvent.objects.filter( + event__organizer=self.request.organizer, + event__is_public=True, + event__live=True, + event__sales_channels__contains=self.request.sales_channel.identifier + ).prefetch_related( + 'event___settings_objects', 'event__organizer___settings_objects' + )), self.request).using(settings.DATABASE_REPLICA), before, after, ebd, timezones) + self._multiple_timezones = len(timezones) > 1 + return ebd + + @method_decorator(cache_page(300), name='dispatch') class OrganizerIcalDownload(OrganizerViewMixin, View): def get(self, request, *args, **kwargs): diff --git a/src/pretix/static/pretixpresale/js/ui/main.js b/src/pretix/static/pretixpresale/js/ui/main.js index 3c3c13f8e4..a1d8fa3e70 100644 --- a/src/pretix/static/pretixpresale/js/ui/main.js +++ b/src/pretix/static/pretixpresale/js/ui/main.js @@ -315,7 +315,11 @@ $(function () { $("#monthselform select").change(function () { $(this).closest("form").get(0).submit(); }); - + $("#monthselform input").on("dp.change", function () { + if ($(this).data("DateTimePicker")) { // prevent submit after dp init + this.form.submit(); + } + }); var update_cart_form = function () { var is_enabled = $(".product-row input[type=checkbox]:checked, .variations input[type=checkbox]:checked, .product-row input[type=radio]:checked, .variations input[type=radio]:checked").length; if (!is_enabled) { @@ -468,9 +472,11 @@ $(function () { $("span[data-timezone], small[data-timezone]").each(function() { var t = moment.tz($(this).attr("data-time"), $(this).attr("data-timezone")) var tz = moment.tz.zone($(this).attr("data-timezone")) + var tpl = ''; $(this).tooltip({ - 'title': gettext("Time zone:") + " " + tz.abbr(t) + "title": gettext("Time zone:") + " " + tz.abbr(t), + "template": tpl }); if (t.tz(tz.name).format() !== t.tz(local_tz).format()) { var $add = $("") @@ -488,7 +494,8 @@ $(function () { } $add.insertAfter($(this)); $add.tooltip({ - 'title': gettext("Time zone:") + " " + moment.tz.zone(local_tz).abbr(t), + "title": gettext("Time zone:") + " " + moment.tz.zone(local_tz).abbr(t), + "template": tpl }); } }); @@ -501,6 +508,80 @@ $(function () { }); } + // Day calendar + $(".day-calendar [data-concurrency]").each(function() { + var c = parseInt(this.getAttribute("data-concurrency"), 10); + if (c > 9) this.style.setProperty('--concurrency', c); + }) + $(".day-calendar").each(function() { + var timezone = this.getAttribute("data-timezone"); + var startTime = moment.tz(this.getAttribute("data-start"), timezone); + + var currentTime = moment().tz(timezone); + if (!currentTime.isSame(startTime, 'day')) { + // Not on same day + return; + } + + // scroll to best matching tick + var currentTimeCmp = parseInt(currentTime.format("Hmm"), 10); + var ticks = this.querySelectorAll(".ticks li"); + var currentTick; + var t; + for (var i=0, max=ticks.length; i < max; i++) { + currentTick = ticks[i] + t = parseInt(currentTick.getAttribute("data-start").replace(":", ""), 10); + if (t > currentTimeCmp) { + break; + } + } + currentTick.scrollIntoView({behavior:"smooth", inline: "center"}); + + + var thisCalendar = this; + var currentTimeInterval; + + var timeFormat = document.body.getAttribute("data-timeformat"); + var timeFormatParts = timeFormat.match(/([a-zA-Z_\s]+)([^a-zA-Z_\s])(.*)/); + if (!timeFormatParts) timeFormatParts = [timeFormat]; + if (timeFormatParts.length > 1) timeFormatParts.shift(); + var currentTimeBar = $('').appendTo(this); + var currentTimeDisplay = currentTimeBar.find("time"); + var currentTimeDisplayParts = []; + timeFormatParts.forEach(function(format) { + currentTimeDisplayParts.push([format, $("").appendTo(currentTimeDisplay)]) + }); + var duration = this.getAttribute("data-duration").split(":").reduce(function(previousValue, currentValue, currentIndex) { + return previousValue + (currentIndex ? parseInt(currentValue, 10) * 60 : parseInt(currentValue, 10) * 60 * 60); + }, 0); + function setCurrentTimeBar() { + var currentTime = moment().tz(timezone); + var currentTimeDelta = Math.floor((currentTime - startTime)/1000); + if (currentTimeDelta < 0 || currentTimeDelta > duration) { + // Too early || Too late + window.clearInterval(currentTimeInterval); + currentTimeBar.remove(); + return; + } + + var offset = thisCalendar.querySelector("h3").getBoundingClientRect().width; + var dx = Math.round(offset + (thisCalendar.scrollWidth-offset)*(currentTimeDelta/duration)); + currentTimeDisplayParts.forEach(function(part) { + part[1].text(currentTime.format(part[0])); + }); + if (currentTimeDisplay.get(0).getBoundingClientRect().width + dx >= thisCalendar.scrollWidth) { + currentTimeBar.addClass("swap-side"); + } + else { + currentTimeBar.removeClass("swap-side"); + } + thisCalendar.style.setProperty('--current-time-offset', dx + "px"); + } + currentTimeInterval = window.setInterval(setCurrentTimeBar, 15*1000); + $(window).on("resize", setCurrentTimeBar); + setCurrentTimeBar(); + }); + // Lightbox lightbox.init(); }); diff --git a/src/pretix/static/pretixpresale/scss/_calendar.scss b/src/pretix/static/pretixpresale/scss/_calendar.scss index 866470fb40..b082a4e53d 100644 --- a/src/pretix/static/pretixpresale/scss/_calendar.scss +++ b/src/pretix/static/pretixpresale/scss/_calendar.scss @@ -1,4 +1,4 @@ -.table-calendar, .week-calendar { +.table-calendar, .week-calendar, .day-calendar { td, th { width: 14.29%; } @@ -113,6 +113,266 @@ display: none; } } + + + + +.day-calendar { + position: relative; + display: grid; + grid-template-columns: 1fr 7fr; + gap: 0; + overflow-x: auto; + margin-bottom: 10px; + padding-bottom: 16px; /* use padding to make space for link focus-outline and overlayed scrollbar */ +} +.cal-size-8 {--col-min-size: 9em} +.cal-size-7 {--col-min-size: 7.8em} +.cal-size-6 {--col-min-size: 6.6em} +.cal-size-5 {--col-min-size: 5.4em} +.cal-size-4 {--col-min-size: 4.2em} +.cal-size-3 {--col-min-size: 3em} +.cal-size-2 {--col-min-size: 1.8em} +.cal-size-1 {--col-min-size: 0.6em} +.cal-size-0 {--col-min-size: 0.6em} + +.day-calendar .current-time-bar { + position: absolute; + top: 4px; + left: -2px; + height: calc(100% - 10px); /* remove 5px padding for link focus-outline */ + width: 1px; + background-color: $brand-primary; + outline: 1px solid #fff; + transform: translateX(var(--current-time-offset, 0)); +} +[dir=rtl] .day-calendar .current-time-bar { + left: auto; + right: -2px; + transform: translateX(calc(-1 * var(--current-time-offset, 0))); +} +.day-calendar .current-time-bar time { + background: $brand-primary; + position: absolute; + top: 0; + color: #fff; + padding: 1px 3px; + white-space: nowrap; + text-align: center; +} +.day-calendar .current-time-bar time, +[dir=rtl] .day-calendar .current-time-bar.swap-side time { + left: 0; +} +.day-calendar .current-time-bar.swap-side time, +[dir=rtl] .day-calendar .current-time-bar time { + left: auto; + right: 0; +} + + +.day-calendar.no-headlines { + display: block; +} +.day-calendar.no-headlines h3 { + display: none; +} + + +.day-calendar .current-time-bar time span:nth-child(2) { + animation: watch-blinker 2s step-end infinite; +} +@-webkit-keyframes watch-blinker { + 0% { visibility: visible; } + 75% { visibility: hidden; } +} + +@-moz-keyframes watch-blinker { + 0% { visibility: visible; } + 75% { visibility: hidden; } +} + +@keyframes watch-blinker { + 0% { visibility: visible; } + 75% { visibility: hidden; } +} + +.day-row-name { + position: sticky; + left: 0; + margin: 0; + padding: 3px 8px 3px 0; + background-color: rgba(255,255,255,.9); + white-space: nowrap; + z-index: 10; +} + +.day-calendar a.event { + margin: 1px; +} +.day-calendar a.event .event-time { + display: inline; +} + +[data-raster-size="5"] {--raster-size: 5} +[data-raster-size="10"] {--raster-size: 10} +[data-raster-size="15"] {--raster-size: 15} +[data-raster-size="30"] {--raster-size: 30} +[data-raster-size="60"] {--raster-size: 60} + +/* +concurrency has two meanings: + on .day-timeline it means total nummber concurrently running events at any given time + on .day-timeline>li it means the current concurrency of this li-element +if concurrency is higher than 9, JavaScript (currently in pretixpresale/js/ui/main.js) sets --concurrency accordingly +*/ +[data-concurrency="1"] {--concurrency: 1} +[data-concurrency="2"] {--concurrency: 2} +[data-concurrency="3"] {--concurrency: 3} +[data-concurrency="4"] {--concurrency: 4} +[data-concurrency="5"] {--concurrency: 5} +[data-concurrency="6"] {--concurrency: 6} +[data-concurrency="7"] {--concurrency: 7} +[data-concurrency="8"] {--concurrency: 8} +[data-concurrency="9"] {--concurrency: 9} + + +[data-offset^="01"] {--offset-hour: 1} +[data-offset^="02"] {--offset-hour: 2} +[data-offset^="03"] {--offset-hour: 3} +[data-offset^="04"] {--offset-hour: 4} +[data-offset^="05"] {--offset-hour: 5} +[data-offset^="06"] {--offset-hour: 6} +[data-offset^="07"] {--offset-hour: 7} +[data-offset^="08"] {--offset-hour: 8} +[data-offset^="09"] {--offset-hour: 9} +[data-offset^="10"] {--offset-hour: 10} +[data-offset^="11"] {--offset-hour: 11} +[data-offset^="12"] {--offset-hour: 12} +[data-offset^="13"] {--offset-hour: 13} +[data-offset^="14"] {--offset-hour: 14} +[data-offset^="15"] {--offset-hour: 15} +[data-offset^="16"] {--offset-hour: 16} +[data-offset^="17"] {--offset-hour: 17} +[data-offset^="18"] {--offset-hour: 18} +[data-offset^="19"] {--offset-hour: 19} +[data-offset^="20"] {--offset-hour: 20} +[data-offset^="21"] {--offset-hour: 21} +[data-offset^="22"] {--offset-hour: 22} +[data-offset^="23"] {--offset-hour: 23} +[data-offset^="24"] {--offset-hour: 24} + +[data-offset$="05"] {--offset-minute: 5} +[data-offset$="10"] {--offset-minute: 10} +[data-offset$="15"] {--offset-minute: 15} +[data-offset$="20"] {--offset-minute: 20} +[data-offset$="25"] {--offset-minute: 25} +[data-offset$="30"] {--offset-minute: 30} +[data-offset$="35"] {--offset-minute: 35} +[data-offset$="40"] {--offset-minute: 40} +[data-offset$="45"] {--offset-minute: 45} +[data-offset$="50"] {--offset-minute: 50} +[data-offset$="55"] {--offset-minute: 55} + +/* minute based micro adjustment offset from 5-minute raster */ +[data-offset-shift^="1"] {--offset-start: 1} +[data-offset-shift^="2"] {--offset-start: 2} +[data-offset-shift^="3"] {--offset-start: 3} +[data-offset-shift^="4"] {--offset-start: 4} + +[data-offset-shift$="1"] {--offset-end: 1} +[data-offset-shift$="2"] {--offset-end: 2} +[data-offset-shift$="3"] {--offset-end: 3} +[data-offset-shift$="4"] {--offset-end: 4} + + +[data-duration^="00"] {--duration-hour: 0} +[data-duration^="01"] {--duration-hour: 1} +[data-duration^="02"] {--duration-hour: 2} +[data-duration^="03"] {--duration-hour: 3} +[data-duration^="04"] {--duration-hour: 4} +[data-duration^="05"] {--duration-hour: 5} +[data-duration^="06"] {--duration-hour: 6} +[data-duration^="07"] {--duration-hour: 7} +[data-duration^="08"] {--duration-hour: 8} +[data-duration^="09"] {--duration-hour: 9} +[data-duration^="10"] {--duration-hour: 10} +[data-duration^="11"] {--duration-hour: 11} +[data-duration^="12"] {--duration-hour: 12} +[data-duration^="13"] {--duration-hour: 13} +[data-duration^="14"] {--duration-hour: 14} +[data-duration^="15"] {--duration-hour: 15} +[data-duration^="16"] {--duration-hour: 16} +[data-duration^="17"] {--duration-hour: 17} +[data-duration^="18"] {--duration-hour: 18} +[data-duration^="19"] {--duration-hour: 19} +[data-duration^="20"] {--duration-hour: 20} +[data-duration^="21"] {--duration-hour: 21} +[data-duration^="22"] {--duration-hour: 22} +[data-duration^="23"] {--duration-hour: 23} +[data-duration^="24"] {--duration-hour: 24} + +[data-duration$="00"] {--duration-minute: 0} +[data-duration$="05"] {--duration-minute: 5} +[data-duration$="10"] {--duration-minute: 10} +[data-duration$="15"] {--duration-minute: 15} +[data-duration$="20"] {--duration-minute: 20} +[data-duration$="25"] {--duration-minute: 25} +[data-duration$="30"] {--duration-minute: 30} +[data-duration$="35"] {--duration-minute: 35} +[data-duration$="40"] {--duration-minute: 40} +[data-duration$="45"] {--duration-minute: 45} +[data-duration$="50"] {--duration-minute: 50} +[data-duration$="55"] {--duration-minute: 55} + +.day-timeline { + list-style: none; + margin: 0; + padding: 0; + + display: grid; + gap: 0; + --grid-cols: calc(var(--duration-hour, 0) * 60/var(--raster-size, 5) + var(--duration-minute, 0) / var(--raster-size, 5)); + grid-template-columns: repeat(var(--grid-cols), minmax(var(--col-min-size, 3em), 1fr)); + grid-template-rows: repeat(var(--concurrency, 1), auto); +} + +.day-timeline > li { + grid-column: calc(1 + var(--offset-hour, 0)*60/var(--raster-size, 5) + var(--offset-minute, 0)/var(--raster-size, 5)) / span calc(var(--duration-hour, 0)*60/var(--raster-size, 5) + var(--duration-minute, 0)/var(--raster-size, 5)); + grid-row: var(--concurrency, 1) / span 1; +} +.day-timeline > li:focus-within { + z-index: 2; /* move to front so focus-outline overlays other elements */ +} + +.ticks li, .tick { + grid-row: 1 / span var(--concurrency, 1) !important; +} +.ticks li { + padding: 8px 3px 5px; + line-height: 1; + border-top-left-radius: $border-radius-base; + border-top-right-radius: $border-radius-base; +} +.ticks li:nth-of-type(odd), .tick { + background-color: $table-bg-accent; +} +.day-timeline:nth-last-of-type(1) li { + border-bottom-left-radius: $border-radius-base; + border-bottom-right-radius: $border-radius-base; +} + + + +.day-calendar a.event { + --duration: calc(var(--duration-hour, 0)*60 + var(--duration-minute)); + margin-left: calc(var(--offset-start, 0) / var(--duration) * 100%); + margin-right: calc(var(--offset-end, 0) / var(--duration) * 100%); +} + + + + @media (min-width: $screen-md-min) { .week-calendar { display: flex;