Compare commits

...

4 Commits

Author SHA1 Message Date
jordi fita mas ff27ea89d9 Show the timeline more like a Excel-like grid 2025-01-15 15:22:35 +01:00
jordi fita mas 2ba3167d10 Fix off-by-one error in TimelineView’s implicit width
QDate("2025-01-01").daysTo(QDate("2025-01-01")) returns 1, but the width
should be for two days, because the view shows the last day too.
2025-01-15 14:29:28 +01:00
jordi fita mas ed56ba543d Remove *and* delete items once no longer in results
There was a memory leak: i was not deleting the elements from the QList
once they are no longer in the results of the SQL query.
2025-01-15 13:04:35 +01:00
jordi fita mas 0e371d71dd Check QSqlQuery errors when doing a query
This is more for me than the end user, because if there is an error with
a query, there is—almost aways—nothing a user can do, since it is
probably an error in the static SQL string or the database.  However, it
is better to show an error, than to do nothing at all when there is a
failure.

According to Qt’s documentation[0], QFuture relies on exceptions for the
error handling. At first i assumed that i had to attach an onFailed
handler to QFuture in order to receive that exception and skip the then
handler.

However, i can not attach the onFailure inside Database::query, before
returning the QFuture, because the subsequent then is only executed when
there _is_ an error, never in the normal, non-exceptional, case.  I
would have to add the onFailure after then.

Nonetheless, i found out that there is no need for onFailure: since i do
not call result(), i do not get the exception, and the actual work is
performed in the then handler when no exception is raised.

[0]: https://doc.qt.io/qt-6/qfuture.html
2025-01-15 13:01:40 +01:00
12 changed files with 400 additions and 145 deletions

View File

@ -23,8 +23,11 @@ qt_add_qml_module(${PROJECT_NAME}
Main.qml Main.qml
MnemonicAction.qml MnemonicAction.qml
MnemonicLabel.qml MnemonicLabel.qml
PermanentScrollBar.qml
ReservationsPage.qml ReservationsPage.qml
ReservationsTimeline.qml
SelectableLabel.qml SelectableLabel.qml
Separator.qml
TimelineDayRow.qml TimelineDayRow.qml
TimelineMonthRow.qml TimelineMonthRow.qml
) )

View File

@ -0,0 +1,5 @@
import QtQuick.Controls
ScrollBar {
policy: size < 1 ? ScrollBar.AlwaysOn : ScrollBar.AlwaysOff
}

View File

