Many changes to try to improve reliability

This commit is contained in:
2024-05-17 18:17:25 -04:00
parent dd631b71bb
commit b506ab7ca9
11 changed files with 218 additions and 134 deletions

20
docs/time.md Normal file
View File

@@ -0,0 +1,20 @@
# Dealing with Time
Dealing with time sucks, okay? We have three different formats we need to deal with:
1. ES Date - The native ECMAScript Date object. This is saddled with all the legacy of the decades. Hopefully, we will be able to retire this one day... Ref: https://tc39.es/proposal-temporal/docs/index.html
2. ISO 8601 Date - Used by Appwrite backend. This is just a string, but can represent any date, with or without a timezone.
3. Timestamp - Used internally by QCalendar.
We can't just use one format. We need ISO8601 format for Appwrite, and we get passed Timestamp objects by QCalendar. In the middle of that, we need ES Date objects to do some underlying math.
Componentization:
In order to make things clean and modular, we will rely on Timestamp as the main format in a component.
In data that comes from, or goes to the backend, we will store absolute dates in ISO format.
For any user-facing dates / times, the data will be rendered in the users local time.
For time-only data (as used in Intervals, eg: '09:00'), the template will be stored as a string of 'hh:mm', and represent the users local time. We may want to change this in the future, as this could prove a problem when a user is travelling, but wants to apply a template to their home location.
For now, we'll use the Timestamp object provided by QCalendar. We might need to refactor this in the future.

View File

