Monorepo with centralized build props, npm workspaces, LlamaSharp AI, SQLite/SQLCipher storage, Svelte frontend, and unified smoke tests. Co-Authored-By: Oz <oz-agent@warp.dev>
320 lines
7.3 KiB
Svelte
320 lines
7.3 KiB
Svelte
<!-- @format -->
|
|
<script lang="ts">
|
|
export let onVisibleMonthChange: (month: {
|
|
year: number;
|
|
month: number;
|
|
label: string;
|
|
}) => void = () => {};
|
|
export let onSelectedDateChange: (payload: {
|
|
year: number;
|
|
month: number;
|
|
day: number;
|
|
key: string;
|
|
}) => void = () => {};
|
|
export let onDateActivate: (payload: {
|
|
year: number;
|
|
month: number;
|
|
day: number;
|
|
key: string;
|
|
}) => void = () => {};
|
|
|
|
const today = new Date();
|
|
let currentYear = today.getFullYear();
|
|
let currentMonth = today.getMonth();
|
|
let selectedDateKey = getDateKey(
|
|
today.getFullYear(),
|
|
today.getMonth(),
|
|
today.getDate(),
|
|
);
|
|
|
|
const weekdays = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
|
|
|
type CalendarCell = {
|
|
day: number;
|
|
month: number;
|
|
year: number;
|
|
inMonth: boolean;
|
|
isToday: boolean;
|
|
isSelected: boolean;
|
|
};
|
|
|
|
function getDateKey(year: number, month: number, day: number): string {
|
|
const mm = String(month + 1).padStart(2, "0");
|
|
const dd = String(day).padStart(2, "0");
|
|
return `${year}-${mm}-${dd}`;
|
|
}
|
|
|
|
function setViewDate(year: number, month: number) {
|
|
const next = new Date(year, month, 1);
|
|
currentYear = next.getFullYear();
|
|
currentMonth = next.getMonth();
|
|
}
|
|
|
|
function changeMonth(offset: number) {
|
|
setViewDate(currentYear, currentMonth + offset);
|
|
}
|
|
|
|
function selectCell(cell: CalendarCell) {
|
|
if (!cell.inMonth) {
|
|
setViewDate(cell.year, cell.month);
|
|
}
|
|
const key = getDateKey(cell.year, cell.month, cell.day);
|
|
selectedDateKey = key;
|
|
onDateActivate({
|
|
year: cell.year,
|
|
month: cell.month,
|
|
day: cell.day,
|
|
key,
|
|
});
|
|
}
|
|
|
|
function getCalendarCells(year: number, month: number): CalendarCell[] {
|
|
const firstDay = new Date(year, month, 1);
|
|
const lastDay = new Date(year, month + 1, 0);
|
|
const prevMonthLastDay = new Date(year, month, 0).getDate();
|
|
const startOffset = (firstDay.getDay() + 6) % 7;
|
|
const daysInMonth = lastDay.getDate();
|
|
|
|
const nextCells: CalendarCell[] = [];
|
|
|
|
for (let i = 0; i < startOffset; i += 1) {
|
|
const day = prevMonthLastDay - startOffset + i + 1;
|
|
const prevMonthDate = new Date(year, month - 1, day);
|
|
const key = getDateKey(
|
|
prevMonthDate.getFullYear(),
|
|
prevMonthDate.getMonth(),
|
|
day,
|
|
);
|
|
nextCells.push({
|
|
day,
|
|
month: prevMonthDate.getMonth(),
|
|
year: prevMonthDate.getFullYear(),
|
|
inMonth: false,
|
|
isToday:
|
|
key ===
|
|
getDateKey(today.getFullYear(), today.getMonth(), today.getDate()),
|
|
isSelected: key === selectedDateKey,
|
|
});
|
|
}
|
|
|
|
for (let day = 1; day <= daysInMonth; day += 1) {
|
|
const key = getDateKey(year, month, day);
|
|
nextCells.push({
|
|
day,
|
|
month,
|
|
year,
|
|
inMonth: true,
|
|
isToday:
|
|
key ===
|
|
getDateKey(today.getFullYear(), today.getMonth(), today.getDate()),
|
|
isSelected: key === selectedDateKey,
|
|
});
|
|
}
|
|
|
|
const trailing = (7 - (nextCells.length % 7)) % 7;
|
|
for (let day = 1; day <= trailing; day += 1) {
|
|
const nextMonthDate = new Date(year, month + 1, day);
|
|
const key = getDateKey(
|
|
nextMonthDate.getFullYear(),
|
|
nextMonthDate.getMonth(),
|
|
day,
|
|
);
|
|
nextCells.push({
|
|
day,
|
|
month: nextMonthDate.getMonth(),
|
|
year: nextMonthDate.getFullYear(),
|
|
inMonth: false,
|
|
isToday:
|
|
key ===
|
|
getDateKey(today.getFullYear(), today.getMonth(), today.getDate()),
|
|
isSelected: key === selectedDateKey,
|
|
});
|
|
}
|
|
|
|
return nextCells;
|
|
}
|
|
|
|
$: monthLabel = new Date(currentYear, currentMonth, 1).toLocaleString(
|
|
undefined,
|
|
{ month: "long" },
|
|
);
|
|
$: cells = getCalendarCells(currentYear, currentMonth);
|
|
$: onVisibleMonthChange({
|
|
year: currentYear,
|
|
month: currentMonth,
|
|
label: monthLabel,
|
|
});
|
|
$: {
|
|
const parts = selectedDateKey.split("-");
|
|
const [year, month, day] = parts.map((value) => Number(value));
|
|
if (
|
|
parts.length === 3 &&
|
|
!Number.isNaN(year) &&
|
|
!Number.isNaN(month) &&
|
|
!Number.isNaN(day)
|
|
) {
|
|
onSelectedDateChange({
|
|
year,
|
|
month: month - 1,
|
|
day,
|
|
key: selectedDateKey,
|
|
});
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<section class="calendar-widget" aria-label="Monthly calendar">
|
|
<header class="calendar-header">
|
|
<button
|
|
type="button"
|
|
class="nav-icon"
|
|
aria-label="Previous month"
|
|
on:click={() => changeMonth(-1)}
|
|
>
|
|
<span class="material-symbols-outlined">chevron_left</span>
|
|
</button>
|
|
|
|
<div class="month-title">
|
|
<h3>{monthLabel}</h3>
|
|
<span>{currentYear}</span>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
class="nav-icon"
|
|
aria-label="Next month"
|
|
on:click={() => changeMonth(1)}
|
|
>
|
|
<span class="material-symbols-outlined">chevron_right</span>
|
|
</button>
|
|
</header>
|
|
|
|
<div class="calendar-weekdays">
|
|
{#each weekdays as weekday}
|
|
<span>{weekday}</span>
|
|
{/each}
|
|
</div>
|
|
|
|
<div class="calendar-grid">
|
|
{#each cells as cell}
|
|
<button
|
|
type="button"
|
|
class="calendar-cell"
|
|
class:is-muted={!cell.inMonth}
|
|
class:is-today={cell.isToday}
|
|
class:is-selected={cell.isSelected}
|
|
aria-label={`Day ${cell.day}`}
|
|
on:click={() => selectCell(cell)}
|
|
>
|
|
<span class="day-number">{cell.day}</span>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</section>
|
|
|
|
<style>
|
|
.calendar-widget {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
border: 1px solid var(--border-soft);
|
|
border-radius: 10px;
|
|
background: var(--surface-1);
|
|
padding: 10px;
|
|
}
|
|
|
|
.calendar-header {
|
|
display: grid;
|
|
grid-template-columns: 28px minmax(0, 1fr) 28px;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.nav-icon {
|
|
width: 28px;
|
|
height: 28px;
|
|
border: 1px solid var(--border-soft);
|
|
border-radius: 6px;
|
|
display: grid;
|
|
place-items: center;
|
|
color: var(--text-muted);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.nav-icon .material-symbols-outlined {
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.nav-icon:hover {
|
|
background: var(--bg-hover);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.month-title {
|
|
text-align: center;
|
|
}
|
|
|
|
.month-title h3 {
|
|
font-size: 0.86rem;
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.month-title span {
|
|
font-size: 0.74rem;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.calendar-weekdays,
|
|
.calendar-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(7, minmax(0, 1fr));
|
|
gap: 4px;
|
|
}
|
|
|
|
.calendar-weekdays span {
|
|
text-align: center;
|
|
font-size: 0.7rem;
|
|
color: var(--text-dim);
|
|
font-weight: 500;
|
|
}
|
|
|
|
.calendar-cell {
|
|
height: 36px;
|
|
border-radius: 7px;
|
|
border: 1px solid transparent;
|
|
font-size: 0.76rem;
|
|
color: var(--text-muted);
|
|
cursor: pointer;
|
|
position: relative;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.day-number {
|
|
line-height: 1;
|
|
}
|
|
|
|
.calendar-cell:hover {
|
|
color: var(--text-primary);
|
|
background: var(--bg-hover);
|
|
border-color: var(--border-soft);
|
|
}
|
|
|
|
.calendar-cell.is-muted {
|
|
color: var(--text-dim);
|
|
}
|
|
|
|
.calendar-cell.is-today {
|
|
color: var(--text-primary);
|
|
background: var(--surface-3);
|
|
border-color: var(--border-strong);
|
|
}
|
|
|
|
.calendar-cell.is-selected {
|
|
border-color: var(--zinc-300);
|
|
box-shadow: inset 0 0 0 1px var(--zinc-300);
|
|
}
|
|
</style>
|