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
MnemonicAction.qml
MnemonicLabel.qml
PermanentScrollBar.qml
ReservationsPage.qml
ReservationsTimeline.qml
SelectableLabel.qml
Separator.qml
TimelineDayRow.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
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Camper
@ -7,10 +6,6 @@ import Camper
Page {
id: page
property real dayWidth: 24
property date fromDate: "2024-11-01"
property date toDate: "2025-01-31"
title: qsTr("Reservations")
header: ToolBar {
@ -23,118 +18,15 @@ Page {
}
}
ListView {
id: lodgingList
ReservationsTimeline {
anchors.fill: parent
anchors.margins: 32
dayWidth: 24
ScrollBar.vertical: verticalScroll
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.top: parent.top
headerPositioning: ListView.OverlayHeader
model: timelineListModel
width: 100
delegate: Label {
required property string name
text: name
model: TimelineListModel {
fromDate: "2024-11-01"
toDate: "2025-01-31"
}
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 {

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 {
required property string display
anchors.bottom: parent.bottom
anchors.top: parent.top
font.bold: true
horizontalAlignment: Text.AlignHCenter
text: display
verticalAlignment: Text.AlignVCenter
width: control.dayWidth
background: Rectangle {
border.color: "red"
border.width: 1
color: "yellow"
Separator {
anchors.bottom: parent.bottom
anchors.right: parent.right
anchors.top: parent.top
palette: control.palette
}
}
}

View File

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

View File

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

View File

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

View File

@ -9,6 +9,8 @@ class TimelineListModel : public QAbstractListModel
{
Q_OBJECT
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:
enum Roles {
@ -22,22 +24,33 @@ public:
QVariant headerData(int section,
Qt::Orientation orientation,
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;
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:
Q_DISABLE_COPY_MOVE(TimelineListModel)
struct Item;
QModelIndex findIndexById(int id);
void fetch();
void refill();
QList<Item *> m_items;
QDate m_fromDate;
QDate m_toDate;
};
#endif // TIMELINELISTMODEL_H

View File

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