@@ -107,7 +107,6 @@ import {
QCalendarResource,
TimestampOrNull,
today,
parseDate,
parseTimestamp,
addToDate,
Timestamp,
@@ -172,8 +171,8 @@ function monthFormatter() {
}
function getEvents(scope: ResourceIntervalScope) {
const resourceEvents = reservationStore.getBoatReservations(
parseDate(date.extractDate(selectedDate.value, 'YYYY-MM-DD')) as Timestamp,
const resourceEvents = reservationStore.getReservationsByDate(
selectedDate.value,
scope.resource.$id
);

View File

@@ -70,6 +70,7 @@ import {
parseTimestamp,
parseDate,
addToDate,
getDate,
} from '@quasar/quasar-ui-qcalendar';
import CalendarHeaderComponent from './CalendarHeaderComponent.vue';
@@ -189,13 +190,13 @@ function getBoatBlocks(scope: DayBodyScope): Interval[] {
function getBoatReservations(scope: DayBodyScope): Reservation[] {
const boat = boats.value[scope.columnIndex];
return boat
? reservationStore.getBoatReservations(scope.timestamp, boat.$id)
? reservationStore.getReservationsByDate(getDate(scope.timestamp), boat.$id)
: [];
}
// function changeEvent({ start }: { start: string }) {
// const newBlocks = scheduleStore.getIntervalsForDate(start);
// const reservations = scheduleStore.getBoatReservations(
// const reservations = scheduleStore.getReservationsByDate(
// parsed(start) as Timestamp
// );
// boats.value.map((boat) => {
@@ -268,4 +269,7 @@ const disabledBefore = computed(() => {
font-size: 0.8em
.q-calendar-day__day.q-current-day
padding: 1px
.q-calendar-day__head--days__column
background: $primary
color: white
</style>

View File

@@ -56,7 +56,12 @@ import { useReservationStore } from 'src/stores/reservation';
import { Reservation } from 'src/stores/schedule.types';
import { ref } from 'vue';
const reservationStore = useReservationStore();
import { TimestampOrNull, parsed, today } from '@quasar/quasar-ui-qcalendar';
import {
TimestampOrNull,
getDate,
parsed,
today,
} from '@quasar/quasar-ui-qcalendar';
import { QCalendarDay } from '@quasar/quasar-ui-qcalendar';
import { date } from 'quasar';
import { Timestamp } from '@quasar/quasar-ui-qcalendar';
@@ -87,7 +92,7 @@ function slotStyle(
}
function reservationEvents(timestamp: Timestamp) {
return reservationStore.getBoatReservations(timestamp);
return reservationStore.getReservationsByDate(getDate(timestamp));
}
function onMoved(data: Event) {
console.log('onMoved', data);

View File

@@ -23,7 +23,7 @@
>
<template #day="{ scope }">
<div
v-if="getIntervals(scope.timestamp, scope.resource)"
v-if="filteredIntervals(scope.timestamp, scope.resource).length"
style="
display: flex;
flex-wrap: wrap;
@@ -33,21 +33,22 @@
"
>
<template
v-for="block in getIntervals(
v-for="block in sortedIntervals(
scope.timestamp,
scope.resource
).sort((a, b) => Date.parse(a.start) - Date.parse(b.start))"
)"
:key="block.id"
>
<q-chip class="cursor-pointer">
{{ date.formatDate(block.start, 'HH:mm') }} -
{{ date.formatDate(block.end, 'HH:mm') }}
<q-popup-edit
<!-- <q-popup-edit
:model-value="block"
v-slot="scope"
buttons
@save="updateInterval(block)"
@save="saveInterval"
>
TODO: Why isn't this saving?
<q-input
:model-value="date.formatDate(scope.value.start, 'HH:mm')"
dense
@@ -56,13 +57,14 @@
label="start"
@keyup.enter="scope.set"
@update:model-value="
(t) =>
(block.start = buildISODate(
date.formatDate(scope.value.start, 'YYYY-MM-DD'),t as string
))
(t) => {
block.start = new Date(
scope.value.start.split('T')[0] + 'T' + t
).toISOString();
}
"
/>
<!-- TODO: Clean this up -->
TODO: Clean this up
<q-input
:model-value="date.formatDate(scope.value.end, 'HH:mm')"
dense
@@ -71,12 +73,12 @@
@keyup.enter="scope.set"
@update:model-value="
(t) =>
(block.end = buildISODate(
date.formatDate(scope.value.end, 'YYYY-MM-DD'),t as string
))
(block.end = new Date(
scope.value.end.split('T')[0] + 'T' + t
).toISOString())
"
/>
</q-popup-edit> </q-chip
</q-popup-edit>--> </q-chip
><q-btn
size="xs"
icon="delete"
@@ -89,7 +91,7 @@
</q-calendar-scheduler>
</div>
</div>
<div class="q-pa-md" style="width: 400">
<div class="q-pa-md" style="width: 400px">
<q-list padding bordered class="rounded-borders">
<q-item>
<q-item-section>
@@ -125,8 +127,12 @@
<div class="text-h6">Warning!</div>
</q-card-section>
<q-card-section class="q-pt-none">
This will overwrite existing blocks!
{{ overlapped }}
Conflicting times! Please delete overlapped items!
<q-chip v-for="item in overlapped" :key="item.index"
>{{ boats.find((b) => b.$id === item.boatId)?.name }}:
{{ date.formatDate(item.start, 'hh:mm') }} -
{{ date.formatDate(item.end, 'hh:mm') }}
</q-chip>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="OK" color="primary" v-close-popup />
@@ -147,7 +153,6 @@ import {
useScheduleStore,
} from 'src/stores/schedule';
import { onMounted, ref } from 'vue';
import { buildISODate } from 'src/utils/misc';
import type {
Interval,
IntervalTemplate,
@@ -160,19 +165,17 @@ import { storeToRefs } from 'pinia';
const selectedDate = ref(today());
const { fetchBoats } = useBoatStore();
const { getIntervals, fetchIntervals, updateInterval, fetchIntervalTemplates } =
useScheduleStore();
const scheduleStore = useScheduleStore();
const { boats } = storeToRefs(useBoatStore());
const { timeblockTemplates } = storeToRefs(useScheduleStore());
const calendar = ref();
const overlapped = ref();
const blankTemplate: IntervalTemplate = {
const alert = ref(false);
const newTemplate = ref<IntervalTemplate>({
$id: '',
name: 'NewTemplate',
timeTuples: [['09:00', '12:00']],
};
const newTemplate = ref<IntervalTemplate>({ ...blankTemplate });
const alert = ref(false);
});
/* TODOS:
* Need more validation:
@@ -183,20 +186,37 @@ const alert = ref(false);
onMounted(async () => {
await fetchBoats();
await fetchIntervals();
await fetchIntervalTemplates();
await scheduleStore.fetchIntervals();
await scheduleStore.fetchIntervalTemplates();
});
const filteredIntervals = (date: Timestamp, boat: Boat) => {
return scheduleStore.getIntervals(date, boat).value;
};
const sortedIntervals = (date: Timestamp, boat: Boat) => {
return filteredIntervals(date, boat).sort(
(a, b) => Date.parse(a.start) - Date.parse(b.start)
);
};
function resetNewTemplate() {
newTemplate.value = { ...blankTemplate };
newTemplate.value = {
$id: 'unsaved',
name: 'NewTemplate',
timeTuples: [['09:00', '12:00']],
};
}
function createTemplate() {
newTemplate.value.$id = 'unsaved';
}
function createIntervals(boat: Boat, templateId: string, date: string) {
timeBlocksFromTemplate(boat, templateId, date)?.map((block) =>
useScheduleStore().createInterval(block)
);
const intervals = timeBlocksFromTemplate(boat, templateId, date);
intervals.forEach((interval) => scheduleStore.createInterval(interval));
}
function getIntervals(date: Timestamp, boat: Boat) {
return scheduleStore.getIntervals(date, boat);
}
function timeBlocksFromTemplate(
@@ -204,25 +224,28 @@ function timeBlocksFromTemplate(
templateId: string,
date: string
): Interval[] {
const timeBlock = timeblockTemplates.value.find((t) => t.$id === templateId);
return (
timeBlock?.timeTuples.map((tb: TimeTuple) =>
buildInterval(boat, tb, date)
) || []
);
const template = timeblockTemplates.value.find((t) => t.$id === templateId);
return template
? template.timeTuples.map((timeTuple: TimeTuple) =>
buildInterval(boat, timeTuple, date)
)
: [];
}
function deleteBlock(block: Interval) {
if (block.$id) {
useScheduleStore().deleteInterval(block.$id);
scheduleStore.deleteInterval(block.$id);
}
return false;
}
function onDragEnter(e: DragEvent, type: string) {
if (type === 'day' || type === 'head-day') {
e.preventDefault();
if (e.target instanceof HTMLDivElement)
if (
e.target instanceof HTMLDivElement &&
(e.target.classList.contains('q-calendar-scheduler__head--day') ||
e.target.classList.contains('q-calendar-scheduler__day'))
)
e.target.classList.add('bg-secondary');
}
}
@@ -230,16 +253,18 @@ function onDragEnter(e: DragEvent, type: string) {
function onDragOver(e: DragEvent, type: string) {
if (type === 'day' || type === 'head-day') {
e.preventDefault();
return true;
}
}
function onDragLeave(e: DragEvent, type: string) {
if (type === 'day' || type === 'head-day') {
e.preventDefault();
if (e.target instanceof HTMLDivElement)
if (
e.target instanceof HTMLDivElement &&
(e.target.classList.contains('q-calendar-scheduler__head--day') ||
e.target.classList.contains('q-calendar-scheduler__day'))
)
e.target.classList.remove('bg-secondary');
return false;
}
}
@@ -249,34 +274,28 @@ function onDrop(
type: string,
scope: { resource: Boat; timestamp: Timestamp }
) {
if (e.target instanceof HTMLDivElement)
e.target.classList.remove('bg-secondary');
if ((type === 'day' || type === 'head-day') && e.dataTransfer) {
const templateId = e.dataTransfer.getData('ID');
const date = scope.timestamp.date;
if (type === 'head-day') {
overlapped.value = boats.value.map((boat) =>
const resource = scope.resource;
const existingIntervals = getIntervals(scope.timestamp, resource).value;
const boatsToApply = type === 'head-day' ? boats.value : [resource];
overlapped.value = boatsToApply
.map((boat) =>
blocksOverlapped(
getIntervals(scope.timestamp, boat).concat(
existingIntervals.concat(
timeBlocksFromTemplate(boat, templateId, date)
)
)
);
if (overlapped.value.length === 0) {
boats.value.map((b) => createIntervals(b, templateId, date));
} else {
alert.value = true;
}
)
.flat(1);
if (overlapped.value.length === 0) {
boatsToApply.map((b) => createIntervals(b, templateId, date));
} else {
overlapped.value = blocksOverlapped(
getIntervals(scope.timestamp, scope.resource).concat(
timeBlocksFromTemplate(scope.resource, templateId, date)
)
);
if (overlapped.value.length === 0) {
createIntervals(scope.resource, templateId, date);
} else {
alert.value = true;
}
alert.value = true;
}
}
if (e.target instanceof HTMLDivElement)

View File

@@ -44,12 +44,16 @@ export const useAuthStore = defineStore('auth', () => {
'/userinfo/' + id,
ExecutionMethod.GET
)
.then(
(res) => (userNames.value[id] = JSON.parse(res.responseBody).name)
);
.then((res) => {
if (res.responseBody) {
userNames.value[id] = JSON.parse(res.responseBody).name;
} else {
console.error(res, id);
}
});
}
} catch (e) {
console.log('Failed to get username. Error: ' + e);
console.error('Failed to get username. Error: ' + e);
}
return userNames.value[id];
}

View File

@@ -2,17 +2,20 @@ import { defineStore } from 'pinia';
import type { Reservation } from './schedule.types';
import { ref } from 'vue';
import { AppwriteIds, databases } from 'src/boot/appwrite';
import { Timestamp, parsed } from '@quasar/quasar-ui-qcalendar';
import { Query } from 'appwrite';
import { date } from 'quasar';
import { Timestamp, parseDate } from '@quasar/quasar-ui-qcalendar';
export const useReservationStore = defineStore('reservation', () => {
const reservations = ref<Reservation[]>([]);
const reservations = ref<Map<string, Reservation>>(new Map());
const datesLoaded = ref<Record<string, boolean>>({});
const getConflictingReservations = (
resource: string,
start: Date,
end: Date
): Reservation[] => {
const overlapped = reservations.value.filter(
const overlapped = Array.from(reservations.value.values()).filter(
(entry: Reservation) =>
entry.resource == resource &&
new Date(entry.start) < end &&
@@ -37,46 +40,75 @@ export const useReservationStore = defineStore('reservation', () => {
);
};
const addOrCreateReservation = (reservation: Reservation) => {
const index = reservations.value.findIndex(
(res) => res.id == reservation.id
);
index != -1
? (reservations.value[index] = reservation)
: reservations.value.push(reservation);
};
async function fetchReservations() {
function setDateLoaded(start: Date, end: Date, state: boolean) {
let curDate = start;
while (curDate < end) {
datesLoaded.value[(parseDate(curDate) as Timestamp).date] = state;
curDate = date.addToDate(curDate, { days: 1 });
}
}
async function fetchReservationsForDateRange(start: string, end?: string) {
const startDate = new Date(start + 'T00:00');
const endDate = new Date(end || start + 'T23:59');
datesLoaded.value[start] = false;
if (end) {
setDateLoaded(startDate, endDate, false);
}
try {
const response = await databases.listDocuments(
AppwriteIds.databaseId,
AppwriteIds.collection.reservation
);
reservations.value = response.documents as Reservation[];
databases
.listDocuments(
AppwriteIds.databaseId,
AppwriteIds.collection.reservation,
[
Query.and([
Query.greaterThanEqual('end', startDate.toISOString()),
Query.lessThanEqual('start', endDate.toISOString()),
]),
]
)
.then((response) => {
response.documents.forEach((d) =>
reservations.value.set(d.$id, d as Reservation)
);
setDateLoaded(startDate, endDate, true);
});
} catch (error) {
console.error('Failed to fetch timeblocks', error);
console.error('Failed to fetch reservations', error);
}
}
const getBoatReservations = (
searchDate: Timestamp,
async function fetchReservations() {
return;
// fetchReservationsForDateRange(
// today(),
// date.addToDate(today(), { days: 7 }).toLocaleDateString()
// );
}
const getReservationsByDate = (
searchDate: string,
boat?: string
): Reservation[] => {
const result = reservations.value.filter((x) => {
return (
((parsed(x.start)?.date == searchDate.date ||
parsed(x.end)?.date == searchDate.date) && // Part of reservation falls on day
x.resource != undefined && // A boat is defined
!boat) ||
x.resource == boat // A specific boat has been passed, and matches
);
if (!datesLoaded.value[searchDate]) {
fetchReservationsForDateRange(searchDate);
}
const result = Array.from(reservations.value.values()).filter((x) => {
return new Date(x.start) < new Date(searchDate + 'T' + '23:59') &&
new Date(x.end) > new Date(searchDate + 'T' + '00:00') && // Part of reservation falls on day
boat
? boat === x.boat
: true; // A specific boat has been passed, and matches
});
console.log(result);
return result;
};
return {
getBoatReservations,
getReservationsByDate,
fetchReservations,
addOrCreateReservation,
isReservationOverlapped,
isResourceTimeOverlapped,
getConflictingReservations,

View File

@@ -1,5 +1,5 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { ComputedRef, computed, ref } from 'vue';
import { Boat } from './boat';
import {
Timestamp,
@@ -11,7 +11,6 @@ import {
import { IntervalTemplate, TimeTuple, Interval } from './schedule.types';
import { AppwriteIds, databases } from 'src/boot/appwrite';
import { ID, Models } from 'appwrite';
import { buildISODate } from 'src/utils/misc';
export function arrayToTimeTuples(arr: string[]) {
const timeTuples: TimeTuple[] = [];
@@ -70,28 +69,34 @@ export function buildInterval(
as a UTC time and date-time forms are interpreted as local time. */
const result = {
boatId: resource.$id,
start: buildISODate(blockDate, time[0]),
end: buildISODate(blockDate, time[1]),
start: new Date(blockDate + 'T' + time[0]).toISOString(),
end: new Date(blockDate + 'T' + time[1]).toISOString(),
};
return result;
}
export const useScheduleStore = defineStore('schedule', () => {
// TODO: Implement functions to dynamically pull this data.
const timeblocks = ref<Interval[]>([]);
const timeblockTemplates = ref<IntervalTemplate[]>([]);
const intervals = ref<Interval[]>([]);
const intervalTemplates = ref<IntervalTemplate[]>([]);
const getIntervals = (date: Timestamp, boat: Boat): Interval[] => {
return timeblocks.value.filter((block) => {
return (
compareDate(parseDate(new Date(block.start)) as Timestamp, date) &&
block.boatId === boat.$id
);
});
const getIntervals = (
date: Timestamp,
boat: Boat
): ComputedRef<Interval[]> => {
return computed(() =>
intervals.value.filter((block) => {
return (
compareDate(parseDate(new Date(block.start)) as Timestamp, date) &&
block.boatId === boat.$id
);
})
);
};
const getIntervalsForDate = (date: string): Interval[] => {
// TODO: This needs to actually make sure we have the dates we need, stay in sync, etc.
return timeblocks.value.filter((b) => {
return intervals.value.filter((b) => {
return compareDate(
parseDate(new Date(b.start)) as Timestamp,
parsed(date) as Timestamp
@@ -105,7 +110,7 @@ export const useScheduleStore = defineStore('schedule', () => {
AppwriteIds.databaseId,
AppwriteIds.collection.timeBlock
);
timeblocks.value = response.documents as Interval[];
intervals.value = response.documents as Interval[];
} catch (error) {
console.error('Failed to fetch timeblocks', error);
}
@@ -117,7 +122,7 @@ export const useScheduleStore = defineStore('schedule', () => {
AppwriteIds.databaseId,
AppwriteIds.collection.timeBlockTemplate
);
timeblockTemplates.value = response.documents.map(
intervalTemplates.value = response.documents.map(
(d: Models.Document): IntervalTemplate => {
return {
...d,
@@ -153,7 +158,7 @@ export const useScheduleStore = defineStore('schedule', () => {
ID.unique(),
interval
);
timeblocks.value.push(response as Interval);
intervals.value.push(response as Interval);
} catch (e) {
console.error('Error creating Interval: ' + e);
}
@@ -167,7 +172,8 @@ export const useScheduleStore = defineStore('schedule', () => {
interval.$id,
{ ...interval, $id: undefined }
);
timeblocks.value.push(response as Interval);
intervals.value.push(response as Interval);
console.log(`Saved Interval: ${interval.$id}`);
} else {
console.error('Update interval called without an ID');
}
@@ -182,7 +188,7 @@ export const useScheduleStore = defineStore('schedule', () => {
AppwriteIds.collection.timeBlock,
id
);
timeblocks.value = timeblocks.value.filter((block) => block.$id !== id);
intervals.value = intervals.value.filter((block) => block.$id !== id);
} catch (e) {
console.error('Error deleting Interval: ' + e);
}
@@ -195,7 +201,7 @@ export const useScheduleStore = defineStore('schedule', () => {
ID.unique(),
{ name: template.name, timeTuple: template.timeTuples.flat(2) }
);
timeblockTemplates.value.push(response as IntervalTemplate);
intervalTemplates.value.push(response as IntervalTemplate);
} catch (e) {
console.error('Error updating IntervalTemplate: ' + e);
}
@@ -207,7 +213,7 @@ export const useScheduleStore = defineStore('schedule', () => {
AppwriteIds.collection.timeBlockTemplate,
id
);
timeblockTemplates.value = timeblockTemplates.value.filter(
intervalTemplates.value = intervalTemplates.value.filter(
(template) => template.$id !== id
);
} catch (e) {
@@ -228,7 +234,7 @@ export const useScheduleStore = defineStore('schedule', () => {
timeTuple: template.timeTuples.flat(2),
}
);
timeblockTemplates.value = timeblockTemplates.value.map((b) =>
intervalTemplates.value = intervalTemplates.value.map((b) =>
b.$id !== id
? b
: ({
@@ -242,8 +248,8 @@ export const useScheduleStore = defineStore('schedule', () => {
};
return {
timeblocks,
timeblockTemplates,
timeblocks: intervals,
timeblockTemplates: intervalTemplates,
getIntervalsForDate,
getIntervals,
fetchIntervals,

View File

@@ -3,8 +3,8 @@ import { Models } from 'appwrite';
export type StatusTypes = 'tentative' | 'confirmed' | 'pending' | undefined;
export type Reservation = Partial<Models.Document> & {
user: string;
start: string; // ISODate
end: string; //ISODate
start: string;
end: string;
resource: string; // Boat ID
status?: StatusTypes;
};

View File

@@ -151,7 +151,6 @@ export const useTaskStore = defineStore('tasks', {
const result = state.tasks.filter((task) =>
task.title.toLowerCase().includes(searchQuery.toLowerCase())
);
console.log(result);
return result;
},
},

View File

@@ -1,7 +1,3 @@
export function buildISODate(date: string, time: string | null): string {
return new Date(date + 'T' + time || '00:00').toISOString();
}
export function getNewId(): string {
return [...Array(20)]
.map(() => Math.floor(Math.random() * 16).toString(16))