@ -1,5 +1,4 @@
pragma ComponentBehavior: Bound pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls import QtQuick.Controls
import QtQuick.Layouts import QtQuick.Layouts
import Camper import Camper
@ -7,10 +6,6 @@ import Camper
Page { Page {
id: page id: page
property real dayWidth: 24
property date fromDate: "2024-11-01"
property date toDate: "2025-01-31"
title: qsTr("Reservations") title: qsTr("Reservations")
header: ToolBar { header: ToolBar {
@ -23,118 +18,15 @@ Page {
} }
} }
ListView { ReservationsTimeline {
id: lodgingList anchors.fill: parent
anchors.margins: 32
dayWidth: 24
ScrollBar.vertical: verticalScroll model: TimelineListModel {
anchors.bottom: parent.bottom fromDate: "2024-11-01"
anchors.left: parent.left toDate: "2025-01-31"
anchors.top: parent.top
headerPositioning: ListView.OverlayHeader
model: timelineListModel
width: 100
delegate: Label {
required property string name
text: name
} }
header: Pane {
height: timelineList.headerItem.height
z: 2
Label {
text: qsTr("Lodging")
}
}
}
ListView {
id: timelineList
anchors.bottom: parent.bottom
anchors.left: lodgingList.right
anchors.right: parent.right
anchors.top: parent.top
clip: true
contentWidth: headerItem.implicitWidth
flickableDirection: Flickable.AutoFlickDirection
headerPositioning: ListView.OverlayHeader
model: timelineListModel
ScrollBar.horizontal: ScrollBar {
}
ScrollBar.vertical: ScrollBar {
id: verticalScroll
}
delegate: TimelineView {
required property TimelineModel timeline
dayWidth: page.dayWidth
fromDate: page.fromDate
height: 16
model: timeline
toDate: page.toDate
viewportWidth: ListView.view.width
viewportX: ListView.view.contentX
delegate: Rectangle {
id: booking
required property string holder
required property string reservationStatus
function reservationStatusColor(status) {
switch (status) {
case "created":
return "#cbebff";
case "cancelled":
return "#ffbaa6";
case "confirmed":
return "#ffe673";
case "checked-in":
return "#9fefb9";
case "invoiced":
return "#e1dbd6";
}
}
border.color: "black"
border.width: 1
color: reservationStatusColor(reservationStatus)
Label {
anchors.fill: parent
elide: Text.ElideRight
text: booking.holder
}
}
}
header: Pane {
leftPadding: 0
rightPadding: 0
z: 2
Column {
TimelineMonthRow {
dayWidth: page.dayWidth
fromDate: page.fromDate
toDate: page.toDate
}
TimelineDayRow {
dayWidth: page.dayWidth
fromDate: page.fromDate
toDate: page.toDate
}
}
}
}
TimelineListModel {
id: timelineListModel
} }
MnemonicAction { MnemonicAction {

View File

@ -0,0 +1,235 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls
import QtQuick.Controls.Fusion
Control {
id: control
required property real dayWidth
property date fromDate: model.fromDate
required property TimelineListModel model
property real rowHeight: 32
property date toDate: model.toDate
Rectangle {
id: lodgingList
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.top: parent.top
border.color: Fusion.outline(control.palette)
color: control.palette.base
width: 100
ListView {
ScrollBar.vertical: verticalScroll
anchors.fill: parent
boundsBehavior: Flickable.StopAtBounds
clip: true
headerPositioning: ListView.OverlayHeader
model: control.model
delegate: Column {
required property string name
width: ListView.view.width
Label {
anchors.left: parent.left
anchors.leftMargin: 6
anchors.right: parent.right
height: control.rowHeight - 1
text: parent.name
verticalAlignment: Text.AlignVCenter
}
Separator {
anchors.left: parent.left
anchors.right: parent.right
palette: control.palette
}
}
header: Rectangle {
border.color: Fusion.outline(control.palette)
color: control.palette.base
height: timelineList.headerItem.height
width: parent.width
z: 2
Label {
anchors.centerIn: parent
font.bold: true
text: qsTr("Lodging")
}
Separator {
anchors.bottom: parent.bottom
anchors.bottomMargin: 1
anchors.left: parent.left
anchors.right: parent.right
palette: control.palette
}
}
}
}
Rectangle {
anchors.bottom: parent.bottom
anchors.left: lodgingList.right
anchors.right: parent.right
anchors.top: parent.top
border.color: Fusion.outline(control.palette)
color: control.palette.base
ListView {
id: timelineList
anchors.fill: parent
boundsBehavior: Flickable.StopAtBounds
clip: true
contentWidth: headerItem.implicitWidth
flickableDirection: Flickable.AutoFlickDirection
headerPositioning: ListView.OverlayHeader
model: control.model
ScrollBar.horizontal: PermanentScrollBar {
}
ScrollBar.vertical: PermanentScrollBar {
id: verticalScroll
}
delegate: TimelineView {
property real contentWidth: ListView.view.contentWidth
required property TimelineModel timeline
dayWidth: control.dayWidth
fromDate: control.fromDate
height: control.rowHeight
model: timeline
toDate: control.toDate
viewportWidth: ListView.view.width
viewportX: ListView.view.contentX
delegate: Label {
id: reservation
required property string holder
required property string reservationStatus
elide: Text.ElideRight
padding: 2
text: holder
verticalAlignment: Text.AlignVCenter
z: 1
background: Rectangle {
function backgroundColor(status) {
switch (status) {
case "created":
return "#cbebff";
case "cancelled":
return "#ffbaa6";
case "confirmed":
return "#ffe673";
case "checked-in":
return "#9fefb9";
case "invoiced":
return "#e1dbd6";
}
}
function borderColor(status) {
switch (status) {
case "created":
return "#6fc7fe";
case "cancelled":
return "#ff7851";
case "confirmed":
return "#ffd829";
case "checked-in":
return "#5ae387";
case "invoiced":
return "#bbaea3";
}
}
border.color: borderColor(reservation.reservationStatus)
color: backgroundColor(reservation.reservationStatus)
radius: 5
}
}
Separator {
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
palette: control.palette
}
}
header: Rectangle {
border.color: Fusion.outline(control.palette)
color: control.palette.base
implicitHeight: headerLayout.implicitHeight + 1
implicitWidth: headerLayout.implicitWidth
z: 2
Column {
id: headerLayout
TimelineMonthRow {
dayWidth: control.dayWidth
fromDate: control.fromDate
height: 24
toDate: control.toDate
}
Separator {
anchors.left: parent.left
anchors.right: parent.right
palette: control.palette
}
TimelineDayRow {
dayWidth: control.dayWidth
fromDate: control.fromDate
height: 24
toDate: control.toDate
}
}
Separator {
anchors.bottom: parent.bottom
anchors.bottomMargin: 1
anchors.left: parent.left
anchors.right: parent.right
palette: control.palette
}
}
Row {
height: parent.height
parent: timelineList.contentItem
Repeater {
delegate: Rectangle {
color: "transparent"
height: parent.height
width: control.dayWidth
Separator {
anchors.bottom: parent.bottom
anchors.right: parent.right
anchors.top: parent.top
palette: control.palette
}
}
model: TimelineDayModel {
fromDate: control.fromDate
toDate: control.toDate
}
}
}
}
}
}

12
src/Separator.qml Normal file
View File

@ -0,0 +1,12 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls.Fusion
Rectangle {
required property Palette palette
Accessible.role: Accessible.Separator
color: Fusion.outline(palette)
implicitHeight: 1
implicitWidth: 1
}

View File

@ -16,13 +16,19 @@ Control {
delegate: Label { delegate: Label {
required property string display required property string display
anchors.bottom: parent.bottom
anchors.top: parent.top
font.bold: true
horizontalAlignment: Text.AlignHCenter
text: display text: display
verticalAlignment: Text.AlignVCenter
width: control.dayWidth width: control.dayWidth
background: Rectangle { Separator {
border.color: "red" anchors.bottom: parent.bottom
border.width: 1 anchors.right: parent.right
color: "yellow" anchors.top: parent.top
palette: control.palette
} }
} }
} }

View File

@ -1,6 +1,7 @@
pragma ComponentBehavior: Bound pragma ComponentBehavior: Bound
import QtQuick import QtQuick
import QtQuick.Controls import QtQuick.Controls
import QtQuick.Controls.Fusion
Control { Control {
id: control id: control
@ -14,18 +15,32 @@ Control {
model: model model: model
delegate: Label { delegate: Label {
id: label
required property int dayCount required property int dayCount
required property string display required property string display
required property int index
anchors.bottom: parent.bottom
anchors.top: parent.top
elide: Text.ElideRight elide: Text.ElideRight
font.bold: true
horizontalAlignment: Text.AlignHCenter horizontalAlignment: Text.AlignHCenter
leftInset: index == 0 ? 1 : 0
text: display text: display
topInset: 1
verticalAlignment: Text.AlignVCenter
width: control.dayWidth * dayCount width: control.dayWidth * dayCount
background: Rectangle { background: Rectangle {
border.color: "red" color: label.index % 2 ? control.palette.alternateBase : control.palette.base
border.width: 1 }
color: "lightgreen"
Separator {
anchors.bottom: parent.bottom
anchors.right: parent.right
anchors.top: parent.top
palette: control.palette
} }
} }
} }

View File

@ -89,4 +89,15 @@ QFuture<void> Database::close()
}); });
} }
void Database::checkError(const QSqlQuery &query)
{
QSqlError lastError = query.lastError();
if (!lastError.isValid()) {
return;
}
QString errorMessage = lastError.text();
emit getInstance().errorOcurred(errorMessage);
throw std::runtime_error(errorMessage.toStdString());
}
#include "moc_database.cpp" #include "moc_database.cpp"

