diff --git a/calendar-main.css b/calendar-main.css deleted file mode 100644 index 9597771..0000000 --- a/calendar-main.css +++ /dev/null @@ -1,6 +0,0 @@ -/* Calendar CSS - Main file with imports */ -@import url('colors.css'); -@import url('layout.css'); -@import url('cells.css'); -@import url('events.css'); -@import url('utilities.css'); diff --git a/calendar.js b/calendar.js index 165d438..4f7a18f 100644 --- a/calendar.js +++ b/calendar.js @@ -1,30 +1,19 @@ // calendar.js — Infinite scrolling week-by-week with overlay event rendering -const monthAbbr = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec'] -const DAY_MS = 86400000 -const WEEK_MS = 7 * DAY_MS - -const isoWeekInfo = date => { - const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())) - const day = d.getUTCDay() || 7 - d.setUTCDate(d.getUTCDate() + 4 - day) - const year = d.getUTCFullYear() - const yearStart = new Date(Date.UTC(year, 0, 1)) - const diffDays = Math.floor((d - yearStart) / DAY_MS) + 1 - return { week: Math.ceil(diffDays / 7), year } -} - -function toLocalString(date = new Date()) { - const pad = n => String(Math.floor(Math.abs(n))).padStart(2, '0') - return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` -} - -function fromLocalString(dateString) { - const [year, month, day] = dateString.split('-').map(Number) - return new Date(year, month - 1, day) -} - -const mondayIndex = d => (d.getDay() + 6) % 7 -const pad = n => String(n).padStart(2, '0') +import { + monthAbbr, + DAY_MS, + WEEK_MS, + isoWeekInfo, + toLocalString, + fromLocalString, + mondayIndex, + pad, + daysInclusive, + addDaysStr, + getLocalizedWeekdayNames, + getLocalizedMonthName, + formatDateRange +} from './date-utils.js' class InfiniteCalendar { constructor(config = {}) { @@ -68,6 +57,7 @@ class InfiniteCalendar { this.setupYearScroll() this.setupSelectionInput() this.setupCurrentDate() + this.setupEventDialog() this.setupInitialView() } @@ -189,29 +179,13 @@ class InfiniteCalendar { return Math.round(h) } - getLocalizedWeekdayNames() { - const res = [] - const base = new Date(2025, 0, 6) - for (let i = 0; i < 7; i++) { - const d = new Date(base) - d.setDate(base.getDate() + i) - res.push(d.toLocaleDateString(undefined, { weekday: 'short' })) - } - return res - } - - getLocalizedMonthName(idx, short = false) { - const d = new Date(2025, idx, 1) - return d.toLocaleDateString(undefined, { month: short ? 'short' : 'long' }) - } - createHeader() { this.yearLabel = document.createElement('div') this.yearLabel.className = 'year-label' this.yearLabel.textContent = isoWeekInfo(new Date()).year this.header.appendChild(this.yearLabel) - const names = this.getLocalizedWeekdayNames() + const names = getLocalizedWeekdayNames() names.forEach((name, i) => { const c = document.createElement('div') c.classList.add('dow') @@ -434,7 +408,7 @@ class InfiniteCalendar { const label = document.createElement('span') const year = String((labelYear ?? monday.getFullYear())).slice(-2) - label.textContent = `${this.getLocalizedMonthName(monthToLabel)} '${year}` + label.textContent = `${getLocalizedMonthName(monthToLabel)} '${year}` overlayCell.appendChild(label) weekDiv.appendChild(overlayCell) weekDiv.style.zIndex = '18' @@ -481,31 +455,17 @@ class InfiniteCalendar { // -------- Selection -------- - daysInclusive(aStr, bStr) { - const a = fromLocalString(aStr) - const b = fromLocalString(bStr) - const A = new Date(a.getFullYear(), a.getMonth(), a.getDate()).getTime() - const B = new Date(b.getFullYear(), b.getMonth(), b.getDate()).getTime() - return Math.floor(Math.abs(B - A) / DAY_MS) + 1 - } - - addDaysStr(str, n) { - const d = fromLocalString(str) - d.setDate(d.getDate() + n) - return toLocalString(d) - } - clampRange(anchorStr, otherStr) { if (this.config.select_days <= 1) return [otherStr, otherStr] const limit = this.config.select_days const forward = fromLocalString(otherStr) >= fromLocalString(anchorStr) - const span = this.daysInclusive(anchorStr, otherStr) + const span = daysInclusive(anchorStr, otherStr) if (span <= limit) { const a = [anchorStr, otherStr].sort() return [a[0], a[1]] } - if (forward) return [anchorStr, this.addDaysStr(anchorStr, limit - 1)] - return [this.addDaysStr(anchorStr, -(limit - 1)), anchorStr] + if (forward) return [anchorStr, addDaysStr(anchorStr, limit - 1)] + return [addDaysStr(anchorStr, -(limit - 1)), anchorStr] } setSelection(aStr, bStr) { @@ -513,7 +473,7 @@ class InfiniteCalendar { this.selStart = start this.selEnd = end this.applySelectionToVisible() - this.selectedDateInput.value = this.formatDateRange(fromLocalString(start), fromLocalString(end)) + this.selectedDateInput.value = formatDateRange(fromLocalString(start), fromLocalString(end)) } clearSelection() { @@ -540,17 +500,6 @@ class InfiniteCalendar { } } - formatDateRange(startDate, endDate) { - if (toLocalString(startDate) === toLocalString(endDate)) return toLocalString(startDate) - const startISO = toLocalString(startDate) - const endISO = toLocalString(endDate) - const [sy, sm] = startISO.split('-') - const [ey, em, ed] = endISO.split('-') - if (sy === ey && sm === em) return `${startISO}/${ed}` - if (sy === ey) return `${startISO}/${em}-${ed}` - return `${startISO}/${endISO}` - } - setupGlobalDragHandlers() { document.addEventListener('mouseup', () => { if (!this.isDragging) return @@ -600,33 +549,232 @@ class InfiniteCalendar { this.setSelection(this.dragAnchor, dateStr) document.body.style.cursor = 'default' if (this.selStart && this.selEnd) { - setTimeout(() => this.promptForEvent(), 100) + setTimeout(() => this.showEventDialog('create'), 50) } } // -------- Event Management (overlay-based) -------- - promptForEvent() { - const title = prompt('Enter event title:') - if (!title || title.trim() === '') { - this.clearSelection() - return - } - this.createEvent({ - title: title.trim(), - startDate: this.selStart, - endDate: this.selEnd + // Build dialog DOM once + setupEventDialog() { + const tpl = document.createElement('template') + tpl.innerHTML = ` + ` + + document.body.appendChild(tpl.content) + this.eventModal = document.querySelector('.ec-modal-backdrop') + this.eventForm = this.eventModal.querySelector('form.ec-form') + this.eventTitleInput = this.eventForm.elements['title'] + this.eventStartDateInput = this.eventForm.elements['startDate'] + this.eventStartTimeInput = this.eventForm.elements['startTime'] + this.eventDurationInput = this.eventForm.elements['duration'] + this.eventTimeRow = this.eventForm.querySelector('.ec-time-row') + this.eventColorInputs = Array.from(this.eventForm.querySelectorAll('input[name="colorId"]')) + // duration change toggles time visibility + this.eventDurationInput.addEventListener('change', () => this.updateTimeVisibilityByDuration()) + // color selection visual state + this.eventColorInputs.forEach(radio => { + radio.addEventListener('change', () => { + const swatches = this.eventForm.querySelectorAll('.ec-color-swatches .swatch') + swatches.forEach(s => s.classList.toggle('selected', s.checked)) + }) }) - this.clearSelection() + + this.eventForm.addEventListener('submit', e => { + e.preventDefault() + const data = this.readEventForm() + if (!data.title.trim()) return + if (this._dialogMode === 'create') { + const computed = this.computeDatesFromForm(data) + this.createEvent({ + title: data.title.trim(), + startDate: computed.startDate, + endDate: computed.endDate, + colorId: data.colorId, + startTime: data.startTime, + durationMinutes: data.duration + }) + this.clearSelection() + } else if (this._dialogMode === 'edit' && this._editingEventId != null) { + const computed = this.computeDatesFromForm(data) + this.applyEventEdit(this._editingEventId, { ...data, ...computed }) + } + this.hideEventDialog() + }) + + this.eventForm.querySelector('[data-action="cancel"]').addEventListener('click', () => { + this.hideEventDialog() + if (this._dialogMode === 'create') this.clearSelection() + }) + + this.eventModal.addEventListener('click', e => { + if (e.target === this.eventModal) this.hideEventDialog() + }) + document.addEventListener('keydown', e => { + if (this.eventModal.hidden) return + if (e.key === 'Escape') { + this.hideEventDialog() + if (this._dialogMode === 'create') this.clearSelection() + } + }) + } + + showEventDialog(mode, opts = {}) { + this._dialogMode = mode + this._editingEventId = null + + if (mode === 'create') { + // Defaults for new event + this.eventTitleInput.value = '' + this.eventStartTimeInput.value = '09:00' + // start date defaults + this.eventStartDateInput.value = this.selStart || toLocalString(new Date()) + // duration defaults from selection (full days) or 60 min + if (this.selStart && this.selEnd) { + const days = daysInclusive(this.selStart, this.selEnd) + this.setDurationValue(days * 1440) + } else { + this.setDurationValue(60) + } + // suggest least-used color across range + const suggested = this.selectEventColorId(this.selStart, this.selEnd) + this.eventColorInputs.forEach(r => r.checked = Number(r.value) === suggested) + this.updateTimeVisibilityByDuration() + } else if (mode === 'edit') { + const ev = this.getEventById(opts.id) + if (!ev) return + this._editingEventId = ev.id + this.eventTitleInput.value = ev.title || '' + this.eventStartDateInput.value = ev.startDate + if (ev.startDate !== ev.endDate) { + const days = daysInclusive(ev.startDate, ev.endDate) + this.setDurationValue(days * 1440) + } else { + this.setDurationValue(ev.durationMinutes || 60) + } + this.eventStartTimeInput.value = ev.startTime || '09:00' + this.eventColorInputs.forEach(r => r.checked = Number(r.value) === (ev.colorId ?? 0)) + this.updateTimeVisibilityByDuration() + } + this.eventModal.hidden = false + // simple focus + setTimeout(() => this.eventTitleInput.focus(), 0) + } + + toggleTimeRow(show) { + if (!this.eventTimeRow) return + this.eventTimeRow.style.display = show ? '' : 'none' + } + + updateTimeVisibilityByDuration() { + const minutes = Number(this.eventDurationInput.value || 0) + const isFullDayOrMore = minutes >= 1440 + this.toggleTimeRow(!isFullDayOrMore) + } + + hideEventDialog() { + this.eventModal.hidden = true + } + + readEventForm() { + const colorId = Number(this.eventForm.querySelector('input[name="colorId"]:checked')?.value ?? 0) + const timeRowVisible = this.eventTimeRow && this.eventTimeRow.style.display !== 'none' + return { + title: this.eventTitleInput.value, + startDate: this.eventStartDateInput.value, + startTime: timeRowVisible ? (this.eventStartTimeInput.value || '09:00') : null, + duration: timeRowVisible ? Math.max(15, Number(this.eventDurationInput.value) || 60) : null, + colorId + } + } + + setDurationValue(minutes) { + const v = String(minutes) + const exists = Array.from(this.eventDurationInput.options).some(o => o.value === v) + if (!exists) { + const opt = document.createElement('option') + opt.value = v + const days = Math.floor(minutes / 1440) + opt.textContent = days >= 1 ? `${days} day${days > 1 ? 's' : ''}` : `${minutes} minutes` + this.eventDurationInput.appendChild(opt) + } + this.eventDurationInput.value = v + } + + computeDatesFromForm(data) { + const minutes = Number(this.eventDurationInput.value || 0) + if (minutes >= 1440) { + const days = Math.max(1, Math.floor(minutes / 1440)) + return { startDate: data.startDate, endDate: addDaysStr(data.startDate, days - 1) } + } + return { startDate: data.startDate, endDate: data.startDate } } createEvent(eventData) { + const singleDay = eventData.startDate === eventData.endDate const event = { id: this.eventIdCounter++, title: eventData.title, startDate: eventData.startDate, endDate: eventData.endDate, - colorId: this.generateEventColorId() + colorId: eventData.colorId ?? this.selectEventColorId(eventData.startDate, eventData.endDate), + startTime: singleDay ? (eventData.startTime || '09:00') : null, + durationMinutes: singleDay ? (eventData.durationMinutes || 60) : null } const startDate = new Date(fromLocalString(event.startDate)) @@ -641,9 +789,62 @@ class InfiniteCalendar { this.refreshEvents() } - generateEventColorId() { - // Return a color ID from 0-11 for 12 evenly spaced hues - return Math.floor(Math.random() * 12) + applyEventEdit(eventId, data) { + // Update all instances of this event across dates + for (const [, list] of this.events) { + for (let i = 0; i < list.length; i++) { + if (list[i].id === eventId) { + const isMulti = list[i].startDate !== list[i].endDate + list[i] = { + ...list[i], + title: data.title.trim(), + colorId: data.colorId, + startTime: isMulti ? null : data.startTime, + durationMinutes: isMulti ? null : data.duration + } + } + } + } + this.refreshEvents() + } + + getEventById(id) { + for (const [, list] of this.events) { + const found = list.find(e => e.id === id) + if (found) return found + } + return null + } + + selectEventColorId(startDateStr, endDateStr) { + // Count frequency of each color used on the date range + const colorCounts = [0, 0, 0, 0, 0, 0, 0, 0] + const startDate = new Date(fromLocalString(startDateStr)) + const endDate = new Date(fromLocalString(endDateStr)) + + for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) { + const dateStr = toLocalString(d) + const dayEvents = this.events.get(dateStr) || [] + for (const event of dayEvents) { + if (event.colorId >= 0 && event.colorId < 8) { + colorCounts[event.colorId]++ + } + } + } + + // Find the color with the lowest count + // For equal counts, prefer the lowest color number + let minCount = colorCounts[0] + let selectedColor = 0 + + for (let colorId = 1; colorId < 8; colorId++) { + if (colorCounts[colorId] < minCount) { + minCount = colorCounts[colorId] + selectedColor = colorId + } + } + + return selectedColor } refreshEvents() { @@ -682,8 +883,26 @@ class InfiniteCalendar { } } - const spans = Array.from(weekEvents.values()) - .sort((a, b) => a.startIdx - b.startIdx || (b.endIdx - b.startIdx) - (a.endIdx - a.startIdx)) + const timeToMin = t => { + if (typeof t !== 'string') return 1e9 + const m = t.match(/^(\d{2}):(\d{2})/) + if (!m) return 1e9 + return Number(m[1]) * 60 + Number(m[2]) + } + + const spans = Array.from(weekEvents.values()).sort((a, b) => { + if (a.startIdx !== b.startIdx) return a.startIdx - b.startIdx + // Prefer longer spans to be placed first for packing + const aLen = a.endIdx - a.startIdx + const bLen = b.endIdx - b.startIdx + if (aLen !== bLen) return bLen - aLen + // Within the same day and same span length, order by start time + const at = timeToMin(a.startTime) + const bt = timeToMin(b.startTime) + if (at !== bt) return at - bt + // Stable fallback by id + return (a.id || 0) - (b.id || 0) + }) const rowsLastEnd = [] for (const w of spans) { @@ -694,9 +913,30 @@ class InfiniteCalendar { w._row = placedRow + 1 } - overlay.style.gridTemplateRows = `repeat(${Math.max(1, rowsLastEnd.length)}, 1fr)` - overlay.style.rowGap = '.2em' + const numRows = Math.max(1, rowsLastEnd.length) + // Decide between "comfortable" layout (with gaps, not stretched) + // and "compressed" layout (fractional rows, no gaps) based on fit. + const cs = getComputedStyle(overlay) + const overlayHeight = overlay.getBoundingClientRect().height + const marginTopPx = parseFloat(cs.marginTop) || 0 + const available = Math.max(0, overlayHeight - marginTopPx) + const baseEm = parseFloat(cs.fontSize) || 16 + const rowPx = 1.2 * baseEm // preferred row height ~ 1.2em + const gapPx = 0.2 * baseEm // preferred gap ~ .2em + const needed = numRows * rowPx + (numRows - 1) * gapPx + + if (needed <= available) { + // Comfortable: keep gaps and do not stretch rows to fill + overlay.style.gridTemplateRows = `repeat(${numRows}, ${rowPx}px)` + overlay.style.rowGap = `${gapPx}px` + } else { + // Compressed: use fractional rows so everything fits; remove gaps + overlay.style.gridTemplateRows = `repeat(${numRows}, 1fr)` + overlay.style.rowGap = '0' + } + + // Create the spans for (const w of spans) this.createOverlaySpan(overlay, w) } @@ -707,12 +947,16 @@ class InfiniteCalendar { span.style.gridRow = `${w._row}` span.textContent = w.title span.title = `${w.title} (${w.startDate === w.endDate ? w.startDate : w.startDate + ' - ' + w.endDate})` + span.addEventListener('click', e => { + e.stopPropagation() + this.showEventDialog('edit', { id: w.id }) + }) overlay.appendChild(span) } } document.addEventListener('DOMContentLoaded', () => { new InfiniteCalendar({ - select_days: 14 + select_days: 1000 }) }) diff --git a/cells.css b/cells.css index a195201..c40279a 100644 --- a/cells.css +++ b/cells.css @@ -32,3 +32,6 @@ /* Fixed heights for cells and labels */ .week-row .cell, .week-row .week-label { height: var(--cell-h) } + +.weekend { color: var(--weekend) } +.firstday { color: var(--firstday); text-shadow: 0 0 .1em var(--strong); } diff --git a/colors.css b/colors.css index 0abb4bc..b3fe54c 100644 --- a/colors.css +++ b/colors.css @@ -3,7 +3,7 @@ --panel: #fff; --today: #f83; --ink: #222; - --inkstrong: #000; + --strong: #000; --muted: #888; --weekend: #888; --firstday: #000; @@ -14,31 +14,28 @@ } /* Month tints (light) */ -.dec { background: hsl(220 20% 95%) } -.jan { background: hsl(220 20% 88%) } -.feb { background: hsl(220 20% 95%) } -.mar { background: hsl(125 60% 88%) } +.dec { background: hsl(220 50% 95%) } +.jan { background: hsl(220 50% 92%) } +.feb { background: hsl(220 50% 95%) } +.mar { background: hsl(125 60% 92%) } .apr { background: hsl(125 60% 95%) } -.may { background: hsl(125 60% 88%) } +.may { background: hsl(125 60% 92%) } .jun { background: hsl(45 85% 95%) } -.jul { background: hsl(45 85% 88%) } +.jul { background: hsl(45 85% 92%) } .aug { background: hsl(45 85% 95%) } -.sep { background: hsl(18 78% 88%) } +.sep { background: hsl(18 78% 92%) } .oct { background: hsl(18 78% 95%) } -.nov { background: hsl(18 78% 88%) } +.nov { background: hsl(18 78% 92%) } -.event-color-0 { background: hsl(0, 40%, 80%); } -.event-color-1 { background: hsl(30, 40%, 80%); } -.event-color-2 { background: hsl(60, 40%, 80%); } -.event-color-3 { background: hsl(90, 40%, 80%); } -.event-color-4 { background: hsl(120, 40%, 80%); } -.event-color-5 { background: hsl(150, 40%, 80%); } -.event-color-6 { background: hsl(180, 40%, 80%); } -.event-color-7 { background: hsl(210, 40%, 80%); } -.event-color-8 { background: hsl(240, 40%, 80%); } -.event-color-9 { background: hsl(270, 40%, 80%); } -.event-color-10 { background: hsl(300, 40%, 80%); } -.event-color-11 { background: hsl(330, 40%, 80%); } +/* Light mode — gray shades and colors */ +.event-color-0 { background: hsl(0, 0%, 85%) } /* lightest grey */ +.event-color-1 { background: hsl(0, 0%, 75%) } /* light grey */ +.event-color-2 { background: hsl(0, 0%, 65%) } /* medium grey */ +.event-color-3 { background: hsl(0, 0%, 55%) } /* dark grey */ +.event-color-4 { background: hsl(0, 80%, 70%) } /* red */ +.event-color-5 { background: hsl(40, 80%, 70%) } /* orange */ +.event-color-6 { background: hsl(200, 80%, 70%) } /* green */ +.event-color-7 { background: hsl(280, 80%, 70%) } /* purple */ /* Color tokens (dark) */ @media (prefers-color-scheme: dark) { @@ -46,52 +43,35 @@ --panel: #111318; --today: #f83; --ink: #ddd; - --inkstrong: #fff; + --strong: #fff; --muted: #888; --weekend: #999; --firstday: #fff; - --select: #44f; + --select: #22a; --shadow: #888; --label-bg: #1a1d25; --label-bg-rgb: 26, 29, 37; } - /* Month tints (dark) */ - .dec { background: hsl(220 20% 22%) } - .jan { background: hsl(220 20% 16%) } - .feb { background: hsl(220 20% 22%) } - .mar { background: hsl(125 40% 18%) } - .apr { background: hsl(125 40% 26%) } - .may { background: hsl(125 40% 18%) } - .jun { background: hsl(45 70% 24%) } - .jul { background: hsl(45 70% 18%) } - .aug { background: hsl(45 70% 24%) } - .sep { background: hsl(18 70% 18%) } - .oct { background: hsl(18 70% 26%) } - .nov { background: hsl(18 70% 18%) } + .dec { background: hsl(220 50% 8%) } + .jan { background: hsl(220 50% 6%) } + .feb { background: hsl(220 50% 8%) } + .mar { background: hsl(125 60% 6%) } + .apr { background: hsl(125 60% 8%) } + .may { background: hsl(125 60% 6%) } + .jun { background: hsl(45 85% 8%) } + .jul { background: hsl(45 85% 6%) } + .aug { background: hsl(45 85% 8%) } + .sep { background: hsl(18 78% 6%) } + .oct { background: hsl(18 78% 8%) } + .nov { background: hsl(18 78% 6%) } - .event-color-0 { background: hsl(0, 40%, 50%); } - .event-color-1 { background: hsl(30, 40%, 50%); } - .event-color-2 { background: hsl(60, 40%, 50%); } - .event-color-3 { background: hsl(90, 40%, 50%); } - .event-color-4 { background: hsl(120, 40%, 50%); } - .event-color-5 { background: hsl(150, 40%, 50%); } - .event-color-6 { background: hsl(180, 40%, 50%); } - .event-color-7 { background: hsl(210, 40%, 50%); } - .event-color-8 { background: hsl(240, 40%, 50%); } - .event-color-9 { background: hsl(270, 40%, 50%); } - .event-color-10 { background: hsl(300, 40%, 50%); } - .event-color-11 { background: hsl(330, 40%, 50%); } - -} - -/* Selection styles */ -.weekend { color: var(--weekend) } -.firstday { color: var(--firstday); text-shadow: 0 0 .1em #fff; } - -.selected { - background: var(--select); - border: 2px solid var(--ink); - box-shadow: inset 0 0 0 1px rgba(255,255,255,.3); -} -.selected .event { opacity: .7 } + .event-color-0 { background: hsl(0, 0%, 20%) } /* lightest grey */ + .event-color-1 { background: hsl(0, 0%, 30%) } /* light grey */ + .event-color-2 { background: hsl(0, 0%, 40%) } /* medium grey */ + .event-color-3 { background: hsl(0, 0%, 50%) } /* dark grey */ + .event-color-4 { background: hsl(0, 70%, 50%) } /* red */ + .event-color-5 { background: hsl(40, 70%, 50%) } /* orange */ + .event-color-6 { background: hsl(200, 70%, 50%) } /* green */ + .event-color-7 { background: hsl(280, 70%, 50%) } /* purple */ +} \ No newline at end of file diff --git a/date-utils.js b/date-utils.js new file mode 100644 index 0000000..076306e --- /dev/null +++ b/date-utils.js @@ -0,0 +1,139 @@ +// date-utils.js — Date handling utilities for the calendar +const monthAbbr = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec'] +const DAY_MS = 86400000 +const WEEK_MS = 7 * DAY_MS + +/** + * Get ISO week information for a given date + * @param {Date} date - The date to get week info for + * @returns {Object} Object containing week number and year + */ +const isoWeekInfo = date => { + const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())) + const day = d.getUTCDay() || 7 + d.setUTCDate(d.getUTCDate() + 4 - day) + const year = d.getUTCFullYear() + const yearStart = new Date(Date.UTC(year, 0, 1)) + const diffDays = Math.floor((d - yearStart) / DAY_MS) + 1 + return { week: Math.ceil(diffDays / 7), year } +} + +/** + * Convert a Date object to a local date string (YYYY-MM-DD format) + * @param {Date} date - The date to convert (defaults to new Date()) + * @returns {string} Date string in YYYY-MM-DD format + */ +function toLocalString(date = new Date()) { + const pad = n => String(Math.floor(Math.abs(n))).padStart(2, '0') + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +} + +/** + * Convert a local date string (YYYY-MM-DD) to a Date object + * @param {string} dateString - Date string in YYYY-MM-DD format + * @returns {Date} Date object + */ +function fromLocalString(dateString) { + const [year, month, day] = dateString.split('-').map(Number) + return new Date(year, month - 1, day) +} + +/** + * Get the index of Monday for a given date (0-6, where Monday = 0) + * @param {Date} d - The date + * @returns {number} Monday index (0-6) + */ +const mondayIndex = d => (d.getDay() + 6) % 7 + +/** + * Pad a number with leading zeros to make it 2 digits + * @param {number} n - Number to pad + * @returns {string} Padded string + */ +const pad = n => String(n).padStart(2, '0') + +/** + * Calculate number of days between two date strings (inclusive) + * @param {string} aStr - First date string (YYYY-MM-DD) + * @param {string} bStr - Second date string (YYYY-MM-DD) + * @returns {number} Number of days inclusive + */ +function daysInclusive(aStr, bStr) { + const a = fromLocalString(aStr) + const b = fromLocalString(bStr) + const A = new Date(a.getFullYear(), a.getMonth(), a.getDate()).getTime() + const B = new Date(b.getFullYear(), b.getMonth(), b.getDate()).getTime() + return Math.floor(Math.abs(B - A) / DAY_MS) + 1 +} + +/** + * Add days to a date string + * @param {string} str - Date string in YYYY-MM-DD format + * @param {number} n - Number of days to add (can be negative) + * @returns {string} New date string + */ +function addDaysStr(str, n) { + const d = fromLocalString(str) + d.setDate(d.getDate() + n) + return toLocalString(d) +} + +/** + * Get localized weekday names starting from Monday + * @returns {Array} Array of localized weekday names + */ +function getLocalizedWeekdayNames() { + const res = [] + const base = new Date(2025, 0, 6) // A Monday + for (let i = 0; i < 7; i++) { + const d = new Date(base) + d.setDate(base.getDate() + i) + res.push(d.toLocaleDateString(undefined, { weekday: 'short' })) + } + return res +} + +/** + * Get localized month name + * @param {number} idx - Month index (0-11) + * @param {boolean} short - Whether to return short name + * @returns {string} Localized month name + */ +function getLocalizedMonthName(idx, short = false) { + const d = new Date(2025, idx, 1) + return d.toLocaleDateString(undefined, { month: short ? 'short' : 'long' }) +} + +/** + * Format a date range for display + * @param {Date} startDate - Start date + * @param {Date} endDate - End date + * @returns {string} Formatted date range string + */ +function formatDateRange(startDate, endDate) { + if (toLocalString(startDate) === toLocalString(endDate)) return toLocalString(startDate) + const startISO = toLocalString(startDate) + const endISO = toLocalString(endDate) + const [sy, sm] = startISO.split('-') + const [ey, em, ed] = endISO.split('-') + if (sy === ey && sm === em) return `${startISO}/${ed}` + if (sy === ey) return `${startISO}/${em}-${ed}` + return `${startISO}/${endISO}` +} + +// Export all functions and constants +export { + monthAbbr, + DAY_MS, + WEEK_MS, + isoWeekInfo, + toLocalString, + fromLocalString, + mondayIndex, + pad, + daysInclusive, + addDaysStr, + getLocalizedWeekdayNames, + getLocalizedMonthName, + formatDateRange +} diff --git a/events.css b/events.css index 32e5a70..1286421 100644 --- a/events.css +++ b/events.css @@ -1,35 +1,26 @@ -/* Event (per-cell, if used) */ -.event { - font-size: .75em; - padding: .1em .3em; - margin: .1em 0; - border-radius: .2em; - font-weight: 500; - white-space: normal; - overflow: hidden; - text-overflow: ellipsis; - width: 100%; - max-width: calc(100% - .5em); - line-height: 1.2; - cursor: pointer; - z-index: 5; -} - -/* Spanning events in the overlay (grid-positioned, not absolutely measured) */ .event-span { - font-size: .75em; + font-size: clamp(.45em, 1.8vh, .75em); padding: 0 .5em; border-radius: .4em; - color: var(--inkstrong); + color: var(--strong); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - line-height: 1.2; - height: 1.2em; - align-self: center; /* vertically center within the overlay row */ + line-height: 1; /* let the track height define the visual height */ + height: 100%; /* fill the grid row height */ + align-self: stretch; /* fill row vertically */ justify-self: stretch; /* stretch across chosen grid columns */ - text-align: center; + display: flex; + align-items: center; + justify-content: center; pointer-events: auto; /* clickable despite overlay having none */ z-index: 1; } + +/* Selection styles */ +.cell.selected { + background: var(--select); + box-shadow: 0 0 .1em var(--muted) inset; +} +.cell.selected .event { opacity: .7 } diff --git a/index.html b/index.html index 96b8b0a..435ebcc 100644 --- a/index.html +++ b/index.html @@ -24,6 +24,6 @@
- + \ No newline at end of file diff --git a/layout.css b/layout.css index 4b2eeb0..5a291e9 100644 --- a/layout.css +++ b/layout.css @@ -131,13 +131,14 @@ header { /* Overlay sitting above the day cells, same 7-col grid */ .week-row > .days-grid > .week-overlay { - margin-top: 1.2em; + margin-top: 1.5em; position: absolute; inset: 0; pointer-events: none; display: grid; grid-template-columns: repeat(7, 1fr); grid-auto-rows: 1fr; + row-gap: 0; /* eliminate gaps so space is fully usable */ z-index: 15; } diff --git a/utilities.css b/utilities.css index fd88bdf..88cfcba 100644 --- a/utilities.css +++ b/utilities.css @@ -17,3 +17,43 @@ input { width: 11em; } label:has(input[value]) { display: block } + +/* Modal dialog */ +.ec-modal-backdrop[hidden] { display: none } +.ec-modal-backdrop { + position: fixed; + inset: 0; + background: color-mix(in srgb, var(--strong) 30%, transparent); + display: grid; + place-items: center; + z-index: 1000; +} +.ec-modal { + background: var(--panel); + color: var(--ink); + border-radius: .6rem; + min-width: 320px; + max-width: min(520px, 90vw); + box-shadow: 0 10px 30px rgba(0,0,0,.35); +} +.ec-form { padding: 1rem; display: grid; gap: .75rem } +.ec-header h2 { margin: 0; font-size: 1.1rem } +.ec-body { display: grid; gap: .75rem } +.ec-row { display: grid; grid-template-columns: 1fr 1fr; gap: .75rem } +.ec-field { display: grid; gap: .25rem } +.ec-field > span { font-size: .85em; color: var(--muted) } +.ec-field input[type="text"], +.ec-field input[type="time"], +.ec-field input[type="number"] { + border: 1px solid var(--muted); + border-radius: .4rem; + padding: .5rem .6rem; + width: 100%; +} +.ec-color-swatches { display: grid; grid-template-columns: repeat(4, 1fr); gap: .3rem } +.ec-color-swatches .swatch { display: grid; place-items: center; border-radius: .4rem; padding: .25rem; outline: 2px solid transparent; outline-offset: 2px; cursor: pointer } +.ec-color-swatches .swatch { appearance: none; width: 3em; height: 1em; } +.ec-color-swatches .swatch:checked { outline-color: var(--ink) } +.ec-footer { display: flex; justify-content: end; gap: .5rem } +.ec-btn { border: 1px solid var(--muted); background: transparent; color: var(--ink); padding: .5rem .8rem; border-radius: .4rem; cursor: pointer } +.ec-btn.primary { background: var(--today); color: #000; border-color: transparent }