1
0

feat: use AI to migrate calendar and event views

This commit is contained in:
2026-07-09 16:59:00 +08:00
parent e6fc06c0f9
commit 0f674d0483
12 changed files with 1276 additions and 24 deletions

View File

@@ -10,6 +10,7 @@ export enum TabType {
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import {
MAX_DATETIME,
MIN_DATETIME,
@@ -19,6 +20,7 @@ import {
dayOfWeek,
isLeapYear
} from '@/utils/datetime';
import { MONTH_NAMES, WEEK_NAMES } from '@/utils/calendar-names';
const DIAL_PLATE_WIDTH: number = 200;
const DIAL_PLATE_RADIUS: number = DIAL_PLATE_WIDTH / 2;
@@ -29,13 +31,6 @@ const DIAL_PLATE_MINUTE_PERCENT: number = 0.8;
const DIAL_PLATE_HOUR_RESOLUTION: number = Math.PI * 2 / 12;
const DIAL_PLATE_MINUTE_RESOLUTION: number = Math.PI * 2 / 60;
const MONTH_NAMES: string[] = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
];
const WEEK_NAMES: string[] = [
'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'
];
const NBSP: string = '\u00A0';
const isVisible = ref(false);
@@ -404,7 +399,7 @@ onUnmounted(() => {
<div class="level-left">
<div class="level-item control">
<a class="button" @click="prevNextYear(true)">
<span class="icon is-small"><i class="fas fa-chevron-circle-left"></i></span>
<span class="icon is-small"><font-awesome-icon icon="fas fa-chevron-circle-left"></font-awesome-icon></span>
</a>
</div>
</div>
@@ -412,7 +407,7 @@ onUnmounted(() => {
<div class="level-right">
<div class="level-item control">
<a class="button" @click="prevNextYear(false)">
<span class="icon is-small"><i class="fas fa-chevron-circle-right"></i></span>
<span class="icon is-small"><font-awesome-icon icon="fas fa-chevron-circle-right"></font-awesome-icon></span>
</a>
</div>
</div>
@@ -432,7 +427,7 @@ onUnmounted(() => {
<div class="level-left">
<div class="level-item control">
<a class="button" @click="prevNextMonth(true)">
<span class="icon is-small"><i class="fas fa-chevron-circle-left"></i></span>
<span class="icon is-small"><font-awesome-icon icon="fas fa-chevron-circle-left"></font-awesome-icon></span>
</a>
</div>
</div>
@@ -440,7 +435,7 @@ onUnmounted(() => {
<div class="level-right">
<div class="level-item control">
<a class="button" @click="prevNextMonth(false)">
<span class="icon is-small"><i class="fas fa-chevron-circle-right"></i></span>
<span class="icon is-small"><font-awesome-icon icon="fas fa-chevron-circle-right"></font-awesome-icon></span>
</a>
</div>
</div>
@@ -460,7 +455,7 @@ onUnmounted(() => {
<div class="level-left">
<div class="level-item control">
<a class="button" @click="prevNextDay(true)">
<span class="icon is-small"><i class="fas fa-chevron-circle-left"></i></span>
<span class="icon is-small"><font-awesome-icon icon="fas fa-chevron-circle-left"></font-awesome-icon></span>
</a>
</div>
</div>
@@ -468,7 +463,7 @@ onUnmounted(() => {
<div class="level-right">
<div class="level-item control">
<a class="button" @click="prevNextDay(false)">
<span class="icon is-small"><i class="fas fa-chevron-circle-right"></i></span>
<span class="icon is-small"><font-awesome-icon icon="fas fa-chevron-circle-right"></font-awesome-icon></span>
</a>
</div>
</div>

View File

@@ -0,0 +1,310 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import DateTimePicker, { TabType } from '@/components/DateTimePicker.vue';
import { resolveLoopRules4UI, getDayInMonth } from '@/utils/datetime';
import { WEEK_NAMES } from '@/utils/calendar-names';
import { format } from '@/utils/utils';
type LoopMethod = 'never' | 'day' | 'week' | 'month' | 'year';
type StopMethod = 'forever' | 'datetime' | 'times';
const props = defineProps<{
modelValue: string;
startDate: Date;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
}>();
const stopPicker = ref<InstanceType<typeof DateTimePicker>>();
const loopMethod = ref<LoopMethod>('never');
const daySpan = ref<number>(1);
const weekSpan = ref<number>(1);
const monthSpan = ref<number>(1);
const yearSpan = ref<number>(1);
// default: today's weekday checked (Monday-first 0-6)
const initialWeekday = (new Date().getDay() + 6) % 7;
const weekChecks = ref<boolean[]>(Array.from({ length: 7 }, (_, i) => i === initialWeekday));
const monthMode = ref<'A' | 'B' | 'C' | 'D'>('A');
const isStrict = ref<boolean>(true);
const stopMethod = ref<StopMethod>('forever');
const stopTimes = ref<number>(1);
const stopDateTime = ref<Date>(new Date());
// region: Serialize / parse
const buildString = (): string => {
let loopPart = '';
switch (loopMethod.value) {
case 'never':
return '';
case 'day':
loopPart = `D${daySpan.value}`;
break;
case 'week': {
const checks = weekChecks.value.map(b => b ? 'T' : 'F').join('');
loopPart = `W${checks}${weekSpan.value}`;
break;
}
case 'month':
loopPart = `M${isStrict.value ? 'S' : 'R'}${monthMode.value}${monthSpan.value}`;
break;
case 'year':
loopPart = `Y${isStrict.value ? 'S' : 'R'}${yearSpan.value}`;
break;
}
let stopPart = '';
switch (stopMethod.value) {
case 'forever':
stopPart = '-F';
break;
case 'datetime':
stopPart = `-D${Math.floor(stopDateTime.value.getTime() / 60000)}`;
break;
case 'times':
stopPart = `-T${stopTimes.value}`;
break;
}
return loopPart + stopPart;
};
const serialized = computed<string>(() => buildString());
// Cast helpers: the parsed rule tuples are a union; index access is loose here.
const parseFromModel = (val: string): void => {
if (val === serialized.value) return;
if (val === '') {
loopMethod.value = 'never';
return;
}
const parsed = resolveLoopRules4UI(val);
if (typeof parsed === 'undefined') {
loopMethod.value = 'never';
return;
}
const loopRule = parsed[0];
const stopRule = parsed[1];
switch (loopRule[0]) {
case 0:
loopMethod.value = 'year';
isStrict.value = loopRule[1];
yearSpan.value = loopRule[2];
break;
case 1:
loopMethod.value = 'month';
isStrict.value = loopRule[1];
monthMode.value = loopRule[2];
monthSpan.value = loopRule[3];
break;
case 2:
loopMethod.value = 'week';
weekChecks.value = [loopRule[1], loopRule[2], loopRule[3], loopRule[4], loopRule[5], loopRule[6], loopRule[7]];
weekSpan.value = loopRule[8];
break;
case 3:
loopMethod.value = 'day';
daySpan.value = loopRule[1];
break;
}
switch (stopRule[0]) {
case 0:
stopMethod.value = 'forever';
break;
case 1:
stopMethod.value = 'datetime';
// NOTE: treated as a plain UTC instant (day-precision stop), deviating
// from the legacy timezone-shifted display. See migration notes.
stopDateTime.value = new Date(stopRule[1] * 60000);
break;
case 2:
stopMethod.value = 'times';
stopTimes.value = stopRule[1];
break;
}
};
watch(() => props.modelValue, (val) => parseFromModel(val), { immediate: true });
watch(serialized, (val) => {
if (val !== props.modelValue) emit('update:modelValue', val);
});
// endregion
const showLoopStop = computed(() => loopMethod.value !== 'never');
const showStrictMode = computed(() => loopMethod.value === 'month' || loopMethod.value === 'year');
const monthOptionTexts = computed(() => {
const d = getDayInMonth(
props.startDate.getFullYear(),
props.startDate.getMonth() + 1,
props.startDate.getDate()
);
return {
A: format('Day {0} in month', d[0]),
B: format('Day {0} from the end of the month', d[1]),
C: format('Day {1} in week {0}', d[2], d[3] + 1),
D: format('Day {1} in week {0} from the end of the month', d[4], d[5] + 1),
};
});
const stopDateTimeText = computed(() => stopDateTime.value.toLocaleDateString());
const openStopPicker = () => {
stopPicker.value?.modal(stopDateTime.value, TabType.Day);
};
const onStopPickerConfirm = (date: Date) => {
stopDateTime.value = date;
};
</script>
<template>
<section class="section">
<h2 class="subtitle">Event Loop</h2>
<div class="button-list">
<label class="radio">
<input type="radio" value="never" v-model="loopMethod">
<span>Never</span>
</label>
<label class="radio">
<input type="radio" value="day" v-model="loopMethod">
<span>Day</span>
</label>
<label class="radio">
<input type="radio" value="week" v-model="loopMethod">
<span>Week</span>
</label>
<label class="radio">
<input type="radio" value="month" v-model="loopMethod">
<span>Month</span>
</label>
<label class="radio">
<input type="radio" value="year" v-model="loopMethod">
<span>Year</span>
</label>
</div>
<div v-if="loopMethod === 'day'">
<div class="field">
<label class="label">Day span</label>
<div class="control">
<input v-model.number="daySpan" class="input spanpicker" type="number" min="1" max="100" step="1">
</div>
</div>
</div>
<div v-if="loopMethod === 'week'">
<div class="field">
<label class="label">Week span</label>
<div class="control">
<input v-model.number="weekSpan" class="input spanpicker" type="number" min="1" max="100" step="1">
</div>
</div>
<div class="field">
<label class="label">Week options</label>
<div class="button-list">
<label v-for="(name, i) in WEEK_NAMES" :key="i" class="checkbox">
<input type="checkbox" v-model="weekChecks[i]">
<span>{{ name }}</span>
</label>
</div>
</div>
</div>
<div v-if="loopMethod === 'month'">
<div class="field">
<label class="label">Month span</label>
<div class="control">
<input v-model.number="monthSpan" class="input spanpicker" type="number" min="1" max="100" step="1">
</div>
</div>
<div class="field">
<label class="label">Month mode</label>
<div class="button-list">
<label class="radio">
<input type="radio" value="A" v-model="monthMode">
<span>{{ monthOptionTexts.A }}</span>
</label>
<label class="radio">
<input type="radio" value="B" v-model="monthMode">
<span>{{ monthOptionTexts.B }}</span>
</label>
<label class="radio">
<input type="radio" value="C" v-model="monthMode">
<span>{{ monthOptionTexts.C }}</span>
</label>
<label class="radio">
<input type="radio" value="D" v-model="monthMode">
<span>{{ monthOptionTexts.D }}</span>
</label>
</div>
</div>
</div>
<div v-if="loopMethod === 'year'">
<div class="field">
<label class="label">Year span</label>
<div class="control">
<input v-model.number="yearSpan" class="input spanpicker" type="number" min="1" max="100" step="1">
</div>
</div>
</div>
</section>
<section v-if="showStrictMode" class="section">
<h2 class="subtitle">Strict Mode in Event Loop</h2>
<p>You can choose strict mode or rough mode in following content. This is only effect on looped event.</p>
<div class="button-list">
<label class="radio">
<input type="radio" :value="true" v-model="isStrict">
<span>Strict Mode. If ordered day is not existing, skip it.</span>
</label>
<label class="radio">
<input type="radio" :value="false" v-model="isStrict">
<span>Rough mode. If ordered day is not existing, choose the day closing with original day to arrange event.</span>
</label>
</div>
</section>
<section v-if="showLoopStop" class="section">
<h2 class="subtitle">Event Loop Stop</h2>
<div class="button-list">
<label class="radio">
<input type="radio" value="forever" v-model="stopMethod">
<span>Forever</span>
</label>
<label class="radio">
<input type="radio" value="datetime" v-model="stopMethod">
<span>Date Time</span>
</label>
<label class="radio">
<input type="radio" value="times" v-model="stopMethod">
<span>Times</span>
</label>
</div>
<div v-if="stopMethod === 'datetime'">
<a class="button" @click="openStopPicker">
<span>{{ stopDateTimeText }}</span>
</a>
</div>
<div v-if="stopMethod === 'times'">
<div class="field">
<div class="control">
<input v-model.number="stopTimes" class="input spanpicker" type="number" min="1" max="100" step="1">
</div>
</div>
</div>
</section>
<DateTimePicker ref="stopPicker" @confirm="onStopPickerConfirm" />
</template>
<style scoped>
.section {
border-top: 1px solid rgba(219, 219, 219, .5);
padding-top: 1.25rem;
margin-top: 1.25rem;
}
</style>

View File

@@ -0,0 +1,110 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { DisplayDay } from './types';
import { format } from '@/utils/utils';
const props = defineProps<{
cells: DisplayDay[];
weekNames: string[];
}>();
const emit = defineEmits<{
(e: 'event-click', uuid: string): void
}>();
const rows = computed<DisplayDay[][]>(() => {
const r: DisplayDay[][] = [];
for (let i = 0; i < 6; i++) r.push(props.cells.slice(i * 7, i * 7 + 7));
return r;
});
const overflowText = (count: number): string => {
return format('{0} items', count.toString());
};
</script>
<template>
<div class="calendar-grid">
<div class="calendar-grid-header">
<div v-for="(name, i) in weekNames" :key="i" :class="{ weekend: i >= 5 }"><b>{{ name }}</b></div>
</div>
<div v-for="(row, ri) in rows" :key="ri" class="calendar-grid-row">
<div v-for="(cell, ci) in row" :key="ci" class="calendar-grid-cell"
:class="{ 'not-current-month': !cell.isCurrentMonth }">
<p class="cell-title">
<b>{{ cell.day }}</b>
<span>{{ cell.subcalendar }}</span>
</p>
<div v-for="(e, ei) in cell.events.slice(0, 4)" :key="ei" class="event-bar"
:style="{ background: e.color }" @click="emit('event-click', e.uuid)"></div>
<p v-if="cell.events.length > 4" class="cell-overflow">{{ overflowText(cell.events.length) }}</p>
</div>
</div>
</div>
</template>
<style scoped>
.calendar-grid {
display: flex;
flex-flow: column;
}
.calendar-grid-header,
.calendar-grid-row {
display: flex;
flex-flow: row;
}
.calendar-grid-cell {
flex-grow: 1;
flex-basis: 0;
flex-shrink: 0;
border-top: 1px solid black;
border-left: 1px solid black;
border-right: 1px solid black;
border-bottom: 1px solid black;
padding: 0.75em;
display: flex;
flex-flow: column;
align-items: flex-start;
overflow: hidden;
}
.calendar-grid-cell.not-current-month {
background: #d0d0d0;
}
/* remove the double border between adjacent cells */
.calendar-grid-row .calendar-grid-cell:nth-child(n+2) {
border-left: 0;
}
.calendar-grid-row:nth-child(n+2) .calendar-grid-cell {
border-top: 0;
}
.cell-title {
margin-bottom: 0.25rem;
}
.cell-overflow {
margin-top: 0.2rem;
}
.event-bar {
border: 1px solid black;
border-radius: 2px;
margin-top: 0.2rem;
height: 0.75rem;
width: 100%;
cursor: pointer;
}
.weekend {
color: red;
}
</style>

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
defineProps<{
name: string;
subtitle?: string;
isShow: boolean;
}>();
const emit = defineEmits<{
(e: 'toggle'): void
}>();
</script>
<template>
<div class="paperbox-item card">
<div class="paperbox-item-words">
<p v-if="subtitle === undefined"><b>{{ name }}</b></p>
<div v-else>
<b>{{ name }}</b>
<p>
<span>Shared by: </span>
<span>{{ subtitle }}</span>
</p>
</div>
</div>
<div class="paperbox-item-icon control" @click="emit('toggle')">
<a class="button">
<span class="icon is-small">
<font-awesome-icon :icon="isShow ? 'fas fa-eye' : 'fas fa-eye-slash'"></font-awesome-icon>
</span>
</a>
</div>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,112 @@
<script setup lang="ts">
import type { DisplayDay } from './types';
import { MONTH_NAMES, WEEK_NAMES } from '@/utils/calendar-names';
defineProps<{
days: DisplayDay[];
}>();
const emit = defineEmits<{
(e: 'event-click', uuid: string): void
}>();
</script>
<template>
<div class="schedule-list">
<div v-for="(day, di) in days" :key="di" class="schedule-day container">
<div class="schedule-day-words">
<b>{{ MONTH_NAMES[day.month - 1] }}</b>
<b>{{ day.day }}</b>
<b>{{ WEEK_NAMES[day.dayOfWeek - 1] }}</b>
</div>
<div class="schedule-event-list">
<template v-for="(ev, ei) in day.events" :key="ei">
<div v-if="ev.isVisible" class="schedule-event-outter card" @click="emit('event-click', ev.uuid)">
<div class="schedule-event-color" :style="{ background: ev.color }"></div>
<div class="schedule-event-inner">
<div class="schedule-event-words">
<p class="level-item"><b>{{ ev.title }}</b></p>
<p class="level-item">{{ ev.description }}</p>
<p class="level-item"><span>{{ ev.start }}</span>-<span>{{ ev.end }}</span></p>
<p v-if="ev.loopText !== ''">
<span class="icon is-small"><font-awesome-icon icon="fas fa-retweet"></font-awesome-icon></span>
<span>{{ ev.loopText }}</span>
</p>
</div>
<div class="schedule-event-icon">
<span v-if="ev.isLocked" class="icon is-small">
<font-awesome-icon icon="fas fa-lock"></font-awesome-icon>
</span>
<span v-if="ev.timezoneWarning" class="icon is-small">
<font-awesome-icon icon="fas fa-globe"></font-awesome-icon>
</span>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
</template>
<style scoped>
.schedule-day {
display: flex;
flex-flow: column;
}
.schedule-day-words {
margin-top: 0.75rem;
margin-bottom: 0.75rem;
}
.schedule-day-words b {
margin-right: 0.5rem;
}
.schedule-event-list {
display: flex;
flex-flow: column;
}
.schedule-event-outter {
display: flex;
flex-flow: row;
align-items: flex-start;
margin-bottom: 1.25rem;
cursor: pointer;
}
.schedule-event-inner {
display: flex;
flex-flow: row;
align-items: flex-start;
padding: 1.25rem;
}
.schedule-event-words {
display: flex;
flex-flow: column;
align-items: flex-start;
flex-grow: 1;
flex-basis: 0;
word-break: break-all;
}
.schedule-event-icon {
margin-left: 0.75rem;
}
.schedule-event-color {
width: 0.75rem;
height: 100%;
min-height: 2rem;
}
.schedule-list .schedule-day:nth-child(n+2) {
border-top: 1px solid rgba(219, 219, 219, .5);
}
</style>

View File

@@ -0,0 +1,26 @@
/** A single analyzed event occurrence placed into a calendar day cell. */
export interface DisplayEvent {
uuid: string;
belongTo: string;
title: string;
description: string;
color: string;
isVisible: boolean;
isLocked: boolean;
loopText: string;
timezoneWarning: boolean;
start: string;
end: string;
}
/** A single day cell in the 6x7 calendar grid, carrying its events. */
export interface DisplayDay {
/** 1-12 */
month: number;
day: number;
/** 1-7, Monday = 1 */
dayOfWeek: number;
isCurrentMonth: boolean;
subcalendar: string;
events: DisplayEvent[];
}

View File

@@ -3,7 +3,7 @@ import { createPinia } from 'pinia'
import { library } from '@fortawesome/fontawesome-svg-core'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { faUser, faLock, faPen, faShare, faTrash, faCheck, faTimes, faPlus, faSync } from '@fortawesome/free-solid-svg-icons'
import { faUser, faLock, faPen, faShare, faTrash, faCheck, faTimes, faPlus, faSync, faChevronCircleLeft, faChevronCircleRight, faEye, faEyeSlash, faRetweet, faGlobe } from '@fortawesome/free-solid-svg-icons'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
@@ -19,7 +19,7 @@ const app = createApp(App);
app.use(pinia);
app.use(router);
library.add(faUser, faLock, faPen, faShare, faTrash, faCheck, faTimes, faPlus, faSync);
library.add(faUser, faLock, faPen, faShare, faTrash, faCheck, faTimes, faPlus, faSync, faChevronCircleLeft, faChevronCircleRight, faEye, faEyeSlash, faRetweet, faGlobe);
app.component('font-awesome-icon', FontAwesomeIcon);
app.mount('#app');

View File

@@ -18,7 +18,8 @@ const routes = [
{ path: '/todo', name: "Todo", meta: { requireLoggedInCheck: true }, component: Todo },
{ path: '/admin', name: "Admin", meta: { requireLoggedInCheck: true }, component: Admin },
{ path: '/calendar/event', name: "CalendarEvent", meta: { requireLoggedInCheck: true }, component: CalendarEvent },
{ path: '/calendar/event', name: "CalendarEventAdd", meta: { requireLoggedInCheck: true }, component: CalendarEvent },
{ path: '/calendar/event/:uuid', name: "CalendarEventUpdate", meta: { requireLoggedInCheck: true }, component: CalendarEvent },
{ path: '/login', name: "Login", meta: { requireLoggedOutCheck: true }, component: Login },
{ path: '/404', name: "NotFound", component: NotFound },
@@ -52,4 +53,8 @@ export const goToHome = () => {
router.push({ name: 'Home' })
}
export const goToCalendar = () => {
router.push({ name: 'Calendar' })
}
export default router

View File

@@ -0,0 +1,8 @@
export const MONTH_NAMES: string[] = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
];
export const WEEK_NAMES: string[] = [
'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'
];

View File

@@ -451,7 +451,7 @@ type DayInMonthInfo = [
weeksBackwardDayOfWeek: number,
];
function getDayInMonth(year: number, month: number, day: number): DayInMonthInfo {
export function getDayInMonth(year: number, month: number, day: number): DayInMonthInfo {
const days = MONTH_DAY_COUNT[month - 1]! + ((month == 2 && isLeapYear(year)) ? 1 : 0);
const firstDayOfWeek = dayOfWeek(year, month, 1);
const bilateralDayOfWeek = (firstDayOfWeek + day - 1) % 7;

View File

@@ -1,8 +1,344 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useTokenStore } from '@/stores/token';
import MessageBox from '@/components/MessageBox.vue';
import DateTimePicker, { TabType } from '@/components/DateTimePicker.vue';
import CalendarGrid from '@/components/calendar/CalendarGrid.vue';
import ScheduleList from '@/components/calendar/ScheduleList.vue';
import CollectionToggleItem from '@/components/calendar/CollectionToggleItem.vue';
import type { DisplayDay, DisplayEvent } from '@/components/calendar/types';
import {
getFull as apiCalendarGetFull,
deserializeDescription,
type CalendarRow,
} from '@/api/calendar';
import {
getFullOwn as apiCollectionGetFullOwn,
getShared as apiCollectionGetShared,
type CollectionRow,
type SharedCollectionRow,
} from '@/api/collection';
import {
DAY1_SPAN,
dayOfWeek,
resolveLoopRules2Event,
resolveLoopRules4Text,
} from '@/utils/datetime';
import { MONTH_NAMES, WEEK_NAMES } from '@/utils/calendar-names';
const token = useTokenStore();
const router = useRouter();
const messagebox = ref<InstanceType<typeof MessageBox>>();
const picker = ref<InstanceType<typeof DateTimePicker>>();
const activeTab = ref<number>(1);
// The viewed month cursor. Only year + month are meaningful; the time-of-day
// participates in the grid start timestamp exactly like the legacy code.
const currentMonth = ref<Date>(new Date());
// Raw event rows covering the current grid window.
const eventsCache = ref<CalendarRow[]>([]);
// Collection lists for tab 2.
const ownedCollections = ref<CollectionRow[]>([]);
const sharedCollections = ref<SharedCollectionRow[]>([]);
// Per-collection visibility flags, keyed by collection uuid. Default (absent)
// means hidden, matching the legacy behaviour where missing cache entries are
// treated as false.
const ownedVisible = ref<Map<string, boolean>>(new Map());
const sharedVisible = ref<Map<string, boolean>>(new Map());
// region: Computed grid window and analyzed display data
const gridYear = computed(() => currentMonth.value.getFullYear());
const gridMonth = computed(() => currentMonth.value.getMonth() + 1);
const firstDow = computed(() => dayOfWeek(gridYear.value, gridMonth.value, 1));
const gridStartTimestamp = computed(() =>
Math.floor(currentMonth.value.getTime() / 60000) - firstDow.value * DAY1_SPAN
);
const gridEndTimestamp = computed(() => gridStartTimestamp.value + DAY1_SPAN * 6 * 7 - 1);
const textMonth = computed(() => `${gridYear.value} - ${MONTH_NAMES[gridMonth.value - 1]}`);
const myTimezoneOffset = computed(() => -(new Date().getTimezoneOffset()));
// Port of ccn_calendar_calendar_Analyse: build the 42-cell display cache,
// expand loop events and split occurrences that cross day boundaries.
const displayDays = computed<DisplayDay[]>(() => {
const startTimestamp = gridStartTimestamp.value;
const endTimestamp = gridEndTimestamp.value;
const gottenMonth = gridMonth.value;
const myTz = myTimezoneOffset.value;
// Build the 42 day cells.
const days: DisplayDay[] = [];
const cursor = new Date(startTimestamp * 60000);
for (let i = 0; i < 6 * 7; i++) {
days.push({
month: cursor.getMonth() + 1,
day: cursor.getDate(),
dayOfWeek: (cursor.getDay() === 0 ? 6 : cursor.getDay() - 1) + 1,
isCurrentMonth: (cursor.getMonth() + 1) === gottenMonth,
subcalendar: '',
events: [],
});
cursor.setTime(cursor.getTime() + DAY1_SPAN * 60000);
}
// Expand and place each event.
for (const item of eventsCache.value) {
const deserialized = deserializeDescription(item[3]);
const minStartTimestamp = startTimestamp - (item[6] - item[5]);
const result = resolveLoopRules2Event(
item[8],
item[9] < minStartTimestamp ? minStartTimestamp : item[9],
Math.min(item[10], endTimestamp),
item[5],
item[6],
item[7],
startTimestamp
);
if (typeof result === 'undefined') continue;
const loopText = resolveLoopRules4Text(item[8], item[5], item[7]);
const timezoneWarning = myTz !== item[7];
for (const it of result) {
const eventDateTime = new Date(it[0] * 60000);
let count = Math.floor((it[0] - startTimestamp) / DAY1_SPAN);
let exitFlag = false;
while (count < 6 * 7) {
// NOTE: legacy indexes the owned cache by the *event* uuid (item[0]),
// which is collection-keyed, so this is always false. Kept faithful.
const isLocked = ownedVisible.value.has(item[0]);
const belongVisible = ownedVisible.value.get(item[1]) || sharedVisible.value.get(item[1]) || false;
const eventItem: DisplayEvent = {
uuid: item[0],
belongTo: item[1],
title: item[2],
description: deserialized.description,
color: deserialized.color,
isVisible: belongVisible,
isLocked,
loopText,
timezoneWarning,
start: eventDateTime.toLocaleTimeString(),
end: '',
};
eventDateTime.setHours(23, 59, 0, 0);
if (it[1] <= Math.floor(eventDateTime.getTime() / 60000)) {
exitFlag = true;
eventDateTime.setTime(it[1] * 60000);
}
eventItem.end = eventDateTime.toLocaleTimeString();
days[count]!.events.push(eventItem);
if (exitFlag) break;
eventDateTime.setMinutes(eventDateTime.getMinutes() + 1, 0, 0);
count++;
}
}
}
return days;
});
// endregion
// region: Data fetching
const refreshEvents = async () => {
const rv = await apiCalendarGetFull(token.currentToken, gridStartTimestamp.value, gridEndTimestamp.value);
eventsCache.value = typeof rv === 'undefined' ? [] : rv;
};
const refreshCollections = async () => {
const newOwned = new Map<string, boolean>();
const newShared = new Map<string, boolean>();
const sharedRv = await apiCollectionGetShared(token.currentToken);
sharedCollections.value = typeof sharedRv === 'undefined' ? [] : sharedRv;
if (sharedCollections.value.length > 0) {
for (const item of sharedCollections.value) {
newShared.set(item[0], true);
}
}
const ownedRv = await apiCollectionGetFullOwn(token.currentToken);
ownedCollections.value = typeof ownedRv === 'undefined' ? [] : ownedRv;
if (ownedCollections.value.length > 0) {
for (const item of ownedCollections.value) {
newOwned.set(item[0], true);
}
}
ownedVisible.value = newOwned;
sharedVisible.value = newShared;
};
// endregion
// region: Navigation
const prevMonth = () => {
const d = new Date(currentMonth.value);
d.setMonth(d.getMonth() - 1);
currentMonth.value = d;
};
const nextMonth = () => {
const d = new Date(currentMonth.value);
d.setMonth(d.getMonth() + 1);
currentMonth.value = d;
};
const today = () => {
currentMonth.value = new Date();
};
const jump = () => {
picker.value?.modal(currentMonth.value, TabType.Month);
};
const onPickerConfirm = (date: Date) => {
currentMonth.value = date;
};
const addEvent = () => {
router.push({ name: 'CalendarEventAdd' });
};
const editEvent = (uuid: string) => {
router.push({ name: 'CalendarEventUpdate', params: { uuid } });
};
// endregion
// region: Collection visibility toggles
const toggleOwned = (uuid: string) => {
const m = new Map(ownedVisible.value);
m.set(uuid, !m.get(uuid));
ownedVisible.value = m;
};
const toggleShared = (uuid: string) => {
const m = new Map(sharedVisible.value);
m.set(uuid, !m.get(uuid));
sharedVisible.value = m;
};
// endregion
watch(currentMonth, () => { refreshEvents(); });
onMounted(async () => {
await refreshCollections();
await refreshEvents();
});
</script>
<template>
<h1>Congratulations</h1>
<p>This is calendar.</p>
<div class="container" style="margin-top: 20px;">
<div class="tabs">
<ul>
<li :class="{ 'is-active': activeTab === 1 }" @click="activeTab = 1"><a>Calendar</a></li>
<li :class="{ 'is-active': activeTab === 2 }" @click="activeTab = 2"><a>Collection</a></li>
<li :class="{ 'is-active': activeTab === 3 }" @click="activeTab = 3"><a>Display</a></li>
</ul>
</div>
</div>
<!-- Tab 1: Calendar -->
<div v-show="activeTab === 1" class="container" style="margin-top: 20px;">
<nav class="level is-mobile">
<div class="level-left">
<div class="level-item control">
<a class="button" @click="prevMonth">
<span class="icon is-small"><font-awesome-icon icon="fas fa-chevron-circle-left"></font-awesome-icon></span>
</a>
</div>
</div>
<div class="level-item control">
<a class="button" @click="jump">
<span>{{ textMonth }}</span>
</a>
</div>
<div class="level-right">
<div class="level-item control">
<a class="button" @click="nextMonth">
<span class="icon is-small"><font-awesome-icon icon="fas fa-chevron-circle-right"></font-awesome-icon></span>
</a>
</div>
</div>
</nav>
<nav class="level is-mobile">
<div class="level-item control">
<a class="button is-info" @click="today">Today</a>
</div>
<div class="level-item control">
<a class="button is-primary" @click="addEvent">Add...</a>
</div>
</nav>
<div class="card" style="padding: 1.25rem;">
<CalendarGrid :cells="displayDays" :week-names="WEEK_NAMES" @event-click="editEvent" />
</div>
<div class="container" style="padding: 1.25rem; display: flex; flex-flow: column; margin-top: 1.25rem;">
<h1 class="title">Schedule</h1>
<ScheduleList :days="displayDays" @event-click="editEvent" />
</div>
</div>
<!-- Tab 2: Collection -->
<div v-show="activeTab === 2" class="container" style="margin-top: 20px;">
<div class="control" style="margin: 0.75rem;">
<a class="button is-primary" @click="refreshCollections">
<span class="icon is-small"><font-awesome-icon icon="fas fa-sync"></font-awesome-icon></span>
</a>
</div>
<h1 class="title">My collections</h1>
<div style="display: flex; flex-flow: column; margin-top: 1.25rem; margin-bottom: 1.25rem;">
<CollectionToggleItem v-for="item in ownedCollections" :key="item[0]" :name="item[1]"
:is-show="ownedVisible.get(item[0]) || false" @toggle="toggleOwned(item[0])" />
</div>
<h1 class="title">Shared collections</h1>
<div style="display: flex; flex-flow: column; margin-top: 1.25rem; margin-bottom: 1.25rem;">
<CollectionToggleItem v-for="item in sharedCollections" :key="item[0]" :name="item[1]" :subtitle="item[2]"
:is-show="sharedVisible.get(item[0]) || false" @toggle="toggleShared(item[0])" />
</div>
</div>
<!-- Tab 3: Display (legacy dead UI, reproduced as-is) -->
<div v-show="activeTab === 3" class="container" style="margin-top: 20px;">
<div class="field">
<label class="label">The first day of week</label>
<div class="control">
<div class="select">
<select>
<option v-for="(name, i) in WEEK_NAMES" :key="i">{{ name }}</option>
</select>
</div>
</div>
</div>
<div class="field">
<label class="label">Sub-Calendar</label>
<div class="control">
<div class="select">
<select>
<option>Chinese Lunisolar Calendar</option>
</select>
</div>
</div>
</div>
</div>
<MessageBox ref="messagebox" />
<DateTimePicker ref="picker" @confirm="onPickerConfirm" />
</template>
<style scoped></style>

View File

@@ -1,8 +1,322 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useTokenStore } from '@/stores/token';
import MessageBox from '@/components/MessageBox.vue';
import DateTimePicker, { TabType } from '@/components/DateTimePicker.vue';
import EventLoopEditor from '@/components/calendar-event/EventLoopEditor.vue';
import {
getDetail as apiCalendarGetDetail,
add as apiCalendarAdd,
update as apiCalendarUpdate,
serializeDescription,
deserializeDescription,
type CalendarRow,
} from '@/api/calendar';
import {
getFullOwn as apiCollectionGetFullOwn,
type CollectionRow,
} from '@/api/collection';
import { DEFAULT_COLOR } from '@/utils/utils';
import { goToCalendar } from '@/router';
const token = useTokenStore();
const route = useRoute();
const messagebox = ref<InstanceType<typeof MessageBox>>();
const picker = ref<InstanceType<typeof DateTimePicker>>();
const uuid = computed<string | undefined>(() => route.params.uuid as string | undefined);
const isAdd = computed(() => typeof uuid.value === 'undefined');
const editingEvent = ref<CalendarRow | undefined>(undefined);
const title = ref<string>('');
const description = ref<string>('');
const color = ref<string>(DEFAULT_COLOR);
const belongTo = ref<string>('');
const startDt = ref<Date>(new Date());
const endDt = ref<Date>(new Date());
const loopRules = ref<string>('');
const collections = ref<CollectionRow[]>([]);
const keepTimezone = ref<boolean>(true);
const showTimezoneBox = ref<boolean>(false);
let activeSlot: 'start' | 'end' | null = null;
const startDtText = computed(() => startDt.value.toLocaleString());
const endDtText = computed(() => endDt.value.toLocaleString());
// region: Initialization (port of ccn_event_Init)
const init = async () => {
// Fetch collections for the dropdown.
const collectionsRv = await apiCollectionGetFullOwn(token.currentToken);
collections.value = typeof collectionsRv === 'undefined' ? [] : collectionsRv;
if (isAdd.value) {
// Add mode: start = current hour (truncated), end = start + 2 hours.
const start = new Date();
start.setMilliseconds(0);
start.setSeconds(0);
start.setMinutes(0);
startDt.value = start;
const end = new Date(start);
end.setHours(end.getHours() + 2);
endDt.value = end;
title.value = '';
description.value = '';
color.value = DEFAULT_COLOR;
belongTo.value = '';
loopRules.value = '';
showTimezoneBox.value = false;
return;
}
// Update mode: load the editing event.
const detail = await apiCalendarGetDetail(token.currentToken, uuid.value!);
if (typeof detail === 'undefined') {
messagebox.value?.show("A get operation failed. It may caused by server internal error or your limited permission. Refreshing page may fix system problem. Before refreshing page, please backup all your unsaved data.");
return;
}
editingEvent.value = detail;
const deserialized = deserializeDescription(detail[3]);
title.value = detail[2];
description.value = deserialized.description;
color.value = deserialized.color;
belongTo.value = detail[1];
// Wall-clock-shifted dates: local getters yield the event's original timezone.
startDt.value = new Date((detail[5] + detail[7]) * 60000);
endDt.value = new Date((detail[6] + detail[7]) * 60000);
loopRules.value = detail[8];
// Show the timezone prompt only when the browser tz differs from the event tz.
showTimezoneBox.value = (-new Date().getTimezoneOffset()) !== detail[7];
keepTimezone.value = true;
};
// endregion
// region: Quick actions
const spot = () => {
const d = new Date(startDt.value);
d.setMinutes(d.getMinutes() + 1);
endDt.value = d;
};
const fullDay = () => {
const s = new Date(startDt.value);
s.setMinutes(0);
s.setHours(0);
startDt.value = s;
const e = new Date(s);
e.setMinutes(59);
e.setHours(23);
endDt.value = e;
};
// endregion
// region: DateTimePicker
const openStart = () => {
activeSlot = 'start';
picker.value?.modal(startDt.value, TabType.Minute);
};
const openEnd = () => {
activeSlot = 'end';
picker.value?.modal(endDt.value, TabType.Minute);
};
const onPickerConfirm = (date: Date) => {
if (activeSlot === 'start') startDt.value = date;
else if (activeSlot === 'end') endDt.value = date;
activeSlot = null;
};
// endregion
// region: Submit (port of ccn_event_GetForm / btnSubmit)
const submit = async () => {
// Validate required fields.
if (title.value === '' || description.value === '' || color.value === '' || belongTo.value === '') {
messagebox.value?.show("Your filled event form is not fufilled or have error. Please check it and try again.");
return;
}
const serializedDescription = serializeDescription(description.value, color.value);
// Compute event start/end timestamps + timezone offset.
// The Date objects' local getters expose the event wall-clock time.
let eventStart: number;
let eventEnd: number;
let timezoneOffset: number;
if (!isAdd.value && !keepTimezone.value) {
// "Use my timezone" / replace path: reinterpret the wall-clock as the
// event's original timezone, then convert back to true UTC minutes.
timezoneOffset = editingEvent.value![7];
eventStart = Math.floor(Date.UTC(
startDt.value.getFullYear(), startDt.value.getMonth(), startDt.value.getDate(),
startDt.value.getHours(), startDt.value.getMinutes()
) / 60000) - timezoneOffset;
eventEnd = Math.floor(Date.UTC(
endDt.value.getFullYear(), endDt.value.getMonth(), endDt.value.getDate(),
endDt.value.getHours(), endDt.value.getMinutes()
) / 60000) - timezoneOffset;
} else {
// Keep / add path: use the Date's UTC instant directly, browser tz wins.
timezoneOffset = -startDt.value.getTimezoneOffset();
eventStart = Math.floor(startDt.value.getTime() / 60000);
eventEnd = Math.floor(endDt.value.getTime() / 60000);
}
const newLoopRules = loopRules.value;
if (isAdd.value) {
const rv = await apiCalendarAdd(
token.currentToken,
belongTo.value,
title.value,
serializedDescription,
eventStart,
eventEnd,
newLoopRules,
timezoneOffset
);
if (typeof rv === 'undefined') {
messagebox.value?.show("An add operation failed. It may caused by wrong arguments. Refreshing page may fix system problem. Before refreshing page, please backup all your unsaved data.");
return;
}
goToCalendar();
} else {
const ev = editingEvent.value!;
const rv = await apiCalendarUpdate(
token.currentToken,
ev[0],
ev[4],
ev[1] === belongTo.value ? undefined : belongTo.value,
ev[2] === title.value ? undefined : title.value,
ev[3] === serializedDescription ? undefined : serializedDescription,
ev[5] === eventStart ? undefined : eventStart,
ev[6] === eventEnd ? undefined : eventEnd,
ev[8] === newLoopRules ? undefined : newLoopRules,
ev[7] === timezoneOffset ? undefined : timezoneOffset,
);
if (typeof rv === 'undefined') {
messagebox.value?.show("An update operation failed. It may caused by wrong arguments or lost target. Refreshing page may fix system problem. Before refreshing page, please backup all your unsaved data.");
return;
}
goToCalendar();
}
};
const cancel = () => {
goToCalendar();
};
// endregion
onMounted(() => { init(); });
</script>
<template>
<h1>Congratulations</h1>
<p>This is calendar event.</p>
<div class="container" style="margin-top: 20px;">
<h1 class="title">Edit Event</h1>
<section class="section">
<div class="field">
<label class="label">Title</label>
<div class="control">
<input v-model="title" class="input" type="text">
</div>
</div>
<div class="field">
<label class="label">Description</label>
<div class="control">
<textarea v-model="description" class="textarea"></textarea>
</div>
</div>
<div class="field">
<label class="label">Color</label>
<div class="control">
<input v-model="color" class="input" type="color">
</div>
</div>
<div class="field">
<label class="label">Collection</label>
<div class="control">
<div class="select">
<select v-model="belongTo">
<option v-for="item in collections" :key="item[0]" :value="item[0]">{{ item[1] }}</option>
</select>
</div>
</div>
</div>
</section>
<section class="section">
<h2 class="subtitle">Start Date Time</h2>
<a class="button" @click="openStart">
<span>{{ startDtText }}</span>
</a>
<h2 class="subtitle">Stop Date Time</h2>
<div class="button-list">
<div class="control">
<a class="button is-link" @click="spot">Spot</a>
</div>
<div class="control">
<a class="button is-link" @click="fullDay">Full day</a>
</div>
</div>
<a class="button" @click="openEnd">
<span>{{ endDtText }}</span>
</a>
</section>
<EventLoopEditor v-model="loopRules" :start-date="startDt" />
<section v-if="showTimezoneBox" class="section">
<h2 class="subtitle">Timezone</h2>
<p>The timezone of this event is not corresponding with your current timezone. All of date and time in this page are shown as the original timezone of this event. You can choose a timezone option in follwing content. If you are not familar with this, please pick keep timezone.</p>
<div class="button-list">
<label class="radio">
<input type="radio" :value="true" v-model="keepTimezone">
<span>Keep timezone</span>
</label>
<label class="radio">
<input type="radio" :value="false" v-model="keepTimezone">
<span>Use my timezone</span>
</label>
</div>
</section>
<section class="section">
<div class="button-list">
<a class="button is-success" @click="submit">Submit</a>
<a class="button" @click="cancel">Cancel</a>
</div>
</section>
</div>
<MessageBox ref="messagebox" />
<DateTimePicker ref="picker" @confirm="onPickerConfirm" />
</template>
<style scoped></style>
<style scoped>
.section {
border-top: 1px solid rgba(219, 219, 219, .5);
padding-top: 1.25rem;
margin-top: 1.25rem;
}
</style>