View File

@ -7,6 +7,7 @@
#include <QThreadPool> #include <QThreadPool>
#include <QtConcurrent> #include <QtConcurrent>
class QSqlQuery;
class Database : public QObject class Database : public QObject
{ {
Q_OBJECT Q_OBJECT
@ -27,6 +28,8 @@ public:
const QString &connectOptions); const QString &connectOptions);
Q_INVOKABLE QFuture<void> close(); Q_INVOKABLE QFuture<void> close();
static void checkError(const QSqlQuery &query);
template<class Function> template<class Function>
static auto query(Function &&f) static auto query(Function &&f)
{ {

View File

@ -5,6 +5,8 @@
#include "database.h" #include "database.h"
#include "timelinemodel.h" #include "timelinemodel.h"
using namespace Qt::Literals::StringLiterals;
struct TimelineListModel::Item struct TimelineListModel::Item
{ {
Item(int id, const QString name, QObject *parent = nullptr) Item(int id, const QString name, QObject *parent = nullptr)
@ -23,9 +25,9 @@ struct TimelineListModel::Item
TimelineListModel::TimelineListModel(QObject *parent) TimelineListModel::TimelineListModel(QObject *parent)
: QAbstractListModel{parent} : QAbstractListModel{parent}
, m_items{} , m_items{}
{ , m_fromDate{}
fetch(); , m_toDate{}
} {}
TimelineListModel::~TimelineListModel() TimelineListModel::~TimelineListModel()
{ {
@ -57,6 +59,47 @@ QHash<int, QByteArray> TimelineListModel::roleNames() const
return roles; return roles;
} }
void TimelineListModel::clear()
{
if (m_items.isEmpty()) {
return;
}
beginResetModel();
qDeleteAll(m_items);
m_items.clear();
endResetModel();
}
QDate TimelineListModel::fromDate() const
{
return m_fromDate;
}
void TimelineListModel::setFromDate(QDate date)
{
if (date == m_fromDate) {
return;
}
m_fromDate = date;
emit fromDateChanged(m_fromDate);
refill();
}
QDate TimelineListModel::toDate() const
{
return m_toDate;
}
void TimelineListModel::setToDate(QDate date)
{
if (date == m_toDate) {
return;
}
m_toDate = date;
emit toDateChanged(m_toDate);
refill();
}
QModelIndex TimelineListModel::findIndexById(int id) QModelIndex TimelineListModel::findIndexById(int id)
{ {
for (qsizetype row = 0; row < m_items.count(); ++row) { for (qsizetype row = 0; row < m_items.count(); ++row) {
@ -67,8 +110,13 @@ QModelIndex TimelineListModel::findIndexById(int id)
return {}; return {};
} }
void TimelineListModel::fetch() void TimelineListModel::refill()
{ {
if (!m_fromDate.isValid() || !m_toDate.isValid()) {
clear();
return;
}
struct Lodging struct Lodging
{ {
int id; int id;
@ -81,29 +129,40 @@ void TimelineListModel::fetch()
QHash<int, QList<TimelineModel::Item *>> bookings; QHash<int, QList<TimelineModel::Item *>> bookings;
}; };
Database::query([]() { QDate fromDate = m_fromDate;
QDate toDate = m_toDate;
Database::query([fromDate, toDate]() {
Results results; Results results;
{ {
QSqlQuery query("select campsite_id, label from campsite order by label, campsite_id"); QSqlQuery query("select campsite_id, label from campsite order by label, campsite_id");
Database::checkError(query);
while (query.next()) { while (query.next()) {
Lodging item{query.value(0).toInt(), query.value(1).toString()}; Lodging item{query.value(0).toInt(), query.value(1).toString()};
results.lodgings.append(item); results.lodgings.append(item);
} }
} }
{ {
QSqlQuery query(" select " QSqlQuery query;
" campsite_id " query.setForwardOnly(true);
" , booking_id " query.prepare(" select "
" , lower(booking_campsite.stay) " " campsite_id "
" , upper(booking_campsite.stay) - lower(booking_campsite.stay)" " , booking_id "
" , holder_name " " , lower(booking_campsite.stay) "
" , booking_status " " , upper(booking_campsite.stay) - lower(booking_campsite.stay)"
" , true" " , holder_name "
" , true" " , booking_status "
" from booking_campsite " " , true"
" join booking using (booking_id) " " , true"
" order by lower(booking_campsite.stay), booking_id " " from booking_campsite "
""); " join booking using (booking_id) "
" where booking_campsite.stay && daterange(:fromDate, :toDate)"
" order by lower(booking_campsite.stay), booking_id "
""_L1);
Database::checkError(query);
query.bindValue(":fromDate"_L1, fromDate);
query.bindValue(":toDate"_L1, toDate);
query.exec();
Database::checkError(query);
while (query.next()) { while (query.next()) {
int lodgingId = query.value(0).toInt(); int lodgingId = query.value(0).toInt();
auto *item = new TimelineModel::Item{ auto *item = new TimelineModel::Item{
@ -152,6 +211,7 @@ void TimelineListModel::fetch()
} }
if (itemsRow < m_items.count()) { if (itemsRow < m_items.count()) {
beginRemoveRows({}, itemsRow, m_items.count() - 1); beginRemoveRows({}, itemsRow, m_items.count() - 1);
qDeleteAll(m_items.begin() + itemsRow, m_items.end());
m_items.remove(itemsRow, m_items.count() - itemsRow); m_items.remove(itemsRow, m_items.count() - itemsRow);
endRemoveRows(); endRemoveRows();
} }

View File

@ -9,6 +9,8 @@ class TimelineListModel : public QAbstractListModel
{ {
Q_OBJECT Q_OBJECT
QML_ELEMENT QML_ELEMENT
Q_PROPERTY(QDate fromDate READ fromDate WRITE setFromDate NOTIFY fromDateChanged REQUIRED)
Q_PROPERTY(QDate toDate READ toDate WRITE setToDate NOTIFY toDateChanged REQUIRED)
public: public:
enum Roles { enum Roles {
@ -22,22 +24,33 @@ public:
QVariant headerData(int section, QVariant headerData(int section,
Qt::Orientation orientation, Qt::Orientation orientation,
int role = Qt::DisplayRole) const override; int role = Qt::DisplayRole) const override;
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
int rowCount(const QModelIndex &parent = QModelIndex()) const override; int rowCount(const QModelIndex &parent = QModelIndex()) const override;
QHash<int, QByteArray> roleNames() const override; QHash<int, QByteArray> roleNames() const override;
void clear();
QDate fromDate() const;
void setFromDate(QDate date);
QDate toDate() const;
void setToDate(QDate date);
signals:
void fromDateChanged(QDate date);
void toDateChanged(QDate date);
private: private:
Q_DISABLE_COPY_MOVE(TimelineListModel) Q_DISABLE_COPY_MOVE(TimelineListModel)
struct Item; struct Item;
QModelIndex findIndexById(int id); QModelIndex findIndexById(int id);
void fetch(); void refill();
QList<Item *> m_items; QList<Item *> m_items;
QDate m_fromDate;
QDate m_toDate;
}; };
#endif // TIMELINELISTMODEL_H #endif // TIMELINELISTMODEL_H

View File

@ -288,7 +288,7 @@ void TimelineView::populate()
void TimelineView::updateImplicitWidth() void TimelineView::updateImplicitWidth()
{ {
setImplicitWidth(m_dayWidth * m_fromDate.daysTo(m_toDate)); setImplicitWidth(m_dayWidth * (m_fromDate.daysTo(m_toDate) + 1));
} }
#include "moc_timelineview.cpp" #include "moc_timelineview.cpp"