| <script lang="ts"> |
| import type { CalendarEvent } from "../lib/types"; |
| import { WEEKDAY_NAMES, dayKey, formatTime, isToday, isWeekend, weekDays } from "../lib/dates"; |
| import { allDayEvents, eventHue, eventStart, layoutDay } from "../lib/events"; |
| import type { DisplayZone } from "../lib/timezone"; |
| import { hourLabels, nowInZone, shortZoneLabel, zoneLabel, resolveZone } from "../lib/timezone"; |
| import EventChip from "./EventChip.svelte"; |
| |
| interface Props { |
| cursor: Date; |
| events: CalendarEvent[]; |
| /** One day for the day view, seven for the week view. */ |
| days?: number; |
| zone?: DisplayZone; |
| /** Extra clocks to show beside the grid, left of the primary one. */ |
| compareZones?: DisplayZone[]; |
| onselect: (event: CalendarEvent) => void; |
| onpickday: (day: Date) => void; |
| } |
| |
| let { |
| cursor, |
| events, |
| days = 7, |
| zone = "local", |
| compareZones = [], |
| onselect, |
| onpickday, |
| }: Props = $props(); |
| |
| const HOURS = Array.from({ length: 24 }, (_, hour) => hour); |
| |
| let now = $derived(nowInZone(zone)); |
| |
| let columns = $derived(days === 1 ? [new Date(cursor)] : weekDays(cursor)); |
| let allDayRows = $derived(columns.map((day) => allDayEvents(events, day, zone))); |
| let hasAllDay = $derived(allDayRows.some((row) => row.length > 0)); |
| let laidOut = $derived(columns.map((day) => layoutDay(events, day, zone))); |
| // The primary zone sits closest to the grid, since that is the one the rows |
| // actually line up with; comparison clocks go to its left. |
| let gutters = $derived([...compareZones, zone]); |
| let labels = $derived( |
| gutters.map((other) => |
| other === zone |
| ? HOURS.map((hour) => `${String(hour).padStart(2, "0")}:00`) |
| : hourLabels(columns[0], zone, other), |
| ), |
| ); |
| </script> |
| |
| <div |
| class="week" |
| class:single={columns.length === 1} |
| style="--cols: {columns.length}; --gutters: {gutters.length}" |
| > |
| <div class="headers"> |
| {#each gutters as other, index (index)} |
| <div class="gutter zonehead" title={zoneLabel(resolveZone(other))}> |
| {shortZoneLabel(other)} |
| </div> |
| {/each} |
| {#each columns as day, index (dayKey(day))} |
| <button |
| type="button" |
| class="dayhead" |
| class:today={isToday(day, now)} |
| class:weekend={isWeekend(day)} |
| onclick={() => onpickday(day)} |
| > |
| <span class="dow">{WEEKDAY_NAMES[(day.getDay() + 6) % 7]}</span> |
| <span class="dom">{day.getDate()}</span> |
| {#if allDayRows[index].length > 0} |
| <span class="badge">{allDayRows[index].length}</span> |
| {/if} |
| </button> |
| {/each} |
| </div> |
| |
| {#if hasAllDay} |
| <div class="allday"> |
| {#each gutters as _other, index (index)} |
| <div class="gutter"><span>{index === gutters.length - 1 ? "All day" : ""}</span></div> |
| {/each} |
| {#each columns as day, index (dayKey(day))} |
| <div class="allday-cell" class:weekend={isWeekend(day)}> |
| {#each allDayRows[index] as event (event.id)} |
| <EventChip {event} {zone} {onselect} showTime={false} /> |
| {/each} |
| </div> |
| {/each} |
| </div> |
| {/if} |
| |
| <div class="scroll"> |
| <div class="body"> |
| {#each gutters as _other, index (index)} |
| <div class="gutter hours" class:secondary={index < gutters.length - 1}> |
| {#each labels[index] as label, hour (hour)} |
| <div class="hour"><span>{label}</span></div> |
| {/each} |
| </div> |
| {/each} |
| |
| {#each columns as day, index (dayKey(day))} |
| <div class="daycol" class:weekend={isWeekend(day)} class:today={isToday(day, now)}> |
| {#each HOURS as hour (hour)} |
| <div class="slot"></div> |
| {/each} |
| |
| {#each laidOut[index] as placed (placed.event.id)} |
| <button |
| type="button" |
| class="timed" |
| class:private={placed.event.visibility === "private"} |
| style="--hue: {eventHue(placed.event)}; |
| top: {placed.top * 100}%; |
| height: {placed.height * 100}%; |
| left: calc({(placed.column / placed.columns) * 100}% + 2px); |
| width: calc({(1 / placed.columns) * 100}% - 4px);" |
| onclick={() => onselect(placed.event)} |
| title={placed.event.title} |
| > |
| <span class="timed-time">{formatTime(eventStart(placed.event, zone))}</span> |
| <span class="timed-title">{placed.event.title}</span> |
| </button> |
| {/each} |
| </div> |
| {/each} |
| </div> |
| </div> |
| </div> |
| |
| <style> |
| .week { |
| display: flex; |
| flex-direction: column; |
| height: 100%; |
| min-height: 0; |
| } |
| |
| .headers, |
| .allday, |
| .body { |
| display: grid; |
| grid-template-columns: repeat(var(--gutters, 1), 3.5rem) repeat(var(--cols, 7), minmax(0, 1fr)); |
| } |
| |
| .headers { |
| border-bottom: 1px solid var(--border); |
| } |
| |
| .dayhead { |
| display: flex; |
| align-items: baseline; |
| justify-content: center; |
| gap: 0.35rem; |
| padding: 0.45rem 0.25rem; |
| background: none; |
| border: 0; |
| border-left: 1px solid var(--border); |
| } |
| |
| .dayhead:hover { |
| background: var(--bg-hover); |
| } |
| |
| .dayhead.weekend { |
| background: var(--bg-subtle); |
| } |
| |
| .dayhead.today { |
| background: var(--today); |
| } |
| |
| .dow { |
| font-size: 11px; |
| text-transform: uppercase; |
| letter-spacing: 0.04em; |
| color: var(--text-muted); |
| } |
| |
| .dom { |
| font-size: 16px; |
| font-weight: 600; |
| font-variant-numeric: tabular-nums; |
| } |
| |
| .today .dom { |
| color: var(--asf-red); |
| } |
| |
| .badge { |
| font-size: 10px; |
| background: var(--border-strong); |
| color: var(--bg-panel); |
| border-radius: 999px; |
| padding: 0 5px; |
| } |
| |
| .allday { |
| border-bottom: 1px solid var(--border-strong); |
| max-height: 6rem; |
| overflow-y: auto; |
| } |
| |
| .allday-cell { |
| display: flex; |
| flex-direction: column; |
| gap: 2px; |
| padding: 2px; |
| min-width: 0; |
| border-left: 1px solid var(--border); |
| } |
| |
| .gutter { |
| font-size: 11px; |
| color: var(--text-faint); |
| text-align: right; |
| padding-right: 0.4rem; |
| } |
| |
| .zonehead { |
| display: flex; |
| align-items: flex-end; |
| justify-content: flex-end; |
| padding-bottom: 0.45rem; |
| font-weight: 700; |
| letter-spacing: 0.02em; |
| color: var(--text-muted); |
| overflow: hidden; |
| } |
| |
| /* Comparison clocks are there for reference, so they read quieter than the |
| zone the grid is actually drawn in. */ |
| .hours.secondary { |
| color: var(--text-faint); |
| opacity: 0.75; |
| } |
| |
| .hours.secondary + .hours { |
| border-left: 1px solid var(--border); |
| } |
| |
| .allday .gutter { |
| display: flex; |
| align-items: center; |
| justify-content: flex-end; |
| } |
| |
| .scroll { |
| flex: 1; |
| min-height: 0; |
| overflow-y: auto; |
| background: var(--bg-panel); |
| } |
| |
| .hours .hour { |
| height: 3rem; |
| position: relative; |
| } |
| |
| .hours .hour span { |
| position: absolute; |
| top: -0.6em; |
| right: 0.4rem; |
| } |
| |
| .daycol { |
| position: relative; |
| min-width: 0; |
| border-left: 1px solid var(--border); |
| } |
| |
| .daycol.weekend { |
| background: var(--bg-subtle); |
| } |
| |
| .daycol.today { |
| background: var(--today); |
| } |
| |
| .slot { |
| height: 3rem; |
| border-bottom: 1px solid var(--border); |
| } |
| |
| .timed { |
| position: absolute; |
| display: flex; |
| flex-direction: column; |
| gap: 0; |
| overflow: hidden; |
| padding: 1px 4px; |
| text-align: left; |
| font-size: 12px; |
| line-height: 1.25; |
| background: var(--chip-bg); |
| color: var(--chip-text); |
| border: 1px solid var(--chip-border); |
| border-left: 3px solid var(--chip-border); |
| border-radius: 4px; |
| } |
| |
| .timed.private { |
| border-style: dashed; |
| border-left-style: solid; |
| } |
| |
| .timed:hover { |
| filter: brightness(0.96); |
| z-index: 2; |
| } |
| |
| .timed-time { |
| font-variant-numeric: tabular-nums; |
| opacity: 0.75; |
| } |
| |
| .timed-title { |
| font-weight: 600; |
| overflow: hidden; |
| text-overflow: ellipsis; |
| } |
| </style> |