blob: 2196c028f7f427a80b39dc74c504b2fe5a0342df [file]
<script lang="ts">
import type { CalendarEvent } from "../lib/types";
import { MONTH_NAMES, dayKey, isToday, monthGrid, yearMonths } from "../lib/dates";
import { eventHue, occursOnDay } from "../lib/events";
import type { DisplayZone } from "../lib/timezone";
import { nowInZone } from "../lib/timezone";
interface Props {
cursor: Date;
events: CalendarEvent[];
zone?: DisplayZone;
onpickday: (day: Date) => void;
onpickmonth: (month: Date) => void;
}
let { cursor, events, zone = "local", onpickday, onpickmonth }: Props = $props();
let now = $derived(nowInZone(zone));
/** Up to three dots per day, so a busy day still reads as busy. */
const MAX_DOTS = 3;
function dotsFor(day: Date): CalendarEvent[] {
return events.filter((event) => occursOnDay(event, day, zone)).slice(0, MAX_DOTS);
}
let months = $derived(yearMonths(cursor));
</script>
<div class="year">
{#each months as month (month.getMonth())}
<section class="month">
<button type="button" class="title" onclick={() => onpickmonth(month)}>
{MONTH_NAMES[month.getMonth()]}
</button>
<div class="mini">
{#each ["M", "T", "W", "T", "F", "S", "S"] as label, index (index)}
<div class="dow">{label}</div>
{/each}
{#each monthGrid(month).slice(0, 42) as day (dayKey(day))}
{@const inMonth = day.getMonth() === month.getMonth()}
{@const dots = inMonth ? dotsFor(day) : []}
<button
type="button"
class="day"
class:outside={!inMonth}
class:today={isToday(day, now)}
onclick={() => onpickday(day)}
aria-label={`${day.getDate()} ${MONTH_NAMES[day.getMonth()]}${dots.length ? `, ${dots.length} events` : ""}`}
>
<span class="num">{day.getDate()}</span>
<span class="dots">
{#each dots as event (event.id)}
<i style="--hue: {eventHue(event)}"></i>
{/each}
</span>
</button>
{/each}
</div>
</section>
{/each}
</div>
<style>
.year {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
gap: 1rem;
padding: 1rem;
overflow-y: auto;
height: 100%;
}
.month {
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.6rem;
box-shadow: var(--shadow);
}
.title {
display: block;
width: 100%;
background: none;
border: 0;
padding: 0 0 0.4rem;
text-align: left;
font-weight: 700;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--asf-red);
}
.title:hover {
text-decoration: underline;
}
.mini {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 1px;
}
.dow {
font-size: 10px;
color: var(--text-faint);
text-align: center;
padding-bottom: 2px;
}
.day {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 1px;
background: none;
border: 0;
border-radius: 3px;
padding: 2px 0 3px;
font-size: 11px;
font-variant-numeric: tabular-nums;
min-height: 1.9rem;
}
.day:hover {
background: var(--bg-hover);
}
.day.outside {
opacity: 0.3;
}
.day.today .num {
background: var(--asf-red);
color: #fff;
border-radius: 999px;
padding: 0 4px;
font-weight: 700;
}
.dots {
display: flex;
gap: 1px;
height: 4px;
}
.dots i {
width: 4px;
height: 4px;
border-radius: 999px;
background: hsl(var(--hue) 60% 50%);
}
</style>