journal/Journal.App/src/lib/components/CalendarWidget.svelte
Jacob Schmidt 0d77300c22 feat: Project Journal backend monorepo
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>
2026-03-02 20:56:26 -06:00

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>