Weekday and event cleanup, minor, random stuff.
This commit is contained in:
parent
45a9078675
commit
5cb45b5fe6
@ -1,10 +1,18 @@
|
|||||||
/* Color tokens */
|
/* Color tokens */
|
||||||
:root {
|
:root {
|
||||||
--panel: #fff;
|
--panel: #ffffff;
|
||||||
|
--panel-alt: #f6f8fa;
|
||||||
|
--panel-accent: #eef4ff;
|
||||||
--today: #f83;
|
--today: #f83;
|
||||||
--ink: #222;
|
--ink: #222;
|
||||||
--strong: #000;
|
--strong: #000;
|
||||||
--muted: #888;
|
--muted: #6a6f76;
|
||||||
|
--muted-alt: #9aa2ad;
|
||||||
|
--accent: #2563eb; /* blue */
|
||||||
|
--accent-soft: #dbeafe;
|
||||||
|
--accent-hover: #1d4ed8;
|
||||||
|
--danger: #dc2626;
|
||||||
|
--danger-hover: #b91c1c;
|
||||||
--weekend: #888;
|
--weekend: #888;
|
||||||
--firstday: #000;
|
--firstday: #000;
|
||||||
--select: #aaf;
|
--select: #aaf;
|
||||||
@ -12,6 +20,14 @@
|
|||||||
--label-bg: #fafbfe;
|
--label-bg: #fafbfe;
|
||||||
--label-bg-rgb: 250, 251, 254;
|
--label-bg-rgb: 250, 251, 254;
|
||||||
|
|
||||||
|
/* Input / recurrence tokens */
|
||||||
|
--input-border: var(--muted-alt);
|
||||||
|
--input-focus: var(--accent);
|
||||||
|
--pill-bg: var(--panel-alt);
|
||||||
|
--pill-active-bg: var(--accent);
|
||||||
|
--pill-active-ink: #fff;
|
||||||
|
--pill-hover-bg: var(--accent-soft);
|
||||||
|
|
||||||
/* Vue component color mappings */
|
/* Vue component color mappings */
|
||||||
--bg: var(--panel);
|
--bg: var(--panel);
|
||||||
--border-color: #ddd;
|
--border-color: #ddd;
|
||||||
@ -36,25 +52,40 @@
|
|||||||
.event-color-1 { background: hsl(0, 0%, 75%) } /* light grey */
|
.event-color-1 { background: hsl(0, 0%, 75%) } /* light grey */
|
||||||
.event-color-2 { background: hsl(0, 0%, 65%) } /* medium grey */
|
.event-color-2 { background: hsl(0, 0%, 65%) } /* medium grey */
|
||||||
.event-color-3 { background: hsl(0, 0%, 55%) } /* dark grey */
|
.event-color-3 { background: hsl(0, 0%, 55%) } /* dark grey */
|
||||||
.event-color-4 { background: hsl(0, 80%, 70%) } /* red */
|
.event-color-4 { background: hsl(0, 70%, 70%) } /* red */
|
||||||
.event-color-5 { background: hsl(40, 80%, 70%) } /* orange */
|
.event-color-5 { background: hsl(90, 70%, 70%) } /* green */
|
||||||
.event-color-6 { background: hsl(200, 80%, 70%) } /* green */
|
.event-color-6 { background: hsl(230, 70%, 70%) } /* blue */
|
||||||
.event-color-7 { background: hsl(280, 80%, 70%) } /* purple */
|
.event-color-7 { background: hsl(280, 70%, 70%) } /* purple */
|
||||||
|
|
||||||
/* Color tokens (dark) */
|
/* Color tokens (dark) */
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root {
|
||||||
--panel: #000;
|
--panel: #121417;
|
||||||
|
--panel-alt: #1d2228;
|
||||||
|
--panel-accent: #1a2634;
|
||||||
--today: #f83;
|
--today: #f83;
|
||||||
--ink: #ddd;
|
--ink: #e5e7eb;
|
||||||
--strong: #fff;
|
--strong: #fff;
|
||||||
--muted: #888;
|
--muted: #7d8691;
|
||||||
|
--muted-alt: #5d646d;
|
||||||
|
--accent: #3b82f6;
|
||||||
|
--accent-soft: rgba(59,130,246,0.15);
|
||||||
|
--accent-hover: #2563eb;
|
||||||
|
--danger: #ef4444;
|
||||||
|
--danger-hover: #dc2626;
|
||||||
|
--workday: var(--ink);
|
||||||
--weekend: #999;
|
--weekend: #999;
|
||||||
--firstday: #fff;
|
--firstday: #fff;
|
||||||
--select: #22a;
|
--select: #3355ff;
|
||||||
--shadow: #888;
|
--shadow: #000;
|
||||||
--label-bg: #1a1d25;
|
--label-bg: #1a1d25;
|
||||||
--label-bg-rgb: 26, 29, 37;
|
--label-bg-rgb: 26, 29, 37;
|
||||||
|
--input-border: var(--muted-alt);
|
||||||
|
--input-focus: var(--accent);
|
||||||
|
--pill-bg: #222a32;
|
||||||
|
--pill-active-bg: var(--accent);
|
||||||
|
--pill-active-ink: #fff;
|
||||||
|
--pill-hover-bg: rgba(255,255,255,0.08);
|
||||||
|
|
||||||
/* Vue component color mappings (dark) */
|
/* Vue component color mappings (dark) */
|
||||||
--bg: var(--panel);
|
--bg: var(--panel);
|
||||||
@ -74,12 +105,12 @@
|
|||||||
.oct { background: hsl(18 78% 8%) }
|
.oct { background: hsl(18 78% 8%) }
|
||||||
.nov { background: hsl(18 78% 6%) }
|
.nov { background: hsl(18 78% 6%) }
|
||||||
|
|
||||||
.event-color-0 { background: hsl(0, 0%, 20%) } /* lightest grey */
|
.event-color-0 { background: hsl(0, 0%, 50%) } /* lightest grey */
|
||||||
.event-color-1 { background: hsl(0, 0%, 30%) } /* light grey */
|
.event-color-1 { background: hsl(0, 0%, 40%) } /* light grey */
|
||||||
.event-color-2 { background: hsl(0, 0%, 40%) } /* medium grey */
|
.event-color-2 { background: hsl(0, 0%, 30%) } /* medium grey */
|
||||||
.event-color-3 { background: hsl(0, 0%, 50%) } /* dark grey */
|
.event-color-3 { background: hsl(0, 0%, 20%) } /* dark grey */
|
||||||
.event-color-4 { background: hsl(0, 70%, 50%) } /* red */
|
.event-color-4 { background: hsl(0, 70%, 40%) } /* red */
|
||||||
.event-color-5 { background: hsl(40, 70%, 50%) } /* orange */
|
.event-color-5 { background: hsl(90, 70%, 30%) } /* green - darker for perceptional purposes */
|
||||||
.event-color-6 { background: hsl(200, 70%, 50%) } /* green */
|
.event-color-6 { background: hsl(230, 70%, 40%) } /* blue */
|
||||||
.event-color-7 { background: hsl(280, 70%, 50%) } /* purple */
|
.event-color-7 { background: hsl(280, 70%, 40%) } /* purple */
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
day: Object
|
day: Object,
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['event-click'])
|
const emit = defineEmits(['event-click'])
|
||||||
@ -19,8 +19,8 @@ const handleEventClick = (eventId) => {
|
|||||||
today: props.day.isToday,
|
today: props.day.isToday,
|
||||||
weekend: props.day.isWeekend,
|
weekend: props.day.isWeekend,
|
||||||
firstday: props.day.isFirstDay,
|
firstday: props.day.isFirstDay,
|
||||||
selected: props.day.isSelected
|
selected: props.day.isSelected,
|
||||||
}
|
},
|
||||||
]"
|
]"
|
||||||
:data-date="props.day.date"
|
:data-date="props.day.date"
|
||||||
>
|
>
|
||||||
@ -37,7 +37,9 @@ const handleEventClick = (eventId) => {
|
|||||||
:title="event.title"
|
:title="event.title"
|
||||||
@click.stop="handleEventClick(event.id)"
|
@click.stop="handleEventClick(event.id)"
|
||||||
></div>
|
></div>
|
||||||
<div v-if="props.day.events.length > 3" class="event-more">+{{ props.day.events.length - 3 }}</div>
|
<div v-if="props.day.events.length > 3" class="event-more">
|
||||||
|
+{{ props.day.events.length - 3 }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -100,32 +102,9 @@ const handleEventClick = (eventId) => {
|
|||||||
|
|
||||||
.lunar-phase {
|
.lunar-phase {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 2px;
|
top: 0.1em;
|
||||||
right: 2px;
|
right: 0.1em;
|
||||||
font-size: 10px;
|
font-size: 0.8em;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.day-events {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 2px;
|
|
||||||
left: 2px;
|
|
||||||
display: flex;
|
|
||||||
gap: 2px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-color-0 { background: var(--event-color-0); }
|
|
||||||
.event-color-1 { background: var(--event-color-1); }
|
|
||||||
.event-color-2 { background: var(--event-color-2); }
|
|
||||||
.event-color-3 { background: var(--event-color-3); }
|
|
||||||
.event-color-4 { background: var(--event-color-4); }
|
|
||||||
.event-color-5 { background: var(--event-color-5); }
|
|
||||||
.event-color-6 { background: var(--event-color-6); }
|
|
||||||
.event-color-7 { background: var(--event-color-7); }
|
|
||||||
|
|
||||||
.event-more {
|
|
||||||
font-size: 8px;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
@ -68,13 +68,7 @@ const weekdayNames = computed(() => {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--ink);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dow.weekend {
|
|
||||||
color: var(--weekend);
|
|
||||||
}
|
|
||||||
|
|
||||||
.overlay-header-spacer {
|
.overlay-header-spacer {
|
||||||
/* Empty spacer for the month label column */
|
/* Empty spacer for the month label column */
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { useCalendarStore } from '@/stores/CalendarStore'
|
import { useCalendarStore } from '@/stores/CalendarStore'
|
||||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
|
import WeekdaySelector from './WeekdaySelector.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
selection: { type: Object, default: () => ({ start: null, end: null }) }
|
selection: { type: Object, default: () => ({ start: null, end: null }) },
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['clear-selection'])
|
const emit = defineEmits(['clear-selection'])
|
||||||
@ -15,12 +16,108 @@ const dialogMode = ref('create') // 'create' or 'edit'
|
|||||||
const editingEventId = ref(null) // base event id if repeating occurrence clicked
|
const editingEventId = ref(null) // base event id if repeating occurrence clicked
|
||||||
const occurrenceContext = ref(null) // { baseId, occurrenceIndex, weekday, occurrenceDate }
|
const occurrenceContext = ref(null) // { baseId, occurrenceIndex, weekday, occurrenceDate }
|
||||||
const title = ref('')
|
const title = ref('')
|
||||||
const repeat = ref('none')
|
const recurrenceEnabled = ref(false)
|
||||||
const repeatWeekdays = ref([false, false, false, false, false, false, false]) // Sun-Sat
|
const recurrenceInterval = ref(1) // N in "Every N weeks/months"
|
||||||
|
const recurrenceFrequency = ref('weeks') // 'weeks' | 'months' | 'years'
|
||||||
|
const recurrenceWeekdays = ref([false, false, false, false, false, false, false])
|
||||||
|
const recurrenceOccurrences = ref(0) // 0 = unlimited
|
||||||
const colorId = ref(0)
|
const colorId = ref(0)
|
||||||
const eventSaved = ref(false)
|
const eventSaved = ref(false)
|
||||||
const titleInput = ref(null)
|
const titleInput = ref(null)
|
||||||
|
|
||||||
|
// Helper to get starting weekday (Sunday-first index)
|
||||||
|
function getStartingWeekday() {
|
||||||
|
if (!props.selection.start) return 0 // Default to Sunday
|
||||||
|
const date = new Date(props.selection.start + 'T00:00:00')
|
||||||
|
const dayOfWeek = date.getDay() // 0=Sunday, 1=Monday, ...
|
||||||
|
return dayOfWeek // Keep Sunday-first (0=Sunday, 6=Saturday)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Computed property for fallback weekdays - true for the initial day of the event, false for others
|
||||||
|
const fallbackWeekdays = computed(() => {
|
||||||
|
const startingDay = getStartingWeekday()
|
||||||
|
const fallback = [false, false, false, false, false, false, false]
|
||||||
|
fallback[startingDay] = true
|
||||||
|
return fallback
|
||||||
|
})
|
||||||
|
|
||||||
|
function preventFocusOnMouseDown(event) {
|
||||||
|
// Prevent focus when clicking with mouse, but allow keyboard navigation
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bridge legacy repeat API (store still expects repeat & repeatWeekdays)
|
||||||
|
const repeat = computed({
|
||||||
|
get() {
|
||||||
|
if (!recurrenceEnabled.value) return 'none'
|
||||||
|
if (recurrenceFrequency.value === 'weeks') {
|
||||||
|
if (recurrenceInterval.value === 1) return 'weekly'
|
||||||
|
if (recurrenceInterval.value === 2) return 'biweekly'
|
||||||
|
// Fallback map >2 to weekly (future: custom)
|
||||||
|
return 'weekly'
|
||||||
|
} else if (recurrenceFrequency.value === 'months') {
|
||||||
|
if (recurrenceInterval.value === 1) return 'monthly'
|
||||||
|
if (recurrenceInterval.value === 12) return 'yearly'
|
||||||
|
// Fallback map >1 to monthly
|
||||||
|
return 'monthly'
|
||||||
|
} else {
|
||||||
|
// years (map to yearly via 12 * interval months)
|
||||||
|
if (recurrenceInterval.value === 1) return 'yearly'
|
||||||
|
// Multi-year -> treat as yearly (future: custom)
|
||||||
|
return 'yearly'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
if (val === 'none') {
|
||||||
|
recurrenceEnabled.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
recurrenceEnabled.value = true
|
||||||
|
switch (val) {
|
||||||
|
case 'weekly':
|
||||||
|
recurrenceFrequency.value = 'weeks'
|
||||||
|
recurrenceInterval.value = 1
|
||||||
|
break
|
||||||
|
case 'biweekly':
|
||||||
|
recurrenceFrequency.value = 'weeks'
|
||||||
|
recurrenceInterval.value = 2
|
||||||
|
break
|
||||||
|
case 'monthly':
|
||||||
|
recurrenceFrequency.value = 'months'
|
||||||
|
recurrenceInterval.value = 1
|
||||||
|
break
|
||||||
|
case 'yearly':
|
||||||
|
recurrenceFrequency.value = 'years'
|
||||||
|
recurrenceInterval.value = 1
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
recurrenceFrequency.value = 'weeks'
|
||||||
|
recurrenceInterval.value = 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Convert Sunday-first recurrenceWeekdays to Sunday-first pattern for store
|
||||||
|
function buildStoreWeekdayPattern() {
|
||||||
|
// store expects Sun..Sat; we have Sun..Sat
|
||||||
|
// Direct mapping: recurrenceWeekdays indices 0..6 (Sun..Sat) -> store array [Sun,Mon,Tue,Wed,Thu,Fri,Sat]
|
||||||
|
let sunFirst = [...recurrenceWeekdays.value]
|
||||||
|
|
||||||
|
// Ensure at least one day is selected - fallback to starting day
|
||||||
|
if (!sunFirst.some(Boolean)) {
|
||||||
|
const startingDay = getStartingWeekday()
|
||||||
|
sunFirst[startingDay] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return sunFirst
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadWeekdayPatternFromStore(storePattern) {
|
||||||
|
if (!Array.isArray(storePattern) || storePattern.length !== 7) return
|
||||||
|
// store: Sun..Sat -> keep as Sun..Sat
|
||||||
|
recurrenceWeekdays.value = [...storePattern]
|
||||||
|
}
|
||||||
|
|
||||||
const selectedColor = computed({
|
const selectedColor = computed({
|
||||||
get: () => colorId.value,
|
get: () => colorId.value,
|
||||||
set: (val) => {
|
set: (val) => {
|
||||||
@ -29,18 +126,25 @@ const selectedColor = computed({
|
|||||||
if (editingEventId.value) {
|
if (editingEventId.value) {
|
||||||
updateEventInStore()
|
updateEventInStore()
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
function openCreateDialog() {
|
function openCreateDialog() {
|
||||||
occurrenceContext.value = null
|
occurrenceContext.value = null
|
||||||
dialogMode.value = 'create'
|
dialogMode.value = 'create'
|
||||||
title.value = ''
|
title.value = ''
|
||||||
repeat.value = 'none'
|
recurrenceEnabled.value = false
|
||||||
repeatWeekdays.value = [false, false, false, false, false, false, false]
|
recurrenceInterval.value = 1
|
||||||
|
recurrenceFrequency.value = 'weeks'
|
||||||
|
recurrenceWeekdays.value = [false, false, false, false, false, false, false]
|
||||||
|
recurrenceOccurrences.value = 0
|
||||||
colorId.value = calendarStore.selectEventColorId(props.selection.start, props.selection.end)
|
colorId.value = calendarStore.selectEventColorId(props.selection.start, props.selection.end)
|
||||||
eventSaved.value = false
|
eventSaved.value = false
|
||||||
|
|
||||||
|
// Auto-select starting day for weekly recurrence
|
||||||
|
const startingDay = getStartingWeekday()
|
||||||
|
recurrenceWeekdays.value[startingDay] = true
|
||||||
|
|
||||||
// Create the event immediately in the store
|
// Create the event immediately in the store
|
||||||
editingEventId.value = calendarStore.createEvent({
|
editingEventId.value = calendarStore.createEvent({
|
||||||
title: '',
|
title: '',
|
||||||
@ -48,7 +152,9 @@ function openCreateDialog() {
|
|||||||
endDate: props.selection.end,
|
endDate: props.selection.end,
|
||||||
colorId: colorId.value,
|
colorId: colorId.value,
|
||||||
repeat: repeat.value,
|
repeat: repeat.value,
|
||||||
repeatWeekdays: repeatWeekdays.value
|
repeatCount:
|
||||||
|
recurrenceOccurrences.value === 0 ? 'for now' : String(recurrenceOccurrences.value),
|
||||||
|
repeatWeekdays: buildStoreWeekdayPattern(),
|
||||||
})
|
})
|
||||||
|
|
||||||
showDialog.value = true
|
showDialog.value = true
|
||||||
@ -85,7 +191,8 @@ function openEditDialog(eventInstanceId) {
|
|||||||
const repeatWeekdaysLocal = event.repeatWeekdays
|
const repeatWeekdaysLocal = event.repeatWeekdays
|
||||||
let idx = 0
|
let idx = 0
|
||||||
let cur = new Date(event.startDate + 'T00:00:00')
|
let cur = new Date(event.startDate + 'T00:00:00')
|
||||||
while (idx < occurrenceIndex && idx < 10000) { // safety bound
|
while (idx < occurrenceIndex && idx < 10000) {
|
||||||
|
// safety bound
|
||||||
cur.setDate(cur.getDate() + 1)
|
cur.setDate(cur.getDate() + 1)
|
||||||
if (repeatWeekdaysLocal[cur.getDay()]) idx++
|
if (repeatWeekdaysLocal[cur.getDay()]) idx++
|
||||||
}
|
}
|
||||||
@ -94,8 +201,11 @@ function openEditDialog(eventInstanceId) {
|
|||||||
dialogMode.value = 'edit'
|
dialogMode.value = 'edit'
|
||||||
editingEventId.value = baseId
|
editingEventId.value = baseId
|
||||||
title.value = event.title
|
title.value = event.title
|
||||||
repeat.value = event.repeat
|
loadWeekdayPatternFromStore(event.repeatWeekdays)
|
||||||
repeatWeekdays.value = event.repeatWeekdays
|
repeat.value = event.repeat // triggers setter mapping into recurrence state
|
||||||
|
// Map repeatCount
|
||||||
|
const rc = event.repeatCount ?? 'unlimited'
|
||||||
|
recurrenceOccurrences.value = rc === 'unlimited' ? 0 : parseInt(rc, 10) || 0
|
||||||
colorId.value = event.colorId
|
colorId.value = event.colorId
|
||||||
eventSaved.value = false
|
eventSaved.value = false
|
||||||
if (event.isRepeating && occurrenceIndex >= 0 && weekday != null) {
|
if (event.isRepeating && occurrenceIndex >= 0 && weekday != null) {
|
||||||
@ -133,9 +243,10 @@ function updateEventInStore() {
|
|||||||
event.title = title.value
|
event.title = title.value
|
||||||
event.colorId = colorId.value
|
event.colorId = colorId.value
|
||||||
event.repeat = repeat.value
|
event.repeat = repeat.value
|
||||||
event.repeatWeekdays = [...repeatWeekdays.value]
|
event.repeatWeekdays = buildStoreWeekdayPattern()
|
||||||
// Update repeat status
|
event.repeatCount =
|
||||||
event.isRepeating = (repeat.value && repeat.value !== 'none')
|
recurrenceOccurrences.value === 0 ? 'unlimited' : String(recurrenceOccurrences.value)
|
||||||
|
event.isRepeating = recurrenceEnabled.value && repeat.value !== 'none'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -164,7 +275,6 @@ function deleteEventOne() {
|
|||||||
if (occurrenceContext.value) {
|
if (occurrenceContext.value) {
|
||||||
calendarStore.deleteSingleOccurrence(occurrenceContext.value)
|
calendarStore.deleteSingleOccurrence(occurrenceContext.value)
|
||||||
} else if (isRepeatingBaseEdit.value && editingEventId.value) {
|
} else if (isRepeatingBaseEdit.value && editingEventId.value) {
|
||||||
// Delete the first occurrence of the repeating series
|
|
||||||
calendarStore.deleteFirstOccurrence(editingEventId.value)
|
calendarStore.deleteFirstOccurrence(editingEventId.value)
|
||||||
}
|
}
|
||||||
closeDialog()
|
closeDialog()
|
||||||
@ -176,6 +286,10 @@ function deleteEventFrom() {
|
|||||||
closeDialog()
|
closeDialog()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleWeekday(index) {
|
||||||
|
recurrenceWeekdays.value[index] = !recurrenceWeekdays.value[index]
|
||||||
|
}
|
||||||
|
|
||||||
// Watch for title changes and update the event immediately
|
// Watch for title changes and update the event immediately
|
||||||
watch(title, (newTitle) => {
|
watch(title, (newTitle) => {
|
||||||
if (editingEventId.value && showDialog.value) {
|
if (editingEventId.value && showDialog.value) {
|
||||||
@ -183,27 +297,19 @@ watch(title, (newTitle) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Watch for repeat changes and update the event immediately
|
watch([recurrenceEnabled, recurrenceInterval, recurrenceFrequency], () => {
|
||||||
watch(repeat, (newRepeat) => {
|
if (editingEventId.value && showDialog.value) updateEventInStore()
|
||||||
if (editingEventId.value && showDialog.value) {
|
})
|
||||||
// If switching to weekly, default to the current weekday
|
watch(
|
||||||
if (newRepeat === 'weekly' && !repeatWeekdays.value.some(Boolean)) {
|
recurrenceWeekdays,
|
||||||
const event = calendarStore.getEventById(editingEventId.value)
|
() => {
|
||||||
if (event) {
|
if (editingEventId.value && showDialog.value && repeat.value === 'weekly') updateEventInStore()
|
||||||
const startDate = new Date(event.startDate + 'T00:00:00')
|
},
|
||||||
repeatWeekdays.value[startDate.getDay()] = true
|
{ deep: true },
|
||||||
}
|
)
|
||||||
}
|
watch(recurrenceOccurrences, () => {
|
||||||
updateEventInStore()
|
if (editingEventId.value && showDialog.value) updateEventInStore()
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Watch for repeatWeekdays changes and update the event immediately
|
|
||||||
watch(repeatWeekdays, () => {
|
|
||||||
if (editingEventId.value && showDialog.value && repeat.value === 'weekly') {
|
|
||||||
updateEventInStore()
|
|
||||||
}
|
|
||||||
}, { deep: true })
|
|
||||||
|
|
||||||
// Handle Esc key to close dialog
|
// Handle Esc key to close dialog
|
||||||
function handleKeydown(event) {
|
function handleKeydown(event) {
|
||||||
@ -222,29 +328,120 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
openCreateDialog,
|
openCreateDialog,
|
||||||
openEditDialog
|
openEditDialog,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Computed helpers for delete UI
|
// Computed helpers for delete UI
|
||||||
const isRepeatingEdit = computed(() => dialogMode.value === 'edit' && repeat.value !== 'none')
|
const isRepeatingEdit = computed(
|
||||||
|
() => dialogMode.value === 'edit' && recurrenceEnabled.value && repeat.value !== 'none',
|
||||||
|
)
|
||||||
const showDeleteVariants = computed(() => isRepeatingEdit.value && occurrenceContext.value)
|
const showDeleteVariants = computed(() => isRepeatingEdit.value && occurrenceContext.value)
|
||||||
const isRepeatingBaseEdit = computed(() => isRepeatingEdit.value && !occurrenceContext.value)
|
const isRepeatingBaseEdit = computed(() => isRepeatingEdit.value && !occurrenceContext.value)
|
||||||
const formattedOccurrenceShort = computed(() => {
|
const formattedOccurrenceShort = computed(() => {
|
||||||
if (occurrenceContext.value?.occurrenceDate) {
|
if (occurrenceContext.value?.occurrenceDate) {
|
||||||
try {
|
try {
|
||||||
return occurrenceContext.value.occurrenceDate.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
return occurrenceContext.value.occurrenceDate
|
||||||
} catch { /* noop */ }
|
.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||||
|
.replace(/, /, ' ')
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (isRepeatingBaseEdit.value && editingEventId.value) {
|
if (isRepeatingBaseEdit.value && editingEventId.value) {
|
||||||
const ev = calendarStore.getEventById(editingEventId.value)
|
const ev = calendarStore.getEventById(editingEventId.value)
|
||||||
if (ev?.startDate) {
|
if (ev?.startDate) {
|
||||||
try {
|
try {
|
||||||
return new Date(ev.startDate + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
return new Date(ev.startDate + 'T00:00:00')
|
||||||
} catch { /* noop */ }
|
.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||||
|
.replace(/, /, ' ')
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ''
|
return ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const finalOccurrenceDate = computed(() => {
|
||||||
|
if (!recurrenceEnabled.value) return null
|
||||||
|
const count = recurrenceOccurrences.value
|
||||||
|
if (!count || count < 1) return null // unlimited or invalid
|
||||||
|
// Need start date
|
||||||
|
const base = editingEventId.value ? calendarStore.getEventById(editingEventId.value) : null
|
||||||
|
if (!base) return null
|
||||||
|
const start = new Date(base.startDate + 'T00:00:00')
|
||||||
|
if (recurrenceFrequency.value === 'weeks') {
|
||||||
|
// iterate days until we count 'count-1' additional occurrences (first is base if selected weekday)
|
||||||
|
const pattern = buildStoreWeekdayPattern() // Sun..Sat
|
||||||
|
// Build Monday-first pattern again for selection clarity
|
||||||
|
const monFirst = recurrenceWeekdays.value
|
||||||
|
const selectedCount = monFirst.some(Boolean)
|
||||||
|
if (!selectedCount) return null
|
||||||
|
let occs = 0
|
||||||
|
// Determine if the start day counts
|
||||||
|
const startWeekdaySun = start.getDay()
|
||||||
|
// Convert to Monday-first index
|
||||||
|
// We'll just check store pattern
|
||||||
|
if (pattern[startWeekdaySun]) occs = 1
|
||||||
|
let cursor = new Date(start)
|
||||||
|
while (occs < count && occs < 10000) {
|
||||||
|
cursor.setDate(cursor.getDate() + 1)
|
||||||
|
if (pattern[cursor.getDay()]) occs++
|
||||||
|
}
|
||||||
|
if (occs === count) return cursor
|
||||||
|
return null
|
||||||
|
} else if (recurrenceFrequency.value === 'months') {
|
||||||
|
const monthsToAdd = recurrenceInterval.value * (count - 1)
|
||||||
|
const d = new Date(start)
|
||||||
|
d.setMonth(d.getMonth() + monthsToAdd)
|
||||||
|
return d
|
||||||
|
} else {
|
||||||
|
// years
|
||||||
|
const yearsToAdd = recurrenceInterval.value * (count - 1)
|
||||||
|
const d = new Date(start)
|
||||||
|
d.setFullYear(d.getFullYear() + yearsToAdd)
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const formattedFinalOccurrence = computed(() => {
|
||||||
|
const d = finalOccurrenceDate.value
|
||||||
|
if (!d) return ''
|
||||||
|
const now = new Date()
|
||||||
|
const includeYear =
|
||||||
|
d.getFullYear() !== now.getFullYear() ||
|
||||||
|
d.getTime() - now.getTime() >= 1000 * 60 * 60 * 24 * 365
|
||||||
|
const opts = {
|
||||||
|
weekday: 'short',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
...(includeYear ? { year: 'numeric' } : {}),
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return d.toLocaleDateString(undefined, opts)
|
||||||
|
} catch {
|
||||||
|
return d.toDateString()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const recurrenceSummary = computed(() => {
|
||||||
|
if (!recurrenceEnabled.value) return 'Does not recur'
|
||||||
|
const unit = recurrenceFrequency.value // weeks | months | years (plural)
|
||||||
|
const singular = unit.slice(0, -1)
|
||||||
|
const unitary = { weeks: 'Weekly', months: 'Monthly', years: 'Annually' }
|
||||||
|
let base =
|
||||||
|
recurrenceInterval.value > 1 ? `Every ${recurrenceInterval.value} ${unit}` : unitary[unit]
|
||||||
|
if (recurrenceFrequency.value === 'weeks') {
|
||||||
|
const sel = weekdays.filter((_, i) => recurrenceWeekdays.value[i])
|
||||||
|
if (sel.length) base += ' on ' + sel.join(', ')
|
||||||
|
}
|
||||||
|
base +=
|
||||||
|
' · ' +
|
||||||
|
(recurrenceOccurrences.value === 0
|
||||||
|
? 'no end'
|
||||||
|
: `${recurrenceOccurrences.value} ${recurrenceOccurrences.value === 1 ? 'time' : 'times'}`)
|
||||||
|
return base
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -268,32 +465,86 @@ const formattedOccurrenceShort = computed(() => {
|
|||||||
name="colorId"
|
name="colorId"
|
||||||
:value="i - 1"
|
:value="i - 1"
|
||||||
v-model="selectedColor"
|
v-model="selectedColor"
|
||||||
>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<label class="ec-field">
|
<div class="recurrence-block">
|
||||||
|
<div class="recurrence-header">
|
||||||
|
<label class="switch">
|
||||||
|
<input type="checkbox" v-model="recurrenceEnabled" />
|
||||||
<span>Repeat</span>
|
<span>Repeat</span>
|
||||||
<select v-model="repeat">
|
|
||||||
<option value="none">No repeat</option>
|
|
||||||
<option value="weekly">Weekly</option>
|
|
||||||
<option value="biweekly">Every 2 weeks</option>
|
|
||||||
<option value="monthly">Monthly</option>
|
|
||||||
<option value="yearly">Yearly</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
</label>
|
||||||
<div v-if="repeat === 'weekly'" class="ec-weekday-selector">
|
<span class="recurrence-summary" v-if="recurrenceEnabled">
|
||||||
<span class="ec-field-label">Repeat on:</span>
|
{{
|
||||||
<div class="ec-weekdays">
|
recurrenceInterval === 1
|
||||||
<label v-for="(day, index) in ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']"
|
? recurrenceFrequency === 'months'
|
||||||
:key="index"
|
? 'Monthly'
|
||||||
class="ec-weekday-label">
|
: recurrenceFrequency === 'years'
|
||||||
<input
|
? 'Annually'
|
||||||
type="checkbox"
|
: 'Every week'
|
||||||
v-model="repeatWeekdays[index]"
|
: `Every ${recurrenceInterval} ${recurrenceFrequency}`
|
||||||
class="ec-weekday-checkbox"
|
}}
|
||||||
|
<template v-if="recurrenceOccurrences > 0">
|
||||||
|
until {{ formattedFinalOccurrence }}</template
|
||||||
>
|
>
|
||||||
<span class="ec-weekday-text">{{ day }}</span>
|
</span>
|
||||||
</label>
|
<span class="recurrence-summary muted" v-else>Does not recur</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="recurrenceEnabled" class="recurrence-form">
|
||||||
|
<div class="line compact">
|
||||||
|
<span>Every</span>
|
||||||
|
<div class="mini-stepper" aria-label="Interval">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="step"
|
||||||
|
@click="recurrenceInterval = Math.max(1, recurrenceInterval - 1)"
|
||||||
|
:disabled="recurrenceInterval <= 1"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<span class="value" role="textbox" aria-readonly="true">{{
|
||||||
|
recurrenceInterval
|
||||||
|
}}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="step"
|
||||||
|
@click="recurrenceInterval = Math.min(999, recurrenceInterval + 1)"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<select v-model="recurrenceFrequency" class="freq-select">
|
||||||
|
<option value="weeks">weeks</option>
|
||||||
|
<option value="months">months</option>
|
||||||
|
<option value="years">years</option>
|
||||||
|
</select>
|
||||||
|
<div class="mini-stepper occ" aria-label="Occurrences (0 = no end)">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="step"
|
||||||
|
@click="recurrenceOccurrences = Math.max(0, recurrenceOccurrences - 1)"
|
||||||
|
:disabled="recurrenceOccurrences <= 0"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<span class="value" role="textbox" aria-readonly="true">{{
|
||||||
|
recurrenceOccurrences === 0 ? '∞' : recurrenceOccurrences
|
||||||
|
}}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="step"
|
||||||
|
@click="
|
||||||
|
recurrenceOccurrences =
|
||||||
|
recurrenceOccurrences === 0 ? 2 : Math.min(999, recurrenceOccurrences + 1)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="recurrenceFrequency === 'weeks'" @click.stop>
|
||||||
|
<WeekdaySelector v-model="recurrenceWeekdays" :fallback="fallbackWeekdays" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -305,19 +556,27 @@ const formattedOccurrenceShort = computed(() => {
|
|||||||
<template v-else>
|
<template v-else>
|
||||||
<template v-if="showDeleteVariants">
|
<template v-if="showDeleteVariants">
|
||||||
<div class="ec-delete-group">
|
<div class="ec-delete-group">
|
||||||
<button type="button" class="ec-btn delete-btn" @click="deleteEventOne">Delete {{ formattedOccurrenceShort }}</button>
|
<button type="button" class="ec-btn delete-btn" @click="deleteEventOne">
|
||||||
<button type="button" class="ec-btn delete-btn" @click="deleteEventFrom">Rest</button>
|
Delete {{ formattedOccurrenceShort }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="ec-btn delete-btn" @click="deleteEventFrom">
|
||||||
|
Rest
|
||||||
|
</button>
|
||||||
<button type="button" class="ec-btn delete-btn" @click="deleteEventAll">All</button>
|
<button type="button" class="ec-btn delete-btn" @click="deleteEventAll">All</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="isRepeatingBaseEdit">
|
<template v-else-if="isRepeatingBaseEdit">
|
||||||
<div class="ec-delete-group">
|
<div class="ec-delete-group">
|
||||||
<button type="button" class="ec-btn delete-btn" @click="deleteEventOne">Delete {{ formattedOccurrenceShort }}</button>
|
<button type="button" class="ec-btn delete-btn" @click="deleteEventOne">
|
||||||
|
Delete {{ formattedOccurrenceShort }}
|
||||||
|
</button>
|
||||||
<button type="button" class="ec-btn delete-btn" @click="deleteEventAll">All</button>
|
<button type="button" class="ec-btn delete-btn" @click="deleteEventAll">All</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<button type="button" class="ec-btn delete-btn" @click="deleteEventAll">Delete</button>
|
<button type="button" class="ec-btn delete-btn" @click="deleteEventAll">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
</template>
|
</template>
|
||||||
<button type="button" class="ec-btn close-btn" @click="closeDialog">Close</button>
|
<button type="button" class="ec-btn close-btn" @click="closeDialog">Close</button>
|
||||||
</template>
|
</template>
|
||||||
@ -384,9 +643,9 @@ const formattedOccurrenceShort = computed(() => {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ec-field input[type="text"],
|
.ec-field input[type='text'],
|
||||||
.ec-field input[type="time"],
|
.ec-field input[type='time'],
|
||||||
.ec-field input[type="number"],
|
.ec-field input[type='number'],
|
||||||
.ec-field select {
|
.ec-field select {
|
||||||
border: 1px solid var(--muted);
|
border: 1px solid var(--muted);
|
||||||
border-radius: 0.4rem;
|
border-radius: 0.4rem;
|
||||||
@ -512,4 +771,224 @@ const formattedOccurrenceShort = computed(() => {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* New recurrence block */
|
||||||
|
.recurrence-block {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
.recurrence-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.recurrence-header .recurrence-summary {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--ink);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.recurrence-header .recurrence-summary.muted {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.switch {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.switch input {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
|
.recurrence-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.6rem 0.75rem 0.75rem;
|
||||||
|
border: 1px solid var(--muted);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: color-mix(in srgb, var(--muted) 15%, transparent);
|
||||||
|
}
|
||||||
|
.line.compact {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.freq-select {
|
||||||
|
padding: 0.4rem 0.55rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
border: 1px solid var(--input-border);
|
||||||
|
background: var(--panel-alt);
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
transition:
|
||||||
|
border-color 0.18s ease,
|
||||||
|
background-color 0.18s ease;
|
||||||
|
}
|
||||||
|
.freq-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--input-focus);
|
||||||
|
background: var(--panel-accent);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px var(--input-focus),
|
||||||
|
0 0 0 4px rgba(37, 99, 235, 0.15);
|
||||||
|
}
|
||||||
|
.interval-input,
|
||||||
|
.occ-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.ec-field input[type='text'] {
|
||||||
|
border: 1px solid var(--input-border);
|
||||||
|
background: var(--panel-alt);
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
transition:
|
||||||
|
border-color 0.18s ease,
|
||||||
|
background-color 0.18s ease,
|
||||||
|
box-shadow 0.18s ease;
|
||||||
|
}
|
||||||
|
.ec-field input[type='text']:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--input-focus);
|
||||||
|
background: var(--panel-accent);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px var(--input-focus),
|
||||||
|
0 0 0 4px rgba(37, 99, 235, 0.15);
|
||||||
|
}
|
||||||
|
.mini-stepper {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--panel-alt);
|
||||||
|
border: 1px solid var(--input-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
height: 1.9rem;
|
||||||
|
}
|
||||||
|
.mini-stepper .step {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--ink);
|
||||||
|
padding: 0 0.55rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
transition:
|
||||||
|
background-color 0.15s ease,
|
||||||
|
color 0.15s ease;
|
||||||
|
}
|
||||||
|
.mini-stepper .step:hover:not(:disabled) {
|
||||||
|
background: var(--pill-hover-bg);
|
||||||
|
}
|
||||||
|
.mini-stepper .step:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.mini-stepper .value {
|
||||||
|
min-width: 1.6rem;
|
||||||
|
text-align: center;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.mini-stepper:focus-within {
|
||||||
|
border-color: var(--input-focus);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px var(--input-focus),
|
||||||
|
0 0 0 4px rgba(37, 99, 235, 0.15);
|
||||||
|
}
|
||||||
|
.mini-stepper.occ .value {
|
||||||
|
min-width: 2rem;
|
||||||
|
}
|
||||||
|
.mini-stepper .step:focus-visible {
|
||||||
|
outline: 2px solid var(--input-focus);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Recurrence UI */
|
||||||
|
.ec-recurrence-section {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.ec-recurrence-toggle {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
border: 1px solid var(--muted);
|
||||||
|
background: var(--panel);
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-align: left;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
.ec-recurrence-toggle:hover {
|
||||||
|
background: var(--muted);
|
||||||
|
}
|
||||||
|
.ec-recurrence-toggle .toggle-icon {
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.ec-recurrence-toggle .toggle-icon.open {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
.ec-recurrence-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.6rem;
|
||||||
|
border: 1px solid var(--muted);
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
background: color-mix(in srgb, var(--muted) 20%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Repeat modes */
|
||||||
|
.ec-repeat-modes {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
.ec-repeat-modes .mode-btn {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
border: 1px solid var(--muted);
|
||||||
|
background: var(--panel);
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition:
|
||||||
|
background-color 0.15s ease,
|
||||||
|
color 0.15s ease,
|
||||||
|
border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
.ec-repeat-modes .mode-btn.active {
|
||||||
|
background: var(--today);
|
||||||
|
color: #000;
|
||||||
|
border-color: var(--today);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.ec-repeat-modes .mode-btn:hover {
|
||||||
|
background: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-occurrences-field {
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
.ec-occurrences-field .ec-field input[type='number'] {
|
||||||
|
max-width: 6rem;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
249
src/components/WeekdaySelector.vue
Normal file
249
src/components/WeekdaySelector.vue
Normal file
@ -0,0 +1,249 @@
|
|||||||
|
<template>
|
||||||
|
<div class="weekgrid" @pointerleave="dragging = false">
|
||||||
|
<button
|
||||||
|
v-for="(d, di) in displayLabels"
|
||||||
|
:key="d + di"
|
||||||
|
type="button"
|
||||||
|
class="day"
|
||||||
|
:class="{
|
||||||
|
on: anySelected && displayDisplayValues[di],
|
||||||
|
// Show fallback styling on the reordered fallback day when none selected
|
||||||
|
fallback: !anySelected && displayDefault[di],
|
||||||
|
pressing: isPressing(di),
|
||||||
|
preview: previewActive && inPreviewRange(di),
|
||||||
|
}"
|
||||||
|
@pointerdown="onPointerDown(di)"
|
||||||
|
@pointerenter="onDragOver(di)"
|
||||||
|
@pointerup="onPointerUp"
|
||||||
|
>
|
||||||
|
{{ d.slice(0, 3) }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-for="g in barGroups"
|
||||||
|
:key="g.start"
|
||||||
|
type="button"
|
||||||
|
tabindex="-1"
|
||||||
|
class="workday-weekend"
|
||||||
|
:style="{ gridColumn: 'span ' + g.span }"
|
||||||
|
@click.stop="toggleWeekend(g.type)"
|
||||||
|
>
|
||||||
|
<div :class="{ workday: !g.type, weekend: g.type }"></div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { getLocalizedWeekdayNames } from '@/utils/date'
|
||||||
|
|
||||||
|
const model = defineModel({
|
||||||
|
type: Array,
|
||||||
|
default: () => [false, false, false, false, false, false, false],
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
weekend: { type: Array, default: undefined },
|
||||||
|
fallback: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [false, false, false, false, false, false, false],
|
||||||
|
},
|
||||||
|
firstDay: { type: Number, default: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
// If external model provided is entirely false, keep as-is (user will see fallback styling),
|
||||||
|
// only overwrite if null/undefined.
|
||||||
|
if (!model.value) model.value = [...props.fallback]
|
||||||
|
const labelsMondayFirst = getLocalizedWeekdayNames()
|
||||||
|
const labels = [labelsMondayFirst[6], ...labelsMondayFirst.slice(0, 6)]
|
||||||
|
const anySelected = computed(() => model.value.some(Boolean))
|
||||||
|
const localeFirst = new Intl.Locale(navigator.language).weekInfo.firstDay % 7
|
||||||
|
const localeWeekend = new Intl.Locale(navigator.language).weekInfo.weekend
|
||||||
|
const firstDay = computed(() => (props.firstDay ?? localeFirst) % 7)
|
||||||
|
|
||||||
|
const weekendDays = computed(() => {
|
||||||
|
if (props.weekend && props.weekend.length === 7) return props.weekend
|
||||||
|
const dayidx = new Set(localeWeekend)
|
||||||
|
return Array.from({ length: 7 }, (_, i) => dayidx.has(i || 7))
|
||||||
|
})
|
||||||
|
|
||||||
|
const reorder = (days) => Array.from({ length: 7 }, (_, i) => days[(i + firstDay.value) % 7])
|
||||||
|
const displayLabels = computed(() => reorder(labels))
|
||||||
|
const displayValuesCommitted = computed(() => reorder(model.value))
|
||||||
|
const displayWorking = computed(() => reorder(weekendDays.value))
|
||||||
|
const displayDefault = computed(() => reorder(props.fallback))
|
||||||
|
|
||||||
|
// Mapping from display index to original model index
|
||||||
|
const orderIndices = computed(() => Array.from({ length: 7 }, (_, i) => (i + firstDay.value) % 7))
|
||||||
|
|
||||||
|
const barGroups = computed(() => {
|
||||||
|
const arr = displayWorking.value
|
||||||
|
const groups = []
|
||||||
|
let type = arr[0]
|
||||||
|
let start = 0
|
||||||
|
for (let i = 1; i <= arr.length; i++) {
|
||||||
|
if (i === arr.length || arr[i] !== type) {
|
||||||
|
groups.push({ type, start, span: i - start })
|
||||||
|
if (i < arr.length) {
|
||||||
|
type = arr[i]
|
||||||
|
start = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
})
|
||||||
|
|
||||||
|
const dragging = ref(false)
|
||||||
|
const previewActive = ref(false)
|
||||||
|
const dragVal = ref(false)
|
||||||
|
const dragStart = ref(null)
|
||||||
|
const previewEnd = ref(null)
|
||||||
|
let originalValues = null
|
||||||
|
|
||||||
|
// Preview (drag) values; when none selected, still return committed (not fallback) so 'on' class
|
||||||
|
// is suppressed and only fallback styling applies via displayDefault
|
||||||
|
const displayPreviewValues = computed(() => {
|
||||||
|
if (
|
||||||
|
!dragging.value ||
|
||||||
|
!previewActive.value ||
|
||||||
|
dragStart.value == null ||
|
||||||
|
previewEnd.value == null ||
|
||||||
|
!originalValues
|
||||||
|
) {
|
||||||
|
return displayValuesCommitted.value
|
||||||
|
}
|
||||||
|
const [s, e] =
|
||||||
|
dragStart.value < previewEnd.value
|
||||||
|
? [dragStart.value, previewEnd.value]
|
||||||
|
: [previewEnd.value, dragStart.value]
|
||||||
|
return displayValuesCommitted.value.map((v, di) => (di >= s && di <= e ? dragVal.value : v))
|
||||||
|
})
|
||||||
|
const displayDisplayValues = displayPreviewValues
|
||||||
|
|
||||||
|
function inPreviewRange(di) {
|
||||||
|
if (!previewActive.value || dragStart.value == null || previewEnd.value == null) return false
|
||||||
|
const [s, e] =
|
||||||
|
dragStart.value < previewEnd.value
|
||||||
|
? [dragStart.value, previewEnd.value]
|
||||||
|
: [previewEnd.value, dragStart.value]
|
||||||
|
return di >= s && di <= e
|
||||||
|
}
|
||||||
|
function isPressing(di) {
|
||||||
|
return dragging.value && !previewActive.value && dragStart.value === di
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerDown(di) {
|
||||||
|
originalValues = [...model.value]
|
||||||
|
dragVal.value = !model.value[(di + firstDay.value) % 7]
|
||||||
|
dragStart.value = di
|
||||||
|
previewEnd.value = di
|
||||||
|
dragging.value = true
|
||||||
|
previewActive.value = false
|
||||||
|
window.addEventListener('pointerup', onPointerUp, { once: true })
|
||||||
|
}
|
||||||
|
function onDragOver(di) {
|
||||||
|
if (!dragging.value) return
|
||||||
|
if (previewEnd.value === di) return
|
||||||
|
if (!previewActive.value && di !== dragStart.value) previewActive.value = true
|
||||||
|
previewEnd.value = di
|
||||||
|
}
|
||||||
|
function onPointerUp() {
|
||||||
|
if (!dragging.value) return
|
||||||
|
if (!previewActive.value) {
|
||||||
|
// simple click: toggle single
|
||||||
|
const next = [...originalValues]
|
||||||
|
next[(dragStart.value + firstDay.value) % 7] = dragVal.value
|
||||||
|
model.value = next
|
||||||
|
cleanupDrag()
|
||||||
|
} else {
|
||||||
|
commitDrag()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function commitDrag() {
|
||||||
|
if (dragStart.value == null || previewEnd.value == null || !originalValues) return cancelDrag()
|
||||||
|
const [s, e] =
|
||||||
|
dragStart.value < previewEnd.value
|
||||||
|
? [dragStart.value, previewEnd.value]
|
||||||
|
: [previewEnd.value, dragStart.value]
|
||||||
|
const next = [...originalValues]
|
||||||
|
for (let di = s; di <= e; di++) next[orderIndices.value[di]] = dragVal.value
|
||||||
|
model.value = next
|
||||||
|
cleanupDrag()
|
||||||
|
}
|
||||||
|
function cancelDrag() {
|
||||||
|
cleanupDrag()
|
||||||
|
}
|
||||||
|
function cleanupDrag() {
|
||||||
|
dragging.value = false
|
||||||
|
previewActive.value = false
|
||||||
|
dragStart.value = null
|
||||||
|
previewEnd.value = null
|
||||||
|
originalValues = null
|
||||||
|
}
|
||||||
|
function toggleWeekend(work) {
|
||||||
|
const base = weekendDays.value
|
||||||
|
const target = work ? base : base.map((v) => !v)
|
||||||
|
const current = model.value
|
||||||
|
const allOn = current.every(Boolean)
|
||||||
|
const isTargetActive = current.every((v, i) => v === target[i])
|
||||||
|
if (allOn || isTargetActive) {
|
||||||
|
model.value = [false, false, false, false, false, false, false]
|
||||||
|
} else {
|
||||||
|
model.value = [...target]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.weekgrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
grid-auto-rows: auto;
|
||||||
|
}
|
||||||
|
.workday-weekend {
|
||||||
|
height: 1em;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.workday-weekend div {
|
||||||
|
height: 0.3em;
|
||||||
|
border-radius: 0.15em;
|
||||||
|
margin: 0.1em;
|
||||||
|
}
|
||||||
|
.workday {
|
||||||
|
background: var(--workday, #888);
|
||||||
|
}
|
||||||
|
.weekend {
|
||||||
|
background: var(--weekend, #f88);
|
||||||
|
}
|
||||||
|
.day {
|
||||||
|
flex: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--panel-alt);
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0.55rem 0.35rem;
|
||||||
|
border: none;
|
||||||
|
margin: 0 1px;
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.day.on {
|
||||||
|
background: var(--pill-active-bg);
|
||||||
|
color: var(--pill-active-ink);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.day.pressing {
|
||||||
|
filter: brightness(1.15);
|
||||||
|
}
|
||||||
|
.day.preview {
|
||||||
|
filter: brightness(1.15);
|
||||||
|
}
|
||||||
|
.day.fallback {
|
||||||
|
background: var(--muted-alt);
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
</style>
|
@ -10,4 +10,3 @@ const app = createApp(App)
|
|||||||
app.use(createPinia())
|
app.use(createPinia())
|
||||||
|
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|
||||||
|
@ -1,5 +1,18 @@
|
|||||||
// date-utils.js — Date handling utilities for the calendar
|
// date-utils.js — Date handling utilities for the calendar
|
||||||
const monthAbbr = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec']
|
const monthAbbr = [
|
||||||
|
'jan',
|
||||||
|
'feb',
|
||||||
|
'mar',
|
||||||
|
'apr',
|
||||||
|
'may',
|
||||||
|
'jun',
|
||||||
|
'jul',
|
||||||
|
'aug',
|
||||||
|
'sep',
|
||||||
|
'oct',
|
||||||
|
'nov',
|
||||||
|
'dec',
|
||||||
|
]
|
||||||
const DAY_MS = 86400000
|
const DAY_MS = 86400000
|
||||||
const WEEK_MS = 7 * DAY_MS
|
const WEEK_MS = 7 * DAY_MS
|
||||||
|
|
||||||
@ -8,7 +21,7 @@ const WEEK_MS = 7 * DAY_MS
|
|||||||
* @param {Date} date - The date to get week info for
|
* @param {Date} date - The date to get week info for
|
||||||
* @returns {Object} Object containing week number and year
|
* @returns {Object} Object containing week number and year
|
||||||
*/
|
*/
|
||||||
const isoWeekInfo = date => {
|
const isoWeekInfo = (date) => {
|
||||||
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()))
|
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()))
|
||||||
const day = d.getUTCDay() || 7
|
const day = d.getUTCDay() || 7
|
||||||
d.setUTCDate(d.getUTCDate() + 4 - day)
|
d.setUTCDate(d.getUTCDate() + 4 - day)
|
||||||
@ -24,7 +37,7 @@ const isoWeekInfo = date => {
|
|||||||
* @returns {string} Date string in YYYY-MM-DD format
|
* @returns {string} Date string in YYYY-MM-DD format
|
||||||
*/
|
*/
|
||||||
function toLocalString(date = new Date()) {
|
function toLocalString(date = new Date()) {
|
||||||
const pad = n => String(Math.floor(Math.abs(n))).padStart(2, '0')
|
const pad = (n) => String(Math.floor(Math.abs(n))).padStart(2, '0')
|
||||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -43,14 +56,14 @@ function fromLocalString(dateString) {
|
|||||||
* @param {Date} d - The date
|
* @param {Date} d - The date
|
||||||
* @returns {number} Monday index (0-6)
|
* @returns {number} Monday index (0-6)
|
||||||
*/
|
*/
|
||||||
const mondayIndex = d => (d.getDay() + 6) % 7
|
const mondayIndex = (d) => (d.getDay() + 6) % 7
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pad a number with leading zeros to make it 2 digits
|
* Pad a number with leading zeros to make it 2 digits
|
||||||
* @param {number} n - Number to pad
|
* @param {number} n - Number to pad
|
||||||
* @returns {string} Padded string
|
* @returns {string} Padded string
|
||||||
*/
|
*/
|
||||||
const pad = n => String(n).padStart(2, '0')
|
const pad = (n) => String(n).padStart(2, '0')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate number of days between two date strings (inclusive)
|
* Calculate number of days between two date strings (inclusive)
|
||||||
@ -133,12 +146,12 @@ function lunarPhaseSymbol(date) {
|
|||||||
// Use UTC noon of given date to reduce timezone edge effects
|
// Use UTC noon of given date to reduce timezone edge effects
|
||||||
const dUTC = Date.UTC(date.getFullYear(), date.getMonth(), date.getDate(), 12, 0, 0)
|
const dUTC = Date.UTC(date.getFullYear(), date.getMonth(), date.getDate(), 12, 0, 0)
|
||||||
const daysSince = (dUTC - ref) / DAY_MS
|
const daysSince = (dUTC - ref) / DAY_MS
|
||||||
const phase = ((daysSince / synodic) % 1 + 1) % 1
|
const phase = (((daysSince / synodic) % 1) + 1) % 1
|
||||||
const phases = [
|
const phases = [
|
||||||
{ t: 0.0, s: '🌑' }, // New Moon
|
{ t: 0.0, s: '🌑' }, // New Moon
|
||||||
{ t: 0.25, s: '🌓' }, // First Quarter
|
{ t: 0.25, s: '🌓' }, // First Quarter
|
||||||
{ t: 0.5, s: '🌕' }, // Full Moon
|
{ t: 0.5, s: '🌕' }, // Full Moon
|
||||||
{ t: 0.75, s: '🌗' } // Last Quarter
|
{ t: 0.75, s: '🌗' }, // Last Quarter
|
||||||
]
|
]
|
||||||
// threshold in days from exact phase to still count for this date
|
// threshold in days from exact phase to still count for this date
|
||||||
const thresholdDays = 0.5 // ±12 hours
|
const thresholdDays = 0.5 // ±12 hours
|
||||||
@ -165,5 +178,5 @@ export {
|
|||||||
getLocalizedWeekdayNames,
|
getLocalizedWeekdayNames,
|
||||||
getLocalizedMonthName,
|
getLocalizedMonthName,
|
||||||
formatDateRange,
|
formatDateRange,
|
||||||
lunarPhaseSymbol
|
lunarPhaseSymbol,
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user