Major new version (#2)

Release Notes

Architecture
- Component refactor: removed monoliths (`Calendar.vue`, `CalendarGrid.vue`); expanded granular view + header/control components.
- Dialog system introduced (`BaseDialog`, `SettingsDialog`).

State & Data
- Store redesigned: Map-based events + recurrence map; mutation counters.
- Local persistence + undo/redo history (custom plugins).

Date & Holidays
- Migrated all date logic to `date-fns` (+ tz).
- Added national holiday support (toggle + loading utilities).

Recurrence & Events
- Consolidated recurrence handling; fixes for monthly edge days (29–31), annual, multi‑day, and complex weekly repeats.
- Reliable splitting/moving/resizing/deletion of repeating and multi‑day events.

Interaction & UX
- Double‑tap to create events; improved drag (multi‑day + position retention).
- Scroll & inertial/momentum navigation; year change via numeric scroller.
- Movable event dialog; live settings application.

Performance
- Progressive / virtual week rendering, reduced off‑screen buffer.
- Targeted repaint strategy; minimized full re-renders.

Plugins Added
- History, undo normalization, persistence, scroll manager, virtual weeks.

Styling & Layout
- Responsive + compact layout refinements; header restructured.
- Simplified visual elements (removed dots/overflow text); holiday styling adjustments.

Reliability / Fixes
- Numerous recurrence, deletion, orientation/rotation, and event indexing corrections.
- Cross-browser fallback (Firefox week info).

Dependencies Added
- date-fns, date-fns-tz, date-holidays, pinia-plugin-persistedstate.

Net Change
- 28 files modified; ~4.4K insertions / ~2.2K deletions (major refactor + feature set).
This commit is contained in:
2025-08-26 03:58:24 +00:00
parent 86a06acccb
commit dbe4c99b07
28 changed files with 4465 additions and 2207 deletions
+47 -14
View File
@@ -33,7 +33,7 @@
</template>
<script setup>
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import {
getLocalizedWeekdayNames,
getLocaleFirstDay,
@@ -44,7 +44,10 @@ import {
const model = defineModel({
type: Array,
default: () => [false, false, false, false, false, false, false],
})
}) // external value consumers see
// Internal state preserves the user's explicit picks even if all false
const internal = ref([false, false, false, false, false, false, false])
const props = defineProps({
weekend: { type: Array, default: undefined },
@@ -55,12 +58,11 @@ const props = defineProps({
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]
// Initialize internal from external if it has any true; else keep empty (fallback handled on emit)
if (model.value?.some?.(Boolean)) internal.value = [...model.value]
const labelsMondayFirst = getLocalizedWeekdayNames()
const labels = [labelsMondayFirst[6], ...labelsMondayFirst.slice(0, 6)]
const anySelected = computed(() => model.value.some(Boolean))
const anySelected = computed(() => internal.value.some(Boolean))
const localeFirst = getLocaleFirstDay()
const localeWeekend = getLocaleWeekendDays()
const firstDay = computed(() => (props.firstDay ?? localeFirst) % 7)
@@ -71,10 +73,38 @@ const weekendDays = computed(() => {
})
const displayLabels = computed(() => reorderByFirstDay(labels, firstDay.value))
const displayValuesCommitted = computed(() => reorderByFirstDay(model.value, firstDay.value))
const displayValuesCommitted = computed(() => reorderByFirstDay(internal.value, firstDay.value))
const displayWorking = computed(() => reorderByFirstDay(weekendDays.value, firstDay.value))
const displayDefault = computed(() => reorderByFirstDay(props.fallback, firstDay.value))
// Expose a normalized pattern (Sunday-first) that substitutes the fallback day if none selected.
// This keeps UI visually showing fallback (muted) but downstream logic can opt-in by reading this.
function computeFallbackPattern() {
const fb = props.fallback && props.fallback.length === 7 ? props.fallback : null
if (fb && fb.some(Boolean)) return [...fb]
const arr = [false, false, false, false, false, false, false]
const idx = fb ? fb.findIndex(Boolean) : -1
if (idx >= 0) arr[idx] = true
else arr[0] = true
return arr
}
function emitExternal() {
model.value = internal.value.some(Boolean) ? [...internal.value] : computeFallbackPattern()
}
emitExternal()
watch(
() => model.value,
(nv) => {
if (!nv) return
if (!nv.some(Boolean)) return
const fb = computeFallbackPattern()
const isFallback = fb.every((v, i) => v === nv[i])
// If internal is empty and model only reflects fallback, do not sync into internal
if (isFallback && !internal.value.some(Boolean)) return
internal.value = [...nv]
},
)
// Mapping from display index to original model index
const orderIndices = computed(() => Array.from({ length: 7 }, (_, i) => (i + firstDay.value) % 7))
@@ -135,8 +165,8 @@ function isPressing(di) {
}
function onPointerDown(di) {
originalValues = [...model.value]
dragVal.value = !model.value[(di + firstDay.value) % 7]
originalValues = [...internal.value]
dragVal.value = !internal.value[(di + firstDay.value) % 7]
dragStart.value = di
previewEnd.value = di
dragging.value = true
@@ -155,7 +185,8 @@ function onPointerUp() {
// simple click: toggle single
const next = [...originalValues]
next[(dragStart.value + firstDay.value) % 7] = dragVal.value
model.value = next
internal.value = next
emitExternal()
cleanupDrag()
} else {
commitDrag()
@@ -169,7 +200,8 @@ function commitDrag() {
: [previewEnd.value, dragStart.value]
const next = [...originalValues]
for (let di = s; di <= e; di++) next[orderIndices.value[di]] = dragVal.value
model.value = next
internal.value = next
emitExternal()
cleanupDrag()
}
function cancelDrag() {
@@ -185,14 +217,15 @@ function cleanupDrag() {
function toggleWeekend(work) {
const base = weekendDays.value
const target = work ? base : base.map((v) => !v)
const current = model.value
const current = internal.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]
internal.value = [false, false, false, false, false, false, false]
} else {
model.value = [...target]
internal.value = [...target]
}
emitExternal()
}
</script>